@wsrpc/server
v4.5.1
Published
Bidirectional WebSocket RPC for Node.js and TypeScript
Readme
@wsrpc/server
Use this server with Node.js HTTP, Express or Fastify. For Workers, Deno or Bun,
use RPCPeer from @wsrpc/client/core with the host's WebSocket API.
@wsrpc/server/core remains an alias of that core. See the
development guide
for packaging details.
Bidirectional WebSocket RPC for Node.js and TypeScript. It uses the WSRPC
protocol shared by wsrpc-aiohttp, wsrpc-asgi and the original browser
client. Both ends can expose methods and call each other over one connection.
This repository contains two npm packages:
| Package | Purpose |
| --------------- | ------------------------------------------------------------------------------- |
| @wsrpc/client | Existing MIT browser client, plus the typed Node client at @wsrpc/client/node |
| @wsrpc/server | ESM Node.js 22+ server with HTTP, Express and Fastify integration |
The client is imported from wsrpc-js-client
with its complete Git history and original browser source. CommonJS, the ESM
module, classic script WSRPC global and existing distribution paths remain
available. The shared protocol lives in @wsrpc/client/core; the server
re-exports its route classes, errors and types. Both packages use version 4.5.0,
continuing the existing client's published 4.4.1 series.
Run from this checkout
npm ci
npm run build
npm testTo run the complete examples, compile them and start a server:
npx tsc -p tsconfig.test.json
node build/examples/http.js
# Alternatives:
# node build/examples/express.js
# node build/examples/fastify.jsEach example is a standalone file with its RPC methods, framework integration,
HTTP /health endpoint and shutdown handling. In another terminal:
node build/examples/client.jsExpected output includes { message: 'Hello' } and 42, the latter produced
by a reverse call to the client's double route. After npm run build,
create both npm artifacts and install them in another application:
npm run pack
# In your application directory:
npm install /path/to/wsrpc-ts/artifacts/wsrpc-client-4.5.0.tgz /path/to/wsrpc-ts/artifacts/wsrpc-server-4.5.0.tgzAfter publication, use npm install @wsrpc/client @wsrpc/server.
Server API
import { createServer } from "node:http";
import { WebSocketAsync } from "@wsrpc/server";
class RPC extends WebSocketAsync {}
RPC.addRoute("echo", (_socket, params: Record<string, unknown>) => params);
RPC.addRoute("callback", (socket, { value }: { value: number }) =>
socket.call<number>("double", { value }),
);
const http = createServer((_request, response) => response.end("Hello"));
const rpc = RPC.attach(http, { path: "/ws/" });
http.listen(8000, "127.0.0.1");
// During application shutdown:
// await rpc.close();
// http.close();Use the same attach(httpServer) with Express's HTTP server or Fastify's
app.server. It does not replace HTTP request handling. Only the exact RPC
upgrade path is handled; other upgrade listeners can serve other WebSockets.
Call rpc.close() before closing the parent server. This detaches the upgrade
listener, aborts outstanding authorization and closes active RPC sockets.
A framework which already accepted a ws WebSocket can pass it to
RPC.asServer().serve(webSocket). Create and retain that RPCServer once per
application so its clients, signals and shutdown remain available.
Calls and typing
Callbacks receive (socket, params). Object parameters are passed as one
object; arrays stay arrays. Unlike Python's keyword arguments, use object
destructuring for named parameters. A missing or null params field becomes
an empty object. An undefined result becomes JSON null.
import { WSRPCClient } from "@wsrpc/client/node";
const client = await WSRPCClient.connect("ws://127.0.0.1:8000/ws/", {
routes: new Map([
["double", (_socket, { value }: { value: number }) => value * 2],
]),
});
try {
console.log(await client.call("callback", { value: 21 }));
} finally {
await client.close();
}socket.proxy provides dynamic dotted calls. For checked arguments and return
types, apply a remote contract with remote<T>():
interface Remote {
counter: {
add(params: { value: number }): number;
};
}
// Inside a handler or after connecting:
// const remote = socket.remote<Remote>();
// const result: number = await remote.counter.add({ value: 2 });These are compile-time contracts; validate incoming application data in handlers.
Calls run concurrently and can finish out of order. A handler can await a
reverse call without blocking the receipt of its reply. call() accepts a
third options argument: { timeout: 5000, signal: controller.signal }.
Timeouts use milliseconds; zero disables the local timeout. Timeout/abort
removes the pending call locally; the existing wire protocol has no remote
cancellation message. Running handlers can observe socket.signal on disconnect.
Routes with connection state
import { AllowedRoute, WebSocketAsync } from "@wsrpc/server";
class Counter extends AllowedRoute {
value = 0;
init({ value = 0 }: { value?: number }) {
this.value = value;
return this.value;
}
add({ value }: { value: number }) {
this.value += value;
return this.value;
}
override _onClose() {
// Release resources owned by this connection.
}
}
class RPC extends WebSocketAsync {}
RPC.addRoute("counter", Counter);You can also register an already initialized Route object, including its application dependencies. Omit the socket argument when creating a shared object:
import { AllowedRoute, getCurrentSocket } from "@wsrpc/server";
class Service extends AllowedRoute {
constructor(readonly prefix: string) {
super();
}
async echo(params: { value: number }) {
const peer = this.socket;
await Promise.resolve();
// The same peer remains current after await, even across concurrent clients.
return { prefix: this.prefix, value: params.value, connection: peer.id };
}
}
// RPC.addRoute("service", new Service("configured"));
// Helpers called from a route can use getCurrentSocket() without a socket argument.
function currentConnection() {
return getCurrentSocket().id;
}A registered instance shares its application state across connections. The library
does not overwrite its socket field: this.socket uses Node AsyncLocalStorage
for the current call. getCurrentSocket() and getCurrentCall() expose the same
context to ordinary helper functions. They throw outside a request/lifecycle
context. Plain function handlers also receive the peer as their this binding;
arrow functions use the explicit socket argument instead. _onClose() runs once
per connection that used an object, with that connection's context. Shared
application resources must therefore be disposed by the application, rather than
by a connection close hook. Do not retain an ambient context past the request's
lifetime.
Each connection lazily creates its own route instance. Calling counter
invokes init explicitly; counter.add invokes add without implicitly
calling init. AllowedRoute/WebSocketRoute expose public prototype methods.
Private names, constructors, getters, instance fields and Object methods are
excluded. Use noproxy(Counter.prototype.someMethod) to mask a public method.
Route exposes an explicit static methods set or methods marked with @proxy; PrefixRoute exposes
rpc_name as name (its static prefix can be overridden).
A WebSocketAsync subclass owns its route registrations. Each RPCServer takes
an isolated snapshot when created. Register routes before attach/asServer;
peer.addRoute() and deleteRoute() change only that connection.
Events, binary data and lifecycle
await socket.emit({ topic: 'changed', value: 42 }) sends an event without
an RPC reply. Events cannot contain an id. socket.onEvent.connect()
registers an event listener. sendRaw(Uint8Array) and onBinary carry raw
binary WebSocket messages separately from JSON RPC.
rpc.broadcast(method, params, options) calls all current clients and returns
an array of PromiseSettledResult values. Clients and broadcasts are local
to the process; applications supply any cross-worker event distribution.
Signals use connect(listener) and return an unsubscribe function. The server
has onConnect, onDisconnect and onError; each peer has onCallStart,
onCallSuccess, onCallFail, onEvent, onBinary, onClose and onError.
Listener failures are reported through onError; they do not suppress replies
or prevent pending calls from being rejected on shutdown. _onClose() hooks
are awaited; keep them finite. Synchronous handlers execute on the Node event
loop. CPU-heavy work belongs in application-managed worker threads.
Authorization, errors and limits
RPC.attach(http, { authorize: (request, signal) => ... }) runs before the
WebSocket upgrade. Returning false produces HTTP 403; any other result becomes
socket.context. The signal aborts if the upgrade disconnects, authorization
times out or the server shuts down. Existing HTTP middleware does not perform
this upgrade authorization automatically.
| Option | Default | Unit / meaning |
| ---------------------- | ------- | --------------------------------------------------- |
| path | /ws/ | Exact URL path, including the trailing slash |
| timeout | 30000 | Outgoing RPC timeout in milliseconds |
| maxPendingCalls | 1024 | Unanswered outgoing calls per connection |
| maxConcurrentCalls | 128 | Incoming handlers per connection |
| maxPayload | 1048576 | Maximum incoming WebSocket message bytes |
| maxBufferedBytes | 1048576 | Maximum queued outgoing message bytes |
| authorizationTimeout | 10000 | Milliseconds, zero disables the deadline |
| closeTimeout | 1000 | Milliseconds before terminating a closing ws socket |
Remote failures reject with RemoteError (ClientException is an alias),
preserving the remote payload. CallTimeoutError, ConnectionClosedError
and CapacityError describe local failures. Disconnect rejects pending calls
and aborts the peer's signal. Invalid JSON/identifiers and duplicate in-flight
request IDs close the connection; late replies to aborted calls are ignored.
JSON uses native JSON.parse/JSON.stringify. Supply { codec: { loads,
dumps } } on both peers to customize it. JSON.stringify semantics apply:
BigInt/cyclic values fail serialization, and embedded byte arrays are not
implicitly converted to Python's base64 representation. Use raw binary frames
or an agreed codec. Async handlers which continue after disconnect cannot send
late replies. Application-level ping echoes its parameters; ws handles
WebSocket control frames separately.
Verification and development
npm run check
npm run test:types
npm run test:interop
npm run test:packagestest:interop requires uv and installs its locked Python environment under
tests/interop/. It checks both Python clients against this server and this
client against both Python servers, including callbacks and remote errors.
The ASGI version is pinned to a source revision in that lockfile.
The Node suite covers the unchanged original browser client, Node HTTP, Express, Fastify, concurrent callbacks, route state/policies, auth, payload and queue limits, malformed messages, errors, timeouts, abort and shutdown. CI runs Node 22/24/26 on Linux plus Node 24 on Windows/macOS, and a separate Python interoperability job. The npm artifact includes declarations, source maps, source and license notices. It contains no Python library implementation.
The server is licensed under Apache-2.0; the client retains its MIT license. Contact: [email protected].
Publishing releases
The publish.yml workflow runs when a GitHub Release is published, just
like the Python repositories. A tag push alone does not publish. The workflow
checks out the release tag, derives the npm version from v4.5.0 or 4.5.0,
runs the full checks and Python interoperability, and publishes the tested client artifact before the server artifact. Both receive
the tag version, including the server dependency on the client. The version changes only inside CI: no version commit or new tag is
created. Tags may include SemVer prerelease suffixes such as v1.2.3-rc.1.
Prerelease versions and releases marked prerelease use next; stable releases
use latest. Build metadata suffixes are not used as npm release versions.
Publishing uses GitHub OIDC with npm CLI 11.19.1 and the npm GitHub
environment. Set up the npm trusted publisher for each package once:
| Setting | Value |
| ----------------- | ----------------------------------- |
| Packages | @wsrpc/client and @wsrpc/server |
| GitHub owner | wsrpc |
| Repository | wsrpc-ts |
| Workflow filename | publish.yml |
| Environment | npm |
| Allowed action | Enable direct npm publish |
For a brand-new package, initialize it from an authenticated npm maintainer
account before configuring its package settings. That account needs access
to the @wsrpc scope. Subsequent releases use OIDC without a stored npm token.
No npm package, release or tag is created by the local checks.
See npm trusted publishing for account configuration. Publish a fresh version after that connection is set up; npm does not allow an existing name/version to be overwritten.
An interrupted publication can be rerun: an already published package is skipped only if its registry integrity matches the tested archive. A different artifact with the same name and version fails the workflow. npm publication of two packages is sequential, so it is not atomic.
test:types uses strict tsc --noEmit with library declaration checking
and expected compiler errors. It covers nested remote contracts, async results,
optional/no-argument calls, browser constructor generics, handler this, prepared
routes and typed signals. CI checks declarations with TypeScript 5.9 and 7.
The packed-package test also compiles separate ESM and CommonJS consumers from
a clean temporary install, without skipLibCheck.
