Skip to content

Server

There are two server objects, and the difference matters:

  • RpcServer is the engine-agnostic core. It knows nothing about node:http, sockets or listening — you hand it a socket (attachSocket), a MessagePort (attachPort), or an abstract HTTP call (handleHttpCall), and it does the RPC. Every adapter is a thin shell around one.
  • Server is the batteries-included shell: a node:http(s) listener plus a WebSocket engine, composed around an RpcServer. It is what you want unless you already have a framework owning the port.
js
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

OptionDefaultMeaning
routerThe Router. Required.
basePath'/api'Where both endpoints live. '' serves from the root.
sessions{}Session store, cookie and token generator.
corsnullSee CORS below.
backplanenullCarries room events between instances — see Scaling.
instanceIda uuidIdentifies this instance on the backplane.
generateIduuid v4Context uuids, server stream ids, REST packet ids — bring your own (cuid/ulid).
introspectiontruesystem/introspect exposure: true public, 'session' gated, false off.
maxBatch128Packets accepted in one batch frame.
maxSubscriptions256Concurrent subscriptions per client.
maxCalls1000In-flight calls per client; past it a call answers 429.
sse{}SSE channel options, or false to remove the endpoint.
loggerglobalThis.consoleWhere the server logs — a Console or a pino-shaped logger; false silences it. See Logging.
telemetrynullOTel 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

OptionDefaultMeaning
host / portPassed to listen(). Port 0 binds a free one.
protocol'https''http' uses node:http; anything else uses node:https.
key / cert / SNICallbackTLS material, forwarded to https.createServer.
nagletruefalse sets noDelay on the listener.
enginecreateNodeEngine()The WebSocket engine.
ws{}Forwarded to the engine's attach()path, protocols, verifyClient, perMessageDeflate, …
maxBodySize10 MiBRequest-body cap in bytes for the built-in HTTP path.
retry3EADDRINUSE bind attempts before giving up.
timeouts.bind2000Milliseconds 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:

bash
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:

js
new Server({
  router,
  cors: {
    origins: ['https://app.example'],   // or (origin) => boolean
    credentials: true,                  // needed for the session cookie
  },
});
FieldDefaultMeaning
originsAn allowlist array, or a predicate.
credentialsfalseEmits 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 ', '.
metaHeadersMeta keys allowed as per-key x-wrpc-meta-<key> headers. Appended to headers.

Four things worth knowing:

  • Once origins is set, every response carries Vary: Origin — allowed or not — so a shared cache can never serve one origin's grant to another. credentials is 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 headers drops x-wrpc-channel, last-event-id and x-wrpc-meta unless 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.
  • metaHeaders exists because CORS has no wildcard for header names. A client using metaFormat: 'prefixed' sends one real header per meta key, so each key must be named: metaHeaders: ['userId'] grants x-wrpc-meta-user-id — normalized with the same rule the client uses, so a camelCase config still grants the name that actually arrives. The default metaFormat: 'json' needs none of this: it sends the one already-allowed x-wrpc-meta header whatever the keys are.
  • A request with no Origin header 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

js
await server.listen();   // resolves with the server; retries EADDRINUSE
await server.close();    // closes clients, the engine, and the listener

listen() 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:

js
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:

js
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.

Released under the MIT License.