Declarative REST
One procedure, two audiences. A procedure that declares an http mapping is addressable as a real REST endpoint — proper verb, proper path, proper status — while staying an ordinary wRPC procedure on every other transport:
const { defineRouter, procedure } = require('@alexify/wrpc');
const router = defineRouter({
projects: {
create: procedure({
access: 'session',
http: { method: 'POST', path: '/projects/:orgId', status: 201 },
schema: {
params: { type: 'object', properties: { orgId: { type: 'string' } }, required: ['orgId'] },
body: { type: 'object', properties: { name: { type: 'string', minLength: 2 } }, required: ['name'] },
response: { 201: { type: 'object', properties: { id: { type: 'string' } } } },
tags: ['Projects'],
},
handler: async (context, { params, query, body }) => createProject(params.orgId, body),
}),
},
});curl -X POST https://host/api/projects/42 -H 'content-type: application/json' -d '{"name":"Alpha"}'The response is the plain result with status 201 — external REST semantics, no callback envelope. The same procedure called over a WebSocket — or any other transport — is projects/create with the same { params, query, body } args object: the mapping only defines how an HTTP request is unpacked.
The http option
| Field | Meaning |
|---|---|
method | GET, HEAD, POST, PUT, PATCH or DELETE. |
path | Relative to the server's basePath. Segments are static or :name. |
status | Success status; default 200. 204 discards the result body by contract. |
headers | Static response headers on every answer of this route — see Response headers. |
cache | The cache policy of a successful response — see Caching. GET/HEAD only. |
Validated when the procedure is built: a typo'd verb, a wildcard segment or a status outside 200–599 throws immediately. Subscriptions cannot carry a mapping — they are already refused on plain HTTP.
Routing rules match find-my-way's: one tree per verb, a static segment beats a parameter, and within one verb a duplicate path or two different parameter names at the same position throw when the router is built, naming both procedures. A known path hit with the wrong verb answers 405 with an Allow header. Errors on a mapped route answer the wire error object — { message, code, details? } — with the code as the HTTP status.
The conventional ANY {basePath}/:unit/:method mode keeps working underneath as a fallback, unchanged, callback envelopes and all.
Response headers
Two seams reach the HTTP response of a mapped route. Statically, on the mapping — every answer of the route carries them, results and errors alike:
export: procedure({
access: 'public',
http: { method: 'GET', path: '/export', headers: { 'Content-Disposition': 'attachment; filename="rows.json"' } },
handler: async () => rows(),
}),Dynamically, from the handler (or any hook), through context.http:
handler: async (ctx, { params }) => {
ctx.http.setHeader('X-Request-Id', ctx.uuid);
ctx.http.status(202); // this response's success status
return queue(params.id);
},context.http is { method, url, headers, setHeader(name, value), status(code) } — the request line and headers to read, the response to shape. It is null everywhere else: on a WebSocket or WebTransport call, on SSE, on a worker port, and on packet-mode POST {basePath}, where one response answers a whole batch and no single call may speak for it — write handlers that check it, or keep the HTTP-only ones on mapped routes. The transport-owned names (Content-Length, Set-Cookie, wrpc-version, Transfer-Encoding, Connection) are refused, at build time for headers and with a throw for setHeader; so is a setHeader after the answer was written. HEAD is served by the GET route, headers included, body stripped by the host. A Content-Encoding set here is honoured by http.compression: a route that ships pre-encoded bytes is never re-encoded.
Under the fastify adapter both seams map onto fastify's own reply.
Caching
A public read behind a CDN is the one thing a single WebSocket connection cannot give you — a mapped route can. cache declares how a shared cache may treat a successful response:
catalog: procedure({
access: 'public',
http: {
method: 'GET',
path: '/catalog',
cache: { maxAge: 60, public: true, staleWhileRevalidate: 30 },
},
handler: async () => listCatalog(),
}),| Field | Meaning |
|---|---|
maxAge | Seconds, required. |
public | public in Cache-Control (a CDN may store it); default false → private (browser only). |
staleWhileRevalidate | Seconds; adds stale-while-revalidate. |
etag | A weak ETag over the body and 304 Not Modified on a matching If-None-Match; default true. |
What a hit answers:
HTTP/1.1 200 OK
Cache-Control: public, max-age=60, stale-while-revalidate=30
ETag: W/"5YgP…"and a GET with If-None-Match: W/"5YgP…" (or the bare tag, or a list) answers 304 with the same ETag and Cache-Control and no body. HEAD gets the same headers.
The session rule. The policy is decided once the session is known, and it is the same function for every host: the declared Cache-Control goes out only for a public procedure on a request that restored no session and set no cookie. Anything session-bearing — a session procedure, a public one reached with a valid session cookie, a public one whose handler called startSession — answers Cache-Control: private, no-store and no ETag, whatever the route declared. One shared cache serving one user's answer to another is the failure this exists to stop, and it is not a discipline each handler has to remember. Error responses carry no cache headers at all.
Two things to know: the ETag is a SHA-1 of the body per response (cold, but a cost); and under a codec.rest whose bytes are not deterministic for equal values (a nonce, a random field order) the tag never matches, so 304 never happens and the hash is wasted — set etag: false there. Under the fastify adapter Cache-Control follows the same rule and ETag/304 are fastify's business (@fastify/etag).
Versioning
By default a versioned unit's declared path registers verbatim — so two versions declaring the same path collide at build time. The router-level rest.version strategy maps each version onto its own URL instead:
defineRouter(
{
'auth.v1': {
signIn: procedure({ http: { method: 'POST', path: '/auth/signIn' }, handler }),
},
},
{ rest: { version: 'path' } },
);
// POST {basePath}/v1/auth/signIn — the wire target stays auth.v1/signInThe rules:
'path'prefixes the declared path with/vN— the version token of the unit key, verbatim (auth.v1→/v1/...;path: '/'becomes/v1).- The default version stays unprefixed:
auth+/auth/signInkeeps/auth/signIn, so the same declared path inauthandauth.v1no longer conflicts and both dispatch. - A function form takes full control:
version: (version, path) => stringreceives the token ('v1') and the declared path and returns the effective path (it must start with/). proc.httpis the declaration and never mutates — the prefix is computed where routes surface (the dispatch trie,restRoutes(), and introspection), which is what keeps the shell, the host adapters and the client's REST leg version-consistent without any of them knowing about the strategy. The strategy also survivesmerge().
The schema option
The shape is fastify.route.schema, verbatim: params, querystring (or its alias query — interchangeable), body, headers, response keyed by status code, plus any passthrough keys (tags, summary, security, operationId, …) that wRPC never interprets but forwards to hosts — which is what makes swagger documentation free under the fastify adapter.
headers is an input part like the other three, with one asymmetry: what it validates is not an args slice but the connection'scontext.meta.headers — the request headers on http/sse, the upgrade headers (merged with the client-declared ones) on a socket. Issues carry the /headers prefix in error.details.issues. On a delegated fastify route the part travels verbatim and fastify validates it — never twice. The validator runs against a copy of the frozen headers snapshot, so an ajv compiled with coerceTypes cannot corrupt (or throw on) it.
schema and the programmatic input/output validators are mutually exclusive on one procedure. How schemas become validation is the injected compiler's job.
Default error responses
wRPC documents its own lifecycle errors in the effective response — what a host and swagger see — derived from the procedure's options:
| Status | When it is documented |
|---|---|
429, 500, 503 | Always — every call can hit maxCalls, a handler bug, a drain or a full queue. |
400 | The procedure validates (schema or input). |
403 | access is not 'public'. |
408 | The procedure has a timeout. |
Each entry is the wire error shape. Your own schema.response[code]overrides the default for that code; response[code]: false removes it. 499 (cancel) is deliberately absent — a cancelled request gets no response to document.
The client side
A client on the http transport sends mapped calls as the same REST requests external consumers do — URL built from http.path and args.params, query string from args.query, JSON body from args.body — and reads the plain result back. Every other transport speaks packets. The mapping travels through system/introspect (info.http), so nothing is configured on the client.
Pluggable query strings
The default query parser is the prototype-safe URLSearchParams path — flat string values, last one wins. To parse (and, client-side, serialize) nested objects and arrays, inject your own — qs, for example:
// Server
const qs = require('qs');
new Server({ router, querystring: { parse: (text) => qs.parse(text) } });
// Client — the mirror, so array encodings agree end to end
await WrpcClient.connect(url, { querystring: { stringify: (query) => qs.stringify(query) } });An injected parser takes over prototype-pollution responsibility — qs guards against __proto__ keys itself; a hand-rolled parser must too.
Hosts
Under the fastify adapter every mapped procedure becomes a native fastify route — fastify owns routing, validation, serialization and swagger; see Fastify. Under the built-in Server, express and uws, the core serves the mapped routes itself, schemas compiled by the injected validator.