Skip to content

[Feature?]: Support higher-order server functions (composable server-function helpers) #2086

@yinonburgansky

Description

@yinonburgansky

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
  }
);

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions