Routes and TypeScript API¶
wsrpc-ts implements the same bidirectional WSRPC protocol as the Python
servers. Use Node HTTP, Express or Fastify with ws, or connect an existing
accepted ws socket to the RPC endpoint. The library supports Node.js 22+;
the standalone .ts examples in this guide use Node.js 24+.
Start with the framework tabs in the JavaScript quick start.
Every server example is one complete app.ts file. It includes echo, a
reverse callback, an ordinary HTTP health route and shutdown handling.
Install from a checkout¶
The monorepository contains @wsrpc/client and @wsrpc/server. The browser
client is the existing wsrpc-js-client, with its original API and Git history.
Build both archives from a wsrpc-ts checkout:
cd /path/to/wsrpc-ts
npm ci
npm run pack
In your application directory, install both archives:
npm install /path/to/wsrpc-ts/artifacts/wsrpc-client-4.5.0.tgz /path/to/wsrpc-ts/artifacts/wsrpc-server-4.5.0.tgz
# For an Express or Fastify application, also install its framework:
# npm install express
# npm install fastify
These commands work before publication. After release, use
npm install @wsrpc/client @wsrpc/server. Both versions are derived from the
same GitHub Release tag; version 4.5.0 continues the existing client’s 4.4.1
series. Copy the selected server from the quick start to app.ts and run
node app.ts. For static checking, install TypeScript and the Node/framework
declaration packages in your application and use your normal TypeScript build.
The browser client uses import WSRPC from '@wsrpc/client', CommonJS
require('@wsrpc/client'), or the classic dist/wsrpc.min.js script. Its
module and distribution paths remain available. The Node client is a separate
entry, import { WSRPCClient } from '@wsrpc/client/node'. Core routes, errors
and protocol types are exported through @wsrpc/client/core and re-exported
by @wsrpc/server.
Register methods and serve connections¶
import { createServer } from 'node:http';
import { WebSocketAsync } from '@wsrpc/server';
class RPC extends WebSocketAsync {}
RPC.addRoute('add', (_socket, { a, b }: { a: number; b: number }) => a + b);
const http = createServer((_request, response) => response.end('HTTP works'));
const rpc = RPC.attach(http, { path: '/ws/' });
http.listen(8000, '127.0.0.1');
attach() adds one HTTP upgrade listener and preserves ordinary HTTP handling.
The RPC path is exact, including its trailing slash. Other upgrade listeners
can handle other paths. Express uses its HTTP server; Fastify uses
app.server. For accepted sockets, create const rpc = RPC.asServer() once
and call rpc.serve(webSocket) for each connection. The returned RPCPeer
provides calls, context, lifecycle signals and a connection abort signal.
Create a separate WebSocketAsync subclass per application. Each RPCServer
snapshots that class’s route registrations when created, and exposes its
active clients map. Register application routes before attach() or
asServer(); peer.addRoute() changes only that peer’s routes.
Call await rpc.close() before stopping the parent HTTP server. The Fastify
example does this in preClose. Shutdown detaches the upgrade listener,
aborts pending authorization, rejects unanswered RPC calls and closes sockets.
It does not take ownership of the parent HTTP server.
Typed calls and callbacks¶
Functions receive (socket, params). Object parameters are passed as one
object and arrays stay arrays; use object destructuring for the equivalent
of Python keyword arguments. Missing/null parameters become {}. An
undefined return value becomes JSON null. Both synchronous and async handlers
are supported, with all JavaScript execution on the Node event loop.
await socket.call('double', { value: 21 }) works with the existing Python
and browser clients. socket.proxy provides dynamic dotted calls. Apply an
interface to obtain checked arguments and results:
import type { RPCPeer } from '@wsrpc/server';
interface Remote {
counter: {
add(params: { value: number }): number;
};
}
async function increment(socket: RPCPeer): Promise<number> {
return socket.remote<Remote>().counter.add({ value: 2 });
}
The generic contract checks nested method names, argument shapes and results.
Synchronous and async return types both become Promises. Methods can have no
parameters, optional parameters or one required params object. The wire protocol
carries a single params value, so multi-argument signatures are not exposed by
the typed proxy. Pass call options after params, for example
remote.counter.add({ value: 2 }, { timeout: 5000 }).
TypeScript contracts are compile-time checks; handlers validate incoming data. Each call returns an independent Promise, so replies can arrive out of order. Handlers can await reverse calls while other messages continue to be processed. For a complete client with callbacks, see TypeScript / Node.js.
Route classes and 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);
A route object is created lazily for each connection. Calling counter
explicitly invokes init; counter.add does not implicitly call it.
AllowedRoute and WebSocketRoute expose public prototype methods, excluding
private names, constructors, getters, instance fields and Object methods.
noproxy(Counter.prototype.someMethod) masks a public method. Route uses
an explicit static methods set or methods decorated with @proxy. PrefixRoute exposes rpc_name as name
and supports an overridable static prefix. TypeScript’s private modifier is
erased; use #private, underscore-prefixed names or noproxy for runtime
masking. Standard @proxy/@noproxy method decorators require a TypeScript
build; Node’s native type stripping does not transform decorators.
Pre-created routes and implicit context¶
A Route class is instantiated lazily once per connection. An already initialized Route object keeps its dependencies and shares its application state across connections:
import { AllowedRoute, getCurrentSocket, WebSocketAsync } from '@wsrpc/server';
function connectionId(): string {
return getCurrentSocket().id;
}
class Service extends AllowedRoute {
calls = 0;
constructor(readonly prefix: string) { super(); }
async echo({ value }: { value: number }) {
this.calls++;
const before = this.socket.id;
await Promise.resolve();
return { value, prefix: this.prefix, sameConnection: before === connectionId() };
}
async callback({ value }: { value: number }) {
return this.socket.call<number>('double', { value });
}
}
class RPC extends WebSocketAsync {}
RPC.addRoute('service', new Service('configured'));
Create shared objects without a socket constructor argument. this.socket
resolves the connection using Node’s AsyncLocalStorage: concurrent calls from
different connections do not overwrite a field on the shared object. It remains
correct through await and reverse callbacks. Ordinary helper functions can
use getCurrentSocket() and getCurrentCall() without receiving a socket
argument; these throw outside an active RPC context. Authentication data is
available through getCurrentSocket().context. Plain function handlers also
have the peer bound as this; arrow functions use their explicit socket
argument.
_onClose() runs once per connection that used an instance, with that
connection’s context. It releases connection resources. Dispose shared
application resources at application shutdown. Keep background jobs outside
request lifetimes and pass any context they need explicitly. Method exposure
policies apply equally to classes and registered objects.
Events, binary frames, signals and broadcast¶
await peer.emit({ topic: 'changed', value: 42 }) sends an event with no RPC
reply. Events cannot contain id. Receive events with
peer.onEvent.connect(listener). Send a binary WebSocket frame with
peer.sendRaw(new Uint8Array(...)); onBinary receives Uint8Array values.
The shared protocol guide explains binary versus JSON data.
rpc.broadcast(method, params, options) calls every current client and
returns PromiseSettledResult[], preserving failures for individual peers.
Connections and broadcasts are local to one process.
Signals use connect(listener) and return an unsubscribe function:
Object |
Signals |
|---|---|
RPCServer |
|
RPCPeer |
|
Call signals include the peer, request id, method and params; completion
signals add the result or error. Listener failures are reported through
onError without suppressing replies or pending-call cleanup. Close hooks
are awaited, so keep them finite. A running handler observes peer.signal
when its connection closes; JavaScript cannot forcibly cancel arbitrary work.
Errors and JSON codecs¶
Remote errors reject with RemoteError (ClientException is an alias); its
payload preserves the remote value. Local failures use CallTimeoutError,
ConnectionClosedError or CapacityError. Closing a connection rejects all
pending calls. Invalid JSON/identifiers and duplicate in-flight IDs close the
connection. Stack traces are not sent as error payloads.
Default JSON uses native JSON.parse and JSON.stringify. Supply a
codec: { loads, dumps } to customize it. BigInt and cyclic values fail
serialization. Embedded byte arrays are not automatically converted to
Python’s base64 representation; use raw binary frames or an agreed codec.
Verification and development¶
In the wsrpc-ts checkout:
npm run check
npm run test:interop
npm run pack
The Node suite exercises the original JS client, Node HTTP/Express/Fastify,
concurrency, callbacks, route policies, authorization, limits and cleanup.
Interoperability uses a locked uv environment and real connections in both
directions with wsrpc-aiohttp and wsrpc-asgi. The shared documentation
checks TypeScript syntax without requiring the unpublished package. See
maintaining the documentation for its optional runtime
checks against the locally built npm artifact.
For package boundaries, conditional exports and compatibility gates, see the WSRPC development guide. These checks run in CI; applications only need the import and installation instructions for their runtime.