Skip to content

Router & procedures ​

A router is a plain object of units, each holding procedures. It is the one thing the server needs to know, and the thing system/introspect describes to clients.

js
const { defineRouter, procedure } = require('@alexify/wrpc');

const router = defineRouter({
  chat: {
    send: procedure({
      access: 'session',
      input: (args) => {
        if (typeof args?.text !== 'string') throw new Error('text is required');
      },
      handler: async (context, { text }) => ({ id: await store.append(text) }),
    }),
  },
  'auth.v1': {
    signIn: procedure({ access: 'public', handler: async (context, args) => { /* ... */ } }),
  },
});

A call, end to end ​

A failure takes the same path back: the procedure throws, and the client receives callback { id, error: { code, message } } with the error's numeric code. Nothing about that shape depends on the transport — the same exchange happens over HTTP, SSE and a worker port.

Procedures ​

procedure(options) builds one. Everything but handler is optional:

OptionDefaultMeaning
handler—(context, args, subscription) => result. Required.
access'session''public' is callable by anyone. Any other value requires a session.
input—Validates/coerces the arguments. Failure is code 400.
output—Validates/coerces the result. Failure is code 500.
timeout0Milliseconds; exceeding it fails the call with code 408.
queue—{ concurrency, size, timeout }; overflow fails with code 503.
meta{}Free-form; surfaced by introspection. meta.description becomes a doc comment in generated types.
signature—A descriptor for codegen, not validation.
kindinferred'call' or 'subscription'; an async generator handler is detected.

Three spellings are accepted wherever a procedure goes, so the simple case stays simple:

js
defineRouter({
  math: {
    double: procedure({ access: 'public', handler: async (ctx, { n }) => n * 2 }),
    triple: { access: 'public', handler: async (ctx, { n }) => n * 3 },  // options object
    quadruple: async (ctx, { n }) => n * 4,                              // bare function
  },
});

A bare function gets the defaults — which means access: 'session'. That is deliberate: forgetting to declare access should close a procedure, not open it.

Access ​

The dispatcher's rule is exactly one line: a client with no session may call access: 'public' and nothing else. Any other string — 'admin', 'internal' — behaves like 'session' at that gate and is carried through introspection for your own layer to interpret. Fine-grained authorization is not wRPC's job; do it in the handler, or with a policy engine.

Validators ​

An input/output validator is either a plain function or a Standard Schema object — which means Zod, Valibot, ArkType and friends plug in without wRPC depending on any of them:

js
const { z } = require('zod');

procedure({
  input: z.object({ text: z.string().min(1) }),
  output: z.object({ id: z.string() }),
  handler: async (context, { text }) => ({ id: '1' }),
});

The third spelling is declarative: a fastify-shaped schema object plus an injected compiler. wRPC imports no schema library — you pass your own ajv (and, optionally, a fast-json-stringify) and the router compiles every declared part once, when it is built:

js
const Ajv = require('ajv');
const fjs = require('fast-json-stringify');

const router = defineRouter(units, {
  validation: {
    ajv: new Ajv({ coerceTypes: true }),          // anything with compile(schema)
    serializer: { compile: (schema) => fjs(schema) },
  },
});

procedure({
  http: { method: 'POST', path: '/projects/:orgId', status: 201 },
  schema: {
    params: { type: 'object', properties: { orgId: { type: 'string' } } },
    body: { type: 'object', required: ['name'] },
    response: { 201: { type: 'object', properties: { id: { type: 'string' } } } },
  },
  handler: async (context, { params, query, body }) => { /* ... */ },
});
  • params/querystring (or query)/body compile into ONE input validator; a failure answers 400 with part-prefixed issue paths in error.details.
  • schema.response[status] (the mapped status, else the first 2xx) becomes the output validator — and, with a serializer injected, a compiled serializer the dispatcher feeds straight to the wire (bench/serialize-callback.js is the measurement).
  • A schema with validation parts in a router without validation.ajv throws at build: a declaration nothing enforces would be an authorization bug in waiting. Under the fastify adapter fastify validates the delegated HTTP routes itself, but the same procedures over a WebSocket, or any other transport, are wRPC's to validate — inject the ajv either way.
  • schema and input/output are mutually exclusive on one procedure.
  • The input parts travel through introspection, so a browser client with its own injected ajv (WrpcClient.connect(url, { validation: { ajv } })) pre-validates locally and rejects a doomed call with the same 400 + details, without the round trip. Strip them with introspection: { schemas: false } if the payload matters.

A function validator returns the (possibly coerced) value, or throws. Returning undefined keeps the original — so a pure assertion needs no return:

js
procedure({
  input: (args) => {
    if (!args?.room) throw new Error('room is required');
  },
  handler,
});

On a subscription, output validates each yielded value, and it sees the payload rather than the tracked() wrapper: a schema describes what the client receives, not how it is labelled.

Timeouts and queues ​

js
procedure({
  timeout: 5000,
  queue: { concurrency: 10, size: 100, timeout: 1000 },
  handler: async (context, args) => expensive(args),
});

timeout rejects the caller with code 408. It does not cancel the handler — nothing in JavaScript can — so a handler that should stop when nobody is listening must watch context.signal.

queue is a semaphore: concurrency run at once, up to size wait, and a waiter gives up after timeout. Both overflow and starvation fail with code 503. The slot is held until the handler settles, not until the caller is answered, so a timed-out call still occupies its slot — anything else would break the concurrency guarantee it exists to provide.

Neither is available on a subscription: a feed lives until it is cancelled, so both would have to mean something else, and quietly meaning something else is worse than refusing. procedure.subscription({ timeout }) throws.

The context ​

Every handler's first argument:

MemberWhat it is
context.clientThe Client — rooms, events, streams, sessions.
context.sessionThe Session, or null.
context.serverThe RpcServer this call arrived on — how a handler reaches rooms.
context.signalAborted on cancel, unsubscribe or disconnect.
context.stateA per-call scratch object.
context.uuidThe call id.

Use context.server rather than closing over the server you created: the router is usually defined before the server that serves it exists.

js
handler: async (context, { room, text }) => {
  const sent = context.server.to(room).except(context.client).emit('chat/message', { text });
  return { sent };
},

Units and versions ​

A unit key is unit or unit.vN — the version token is v plus a number, and anything else (auth.1, auth.beta) is rejected loudly. Both live side by side, and a client loads whichever it wants:

js
defineRouter({
  auth: { signIn: procedure({ /* the default version */ }) },
  'auth.v1': { signIn: procedure({ /* the pinned one */ }) },
});
js
await client.load('auth');    // the default version
await client.load('auth.v1');  // the pinned one

On the wire that is the method string: auth/signIn or auth.v1/signIn. 'unit.v1.2' is rejected outright rather than silently truncated.

on is reserved inside a unit — it holds the unit's inbound event handlers, so no method may be called on. hooks is reserved too: the unit's slice of the lifecycle pipeline, and so are emits (the declared outbound events) and consumes (the unit's queue consumers). None is usable as a method name:

js
defineRouter({
  chat: {
    send: procedure({ /* ... */ }),
    on: {
      typing: procedure({ access: 'session', handler: async (context, data) => { /* ... */ } }),
    },
  },
});

Event handlers are procedures too, so access, input and queue all apply. An event never answers, though — a rejected one is logged, not reported. See the protocol reference.

Composing routers ​

merge() returns a new router; on a collision the other router's procedure wins:

js
const router = defineRouter(core).merge(defineRouter(plugins));

That is also how system/introspect gets there: the server merges it in unless your router already defines one, so you can replace it (to hide units from anonymous clients, say) simply by declaring system.introspect yourself.

Introspection ​

router.introspect(units?) is what load() and wrpc types consume:

jsonc
{
  "chat": {
    "send":      { "access": "session", "meta": { "description": "Post a message" } },
    "onMessage": { "access": "session", "kind": "subscription" }
  },
  "auth.v1": { "signIn": { "access": "public" } }
}

kind appears only on subscriptions — a client scaffolds a call unless told otherwise. The full shape, including the signature descriptor, is in the protocol reference.

Released under the MIT License.