Server
There are two server objects, and the difference matters:
RpcServeris the engine-agnostic core. It knows nothing aboutnode:http, sockets or listening — you hand it a socket (attachSocket), aMessagePort(attachPort), an abstract HTTP call (handleHttpCall), or any persistent transport (attach) — which is how a WebTransport session, a WebRTC data channel and the broker binding get in — and it does the RPC. Every adapter is a thin shell around one.Serveris the batteries-included shell: anode:http(s)listener plus a WebSocket engine, composed around anRpcServer. It is what you want unless you already have a framework owning the port.
const { Server, defineRouter, procedure } = require('@alexify/wrpc');
const server = new Server({
router: defineRouter({ /* ... */ }),
host: '127.0.0.1',
port: 8000,
protocol: 'http',
});
await server.listen();
console.log(server.address()); // { address: '127.0.0.1', family: 'IPv4', port: 8000 }server.rpc is the core underneath, should you need it.
Options
Everything is one options object. The RPC half is shared with RpcServer and with every adapter; the network half belongs to the shell.
RPC options
| Option | Default | Meaning |
|---|---|---|
router | — | The Router. Required. |
basePath | '/api' | Where both endpoints live. '' serves from the root. |
sessions | {} | Session store, cookie and token generator. |
cors | null | See CORS below. |
backplane | null | Carries room events between instances — see Scaling. |
instanceId | a uuid | Identifies this instance on the backplane. |
generateId | uuid v4 | Every id the server mints — instanceId, client ids, context uuids, stream ids, REST packet ids, SSE channel ids, the cluster epoch. Bring your own (cuid/ulid). See Identifiers. |
introspection | true | system/introspect exposure: true public, 'session' gated, false off. |
maxBatch | 128 | Packets accepted in one batch frame. |
maxSubscriptions | 256 | Concurrent subscriptions per client. |
maxCalls | 1000 | In-flight calls per client; past it a call answers 429. |
maxStreams | 256 | Binary streams a client may hold open — announced and not yet ended; past it a stream packet answers 429. A peer decides how many it announces, and each holds memory until a handler reads it. |
sse | {} | SSE channel options, or false to remove the endpoint. |
http | {} | The HTTP side's own options: compression, off by default — see Compression. |
compression | off | Accept per-message compressed frames from a Node WebSocket client that negotiated them — see Compression. |
maxMessage | 16 MiB | The largest inflated client frame accepted on a socket. |
attachments | true | Bytes in args, results and events travel as binary attachments to every peer that negotiated revision 2; false makes the server speak revision 1 — JSON, to everyone, with wrpc.v1 selected and wrpc-version: 1 answered. |
logger | globalThis.console | Where the server logs — a Console or a pino-shaped logger; false silences it. See Logging. |
telemetry | null | OTel traces and metrics — see Telemetry. |
Lifecycle hooks are not a server option: they belong to the router (defineRouter(units, { hooks })), which is what every shell and adapter shares.
Network options
| Option | Default | Meaning |
|---|---|---|
host / port | — | Passed to listen(). Port 0 binds a free one. |
protocol | 'https' | 'http' uses node:http; anything else uses node:https. |
key / cert / SNICallback | — | TLS material, forwarded to https.createServer. |
nagle | true | false sets noDelay on the listener. |
engine | createNodeEngine() | The WebSocket engine. |
ws | {} | Forwarded to the engine's attach() — path, protocols, verifyClient, perMessageDeflate, … Compression is off until you pass perMessageDeflate — see performance. |
maxBodySize | 10 MiB | Request-body cap in bytes for the built-in HTTP path. |
retry | 3 | EADDRINUSE bind attempts before giving up. |
timeouts.bind | 2000 | Milliseconds between those attempts. |
What it serves
Under basePath (/api by default) the server answers three shapes of request, plus the WebSocket upgrade:
WS {basePath} the WebSocket upgrade (bare '/' is accepted too)
POST {basePath} packet mode: a JSON call packet as the body
ANY {basePath}/:unit/:method REST mode: args from the query string and body
GET {basePath}/events the SSE stream (see the SSE guide)Every HTTP request, whichever host received it, goes through the same handleHttpCall and is routed like this:
A packet-mode or REST request gets a short-lived Client of its own, so sessions, hooks, validation and telemetry run exactly as they do on a socket. In packet mode, one response answers a whole batch. A channel POST is the exception: it belongs to the channel's long-lived Client.
Packet mode is the wire protocol verbatim — the same JSON object a WebSocket frame carries, in an HTTP body. It is what the HTTP client transport and the wrpc types CLI use.
REST mode exists so anything that speaks HTTP can call a procedure without knowing the protocol. Query parameters and the JSON body are merged into the args object, with the body winning:
curl 'http://localhost:8000/api/greeting/hello?name=World'
curl -X POST http://localhost:8000/api/greeting/hello -d '{"name":"World"}' \
-H 'content-type: application/json'A versioned unit keeps its version in the path segment: {basePath}/auth.v1/signIn.
The response is always a callback packet, and the error code becomes the HTTP status — 404 unknown method, 403 no session, 418 if that is what your handler threw. See the protocol reference.
CORS
With no cors.origins configured, responses carry Access-Control-Allow-Origin: * and the WebSocket upgrade accepts any origin. That is convenient and it is not what you want in production for anything holding a session — configure an allowlist:
new Server({
router,
cors: {
origins: ['https://app.example'], // or (origin) => boolean
credentials: true, // needed for the session cookie
},
});| Field | Default | Meaning |
|---|---|---|
origins | — | An allowlist array, or a predicate. |
credentials | false | Emits Access-Control-Allow-Credentials. |
methods | 'POST, GET, OPTIONS' | Access-Control-Allow-Methods. |
headers | 'Content-Type, x-wrpc-channel, last-event-id, x-wrpc-meta' | Access-Control-Allow-Headers. A string, or an array joined with ', '. |
metaHeaders | — | Meta keys allowed as per-key x-wrpc-meta-<key> headers. Appended to headers. |
Four things worth knowing:
- Once
originsis set, every response carriesVary: Origin— allowed or not — so a shared cache can never serve one origin's grant to another.credentialsis what the session cookie needs; a wildcard origin cannot carry credentials, which is the other reason to configure an allowlist. - A disallowed origin is refused 403, not merely denied the grant. The page could not read the answer either way — but without the refusal the call would still have run, with the cookie session restored, which is exactly the cross-site request an allowlist exists to stop.
- Replacing
headersdropsx-wrpc-channel,last-event-idandx-wrpc-metaunless you put them back — the first two are what the SSE transport sends, so removing them disables cross-origin SSE, and the third carries connection metadata. metaHeadersexists because CORS has no wildcard for header names. A client usingmetaFormat: 'prefixed'sends one real header per meta key, so each key must be named:metaHeaders: ['userId']grantsx-wrpc-meta-user-id— normalized with the same rule the client uses, so a camelCase config still grants the name that actually arrives. The defaultmetaFormat: 'json'needs none of this: it sends the one already-allowedx-wrpc-metaheader whatever the keys are.- A request with no
Originheader always passes the upgrade check. Non-browser peers (curl, server-to-server) send none, and the header is not a credential in any case.
The same origin check gates the WebSocket upgrade, where it is a real defence rather than a browser courtesy: browsers do not apply the same-origin policy to WebSockets, so without an allowlist any page anywhere can open a socket to your server with the user's cookies attached. Configure cors.origins and the default verifyClient refuses a mismatched Origin outright.
Compression
Off by default, like every compression knob in wRPC: a gzip per answer is CPU spent for every peer to save bytes only some of them need. Turn it on for the HTTP side with http.compression:
new Server({
router,
http: {
compression: {
threshold: 1024, // bytes; smaller answers go plain
filter: (call) => !call.headers['x-internal'], // per request, optional
async: { threshold: 256 * 1024 }, // hand bodies this large to the threadpool
encodings: ['zstd', 'br', 'gzip'], // this server's order; the default is ['gzip']
},
},
});compression: true takes the defaults — gzip alone. An answer is encoded when the request's Accept-Encoding admits one of the server's codings, the body is at or over threshold, nothing upstream set a Content-Encoding already (a route's own headers, a framework compression plugin), and filter(call) — when given — returns true. The response then carries Content-Encoding, Vary: Accept-Encoding (joined onto the CORS Vary: Origin) and the encoded Content-Length. Packet-mode POSTs, batch frames, REST results and errors all leave through the same funnel; a REST route's ETag is computed over the plain body, so a 304 is the same validator whichever encoding was asked for, and a 204 or 304 is never encoded.
Which coding is encodings, the server's list in its order of preference: the first one on it the request accepts is used. A weight of zero refuses a coding and * covers the ones not named; other weights say acceptable, not preferred — which acceptable coding costs this server least is not the client's to know (nginx reads the header the same way).
encodings entry | Default level | On a 27 KB answer (bench/algorithms.js) |
|---|---|---|
'gzip' or { encoding: 'gzip', level, memLevel } | zlib's 6 | 110 µs, 3,214 B — what every client accepts |
'br' or { encoding: 'br', quality } | 4 | 93 µs, 2,601 B — the smallest at gzip's cost |
'zstd' or { encoding: 'zstd', level } | 1 | 36 µs, 2,848 B — a third of the CPU; Node 22.15+ / 23.8+, a TypeError at construction before |
{ encoding, encode(bytes), createStream? } | — | your own coding; encode may answer a promise, a failure answers the plain body |
Under ~2 KB the three are within a few bytes and microseconds of each other, so the list earns its place on large answers. zlib's own Brotli default is quality 11 — 33 ms on that answer — which is why the default here is 4. Browsers announce br and zstd over HTTPS only, so plain-HTTP development sees gzip whatever the list says.
Nothing changes on the client: fetch sends Accept-Encoding and inflates by itself, in browsers and in Node. The event stream has its own option, sse.compression; the WebSocket has perMessageDeflate.
The Node client's frames
perMessageDeflate compresses what the server sends; a Node client's built-in WebSocket only ever inflates, so its uploads — a 4 KB call, a stream chunk — arrive as they are. The server-level compression option accepts per-message compressed frames from a Node client that asked for them: the client sends { type: 'ping', enc: ['deflate-raw'] } on open — its codecs, in its order of preference — a server answers with the first of them it holds as the enc of its pong, and from then on the client sends every packet or chunk past the threshold as a binary frame under a 0x00 marker (a stream chunk never starts with one) that the server inflates before dispatch. Off on both ends by default; a lone end stays plain. A browser never needs it — it compresses both directions itself under perMessageDeflate.
new Server({ router, compression: true }); // accept them
const client = await connect('wss://host/api', { compression: true }); // send them (Node)What it costs — bench/http-compression.js, one-shot gzip of a callback:
| Answer | rate | ratio |
|---|---|---|
| 295 B (2 rows) | 110,649/sec | 1.7× |
| 1.6 KB (12 rows) | 76,920/sec | 5.5× |
| 8.5 KB (64 rows) | 27,563/sec | 9.9× |
| 135 KB (1000 rows) | 2,056/sec | 12.0× |
The first row is the reason for the 1 KiB threshold; the last one — half a millisecond on the event loop — is what async is for. Under the fastify adapter, delegated REST routes answer through fastify's own reply and are @fastify/compress's to encode; the packet endpoint and conventional REST paths under the plugin follow this option like any host.
Revisions
rpc.revision is the newest protocol revision this server speaks — 2, or 1 under attachments: false or a packet codec — and context.client.revision the one a connection settled on. They are the questions an upgrade from 1.0 asks: who is still on revision 1 is the 1 series of wrpc.server.connections by wrpc.revision (telemetry), and each such connection is a revision.peer debug line (logging).
Lifecycle
await server.listen(); // resolves with the server; retries EADDRINUSE
await server.close(); // closes clients, the engine, and the listenerlisten() retries only EADDRINUSE (retry times, timeouts.bind apart) — any other bind error rejects immediately.
Read the address through server.address()
With a standalone engine (uWebSockets.js) there is no node http server at all and server.httpServer is null. server.address() answers for both shapes.
Using the core directly
If something else owns the port, skip the shell. This is exactly what the express adapter does:
const { RpcServer } = require('@alexify/wrpc');
const rpc = new RpcServer({ router });
rpc.attachSocket(socket, { headers, remoteAddress }); // a WrpcSocket or Connection
rpc.attachPort(port); // a node:worker_threads MessagePort
rpc.attach(transport); // any persistent 'packet'/'chunk' transport
await rpc.handleHttpCall({ method, url, headers, body, respond });handleHttpCall takes an abstract call description rather than a node req/res pair — that is the seam every framework adapter plugs into. The shape is in index.d.ts as HttpCall; src/adapters/common.js has the helpers that build one from a framework request.
Ports
attachPort(port) speaks the protocol over a MessagePort instead of a socket: JSON packets as strings, binary chunks as Uint8Array. A port has no handshake, so the revision rides its first ping — a wRPC client sends it by itself — and until then the port is sent no framed message. That covers a worker thread, an embedded peer, or a test harness that wants a real client against a real server with no network in between. The Server shell wires it to a 'port' event, so a host can hand ports in without reaching for server.rpc:
server.emit('port', port);This is not the browser Service Worker story — there, the worker holds a real WebSocket to the server and the page reaches the worker over a MessagePort. That is entirely a client-side arrangement; see Client.
attach(transport) is the seam under both: any persistent transport that announces inbound text as 'packet' and bytes as 'chunk' events becomes a client, whether this package knows the wire or not. The one it ships on top of it is attachChannel(rpc, dc, options) from @alexify/wrpc/webrtc — a WebRTC data channel the application negotiated itself, a browser reaching this server peer to peer with connect(url, { transport: 'webrtc', channel }). Like a port, a channel carries no request: the client starts with no session, and what the application observed about the peer goes in headers / data.