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), or an abstract HTTP call (handleHttpCall), 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 | Context uuids, server stream ids, REST packet ids — bring your own (cuid/ulid). |
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. |
sse | {} | SSE channel options, or false to remove the endpoint. |
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, … |
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)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.
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 MessagePort (Service Worker)
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. 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.