Skip to content

Hooks ​

Cross-cutting behavior — authorization beyond access, tenancy, audit, rate limits — lives in hooks: named lifecycle phases, in the fastify tradition. There is deliberately no (ctx, next) middleware chain: a hook runs and either returns (the pipeline continues) or throws a coded error (the call ends with that code). "After the handler" is a later phase, not code after a next() call — which is what keeps every hook a plain awaited function, the order explicit, and the empty case a skipped if rather than a chain of closures.

js
const router = defineRouter(units, {
  hooks: {
    onRequest: async (context, packet) => {
      if (blocked.has(context.client.source)) {
        const error = new Error('Blocked');
        error.code = 403;
        throw error;
      }
    },
    preHandler: async (context) => {
      context.state.user = await loadUser(context.session);
    },
  },
});

The pipeline ​

Diamonds are hook phases; the labels on the arrows are the machinery that runs between them. Everything on the solid path can end the call by throwing — onResponse runs after the write and cannot.

Phases ​

PhaseWhenPayloadTypical use
onRequestpacket accepted, before session restore and accessthe packetrate limit, IP block
preValidationafter access, before input validationraw argsroles, tenant, ACL
preHandlerafter input validationvalidated argsload the user, open a transaction
preSerializationafter the handler, before output validationthe resultredact fields (return a value to replace)
onSendbefore the callback packet is writtenthe packet (mutable)last-chance reshaping
onResponseafter the writethe packetaudit, metrics
onErrorany failurethe errorreporting
onTimeouta 408 specifically (before onError)the errortimeout-specific signals
onSubscribeafter access, before a subscription startsthe packetsubscription quotas
onUnsubscribeafter a subscription ended, whatever ended itthe terminal packetquota release
onConnect / onDisconnecta client attached / went away (router-level only)null / { rooms } — the pre-destroy room snapshotper-connection state

Rules that hold everywhere:

  • A throw ends the call. The error's numeric code becomes the wire code, and (for 4xx or error.expose = true) its message travels.
  • The observational phases are contained: a throwing onResponse/onError/onTimeout/onUnsubscribe/onConnect/onDisconnect is logged and never breaks what it observes.
  • onConnect is ordered against dispatch: client.sessionReady is assigned before the hooks run (so await client.sessionReady inside one is safe), and the dispatcher waits for client.ready — session restore plus settled onConnect hooks — before the access check. A hook that never settles holds that client's dispatch; the server logs onConnect.stalled after 5 s so the hang is visible.
  • context.state is the hand-off: what onRequest or preHandler loads is what the handler (and every later phase) reads.
  • The context knows its call: context.method is the wire target ('unit/name', 'unit.vN/name', or the event name for an inbound event) and context.procedure is the resolved Procedure — so a cross-cutting logging or tracing hook reads the identity instead of re-deriving it from the packet. context.procedure.meta is the natural place for per-procedure hook configuration.
  • onDisconnect receives { rooms } — a snapshot of the client's rooms taken before teardown emptied the registry; by the time the hook runs, client.rooms is already empty (see Rooms).
  • Inbound events run the invocation phases (preValidation, preHandler, onError); there is no packet to answer, so onRequest/onSend do not apply.
  • Subscriptions run preValidation/preHandler around their setup and never per value — the value loop is the hottest path in the library and stays hook-free by design.
  • system/introspect is a procedure like any other: router-level hooks apply to it (that is also why introspection: 'session' | false exists).

Three levels, one flat pipeline ​

js
const router = defineRouter(
  {
    billing: {
      hooks: { preHandler: requireTenant },   // this unit only
      charge: procedure({
        access: 'session',
        preHandler: requireRole('admin'),      // this procedure only
        handler: async (context, args) => { /* ... */ },
      }),
    },
  },
  { hooks: { onRequest: rateLimit } },         // every procedure
);

router.addHook('onError', reportToSentry);     // additive, after the fact

The levels flatten once, when the router is built: dispatch walks one frozen array per phase — router hooks first, then the unit's, then the procedure's — and a phase nobody registered costs a single length check. merge() carries all three levels with it (router-level lists concatenate, this router's first).

Like fastify's route-level hooks, a unit or procedure hook scopes behavior to exactly the surface that owns it; hooks and on are the two reserved keys inside a unit definition.

Recipe: a rate limit ​

The onRequest phase plus a token bucket is a per-connection rate limit in a dozen lines — no core option needed:

js
const buckets = new WeakMap();

const rateLimit = (limit = 50, windowMs = 1000) => async (context) => {
  const key = context.client;
  let bucket = buckets.get(key);
  const now = Date.now();
  if (!bucket || now - bucket.start >= windowMs) {
    bucket = { start: now, used: 0 };
    buckets.set(key, bucket);
  }
  if (++bucket.used > limit) {
    const error = new Error('Rate limit exceeded');
    error.code = 429;
    error.expose = true;
    throw error;
  }
};

const router = defineRouter(units, { hooks: { onRequest: rateLimit(50) } });

Keyed by the Client (a WeakMap, so state dies with the connection); key by session token or a header instead when limits should survive reconnects. The built-in maxCalls cap bounds concurrency per connection; this bounds rate — production deployments usually want both. See Rate limiting & throttling for per-procedure limits via meta and how this same hook covers every transport (WebSocket, HTTP, SSE, WebTransport, WebRTC, a worker port, the broker binding and queue consumers) uniformly.

Recipe: subscription quotas ​

onSubscribe refuses, onUnsubscribe releases — and because onUnsubscribe fires on every ending (completion, error, unsubscribe, disconnect), the quota cannot leak:

js
const held = new WeakMap();

const hooks = {
  onSubscribe: async (context) => {
    const used = held.get(context.client) ?? 0;
    if (used >= 10) {
      const error = new Error('Subscription quota exceeded');
      error.code = 429;
      error.expose = true;
      throw error;
    }
    held.set(context.client, used + 1);
  },
  onUnsubscribe: async (context) => {
    held.set(context.client, (held.get(context.client) ?? 1) - 1);
  },
};

Released under the MIT License.