-
Notifications
You must be signed in to change notification settings - Fork 408
Open
Labels
Description
Duplicates
- I have searched the existing issues
Latest version
- I have tested the latest version
Summary 💡
The problem with higher-order server functions
Inspired by https://next-safe-action.dev/
The current "use server" directive doesn't support higher-order server functions. As a result, we must repeat nested boilerplate to achieve common behaviors.
For example, we'd like a generic createServerAction that can:
- Validate input against a schema on both client and server and return appropriate errors.
- Handle unexpected internal server errors (for example, database failures) and return a generic, user-facing error to the client.
- Enforce authentication and authorization.
- Provide logging and monitoring hooks.
- Add caching.
- Support retries and fallback mechanisms.
I'm not aware of a design pattern that allows higher-order server functions to be composed without duplicating boilerplate, which increases maintenance overhead.
const errorHandlerImpl = <T extends (...args: any[]) => Promise<any>>(fn: T): T => {
return async (...args) => {
try {
return await fn(...args);
} catch (e: any) {
if (e instanceof AppError) {
throw e;
}
// Unexpected server error: log to your error provider and return a generic error to the client
sentry.captureException(e, {
level: 'error',
tags: { context: 'server-function' },
extra: { message: 'Unexpected server function error', args }
});
throw new UnexpectedError('Server function failed unexpectedly');
}
};
};
// Higher-order function that validates a schema.
const validateSchemaImpl = <T>(schema: any, fn: (input: T) => Promise<any>) => {
return async (input: T) => {
const validationResult = validate(schema, input);
if (!validationResult.success) {
throw new ValidationError('Invalid input', { details: validationResult.errors });
}
return fn(input);
};
};
// Higher-order function that enforces authentication and authorization.
const withAuthImpl = <T>(role: 'admin' | 'user', fn: (input: T) => Promise<any>) => {
return async (input: T) => {
const user = await getCurrentUser();
if (!user) {
throw new AuthenticationError('User not authenticated');
}
if (role === 'admin' && !user.isAdmin) {
throw new AuthorizationError('User not authorized');
}
return fn(input);
};
};
// Higher-order function that handles retries and fallbacks.
const withRetriesImpl = <T>(retries: number, fn: (input: T) => Promise<any>) => {
return async (input: T) => {
let attempt = 0;
let delay = 1000; // start with 1 second
while (attempt < retries) {
try {
return await fn(input);
} catch (e) {
if (!isTimeoutError(e)) {
// If it's not a timeout error, don't retry
throw e;
}
attempt++;
// Only retry on specific timeout errors for this example
if (attempt >= retries) {
console.warn('withRetries: giving up after', attempt, 'attempt(s)', e?.message || e);
// report to Sentry or other observability provider here
throw new ServerUnavailableError('Server function failed');
}
// wait then double the delay
await new Promise((res) => setTimeout(res, delay));
delay *= 2;
}
}
};
};
// Higher-order function that handles caching.
const withCacheImpl = <T>(cacheKey: string, ttl: number, fn: (input: T) => Promise<any>) => {
// ...
};
const withLoggingImpl = <T>(fnName: string, fn: (input: T) => Promise<any>) => {
// ...
};
// Inspired by https://next-safe-action.dev/
const {
withAuth,
validateSchema,
withRetries,
withCache,
withLogging,
errorHandler
} = createChainableServerFunction({
withAuth: withAuthImpl,
validateSchema: validateSchemaImpl,
withRetries: withRetriesImpl,
withCache: withCacheImpl,
withLogging: withLoggingImpl,
errorHandler: errorHandlerImpl
});
// Compose the higher-order functions to build a server function with the desired behaviors.
const getData =
validateSchema(getDataSchema) // validate schema on both client and server
.withRetries(3)
.withCache("getData", 600) // client side cache
(async (input: InputTypes) => { // we have to duplicate the types here and create another function with the directive
"use server";
return await (
errorHandler()
.withAuth('user')
.validateSchema(getDataSchema)
.withLogging('getData')
.withCache('getData', 600)(
async (input: InputTypes) => {
// Your server function logic here
}
)
)(input);
}));Potential solution
One approach is for a USE_SERVER helper to behave like the "use server" directive but accept an expression:
// These two definitions would be equivalent:
const myFn = async (input) => {
'use server';
// server logic
};
const myFn = USE_SERVER(async (input) => {
// server logic
});How this simplifies code:
const getData =
validateSchema(getDataSchema) // validate schema on both client and server
.withRetries(3)
.withCache('getData', 600)( // client-side cache
USE_SERVER(
// Everything inside this expression is extracted as a server function, similar to the current "use server" directive.
errorHandler()
.withAuth('user')
.validateSchema(getDataSchema)
.withLogging('getData')
.withCache('getData', 600)( // server-side cache
async (input: InputTypes) => {
// Server-side logic
}
)
)
);This is the simplest API and stays close to the current behavior. There may be cleaner or more ergonomic APIs, but this minimizes changes while solving the duplication problem.
Other ideas:
// Create a custom server function factory that supplies different implementations
// for server and client environments.
export const createServerFn = CREATE_SERVER_FN(
(...args) => (...serverInput) => {
if (args.cache) { /* server-side cache behavior */ }
},
(...args) => (...clientInput) => {
if (args.cache) { /* client-side cache behavior */ }
}
);
// Detect the CREATE_SERVER_FN call, extract the server and client functions,
// and replace the placeholder with the appropriate implementation for each environment.
const myFn = createServerFn({ retry: 3, cache: { key: 'myCacheKey', ttl: 600 }, auth: 'user', schema: getDataSchema, log: 'getData' })(
async (input: InputTypes) => {
// Server function logic
}
);Reactions are currently unavailable