Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,19 @@ narrower promise — see

### Added

**Shared Workers behind the `event` transport**
- `connect(url, { worker })` takes a `SharedWorker` (reached through its
`port`), a dedicated `Worker` or a raw `MessagePort` as well as a
`ServiceWorker` — the page side posts to `worker.port ?? worker`, and every
client still gets its own `MessageChannel`.
- `WrpcClientProxy` also listens on the SharedWorker `connect` event and
treats each page's port as the control bus `self` is in a Service Worker;
both listeners are registered, no context sniffing.
- `WrpcClientProxy` takes a `url` option — the server the worker connects to,
defaulting to the one derived from `self.location` as before.
- The proxy releases a port, and the answers parked on it, when the page
closes it (the `MessagePort` `close` event; best effort in older engines).

**WebRTC: peer-to-peer wrpc (`@alexify/wrpc/webrtc`)**
- `WrpcPeer` — a router others call, a signaler to find them through, an
RTC adapter to reach them with. Two browsers (or a browser and a Node
Expand Down Expand Up @@ -83,6 +96,9 @@ narrower promise — see
subscriptions and streams work over it as over a socket.

### Fixed
- `ClientEventTransport.close()` is idempotent: `terminate()` after `close()`,
or the cleanup after an `open()` that threw before a port existed, no
longer throws a `TypeError` in place of the original error.
- `attachPort` routed a `Buffer` chunk to the text handler; binary chunks
now reach `handleBinary` whatever the view type.
- `WrpcClient.write()` returns the transport's backpressure signal and the
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ wrpc (`@alexify/wrpc`) is a fast, zero-dependency, WebSocket-based RPC protocol

**Protocol architecture.** The wire protocol is JSON packets (`{ type: 'call' | 'callback' | 'event' | 'stream' | 'ping' | 'pong', ... }`) over WebSocket or HTTP, plus a small binary framing (`chunkEncode`/`chunkDecode`) for streamed uploads/downloads. It is documented in `docs/reference/protocol.md`, which is updated per phase and frozen at 1.0. Module layout under `src/`:

- `src/client.js` (barrel) + `src/client/` — `WrpcClient`/`WrpcClientProxy`: the RPC client (WS/HTTP/ServiceWorker-event transports, call scaffolding via `#scaffold`, reconnect with exponential backoff + full jitter, app-level `ping`/`pong` heartbeat, automatic re-`load()` of loaded units on `'reconnect'`), plus `connect` — a one-line alias of `WrpcClient.connect` that exists so the contract type has a natural home (`connect<Api>(url)`). The `authenticate` hook is awaited inside `open()` and again on every reconnect BEFORE `#restore` re-sends its subscribe packets (the window an `'open'` listener cannot reach); `refresh` is a single-flight one-shot-retry credential refresh; `client.call(target, args, options)` is the unscaffolded escape hatch those hooks use; the `headers`/`meta` options are connection-phase declarations re-resolved per open (real headers on http/sse, `wrpc_h`/`wrpc_meta` query parameters on browser ws).
- `src/client.js` (barrel) + `src/client/` — `WrpcClient`/`WrpcClientProxy`: the RPC client (WS/HTTP/worker-event transports — the `event` one takes `worker` as a ServiceWorker, a SharedWorker (reached through its `port`), a dedicated Worker or a raw MessagePort, and `WrpcClientProxy` on the worker side listens on both `self` `message` and the SharedWorker `connect` event, call scaffolding via `#scaffold`, reconnect with exponential backoff + full jitter, app-level `ping`/`pong` heartbeat, automatic re-`load()` of loaded units on `'reconnect'`), plus `connect` — a one-line alias of `WrpcClient.connect` that exists so the contract type has a natural home (`connect<Api>(url)`). The `authenticate` hook is awaited inside `open()` and again on every reconnect BEFORE `#restore` re-sends its subscribe packets (the window an `'open'` listener cannot reach); `refresh` is a single-flight one-shot-retry credential refresh; `client.call(target, args, options)` is the unscaffolded escape hatch those hooks use; the `headers`/`meta` options are connection-phase declarations re-resolved per open (real headers on http/sse, `wrpc_h`/`wrpc_meta` query parameters on browser ws).
- `src/rpc/` — the engine-agnostic server core (never imports `src/websocket/`; node built-ins are fine — `client.js` uses `node:http`'s STATUS_CODES): `router.js` (`defineRouter`/`procedure`, versions as `'unit.vN'` keys, input/output validators, timeout/queue, the reserved `on` key holding a unit's inbound event handlers and the declaration-only `emits` key describing outbound ones), `rest.js` (the declarative REST bridge split out of router: `normalizeHttp`, the `rest.version` strategy, the per-verb segment trie — all cold paths), `core.js` (`RpcServer` with `attachSocket`/`attachPort`/`attach`/`handleHttpCall`/`to`/`except`/`broadcast` — `attach` is the structural seam, any persistent transport announcing `'packet'`/`'chunk'` events, checked by `isInboundTransport` from `serverTransport.js`; the WebRTC `attachChannel` is built on it from `src/webrtc/index.js`, so `src/rpc/` never requires `src/webrtc/`), `client.js` (`Client`/`Context`, split out of core — core requires it, never the reverse), `meta.js` (the connection-metadata parser: `declaredHeaders`, `declaredData` — token transports receive its OUTPUT, never re-run it), `dispatcher.js` (packet routing over a `Router`), `sessions.js` (`SessionManager`, structural `SessionStore`, `MemorySessionStore`, cookie building, the widened `TokenTransport` port `read({ headers, url, declared, meta })`), `rooms.js` (`RoomRegistry`, the immutable chainable `Broadcast`, and `RoomsBackplane` binding a room registry to an injected backplane — with subscribe retry, a `linger` window on emptied channels and a `healthy` getter), `cluster.js` (presence with digest+sync corrective flow, a `rooms` replication filter, `fetchClients` with a loud `maxFetch` cap, addressed commands, node-to-node asks, opt-in HMAC envelope auth via `cluster.secret`, `instanceOfClientId`). The SSE channel registry is a core BUILT-IN with an `sse: false` opt-out, not an injected port — `SseChannels` needs `addClient` and the session identity, both core-owned; the shared `x-wrpc-channel` string comes from `src/sse/constants.js`, never from `sse/server.js`.
- `src/webrtc/` — peer-to-peer wrpc over WebRTC data channels, behind the `@alexify/wrpc/webrtc` subpath (the second with a `browser` condition). Browser-safe (bundled into `webrtc.browser.js`): `port.js` (wrpc's OWN W3C-shaped structural `RtcAdapter` contract — `isRtcAdapter`/`isRtcPeerConnection`/`isRtcDataChannel`, `createW3cAdapter` over any `RTCPeerConnection` constructor; the core binds to NO Node WebRTC package), `framing.js` (the one-byte header + fragmentation to the negotiated message size; `FrameEncoder` reuses a scratch buffer — `bench/rtc-framing.js`), `link.js` (`RtcLink`: one pc, perfect negotiation, two negotiated channels — one per client→host direction — ICE restart, redial; roles by id order: lower id = initiator/polite=false), `transport.js` (`ClientRtcTransport`, registered as `WrpcClient.transport.webrtc`, and `RtcPeerTransport`; both run over an `RtcLink` OR over a raw data channel the application owns — `link` vs `channel`, the client's `channel` may be a factory the reconnect cycle asks for the next one; over a link `close()` on EITHER half closes the LINK and `terminate()` is local and asks the link for an ICE restart, over a raw channel both close the channel), `host.js` (`PeerHost`: router + dispatcher + one `Client` per peer, `trust: 'link'` pseudo-sessions; composed from `src/rpc/` leaves, never `RpcServer`), `signaler.js` (the `Signaler`/`RosterSignaler` contract + `wrpcSignaler(client)`), `peer.js` (`WrpcPeer`, `PeerLink`; `connect()` from the higher id sends a `connect` knock so there is never an offer glare), `mesh.js` (`Mesh`: host Clients kept in `mesh:<room>`, so broadcast/ask are one `Broadcast` fan-out). Node-only, in `index.js` only: `signaling.js` (`createSignalingUnit`/`createSignalingHooks` — a router fragment relayed through `RpcServer.sendTo`; a peer's id IS its signaling client id) and `attachChannel(server, channel, options)` — the `attachPort` of WebRTC, an `RtcPeerTransport` over a raw channel handed to `RpcServer.attach`. Two support leaves exist for the browser bundle's sake: `src/status.js` (the frozen `STATUS_CODES` table, replacing `node:http` wherever only a status line was wanted) and `src/rpc/errors.js` (`publicErrorMessage`/`wireError`, re-exported by `src/transport.js`); `ServerTransport` itself lives in `src/rpc/serverTransport.js`, re-exported by `src/transport.js`, so a browser peer host never drags the HTTP/WS subclasses in.
- `src/wire.js` — the import-free wire-name constants (`wrpc_h`, `wrpc_meta`, `x-wrpc-meta`, `x-wrpc-meta-`, re-exported `x-wrpc-channel`), required by the five modules that speak them; a spelling change is one file, not a five-file grep.
Expand Down
22 changes: 11 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
A fast, **zero-dependency** WebSocket-based RPC protocol for Node.js and
browsers. Router and procedures, subscriptions that resume, rooms
that scale across processes, and binary streams with backpressure that reaches
all the way into TCP — [under 15 KB min+gzip](#bundle-size) in a browser bundle, and
all the way into TCP — [~15 KB min+gzip](#bundle-size) in a browser bundle, and
nothing at all in your lockfile.

```javascript
Expand Down Expand Up @@ -117,16 +117,16 @@ then gzipped):

| Entry | min | min+gzip | budget |
| ----- | ---:| --------:| ------:|
| `@alexify/wrpc` — browser (client, streams, chunks) | 44.3 KB | **14.9 KB** | 15.0 KB |
| `@alexify/wrpc` — node (client + server) | 158.2 KB | 52.2 KB | — |
| `@alexify/wrpc` — browser (client, streams, chunks) | 44.7 KB | **15.1 KB** | 16.0 KB |
| `@alexify/wrpc` — node (client + server) | 162.6 KB | 53.9 KB | — |
| `@alexify/wrpc/ws` (WebSocket engine) | 20.7 KB | 7.5 KB | — |
| `@alexify/wrpc/engine` (engine port) | 21.2 KB | 7.7 KB | — |
| `@alexify/wrpc/uws` (uWebSockets.js adapter) | 16.0 KB | 6.4 KB | — |
| `@alexify/wrpc/fastify` | 135.3 KB | 46.2 KB | — |
| `@alexify/wrpc/express` | 122.9 KB | 41.3 KB | — |
| `@alexify/wrpc/uws` (uWebSockets.js adapter) | 17.9 KB | 7.3 KB | — |
| `@alexify/wrpc/fastify` | 139.3 KB | 47.8 KB | — |
| `@alexify/wrpc/express` | 127.0 KB | 42.9 KB | — |
| `@alexify/wrpc/scaling` (rooms backplane) | 4.1 KB | 1.7 KB | — |
| `@alexify/wrpc/sse` — browser (client transport) | 47.2 KB | **15.9 KB** | 16.0 KB |
| `@alexify/wrpc/sse` — node | 86.1 KB | 28.6 KB | — |
| `@alexify/wrpc/sse` — browser (client transport) | 47.6 KB | **16.0 KB** | 17.0 KB |
| `@alexify/wrpc/sse` — node | 88.4 KB | 29.6 KB | — |
| `@alexify/wrpc/query` (TanStack bindings) | 2.7 KB | **1.1 KB** | 2.0 KB |
| `@alexify/wrpc/auth` (token strategies) | 3.3 KB | **1.5 KB** | 2.0 KB |
| `@alexify/wrpc/webrtc` — browser (peer, link, mesh) | 125.9 KB | **40.7 KB** | 41.0 KB |
Expand Down Expand Up @@ -225,7 +225,7 @@ for await (const message of client.api.chat.onMessage.iterate()) {
| **REST** | [Declarative endpoints](https://wrpc.vercel.app/guide/rest) on the same procedures — verb, path, status, fastify-shaped schemas, `/vN` version paths, OpenAPI via [`wrpc types --openapi`](https://wrpc.vercel.app/guide/cli) |
| **Sessions & auth** | [Cookie-backed sessions](https://wrpc.vercel.app/guide/sessions) restored on reconnect, pluggable store, CSRF-aware REST dispatch; [bearer/payload token carriers, client stores and the authenticate/refresh lifecycle](https://wrpc.vercel.app/guide/auth) |
| **Scaling** | [Rooms backplane](https://wrpc.vercel.app/guide/scaling) over any pub/sub; Redis and in-memory adapters included; [cluster layer](https://wrpc.vercel.app/guide/cluster) — replicated presence (`count` with no round-trip), `fetchClients`, cross-instance commands, node-to-node ask |
| **Transports** | WebSocket, plain HTTP, [Server-Sent Events](https://wrpc.vercel.app/guide/sse), Service Worker `MessagePort`, [WebRTC data channels](https://wrpc.vercel.app/guide/webrtc) between browsers (symmetric peers, mesh, built-in or pluggable signaling); [pluggable wire codec](https://wrpc.vercel.app/guide/codec) and [request metadata](https://wrpc.vercel.app/guide/metadata) (`x-wrpc-meta-<key>`) |
| **Transports** | WebSocket, plain HTTP, [Server-Sent Events](https://wrpc.vercel.app/guide/sse), a worker `MessagePort` (Service Worker or SharedWorker), [WebRTC data channels](https://wrpc.vercel.app/guide/webrtc) between browsers (symmetric peers, mesh, built-in or pluggable signaling); [pluggable wire codec](https://wrpc.vercel.app/guide/codec) and [request metadata](https://wrpc.vercel.app/guide/metadata) (`x-wrpc-meta-<key>`) |
| **Hosts** | Batteries-included [server](https://wrpc.vercel.app/guide/server), or [fastify](https://wrpc.vercel.app/guide/adapters/fastify) / [express](https://wrpc.vercel.app/guide/adapters/express) / [uWebSockets.js](https://wrpc.vercel.app/guide/adapters/uws) / bare `node:http` |
| **Client** | Exponential backoff with full jitter, app-level heartbeat, automatic re-`load()` and re-subscribe, offline/online |
| **Observability** | [Structured logging](https://wrpc.vercel.app/guide/logging) into your pino, [OpenTelemetry](https://wrpc.vercel.app/guide/telemetry) spans and metrics, W3C trace context across the wire |
Expand Down Expand Up @@ -269,8 +269,8 @@ See [Logging](https://wrpc.vercel.app/guide/logging) and
| `@alexify/wrpc/ws` | `WebsocketServer`, `Connection`, `Frame`, `FrameParser`, `OPCODES`, `CLOSE_CODES` | [Wire format](https://wrpc.vercel.app/reference/wire-format) |
| `@alexify/wrpc/engine` | `createNodeEngine`, `isEngine`, the `Engine`/`WrpcSocket` contracts | [Engine port](https://wrpc.vercel.app/reference/engine) |
| `@alexify/wrpc/uws` | `createUwsEngine`, `UwsSocket` | [uWebSockets.js](https://wrpc.vercel.app/guide/adapters/uws) |
| `@alexify/wrpc/fastify` | `wrpcFastify`, `findUwsApp` | [Fastify](https://wrpc.vercel.app/guide/adapters/fastify) |
| `@alexify/wrpc/express` | `createWrpc` | [Express](https://wrpc.vercel.app/guide/adapters/express) |
| `@alexify/wrpc/fastify` | 139.3 KB | 47.8 KB | — |
| `@alexify/wrpc/express` | 127.0 KB | 42.9 KB | — |
| `@alexify/wrpc/scaling` | `MemoryBackplane`, `createRedisAdapter`, `isBackplane` | [Scaling](https://wrpc.vercel.app/guide/scaling) |
| `@alexify/wrpc/sse` | `SseChannels`, `ServerSseTransport`, `ClientSseTransport`, `SseParser` | [Server-Sent Events](https://wrpc.vercel.app/guide/sse) |
| `@alexify/wrpc/query` | `createQueryUtils` | [TanStack Query](https://wrpc.vercel.app/guide/query) |
Expand Down
20 changes: 18 additions & 2 deletions client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -792,7 +792,14 @@ export interface WrpcClientOptions {
/** Shorthand for `reconnect.minDelay`. */
reconnectTimeout?: number;
heartbeat?: HeartbeatOptions | false;
worker?: ServiceWorker;
/**
* Selects the `event` transport: the connection lives in the worker, the
* page talks to it over a private `MessagePort`. A `ServiceWorker`
* (`navigator.serviceWorker.controller`), a `SharedWorker` (reached
* through its `port`), a dedicated `Worker`, or a raw `MessagePort` — the
* worker side runs a `WrpcClientProxy`.
*/
worker?: ServiceWorker | SharedWorker | Worker | MessagePort;
/** The RtcLink a `transport: 'webrtc'` client speaks over (`@alexify/wrpc/webrtc`). */
link?: import('./webrtc.browser.js').RtcLink;
/**
Expand Down Expand Up @@ -833,8 +840,17 @@ export interface WrpcClientOptions {
proxy?: (data: string, packet: object | null) => void;
}

export interface WrpcClientProxyOptions extends WrpcClientOptions {
/**
* The server the worker connects to. Defaults to `self.location`'s origin
* with the matching `ws:`/`wss:` scheme — right for a Service Worker on
* the site it serves; a SharedWorker proxying to another origin names it.
*/
url?: string;
}

export class WrpcClientProxy extends Emitter {
constructor(options?: WrpcClientOptions);
constructor(options?: WrpcClientProxyOptions);
open(): Promise<void>;
close(): void;
}
Expand Down
39 changes: 32 additions & 7 deletions docs/guide/browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,12 @@ the run — it is a ratchet, and it runs in CI's lint job.

| Entry | min | min+gzip | budget |
| --- | ---: | ---: | ---: |
| `@alexify/wrpc` — browser | 44.3 KB | **14.9 KB** | 15.0 KB |
| `@alexify/wrpc/sse` — browser | 47.2 KB | **15.9 KB** | 16.0 KB |
| `@alexify/wrpc` — browser | 44.7 KB | **15.1 KB** | 16.0 KB |
| `@alexify/wrpc/sse` — browser | 47.6 KB | **16.0 KB** | 17.0 KB |
| `@alexify/wrpc/query` | 2.7 KB | **1.1 KB** | 2.0 KB |
| `@alexify/wrpc/auth` | 3.3 KB | **1.5 KB** | 2.0 KB |
| `@alexify/wrpc/webrtc` — browser | 125.9 KB | **40.7 KB** | 41.0 KB |
| `@alexify/wrpc` — node | 158.2 KB | 52.2 KB | — |
| `@alexify/wrpc` — node | 162.6 KB | 53.9 KB | — |

The Node-only entries carry no budget because their gzip size is not a shipping
cost; they are measured so a regression is *visible*, not gated.
Expand All @@ -102,18 +102,20 @@ comment saying why.
| `ws` (default) | Normal. Full duplex, binary streams, everything on this site. |
| `http` | One-shot calls with no connection — no events, no subscriptions, no streams. |
| `sse` | WebSockets are blocked by a proxy or corporate network. Text only. |
| `event` | The connection lives in a Service Worker; the page talks over a `MessagePort`. |
| `event` | The connection lives in a worker — a Service Worker or a SharedWorker; the page talks over a `MessagePort`. |
| `webrtc` | Peer to peer: the other end is another browser (or a Node process), reached through a [`WrpcPeer`](./webrtc)'s `link` or a data `channel` you negotiated yourself — not a URL. |

`WrpcClient.transport` is a plain lookup table on purpose, and a subpath
entrypoint registers into it at require time — which is how `@alexify/wrpc/sse`
adds `'sse'` without the core knowing it exists. Registering your own works the
same way.

## Service Workers and offline
## Workers: Service Worker and SharedWorker

The `event` transport is how one socket serves every tab and survives a page
reload: the worker holds the connection, the page talks to the worker.
reload: the worker holds the connection, the page talks to the worker over a
private `MessagePort`. The worker side is the same `WrpcClientProxy` whichever
kind of worker it is.

```js
// in the Service Worker
Expand All @@ -124,8 +126,31 @@ await proxy.open();
const client = await WrpcClient.connect(url, { worker: navigator.serviceWorker.controller });
```

```js
// in the shared worker (wrpc-worker.js)
const proxy = new WrpcClientProxy({ url: 'wss://api.example.com' });
await proxy.open();

// in the page
const worker = new SharedWorker('/wrpc-worker.js', { name: 'wrpc' });
const client = await WrpcClient.connect(url, { worker });
```

The packets are identical on both hops, so nothing above the transport changes.
See [Client → Service Workers](./client#service-workers).
Pick the worker by what you need from it: a **Service Worker** also serves the
page offline and outlives a reload, at the price of registration and a
lifecycle the browser controls; a **SharedWorker** is only the shared socket —
no registration, alive exactly as long as a tab of the site is — and can point
at another origin through the proxy's `url`. `worker` also takes a dedicated
`Worker` or a raw `MessagePort`. See [Client → Workers](./client#workers).

::: warning Browser support
SharedWorker is missing from Chrome for Android; feature-detect
(`typeof SharedWorker !== 'undefined'`) and fall back to a direct connection.
The proxy releases a closed tab's port on the `MessagePort` `close` event, which
older engines never fire — there the entry lives until the worker does, as it
always has for a Service Worker.
:::

The client also listens to `online`/`offline`: going offline stops the
reconnect timer instead of burning retries, and coming back reconnects
Expand Down
Loading
Loading