Skip to content

Architecture ​

wRPC is a handful of pieces with narrow seams between them: a client, a wire, a server core, and the hosts and carriers that connect the two. This page is the map. Each diagram shows where two pieces meet, and each section points to the page that owns the details. For the one-picture version, see Getting Started.

The layers ​

A call crosses the same layers in both directions, and each layer has one job:

LayerOwnsSwapped byDetails
WrpcClientcall ids and the pending map, units scaffolded from introspection, subscriptions and their lastEventId, streams, reconnect, heartbeat—Client
Client transportone wire: opening it, writing to it, reporting open, close and messagea registered transport, WrpcClient.transport[name]Client › Transports
EncodingJSON or an injected packet codec; bytes as attachments frames under revision 2; optional compression and encryptionthe codec, compression and encryption optionsWire codec, Compression, Encryption
Hostthe network: listening, parsing HTTP, the WebSocket handshake and framesan engine, a framework adapter, an injected HTTP/3 or WebRTC stack, a brokerServer, Engine port
RpcServerthe four entry points, a Client per connection, sessions, rooms, the cluster, SSE channels— (it is the core)Server › Using the core directly
Dispatcher and Routerrouting packets, hooks, validation, timeouts, queues—Router, Hooks

Nothing above the transport knows which wire it is on, and nothing below the dispatcher knows what a procedure does. Every other section of this page depends on that.

Life of a call ​

One call over a WebSocket, through every layer of the picture above:

The hook phases at steps 5 and 6 are on Hooks, and the deadline and the queue slot on Router › Timeouts and queues. What a timeout or a cancel does to a call in flight is in the protocol reference.

Four ways in ​

RpcServer never listens on anything. Every host hands it connections through one of four methods, and all four end in the same place: a server-side Client that the dispatcher talks to.

Entry pointTakesUsed by
attachSocket(socket, meta)anything shaped like the engine port's WrpcSocket: send, close, 'message' and 'close' eventsevery WebSocket engine; WebTransport, whose session becomes a WtSocket; under session encryption, a SealedSocket wrapped around either
handleHttpCall(call)an abstract { method, url, headers, body, respond, stream? }, never a node req/res pairthe Server shell, the express middleware, the fastify routes, the uWebSockets.js engine's onHttpCall, a stateless broker request; SSE channels open here too
attach(transport, options)any transport that announces inbound text as 'packet' and bytes as 'chunk'a WebRTC data channel, a broker session, a queue consumer (persistent: false: it runs calls, never receives events)
attachPort(port)a MessagePorta worker thread, an embedded peer, a test harness

From there every connection takes the same steps. A Client is created, its session is restored from whatever the connection presented, and the router's onConnect hooks run. Both of those make up client.ready, which the dispatcher awaits before the first access check. Every packet after that goes through one dispatcher. A procedure cannot tell a WebSocket client from a broker message, except by asking ctx.client.transportKind.

One connection on the server ​

  • Client.id is an address. It is <instanceId>.<generated id>, so a cluster command or server.sendTo(id, …) goes straight to the instance that holds the client, with no broadcast.
  • A Client keeps what a peer can take back. calls holds an AbortController per call in flight, which cancel and a disconnect both reach (it is ctx.signal). subscriptions and streams are capped by maxSubscriptions and maxStreams.
  • ServerTransport is the only piece that knows the wire. There is one subclass per kind: http, ws (WebTransport extends it), event, sse, WebRTC's RtcPeerTransport and the broker's session transport. The Client above it is the same for all of them.
  • A Context lives for one call. It reads session and meta through its Client and reaches the server as ctx.server. That is how ctx.server.to(room) works with no server captured in a closure.
  • Three things are injected: the session store and the token transport (Sessions, Authentication), and the backplane under rooms and the cluster (Scaling).

The receive path ​

What the core does with one inbound WebSocket message. The other entry points join this path at handleMessage (or at the chunk branch), so the dispatcher below is the only one.

Pausing the socket while a chunk is being consumed is what lets backpressure reach the peer through TCP. See Binary streams › Backpressure. The frame kinds are in the protocol reference.

The send path ​

A packet is encoded once, in exactly one of four ways, and handed to its transport. Everything below that is the carrier's business:

What happens below write is per carrier, and some layers exclude others:

CarrierBelow write, in order
WebSocket, server → clientsealed when the connection is encrypted (then sent with compress: false: ciphertext does not deflate) → engine frame → permessage-deflate when negotiated
WebSocket, Node client → servercompressed when the ping/pong agreed a codec → sealed when encrypted. Compress, then seal
WebTransportfive-byte length and kind header, compressed when the capabilities agreed a codec → the control stream. Under session encryption there is no compression, no stream mux and no datagram
WebRTCcompressed when the description agreed a codec → fragmented to the channel's message size → the data channel, which is DTLS already
HTTP, SSEthe response body or the SSE event → Content-Encoding when the request accepts one. A sealed request carries no content coding inside
Broker sessioncompressed if smaller (wrpc-enc names the codec) → sealed under the keyring → the broker

Compression and Encryption explain each layer.

A room broadcast reuses the single encoding:

A sealed connection is the exception: it gets the shared plaintext frame and seals it for itself, so a broadcast to n sealed clients costs n seals.

The client ​

Outbound, a call leaves through one queue and one encoder:

Inbound, everything is sorted by what arrived:

client.api is built from what the server says about itself (system/introspect) or from a generated artifact (Typed client › Static introspection). That is why a unit is undefined until load() resolves. The connection's own lifecycle (connecting, authenticating, restoring, waiting for the next attempt) is a state machine on Client › Reconnecting.

Plugging in ​

Each subpath plugs into one of a few seams. There are two registries that a subpath fills in when it is required, the four entry points above, and a few options that take a structural contract.

Every contract is checked by shape (isEngine, isBackplane, isCompressor, isWtSession, …), never by instanceof. That is why no framework, broker client or WebRTC stack is a dependency of the package. Each one is injected. Requiring a subpath is enough to register its transport. The registries are plain objects on purpose, so a transport of your own registers the same way.

Across instances ​

ChannelCarriesSubscribed by
room:<name>an emit to exactly one roominstances that hold a member of that room
broadcastserver.broadcast() and multi-room emitsevery instance
clusterpresence snapshots and deltas, cluster-wide commands and questionsevery instance
inst:<id>one instance's inbox: sendTo a client it holds, answers to its questionsthat instance

The backplane carries events and requests, never connections. A client that reconnects to another instance gets its session back from the shared store, its rooms back when an onConnect hook re-joins them (Rooms › Rooms and reconnects), and resumes its subscriptions from lastEventId. Scaling covers the contract and delivery guarantees, Cluster the presence protocol, and Running in production what still needs affinity.

Released under the MIT License.