diff --git a/CHANGELOG.md b/CHANGELOG.md index ad3bfdb..cf88441 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 962086f..dadd1b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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(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(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:`, 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. diff --git a/README.md b/README.md index c8460bd..3963a43 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | @@ -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-`) | +| **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-`) | | **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 | @@ -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) | diff --git a/client.d.ts b/client.d.ts index e0161ca..5849020 100644 --- a/client.d.ts +++ b/client.d.ts @@ -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; /** @@ -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; close(): void; } diff --git a/docs/guide/browser.md b/docs/guide/browser.md index 99e6a20..ee0b722 100644 --- a/docs/guide/browser.md +++ b/docs/guide/browser.md @@ -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. @@ -102,7 +102,7 @@ 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 @@ -110,10 +110,12 @@ 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 @@ -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 diff --git a/docs/guide/client.md b/docs/guide/client.md index 801bd49..9a80006 100644 --- a/docs/guide/client.md +++ b/docs/guide/client.md @@ -63,7 +63,7 @@ await WrpcClient.connect(url, { | `batch` | off | `true` takes the defaults. | | `retry` | off | Opt-in per-call retry: `{ attempts, on: [503], minDelay, maxDelay, factor, jitter }`, `true` for the defaults. Coded failures re-issue with a fresh packet id after a jittered backoff — never a silent offline buffer. | | `transport` | from the URL | `'ws'`, `'http'`, `'sse'`, or anything registered. | -| `worker` | — | A `ServiceWorker` to proxy through. | +| `worker` | — | A worker to proxy through: a `ServiceWorker`, a `SharedWorker`, a dedicated `Worker` or a raw `MessagePort` — see [Workers](#workers). | | `authenticate` | — | Presents the connection's credential; awaited before the reconnect restore — see [Authenticating](#authenticating). | | `refresh` | — | Single-flight credential refresh with a one-shot retry — see [Refreshing a credential](#refreshing-a-credential). | | `headers` | — | Connection-phase headers, re-evaluated per open; validated by `schema.headers` — see [Metadata](./metadata). | @@ -355,10 +355,11 @@ Static, because connectivity is a property of the machine rather than of one connection. `WrpcClient.initialize()` wires them to the browser's `online` / `offline` events; `WrpcClient.connections` is the live set. -## Service Workers +## Workers -For an offline-capable app, the Service Worker holds the connection and the -page talks to the worker over a `MessagePort`. In the worker: +A worker can hold the connection while every page talks to the worker over a +`MessagePort` — one socket for all tabs, and (with a Service Worker) a peer +that survives a page reload. In the worker: ```js const { WrpcClientProxy } = require('@alexify/wrpc'); @@ -367,15 +368,27 @@ const proxy = new WrpcClientProxy({ callTimeout: 7000 }); await proxy.open(); ``` -…and in the page: +…and in the page, hand `worker` whatever holds the proxy: ```js +// a Service Worker const client = await WrpcClient.connect(url, { worker: navigator.serviceWorker.controller }); + +// a SharedWorker — reached through its port +const shared = new SharedWorker('/wrpc-worker.js', { name: 'wrpc' }); +const client = await WrpcClient.connect(url, { worker: shared }); ``` +A dedicated `Worker` or a raw `MessagePort` work the same way. The proxy takes +every client option plus `url`, the server it connects to; without it the URL +is derived from the worker's own `location` — right for a Service Worker on +the site it serves, and what a SharedWorker proxying to another origin +overrides. + The packets are identical on both hops, so nothing above the transport -changes — which is what makes the arrangement worth having: one socket for -every tab, and a peer that survives a page reload. +changes. Each `connect()` gets its own `MessageChannel` to the worker; the +proxy routes answers back to the port that asked and broadcasts events to +every port, and lets go of a port when its page closes it. ## In a browser bundle @@ -385,6 +398,6 @@ bundler that honours the `browser` field — webpack, Vite, esbuild with `browser: true`), Parcel, Bun. It contains the client, the streams and the chunk helpers, and **no Node builtins** — the server half is not in it. -The main entry is under 15 KB min+gzip in that build; `scripts/size.js` enforces a +The main entry is ~15 KB min+gzip in that build; `scripts/size.js` enforces a budget on it in CI. See [Browser & bundling](./browser) for the full table, the `browser` field map, and what is deliberately missing from that entry. diff --git a/docs/guide/router.md b/docs/guide/router.md index e9c1eea..d85d6b6 100644 --- a/docs/guide/router.md +++ b/docs/guide/router.md @@ -44,7 +44,7 @@ sequenceDiagram A failure takes the same path back: the procedure throws, and the client receives `callback { id, error: { code, message } }` with the error's numeric `code`. Nothing about that shape depends on the transport — the same exchange -happens over [HTTP](./server), [SSE](./sse) and a Service Worker port. +happens over [HTTP](./server), [SSE](./sse) and a worker port. ## Procedures diff --git a/docs/guide/server.md b/docs/guide/server.md index 3360165..a007c4a 100644 --- a/docs/guide/server.md +++ b/docs/guide/server.md @@ -187,7 +187,7 @@ 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) +rpc.attachPort(port); // a node:worker_threads MessagePort rpc.attach(transport); // any persistent 'packet'/'chunk' transport await rpc.handleHttpCall({ method, url, headers, body, respond }); ``` @@ -214,7 +214,7 @@ 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](./client#service-workers). +[Client](./client#workers). `attach(transport)` is the seam under both: any persistent transport that announces inbound text as `'packet'` and bytes as `'chunk'` events becomes a diff --git a/docs/guide/sse.md b/docs/guide/sse.md index e0da24d..751020d 100644 --- a/docs/guide/sse.md +++ b/docs/guide/sse.md @@ -64,7 +64,7 @@ channel and letting the client re-load and re-subscribe. Both halves belong to **one** server-side `Client`, which is what lets a subscription opened by a POST deliver its values down the stream. A POST answers `202` with no body: every reply, callbacks included, travels on the -stream — the same shape the Service Worker port transport has. +stream — the same shape the worker port transport has. Each frame carries the channel's own monotonic `id:`, and a dropped stream does **not** destroy the channel. It is held for `retention` (30 s by default), so a diff --git a/docs/guide/testing.md b/docs/guide/testing.md index cb3dfd6..be13265 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -129,7 +129,7 @@ that reason. ## Testing without a network at all `rpc.attachPort(port)` attaches a `MessagePort` as a transport — the same seam -the [Service Worker](./client#service-workers) client uses. A +the [worker](./client#workers) client uses. A `node:worker_threads` `MessageChannel` gives you a full client/server exchange with no listener, no port and no sockets: diff --git a/docs/guide/webrtc.md b/docs/guide/webrtc.md index d7f1452..3d23c30 100644 --- a/docs/guide/webrtc.md +++ b/docs/guide/webrtc.md @@ -52,7 +52,7 @@ mesh.on('join', async ({ id, data }) => { The webrtc browser entry exports `defineRouter`, `procedure`, `tracked` and `createEventLog` — a browser peer defines its router with them; the main -browser entry leaves them out to stay under its [15 KB budget](./browser#bundle-size). +browser entry leaves them out to stay under its [16 KB budget](./browser#bundle-size). ### Node as a peer @@ -412,8 +412,9 @@ on. A client-only peer (no router) still counts its links. - **Identity across signaling reconnects.** A peer's id is its signaling connection's. A reconnect is a `leave` + `join` under a new id (a `Mesh` handles it); pin stable identity in the join `data`. -- **The Service Worker proxy.** The `event` transport builds its URL from - the worker's location; a data channel cannot be reached through it. +- **The worker proxy.** `WrpcClientProxy` connects to a URL (its `url` + option, or one built from the worker's location); a data channel cannot + be reached through it. ## Bundle size diff --git a/docs/index.md b/docs/index.md index 83cd6d1..87ec7ce 100644 --- a/docs/index.md +++ b/docs/index.md @@ -70,7 +70,7 @@ features: linkText: Adapters - icon: 🌍 title: Node.js and the browser - details: One protocol implementation, under 15 KB min+gzip in a browser bundle with no Node builtins (a budget CI enforces) — plus offline through a Service Worker and TanStack Query bindings at ~1 KB. + details: One protocol implementation, ~15 KB min+gzip in a browser bundle with no Node builtins (a budget CI enforces) — plus one socket for every tab through a Service Worker or a SharedWorker, and TanStack Query bindings at ~1 KB. link: /guide/client linkText: Client guide --- diff --git a/docs/public/llms.txt b/docs/public/llms.txt index 9ad4074..b64875f 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -5,9 +5,9 @@ Key facts: - npm: `@alexify/wrpc` — zero runtime dependencies (no `dependencies`, `peerDependencies` or `optionalDependencies` field), CommonJS, ships `src/` as-is with no build step. -- Size: 10.4 KB min+gzip for the browser entry; 11.4 KB with the SSE transport; 1.0 KB for the TanStack Query bindings. +- Size: 15.1 KB min+gzip for the browser entry; 16.0 KB with the SSE transport; 1.1 KB for the TanStack Query bindings. - Runtimes: Node.js >= 22 on the server, any modern browser through a bundler on the client. The browser build contains zero Node builtins. -- Transports: WebSocket, plain HTTP, Server-Sent Events, Service Worker `MessagePort`. +- Transports: WebSocket, plain HTTP, Server-Sent Events, a worker `MessagePort` (Service Worker or SharedWorker). - Hosts: batteries-included `Server`, or fastify / express / bare `node:http` / uWebSockets.js — every host injected, never depended on. - Public API: `Server`, `RpcServer`, `defineRouter`, `procedure`, `WrpcClient`, `connect()`, `tracked`, `createEventLog`, `RoomRegistry`, `Broadcast`, `Cluster`, `WrpcReadable`, `WrpcWritable`. - Subpaths: `@alexify/wrpc`, `/ws`, `/engine`, `/uws`, `/fastify`, `/express`, `/scaling`, `/sse`, `/query`, plus a `wrpc types` codegen CLI. @@ -33,7 +33,7 @@ Key facts: ## Client -- [Client](https://wrpc.vercel.app/guide/client): Transports, options, calls, batching, reconnect with jittered backoff, heartbeat, events, Service Workers. +- [Client](https://wrpc.vercel.app/guide/client): Transports, options, calls, batching, reconnect with jittered backoff, heartbeat, events, workers (Service Worker, SharedWorker). - [Typed client](https://wrpc.vercel.app/guide/typed-client): `connect()` with a hand-written contract, and its three sharp edges. - [Browser & bundling](https://wrpc.vercel.app/guide/browser): The `browser` field map, what is in the browser entry, bundle budgets, framework notes. - [Codegen CLI](https://wrpc.vercel.app/guide/cli): `wrpc types --out api.d.ts` against a running server. diff --git a/docs/reference/protocol.md b/docs/reference/protocol.md index b59f0ec..17f9817 100644 --- a/docs/reference/protocol.md +++ b/docs/reference/protocol.md @@ -2,9 +2,9 @@ The wire protocol is deliberately small: **JSON packets** for everything addressable, plus a **binary framing** for stream payloads. It rides on a -WebSocket, an HTTP request/response pair, or a `MessagePort` to a Service -Worker — the packets are identical on all three, which is what lets the same -client code work offline behind a worker. +WebSocket, an HTTP request/response pair, or a `MessagePort` to a worker (a +Service Worker or a SharedWorker) — the packets are identical on all three, +which is what lets the same client code work behind a worker. ## Stability @@ -71,7 +71,7 @@ request/response pair is self-contained. | WebSocket | text frames | binary frames | | HTTP | request body / response body | not available | | SSE | `POST` body out, `data:` lines back | not available | -| Service Worker port | `postMessage(string)` | `postMessage(Uint8Array)` | +| Worker port | `postMessage(string)` | `postMessage(Uint8Array)` | | WebRTC data channel | binary frames, `KIND = 0` | binary frames, `KIND = 1` | A transport that cannot stay open (plain HTTP) carries calls only: events, @@ -597,7 +597,7 @@ answers `503` or `429`. Both halves belong to **one** server-side client, which is what lets a subscription opened by a POST deliver its values down the stream. A POST answers `202` with no body: every reply, callbacks included, travels on the -stream — the same shape the Service Worker port transport has. A POST naming +stream — the same shape the worker port transport has. A POST naming an unknown channel answers `409`, like the GET. Each frame carries the channel's own monotonic `id:`, and a dropped stream diff --git a/scripts/size.js b/scripts/size.js index 2f3471b..70687f1 100644 --- a/scripts/size.js +++ b/scripts/size.js @@ -49,7 +49,10 @@ const ENTRIES = [ // the reconnect stability window (stableAfter), refresh-aware subscribe // restore, the joined-run refresh guard, coded 408/503 rejections and the // shared failPackets settlement — measured together at ~+450 B min+gzip. - { label: 'main entry — browser (@alexify/wrpc)', entry: 'browser.js', platform: 'browser', budget: 15 }, + // 15 -> 16: the worker proxy grew a SharedWorker leg (the `connect` + // listener, the `url` option, port cleanup on `close`) with 16 bytes of + // headroom left; the marketing line moved to "~15 KB" in the same change. + { label: 'main entry — browser (@alexify/wrpc)', entry: 'browser.js', platform: 'browser', budget: 16 }, { label: 'main entry — node (@alexify/wrpc)', entry: 'index.js', platform: 'node' }, { label: 'websocket engine (@alexify/wrpc/ws)', entry: 'ws.js', platform: 'node' }, { label: 'engine port (@alexify/wrpc/engine)', entry: 'engine.js', platform: 'node' }, @@ -64,7 +67,8 @@ const ENTRIES = [ // the sse transport's own declared-headers/meta legs); 15 -> 16 with its // resilience raise (same shared core, plus the sse POST settling its own // refused calls through failPackets). - { label: 'sse — browser (@alexify/wrpc/sse)', entry: 'sse.browser.js', platform: 'browser', budget: 16 }, + // 16 -> 17: bundles the main browser entry, so it inherits its SharedWorker bytes. + { label: 'sse — browser (@alexify/wrpc/sse)', entry: 'sse.browser.js', platform: 'browser', budget: 17 }, { label: 'sse — node (@alexify/wrpc/sse)', entry: 'sse.js', platform: 'node' }, { label: 'query bindings (@alexify/wrpc/query)', entry: 'query.js', platform: 'browser', budget: 2 }, // Browser-reachable like query (stores + bearerAuth ship to pages), and diff --git a/src/client/core.js b/src/client/core.js index 2ceea54..5a37d02 100644 --- a/src/client/core.js +++ b/src/client/core.js @@ -244,7 +244,7 @@ class ClientTransport extends Emitter { persistent = true; // Opt-in: only a transport that can silently die needs an app-level // heartbeat. A request/response transport has nothing to keep alive, and - // a MessagePort to a Service Worker cannot half-close. + // a MessagePort to a worker cannot half-close. heartbeat = false; constructor(url) { diff --git a/src/client/proxy.js b/src/client/proxy.js index 5290561..214f763 100644 --- a/src/client/proxy.js +++ b/src/client/proxy.js @@ -1,8 +1,9 @@ 'use strict'; -// The Service Worker proxy: forwards packets between a page's WrpcClient -// and a worker-held connection. Shares nothing with WrpcClient beyond -// calling connect() — which is why it lives in its own module. +// The worker-side proxy (Service Worker, SharedWorker or dedicated Worker): +// forwards packets between a page's WrpcClient and a worker-held +// connection. Shares nothing with WrpcClient beyond calling connect() — +// which is why it lives in its own module. const { Emitter, jsonParse } = require('../utils.js'); const { WrpcClient, CALL_TIMEOUT, normalizeReconnect } = require('./core.js'); @@ -16,10 +17,20 @@ class WrpcClientProxy extends Emitter { #heartbeat = undefined; #logger = undefined; #telemetry = undefined; + #url = undefined; + + // The control bus: `wrpc:*` objects, a transferred port riding on + // `wrpc:connect`. On a ServiceWorker (and a dedicated Worker) it is `self`; + // on a SharedWorker it is the per-page port the `connect` event hands + // over. Optional chaining because a page can post anything on that port. + #onControl = (event) => { + const type = event.data?.type; + if (typeof type === 'string' && type.startsWith('wrpc')) this.#handleEvent(event); + }; constructor(options = {}) { super(); - const { callTimeout, heartbeat, logger, telemetry } = options; + const { callTimeout, heartbeat, logger, telemetry, url } = options; if (callTimeout) this.#callTimeout = callTimeout; this.#reconnect = normalizeReconnect(options); this.#heartbeat = heartbeat; @@ -27,12 +38,17 @@ class WrpcClientProxy extends Emitter { // is silently lost on the connection it owns. this.#logger = logger; this.#telemetry = telemetry; + this.#url = url; if (typeof self === 'undefined') { - throw new Error('WrpcClientProxy must run in ServiceWorker context'); + throw new Error('WrpcClientProxy must run in a worker context'); } - self.addEventListener('message', (event) => { - const { type } = event.data; - if (type?.startsWith('wrpc')) this.#handleEvent(event); + // Both listeners, no context sniffing: a ServiceWorker never fires + // `connect`, a SharedWorker never fires `message` on self. + self.addEventListener('message', this.#onControl); + self.addEventListener('connect', (event) => { + const port = event.ports[0]; + port.addEventListener('message', this.#onControl); + port.start(); }); } @@ -43,7 +59,7 @@ class WrpcClientProxy extends Emitter { return; } const protocol = self.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const url = `${protocol}//${self.location.host}`; + const url = this.#url ?? `${protocol}//${self.location.host}`; const options = { callTimeout: this.#callTimeout, reconnect: this.#reconnect, @@ -70,6 +86,15 @@ class WrpcClientProxy extends Emitter { port.addEventListener('message', (messageEvent) => { this.#handleMessage(messageEvent, port); }); + // Best effort: the page half closing fires `close` here in current + // engines (and in Node), so a closed tab does not pin its port — or + // the answers it was still waiting for — for the life of the worker. + port.addEventListener('close', () => { + this.#ports.delete(port); + for (const [id, pending] of this.#pending) { + if (pending === port) this.#pending.delete(id); + } + }); port.start(); return; } diff --git a/src/client/transports.js b/src/client/transports.js index 0f7921a..f29a410 100644 --- a/src/client/transports.js +++ b/src/client/transports.js @@ -272,8 +272,11 @@ class ClientEventTransport extends ClientTransport { async open(options = {}) { if (this.active) return; const worker = options.worker ?? this.#worker; - if (!worker) throw new Error('Service Worker not provided'); - this.#worker = worker; + if (!worker) throw new Error('Worker not provided'); + // A SharedWorker is reached through its `port`; a ServiceWorker, a + // dedicated Worker or a raw MessagePort posts directly. Resolved once, + // so online()/offline() and every reopen address the same target. + this.#worker = worker.port ?? worker; const { port1, port2 } = new MessageChannel(); this.#port = port1; port1.addEventListener('message', ({ data }) => { @@ -294,6 +297,10 @@ class ClientEventTransport extends ClientTransport { } close() { + // A second close (terminate() after close(), or #openOrClose cleaning up + // an open() that threw before a port existed) has nothing to close — + // and must not replace the original error with a TypeError. + if (!this.active) return; this.active = false; this.#port.close(); this.#port = null; diff --git a/tests/client.internals.test.js b/tests/client.internals.test.js index 6bdf7b5..6ee77b5 100644 --- a/tests/client.internals.test.js +++ b/tests/client.internals.test.js @@ -211,7 +211,7 @@ test('WrpcClient.connect with a Service Worker (event transport)', async (t) => await t.test('a fresh instance requires a worker to open', async () => { const EventTransport = WrpcClient.transport.event; const transport = new EventTransport('worker://fresh'); - await assert.rejects(transport.open({}), /Service Worker not provided/); + await assert.rejects(transport.open({}), /Worker not provided/); }); await t.test('connect() opens the singleton, exposes online/offline, and closes cleanly', async () => { @@ -231,6 +231,38 @@ test('WrpcClient.connect with a Service Worker (event transport)', async (t) => client.close(); assert.strictEqual(client.active, false); }); + + await t.test('a SharedWorker is reached through its port', async () => { + const sent = []; + const worker = { port: { postMessage: (msg) => sent.push(msg) } }; + const EventTransport = WrpcClient.transport.event; + const transport = new EventTransport('worker://shared-worker'); + await transport.open({ worker, headers: { a: '1' } }); + assert.strictEqual(sent.length, 1); + assert.deepStrictEqual(sent[0], { type: 'wrpc:connect', headers: { a: '1' } }); + transport.online(); + transport.offline(); + assert.deepStrictEqual( + sent.slice(1).map((m) => m.type), + ['wrpc:online', 'wrpc:offline'], + ); + transport.close(); + // A reopen addresses the resolved port, not the SharedWorker object. + await transport.open({}); + assert.strictEqual(sent.at(-1).type, 'wrpc:connect'); + transport.close(); + }); + + await t.test('close() is idempotent and safe before open()', async () => { + const EventTransport = WrpcClient.transport.event; + const transport = new EventTransport('worker://idle'); + assert.doesNotThrow(() => transport.close()); + await transport.open({ worker: { postMessage() {} } }); + transport.close(); + assert.doesNotThrow(() => transport.close()); + assert.doesNotThrow(() => transport.terminate()); + assert.strictEqual(transport.active, false); + }); }); // --------------------------------------------------------------------------- diff --git a/tests/index.test-d.ts b/tests/index.test-d.ts index 54b1dea..79adae2 100644 --- a/tests/index.test-d.ts +++ b/tests/index.test-d.ts @@ -4,6 +4,7 @@ import type { Emitter, WrpcClient, WrpcClientProxy, + WrpcClientOptions, WrpcError, Server, RpcServer, @@ -28,6 +29,14 @@ import type { Engine, EngineConnectionSource } from '../engine.js'; expectType(wrpc.Emitter); expectType(wrpc.WrpcClient); expectType(wrpc.WrpcClientProxy); +// The `event` transport's worker: a ServiceWorker, a SharedWorker (through +// its port), a dedicated Worker or a raw MessagePort. +expectAssignable({ worker: {} as ServiceWorker }); +expectAssignable({ worker: {} as SharedWorker }); +expectAssignable({ worker: {} as Worker }); +expectAssignable({ worker: {} as MessagePort }); +expectError({ worker: 'sw.js' }); +expectType(new wrpc.WrpcClientProxy({ url: 'wss://api.example.com', callTimeout: 7000 })); expectType(wrpc.WrpcError); expectType(wrpc.Server); expectType(wrpc.RpcServer); diff --git a/tests/proxy.test.js b/tests/proxy.test.js index d52b330..a90f400 100644 --- a/tests/proxy.test.js +++ b/tests/proxy.test.js @@ -7,8 +7,9 @@ const assert = require('node:assert'); class MockWebSocket { static last = null; - constructor() { + constructor(url) { MockWebSocket.last = this; + this.url = url; this._listeners = new Map(); queueMicrotask(() => this._emit('open')); } @@ -58,14 +59,18 @@ const createSwEnv = () => { listeners.push({ type, fn }); }, location: { protocol: 'http:', host: 'localhost:8020' }, - dispatch(event) { + // `message` is what a ServiceWorker's self fires; `connect` is a + // SharedWorker's, carrying the per-page port. + dispatch(event, eventType = 'message') { for (const { type, fn } of listeners) { - if (type === 'message') fn(event); + if (type === eventType) fn(event); } }, }; }; +const tick = () => new Promise((resolve) => setImmediate(resolve)); + test('WrpcClientProxy', async (t) => { let savedSelf; @@ -84,7 +89,7 @@ test('WrpcClientProxy', async (t) => { await t.test('Throws when not in Service Worker context', () => { savedSelf = globalThis.self; delete globalThis.self; - assert.throws(() => new WrpcClientProxy(), /WrpcClientProxy must run in ServiceWorker context/); + assert.throws(() => new WrpcClientProxy(), /WrpcClientProxy must run in a worker context/); }); await t.test('Constructs when self is defined', () => { @@ -242,4 +247,136 @@ test('WrpcClientProxy', async (t) => { port1.close(); port2.close(); }); + + await t.test('SharedWorker: wrpc:connect over the connection port, then a round trip', async () => { + savedSelf = globalThis.self; + globalThis.self = createSwEnv(); + const proxy = new WrpcClientProxy(); + const conn = new MessageChannel(); + const page = new MessageChannel(); + globalThis.self.dispatch({ ports: [conn.port2] }, 'connect'); + conn.port1.postMessage({ type: 'wrpc:connect' }, [page.port2]); + await tick(); + await proxy.open(); + + const received = []; + page.port1.onmessage = (e) => received.push(e.data); + page.port1.start(); + + page.port1.postMessage(JSON.stringify({ type: 'call', id: 'sw-1', method: 'x/y', args: {} })); + await tick(); + assert.deepStrictEqual(JSON.parse(MockWebSocket.last.sentData.at(-1)), { + type: 'call', + id: 'sw-1', + method: 'x/y', + args: {}, + }); + MockWebSocket.last.dispatchMessage(JSON.stringify({ type: 'callback', id: 'sw-1', result: 1 })); + await tick(); + assert.deepStrictEqual(JSON.parse(received.at(-1)), { type: 'callback', id: 'sw-1', result: 1 }); + + proxy.close(); + page.port1.close(); + conn.port1.close(); + }); + + await t.test('SharedWorker: online/offline over the connection port', async () => { + savedSelf = globalThis.self; + globalThis.self = createSwEnv(); + const proxy = new WrpcClientProxy(); + const conn = new MessageChannel(); + globalThis.self.dispatch({ ports: [conn.port2] }, 'connect'); + const calls = []; + const { online, offline } = WrpcClient; + WrpcClient.online = () => calls.push('online'); + WrpcClient.offline = () => calls.push('offline'); + try { + conn.port1.postMessage({ type: 'wrpc:online' }); + conn.port1.postMessage({ type: 'wrpc:offline' }); + await tick(); + } finally { + WrpcClient.online = online; + WrpcClient.offline = offline; + } + assert.deepStrictEqual(calls, ['online', 'offline']); + proxy.close(); + conn.port1.close(); + }); + + await t.test('SharedWorker: junk on the connection port is ignored', async () => { + savedSelf = globalThis.self; + globalThis.self = createSwEnv(); + const proxy = new WrpcClientProxy(); + const conn = new MessageChannel(); + globalThis.self.dispatch({ ports: [conn.port2] }, 'connect'); + conn.port1.postMessage(null); + conn.port1.postMessage('hello'); + conn.port1.postMessage({ type: 42 }); + await tick(); + // Still alive: a real connect after the junk registers as usual. + const page = new MessageChannel(); + conn.port1.postMessage({ type: 'wrpc:connect' }, [page.port2]); + await tick(); + const received = []; + page.port1.onmessage = (e) => received.push(e.data); + page.port1.start(); + await proxy.open(); + MockWebSocket.last.dispatchMessage('payload'); + await tick(); + assert.deepStrictEqual(received, ['payload']); + proxy.close(); + page.port1.close(); + conn.port1.close(); + }); + + await t.test('url option overrides the location-derived one', async () => { + savedSelf = globalThis.self; + globalThis.self = createSwEnv(); + const derived = new WrpcClientProxy(); + await derived.open(); + assert.strictEqual(MockWebSocket.last.url, 'ws://localhost:8020'); + derived.close(); + const explicit = new WrpcClientProxy({ url: 'wss://api.example.com/rpc' }); + await explicit.open(); + assert.strictEqual(MockWebSocket.last.url, 'wss://api.example.com/rpc'); + explicit.close(); + }); + + await t.test('a page closing its port releases the port and its pending answers', async () => { + savedSelf = globalThis.self; + globalThis.self = createSwEnv(); + const proxy = new WrpcClientProxy(); + const ch1 = new MessageChannel(); + const ch2 = new MessageChannel(); + globalThis.self.dispatch({ data: { type: 'wrpc:connect' }, ports: [ch1.port2] }); + globalThis.self.dispatch({ data: { type: 'wrpc:connect' }, ports: [ch2.port2] }); + await proxy.open(); + const received = []; + ch1.port1.onmessage = (e) => received.push({ port: 1, data: e.data }); + ch2.port1.onmessage = (e) => received.push({ port: 2, data: e.data }); + ch1.port1.start(); + ch2.port1.start(); + + // A call parked on port 1, then the page goes away. The proxy's own + // `close` listener was registered at connect time, so it runs before + // this one — and Node delivers `close` later than the next tick. + ch1.port1.postMessage(JSON.stringify({ type: 'call', id: 'gone', method: 'x/y', args: {} })); + await tick(); + const closed = new Promise((resolve) => ch1.port2.addEventListener('close', resolve, { once: true })); + ch1.port1.close(); + await closed; + + MockWebSocket.last.dispatchMessage(JSON.stringify({ type: 'event', name: 'unit/name', data: 1 })); + // The pending slot is gone with the port, so the answer falls back to a + // broadcast — which no longer includes the closed port either. + MockWebSocket.last.dispatchMessage(JSON.stringify({ type: 'callback', id: 'gone', result: 0 })); + await tick(); + assert.deepStrictEqual( + received.map((r) => r.port), + [2, 2], + ); + + proxy.close(); + ch2.port1.close(); + }); });