solidjs / solidjs/solid-start

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

Open
#2,086 1 comment 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

enhancement Start 3.x
Dominant language
TypeScript
Stars
5.9k
Forks
426
Avg merge
1d 3h
Merged PRs (30d)
11

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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

The issue names no files, tests, or implementation entry points. Start by locating the current handling of the "use server" directive and documenting how server functions are transformed, then determine whether composable helpers or a USE_SERVER-style expression fit that design. Done means an agreed API, defined client/server behavior, and coverage for composition and error cases.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
backend-api-design, web-dev
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Needs clarification
Newbie friendliness
20/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.