Skip to content

Middleware

Middleware lets you run logic before and after an action executes — authentication checks, parameter normalization, response enrichment, logging, that sort of thing. If you've used Express middleware, the concept is similar, but scoped to individual actions rather than applied globally.

The Basics

Here's the session middleware we use for authenticated endpoints. It's about as simple as middleware gets:

ts
import { ErrorType, TypedError, type ActionMiddleware } from "keryx";

export const SessionMiddleware: ActionMiddleware = {
  runBefore: async (_params, connection) => {
    if (!connection.session || !connection.session.data.userId) {
      throw new TypedError({
        message: "Session not found",
        type: ErrorType.CONNECTION_SESSION_NOT_FOUND,
      });
    }
  },
};

If runBefore throws, the action's run() method is skipped entirely and the error goes back to the client. That's the primary pattern for auth — check the session, throw if it's missing.

Interface

ts
type ActionMiddleware = {
  runBefore?: (
    params: ActionParams<Action>,
    connection: Connection,
  ) => Promise<ActionMiddlewareResponse | void>;
  runAfter?: (
    params: ActionParams<Action>,
    connection: Connection,
    error?: TypedError,
  ) => Promise<ActionMiddlewareResponse | void>;
};

Both methods are optional. You can have middleware that only runs before (auth), only runs after (logging), or both. runAfter always executes (even when the action throws) and receives the error as an optional third parameter — useful for cleanup like rolling back a transaction. Middleware can also modify params and responses by returning an ActionMiddlewareResponse:

ts
type ActionMiddlewareResponse = {
  updatedParams?: ActionParams<Action>;
  updatedResponse?: any;
};

Applying Middleware

Add middleware to an action via the middleware array:

ts
export class UserEdit implements Action {
  name = "user:edit";
  middleware = [SessionMiddleware];
  // ...
}

Middleware runs in array order. If you have [AuthMiddleware, RateLimitMiddleware], auth runs first — if it throws, rate limiting never executes.

Common Patterns

Authentication

This is the most common use case. Check that a session exists and has the data you expect:

ts
export const SessionMiddleware: ActionMiddleware = {
  runBefore: async (_params, connection) => {
    if (!connection.session?.data.userId) {
      throw new TypedError({
        message: "Session not found",
        type: ErrorType.CONNECTION_SESSION_NOT_FOUND,
      });
    }
  },
};

Param Normalization

You can modify params before the action sees them — useful for things like lowercasing emails:

ts
export const NormalizeMiddleware: ActionMiddleware = {
  runBefore: async (params) => {
    return {
      updatedParams: {
        ...params,
        email: params.email?.toLowerCase(),
      },
    };
  },
};

That said, you can also handle this in the Zod schema with .transform() — so use whichever approach makes more sense for your case.

Rate Limiting

The built-in RateLimitMiddleware uses a Redis-backed sliding window to limit request rates per client. It identifies users by user ID (authenticated) or IP address (unauthenticated):

ts
import { RateLimitMiddleware } from "keryx";

export class ApiEndpoint implements Action {
  name = "api:endpoint";
  middleware = [SessionMiddleware, RateLimitMiddleware];
  // ...
}

When a client exceeds the limit, the middleware throws a CONNECTION_RATE_LIMITED error (HTTP 429). Rate limit info is attached to the connection and included in response headers automatically.

See the Security guide for configuration options and custom limit overrides.

Database Transactions

The built-in TransactionMiddleware wraps the entire action lifecycle in a database transaction. It opens a transaction in runBefore, stores it on connection.metadata.transaction, and commits or rolls back in runAfter based on whether the action succeeded:

ts
import { TransactionMiddleware, type Transaction } from "keryx";

export class TransferFunds extends Action {
  constructor() {
    super({
      name: "transfer:funds",
      middleware: [SessionMiddleware, TransactionMiddleware],
      web: { route: "/transfer", method: HTTP_METHOD.POST },
      inputs: z.object({ fromId: z.number(), toId: z.number(), amount: z.number() }),
    });
  }

  async run(params: ActionParams<TransferFunds>, connection?: Connection) {
    const tx = connection!.metadata.transaction as Transaction;
    // Both updates happen atomically — if either fails, both roll back
    await tx.update(accounts).set({ ... }).where(eq(accounts.id, params.fromId));
    await tx.update(accounts).set({ ... }).where(eq(accounts.id, params.toId));
    return { success: true };
  }
}

For one-off transactions outside the middleware lifecycle, use the withTransaction() utility. See the Advanced Patterns guide for more details.

Passing Data Between Middleware and Actions

Use connection.metadata to pass request-scoped data from middleware to actions (or between runBefore and runAfter). Metadata is reset to {} at the start of each act() call, so long-lived connections like WebSockets won't leak state between requests.

First, declare your metadata shape:

ts
// types.ts
import type { Membership, Project } from "./models";

export type AppConnectionMeta = {
  membership?: Membership;
  project?: Project;
  auditBefore?: Record<string, unknown>;
  auditAfter?: Record<string, unknown>;
};

Then use it in middleware and actions with the second generic on Connection:

ts
import type { Connection } from "keryx";
import type { AppConnectionMeta } from "../types";

export const RbacMiddleware: ActionMiddleware = {
  runBefore: async (
    _params,
    connection: Connection<any, AppConnectionMeta>,
  ) => {
    const membership = await resolveMembership(connection.session!.data.userId);
    connection.metadata.membership = membership; // type-safe write
  },
};
ts
export class OrgView extends Action {
  middleware = [SessionMiddleware, RbacMiddleware];

  async run(
    params: ActionParams<OrgView>,
    connection: Connection<any, AppConnectionMeta>,
  ) {
    const membership = connection.metadata.membership; // type-safe read
    // ...
  }
}

This replaces the old pattern of casting through unknown to attach properties to the connection.

Response Enrichment

runAfter can add data to the response. This runs after the action's run() method completes:

ts
export const TimingMiddleware: ActionMiddleware = {
  runAfter: async (_params, connection) => {
    return {
      updatedResponse: {
        requestDuration: Date.now() - connection.startTime,
      },
    };
  },
};

Released under the MIT License.