diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3207b3e..3e912e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,9 +5,15 @@ on: branches: [main] pull_request: +# Nothing here writes to the repository: a read token is all a leaked action +# could use. +permissions: + contents: read + jobs: lint: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -38,9 +44,138 @@ jobs: node-version: ${{ matrix.node-version }} cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm test:coverage + # test:coverage with a guard: a file that outlives its tests (a leaked + # listen socket, a timer nobody cleared) is cut off and FAILS here in + # minutes, where it used to hang until the job's own timeout with no + # summary. `pnpm test` and `pnpm test:coverage` stay without it — a + # leak must stay visible locally. WRPC_INTEGRATION_STRICT turns a + # suite that would SKIP for a missing devDependency (the interop + # suite's `wrpc-v1` alias) into a failure: here, everything is installed. + - run: pnpm test:ci + env: + WRPC_INTEGRATION_STRICT: 1 + # The global thresholds are an average; the directories new in 2.0 are + # also held per file (scripts/coverage-floors.js). Reads the coverage + # the step above left behind — same job, same workspace. + - run: pnpm test:coverage:floors - run: pnpm test:types + # The Redis leg of the scaling suites against a real server — the fake in + # tests/scaling/redis.test.js is what `pnpm test` runs; this is the + # contract check against ioredis + a broker. The multi-node benchmark + # (bench/cluster-nodes.js) stays a manual run: a benchmark in CI is noise. + redis: + runs-on: ubuntu-latest + timeout-minutes: 10 + services: + redis: + image: redis:7.4-alpine + ports: ['6379:6379'] + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: node --test tests/scaling/redis.integration.test.js tests/broker/redis.integration.test.js + env: + REDIS_URL: redis://127.0.0.1:6379 + # The client IS installed here: a skip would be a broken environment passing. + WRPC_INTEGRATION_STRICT: 1 + + # The NATS leg of the broker suites against a real server WITH JetStream; + # the in-repo fake in tests/broker/fakeNats.js is what `pnpm test` runs. + # Not a `services:` container: GitHub cannot pass a command to one, and a + # nats without `-js` makes the log, queue and feed suites skip — green + # and untested. compose.yaml is the one place the flags live. + nats: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - run: docker compose up -d --wait nats + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: node --test tests/broker/nats.integration.test.js + env: + NATS_URL: nats://127.0.0.1:4222 + WRPC_INTEGRATION_STRICT: 1 + - run: docker compose down + if: always() + + # The RabbitMQ leg of the broker suites against a real server; the in-repo + # fake in tests/broker/fakeAmqp.js is what `pnpm test` runs. + rabbitmq: + runs-on: ubuntu-latest + timeout-minutes: 15 + services: + rabbitmq: + image: rabbitmq:4.1-alpine + ports: ['5672:5672'] + options: >- + --health-cmd "rabbitmq-diagnostics -q ping" + --health-interval 10s + --health-timeout 5s + --health-retries 20 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: node --test tests/broker/amqp.integration.test.js + env: + AMQP_URL: amqp://127.0.0.1:5672 + WRPC_INTEGRATION_STRICT: 1 + + # The Kafka leg of the broker suites against a real broker (KRaft), on + # whichever KafkaJS-shaped clients are installed; the in-repo fake in + # tests/broker/fakeKafka.js covers both shapes in `pnpm test`. + kafka: + runs-on: ubuntu-latest + timeout-minutes: 20 + services: + kafka: + image: apache/kafka:3.9.1 + ports: ['9092:9092'] + env: + KAFKA_NODE_ID: 1 + KAFKA_PROCESS_ROLES: broker,controller + KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://127.0.0.1:9092 + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT + KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: node --test tests/broker/kafka.integration.test.js + env: + KAFKA_BROKERS: 127.0.0.1:9092 + WRPC_INTEGRATION_STRICT: 1 + # Node 22 only: the docs build exercises VitePress, not the package runtime, # so repeating it across the test matrix would just repeat the same work. docs: diff --git a/.gitignore b/.gitignore index e381e9a..2845406 100644 --- a/.gitignore +++ b/.gitignore @@ -128,5 +128,10 @@ docs/.vitepress/cache # Autobahn testsuite output scripts/autobahn/reports/ -# examples/chat's build.js output — regenerate with `node build.js` +# the examples' build.js output — regenerate with `node build.js` examples/chat/public/wrpc.browser.js +examples/syncom-mesh/public/wrpc.browser.js +examples/wt/public/wrpc.browser.js + +# node scripts/wt-cert.js output (short-lived WebTransport dev certificates) +certs/ diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 592fdbc..b5986be 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -5,5 +5,5 @@ "printWidth": 120, "trailingComma": "all", "sortPackageJson": false, - "ignorePatterns": [] + "ignorePatterns": ["examples/**/public/**", "examples/**/*.md"] } diff --git a/.oxlintrc.json b/.oxlintrc.json index 0d3a62c..f0e8fc3 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -21,6 +21,7 @@ ".idea", "**/*.d.ts", "**/*.test-d.ts", + "examples/**/public/**", "*.log", "npm-debug.log*", "yarn-debug.log*", diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c3ea95..1b8a700 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,3559 @@ Semver here versions the **JavaScript API**. The wire protocol carries its own, narrower promise — see [Stability](./docs/reference/protocol.md#stability). -## [1.0.0] - 2026-08-23 +## [Unreleased] + +### Changed (breaking) + +## [2.0.0] - 2026-10-04 + +### Added + +**Protocol revision 2 (`wrpc.v2`), negotiated — a 1.0 peer is spoken to as 1.0 was** + +- The framed messages of 2.0 — a packet whose bytes travel as bytes — are + **revision 2** of the protocol, and a 2.x peer speaks both revisions. A + frame is sent only where the peer said it reads one; everywhere else a + packet holding bytes travels as the JSON 1.0 made of it. A 1.0 client and + a 1.0 server therefore work against 2.x with nothing to set on either end + — the SSE breakage that had no flag at all is gone too — except where the + [migration notes](#migrating-from-10) say otherwise: several instances of + both versions behind one HTTP address (a frame a 1.0 instance refuses + costs a resend), a browser page's `headers`/`meta` against a 1.0 server + (a `wrpc.v1` answer is ambiguous; `carrier: 'query'` is the sure way), + and a backplane or cluster, which has no handshake. Each carrier says the + revision where it can: + - **WebSocket** — the subprotocol. A client offers `wrpc.v2, wrpc.v1` + (newest first); every engine selects the newest revision offered. A 1.0 + server picks `wrpc.v1` from that offer, a 1.0 client offers it alone. + - **HTTP** — a response's `wrpc-version` is the newest revision the server + speaks (`2`; exposed to other origins), and a client sends a framed body + only after an answer said so. A result holding bytes is a frame only + for a request whose `Accept` names `application/octet-stream` — in + packet mode, in a batch and in the conventional REST mode — so a 1.0 + client, `curl` and a page's own `fetch` read JSON. (`Accept` because it + is CORS-safelisted: a `wrpc-version` request header needs a preflight a + 1.0 server, or an application's own `cors.headers`, refuses.) + - **Worker port** — `v` on the port's first `ping`/`pong`. The worker + proxy keeps a revision per page and translates: a frame from upstream + reaches a stale 1.0 page as JSON, a page's frame leaves as JSON when the + server upstream is 1.0. `attachPort(port, meta)` takes `meta.v`. + - **SSE** carries no framed message: a channel speaks revision 1 to every + client, 1.0 or 2.x — the stream's GET does not say which is asking — + so a packet's bytes travel on it as the JSON 1.0 made of them, both + ways, and `client.revision` is `1` on both ends. (It answered a result + holding bytes with `501` and dropped such an event, which a 1.0 SSE + client met with no flag to set.) WebRTC, WebTransport and the broker + binding are carriers 1.0 never had: nothing to negotiate. +- `revision` (1 | 2), read-only, on `WrpcClient`, on the server-side + `Client` and on `RpcServer` (the newest it speaks). No new option: + `attachments: false` — or a packet codec — now means *this end speaks + revision 1*. It offers or selects `wrpc.v1` and answers `wrpc-version: 1`, + so two 2.x ends whose flags disagree settle on revision 1 instead of one + answering the other's frame with an id-less `500`. The built-in shells + (`Server`, the fastify plugin, the express middleware) narrow their engine + themselves; an engine composed by hand takes `protocols: ['wrpc.v1']`, and + `revision.mismatch` says when it did not. +- A room of both revisions costs one extra `JSON.stringify` per emit, not + one per member: the fan-out shares one frame among the 2.x clients and one + JSON text among the 1.0 ones. +- A browser client's declared `headers`/`meta` reach a 1.0 server again: a + handshake that offered `wrpc.v2`, carried them as `wrpc.h.`/`wrpc.m.` + tokens and was answered `wrpc.v1` is dialled **once more** with the + connect-URL query 1.0 reads, which that transport keeps for its later + reconnects (`handshake.requery`). `carrier: 'query'` up front saves the + second handshake; `carrier: 'protocol'` forbids the query. +- The whole matrix runs against the **published 1.0**: `tests/interop/` + takes `@alexify/wrpc@1.0.0` from npm under the test-only alias `wrpc-v1` + and drives it as a client and as a server over ws, http, sse, a worker + port and the worker proxy. +- [Protocol reference: Versioning](./docs/reference/protocol.md#versioning) + has the rules, [Changes since 1.0](./docs/reference/protocol.md#changes-since-1-0) + the table. +- The main browser entry's budget is 28 KB (was 27) and the sse entry's + 29 KB (was 28), for the client half of the negotiation: the offer and + the redial, the `Accept` and `wrpc-version` of the HTTP transport, the + worker port's `v` — measured 27,692 B and 28,735 B against 27,648 and + 28,672. + +**Docs: the encryption guide** + +- [Encryption](./docs/guide/encryption.md): whether you need it at all (a + table of where TLS ends before the data does — and the row that says you + do not), what it is built from and what is left to injection, keys and + rotation, each layer with its cost, what it does NOT protect. Linked from + the security, production, codec, compression, scaling and sessions pages; + the security page no longer says "it does not encrypt" without saying + where the opt-in is, and the codec page no longer offers a codec as the + way to encrypt. + +**End-to-end helpers: `createIdentity`, `createSealer`, `createOpener`** + +- For a payload the SERVER should not read — a chat message it only relays. + `createSealer({ recipientPublicKey }).seal(data)` answers bytes (`enc ‖ + ciphertext`, HPKE with a fresh context per message) that wrpc carries as + they are: in a call, an event, a room broadcast and — since the previous + change — across the backplane, to a room's members and to one client by + `sendTo`. `createOpener({ keyPair }).open(sealed)`. +- With `senderKey` / `senderPublicKey` it is HPKE **auth mode**: the + recipient learns which identity sealed the message, and one from anybody + else does not open. `info` says what the messages are for (a room, a + conversation), and one sealed for one purpose does not open for another. +- HPKE gained the auth and auth_psk modes for it (RFC 9180 vectors for all + four modes now). +- Said plainly, in the types and the guide: this is not a messaging + protocol — no forward secrecy for the recipient, no group key, no replay + memory; an application that needs those runs Double Ratchet or MLS over + the same bytes. And in a browser it protects against a server that READS, + not one that serves the page a different script. + +**Room events with bytes cross the backplane** + +- An event whose data holds a `Uint8Array` used to reach only the members on + the emitting instance: the backplane's envelopes are JSON, so the + cross-instance half was refused and logged `backplane.bytes`. The envelope + now rides as the binary attachments frame itself — base64 under a + `wrpc-bin:` marker, or inside the sealed envelope (flag bit 1) under + `rooms.encryption` — and members on every instance receive bytes. This is + what a relayed end-to-end payload needs: opaque bytes, room-wide, + cluster-wide. +- The core always injects the envelope now (it used to be `null` without + `compression`); text envelopes pass through it untouched. A + `RoomsBackplane` wired by hand without one still names the loss. + +**Sealed broker messages: `encryption` on every broker binding** + +- A broker KEEPS what it carries — a Kafka topic, a stream, a quorum queue + hold every message for their retention. Until now that was every RPC + packet, every published event, and the bearer token in a client's headers + (`authorization` rode as a plaintext broker header on every `hello` and + every stateless `request`). `encryption: { keys }` — the keyring option of + `rooms.encryption`, rollout flags included — on `attachBrokerRpc`, the + `broker` client transport, `createPublisher`, `brokerFeed` and + `attachConsumers` seals them. +- **The headers move inside with the body.** What stays readable is what the + binding routes by — `wrpc-kind`, `wrpc-seq`, the key id — and that is bound + into the seal with the address and the correlation id, so it cannot be + rewritten and a frame does not open in another conversation. RPC frames + get a replay window; published events are bound to their topic and have + none (a log is read again, a delivery redelivered). +- Compression composes: compress, then seal, the codec id inside. +- A message that does not open is dropped (`broker.rpc.refused`), skipped by + a feed (`feed.refused`) or dead-lettered with 400 by a consumer + (`broker.refused`) — logged with its reason, never answered. +- A dead letter is forwarded as it arrived — sealed, key id and all — so a + dead-letter queue is as unreadable as the work queue. `onDeadLetter` + receives `opened` (the body and headers as the binding opened them); + a binding on the dead-letter queue names the queue its messages were + sealed for with `sealedFor`, since the seal binds a message to its queue; + and `openSealedMessage(encryption, { topic, headers, body })` opens one + by hand for a script. +- A sealed binding is what `encryption.required` on the server accepts from + a broker; an unsealed `hello` is answered `bye: encryption required`. +- Published bodies ride as base64 text: a log or a queue is only promised to + keep a string as it was (a Redis stream field is one). The partition `key` + stays the broker's to read. + +**Sealed Server-Sent Events** + +- The `sse` client transport carries `options.encryption` through the same + per-request binding as `http`: the stream request and every channel POST + are sealed requests, so the channel id and `Last-Event-ID` travel inside — + the channel id is no longer something an observer can lift. +- The stream comes back as `text/event-stream; wrpc-sealed=1`: one opaque + `data:` event per chunk of the real stream, the `ready` frame that hands + out the channel id and the heartbeat included. Each stream has its own key, + exported from the request that opened it and salted with a nonce the + server draws per stream (the `n` parameter of the `Content-Type`), and + counts its frames from zero — so a re-attach with `Last-Event-ID` replays + under a NEW key, nothing of the first stream repeats on the wire, and the + same open request replayed onto another instance gets a different key + too. A dropped, reordered or altered frame errors the stream; the client + re-attaches as it does after any drop. +- It is sealed at the writer, not in the channel: the replay ring, retention + and `resume()` are untouched. The type stays `text/event-stream` for the + intermediaries; no HTTP content coding is applied to a sealed stream or a + sealed answer (the outer `Accept-Encoding` no longer reaches the inner + request). +- With this, `encryption.required` holds on every client↔server transport: + ws, WebTransport, http and sse. + +**Sealed HTTP requests: HPKE per request** + +- The `http` client transport carries `options.encryption` too. A request is + not a connection, so each one is sealed on its own with **HPKE (RFC 9180)** + — `DHKEM(X25519, HKDF-SHA256)`, AES-256-GCM or ChaCha20Poly1305, checked + against the RFC's vectors on both primitive halves — to the pinned server + key, and the answer under a key both ends export from that same context + (the Oblivious HTTP construction, RFC 9458 §4, without the relay). +- The real request is INSIDE: an observer sees `POST ` with + `Content-Type: application/wrpc-sealed` and a `200`, whatever the method, + route or status really was. On the server it is one block in + `handleHttpCall`: the call is unwrapped before routing and carries on as an + ordinary one, so packets, batches and mapped REST routes all ride it — over + the built-in server, uWebSockets.js, express and fastify (whose adapter now + registers the content type, which fastify would otherwise answer 415, and + the discovery route). +- On the client it is a wrapped `fetch`: `createEncryption(...).fetch`. A + response that is not sealed is an error whatever its status — a client + that encrypts does not read plaintext. +- Replay: HPKE has none, so a sealed request carries the sender's clock and + is accepted once — `maxSkew` (5 min) and an `enc` memory, in process by + default, injectable as `replay: { seen(id, ttl) }` for a shared one. + Refusals are bare statuses (`400` does not parse or open, `409` stale or + replayed, `426` plaintext under `required`), the reason in the log only. +- `Set-Cookie` stays on the outer response, so a cookie session keeps + working; everything else of the answer is inside. +- `GET /encryption-key` publishes the bundle (`fetchServerKey(url)` + on the client; `discovery: false` removes it). Trust on first use — pin the + bundle wherever the client can be shipped it. +- `createHpke`, `dhKem`, `isKem` are exported: the KEM is a structural seam, + which is where ML-KEM or a hybrid is injected. +- HKDF is now a platform pair as well: `node:crypto` on Node. A sealed + request costs 219 µs for both ends against 550 µs over `crypto.subtle`, and + a Noise handshake 291 µs against 745 (`bench/encryption.js`). + +**Session encryption over WebTransport** + +- The `wt` client transport carries `options.encryption` too (`static + encrypts`), and the server needs nothing new: `attachSession` hands its + `WtSocket` to `attachSocket`, which wraps it exactly as it wraps a + WebSocket. The handshake runs over the control stream after the + capabilities exchange, with `wt` bound into the prologue — a handshake + recorded on one kind of connection does not finish on the other. +- Under it everything rides the control stream, sealed: no per-stream + transport (no `streams` capability is announced, so binary streams fall + back to control-stream chunks), no datagrams (an `unreliable` event goes + reliably), and the carrier's per-message compression is left off — each + would be a way around the channel, or work spent on ciphertext. QUIC is + already TLS 1.3, so what this protects against is the edge that + terminates it. + +**Session encryption: a Noise handshake on the WebSocket, then every frame sealed** + +- `new Server({ encryption: { keys } })` and + `connect(url, { encryption: createEncryption({ serverKey }) })` from + `@alexify/wrpc/encryption`. For the TLS terminator you do not trust — a + CDN, a corporate proxy, a body-logging balancer — and for `ws://` where no + certificate can be had. Opt-in, and never in place of TLS. +- The handshake is the **Noise Protocol Framework under its canonical + names**: `Noise_{NN,NK,XX,NNpsk0}_25519_{AESGCM,ChaChaPoly}_SHA256`, + checked against the cacophony vectors on both the `node:crypto` and the + `crypto.subtle` primitives. `NK` is the default — the client pins the + server's key, as TLS does for a browser; `XX` is mutual, and the server's + `authorize(peer)` sees the client's authenticated key; `NN` and `NNpsk0` + have to be named on both sides. A client names its protocol and the + server holds it or refuses: nothing is negotiated down. +- On the server it is a wrapper around the engine's socket (`SealedSocket`), + not a change to the dispatcher: decrypted messages are re-announced as the + engine would, so compression (now inside the sealed frame), attachments + and stream chunks work unchanged — over the built-in engine and + uWebSockets.js alike. `attachSocket` stays synchronous: the session + restore waits for the handshake, and `client.encryption` is set by then. +- `client.encryption` on both ends: `{ protocol, pattern, cipher, kid, + remoteStatic, handshakeHash }`. The hash is unique to the handshake and + the same on both sides — bind a credential to it and a relayed credential + is worthless on another connection. +- A client that was handed `encryption` never speaks plaintext: a transport + that cannot carry it is a `TypeError` at `connect()`, the fallback list + included; a server that does not answer, or answers in plaintext, is a + failed connection. `encryption.required` is the server's half: sockets + close `1008`, HTTP answers `426` (until the per-request binding lands), + `attach()` must be told `encrypted: true`. +- `rpc.encryptionKey()` → the public bundle `::` + a client pins. Static keys are derived from one secret per kid, never + shared between the two protocols; an old pin keeps working while its kid + is on the ring. +- **What it costs** (`bench/encryption.js`): 3.6 µs to seal a 1 KB packet, + 0.6 ms for a handshake — and the single shared frame of a broadcast: each + recipient has its own key, so an emit to N sealed clients is N seals, and + permessage-deflate is told to leave the ciphertext alone. +- Not covered, by design: whatever the upgrade itself carried — the URL, + headers, `wrpc.bearer.` tokens, cookies. Under session encryption a + credential belongs in `authenticate`. Wire format in the + [protocol reference](./docs/reference/protocol.md#session-encryption); + main browser entry +0.4 KB for the seam, `./encryption` 7.8 KB. + +**`sealedStore()`: a session store that holds nothing readable** + +- `sealedStore(store, { keys })` from `@alexify/wrpc/encryption` wraps any + `sessions: { store }` — the Redis one, a table, the in-memory default. + Until now the Redis store kept each session's state as JSON **under the + bearer token itself**: a keyspace listing was a list of live credentials. + Wrapped, a row is keyed by `HMAC-SHA256(index key, token)` and holds the + state sealed (the backplane envelope's frame — a key per writing process, + a counter nonce) with the row's own key as additional data, so a row + copied into another session's slot does not open. +- A key rotation signs nobody out: a read that misses under the current kid + finds the row under an older one and moves it (the index key rotates with + the rest). `acceptPlaintext: true` is the same move for adopting it over a + store that already holds sessions. `touch` is forwarded when the wrapped + store has one. +- A row that does not open is a missing session and one warning + (`session.open`, `session.unsealed`, `session.migrate`); the token is + never logged. See [Sessions](./docs/guide/sessions.md#sealed). + +**`rooms.encryption` / `cluster.encryption`: sealed backplane envelopes** + +- TLS to Redis protects the hop, not what Redis holds: until now every room + event, presence delta, `sendTo` payload and `fetchClients` reply crossed + the backplane as readable JSON. `rooms: { encryption: { keys } }` and + `cluster: { encryption: { keys } }` seal each envelope under a shared + keyring (AES-256-GCM, or `cipher: 'chacha20-poly1305'`, or an injected + synchronous `Cipher`): the backplane carries + `wrpc-sealed::`. Off by default; ~3.5 µs per 1 KB envelope + each way (`bench/encryption.js`). See + [Scaling](./docs/guide/scaling.md#encryption) and the + [protocol reference](./docs/reference/protocol.md). +- No random nonces: each process derives its own key from the keyring key + and a salt it draws at boot (HKDF-SHA256) and counts under it, reseeding + after 2^32 messages — GCM's random-nonce bound is hours away on a busy + fan-out. The additional data binds layer, key id and **channel**, so an + envelope moved to another room's channel does not open, and a per-sender + sliding window (`replayWindow`, default 1024) drops a replay. +- Composes with `compression` inside ONE frame — compress, then seal, one + base64 — rather than a marker inside a marker. +- A pub/sub backplane delivers at most once, so turning it on is three + deploys, not a flag day: `{ seal: false, acceptPlaintext: true }`, then + `{ acceptPlaintext: true }`, then neither. Key rotation is the same shape + through `keys: { current, ring }`; the key id selects the key, nothing is + tried until it fits. +- New log events, all warnings: `backplane.open` / `cluster.open` (does not + open — `reason`: `kid`, `open`, `replay`, `format`, `codec`; for the log + only, nothing is answered), `backplane.unsealed` / `cluster.unsealed` + (plaintext where none is accepted — which also makes a sealing cluster + refuse a forged plaintext command even without `secret`), + `backplane.sealed` / `cluster.sealed` (an instance without the keys). +- A sealing instance no longer decodes its own echo: it recognizes its salt + and skips the envelope before any cryptography or JSON parsing. +- Not hidden, by construction: the channel name (it names the room), the + key id, sizes and timing. Every instance holds the key — this is a sealed + fan-out, not end-to-end. + +**`@alexify/wrpc/encryption`: the primitives (experimental)** + +- A new subpath, with a `browser` condition, for application-level encryption + — opt-in like every knob in wrpc and **never a substitute for TLS**: it is + for where TLS ends before the data does (a backplane, a broker's log, a + TLS-terminating proxy, a relay that should not read what it relays). This + release lands the primitives the later pieces are built from; nothing in + the core reads them yet. +- `aead({ algorithm })` — the platform AEADs behind one structural `Cipher` + contract (`isCipher`): `'aes-256-gcm'` on both platforms and + `'chacha20-poly1305'` on Node. Synchronous over `node:crypto` on Node (a + 1 KB seal is 2.4 µs against 14 µs through `crypto.subtle` on the same + machine — `bench/encryption.js`), promise-answering over `crypto.subtle` in + a browser, where a key is imported once into a **non-extractable** + `CryptoKey`. Every failure to open is one `OpenError`, whatever the cause. + An application injects its own cipher (XChaCha20, AES-GCM-SIV, AEGIS) + through the same contract. +- `x25519()` (RFC 7748, the `Dh` contract Noise and HPKE's DHKEM consume; + a low-order public key is refused), `createKdf()` (SHA-256, HMAC and + HKDF with Extract and Expand apart). HKDF is one implementation over + `crypto.subtle` for both platforms; X25519 is a platform pair like the + AEADs — `node:crypto` on Node, because the same algorithm through subtle + prints an `ExperimentalWarning` on the early Node 22 releases `engines` + admits (22.10 does, 22.23 does not). +- `normalizeKeys(keys)` — the keyring: one key, `{ current, ring }` for + rotation, or an injected provider (`isKeyProvider` — a KMS or Vault + client). A kid is a closed alphabet and SELECTS the key; nothing tries key + after key on one message. +- No `Math.random` fallback anywhere in it: without `crypto.getRandomValues` + and `crypto.subtle` (a page served over plain http has neither) the + factories throw where they are built. +- `Sequencer` moved to the `src/sequencer.js` leaf so the new entry keeps + message order around an asynchronous cipher without carrying the + compression negotiation; `src/compression/index.js` still exports it. + Bundle: 3.1 KB min+gzip, its own row and budget in `scripts/size.js`. + +**The ws handshake: declared `headers`/`meta` as subprotocol offers, and `readHandshake`** + +- The server reads a browser client's declared bags from the one handshake + header a page controls: `Sec-WebSocket-Protocol: wrpc.v2, wrpc.v1, + wrpc.h., wrpc.m.` — the generalization of `wrpc.bearer.`. A + carrier is chosen, never merged (real `x-wrpc-meta` header → offer → + `wrpc_h`/`wrpc_meta` query, which stays for `carrier: 'query'` and + WebTransport), the two offers share **one** `metaMaxBytes` budget, and + every rule is still a refusal rather than a closed connection. See the + [protocol reference](./docs/reference/protocol.md#connection-metadata). +- `readHandshake(req, { metaMaxBytes? })` → `{ headers, meta }`: what a + `verifyClient` gate should call instead of parsing `wrpc_h` by hand. It is + the function `attachSocket` itself runs, so a gate sees exactly what the + connection will get. +- Carrier tokens are data, never a protocol to select: both negotiators (the + built-in engine and the uWebSockets.js one) remove `wrpc.h.`, `wrpc.m.` and + `wrpc.bearer.` from the offer before `protocols`/`handleProtocols` sees it + and refuse to echo one, so a selector like `(offered) => offered.at(-1)` + cannot reflect a credential into the response. The tokens also leave the + `sec-websocket-protocol` of `context.meta.headers`. + +**Docs: which compression algorithm** + +- The [compression guide](./docs/guide/compression.md#algorithm) has the + table that decides — deflate 3 / 6, Brotli 4 / 11, zstd 1 over 361 B to + 255 KB — and what follows from it: deflate up to ~2 KB and wherever a + browser or the router dictionary is involved, `['zstd', 'deflate-raw']` + on Node↔Node wires with large answers, Brotli 4 when the link is the + cost, gzip for SSE, and an injected LZ4 as the worked example of the + seam. The protocol reference states the list rule once, under its + compression table. + +**`http.compression.encodings` / `sse.compression.encodings` — Brotli, zstd, or your own coding** + +- `encodings` is the server's list of `Content-Encoding`s in **its** order + of preference; the first one the request's `Accept-Encoding` admits is + used (a zero weight refuses a coding, `*` covers the unnamed, other + weights say acceptable rather than preferred — as nginx reads it). + Default `['gzip']`, so nothing changes until it is set. Entries: `'gzip'`, + `'br'`, `'zstd'`, the same with their knobs (`{ encoding: 'br', quality }`), + or an application's own `{ encoding, encode(bytes), createStream? }` — + `encode` may answer a promise, and a coding that throws or rejects + answers the plain body, honestly labelled. +- Measured defaults (`bench/algorithms.js`, a 27 KB answer): gzip 110 µs / + 3,214 B, Brotli quality 4 93 µs / 2,601 B, zstd level 1 36 µs / 2,848 B. + `'zstd'` is a `TypeError` at construction where `node:zlib` has none. +- SSE takes the same list, every coding flushed per event; a coding with + no `createStream()` is refused at construction. gzip stays the default + and the recommendation there — Brotli and zstd save nothing on small + events and hold 570 / 930 KB per open response against gzip's 320. +- The `Accept-Encoding` scan stays one pass with no per-token allocation: + twice the split-and-map spelling (`bench/http-compression.js`). + +**`compression: { codec: [...] }` — a preference list, negotiated per direction** + +- `codec` takes a list in order of preference, names and injected codecs + alike. Each end announces the ids it can decode and **a sender compresses + with the first codec of its own list the other end announced** — so a + list is the fallback (a peer without zstd is served deflate instead of + plain), the two directions choose independently (a Node server answers in + zstd a browser that sends deflate), and no frame has to name its codec. + A name the platform lacks is skipped in a list, refused alone. A peer's + list is bounded (16 ids) and only ever compared, never a key. +- On every negotiated wire: the `enc` list of the WebTransport capabilities + message and of the WebRTC description `caps`, `{ type: 'ping', enc: [ids] }` + from a Node WebSocket client (the `pong` names the one to send with), the + comma-separated `wrpc-enc` of a broker `request` / `hello` / `welcome`. +- The backplane and cluster envelopes negotiate nothing, so there an + instance **encodes with the head of the list and decodes any codec on + it** — a change of codec becomes a rollout without a lost envelope (list + both everywhere, then swap the order). A raw WebRTC channel uses the head + both ways. +- `transport.compression` (WebTransport, WebRTC, the broker client) is + `{ encode, decode } | null`; `NegotiatedCompression` in the types. The + Node↔Node carriers probe every codec of the list for a synchronous answer. + +**`compression: { codec: 'zstd' | 'brotli' }` — the platform codecs by name, and their factories** + +- `codec` takes the name of a platform codec beside an injected one. The ids + are `CompressionStream`'s format names — `'deflate-raw'`, `'brotli'`, + `'zstd'` — so Node (`node:zlib`) and a browser negotiate the same id with + no table between them. `compression: true` still means `'deflate-raw'`: + level with zstd up to ~2 KB and the one format every platform has. + `'zstd'` is detected, not assumed (`node:zlib` has it since 22.15 / 23.8): + a `TypeError` at construction where it is missing; in a browser a format + the `CompressionStream` lacks leaves compression off. +- `deflateCompressor({ level })`, `brotliCompressor({ quality })` and + `zstdCompressor({ level })` from the main entry (Node), each with + `threshold` and `async`, for another level than the name takes. An id + names the format, never the level. +- The default levels are measured (`bench/algorithms.js`): Brotli quality 4 + and zstd level 1 with the source size pledged — zlib's own Brotli default, + quality 11, is 33 ms on a 27 KB message. + +**`bench/algorithms.js` — which algorithm, at which level** + +- deflate, Brotli and Zstandard out of `node:zlib`, at the levels a + per-message codec can afford, over 200 B – 255 KB callback packets, plus + the flushed persistent stream an SSE response uses with its memory per + open response. What it found: up to ~2 KB deflate is level with zstd in + bytes and CPU; deflate's knee is level 3 (27 KB: 46 µs / 3,351 B against + level 6's 118 µs / 3,196 B, and level 4 is slower and larger than 3); + zstd level 1 is the cheapest at 27 KB and past it (36 µs / 2,848 B); + Brotli quality 4 is the smallest at deflate-6 cost (93 µs / 2,601 B), and + zlib's own Brotli default, quality 11, takes 33 **ms** there; a flushed + Brotli or zstd stream saves nothing over gzip on small events (23 / 18 / + 20 B an event) and holds 570 / 930 KB per open response against 320. + +**`compression: { async }` — large messages deflate on zlib's threadpool on WebTransport and WebRTC** + +- The Node platform codec and `dictionaryCompressor(dict, { async })` take + `async: true | { threshold }` (256 KiB): a message that large is handed to + zlib's callback API and `encode` answers a promise for it, which the + WebTransport and WebRTC transports already keep in order; everything + smaller — and every inflate — stays synchronous. Measured, not assumed + (`bench/zlib-async.js`): the threadpool hand-off costs ~20 µs a call, so + below ~256 KB it only slows a message down (291 B: 28 µs against 9), and + inflate is cheaper on the loop at every size up to the cap. The same + default as `perMessageDeflate.async` and `http.compression.async`. +- Off by default, like every compression knob. The Node↔Node carriers + (the broker binding, the backplane envelopes, a Node WebSocket client) + refuse a codec that declares `async` at construction — they have no + ordering queue for a promise; `compression.async` with an injected + `codec` is refused too (the option belongs on the codec's factory). +- `Compressor.async` (a byte threshold or null) in the types; the + [compression guide](./docs/guide/compression.md#async) has the table. + +**Binary attachments: bytes in args, results and events travel as bytes** +- A `Uint8Array` in a call's arguments used to arrive as `{"0":137,"1":80,…}` + — nine times the size, and a plain object, silently. Now any typed + array, `ArrayBuffer` or `DataView` anywhere in a packet's args, result, + event data or error details travels as **binary attachments**: one frame + (`0x00 01`, a length-prefixed JSON `[packet, index]` with `null` at each + byte leaf and the buffers back to back, paths in the Jupyter + `buffer_paths` shape so nothing in user data can collide) and arrives as + fresh `Uint8Array`s that own their bytes — copied out of the frame, since + the WebSocket engine hands over zero-copy views into its socket segments. + Every transport carries it — a BINARY WebSocket frame (the shared fan-out + frame included: `PreparedFrames` and `SharedMessage.text` take bytes), a + WebTransport or data-channel message, a broker session frame, a worker port, a + packet-mode HTTP body under `application/octet-stream`, both ways, batches + included. SSE speaks revision 1 to every client — bytes travel as the + JSON 1.0 made of them, both ways — and refuses a frame on a channel POST + with `415`. A REST result with bytes answers `501` without + `codec.rest`; a room event with bytes is delivered locally and refused + for the JSON backplane (`backplane.bytes`). +- The fastify plugin registers a buffer parser for + `application/octet-stream`, as it does for `application/wrpc-sealed`, + unless the app already has one: fastify parses neither type, so a call + whose arguments hold bytes was answered `415` before the plugin's route + ran. An app's own parser for the type stands, and must hand the body + over as a `Buffer`. +- On by default wherever revision 2 was negotiated (the first entry of this + section); `attachments: false` on either end makes that end speak + revision 1 — JSON, to everyone — and a packet codec does so by itself. The cost is + `hasBytes`, a walk of every outbound packet (`bench/attachments.js`); + the compiled-serializer fast path walks the result only when a + serializer is compiled. + +**`@alexify/wrpc/deflate` — the dictionary in a browser, and a synchronous codec anywhere** +- A DEFLATE codec in plain JavaScript on its own subpath (3.8 KB + min+gzip, loaded only by a page that injects it). `createDeflateCodec({ + dictionary })` is a `Compressor` whose `id` matches + `dictionaryCompressor`'s for the same bytes, so a browser peer on it and + a Node peer on node:zlib negotiate with each other; without a dictionary + it is `deflate-raw`, which the platform codecs read. The inflater is + complete — stored, fixed and dynamic blocks, a preset dictionary, the + inflated-size cap — and every malformed input is a coded `DeflateError`. + The encoder is LZ77 against the dictionary written as one fixed-Huffman + block, measured first (`bench/deflate-js.js`): on a 108 B event with the + dictionary it produces the same 51 B zlib's dynamic trees do, at 100K/sec + against zlib's 136K and CompressionStream's 24K, and its inflate runs at + a million a second. Past `nativeAbove` (4 KiB) — where fixed codes fall + 30–45% behind — a browser hands the message to CompressionStream + (dynamic Huffman, no dictionary; the output still inflates on the + dictionary side); in Node the codec stays synchronous. `inflateRaw` and + `deflateRaw` are exported as primitives. +- Tests, the main body of it: an interop matrix both ways against + node:zlib at every level and strategy and against the platform streams, + a 400-payload fuzz corpus, and the malformed-input table. -This package has not been published to npm yet — there is no `[1.0.0]` -release section until the first publish; everything below lands there -verbatim on release day. +**A preset dictionary from the router (`buildDictionary`, `dictionaryCompressor`)** +- One-shot deflate has no history: a 108 B event compresses to 99 B. + `buildDictionary(router)` reads the router's introspection — field names + from signatures and schemas, every `unit/method` target and `unit/event` + name, the packet skeletons — ordered least to most frequent and cut from + the front past 32 KiB, deterministically, so a fleet builds the same + bytes. `dictionaryCompressor(dictionary)` is the codec over them (raw + deflate through node:zlib), its `id` carrying the dictionary's hash so + every wire's negotiation compares dictionaries too: a rolling deploy's + odd instance names another id and stays plain. Injected as + `compression: { codec }` on any Node↔Node carrier. Measured + (`bench/dictionary.js`, a 546 B dictionary): a 108 B event 99 → 55 B, an + 84 B call 77 → 30 B, a 1.1 KB callback 201 → 160 B, at the same rate — so + the dictionary codec's threshold is 64 B against the plain codec's 1 KiB. + `buildDictionary` is browser-safe and in the WebRTC browser barrel too, + for the pure-JS codec that follows. +- A **Compression** guide page collecting every knob, its negotiation and + its numbers in one place. + +**Per-message compression from a Node WebSocket client (`compression` on both ends)** +- Node's built-in `WebSocket` only ever inflates: a Node client's uploads + arrived as they were whatever the server negotiated. Now + `connect(url, { compression: true })` in Node sends `{ type: 'ping', enc: + 'deflate-raw' }` on open; a server with `new Server({ compression: true + })` answers the `enc` on its `pong`, and from then on every packet or + chunk past the threshold leaves as a binary frame under a `0x00` marker + — a stream chunk never starts with one — that the server inflates before + dispatch (`maxMessage`, 16 MiB, caps it; a frame before negotiation, of + an unknown kind or past the cap is an id-less 400, not a hang-up). Off + on both ends by default; a lone end stays plain; a browser is untouched + (it compresses both directions under permessage-deflate) and its bundle + carries none of this — the client half is a `package.json#browser` pair + whose browser side is a stub. + +**Per-message compression on the broker binding and the backplane (`compression`)** +- The broker binding: `attachBrokerRpc(server, broker, { compression })` + and `connect('broker://…', { compression })`, off by default and + negotiated so the two ends upgrade in any order. A session names its + codec on `hello` (`wrpc-enc`), the server that agreed answers it on + `welcome`, and every frame past the threshold then travels compressed + both ways — packets, events, subscription values, stream chunks — + marked `wrpc-enc` on the frame; `{ compress: false }` per message and + `writeWith` as on a WebSocket. A stateless request names what it accepts + and travels plain itself (HTTP's `Accept-Encoding` shape); only the + answer is compressed. A marked frame the receiver cannot inflate ends + the session like a sequence gap; `maxMessage` (16 MiB) caps the inflate. + Node↔Node, so the codec must answer synchronously — a promise-answering + one is refused at construction (`src/compression/sync.js`). Measured + (`bench/broker.js`): a session call answering a 9 KB result 9,315/sec + plain, 5,407/sec compressed — ~80 µs a round trip for ~10× fewer bytes. +- The backplane envelopes: `rooms: { compression }` and `cluster: + { compression }` deflate what this instance publishes past the threshold + and carry it as base64 under a `wrpc-enc::` marker, since the + backplane contract is strings. Nothing to negotiate against on a fan-out, + so this one is a two-step rollout: an instance without the option drops + such an envelope and logs `backplane.encoded` / `cluster.encoded` rather + than staying silent. The cluster signs first, then compresses, so HMAC + verification is unchanged. The codec is injected into `RoomsBackplane` + and `Cluster` by the core, so the browser-bundled `rooms.js` carries none + of zlib or base64. + +**Per-message compression on WebRTC (`compression` on the peer, the transports and `attachChannel`)** +- The other half of the transports with nothing under them: SCTP over + DTLS carries a data channel's bytes as they are. `new WrpcPeer({ + compression: true })` names the codec in every description this peer + sends (`caps: { deflate: "deflate-raw" }` in the signal, next to the + assertion when there is one) and a link compresses only once the other + peer named the same — a peer without the option is served plain, and + nothing hangs up. Bit 2 of the data-channel header is the DEFLATE flag, + a reserved bit (a protocol error) until negotiated; a message past the + threshold is compressed **before** fragmentation, the one place it + exists whole, and every fragment carries the flag. `{ compress: false }` + per message and `writeWith` on the peer transport, as on a WebSocket; + an inflate past `maxReassembly` closes the channel like any bad frame. +- Over a raw channel there is no description to announce in: + `compression` on `attachChannel`, on `connect(url, { channel, + compression })` and on either transport is applied as given, so both + applications turn it on or neither — documented, and tested: the plain + side closes on the first flagged frame. +- `RtcLink` grew `caps` (announced) and `peerCaps` (read before the + description is applied, so the channels open already knowing). The + webrtc browser entry's budget 48 → 50 KB (measured 49.2). + +**Per-message compression on WebTransport (`compression`, both ends)** +- Nothing compresses a QUIC stream's payload — HTTP/3 does headers only — + so a WebTransport session carried exactly the bytes wrpc handed it. Now + `attachSession`/`acceptSessions` and the client (`connect(url, + { compression })` or the `wt` bag) take `compression: true | { codec, + threshold }`, off by default and negotiated: each end names its codec in + the capabilities message (`deflate: "deflate-raw"`) and compresses only + once the other named the same, so a lone end is served plain. A packet + or chunk past the threshold leaves as KIND 3 or 4 and is inflated before + delivery; `{ compress: false }` per message still works; chunks on their + own streams and datagrams are never compressed; an inflate past + `maxMessage` is a 1002. +- `src/compression/`: the structural `Compressor` seam (`isCompressor`, + exported from the main entry — `id`, `encode`, `decode(bytes, maxOutput)`, + either may answer a promise), the platform codec as a browser-swapped + pair (node:zlib raw deflate, sync, 1 KiB threshold; `CompressionStream`, + async, 4 KiB — ~6× the cost per call and no dictionary), and the + `Sequencer` that keeps the wire in order around an asynchronous codec + with a synchronous fast path when nothing is in flight. Measured + (`bench/message-compression.js`): a 1.4 KB callback 6.4× at 84K/sec, a + 24 KB one 13.8× at 12K/sec, a 108 B event 1.1× — the threshold's reason. +- The main browser entry's budget 20 → 22 KB (measured 20.8), sse 21 → 23: + the seam every browser transport will share. + +**`Content-Encoding` on the HTTP side (`http.compression`, `sse.compression`)** +- gzip for packet-mode and REST answers, opt-in and off by default: + `http: { compression: true | { threshold, filter, level, memLevel, async } }`. + Applied in the one funnel every HTTP answer leaves through, so packet + POSTs, batch frames, REST results and errors all qualify. A response is + encoded when the peer's `Accept-Encoding` admits gzip, the body is at or + over `threshold` (1 KiB), nothing upstream set a `Content-Encoding` (a + route's own `headers`, a framework plugin) and `filter(call)` agrees; it + then carries `Vary: Accept-Encoding`, joined onto the CORS `Vary`. A REST + ETag stays over the plain body, a `204`/`304` is never encoded, and + `async: { threshold }` hands large bodies to zlib's threadpool — the same + shape as `perMessageDeflate.async`. Nothing changes on the client: `fetch` + inflates by itself. `bench/http-compression.js` prices it: a 1.6 KB + callback 5.5× at 77K/sec, an 8.5 KB one 9.9× at 28K/sec. +- The event stream: `sse: { compression: true | { filter, level, memLevel } }` + — one gzip member per response, flushed after every event, which is + context takeover for free: a repeated 125 B tick leaves as 16 B (7.8×), + and the `ready` frame is not held back by it. Decided per GET, so a + re-attach negotiates again and replays through the new member. The wrpc + SSE client needed no change; every host's `stream` writer is wrapped the + same way. +- `rpcOptions` learned the `http` key: an `http` option given to `Server`, + `wrpcFastify` or `createWrpc` reaches the core. Fastify's delegated REST + routes stay fastify's — `@fastify/compress` encodes those. + +**Compression, documented as the choice it is** +- Nothing in wrpc compresses by default — `perMessageDeflate` on the + built-in engine, `compression` on uws — and that stays: a default would + spend a deflate on every frame of every peer to save bytes only some of + them need. The performance guide now says so in one place ("Compression is + off by default"), with the cost of the knob from `bench/deflate-context.js` + next to the ratio it buys; the knob table names `perMessageDeflate` itself + ahead of `contextTakeover` and `async`, which do nothing without it; the + server options, the rooms page, the proxy notes and the deploy checklist + point at the same section. No behavior changed. +- The honest limits are written down too: `perMessageDeflate` covers the + WebSocket only — every other wire has its own knob (the entries above) — + and a Node client never sends a compressed frame by itself: the built-in + `WebSocket` offers `permessage-deflate` but only inflates, which is what + the client's own `compression` option is for. + +**Telemetry: the honest set** +- `wrpc.server.sessions` records all five operations. It only ever said + `restore`, while a unit test asserted a `create` shape nothing in the + package could produce — the metric described a surface that did not exist. + `evict` is the one worth alerting on: unlike `expire` it discards sessions + that are still live, which is signed-in users being signed out to stay + under `maxSessions`. +- `wrpc.stream.direction` has both values. The `send` half was never + recorded anywhere, so an attribute that promised two values had exactly + one for the life of the metric. The server records it; the client does + not, where the counter has nothing to feed and the bytes would only cost + the browser bundle. +- **`wrpc.client.heartbeat.rtt`** — the client's only true latency signal + that needs no call to produce it. The ping/pong pair is an exact round + trip and both ends were already timestamped; nothing read them. A + heartbeat that times out is counted, never timed: there is no round trip + to measure, and a sample invented from the timeout would describe your + configuration rather than the network. +- **`wrpc.server.queue.wait`** and **`wrpc.server.queue.depth`**. Time spent + waiting for a concurrency slot was folded into `rpc.server.duration`, + which made a saturated queue and a slow handler indistinguishable — two + opposite problems with opposite fixes. `depth` carries no attribute at + all: one series per procedure is a cardinality bomb, and the question is a + whole-process one. +- **`wrpc.broker.delivery.attempts`**, recorded on settlement only (an ack + or a dead-letter, never a retry), so it is the distribution of deliveries + per message rather than a triangle counting each message once per + attempt. The number was on every delivery all along and never read. +- **`wrpc.cluster.verifications`** and **`wrpc.rtc.assertions`** put a + counter behind two security signals that had none: a backplane envelope + that fails authentication, and a peer presenting a trust assertion that + does not bind to the DTLS fingerprint of the connection it arrived on. +- **`wrpc.client.calls`** and **`wrpc.server.rooms`**: a client error *rate* + was not derivable from the duration histogram alone (a call with no + elapsed time recorded nothing), and live rooms had no gauge. The room + gauge rides the existing first-member/last-member callbacks, so the hot + join/leave path is untouched. + +**Logging reaches the silent paths** +- `RpcServer` gains a `log` getter, the pair of the `otel` one that already + existed for the same reason: a framework adapter or an external attacher + now has somewhere to report a failure that never reaches a `Client`, + instead of emitting an `'error'` nobody listens for. `Client` had both + getters all along; the server having only one was the anomaly. +- The **dispatcher's six refused calls** — unknown method, duplicate id, + `maxCalls`, draining, unknown packet type, oversize batch — now log. Their + subscription twin had logged from the day it was written, so half of what + a dispatcher rejects was invisible to an operator, and the invisible half + was the one that says "your client and my router disagree about what + exists". The levels are not uniform on purpose: the two any peer can drive + in a loop go to `debug`, so a refused flood cannot become a log flood. +- The **WebSocket engine** logs for the first time. Every way a connection + died at the framing layer — invalid UTF-8, a protocol violation, a message + past `maxBuffer`, a backpressure limit, a failed inflate — emitted an + `'error'` a server rarely listens for and then closed, which is the + "it just disconnects sometimes" report with nothing to work from. They go + through one seam now and name the limit that closed the connection, since + raising it is the operator's decision to make. `WebsocketServer` takes a + `logger` option; the `Server` shell passes its own writer down. +- `acceptSessions` (WebTransport) defaulted `onError` to `null`, so a session + that failed to attach vanished without a trace — the one failure mode of a + WebTransport server nothing above could observe. It now reports through the + server's writer; an explicit `onError` still wins. +- Broker consumers log every settlement decision, not only the terminal one: + a queue retrying itself in a circle used to look exactly like a healthy + one. The dead-letter line carries the error, not just its code, and the + per-token client cache says when it is thrashing. +- A tampered **resume token** on a broker feed is now a `warn` naming + `reason: 'signature'` — previously indistinguishable from a stale or + garbled one, and silent. The token itself is never logged. +- Session capacity eviction says so. Unlike a TTL sweep it discards sessions + that are still live — signed-in users signed out with no error anywhere — + and it logs once per sweep with a count, never once per session. +- `createUwsEngine` and `createRedisSessionStore` take a `logger`. + uWebSockets.js dropping an outbound frame, and a stored session row that + will not parse, both had no channel at all. +- `docs/guide/logging.md` gains an **event catalogue** — the `event` names to + alert on, by component — and a section on what is deliberately left + unlogged, so the four principled zeroes are not "fixed" later. + +**Pluggable identifiers, everywhere an id is minted** +- `generateId` had shipped on the server, the client, the cluster, a peer + host and the signaler, and then fourteen other places went on calling + `randomUUID` directly — including the server's own `instanceId`, the SSE + channel id and the broker RPC session id. One resolver in `src/utils.js` + now backs every one of them, and the ids that had no seam got one. +- The **SSE channel id** is the one that mattered most: it is peer-visible, + it keys the channel registry, and holding one is most of what proves a + request belongs to a channel. An `RpcServer` passes its own `generateId` + down, so a server that injected a generator now covers channel ids too; + `SseChannels` takes the option directly when driven standalone. +- The **broker RPC session id** is the server's key for a connection's frame + state, so a guessable one lets a sender inject frames into somebody else's + session. The broker transport now mints it with the owning client's + generator, handed down the same seam as `codec` and `log` — one option, + resolved once, rather than the same option resolved twice in two modes. +- `instanceId` on `RpcServer` and `PeerHost` is minted by `generateId` when + omitted, instead of a `randomUUID` the option could not reach. The "must + not contain `.`" rule now covers a generated id too, and says which of the + two options produced the offending one. +- Each broker adapter (`redis`, `nats`, `amqp`, `kafka`) and `MemoryBroker` + takes a `generateId` for the names it puts on the wire — consumer names, + inboxes, groups, message ids. An injected generator is used **verbatim**: + the two sites that shortened a uuid still shorten the default, never a + value you supplied. +- `rooms.epoch` is declared and forwarded. `RoomsBackplane` had accepted it + all along, but `RpcServer` passed only `linger`, so it was unreachable + from the public surface. +- Validation is uniform and happens once: a generator must be a function + answering a non-empty string of at most 255 characters — the binary chunk + header's own limit, now applied to every id rather than only to stream + ids. The check calls the generator, and that first id becomes the + `instanceId` rather than being discarded, so a counter-based generator + still starts where you expect. The per-stream check stays: it catches a + generator that only *sometimes* answers something too long. + +**Message brokers, part 9: benchmarks and the finished guide** +- `bench/broker.js` measures what the bindings cost on top of a broker, on + the in-process `MemoryBroker` so the numbers are wrpc's own overhead and + not a network's: a queue delivery into a procedure end to end (~530k + ops/sec bare, ~400k through a validator, a hook and a `meta` header), + `createPublisher.publish` into a log append (~2.1M ops/sec) and a durable + feed's pump, append to tracked value (~750k ops/sec). +- `docs/guide/brokers.md` gains a "choosing one" table, the README its + broker rows, and `pnpm brokers:up`/`brokers:down` bring the four servers + in `compose.yaml` up and down for the integration suites. + +**Message brokers, part 8: the Kafka adapter (`@alexify/wrpc/broker/kafka`)** +- `createKafkaBroker({ kafka })` over an injected KafkaJS-shaped client — + `kafkajs` or `@confluentinc/kafka-javascript`'s `.KafkaJS`. The two differ + in CONFIG, not in method names, and `src/broker/kafka/shape.js` + normalizes every difference the phase-0 spike found: the `kafkaJS` config + nesting, `fromBeginning`/`autoCommit` placement, the `consumer.events` + getter that THROWS on one of them, `fetchTopicMetadata` answering an + array vs `{ topics }`, and the join signal (a GROUP_JOIN event vs polling + `assignment()`). +- `log`: a topic per feed, single-partition by default (order), and the + resume token is a VECTOR of partition offsets. Readers pin their position + by seeking to a watermark captured before the join, because a fresh group + resolves `latest` at its first fetch — a race that would otherwise drop + the first entries. +- `queue`: one consumer group, manual commits, `partitionsConsumedConcurrently` + as the prefetch. Kafka has no nack, so a retry is a republish carrying + `x-wrpc-attempt` (the delay waits in-process, the original stays + uncommitted meanwhile) and an exhausted message goes to a dead-letter + topic. +- `backplane`: one topic, the channel in a header, a unique consumer group + per instance, publishes chained so a per-channel sequence cannot reorder. + Caveated in the guide: an instance is deaf until its group joins. +- **No `direct`** — and it says so: RPC over a broker refuses Kafka rather + than limping. +- Suites run over an in-repo fake Kafka (both client shapes) in `pnpm test` + and against a real broker in CI's new `kafka` job. + +**Message brokers, part 7: the RabbitMQ adapter (`@alexify/wrpc/broker/amqp`)** +- `createAmqpBroker({ connection })` over an injected amqplib connection: a + direct exchange with one exclusive queue per instance for the backplane, + stream queues (`x-stream-offset` as the feed's resume token) for logs, + quorum queues with a TTL retry queue and a dead-letter queue for work, and + a fanout exchange per address for RPC (plain listeners each bind their own + exclusive queue, a group binds one durable queue). +- Two RabbitMQ 4 behaviours the phase-0 spike measured are built in: a + requeue does NOT count a delivery, so `retry()` republishes with an + `x-wrpc-attempt` header while `release()` is the plain requeue that must + not count one; and a transient non-exclusive queue is refused at the + CONNECTION level, so every shared queue the adapter declares is durable. +- `mandatory` + `basic.return` turns "nobody is listening" into the fast + `503` an RPC caller wants, and a request's timeout rides as the message's + `expiration` so a stale one is dropped rather than executed late. +- Suites run over an in-repo fake RabbitMQ in `pnpm test` and against a real + server in CI's new `rabbitmq` job. + +**Message brokers, part 6: the NATS adapter (`@alexify/wrpc/broker/nats`)** +- `createNatsBroker({ nc, headers, jetstream, jetstreamManager })` over an + injected nats.js connection: core subjects carry the backplane and RPC + (queue groups for a service address, native reply subjects for inboxes), + JetStream carries feeds (a stream per topic, the message sequence as the + resume token) and work queues (a durable pull consumer per group, + `max_ack_pending` as the prefetch, `working()` keeping a slow handler's + lease and releasing it the moment the consumer stops). +- Without the JetStream factories the broker has backplane and direct only — + the capability checks do the rest. +- Every application name becomes exactly ONE subject token, so a room called + `room:*` can never become a wildcard subscription; stream names are encoded + the same way. +- Suites run over an in-repo fake NATS + JetStream in `pnpm test` and against + a real server in CI's new `nats` job. + +**Message brokers, part 5: the Redis adapter (`@alexify/wrpc/broker/redis`)** +- `createRedisBroker({ client })` implements all four capabilities over an + injected ioredis-shaped client — pub/sub for the backplane (the adapter + `./scaling` already shipped), Streams for logs and queues (`XREADGROUP`, + `XACK`+`XDEL`, `XAUTOCLAIM` for what a stopped consumer held, a sorted set + for delayed retries), pub/sub plus a list for RPC inboxes and service + groups. Valkey, KeyDB and Dragonfly need no adapter of their own. +- One blocking `XREAD` per instance serves every live feed reader + (`TopicTails`), and a stream id is the feed's resume token. The adapter + opens the extra connections blocking reads need through `duplicate()` (or + an injected `connect`) and quits only those — never the injected client. +- The four contract suites run over an in-repo fake Redis in `pnpm test` and + against a real server in CI's `redis` job. + +**Message brokers, part 4: RPC over a broker (`attachBrokerRpc`, `transport: 'broker'`)** +- `attachBrokerRpc(server, broker, { service })` serves wrpc over a broker's + `direct` capability, and `connect('broker://', { transport: + 'broker', broker, mode })` calls it — no HTTP, no WebSocket, no service + discovery between services that share a broker. +- `mode: 'stateless'` (default): a request is handled exactly like a + packet-mode HTTP POST (`handleHttpCall`: batches, header sessions, meta), + by whichever instance the broker's group hands it to. +- `mode: 'session'`: the full protocol (events, subscriptions, cancellation, + binary streams) with one instance, found by a `hello`/`welcome` + handshake. Frames are numbered per direction and a gap is a lost + connection; `bye` both ways; a server-side `idleTimeout` (90 s) reclaims + sessions whose client vanished; backpressure from unconfirmed sends. + Losing the instance is an ordinary reconnect, and subscriptions resume. +- Draining stops consuming the service address while held sessions finish. +- The carrier is specified as the experimental + [Broker binding](./docs/reference/protocol.md#broker-binding) section of + the protocol reference. + +**Message brokers, part 3: queue consumers and publishing (`attachConsumers`, `createPublisher`, `consumes`)** +- A unit's reserved `consumes` block declares queue consumers — full + procedures with an optional `consume` policy — that no call packet can + reach and introspection does not list. `attachConsumers(server, broker, + table)` binds them (and, through the table, overrides their policy or binds + ORDINARY procedures to queues) and delivers each message through the same + pipeline a call takes: hooks, validators, access, queue/timeout, telemetry. +- Settlement follows one table for every broker: ack on success; retry with + full-jitter backoff on `408/429/500/503` up to `attempts`; dead letter (with + `x-wrpc-dead-reason`/`x-wrpc-attempt`, after `onDeadLetter`) on anything + else or when exhausted; release on `503` while draining. `ctx.callMeta` + carries `messageId`, `attempt`, `queue` and allowlisted headers. +- Identity per binding: `none` (default; a session procedure refuses to + bind), `service` (a pseudo-session) or `token` (a bearer header restores a + real session, one LRU-cached client per token). +- The server's `'draining'` pauses every binding (held messages finish and + ack); `close()` now emits `'close'` first and stops them, releasing + whatever a close cut off. `RpcServer#limits` exposes the per-connection + caps; `attach(transport, { persistent: false })` attaches a + request/response carrier that is not counted among connected clients. +- `createPublisher(server, broker, table)` publishes a unit's declared + `emits` by name to a log or a queue, with an optional validator and key; + each publish is a `PRODUCER` span whose context rides in the headers, so a + trace runs from the call that placed an order to the consumer that charged + it. +- The queue contract gains `pause()`/`resume()`: no new deliveries, held + ones stay settleable. + +**Message brokers, part 2: durable subscription feeds (`brokerFeed`)** +- `procedure.subscription({ handler: brokerFeed(broker, topic, options) })` + reads a broker log and `tracked()`s every value with the log's resume + token, so a client re-subscribing with `lastEventId` resumes on ANY + instance — the "broker-backed feed" recipe of the rooms guide, made + first-class. The topic may be a function of the context and arguments; + `map` reshapes or skips entries; an undecodable entry is logged + (`feed.decode`) and skipped. +- A `lastEventId` the log cannot resume from ends the feed with `400` + (malformed, forged, past the tip) or `410` (history gone, another log) — + or, with `onGap`, becomes a snapshot followed by everything appended from + the moment of the gap (the read is positioned before the snapshot is + built). A reader the retention overtook mid-stream takes the same path. +- `secret` HMAC-signs the ids a feed hands out and refuses any it never + issued; `maxIdLength` caps what a peer may send. + +**Message brokers, part 1: the broker-agnostic core (`@alexify/wrpc/broker`, experimental)** +- A broker is described by four capabilities — `backplane` (the existing + at-most-once fan-out), `log` (ordered, replayable), `queue` (at-least-once, + competing consumers) and `direct` (addressable inboxes) — and every adapter + implements the subset natural to it. `isBroker`/`isBrokerLog`/ + `isBrokerQueue`/`isBrokerDirect` are the structural checks. +- `MemoryBroker` implements all four in one process: the reference the + contracts are written against, and what makes a multi-instance setup + testable without infrastructure. The contracts are executable + (`tests/broker/*Contract.js`) and run over the memory broker, the scaling + backplanes (a first shared backplane contract: `MemoryBackplane` and the + Redis adapter over its fake — which surfaced that the fake delivered + synchronously inside `publish()`, as no real Redis does) and, from the + adapter releases on, real servers. +- Adapter building blocks: `TopicTails` (one live reader per topic shared by + every local read, catch-up joined without a gap or a duplicate, bounded + memory per slow reader) and `encodeToken` (arbitrary names into a broker's + alphabet, injectively — a room called `room:*` must never become a NATS + wildcard subscription). +- Core seams, additive: `RpcServer.attach(transport, { session })` gives an + attached client a vouched-for identity before the onConnect hooks run, and + `{ request: { headers, url } }` restores a real one through the token + carrier; `drain()` announces itself once as a `'draining'` event; a + host-built client may set `spanKind`/`spanAttributes` for its call spans, + and the server telemetry writer gains `withMessagingSpan` and the + `wrpc.broker.deliveries`/`wrpc.broker.published` counters. The webrtc + browser budget moves 46 → 47 KB for those telemetry bytes (PeerHost + bundles the server writer). +- `compose.yaml` starts RabbitMQ 4, Kafka 3.9 (KRaft) and NATS 2 with + JetStream next to Redis. + + +**Injectable `fetch` for the http/sse client transports (`options.fetch`)** +- `WrpcClient.connect(url, { transport: 'http' | 'sse', fetch })` lets the + transport call an injected `fetch` instead of the runtime's global one, + re-resolved on every open like `headers`/`meta`. The seam is for a Node + process that talks wrpc to another wrpc server and wants undici's + connection pooling, proxying, or a caching interceptor tuned for that + traffic — without wrpc ever depending on undici (it stays uninstalled; + the caller supplies the function). Not a way to reach arbitrary + third-party REST APIs through wrpc: the http/sse transports still only + call the one connected wrpc server. + +**Backplane loss detection (`epoch`/`seq`, `backplane.gap`, `wrpc.server.backplane.gaps`)** +- Every backplane envelope carries the publisher's boot `epoch` and a + per-channel `seq`; a receiver that sees the sequence jump within one + epoch logs `backplane.gap` — `{ channel, instance, missed }` — and adds + `missed` to the new `wrpc.server.backplane.gaps` counter. Redis pub/sub + loses envelopes silently (a subscriber blip, an output-buffer eviction); + the loss is now a number on a dashboard. Additive fields: an older + instance's envelopes are delivered untracked; a new epoch resets rather + than reports. At-most-once stays the contract — the rooms guide gains + the three recipes for the cases that need more (a broker-backed + subscription, a room-backed event log, an acknowledged `ask`). + +**A multi-node bench and the Redis it needs (`bench/cluster-nodes.js`, `compose.yaml`)** +- `pnpm redis:up` starts a Redis (`compose.yaml`); with `REDIS_URL` set, + `bench/cluster-nodes.js` forks N wrpc processes over that Redis (backplane + + session store, bearer sessions) with M clients spread across them and + measures cross-instance emit rate and latency, presence convergence after + a join/leave storm, broadcast ask across instances, and an instance loss — + its clients rehomed with their session token and no sticky routing, the + presence count converging, the loss detector's count. 4 instances × 200 + clients: emit latency p50 2 ms / p99 4 ms across the broker, a 200-join + storm converging in 6 ms, ask answered 200/200, 50 rehomed clients with + 50/50 sessions restored and 0 gaps. Without `REDIS_URL` it skips, so + `pnpm bench` stays self-contained. CI gains a `redis` job + (a Redis service) for `tests/scaling/redis.integration.test.js`. + +**REST response headers and a cache policy (`http.headers`, `http.cache`, `context.http`)** +- The paper's "loss of intermediate HTTP caching" was a missing seam, not a + property of the model: a mapped route can now declare static response + `headers`, a handler (or hook) can shape the response through + `context.http` — `{ method, url, headers, setHeader(name, value), + status(code) }`, null on every non-REST transport and on packet-mode + HTTP — and `http.cache: { maxAge, public?, staleWhileRevalidate?, etag? }` + answers `Cache-Control`, a weak `ETag` and `304` on `If-None-Match`, on + GET and HEAD (HEAD is now served by the GET route, RFC 9110 9.3.2). The + policy is decided once the session is known and is the same function on + every host (`cacheHeadersFor`): only a public procedure on a request that + restored no session and set no cookie gets the declared policy; anything + session-bearing answers `private, no-store` and no ETag. Transport-owned + header names are refused. The fastify adapter applies both seams onto its + `reply` and leaves ETag/304 to `@fastify/etag`. Introspection carries + `headers`/`cache` only when declared. + +**`createRedisSessionStore` (`@alexify/wrpc/scaling`)** +- An ioredis-shaped, injected session store — `get`, `set(key, value, + 'PX', ttl)`, `del`, `pexpire` for the sliding expiry — so a client that + reconnects to another instance keeps its session and WebSocket traffic + needs no sticky routing. The scaling guide gains an affinity table: with a + shared store and a backplane only SSE channels stay pinned. + +**Context takeover and async deflate (`perMessageDeflate.contextTakeover`, `.async`)** +- `contextTakeover: 'server' | 'client' | true` keeps a live zlib stream per + direction per connection (`src/websocket/deflateContext.js`), so a message + may reference the ones before it — the ratio the stateless default gives + up, at ~160 KiB per direction per connection at the defaults (`level`, + `memLevel` tune it). A peer's own `*_no_context_takeover` request is + always honoured. `async: { threshold }` (256 KiB by default) runs the + deflate and inflate of larger messages on zlib's threadpool instead of + the event loop. Both go through per-connection ordering queues: writes + issued behind an in-flight deflate wait for it, inbound messages are + delivered in arrival order, `bufferedAmount` counts the queued bytes so + the `send()` boolean and `'drain'` stay honest, and a fan-out over the + async threshold still deflates once (the first recipient starts it, the + rest wait on the same frame). `bench/deflate-context.js`: a repeated JSON + event compresses 10.6× with a context against 1.1× without; a burst of + 100 × 252 KB frames stalls the loop for 328 ms synchronously and 2 ms + async, at four times the throughput. Takeover members of a room compress per + connection, the stateless default keeps the shared frame. Autobahn passes + in both modes (`AUTOBAHN_DEFLATE=takeover node scripts/autobahn/run.js`). + +**The call path, measured and trimmed (phase 1b of the paper's findings)** +- `bench/support/rpc-stacks.js` gains two rows: the same RPC path over the + uws engine, and the own engine with `batch: true`. They answer the + question the old table could not: with 64 calls in flight wrpc runs at + 95.8K over its own engine and 112K over uws against 129K for a raw uws + echo — the JavaScript engine is ~17 % of the gap, the RPC layer and the + client are the rest; client batching is worth as much as the engine swap. +- `pnpm bench:browser` (`scripts/bench-browser.js` + `bench/browser/calls.js`, + `playwright-core` as a devDependency driving the installed Chrome) measures + the client's call path where it ships, over an in-page echo on a + `MessageChannel`. Chrome 152: 64 calls in flight 101,666 → 114,158 ops/sec, + 1024 in flight 90,665 → 104,864 (the table is in `docs/guide/performance.md`). +- Client call deadlines are one bucketed timer per client instead of a + `setTimeout` plus three closures per call: one lane per distinct timeout, + buckets of 1/32 of it (a call times out between its deadline and 3 % after, + never before), settled calls leave their bucket at once so an idle client + arms nothing. `bench/bench.js` call + server event round-trip 14.7K → 17.0K. +- `Client.isReady` (server side): true once `ready` resolved, so a call + after the first skips the `await` and its microtask; `Context.uuid` is + minted on first read; `Procedure.invoke` calls the handler directly + instead of through `Promise.resolve().then()`; the dispatcher skips the + span wrapper and its options object when telemetry is off, as the client + already did; logger writers carry `debugEnabled` so the per-call debug + entry is not built for a writer that drops it. The client settles a + `callback` before the batch and heartbeat checks, without the `#dispatch` + hop. +- Browser budgets raised with the measurement that earned them + (`scripts/size.js`): main 19 → 20 KB, sse 20 → 21 KB, webrtc 45 → 46 KB. + +**Fan-out frames encoded once (`Connection.sendPrepared`, capability `prepared`)** +- A room broadcast now hands every recipient ONE shared + `{ text, frames, compress }` message (`Client.sendShared`, + `ServerWsTransport.writeShared`): the built-in engine encodes the frame — + and, with permessage-deflate, deflates it once per negotiated window — into + the message's cache slot on first use and writes that buffer to every later + recipient. `bench/send-path.js`: room fan-out ×50 went 102,997 → 637,248/sec, + ×200 28,651 → 321,820/sec, and ×50 with deflate 2,708 → 101,609/sec (the + 36× compression cliff was N deflates per emit; it is now one per window). + `WrpcSocket.sendPrepared?()` is an optional, feature-detected addition to + the engine contract (`EngineCapabilities.prepared`; the contract suite + exercises it when present), so third-party engines need no change. +- **Selective compression.** `perMessageDeflate: { filter: (req) => boolean }` + decides per connection whether a peer's offer is accepted — compression for + bandwidth-bound browsers, none for in-datacenter peers — and + `{ compress: false }` on `server.to(room).emit()`, `client.sendEvent()`, + `client.sendRaw()`, `client.send()` and `Connection.send()` sends one + message uncompressed past the threshold. `WrpcSocket.send()` gains the + optional second argument; sockets that ignore it compress as before. +- `Connection` option `coalesce` (default `true` for server connections, off + for a bare `Connection`): the first write of an event-loop turn corks the + socket and the next tick uncorks it, so a batch of N answers or a burst of + events leaves in one `writev` instead of N syscalls. The `send()` boolean + and `'drain'` are unchanged. + +**WebTransport, experimental (`transport: 'wt'`, `@alexify/wrpc/wt`)** +- `connect(url, { transport: ['wt', 'ws'], wt: { serverCertificateHashes } })` + runs the ordinary client over a WebTransport session (HTTP/3): every + packet and stream chunk travels on ONE client-opened bidirectional + stream — the control stream — under a five-byte length/kind header + (`docs/reference/protocol.md#webtransport`), so calls, events, + subscriptions with resume, binary streams with backpressure, heartbeat and + reconnect all work unchanged and the WebSocket fallback is invisible. The + transport is in the base client entry (no import for the fallback list; + the browser budget went 16 → 19 KB for it, datagrams and the stream mux included); the session comes from + `globalThis.WebTransport` or an injected `wt.WebTransport`. Declared + headers and meta ride the connect URL as on ws; a `CONNECT` sends no + cookies, so sessions need `bearerTransport()`/`payloadTransport()`. +- `@alexify/wrpc/wt` is the server half, and binds to no implementation — + Node has no WebTransport of its own (`node:quic` is behind a compile-time + flag and speaks no WebTransport): `attachSession(server, session, meta)` + waits for the control stream, wraps the W3C-shaped session as a + `WrpcSocket` (`WtSocket`) and attaches it through `RpcServer.attachSocket`, + so a WebTransport client lands in the same `Server`, rooms, cluster and + session store as a WebSocket one; `acceptSessions(server, sessions)` loops + a host's `sessionStream()` (or any iterable); `fromFails(session)` and + `fromQuico(req, res)` read the `CONNECT` request off + `@fails-components/webtransport` and `quico` respectively (both + devDependencies, exercised by `WRPC_WT=`-guarded integration tests), + `failsRequestCallback` is the request callback a fails server needs, and + `isWtSession` is the structural check. `idleTimeout` terminates a session + that sends nothing — the liveness a host without its own session-end + reporting (quico) lacks. Close codes carry over: 1001 on + shutdown, 1002 on a framing violation, 403/408 on a refused session. +- **Binary streams on their own WebTransport streams.** Once both ends have + announced it (each end's first message on the control stream is a KIND 2 + capabilities message), every `createStream()` on either side gets a + unidirectional WebTransport stream: its chunks travel there, its `end()` + is that stream's FIN and its `terminate()` a reset, while calls, events + and subscriptions keep flowing on the control stream beside a large + transfer instead of behind it. The receiver holds early chunks until the + opening `stream` packet has passed and synthesizes the end only after the + stream's own FIN, so ordering is what a WebSocket gave; a peer that + announces nothing (an older peer, a wire codec) gets every chunk on the + control stream as before. `ServerWtTransport` (registered as + `ServerTransport.transport.wt`, which `attachSocket` now consults for + `meta.kind`) is what lets the server side see a stream packet before it + is serialized. +- **Unreliable events.** `sendEvent(name, data, { unreliable: true })` on + the client, `client.sendEvent(...)` / `server.to(room).emit(...)` with the + same option on the server, send an event as ONE WebTransport datagram — + at most once, unordered, for state a later event supersedes (a cursor, a + position) — and reliably wherever the transport has no datagrams or the + packet does not fit in one, so application code is written once. A + `Broadcast` picks per recipient and carries the flag across the + backplane; `ask()` refuses it. The wire (`[KIND][packet]`) is in + `docs/reference/protocol.md#webtransport-datagrams`; `ClientTransport` + and `ServerTransport` gain an optional `writeUnreliable(text)` seam. +- `node:quic` was evaluated as a host on a custom Node build and does not + qualify yet (it cannot send the WebTransport SETTINGS a client waits + for); the findings are in the guide. +- The whole feature is marked `@experimental` and may change in a minor. + +**Transport fallback on the first connect** +- `connect()` with a transport list now walks it on the FIRST connect too: a + candidate whose `open()` rejects outright (no `WebTransport` in this + runtime, a refused upgrade) hands over to the next one at once, with no + reconnect budget to burn first; only the last candidate's rejection is + `connect()`'s. Reconnect exhaustion falls through the list as before. +- `RpcServer.attachSocket(socket, meta)` takes `meta.kind` — what + `Client.transportKind` and the log lines report for a socket that is not a + WebSocket (`'wt'`). + +**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`)** +- WebRTC ships stable, signaling included: the whole surface — `WrpcPeer`, + `PeerLink`, `Mesh`, the link and the transports, the trust assertions and + the signaling unit — is under the ordinary semver promise, none of it + `@experimental`. The release gate the review set for it closed in this + release, each item with its normative line in the protocol reference: + reserved `peer`/`room`/`claims` in session data, a `members` roster for + members only, no empty fragments and a `maxFragments` cap, a minimum + negotiable message size, every `a=fingerprint` line bound by an + assertion, a responder that gives up without ending the link, the + goodbye's reason on the wire, the relay's limits, TypeErrors on water + marks and on a closing channel, `maxBackpressure`. +- `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 + process with an injected implementation) each serve a router and call the + other's over ONE `RTCPeerConnection` carrying two negotiated data + channels, one per client→host direction, so the ordinary `WrpcClient` and + dispatcher speak across the link unchanged: calls, events, ask/respond, + subscriptions with `lastEventId` resume, binary streams, heartbeat and + reconnect. `PeerLink` is one peer, both directions (`remote`, `api`, + `client`, `send`/`ask`/`createStream`); roles are by id order alone and + `connect()` works from either side (the non-initiator knocks). +- `Mesh` (`peer.join(room)`) — everyone in a signaling room linked to + everyone, with `broadcast()`/`ask()` as one `Broadcast` fan-out over the + host room `mesh:`, `respond()` covering members present and + future, and a rebuild on a signaling reset. A member whose signaling + connection dropped is only `away`: its link stays up until it leaves for + real, comes back as another incarnation, or the link itself fails. +- Signaling: the structural `Signaler`/`RosterSignaler` contract + (`isSignaler`, `hasRoster`), the built-in server unit + `createSignalingUnit()` + `createSignalingHooks()` (relayed through + `RpcServer.sendTo`, so it clusters with no extra state) and its client + half `wrpcSignaler(client)` over any transport. +- Stable peer identity: `createSignalingUnit({ identity })` decides a + connection's peer id — a user id from the session, say — from the context + and the id the client proposed (`wrpcSignaler(client, { identity })`); the + default stays the connection's client id. The id survives a signaling + reconnect, so a `WrpcPeer`'s links do too (`reset` with `id === previous` + keeps them). Each signaler carries one `instance` (`generateId` option), + which tells two incarnations of one id apart: a signal from another + incarnation abandons the stale link and relinks. `duplicate: 'replace' | + 'refuse'` decides a second connection under a held id; the first hears + `replaced` (the peer emits it and closes). Rosters, `join`/`leave` and + signals carry `instance` and the routable `address`, and `leave` a + `reason` (`'left' | 'disconnect' | 'replaced'`). +- Trust assertions: `createSignalingUnit({ assertions: { key, ttl, issuer, + claims } })` signs a JWS (compact, ES256, `typ: 'wrpc-rtc+jwt'`) per peer + binding its id to the DTLS certificate fingerprint it dials with, and + publishes its keys (`assert({ fingerprint })`, public `keys()`; the + client half's `signaler.assert()`/`keys()`, `hasAssertions`). `WrpcPeer({ + assertions })` gets a token for every description it sends and verifies + every one it receives — `sub` is the sender, `fp` is the description's + fingerprint, `exp`/`iss` hold, the signature checks against the server's + public key (rotation by `kid`) — before the link applies it and before + `accept(from, room, { instance, claims })` runs; a redial's new + certificate is verified anew, an ICE restart is a string compare. The + verified claims are `link.claims`, and `PeerHost({ trust: 'assertion' })` + requires them and exposes them as `context.session.data.claims`. Exported + for both sides: `createAssertionVerifier`, `sdpFingerprint`, + `normalizeFingerprint`, `isAssertion`, `AssertionError` (browser and + Node), `createAssertionIssuer`, `generateAssertionKeys` (Node). The + format is specified in the protocol reference. +- Bring your own data channel — the level under `RtcLink`, the `event` + transport's arrangement for WebRTC: `connect(url, { transport: 'webrtc', + channel })` speaks on an `RTCDataChannel` the application negotiated + itself (a factory instead of a channel plugs the application's own + recovery into the client's reconnect cycle), `RtcPeerTransport` takes a + raw channel for a `PeerHost`, and `attachChannel(rpc, dc, { peer, + headers, data, maxMessageSize })` from the Node barrel is the + `attachPort` of WebRTC — an ordinary server, sessions and cluster + included, reachable peer to peer. Under it, `RpcServer.attach(transport)` + accepts any persistent transport that announces inbound traffic as + `'packet'`/`'chunk'` events; the core stays free of any framing. +- The lower layers, all exported: the W3C-shaped `RtcAdapter` port + (`createW3cAdapter`, structural checks — wrpc binds to no Node WebRTC + package), `RtcLink` (perfect negotiation, trickle ICE, ICE restart, + redial, configurable negotiated channel ids), the one-byte data-channel + framing (`FrameEncoder`/`FrameDecoder`, fragmentation to the negotiated + message size, documented in the protocol reference), `ClientRtcTransport` + (registered as `WrpcClient.transport.webrtc`), `RtcPeerTransport` and the + browser-safe `PeerHost` (`trust: 'link'` pseudo-sessions so + `access: 'session'` procedures run on a peer). +- Peer telemetry: `WrpcPeer({ telemetry })` / `PeerHost({ telemetry })` take + the server's injection — SERVER spans for what a peer answers, joined to + the calling peer's CLIENT spans through the packet's traceparent, + `wrpc.server.connections` under `wrpc.transport: 'webrtc'` — plus four + instruments of the peer layer's own: `wrpc.rtc.links`, `wrpc.rtc.redials`, + `wrpc.rtc.ice_restarts`, `wrpc.rtc.closes`. `RtcLink` emits `'restart'` + with the outcome. +- A link's goodbye says why: `{ type: 'close', reason }` on the wire — + `goodbye`, `refused` (a failed trust assertion, a `false` from `accept()`) + or `gave-up` (the redial budget) — and both `PeerLink`s (and the `RtcLink` + under them) emit `'close'` with a `{ reason, remote }` closure, kept on + `link.closure`; `abandoned` is a close this side never sent, `unknown` a + reason a newer peer named. `RtcLink.close(reason)` and `abandon(reason)` + take it, and `wrpc.rtc.closes` counts by `wrpc.rtc.reason` and + `wrpc.rtc.side` — a refusal, a give-up and a goodbye used to look the same + from the other end. +- The webrtc browser entry also exports `defineRouter`, `procedure`, + `tracked` and `createEventLog` — a browser peer defines its router with + them, and the main browser entry leaves them out for its byte budget. +- Cluster: `cluster.send(clientId, name, data, { room })` and + `RpcServer.sendTo()` / `Server.sendTo()` — one event to one client by id, + on this instance or through the cluster's addressed command, optionally + bounded by a room membership. +- Types: `rpc.d.ts`, the node-free server-core types (routers, sessions, + rooms, `Client`, `Context`) shared by the Node surface and the browser + peer types, and `ClientHost` — the host contract `context.server` is now + typed as (see Changed). + +### Changed (breaking) + +- **Breaking — binary attachments are on by default between two 2.x + ends.** A `Buffer`, typed array, `ArrayBuffer` or `DataView` anywhere + in a call's arguments, result, event data or error details now leaves as + the binary attachments frame (`0x00 01`, the Added entry above) and + arrives as a `Uint8Array` — not as `{ type: 'Buffer', data: [...] }` or + `{ "0": 137, ... }`, which is what `JSON.stringify` made of it in 1.0. + What that changes: (1) a handler or listener that read `.data` or + `Object.values()` off such a value reads a `Uint8Array` now; (2) a + declared REST route's result with bytes answers `501` unless `codec.rest` + is set. (SSE is unchanged: it speaks revision 1, bytes as 1.0's JSON.) It is + **not** a wire break for a 1.0 peer: the frame is protocol revision 2, + negotiated (the first Added entry), and a connection with a 1.0 client or + server carries the JSON 1.0 sent — so during an upgrade the same handler + sees a `Uint8Array` from a 2.x peer and the 1.0 object from a 1.0 one, + and `client.revision` (on either end) says which. `attachments: false` + makes an end speak revision 1 to everyone; a packet `codec` does the + same by itself. See [Migrating from 1.0](#migrating-from-10). +- **Breaking — the ws client no longer puts `headers`/`meta` in the connect + URL.** From Node they are real request headers on the upgrade (the built-in + `WebSocket` takes `{ protocols, headers }`); from a browser — where no API + can set a handshake header — they are `wrpc.h.` / `wrpc.m.` subprotocol + carrier tokens (the budget is measured on that base64url text, a third + longer than the JSON — about 1.5 KB of JSON for both bags; a JWT belongs + in `bearerAuth`, outside it). What the client could not send it says + only through its `logger`, off by default: `meta.oversize`, + `declared.unsendable`, `declared.exposed`, `handshake.fallback`. + The new `carrier` option (`'auto'` | `'protocol'` | + `'query'`) brings the query back for an intermediary that mangles + `Sec-WebSocket-Protocol`; `protocols: []` implies it. What changes for an + application: a **new browser client against a 1.0 server pays a second + handshake** (that server reads the query only, so the client, answered + `wrpc.v1`, dials again with it — the first Added entry; `carrier: + 'query'` up front saves the round trip); a Node client's declared `cookie`/`origin` now arrive, + being real headers no deny list applies to; `metaFormat: 'prefixed'` now + shapes the ws wire from Node too; the client caps the two tokens **together** + at 2048 bytes (the query was capped per parameter, against a server that + measured it whole); and an `authorization` that ends up in the query is a + `declared.exposed` warning. uWebSockets.js hosts: mind its 4096-byte limit + on all request headers — see the [uws adapter guide](./docs/guide/adapters/uws.md#request-header-limit). +- **Declared headers: the deny list grew.** `forwarded`, `via`, `x-real-ip`, + `x-client-ip`, `true-client-ip`, `cf-connecting-ip` and everything under + `x-forwarded-` are now dropped from a peer-declared header bag, next to + `cookie`/`host`/`origin` and the `sec-`/`content-`/`proxy-`/`x-wrpc-` + prefixes. A page could previously ADD `x-forwarded-for` to + `context.meta.headers` whenever no proxy had set one. `declaredHeaders` and + `RESERVED_DECLARED` moved from `src/rpc/meta.js` to the Node-only + `src/rpc/handshake.js` (neither was exported from the package). +- `Context.server` and `Client.server` are typed as `ClientHost | null` + instead of `RpcServer | null`: the contract both an `RpcServer` and a + WebRTC `PeerHost` satisfy (`router`, `rooms`, `getClient`, `to`, `except`, + `broadcast`). Narrow with `instanceof RpcServer` to reach sessions, the + cluster or `sendTo`. Runtime behaviour is unchanged; TypeScript code that + reached `context.server.sessions` without a check stops compiling. +- `ServerEventTransport` (the `attachPort` transport) now exposes + `connection`, so a MessagePort client is `persistent`: events, + subscriptions and streams work over it as over a socket. A server that + relied on a port client being request-scoped — one `Client` per call, no + `onConnect` — sees one long-lived `Client` per port instead. +- **Breaking — under `cluster.secret`, a signed envelope must be counted, + and a 1.x node's is not.** Every envelope a node signs now carries, inside + the signed bytes, its counter (`seq`), the channel it is published on + (`ch`) and its clock (`at`), and a receiver refuses one that repeats, sits + on another channel, is further than `maxSkew` (30 s, new option) from its + own clock, or has no counter at all — the Security entry below has the + why. A 1.x node signs but does not count, so **a 2.0 node ignores a 1.x + node** and a rolling upgrade of a cluster that uses `secret` splits in two + until it completes: set `cluster: { secret, replay: 'accept' }` on the 2.0 + nodes for its length. (The other direction is additive: a 1.x node + verifies the HMAC over the whole envelope, new fields included.) Clusters + without `secret` are unaffected, and so is `cluster.encryption`. The nodes + of a signed cluster now need clocks within `maxSkew` of each other. +- **`generateId` on `RpcServer`/`Server`, `WrpcClient` and `PeerHost` is + validated strictly.** A value that is not a function, or a function that + answers something other than a non-empty string of at most 255 + characters, is a `TypeError` at construction — as it has been on every + option added since 1.0 (`SseChannels`, the broker adapters, the + signaler). 1.0 ignored such a value silently; 1.x reported it through the + logger as `event: 'options.generateId'` and fell back to uuid v4, with + the note that 2.0 would throw. That log event is gone with the fallback. + +#### Migrating from 1.0 + +Clients and servers upgrade **in any order, with no flag**: the protocol +revision is negotiated, and a 2.x end speaks revision 1 to a 1.0 peer — +bytes as 1.0's JSON, labels by the carrier 1.0 reads, the SSE channel +reference as one opaque string. What is left to do: + +1. **Bytes during the upgrade**: a handler or listener that receives bytes + sees a `Uint8Array` from a 2.x peer and 1.0's `{ "0": … }` / + `{ type: 'Buffer', data }` object from a 1.0 one until the last peer is + upgraded; `context.client.revision` (server) and `client.revision` + (client) say which a connection is. +2. **Several instances behind one address, over HTTP**: an HTTP client keeps + what one answer told it, and a frame it then sends to a 1.0 instance is + refused — the client takes the hint, speaks revision 1 again and resends + those packets once as JSON, so nothing is lost, but each such call costs + a second round trip. To spare it while a 1.0 instance is still in the + pool (or to make a rollback to 1.0 free), deploy the 2.0 ones with + `attachments: false` — they then answer `wrpc-version: 1` and nobody is + sent a frame. Drop the flag once the pool is all 2.0. (A WebSocket is one + connection to one instance and needs nothing.) +3. **A page's `headers`/`meta` on a WebSocket** ride as subprotocol tokens, + which a 1.0 server does not read. A page that offered `wrpc.v2` and was + answered `wrpc.v1` dials once more with the connect-URL query 1.0 reads + (`handshake.requery`, and `declared.exposed` naming every header that is + now in the URL) — but a 2.x server deployed with `attachments: false` + answers `wrpc.v1` too, and is redialled for nothing, and a page that + itself sets `attachments: false` offers `wrpc.v1` alone and is never + redialled (`handshake.ambiguous`). Pages that must reach a 1.0 server set + `carrier: 'query'`; pages whose labels must stay out of URLs set + `carrier: 'protocol'`. +4. **A rooms backplane or a cluster** has no handshake to negotiate on. The + same `attachments: false` on the 2.0 instances keeps their envelopes + JSON while a 1.0 instance subscribes — it drops a `wrpc-bin:` envelope + without a word. And under **`cluster.secret`**: deploy 2.0 with + `cluster: { secret, replay: 'accept' }` while a 1.x node is still in the + cluster — without it the 2.0 nodes refuse the 1.x nodes' envelopes + (`cluster.replay`, reason `unsequenced`) and each half sees only itself. + Drop both once the last 1.x node is gone. +5. **TypeScript**: `context.server.sessions`, `.cluster` and `.sendTo` need + `if (context.server instanceof RpcServer)` (or a cast) — the type is the + `ClientHost` contract now. +6. **`@alexify/wrpc/ws` directly**: `WebsocketServer` logs through + `globalThis.console` by default (it had no logger); `logger: false` + restores the silence, `logger: pino` routes it. An `RpcServer` wired to + it by hand with `attachments: false` passes `protocols: ['wrpc.v1']`. +7. **`generateId`**: a bad value is a `TypeError` at construction now (it + was ignored in 1.0, logged in 1.x) — fix the option rather than catch + the error; a valid generator behaves exactly as before. + +### Changed +- **Docs: the project is presented as wRPC — Web RPC.** 2.0 serves one + router over WebSocket, HTTP and REST, SSE, WebTransport, WebRTC, a worker + port and a message broker, and the site, the README, `llms.txt`, the og + image and `package.json#description` still described a WebSocket RPC + library. The name in prose is now **wRPC**; the package, the `wrpc` CLI, + every wire name (`wrpc.v2`, `x-wrpc-*`) and the classes keep their + spelling. The home page has sixteen tiles — WebTransport, WebRTC, + brokers, encryption, compression, bytes as bytes and "upgrade in any + order" among them — and a "one router, every transport" table that a + guard keeps equal to the client guide's matrix. The why page compares + wRPC with tRPC, Socket.IO, gRPC/Connect, the Bytecode Alliance's wRPC (an + unrelated project of the same name) and webrpc, every cell sourced; the + guides that listed transports list all of them. +- **WebTransport and WebRTC are measured end to end.** `bench/transports.js`, + four new rows in `bench/rpc-comparison.js` and `pnpm bench:browser + transports` run the same Server and client over a real HTTP/3 session + (`@fails-components/webtransport`) and a real data channel + (`node-datachannel`, and Chrome's own), next to a raw echo over the same + stack and a WebSocket on the same machine — gated like the integration + tests (`WRPC_WT=fails`, `WRPC_RTC=node-datachannel`), skipped with the + reason otherwise. The numbers and how to read them are in + [Performance](./docs/guide/performance.md#across-transports). +- **The benchmarks are charted, and wRPC is measured against more + neighbours.** Every comparison on the performance page has a bar chart + before its table — wRPC in the brand green, the rest in gray, a metric + switch, best first — and the home page shows the RPC-framework one. The + charts read `docs/.vitepress/theme/benchmarks.json`, which a guard keeps + equal to the printed tables. `bench/rpc-comparison.js` gained a gRPC row + (`@grpc/grpc-js` unary over HTTP/2, the `.proto` loaded at runtime), and the + new `bench/http-comparison.js` loads wRPC's two HTTP paths next to fastify, + express, tRPC's standalone adapter and bare `node:http` with autocannon, as + fastify/benchmarks does. Over a WebSocket wRPC leads the frameworks with + calls in flight and at 10 KB; over plain HTTP it answers 0.9× fastify's + requests and 1.2× express's — said on the page, with why. +- **An HTTP request costs wRPC about half what it did.** Under + `bench/http-comparison.js`'s load the two HTTP paths answered ~35,000 + requests a second, 0.58× fastify; they now answer ~61,000, 0.9×. A profile + of that load put a fifth of the server's CPU in `Client#destroy`: the + HTTP transport closes as its answer is written, the dispatcher released the + call only after, so every request built an `Error('Client disconnected')` + — a captured stack — to abort the call that had just answered. A call is + now released the moment its answer is written, and the reason is built + only when something is still in flight. The rest, each measured: a + packet-mode body is parsed once, not twice; `receiveBody` listens on the + request instead of `for await` (node's own stream destroyer still answers + an oversized body with its 400); `meta.headers` is a fast-mode + null-prototype copy where a `__proto__: null` literal made a dictionary; + the per-client close listener is `on` and a flag, not `once`; a declared + route's path skips the per-segment decode when nothing is escaped, an + absent query string skips `URLSearchParams`, and a body is handed to + `JSON.parse` as text. Each cites `bench/http-call.js`, new: it drives + `handleHttpCall` with a stub where the socket would be, and the core went + from 85K to ~320K requests a second on it. `bench/http-headers.js`, also + new, prices the default headers at about a tenth of `node:http`'s own + ceiling — they stay on. One thing a handler can see: on HTTP, `ctx.signal` + is no longer aborted once the call has answered — as on a WebSocket; a + request that goes away before its answer still aborts it. +- **What the AMQP adapter reads is checked against amqplib.** Every member + the adapter reads off a channel or a connection must exist on amqplib's + own `ConfirmChannel` / `ChannelModel` (a devDependency), not only on the + suite's fake — which had a `closed` amqplib has none of, and hid a + retry that published three copies on a dead channel. +- **The interop suite fails in CI when the published 1.0 is missing.** + Without the `wrpc-v1` alias every interop case skipped — the one that + guards the alias included — and the run passed green. Under + `WRPC_INTEGRATION_STRICT` (now set on CI's `test` job, as on the broker + jobs) a missing alias is a failure; a local run without it still skips. + The matrix gained this review's cases: SSE with bytes both ways, a page + against a 2.x server without frames and a page that offers `wrpc.v1` + alone, a worker port under a packet codec, and 2.x and 1.0 instances + behind one HTTP address. +- **Connections are counted by the protocol revision they speak.** + `wrpc.server.connections` carries `wrpc.revision` (`1` or `2`) beside + `wrpc.transport`, so who is still on 1.0 during an upgrade is the `1` + series rather than a guess — a worker port or a WebTransport session whose + revision is settled after it opened moves from one series to the other — + and each connection that settles on revision 1 is a `revision.peer` debug + line. Every change of a server transport's revision goes through + `ServerTransport#setRevision`, which emits `'revision'`; an HTTP request's + revision is settled before its client exists. `rpc.revision` and + `context.client.revision` are in the server and rooms guides. +- **A WebRTC message larger than `maxBackpressure` goes on an empty + channel.** The cap counted the message itself on top of what was queued, + so one message past it closed a channel that held nothing — the comment + over the check promised the opposite, and WebTransport and the WebSocket + engine let it through. Only what is already queued counts now. +- **A WebTransport session cut off for not reading is a warn line.** Past + `maxBackpressure` the session was terminated with only `socket.error` and + `wt.close` at debug, where the WebSocket engine says `ws.backpressure` at + warn. It is `wt.backpressure` now (`buffered`, `max`), at warn. +- **Bytes as deep as a direct send carries them cross the cluster as bytes.** + A backplane envelope wraps the packet, or a question's `args`, up to two + levels deeper than a packet holds its data, and the frame's depth limit + was counted from the envelope: bytes 31 levels deep in a node-to-node + question or answer — which a direct send delivers — reached the other + node as `{"0":…}`. A backplane envelope's frame reaches two levels + further (`ENVELOPE_DEPTH`), encoding, decoding and the HMAC's re-encoding + alike. +- **A cluster node isolated by its clock is named.** A node whose clock + sat outside `maxSkew` had every envelope refused as `stale` while + `healthy` stayed true and `fetchClients` came back complete without it — + one `cluster.replay` line a `presenceTimeout`, with no measure of the + skew. The line carries `skew` (ms) now, and three refusals in a row emit + `'degraded'` with `{ instance, reason: 'skew', skew }` — `'recovered'` + with `{ instance }` once it is heard again. +- **What `cluster: { replay: 'accept' }` lets through is counted and said.** + The option exists for a rolling upgrade from 1.x and should be dropped + once the last 1.x node is gone — but the envelopes it accepted were + neither counted nor logged, so nothing showed when that was. They are + `wrpc.cluster.verifications` with outcome `unsequenced`, and a + `cluster.unsequenced` line at info once per sender a `presenceTimeout`. +- **A refused backplane or cluster envelope is a warn once, then debug.** + `cluster.unsigned`, `cluster.badsig`, `cluster.encoded` and + `cluster.sealed` were a warn per message — fifty unsigned envelopes, + fifty warns — and so were `*.unsealed` and `*.open` of the sealing + envelope on the backplane and the cluster. They are a warn once per + sender and reason a `presenceTimeout` (as `cluster.replay` was), and once + per reason and channel in ten seconds on the envelope, debug in between; + the memory behind them is capped, as an unsigned envelope names whatever + sender it likes. +- **`subscribe.refused` clips the peer's id.** The line carried the + subscription's `id` as the peer sent it — 100 000 characters gave a + 100 048-character warn. Ids are bounded at 255 now (the packet rule), and + this line clips it to 128 like every other peer-supplied text in a log. +- **`revision.mismatch` is said at warn once, then at debug.** It is a + configuration error — a hand-composed engine selecting `wrpc.v2` for a + server that reads no frames — and the same on every connection, so it was + a warn per connection. +- **The logging guide catalogues every `event` wrpc writes, and a test keeps + it so.** `docs/guide/logging.md` promised "every line an operator would + want an alert for" and listed about a hundred of some 240 names: none of + the broker adapters' lines (`broker..*` — the ones that say a + consumer stopped consuming), most of `rtc.*`, the rooms, backplane and + cluster failures, the SSE channel lines, the client's escalated errors. + They are all there now, by component, with their level and what they + mean. A guard in `tests/package/consistency.test.js` reads the event + names out of `src/` and fails on one with no row — and on a row whose + event is gone — so a log line cannot be added or renamed without its + documentation. `docs/guide/telemetry.md` lost four wrong statements with + it: a heartbeat timeout records **nothing** on `wrpc.client.heartbeat.rtt` + (it said "counted as `timeout`"; the signal is the `heartbeat.timeout` + line and an `attempted` reconnect), `wrpc.server.sessions` has six + operations and the gauges are seven (both said five), and + `wrpc.transport` also takes `wt` and `broker`. +- **`RpcServer`: one `#identify` where five entry points each spelled out + who a request is (internal).** An SSE channel's GET, a packet POST, a + REST call, a host-delegated route and `attach({ request })` each built + the declared data, the session-restoring thunk and the client's meta by + hand, and two of them repeated the safe-method CSRF rule. They share one + private method now, and the rule lives only there. No behaviour change. + Two things caught up alongside: `RpcServer.attach({ meta })` normalizes + the meta it is given like every other entry point's — frozen, `headers` a + null-prototype copy, the rest defaulted — so `{ headers }` written by + hand is enough (typed `Partial`; a non-object is a + `TypeError`), and `RpcServer#limits` is typed with the `compression` and + `attachments` fields it always returned (`NormalizedCompression`). +- **Broker adapters share one leaf instead of four copies (internal).** + `src/broker/adapter.js` now holds what Redis, NATS, RabbitMQ and Kafka + each spelled out themselves: the header names a redelivery carries, the + id factory, a refused `log.read` and a position-checked one (three + variants of the same wrapper, one of which differed only by accident), + the checks `consume` and `listen` open with, and what happens to a + delivery whose handler threw. The delivery object itself stays per + adapter — how a message is settled is where the brokers really differ. + No behaviour change: the four contract suites and the live brokers run + the same before and after; about 220 lines fewer. The RPC binding's two + ends likewise read and build a session frame through one `frameBody` and + one `sessionFrame` in `src/broker/rpc/frames.js`, where each had its own + (`bench/broker.js`, the rpc session rows, level before and after). And a + type caught up with its runtime: `ClientBrokerTransport.write(data, + options?)` takes the `{ compress: false }` it always honoured. +- **WebRTC: `PeerLink` has a file of its own, and `WrpcPeer`'s internal + methods are private (internal).** `src/webrtc/peer.js` held three state + machines; `PeerLink` — the link's life, the redial and knock cycle — is + `src/webrtc/peerLink.js` now, re-exported from where it was. It and + `Mesh` used to be handed the whole peer and call public methods marked + `@internal` — `signal`, `stamp`, `verifyDescription`, `released`, `held` + — which were never in the types and could be called all the same, to + desynchronize it. They are `#private`, and what a link or a mesh needs + is a narrow port the peer builds over them. `escalate` stays public. One + `deferred()` (`src/webrtc/ids.js`) instead of two. No change of + behaviour: the WebRTC suites pass with one test rewritten, the one that + called `peer.signal()` directly. +- **WebRTC fan-out: the message is prepared once for every link.** + `Mesh.broadcast` — `PeerHost.to(room).emit` — serialized its payload once + and then did everything else per link: an emit to 32 peers was 32 UTF-8 + encodes of the same string and, under compression, 32 deflates of the + same bytes. A data channel cannot share a frame (each link has its own + message size), but everything before the fragments is common: + `RtcPeerTransport.writeShared` + `ChannelCodec.sendShared` keep the UTF-8 + and one compressed body per codec id in the shared message, and only + fragment per link. `bench/rtc-fanout.js` (new), per recipient over 32 + links: 16 KB compressed 37.7 → 2.2 µs, 16 KB plain 10.3 → 1.7, 512 B + 0.51 → 0.14 plain and 9.1 → 0.44 compressed; an emit to exactly one link + pays 0.2 µs for it. An asynchronous codec is awaited once and each link + keeps its own send order around it; a codec that throws is asked once per + emit and the message goes plain; a slot a WebSocket engine filled (a room + mixing both) is left alone. The webrtc browser entry's budget is 58 KB + (was 57), for this and for the mesh's re-linking. +- **WebTransport: one channel core for both ends (internal).** `WtSocket` + (the server's socket) and `ClientWtTransport` were mirror copies of the + same channel — the capabilities exchange, compression and its ordering, + the stream mux wiring, datagrams, the byte accounting, the graceful + close — and every change of it was two edits that could disagree. That + part is `src/webtransport/channel.js` now (browser-safe: callbacks, no + `node:events`); the two classes are adapters that keep what is theirs — + events, `pause()`, the idle timer, the log and the close codes on the + server; the open lifecycle, the wire codec and the encryption seam on the + client. No change of behaviour beyond the fix above: the suites of both + ends, the shared channel contract included, pass unchanged, and a + 200-byte packet costs what it did through a socket (570 ns to send, + 295 ns to receive, before and after). The main browser entry is 0.5 KB + larger for it (27,424 B of its 27 KB budget): the core carries what only + the server uses. +- **WebTransport stream mux: measured, and one decode removed.** The new + `bench/wt-streams.js` times the per-chunk paths `bench/wt-framing.js` + stops short of. An outbound chunk decoded its stream id twice — once to + find the stream, once more only for the payload offset, which is one + byte read: `StreamMux.chunk()` is 13 % faster (867K → 980K ops/sec at + 1 KiB). Two further candidates were measured and left alone, with the + numbers in the bench file's header: re-framing an inbound read from a + header cached per stream (13 % of the re-frame at 1 KiB, 7 % at 16 KiB — + the payload copy is the cost) and an idle timer that keeps a timestamp + instead of re-arming (55 ns a read, behind an option that is off by + default). +- **`@alexify/wrpc/deflate`: a codec prepares its dictionary once.** The + pure-JS encoder allocated its window and 128 KB of hash heads on every + call and hashed the whole dictionary again each time, so a 108 B event + cost 11 µs — most of it the fill — and a 2 KB callback 32 µs against a + 4 KB dictionary and 116 µs against a 32 KiB one: the codec that exists + for small frequent messages on a browser's main thread paid for the + dictionary per message. `createDeflateCodec` now keeps the window, the + dictionary's chains and a scratch region, built on its first message; a + call copies the message in, runs the same pass, and the next call first + puts back the hash slots the last one wrote. Same bytes — a thousand + messages through one codec are each compared with a fresh `deflateRaw`, + at three levels, with and without a dictionary. `bench/deflate-js.js`, + the new codec rows: the event 10.9 → 1.4 µs, the callback 32 → 13 and + 116 → 13 µs; a message over 8 KiB takes the one-shot path as before. + The public `deflateRaw` is unchanged. One codec holds about 0.2 MB plus + five bytes per dictionary byte: one per page or process, not per + connection. The entry is 4.4 KB (budget 5). +- **Broker RPC sessions and feed tails say when a broker loses frames or a + reader cannot keep up.** Why a session ended was in the log and nowhere + on a graph: `wrpc.broker.rpc.session.ends` counts every end by a closed + `wrpc.broker.reason` (`gap`, `peer_gap`, `undecodable`, `send_failed`, + `idle`, `replaced`, `bye`, `closing`) — the sequence numbers of a gap stay + in the log line and the peer's `wrpc-reason`. A frame lost on the + SERVER's way out is one only the client can see: its goodbye now carries + `wrpc-reason: gap`, and the server ends the session as `peer_gap`, at + warn, where it used to read as a routine `bye`. And a `TopicTails` reader + that falls `highWaterMark` entries behind had its buffer dropped and went + to `range()` without a sign: `onLag(topic, readers)` hears of it — once + per fall, nothing while everybody keeps up — and the four adapters log it + as `broker.tail.lag` (info). +- **Docs: a heading with a badge is announced as its text.** A section + heading carrying a `since 2.0` badge and an explicit anchor had a + permalink whose `aria-label` was the heading's raw source — a screen + reader read out ` {#webrtc}`. The label is now + `Permalink to "WebRTC (since 2.0)"`. +- **The examples are linted, formatted and checked against the package.** + `examples/` — what a reader copies first — was outside lint and format, + and nothing ran it, while the entries it requires (`webrtc.js`, `wt.js`, + `auth.js`) were reshaped on the way to 2.0. Its scripts are lint and + format targets now, and `tests/examples.test.js` compiles each one and + checks every name it takes from a root entry against what that entry + exports: a rename no longer leaves an example that throws on its first + line. +- **The Redis session stores are tested against a real Redis.** + `createRedisSessionStore` and `sealedStore` over it were only ever run on + in-repo fakes, which accept an expiring `SET`, `PEXPIRE` and `SET … XX` + whatever they are handed. `tests/scaling/redis.integration.test.js` (CI's + `redis` job) now checks them on the server: the row's TTL and its slide + on `touch`, an update that does not bring back a deleted session, that + neither the token nor the state rests in Redis under `sealedStore`, and + that a key rotation moves the row — one per token, still expiring — and + signs nobody out. +- **Coverage is held per file where the code is new.** The global + thresholds are an average, and a new adapter at 80% branches hid behind + three hundred well-covered files. `pnpm test:coverage:floors` + (`scripts/coverage-floors.js`, a step of CI's `test` job) checks every + file of the six directories new in 2.0 against its directory's floor — + set just under today's weakest file, with one named exception — and a + floor only ever moves up. +- **Tests wait for what they assert, and watch as long as a real broker + needs.** The suites had seventeen copies of `waitFor` in three signatures, + most of them a count of polls — which stretches under load and still fails + a slow machine; they are one deadline-based helper now + (`tests/helpers/wait.js`, 5 s or `WRPC_TEST_TIMEOUT`). Three positive + assertions that slept a fixed time first wait for their event instead. + And the queue contract's "nothing more arrives" windows scale with the + harness: against a real broker an acked message was checked for 50 ms — + shorter than any redelivery — and is now watched for the broker's own + redelivery time (`ackWindow`), so a lost acknowledgement fails the live + suite instead of passing it. (Negative control: with the ack dropped and a + 400 ms redelivery, the 50 ms window passes and the scaled one fails.) +- **CI's test run cannot hang on a leak.** `pnpm test:ci` is + `test:coverage` with `--test-timeout=300000 --test-force-exit`, and it is + what the `test` job runs: a suite that leaves a listener or a timer behind + now fails in minutes with a summary, where it used to sit until the job's + own timeout. `pnpm test` and `test:coverage` are unchanged, so a leak stays + visible locally. The Kafka suite registers every broker's `close()` right + after it is opened, and the live Kafka suite warms the broker — one + throwaway group join and round trip, tried twice — before its first + contract case, so a broker that has just come up no longer fails the run. +- **A durable feed opens and signs an entry once, not once per + subscriber.** `TopicTails` made a topic one broker read for all its local + subscribers, and each of them then opened the sealed entry and signed its + resume token again — the same bytes, N times. `brokerFeed` now remembers + both per entry: the opening by the entry the live tail shares, the token + in a small FIFO per feed. `bench/feed-fanout.js` (new), 1000 subscribers, + per subscriber-entry: with `secret` 2.34 → 0.84 µs, with `encryption` + 4.67 → 0.71 µs at 0.2 KB and 5.71 → 0.74 µs at 2 KB, with both 6.69 → + 0.91 µs — against 0.7 µs for a feed with neither. `decode` is still each + subscriber's own (a decoded object is never shared between connections; + the bench's `json` rows are that cost), and so is a catch-up page. One + visible change: an entry that does not open is logged and counted + (`broker.feed.refused`, `wrpc.broker.refused`) once, by the subscriber + that opened it, where every subscriber of the topic used to report it. +- **`attachBrokerRpc().healthy` knows when its listeners are deaf.** It was + `true` until `stop()`, whatever happened to the broker — an instance whose + service consumer had been cancelled stayed in rotation, answering nothing. + The function `direct.listen()` resolves with may now carry a `healthy` + getter (`DirectStop`, optional — the structural check is unchanged and a + custom broker without it reads as healthy), and the binding's `healthy` is + `false` while either of its listeners says so. Redis: the service's + `BLPOP` or its presence lease is failing, or the connection reports + itself down — and back to `true` when they work again. NATS: the + subscription reported an error, or the connection closed. RabbitMQ: the + consumer was cancelled, its channel closed, or the connection is lost. + The last two do not recover by themselves: an unhealthy binding there is + the signal to restart the instance. +- **A compression codec that fails is said, and counted.** A message is + never lost to compression — a codec that throws sends it plain, a frame + that does not inflate is refused and answered — which is exactly why a + dictionary that stopped matching, or a codec out of memory, showed up + nowhere but in the traffic. Every such failure is now counted + (`wrpc.compression.failures`, by `wrpc.compression.carrier` and + `wrpc.compression.direction`) and logged as `compression.failed` once per + carrier, direction and codec: the three encode paths of an HTTP answer, + an SSE encoder stream (which used to end the response without a word), + a WebTransport session, the rooms and cluster envelopes, the broker RPC + binding. A socket frame that does not inflate carries its `codec` and the + `code` in `frame.refused` — `ERR_BUFFER_TOO_LARGE` is `maxMessage`, + anything else the bytes. `encodeIfSmaller`/`decodeOrNull` take an error + receiver; `RpcServer#compressionFailed` is the seam the attachers report + through, `WtSocket` takes `onCodecError`. A WebRTC data channel stays + silent on purpose (per-frame, and in a browser bundle); nothing is added + per message — the counter is touched on the failure path alone. +- **WebTransport: an unreliable datagram the session has not kept up with + is dropped, not queued.** `WtSocket.sendUnreliable` wrote to the + session's datagram sink without counting what it had not taken yet. On + the two hosts wrpc runs against that sink is synchronous and nothing + accumulates; on a W3C-shaped host whose sink holds its promise under + congestion, a room emitting positions `{ unreliable: true }` would have + queued stale ones without bound — the opposite of the option's promise. + Past 64 datagrams in flight a further one is dropped and still answered + `true` (false would re-send it on the control stream, behind the same + congestion), `wt.datagram.dropped` is logged once per session and the + total rides the session's `wt.close` line; `droppedDatagrams` reads it. + The protocol reference says a sender SHOULD drop rather than queue. +- **WebTransport: `close()` delivers what was sent before it.** A session + close resets every stream, and both ends closed the session at once: the + last callback after `drain()`, the event sent before a kick and a + client's `flush()` were lost on `wt` and delivered on `ws`, so a + `['wt', 'ws']` fallback list was not invisible. A graceful `close()` on + either end now closes the control stream first — which resolves once its + queue was taken — and the session after it with the code, bounded by the + new `closeTimeout` (1000 ms; `attachSession`, `WtSocket`, the client's + `wt` options). `'close'` still fires synchronously on the closing end. + The end that reads the stream's end gives the session close 200 ms to + arrive, so the code it reports is the closer's rather than its own 1000. + `terminate()` is immediate, as before. +- **ws engine: `close()` delivers what `send()` accepted, with or without + context takeover.** A message whose deflate runs off the loop (context + takeover, or `async` past its threshold) waits in an outbound queue with + everything sent behind it, and `close()` dropped that queue before + writing its Close frame — so `send(x); close()` delivered `x` on a plain + connection and silently lost it on one with takeover: the answer of the + call that ends a session, the event that says why a client is being + kicked. The Close frame now takes the queue's tail; nothing new is + accepted meanwhile, and `closeTimeout` bounds the whole close — a queue + that has not drained by then is dropped with one `ws.close.dropped` line + (`frames`, `bytes`) and the Close goes out. `terminate()`, a close for a + protocol violation or a limit, and a Close the peer sent first drop the + queue at once, as before. A behaviour of the stable `./ws` engine, hence + under Changed: code that relied on `close()` discarding queued writes + wants `terminate()`. +- **A sealed fan-out builds its plaintext once.** Under session encryption + every recipient of a broadcast has its own key, so the frame cannot be + shared — but the plaintext inner frame can: the kind byte and the text's + UTF-8 were rebuilt from the same string for every sealed recipient. + `SealedSocket.sendPrepared` now builds it once per emit, on the shared + message, and seals it per recipient (`SecureChannel.sealInner`); each + recipient still gets its own nonce and ciphertext. Per recipient, in + `bench/encryption.js`: 2.76 → 2.68 µs at 64 B, 3.61 → 3.22 at 1 KB + (−11 %), 14.5 → 9.9 at 16 KB (−32 %) — the saving is the copy, so it grows + with the message; 10 000 recipients × 16 KB is ≈ 99 ms an emit where it + was ≈ 145. A broadcast held for a client still in its handshake is sealed + from the text when the handshake completes, as before. +- **Tests: the broker adapters' recovery branches run against faults the + fakes now inject.** A JetStream consume that rejects (`failConsume` on the + NATS fake), a Redis server refusing `XREADGROUP` and `BLPOP` for a moment + (the fake's `fail` hook), a RabbitMQ `basic.cancel` (`cancelConsumer` on + the AMQP fake) — the branches where `healthy` goes false and comes back, + and the lines that say so, are covered where they used to be reachable + only against a live broker. The Kafka fake's comment on a handler that + throws says what the real clients do and what the fake does. +- **Broker bindings log through the server's writer, and three events are + named by their family.** `attachConsumers` and `attachBrokerRpc` defaulted + their logger to the console, so a pino-configured server saw a dead + letter on the raw console without `event`, `queue` or `id`; they use + `rpc.log` now (`logger: false` still silences them). `feed.decode`, + `feed.refused` and `channel.error` are `broker.feed.decode`, + `broker.feed.refused` and `rtc.channel.error` — the prefix every other + line of their family carries — before any of them is released. +- **README's Exports table is whole again, the size tables are current, and + the headlines quote the budget.** Two Exports rows (`wrpcFastify`/ + `findUwsApp`, `createWrpc`) had been overwritten with size numbers; the + size tables in README and the browser guide are regenerated from + `pnpm size` with the `deflate`, `encryption` and broker rows added; the + headline sizes (README, the docs home, `llms.txt`, the performance and + WebTransport guides) quote the budget CI enforces rather than a number + that drifts, and the guides link the measured table. A package test holds + the tables' budget column to `scripts/size.js`. +- **`http.compression.async` is validated by the one normalizer every + `async` knob shares.** `{ threshold: '64kb' }`, `{ threshold: 0 }` and + `async: []` landed silently on the 256 KiB default; they are TypeErrors + now, as on the socket codecs. The permessage-deflate `async` stays + lenient on purpose (the `./ws` engine must not import the negotiation + module) and the compression guide says so. The pure-JS deflate directory + keeps one `toBytes`. +- **CI's NATS job runs JetStream, a missing client fails a service job + instead of skipping it, and the images are pinned.** A GitHub service + container cannot be given a command, so CI's NATS ran without `-js` and + the log, queue and feed suites skipped — green and untested; the job + starts `compose.yaml`'s service now, the one place the flags live. + `WRPC_INTEGRATION_STRICT=1` (set in the four service jobs) turns "client + not installed" from a skip into a failure. Images are pinned to a minor + in CI and compose alike (Redis 7.4, NATS 2.11, RabbitMQ 4.1, Kafka 3.9.1), + the workflow declares `permissions: contents: read`, and the lint job + has a timeout. +- **SECURITY.md knows about the cryptography and the new parsers, and + says which line is supported.** The scope names `src/encryption/` (nonce + reuse, cross-layer opening, distinguishable `OpenError`s, downgrade, + replay), the parsers of hostile bytes beyond the WebSocket engine + (WebTransport framing, the pure-JS inflater, attachments frames, WebRTC + framing and trust assertions, broker resume tokens and sealed messages), + and says `@experimental` subpaths are in scope; a "documented limits" + paragraph points at what the encryption does not protect. The version + table says `2.x` is supported and `1.x` is not (no maintenance branch), + and a package test guards the table and the scope list. +- **The types and the runtime are checked against each other both ways, on + every subpath.** The package test compared six barrels one way (a runtime + export the types never declared); it derives every subpath under both + conditions from `package.json#exports` now and checks both directions — + and found two: `ClientWtTransport` was declared as an exported class the + barrels never export (it is a type now, reached through + `WrpcClient.transport.wt`), and `@alexify/wrpc/deflate` exported an + `isPromise` helper its types never named (gone). +- **The server half of WebTransport logs a session's death.** After + `attachSession` nothing was written: a refused handshake, a frame that + would not read, a silent peer, a session the host failed — none of them a + line. Every session has a `wt` child logger bound to its peer now + (`attachSession`'s `logger`, the server's by default, what + `acceptSessions` hands down): `wt.refused` (403/408), `wt.violation` + (`code`), `wt.idle`, `wt.session.error`, `wt.mux.refused`, a + once-per-session `wt.mux.fallback`, and a debug `wt.close` with the code + and the peer's reason clipped. +- **A broker RPC session's end is logged at the level its reason + deserves, and a frame for a lost session leaves a debug line.** Every + `broker.rpc.session.end` was debug, so a broker losing frames (a sequence + gap, an undecodable frame) was as quiet as a goodbye; those are warn now, + an idle client info, the routine ends stay debug. A frame for a session + this instance does not hold answers the same bye it always did and says so + as `broker.rpc.session.unknown` — at debug, because any participant can + send those in a loop. +- **A key provider that throws is an error line with the error, under + its own event — not a refusal without a reason.** On the rooms backplane, + the cluster channel, a broker feed or consumer and the sealed session + store, a provider that threw while a message was being opened used to + produce a warn without `reason` or `err` (a "vault unreachable" looked + like a peer's bad envelope). It is `backplane.keys`, `cluster.keys`, + `session.keys`, and `broker.refused`/`feed.refused` with reason `keys`, + at error with `err`; a refusal's `*.open` line carries the `kid` when it + is one; and a plaintext session row adopted under `acceptPlaintext` is a + `session.adopt` info line — what to count down before turning it off. +- **A sealed request the server refused is a typed client error, with the + status the calls can act on.** The sealed `fetch` threw "answered in + plaintext (N)" and every call in the request failed `503`, so a device + with a wrong clock (`409`), a retired pinned key (`400`) and a server that + now requires encryption (`426`) all looked like an outage. The client + emits `EncryptionRefusedError` (`code: 'ENCRYPTION_REFUSED'`, `status`, a + message that says what to check) and the calls fail with that status — + for that closed set only, since the outer status is unauthenticated; + anything else stays `503`. On SSE a sealed-layer `409` never closes the + channel (that is the plain transport's "channel gone"), and a stale open + rejects with the typed error instead of reconnecting forever. The errors + reference lists the sealed statuses and the `encryption` close reasons. +- **A refused session handshake says who, why, and at the level it + deserves — and is counted.** `encryption.refused` on a socket carries the + `peer` (a child binding), a `kid` or `protocol` only when it is shaped + like one (else its length — peer text stays out of the line), and a + failure on the server's side — a key provider or the `authorize` hook + that threw — is an error line with `err` under its own reason (`keys`, + `hook`) rather than a peer's `handshake` at debug. A new counter, + `wrpc.server.encryption`, counts handshakes established and refusals by + `wrpc.kind` (`ws`, `wt`, `http`) and `wrpc.outcome`. +- **Trace context on a broker message rides as `traceparent`/`tracestate`.** + The publisher wrote the packet's field names (`tp`/`ts`) into the message + headers, which no consumer that is not wrpc reads; it writes the W3C + header names now, and the consumer reads them first (`tp`/`ts` still + read, for a message an earlier publisher left in a topic). The packet + fields on the wire are unchanged. The telemetry guide also stops promising + B3 or Jaeger propagators: the fields carry the W3C keys only. +- **`wrpc.server.backplane.gaps` is labelled by `wrpc.channel.kind`, not + by the room's name.** The counter carried `wrpc.channel` — a room name, a + peer-chosen and unbounded value, which is a cardinality bomb in any + metrics backend. It carries `wrpc.channel.kind` (`room` or `broadcast`) + now; the exact channel and the count stay on the `backplane.gap` log line. + (The counter is new in this release, so no dashboard breaks.) +- **Tests: one log recorder that keeps a child's bindings, one metrics rig.** + `tests/helpers/recorder.js` answers `child()` with a writer over the same + entries with the bindings merged in — the copies it replaces answered + `this`, so a line's `component` or `peer` never reached an assertion; + `tests/helpers/metrics.js` is the in-memory OpenTelemetry rig the WebRTC + telemetry test carried. The suites the observability work touches next use + them; the rest migrate as they are touched. +- **The bench rows and the guide's numbers price what actually runs.** The + sealed fan-out row measured the bare AEAD per recipient (`bench/encryption.js`) + and the guide quoted it: 10 000 × 1 KB "≈ 25 ms". A recipient's + `SecureChannel.seal(text)` — the inner frame, the counter nonce, the + header — is 36 ms; the bench has that row now, a 64 KB binary chunk (27 + µs) and the NK handshake (0.4 ms, the default pattern — the guide said + NN's 0.3), and the guide says so. A mesh broadcast is "serialized once", + not "single-encode": utf8, compression and fragmentation run per link + (`mesh.js`, the WebRTC guide, `scripts/size.js`). The pure-JS deflate + encoder re-hashes its dictionary on every call, and its comment and + `bench/deflate-js.js` (4 KB and 32 KiB dictionary rows: 34 and 138 µs on + a 2 KB callback) say so. +- **The broker feed's resume, gap and snapshot scenarios run on the Redis + adapter, not only on the reference broker.** They live in one spec now + (`tests/broker/feedSpec.js`, the shape of the adapter spec), replayed over + the `MemoryBroker` and the fake Redis server — the reference and an + adapter can no longer drift apart unnoticed on what a feed promises. +- **A raw channel's `maxMessageSize` is validated where it is given.** + `ClientRtcTransport`, `RtcPeerTransport` and `attachChannel` refuse a + value that is not an integer between 1024 and 262144 — negotiation's own + floor and ceiling — with a TypeError at construction (and at `open()`), + rather than a framing error at the first write or a scratch buffer of + whatever size was typed. (SEC-3; WebRTC ships stable, so the TypeError + lands before the release.) +- **The compiled-serializer fast path does not walk a result for bytes + where no attachments frame could leave.** Under `attachments: false` (or + a packet codec) the dispatcher used to run `hasBytes` over every result + before its compiled serializer — a walk that costs about what the + envelope surgery saves on a large result (`bench/serialize-callback.js` + gained the row). `Client.attachments` says whether bytes would leave as a + frame on that connection; the walk runs only when they would. +- **`rekeyAfter` is a per-deployment constant, and said to be one.** The + rekey interval of a Noise session travels in no handshake message, so a + client and a server that disagree fail at the first rekey — exactly that + many messages in, as a plain decrypt error. Both ends default to the same + 2^20 out of one module (asserted), the option's JSDoc on both ends, the + guide and the protocol reference say it is not negotiated, and the + client's `encryption` object exposes its `rekeyAfter` read-only so a + deployment can compare the two. + +- `@alexify/wrpc/encryption` is `@experimental` **whole**: the subpath's + types, every `encryption` option it feeds (the server's and the client's, + `rooms`/`cluster`, the broker bindings, `sealedStore`), + `Client.encryption`, `encryptionKey()`/`encryptionRequired`, + `attach({ encrypted })`, and the session-encryption, sealed-request and + broker-sealing wire formats — the fifth carve-out next to telemetry, the + engine port, WebTransport and the brokers + ([Stability](./docs/reference/stability.md#experimental-carve-outs)). The + formats may change in a minor until they have been reviewed against real + deployments; `consistency.test.js` keeps the stability page, CONTRIBUTING + and `llms.txt` in step with `exports`. +- `http.compression` / `sse.compression`: `level` and `memLevel` moved into + the coding they tune — `encodings: [{ encoding: 'gzip', level, memLevel }]` + — and are a `TypeError` at the top level (nothing of this was released). +- Wire names that said "deflate" while carrying any codec are neutral + (nothing of this was released): the capabilities key `deflate` → `enc` + (WebTransport, WebRTC `caps`), `KIND_TEXT_DEFLATE` / `KIND_BINARY_DEFLATE` + → `KIND_TEXT_COMPRESSED` / `KIND_BINARY_COMPRESSED` and + `StreamParser.deflate` → `.compressed` in `@alexify/wrpc/wt`, the WebRTC + header's DEFLATE bit → COMPRESSED. Byte values are unchanged. +- `@alexify/wrpc/deflate` is 3.8 KB min+gzip (was 4.5): it takes the + dictionary id from a leaf instead of the whole negotiation; its budget + goes 5 → 4. The main browser entry's goes 23 → 24 for the codec list + (+0.4 KB, measured 23.1), sse 24 → 25. +- The platform deflate codec (`compression: true` on WebTransport, WebRTC, + the broker binding, the backplane envelopes and a Node WebSocket client) + compresses at zlib level **3** instead of 6: the knee of the curve — at + 27 KB 46 µs for 3,351 B against 118 µs for 3,196 B, and the same bytes + under ~400 B (`bench/algorithms.js`). `deflateCompressor({ level: 6 })` + is the old behaviour. +- `ClientEventTransport.getInstance` is deprecated. It still returns the + class-level singleton it always did, but `connect({ worker })` no longer + uses it — construct one with `new WrpcClient.transport.event(url)`. + `WrpcClient.transport.event` is now typed as that constructor. + +### Fixed +- **A cancel that arrives while `onResponse` hooks run no longer answers the + call a second time.** The call stayed registered until its hooks settled, + so a `cancel` in that window aborted it and sent a 499 after the result + had gone out; an answered call is now released as its answer is written. + And an empty packet-mode POST is a malformed packet — one + `packet.malformed` warning and the id-less 500 — where it used to throw + inside the router and be logged at error level as `http.failed`. +- **A connection refused by `authenticate` no longer resets the reconnect + backoff.** A hook that throws synchronously runs inside the transport's + `'open'` emit, where the ws transport is still settling its open, so the + `terminate()` the failure forces can only start a close handshake — and + the `'close'` comes when the server answers it. If that took longer than + `reconnect.stableAfter` (which defaults to `minDelay`), the stability + window counted the refused connection as proven and zeroed the attempt + count: the backoff restarted at `minDelay` (`10, 20, 10, 20, 40` on a + slow CI runner), and with a close slow enough every time it never grew + and `retries` never exhausted. A connection that fails its credential — + or its restore — now stops the stability window before it reports the + close; a test drives a transport whose forced close always arrives late. +- **A finished `WrpcWritable` lets go of its transport, and a server's + downloads no longer lock a connection's uploads out.** Every writable + arms a `'close'` listener on its transport — so a disconnect mid-stream + sets `closed` and releases a parked `'drain'` — and nothing took it off + again: a long-lived connection held one listener (and one writable) per + stream it had ever carried, and a `WrpcClient` printed + `MaxListenersExceededWarning: Possible close memory leak` from the 11th + (`node bench/support/transport-worker.js ws`, 60 sequential 16 MiB + uploads: 52 warnings, 62 listeners) — in 1.0 too. The listener comes off + once the stream is finished: ended or terminated, with no `'drain'` still + owed to a producer, which the close listener stays to release if the + transport dies first. A stream that is still open notices the disconnect + as before; one already finished stays `closed: false`. The server-side + `Client.createStream()` also kept every writable in `client.streams` for + the connection's life, where the `maxStreams` cap counts what the peer + announced: once 256 downloads had gone, every later upload on that + connection was answered `429`. The map holds the peer's streams only now — + `getStream()` is never handed this end's own writable, and the typing is + `Map`. `WrpcReadable` waits on its own events + only and had no such listener. +- **WebTransport: a session on `@fails-components/webtransport` no longer + logs `datagrams.writable is deprecated`.** The session check of + `attachSession` (`isWtDatagrams`) read the legacy `datagrams.writable` + getter before looking for `createWritable()`, and that host warns — and + creates a writable — on the read. The factory is tested first now, as the + datagram writer already did, so the getter is read only by a host that + has nothing else; such a host validates and sends as before. +- **`@alexify/wrpc/query` type-checks in a project without Node types.** + Its declarations imported the Node barrel (`index.d.ts`) for seven names + `client.d.ts` declares, so a browser-only TypeScript project without + `@types/node` got some 40 errors (`node:http`, `Buffer`) for importing it — + in 1.0 too. They import `client.d.ts` now, and a guard type-checks every + browser-reachable entry (`browser`, `sse`, `query`, `auth`, `deflate`, + `encryption`, `webrtc`) with no Node types at all. +- **A sealed delivery whose key provider threw is retried, not + dead-lettered.** A consumer under `encryption` dead-lettered the message + with `400 Sealed delivery refused`, unprocessed, on the provider's first + failure. A provider that throws is this service's own blip: it is + retried like an unknown key id — `503` under the binding's `retry` — + before it dead-letters. +- **AMQP: a direct listener the server cancelled, or whose channel closed, + comes back.** It went `healthy: false` for good, and a send to its + address was taken and delivered nowhere. It re-opens now as a queue + consumer does — a fresh channel, its queue declared and bound again, + with a backoff, each failed attempt logged `broker.amqp.direct.reopen` — + and `healthy` is true again once it is in place. +- **AMQP: a paused consumer stays paused across a re-open, and the + backplane hears again after a cancel.** A consumer whose channel the + server closed during `pause()` came back consuming, and delivered what + was sent during the pause; the re-open respects the pause now and + `resume()` starts it. The backplane's consumer, cancelled by the server + (its queue deleted, its node gone), took the cancel for a closed channel + — which it was not — and the rooms went deaf without a line. It is + logged `broker.amqp.cancelled` and its channel closed, so + `backplane.rebind` brings it back. +- **A session store that fails is a `503`, not a `403` — so a broker + delivery retries it.** A connection whose token restore threw + (`session.restore`) went on without a session, and a `session` procedure + answered it `403` — which a consumer in token mode dead-letters at once: + one blip of the store sent deliveries to the dead-letter queue + unprocessed. Such a connection answers `503` now (a subscribe: + `Session store unavailable`), which the binding's `retry` takes; + `Client#sessionUnavailable` says which it is. A token that names no + session is still `403`. +- **NATS: a feed reader's consumer is reaped 30 s after its reader dies, not + 347 days.** The ephemeral consumers of a log read and its catch-up carry + `inactive_threshold`, which the adapter passed in nanoseconds to + `consumers.get()` — and that takes milliseconds and converts them + itself, so the server was told 3·10¹⁶ ns. The in-repo fake converts as + `@nats-io/jetstream` does now, and the suite reads what the server + would be told. +- **AMQP: a retry or a dead letter on a consumer channel that died writes + one copy.** A settlement publishes its copy (the retry, the dead letter) + and then acks the original; the two were retried together, under a + `channel.closed` amqplib's channel does not have — so on a consumer + channel that had died the ack kept failing and every round published + again: three copies of one delivery. The publish is retried alone now, + while the channel is still the consumer's, and the ack runs once after + it; a channel gone by then hands the original back itself — one more + delivery, never one more copy. The suite's fake channel took an ack on a + closed channel silently, as amqplib does not, and that is how no test + saw it. +- **A WebSocket closed while its inflate queue holds the socket reads its + peer's Close.** A burst of compressed frames pauses the socket until the + inflates drain; a `close()` meanwhile dropped the queue but left the + socket paused, so the peer's Close went unread and the connection ended + at `closeTimeout` with `1006`. Dropping the queue resumes what the hold + paused (an application's own `pause()` stands). +- **The `.d.ts` files describe what the runtime has.** `ServerTransport` + gains `revision`, `attachments` and `setRevision`, the port transport + `max` and `negotiate`, `buildHeaders` its third argument (`revision`), and + the server `Client` its `attachments` getter; `attachPort`'s comment no + longer says a page's `wrpc:connect` carries `v` — a wrpc page sends none. + The parity guard of `tests/package/consistency.test.js` now compares + MEMBERS too: every public member of `ServerTransport` and its transports, + the server `Client`, `RpcServer` and `WrpcClient` must be declared on its + d.ts class, the few the core calls on itself named in the test. +- **The engine contract says that `socket.protocol` decides the revision.** + The core reads it once at attach — `wrpc.v2` is revision 2, anything else + revision 1 — so an adapter that never set it served every client at + revision 1 without a word. `docs/reference/engine.md` and `engine.d.ts` + say so, and the engine contract checks that the server-side socket's + `protocol` is the subprotocol the handshake selected, on both engines. +- **The broker binding negotiates the revision; a stateless request holding + bytes is answered.** A session's `hello` and `welcome` now say whether + each side reads framed messages (`wrpc-version: 2`), and a session sends + a frame only to a side that said so — two ends whose `attachments` + disagreed used to send each other frames the other refused unread, a + `408` per call. A stateless request is revision 1 both ways: its body is + read as text and its answer was always JSON, yet the client sent a frame + whenever the arguments held bytes, and the call timed out even between + two ends with the defaults. Its bytes now travel as 1.0's JSON. +- **WebRTC negotiates the revision, per half of a peer.** Two `WrpcPeer`s + whose `attachments` disagreed (`host: { attachments: false }`, or a + client half with `attachments: false`) sent each other frames the other + refused unread, and a call holding bytes was never answered. Each + description now carries `caps.f` — bit `1` this peer's host reads framed + messages, bit `2` its client does — and each half sends a frame only where + the other peer's matching half said so. `PeerHost#revision` is what a peer + announces for its host. Over a raw channel there is no handshake: the two + applications set `attachments` alike, as they agree on compression. +- **WebTransport negotiates the revision.** It was "always revision 2", so + two ends whose `attachments` disagreed sent each other frames the other + refused unread — a call holding bytes timed out (`408`) where the same + pair over a WebSocket settled on revision 1. Each end now says in its + capabilities message whether it reads framed messages (`f: 1`) and sends a + frame only once the peer said so and it reads them itself; until then a + packet's bytes leave as 1.0's JSON. `client.revision` and the server's + `context.client.revision` say what was settled. +- **An answer whose form follows `Accept` says `Vary: Accept`.** A packet-mode + or conventional REST answer is a frame for a request that names + `application/octet-stream` and JSON otherwise, and carried no `Vary` for + it: a shared cache could hand one caller's frame to another that reads + JSON. It is added (joined onto `Vary: Origin` and `Accept-Encoding`) + wherever the server can answer a frame; a server under + `attachments: false` or a packet codec does not vary on it. +- **WebTransport delivers what the peer sent before a graceful close, under + an asynchronous codec too.** A channel stopped reading its control stream + at four inflates in flight — a browser's `DecompressionStream` answers + asynchronously — so a peer's last burst stayed unread in the stream, and + the session close that follows a graceful close resets the stream and + drops what it holds; what had been read was dropped by the shut. The + reader now stops on BYTES held (1 MiB read and not yet delivered), not on + a count, and on the session's graceful end both ends let the channel + deliver what it read (`closeTimeout` at most) before shutting it. +- **`acceptSessions` checks the per-session options at the call.** A bad + `maxBackpressure`, timeout, stream cap or compression codec was accepted + and then failed every session it attached — each client saw `Not + connected`, `onError` a `TypeError` per session. They are checked once, + where `acceptSessions` is called, by the same check `attachSession` runs + before a handshake. +- **A `WtSocket` whose option is refused leaves no timer behind.** The idle + timer was armed before the channel was built, so an option the channel + refuses (an unknown compression codec) threw its `TypeError` — and + `idleTimeout` ms later the timer read the channel that never was: an + uncaught `TypeError` that ended the process. The timer is armed once the + channel exists. +- **`upload()` of a blob uploader runs once.** A second call wrote into the + stream the first had ended and waited for a `'drain'` that never came — + a promise that never settled. It rejects at once now; create another + uploader for another upload. +- **The worker proxy opens one upstream connection, and answers its pages + when it is lost.** Pages whose first packets arrived together each started + a connect — three tabs, three sockets to the server — and when the + upstream closed under a call, the page heard nothing until its own + `callTimeout`: the proxy's client answers only its own calls, and a page's + went up as raw packets. `open()` is single-flight now, and on the + upstream's `close` every page's call in flight is answered `503` and every + subscription ended with the same error. +- **An HTTP client that sent a frame to a server that reads none sends it + again as JSON instead of timing out.** The revision an HTTP client learns + from `wrpc-version: 2` was never lowered, so behind one address shared by + 2.x and 1.0 instances — or after a rollback to 1.0 — every call holding + bytes that landed on a 1.0 instance was refused before it was read (an + id-less `500`) and ended in a `408` after `callTimeout`. A frame answered + by wrpc without `wrpc-version: 2` now lowers the client to revision 1 and + the same packets go once more as JSON; an answer with no packet in it (a + proxy's error page) changes nothing. +- **A worker port under a packet codec no longer opens with a malformed + packet.** The page's first `ping` — the one naming its revision — was + JSON text whatever the client's `codec`, and a server attached to the + port decodes everything with its own: `packet.malformed` on every + connect, and an id-less `500` the page heard as an error. The ping goes + through the codec now and its `pong` is read back through it, so the page + still learns it talks to a 2.x end and still says goodbye on `close()`. +- **The Autobahn runner no longer calls a truncated run a pass.** + `node scripts/autobahn/run.js` graded whatever cases the report held, and + the fuzzing client writes a report even when it gives up half-way — one + connect to the echo server that times out (an emulated image on a busy + Docker VM) ends the whole suite. Such a run printed "all cases passed" on + 262 of 517 cases. The runner now reads the client's output as it goes: a + lost connection, or a report with fewer cases than were started, fails + the run, and a pass says how many cases it stands on ("all 517 cases + passed"). +- **Kafka feeds: a resume storm is queued, and identical pages are one + read.** A catch-up page is a consumer group of its own (the KafkaJS shape + has no manual assignment) — a connection, a JoinGroup, seconds of a + coordinator's time — and after a deploy every resuming client opened one + at once: hundreds of groups joining together. `createKafkaBroker` takes + `maxCatchUp` (4): that many pages are read at a time, the rest wait first + come first served, and a page that fails hands its turn on. Pages asked + for with the same topic, resume token and size — a room that lost one + instance — share a single read. Latency under a storm is the trade; the + option is validated (a positive integer). +- **Docs: what a retry delay means on RabbitMQ, and how to drain a + dead-letter queue.** The AMQP adapter republishes every retry to one + `.retry` queue with a per-message expiration, and RabbitMQ expires + only the head of a queue — so under the *default* policy (exponential, + full jitter) a retry due in 300 ms can wait behind one due in 8 s. The + guide advised a "uniform" schedule without saying the default was not + one; it now says the delays are a lower bound there, gives the policy + that makes them exact (`backoff: { base, max: base, factor: 1, jitter: + false }`), and that the retry queue is classic — durable, not replicated. + The consumers guide gained "Draining a dead-letter queue": the two + headers a dead letter carries, where to alert, and why a message moved + back verbatim goes straight to the dead-letter queue again (it still + carries `x-wrpc-attempt`) — re-drive by publishing a new one. +- **A mesh makes a lost edge again.** `Mesh` linked a member when it + joined and never looked back: once a link gave up — its redial budget + spent, a goodbye to the link that was not a leave of the room, an + `accept()` that refused — the two peers stayed in the room unlinked for + good, and `broadcast()`/`ask()` went around the member without a word. + The mesh keeps the roster now, and an edge to a member still on it is + dialled again: after `minDelay` (1 s), backing off with full jitter to + `maxDelay` (60 s), then every minute while the member stays — no attempt + count, since a refusal arrives as a close like any other. `'unreachable'` + (`{ id, attempts }`, and `mesh.unreachable` in the log) says once per + outage that the pace has reached that floor; `'join'` fires again when + the edge is back. `peer.join(room, { relink })` sets the pace or, with + `false`, restores the old behaviour. A member that left, a mesh that + left, and a member that was only `away` when its link went are not + dialled. +- **WebRTC port: `send()` must copy, and the contract says so; the + declarations match the runtime.** The framing layer writes every fragment + of a message into one reused buffer and hands `channel.send()` a view of + it — which relies on `send` copying synchronously, and copying the view's + own offset and length. A browser and node-datachannel do; neither the port + contract, its types nor the wrapper advice in the guide said so, and the + shared contract suite sent only whole-buffer views, so a wrapper doing + `Buffer.from(view.buffer)` passed it and corrupted every multi-fragment + message. The contract is stated (`src/webrtc/port.js`, the JSDoc of + `RtcDataChannelLike.send`, the guide) and tested + (`tests/webrtc/portContract.js`, against the fake and, by hand, + node-datachannel). Types: `PeerHostOptions.introspection` takes + `'session'` and `{ access, schemas }` as the runtime does; + `FrameEncoder.encode`/`encodeText` answer the fragment count; + `FrameDecoder` has `compressed` and `push()` says whether its result is + still deflated; `FramingError` codes include `'inflate'`; `WrpcPeer#log` + is declared. +- **WebTransport client: `highWaterMark`, `lowWaterMark`, `maxBackpressure` + and `maxMessage` in the `wt` bag of `connect()` take effect.** The + transport read those four from its constructor's options only, and + `connect()` builds the transport with none — so the documented + `connect(url, { wt: { highWaterMark } })` was ignored and the defaults + applied. They are read per open, from the same merged bag as + `compression` and `closeTimeout`; a bad `maxBackpressure` there rejects + `open()`. +- **`@alexify/wrpc/wt`: the types caught up with the runtime, and a + throwing `onError` no longer rejects `done`.** `acceptSessions` calls + `onError(error, null)` when the session SOURCE fails, which the type did + not admit (`session: WtSession`) — so the natural handler, + `(error, session) => session.close()`, threw there, and that throw + rejected the `done` promise documented as always resolving: an unhandled + rejection. `onError` is typed `WtSession | null`, and one that throws is + logged as `wt.onError`. Also typed now: `WtSocket.send(data, { compress })`, + `WtSocket.streamControl(packet)`, and `frame()` with the compressed kinds + 3 and 4 it has always written. +- **`rooms.compression` and `rooms.encryption` are validated on an instance + without a backplane, `rooms.backplane` is refused, an unknown `rooms` key + warns.** The rooms envelope was only built once a backplane existed, so a + single-instance server accepted a codec name the platform lacks or a + keyring that is not one and found out on the day a backplane was added; + and `rooms: { backplane }` — the compression guide's own snippet — was a + silent no-op (the backplane is a top-level option): a TypeError now, and + any other unknown key a `rooms.option` warning. The guide's snippets and + the wire-format reference are brought to the code (the data-channel + header's COMPRESSED bit, the five WebTransport frame kinds, the PeerHost's + real `maxCalls` default, the WebTransport example's manual fallback, the + `auth` import the WebTransport guide's snippet needed). +- **`createRedisBroker({ client: new Redis() })` type-checks again — and + every injected client type is held against the real package.** The + Redis client interface spelled each stream, sorted-set and `set` command + with exact arguments; ioredis declares them as stacks of overloads, which + no single signature is assignable from, so the documented use was a + TypeScript error (ten of twenty-six members). Those commands are + `(...args) => Promise` now, in `RedisBrokerClient` and the session + store's client alike, and the tsd suites hold the real `Redis`, kafkajs + `Kafka`, Confluent `KafkaJS.Kafka`, NATS connection and amqplib + `ChannelModel` types against the interfaces they are meant to satisfy. +- **`wrpc.rtc.assertions` says why a trust assertion was refused.** The + counter read a `reason` no `AssertionError` ever had, so every refusal + was `invalid` and a substitution (`signature`, `fingerprint`, `subject`) + could not be told from a clock or a rotation (`expired`, `kid`). It + carries the verifier's code now; the telemetry guide says which outcomes + are security signals. Redials are counted where they happen, and a + `signaling.undeliverable` is a debug line (a trickled candidate that + crossed a `leave` is a routine race). +- **A connection that dies on an engine leaves a line.** The uWebSockets.js + engine ignored the logger the `Server` shell handed to `attach()` (its own + default was `false`), so `uws.dropped` went nowhere whatever the shell was + told; it takes the attached writer now unless built with its own, and the + fastify and express adapters pass the server's. A permessage-deflate + failure on the way OUT was an `'error'` nobody may be listening to; it is + `ws.deflate`, the mirror of `ws.inflate`. And a socket's `error` under a + client is a debug `socket.error` line beside the engine's own. +- **A broadcast flood during a client's Noise handshake no longer costs + it the connection.** A sealed socket held everything sent before the + handshake in one queue of 256 and closed the connection past it — so a + busy room closed every client that was still connecting. It offers + `sendPrepared` now: a shared (broadcast) message before the handshake is + held under its own bound of 256, past which the rest is dropped for that + client, counted, and said once as `encryption.queue.dropped` when the + handshake completes; a direct send flood still closes (`1008`, `queue`). + After the handshake every recipient seals its own copy, as before. +- **The rooms backplane's per-channel publish counters no longer grow with + every room name an instance ever published to.** The `seq` table of loss + detection kept one entry per channel forever (300k unique rooms held ~38 + MiB). It is two generations of `rooms.maxTracked` channels now (16384 by + default; the hot path is still one get and one increment); a channel + evicted and published to again starts a new count under a suffixed epoch, + `.`, which a receiver already reads as a restart — never a + false gap. `maxTracked` must be a positive integer. +- **`sendTo` carries bytes to a client on another instance.** A cluster + command envelope was JSON, so a `Uint8Array` in the event's data arrived + on the other node as the `{"0":…}` object JSON makes of it, and `sendTo` + still answered `true` — the natural 1:1 relay of a `createSealer` payload + broke only in production, with two instances or more. An envelope whose + application payload holds bytes now leaves as the binary envelope the + rooms backplane already uses (`wrpc-bin:` + the attachments frame, or the + same frame inside a sealed one): `sendTo`/`cluster.send`, + `cluster.sendEvent`, the question and the answers of `cluster.ask`, and + the answers a broadcast `ask` collects from other instances. Under + `cluster.secret` it is signed over the frame's bytes — verified by + encoding the decoded envelope again — with the same counter, channel and + clock as a JSON one, and a signature made for one form does not verify + as the other. Presence and room commands are never walked for bytes; + `attachments: false` keeps every leg the JSON of 1.0. `cluster.bytes` is + now only what a `Cluster` constructed without the core's envelope logs + when it has to refuse (`false`) rather than mangle. +- **Worker proxy: an answer for a tab that left is dropped, and a tab's + release cancels what it was waiting for upstream.** A `callback`, `data` + or `end` whose id no port waited for — the tab closed, or unsubscribed + — was broadcast to every other tab (a private answer handed around, and + a feed nobody read fanned out); it is dropped now, while events and + server-opened streams still reach every tab. A closed tab's calls ran on + and its subscriptions kept streaming for the life of the connection: + releasing a port sends `cancel` for its calls and `unsubscribe` for its + subscriptions. The page transport's `close()` posts a `wrpc:close` + goodbye before closing its port — to an end that named a revision on the + port's first ping and pong, a 2.x one: a 1.0 proxy reads every message as + a packet and throws on it — so an engine that never fires the + `MessagePort` close event releases a tab of a 2.x worker too; an upstream + close answers what the pages were waiting for and forgets every id. +- **The connect URL's two declared bags share ONE query budget, as the + server measures it.** Each bag was capped on its own, so two that fit + separately — with whatever query the url already carried — were sent + together past the server's limit, where the WHOLE query is dropped + silently on the side that cannot see it. Headers are declared first, + then meta, against one 2048-byte budget over everything after `?` + (the encryption marker of a WebTransport session included); what does + not fit is refused with `meta.oversize { carrier: 'query', param, bytes }` + and the connection works un-labelled. +- **`sealedStore`: a key provider must answer `kids()`, and `seal: false` + is the first of three deploys.** A provider without `kids()` was + accepted and every session under an older kid became unreadable the + moment `current` moved — a mass logout at the first rotation; it is a + TypeError at construction now. Adopting the store over a fleet that + already holds sessions needed a deploy that reads sealed rows without + writing them (an instance rolled back must still find every session): + `seal: false` writes plaintext under the token and removes the token's + sealed slots, reads a sealed row where it is without moving it, and + touches and deletes the row on either side. The sessions guide has the + three-deploy table, mirroring the backplane's. +- **permessage-deflate: `level` and `memLevel` apply on every deflate + path, and are validated at construction.** The two knobs, introduced on + this branch with context takeover, reached the live context only: a + `level` on the default no-takeover connection was silently ignored on + the one-shot, fan-out and threadpool paths, which compressed at zlib's + 6 whatever was asked. The negotiated params now carry the zlib options + once (`zlibOptions`, null when neither is set, so the common path builds + nothing), and every path takes them; the fan-out cache stays keyed by + window (two engines on different levels in one process would share the + first recipient's bytes — correct for all, and one `PreparedFrames` is + one broadcast). `level` outside −1..9 or `memLevel` outside 1..9 is a + TypeError from `WebsocketServer`, not a zlib error at the first + message. `bench/send-path.js` measures levels 1, 3 and 6 on 2 KB and + 24 KB, and `bench/deflate-context.js` the same under a live context. The + default stays 6: one-shot it has the knee `bench/algorithms.js` found + (level 3 at 3.2× the throughput for the same ratio on 24 KB), but under + context takeover — the mode where the level matters most — level 6 buys + a 19% better ratio (10.6× vs 8.9×) for 6% more CPU, because the stream's + own overhead dominates there. +- **Trust assertions: one failure of `keys()` no longer poisons the + verifier, a broken JWK is no unhandled rejection, and a refresh for an + unknown kid is bounded.** The verifier kept a failed first load as "the" + load, so every verify after an outage of the keys endpoint awaited the + same rejection until the process restarted; the set and the load in + flight are kept apart now — a failed first load is retried on the next + verify, concurrent verifies share one load, and a refresh that fails + keeps the set that was. A JWK the platform refuses (`use: 'enc'`, bad + coordinates) rejected its import with nobody listening until a token + named its kid — handled then, never unhandled. Every token under an + unknown kid asked `keys()` again: at most once per `refreshInterval` + now (30 s; 0 asks every time), on `createAssertionVerifier` and + `WrpcPeer`'s `assertions`. +- **WebRTC redial: a responder no longer burns its budget in milliseconds + or kills the link its initiator is rebuilding; a knock on a link the + initiator thinks is up rebuilds it.** A responder whose link failed + knocked, re-armed itself at once and counted each knock as an attempt — + the whole `retries` budget gone within one backoff step, before the + initiator's dial had even timed out — and then gave up with a goodbye + that ended the link the initiator was still redialling. It now re-arms + once the connect window has passed, and gives up quietly (`abandon`, + no goodbye); the initiator's goodbye still ends a link nobody else can + rebuild. A knock arriving at an initiator whose link looks connected + but once opened is the responder saying its half is gone — an + asymmetric failure the initiator's pc never noticed — so the link is + failed (`RtcLink.fail()`) for the redial cycle to rebuild; a knock + during a dial is ignored. The mirror case is covered too: an offer under + a NEW certificate arriving at a responder whose link still looks + connected — the initiator redialled after a failure only it noticed — + makes the responder dial a fresh pc rather than renegotiate the old one, + which was never the new pc's to renegotiate. The fake's channels now + carry a close to the peer only over a live path (a dead one carries no + SCTP reset), which is what surfaced this. `wrpc.rtc.redials` is counted where a redial + or knock actually happened, not where one was scheduled, and a + scheduled one is cancelled when the link comes up on the peer's offer. + The relay's `signaling.undeliverable` — a trickle candidate that + crossed the peer's leave, mostly — is `debug`, not a warning per + candidate. +- **WebRTC peers: one link per peer under a racing `connect()`, and no + unhandled rejection from a signal, a join or a dial.** A `connect()` + made while an inbound open for the same peer was still in its + `accept()` hook got a second `PeerLink` over it when the hook answered + — the first leaked, with its client and its half of the signals: the + hook's queue is the connecting link's now, and an accept that finds its + queue taken opens nothing (`#create` also refuses to make a second link + over one that is not closed, logged `rtc.peer.duplicate`). A signal + whose handling rejected, a fire-and-forget `peer.join()` whose roster + fetch failed, and a redial whose `createPeerConnection()` threw (a + browser past its connection cap) were each an unhandled rejection or an + uncaught exception from a timer: the first two reach the peer's error + listener (the mesh detaches — it never joined), the dial failure is + reported and leaves the link failed for the next attempt, and a first + dial that cannot be made throws from `start()`/`connect()` with no link + kept. +- **`createBlobUploader().upload()` honours backpressure, and a connection + closing mid-upload is a rejection.** It wrote every chunk of the blob + as fast as the source produced them — a 1 GiB file went into the + transport's buffer whole, whatever `write()` answered — and resolved + even when the connection had closed halfway. A chunk the transport did + not accept now waits for the stream's `'drain'`, a close before the last + chunk rejects with the `503` `WrpcError` a call gets, and a source that + throws terminates the stream so the server stops waiting for its end. +- **Compression on WebTransport and WebRTC: an inflate is never an + unhandled rejection, and no more than four run at once.** The + `Sequencer` that keeps an asynchronous codec's output in order attached + its handler to a promise only when that promise's turn came, so a decode + rejecting behind a slower one (a bomb, a truncated frame) was reported + unhandled before it was handled; a promise queued behind another is + adopted the moment it is pushed. Every compressed frame used to start + its inflate at once — a burst of them was a burst of parallel inflates + on `CompressionStream` or the threadpool: past four in flight the rest + start in their slot (the `Sequencer` takes a thunk), and a WebTransport + reader waits for them before it reads on, so the bytes wait in the + stream under QUIC's flow control. `bench/message-compression.js`: the + plain path is unchanged (104M vs 107M pushes/s), the queued-promise path + pays the adoption (1.65M vs 1.93M/s on 200 000 resolved promises). +- **`RpcServer.attach` and `PeerHost.attach`: the transport contract is + checked whole, and nothing a packet handler throws escapes as an + unhandled rejection.** The structural check accepted a transport with + `write`/`close`/`on`/`once` and no `send`/`error`/`off`, which the + dispatcher then called on the first packet — a TypeError at attach now, + named in the message; `InboundTransport` in the types says so. A 'chunk' + whose handling rejected (a stream the client never opened, on a transport + whose `error()` throws) was an unhandled rejection, and a 'packet' + listener that threw rejected the transport's `emit()`, which nobody + awaited: both are contained and logged through the client, as every + other handler failure is (`dispatchMessage`/`dispatchBinary` in the + dispatcher, used by `attach`, `attachPort` and `PeerHost.attach`). +- **WebRTC: a `send()` the channel throws from cannot desynchronise the + wire, and never reaches the core.** `RTCDataChannel.send()` throws when + the channel closed under the transport or refuses a message, and the + frame encoder used to go on with the next fragment — the peer was left a + message with no end, after which nothing parsed — while the exception + came out of `write()` into the dispatcher. The codec now owns the one + call to `send()`: thrown mid-message, the encoder stops, the fault is + reported (code `desync`) and the CHANNEL is closed, locally, so over a + link the link redials rather than ending; thrown before the first + fragment, that message alone is lost and reported (code `send`); on a + channel that is no longer open the close event says it all. `write()` + answers `false` for a lost message. Also: an `RtcPeerTransport` over a + channel that is closing or closed is a `TypeError` at construction, not + a surprise at the first write; a channel a factory produced after + `terminate()` won is closed, not left open for nobody; an inflate + settling on a channel a redial replaced no longer hangs up the new one. +- **WebRTC: a `false` from `write()` under an asynchronous codec is + followed by `'drain'`; the water marks are validated.** The transports + counted the bytes a codec was still compressing towards the high-water + mark but left `'drain'` to the channel's own `bufferedamountlow`, which + bytes waiting in a codec never cross — so a `false` answered for them + (a large message under `CompressionStream`, or zlib's threadpool) was a + false with no drain ever after, and a `WrpcWritable` waiting for it hung. + The codec now owns the decision: one `'drain'` after a false, once the + channel's buffer and its own pending bytes are under the low mark, + whichever of the two settled last. `highWaterMark`/`lowWaterMark` on + `ClientRtcTransport`, `RtcPeerTransport`, `attachChannel` and + `WrpcPeer`'s `host` are validated at construction: positive integers, + the low mark no higher than the high one (a `TypeError`), and a high mark + set below the default low mark pulls the low mark down with it — a low + mark above the high one used to mean a drain that never came. +- **WebTransport: `pause()` stops the side streams too, and their bytes + keep the session alive.** The server socket's `pause()` — what the core + calls while a binary stream's readable is over its high-water mark — + stopped the control stream only, so an upload on its own WebTransport + stream kept flowing around it and the readable's mark had to grow to + hold what arrived (an overflow in the making on a large upload into a + slow handler). Every inbound read now waits on one gate the pause holds + and `resume()` (or the close) releases. Bytes on a side stream also + re-arm `idleTimeout`: a session busy with nothing but an upload used to + idle out. `bench/wt-framing.js` is unchanged by it — the gate is one + call per read on the mux path, which the bench does not cover. +- **WebTransport: every `false` from `send()`/`write()` is followed by a + `'drain'`; chunks waiting for a side stream are counted; an open the + host never answers falls back.** A `false` answered from the side-stream + path, from a message compressing in flight or from a frame queued behind + one set no mark, so the `'drain'` that should follow never came and a + `WrpcWritable` waiting for it waited forever — the same on the client + transport's sealed path under session encryption. Every path now answers + through one gate that arms the drain. Chunks held for a WebTransport + stream still opening did not count towards `bufferedAmount` (an unbounded + hold against a host that never opened it); they count from the moment + they are held, once. A `createUnidirectionalStream()` that never settles + — a browser waits for stream credit — is given five seconds, after which + that stream and every later one ride the control stream, as a refused + open already did; a stream granted late is reset unused. A write that + settled after `terminate()` took `bufferedAmount` negative; the count + is zeroed and stays so. `ClientWtTransport` gained `bufferedAmount`. + The two ends of the channel now share one behavioural suite + (`tests/wt/channelContract.js`), so a fix on one cannot skip the other. +- **Kafka: topics are created with the broker's default replication + factor, and their creation is logged.** `replicationFactor` defaulted to + `1`, so every topic the adapter made on a production cluster was a + single-replica topic whatever the cluster's own default said. The default + is now `-1` — the broker's `default.replication.factor` (KIP-464, Kafka + 2.4+), verified on both client shapes — and each creation is logged once + as `broker.kafka.topic` with the partition count and replication factor. + An existing topic keeps what it was created with; the option is + documented, and the guide has a Topics section on sizing replication. +- **NATS queues: `prefetch` is each instance's, the group's cap is + `maxAckPending`; a pause keeps its leases; readers delete their + consumers.** `prefetch` was written into the durable consumer's + `max_ack_pending` — the cap on unacked messages for the WHOLE group — + so a fleet of four with `prefetch: 16` held 16 between them, not 64, and + the last instance to bind set it for all. The prefetch is now counted + per instance (a pull waits for a settlement at capacity), and the new + broker option `maxAckPending` sets the group's cap (JetStream's default + otherwise); a durable that already exists with other values is served + with those and logged `broker.nats.consumer.config` once. `pause()` + used to clear the keepalives of the messages it still held, so + JetStream redelivered them to another member while a draining node was + still working on them — a pause keeps its leases now, a stop lets them + go. A feed's readers left their ephemeral consumers on the server (one + per catch-up page, one per tail) until its default reaping; they are + deleted when the read is done, with a 30-second `inactive_threshold` as + the backstop for a process that died mid-read. +- **NATS queues: a short `ackWait` no longer expires under a live + handler.** The keepalive (`working()`) ran every `ackWait / 2` but never + more often than once a second, so a window shorter than two seconds + expired before the first keepalive — the redelivery it existed to + prevent. It runs every third of the window now (100 ms at the least). + The unit test that was meant to prove the keepalive could not: it waited + 900 ms of real time against a 2-second window, which passes with no + keepalive at all; it now ticks a mocked interval and counts the + `working()` calls the fake receives, and the integration suite holds a + message for longer than `ackWait` against a real server. A broker built + without JetStream simply has no `log` and no `queue` — the `501` guards + behind those two are gone with the test that reached past them. +- **Redis RPC groups: one instance stopping no longer takes the group + offline, and `close()` stops its timers.** A service group's presence was + one key, refreshed by every member and deleted by whichever member + stopped first — from that moment a sender found "nobody there" (`503`) + while the other instances were still taking work off the list. Each + listener now holds its own lease in a members set (`…:members`, a + sorted set scored with the lease's expiry), and the group is present + while any lease is live; a crashed member's lease runs out on its own. + `close()` releases the leases and clears the heartbeat and sweep timers, + which used to keep firing against the injected client after the broker + was closed. +- **Redis queues: a delayed retry can no longer overwrite another, and its + promotion is atomic.** A delayed retry was a sorted-set member made of + the message's body and headers, so two retries of one payload were ONE + member and the second overwrote the first — a message gone; and the + promotion was `ZREM` then `XADD` as two commands, so an instance that + fell between them (a crash, a refused `XADD`) took the entry off the set + and never put it on the stream. The member now carries the message id, + and the promotion is one `EVAL` (`ZREM` then `XADD`) — a client without + `eval` gets the two steps apart, with the entry put back at its original + time when the stream refuses it, and kept in memory for the next sweep + if even that fails. A queue's keys are `:q:{}` and + `…:delayed` — braces being the Cluster hash tag that keeps both in one + slot, so the script runs on a cluster too. (The key layout changed from + the unreleased `:q:`; nothing migrates a queue left + there.) +- **A feed's live read that the broker ends is replaced; the subscribers + miss nothing.** `TopicTails` shares one live read of a topic among every + local subscriber, and that read had no way to say it had died: a + RabbitMQ channel the server closed, a NATS pull that ended, a kafkajs + consumer that crashed left every subscription of the topic frozen for + good, silently. The adapters' `live()` now reports its end (`onEnd`, + additive — `broker..tail` in the log); the readers move to a fresh + tail, positioned before each catches up from its own cursor, so nothing + appended meanwhile is lost. A broker that cannot start the fresh tail is + asked again five times with a backoff, then the subscriptions end with + `503` and clients resubscribe with `lastEventId`. `TopicTails.close()` + fails a parked reader with `503` instead of leaving it waiting. +- **A feed's catch-up no longer mistakes a slow page for the tip.** A + reader resuming from a position, or reading from `'earliest'`, catches + up through range reads until a page comes back short — which is the end + for a log read straight off its store (memory, Redis), and was taken for + the end on the three brokers that page by *time* too: a RabbitMQ stream + reader that paused for 60 ms handed back a page called complete with + entries still behind it, a Kafka page whose 10-second window opened + before the group had joined, a NATS pull cut by its expiry. The reader + then joined the live tail past what it had not read: a silent hole in a + durable feed. Those three now answer `{ entries, done }`, complete only + once the page reached the tip read before it started — RabbitMQ reads + the stream's last offset first and waits up to 250 ms of idle / 10 s + overall, Kafka opens its window after the join and the seek and counts + from the low watermark, NATS stops at the stream's last sequence instead + of parking for the expiry — and `TopicTails` pages on until `done`, + asking again after a pause when a page brought nothing (503 after ten). + RabbitMQ also declares its offsets dense: a page whose head skipped past + the cursor is asked again rather than yielded with a hole. The in-repo + fakes now pause stream deliveries and park a NATS fetch for its expiry, + as the brokers do, and the log contract reads 300 entries across two + pages on every adapter. +- **Kafka and RabbitMQ: a settlement the broker refuses no longer loses + the message.** On Kafka a refused settlement — a retry's copy the + producer could not write, a commit the group would not take — was + swallowed after one log line, and the next message's commit moved the + group's offset past it; on RabbitMQ the retry's or dead letter's publish + failed once and the original sat unacked on a channel that might never + close. Both now try the settlement again (100 ms doubling to 1 s, three + times), then hand the message back on purpose: Kafka seeks the partition + back to that offset (`broker.kafka.settle`, `healthy` false until a + settlement lands), RabbitMQ requeues it (`broker.amqp.settle`, attempt + untouched). A Kafka retry's in-process delay is cut into heartbeat-sized + steps (3 s), so a long wait no longer looks like a dead member to the + coordinator; a heartbeat that fails mid-wait is a rebalance, and the + message is left to the partition's new owner (`broker.kafka.rebalanced`) + — neither republished nor committed by a member that no longer owns it. + The queue contract exercises a refused settlement on every fake that can + refuse a publish. +- **`consumers.healthy` on RabbitMQ and Kafka now turns `false`.** The + guide says to wire it into readiness, and on those two brokers it never + moved: a RabbitMQ consumer whose channel the server closed, and a + kafkajs consumer whose fetch loop crashed, both reported a binding that + consumed nothing as healthy. RabbitMQ flips while the channel is + re-opened; kafkajs flips on `CRASH` (`broker.kafka.crash`) and back on + the rejoin. A join that times out (more instances than partitions) is + logged `broker.kafka.join-timeout` once and stays healthy — an empty + assignment is a legitimate state, and a readiness that flipped there + would crash-loop the fourth instance of a three-partition queue. The + confluent facade reports no consumer events, which the guide now says. + The queue contract exercises the flip on every fake that can break a + consumer from the outside. +- **RabbitMQ backplane: one channel and one queue for every room, not + one of each per room.** Every `subscribe` opened its own channel and + declared its own exclusive queue — a room is a subscription, so a + server with a few hundred rooms ran into RabbitMQ's `channel_max` + (2047 by default, lower behind many proxies), after which every new + room failed to subscribe. An instance now keeps one consumer channel + and one exclusive auto-delete queue for the whole backplane, bound and + unbound per room by routing key (bind and unbind of one room run in + order, so a subscribe racing an unsubscribe settles as the last caller + asked); a consumer channel closed under live rooms is replaced and every + room bound again with a backoff (`broker.amqp.backplane.rebind`). +- **RabbitMQ: a channel the server closed is not used again, and a + consumer comes back.** A channel-level error — a declaration that did + not match what the server held (`406`), a queue deleted under a reader + (`404`), a node going away — closes that channel, and the adapter kept + handing out the closed one: every later declaration, publish and send on + the broker failed with "channel closed" until the broker itself was + closed, and a queue consumer sat on its closed channel forever, + `healthy` still `true`, taking nothing. The memoized channels are + forgotten the moment they close; a consumer re-opens its channel with a + backoff (200 ms doubling to 5 s, `broker.amqp.consumer.closed` / + `.reopen`), declares a deleted queue again, and settles every delivery + on the channel it arrived on. A closed **connection** is reported once + (`broker.amqp.connection`), marks every binding unhealthy for good and + stops the re-open loops — nothing can be re-opened on it. The in-repo + fake answers a mismatched redeclaration with `406`, cancels the + consumers of a deleted queue, and refuses work on a closed channel, as + RabbitMQ does. +- **RabbitMQ `direct`: one exchange, not one per address.** Every address + — and an address is whatever a peer puts in `replyTo`, one per client + inbox — got its own durable fanout exchange, which RabbitMQ never + deletes by itself: a service's topology grew by one exchange per client + for as long as the broker lived. Every address now binds to the one + `.direct` exchange under its own routing key; `mandatory` still + reports "nobody there" as the `503`, and so does a publish the broker + nacks (a queue being deleted under a listener that just stopped) — an + inbox unbinds its queue before it goes, so that race is rare. The + adapter declares exactly two exchanges, `.bp` and + `.direct`. (A dev RabbitMQ that ran + the unreleased per-address code keeps its `wrpc.direct.
` + exchanges; delete them by hand — nothing migrates them.) +- **Broker adapters: a bad numeric option is a `TypeError` at construction, + not a silent misbehaviour later.** Redis `blockMs` (`0` was `XREAD BLOCK + 0`, forever, and no pause after a failed read), `claimIdleMs` (`0` stole + every other consumer's in-flight message), `maxLen`, `inboxTtl`; NATS + `ackWait`; RabbitMQ `queueType` (`'quorum'` or `'classic'`), `inboxTtl`, + `streamMaxBytes`; Kafka `replicationFactor` (a positive integer or `-1` + for the broker's default) and `maxRetryDelay` — a string read from an + environment variable compared as a number in each of these places. + `queue.consume` on every adapter refuses a `deadLetter` that is not a + queue name, as the in-process broker did. The NATS broker's `close()` + stops its queue consumers, which kept their keepalives and their pull + loop running against a connection the caller was about to drain. +- **`attachBrokerRpc`: a cap on sessions, and a live session cannot be + taken over by a second `hello`.** Every `hello` that reached the service + address opened a session — a Client, a transport, a table entry until + `idleTimeout` — with no ceiling, so one sender could grow an instance + without bound; and a `hello` naming an existing session id replaced that + session outright, ending it for the client that held it and handing the + id to whoever sent the hello. `maxSessions` (default 10 000, `0` for no + limit) answers a hello past it with `bye` and reports the refusals as one + `broker.rpc.capacity` line per sweep; a hello for a session that has seen + a frame, or from another inbox, is ignored — only a client re-saying + hello for a session that never got going is given a fresh one. The + [trust model](./docs/guide/brokers/rpc.md#what-the-brokers-acl-is-the-boundary-for) + of the stateless mode is written down. +- **Sealed broker messages: a key the service does not hold yet is a + retry, refusals are counted, and a feed's are not a log flood.** A + consumer dead-lettered every delivery it could not open with `400`, + including one sealed under a key id its ring lacked — which is what a + rotation in progress looks like across a fleet, and what a key dropped a + moment too early looks like. That case (`reason: 'kid'`) is retried like + a `503`, to the binding's `attempts`, before it dead-letters; the others + (`unsealed`, `open`, `format`) stay dead at once. A feed logged every + refused entry at `warn` — a topic holding a thousand entries under a + retired key was a thousand lines per subscriber — and now logs one + `feed.refused` per reason per ten seconds with the running `count`, the + rest at `debug`, and an `info` summary when the feed ends. Both count + `wrpc.broker.refused` by `messaging.system` and `wrpc.reason`. The + headers a sealed message carries inside are the same string map a + plaintext one has (`{ n: 7 }` arrives as `'7'` on both paths). +- **`MemoryBroker`: a handler that throws is retried with a backoff, and + `trim()` frees what it trims.** A delivery whose handler threw was + released — back at the head of the queue and re-dispatched on the next + microtask — so a handler that always threw spun the queue without ever + yielding to a timer. It is retried after a backoff now (50 ms doubling + to 1 s, attempt + 1) — the delivery contract, and what the Redis, NATS, + RabbitMQ and Kafka adapters do too (each released as well; the queue + contract now pins attempt + 1). `trim()` moved the head index but never + compacted the array, so + a topic trimmed by hand kept every trimmed entry alive; it compacts like + the retention cap does. +- **A dead-letter reason spanning lines no longer keeps the message + alive.** `x-wrpc-dead-reason` is a broker header, and a header is a + line: NATS refuses a value holding CR or LF, so a handler error whose + message listed its failures one per line (a validator's) made the + dead-letter publish reject, the settlement never landed, and the message + was redelivered forever. Every adapter — memory, Redis, NATS, RabbitMQ, + Kafka — now folds the reason to one line of at most 512 characters + (runs of control characters become one space), and `attachConsumers` + folds its `" "` the same way; `onDeadLetter` still + receives the whole `error`. The in-repo NATS fake refuses CR/LF like the + real client, so the contract suite proves it. +- **`attachConsumers`: evicting a token client no longer releases the + deliveries it holds.** Under `identity.trust: 'token'` the per-token + client cache (`tokenClients`, LRU) closed the evicted client on the spot, + which settled its in-flight calls as released — the broker redelivered + them, the handler ran twice, and with `prefetch` above `tokenClients` a + busy queue could livelock, every delivery evicting the next before it + finished. An evicted client now finishes what it holds and closes after + its last settlement (never inside it, where the close would destroy the + `Client` under the dispatcher). `tokenClients` must be a positive + integer (`0` used to evict everything at once, a string never evicted). +- **WebRTC: a `leave` naming another incarnation of a member no longer + tears down the live one.** A signaling `leave` that reached `Mesh` or + `wrpcSignaler` late — a peer that had crashed and rejoined under the same + id, or a relay replaying the old instance's departure — was applied to + whichever link now carried that id. `Mesh` ignores a leave whose + `instance` is not the member's (`mesh.leave.stale`, debug) and + `wrpcSignaler` one whose `address` is not the one it knows for that id. + Peer ids have one bound — `MAX_ID_LENGTH` (256) in `src/webrtc/ids.js`, + what the signaling unit, the signaler and the mesh validate against — so + `connect('x'.repeat(257))` is a `TypeError` at once rather than an id the + relay refuses later. +- **`protocols: []` with a Bearer credential could not connect.** The client + lifted the token into a lone `wrpc.bearer.` offer, which no server + echoes — and a client fails a handshake whose offers all went unanswered + (Chrome closes 1006, Node's WebSocket errors). With nothing offered the + credential now stays in the query carrier. +- **Two public telemetry types rejected the things they exist to accept.** + A real `@opentelemetry/api` `Tracer` was not assignable to `WrpcTracer`, + because `WrpcSpan` declared `addEvent`, `setStatus` and `recordException` + more narrowly than the article they describe — so the structural view that + exists precisely to let an SDK in kept it out. And `startActiveSpan` was + declared with only its 3-argument form while `startSpanWith` calls the + 4-argument one whenever the arity allows, so a hand-written parented + tracer type-checked and then broke at runtime. Both are typed from what + wrpc actually calls now, with tsd assertions against the real SDK. +- `connect(url, { worker })` builds its own `ClientEventTransport` per + client instead of sharing a class-level singleton. A second `connect` to a + DIFFERENT worker on the same page used to reuse the first `MessageChannel` + and never reach its worker, and closing one worker client closed the port + of every other one; now each client has its own channel and lifecycle. +- `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 + client re-announces the transport's `'drain'`, so a `WrpcWritable` on the + client side actually waits for the wire. +- **`SessionManager#destroy` could take the process down.** + `initializeSession` calls `finalizeSession()` through `void`, so a + `store.delete` that rejected became an unhandled rejection: a Redis blip + ended the server rather than one session. It is guarded and logged now. +- The Redis backplane wrote its entries as `{ err, component }` with **no + `event` field** — the one writer in the package breaking the convention + its own guide documents, so those lines could not be alerted on alongside + the rest. A test now asserts the rule on every entry a server writes. +- **A refused WebTransport handshake waited out the handshake timeout.** + The server hung up on a hello it refused (a pin under a kid it no longer + holds, say), the session closed, and the client's `open()` stayed + pending until `handshakeTimeout` — ten seconds by default — because + nothing told the handshake its transport was gone. `secure()` gained + `cancel(error)`: the ws transport calls it from its close and terminate + paths, the WebTransport client from its teardown, and a pending `ready` + rejects the moment the connection does. The handshake timer is `unref`ed. +- **Five encryption seams could take the process down, or crash a handler.** + `sealedStore.set` threw synchronously when the keyring lost its current + key, out of the session manager's microtask flush (now `session.save`, + logged); a shared replay store that rejected (`replay.seen` over a Redis + that is down) was an unhandled rejection per sealed request (now a `503` + and one `encryption.replay` line — a request that cannot be vouched + fresh is not served); a primitive throwing inside `unwrap` was one too + (now a `500` and `encryption.unwrap`); a static-key derivation that + failed was cached as the answer for that kid forever (forgotten now); + and a sealer that could not seal — the same lost key — threw out of + `Broadcast.emit()` into the handler, or was filed as a serialization + problem (`backplane.seal` / `cluster.seal` now, and the event stays + local: never plaintext across the wire). +- **`connect()` with a transport list resolved unauthenticated when the + hook refused the first candidate.** The `authenticate` hook throwing on + candidate A was treated like A failing to open: the client advanced to + B with `#connected` still set from A, so B opened as a "reconnect", the + hook ran unawaited on the reconnect path, and `connect()` resolved with + a client the application had just refused. A candidate that never opens + still hands over to the next; a hook that refused after the transport + opened is the application's verdict, and `connect()` rejects with it — + the state a failed first connect leaves is reset the way `close()` + resets it (`#resetSession`). +- **`encryption.required` broke `attachConsumers` and `attachChannel`.** A + consumer binding attached its clients through `attach()` without + vouching for the transport, so under `required` every attach threw + inside the delivery path and every delivery settled nowhere — the queue + stood still with no line to say why; a raw data channel (DTLS end to + end) was refused the same way. `attachConsumers` now refuses to bind + without `encryption` (a `TypeError` at bind time), attaches a sealing + binding's clients as encrypted, dead-letters a plaintext delivery its + `acceptPlaintext` let through (`broker.refused reason: 'plaintext'`, + `400`) and settles a delivery whose `attach()` threw as a bounded retry + then dead; `attachChannel` vouches for the channel by default + (`encrypted: true`, `false` for one relayed in the clear). + `RpcServer.encryptionRequired` is the flag a binding of your own reads. +- **Kafka: `close()` deleted the queue's durable consumer group.** The + adapter deleted every group it had opened — to keep a backplane + instance's or a reader's empty group from lingering until + `offsets.retention` — and a queue's group was among them, committed + offsets included: the last instance's clean restart redelivered the + queue's whole retention. Only the groups the broker made for itself are + deleted now, and each with its consumer rather than at `close()`: a + catch-up page's group goes with the page, a tail's with the tail, and a + reader whose subscribe or run fails leaves neither a joined consumer nor + a group behind (it used to leave both, one per failed read). A group the + broker refuses to delete yet (`NON_EMPTY_GROUP`) is a debug line. +- **A `'latest'` feed read lost what was appended between `ready` and its + first `next()`.** `TopicTails` took a reader's start position from the + shared tail at the first `next()`, not at `ready` — and the tail had + already advanced past whatever arrived in between, so those entries, + sitting in the reader's own queue, were called "covered" and dropped. + The contract says entries appended after `ready` are read; the position + is fixed at `ready` now, on every adapter built on `TopicTails` (Redis, + NATS, AMQP, Kafka — `MemoryBroker` always did), and the log contract + pins it. +- **A feed read closed before its first `next()` held the live tail + forever.** `return()` on an async generator that never started runs no + `finally`, so a subscription refused at once (a stale `lastEventId`, a + snapshot that threw) left its reader on the shared tail — one broker + consumer per such subscription, for the life of the process — and the + same for a read whose iterator was taken and abandoned. The place is + released from the iterator's `return`/`throw` now, an abort lets go until + the body runs, a tail still positioning is stopped once it is (never in + the middle of the adapter's setup), and `brokerFeed` scopes every read it + opens to its own `AbortController`, aborted when the feed ends. + +### Security +- **A sealed dead letter carries its code outside the seal, not its + reason.** Under `encryption` a dead-lettered message keeps its body + sealed, but `x-wrpc-dead-reason` rides outside the seal — and held the + code and the handler's message, which is whatever the handler wrote, + the data the seal was for included. It is the code alone now; the whole + reason is on the `broker.dead` line and in `onDeadLetter`. The binding's + own refusals (`Sealed delivery refused`, `Plaintext delivery refused`) + keep their fixed text. +- **The sealed-envelope sender memory evicts the least recently heard + sender, not the oldest.** Past `maxSenders`, a new sender evicted the + first one in — the busiest, as often as not — which came back with a + fresh replay window, so a frame of its that had just been refused as a + replay opened a second time. The entry a message opens is moved to the + end now (+0.05 µs an open at 64 B, medians of five processes). And the + set of a process's own salts, kept to skip its echoes, holds the last 16 + rather than every salt of every reseed and rotation. +- **A full replay memory answers `503`, not a `409` that blames the clock.** + The built-in memory of sealed requests can be filled by anyone holding the + server's public key — which is public by design — and past its cap every + sealed request was refused `409`, which the client reported as "refused + as stale or replayed — check this device's clock". It is a `503` now (the + server cannot vouch the request fresh; retry later), with that message on + the client, and the encryption guide asks for a rate limit per client in + front of the server, sized under `max / (2·maxSkew)`. +- **`maxStreams` caps the binary streams one connection holds open.** A + peer announces a stream with one packet and the server held a readable + for it until a handler read it — with no bound: 20 000 announcements from + an unauthenticated WebSocket were 20 000 streams (about 45 MB). Past + `maxStreams` (256 by default, on `RpcServer` and `PeerHost`, in + `limits`) a `stream` packet answers `429`, logged as `stream.capacity` at + debug, like `maxCalls` and `maxSubscriptions`. +- **The assertion verifier backs off a failed key load, and a revoked key + stops verifying.** A `keys()` that failed was asked again by every verify + — 25 verifies in a burst were 25 calls to an endpoint that was down — and + a set was asked again only for an unknown `kid`, so a key the endpoint + stopped publishing verified for the life of the verifier. A failure is + now left alone for a second (at most `refreshInterval`), and a set is + asked again once it is `maxAge` old (600 000 ms by default; `0` keeps + the old behaviour), on `createAssertionVerifier` and `WrpcPeer`'s + `assertions`. +- **A mesh does not dial again a link an application closed.** A + `mesh.link(id).close()` with the member still in the room was undone + within half a second — the mesh (and the other side, which heard the + goodbye) dialled the edge again, and the member came back in `peers` with + a new `join`. A link that ended with a goodbye — `link.close()` on either + side — stays ended; one that ended by itself (a failure, a redial budget + run out, a refusal) is still dialled again under `relink`. The goodbye + goes through the signaling server while the channel resets go peer to + peer, so the other side can see its channels close first and start a + redial before the goodbye reaches it: the side that said goodbye ignores a + knock or an offer from that incarnation for 5 s (`rtc.signal.goodbye`, + debug), and dialling it again itself — `peer.connect(id)` — lifts that. + **Changed:** the WebRTC guide used to promise a redial after a + `link.close()` on the OTHER side, which is what undid a close. +- **The signaling unit's `maxRooms` holds for joins sent together.** The + count was checked before the awaited `allow` hook and the room written + after it, so joins sent at once all passed the check: ten joins under + `maxRooms: 2` were ten rooms. A room being joined now counts from the + check to the write. +- **A logout ends the session on every connection of the instance.** + `finalizeSession()` deleted the session from the store and dropped it on + the connection that called it, while another connection that had restored + the same token — a second tab, a second device — kept answering `session` + procedures as that user until it reconnected: the access gate looked only + at whether a session object was there. The other connections' copies are + now marked ended and the gate refuses an ended session (`403`); a handler + already running finishes. Across instances the store is the signal, on the + next restore — the sessions guide says so. +- **What the ws redial puts in the URL is named, and an ambiguous `wrpc.v1` + answer is said.** A browser client that offered `wrpc.v2`, carried its + `headers`/`meta` as subprotocol tokens and was answered `wrpc.v1` redials + with the connect-URL query — where access logs keep it — and only an + `authorization` header was named (`declared.exposed`); an `x-api-key` + went into the URL without a word. The redial now names every declared + header it moved (`declared.exposed`, `keys`). And a `wrpc.v1` answer is + ambiguous — a 2.x server without frames gives it too, and reads the + tokens — so a page that offered `wrpc.v1` alone (its own + `attachments: false`) is never redialled and against a 1.0 server lost its + labels silently: it says so once (`handshake.ambiguous`). The metadata + guide, the protocol's versioning section and the migration notes describe + both cases and the answer to each: `carrier: 'query'` for a 1.0 server, + `carrier: 'protocol'` to keep labels out of URLs. +- **An SSE channel reference whose secret is not ASCII is a 409, not an + unanswered request.** `holds` compared the secret's length in characters + and handed the bytes to `timingSafeEqual`, which throws when the byte + lengths differ — so a secret of 24 `é` against a 24-character channel + secret, on `GET {basePath}/events` (query or `x-wrpc-channel`) or a + channel POST, threw out of `handleHttpCall`: an unhandled rejection, and + a request nobody answered, from an unauthenticated peer. The lengths are + compared as bytes now, as `openId` in the broker always did. +- **`RpcServer.handleHttpCall` answers what throws while it routes.** Every + host hands it a request without a catch of its own (`void + rpc.handleHttpCall(call)` — the `Server` shell, the express middleware, + the fastify plugin, the uws engine, the broker binding), so anything a + peer's input made throw before a refusal existed for it was an unhandled + rejection and a hung request. It is caught once in the core now: logged + (`http.failed`, error, with `err`), counted on the calls series, and + answered `500` — unless an answer or a stream had already started, which + is left as it was. One case of the adapter spec runs it over every host. +- **A declared header name must be a header name; `_` counts as `-`.** The + deny lists of declared headers are anchored patterns, so `x-real-ip ` (a + trailing space), `x forwarded-for` and `remote_user` passed them and + reached the application beside the real headers — and `remote_user` is + `remote-user` to nginx with `underscores_in_headers`, to CGI and to the + frameworks that fold the two. A name that is not an RFC 9110 token is now + dropped, and `_` is read as `-` before the lists are checked, on the ws + handshake and inside a sealed request alike. +- **A sealed HTTP request can no longer declare what an identity-aware + proxy says about the user.** The names an OAuth2 proxy, AWS ALB OIDC, + Google IAP, Azure Easy Auth or an Apache/nginx auth module set about the + user they authenticated (`remote-user`, `x-auth-request-*`, + `x-amzn-oidc-*`, `x-goog-authenticated-user-*`, `x-goog-iap-*`, + `x-ms-client-principal*`) and the Fastly/Fly client-ip spellings were + added to the handshake's deny list only. Inside a sealed request they were + still the sender's: behind a proxy that set `remote-user: alice` on the + outer request, a page declaring `admin` inside was `admin` to the handler. + Both lists are now built from one set of names in `src/rpc/reserved.js` + (`AMBIENT_HEADERS` adds them), so the inner value is dropped and the outer + one stands, and a test keeps every name in both. +- **A packet `id` is a string of at most 255 characters, or a number.** The + dispatcher checked an id by truthiness and echoed whatever came: an array + nested 10 000 deep in a 20 KB POST threw out of the answer's + `JSON.stringify` (`Maximum call stack size exceeded`) where nothing caught + it, and at 1000 deep the array came back as the answer's id. Any other id + is now refused id-less — `500`, `packet.unknown` — like any packet that is + not one, on every carrier. A number is still answered on: a 1.0 client + whose own `generateId` counted sent one. +- **A frame sent together with the upgrade request no longer ends the + process (node engine).** The bytes behind a handshake (`head`) were + parsed inside the `Connection` constructor: a frame breaking the protocol + there — RSV1 without a negotiated extension, as Autobahn's 3.x cases send + — emitted `'error'` before anyone could listen, `handleUpgrade` re-emitted + it on a `WebsocketServer` none of the shells listens on, and the throw + left the http server's `'upgrade'` listener. They are handed back to the + socket now (`unshift`) and read through `'data'` once the connection is + listened to: that connection is closed `1002`, nothing else. A throw in + `handleUpgrade` itself is logged (`ws.upgrade`, error) and emitted only to + a listener. The same change makes VALID frames sent that way arrive — + they were parsed before the `'connection'` event and lost. (The uws engine + does not deliver frames sent in the same write as the upgrade request + either — uWebSockets.js drops them itself; nothing fails there.) +- **`Accept-Encoding` is read in one pass and capped at 256 bytes.** The + coding scan searched for the next `;` from every token, so a header of + 8000 one-letter tokens cost 1.9 ms of CPU per request and 16 KB of commas + 3.8 ms — unauthenticated, once `http.compression` was on. The next `;` is + remembered now (one pass), and a header longer than 256 bytes is treated + as absent: identity is always a correct answer, and a real header is 20–100 + bytes. `bench/http-compression.js` carries the adversarial rows. +- **Docs without dangerous promises, and one warning.** The encryption + guide's table said a sealed HTTP request hides "the headers"; `Cookie` + and `Set-Cookie` stay on the outer request and response by construction + (script cannot set HttpOnly), and the guide now says so — and says that + under a terminator you do not trust the cookie is therefore the wrong + session credential (`bearerTransport()`/`payloadTransport()` carry it + inside). A server built with `encryption` and the cookie transport warns + once at construction (`encryption.ambient-session`). The end-to-end + section names what it left out: whoever hands out public keys is in the + middle (pin, compare, or exchange out of band), and a stolen recipient + seed forges as well as reads under `senderKey`. The seed exists for + portability; a browser that never needs it keeps a non-extractable + `x25519().generateKeyPair()` in IndexedDB and hands the pair over (JSDoc + and typings say so). The compression guide's custom-codec example checked + the inflated size after inflating — too late for a bomb — and now refuses + before, with the output buffer as the bound. +- **The pure-JS inflater's cost per dynamic block is bounded by its + header, not by the codes it declares.** `inflateRaw` built a decoding + table of `1 << maxLen` entries for every dynamic block — 128 KB, and as + many writes, when a block declared a 15-bit code — and a block that emits + nothing is outside `maxOutput`'s reach, so a stream of such headers cost + seconds of CPU per megabyte. The table is two-level now (zlib's scheme): + a 12-bit root plus sub-tables a prefix-free code bounds by itself, an + eighth of the old worst case per block and the same whatever the block + claims. Decoding is at parity or better on every row of + `bench/deflate-js.js`, which gained a many-blocks row and a Huffman-only + row that runs every rare symbol through the sub-table path. The + `./deflate` budget is 5 KB (was 4; the entry is 4.1). +- **A key id withdrawn from a live key provider stops working at once.** + The server derived its static key pairs once per kid and answered from + that cache before it asked the keyring, and the envelope sealer kept a + known sender's subkey the same way — so with a provider (`{ current(), + get(kid) }` over a KMS) a kid taken off the ring because its key leaked + went on finishing Noise handshakes, opening sealed requests and opening + envelopes sealed under a remembered salt (which is on the wire) until the + process restarted. The ring is asked first now, every time: one + synchronous lookup per handshake or sealed request, and one per envelope + from a known sender (2.6 µs an open before and after on a ring, +0.04 µs + through a provider). A session already established keeps its own keys. + The encryption guide gained "Rotating the server key": an old pin works + only while its key is on the ring, so dropping one is when its clients + stop connecting — and what to do when the reason is a leak. +- **Client: `static encrypts` is checked by deed, and a per-request + transport is checked for its key up front.** The client refused + `options.encryption` for a transport without the static, but a static is + inherited: a subclass of the ws transport with an `open()` of its own + passed the check and could open a session nobody sealed. A session + transport (`encrypts = true`) that says `'open'` with no `encryption` set + is now terminated before `'open'` is announced, the `authenticate` hook + runs or a packet leaves — `connect()` rejects with a `TypeError` (or moves + to the next fallback candidate), and nothing reconnects. The http and sse + transports declare `encrypts = 'request'`: an option with no `serverKey` + (the `NN`, `XX`, `NNpsk0` patterns) is refused for them when the list of + candidates is read, not on the day ws is unreachable and the fallback + nobody tested is taken. `ClientTransport` types the static and the + instance field; the transport contract suite checks both. The sse + browser entry's budget is 28 KB (was 27: it is the main entry plus the SSE + client, sat 11 bytes under, and the check is +0.1 KB in the shared core). +- **Sealed envelopes: `maxSenders`, and a sender is known by its cipher + too.** A receiver remembered senders under (key id, salt) and looked a + known one up without the frame's suite byte, so a copy of a known + sender's envelope with that byte changed was handled differently by an + instance that knew the sender and one that did not; the cache key is + (key id, suite, salt) now — such a copy derives another key and does not + open, on every instance. And the cap on remembered senders, a constant + 1024, is the `maxSenders` option of an envelope `encryption` (rooms, cluster, + brokers): a forgotten sender comes back with an + empty replay window, which a broker RPC address with more client + processes than that reached by itself. The guides and the protocol + reference now state the edges of the replay memory — per sender, in + memory, empty after a restart, bounded by `maxSenders`, no time in the + frame — instead of "a replay is dropped". No change to the wire or the + additional data. +- **`cluster.secret` refuses a replayed or transplanted envelope.** The + HMAC covered an envelope's bytes — not when or where they were seen — so + anything that could read the backplane and write to it (a compromised + broker, a client with too wide an ACL) could publish a signed + `disconnect` again, move an addressed `sendTo` or `join` to another + instance's inbox, or replay a dead process' `hello` to wipe the presence + of the one that replaced it. A signed envelope now carries the sender's + counter, channel and clock inside the signature, and a receiver keeps a + sliding window of 1024 counters per life of each sender: a repeat, an + envelope on a channel it was not signed for, one outside `maxSkew` + (30 s — also the bound on what a node that was not listening can be fed) + and one from a life older than the one being followed are refused, + counted (`wrpc.cluster.verifications`, outcome `replay`) and logged as + `cluster.replay` with the `reason` — one warn per sender and reason each + `presenceTimeout`, debug in between. `cluster: { replay: 'accept' }` is + the opt-out for a rolling upgrade from 1.x (see Changed (breaking)). The + cluster guide's "Trusting the backplane" and the protocol reference + describe the fields and the four reasons. +- **A refusal is one log line, at the level the refusal deserves, with the + peer's strings clipped.** Every error answer the server sent also wrote + `rpc.error` at `error`, so the 429 and oversize-batch refusals the + dispatcher deliberately logs at `debug` — reachable by any peer in a loop — + came with an error-level twin, and a frame that would not parse was + reported three times (`packet.malformed`, then `packet.unknown` for the + empty packet it fell through as, then the answer). `Client.error()` takes + a `level` now: `debug` from every site that already wrote its own line, + `error` where nothing else said why (a handler that threw). A malformed + frame is one `warn` and the same id-less 500. The method, id and packet + type in those lines are clipped to 128 characters (`clip` in + `src/rpc/errors.js`) — a log line is no longer as long as `maxPayload`. +- **Declared ws headers: an exact deny list for identity-aware proxies, an + opt-in allowlist, and `meta.declared` naming what came from the + declaration.** A hostile page could declare the headers an OAuth2 proxy, + an ALB, IAP, Azure Easy Auth or an auth module set ABOUT the user they + authenticated (`x-auth-request-*`, `x-amzn-oidc-*`, + `x-goog-authenticated-user-*`, `x-goog-iap-*`, `x-ms-client-principal*`, + `remote-user`) and the client-ip spellings of Fastly and Fly, and a + handler reading them as the proxy's word could not tell; they are refused + by name now, exactly (`x-auth-request-`, not `x-auth-` — an application's + `x-auth-token` passes). `new RpcServer({ declaredHeaders: + ['authorization'] })` allowlists the names a declaration may carry at + all (`cookie` never), and `context.meta.declared` lists which of + `meta.headers` the peer declared rather than the connection carried — + the one thing a handler needed to tell a label from a fact. + `readHandshake()` takes the allowlist and answers the list too. +- **The sealed-request replay memory refuses at its cap, and checks before + it evicts.** Full of live entries, the built-in cache evicted the oldest + to make room BEFORE looking the id up — so past ~167 requests a second + (the default 100 000 entries over twice the five-minute skew) a replayed + request whose entry had just been evicted was accepted again, and the + "accepted once" guarantee did not hold under exactly the load an + attacker brings. A known live id is a replay whatever the fill now, and + at the cap the cache refuses (`409`, `encryption.replay.overflow` once + per ten seconds with the count, no line per request) unless + `overflow: 'evict'` asks for the old behaviour by name; + `encryption.replay` takes the built-in cache's `{ max, overflow }` + beside a shared `{ seen }`. The guide has the sizing formula. +- **Secrets stay out of logs and transport labels.** A broker RPC session + id — what its client speaks under, a credential — was the transport's + `source` (every log line and span of that client) and the `session` + field of `broker.rpc.session.*`; both carry a 12-character SHA-256 + fingerprint of it now. The client's `handshake.fallback` warning carried + the error undici raised, whose message repeats the offending header's + VALUE (a bearer token, say); it carries the error's name only. A + declared `authorization` header about to ride the connect URL — the + query carrier, or WebTransport, which has no other — is said once as + `declared.exposed` on every path, not only the browser ws one. +- **A session finalized on one connection or instance is not resurrected + by a write on another.** A handler still holding the session — on the + connection that was not the one signing out, or on another instance — + wrote its state with an unconditional `store.set`, which brought the + deleted row back, token and all. Every write after the one that creates + the row is now `set(token, state, { create: false })`; the memory store, + the Redis store (`SET … XX`) and `sealedStore` answer `false` for a row + that is gone, and the session ends there (`session.save`, + `reason: 'gone'`). `sealedStore` treats a row still under an older kid + — or, adopting, in plaintext — as the row to move. The `SessionStore` + contract gained the option; a store that ignores it writes as before. +- **`sealedStore`: a sealed record under the raw token is refused, and a + fleet mid-rotation keeps one row per token.** With `acceptPlaintext` on, + the raw token is a row name the store still answers to, and a sealed + record placed there — a row copied from another session's slot, what + someone holding one would try — was adopted as that session's state, + re-sealed under the token and the original deleted; only a session-shaped + object is taken for a plaintext session now, anything else is a missing + session and a `session.unsealed` line. Two instances on different + `current` kids over one store left two rows per token, each reading its + own kid's stale one: a write removes the token's rows under the other + kids, and a migration deletes the old row only while it is still the row + that was read — a newer state an instance on the old kid wrote meanwhile + is moved instead of dropped. +- **WebSocket engine: the inbound inflate queue has backpressure.** Under + context takeover or `async` inflation every compressed frame the peer + sent started its inflate at once, each holding its output until + delivered in order — thousands of them in one segment were thousands of + inflates in flight, bounded only by the segment queue's `maxBuffer`. At + most 32 messages (4 on the threadpool path) or `maxPayload` of + compressed bytes are in flight now: past that the socket is paused and + the frame loop stops, taken up again once the queue drained below 8 + messages and half the bytes. An internal hold, apart from the + application's `pause()` (`resume()` while held keeps the socket paused, + a hold released under an application pause leaves it paused), and + `isPaused` reports both so the heartbeat does not take a held + connection for dead. +- **The signaling relay has ceilings, one lookup per room, and bounded + queues on the peer.** One connection could join rooms without bound, + replicate any amount of join `data` to every instance, relay a signal + of any size the server's `maxMessage` let through, and make every + signal to a peer this instance did not hold a cluster-wide fetch of its + own — a burst of them to a peer that left was a burst of fetches on + every node. `createSignalingUnit({ limits })` — `maxRooms` 32 (429), + `maxDataBytes` 4096 and `maxSignalBytes` 65536 (413, the signal's size + bounded without serializing it first), `maxResolves` 4 (503) — is on by + default, each `false` to switch off; the room lookup behind `resolve()` + is single-flight per room, so every signal waiting on it shares one + fetch. On the peer, the signals held for a peer whose `accept()` is + still thinking are capped at 64 and the candidates held before a + description at 256, the rest dropped and said once + (`rtc.signal.overflow`). +- **WebTransport and WebRTC: a consumer that never drains is disconnected + at `maxBackpressure`.** Neither carrier had the cap the WebSocket engine + has behind its high-water mark, so a peer that stopped reading could + hold as much as the process had — every broadcast to it queued without + bound. `WtSocket`/`attachSession` and the client's `wt` options take + `maxBackpressure` (64 MiB by default, `0` off), checked before a frame is + queued, as the engine counts it: one frame past the cap on an empty queue + is sent, a queue the peer never drains terminates the session (the + error's `code` is `backpressure`). `ClientRtcTransport`, + `RtcPeerTransport`, `attachChannel` and a `WrpcPeer`'s `host` take the + same option; a write that would put the channel past it — its buffer, the + codec's pending bytes and the message together — is refused and the + channel closed locally, a redial over a link, the end of a raw channel. +- **Broker RPC: a sealed session takes no plaintext frame.** Under + `encryption: { keys, acceptPlaintext: true }` — the rollout's second + deploy — a plaintext frame carrying the right session id and the next + sequence number walked into a session a sealed `hello` had opened, on + both ends: a `packet` was dispatched as the client's, a `bye` ended the + session. A session welcomed sealed now takes sealed frames only; a + plaintext one naming it is logged `broker.rpc.refused` with + `reason: 'downgrade'` and dropped without consuming its sequence number. +- **Broker RPC: a captured `request` cannot be replayed to another + instance.** The envelope's replay window is per sender and per process, + and the service address is consumed by every instance, so a sealed + frame replayed later reached an instance whose window had never seen it + — and was served again. A sealed `request` or `hello` now carries the + sender's clock inside the seal (`wrpc-t`), refused past + `encryption.maxSkew` (five minutes) as `stale`; behind more than one + instance, `encryption.replay` takes a shared "seen once" memory — + `seen(id, ttl)`, as `createReplayCache()` answers it — keyed by the + envelope's own header, and one that cannot be asked serves nothing + (`broker.rpc.replay`). A 1.x sealed client sends no clock and is refused + by a 2.0 service: upgrade the clients with the service. +- **`attachConsumers` under `identity.trust: 'token'`: a logout takes + effect, and a failed restore is not remembered.** The per-token client + cache kept a client, and with it the session it had restored, for the + life of the binding: a token whose session had since been destroyed kept + running deliveries as that user, and a token whose first restore failed + — a store that was down, a token naming no session — was cached as an + anonymous client that refused every later delivery `403`. A cached + client is now re-validated after `tokenTtl` (default 60 s, `0` disables), + `consumers.forget(value)` drops one at once (the logout hook), and a + restore that did not happen is never cached. A client dropped + mid-delivery finishes it first. +- **WebRTC: roster data could impersonate another peer.** `PeerHost.attach` + spread what a peer said about itself at `join` OVER `peer` and `room`, so + a member joining with `data: { peer: 'alice' }` ran every handler on the + other peers as `context.session.data.peer === 'alice'` (and could plant + `claims` where no assertion was verified). `peer`, `room` and `claims` + are now written over the roster data, the built-in signaling unit refuses + a `join` whose `data` names one of them (400), and `members` answers a + member of the room only (403) — the roster of a room could be read, and a + cluster-wide fetch triggered, by anyone who knew its name. +- **WebRTC framing: empty fragments slipped under the reassembly cap.** A + peer could send non-final fragments with no payload for as long as it + liked — each one a buffered view, none of them counted by + `maxReassembly` — and with one-byte fragments the cap was met only after + 16 million views (~200× the bytes sent). A fragment that is not the last + must now carry a byte (`FramingError` code `'empty'`), and the fragment + count of one message is capped as well: `framing.maxFragments`, code + `'fragments'`, default `maxReassembly` in 1 KiB pieces and never under + 1024. And a peer whose `a=max-message-size` is under 1 KiB — a demand for + a send and a view per few bytes, on both sides, for every packet — is + refused: the link fails (`'message-size'`) instead of connecting. + `protocol.md#webrtc-framing` says so. +- **WebRTC trust assertions bound to the FIRST `a=fingerprint:` line.** DTLS + binds to the media-level line (RFC 8122 §5), so a relay in the signaling + path could keep the honest session-level line — the one the token names + — and append its own certificate where DTLS looks: the assertion + verified, the DTLS handshake was with the relay. `sdpFingerprint` now + answers a fingerprint only when every `a=fingerprint:` line of the + description, at either level and of any algorithm, is that one; a + description naming two is refused (`'fingerprint'`), on a dial and on an + ICE restart alike (the pinned shortcut needs a single fingerprint to + compare), and `stamp()` refuses to sign an ambiguous local description. + The `algorithm` parameter of `sdpFingerprint` (unreleased) is gone with + it. `protocol.md#webrtc-assertions` says every line MUST equal `fp`. +- **Binary attachments lifted bytes out from behind `toJSON()`.** The walk + that finds the bytes in a packet went through every enumerable field of + every object, `toJSON()` or not — so a domain object that keeps a Buffer + (a password hash on a user entity) and projects it away in `toJSON()` + had the Buffer sent anyway, as an attachment, on every result and event + that carried the object. An object with a `toJSON()` is opaque to the + walk now: its projection is what travels, exactly as `JSON.stringify` + would write it (the projection itself is never called by the walk), and + the frame skips inherited keys like JSON does. +- **A packet with back-references hung the event loop.** `hasBytes` kept + no memory of what it had visited, so a parent whose children point back + at it was walked once per path — 12 children, 12^16 paths before the + depth cap. The fast walk now hands over to one with an ancestor stack + the moment it reaches the depth cap; a cycle answers `false` and the + packet goes to `JSON.stringify`, whose "Converting circular structure" + `TypeError` is the one the caller saw before attachments existed + (`encodeAttachments` throws the same one). The frame's path bookkeeping + is one mutable path copied at each leaf instead of an array per + container: 414 → 137 µs on a 30 KB result with an attachment + (`bench/attachments.js`). +- **The worker proxy did not know the attachments frame.** A call with + bytes from a page arrived at `WrpcClientProxy` as a frame, which it + refused as "not JSON" (an unhandled rejection in the worker, a call that + waited out its timeout on the page); an answer with bytes from the + server was broadcast to EVERY tab instead of the one that asked, and a + batch or an unparseable frame the same. The proxy routes a frame by the + packet inside it now, exactly like its JSON twin, forwards the page's + frames upstream as they are, and answers a call it could not forward + (no connection, no packet) with a coded error instead of silence. + `failPackets` — the http/sse leg's "this request died" answer — reads a + frame too, so a call with bytes that failed no longer waits out its + `callTimeout`. +- **An injected `Cipher` sealed envelopes under an all-zero key.** The + envelope sealer behind `rooms.encryption`, `cluster.encryption`, every + broker binding's `encryption` and `sealedStore` wiped the derived subkey + right after `cipher.key(subkey)` — correct for the built-in ciphers, + which copy it into a `KeyObject`, and exactly wrong for a cipher that + keeps the reference, which is the shape the guide shows (`key: (raw) => + ({ seal: … xchacha20poly1305(raw, …) })`): every message was sealed with + 32 zero bytes, and its opener, seeing the same zeros, opened it. From + `key(raw)` on the bytes belong to the cipher (the contract says so now); + the sealer wipes only what a cipher built by wrpc was handed. The probe + that admits a cipher is a round trip under a random key — one that + cannot open its own seal is refused where it is configured — and the + guide no longer suggests an injected cipher for a Noise session, whose + server half takes the two built-in names only. +- **A Noise client delivered frames behind one that did not open.** With + a cipher over `crypto.subtle` the sealed frames arriving right behind a + tampered one were already in the inbound queue, resolved after it had + failed, and were handed to the application in order — after the session + had been declared over and the socket told to close. Nothing is + delivered, stepped or sealed once the connection failed; the counter + advancing on a failed decrypt (a departure from Noise §5.1) is documented + as the deliberate choice it is. +- **A sealed event stream's key was a function of the request alone.** It + was `Export("wrpc sse stream")` from the HPKE context, and its frames + counted from zero — so the same open request, replayed onto another + instance (whose replay memory is its own) or accepted twice by a shared + replay store that evicted early, opened a second stream under the SAME + key with the SAME nonces: two ciphertexts of the `ready` frame under one + (key, nonce), which is the one thing an AEAD must never be given. The + server now draws 32 bytes per stream and carries them as the `n` + parameter of the stream's `Content-Type`; the key is + `Expand(Extract(enc ‖ n, Export(…)), "key")`, and a client refuses a + sealed stream without a 32-byte `n`. A wire change, before the format is + frozen. `protocol.md#sealed-requests` says so. +- **A sealed request could declare what the proxy knew about it.** The + headers inside a sealed request were laid over the outer request's, any + name, no cap — so a page behind a proxy that sets `x-forwarded-for`, + `cf-connecting-ip` or `x-real-ip` on the outer request (which the proxy + can see) could name any address inside (which it cannot), and a rate + limit, an allowlist or an audit log keyed on those, or `sse.clientAddress` + counting channels per address, saw the sender's word. What the connection + or a proxy says about the sender — the forwarded chain, the client-ip + spellings, `host`, `origin`, `via`, `sec-*` — is never taken from inside + now, whether or not the outer request carried it; the framing names are + the outer request's; an outer `cookie` (the HttpOnly one script cannot + set) wins over an inner one; and the inner header is refused above 16 KiB + before it is parsed. The list lives in `src/rpc/reserved.js`, beside the + ws handshake's, and the core hands it to the sealing layer. +- **`encryption.required` did not reach the fastify adapter's REST routes.** + The adapter mounts the router's REST routes natively and runs them + through `delegatedContext`, outside `handleHttpCall` — where the `426` + gate lives — so a server that answered every packet, socket and + core-served route with "encryption required" served those routes in the + clear, and added a client for each. `delegatedContext` refuses under + `required` now, before a client exists, with the same `426` and the same + `encryption.refused` line; the adapter's onSend/onError/onResponse + wrappers leave a refused request's payload alone instead of throwing + the refusal a second time into the error reply. +- **The broker binding's goodbye went past the sealer.** Every frame of a + sealed broker session was sealed except `bye`, so a sealed client + dropped the binding's goodbye as `unsealed` — on `stop()`, on a server + close, on `client.close()` from a handler — and went on waiting for a + session that was over. It rides `sealFrame` like the rest now, the + reason inside. +- **An SSE channel opened without a cookie or a bearer was held by its id + alone.** The channel's identity key is `''` for such a channel, so the + authorization check compared `''` with `''` and the id — minted by the + application's `generateId`, which the production guide happily shows as + a counter — was the whole credential: whoever guessed it could POST on + the channel, replay its stream, and take over the session it signed + into. The id stays the application's (a uuid, a cuid, a counter — the + format is its business, and `generateId` still covers it); what + authorizes a request is now a **channel secret** the server draws (18 + random bytes, base64url) and hands out after the id in the `ready` frame + — ONE string, `.`, in the field 1.0 named `channel`. + A re-attach or POST presents it back whole in the one `x-wrpc-channel` + header (split at the last dot; no new header, so a CORS + `Access-Control-Allow-Headers` stays what it was), compared in constant + time; a request without the channel's secret is answered `409` exactly + like an unknown id. The cookie/bearer binding stays on top. **Not a wire + break**: a 1.0 client presents whatever its `ready` frame's `channel` + held, so it presents the secret without knowing there is one. + `protocol.md`'s SSE section says so. +- **A response header could split the response.** `context.http.setHeader`, + a procedure's static `http.headers` and the fastify adapter's reply seam + wrote whatever value they were handed: on node the engine refused a + value with a line break and the request hung, unanswered, until the peer + gave up; on uWebSockets.js — which writes what it is given — the value + reached the wire, `\r\n` and all, as a second header or a second + response. Every seam now checks the name (an RFC 7230 token) and the + value (node's own rule: no CR, LF or NUL) and throws where the value was + set, a handler error the caller sees as `500`; a second `setHeader` under + another spelling of the same name replaces the first instead of sending + both; and a host that still refuses a response answers a bare `500` and + closes the request rather than leaving it hanging. +- **A signed feed id positioned a reader on any feed sharing the secret.** + `brokerFeed`'s `secret` signed the bare id, so a token issued by the + `orders` feed verified on `invoices` when both used one `FEED_SECRET` — + the documented setup — and a subscriber of one could resume from a + position on the other. The MAC binds the id to its topic now, under a + versioned, length-prefixed layout; a token from one feed is a `400` on + another. A MAC change: ids clients hold from before are refused once, + and `onGap` answers as for any rotation. +- **WebTransport: a peer could fill the server's memory through streams it + never named.** The server read every incoming unidirectional stream to + its end and buffered the chunks of any whose open packet had not passed + — for an id the peer never named, forever — from any session, before any + authentication and past `maxMessage`; a stream from a peer that never + announced streams was read the same way. A stream is now read no further + than the read that carried its header until its open packet passes (the + rest waits in the peer's stream under QUIC's own flow control), at most + `maxHeldStreams` (32) are held at once, a held stream is cancelled + unread after `holdTimeout` (10 s), and an empty id, a second stream for + an id, or any stream from a peer that announced none is cancelled at + once — each a `wt.mux.refused` line with the reason, the session left + alone. Both options ride `attachSession` / `WtSocket` and are validated. +- **WebTransport: one silent session blocked every session behind it.** + `acceptSessions` attached sessions one at a time, so a peer that + connected and never opened its control stream held the accept loop for + the whole `acceptTimeout` (10 s) — and a session whose `ready` never + settled held it forever, outside any timeout. Sessions attach + concurrently now, at most `maxPending` (256) in their handshake at once + — the next is refused `503` and `wt.accept.saturated` is logged once per + episode — under ONE `acceptTimeout` that covers `verify`, the session's + `ready` and the first stream; `stop()` closes what is still handshaking + (`1001`) and `done` waits for it; `attachSession` takes a `signal` for + the same. `onClient` may therefore be called out of arrival order. + +## [1.0.0] - 2026-08-23 ### Added @@ -532,6 +4080,23 @@ only by adapter tests; never a runtime dependency). ### Fixed +- A cluster node that asked a peer for its presence `state` and never got + the answer (the reply travels at-most-once too — the node's own inbox + channel may not be subscribed yet on the first digest) kept its + `syncing` flag forever and ignored every later digest, so its view of that + peer stayed wrong until restart. The wait is now bounded: after two + presence intervals without an answer the sync is asked again. Found by + the multi-node bench on its first run. +- A ping arriving after the engine had FAILED the connection (a protocol + error, an oversized or undecodable message) was still answered with a + pong; RFC 6455 7.1.7 says nothing after the failure is acted on. Autobahn + cases 4.1.3–4.2.5 flagged it; the full suite now passes. A ping after an + app-initiated `close()` is still answered, as before. +- The uws engine never compressed an outbound frame: `UwsSocket.send` did not + pass uws' third `compress` argument (which defaults to false), so a + configured `compression` only ever applied to inbound messages while + `capabilities.deflate` reported true. It now asks uws to compress, and + `tests/adapters/uws.test.js` asserts RSV1 on the wire. - `client.sessionReady` is assigned **before** the `onConnect` hooks run — the documented `await client.sessionReady` recipe used to await the constructor's resolved default and see `session === null`; the packet-POST @@ -596,6 +4161,18 @@ only by adapter tests; never a runtime dependency). ### Changed +- Writes to `session.state` coalesce: the assignments of one turn become one + `store.set` on a microtask (`create()` still persists the initial state + immediately), and a session finalized in the meantime is not written back + (`Session.end()`, called by `finalizeSession`). One round trip per turn to + a shared store instead of one per assignment. +- Unicast frames are one contiguous buffer up to 16 KiB — header and payload + in a single socket write, text utf8-encoded from a module scratch buffer + instead of an intermediate `Buffer.from` — and header + payload writes + above it. `sendText 4 KB` 735,099 → 1,671,520/sec; 200 B unchanged. +- `docs/guide/performance.md` no longer claims the fan-out frame was encoded + once — before this release only the JSON was; utf8, deflate and framing + were per recipient. - Server transports carry a `kind` — `ws`, `http`, `sse` or `event` — as a metric attribute and log field. - Every shell and adapter funnels its option bag through `rpcOptions()` diff --git a/CLAUDE.md b/CLAUDE.md index 9183d6d..813f20b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,15 +4,27 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project -wrpc (`@alexify/wrpc`) is a fast, zero-dependency, WebSocket-based RPC protocol for Node.js and browsers. - -**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/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`/`handleHttpCall`/`to`/`except`/`broadcast`), `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/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. +wRPC (`@alexify/wrpc`, Web RPC) is a fast, zero-dependency RPC protocol for Node.js and browsers (WebSocket, HTTP, SSE, WebTransport, WebRTC, worker ports, message brokers). In prose the product is written **wRPC**; the package, the `wrpc` CLI, every wire name (`wrpc.v2`, `x-wrpc-*`) and the classes stay as they are. + +**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 — it is at **revision 2** (`wrpc.v2`): revision 1 (what 1.0 speaks) plus the framed messages, i.e. a packet whose bytes travel as bytes. A 2.x peer speaks BOTH and the revision is negotiated per connection, so a frame is sent only where the peer said it reads one and a 1.0 peer is spoken to as 1.0 was: the ws subprotocol (`wrpc.v2, wrpc.v1` offered, the newest selected — `#negotiateProtocol` in `src/websocket/server.js`, `negotiateProtocol` in `src/adapters/uws.js`), `wrpc-version` on an HTTP response plus the request's `Accept` (`readsFrames` in `src/transport.js` — never a `wrpc-version` request header: CORS), `v` on a worker port's first ping/pong (`negotiate` in `src/transport.js`), nothing on SSE (the `ready` frame's `channel` is the whole `.` reference, opaque to a 1.0 client) and always 2 on the carriers 1.0 never had. There is NO revision option: `attachments: false` or a packet codec means "this end speaks revision 1" (`revisionProtocols` in `src/adapters/common.js` narrows a shell's engine to `wrpc.v1`), and `revision` is a read-only getter on `WrpcClient`, the server `Client` and `RpcServer`. The backplane (rooms/cluster) has no handshake and stays on flags. **A change a 1.0 peer cannot ignore belongs behind the revision**, and `tests/interop/` runs the PUBLISHED 1.0 (devDependency alias `wrpc-v1`) against the tree, both ways, to keep that true. Every `##` section 1.0 did not have carries a `since 2.0` badge, and `## Changes since 1.0` tables what an older peer does with each addition (a guard in `tests/package/consistency.test.js` keeps both). Module layout under `src/`: + +- `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 AND on ws from Node (`src/client/wsHandshake.js`: the built-in undici WebSocket takes `{ protocols, headers }`; a constructor that throws on the init bag falls back to the browser half), `wrpc.h.`/`wrpc.m.` subprotocol carrier tokens on ws from a browser (`wsHandshake.browser.js` — a page can set NO handshake header but `Sec-WebSocket-Protocol`; verified by PoC, and `ws`/socket.io cannot either), ONE 2048-byte budget for both tokens, the Bearer lift `wrpc.bearer.` outside it, and the `wrpc_h`/`wrpc_meta` query only by `carrier: 'query'`, under an empty `protocols` offer (a token needs a protocol the server can echo), or on WebTransport. +- `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: `declaredData` over the `x-wrpc-meta` header, its per-key spelling, the already-decoded `wrpc.m.` offer and the `wrpc_meta` query — token transports receive its OUTPUT, never re-run it; Buffer-free, because it is bundled into the WebRTC browser entry), `handshake.js` (Node-only: the ws handshake's declared bags from every carrier — `carriedBags` base64url-decodes the `wrpc.h.`/`wrpc.m.` subprotocol offers under ONE `metaMaxBytes` budget, `sanitizeDeclared` is the one sanitizer + `RESERVED_DECLARED` deny list for the offer and the `wrpc_h` query, a carrier is chosen never merged, and `readDeclared`/the exported `readHandshake(req)` is the single read both `attachSocket` and a `verifyClient` gate go through), `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, { identity, generateId })`; it remembers peer addresses and drops signals not addressed to its id/instance), `peer.js` (`WrpcPeer`: the links, the signals, a peer held for its `accept()`, incarnations, the assertion layer; `connect()` from the higher id sends a `connect` knock so there is never an offer glare), `peerLink.js` (`PeerLink`: one connected peer, both directions, the redial/knock cycle — it and `Mesh` are handed a narrow PORT built by the peer over its private state, never the peer itself), `ids.js` (the require-free leaf: `isPeerId`, `MAX_ID_LENGTH`, the one `deferred()`), `mesh.js` (`Mesh`: host Clients kept in `mesh:`, so broadcast/ask are one `Broadcast` fan-out; a member whose signaling dropped is `away`, its link kept), `assertions.js` (trust assertions — the JWS ES256 verifier over `crypto.subtle`, `sdpFingerprint`; a token binds a peer id to the DTLS fingerprint of the pc that sent the description, verified in `WrpcPeer` before the link sees it and before `accept()`; `PeerHost` `trust: 'assertion'` requires the claims). Node-only, in `index.js` only: `assertionIssuer.js` (`createAssertionIssuer`/`generateAssertionKeys`, required by `signaling.js` for its `assert`/public `keys` methods — the browser bundle carries the verifier only), `signaling.js` (`createSignalingUnit`/`createSignalingHooks` — a router fragment relayed through `RpcServer.sendTo`; a peer's id is what the `identity` strategy answers — the signaling client id by default — kept in a per-unit registry; every roster member and signal carries the routable `address` and the signaler's `instance`, so the relay never resolves an app-level id on the hot path) 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/webtransport/` — WebTransport (HTTP/3), behind the `@alexify/wrpc/wt` subpath (experimental) for the SERVER half; the client transport (`transport: 'wt'`, `src/client/webtransport.js`) is in the base entry so a `['wt', 'ws']` fallback list needs no import. The server is DI-only: Node speaks no HTTP/3 (`node:quic` was spiked and rejected), so the session comes from an injected implementation and is validated structurally. + - `port.js` — wrpc's OWN W3C-shaped session contract (`isWtSession`/`isWtStream`/`isWtDatagrams`); binds to no library. + - `channel.js` — `WtChannel`, the ONE core both ends run on: the control stream's framing, the outbound queue and its water marks, `maxBackpressure`, the per-message codec behind a `Sequencer`, datagrams (64 in flight, then dropped), the side-stream mux, graceful close (stream first, then the session, bounded by `closeTimeout`). Its callbacks are POSITIONAL on purpose — option keys do not minify, and the main browser entry had no bytes for them. `closeQuietly` lives here, not in `port.js`. + - `framing.js` (length-prefixed frames, kinds 0–4) and `streams.js` (`StreamMux`: one uni stream per binary wrpc stream, refused past `maxHeldStreams`/`holdTimeout`, never read before its open packet) — browser-budgeted with `channel.js`. + - `socket.js` (`WtSocket`, the `WrpcSocket` shim an `RpcServer.attachSocket` takes) and `src/client/webtransport.js` (`ClientWtTransport`) are thin adapters over `WtChannel`; `tests/wt/channelContract.js` is the behavioural suite both satisfy. + - `transport.js` (`ServerWtTransport`, registered as `ServerTransport.transport.wt`), `index.js` (`attachSession`, `acceptSessions` — concurrent, `maxPending`, one `acceptTimeout` per handshake, `onError` contained — and `fromFails`/`failsRequestCallback`), `quico.js` (`fromQuico`: the pure-JS stack's node-stream shape bridged to the port). +- `src/broker/` — the message-broker family behind `@alexify/wrpc/broker` (experimental), Node-only. A broker is `{ name, backplane?, log?, queue?, direct?, close() }`; `port.js` holds the capability contracts in prose (their executable form is `tests/broker/{backplane,log,queue,direct}Contract.js`) and the structural checks, `memory.js` (`MemoryBroker`, the reference implementation of all four, microtask delivery, `trim`), `tail.js` (`TopicTails`: one shared live reader per topic, catch-up joined through `covered`/`advance` cursors — a Kafka cursor is a partition-offset vector, so the yielded id is the ADVANCED cursor), `ids.js` (`encodeToken` for broker alphabets, `signId`/`openId` for peer-supplied event ids, body/header normalization into null-prototype maps), `retry.js` (the ack/retry/release/dead table, `retryOn: [408, 429, 500, 503]`), `adapter.js` (the leaf the four adapters share: the `x-wrpc-attempt`/`-redelivered`/`-dead-reason` and `wrpc-text` header names, `idFactory`, `failedRead`/`guardedRead` for a `log.read` that is refused or position-checked, `checkConsume`/`checkListen`, `runDelivery` — deliberately NOT a shared delivery object: settlement is where the brokers differ). Delivery settlement is `ack()/retry({delay})/release()/deadLetter(reason)` with an ADAPTER-owned attempt counter (`x-wrpc-attempt`): RabbitMQ 4 does not count a requeue. `src/rpc/` never requires `src/broker/`; the bindings stand on `RpcServer.attach(transport, { session | request | persistent: false })`, the `'draining'`/`'close'` events, `RpcServer#limits` and the telemetry writer's `withMessagingSpan`/`recordBroker*` (`src/telemetry/server.js` is bundled into the webrtc browser entry through `PeerHost`, so its bytes count there). The scenarios on top: `feed.js` (`brokerFeed` — a subscription handler over a log, resume tokens signed with `secret`, `onGap` snapshots), `consumers.js` (`attachConsumers` — one long-lived client per binding attached with `persistent: false`, every delivery dispatched through `handleRpc` against a ONE-PROCEDURE ROUTER VIEW, so the dispatcher itself needed no change), `publisher.js` (`createPublisher` — `emits` checked by name, PRODUCER span, `tp`/`ts` in the headers), `rpc/` (`attachBrokerRpc` + the `broker` client transport: stateless requests go through `handleHttpCall`, sessions through `attach` with per-direction frame numbers; `rpc/frames.js` is the one place a frame is read — `frameBody`, the `wrpc-enc` rule — and built — `sessionFrame`: kind, seq, compress-if-smaller, seal — for BOTH ends). +- `src/broker/sealing.js` — sealed broker messages (Node-only): `createBrokerSealing(option, name, { layer, replay, text })` over the keyring envelope sealer of `src/encryption/envelope.js` with `echo: true` (a service may consume what it published). The HEADERS move inside with the body — `sealed(u32 hLen ‖ JSON(headers) ‖ body)`, opened back into a null-prototype STRING map — so a client's `authorization`/`x-wrpc-meta` no longer rest in the broker; the outer message keeps `wrpc-sealed: ` plus what a carrier routes by. RPC (`rpc/frames.js` `sealFrame`/`openFrame`, layer `'broker-rpc'`, replay window ON): context = `address\0kind\0correlationId\0seq`, `wrpc-kind`/`wrpc-seq` stay outer and are re-attached from the OUTER message after opening (an inner header can never forge them); both `attachBrokerRpc` and `ClientBrokerTransport` (`static encrypts = 'keys'` — the client option is the KEYRING form here, which `WrpcClient.#checkEncryption` lets through only for such a transport) seal every send and open every receive; a sealed `hello`/`request` is what passes `encrypted: true` to `rpc.attach`/`handleHttpCall`. Events (`publisher.js`, `feed.js`, `consumers.js`, layer `'broker-log'`, replay OFF — a log is re-read, a delivery redelivered): context = the topic/queue, `text: true` base64 because the log/queue contracts only promise a STRING survives (a Redis stream field); a feed skips what does not open (`feed.refused`), a consumer dead-letters it with 400 (`broker.refused`); `sealing` is passed INTO `bindConsumer` — the delivery handler is not in `attachConsumers`' scope. +- `src/broker/{redis,nats,amqp,kafka}/` — the adapters, one subpath each (`@alexify/wrpc/broker/`), every client INJECTED and duck-typed. Redis: pub/sub + Streams (`XREADGROUP`/`XAUTOCLAIM`, a ZSET for delayed retries), lists for RPC groups. NATS: core subjects + JetStream (`headers()` is a package export, so it is injected separately; without the JetStream factories the broker has backplane + direct only). AMQP: a direct exchange, stream queues (`x-stream-offset`), quorum queues with a TTL retry queue, a fanout exchange per RPC address (RabbitMQ 4: a requeue does not count an attempt, and a transient non-exclusive queue kills the CONNECTION). Kafka: `shape.js` normalizes kafkajs vs `@confluentinc/kafka-javascript` (config nesting, the throwing `events` getter, metadata shape, the join signal); feeds are single-partition by default and seek to a pre-join watermark; NO `direct`. +- `src/wire.js` — the import-free wire-name constants (`wrpc_h`, `wrpc_meta`, `x-wrpc-meta`, `x-wrpc-meta-`, re-exported `x-wrpc-channel`, the revisions `WRPC_V1` `wrpc.v1` and `WRPC_V2` `wrpc.v2`, and the subprotocol CARRIER tokens `wrpc.h.`/`wrpc.m.`/`wrpc.bearer.` with the `CARRIER_PROTOCOL` pattern both negotiators — `src/websocket/server.js` and `src/adapters/uws.js` — filter out of the offer and refuse to echo), required by every module that speaks them; a spelling change is one file, not a multi-file grep. Also the framed-message kinds of a `0x00`-marked binary WebSocket frame (`FRAME_MARK`, `FRAME_PACKET_COMPRESSED` 3, `FRAME_CHUNK_COMPRESSED` 4 — a Node ws client's per-message compression, negotiated on ping/pong — the ping's `enc` is the client's id list, the pong's the ONE id to send with — and inflated in `RpcServer.attachSocket`; kinds 1–2 reserved). - `src/logging.js` — the structured logger writer (Console- and pino-shaped loggers normalized to one `child()`/level facade; the frozen writer table lives here). - `src/telemetry/` — the injected-OpenTelemetry writers (`shared.js` with the `tp`/`ts` W3C propagation fields, `server.js`, `client.js`); everything duck-typed, `@opentelemetry/*` only ever a devDependency. +- `src/attachments.js` — binary attachments: `hasBytes` (the per-packet walk every send pays once the feature is on; `attachments: false` skips it), `encodeAttachments`/`decodeAttachments` (the `0x00 01` frame — `u32 headerLen`, JSON `[packet, [[path, length], …]]` with `null` at each byte leaf, the buffers after; the decoder refuses forbidden keys, non-containers, non-null placeholders, depth > 32 and length mismatches, and COPIES every buffer out of the frame — `SegmentQueue` hands over views). Browser-budgeted (the client's send path). Wired into `ServerTransport.send`, `Client.sendEvent`, `Broadcast.emit` (one BINARY prepared frame per fan-out; the backplane half is refused), the HTTP batch/REST paths, `handleMessage`/`handleBinary` (a frame is a packet), the client's `send`/batch/`message` paths, and refused on SSE. +- `src/rpc/dictionary.js` — `buildDictionary(router)`: a preset dictionary for the per-message codecs from the router's introspection (signature/schema field names, `unit/method` targets, `unit/event` names, the packet skeletons — least to most frequent, the front cut past 32 KiB, deterministic so a fleet agrees on the id). Node-free; exported from the main entry and the WebRTC browser barrel. `src/compression/dictionary.js` is the Node codec over it (`dictionaryCompressor`, zlib `dictionary`, id `deflate-raw+dict:` from `dictionaryId` in `compression/index.js`, threshold 64 B). - `src/rpc/subscriptions.js` — the subscription primitives: `tracked(id, data)` (labels a value for resume), `createEventLog({ size })` (ring buffer whose `since(lastEventId)` returns what was missed or `null` for a real gap), and `runSubscription` (the generator pump: one `data` packet per yield, transport backpressure honoured, exactly one `end`). The push→pull `createEventStream` lives in `src/utils.js` because the client needs it too. - `src/sse/` — Server-Sent Events as a full transport behind the `@alexify/wrpc/sse` subpath: `server.js` (`ServerSseTransport` + `SseChannels`, the channel registry with `retention` and `Last-Event-ID` replay) and `client.js` (browser-safe `fetch` transport + incremental `SseParser`, registered as `WrpcClient.transport.sse`). A channel is `GET {basePath}/events?channel=` plus POSTs carrying `x-wrpc-channel`, both bound to ONE server-side `Client`. Text-only: binary streams are refused on it. The seam that makes it possible is the optional `call.stream({ status, headers })` on the abstract HTTP call, implemented by `nodeStream()` in `src/adapters/common.js` for every host that hands over a node `ServerResponse`. - `src/scaling/` — the rooms backplane behind the `@alexify/wrpc/scaling` subpath: the structural contract (`publish(channel, message)` / `subscribe(channel, handler) -> unsubscribe` / `close()`, checked by `isBackplane`), `memory.js` (`MemoryBackplane`, in-process, microtask delivery) and `redis.js` (`createRedisAdapter({ pub, sub, prefix })`, ioredis-shaped, clients **injected** — see "No runtime dependencies"). Delivery is at-most-once by design. @@ -27,12 +39,15 @@ wrpc (`@alexify/wrpc`) is a fast, zero-dependency, WebSocket-based RPC protocol - `src/query/index.js` — the TanStack Query bindings behind the `@alexify/wrpc/query` subpath: `createQueryUtils(client, { prefix, queryClient })` returning `queryKey`/`queryOptions`/`mutationOptions`/`subscriptionHandler`. Option **factories**, in the style of tRPC v11 — never hooks — so one file serves React/Solid/Svelte/Vue Query and query-core alike. It `require()`s **nothing** (that is what keeps it at ~1 KB min+gzip and browser-safe), takes the client and the `QueryClient` by injection with structural validation, and resolves `['unit','method']` paths *lazily* inside `queryFn` because `client.api` only exists after `load()` and is rebuilt on every reconnect. Path lookups use `Object.hasOwn` on both hops: a unit object is an `Emitter`, so `['chat','on']` would otherwise resolve to `Emitter.prototype.on`. - `src/auth/index.js` — token strategies behind the `@alexify/wrpc/auth` subpath, browser-safe and require-free like `src/query/`: client token stores (`memoryStore`/`webStorage`/`cookieStorage`; a `Map` already satisfies the `get`/`set`/`delete` contract), `bearerAuth()` (composes the client's `headers`+`authenticate`+`refresh` options around a store), and the server halves `bearerTransport()`/`payloadTransport()` for the `sessions: { transport }` injection (`isTokenTransport` in `src/rpc/sessions.js`; the cookie default is byte-identical). Deliberately outside the base browser bundle — the seams live in the core, the strategies do not. - `src/cli/types.js` — the `wrpc types --out api.d.ts` codegen, behind the `bin/wrpc.js` shim. Consumes `system/introspect` and writes the same shape of contract interface a user would hand-write for `connect()`; the `signature` descriptor format it reads is specified in `docs/reference/protocol.md`. Everything beyond argv is injected (`main(argv, { log, error, fetch, write, version })`), so the whole command is testable in-process. **Nothing from the wire is interpolated verbatim**: names go through `quoteKey` (JSON escaping, downgraded to single quotes only when nothing needed escaping) and type names are matched against a closed allowlist, with a depth cap on nesting. +- `src/compression/` — per-message compression for the transports with nothing under them (WebTransport and WebRTC), and the seam the Node↔Node carriers share: `index.js` holds the structural `Compressor` contract (`isCompressor`: `id` + `encode(bytes)` + `decode(bytes, maxOutput)`, either may answer a promise), `normalizeCompression` (strict; off in every spelling of off; `codec` is a name, a `Compressor` or a **preference list** of them — a name the platform lacks is skipped in a list, refused alone; the result carries `codecs`/`ids` plus the head's `codec`/`id`/`threshold`), `negotiate(local, peerIds) -> { encode, decode } | null` — **a sender compresses with the first codec of ITS OWN list the peer announced**, so the two directions choose independently, no frame names its codec, and a peer's list is bounded (16) and only ever compared, never a key — and re-exports the `Sequencer` that keeps messages in order around an asynchronous codec (synchronous fast path when nothing is in flight — `bench/message-compression.js`; the class itself is the `src/sequencer.js` leaf, so the `./encryption` entry has the ordering without the negotiation); `ids.js` is the leaf (`dictionaryId`, `DICTIONARY_ID_PREFIX`, `isPromise`) that `src/deflate/` requires INSTEAD of `index.js`, so the `./deflate` entry carries no negotiation; `native.js` / `native.browser.js` are the platform codecs, named as `CompressionStream` names its formats (`'deflate-raw'` — what `true` means — `'brotli'`, `'zstd'`), swapped via `package.json#browser`: the Node half takes the MEASURED levels of `bench/algorithms.js` (deflate 3 — the knee, level 4 is slower and larger; Brotli quality 4 — zlib's own default, 11, is 33 ms on 27 KB; zstd 1 with the size pledged), detects zstd through the injectable `hasZstd(zlib)` (node:zlib has it since 22.15 / 23.8 and `engines` is `>=22`) and is synchronous BY MEASUREMENT (`bench/zlib-async.js`: zlib's threadpool hand-off costs ~20 µs a call, so it loses below ~256 KB on deflate and at every size on inflate), with `async: { threshold }` handing only encodes that large to the threadpool, and only on the carriers with a `Sequencer` (WT, RTC; `sync.js` refuses a codec that declares `async`); the browser half asks the `CompressionStream` constructor which formats it has and answers null for the rest; `codecs.js` (Node-only, exported from the main entry) is `deflateCompressor`/`brotliCompressor`/`zstdCompressor` — a level, threshold and `async` per codec, the id naming the FORMAT never the level; `sync.js` is the Node-only half for the Node↔Node carriers — `normalizeSyncCompression` (probes EVERY codec of the list; a promise-answering one is a TypeError), `headerNegotiator` (the broker binding's comma-separated `wrpc-enc`, last header memoized because a stateless request names its list every time), `encodeIfSmaller`/`decodeOrNull` over one list entry, and `createEnvelopeCodec` for the STRING backplane (base64 under a `wrpc-enc::` marker; nothing is negotiated there, so it ENCODES with the head and DECODES any codec on the list, markers matched whole and longest first because an id may hold a colon — a change of codec is a rollout without a lost envelope; injected into `RoomsBackplane`/`Cluster` by the core so the browser-bundled `rooms.js` carries none of it). The wire names are codec-neutral: `caps.enc` (WT capabilities, WebRTC description), `KIND_*_COMPRESSED`, `FLAG_COMPRESSED`, `FRAME_*_COMPRESSED`, `parser.compressed`. Browser-budgeted: it lands in the main entry through the wt client and in the webrtc entry through both channel halves. HTTP and SSE compression live elsewhere, in `src/contentEncoding.js` (Node-only): `encodings` is the SERVER's preference list of `Content-Encoding`s (`'gzip'` default, `'br'`, `'zstd'`, each with its knobs, or a custom `{ encoding, encode, createStream? }` whose `encode` may answer a promise), `pickEncoding` is one hand-rolled scan of the peer-controlled `Accept-Encoding` with a bit per encoder (2× the split-and-map spelling, `bench/http-compression.js`; q=0 refuses, `*` covers the unnamed, other weights are not ranked — nginx's reading), `encodedWriter` wraps the SSE writer around a per-event-flushed stream and SSE refuses a coding with no `createStream`; gzip stays the SSE recommendation (Brotli/zstd save nothing on small events and hold 570/930 KB per open response against 320). `permessage-deflate` has no algorithm to choose — RFC 7692 is the only extension a browser implements. +- `src/encryption/` — application-level encryption behind the `@alexify/wrpc/encryption` subpath (experimental; the third subpath with a `browser` condition), opt-in and NEVER a substitute for TLS — it is for where TLS ends before the data does (backplane, broker log, TLS-terminating proxy, a relay that should not read). Phases land one commit each (keyring envelopes for rooms/cluster/brokers, a sealed session store, canonical Noise on ws/wt, HPKE per request on HTTP/SSE, pairwise E2EE helpers). Landed so far — the primitives: `contracts.js` (the structural seams `isCipher`/`isCipherKey`/`isDh`/`isKeyProvider` and the ONE failure `OpenError` — a wrong key, nonce, bit or length are indistinguishable on purpose; a `Cipher` is `{ id, keyLength, nonceLength, tagLength, key(raw) -> { seal(nonce, pt, aad), open(nonce, sealed, aad) } }`, the prepared key existing for the browser's one-time non-extractable `CryptoKey` import — on Node it measures as a wash), `aead.js` / `aead.browser.js` (the platform pair swapped via `package.json#browser`: `'aes-256-gcm'` + Node-only `'chacha20-poly1305'` SYNCHRONOUS over `node:crypto` — 2.4 µs against 14 µs through subtle on the same machine, `bench/encryption.js` — vs promise-answering `crypto.subtle` AES-GCM, where ChaCha is `null` under `optional`), `dh.js` / `dh.browser.js` (`x25519()`, id `'25519'` as Noise names it — the SECOND platform pair of the directory: `node:crypto` KeyObjects on Node because subtle's X25519 prints an `ExperimentalWarning` on early Node 22 (22.10 yes, 22.23 no; run the suite under `~/.nvm/versions/node/v22.10.0` to see it), `crypto.subtle` in a browser, where the public key of a seed is the function applied to the base point, so a private key is never exported; a low-order point is refused with one error whether the platform throws or answers zeros), `hkdf.js` / `hkdf.browser.js` (`createKdf()` — SHA-256/HMAC/HKDF with Extract and Expand APART, because Noise and HPKE need them apart and subtle's own `'HKDF'` only does both), `keyring.js` (`normalizeKeys`: one key → kid `'0'`, `{ current, ring }`, or a SYNCHRONOUS provider; a kid is `[A-Za-z0-9._-]{1,32}`, peer-controlled, held in a `Map`, and SELECTS the key — never trial decryption), `bytes.js` (Buffer-free plumbing, `counterNonce` = 4 zero bytes ‖ u64 BE as Noise gives AESGCM, `randomSource`/`requireSubtle` — there is NO `Math.random` fallback in this directory, a missing `crypto.getRandomValues`/`crypto.subtle` throws where the factory is called). Everything but the AEAD, DH and HKDF pairs is ONE file for both platforms (Node ≥ 22 has the browser's WebCrypto), with `subtle` an injectable default parameter exactly like `src/webrtc/assertions.js`. Node-only on top: `envelope.js` — sealed envelopes for the Node↔Node carriers (`normalizeEnvelopeEncryption` → `{ keys, cipher, seal, acceptPlaintext, replayWindow }`, `createEnvelopeSealer({ encryption, layer })`, `ReplayWindow`): frame `u8 version ‖ u8 suite ‖ salt16 ‖ u64 counter ‖ ct ‖ tag`, NEVER a random nonce — each process derives its own key with `hkdfSync(master, salt, 'wrpc v1\0kid\0cipher')` and counts under it, reseeding at 2^32 (GCM's random-nonce bound is hours away on a busy fan-out); AAD = `'wrpc-sealed v1\0layer\0kid\0channel'`; the receiver's per-(kid, salt) entry — derived key + sliding replay window — is remembered ONLY after a tag verified (a flood of made-up salts evicts nobody), FIFO-capped at 1024; both built-in suites are always openable so a cipher change is config, not rollout; an injected cipher is probed for synchrony like a codec in `normalizeSyncCompression`; the sealer answers `null` for its OWN salt, which spares a publisher decrypting + parsing every echo. It is composed with compression by `src/rpc/envelope.js` (`createEnvelope`, built by the core and injected into `RoomsBackplane`/`Cluster` in place of `createEnvelopeCodec` — identical to it when `encryption` is off): ONE frame, `wrpc-sealed::base64(sealed(u8 flags ‖ [idLen ‖ codec id] ‖ body))`, compress-then-seal with a single base64; `decode` answers the text, `null` (the callers' `*.encoded` warning) or `undefined` for a refusal it already logged itself (`*.unsealed`, `*.open` with a log-only `reason`) — and rooms/cluster name a still-sealed envelope `*.sealed` AFTER decode, because a compression-only codec passes through what is not its own. BYTES cross the backplane through the same composer: `createEnvelope` is now ALWAYS an object (`withBytes(inner)` — identity for text when no option is on, so the no-option path costs one call and one `charCodeAt`), with `encodeBytes(envelope, channel)` = `wrpc-bin:` + base64 of `encodeAttachments(envelope)` (the encoder is generic over any object) or, sealing, the same bytes inside the sealed frame under flag bit 1; `decode` may therefore answer an OBJECT, which `rooms.js` already tolerated — its `*.sealed` check is guarded by `typeof text === 'string'`. `Broadcast.emit` passes `binary: true` instead of refusing; `RoomsBackplane.publish` feature-detects `encodeBytes` and still warns `backplane.bytes` for a registry wired by hand. An instance with NO codec must still answer `null` for a `wrpc-enc:` message (`isEncodedEnvelope`) — the loud `*.encoded` warning is a tested behaviour. The envelope seam takes the `channel` as a second argument for this (`cluster.js` binds it per subscription). Cluster HMAC is untouched: sign → seal, open → verify. Rollout is three deploys (`seal: false, acceptPlaintext: true` → `acceptPlaintext: true` → neither) because pub/sub is at-most-once. `store.js` — `sealedStore(store, { keys, cipher?, acceptPlaintext?, logger? })` over the structural `SessionStore`: row key = `HMAC-SHA256(hkdf(master, 'wrpc store-index v1\0kid'), token)` base64url (the raw bearer token used to BE the Redis key), value = `{ v: 1, kid, s }` (an object, because the Redis store rejects non-objects) sealed by the same envelope sealer under layer `'store'` with `echo: true` (a store reads back what it wrote — the own-salt shortcut is a backplane thing) and `replayWindow: false`, AAD = the row key; a miss under the current kid walks `keys.kids` and MIGRATES the row (so rotation is not a mass logout — and the index key rotates too), `acceptPlaintext` does the same for a row the unwrapped store wrote; a row that does not open is `null` + one warning, the token never logged. SESSION encryption (client↔server, ws): `noise.js` (one file, both platforms — Noise rev 34 under CANONICAL names `Noise_{NN,NK,XX,NNpsk0}_25519_{AESGCM,ChaChaPoly}_SHA256`, verified against the cacophony vectors in `tests/encryption/vectors/noise.json` on BOTH primitive halves; every primitive call is awaited because subtle answers promises; `CipherState` takes its counter SYNCHRONOUSLY so calls made in order are sealed in order and a `Sequencer` only keeps results so; the ChaChaPoly nonce is little-endian, AESGCM big-endian; deterministic `REKEY` every 2^20 messages; `ephemeral` is a test-vector-only option), `session.js` (the shared framing: first handshake `00 05 ‖ ver ‖ nameLen ‖ name ‖ kidLen ‖ kid ‖ noise`, later `00 05 ‖ noise`, sealed `00 06 ‖ AEAD(u8 inner ‖ payload)`; prologue = `'wrpc.v1\0kind\0' ‖ hello header` — `PROLOGUE_LABEL` in `src/encryption/session.js`, the label of THIS framing and deliberately not the negotiated core revision, so the vectors do not move with it; the inner frame uses `Buffer.write` behind a `typeof Buffer` guard — 0.4 vs 1.0 µs on 1 KB, `bench/encryption.js`), `statics.js` (ONE secret per kid → `HKDF(secret,'wrpc noise static v1')` and `…'wrpc hpke static v1'` — never one key for two protocols; the pinned bundle is `kid:noisePub:hpkePub`), `client.js` (`createEncryption` → the INJECTED object `{ param: 'wrpc_e', secure(link) }`; never falls back to plaintext; verifies the server key BEFORE sending XX's third message, which carries the client's own static; inbound frames are taken through a promise chain until it drains, because the server is established the moment it wrote its last message and its sealed frames arrive while the client is still finishing), `server.js` (Node-only: `normalizeServerEncryption` — protocols held in a `Map` by NAME, a peer's name is looked up never parsed, refused never negotiated — and `SealedSocket`, an `EventEmitter` wrapper around the ENGINE socket that re-emits `('message', data, isBinary)`, so `attachSocket`, the dispatcher, compression, attachments and chunks are untouched and one wrapper serves node, uws and wt; it offers NO `sendPrepared`/`sendUnreliable`, so a broadcast to sealed clients is N seals; sends before the handshake are queued up to 256; `ready` never rejects — it resolves `null` after closing; one close reason `'encryption'`, 1002 crypto/parse vs 1008 policy). Core wiring: `RpcServer` option `encryption`, `#sealSocket` (the mode is announced by `wrpc_e=1` in the connect URL because the SERVER may send first — `wantsEncryption(meta.url)`), the restore thunk awaits `sealed.ready` and sets `client.encryption`, `attach(…, { encrypted })` + `handleHttpCall` refuse under `required` (426), `encryptionKey()`. Client wiring: `static encrypts = true` on a transport that carries it, `WrpcClient.#checkEncryption` over every fallback candidate up front, `worker` + `encryption` is a TypeError (the option belongs to `WrpcClientProxy`, which forwards it). On WebTransport (`src/client/webtransport.js`) the same injected object runs over the control stream after the caps exchange with kind `'wt'` in the prologue, and the three ways around the channel are switched off under it: `StreamMux` is `null` (as under a codec — so no `streams` cap, chunks ride the control stream), no datagram writer/reader, `#compression` forced `null`; `#attach` returns the handshake promise so `open()` resolves only once sealed. The SERVER needed no wt change at all — `attachSession` → `attachSocket` wraps the `WtSocket`, and `SealedSocket` offering neither `streamControl` nor `sendUnreliable` is what turns the server's mux and datagrams off. `tests/websocket/protocolClient.js` now keeps the query string of its URL. PER-REQUEST encryption (http): `hpke.js` (one file — RFC 9180 base + psk modes, `dhKem(dh, kdf)` = DHKEM(X25519, HKDF-SHA256) with `deriveKeyPair`, `isKem` the seam where ML-KEM is injected, `HpkeContext` seal/open by sequence-XOR nonce + `export`; verified against the RFC vectors in `tests/encryption/vectors/hpke.json` on both primitive halves; AEAD ids 2/3 only — AES-128 is not in `aead()`), `http.js` (shared + client: the format `ver ‖ aeadId ‖ kidLen ‖ kid ‖ enc ‖ ct`, plaintext `u32 headerLen ‖ JSON{m,u,h,t} ‖ body`, response `nonce32 ‖ AEAD(u32 ‖ JSON{s,h} ‖ body)` keyed RFC 9458 §4.4-style from `context.export`; `sealedFetch` wraps a `fetch` and answers a real `Response`, so `ClientHttpTransport` changed by one block; a non-sealed response is an ERROR whatever its status), `httpServer.js` (Node-only: `createHttpSealing` → `unwrap(call, corsHeaders)` answers the INNER call marked `encrypted` whose `respond` seals — outer status always 200, `Set-Cookie` kept OUTER because script cannot set HttpOnly — plus `createReplayCache`; freshness = `t` within `maxSkew` and `enc` accepted once; refusals go through the core-injected `refuse(call, headers, status, reason)`: bare status, reason in the log). Core: one block in `handleHttpCall` after the origin check — discovery `GET /encryption-key`, then `unwrap`, then the `required` gate (426) — so packet, batch and REST modes are untouched. The fastify adapter registers the `application/wrpc-sealed` buffer parser (else 415) and the `/encryption-key` route (one static segment the parametric route never matches); `tests/adapters/boots.js` forwards `encryption`. SSE rides the same binding and needed NO change in `src/sse/server.js`: `unwrap` gives the inner call a `stream()` (only when the request's `accept` names `text/event-stream`; the key is `context.export('wrpc sse stream')`, exported in `unwrap` because the core opens the stream synchronously) whose writer seals every written chunk — events, the `ready` frame with the channel id, the heartbeat comment — as `data: ` under a counter nonce, type `text/event-stream; wrpc-sealed=1` (a Content-Type PARAMETER because that is the one response header a cross-origin page can always read); sealed at the WRITER, so the replay ring stays plaintext in process memory and `resume()` is untouched, and every (re)attached stream has its own key and counts from zero — which replaced the plan's "nonce = event id" design and its channel-key persistence; the client half is `openSealedStream` in `http.js` (a `ReadableStream` that turns the outer events back into the real stream's bytes for an `SseParser` that never knew; an injected comment line is skipped, anything that does not open errors the stream). `unwrap` deletes the outer `accept-encoding` from the merged headers: a content coding inside a sealed answer would reach a client that reads the bytes as they are. `ClientSseTransport` wraps its injected `fetch` exactly like the http transport. `hkdf.js` / `hkdf.browser.js` became the THIRD platform pair by measurement: a sealed request is 219 µs both ends with node:crypto HMACs vs 565 µs over subtle (a threadpool hop per HMAC), a Noise handshake 291 vs 745. END-TO-END helpers (`e2ee.js`, shared): `createIdentity(seed?)` (the SEED is the secret to keep, and exists for PORTABILITY — the same identity from the same seed on another device; a browser that never needs that keeps a non-extractable `x25519().generateKeyPair()` pair in IndexedDB and hands it to `createOpener`/`senderKey` directly), `createSealer`/`createOpener` — one HPKE context PER MESSAGE (`enc ‖ ct`; two clients may never be online together, so nothing is kept in step), HPKE AUTH mode when a `senderKey`/`senderPublicKey` is given (an anonymous or differently-signed message does not open, and an opener expecting nobody does not open an authenticated one either — the modes differ in the key schedule), `info` = `'wrpc e2ee v1'` ‖ the application's purpose string. Deliberately NOT a messaging protocol (no recipient FS, no group key, no replay memory — the sender-key helper of the plan was CUT; MLS/Double Ratchet ride the same bytes) and, in a browser, no defence against the origin that ships the script. `hpke.js` has all four RFC 9180 modes for it (`mode = (auth ? 2 : 0) + (psk ? 1 : 0)`). `browser.js` is the shared barrel, `index.js` spreads it and adds the Node-only halves as they land. +- `src/deflate/` — the pure-JS DEFLATE codec behind the `@alexify/wrpc/deflate` subpath (browser-reachable, its own budget, deliberately outside every other entry): `inflate.js` (RFC 1951 complete — stored/fixed/dynamic, table-driven, a preset dictionary preloading the output window, `maxOutput` cap, coded `DeflateError`s), `deflate.js` (LZ77 over hash chains against the dictionary, ONE fixed-Huffman block, stored fallback — fixed codes match dynamic on small messages, `bench/deflate-js.js`), `index.js` (`createDeflateCodec`: the `Compressor`, id shared with `dictionaryCompressor` for the same bytes, the `nativeAbove` hand-off to CompressionStream in a browser). Tests in `tests/deflate/` are an interop matrix against zlib and the platform streams plus a fuzz corpus — the codec is the smaller half. - `src/runtime/node.js` / `src/runtime/browser.js` — the one platform split needed beyond chunks: `generateUUID` (`node:crypto` vs `globalThis.crypto`), swapped via `package.json#browser`. - `src/index.js` — the RPC-level barrel (client + server + streams + utils; websocket engine lives in the `./ws` subpath). `src/index.browser.js` — a slimmer barrel excluding server/transport (anything touching `node:http`/`node:https`). **No runtime dependencies.** An earlier pass ported code structurally from `metarhia/metacom`, which depends on `metautil`. Rather than install `metautil` (breaking the zero-dependency guarantee), the handful of functions actually used (`Emitter`, `jsonParse`, `generateUUID`, plus a few single-file-use helpers like `parseCookies`/`parseHost`/`receiveBody`/`split`/`parseParams`/`isError`) were copied in directly and adapted. `package.json` has no `dependencies` field at all — keep it that way; anything reused from another package must be copied in, not installed. -This rule extends to the adapters: **`uWebSockets.js`, `fastify`, `fastify-uws` and `express` are devDependencies used only by the adapter tests, and must never become runtime dependencies (not even `peerDependencies`/`optionalDependencies`).** The same holds for the other injected or compared packages: `ioredis` (only `tests/scaling/redis.integration.test.js`, which is skip-guarded on `REDIS_URL`), and `socket.io`/`socket.io-client`/`@trpc/server`/`@trpc/client` (only `bench/support/rpc-stacks.js`). Every adapter takes its host by injection — `createUwsEngine({ uws: require('uWebSockets.js') })`, `wrpcFastify` receives the fastify instance from `register()`, `createWrpc()` returns middleware the app mounts — and validates that injection structurally (duck typing, e.g. `isEngine`, `isTemplatedApp`) rather than by `instanceof` against an imported class. Nothing under `src/` may `require()` a framework. +This rule extends to the adapters: **`uWebSockets.js`, `fastify`, `fastify-uws` and `express` are devDependencies used only by the adapter tests, and must never become runtime dependencies (not even `peerDependencies`/`optionalDependencies`).** The same holds for the other injected or compared packages: `ioredis` (only `tests/scaling/redis.integration.test.js`, which is skip-guarded on `REDIS_URL`), `wrpc-v1` (the PUBLISHED 1.0 under an npm alias — `npm:@alexify/wrpc@1.0.0` — only `tests/interop/`, the other end of the revision-negotiation matrix), `node-datachannel` (only `tests/webrtc/node-datachannel.integration.test.js`, skip-guarded on `WRPC_RTC`; the WebRTC port in `src/webrtc/port.js` is wrpc's own W3C-shaped structural contract and binds to NO Node implementation — `createW3cAdapter(require('node-datachannel/polyfill'))` is one injection among any), `@fails-components/webtransport` (+ its native `-transport-http3-quiche`) and `quico` (only `tests/wt/fails.integration.test.js` / `tests/wt/quico.integration.test.js`, skip-guarded on `WRPC_WT`), the broker clients (see Commands), `@tanstack/query-core`, `pino`, `ws`, `@fastify/websocket` and the `@opentelemetry/*` packages (injected in tests only), `socket.io`/`socket.io-client`/`@trpc/server`/`@trpc/client`/`@grpc/grpc-js`/`@grpc/proto-loader` (only `bench/support/rpc-stacks.js` and `bench/support/http-servers.js`), and `autocannon` (only `bench/http-comparison.js`). The rule is the list's point, not the list: **every entry of `devDependencies` is test-, bench- or docs-only, and `package.json` has no `dependencies`, `peerDependencies` or `optionalDependencies` field** — a package named here that is missing from `package.json`, or the reverse, changes nothing about it. Every adapter takes its host by injection — `createUwsEngine({ uws: require('uWebSockets.js') })`, `wrpcFastify` receives the fastify instance from `register()`, `createWrpc()` returns middleware the app mounts — and validates that injection structurally (duck typing, e.g. `isEngine`, `isTemplatedApp`) rather than by `instanceof` against an imported class. Nothing under `src/` may `require()` a framework. Package manager is **pnpm** (`packageManager: pnpm@10.34.5`). CommonJS throughout (`require`/`module.exports`), no build/transpile step — `src/` ships as-is (same philosophy as the sibling `@alexify/kerberos` project). @@ -45,15 +60,23 @@ node --test --test-name-pattern="..." # filter tests by name node --test tests/adapters/*.test.js # adapter suites (node --test takes files, not dirs) pnpm test:types # type-check tests/*.test-d.ts against index.d.ts via tsd pnpm test:coverage # c8 coverage over src/ (thresholds: 95% lines/statements, 90% branches, 95% functions) +pnpm test:ci # test:coverage + --test-timeout=300000 --test-force-exit — what CI's test job runs; never the local default (a leak must stay visible) +pnpm test:coverage:floors # per-FILE floors for the directories new in 2.0 (scripts/coverage-floors.js); reads coverage/tmp, so run it right after test:coverage. A floor only moves up pnpm test:perf # 1 GiB stream memory guard (tests/perf/, non-.test.js so node --test skips it) -pnpm lint # oxlint src tests scripts bench bin -pnpm format # oxfmt src tests scripts bench bin (format:check for CI) +WRPC_RTC=node-datachannel node --test tests/webrtc/node-datachannel.integration.test.js # WebRTC port contract against a real implementation (manual, like Redis) +WRPC_WT=fails node --test tests/wt/fails.integration.test.js # WebTransport over @fails-components/webtransport (native binary) — manual, not in CI +WRPC_WT=quico node --test tests/wt/quico.integration.test.js # …and over the pure-JS quico — manual, not in CI +pnpm lint # oxlint src tests scripts bench bin examples +pnpm format # oxfmt src tests scripts bench bin examples (format:check for CI) pnpm bench # runs every script in bench/ (bench/run-all.js), not just bench.js +node bench/http-comparison.js # wrpc's HTTP paths vs fastify/express/tRPC/node:http under autocannon (fastify/benchmarks' settings; WRPC_BENCH_DURATION seconds per stack) +WRPC_WT=fails WRPC_RTC=node-datachannel node bench/transports.js # wrpc over ws/WebTransport/WebRTC on real stacks (bench/support/real-stacks.js; needs node scripts/wt-cert.js certs) — rows skip without the variables; the same gate adds the wt/webrtc rows to bench/rpc-comparison.js +WRPC_WT=fails pnpm bench:browser transports # the same from Chrome (bench/browser/transports.js); without WRPC_WT the WebTransport row is skipped pnpm size # bundle-size report (scripts/size.js), also run in CI's lint job; browser-reachable entries carry a min+gzip budget and exceeding one FAILS the run pnpm docs:dev # VitePress dev server for docs/ (docs:build / docs:preview too) ``` -Linting/formatting is **oxlint/oxfmt** (`.oxlintrc.json`, `.oxfmtrc.json`; the `correctness` category is intentionally off, and `no-void` is intentionally not enabled — this codebase follows the Metarhia style of `return void fn()` for explicit fire-and-forget) — their native bindings require Node ≥20.19. CI (`.github/workflows/ci.yml`) runs three jobs: `lint` (Node 22 — lint, format:check, size), `test` (Node 22/24 matrix — test:coverage, test:types; bare `node --test` recursively discovers `tests/adapters/**` too, so the adapter suites run here rather than in a separate job), `docs` (Node 22 — docs:build). The Autobahn Testsuite (`node scripts/autobahn/run.js`) and the 1 GiB stream memory guard (`pnpm test:perf`) are deliberately **not** wired into CI — run them by hand. `tests/scaling/redis.integration.test.js` covers the Redis backplane against a real server and is likewise a manual/local run (`REDIS_URL=redis://127.0.0.1:6379 node --test tests/scaling/redis.integration.test.js`), not a CI job — the in-repo ioredis-shaped fake in `tests/scaling/redis.test.js` is what `pnpm test` actually exercises. Lint/format deliberately target `src tests scripts bench` only, so `docs/` is not covered by them. +Linting/formatting is **oxlint/oxfmt** (`.oxlintrc.json`, `.oxfmtrc.json`; the `correctness` category is intentionally off, and `no-void` is intentionally not enabled — this codebase follows the Metarhia style of `return void fn()` for explicit fire-and-forget) — their native bindings require Node ≥20.19. CI (`.github/workflows/ci.yml`) runs three always-on jobs — `lint` (Node 22 — lint, format:check, size), `test` (Node 22/24 matrix — test:ci, test:coverage:floors, test:types; bare `node --test` recursively discovers `tests/adapters/**` too, so the adapter suites run here rather than in a separate job), `docs` (Node 22 — docs:build) — plus the four broker service jobs described below. The Autobahn Testsuite (`node scripts/autobahn/run.js`) and the 1 GiB stream memory guard (`pnpm test:perf`) are deliberately **not** wired into CI — run them by hand. `tests/scaling/redis.integration.test.js` covers the Redis backplane against a real server and runs in CI's `redis` job (a Redis service container); locally it is `REDIS_URL=redis://127.0.0.1:6379 node --test tests/scaling/redis.integration.test.js` — the in-repo ioredis-shaped fake in `tests/scaling/fakeRedis.js` is what `pnpm test` actually exercises. `compose.yaml` also starts RabbitMQ, Kafka and NATS (+JetStream) for the broker integration suites (`pnpm brokers:up`); each `tests/broker/*.integration.test.js` skips without its env var (`REDIS_URL`, `NATS_URL`, `AMQP_URL`, `KAFKA_BROKERS`) and has a CI job of its own (`redis`, `nats`, `rabbitmq`, `kafka`). Every broker client is a devDependency used ONLY by those suites — `ioredis`, `@nats-io/transport-node` + `@nats-io/jetstream`, `amqplib`, `kafkajs` and `@confluentinc/kafka-javascript` (the last one is a native build, allow-listed in `pnpm-workspace.yaml`). Lint/format target `src tests scripts bench bin examples` (the examples' scripts — `examples/**/public/**` and their READMEs are ignored in `.oxlintrc.json`/`.oxfmtrc.json`), so `docs/` is not covered by them; `tests/examples.test.js` compiles every example script and checks each name it destructures from a root entry against that entry's exports — the examples are never executed by the suite. Style: 2-space indent, single quotes, semicolons, 120-char lines (see `.editorconfig`, `.oxfmtrc.json`). @@ -68,18 +91,24 @@ Mirrors the `@alexify/kerberos` entry-point pattern: - `fastify.js` → `require('./src/adapters/fastify.js')` + `fastify.d.ts` — the `./fastify` subpath (`wrpcFastify`, `findUwsApp`). - `express.js` → `require('./src/adapters/express.js')` + `express.d.ts` — the `./express` subpath (`createWrpc`). - `scaling.js` → `require('./src/scaling/index.js')` + `scaling.d.ts` — the `./scaling` subpath (`MemoryBackplane`, `createRedisAdapter`, `isBackplane`). -- `sse.js` → `require('./src/sse/index.js')` + `sse.browser.js` → `require('./src/sse/client.js')` + `sse.d.ts` — the `./sse` subpath. The only subpath with a `browser` condition: a browser needs the client transport, never the channel registry. +- `sse.js` → `require('./src/sse/index.js')` + `sse.browser.js` → `require('./src/sse/client.js')` + `sse.d.ts` — the `./sse` subpath. The first subpath with a `browser` condition: a browser needs the client transport, never the channel registry. +- `webrtc.js` → `require('./src/webrtc/index.js')` + `webrtc.browser.js` → `require('./src/webrtc/browser.js')` + `webrtc.d.ts` / `webrtc.browser.d.ts` — the `./webrtc` subpath, the second with a `browser` condition (the browser half omits the signaling unit). The browser barrel also re-exports `defineRouter`/`procedure`/`tracked`/`createEventLog` — a browser peer defines its router with them, and the main browser entry cannot afford them. +- `wt.js` → `require('./src/webtransport/index.js')` + `wt.d.ts` — the `./wt` subpath (the WebTransport SERVER half: `attachSession`, `acceptSessions`, `fromFails`, `fromQuico`, `WtSocket`). Node-only and with no `browser` condition: the client transport ships in the base entry. +- `broker.js` → `require('./src/broker/index.js')` + `broker.d.ts` — the `./broker` subpath (`MemoryBroker`, the capability checks, `TopicTails`, `encodeToken`). Node-only, no browser condition. +- `rpc.d.ts` — the node-free server-core types (routers, subscriptions, sessions, rooms, `Client`, `Context`, `ClientHost`), re-exported by `index.d.ts` and imported by `webrtc.browser.d.ts`. No `rpc.js` exists at runtime — the same pattern as `client.d.ts`. `Context.server`/`Client.server` are typed as `ClientHost` (what an `RpcServer` and a `PeerHost` both satisfy), never `RpcServer`. - `query.js` → `require('./src/query/index.js')` + `query.d.ts` — the `./query` subpath (`createQueryUtils`). Browser-reachable but needs **no** `browser` condition: `src/query/` has no Node-specific code, so one file serves both. `scripts/size.js` bundles it with `platform: 'browser'` under a budget to keep that true. - `auth.js` → `require('./src/auth/index.js')` + `auth.d.ts` — the `./auth` subpath (token stores, `bearerAuth`, `bearerTransport`/`payloadTransport`). Same one-file browser-safe pattern as `./query`, same `platform: 'browser'` budget in `scripts/size.js`. +- `encryption.js` → `require('./src/encryption/index.js')` + `encryption.browser.js` → `require('./src/encryption/browser.js')` + `encryption.d.ts` / `encryption.browser.d.ts` — the `./encryption` subpath, the third with a `browser` condition; its own `platform: 'browser'` budget row in `scripts/size.js`, outside every other entry like `./deflate`. +- `deflate.js` → `require('./src/deflate/index.js')` + `deflate.d.ts` — the `./deflate` subpath (`createDeflateCodec`, `inflateRaw`, `deflateRaw`, `DeflateError`). One file for both platforms like `./query`/`./auth`, `platform: 'browser'` with its own budget in `scripts/size.js`, and deliberately outside every other entry: only a page that injects the codec pays for it. - `bin/wrpc.js` → `require('./src/cli/types.js')` — the `wrpc` command (`package.json#bin`). A shim on purpose: the logic lives under `src/` so the c8 thresholds cover it. Needs the shebang, the executable bit, and `bin/` in both `files` and the lint/format targets. - `browser.js` → `require('./src/index.browser.js')`; resolved via the `package.json#browser` field map and the `browser` condition in `exports`. Do not deduplicate the two files even once they look similar — the browser entry intentionally excludes server-only modules. - `index.d.ts` — hand-maintained types (not generated), covering the full `src/index.js` surface. - `src/index.js` — the full barrel; `src/index.browser.js` — the browser-safe subset (see above). -- Platform-specific code lives in matched pairs swapped via `package.json#browser`: `src/chunks.js`/`src/chunks.browser.js` and `src/runtime/node.js`/`src/runtime/browser.js`. Add new pairs the same way if another Node-only API needs a browser equivalent. +- Platform-specific code lives in matched pairs swapped via `package.json#browser`: `src/chunks.js`/`src/chunks.browser.js`, `src/runtime/node.js`/`src/runtime/browser.js`, `src/compression/native.js`/`src/compression/native.browser.js`, `src/encryption/aead.js`/`src/encryption/aead.browser.js`, `src/encryption/dh.js`/`src/encryption/dh.browser.js` and `src/encryption/hkdf.js`/`src/encryption/hkdf.browser.js` (`node:crypto` vs `crypto.subtle`), `src/client/wsCompression.js`/`src/client/wsCompression.browser.js` (the Node ws client's per-message compression; the browser half is a stub) and `src/client/wsHandshake.js`/`src/client/wsHandshake.browser.js` (how the declared bags leave on the ws handshake; here the BROWSER half is the larger one and the Node half requires it for its fallback). Add new pairs the same way if another Node-only API needs a browser equivalent. ## Testing -Tests live in `tests/` (not `test/` — this differs from `kerberos`), run via Node's built-in `node --test` runner (bare `node --test`, which recursively discovers `**/*.test.js` under the cwd — no vitest/jest config; note that `node --test` accepts *files*, not directories, so a targeted run needs an explicit glob). Non-`.test.js` files under `tests/` are shared test helpers, not test files themselves: `tests/websocket/mockSocket.js`, `tests/websocket/protocolClient.js`, `tests/engine/engineContract.js` (the shared engine port contract, run against both a hosted and a standalone engine via `hostedHarness`/`standaloneHarness`), and the client-transport contract `tests/client/transportContract.js` (structural, run over ws/http/event/sse in `tests/client/transports.test.js`), the shared server boot `tests/helpers/server.js` (`bootServer`/`connectClient` — the default for NEW server-driving suites; never read a bound port through `server.httpServer.address()`), and the swap-test pair `tests/adapters/boots.js` (the five boots — `Server` over each engine, the fastify plugin over each backend, express middleware) + `tests/adapters/spec.js` (the one behavioral spec every boot must satisfy). The `#ws` subpath import (`package.json#imports`) lets tests require the websocket barrel without a relative path. Type tests use `tsd` (`tsd.directory: "tests"` in `package.json`): `tests/index.test-d.ts` plus one file per published subpath — `ws`, `engine`, `uws`, `fastify`, `express`, `scaling`, `sse`, `query` — and every new subpath must bring its own. **`tsd` type-checks with `@tsd/typescript` (5.9), not the repo's own `typescript` (7.x)**, so a type that relies on newer inference must be checked against both; `tests/cli/e2e.test.js` is the one place the repo's `tsc` runs, on the d.ts the CLI generated. +Tests live in `tests/` (not `test/` — this differs from `kerberos`), run via Node's built-in `node --test` runner (bare `node --test`, which recursively discovers `**/*.test.js` under the cwd — no vitest/jest config; note that `node --test` accepts *files*, not directories, so a targeted run needs an explicit glob). Non-`.test.js` files under `tests/` are shared test helpers, not test files themselves: `tests/websocket/mockSocket.js`, `tests/websocket/protocolClient.js`, `tests/engine/engineContract.js` (the shared engine port contract, run against both a hosted and a standalone engine via `hostedHarness`/`standaloneHarness`), and the client-transport contract `tests/client/transportContract.js` (structural, run over ws/http/event/sse/webrtc in `tests/client/transports.test.js`), the WebTransport helpers `tests/wt/fakeWebTransport.js` (`createFakeWt()` — one in-memory W3C-shaped session class for BOTH ends, with `hold()`/`uniQuota`/`cancel` hooks) and `tests/wt/channelContract.js` (the channel's behavioural contract, run over `WtSocket` and `ClientWtTransport`), the WebRTC helpers `tests/webrtc/fakeRtc.js` (`createFakeRtc()` — an in-memory W3C-shaped implementation with `failIce()`/`blackhole()`/`restartIce()` hooks), `tests/webrtc/portContract.js` (the shared `RtcAdapter` contract, run on the fake and, by hand, on node-datachannel), `tests/webrtc/rawChannel.js` (`rawChannelPair()` — two fake pcs with a negotiated channel each, offer/answer done by hand: the raw-channel level's own signaling) and `tests/webrtc/fakeSignalHub.js` (an in-memory `RosterSignaler` hub with `mute`/`drop`/`reset`), the broker contract suites `tests/broker/{backplane,log,queue,direct}Contract.js` plus `tests/broker/support.js` (each suite takes a harness whose `open()` returns the capability, an optional `peer` on the same broker and optional hooks like `trim`/`foreignId`, so one suite runs over memory, an in-repo fake and a real server), the ioredis-shaped fake `tests/scaling/fakeRedis.js` (delivers on a microtask, never inside `publish()`), the four in-process brokers the adapter suites run on — `tests/broker/fakeRedisServer.js` (Streams, groups, lists, blocking reads), `tests/broker/fakeNats.js` (core subjects + JetStream consumers with ack_wait/nak/term), `tests/broker/fakeAmqp.js` (exchanges, quorum/stream queues, TTL + DLX, mandatory returns) and `tests/broker/fakeKafka.js` (partitions, groups, commits — in BOTH KafkaJS client shapes) — the interop peers `tests/interop/peers.js` (`next`/`legacy` — this tree and the published 1.0 — one `boot`/`connect` for either, and `browserBuild()`, which bundles the BROWSER client with esbuild and evaluates it in Node: the only way to drive a page's handshake carriers, since a Node client sends real headers), the shared server boot `tests/helpers/server.js` (`bootServer`/`connectClient` — the default for NEW server-driving suites; never read a bound port through `server.httpServer.address()`), the one `waitFor` of the suites in `tests/helpers/wait.js` (`waitFor(predicate, message | { message, timeout, interval })`, deadline-based, default 5 s or `WRPC_TEST_TIMEOUT`; `server.js`, `tests/broker/support.js` and `tests/webrtc/portContract.js` re-export it — never write another copy, and prefer it to a fixed sleep before a POSITIVE assertion; a sleep is for "nothing more arrives", and the queue contract scales those windows by the harness's `settle`/`ackWindow`; its `keepAlive()` holds the event loop for a file whose tests await an unref'd close/idle timer over a fake — Node 22's runner cancels the rest of the file when the loop goes empty, Node 24's does not, so a local run on 24 never shows it), and the swap-test pair `tests/adapters/boots.js` (the five boots — `Server` over each engine, the fastify plugin over each backend, express middleware) + `tests/adapters/spec.js` (the one behavioral spec every boot must satisfy). The `#ws` subpath import (`package.json#imports`) lets tests require the websocket barrel without a relative path. Type tests use `tsd` (`tsd.directory: "tests"` in `package.json`): `tests/index.test-d.ts` plus one file per published subpath, the per-broker ones under `tests/broker/` — every new subpath must bring its own, and the guard `every published subpath has a tsd file` in `tests/package/consistency.test.js` is the list (do not copy it here). **`tsd` type-checks with `@tsd/typescript` (5.9), not the repo's own `typescript` (7.x)**, so a type that relies on newer inference must be checked against both; `tests/cli/e2e.test.js` is the one place the repo's `tsc` runs, on the d.ts the CLI generated. Adapter tests take their frameworks from devDependencies and skip (never fail) when one is missing — see the `optional()`/`requireUws()` helpers in `tests/adapters/boots.js`, so a machine without a working `uWebSockets.js` binary can still run the suite. @@ -89,28 +118,34 @@ Adapter tests take their frameworks from devDependencies and skip (never fail) w **Hot paths are hand-written; cold paths stay idiomatic.** A path is *hot* when it runs per WebSocket frame, per packet, per batch element, per stream chunk, per broadcast recipient, or per SSE line. Everything else — startup, router definition, introspection, reconnect, subscription resume, per-connection handshake, CLI codegen — is *cold*, and there `.map`/`.filter`/spread/`Object.entries` are the preferred spelling because they read better. This is a rule about **where**, not a ban on **what**: `.map` in `router.js` at definition time is correct code, and the same call per packet is not. -On a hot path, prefer a manual indexed loop, preallocate with `new Array(len)` and truncate with `result.length = index` rather than growing, and use `for (const key in obj)` over `Object.entries(obj)` when the object is one this package built. Never write `push(...array)` — it is `Function.prototype.apply` in disguise and throws `RangeError` once the array exceeds the engine's argument limit, which turns a large cluster fan-in into a hard failure rather than a slow one (`src/rpc/cluster.js:384`). The established examples are `applyMask` in `src/websocket/frame.js:56` (word-wise XOR through a `Uint32Array` view), the once-sweep in `src/utils.js:46` (preallocate + index cursor + truncate, in place of `.filter`), and `SegmentQueue` in `src/websocket/segments.js:9`. +On a hot path, prefer a manual indexed loop, preallocate with `new Array(len)` and truncate with `result.length = index` rather than growing, and use `for (const key in obj)` over `Object.entries(obj)` when the object is one this package built. Never write `push(...array)` — it is `Function.prototype.apply` in disguise and throws `RangeError` once the array exceeds the engine's argument limit, which turns a large cluster fan-in into a hard failure rather than a slow one (the fan-in of `fetchClients` in `src/rpc/cluster.js`). The established examples are `applyMask` in `src/websocket/frame.js` (word-wise XOR through a `Uint32Array` view), the once-sweep of `emit` in `src/utils.js` (preallocate + index cursor + truncate, in place of `.filter`), and `SegmentQueue` in `src/websocket/segments.js`. + +References in this file name a SYMBOL in a file, never a line number: line numbers drifted within a week every time they were refreshed (half of them pointed at the wrong line), and a guard in `tests/package/consistency.test.js` now refuses one — and checks that every path written here exists and every "`symbol` in `file`" still holds. -**A hot-path hand-optimization must cite its benchmark in a comment.** If the win is not measured, the code is only harder to read — so the comment names the file that proves it, as `src/websocket/frame.js:52` cites `bench/unmask.js` and `src/utils.js:3` cites `bench/emitter.js`. New measurements go in a new top-level file under `bench/`; `bench/run-all.js` discovers it automatically. Capture before and after by hand — the harness in `bench/support/harness.js` prints ops/sec but stores no baseline, and nothing in CI runs `pnpm bench`. +**A hot-path hand-optimization must cite its benchmark in a comment.** If the win is not measured, the code is only harder to read — so the comment names the file that proves it, as the comment over `applyMask` in `src/websocket/frame.js` cites `bench/unmask.js` and the one over `Emitter` in `src/utils.js` cites `bench/emitter.js`. New measurements go in a new top-level file under `bench/`; `bench/run-all.js` discovers it automatically. Capture before and after by hand — the harness in `bench/support/harness.js` prints ops/sec but stores no baseline, and nothing in CI runs `pnpm bench`. -**Do not convert a `switch` or an `if`/`else if` chain into a table of handler functions.** The intuition that a table is the O(1) answer to a linear branch chain is half right, and the half that is wrong is the half that matters here. `bench/dispatch.js` measures all three shapes on this codebase's own discriminants: with the branch cost isolated, a 9-branch chain really is linear (165 M ops/s at the first branch, 69 M at the last) and a `switch` is flatter — but once a real handler call is in the loop, the call dominates and `switch` and the chain come out level (55.1 vs 55.0 M ops/s), while a table loses ~25% in every shape tested (42.4 M ops/s). V8 compiles a `switch` over string literals into direct comparisons and keeps the call target inlinable; a table yields a function *value*, which it cannot inline through, and `Object.freeze` and `Map` do not close the gap. So the branch chains in `src/rpc/dispatcher.js:383`, `src/client/core.js:657`, `src/sse/client.js:62` and the `switch` in `src/rpc/cluster.js:594` stay as they are. If a chain ever does become the bottleneck, order its branches by frequency first — that is free and keeps the guards next to the conditions they justify. +**Do not convert a `switch` or an `if`/`else if` chain into a table of handler functions.** The intuition that a table is the O(1) answer to a linear branch chain is half right, and the half that is wrong is the half that matters here. `bench/dispatch.js` measures all three shapes on this codebase's own discriminants: with the branch cost isolated, a 9-branch chain really is linear (165 M ops/s at the first branch, 69 M at the last) and a `switch` is flatter — but once a real handler call is in the loop, the call dominates and `switch` and the chain come out level (55.1 vs 55.0 M ops/s), while a table loses ~25% in every shape tested (42.4 M ops/s). V8 compiles a `switch` over string literals into direct comparisons and keeps the call target inlinable; a table yields a function *value*, which it cannot inline through, and `Object.freeze` and `Map` do not close the gap. So the branch chains of `handlePacket` in `src/rpc/dispatcher.js`, `#dispatch` in `src/client/core.js` and `#line` in `src/sse/client.js`, and the `switch (t)` in `src/rpc/cluster.js`, stay as they are. If a chain ever does become the bottleneck, order its branches by frequency first — that is free and keeps the guards next to the conditions they justify. -**The dispatch tables that do exist are for late registration, not for speed.** `WrpcClient.transport` (`src/client/transports.js:198`) and `ServerTransport.transport` (`src/transport.js:282`) are plain object literals looked up as `TABLE[key]` and guarded with `typeof x !== 'function'`, and are deliberately **not** frozen, because a subpath entrypoint registers into them at require time (`src/sse/client.js:249`). Use that idiom when a third party must be able to add a case; freeze the table when the set is closed (`src/logging.js:25`). A table keyed on anything **peer-controlled** additionally needs `__proto__: null` or `Object.hasOwn` — a plain literal answers `TABLE['toString']` with a real function, which passes the `typeof` guard and silently swallows a packet that should have produced an honest error. `assignKey` in `src/rpc/router.js:127` and the two `Object.hasOwn` hops in `src/query/index.js:64` already defend against this. +**The dispatch tables that do exist are for late registration, not for speed.** `WrpcClient.transport` (`static transport` in `src/client/core.js`) and `ServerTransport.transport` in `src/transport.js` are plain object literals looked up as `TABLE[key]` and guarded with `typeof x !== 'function'`, and are deliberately **not** frozen, because a subpath entrypoint registers into them at require time (`WrpcClient.transport.sse` in `src/sse/client.js`, `WrpcClient.transport.webrtc` in `src/webrtc/transport.js`). Use that idiom when a third party must be able to add a case; freeze the table when the set is closed (`DISABLED` in `src/logging.js`). A table keyed on anything **peer-controlled** additionally needs `__proto__: null` or `Object.hasOwn` — a plain literal answers `TABLE['toString']` with a real function, which passes the `typeof` guard and silently swallows a packet that should have produced an honest error. `assignKey` in `src/rpc/router.js` and the two `Object.hasOwn` hops of `member` in `src/query/index.js` already defend against this. -**A defensive copy is not an allocation to eliminate.** `Client#rooms` (`src/rpc/core.js:297`) exists so a caller can iterate while leaving; the chainable `Broadcast` copies in `src/rpc/rooms.js:195` are what make `b.to('a')` non-mutating; the listener snapshot on `Emitter.emit`'s multi-listener path guards against `off()` from inside a listener; and `Array.from(set)` in `src/scaling/redis.js:71` is required because that loop calls handlers synchronously. Where such a copy is genuinely unnecessary — `src/scaling/memory.js:42`, whose loop only schedules microtasks — say so in a comment, so the asymmetry reads as a decision rather than an oversight. +**A defensive copy is not an allocation to eliminate.** `Client#rooms` (`get rooms` in `src/rpc/client.js`) exists so a caller can iterate while leaving; the copies the chainable `Broadcast` in `src/rpc/rooms.js` makes in `to`/`except` are what make `b.to('a')` non-mutating; the listener snapshot on `Emitter.emit`'s multi-listener path guards against `off()` from inside a listener; and `Array.from(set)` in `src/scaling/redis.js` is required because that loop calls handlers synchronously. Where such a copy is genuinely unnecessary — `publish` in `src/scaling/memory.js`, whose loop only schedules microtasks — say so in a comment, so the asymmetry reads as a decision rather than an oversight. -**Some allocations are load-bearing for security, not correctness.** `Object.fromEntries(new URLSearchParams(...))` in `src/rpc/dispatcher.js:38` looks like an easy win and is not: `fromEntries` uses `CreateDataProperty`, so a `?__proto__=x` query string lands as an own property, while a hand-rolled `obj[key] = value` loop reintroduces prototype pollution on peer-controlled input. The cost there is the `URLSearchParams` parse anyway, not `fromEntries`. +**Some allocations are load-bearing for security, not correctness.** `Object.fromEntries(new URLSearchParams(...))` — `parseParams` in `src/rpc/dispatcher.js` — looks like an easy win and is not: `fromEntries` uses `CreateDataProperty`, so a `?__proto__=x` query string lands as an own property, while a hand-rolled `obj[key] = value` loop reintroduces prototype pollution on peer-controlled input. The cost there is the `URLSearchParams` parse anyway, not `fromEntries`. -**Browser-reachable files are on a byte budget, and manual loops cost source bytes.** `src/client/*`, `src/utils.js`, `src/streams.js`, `src/sse/client.js`, `src/query/*` and `src/chunks.browser.js` all land in an entry with a min+gzip budget that `pnpm size` enforces in CI's lint job, and headroom is under 1 KB. Optimize there only with a measurement that justifies the bytes; if the bytes are earned, raise the budget in `scripts/size.js` alongside the change, with a comment saying why — the ratchet's documented escape hatch. `src/websocket/*`, `src/rpc/*`, `src/adapters/*` and `src/transport.js` are Node-only and carry no budget. +**Browser-reachable files are on a byte budget, and manual loops cost source bytes.** `src/client/*`, `src/utils.js`, `src/streams.js`, `src/sse/client.js`, `src/query/*`, `src/chunks.browser.js` and — through the wt client transport, which is in the MAIN entry — `src/webtransport/{channel,framing,streams}.js` all land in an entry with a min+gzip budget (`src/webrtc/*` and, through `PeerHost`, `src/rpc/{router,dispatcher,client,rooms,subscriptions,errors,serverTransport,meta}.js` land in the webrtc entry's) that `pnpm size` enforces in CI's lint job, and headroom is under 1 KB. The budgets themselves are `budget` in `scripts/size.js` — read them there, a number copied here goes stale (it said 41 KB for an entry that is 58). Optimize there only with a measurement that justifies the bytes; if the bytes are earned, raise the budget in `scripts/size.js` alongside the change, with a comment saying why — the ratchet's documented escape hatch. `src/websocket/*`, `src/rpc/*`, `src/adapters/*` and `src/transport.js` are Node-only and carry no budget. **Two lint rules shape hot-path code.** `no-labels` is an error with `allowLoop: false`, so a manual loop may not use a labelled `break`/`continue` — restructure into a helper with an early `return` instead. `array-callback-return` is an error, so a `.map`/`.filter` callback must return on every path. ## Documentation site (`docs/`) -VitePress 1.x site deployed to Vercel (`vercel.json` pins the build command/output dir); `docs/` is never published to npm (the `files` field in `package.json` is an explicit allowlist that omits it). It is the **full site, not a skeleton**: `docs/index.md` (home), a `guide/` track (introduction → server → client → transports & hosts → operations, plus `guide/adapters/`) and a `reference/` track (`protocol.md` — the wire protocol, frozen at 1.0 — `wire-format.md`, `engine.md`, `errors.md`). Every page is reachable from the sidebar in `docs/.vitepress/config.mts`, so **a new page means a new sidebar entry in the same change** — an orphan page is invisible. Two things in that config are hand-synced with `package.json` and are release-checklist items: the `nav` version label and the `keywords` array. +VitePress 1.x site deployed to Vercel (`vercel.json` pins the build command/output dir); `docs/` is never published to npm (the `files` field in `package.json` is an explicit allowlist that omits it). It is the **full site, not a skeleton**: `docs/index.md` (home), a `guide/` track (introduction → server → client → transports & hosts → operations, plus `guide/adapters/`) and a `reference/` track (`protocol.md` — the wire protocol at revision 2.0 — `wire-format.md`, `engine.md`, `errors.md`). Every page is reachable from the sidebar in `docs/.vitepress/config.mts`, so **a new page means a new sidebar entry in the same change** — an orphan page is invisible. Two things in that config are hand-synced with `package.json` and are release-checklist items: the `nav` version label and the `keywords` array. + +The encryption story has ONE home, `docs/guide/encryption.md` (Operations group): the decision table, the primitives and what is left to injection, each layer, the costs, the limits. The scaling, sessions, security, production, codec and compression pages link INTO it and keep only what is theirs (the rollout table of `rooms.encryption` lives on the scaling page; `sealedStore` on the sessions page); wire formats are in `reference/protocol.md` (`#session-encryption`, `#sealed-requests`, `#broker-sealing`). `pnpm docs:build` fails on dead links, which is what CI's `docs` job is for. Lint/format deliberately do not cover `docs/`. +**Benchmark charts are ``** (`docs/.vitepress/theme/components/BenchChart.vue`, registered in `docs/.vitepress/theme/index.ts` BEFORE its SSR guard so the charts pre-render): horizontal bars, wRPC rows in the brand green and every other row gray, sorted best first, a metric switch, a hover/focus tooltip against the first wRPC row. No chart library. The numbers live in `docs/.vitepress/theme/benchmarks.json` and are a COPY of the table printed under each chart — re-running a benchmark means updating both, and a guard in `tests/package/consistency.test.js` refuses a charted number its page does not print (and a `` naming a dataset or `pick` row that does not exist). The accent/gray pair was validated for contrast and colour-blind separation on both surfaces; keep it. + **Diagrams are mermaid**, through `vitepress-plugin-mermaid` (already wired: the config is wrapped in `withMermaid`). Write them as ` ```mermaid ` fences. Three constraints that are easy to rediscover the hard way: the config must **not** set `mermaid.theme` (the plugin picks `default`/`dark` from VitePress' `isDark` and re-renders on toggle; setting it freezes diagrams in one theme); mermaid's CJS-only transitive deps (`dayjs`) need `vite.optimizeDeps.include` or the dev server fails to boot; and `.mermaid` label `line-height` must be pinned to 1.25 in `custom.css` or multi-line labels overflow the shapes mermaid measured them into. Check a new diagram in **both** themes and at mobile width. **Branding** lives in `docs/public/` (`logo-mark.svg` — the mark: a Node.js hexagon holding the two directions of an RPC — `favicon.png`, `logo.png` for `og:image`, `robots.txt`, `llms.txt`) and `docs/.vitepress/theme/custom.css` (Node.js green `#5FA04E` on ink `#1B1C1E`, applied by overriding VitePress CSS variables — the Pinia/Vite pattern, layout untouched). Light-theme `--vp-c-brand-1/-2` are **darkened** greens because `#5FA04E` fails WCAG AA as link text on white. The raster assets are generated from the mark by `node scripts/brand.js` (macOS `qlmanage`; re-run it after changing `logo-mark.svg`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7cb9048..85eed4d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,6 +18,8 @@ There is no build step: `src/` ships as-is. ```bash pnpm test # node --test, recursive over tests/ pnpm test:coverage # c8 over src/ — thresholds 95 lines / 95 statements / 90 branches / 95 functions +pnpm test:ci # test:coverage as CI runs it: a 5 min per-test timeout and a forced exit, so a leak fails instead of hanging +pnpm test:coverage:floors # per-file floors for the directories new in 2.0 — run right after test:coverage, it reads what that left pnpm test:types # tsd against the .d.ts files pnpm test:perf # the 1 GiB stream memory guard pnpm lint # oxlint @@ -31,6 +33,10 @@ Run a single file with `node --test tests/smoke.test.js`, or filter by name with `--test-name-pattern`. `node --test` takes **files**, not directories — a targeted run of a folder needs a glob (`node --test tests/adapters/*.test.js`). +The `wrpc` command of this tree is `node bin/wrpc.js`. Inside the repository +`npx wrpc` and `node_modules/.bin/wrpc` run the **published 1.0** CLI: the +interop suite's `wrpc-v1` alias installs `@alexify/wrpc@1.0.0`, bin included. + ## House rules **Zero runtime dependencies.** `package.json` has no `dependencies` field, and @@ -50,9 +56,13 @@ entry and a `tests/.test-d.ts`. both. **Wire-protocol changes are documented in -[`docs/reference/protocol.md`](./docs/reference/protocol.md)**, which is frozen -at 1.0: additive, optional fields within a major version; anything else is a -major. +[`docs/reference/protocol.md`](./docs/reference/protocol.md)**, which names +its revision and what each of its tiers promises: additive, optional fields +within a major version; anything else is a major, with a row in the page's +"Changes since" table saying what an older peer does with it and what to set +until every peer is upgraded. A `##` section the previous major did not have +carries a `since N.0` badge (`tests/package/consistency.test.js` holds the +list of the sections that need none). **Style** is enforced by oxlint/oxfmt: 2-space indent, single quotes, semicolons, 120-column lines. The `correctness` category is intentionally off, @@ -72,6 +82,16 @@ Adapter tests **skip** (never fail) when a framework is missing, so a machine without a working `uWebSockets.js` binary can still run the suite. CI has them all. +Coverage has two gates. `test:coverage` checks the global thresholds — an +average, behind which one thin new file hides. So the directories that are +new in 2.0 (`src/broker`, `src/webtransport`, `src/encryption`, `src/webrtc`, +`src/compression`, `src/deflate`) are also held **per file** by +`test:coverage:floors` (`scripts/coverage-floors.js` has the numbers and the +one exception). Only those: the 1.0 files below these floors predate the rule, +and a floor low enough to admit them would hold nothing. A floor moves up when +its directory's weakest file improves — never down to let a change in; cover +the new branch instead. + > **A leaked uws engine hangs `node --test` forever.** A standalone engine holds > a native listen socket; if a test fails an assertion before its cleanup line > runs, the socket is never released and the whole run wedges instead of @@ -80,37 +100,75 @@ all. ## CI -Three jobs (`.github/workflows/ci.yml`): +Three always-on jobs and four service jobs (`.github/workflows/ci.yml`): - **lint** (Node 22) — `lint`, `format:check`, `size` -- **test** (Node 22 and 24) — `test:coverage`, `test:types` +- **test** (Node 22 and 24) — `test:ci` (`test:coverage` with + `--test-timeout=300000 --test-force-exit`: a file that outlives its tests + fails in minutes instead of hanging to the job's timeout), + `test:coverage:floors`, `test:types` - **docs** (Node 22) — `docs:build`, which fails on dead links +- **redis**, **nats**, **rabbitmq**, **kafka** — the integration suites + against a real server each (Redis 7.4, NATS 2.11 with JetStream — started + through `compose.yaml`, since a GitHub service container cannot be given + the `-js` flag — RabbitMQ 4.1, Kafka 3.9.1), under + `WRPC_INTEGRATION_STRICT=1`: there a missing client package fails the job + instead of skipping it green. Locally, `pnpm brokers:up` starts the same + four and each suite skips itself without its env var. -Lint and format deliberately target `src tests scripts bench bin` only, so -`docs/` is not covered by them. +Lint and format target `src tests scripts bench bin examples` — the examples' +scripts, not their pages or READMEs — and deliberately not `docs/`. The +examples are not run by the suite (they need servers and a browser); +`tests/examples.test.js` checks that each compiles and that every name it +takes from a root entry is one that entry exports. -Three checks are deliberately **not** in CI — run them by hand: +Checks deliberately **not** in CI — run them by hand: - `node scripts/autobahn/run.js` — the RFC 6455/7692 conformance suite against - the engine in `src/websocket/`. Needs docker; several minutes for 500+ - cases. `FAILED` and `WRONG CODE` fail the run, `NON-STRICT` and - `INFORMATIONAL` do not. + the engine in `src/websocket/`, and again with `AUTOBAHN_DEFLATE=takeover` + for the queued, asynchronous compression paths. Needs docker; several + minutes for 517 cases. `FAILED` and `WRONG CODE` fail the run, `NON-STRICT` + and `INFORMATIONAL` do not — and so does a run the fuzzing client cut + short (it gives up on the suite when one connect times out). Run it on an + otherwise idle Docker: with the four broker containers up, the emulated + client image loses the server half-way. - `pnpm test:perf` — the 1 GiB stream memory guard. -- `REDIS_URL=redis://127.0.0.1:6379 node --test tests/scaling/redis.integration.test.js` - — the scaling backplane against a real Redis. `pnpm test` already covers the - adapter's contract through an in-repo ioredis-shaped fake - (`tests/scaling/redis.test.js`); this is only useful when you want to check - a live server, and the file skips itself without `REDIS_URL`. +- `WRPC_RTC=node-datachannel node --test tests/webrtc/node-datachannel.integration.test.js` + — the WebRTC port contract against a real implementation (the fake in + `tests/webrtc/fakeRtc.js` is what `pnpm test` runs); needs the native + `node-datachannel` build. +- `WRPC_WT=fails node --test tests/wt/fails.integration.test.js` and + `WRPC_WT=quico node --test tests/wt/quico.integration.test.js` — the + WebTransport server half against a real HTTP/3 stack + (`@fails-components/webtransport` with its native `-transport-http3-quiche` + binary, or the pure-JS `quico`). `pnpm test` covers the same code through + the in-memory fake (`tests/wt/fakeWebTransport.js`); both files skip + themselves without `WRPC_WT`. `node scripts/wt-cert.js` makes the + short-lived ECDSA certificate they and a browser need. ## Stability and deprecation -The published surface is stable under semver, with two carve-outs marked -`@experimental` in the `.d.ts` files: +The published surface is stable under semver, with five carve-outs marked +`@experimental` in the `.d.ts` files (the consumer-facing list is +[docs/reference/stability.md](./docs/reference/stability.md#experimental-carve-outs); +`tests/package/consistency.test.js` keeps the two counts equal): - the **telemetry** writer shapes and metric set (`telemetry` option) — the signals will keep improving in minors; - the **engine port** internals beyond the documented `WrpcSocket` contract - (`capabilities` in particular). + (`capabilities` in particular); +- **WebTransport**, whole — the `wt` client transport, the `@alexify/wrpc/wt` + subpath and the control-stream framing — until Node has a WebTransport of + its own to settle the carrier against; +- the **message-broker family**, whole — `@alexify/wrpc/broker` and the + `@alexify/wrpc/broker/*` adapters, their capability contracts, the broker + metrics, the `broker` client transport and the broker binding — until every + adapter has shipped; +- **application-level encryption**, whole — `@alexify/wrpc/encryption` and + every `encryption` option it feeds, `Client.encryption`, + `encryptionKey()`/`encryptionRequired`, `attach({ encrypted })`, and the + session-encryption, sealed-request and broker-sealing wire formats — until + the formats have been reviewed against real deployments. An `@experimental` API may change in a minor release, with the change described in the CHANGELOG. Everything else follows the usual rule: removal @@ -118,8 +176,11 @@ or breaking change of a stable API needs (1) a deprecation note in the CHANGELOG and the docs for at least one minor release, and (2) a major version to actually remove it. The wire protocol has its own, stronger promise — see [protocol.md](./docs/reference/protocol.md#stability): packet -shapes never break inside a major, and the `wrpc.v1` subprotocol names the -revision on the wire. +shapes never break inside a major, and the subprotocol names the core's +revision on the wire — `wrpc.v2` since 2.0 (framed messages), negotiated +down to `wrpc.v1` with a 1.0 peer. A change a 1.0 peer cannot ignore belongs +behind the revision, and `tests/interop/` runs the published 1.0 against the +tree to keep that true. ## Release checklist @@ -134,18 +195,21 @@ publishes. `docs/.vitepress/config.mts`'s keywords or nav version label drift from `package.json` — there is no separate manual sync step for either. 2. **Move `[Unreleased]` to the new version** in `CHANGELOG.md`, with the date, - and open a fresh empty `[Unreleased]` above it. Before the first publish, - `[Unreleased]` carries a "not published to npm yet" note right under its - heading — delete that note as part of this move; it does not apply again - after v1.0.0 ships. + and open a fresh empty `[Unreleased]` above it — with an empty + `### Changed (breaking)` section, which `tests/package/consistency.test.js` + requires to exist. **Read that section before choosing the bump**: a + non-empty one is a major, whatever the other sections say, and the same + test fails the build if an entry marked `**Breaking` sits anywhere else + (an "Added" feature that a 1.x peer cannot ignore is breaking too). A + non-empty breaking section also carries a `#### Migrating from ` + block — the order of upgrade and the flags that keep an old peer working. 3. **Bump `version` in `package.json`, and confirm it differs from what npm already has.** Semver against the **JavaScript API**; the wire protocol has - [its own promise](./docs/reference/protocol.md#stability). Before the first - publish there is nothing on the registry to compare against, so this half - of the step is a no-op; from the second release on, run + [its own promise](./docs/reference/protocol.md#stability). Run `npm view @alexify/wrpc version` and make sure the bumped value doesn't - match it — publishing an already-used version is a rejected `npm publish`, - not a warning. + match it (`npm view @alexify/wrpc versions` lists every one, 1.0.0 + included) — publishing an already-used version is a rejected + `npm publish`, not a warning. 4. **Check what would ship.** `files` in `package.json` is an explicit allowlist, so a new root shim or `.d.ts` that was not added to it silently disappears from the tarball: diff --git a/README.md b/README.md index bb5e002..645c32a 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@

- wrpc + wRPC

-

wrpc

+

wRPC

[![npm](https://img.shields.io/npm/v/%40alexify%2Fwrpc)](https://www.npmjs.com/package/@alexify/wrpc) [![CI](https://github.com/Alexis-Technologies/wrpc/actions/workflows/ci.yml/badge.svg)](https://github.com/Alexis-Technologies/wrpc/actions/workflows/ci.yml) @@ -11,11 +11,14 @@ [![docs](https://img.shields.io/badge/docs-online-blue)](https://wrpc.vercel.app/) [![license](https://img.shields.io/npm/l/%40alexify%2Fwrpc)](./LICENSE) -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 -nothing at all in your lockfile. +**wRPC** (Web RPC) is a fast, **zero-dependency** RPC protocol for Node.js and +browsers: one router of procedures, served over every transport the web has — +WebSocket, HTTP and REST, Server-Sent Events, WebTransport and WebRTC — and +between services over Redis, NATS, RabbitMQ or Kafka. Subscriptions that +resume, rooms that scale across processes, binary streams with backpressure +that reaches all the way into TCP, compression and encryption where you want +them — [under 28 KB min+gzip](#bundle-size) in a browser bundle, a budget CI +enforces, and nothing at all in your lockfile. ```javascript const { Server, WrpcClient, defineRouter, procedure } = require('@alexify/wrpc'); @@ -52,7 +55,7 @@ installed. **Optional integrations are injected, not depended on.** uWebSockets.js, Redis, fastify, express and TanStack Query all work — you `require` them and hand them -over, and wrpc duck-types the injection at the boundary. So the integrations +over, and wRPC duck-types the injection at the boundary. So the integrations you do not use cost you nothing, and the ones you do use are on your version, not ours. @@ -62,31 +65,44 @@ reimplementation. ### Positioning -| | **wrpc** | **tRPC** | **Socket.IO** | -| --- | --- | --- | --- | -| Transport | WebSocket, HTTP, SSE, worker port | HTTP, WebSocket | WebSocket + long-poll fallback | -| Types | contract-first + codegen, no build step | TypeScript inference | none built in | -| Realtime | subscriptions, events, rooms, acks | subscriptions | events, rooms, acks | -| Binary | streams with backpressure | ❌ | ❌ (messages only) | -| Scaling | rooms backplane (any pub/sub) | your own | adapters | -| Cluster ops | fetchClients, commands, presence (local read) | your own | fetchSockets (round-trip) | -| Runtime deps | **0** | a few | several | -| Needs TypeScript | ❌ | effectively yes | ❌ | - -### When NOT to use wrpc - -- **You want a public HTTP API.** wrpc has a REST mode, but it is a convenience - for reaching procedures, not an API design. If third parties consume it, - write an HTTP API and document it. +| | **wRPC** (`@alexify/wrpc`) | **tRPC** | **Socket.IO** | **gRPC / Connect** | **wRPC** (Bytecode Alliance) | **webrpc** | +| --- | --- | --- | --- | --- | --- | --- | +| Transports | WebSocket, HTTP + REST, SSE, WebTransport, WebRTC, worker port, message broker | HTTP, SSE, WebSocket | WebSocket, long-polling, WebTransport | HTTP/2; Connect: HTTP/1.1–3 | TCP, QUIC, WebTransport, Unix sockets | HTTP POST | +| Peer to peer | WebRTC data channels | — | — | — | — | — | +| Contract / types | declared or generated, no build step | inferred from the router | hand-written generics | `.proto` + codegen | WIT + codegen | RIDL + codegen | +| Realtime | subscriptions (resume), events, rooms, acks | subscriptions (resume) | events, rooms, acks | streaming RPCs | streams and futures | server-streaming | +| Binary | bytes in any packet, streams with backpressure | binary inputs | bytes in events | protobuf bytes | `list`, `stream` | file up/download | +| Message brokers | feeds, consumers, RPC (experimental) | — | a broadcast backplane | — | NATS (0.17 release) | — | +| Encryption beyond TLS | sealed envelopes, Noise, HPKE, E2EE (experimental) | — | — | — | — | — | +| JS runtime deps | **0** | 0 | 6 | 0 + a peer (Connect) | 0 | 0 (generated) | +| Polyglot | JavaScript only | TypeScript only | many clients | many languages | Rust, Go, JS | 6 languages | + +**Which wRPC?** The Bytecode Alliance's wRPC is an unrelated WIT-based RPC +framework for WebAssembly components; webrpc is schema-first code generation +over HTTP and JSON. Both are in the table because both are reasonable choices +— see [Why wRPC?](https://wrpc.vercel.app/guide/why#against-the-alternatives), +where every cell has its source. + +### When NOT to use wRPC + +- **You want an API wRPC does not shape.** A procedure can declare a real + REST mapping — verb, path, status, `/vN` version paths, schemas, + `wrpc types --openapi` — but the URL surface follows your router. If the + HTTP contract itself is the product, design it first. - **Your stack is polyglot.** The protocol is documented and small enough to - reimplement, but the only implementation today is JavaScript. gRPC exists for - a reason. -- **You need guaranteed delivery.** Rooms are at-most-once fan-out, not a - queue. Subscriptions with `tracked()` values and an event log let a client - catch up; if you need a broker's guarantees, use a broker. + reimplement, but the only implementation today is JavaScript. gRPC or + Connect, webrpc, or — for WebAssembly components — the Bytecode Alliance's + wRPC are built for many languages. +- **You need delivery guarantees from rooms.** Rooms are at-most-once fan-out, + not a queue. Subscriptions with `tracked()` values and an event log let a + client catch up, and the broker family adds at-least-once queue consumers + and durable feeds (experimental) — exactly-once is the broker's and your + application's to build. +- **You need audio or video.** The WebRTC layer moves data over data + channels, not media tracks. - **You want end-to-end type inference from server code.** tRPC's model — where the client's types come from the server's implementation with nothing written - twice — is genuinely nicer if your whole stack is TypeScript. wrpc's contract + twice — is genuinely nicer if your whole stack is TypeScript. wRPC's contract is declared or generated, deliberately, because it must also work for JavaScript users and across a network boundary the compiler cannot see. @@ -117,18 +133,29 @@ 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/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/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` — browser (client, streams, chunks) | 82.3 KB | **27.7 KB** | 28.0 KB | +| `@alexify/wrpc` — node (client + server) | 300.4 KB | 101.0 KB | — | +| `@alexify/wrpc/ws` (WebSocket engine) | 32.9 KB | 11.6 KB | — | +| `@alexify/wrpc/engine` (engine port) | 33.5 KB | 11.8 KB | — | +| `@alexify/wrpc/uws` (uWebSockets.js adapter) | 44.1 KB | 16.3 KB | — | +| `@alexify/wrpc/fastify` | 244.9 KB | 84.2 KB | — | +| `@alexify/wrpc/express` | 231.3 KB | 78.9 KB | — | +| `@alexify/wrpc/scaling` (rooms backplane) | 5.5 KB | 2.2 KB | — | +| `@alexify/wrpc/broker` (broker core, memory broker) | 166.8 KB | 57.6 KB | — | +| `@alexify/wrpc/broker/redis` | 31.6 KB | 11.8 KB | — | +| `@alexify/wrpc/broker/nats` | 30.2 KB | 11.2 KB | — | +| `@alexify/wrpc/broker/amqp` | 34.1 KB | 12.4 KB | — | +| `@alexify/wrpc/broker/kafka` | 28.6 KB | 10.6 KB | — | +| `@alexify/wrpc/sse` — browser (client transport) | 85.5 KB | **28.8 KB** | 29.0 KB | +| `@alexify/wrpc/sse` — node | 146.1 KB | 49.2 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/auth` (token strategies) | 3.9 KB | **1.8 KB** | 2.0 KB | +| `@alexify/wrpc/deflate` (pure-JS DEFLATE codec) | 10.7 KB | **4.4 KB** | 5.0 KB | +| `@alexify/wrpc/encryption` — browser (primitives, sessions, HPKE, E2EE) | 31.5 KB | **11.5 KB** | 12.0 KB | +| `@alexify/wrpc/encryption` — node | 45.1 KB | 16.5 KB | — | +| `@alexify/wrpc/webrtc` — browser (peer, link, mesh, assertions) | 179.2 KB | **58.3 KB** | 59.0 KB | +| `@alexify/wrpc/webrtc` — node | 193.2 KB | 62.9 KB | — | +| `@alexify/wrpc/wt` (WebTransport server half) | 63.6 KB | 22.5 KB | — | The Node-only rows are reported for visibility into what each subpath pulls in — they never ship to a browser, and the adapter rows include the whole core @@ -223,11 +250,13 @@ 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`; [pluggable wire codec](https://wrpc.vercel.app/guide/codec) and [request metadata](https://wrpc.vercel.app/guide/metadata) (`x-wrpc-meta-`) | +| **Message brokers** | [Broker-agnostic capabilities](https://wrpc.vercel.app/guide/brokers) (backplane, log, queue, direct) with adapters for [Redis](https://wrpc.vercel.app/guide/brokers/redis), [NATS](https://wrpc.vercel.app/guide/brokers/nats), [RabbitMQ](https://wrpc.vercel.app/guide/brokers/amqp) and [Kafka](https://wrpc.vercel.app/guide/brokers/kafka), every client injected: [durable feeds](https://wrpc.vercel.app/guide/brokers/feeds) that resume on any instance, [queue consumers](https://wrpc.vercel.app/guide/brokers/consumers) into procedures with retry and dead-lettering, and [RPC between services](https://wrpc.vercel.app/guide/brokers/rpc) over the broker (experimental) | +| **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, stable app-level identity, [server-signed trust assertions](https://wrpc.vercel.app/guide/webrtc-trust)); [WebTransport](https://wrpc.vercel.app/guide/wt) over HTTP/3 (experimental; WebSocket as the fallback, sessions from an injected host); [pluggable wire codec](https://wrpc.vercel.app/guide/codec) and [request metadata](https://wrpc.vercel.app/guide/metadata) (`x-wrpc-meta-`); [compression](https://wrpc.vercel.app/guide/compression) on every wire, off by default — performance first; deflate, Brotli, zstd or your own codec, negotiated as a preference list | | **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 | | **Operations** | [Security](https://wrpc.vercel.app/guide/security) hardening, [graceful drain and shutdown](https://wrpc.vercel.app/guide/production), [benchmarks](https://wrpc.vercel.app/guide/performance) you can reproduce, [testing patterns](https://wrpc.vercel.app/guide/testing) | +| **Encryption** | Opt-in, never in place of TLS, for where TLS ends before the data does: [sealed backplane, cluster and broker messages](https://wrpc.vercel.app/guide/encryption) under a rotating keyring, a [sealed session store](https://wrpc.vercel.app/guide/sessions#sealed), [session encryption](https://wrpc.vercel.app/guide/encryption#session) — canonical Noise on WebSocket/WebTransport, HPKE per request on HTTP/SSE, the server key pinned — and [end-to-end helpers](https://wrpc.vercel.app/guide/encryption#end-to-end). Platform crypto only (AES-256-GCM, X25519, HKDF), checked against the published vectors; other ciphers, KMS keys and post-quantum KEMs by injection | | **DX** | [Contract-first typed client](https://wrpc.vercel.app/guide/typed-client), [`wrpc types` codegen](https://wrpc.vercel.app/guide/cli), [TanStack Query bindings](https://wrpc.vercel.app/guide/query), hand-maintained `.d.ts` for every subpath | ## Observability @@ -243,7 +272,7 @@ const server = new Server({ router, logger: pino(), telemetry: { api } }); ``` `logger` takes a structured logger (pino, bunyan, winston), a `Console`, or -`false` to go silent. wrpc binds children for you — `component`, `peer` and a +`false` to go silent. wRPC binds children for you — `component`, `peer` and a per-call `callId` — and `context.log` inside a handler is already scoped to that call. @@ -263,16 +292,22 @@ See [Logging](https://wrpc.vercel.app/guide/logging) and | Subpath | Exports | Docs | | --- | --- | --- | -| `@alexify/wrpc` | `Server`, `RpcServer`, `WrpcClient`, `connect`, `defineRouter`, `procedure`, `tracked`, `createEventLog`, `createEventStream`, `MemorySessionStore`, `WrpcReadable`, `WrpcWritable`, `WrpcError`, `chunkEncode`/`chunkDecode` | [Server](https://wrpc.vercel.app/guide/server) · [Client](https://wrpc.vercel.app/guide/client) | +| `@alexify/wrpc` | `Server`, `RpcServer`, `WrpcClient`, `connect`, `defineRouter`, `procedure`, `tracked`, `createEventLog`, `createEventStream`, `MemorySessionStore`, `WrpcReadable`, `WrpcWritable`, `WrpcError`, `chunkEncode`/`chunkDecode`, `buildDictionary`, `dictionaryCompressor`, `deflateCompressor`, `brotliCompressor`, `zstdCompressor`, `isCompressor` | [Server](https://wrpc.vercel.app/guide/server) · [Client](https://wrpc.vercel.app/guide/client) · [Compression](https://wrpc.vercel.app/guide/compression) | | `@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/scaling` | `MemoryBackplane`, `createRedisAdapter`, `isBackplane` | [Scaling](https://wrpc.vercel.app/guide/scaling) | +| `@alexify/wrpc/broker` | `MemoryBroker`, `brokerFeed`, `attachConsumers`, `createPublisher`, `attachBrokerRpc`, `isBroker`, `TopicTails`, `encodeToken` (experimental) | [Message brokers](https://wrpc.vercel.app/guide/brokers) | +| `@alexify/wrpc/broker/{redis,nats,amqp,kafka}` | `createRedisBroker`, `createNatsBroker`, `createAmqpBroker`, `createKafkaBroker` — every client injected (experimental) | [Redis](https://wrpc.vercel.app/guide/brokers/redis) · [NATS](https://wrpc.vercel.app/guide/brokers/nats) · [RabbitMQ](https://wrpc.vercel.app/guide/brokers/amqp) · [Kafka](https://wrpc.vercel.app/guide/brokers/kafka) | | `@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) | | `@alexify/wrpc/auth` | `bearerAuth`, `memoryStore`, `webStorage`, `cookieStorage`, `bearerTransport`, `payloadTransport` | [Authentication](https://wrpc.vercel.app/guide/auth) | +| `@alexify/wrpc/deflate` | `createDeflateCodec`, `inflateRaw`, `deflateRaw`, `DeflateError` — a pure-JS DEFLATE codec with a preset dictionary, for browsers and anywhere a synchronous codec is wanted | [Compression](https://wrpc.vercel.app/guide/compression#deflate) | +| `@alexify/wrpc/encryption` | `createEncryption`, `fetchServerKey`, `sealedStore`, `createIdentity`, `createSealer`, `createOpener`, `generateKey`, `normalizeKeys`, `aead`, `x25519`, `createKdf`, `createNoise`, `createHpke`, `dhKem`, `isCipher`/`isDh`/`isKem`/`isKeyProvider` (experimental) | [Encryption](https://wrpc.vercel.app/guide/encryption) | +| `@alexify/wrpc/webrtc` | `WrpcPeer`, `PeerLink`, `Mesh`, `PeerHost`, `RtcLink`, `wrpcSignaler`, `createSignalingUnit`, `createSignalingHooks`, `createAssertionIssuer`, `createAssertionVerifier`, `createW3cAdapter` | [WebRTC](https://wrpc.vercel.app/guide/webrtc), [identity and trust](https://wrpc.vercel.app/guide/webrtc-trust) | +| `@alexify/wrpc/wt` | `attachSession`, `acceptSessions`, `fromFails`, `failsRequestCallback`, `fromQuico`, `WtSocket`, `isWtSession` (experimental; the `wt` client transport is in the main entry) | [WebTransport](https://wrpc.vercel.app/guide/wt) | | `wrpc` (bin) | `wrpc types --out api.d.ts` | [Codegen CLI](https://wrpc.vercel.app/guide/cli) | Every subpath ships hand-maintained TypeScript declarations — no generation, no @@ -281,7 +316,7 @@ Every subpath ships hand-maintained TypeScript declarations — no generation, n ## Documentation - **Start here** — [getting started](https://wrpc.vercel.app/guide/getting-started), - [why wrpc?](https://wrpc.vercel.app/guide/why). + [why wRPC?](https://wrpc.vercel.app/guide/why). - **Server** — [server](https://wrpc.vercel.app/guide/server), [router](https://wrpc.vercel.app/guide/router), [hooks](https://wrpc.vercel.app/guide/hooks), @@ -294,26 +329,43 @@ Every subpath ships hand-maintained TypeScript declarations — no generation, n [REST](https://wrpc.vercel.app/guide/rest), [authentication](https://wrpc.vercel.app/guide/auth), [metadata](https://wrpc.vercel.app/guide/metadata). +- **Message brokers** — [overview & contracts](https://wrpc.vercel.app/guide/brokers), + [durable feeds](https://wrpc.vercel.app/guide/brokers/feeds), + [queue consumers & publishing](https://wrpc.vercel.app/guide/brokers/consumers), + [RPC over a broker](https://wrpc.vercel.app/guide/brokers/rpc), + [Redis](https://wrpc.vercel.app/guide/brokers/redis), + [NATS](https://wrpc.vercel.app/guide/brokers/nats), + [RabbitMQ](https://wrpc.vercel.app/guide/brokers/amqp), + [Kafka](https://wrpc.vercel.app/guide/brokers/kafka). - **Client** — [client](https://wrpc.vercel.app/guide/client), [typed client](https://wrpc.vercel.app/guide/typed-client), + [multiple backends](https://wrpc.vercel.app/guide/multiple-backends), [browser & bundling](https://wrpc.vercel.app/guide/browser), [CLI](https://wrpc.vercel.app/guide/cli), [TanStack Query](https://wrpc.vercel.app/guide/query). - **Transports & hosts** — [SSE](https://wrpc.vercel.app/guide/sse), + [WebRTC](https://wrpc.vercel.app/guide/webrtc), + [WebRTC identity and trust](https://wrpc.vercel.app/guide/webrtc-trust), + [WebTransport](https://wrpc.vercel.app/guide/wt), [wire codec](https://wrpc.vercel.app/guide/codec), [uWebSockets.js](https://wrpc.vercel.app/guide/adapters/uws), [fastify](https://wrpc.vercel.app/guide/adapters/fastify), [express](https://wrpc.vercel.app/guide/adapters/express). - **Operations** — [security](https://wrpc.vercel.app/guide/security), + [rate limiting & throttling](https://wrpc.vercel.app/guide/rate-limiting), [running in production](https://wrpc.vercel.app/guide/production), [testing](https://wrpc.vercel.app/guide/testing), [performance](https://wrpc.vercel.app/guide/performance), + [compression](https://wrpc.vercel.app/guide/compression), + [encryption](https://wrpc.vercel.app/guide/encryption), [logging](https://wrpc.vercel.app/guide/logging), [OpenTelemetry](https://wrpc.vercel.app/guide/telemetry). - **Reference** — [wire protocol](https://wrpc.vercel.app/reference/protocol) - (frozen at 1.0), [wire format](https://wrpc.vercel.app/reference/wire-format), + (revision 2, negotiated with a 1.0 peer; with a changes-since-1.0 table), + [wire format](https://wrpc.vercel.app/reference/wire-format), [engine port](https://wrpc.vercel.app/reference/engine), - [errors & close codes](https://wrpc.vercel.app/reference/errors). + [errors & close codes](https://wrpc.vercel.app/reference/errors), + [stability & deprecation](https://wrpc.vercel.app/reference/stability). - **Types** — [`index.d.ts`](./index.d.ts) is the full public surface, plus one `.d.ts` per subpath. diff --git a/SECURITY.md b/SECURITY.md index abb2207..f7997a5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -39,18 +39,50 @@ areas where a report matters most: shape validation exist to be unbreakable too. - **The codegen CLI** (`src/cli/`): everything read from a remote server's introspection is untrusted input to a file generator. +- **Encryption** (`src/encryption/`, and the sealed layers built on it in + `src/rpc/envelope.js`, `src/broker/sealing.js`, `src/webtransport/`): + a counter nonce that repeats (including across a reseed), a message that + opens under another layer, channel, key id or AAD than it was sealed for, + an `OpenError` that is distinguishable by cause, a downgrade to plaintext + where a peer or a mode forbids it, a replay the window or the request + memory should have refused, a static key derived for one protocol usable + under another. +- **Parsers of hostile bytes** beyond the WebSocket engine: + `src/webtransport/` (the control-stream framing and the stream mux), + `src/deflate/inflate.js` (a complete DEFLATE decoder — a block that + costs more than its header says is in scope), `src/attachments.js` + (the binary attachments frame), `src/webrtc/framing.js` and + `src/webrtc/assertions.js` (data-channel framing, the JWS trust + assertions and the SDP fingerprint they bind to), `src/broker/ids.js` + and `src/broker/sealing.js` (resume tokens, sealed broker messages). Denial-of-service through resource exhaustion on any of these surfaces is -in scope; the limits exist to be unbreakable. +in scope; the limits exist to be unbreakable. Subpaths marked +`@experimental` (`./wt`, `./encryption`, `./broker/*`) are in scope too: +experimental describes the API's stability, not how seriously a hole in it +is taken. + +### Documented limits — not vulnerabilities + +Some things the encryption does not protect are stated up front, in +[what it does not protect](./docs/guide/encryption.md#what-it-does-not-protect): +metadata (sizes, timing, channel and room names, key ids), a compromised +end, replay on a log that is meant to be re-read, and a browser against the +origin that ships its script. A report that one of these holds is +confirming the documentation; a report that the documentation is wrong +about where a limit lies is very much wanted. ## Supported versions | Version | Supported | | --- | --- | -| Unreleased (`main` at HEAD, pre-first-publish) | ✔ fixes land on `main` | -| Latest published minor (once 1.0.0 ships) | ✔ security fixes | +| `2.x` (the latest published minor) | ✔ security fixes | +| `1.x` | ✖ not supported — upgrade to `2.x` (the [migration notes](./CHANGELOG.md) list every breaking change) | | Older releases | ✖ upgrade to the latest | +There is no maintenance branch for `1.x`: 1.0.0 shipped five weeks before +2.0, and the 2.0 line is where every fix lands. + The wire protocol carries its own compatibility promise ([protocol.md](./docs/reference/protocol.md#stability)) — a security fix will never need a wire-breaking change inside a major version. diff --git a/auth.d.ts b/auth.d.ts index 02dead2..a52bf80 100644 --- a/auth.d.ts +++ b/auth.d.ts @@ -65,7 +65,8 @@ export declare function bearerAuth(options: BearerAuthOptions): Pick< /** * The server half (`sessions.transport`): token in * `Authorization: ` — the real header where the transport - * can send one, the `wrpc_h` connect-URL parameter on browser ws. + * can send one (http, sse, ws from Node), the `wrpc.bearer.` + * subprotocol offer on browser ws, else the declared bag the core parsed. * Non-ambient: exempt from the safe-method CSRF rule, `write()` stamps * nothing. */ diff --git a/bench/algorithms.js b/bench/algorithms.js new file mode 100644 index 0000000..c656966 --- /dev/null +++ b/bench/algorithms.js @@ -0,0 +1,231 @@ +'use strict'; + +// Which algorithm, at which level — the measurement behind every default in +// src/compression/native.js and src/contentEncoding.js. Three families +// node:zlib ships (deflate, Brotli, Zstandard where this Node has it), at +// the levels a per-message codec can afford, over the sizes RPC messages +// come in; then the persistent-stream shape an SSE response uses (one +// context, flushed after every event), with what a thousand open streams +// cost in memory — a context is per connection there, not per message. +// +// What it decides: +// - `compression: true` stays deflate: level with zstd in bytes and CPU +// up to ~2 KB, where RPC messages live, and the only format every +// CompressionStream and every Node has. +// - the platform deflate's default level, 3: zlib's levels 1–3 are its +// fast strategy and 4+ the lazy one, and 3 is the knee — at 27 KB it +// costs under half of level 6 for ~5% more bytes, and level 4 is both +// slower AND larger than it. +// - the default Brotli quality (4) and zstd level (1, with the source +// size pledged) — and that zlib's own Brotli default, quality 11, is +// never an option on a message path: milliseconds where the rest take +// microseconds. +// - SSE stays gzip: a flushed Brotli or zstd stream saves no bytes over +// it on small events and holds 2–3x the memory per open response, +// which a smaller window does not give back (the tables are the cost). + +const { execFileSync } = require('node:child_process'); +const { performance } = require('node:perf_hooks'); +const zlib = require('node:zlib'); + +const c = zlib.constants; +const hasZstd = typeof zlib.zstdCompressSync === 'function'; + +const callback = (rows) => + Buffer.from( + JSON.stringify({ + type: 'callback', + id: 42, + result: { + items: Array.from({ length: rows }, (_, i) => ({ + id: 1000 + i, + name: `User ${i}`, + email: `user${i}@example.com`, + createdAt: new Date(1.7e12 + i * 86400000).toISOString(), + roles: i % 3 ? ['member'] : ['admin', 'member'], + active: i % 2 === 0, + score: Math.round(Math.sin(i) * 1e4) / 100, + })), + total: rows, + cursor: `c${rows}`, + }, + }), + ); + +const event = (i) => + `data: ${JSON.stringify({ + type: 'event', + name: 'chat/message', + data: { + id: 9000 + i, + room: 'general', + from: `user${i % 7}`, + text: `hello there number ${i} how are you`, + ts: 1.7e12 + i * 1000, + }, + })}\n\n`; + +const brotli = (quality) => (bytes) => + zlib.brotliCompressSync(bytes, { + params: { + [c.BROTLI_PARAM_QUALITY]: quality, + [c.BROTLI_PARAM_MODE]: c.BROTLI_MODE_TEXT, + [c.BROTLI_PARAM_SIZE_HINT]: bytes.length, + }, + }); + +const zstd = (level) => (bytes) => + zlib.zstdCompressSync(bytes, { params: { [c.ZSTD_c_compressionLevel]: level }, pledgedSrcSize: bytes.length }); + +const codecs = [ + ['deflate-raw L1', (bytes) => zlib.deflateRawSync(bytes, { level: 1 }), (bytes) => zlib.inflateRawSync(bytes)], + ['deflate-raw L3', (bytes) => zlib.deflateRawSync(bytes, { level: 3 }), (bytes) => zlib.inflateRawSync(bytes)], + ['deflate-raw L4', (bytes) => zlib.deflateRawSync(bytes, { level: 4 }), (bytes) => zlib.inflateRawSync(bytes)], + ['deflate-raw L6', (bytes) => zlib.deflateRawSync(bytes), (bytes) => zlib.inflateRawSync(bytes)], + ['gzip L6', (bytes) => zlib.gzipSync(bytes), (bytes) => zlib.gunzipSync(bytes)], + ['brotli q1', brotli(1), (bytes) => zlib.brotliDecompressSync(bytes)], + ['brotli q4', brotli(4), (bytes) => zlib.brotliDecompressSync(bytes)], + ['brotli q5', brotli(5), (bytes) => zlib.brotliDecompressSync(bytes)], + ['brotli q11 (zlib default)', brotli(11), (bytes) => zlib.brotliDecompressSync(bytes)], +]; +if (hasZstd) { + codecs.push( + ['zstd L1', zstd(1), (bytes) => zlib.zstdDecompressSync(bytes)], + ['zstd L3', zstd(3), (bytes) => zlib.zstdDecompressSync(bytes)], + ); +} + +// Microseconds per call: a fixed warm-up, then batches until the window is +// spent — quality 11 on 255 KB takes a third of a second a call, so a fixed +// iteration count would either starve the fast rows or stall on the slow one. +const time = (fn, input, windowMs = 300) => { + const warm = Math.min(50, Math.max(2, Math.floor(2e6 / input.length))); + for (let i = 0; i < warm; i++) fn(input); + let count = 0; + const started = performance.now(); + let elapsed = 0; + do { + fn(input); + count++; + elapsed = performance.now() - started; + } while (elapsed < windowMs); + return (elapsed * 1000) / count; +}; + +const micros = (value) => value.toFixed(1).padStart(10); + +const oneShot = () => { + for (const rows of [1, 2, 12, 180, 1700]) { + const plain = callback(rows); + console.log(`callback, ${plain.length.toLocaleString('en-US')} B plain`); + for (const [name, encode, decode] of codecs) { + const encoded = encode(plain); + const share = ((encoded.length / plain.length) * 100).toFixed(1).padStart(5); + console.log( + ` ${name.padEnd(28)}${encoded.length.toLocaleString('en-US').padStart(9)} B ${share}%` + + ` encode${micros(time(encode, plain))} µs decode${micros(time(decode, encoded))} µs`, + ); + } + } +}; + +const streams = [ + ['gzip L6', () => zlib.createGzip(), c.Z_SYNC_FLUSH], + ['gzip L6, windowBits 12', () => zlib.createGzip({ windowBits: 12, memLevel: 5 }), c.Z_SYNC_FLUSH], + ['brotli q4', () => zlib.createBrotliCompress({ params: { [c.BROTLI_PARAM_QUALITY]: 4 } }), c.BROTLI_OPERATION_FLUSH], + [ + 'brotli q4, lgwin 16', + () => zlib.createBrotliCompress({ params: { [c.BROTLI_PARAM_QUALITY]: 4, [c.BROTLI_PARAM_LGWIN]: 16 } }), + c.BROTLI_OPERATION_FLUSH, + ], +]; +if (hasZstd) { + streams.push( + ['zstd L3', () => zlib.createZstdCompress(), c.ZSTD_e_flush], + [ + 'zstd L3, windowLog 16', + () => zlib.createZstdCompress({ params: { [c.ZSTD_c_compressionLevel]: 3, [c.ZSTD_c_windowLog]: 16 } }), + c.ZSTD_e_flush, + ], + ); +} + +// One persistent stream, every event flushed before the next is written — +// the SSE writer's shape. The time includes the flush round trip through +// the stream, which is what an event really waits for. +const flushed = (create, flush, events) => + new Promise((resolve, reject) => { + const stream = create(); + let bytes = 0; + stream.on('data', (chunk) => { + bytes += chunk.length; + }); + stream.once('error', reject); + let i = 0; + const started = performance.now(); + const step = () => { + if (i === events) { + const elapsed = performance.now() - started; + stream.destroy(); + return void resolve({ perEvent: bytes / events, micros: (elapsed * 1000) / events }); + } + stream.write(event(i++)); + stream.flush(flush, step); + }; + step(); + }); + +// What N open streams hold on to: each has written one event and flushed, +// so its window and hash tables are allocated, as on a live connection. +// Measured in a child process per row — in one process the memory a +// destroyed row gave back is what the next row allocates from, and the +// later rows read as free. +const resident = async (index, count) => { + const [, create, flush] = streams[index]; + const before = process.memoryUsage().rss; + const open = []; + for (let i = 0; i < count; i++) { + const stream = create(); + stream.on('data', () => {}); + open.push(stream); + await new Promise((resolve) => { + stream.write(event(i)); + stream.flush(flush, resolve); + }); + } + return (process.memoryUsage().rss - before) / count; +}; + +const residentInChild = (index, count) => + Number(execFileSync(process.execPath, [__filename, 'resident', String(index), String(count)], { encoding: 'utf8' })); + +const persistent = async () => { + console.log(`one stream, flushed per event (${event(1).length} B plain)`); + for (let index = 0; index < streams.length; index++) { + const [name, create, flush] = streams[index]; + const { perEvent, micros: perFlush } = await flushed(create, flush, 2000); + const each = residentInChild(index, 1000); + console.log( + ` ${name.padEnd(28)}${perEvent.toFixed(1).padStart(9)} B/event${micros(perFlush)} µs/event` + + `${Math.round(each / 1024) + .toLocaleString('en-US') + .padStart(8)} KB resident/stream`, + ); + } +}; + +const main = async () => { + if (process.argv[2] === 'resident') { + const each = await resident(Number(process.argv[3]), Number(process.argv[4])); + process.stdout.write(String(each)); + return void process.exit(0); + } + if (!hasZstd) console.log('(this Node has no zstd in node:zlib — those rows are skipped)'); + oneShot(); + await persistent(); +}; + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/bench/attachments.js b/bench/attachments.js new file mode 100644 index 0000000..314717e --- /dev/null +++ b/bench/attachments.js @@ -0,0 +1,89 @@ +'use strict'; + +// Binary attachments (src/attachments.js), priced: `hasBytes` is the walk +// every outbound packet pays once the feature is on (the reason +// `attachments: false` exists), and the frame is what a packet with bytes +// costs against the two alternatives it replaces — JSON's own view of a +// typed array (nine times the bytes, and a plain object on arrival) and +// base64 in a string (a third more, plus the decode). + +const { performance } = require('node:perf_hooks'); + +const { hasBytes, encodeAttachments, decodeAttachments } = require('../src/attachments.js'); + +const report = (name, count, elapsedMs, extra = '') => + console.log( + ` ${name.padEnd(52)}${Math.round((count / elapsedMs) * 1000) + .toLocaleString('en-US') + .padStart(12)}/sec${extra}`, + ); + +const time = (label, fn, iterations, extra = '') => { + let sink = 0; + const started = performance.now(); + for (let i = 0; i < iterations; i++) sink += fn() ? 1 : 0; + const elapsed = performance.now() - started; + const ns = ((elapsed * 1e6) / iterations).toFixed(0); + report(label, iterations, elapsed, ` ${ns} ns${extra}`); + return sink; +}; + +const rows = (n) => ({ + type: 'callback', + id: 'c1', + result: Array.from({ length: n }, (_, i) => ({ id: i, name: `row-${i}`, tags: ['a', 'b'], score: i * 1.5 })), +}); + +const main = () => { + const small = rows(3); // ~233 B + const medium = rows(40); // ~3 KB + const large = rows(400); // ~30 KB + console.log('hasBytes: the per-packet walk (against JSON.stringify of the same packet)'); + time('233 B callback, walk', () => hasBytes(small), 500_000); + time('233 B callback, JSON.stringify', () => JSON.stringify(small).length > 0, 500_000); + time('3 KB callback, walk', () => hasBytes(medium), 100_000); + time('3 KB callback, JSON.stringify', () => JSON.stringify(medium).length > 0, 100_000); + time('30 KB callback, walk', () => hasBytes(large), 10_000); + time('30 KB callback, JSON.stringify', () => JSON.stringify(large).length > 0, 10_000); + // A graph with back-references: every child points at its parent. A walk + // with no memory of what it visited is exponential here (8^16 paths + // before the depth cap); the ancestor stack the slow walk keeps makes it + // linear — and the fast walk hands over the moment it hits the cap. + const graph = { children: [] }; + for (let i = 0; i < 8; i++) graph.children.push({ parent: graph, name: `c${i}`, tags: ['a'] }); + time('graph, 8 children with parent refs, walk', () => hasBytes(graph), 100_000); + const entity = { toJSON: () => ({ id: 1 }), passwordHash: new Uint8Array(32) }; + time('toJSON() entity holding a Buffer, walk (opaque)', () => hasBytes(entity), 1_000_000); + + console.log('strip: the path copies when a packet has bytes (encodeAttachments minus the frame copy)'); + const withBytes = { ...rows(400), attachment: new Uint8Array(1024) }; + time('30 KB rows + 1 KB attachment: encodeAttachments', () => encodeAttachments(withBytes).length > 0, 5_000); + const tiny = { type: 'event', name: 'x', data: { name: 'a.bin', body: new Uint8Array(16) } }; + time('tiny packet + 16 B attachment: encodeAttachments', () => encodeAttachments(tiny).length > 0, 500_000); + + console.log('a packet with a 1 KB and a 64 KB attachment: the frame against the alternatives'); + for (const size of [1024, 65536]) { + const body = new Uint8Array(size).map((_, i) => i & 255); + const packet = { type: 'call', id: 'c1', method: 'files/put', args: { name: 'a.bin', body } }; + const frame = encodeAttachments(packet); + const jsonSize = JSON.stringify(packet).length; + const base64Size = JSON.stringify({ + ...packet, + args: { ...packet.args, body: Buffer.from(body).toString('base64') }, + }).length; + console.log(` ${size} B attachment: frame ${frame.length} B, JSON ${jsonSize} B, base64 ${base64Size} B`); + time(`${size} B: encodeAttachments`, () => encodeAttachments(packet).length > 0, size > 4096 ? 5_000 : 100_000); + time( + `${size} B: decodeAttachments (copies the bytes)`, + () => decodeAttachments(frame) !== null, + size > 4096 ? 5_000 : 100_000, + ); + time( + `${size} B: JSON.stringify (what it used to do, wrongly)`, + () => JSON.stringify(packet).length > 0, + size > 4096 ? 500 : 20_000, + ); + } +}; + +main(); diff --git a/bench/broadcast-revisions.js b/bench/broadcast-revisions.js new file mode 100644 index 0000000..1846552 --- /dev/null +++ b/bench/broadcast-revisions.js @@ -0,0 +1,84 @@ +/** + * `Broadcast.emit` over a room whose clients do not all speak the same + * protocol revision (docs/reference/protocol.md#versioning). + * + * An event holding bytes is ONE attachments frame for the whole fan-out — + * that is the single-encode property the broadcast path has always had. A + * 1.0 client negotiated revision 1 and reads no frame, so in a mixed room + * the same event also exists as the JSON 1.0 sent: built on the first + * revision-1 recipient and shared by the rest (the `plain` twin in + * src/rpc/rooms.js). The rows below are what that costs: + * + * - a text event must cost the same whatever the room is made of — the + * twin is only ever looked for when the packet holds bytes; + * - a bytes event to an all-2.x room is the baseline (one frame); + * - a mixed room pays ONE extra JSON.stringify per emit, not one per + * revision-1 member — compare it with the all-1.0 row, which is that + * stringify alone. + * + * Measured when the negotiation landed (Node 24, 100 members): a text event + * 686K ops/s before and after; a 1 KB bytes event to an all-2.x room 287K + * before, 297K after; a half-1.0 room 21.0K against 21.4K for an all-1.0 + * one — the whole cost is the stringify of a typed array, which is what 1.0 + * paid on every event, and it is paid once. + * + * Run with `node bench/broadcast-revisions.js` or `pnpm bench`. + */ + +'use strict'; + +const { bench } = require('./support/harness.js'); +const { RpcServer } = require('../src/rpc/core.js'); +const { defineRouter, procedure } = require('../src/rpc/router.js'); + +const MEMBERS = 100; + +// Enough of the WrpcSocket shape for attachSocket; `send` is where the wire +// would be, so what is measured is the fan-out, not a socket. +let sink = 0; +const fakeSocket = (protocol) => ({ + protocol, + on() {}, + once() {}, + off() {}, + send(data) { + sink += data.length; + }, + close() {}, + terminate() {}, +}); + +const room = (protocols) => { + const router = defineRouter({ unit: { ping: procedure({ access: 'public', handler: async () => 'pong' }) } }); + const rpc = new RpcServer({ router, logger: false, sse: false }); + for (let i = 0; i < MEMBERS; i++) { + const client = rpc.attachSocket(fakeSocket(protocols[i % protocols.length]), { headers: {} }); + client.join('room'); + } + return rpc; +}; + +const TEXT = { user: 'ada', text: 'a chat line of ordinary length', at: 1790000000000 }; +const BYTES = { name: 'thumb.png', body: new Uint8Array(1024).fill(7) }; + +const main = async () => { + console.log(`Broadcast over mixed revisions — Node ${process.version}, ${MEMBERS} members\n`); + + const modern = room(['wrpc.v2']); + const mixed = room(['wrpc.v2', 'wrpc.v1']); + const old = room(['wrpc.v1']); + + await bench('text event — all revision 2', () => modern.to('room').emit('chat/message', TEXT)); + await bench('text event — half revision 1', () => mixed.to('room').emit('chat/message', TEXT)); + await bench('1 KB bytes event — all revision 2 (one frame)', () => modern.to('room').emit('files/ready', BYTES)); + await bench('1 KB bytes event — half revision 1 (frame + JSON)', () => mixed.to('room').emit('files/ready', BYTES)); + await bench('1 KB bytes event — all revision 1 (JSON)', () => old.to('room').emit('files/ready', BYTES)); + + await Promise.all([modern.close(), mixed.close(), old.close()]); + if (sink < 0) console.log(sink); +}; + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/bench/broker.js b/bench/broker.js new file mode 100644 index 0000000..45a13a2 --- /dev/null +++ b/bench/broker.js @@ -0,0 +1,159 @@ +'use strict'; + +// What the broker bindings cost on top of a broker, measured against the +// in-process MemoryBroker so the numbers are wrpc's own overhead — the +// dispatch, the settlement, the feed pump — and not a network's. +// +// - a queue delivery into a procedure, end to end: decode, the call +// pipeline (hooks, validators, access), the settlement; +// - the same with a validator and a hook, so the pipeline's share shows; +// - a publish through createPublisher (validate, encode, append); +// - a durable feed's pump: append -> tail -> tracked value on the wire; +// - an RPC session call answering a ~9 KB result, plain and with +// per-message compression on (the wrpc-enc frames), so the deflate's +// share of a broker round trip shows against the binding's own cost. +// +// Run with `pnpm bench` (bench/run-all.js) or `node bench/broker.js`. + +const { RpcServer } = require('../src/rpc/core.js'); +const { defineRouter, procedure } = require('../src/rpc/router.js'); +const { + MemoryBroker, + attachConsumers, + createPublisher, + brokerFeed, + attachBrokerRpc, +} = require('../src/broker/index.js'); +const { WrpcClient } = require('../src/client.js'); +const { bench } = require('./support/harness.js'); + +const quiet = { log() {}, info() {}, warn() {}, error() {}, debug() {} }; + +const settled = () => { + let resolve; + const promise = new Promise((_resolve) => { + resolve = _resolve; + }); + return { promise, resolve }; +}; + +// One delivery at a time: the number that matters is per-message latency +// through the whole binding, not how deep a queue can be filled. +const consumerBench = async (label, build, options = {}) => { + const broker = new MemoryBroker({ logger: quiet }); + let pending = null; + const rpc = new RpcServer({ router: build(() => pending?.resolve()), logger: quiet, sse: false }); + const consumers = await attachConsumers(rpc, broker, {}, { logger: quiet }); + const result = await bench(label, async () => { + pending = settled(); + await broker.queue.produce('work', '{"n":1}', options.headers ? { headers: options.headers } : undefined); + await pending.promise; + }); + await consumers.stop(); + await rpc.close(); + broker.close(); + return result; +}; + +const run = async () => { + console.log('\nbroker bindings (MemoryBroker — wrpc overhead only)\n'); + + await consumerBench('queue delivery -> procedure (public, no validators)', (done) => + defineRouter({ + bench: { + consumes: { + work: procedure({ access: 'public', handler: async () => void done() }), + }, + }, + }), + ); + + await consumerBench( + 'queue delivery -> procedure (validator, hook, meta header)', + (done) => + defineRouter({ + bench: { + hooks: { preHandler: () => {} }, + consumes: { + work: procedure({ + access: 'public', + input: (args) => { + if (typeof args.n !== 'number') throw new Error('n must be a number'); + }, + consume: { meta: ['x-tenant'] }, + handler: async () => void done(), + }), + }, + }, + }), + { headers: { 'x-tenant': 't1' } }, + ); + + // Publishing: validate, encode, append. + const broker = new MemoryBroker({ logger: quiet }); + const router = defineRouter({ + 'orders.v1': { + emits: { created: { data: { id: 'string' } } }, + place: procedure({ access: 'public', handler: async () => ({}) }), + }, + }); + const rpc = new RpcServer({ router, logger: quiet, sse: false }); + const publisher = createPublisher(rpc, broker, { + 'orders.v1/created': { validate: (order) => order }, + }); + await bench('createPublisher.publish -> log append', () => publisher.publish('orders.v1/created', { id: 'o-1' })); + + // A live feed: one append, one tracked value out of the pump. + const feed = brokerFeed(broker, 'orders.v1.created'); + const controller = new AbortController(); + const iterator = feed({}, {}, { signal: controller.signal }); + let next = iterator.next(); + await bench('brokerFeed: append -> tracked value', async () => { + await broker.log.append('orders.v1.created', '{"id":"o"}'); + await next; + next = iterator.next(); + }); + controller.abort(); + await iterator.return?.(); + await rpc.close(); + broker.close(); + + // RPC over the broker, a session: the same ~9 KB answer plain and + // compressed — what turning `compression` on costs per round trip. + const rows = Array.from({ length: 300 }, (_, i) => ({ id: i, name: `row-${i}`, tags: ['a', 'b'] })); + const rpcRound = async (label, compression) => { + const memory = new MemoryBroker({ logger: quiet }); + const server = new RpcServer({ + router: defineRouter({ calc: { big: procedure({ access: 'public', handler: async () => rows }) } }), + logger: quiet, + sse: false, + }); + const handle = await attachBrokerRpc(server, memory, { service: 'calc', logger: quiet, compression }); + const client = await WrpcClient.connect('broker://calc', { + transport: 'broker', + broker: memory, + mode: 'session', + compression, + heartbeat: false, + reconnect: false, + logger: false, + }); + await client.load('calc'); + await bench(label, () => client.api.calc.big()); + client.close(); + await handle.stop(); + await server.close(); + memory.close(); + }; + await rpcRound('rpc session: a 9 KB answer, plain', false); + await rpcRound('rpc session: a 9 KB answer, compression on', true); +}; + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} + +module.exports = { run }; diff --git a/bench/browser/calls.js b/bench/browser/calls.js new file mode 100644 index 0000000..136c272 --- /dev/null +++ b/bench/browser/calls.js @@ -0,0 +1,91 @@ +'use strict'; + +// The page half of the browser benchmark (scripts/bench-browser.js injects +// the browser bundle as `wrpc`, then this file, then evaluates +// `__wrpcBench()`). It measures the CLIENT's per-call cost in a real +// browser — the id, the packet, the pending-call record, the deadline +// bookkeeping, the callback settle — over an in-page echo server on a +// MessageChannel, so no network and no server code is in the number. +// +// Not discovered by bench/run-all.js (top-level files only): run it with +// `pnpm bench:browser`. + +/* global wrpc */ + +const PIPELINES = [1, 64, 1024]; +const WARMUP_MS = 300; +const MEASURE_MS = 1000; + +// Answers wrpc packets on the port it is handed in the `wrpc:connect` +// message: every call is echoed back as its callback, a ping as a pong. +const fakeWorker = { + postMessage(message, transfer) { + if (message?.type !== 'wrpc:connect') return; + const port = transfer[0]; + port.onmessage = ({ data }) => { + const packet = JSON.parse(data); + const answer = (one) => { + if (one.type === 'call') return { type: 'callback', id: one.id, result: one.args }; + if (one.type === 'ping') return { type: 'pong' }; + return null; + }; + if (Array.isArray(packet)) { + port.postMessage(JSON.stringify(packet.map(answer).filter(Boolean))); + } else { + const reply = answer(packet); + if (reply) port.postMessage(JSON.stringify(reply)); + } + }; + }, +}; + +const runFor = async (fn, ms) => { + const end = performance.now() + ms; + let iterations = 0; + while (performance.now() < end) { + await fn(); + iterations++; + } + return iterations; +}; + +const measure = async (name, fn, opsPerIteration) => { + await runFor(fn, WARMUP_MS); + const started = performance.now(); + const iterations = await runFor(fn, MEASURE_MS); + const elapsed = performance.now() - started; + return { name, opsPerSec: Math.round((iterations * opsPerIteration * 1000) / elapsed) }; +}; + +globalThis.__wrpcBench = async () => { + const client = await wrpc.connect('event://bench', { transport: 'event', worker: fakeWorker, heartbeat: false }); + client.use({ bench: { echo: { access: 'public' } } }); + const echo = client.api.bench.echo; + const payload = { name: 'Ada' }; + const results = []; + for (const depth of PIPELINES) { + const fn = + depth === 1 + ? () => echo(payload) + : () => { + const calls = new Array(depth); + for (let i = 0; i < depth; i++) calls[i] = echo(payload); + return Promise.all(calls); + }; + results.push(await measure(`call echo ×${depth} in flight`, fn, depth)); + } + // A per-call timeout puts the calls in their own deadline lane. + results.push( + await measure( + 'call echo ×64 in flight, per-call timeout', + () => { + const calls = new Array(64); + for (let i = 0; i < 64; i++) calls[i] = echo(payload, { timeout: 5000 }); + return Promise.all(calls); + }, + 64, + ), + ); + client.close(); + return results; +}; diff --git a/bench/browser/transports.js b/bench/browser/transports.js new file mode 100644 index 0000000..ab3641b --- /dev/null +++ b/bench/browser/transports.js @@ -0,0 +1,255 @@ +'use strict'; + +// The page half of the browser transport benchmark (`pnpm bench:browser +// transports` — scripts/bench-browser.js serves this page from +// http://127.0.0.1, a secure context, with one bundle as `wrpc`: the +// browser entry plus the WebRTC browser barrel as `wrpc.webrtc`, so both +// share one transport registry). wrpc's client in Chrome over: +// +// ws the browser's WebSocket to the Node Server +// wt the browser's WebTransport to the same Server's HTTP/3 host +// (@fails-components/webtransport; only when the runner booted it) +// webrtc a data channel between two RTCPeerConnections in this page — +// Chrome's own SCTP/DTLS on loopback, a PeerHost on the far end. +// Both peers share this renderer, so the row pays both ends. +// +// Not discovered by bench/run-all.js (top-level files only). + +/* global wrpc */ + +const WARMUP_MS = 300; +const MEASURE_MS = 1000; +const OPENS = 10; +const STREAM_BYTES = 16 * 1024 * 1024; +const LOAD_MS = 2000; +const CHUNK = 64 * 1024; +const BLOCK = new Uint8Array(CHUNK).fill(7); + +const runFor = async (fn, ms) => { + const end = performance.now() + ms; + let iterations = 0; + while (performance.now() < end) { + await fn(); + iterations++; + } + return iterations; +}; + +const opsPerSec = async (fn, opsPerIteration = 1) => { + await runFor(fn, WARMUP_MS); + const started = performance.now(); + const iterations = await runFor(fn, MEASURE_MS); + return Math.round((iterations * opsPerIteration * 1000) / (performance.now() - started)); +}; + +const percentile = (sorted, p) => sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))]; +const round = (value, digits = 2) => Number(value.toFixed(digits)); + +const pump = async (writable, size) => { + for (let sent = 0; sent < size; sent += CHUNK) { + const piece = size - sent >= CHUNK ? BLOCK : BLOCK.subarray(0, size - sent); + if (!writable.write(piece)) { + if (writable.closed) throw new Error('the stream closed mid-upload'); + await new Promise((resolve) => writable.once('drain', resolve)); + } + } + writable.end(); +}; + +const drain = async (readable) => { + let bytes = 0; + for await (const chunk of readable) bytes += chunk.length; + return bytes; +}; + +// The in-page host the webrtc row calls: the same three procedures the Node +// Server answers (bench/support/transport-api.js), on a PeerHost. +const pageRouter = () => { + const { defineRouter, procedure } = wrpc.webrtc; + return defineRouter({ + bench: { + echo: procedure({ access: 'public', handler: async (_context, args) => args }), + echoStream: procedure({ + access: 'public', + handler: async (context, { stream }) => { + const bytes = await drain(context.client.getStream(stream)); + const back = context.client.createStream('back', bytes); + pump(back, bytes); + return back.id; + }, + }), + sink: procedure({ + access: 'public', + handler: async (context, { stream }) => drain(context.client.getStream(stream)), + }), + }, + }); +}; + +const opened = (channel) => + channel.readyState === 'open' + ? Promise.resolve() + : new Promise((resolve) => channel.addEventListener('open', resolve, { once: true })); + +// Candidates from `from` reach `to` only once `to` has a remote description: +// addIceCandidate before one rejects, and a pair that lost every candidate +// on both sides never connects — it hung this benchmark now and then. +const trickle = (from, to) => { + const held = []; + let flushing = Promise.resolve(); + const flush = async () => { + while (held.length > 0 && to.remoteDescription) await to.addIceCandidate(held.shift()); + }; + from.addEventListener('icecandidate', ({ candidate }) => { + if (!candidate) return; + held.push(candidate); + flushing = flushing.then(flush); + }); + return () => (flushing = flushing.then(flush)); +}; + +const within = (promise, ms, what) => + Promise.race([ + promise, + new Promise((_resolve, reject) => setTimeout(() => reject(new Error(`${what}: no answer in ${ms} ms`)), ms)), + ]); + +// Two peer connections in this page, one negotiated channel each, the +// offer/answer and candidates handed over by hand. +const rtcPair = async () => { + const a = new RTCPeerConnection(); + const b = new RTCPeerConnection(); + const flushA = trickle(a, b); + const flushB = trickle(b, a); + const client = a.createDataChannel('wrpc', { negotiated: true, id: 0 }); + const host = b.createDataChannel('wrpc', { negotiated: true, id: 0 }); + await a.setLocalDescription(); + await b.setRemoteDescription(a.localDescription); + await b.setLocalDescription(); + await a.setRemoteDescription(b.localDescription); + await Promise.all([flushA(), flushB()]); + await within(Promise.all([opened(client), opened(host)]), 10_000, 'the RTCPeerConnection pair'); + return { + client, + host, + close: () => { + a.close(); + b.close(); + }, + }; +}; + +const connector = (kind, config) => { + if (kind === 'ws') { + return async () => ({ client: await wrpc.connect(config.wsUrl, { heartbeat: false }), close() {} }); + } + if (kind === 'wt') { + const value = Uint8Array.from(atob(config.wtHash), (c) => c.charCodeAt(0)); + const wt = { serverCertificateHashes: [{ algorithm: 'sha-256', value }] }; + return async () => ({ + client: await wrpc.connect(config.wtUrl, { transport: 'wt', wt, heartbeat: false }), + close() {}, + }); + } + const host = new wrpc.webrtc.PeerHost({ router: pageRouter() }); + let n = 0; + return async () => { + const pair = await rtcPair(); + const peer = `peer-${n++}`; + host.attach(new wrpc.webrtc.RtcPeerTransport(pair.host, { peer }), { peer }); + const client = await wrpc.connect('webrtc:host', { + transport: 'webrtc', + channel: pair.client, + reconnect: false, + heartbeat: false, + }); + return { client, close: pair.close }; + }; +}; + +const measureKind = async (kind, config) => { + const open = connector(kind, config); + const result = { kind }; + + // Open, load, first call. + const opens = []; + for (let i = 0; i < OPENS + 2; i++) { + const start = performance.now(); + const connection = await open(); + await connection.client.load('bench'); + await connection.client.api.bench.echo({ i }); + if (i >= 2) opens.push(performance.now() - start); + connection.client.close(); + connection.close(); + } + opens.sort((a, b) => a - b); + result.firstCall = round(percentile(opens, 50)); + + const connection = await open(); + const { client } = connection; + await client.load('bench'); + const echo = client.api.bench.echo; + + // Calls: the three columns of bench/rpc-comparison.js. + const small = { name: 'Ada' }; + const large = { text: 'x'.repeat(10_000) }; + result.small = await opsPerSec(() => echo(small)); + result.large = await opsPerSec(() => echo(large)); + result.pipelined = await opsPerSec(() => { + const calls = new Array(64); + for (let i = 0; i < 64; i++) calls[i] = echo(small); + return Promise.all(calls); + }, 64); + + // 16 MiB up, 16 MiB back. + { + const start = performance.now(); + const upload = client.createStream('blob', STREAM_BYTES); + const answer = client.api.bench.echoStream({ stream: upload.id }); + await pump(upload, STREAM_BYTES); + const received = await drain(client.getStream(await answer)); + if (received !== STREAM_BYTES) throw new Error(`${kind}: ${received} bytes came back`); + result.streamMiBps = round((2 * STREAM_BYTES) / (1024 * 1024) / ((performance.now() - start) / 1000), 1); + } + + // A small call while uploads keep the connection busy. + { + const state = { done: false }; + const load = (async () => { + const deadline = performance.now() + LOAD_MS; + while (performance.now() < deadline) { + const upload = client.createStream('load', STREAM_BYTES); + const sunk = client.api.bench.sink({ stream: upload.id }); + await pump(upload, STREAM_BYTES); + await sunk; + } + })().finally(() => { + state.done = true; + }); + const times = []; + while (!state.done) { + const start = performance.now(); + await echo(small); + times.push(performance.now() - start); + } + await load; + times.sort((a, b) => a - b); + result.loaded = { p50: round(percentile(times, 50)), p99: round(percentile(times, 99)), calls: times.length }; + } + + client.close(); + connection.close(); + return result; +}; + +globalThis.__wrpcTransports = async (config) => { + const results = []; + for (const kind of config.kinds) { + try { + results.push(await measureKind(kind, config)); + } catch (error) { + results.push({ kind, error: String(error?.stack ?? error) }); + } + } + return results; +}; diff --git a/bench/cluster-nodes.js b/bench/cluster-nodes.js new file mode 100644 index 0000000..c1e6745 --- /dev/null +++ b/bench/cluster-nodes.js @@ -0,0 +1,242 @@ +'use strict'; + +// The multi-node stand the paper's single-machine measurements lacked: N +// wrpc processes (bench/support/cluster-node-worker.js) over ONE Redis — +// backplane and session store — and M clients spread across them, so what +// is measured is a real fan-out through a real broker between real +// processes. Redis comes from `pnpm redis:up` (compose.yaml). +// +// REDIS_URL=redis://127.0.0.1:6379 node bench/cluster-nodes.js +// +// Without REDIS_URL it prints one line and exits 0: bench/run-all.js runs +// every file here and must stay self-contained. +// +// Scenarios: +// 1. cross-instance emit — one instance emits to a room whose members sit +// on every instance; rate, and the delivery latency distribution seen +// by the remotest member (the timestamp rides the payload); +// 2. presence convergence — every client joins then leaves a room; how +// long until the cluster's count on one instance agrees; +// 3. broadcast ask across instances; +// 4. an instance dies — its clients reconnect elsewhere with their +// session token (no sticky routing), presence converges, and the loss +// detector reports how many envelopes the survivors never saw. + +const { fork } = require('node:child_process'); +const path = require('node:path'); + +const { WrpcClient } = require('../src/client.js'); +const { bearerAuth, memoryStore } = require('../src/auth/index.js'); + +const { REDIS_URL } = process.env; +const WORKER = path.join(__dirname, 'support', 'cluster-node-worker.js'); +const NODES = Number(process.env.WRPC_NODES ?? 4); +const CLIENTS = Number(process.env.WRPC_CLIENTS ?? 200); +const MEASURE_MS = 1000; + +const report = (name, count, elapsedMs, extra = '') => + console.log( + ` ${name.padEnd(48)}${Math.round((count / elapsedMs) * 1000) + .toLocaleString('en-US') + .padStart(12)}/sec${extra}`, + ); + +const percentile = (values, p) => { + if (values.length === 0) return 0; + const sorted = values.slice().sort((a, b) => a - b); + return sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))]; +}; + +// Every worker this run forked, killed on ANY exit — an orchestrator that +// died mid-run used to leave instances behind on the shared Redis, and a +// later run's same-named instances then argued with them over presence. +const children = new Set(); +process.on('exit', () => { + for (const child of children) child.kill('SIGKILL'); +}); +for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => process.exit(1)); + +// Instance ids unique per run: two runs (or a leftover) must never share one. +const RUN = process.pid.toString(36); + +const startNode = (instance) => + new Promise((resolve, reject) => { + const child = fork(WORKER, [], { env: { ...process.env, WRPC_INSTANCE: instance }, stdio: 'inherit' }); + children.add(child); + child.once('exit', () => children.delete(child)); + child.once('message', (message) => { + if (message.type === 'ready') resolve({ child, port: message.port, instance }); + else reject(new Error(message.message ?? 'worker failed')); + }); + child.once('exit', (code) => reject(new Error(`worker ${instance} exited with ${code}`))); + }); + +const stopNode = (node) => + new Promise((resolve) => { + node.child.once('exit', resolve); + node.child.send({ type: 'stop' }); + }); + +const connect = async (node, user) => { + const store = memoryStore(); + const client = await WrpcClient.connect(`ws://127.0.0.1:${node.port}/`, { + heartbeat: false, + reconnect: false, + ...bearerAuth({ store, signIn: (c) => c.call('bench/login', { user }) }), + }); + await client.load('bench'); + client.respond('bench/q', () => 1); + return { client, store, node, user }; +}; + +// `check` returns true when converged, or a value to report if it never does. +const waitUntil = async (check, timeoutMs = 10_000, label = 'convergence') => { + const started = performance.now(); + let last; + while (performance.now() - started < timeoutMs) { + last = await check(); + if (last === true) return performance.now() - started; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error(`timed out waiting for ${label} (last observed: ${JSON.stringify(last)})`); +}; + +// A count probe: true at the target, the observed count otherwise. +const countIs = (client, room, target) => async () => { + const count = await client.api.bench.count({ room }); + return count === target ? true : count; +}; + +const main = async () => { + if (!REDIS_URL) { + console.log('cluster-nodes: skipped (set REDIS_URL, see compose.yaml)'); + return; + } + console.log( + `Multi-node benchmark — Node ${process.version}, ${NODES} instances, ${CLIENTS} clients, Redis at ${REDIS_URL}\n`, + ); + console.log(` ${'scenario'.padEnd(48)}${'rate'.padStart(16)}`); + + const nodes = []; + for (let i = 0; i < NODES; i++) nodes.push(await startNode(`${RUN}-n${i}`)); + const peers = []; + for (let i = 0; i < CLIENTS; i++) peers.push(await connect(nodes[i % NODES], `user-${i}`)); + const room = 'load'; + for (const peer of peers) await peer.client.api.bench.join({ room }); + // Presence needs a moment to replicate before the first count agrees. + await waitUntil(countIs(peers[0].client, room, CLIENTS), 10_000, 'initial presence'); + + // 1. Cross-instance emit: latency is measured at the LAST client of the + // last instance — the longest hop through the broker. + { + const far = peers[peers.length - 1].client; + const latencies = []; + let received = 0; + far.api.bench.on('tick', (data) => { + received++; + latencies.push(Date.now() - data.t); + }); + let emits = 0; + const started = performance.now(); + while (performance.now() - started < MEASURE_MS) { + const before = received; + await peers[0].client.api.bench.fanout({ room, data: { t: Date.now() } }); + emits++; + await waitUntil(() => received > before || received, 2000, 'remote delivery'); + } + const elapsed = performance.now() - started; + report( + `cross-instance emit ×${NODES} instances, ${CLIENTS} members`, + emits, + elapsed, + ` latency p50 ${percentile(latencies, 50)} ms, p99 ${percentile(latencies, 99)} ms`, + ); + } + + // 2. Presence convergence after a join/leave storm. + { + const storm = 'storm'; + const started = performance.now(); + await Promise.all(peers.map((peer) => peer.client.api.bench.join({ room: storm }))); + const joined = await waitUntil(countIs(peers[0].client, storm, CLIENTS), 15_000, 'presence after joins'); + await Promise.all(peers.map((peer) => peer.client.api.bench.leave({ room: storm }))); + const left = await waitUntil(countIs(peers[0].client, storm, 0), 15_000, 'presence after leaves'); + report( + `presence: ${CLIENTS} joins + leaves across ${NODES} instances`, + CLIENTS * 2, + performance.now() - started, + ` converged after ${joined.toFixed(0)} ms / ${left.toFixed(0)} ms`, + ); + } + + // 3. Broadcast ask across instances. + { + let asks = 0; + let answered = 0; + let expected = 0; + const started = performance.now(); + while (performance.now() - started < MEASURE_MS) { + const result = await peers[0].client.api.bench.inquire({ room }); + answered += result.answers; + expected += result.expected; + asks++; + } + report( + `broadcast ask across ${NODES} instances`, + asks, + performance.now() - started, + ` ${(answered / asks).toFixed(1)} of ${(expected / asks).toFixed(1)} expected answers per ask`, + ); + } + + // 4. An instance dies: its clients reconnect elsewhere with their + // session token, presence converges, the loss detector speaks. + { + const victim = nodes[NODES - 1]; + const orphans = peers.filter((peer) => peer.node === victim); + // Sessions were created on the victim; whoami on another instance is + // the shared-store proof. + const started = performance.now(); + await stopNode(victim); + for (const peer of orphans) peer.client.close(); + const survivors = nodes.slice(0, NODES - 1); + let restored = 0; + await Promise.all( + orphans.map(async (peer, i) => { + const node = survivors[i % survivors.length]; + const client = await WrpcClient.connect(`ws://127.0.0.1:${node.port}/`, { + heartbeat: false, + reconnect: false, + ...bearerAuth({ store: peer.store, signIn: () => null }), + }); + await client.load('bench'); + if ((await client.api.bench.whoami()) === peer.user) restored++; + await client.api.bench.join({ room }); + peer.client = client; + peer.node = node; + }), + ); + const converged = await waitUntil(countIs(peers[0].client, room, CLIENTS), 15_000, 'presence after rehoming'); + const elapsed = performance.now() - started; + let gaps = 0; + for (const node of survivors) { + const probe = peers.find((peer) => peer.node === node); + gaps += await probe.client.api.bench.gaps(); + } + report( + `instance loss: ${orphans.length} clients rehomed`, + orphans.length, + elapsed, + ` sessions restored ${restored}/${orphans.length}, presence converged after ${converged.toFixed(0)} ms, backplane gaps ${gaps}`, + ); + } + + for (const peer of peers) peer.client.close(); + for (const node of nodes.slice(0, NODES - 1)) await stopNode(node); + console.log(); +}; + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/bench/deflate-context.js b/bench/deflate-context.js new file mode 100644 index 0000000..45d52de --- /dev/null +++ b/bench/deflate-context.js @@ -0,0 +1,131 @@ +'use strict'; + +// permessage-deflate three ways — the stateless one-shot default, a live +// context (context takeover) and the async threadpool path — on the kind of +// traffic each is for: a repeated JSON event (where a context earns its +// memory) and a large payload (where async earns its hand-off). The last +// block measures what async exists for: event-loop delay while a stream of +// large messages is compressed. + +const { monitorEventLoopDelay } = require('node:perf_hooks'); + +const { compress, compressAsync } = require('../src/websocket/permessageDeflate.js'); +const { DeflateContext } = require('../src/websocket/deflateContext.js'); + +const event = (i) => + JSON.stringify({ + type: 'event', + name: 'market/tick', + data: { symbol: 'BTC-USD', bid: 42000 + (i % 100), ask: 42001 + (i % 100), ts: 1726500000000 + i }, + }); + +const report = (name, count, elapsedMs, extra = '') => + console.log( + ` ${name.padEnd(52)}${Math.round((count / elapsedMs) * 1000) + .toLocaleString('en-US') + .padStart(12)}/sec${extra}`, + ); + +const oneShot = (payloads) => { + let bytes = 0; + const started = performance.now(); + for (let i = 0; i < payloads.length; i++) bytes += compress(payloads[i]).length; + return { elapsed: performance.now() - started, bytes }; +}; + +const takeover = async (payloads, level) => { + const context = new DeflateContext(level === undefined ? { windowBits: 15 } : { windowBits: 15, level }); + let bytes = 0; + const started = performance.now(); + for (let i = 0; i < payloads.length; i++) { + bytes += await new Promise((resolve, reject) => + context.compress(payloads[i], (error, out) => (error ? reject(error) : resolve(out.length))), + ); + } + const elapsed = performance.now() - started; + context.close(); + return { elapsed, bytes }; +}; + +const async = async (payloads) => { + let bytes = 0; + const started = performance.now(); + for (let i = 0; i < payloads.length; i++) { + bytes += await new Promise((resolve, reject) => + compressAsync(payloads[i], 15, (error, out) => (error ? reject(error) : resolve(out.length))), + ); + } + return { elapsed: performance.now() - started, bytes }; +}; + +const ratio = (payloads, bytes) => { + const raw = payloads.reduce((sum, p) => sum + p.length, 0); + return ` ratio ${(raw / bytes).toFixed(1)}x`; +}; + +const main = async () => { + console.log(`permessage-deflate modes — Node ${process.version}\n`); + const events = Array.from({ length: 5_000 }, (_, i) => Buffer.from(event(i))); + let r = oneShot(events); + report('repeated JSON event, one-shot (default)', events.length, r.elapsed, ratio(events, r.bytes)); + r = await takeover(events); + report('repeated JSON event, context takeover', events.length, r.elapsed, ratio(events, r.bytes)); + // The level under a live context (PERF-2): whether the knee of the + // one-shot rows in bench/send-path.js and bench/algorithms.js holds + // when a message references the ones before it. + for (const level of [1, 3, 6]) { + r = await takeover(events, level); + report(`repeated JSON event, context takeover, level ${level}`, events.length, r.elapsed, ratio(events, r.bytes)); + } + r = await async(events); + report('repeated JSON event, async threadpool', events.length, r.elapsed, ratio(events, r.bytes)); + + for (const size of [4, 32, 256]) { + const payloads = Array.from({ length: size >= 256 ? 100 : 500 }, () => + Buffer.from(JSON.stringify({ rows: Array.from({ length: size * 8 }, (_, i) => ({ i, v: i * 7 })) })), + ); + r = oneShot(payloads); + report(`${size} KB-class JSON, one-shot`, payloads.length, r.elapsed, ratio(payloads, r.bytes)); + r = await async(payloads); + report(`${size} KB-class JSON, async threadpool`, payloads.length, r.elapsed, ratio(payloads, r.bytes)); + } + + // Event-loop delay under a BURST of large messages — a fan-out's worth + // issued in one turn, as a broadcast does. Synchronous deflate blocks the + // loop for the whole burst; async hands it to the threadpool and the + // loop keeps serving every other connection meanwhile. + const big = Buffer.from(JSON.stringify({ rows: Array.from({ length: 12288 }, (_, i) => ({ i, v: i * 7 })) })); + const burst = 100; + for (const mode of ['one-shot', 'async']) { + const histogram = monitorEventLoopDelay({ resolution: 1 }); + histogram.enable(); + await new Promise((resolve) => setTimeout(resolve, 20)); + const started = performance.now(); + if (mode === 'one-shot') { + for (let i = 0; i < burst; i++) compress(big); + await new Promise((resolve) => setImmediate(resolve)); + } else { + await Promise.all( + Array.from( + { length: burst }, + () => + new Promise((resolve, reject) => compressAsync(big, 15, (error) => (error ? reject(error) : resolve()))), + ), + ); + } + const elapsed = performance.now() - started; + histogram.disable(); + report( + `${(big.length / 1024).toFixed(0)} KB burst ×${burst}, ${mode}`, + burst, + elapsed, + ` loop delay max ${(histogram.max / 1e6).toFixed(0)} ms`, + ); + } + console.log(); +}; + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/bench/deflate-js.js b/bench/deflate-js.js new file mode 100644 index 0000000..6738dcc --- /dev/null +++ b/bench/deflate-js.js @@ -0,0 +1,229 @@ +'use strict'; + +// The pure-JS DEFLATE codec (@alexify/wrpc/deflate) against node:zlib and, +// where this Node has one, the platform's CompressionStream — at the sizes +// RPC messages come in, with and without the router dictionary. Two +// things this bench decides: that fixed Huffman costs nothing on a small +// message against a dictionary (so the encoder needs no dynamic trees), and +// where the hybrid codec should hand a large message to the platform +// (`nativeAbove`), since fixed codes fall behind dynamic ones there. + +const zlib = require('node:zlib'); +const { performance } = require('node:perf_hooks'); + +const { defineRouter, procedure } = require('../src/rpc/router.js'); +const { buildDictionary } = require('../src/rpc/dictionary.js'); +const { inflateRaw, deflateRaw, createDeflateCodec } = require('../src/deflate/index.js'); + +const router = defineRouter({ + market: { + quote: procedure({ + access: 'public', + signature: { args: { symbol: 'string' }, returns: { bid: 'number', ask: 'number', ts: 'number' } }, + handler: async () => ({}), + }), + orders: procedure({ + access: 'public', + signature: { returns: [{ id: 'string', side: 'string', price: 'number', size: 'number', createdAt: 'string' }] }, + handler: async () => [], + }), + emits: { tick: { data: { symbol: 'string', bid: 'number', ask: 'number', ts: 'number' } } }, + }, +}); +const dictionary = buildDictionary(router); + +const encoder = new TextEncoder(); +const tick = () => + encoder.encode( + JSON.stringify({ + type: 'event', + name: 'market/tick', + data: { symbol: 'BTC-USD', bid: 42000.5, ask: 42001, ts: 1726500000000 }, + }), + ); +const orders = (rows) => + encoder.encode( + JSON.stringify({ + type: 'callback', + id: 'c1', + result: Array.from({ length: rows }, (_, i) => ({ + id: `o-${i}`, + side: i % 2 ? 'buy' : 'sell', + price: 42000 + i, + size: (i % 7) + 1, + createdAt: '2026-09-19T10:00:00.000Z', + })), + }), + ); + +const report = (name, count, elapsedMs, extra = '') => + console.log( + ` ${name.padEnd(48)}${Math.round((count / elapsedMs) * 1000) + .toLocaleString('en-US') + .padStart(12)}/sec${extra}`, + ); + +const timeSync = (label, fn, iterations, size) => { + let out = 0; + const started = performance.now(); + for (let i = 0; i < iterations; i++) out += fn().length; + const elapsed = performance.now() - started; + report(label, iterations, elapsed, size === undefined ? '' : ` ${size} -> ${Math.round(out / iterations)} B`); +}; + +const timeAsync = async (label, fn, iterations, size) => { + let out = 0; + const started = performance.now(); + for (let i = 0; i < iterations; i++) out += (await fn()).length; + const elapsed = performance.now() - started; + report(label, iterations, elapsed, size === undefined ? '' : ` ${size} -> ${Math.round(out / iterations)} B`); +}; + +// A skewed byte distribution — one value nearly always, the rest spread +// thin — is what gives a dynamic Huffman tree its longest codes (zlib caps +// them at 15 bits): the shape that costs a table-driven inflater the most. +// Deterministic (an LCG), so every run measures the same bytes. +const skewed = (size, seed = 7) => { + const out = new Uint8Array(size); + let x = seed >>> 0; + for (let i = 0; i < size; i++) { + x = (Math.imul(x, 1664525) + 1013904223) >>> 0; + out[i] = x % 100 < 92 ? 0x20 : 1 + ((x >>> 8) % 255); + } + return out; +}; + +// One raw stream of many dynamic blocks: zlib fragments that end in a sync +// flush (no final bit) concatenated, the last one finished. Every block +// carries its own tree, so the per-block cost of the inflater is what this +// input measures. +const manyBlocks = (count, chunk) => { + const parts = []; + const plain = []; + for (let i = 0; i < count; i++) { + const bytes = skewed(chunk, i + 1); + plain.push(bytes); + const last = i === count - 1; + parts.push(zlib.deflateRawSync(bytes, last ? {} : { finishFlush: zlib.constants.Z_SYNC_FLUSH })); + } + return { encoded: Buffer.concat(parts), plain: Buffer.concat(plain) }; +}; + +const through = async (stream, bytes) => { + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + const written = writer.write(bytes).then(() => writer.close()); + const chunks = []; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + chunks.push(value); + } + await written; + return chunks.length === 1 ? chunks[0] : Buffer.concat(chunks.map((c) => Buffer.from(c))); +}; + +const main = async () => { + console.log(`dictionary: ${dictionary.length} B from the router`); + { + // The encoder re-hashes the dictionary on every call, so a message's + // cost grows with the dictionary it is compressed against — a 4 KB + // one (a large router) and the 32 KiB zlib looks at, on the same + // 2 KB callback; zlib beside it, which does the same work. + const sample = Buffer.concat(Array.from({ length: 64 }, (_, i) => orders(20 + (i % 7)))); + const bytes = orders(20); + console.log('\ndictionary size: 2 KB callback against a 4 KB and a 32 KiB dictionary'); + for (const size of [4096, 32768]) { + const dict = sample.subarray(0, size); + timeSync( + `own deflate, ${size >> 10} KB dictionary`, + () => deflateRaw(bytes, { dictionary: dict }), + 5_000, + bytes.length, + ); + // What a codec pays: the dictionary's hash chains are built once. + const prepared = createDeflateCodec({ dictionary: dict, native: false }); + timeSync( + `codec.encode, ${size >> 10} KB dictionary (prepared)`, + () => prepared.encode(bytes), + 5_000, + bytes.length, + ); + timeSync( + `zlib, ${size >> 10} KB dictionary (dynamic)`, + () => zlib.deflateRawSync(bytes, { dictionary: dict }), + 5_000, + bytes.length, + ); + } + } + { + const { encoded, plain } = manyBlocks(200, 4096); + console.log(`\nmany dynamic blocks: 200 × 4 KB skewed, ${encoded.length} B encoded, ${plain.length} B plain`); + timeSync('own inflate', () => inflateRaw(encoded), 50); + timeSync('zlib inflate', () => zlib.inflateRawSync(encoded), 50); + } + { + // Huffman-only over the skewed alphabet: the common byte gets a one-bit + // code, every rare byte one of zlib's longest — the input on which the + // inflater's sub-table path (codes past the root table) runs for every + // rare symbol, so this row prices that path. + const plain = skewed(65536); + const encoded = zlib.deflateRawSync(plain, { strategy: zlib.constants.Z_HUFFMAN_ONLY }); + let rare = 0; + for (const byte of plain) if (byte !== 0x20) rare++; + console.log(`\nhuffman-only 64 KB skewed: ${rare} rare symbols on long codes, ${encoded.length} B encoded`); + timeSync('own inflate', () => inflateRaw(encoded), 500); + timeSync('zlib inflate', () => zlib.inflateRawSync(encoded), 500); + } + const cases = [ + ['event 108 B', tick(), 20_000], + ['callback 2 KB', orders(20), 5_000], + ['callback 28 KB', orders(300), 300], + ]; + // The codec as a carrier holds it: one per process, its dictionary state + // prepared on the first message (`native: false` — the pure-JS half). + const codec = createDeflateCodec({ dictionary, native: false }); + const bare = createDeflateCodec({ native: false }); + for (const [label, bytes, n] of cases) { + console.log(`\n${label}`); + const size = bytes.length; + timeSync('own deflate, dictionary (fixed Huffman)', () => deflateRaw(bytes, { dictionary }), n, size); + timeSync('codec.encode, dictionary (prepared)', () => codec.encode(bytes), n, size); + timeSync( + 'zlib, dictionary, Z_FIXED', + () => zlib.deflateRawSync(bytes, { dictionary, strategy: zlib.constants.Z_FIXED }), + n, + size, + ); + timeSync('zlib, dictionary (dynamic)', () => zlib.deflateRawSync(bytes, { dictionary }), n, size); + timeSync('own deflate, no dictionary', () => deflateRaw(bytes), n, size); + timeSync('codec.encode, no dictionary (prepared)', () => bare.encode(bytes), n, size); + timeSync('zlib, no dictionary (dynamic)', () => zlib.deflateRawSync(bytes), n, size); + if (typeof CompressionStream === 'function') { + await timeAsync( + 'CompressionStream, no dictionary', + () => through(new CompressionStream('deflate-raw'), bytes), + Math.max(50, n / 4), + size, + ); + } + const encoded = zlib.deflateRawSync(bytes, { dictionary }); + timeSync('own inflate, dictionary', () => inflateRaw(encoded, { dictionary }), n); + timeSync('zlib inflate, dictionary', () => zlib.inflateRawSync(encoded, { dictionary }), n); + const plainEncoded = zlib.deflateRawSync(bytes); + timeSync('own inflate, no dictionary', () => inflateRaw(plainEncoded), n); + if (typeof DecompressionStream === 'function') { + await timeAsync( + 'DecompressionStream, no dictionary', + () => through(new DecompressionStream('deflate-raw'), plainEncoded), + Math.max(50, n / 4), + ); + } + } +}; + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/bench/dictionary.js b/bench/dictionary.js new file mode 100644 index 0000000..a615973 --- /dev/null +++ b/bench/dictionary.js @@ -0,0 +1,99 @@ +'use strict'; + +// A preset dictionary built from the router (src/rpc/dictionary.js) against +// one-shot deflate with no history, at the sizes RPC messages come in. The +// small end is where a dictionary earns its keep — a repeated event shape +// compresses against field names and targets that are already in the +// window — and the numbers here are what set the dictionary codec's 64 B +// threshold against the plain codec's 1 KiB. + +const { performance } = require('node:perf_hooks'); + +const { defineRouter, procedure } = require('../src/rpc/router.js'); +const { buildDictionary } = require('../src/rpc/dictionary.js'); +const { nativeCompressor } = require('../src/compression/index.js'); +const { dictionaryCompressor } = require('../src/compression/dictionary.js'); + +const router = defineRouter({ + market: { + quote: procedure({ + access: 'public', + signature: { args: { symbol: 'string' }, returns: { bid: 'number', ask: 'number', ts: 'number' } }, + handler: async () => ({}), + }), + orders: procedure({ + access: 'public', + signature: { + args: { symbol: 'string', 'limit?': 'number' }, + returns: [{ id: 'string', side: 'string', price: 'number', size: 'number', createdAt: 'string' }], + }, + handler: async () => [], + }), + emits: { tick: { data: { symbol: 'string', bid: 'number', ask: 'number', ts: 'number' } } }, + }, +}); + +const encoder = new TextEncoder(); +const tick = (i) => + JSON.stringify({ + type: 'event', + name: 'market/tick', + data: { symbol: 'BTC-USD', bid: 42000 + (i % 100), ask: 42001 + (i % 100), ts: 1726500000000 + i }, + }); +const call = (i) => JSON.stringify({ type: 'call', id: `c${i}`, method: 'market/quote', args: { symbol: 'ETH-USD' } }); +const orders = (rows) => + JSON.stringify({ + type: 'callback', + id: 'c1', + result: Array.from({ length: rows }, (_, i) => ({ + id: `o-${i}`, + side: i % 2 ? 'buy' : 'sell', + price: 42000 + i, + size: (i % 7) + 1, + createdAt: '2026-09-19T10:00:00.000Z', + })), + }); + +const report = (name, count, elapsedMs, extra = '') => + console.log( + ` ${name.padEnd(46)}${Math.round((count / elapsedMs) * 1000) + .toLocaleString('en-US') + .padStart(12)}/sec${extra}`, + ); + +const round = (label, codec, texts) => { + const inputs = texts.map((t) => encoder.encode(t)); + let out = 0; + let plainBytes = 0; + const started = performance.now(); + for (let i = 0; i < inputs.length; i++) { + out += codec.encode(inputs[i]).length; + plainBytes += inputs[i].length; + } + const elapsed = performance.now() - started; + const ratio = (plainBytes / out).toFixed(2); + const per = `${Math.round(plainBytes / inputs.length)} -> ${Math.round(out / inputs.length)} B`; + report(label, inputs.length, elapsed, ` ${ratio}x ${per}`); +}; + +const main = () => { + const dictionary = buildDictionary(router); + console.log(`dictionary: ${dictionary.length} B from the router`); + const plain = nativeCompressor(); + const dict = dictionaryCompressor(dictionary); + const N = 20_000; + const ticks = Array.from({ length: N }, (_, i) => tick(i)); + const calls = Array.from({ length: N }, (_, i) => call(i)); + console.log('one-shot deflate, no history'); + round('event, 108 B', plain, ticks); + round('call, 84 B', plain, calls); + round('callback, 12 rows', plain, [orders(12)].concat(Array(1999).fill(orders(12)))); + round('callback, 300 rows', plain, Array(200).fill(orders(300))); + console.log('one-shot deflate with the router dictionary'); + round('event, 108 B', dict, ticks); + round('call, 84 B', dict, calls); + round('callback, 12 rows', dict, Array(2000).fill(orders(12))); + round('callback, 300 rows', dict, Array(200).fill(orders(300))); +}; + +main(); diff --git a/bench/encryption.js b/bench/encryption.js new file mode 100644 index 0000000..f0e7c50 --- /dev/null +++ b/bench/encryption.js @@ -0,0 +1,346 @@ +'use strict'; + +// What sealing a message costs, on the shapes @alexify/wrpc/encryption uses +// (src/encryption/aead.js and aead.browser.js), and the three decisions the +// numbers make: +// +// 1. node:crypto synchronously against crypto.subtle on the SAME machine — +// why the Node half is a platform pair and not the one subtle file the +// rest of the directory is. subtle pays a threadpool hand-off per call, +// the argument bench/zlib-async.js makes for zlib. +// 2. A key prepared once against the raw key handed to every call — why +// the contract is `cipher.key(raw)` and not `seal(rawKey, …)`. On Node +// it is a wash (a KeyObject saves nothing measurable); over +// crypto.subtle an importKey per message costs half as much again, +// and the import is what makes the key non-extractable in a page. +// 3. The fan-out: under session encryption every recipient has its own +// key, so a broadcast is N seals where plain wrpc serializes ONCE +// (sendPrepared). The last rows are that price, per emit. +// +// Sizes are a small packet, a typical one and a large result. + +const crypto = require('node:crypto'); +const { performance } = require('node:perf_hooks'); +const node = require('../src/encryption/aead.js'); +const browser = require('../src/encryption/aead.browser.js'); +const { counterNonce } = require('../src/encryption/bytes.js'); + +const SIZES = [64, 1024, 16 * 1024]; +const RAW = crypto.randomBytes(32); +const AAD = Buffer.from('wrpc-sealed v1\0rooms\0k1\0room:lobby'); + +const payload = (size) => { + const row = JSON.stringify({ type: 'event', name: 'chat/message', data: { from: 'u1', text: 'x'.repeat(24) } }); + return Buffer.from(row.repeat(Math.ceil(size / row.length)).slice(0, size)); +}; + +const MEASURE_MS = 400; + +const micros = (fn) => { + for (let i = 0; i < 2000; i++) fn(i); + let iterations = 0; + const started = performance.now(); + while (performance.now() - started < MEASURE_MS) { + for (let i = 0; i < 100; i++) fn(iterations + i); + iterations += 100; + } + return ((performance.now() - started) * 1000) / iterations; +}; + +const microsAsync = async (fn) => { + for (let i = 0; i < 500; i++) await fn(i); + let iterations = 0; + const started = performance.now(); + while (performance.now() - started < MEASURE_MS) { + await fn(iterations); + iterations++; + } + return ((performance.now() - started) * 1000) / iterations; +}; + +const cell = (value) => `${value.toFixed(2)} µs`.padStart(12); + +async function main() { + const gcm = node.aead().key(RAW); + const chacha = node.aead({ algorithm: 'chacha20-poly1305' }).key(RAW); + const subtle = await browser.aead().key(RAW); + const nonce = new Uint8Array(12); + + console.log('seal, per message'.padEnd(34) + SIZES.map((size) => `${size} B`.padStart(12)).join('')); + const rows = [ + ['JSON copy (no encryption)', (body) => () => Buffer.from(body)], + ['aes-256-gcm node:crypto sync', (body) => (i) => gcm.seal(counterNonce(i, nonce), body, AAD)], + ['chacha20-poly1305 node:crypto sync', (body) => (i) => chacha.seal(counterNonce(i, nonce), body, AAD)], + [ + 'aes-256-gcm raw key per call', + (body) => (i) => { + const cipher = crypto.createCipheriv('aes-256-gcm', RAW, counterNonce(i, nonce)); + cipher.setAAD(AAD); + return Buffer.concat([cipher.update(body), cipher.final(), cipher.getAuthTag()]); + }, + ], + ]; + for (const [label, make] of rows) { + console.log(label.padEnd(34) + SIZES.map((size) => cell(micros(make(payload(size))))).join('')); + } + const awaited = []; + for (const size of SIZES) { + const body = payload(size); + awaited.push(await microsAsync((i) => subtle.seal(counterNonce(i, new Uint8Array(12)), body, AAD))); + } + console.log('aes-256-gcm crypto.subtle (await)'.padEnd(34) + awaited.map(cell).join('')); + const imported = []; + for (const size of SIZES) { + const body = payload(size); + imported.push( + await microsAsync(async (i) => + (await browser.aead().key(RAW)).seal(counterNonce(i, new Uint8Array(12)), body, AAD), + ), + ); + } + console.log('aes-256-gcm subtle + importKey'.padEnd(34) + imported.map(cell).join('')); + + console.log('\nopen, per message'.padEnd(35) + SIZES.map((size) => `${size} B`.padStart(12)).join('')); + const opens = []; + const opensCopied = []; + const opensAsync = []; + const key = crypto.createSecretKey(RAW); + for (const size of SIZES) { + const sealed = gcm.seal(nonce.fill(0), payload(size), AAD); + const split = sealed.length - 16; + opens.push(micros(() => gcm.open(nonce, sealed, AAD))); + // The idiomatic spelling aead.js does not use on this path: the body + // copied once more through Buffer.concat. + opensCopied.push( + micros(() => { + const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce, { authTagLength: 16 }); + decipher.setAuthTag(sealed.subarray(split)); + decipher.setAAD(AAD); + return Buffer.concat([decipher.update(sealed.subarray(0, split)), decipher.final()]); + }), + ); + opensAsync.push(await microsAsync(() => subtle.open(nonce, sealed, AAD))); + } + console.log('aes-256-gcm node:crypto sync'.padEnd(34) + opens.map(cell).join('')); + console.log('aes-256-gcm … via Buffer.concat'.padEnd(34) + opensCopied.map(cell).join('')); + console.log('aes-256-gcm crypto.subtle (await)'.padEnd(34) + opensAsync.map(cell).join('')); + + // A subkey per sender salt is derived once and cached; this is the miss. + const salt = crypto.randomBytes(16); + const derive = micros(() => crypto.hkdfSync('sha256', RAW, salt, AAD, 32)); + const keyed = micros(() => node.aead().key(RAW)); + console.log(`\nhkdfSync subkey (a cache miss) ${cell(derive)}`); + console.log(`cipher.key(raw) (a KeyObject) ${cell(keyed)}`); + + // The whole backplane path (src/rpc/envelope.js): frame, seal, base64 — + // and back, through the sender cache. What a room emit pays per publish, + // and every other instance per receive. The receiving side runs with + // `replayWindow: false` — the loop decodes ONE message again and again, + // which a window would refuse — so the decode row is without the window's + // check (one array read and one write). + const { createEnvelope } = require('../src/rpc/envelope.js'); + const quiet = { warn() {} }; + const envelopeOptions = { maxMessage: 1 << 24, name: 'bench', layer: 'rooms', event: 'backplane', log: quiet }; + const sending = createEnvelope({ ...envelopeOptions, encryption: { keys: RAW } }); + const receiving = createEnvelope({ ...envelopeOptions, encryption: { keys: RAW, replayWindow: false } }); + console.log('\nbackplane envelope, per message'.padEnd(35) + SIZES.map((size) => `${size} B`.padStart(12)).join('')); + const encodes = []; + const decodes = []; + for (const size of SIZES) { + const text = payload(size).toString(); + encodes.push(micros(() => sending.encode(text, 'room:lobby'))); + const wire = sending.encode(text, 'room:lobby'); + decodes.push(micros(() => receiving.decode(wire, 'room:lobby'))); + } + console.log('seal + base64 (encode)'.padEnd(34) + encodes.map(cell).join('')); + console.log('base64 + open (decode)'.padEnd(34) + decodes.map(cell).join('')); + + // A session frame (src/encryption/session.js) as the server sends one: + // the inner kind byte, the seal, the 00 06 header — against the bare + // AEAD above, which is the floor. + const { SecureChannel } = require('../src/encryption/session.js'); + const { createNoise } = require('../src/encryption/noise.js'); + const { x25519 } = require('../src/encryption/dh.js'); + const { createKdf } = require('../src/encryption/hkdf.js'); + const noise = createNoise({ pattern: 'NN', dh: x25519(), cipher: node.aead(), kdf: createKdf() }); + // A fresh pair per row: a frame opens once, in order — the counter is the nonce. + const channels = async () => { + const [initiator, responder] = [await noise.initiator(), await noise.responder()]; + await responder.read(await initiator.write()); + await initiator.read(await responder.write()); + return [new SecureChannel(await initiator.finish()), new SecureChannel(await responder.finish())]; + }; + console.log('\nsession frame, per message'.padEnd(35) + SIZES.map((size) => `${size} B`.padStart(12)).join('')); + const sessionSeals = []; + const sessionOpens = []; + for (const size of SIZES) { + const text = payload(size).toString(); + const [sealing] = await channels(); + sessionSeals.push(micros(() => sealing.seal(text))); + const [server, page] = await channels(); + // Each frame opens once (the counter moves), so a batch is sealed ahead. + const batch = 20000; + let frames = []; + let next = 0; + sessionOpens.push( + micros(() => { + if (next === frames.length) { + frames = Array.from({ length: batch }, () => server.seal(text)); + next = 0; + } + return page.open(frames[next++]); + }), + ); + } + // The inner frame of a text packet, the two ways session.js can build it. + const textEncoder = new TextEncoder(); + const inners = { encodeInto: [], buffer: [] }; + for (const size of SIZES) { + const text = payload(size).toString(); + inners.encodeInto.push( + micros(() => { + const scratch = new Uint8Array(1 + text.length * 3); + return scratch.subarray(0, 1 + textEncoder.encodeInto(text, scratch.subarray(1)).written); + }), + ); + inners.buffer.push( + micros(() => { + const inner = Buffer.allocUnsafe(1 + Buffer.byteLength(text)); + inner.write(text, 1); + return inner; + }), + ); + } + console.log('inner frame TextEncoder.encodeInto'.padEnd(34) + inners.encodeInto.map(cell).join('')); + console.log('inner frame Buffer.write'.padEnd(34) + inners.buffer.map(cell).join('')); + console.log('seal a text packet'.padEnd(34) + sessionSeals.map(cell).join('')); + console.log('open a text packet (incl. sealing)'.padEnd(34) + sessionOpens.map(cell).join('')); + // A binary frame the size of a stream chunk: the inner frame is a copy of + // the bytes, then the seal — the row a file upload under a session pays. + { + const chunk = crypto.randomBytes(64 * 1024); + const [sealing] = await channels(); + console.log(`seal a 64 KB binary chunk ${cell(micros(() => sealing.seal(chunk)))}`); + } + const started = performance.now(); + for (let i = 0; i < 200; i++) { + const [a, b] = [await noise.initiator(), await noise.responder()]; + await b.read(await a.write()); + await a.read(await b.write()); + await Promise.all([a.finish(), b.finish()]); + } + console.log(`a whole NN handshake, both ends ${cell(((performance.now() - started) * 1000) / 200)}`); + // NK is the default pattern a client runs (it pins the server's key): + // one more DH than NN on each side. + { + const nk = createNoise({ pattern: 'NK', dh: x25519(), cipher: node.aead(), kdf: createKdf() }); + const staticKey = await x25519().generateKeyPair(); + const nkStarted = performance.now(); + for (let i = 0; i < 200; i++) { + const a = await nk.initiator({ remoteStatic: staticKey.publicKey }); + const b = await nk.responder({ staticKey }); + await b.read(await a.write()); + await a.read(await b.write()); + await Promise.all([a.finish(), b.finish()]); + } + console.log(`a whole NK handshake, both ends ${cell(((performance.now() - nkStarted) * 1000) / 200)}`); + } + + // One sealed HTTP request, both ends (src/encryption/http.js over hpke.js): + // an X25519 each way, the key schedule, the request and its answer. + const { createHpke, dhKem } = require('../src/encryption/hpke.js'); + const kdfForHpke = createKdf(); + const kem = dhKem(x25519(), kdfForHpke); + const hpke = createHpke({ kem, kdf: kdfForHpke, cipher: node.aead() }); + const recipient = await kem.generateKeyPair(); + const requestBody = payload(1024); + const requests = 300; + const hpkeStarted = performance.now(); + for (let i = 0; i < requests; i++) { + const sent = await hpke.setupSender(recipient.publicKey, { info: AAD }); + const sealedRequest = await sent.context.seal(null, requestBody); + const received = await hpke.setupRecipient(sent.enc, recipient, { info: AAD }); + await received.open(null, sealedRequest); + await received.export(AAD, 32); + await sent.context.export(AAD, 32); + } + console.log(`\na sealed HTTP request, both ends ${cell(((performance.now() - hpkeStarted) * 1000) / requests)}`); + + console.log( + '\nfan-out of one 1 KB event, per emit'.padEnd(35) + [10, 1000, 10000].map((n) => `${n}`.padStart(12)).join(''), + ); + const body = payload(1024); + const recipients = Array.from({ length: 10000 }, () => node.aead().key(crypto.randomBytes(32))); + const shared = []; + const sealedEach = []; + for (const count of [10, 1000, 10000]) { + // Plain wrpc: the frame is built once and the same bytes go to everyone. + shared.push( + micros(() => { + const frame = Buffer.from(body); + let sent = 0; + for (let r = 0; r < count; r++) sent += frame.length; + return sent; + }), + ); + sealedEach.push( + micros((i) => { + counterNonce(i, nonce); + let sent = 0; + for (let r = 0; r < count; r++) sent += recipients[r].seal(nonce, body, null).length; + return sent; + }), + ); + } + console.log('one shared frame (today)'.padEnd(34) + shared.map(cell).join('')); + console.log('one seal per recipient (bare AEAD)'.padEnd(34) + sealedEach.map(cell).join('')); + // What a sealed fan-out ACTUALLY costs: SecureChannel.seal(text) per + // recipient — the inner frame built from the text, the counter nonce, + // the seal, the header — not the bare AEAD above. 2000 established + // channels, reused round-robin for the larger counts (a seal's cost does + // not depend on which channel it is). + const text = payload(1024).toString(); + const pool = []; + for (let i = 0; i < 2000; i++) pool.push((await channels())[0]); + const perRecipient = []; + for (const count of [10, 1000, 10000]) { + perRecipient.push( + micros(() => { + let sent = 0; + for (let r = 0; r < count; r++) sent += pool[r % pool.length].seal(text).length; + return sent; + }), + ); + } + console.log('one SecureChannel.seal(text) each'.padEnd(34) + perRecipient.map(cell).join('')); + // What SealedSocket.sendPrepared does instead: the plaintext inner frame — + // the kind byte and the text's UTF-8 — built ONCE per emit and sealed per + // recipient. Still N seals; what goes is N byteLength + alloc + write of + // the same string, which grows with the message where the seal's fixed + // cost does not. Per recipient, over emits to 100 of them. + const { innerOf } = require('../src/encryption/session.js'); + const perSeal = (message, shared) => { + const emit = shared + ? () => { + const inner = innerOf(message); + let sent = 0; + for (let r = 0; r < 100; r++) sent += pool[r].sealInner(inner).length; + return sent; + } + : () => { + let sent = 0; + for (let r = 0; r < 100; r++) sent += pool[r].seal(message).length; + return sent; + }; + return micros(emit) / 100; + }; + console.log('\nsealed fan-out, per recipient'.padEnd(35) + SIZES.map((size) => `${size} B`.padStart(12)).join('')); + const messages = SIZES.map((size) => payload(size).toString()); + console.log('seal(text): the inner frame each'.padEnd(34) + messages.map((m) => cell(perSeal(m, false))).join('')); + console.log('sealInner(shared inner frame)'.padEnd(34) + messages.map((m) => cell(perSeal(m, true))).join('')); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/bench/feed-fanout.js b/bench/feed-fanout.js new file mode 100644 index 0000000..d40f0f9 --- /dev/null +++ b/bench/feed-fanout.js @@ -0,0 +1,150 @@ +'use strict'; + +/** + * Durable feed fan-out benchmark: one entry appended to a topic that N + * local subscriptions of `brokerFeed` read. + * + * `TopicTails` already makes that one broker read per topic. What is left + * per subscriber is the feed's own pump — and part of it does not depend on + * the subscriber at all: opening a sealed entry (base64, AEAD, the header + * JSON) and signing the resume token (one HMAC) answer the same bytes for + * every reader of the entry. This measures the pump per reader-entry, with + * and without `secret` and `encryption`, at two payload sizes, over a log + * that stands on `TopicTails` the way the four adapters do (a MemoryBroker + * read hands every reader a copy of its own, so nothing is shared there). + * + * When the two memos in src/broker/feed.js were written (one machine; the + * ratios are what carries over), a reader-entry cost, at 1000 subscribers: + * + * 0.2 KB 2 KB + * plain 0.74 -> 0.65 µs 0.72 -> 0.65 µs (unchanged) + * secret 2.34 -> 0.84 µs 2.34 -> 0.83 µs one HMAC per entry + * encryption 4.67 -> 0.71 µs 5.71 -> 0.74 µs one open per entry + * secret + encryption 6.69 -> 0.91 µs 7.94 -> 0.88 µs + * + * The values are read as text here: `decode: 'json'` is a JSON.parse per + * subscriber on top (each gets an object of its own, deliberately — one + * shared object would cross every subscriber's `map`, output validator and + * hooks), which the last rows show. + * + * Run: node bench/feed-fanout.js (or as part of `pnpm bench`) + */ + +const { performance } = require('node:perf_hooks'); + +const { brokerFeed } = require('../src/broker/feed.js'); +const { TopicTails } = require('../src/broker/tail.js'); +const { createBrokerSealing } = require('../src/broker/sealing.js'); +const { generateKey } = require('../src/encryption/index.js'); + +// A log on TopicTails with nothing behind it: live() hands every appended +// entry to the tail, which hands the SAME entry to every reader — what a +// Redis, NATS, AMQP or Kafka adapter's read is, minus the broker. +const createLog = () => { + let onEntry = null; + let last = 0; + const tails = new TopicTails({ + live: async (_topic, { signal, onEntry: push }) => { + onEntry = push; + signal.addEventListener('abort', () => { + onEntry = null; + }); + return last === 0 ? null : String(last); + }, + range: async () => [], + covered: (cursor, entry) => Number(entry.id) <= Number(cursor), + advance: (_cursor, entry) => entry.id, + }); + return { + name: 'bench', + append: async (_topic, value, { headers = {} } = {}) => { + const entry = { id: String(++last), value, headers }; + onEntry?.(entry); + return entry.id; + }, + read: (topic, options) => tails.read(topic, options), + parseId: (text) => (/^\d{1,16}$/.test(text) ? text : null), + }; +}; + +const payloadOf = (size) => { + const rows = []; + let n = 0; + while (JSON.stringify({ rows }).length < size) { + rows.push({ id: n, name: `row-${n}`, at: '2026-10-01T10:00:00.000Z', ok: n % 2 === 0 }); + n++; + } + return JSON.stringify({ rows }); +}; + +const TOPIC = 'orders.v1.created'; + +const fanout = async (subscribers, { secret, encryption, decode = 'text' }, label, size) => { + const log = createLog(); + const feed = brokerFeed(log, TOPIC, { secret, encryption, decode }); + const sealing = createBrokerSealing(encryption, 'bench', { layer: 'broker-log', replay: false, text: true }); + const text = payloadOf(size); + const append = + sealing === null + ? () => log.append(TOPIC, text) + : () => { + const sealed = sealing.seal(TOPIC, { 'content-type': 'application/json' }, text); + return log.append(TOPIC, sealed.body, { headers: sealed.headers }); + }; + const controller = new AbortController(); + const iterators = new Array(subscribers); + const pending = new Array(subscribers); + for (let i = 0; i < subscribers; i++) { + iterators[i] = feed({}, {}, { signal: controller.signal }); + pending[i] = iterators[i].next(); + } + // Every reader is on the tail before the first append. + await new Promise((resolve) => setTimeout(resolve, 20)); + const round = async () => { + await append(); + await Promise.all(pending); + for (let i = 0; i < subscribers; i++) pending[i] = iterators[i].next(); + }; + for (let i = 0; i < 30; i++) await round(); + let rounds = 0; + const started = performance.now(); + while (performance.now() - started < 600) { + await round(); + rounds++; + } + const elapsed = performance.now() - started; + controller.abort(); + await Promise.all(pending); + const mode = `${secret ? 'secret' : ''}${secret && encryption ? ' + ' : ''}${encryption ? 'encryption' : ''}`; + console.log( + ` ${`x${subscribers} ${mode || 'plain'}${decode === 'json' ? ' (json)' : ''}, ${label}`.padEnd(40)}` + + `${((elapsed * 1000) / (rounds * subscribers)).toFixed(2).padStart(8)} µs/reader-entry`, + ); +}; + +const main = async () => { + console.log(`Durable feed fan-out benchmark — Node ${process.version}\n`); + const keys = generateKey(); + for (const subscribers of [100, 1000]) { + for (const options of [ + { secret: null, encryption: null }, + { secret: 'bench-secret', encryption: null }, + { secret: null, encryption: { keys } }, + { secret: 'bench-secret', encryption: { keys } }, + { secret: null, encryption: null, decode: 'json' }, + ]) { + for (const [label, size] of [ + ['0.2 KB', 200], + ['2 KB', 2048], + ]) { + await fanout(subscribers, options, label, size); + } + } + console.log(); + } +}; + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/bench/http-call.js b/bench/http-call.js new file mode 100644 index 0000000..47b901c --- /dev/null +++ b/bench/http-call.js @@ -0,0 +1,198 @@ +'use strict'; + +// What wrpc itself spends on one HTTP request, the network left out: +// RpcServer.handleHttpCall driven with the abstract call a host hands it +// (src/server.js, the adapters), answered into a stub `respond`. The same +// two paths bench/http-comparison.js loads over a socket — a declared REST +// route and packet mode — so a change that moves these numbers is a change +// to wrpc's share of that table, and one that does not is noise there. +// +// The body half is measured apart: receiveBody (src/adapters/common.js) is +// the shell's, not the core's, and runs before handleHttpCall. Then the +// per-request shapes the request path was rewritten around, each next to +// the one it replaced (reproduced inline, as bench/cors-headers.js does): +// the numbers the comments in src/ cite. +// +// node bench/http-call.js + +const { Readable } = require('node:stream'); + +const { bench } = require('./support/harness.js'); +const { RpcServer } = require('../src/rpc/core.js'); +const { defineRouter, procedure } = require('../src/rpc/router.js'); +const { parseParams } = require('../src/rpc/dispatcher.js'); +const { receiveBody } = require('../src/adapters/common.js'); +const { Emitter, jsonParse } = require('../src/utils.js'); + +const router = defineRouter({ + bench: { + echo: procedure({ + access: 'public', + http: { method: 'POST', path: '/echo' }, + handler: async (_context, { body }) => body, + }), + args: procedure({ access: 'public', handler: async (_context, args) => args }), + }, +}); + +const rpc = new RpcServer({ router, logger: false }); + +// What autocannon sends in bench/http-comparison.js, as node:http hands it +// over: lower-cased names, string values. +const HEADERS = { host: '127.0.0.1:3000', 'content-type': 'application/json', 'content-length': '14' }; +// …and what a browser's fetch sends, for the per-header costs below. +const BROWSER_HEADERS = { + host: 'api.example', + connection: 'keep-alive', + 'content-type': 'application/json', + 'content-length': '14', + accept: '*/*', + 'accept-language': 'en-US,en;q=0.9', + 'accept-encoding': 'gzip, deflate, br, zstd', + 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', + origin: 'https://app.example', + referer: 'https://app.example/', + 'sec-fetch-site': 'same-site', + 'sec-fetch-mode': 'cors', +}; +const REST_BODY = Buffer.from(JSON.stringify({ name: 'Ada' })); +const PACKET_BODY = Buffer.from(JSON.stringify({ type: 'call', id: '1', method: 'bench/args', args: { name: 'Ada' } })); + +// One request: resolves with the answer's status once wrpc has written it. +const request = (url, body) => + new Promise((resolve) => { + void rpc.handleHttpCall({ + method: 'POST', + url, + headers: HEADERS, + body, + remoteAddress: '127.0.0.1', + respond: ({ status }) => resolve(status), + onAbort: () => {}, + }); + }); + +// In flight together, as autocannon's pipelined connections keep them: the +// harness awaits per iteration, which would otherwise be most of what is +// measured. +const CONCURRENT = 64; + +const burst = async (url, body) => { + const pending = new Array(CONCURRENT); + for (let i = 0; i < CONCURRENT; i++) pending[i] = request(url, body); + const statuses = await Promise.all(pending); + if (statuses[0] !== 200) throw new Error(`${url} answered ${statuses[0]}`); +}; + +// A request body as node:http delivers a small one: one chunk, then the end. +const bodyStream = () => { + const stream = new Readable({ read() {} }); + stream.push(REST_BODY); + stream.push(null); + return stream; +}; + +// The loop receiveBody was before it listened on the stream. +const receiveByIterator = async (stream) => { + const chunks = []; + for await (const chunk of stream) chunks.push(chunk); + return chunks.length === 0 ? null : chunks.length === 1 ? chunks[0] : Buffer.concat(chunks); +}; + +const BODIES = 256; + +const bodies = (receive) => async () => { + const pending = new Array(BODIES); + for (let i = 0; i < BODIES; i++) pending[i] = receive(bodyStream()); + await Promise.all(pending); +}; + +// A declared route's path, split and decoded segment by segment — what +// #matchDeclaredRoute in src/rpc/core.js did for every path. +const decodeEach = (rest) => { + const raw = rest.split('/'); + const segments = new Array(raw.length); + for (let i = 0; i < raw.length; i++) { + try { + segments[i] = decodeURIComponent(raw[i]); + } catch { + return null; + } + } + return segments; +}; + +// …and what it does now when nothing in the path is escaped. +const decodeEscaped = (rest) => (rest.includes('%') ? decodeEach(rest) : rest.split('/')); + +let closes = 0; +const noteClose = () => { + closes++; +}; + +// The harness awaits per call, which would drown a nanosecond op in promise +// overhead — each iteration below runs a synchronous batch. +const BATCH = 1000; + +const sync = (name, fn) => + bench( + name, + async () => { + let out; + for (let i = 0; i < BATCH; i++) out = fn(); + return out; + }, + { opsPerIteration: BATCH }, + ); + +const run = async () => { + await bench('handleHttpCall, declared REST route', () => burst('/api/echo', REST_BODY), { + opsPerIteration: CONCURRENT, + }); + await bench('handleHttpCall, packet mode', () => burst('/api', PACKET_BODY), { opsPerIteration: CONCURRENT }); + + await bench('receiveBody, one-chunk body (for await, before)', bodies(receiveByIterator), { + opsPerIteration: BODIES, + }); + await bench('receiveBody, one-chunk body (listeners)', bodies(receiveBody), { opsPerIteration: BODIES }); + + await sync('JSON body, JSON.parse(buffer) (before)', () => jsonParse(PACKET_BODY)); + await sync('JSON body, JSON.parse(buffer.toString())', () => jsonParse(PACKET_BODY.toString())); + + await sync('no query string, URLSearchParams (before)', () => Object.fromEntries(new URLSearchParams(''))); + await sync('no query string, parseParams', () => parseParams('')); + + await sync('route /users/42/posts, decode each segment (before)', () => decodeEach('users/42/posts')); + await sync('route /users/42/posts, nothing escaped', () => decodeEscaped('users/42/posts')); + + await sync('transport close, once + two emits (before)', () => { + const transport = new Emitter(); + transport.once('close', noteClose); + transport.emit('close'); + transport.emit('close'); + }); + await sync('transport close, on + flag + two emits', () => { + const transport = new Emitter(); + let closed = false; + transport.on('close', () => { + if (closed) return; + closed = true; + noteClose(); + }); + transport.emit('close'); + transport.emit('close'); + }); + + // Measured alone, the two copies are close; it is the dictionary-mode + // object living through a whole request that the handleHttpCall rows + // above feel (bench/http-call.js before/after, cited in rpc/client.js). + await sync('meta.headers, 12 headers, __proto__ literal (before)', () => + Object.freeze({ __proto__: null, ...BROWSER_HEADERS }), + ); + await sync('meta.headers, 12 headers, spread + setPrototypeOf', () => + Object.freeze(Object.setPrototypeOf({ ...BROWSER_HEADERS }, null)), + ); + if (closes === 0) throw new Error('the close listeners never ran'); +}; + +run(); diff --git a/bench/http-comparison.js b/bench/http-comparison.js new file mode 100644 index 0000000..71a9ecd --- /dev/null +++ b/bench/http-comparison.js @@ -0,0 +1,93 @@ +'use strict'; +/** + * wrpc over plain HTTP next to the frameworks an HTTP API is usually built + * on — fastify, express, tRPC's standalone adapter, and node:http with no + * framework as the ceiling — the way fastify/benchmarks compares Node + * frameworks: autocannon, 100 connections, 10 pipelined requests each, one + * server process per stack, the load generator in this process. + * + * Two wrpc rows: a procedure with a declared REST route (`http: { method, + * path }` — a real endpoint, plain JSON in and out) and packet mode (the call + * packet a WebSocket carries, as one POST). Every server echoes the same + * `{ name: 'Ada' }`; see bench/support/http-servers.js. + * + * node bench/http-comparison.js + * WRPC_BENCH_DURATION=30 node bench/http-comparison.js # seconds per stack (default 10) + * + * autocannon, fastify, express and @trpc/server are devDependencies used + * only by the benchmarks; wrpc itself stays zero-dependency. + */ +const path = require('node:path'); +const { spawn } = require('node:child_process'); + +const autocannon = require('autocannon'); + +const ORDER = ['wrpc-rest', 'wrpc-packet', 'fastify', 'express', 'trpc', 'node-http']; +const WORKER = path.join(__dirname, 'support', 'http-server-worker.js'); +const DURATION = Number(process.env.WRPC_BENCH_DURATION ?? 10); +const CONNECTIONS = 100; +const PIPELINING = 10; + +const boot = (key) => + new Promise((resolve, reject) => { + const child = spawn(process.execPath, [WORKER, key], { stdio: ['pipe', 'pipe', 'inherit'] }); + let out = ''; + child.stdout.on('data', (chunk) => { + out += chunk; + const line = out.split('\n').find((candidate) => candidate.startsWith('READY_JSON:')); + if (line) resolve({ child, ...JSON.parse(line.slice('READY_JSON:'.length)) }); + }); + child.once('exit', (code) => reject(new Error(`http stack "${key}" exited with ${code} before it was ready`))); + }); + +const load = ({ port, path: route, body }) => + autocannon({ + url: `http://127.0.0.1:${port}${route}`, + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + connections: CONNECTIONS, + pipelining: PIPELINING, + duration: DURATION, + }); + +async function main() { + console.log( + `Node ${process.version} | ${new Date().toISOString()} | ` + + `${CONNECTIONS} connections, ${PIPELINING} pipelined, ${DURATION} s per stack\n`, + ); + const servers = require('./support/http-servers.js'); + const rows = []; + for (const key of ORDER) { + const server = await boot(key); + try { + await load({ ...server, duration: 2 }); // warm-up, not counted + const result = await load(server); + if (result.non2xx > 0 || result.errors > 0) { + throw new Error(`"${key}" answered ${result.non2xx} non-2xx and ${result.errors} errors`); + } + const row = { + key, + label: servers[key].label, + requests: Math.round(result.requests.average), + latency: result.latency.average, + p99: result.latency.p99, + throughput: result.throughput.average / (1024 * 1024), + }; + rows.push(row); + console.log( + `${row.label.padEnd(32)} ${row.requests.toLocaleString('en-US').padStart(9)} req/s ` + + `latency ${row.latency.toFixed(2)} ms (p99 ${row.p99} ms) ${row.throughput.toFixed(1)} MiB/s`, + ); + } finally { + server.child.stdin.end(); + await new Promise((resolve) => server.child.once('exit', resolve)); + } + } + console.log(`\nRESULT_JSON:${JSON.stringify(rows)}`); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/bench/http-compression.js b/bench/http-compression.js new file mode 100644 index 0000000..caecf92 --- /dev/null +++ b/bench/http-compression.js @@ -0,0 +1,147 @@ +'use strict'; + +// `Content-Encoding` on the two HTTP-shaped transports, priced: a one-shot +// gzip of a packet/REST answer at the sizes RPC answers actually come in, +// the SSE path — one gzip member per response, sync-flushed after every +// event — against the same events sent plain, and the Accept-Encoding scan +// that picks the coding (src/contentEncoding.js pickEncoding) against the +// split-and-map it is written to avoid. Both options are off by default; +// this is what turning them on costs and buys. Which coding, at which +// level, is bench/algorithms.js. + +const zlib = require('node:zlib'); +const { performance } = require('node:perf_hooks'); + +const { encodedWriter, normalizeCompression, pickEncoding } = require('../src/contentEncoding.js'); + +const report = (name, count, elapsedMs, extra = '') => + console.log( + ` ${name.padEnd(52)}${Math.round((count / elapsedMs) * 1000) + .toLocaleString('en-US') + .padStart(12)}/sec${extra}`, + ); + +const answer = (rows) => + JSON.stringify({ + type: 'callback', + id: 'c1', + result: Array.from({ length: rows }, (_, i) => ({ + id: i, + name: `row-${i}`, + email: `user${i}@example.com`, + createdAt: '2026-09-19T10:00:00.000Z', + tags: ['alpha', 'beta'], + balance: (i * 37) % 1000, + })), + }); + +const oneShot = (label, text, iterations) => { + const body = Buffer.from(text); + let bytes = 0; + const started = performance.now(); + for (let i = 0; i < iterations; i++) bytes += zlib.gzipSync(body).length; + const elapsed = performance.now() - started; + const ratio = (body.length / (bytes / iterations)).toFixed(1); + report(`${label} (${body.length.toLocaleString('en-US')} B)`, iterations, elapsed, ` ${ratio}x`); +}; + +const event = (i) => + `id: ${i}\ndata: ${JSON.stringify({ + type: 'event', + name: 'market/tick', + data: { symbol: 'BTC-USD', bid: 42000 + (i % 100), ask: 42001 + (i % 100), ts: 1726500000000 + i }, + })}\n\n`; + +// The SSE member: every event flushed the moment it is written, so each one +// is its own (tiny) deflate block against the stream's history. +const sseStream = (count) => + new Promise((resolve) => { + let wire = 0; + let plain = 0; + let done = 0; + const sink = { + write: (chunk) => { + wire += chunk.length; + return true; + }, + end: () => resolve({ wire, plain, elapsed: performance.now() - started }), + onClose: () => {}, + onDrain: () => {}, + }; + const writer = encodedWriter(sink, normalizeCompression(true, 'bench').encoders[0]); + const started = performance.now(); + for (let i = 0; i < count; i++) { + const text = event(i); + plain += text.length; + writer.write(text); + done++; + } + if (done === count) writer.end(); + }); + +// The idiomatic spelling of the same choice: split, map, find. +const pickBySplit = (header, encoders) => { + const accepted = new Map(); + for (const part of header.split(',')) { + const [token, ...params] = part.split(';'); + const zero = params.some((param) => /^\s*q\s*=\s*0(?:\.0{0,3})?\s*$/i.test(param)); + // RFC 9110 §18.6: x-gzip is gzip. A coding refused anywhere is refused. + const raw = token.trim().toLowerCase(); + const name = raw === 'x-gzip' ? 'gzip' : raw; + accepted.set(name, (accepted.get(name) ?? true) && !zero); + } + return encoders.find((encoder) => accepted.get(encoder.token) ?? accepted.get('*') ?? false) ?? null; +}; + +const negotiation = () => { + const { encoders } = normalizeCompression({ encodings: ['br', 'gzip'] }, 'bench'); + const headers = ['gzip, deflate, br, zstd', 'gzip, deflate', 'br;q=1.0, gzip;q=0.8, *;q=0.1']; + const iterations = 1_000_000; + for (const [name, pick] of [ + ['pickEncoding (one scan)', pickEncoding], + ['split + map + find', pickBySplit], + ]) { + let hits = 0; + const started = performance.now(); + for (let i = 0; i < iterations; i++) if (pick(headers[i % 3], encoders) !== null) hits++; + report(name, iterations, performance.now() - started, hits === iterations ? '' : ' MISMATCH'); + } + // The header is the peer's: what one request costs when it is as long as + // a request line allows. Commas alone, and a token per two bytes — the + // shapes that make a per-token search walk the rest of the string. + for (const [name, header] of [ + ['16 KB of commas', ','.repeat(16_000)], + ['8000 one-letter tokens', 'a,'.repeat(8_000)], + ['256 B of one-letter tokens (the cap)', `${'a,'.repeat(126)}br`], + ]) { + const iterations = 2_000; + const started = performance.now(); + for (let i = 0; i < iterations; i++) pickEncoding(header, encoders); + const elapsed = performance.now() - started; + report(`adversarial: ${name}`, iterations, elapsed, ` ${((elapsed / iterations) * 1000).toFixed(1)} µs/request`); + } +}; + +const main = async () => { + console.log('HTTP: choosing the coding from Accept-Encoding'); + negotiation(); + + console.log('HTTP: one-shot gzip of an answer (packet mode / REST)'); + oneShot('callback, 2 rows', answer(2), 20_000); + oneShot('callback, 12 rows', answer(12), 20_000); + oneShot('callback, 64 rows', answer(64), 5_000); + oneShot('callback, 1000 rows', answer(1000), 300); + + console.log('SSE: one gzip member per response, flushed per event'); + const count = 20_000; + const { wire, plain, elapsed } = await sseStream(count); + report('repeated 90 B event through the member', count, elapsed, ` ${(plain / wire).toFixed(1)}x`); + console.log( + ` ${''.padEnd(52)}${(plain / count).toFixed(0).padStart(6)} B plain -> ${(wire / count).toFixed(0)} B on the wire per event`, + ); +}; + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/bench/http-headers.js b/bench/http-headers.js new file mode 100644 index 0000000..412257d --- /dev/null +++ b/bench/http-headers.js @@ -0,0 +1,98 @@ +'use strict'; +/** + * What wRPC's default response headers cost `node:http` itself — the ceiling + * row of bench/http-comparison.js, answering the same JSON echo three ways: + * + * bare Content-Type and Content-Length, as that row does + * wrpc defaults the block every wRPC HTTP answer carries (buildHeaders: + * CORS, strict-transport-security, x-content-type-options, + * wrpc-version) + * no preflight the same block without Access-Control-Allow-Methods and + * -Allow-Headers, which a browser reads only on a + * preflight's answer + * + * Building the block is ~20 ns (bench/cors-headers.js); what costs is node + * validating and writing every header of every answer, and the bytes. Same + * load as bench/http-comparison.js: autocannon, 100 connections × 10 + * pipelined, one server process per variant, rounds interleaved so drift + * shows as spread rather than as a difference. + * + * node bench/http-headers.js + * WRPC_BENCH_DURATION=30 node bench/http-headers.js # seconds per variant (default 10) + */ +const http = require('node:http'); +const { fork } = require('node:child_process'); + +const { buildHeaders } = require('../src/transport.js'); + +const DEFAULTS = buildHeaders(undefined, undefined, 2); +const WITHOUT_PREFLIGHT = {}; +for (const name in DEFAULTS) { + if (name !== 'Access-Control-Allow-Methods' && name !== 'Access-Control-Allow-Headers') { + WITHOUT_PREFLIGHT[name] = DEFAULTS[name]; + } +} +const VARIANTS = { + bare: { label: 'bare (as node:http in the comparison)', headers: { 'Content-Type': 'application/json' } }, + defaults: { label: "wRPC's default headers", headers: DEFAULTS }, + 'no-preflight': { label: 'defaults minus the preflight-only two', headers: WITHOUT_PREFLIGHT }, +}; + +const DURATION = Number(process.env.WRPC_BENCH_DURATION ?? 10); +const ROUNDS = 2; + +const serve = (key) => { + const base = VARIANTS[key].headers; + const server = http.createServer((request, response) => { + const chunks = []; + request.on('data', (chunk) => chunks.push(chunk)); + request.on('end', () => { + const body = JSON.stringify(JSON.parse(Buffer.concat(chunks).toString())); + response.writeHead(200, { ...base, 'Content-Length': Buffer.byteLength(body) }); + response.end(body); + }); + }); + server.listen(0, '127.0.0.1', () => process.send(server.address().port)); + process.on('disconnect', () => process.exit(0)); +}; + +async function main() { + const autocannon = require('autocannon'); + console.log(`Node ${process.version} | 100 connections, 10 pipelined, ${DURATION} s per variant\n`); + for (let round = 1; round <= ROUNDS; round++) { + for (const key of Object.keys(VARIANTS)) { + const child = fork(__filename, ['serve', key]); + const port = await new Promise((resolve) => child.once('message', resolve)); + const options = { + url: `http://127.0.0.1:${port}/echo`, + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Ada' }), + connections: 100, + pipelining: 10, + }; + try { + await autocannon({ ...options, duration: 2 }); // warm-up, not counted + const result = await autocannon({ ...options, duration: DURATION }); + const requests = Math.round(result.requests.average); + const bytes = Math.round(result.throughput.average / result.requests.average); + console.log( + `round ${round} ${VARIANTS[key].label.padEnd(40)} ${requests.toLocaleString('en-US').padStart(9)} req/s ` + + `${bytes} B per answer`, + ); + } finally { + child.disconnect(); + await new Promise((resolve) => child.once('exit', resolve)); + } + } + } +} + +if (process.argv[2] === 'serve') { + serve(process.argv[3]); +} else { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/bench/message-compression.js b/bench/message-compression.js new file mode 100644 index 0000000..b6dfe6b --- /dev/null +++ b/bench/message-compression.js @@ -0,0 +1,121 @@ +'use strict'; + +// Per-message compression (src/compression) on the transports that have +// nothing under them — WebRTC data channels and WebTransport streams — +// priced at the sizes RPC messages come in: the codec itself (node:zlib +// one-shot raw deflate, what a Node peer uses; a browser's +// CompressionStream where this Node has one, what a page uses), and the +// Sequencer that keeps an asynchronous codec's output in order. Off by +// default; this is what turning it on costs and buys, and where the +// thresholds (1 KiB on Node, 4 KiB in a browser) come from. + +const { performance } = require('node:perf_hooks'); + +const { nativeCompressor, Sequencer } = require('../src/compression/index.js'); +const browser = require('../src/compression/native.browser.js'); + +const report = (name, count, elapsedMs, extra = '') => + console.log( + ` ${name.padEnd(52)}${Math.round((count / elapsedMs) * 1000) + .toLocaleString('en-US') + .padStart(12)}/sec${extra}`, + ); + +const event = (i) => + JSON.stringify({ + type: 'event', + name: 'market/tick', + data: { symbol: 'BTC-USD', bid: 42000 + (i % 100), ask: 42001 + (i % 100), ts: 1726500000000 + i }, + }); + +const callback = (rows) => + JSON.stringify({ + type: 'callback', + id: 'c1', + result: Array.from({ length: rows }, (_, i) => ({ + id: i, + name: `row-${i}`, + email: `user${i}@example.com`, + createdAt: '2026-09-19T10:00:00.000Z', + tags: ['alpha', 'beta'], + })), + }); + +const encoder = new TextEncoder(); + +const syncRound = (label, codec, text, iterations) => { + const bytes = encoder.encode(text); + let out = 0; + let started = performance.now(); + for (let i = 0; i < iterations; i++) out += codec.encode(bytes).length; + const encodeMs = performance.now() - started; + const encoded = codec.encode(bytes); + started = performance.now(); + for (let i = 0; i < iterations; i++) codec.decode(encoded, bytes.length); + const decodeMs = performance.now() - started; + const ratio = (bytes.length / (out / iterations)).toFixed(1); + report(`${label} ${bytes.length.toLocaleString('en-US')} B, encode`, iterations, encodeMs, ` ${ratio}x`); + report(`${label} ${bytes.length.toLocaleString('en-US')} B, decode`, iterations, decodeMs); +}; + +const asyncRound = async (label, codec, text, iterations) => { + const bytes = encoder.encode(text); + let started = performance.now(); + let out = 0; + for (let i = 0; i < iterations; i++) out += (await codec.encode(bytes)).length; + const encodeMs = performance.now() - started; + const encoded = await codec.encode(bytes); + started = performance.now(); + for (let i = 0; i < iterations; i++) await codec.decode(encoded, bytes.length); + const decodeMs = performance.now() - started; + const ratio = (bytes.length / (out / iterations)).toFixed(1); + report(`${label} ${bytes.length.toLocaleString('en-US')} B, encode`, iterations, encodeMs, ` ${ratio}x`); + report(`${label} ${bytes.length.toLocaleString('en-US')} B, decode`, iterations, decodeMs); +}; + +const sequencer = async () => { + const count = 200_000; + let delivered = 0; + const deliver = () => { + delivered++; + }; + let queue = new Sequencer(); + let started = performance.now(); + for (let i = 0; i < count; i++) queue.push(i, deliver); + report('Sequencer, plain values (the synchronous path)', count, performance.now() - started); + queue = new Sequencer(); + delivered = 0; + started = performance.now(); + for (let i = 0; i < count; i++) queue.push(Promise.resolve(i), deliver); + await new Promise((resolve) => { + const check = () => (delivered === count ? resolve() : setImmediate(check)); + check(); + }); + report('Sequencer, resolved promises (the async path)', count, performance.now() - started); +}; + +const main = async () => { + const node = nativeCompressor(); + console.log('node:zlib raw deflate, one message at a time'); + syncRound('event', node, event(1), 50_000); + syncRound('callback', node, callback(12), 20_000); + syncRound('callback', node, callback(200), 2_000); + syncRound('callback', node, callback(4000), 100); + + const page = browser.nativeCompressor(); + if (page !== null) { + console.log('CompressionStream (deflate-raw), one message at a time — what a page pays'); + await asyncRound('event', page, event(1), 5_000); + await asyncRound('callback', page, callback(12), 5_000); + await asyncRound('callback', page, callback(200), 1_000); + await asyncRound('callback', page, callback(4000), 50); + } + + console.log('Order keeping'); + await sequencer(); +}; + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/bench/rpc-comparison.js b/bench/rpc-comparison.js index 0b4755e..612a10c 100644 --- a/bench/rpc-comparison.js +++ b/bench/rpc-comparison.js @@ -25,7 +25,22 @@ const path = require('node:path'); const { spawnSync } = require('node:child_process'); -const STACK_ORDER = ['wrpc', 'ws', 'uws', 'fastify-websocket', 'fastify-uws', 'socket.io', 'trpc-ws']; +const STACK_ORDER = [ + 'wrpc', + 'wrpc-uws', + 'wrpc-batch', + 'wrpc-wt', + 'wrpc-webrtc', + 'ws', + 'uws', + 'fastify-websocket', + 'fastify-uws', + 'wt-raw', + 'webrtc-raw', + 'socket.io', + 'trpc-ws', + 'grpc', +]; // Matched by suffix against the names rpc-stack-worker.js prints. The // pipelined row keeps its own suffix (`… ×64`), so the match stays unambiguous. const MEASUREMENTS = ['small payload', '10KB payload', 'small payload ×64']; @@ -50,13 +65,19 @@ function main() { console.log(`Node ${process.version} | ${new Date().toISOString()}\n`); const byStack = new Map(); - for (const key of STACK_ORDER) byStack.set(key, runStack(key)); + for (const key of STACK_ORDER) { + const result = runStack(key); + // A stack over an optional native implementation (WebTransport, WebRTC) + // answers why it did not run instead of failing the comparison. + if (result.skipped) console.log(`${key}: skipped — ${result.skipped}`); + else byStack.set(key, result); + } console.log('\nSummary (ops/sec, relative to wrpc):\n'); for (const size of MEASUREMENTS) { const baseline = byStack.get('wrpc').find((r) => r.name.endsWith(size)).opsPerSec; console.log(` ${size}:`); - for (const key of STACK_ORDER) { + for (const key of byStack.keys()) { const result = byStack.get(key).find((r) => r.name.endsWith(size)); const ratio = (result.opsPerSec / baseline).toFixed(2); const label = result.name.slice(0, result.name.indexOf(' — ')); diff --git a/bench/rtc-fanout.js b/bench/rtc-fanout.js new file mode 100644 index 0000000..1a60feb --- /dev/null +++ b/bench/rtc-fanout.js @@ -0,0 +1,139 @@ +'use strict'; + +/** + * WebRTC fan-out benchmark: `PeerHost.to(room).emit` over N data-channel + * links — what `Mesh.broadcast` is. + * + * A WebSocket fan-out builds its frame once for every recipient + * (bench/send-path.js). A data channel cannot share a frame — each link has + * its own message size, so its own fragmentation — but everything BEFORE + * the fragments is the same for every recipient: the JSON (already one per + * emit), its UTF-8, and under compression the deflated body. This measures + * an emit to 8 and 32 links, plain and compressed, at three payload sizes, + * so the per-link share of that common work is a number. + * + * When ChannelCodec.sendShared was written (one machine; the ratios are what + * carries over), an emit to 32 links cost, per recipient: + * + * 512 B 4 KB 16 KB + * plain, before 0.51 µs 2.73 µs 10.3 µs utf8 + frame per link + * plain, after 0.14 µs 0.53 µs 1.7 µs utf8 once + * compressed, before 9.1 µs 15.9 µs 37.7 µs one deflate per link + * compressed, after 0.44 µs 0.87 µs 2.2 µs one deflate per emit + * + * (To 8 links: 0.67 -> 0.35, 3.5 -> 1.6, 13.2 -> 5.8 plain; 9.4 -> 1.5, + * 16.6 -> 3.1, 40.5 -> 8.7 compressed — the once-per-emit work is spread + * over fewer recipients. An emit to ONE link pays 0.2 µs for the sharing.) + * + * Run: node bench/rtc-fanout.js (or as part of `pnpm bench`) + */ + +const { performance } = require('node:perf_hooks'); + +const { PeerHost } = require('../src/webrtc/host.js'); +const { RtcPeerTransport } = require('../src/webrtc/transport.js'); +const { defineRouter, procedure } = require('../src/rpc/router.js'); +const { deflateCompressor } = require('../src/compression/codecs.js'); + +// A data channel that takes every message at once: the host's own work is +// what is timed, not a network. +class SinkChannel { + id = 1; + label = 'bench'; + readyState = 'open'; + bufferedAmount = 0; + bufferedAmountLowThreshold = 0; + binaryType = 'arraybuffer'; + frames = 0; + bytes = 0; + + send(data) { + this.frames++; + this.bytes += typeof data === 'string' ? data.length : data.byteLength; + } + + close() { + this.readyState = 'closed'; + } + + addEventListener() {} + + removeEventListener() {} +} + +const payloadOf = (size) => { + const rows = []; + let n = 0; + while (JSON.stringify(rows).length < size) { + rows.push({ + id: n, + name: `row-${n}`, + email: `user${n}@example.com`, + at: '2026-10-01T10:00:00.000Z', + ok: n % 2 === 0, + }); + n++; + } + return { rows }; +}; + +const fanout = async (links, compression, label, size) => { + const router = defineRouter({ noop: { ping: procedure({ access: 'public', handler: async () => null }) } }); + const host = new PeerHost({ router, logger: false }); + const channels = []; + for (let i = 0; i < links; i++) { + const channel = new SinkChannel(); + // The two sizes a mesh really meets: a browser's and a Node peer's. + const transport = new RtcPeerTransport(channel, { + peer: `peer-${i}`, + maxMessageSize: i % 2 === 0 ? 65536 : 262144, + maxBackpressure: 0, + compression, + }); + const client = host.attach(transport, { peer: `peer-${i}` }); + client.join('mesh:bench'); + channels.push(channel); + } + const data = payloadOf(size); + const emit = () => host.to('mesh:bench').emit('chat/state', data); + for (let i = 0; i < 200; i++) emit(); + let emits = 0; + const started = performance.now(); + while (performance.now() - started < 700) { + for (let i = 0; i < 20; i++) emit(); + emits += 20; + } + const elapsed = performance.now() - started; + const perRecipient = (elapsed * 1000) / (emits * links); + const wire = channels[0].bytes / channels[0].frames; + console.log( + ` ${`x${links} ${compression ? 'compressed' : 'plain'}, ${label}`.padEnd(30)}` + + `${Math.round((emits / elapsed) * 1000) + .toLocaleString('en-US') + .padStart(10)} emits/sec` + + `${perRecipient.toFixed(2).padStart(9)} µs/recipient` + + `${Math.round(wire).toLocaleString('en-US').padStart(9)} B/frame`, + ); + host.close?.(); +}; + +const main = async () => { + console.log(`WebRTC fan-out benchmark — Node ${process.version}\n`); + for (const compression of [null, { codec: deflateCompressor(), threshold: 256 }]) { + for (const links of [8, 32]) { + for (const [label, size] of [ + ['512 B', 512], + ['4 KB', 4096], + ['16 KB', 16384], + ]) { + await fanout(links, compression, label, size); + } + } + console.log(); + } +}; + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/bench/rtc-framing.js b/bench/rtc-framing.js new file mode 100644 index 0000000..e638fac --- /dev/null +++ b/bench/rtc-framing.js @@ -0,0 +1,180 @@ +'use strict'; + +/** + * Data-channel framing benchmark (src/webrtc/framing.js). + * + * Every wrpc packet and every stream chunk on a WebRTC link goes through + * FrameEncoder on the way out and FrameDecoder on the way in, so the two + * choices the file makes are measured here rather than assumed: + * + * encode, bytes: (a) a fresh Uint8Array per fragment + * (b) one reused scratch buffer per encoder — production + * (the sink must copy synchronously, as a channel's + * send() does; the 3x is the allocation, not the copy) + * encode, text: (c) TextEncoder.encode() then copy into a fresh frame + * (d) encodeInto() straight into the scratch frame — production + * decode: (e) subarray over the received buffer — production + * (f) slice (a defensive copy) + * + * Run: node bench/rtc-framing.js (or as part of `pnpm bench`) + */ + +const { performance } = require('node:perf_hooks'); +const crypto = require('node:crypto'); + +const { + FrameEncoder, + FrameDecoder, + KIND_BINARY, + KIND_TEXT, + FLAG_FIN, + HEADER_BYTES, +} = require('../src/webrtc/framing.js'); + +const KIB = 1024; +const MIB = 1024 * 1024; + +const SIZES = [ + { label: '64 B', size: 64, iterations: 400_000 }, + { label: '1 KiB', size: KIB, iterations: 200_000 }, + { label: '64 KiB', size: 64 * KIB, iterations: 4_000 }, + { label: '1 MiB', size: MIB, iterations: 256 }, +]; +const LIMITS = [16 * KIB, 256 * KIB]; + +const TEXT_ENCODER = new TextEncoder(); + +// (a) a fresh frame per fragment — the alternative production rejected. +class FreshEncoder { + constructor(maxMessageSize) { + this.room = maxMessageSize - HEADER_BYTES; + } + encode(kind, bytes, sink) { + const total = bytes.length; + const room = this.room; + let offset = 0; + let count = 0; + do { + const size = total - offset < room ? total - offset : room; + const frame = new Uint8Array(size + HEADER_BYTES); + frame[0] = offset + size === total ? kind | FLAG_FIN : kind; + frame.set(bytes.subarray(offset, offset + size), HEADER_BYTES); + sink(frame); + offset += size; + count++; + } while (offset < total); + return count; + } +} + +// (c) encode-then-copy text into fresh frames — the alternative production rejected. +const encodeTextViaBytes = (encoder, text, sink) => encoder.encode(KIND_TEXT, TEXT_ENCODER.encode(text), sink); + +// A sink shaped like RTCDataChannel.send: touches the frame, keeps nothing. +let sunk = 0; +const sink = (frame) => { + sunk += frame.length; +}; + +const bench = (fn, iterations) => { + const warmup = Math.max(1, iterations >> 3); + for (let i = 0; i < warmup; i++) fn(); + const start = performance.now(); + for (let i = 0; i < iterations; i++) fn(); + return iterations / ((performance.now() - start) / 1000); +}; + +const formatNumber = (num) => num.toLocaleString('en-US', { maximumFractionDigits: 2 }); +const formatOps = (ops) => { + if (ops >= 1_000_000) return `${formatNumber(ops / 1_000_000)}M`; + if (ops >= 1000) return `${formatNumber(ops / 1000)}K`; + return formatNumber(ops); +}; + +const row = (name, ops) => console.log(` ${name.padEnd(44)}${formatOps(ops).padStart(12)} ops/sec`); + +// Every strategy must round-trip byte-identically before being timed. +const verify = () => { + for (const limit of LIMITS) { + const prod = new FrameEncoder(limit); + const fresh = new FreshEncoder(limit); + for (const size of [0, 1, limit - 2, limit - 1, limit, 2 * (limit - 1) + 1, 3 * limit + 7]) { + const bytes = crypto.randomBytes(size); + for (const encoder of [prod, fresh]) { + const decoder = new FrameDecoder(); + let out = null; + encoder.encode(KIND_BINARY, bytes, (frame) => { + const result = decoder.push(Uint8Array.from(frame)); + if (result) out = result.data; + }); + if (!out || Buffer.compare(Buffer.from(out), bytes) !== 0) throw new Error(`round-trip failed at ${size}`); + } + const text = 'x'.repeat(size); + const decoder = new FrameDecoder(); + let out = null; + prod.encodeText(text, (frame) => { + const result = decoder.push(Uint8Array.from(frame)); + if (result) out = result.data; + }); + if (out !== text) throw new Error(`text round-trip failed at ${size}`); + } + } +}; + +const main = () => { + console.log(`Data-channel framing benchmark — Node ${process.version}`); + verify(); + for (const limit of LIMITS) { + console.log(`\nmaxMessageSize ${limit / KIB} KiB`); + const prod = new FrameEncoder(limit); + const fresh = new FreshEncoder(limit); + for (const { label, size, iterations } of SIZES) { + const bytes = crypto.randomBytes(size); + const text = crypto + .randomBytes(Math.ceil(size / 2)) + .toString('hex') + .slice(0, size); + console.log(` -- ${label} (${formatNumber(iterations)} iterations)`); + row( + '(a) bytes: fresh frame per fragment', + bench(() => fresh.encode(KIND_BINARY, bytes, sink), iterations), + ); + row( + '(b) bytes: scratch buffer reuse [prod]', + bench(() => prod.encode(KIND_BINARY, bytes, sink), iterations), + ); + row( + '(c) text: encode() then copy, fresh', + bench(() => encodeTextViaBytes(fresh, text, sink), iterations), + ); + row( + '(d) text: encodeInto() scratch [prod]', + bench(() => prod.encodeText(text, sink), iterations), + ); + // Decode: the single-fragment fast path is what a packet takes; the + // reassembly path is what a chunk over the limit takes. + const frames = []; + prod.encode(KIND_BINARY, bytes, (frame) => frames.push(Uint8Array.from(frame))); + const decoder = new FrameDecoder(); + const plural = frames.length === 1 ? '' : 's'; + row( + `(e) decode: subarray [prod] (${frames.length} fragment${plural})`, + bench(() => { + for (let i = 0; i < frames.length; i++) decoder.push(frames[i]); + }, iterations), + ); + row( + '(f) decode: slice copy', + bench(() => { + for (let i = 0; i < frames.length; i++) { + const result = decoder.push(frames[i]); + if (result) sunk += result.data.slice().length; + } + }, iterations), + ); + } + } + if (sunk < 0) console.log(sunk); // keep the sink observable +}; + +main(); diff --git a/bench/send-path.js b/bench/send-path.js index 2b5aeb9..b0d49ff 100644 --- a/bench/send-path.js +++ b/bench/send-path.js @@ -79,12 +79,46 @@ const benchDeflate = () => { performance.now() - started, payload.length * iterations, ); + + // The `level` knob on the one-shot path (PERF-2): what each level costs + // and buys on a 2 KB event and a 24 KB callback, beside the per-algorithm + // rows of bench/algorithms.js. The default stays zlib's 6. + const rows = JSON.stringify({ + type: 'callback', + id: 'c1', + result: Array.from({ length: 300 }, (_, i) => ({ + id: i, + name: `row-${i}`, + email: `user${i}@example.com`, + createdAt: '2026-09-19T10:00:00.000Z', + tags: ['alpha', 'beta'], + })), + }); + for (const [label, bytes, count] of [ + ['2 KB', payload, 20_000], + ['24 KB', Buffer.from(rows), 2_000], + ]) { + for (const level of [1, 3, 6]) { + const options = { level }; + const size = compress(bytes, 15, options).length; + started = performance.now(); + for (let i = 0; i < count; i++) compress(bytes, 15, options); + report( + `permessage-deflate compress ${label}, level ${level} (${(bytes.length / size).toFixed(1)}x)`, + count, + performance.now() - started, + bytes.length * count, + ); + } + } }; -// Room fan-out over sink sockets. THE baseline for encode-once: today every -// recipient pays its own JSON.stringify + utf8 encode (+ deflate when on), -// so the per-recipient cost is what this number exposes. -const benchFanout = async (clients, deflate) => { +// Room fan-out over sink sockets: the JSON is serialized once per emit and +// the frame — deflated once per negotiated window — is shared by every +// recipient (Connection.sendPrepared), so the per-recipient cost is one +// socket write. `windows` alternates the negotiated window bits across +// members, the mixed-fleet case where the shared frame is built per window. +const benchFanout = async (clients, deflate, windows = [15]) => { const router = defineRouter({ noop: { ping: procedure({ access: 'public', handler: async () => null }) } }); const rpc = new RpcServer({ router, logger: false }); const sockets = []; @@ -92,19 +126,20 @@ const benchFanout = async (clients, deflate) => { const socket = new SinkSocket(); const conn = new Connection(socket, Buffer.alloc(0), { maxBackpressure: 0, - ...(deflate ? { deflate: { threshold: 1, windowBits: 15 } } : {}), + ...(deflate ? { deflate: { threshold: 1, windowBits: windows[i % windows.length] } } : {}), }); const client = rpc.attachSocket(conn, { remoteAddress: `10.0.0.${i}` }); client.join('load'); sockets.push(socket); } const data = { text: 'x'.repeat(512), n: 42 }; - const iterations = deflate ? 500 : 5_000; + const iterations = deflate ? 5_000 : 20_000; const started = performance.now(); for (let i = 0; i < iterations; i++) rpc.to('load').emit('chat/message', data); const elapsed = performance.now() - started; const bytes = sockets.reduce((sum, socket) => sum + socket.bytes, 0); - report(`room fan-out x${clients}${deflate ? ' +deflate' : ''} (512 B)`, iterations, elapsed, bytes); + const mixed = windows.length > 1 ? ` windows ${windows.join('/')}` : ''; + report(`room fan-out x${clients}${deflate ? ' +deflate' : ''}${mixed} (512 B)`, iterations, elapsed, bytes); await rpc.close(); }; @@ -113,11 +148,15 @@ const main = async () => { console.log(` ${'scenario'.padEnd(46)}${'rate'.padStart(18)}${'throughput'.padStart(12)}`); benchSendText('sendText 200 B', 'x'.repeat(200)); benchSendText('sendText 4 KB', 'x'.repeat(4096)); + // Straddles SINGLE_WRITE_MAX: above it the payload is a second write. + benchSendText('sendText 64 KB', 'x'.repeat(65536)); benchSendText('sendText 200 B +deflate', 'x'.repeat(200), { deflate: { threshold: 1, windowBits: 15 } }); benchDeflate(); await benchFanout(50, false); await benchFanout(200, false); await benchFanout(50, true); + await benchFanout(200, true); + await benchFanout(50, true, [10, 15]); console.log(); }; diff --git a/bench/serialize-callback.js b/bench/serialize-callback.js index b74d31d..573ed61 100644 --- a/bench/serialize-callback.js +++ b/bench/serialize-callback.js @@ -12,6 +12,7 @@ // not any particular serializer library. const { bench } = require('./support/harness.js'); +const { hasBytes } = require('../src/attachments.js'); const result = { id: 'p_812', @@ -54,6 +55,23 @@ const run = async () => { }, { opsPerIteration: BATCH }, ); + + // What the fast path pays with attachments on: the result is walked for + // bytes before the serializer runs (a frame cannot carry serializer + // JSON). With `attachments: false` the walk is skipped — the row above. + await bench( + 'hasBytes(result) + envelope surgery + compiled result', + async () => { + let out; + for (let i = 0; i < BATCH; i++) { + out = hasBytes(result) + ? null + : `{"type":"callback","id":${JSON.stringify(id)},"result":${compiledSerialize(result)}}`; + } + return out; + }, + { opsPerIteration: BATCH }, + ); }; // Measured on Node 24 (2026-08): ~1.16x for the seam itself with this diff --git a/bench/support/cluster-node-worker.js b/bench/support/cluster-node-worker.js new file mode 100644 index 0000000..669f6e6 --- /dev/null +++ b/bench/support/cluster-node-worker.js @@ -0,0 +1,113 @@ +'use strict'; + +// One wrpc instance of the multi-node bench (bench/cluster-nodes.js): a +// Server over a Redis backplane and a Redis session store, told its +// instanceId and the Redis URL by the orchestrator, reporting its port and +// its gap count back over IPC. Not discovered by bench/run-all.js (it lives +// under support/). + +const { Server } = require('../../src/server.js'); +const { defineRouter, procedure } = require('../../src/rpc/router.js'); +const { createRedisAdapter, createRedisSessionStore } = require('../../src/scaling/index.js'); +const { bearerTransport } = require('../../src/auth/index.js'); + +const { REDIS_URL, WRPC_INSTANCE = 'node' } = process.env; +const Redis = require('ioredis'); + +let gaps = 0; +// A logger that counts the loss-detection events and stays quiet otherwise. +const logger = { + log() {}, + info() {}, + debug() {}, + error(entry, message) { + console.error(`[${WRPC_INSTANCE}]`, entry?.err?.stack ?? entry, message ?? ''); + }, + warn(entry) { + if (entry && entry.event === 'backplane.gap') gaps += entry.missed ?? 1; + }, + child() { + return this; + }, +}; + +const main = async () => { + const pub = new Redis(REDIS_URL, { lazyConnect: false, maxRetriesPerRequest: null }); + const store = new Redis(REDIS_URL, { maxRetriesPerRequest: null }); + const router = defineRouter({ + bench: { + echo: procedure({ access: 'public', handler: async (_ctx, args) => args }), + join: procedure({ + access: 'public', + handler: async (ctx, { room }) => { + ctx.client.join(room); + return ctx.server.instanceId; + }, + }), + leave: procedure({ + access: 'public', + handler: async (ctx, { room }) => { + ctx.client.leave(room); + return true; + }, + }), + // A cross-instance emit from THIS instance: every member everywhere. + // (`emit`/`ask` would collide with the client-side unit Emitter's own methods.) + fanout: procedure({ + access: 'public', + handler: async (ctx, { room, data }) => ctx.server.to(room).emit('bench/tick', data), + }), + inquire: procedure({ + access: 'public', + handler: async (ctx, { room }) => { + const { answers, expected } = await ctx.server.to(room).ask('bench/q', null, { timeout: 5000 }); + return { answers: answers.length, expected }; + }, + }), + count: procedure({ access: 'public', handler: async (ctx, { room }) => ctx.server.cluster.count(room) }), + login: procedure({ + access: 'public', + handler: async (ctx, { user }) => { + ctx.client.startSession(undefined, { user }); + return { access: ctx.client.session.token }; + }, + }), + whoami: procedure({ access: 'session', handler: async (ctx) => ctx.session.state.user }), + gaps: procedure({ access: 'public', handler: async () => gaps }), + }, + }); + const server = new Server({ + router, + host: '127.0.0.1', + port: 0, + protocol: 'http', + logger, + instanceId: WRPC_INSTANCE, + backplane: createRedisAdapter({ pub, logger: false }), + sessions: { + store: createRedisSessionStore({ client: store, prefix: 'wrpc:bench:session:' }), + transport: bearerTransport(), + }, + cluster: { presenceInterval: 1000 }, + }); + await server.listen(); + // The backplane and cluster channels subscribe asynchronously after + // listen(); a client that joins before they land publishes deltas nobody + // hears (at-most-once), and the digest corrects that a presence interval + // later. A short pause keeps the first scenario from measuring that. + await new Promise((resolve) => setTimeout(resolve, 250)); + process.send({ type: 'ready', port: server.address().port, instance: WRPC_INSTANCE }); + process.on('message', async (message) => { + if (message?.type === 'stop') { + await server.close(); + pub.disconnect(); + store.disconnect(); + process.exit(0); + } + }); +}; + +main().catch((error) => { + process.send?.({ type: 'error', message: error.message }); + process.exit(1); +}); diff --git a/bench/support/echo.proto b/bench/support/echo.proto new file mode 100644 index 0000000..6e82cc5 --- /dev/null +++ b/bench/support/echo.proto @@ -0,0 +1,15 @@ +// The gRPC row of bench/rpc-comparison.js: one unary echo, loaded at runtime +// by @grpc/proto-loader (no codegen). The two fields carry the comparison's +// two payloads — `{ name: 'Ada' }` and `{ text: 'x' × 10 000 }`. +syntax = "proto3"; + +package bench; + +service Bench { + rpc Echo (Payload) returns (Payload); +} + +message Payload { + string name = 1; + string text = 2; +} diff --git a/bench/support/http-server-worker.js b/bench/support/http-server-worker.js new file mode 100644 index 0000000..5de33a0 --- /dev/null +++ b/bench/support/http-server-worker.js @@ -0,0 +1,26 @@ +'use strict'; +/** + * Runs ONE server of bench/support/http-servers.js in its own process for + * bench/http-comparison.js: prints a `READY_JSON:` line with its port, path + * and request body, then serves until its stdin closes. A process per stack + * keeps one framework's warm-up, GC pressure and leftover handles out of the + * next one's numbers, and the load generator out of every server's. + */ +const servers = require('./http-servers.js'); + +async function main() { + const key = process.argv[2]; + if (!Object.hasOwn(servers, key)) throw new Error(`unknown http stack ${key}`); + const { port, path, body, stop } = await servers[key].start(); + console.log(`READY_JSON:${JSON.stringify({ port, path, body })}`); + process.stdin.resume(); + process.stdin.on('end', async () => { + await stop(); + process.exit(0); + }); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/bench/support/http-servers.js b/bench/support/http-servers.js new file mode 100644 index 0000000..cbf74dd --- /dev/null +++ b/bench/support/http-servers.js @@ -0,0 +1,103 @@ +'use strict'; + +/** + * The servers bench/http-comparison.js loads with autocannon: one JSON echo + * over plain HTTP/1.1 per stack, each answering `POST` with the body it was + * given — the shape of fastify/benchmarks' hello-world, with a body because + * an RPC call has arguments. `start()` resolves with what autocannon needs: + * the port, the path, and the exact request body (some stacks wrap the + * arguments in an envelope of their own). + * + * Every framework here is a devDependency used only by the benchmarks; + * wrpc itself stays zero-dependency (see CLAUDE.md). + */ + +const http = require('node:http'); + +const ARGS = { name: 'Ada' }; + +const listen = (server) => + new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server.address().port))); + +// wrpc's REST bridge: a procedure that declares `http: { method, path }` is +// a real endpoint — the body arrives as `body`, the answer is the plain +// result, no envelope. +async function startWrpcRest() { + const { Server } = require('../../src/server.js'); + const { defineRouter, procedure } = require('../../src/rpc/router.js'); + const router = defineRouter({ + bench: { + echo: procedure({ + access: 'public', + http: { method: 'POST', path: '/echo' }, + handler: async (_context, { body }) => body, + }), + }, + }); + const server = new Server({ router, host: '127.0.0.1', port: 0, protocol: 'http', logger: false }); + await server.listen(); + return { port: server.address().port, path: '/api/echo', body: ARGS, stop: () => server.close() }; +} + +// wrpc's packet mode: the call packet a WebSocket would carry, as the body of +// one POST; the answer is its callback packet. +async function startWrpcPacket() { + const { createWrpcServer } = require('./wrpc-echo.js'); + const { server, port } = await createWrpcServer({ bench: { echo: { handler: async (args) => args } } }); + const packet = { type: 'call', id: '1', method: 'bench/echo', args: ARGS }; + return { port, path: '/api', body: packet, stop: () => server.close() }; +} + +async function startFastify() { + const fastify = require('fastify')({ logger: false }); + fastify.post('/echo', async (request) => request.body); + await fastify.listen({ port: 0, host: '127.0.0.1' }); + return { port: fastify.server.address().port, path: '/echo', body: ARGS, stop: () => fastify.close() }; +} + +async function startExpress() { + const express = require('express'); + const app = express(); + app.use(express.json()); + app.post('/echo', (request, response) => response.json(request.body)); + const server = http.createServer(app); + const port = await listen(server); + return { port, path: '/echo', body: ARGS, stop: () => new Promise((resolve) => server.close(resolve)) }; +} + +// tRPC v11's standalone HTTP adapter: a mutation is a POST to its path, the +// input is the body, the answer is `{ result: { data } }`. +async function startTrpc() { + const { initTRPC } = require('@trpc/server'); + const { createHTTPServer } = require('@trpc/server/adapters/standalone'); + const t = initTRPC.create(); + const appRouter = t.router({ echo: t.procedure.input((value) => value).mutation(({ input }) => input) }); + const server = createHTTPServer({ router: appRouter }); + const port = await listen(server); + return { port, path: '/echo', body: ARGS, stop: () => new Promise((resolve) => server.close(resolve)) }; +} + +// No framework: node:http reading the body, parsing it and answering it — +// the ceiling, as the raw echoes are in bench/rpc-comparison.js. +async function startNodeHttp() { + const server = http.createServer((request, response) => { + const chunks = []; + request.on('data', (chunk) => chunks.push(chunk)); + request.on('end', () => { + const body = JSON.stringify(JSON.parse(Buffer.concat(chunks).toString())); + response.writeHead(200, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) }); + response.end(body); + }); + }); + const port = await listen(server); + return { port, path: '/echo', body: ARGS, stop: () => new Promise((resolve) => server.close(resolve)) }; +} + +module.exports = { + 'wrpc-rest': { label: 'wrpc (declared REST route)', start: startWrpcRest }, + 'wrpc-packet': { label: 'wrpc (packet mode)', start: startWrpcPacket }, + fastify: { label: 'fastify', start: startFastify }, + express: { label: 'express', start: startExpress }, + trpc: { label: 'tRPC (standalone HTTP adapter)', start: startTrpc }, + 'node-http': { label: 'node:http (no framework)', start: startNodeHttp }, +}; diff --git a/bench/support/real-stacks.js b/bench/support/real-stacks.js new file mode 100644 index 0000000..8d6c54a --- /dev/null +++ b/bench/support/real-stacks.js @@ -0,0 +1,280 @@ +'use strict'; + +/** + * The REAL stacks the transport benchmarks run wrpc over — the injections + * tests/wt/fails.integration.test.js and + * tests/webrtc/node-datachannel.integration.test.js make, gated the same way + * so `pnpm bench` stays self-contained: + * + * WRPC_WT=fails @fails-components/webtransport — Google's + * libquiche behind a native binding — for the + * HTTP/3 host AND the Node client + * WRPC_RTC=node-datachannel node-datachannel's W3C polyfill — + * libdatachannel (usrsctp, libjuice) — for both + * peers of a loopback pair + * + * Without the variable, or without a valid certificate, a row is SKIPPED + * with its reason, never failed: bench/run-all.js counts a non-zero exit as + * a broken benchmark. These packages are devDependencies used only by the + * integration tests and these benchmarks; wrpc itself injects them. + * + * node scripts/wt-cert.js certs # the 14-day ECDSA certificate WT needs + */ + +const fs = require('node:fs'); +const path = require('node:path'); +const { once } = require('node:events'); +const { performance } = require('node:perf_hooks'); + +const { WrpcClient } = require('../../src/client.js'); + +const CERTS = process.env.WRPC_WT_CERTS + ? path.resolve(process.env.WRPC_WT_CERTS) + : path.join(__dirname, '..', '..', 'certs'); + +const wtSkip = () => { + if (process.env.WRPC_WT !== 'fails') return 'set WRPC_WT=fails'; + const info = path.join(CERTS, 'wt-cert.json'); + if (!fs.existsSync(info)) return `no certificate: node scripts/wt-cert.js ${CERTS}`; + const { notAfter } = JSON.parse(fs.readFileSync(info, 'utf8')); + if (Date.parse(notAfter) < Date.now()) return `the certificate expired ${notAfter}: node scripts/wt-cert.js ${CERTS}`; + return null; +}; + +const rtcSkip = () => (process.env.WRPC_RTC === 'node-datachannel' ? null : 'set WRPC_RTC=node-datachannel'); + +// A started HTTP/3 host on loopback and what a client needs to trust it. +const bootH3 = async () => { + const { Http3Server, WebTransport, quicheLoaded } = await import('@fails-components/webtransport'); + await quicheLoaded; + const { failsRequestCallback } = require('../../src/webtransport/index.js'); + const read = (name) => fs.readFileSync(path.join(CERTS, name), 'utf8'); + const info = JSON.parse(read('wt-cert.json')); + const h3 = new Http3Server({ + port: 0, + host: '127.0.0.1', + secret: 'wrpc-bench', + cert: read('wt-cert.pem'), + privKey: read('wt-key.pem'), + }); + h3.setRequestCallback(failsRequestCallback); + h3.startServer(); + await h3.ready; + const serverCertificateHashes = [{ algorithm: 'sha-256', value: Buffer.from(info.hash, 'base64') }]; + return { h3, port: h3.address().port, WebTransport, serverCertificateHashes, hash: info.hash }; +}; + +// An HTTP/3 host whose sessions land in `server` — the same RpcServer the +// WebSocket clients of the same process reach. Returns what a client needs +// to dial it, and how to take it down. +const bootWt = async (server) => { + const { acceptSessions } = require('../../src/webtransport/index.js'); + const { h3, port, WebTransport, serverCertificateHashes } = await bootH3(); + const acceptor = acceptSessions(server, h3.sessionStream('/api')); + return { + url: `https://127.0.0.1:${port}/api`, + options: { transport: 'wt', wt: { WebTransport, serverCertificateHashes } }, + stop: async () => { + await acceptor.stop(); + await h3.stopServer(); + }, + }; +}; + +// Trickle candidates from `from` to `to`, held until `to` has a remote +// description — the helper tests/webrtc/portContract.js uses, for a pair +// whose signaling is two local variables. +const trickle = (from, to) => { + const queue = []; + let flushing = null; + const flush = async () => { + while (queue.length > 0 && to.remoteDescription) await to.addIceCandidate(queue.shift()); + }; + from.addEventListener('icecandidate', ({ candidate }) => { + queue.push(candidate === null ? null : (candidate.toJSON?.() ?? candidate)); + flushing = (flushing ?? Promise.resolve()).then(flush); + }); + return () => (flushing ?? Promise.resolve()).then(flush); +}; + +const opened = (channel) => (channel.readyState === 'open' ? Promise.resolve() : once(channel, 'open')); + +// Two peer connections on loopback with one negotiated channel each, +// described to each other by hand: the raw-channel level of +// docs/guide/webrtc.md#your-own-connection. `client` goes to a WrpcClient, +// `host` to attachChannel(). +const rtcPair = async () => { + const { createW3cAdapter } = require('../../src/webrtc/port.js'); + const adapter = createW3cAdapter(require('node-datachannel/polyfill')); + const a = adapter.createPeerConnection({ iceServers: [] }); + const b = adapter.createPeerConnection({ iceServers: [] }); + const client = a.createDataChannel('wrpc', { negotiated: true, id: 0 }); + const host = b.createDataChannel('wrpc', { negotiated: true, id: 0 }); + const flushA = trickle(a, b); + const flushB = trickle(b, a); + await a.setLocalDescription(); + await b.setRemoteDescription(a.localDescription); + await b.setLocalDescription(); + await a.setRemoteDescription(b.localDescription); + await flushA(); + await flushB(); + await Promise.all([opened(client), opened(host)]); + return { + client, + host, + close: () => { + a.close(); + b.close(); + }, + }; +}; + +// node-datachannel keeps a native thread alive until it is told to let go. +const rtcCleanup = () => { + try { + require('node-datachannel').cleanup(); + } catch { + // never loaded + } +}; + +// A WrpcClient into `server` over `kind` — 'ws', 'wt' or 'webrtc' — and +// how to take that one connection down. `wt` is a bootWt() result, shared +// by every connection of a run; a webrtc connection brings its own pair. +const connectOver = async (kind, server, { wt, port, ...options } = {}) => { + if (kind === 'ws') { + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/`, options); + return { client, close: () => client.close() }; + } + if (kind === 'wt') { + const client = await WrpcClient.connect(wt.url, { ...wt.options, ...options }); + return { client, close: () => client.close() }; + } + if (kind === 'webrtc') { + const { attachChannel } = require('../../src/webrtc/index.js'); + const pair = await rtcPair(); + attachChannel(server.rpc, pair.host); + const client = await WrpcClient.connect('webrtc:server', { + transport: 'webrtc', + channel: pair.client, + reconnect: false, + ...options, + }); + return { + client, + close: () => { + client.close(); + pair.close(); + }, + }; + } + throw new Error(`unknown transport kind ${kind}`); +}; + +// The same byte round trip with no wrpc at all — `bytes` up in `chunk` +// pieces, the same count back — over the bare stack, so a wrpc stream rate +// can be read against the stack's own. Resolves with the elapsed ms. +// ws: the `ws` package's own client and server (binary messages); +// wt: one bidirectional QUIC stream, written up, then answered down; +// webrtc: data-channel messages paced by bufferedAmountLowThreshold. +const rawRoundTrip = async (kind, bytes, chunk) => { + const block = new Uint8Array(chunk).fill(7); + if (kind === 'ws') { + const http = require('node:http'); + const WebSocket = require('ws'); + const httpServer = http.createServer(); + const wss = new WebSocket.Server({ server: httpServer }); + wss.on('connection', (socket) => { + let seen = 0; + socket.on('message', async (data) => { + seen += data.length; + if (seen < bytes) return; + for (let sent = 0; sent < bytes; sent += chunk) { + socket.send(block); + if (socket.bufferedAmount > 4 * 1024 * 1024) await new Promise((resolve) => setImmediate(resolve)); + } + }); + }); + await new Promise((resolve) => httpServer.listen(0, '127.0.0.1', resolve)); + const socket = new WebSocket(`ws://127.0.0.1:${httpServer.address().port}/`); + await once(socket, 'open'); + const start = performance.now(); + const back = new Promise((resolve) => { + let got = 0; + socket.on('message', (data) => { + got += data.length; + if (got >= bytes) resolve(); + }); + }); + for (let sent = 0; sent < bytes; sent += chunk) { + socket.send(block); + if (socket.bufferedAmount > 4 * 1024 * 1024) await new Promise((resolve) => setImmediate(resolve)); + } + await back; + const elapsed = performance.now() - start; + socket.close(); + wss.close(); + httpServer.close(); + return elapsed; + } + if (kind === 'wt') { + const { h3, port, WebTransport, serverCertificateHashes } = await bootH3(); + const sessions = h3.sessionStream('/raw').getReader(); + sessions.read().then(async ({ value: session }) => { + await session.ready; + const { value: stream } = await session.incomingBidirectionalStreams.getReader().read(); + const reader = stream.readable.getReader(); + for (let seen = 0; seen < bytes;) seen += (await reader.read()).value.byteLength; + const writer = stream.writable.getWriter(); + for (let sent = 0; sent < bytes; sent += chunk) await writer.write(block); + await writer.close(); + }); + const transport = new WebTransport(`https://127.0.0.1:${port}/raw`, { serverCertificateHashes }); + await transport.ready; + const stream = await transport.createBidirectionalStream(); + const start = performance.now(); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + const back = (async () => { + for (let got = 0; got < bytes;) got += (await reader.read()).value.byteLength; + })(); + for (let sent = 0; sent < bytes; sent += chunk) await writer.write(block); + await back; + const elapsed = performance.now() - start; + transport.close(); + await h3.stopServer(); + return elapsed; + } + if (kind === 'webrtc') { + const pair = await rtcPair(); + const LOW = 1024 * 1024; + const send = async (channel) => { + channel.bufferedAmountLowThreshold = LOW; + for (let sent = 0; sent < bytes; sent += chunk) { + if (channel.bufferedAmount > 4 * LOW) await once(channel, 'bufferedamountlow'); + channel.send(block); + } + }; + const received = (channel) => + new Promise((resolve) => { + let got = 0; + channel.addEventListener('message', ({ data }) => { + got += data.byteLength ?? data.length; + if (got >= bytes) resolve(); + }); + }); + pair.host.binaryType = 'arraybuffer'; + pair.client.binaryType = 'arraybuffer'; + received(pair.host).then(() => send(pair.host)); + const back = received(pair.client); + const start = performance.now(); + await send(pair.client); + await back; + const elapsed = performance.now() - start; + pair.close(); + return elapsed; + } + throw new Error(`unknown transport kind ${kind}`); +}; + +module.exports = { wtSkip, rtcSkip, bootH3, bootWt, rtcPair, rtcCleanup, connectOver, rawRoundTrip }; diff --git a/bench/support/rpc-stack-worker.js b/bench/support/rpc-stack-worker.js index 01cb050..ff242cb 100644 --- a/bench/support/rpc-stack-worker.js +++ b/bench/support/rpc-stack-worker.js @@ -30,6 +30,12 @@ async function main() { return; } + const skipped = stack.skip?.(); + if (skipped) { + console.log(`RESULT_JSON:${JSON.stringify({ skipped })}`); + return; + } + const handle = await stack.start(); const results = []; results.push(await bench(`${stack.label} — small payload`, () => handle.call(smallPayload))); @@ -43,6 +49,9 @@ async function main() { await handle.stop(); console.log(`RESULT_JSON:${JSON.stringify(results)}`); + // A native stack (libquiche, libdatachannel) can hold a thread past its + // own close; the orchestrator waits on this process, so it must end. + setTimeout(() => process.exit(), 2000).unref(); } main(); diff --git a/bench/support/rpc-stacks.js b/bench/support/rpc-stacks.js index 0b249ca..16513d8 100644 --- a/bench/support/rpc-stacks.js +++ b/bench/support/rpc-stacks.js @@ -64,6 +64,178 @@ async function startWrpc() { }; } +// The same RPC path over the uWebSockets.js engine: what separates the cost +// of wrpc's dispatch from the cost of its JavaScript WebSocket engine. +async function startWrpcUws() { + const { createUwsEngine } = require('../../src/adapters/uws.js'); + const uws = require('uWebSockets.js'); + const { Server } = require('../../src/server.js'); + const { defineRouter, procedure } = require('../../src/rpc/router.js'); + const server = new Server({ + router: defineRouter({ bench: { echo: procedure({ access: 'public', handler: async (_c, args) => args }) } }), + host: '127.0.0.1', + port: 0, + logger: false, + engine: createUwsEngine({ uws }), + }); + await server.listen(); + const { port } = server.address(); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/`); + await client.load('bench'); + return { + call: (args) => client.api.bench.echo(args), + stop: async () => { + client.close(); + await server.close(); + }, + }; +} + +// Client batching on: the 64 in-flight calls of the pipelined row share +// frames instead of each being one. +async function startWrpcBatch() { + const api = { bench: { echo: { handler: async (args) => args } } }; + const { server, port } = await createWrpcServer(api); + const client = await WrpcClient.connect(`ws://127.0.0.1:${port}/`, { batch: true }); + await client.load('bench'); + return { + call: (args) => client.api.bench.echo(args), + stop: async () => { + client.close(); + await server.close(); + }, + }; +} + +// The same RPC path into the same Server, over the two transports with a +// native stack under them (bench/support/real-stacks.js): a WebTransport +// session (libquiche on both ends) and a WebRTC data channel +// (libdatachannel on both peers, a loopback pair negotiated in-process). +// Skipped, with the reason, unless WRPC_WT=fails / WRPC_RTC=node-datachannel. +async function startWrpcOver(kind) { + const real = require('./real-stacks.js'); + const api = { bench: { echo: { handler: async (args) => args } } }; + const { server, port } = await createWrpcServer(api); + const wt = kind === 'wt' ? await real.bootWt(server) : null; + const connection = await real.connectOver(kind, server, { wt, port }); + await connection.client.load('bench'); + return { + call: (args) => connection.client.api.bench.echo(args), + stop: async () => { + connection.close(); + await wt?.stop(); + await server.close(); + if (kind === 'webrtc') real.rtcCleanup(); + }, + }; +} + +const realSkip = (name) => () => require('./real-stacks.js')[name](); + +// Raw echoes over the same two native stacks, no RPC layer — what separates +// wrpc's cost from the stack's own, as the raw ws/uws rows do for the +// WebSocket. The envelope and the correlation by id are the createEchoClient +// ones. A WebTransport stream is bytes, not messages, so the echo frames +// each message with a u32 length on one bidirectional stream; a data +// channel already carries messages. +const lengthFramed = (text) => { + const body = Buffer.from(text); + const out = Buffer.allocUnsafe(4 + body.length); + out.writeUInt32BE(body.length, 0); + body.copy(out, 4); + return out; +}; + +const readFramed = async (readable, onMessage) => { + const reader = readable.getReader(); + let held = Buffer.alloc(0); + for (;;) { + const { value, done } = await reader.read(); + if (done) return; + held = held.length === 0 ? Buffer.from(value) : Buffer.concat([held, value]); + while (held.length >= 4 && held.length >= 4 + held.readUInt32BE(0)) { + const length = held.readUInt32BE(0); + onMessage(held.subarray(4, 4 + length).toString()); + held = held.subarray(4 + length); + } + } +}; + +const correlate = (send) => { + const pending = new Map(); + const onMessage = (text) => { + const { id, args } = JSON.parse(text); + const resolve = pending.get(id); + if (!resolve) return; + pending.delete(id); + resolve(args); + }; + const call = (args) => + new Promise((resolve) => { + const id = randomUUID(); + pending.set(id, resolve); + send(JSON.stringify({ type: 'call', id, method: 'bench/echo', args })); + }); + return { onMessage, call }; +}; + +async function startWtRaw() { + const { bootH3 } = require('./real-stacks.js'); + const { h3, port, WebTransport, serverCertificateHashes } = await bootH3(); + const serve = async (session) => { + await session.ready; + const streams = session.incomingBidirectionalStreams.getReader(); + for (;;) { + const { value: stream, done } = await streams.read(); + if (done) return; + const writer = stream.writable.getWriter(); + readFramed(stream.readable, (text) => { + const { id, args } = JSON.parse(text); + writer.write(lengthFramed(JSON.stringify({ id, args }))); + }).catch(() => {}); + } + }; + const sessions = h3.sessionStream('/echo').getReader(); + (async () => { + for (;;) { + const { value: session, done } = await sessions.read(); + if (done) return; + serve(session).catch(() => {}); + } + })().catch(() => {}); + const transport = new WebTransport(`https://127.0.0.1:${port}/echo`, { serverCertificateHashes }); + await transport.ready; + const stream = await transport.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + const { onMessage, call } = correlate((text) => writer.write(lengthFramed(text))); + readFramed(stream.readable, onMessage).catch(() => {}); + return { + call, + stop: async () => { + transport.close(); + await h3.stopServer(); + }, + }; +} + +async function startRtcRaw() { + const real = require('./real-stacks.js'); + const pair = await real.rtcPair(); + pair.host.addEventListener('message', ({ data }) => { + const { id, args } = JSON.parse(data); + pair.host.send(JSON.stringify({ id, args })); + }); + const { onMessage, call } = correlate((text) => pair.client.send(text)); + pair.client.addEventListener('message', ({ data }) => onMessage(data)); + return { + call, + stop: async () => { + pair.close(); + real.rtcCleanup(); + }, + }; +} + async function startWs() { const httpServer = http.createServer(); const wss = new WebSocket.Server({ server: httpServer }); @@ -221,12 +393,62 @@ async function startTrpcWs() { }; } +// gRPC — a unary echo over HTTP/2 through @grpc/grpc-js, the service loaded +// from bench/support/echo.proto at runtime by @grpc/proto-loader (no +// codegen). Protobuf on the wire, so its envelope is cheaper to encode than +// the JSON every other row pays; that is part of what gRPC is. +async function startGrpc() { + const path = require('node:path'); + const grpc = require('@grpc/grpc-js'); + const loader = require('@grpc/proto-loader'); + const definition = loader.loadSync(path.join(__dirname, 'echo.proto'), { keepCase: true }); + const { bench } = grpc.loadPackageDefinition(definition); + const server = new grpc.Server(); + server.addService(bench.Bench.service, { Echo: (call, callback) => callback(null, call.request) }); + const port = await new Promise((resolve, reject) => + server.bindAsync('127.0.0.1:0', grpc.ServerCredentials.createInsecure(), (error, bound) => + error ? reject(error) : resolve(bound), + ), + ); + const client = new bench.Bench(`127.0.0.1:${port}`, grpc.credentials.createInsecure()); + await new Promise((resolve, reject) => + client.waitForReady(Date.now() + 5000, (error) => (error ? reject(error) : resolve())), + ); + return { + call: (args) => + new Promise((resolve, reject) => client.Echo(args, (error, reply) => (error ? reject(error) : resolve(reply)))), + stop: async () => { + client.close(); + await new Promise((resolve) => server.tryShutdown(resolve)); + }, + }; +} + module.exports = { wrpc: { label: 'wrpc (own WS + RPC dispatch)', start: startWrpc }, + 'wrpc-uws': { label: 'wrpc (uws engine + RPC dispatch)', start: startWrpcUws }, + 'wrpc-batch': { label: 'wrpc (own WS, batch: true)', start: startWrpcBatch }, + 'wrpc-wt': { + label: 'wrpc (WebTransport, libquiche)', + start: () => startWrpcOver('wt'), + skip: realSkip('wtSkip'), + }, + 'wrpc-webrtc': { + label: 'wrpc (WebRTC data channel, libdatachannel)', + start: () => startWrpcOver('webrtc'), + skip: realSkip('rtcSkip'), + }, ws: { label: 'ws (raw echo RPC)', start: startWs }, uws: { label: 'uWebSockets.js (raw echo RPC)', start: startUws }, 'fastify-websocket': { label: '@fastify/websocket (raw echo RPC)', start: startFastifyWebsocket }, 'fastify-uws': { label: 'fastify-uws (raw echo RPC)', start: startFastifyUws }, + 'wt-raw': { label: 'WebTransport (raw echo RPC, libquiche)', start: startWtRaw, skip: realSkip('wtSkip') }, + 'webrtc-raw': { + label: 'WebRTC data channel (raw echo RPC, libdatachannel)', + start: startRtcRaw, + skip: realSkip('rtcSkip'), + }, 'socket.io': { label: 'socket.io (framework RPC via emitWithAck)', start: startSocketIo }, 'trpc-ws': { label: 'tRPC (framework RPC over wsLink)', start: startTrpcWs }, + grpc: { label: 'gRPC (@grpc/grpc-js unary over HTTP/2)', start: startGrpc }, }; diff --git a/bench/support/transport-api.js b/bench/support/transport-api.js new file mode 100644 index 0000000..baf4114 --- /dev/null +++ b/bench/support/transport-api.js @@ -0,0 +1,52 @@ +'use strict'; + +/** + * The server side every transport benchmark calls — bench/transports.js + * (through transport-worker.js) and the Chrome suite + * (scripts/bench-browser.js transports) — in the bench-local `api` + * shorthand wrpc-echo.js turns into a router: + * + * bench/echo answers its arguments + * bench/echoStream reads an upload to its end, then sends as many bytes + * back on a stream of its own (one round trip each way) + * bench/sink reads an upload to its end and answers its size + */ + +const CHUNK = 64 * 1024; +const BLOCK = new Uint8Array(CHUNK).fill(7); + +// Writes `size` bytes in CHUNK pieces, waiting for 'drain' whenever the +// transport did not take one — the pacing createBlobUploader() does. +async function pump(writable, size) { + for (let sent = 0; sent < size; sent += CHUNK) { + const piece = size - sent >= CHUNK ? BLOCK : BLOCK.subarray(0, size - sent); + if (!writable.write(piece)) { + if (writable.closed) throw new Error('the stream closed mid-upload'); + await new Promise((resolve) => writable.once('drain', resolve)); + } + } + writable.end(); +} + +const drain = async (readable) => { + let bytes = 0; + for await (const chunk of readable) bytes += chunk.length; + return bytes; +}; + +const api = { + bench: { + echo: { handler: async (args) => args }, + echoStream: { + handler: async ({ stream }, context) => { + const bytes = await drain(context.client.getStream(stream)); + const back = context.client.createStream('back', bytes); + pump(back, bytes); + return back.id; + }, + }, + sink: { handler: async ({ stream }, context) => drain(context.client.getStream(stream)) }, + }, +}; + +module.exports = { api, pump, CHUNK }; diff --git a/bench/support/transport-worker.js b/bench/support/transport-worker.js new file mode 100644 index 0000000..49cbd39 --- /dev/null +++ b/bench/support/transport-worker.js @@ -0,0 +1,162 @@ +'use strict'; +/** + * Runs bench/transports.js's scenarios for ONE transport kind — 'ws', 'wt' + * or 'webrtc' — in its own process, and prints its results as a + * `RESULT_JSON:` line for the orchestrator. One process per kind for the + * reason rpc-stack-worker.js gives: a native stack (libquiche, + * libdatachannel) is loaded only where it is measured, and a thread it + * holds past its close cannot keep the run alive. + * + * Every kind reaches the SAME Server (the wrpc-echo one) through the same + * client; only the transport between them differs. + */ +const { performance } = require('node:perf_hooks'); + +const { createWrpcServer } = require('./wrpc-echo.js'); +const { api, pump, CHUNK } = require('./transport-api.js'); +const real = require('./real-stacks.js'); + +const WARM_OPENS = 3; +const OPENS = 20; +const STREAM_BYTES = 16 * 1024 * 1024; +const LOAD_MS = 2000; +const STREAM_RUNS = 3; +const IDLE_CALLS = 500; + +const percentile = (sorted, p) => sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))]; +const round = (value, digits = 2) => Number(value.toFixed(digits)); + +async function main() { + const kind = process.argv[2]; + const skipped = kind === 'wt' ? real.wtSkip() : kind === 'webrtc' ? real.rtcSkip() : null; + if (skipped) { + console.log(`RESULT_JSON:${JSON.stringify({ kind, skipped })}`); + return; + } + const { server, port } = await createWrpcServer(api); + const wt = kind === 'wt' ? await real.bootWt(server) : null; + const open = () => real.connectOver(kind, server, { wt, port }); + const result = { kind }; + + // 1. Open, load, first call — what a page pays before its first answer. + // For webrtc it includes the pair's own offer/answer and ICE on loopback, + // the part a signaling server would relay in a real deployment. + { + const times = []; + for (let i = 0; i < WARM_OPENS + OPENS; i++) { + const start = performance.now(); + const connection = await open(); + await connection.client.load('bench'); + await connection.client.api.bench.echo({ i }); + if (i >= WARM_OPENS) times.push(performance.now() - start); + connection.close(); + } + times.sort((a, b) => a - b); + result.firstCall = { p50: round(percentile(times, 50)), p95: round(percentile(times, 95)) }; + console.log(`${kind}: open + load + first call p50 ${result.firstCall.p50} ms p95 ${result.firstCall.p95} ms`); + if (kind === 'webrtc') { + // How much of that is the pair itself: offer/answer, ICE and DTLS on + // loopback, with no wrpc on it yet. + const pairs = []; + for (let i = 0; i < OPENS; i++) { + const start = performance.now(); + const pair = await real.rtcPair(); + pairs.push(performance.now() - start); + pair.close(); + } + pairs.sort((a, b) => a - b); + result.pairOnly = { p50: round(percentile(pairs, 50)), p95: round(percentile(pairs, 95)) }; + console.log(`${kind}: of which the pair alone p50 ${result.pairOnly.p50} ms p95 ${result.pairOnly.p95} ms`); + } + } + + const connection = await open(); + const { client } = connection; + await client.load('bench'); + + // 2. A 16 MiB upload read back as a 16 MiB download. + { + const rates = []; + for (let run = 0; run < STREAM_RUNS; run++) { + const start = performance.now(); + const upload = client.createStream('blob', STREAM_BYTES); + const answer = client.api.bench.echoStream({ stream: upload.id }); + await pump(upload, STREAM_BYTES); + const backId = await answer; + let received = 0; + for await (const chunk of client.getStream(backId)) received += chunk.length; + if (received !== STREAM_BYTES) throw new Error(`${kind}: ${received} bytes came back`); + rates.push((2 * STREAM_BYTES) / (1024 * 1024) / ((performance.now() - start) / 1000)); + } + rates.sort((a, b) => a - b); + result.streamMiBps = round(rates[Math.floor(rates.length / 2)], 1); + // The same bytes over the bare stack, no wrpc: the stack's own ceiling. + const raw = []; + for (let run = 0; run < STREAM_RUNS; run++) { + const elapsed = await real.rawRoundTrip(kind, STREAM_BYTES, CHUNK); + raw.push((2 * STREAM_BYTES) / (1024 * 1024) / (elapsed / 1000)); + } + raw.sort((a, b) => a - b); + result.rawStreamMiBps = round(raw[Math.floor(raw.length / 2)], 1); + console.log( + `${kind}: 16 MiB up + 16 MiB down ${result.streamMiBps} MiB/s ` + + `(the bare stack: ${result.rawStreamMiBps} MiB/s; median of ${STREAM_RUNS})`, + ); + } + + // 3. A small call's latency, idle and while uploads keep the connection + // busy for LOAD_MS — 16 MiB uploads back to back, each paced by 'drain'. + // Over one ordered byte stream (a WebSocket's TCP) a call queues behind + // whatever chunks are already buffered; WebTransport gives each binary + // stream a QUIC stream of its own. + { + const sample = async (count, until) => { + const times = []; + while (until ? !until() : times.length < count) { + const start = performance.now(); + await client.api.bench.echo({ n: times.length }); + times.push(performance.now() - start); + } + return times.sort((a, b) => a - b); + }; + const idle = await sample(IDLE_CALLS); + let done = false; + let uploaded = 0; + const load = (async () => { + const deadline = performance.now() + LOAD_MS; + while (performance.now() < deadline) { + const upload = client.createStream('load', STREAM_BYTES); + const sunk = client.api.bench.sink({ stream: upload.id }); + await pump(upload, STREAM_BYTES); + if ((await sunk) !== STREAM_BYTES) throw new Error(`${kind}: the sink saw a short upload`); + uploaded += STREAM_BYTES; + } + })().finally(() => { + done = true; + }); + const loaded = await sample(0, () => done); + await load; + result.latency = { + idle: { p50: round(percentile(idle, 50), 3), p99: round(percentile(idle, 99), 3) }, + loaded: { p50: round(percentile(loaded, 50), 3), p99: round(percentile(loaded, 99), 3), calls: loaded.length }, + uploadMiB: uploaded / (1024 * 1024), + }; + console.log( + `${kind}: small call idle p50 ${result.latency.idle.p50} ms p99 ${result.latency.idle.p99} ms | ` + + `under upload load p50 ${result.latency.loaded.p50} ms p99 ${result.latency.loaded.p99} ms ` + + `(${loaded.length} calls while ${result.latency.uploadMiB} MiB went up)`, + ); + } + + connection.close(); + await wt?.stop(); + await server.close(); + if (kind === 'webrtc') real.rtcCleanup(); + console.log(`RESULT_JSON:${JSON.stringify(result)}`); + setTimeout(() => process.exit(), 2000).unref(); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/bench/transports.js b/bench/transports.js new file mode 100644 index 0000000..1b0a90f --- /dev/null +++ b/bench/transports.js @@ -0,0 +1,79 @@ +'use strict'; +/** + * wrpc over each transport, end to end: the same Server and the same client + * over a WebSocket (wrpc's own engine), WebTransport (a real HTTP/3 session + * — @fails-components/webtransport, libquiche on both ends) and a WebRTC + * data channel (node-datachannel — libdatachannel on both peers of a + * loopback pair). What bench/rpc-comparison.js's echo rows do not show: + * + * open + load + first call what a client pays before its first answer + * (for webrtc: the pair's own ICE and DTLS too, + * timed alone as well) + * 16 MiB up + 16 MiB down a binary stream round trip, next to the same + * bytes over the bare stack with no wrpc on it + * small call under load a call's latency idle, and while 16 MiB + * uploads keep the connection busy for 2 s + * + * The rows over a native stack run only when asked for — the gate the + * integration tests use (bench/support/real-stacks.js): + * + * node scripts/wt-cert.js certs + * WRPC_WT=fails WRPC_RTC=node-datachannel node bench/transports.js + * + * Without them those rows say why they were skipped and the run still + * exits 0 (bench/run-all.js counts any other exit as a broken benchmark). + * Loopback has no packet loss, so what QUIC does about loss is not in these + * numbers; read them as shapes on one machine, as everything in bench/. + */ +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const KINDS = ['ws', 'wt', 'webrtc']; +const WORKER = path.join(__dirname, 'support', 'transport-worker.js'); + +function run(kind) { + const result = spawnSync(process.execPath, [WORKER, kind], { encoding: 'utf8', timeout: 10 * 60 * 1000 }); + // A native stack's warnings repeat once per session; say each one once. + const warnings = new Map(); + for (const line of (result.stderr ?? '').split('\n')) { + if (line.trim()) warnings.set(line, (warnings.get(line) ?? 0) + 1); + } + for (const [line, count] of warnings) console.error(` [${kind}] ${line}${count > 1 ? ` (×${count})` : ''}`); + if (result.status !== 0) { + console.error(result.stdout); + throw new Error(`transport "${kind}" exited with status ${result.status ?? result.signal}`); + } + const lines = result.stdout.trim().split('\n'); + const resultLine = lines.find((line) => line.startsWith('RESULT_JSON:')); + for (const line of lines) if (line !== resultLine) console.log(line); + return JSON.parse(resultLine.slice('RESULT_JSON:'.length)); +} + +function main() { + console.log(`Node ${process.version} | ${new Date().toISOString()}\n`); + const results = []; + for (const kind of KINDS) { + const result = run(kind); + if (result.skipped) console.log(`${kind}: skipped — ${result.skipped}`); + else results.push(result); + } + if (results.length === 0) return; + console.log('\nSummary:\n'); + const header = ['transport', 'first call p50', 'stream MiB/s', 'bare stack', 'idle p50', 'loaded p50', 'loaded p99']; + const rows = results.map((r) => [ + r.kind, + `${r.firstCall.p50} ms`, + String(r.streamMiBps), + String(r.rawStreamMiBps), + `${r.latency.idle.p50} ms`, + `${r.latency.loaded.p50} ms`, + `${r.latency.loaded.p99} ms`, + ]); + const widths = header.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i].length))); + const line = (cells) => cells.map((cell, i) => cell.padStart(widths[i])).join(' '); + console.log(` ${line(header)}`); + for (const row of rows) console.log(` ${line(row)}`); + console.log(); +} + +main(); diff --git a/bench/wt-framing.js b/bench/wt-framing.js new file mode 100644 index 0000000..d68d9ce --- /dev/null +++ b/bench/wt-framing.js @@ -0,0 +1,133 @@ +'use strict'; + +/** + * WebTransport stream framing benchmark (src/webtransport/framing.js). + * + * Every wrpc packet and every stream chunk on a WebTransport session goes + * through frame()/frameText() on the way out and StreamParser on the way + * in, so the two choices the file makes are measured here rather than + * assumed: + * + * frame, text: (a) TextEncoder.encode() then copy into an exact frame + * (b) encodeInto() straight into a worst-case-sized frame — production + * under INLINE_TEXT (one allocation, up to 2/3 unused) + * parse: (c) one read per message — the packet path + * (d) a message split over 16 KiB reads — the chunk path + * (head index instead of shift(): what keeps it linear) + * + * Run: node bench/wt-framing.js (or as part of `pnpm bench`) + */ + +const { performance } = require('node:perf_hooks'); +const crypto = require('node:crypto'); + +const { + frame, + frameText, + StreamParser, + KIND_TEXT, + KIND_BINARY, + INLINE_TEXT, +} = require('../src/webtransport/framing.js'); + +const KIB = 1024; +const MIB = 1024 * 1024; + +const SIZES = [ + { label: '64 B', size: 64, iterations: 400_000 }, + { label: '1 KiB', size: KIB, iterations: 200_000 }, + { label: '16 KiB', size: 16 * KIB, iterations: 20_000 }, + { label: '1 MiB', size: MIB, iterations: 256 }, +]; + +const TEXT_ENCODER = new TextEncoder(); + +// (a) encode-then-copy — the alternative production rejected under INLINE_TEXT. +const frameTextViaBytes = (text) => frame(KIND_TEXT, TEXT_ENCODER.encode(text)); + +let sunk = 0; +const sink = (bytes) => { + sunk += bytes.length; +}; + +const bench = (fn, iterations) => { + const warmup = Math.max(1, iterations >> 3); + for (let i = 0; i < warmup; i++) fn(); + const start = performance.now(); + for (let i = 0; i < iterations; i++) fn(); + return iterations / ((performance.now() - start) / 1000); +}; + +const formatNumber = (num) => num.toLocaleString('en-US', { maximumFractionDigits: 2 }); +const formatOps = (ops) => { + if (ops >= 1_000_000) return `${formatNumber(ops / 1_000_000)}M`; + if (ops >= 1000) return `${formatNumber(ops / 1000)}K`; + return formatNumber(ops); +}; + +const row = (name, ops) => console.log(` ${name.padEnd(48)}${formatOps(ops).padStart(12)} ops/sec`); + +// Every strategy must round-trip byte-identically before being timed. +const verify = () => { + for (const size of [0, 1, 4095, 4096, 4097, 70_000]) { + const text = crypto.randomBytes(size).toString('base64').slice(0, size); + let out = null; + const parser = new StreamParser({ onMessage: (_kind, data) => (out = data) }); + for (const framed of [frameText(text), frameTextViaBytes(text)]) { + out = null; + parser.push(framed); + if (out !== text) throw new Error(`text round-trip failed at ${size}`); + } + const bytes = crypto.randomBytes(size); + out = null; + const framed = frame(KIND_BINARY, bytes); + for (let offset = 0; offset < framed.length; offset += 7) parser.push(framed.subarray(offset, offset + 7)); + if (out === null || Buffer.compare(Buffer.from(out), bytes) !== 0) { + throw new Error(`bytes round-trip failed at ${size}`); + } + } +}; + +const main = () => { + console.log(`WebTransport stream framing benchmark — Node ${process.version} (INLINE_TEXT ${INLINE_TEXT})`); + verify(); + for (const { label, size, iterations } of SIZES) { + const text = crypto + .randomBytes(Math.ceil(size / 2)) + .toString('hex') + .slice(0, size); + const bytes = crypto.randomBytes(size); + console.log(`\n-- ${label} (${formatNumber(iterations)} iterations)`); + row( + '(a) text: encode() then copy, exact frame', + bench(() => sink(frameTextViaBytes(text)), iterations), + ); + row( + `(b) text: encodeInto() worst-case frame${size <= INLINE_TEXT ? ' [prod]' : ''}`, + bench(() => { + const out = new Uint8Array(5 + text.length * 3); + const { written } = TEXT_ENCODER.encodeInto(text, out.subarray(5)); + sink(out.subarray(0, 5 + written)); + }, iterations), + ); + const parser = new StreamParser({ onMessage: sink }); + const whole = frame(KIND_BINARY, bytes); + row( + '(c) parse: one read per message', + bench(() => parser.push(whole), iterations), + ); + const reads = []; + for (let offset = 0; offset < whole.length; offset += 16 * KIB) { + reads.push(whole.subarray(offset, offset + 16 * KIB)); + } + row( + `(d) parse: split over 16 KiB reads (${reads.length})`, + bench(() => { + for (let i = 0; i < reads.length; i++) parser.push(reads[i]); + }, iterations), + ); + } + if (sunk < 0) console.log(sunk); // keep the sink observable +}; + +main(); diff --git a/bench/wt-streams.js b/bench/wt-streams.js new file mode 100644 index 0000000..613273b --- /dev/null +++ b/bench/wt-streams.js @@ -0,0 +1,123 @@ +'use strict'; + +/** + * The per-chunk paths of the WebTransport stream mux and the socket's idle + * timer (src/webtransport/streams.js, socket.js) — what bench/wt-framing.js, + * which stops at frame()/StreamParser, does not cover. Every binary stream + * chunk on a session with side streams takes them, so a change there is + * measured here before it is made: + * + * outbound (a) StreamMux.chunk(): the frame of a stream with its own + * WebTransport stream, routed to it — the whole call + * (b) what reading the chunk header a second time costs in + * it: readId() against `1 + frame[0]` + * inbound (c) chunkEncode(id, bytes): what a read off a side stream is + * re-framed with, per read — production + * (d) the same frame from a header cached per stream + * idle (e) clearTimeout + setTimeout + unref on every read — + * production + * (f) a timestamp per read and one timer that re-arms itself + * + * (b), (d) and (f) are the alternatives; the header says which of them the + * code took and by how much they won on THIS machine when it was written: + * + * (b) taken: chunk() decoded the id, and #route decoded it again for the + * offset alone — about 130 ns of a 1.1 µs call; the offset is one + * byte read. (a) went 867K -> 980K ops/sec at 1 KiB. + * (d) not taken: 13 % of the re-frame at 1 KiB, 7 % at 16 KiB — the + * payload copy is what the read costs, and removing THAT needs a seam + * in the dispatcher (a chunk handed over as id + payload), not a + * cache here. + * (f) not taken: about 95 ns a read against 40, on a path where the read + * itself is microseconds, behind an option that is off by default. + * + * (The harness times every call: about 70 ns of each row is its own.) + * + * Run: node bench/wt-streams.js (or as part of `pnpm bench`) + */ + +const crypto = require('node:crypto'); + +const { bench } = require('./support/harness.js'); +const { StreamMux, idHeader, readId } = require('../src/webtransport/streams.js'); +const { chunkEncode } = require('../src/chunks.js'); + +const ID = 'c0ffee00-1234-4abc-8def-0123456789ab'; +const SIZES = [ + ['1 KiB', 1024], + ['16 KiB', 16 * 1024], +]; + +// A session whose unidirectional streams take a write at once: the mux's own +// work is what is timed, not a sink. +const session = () => ({ + createUnidirectionalStream: async () => ({ + getWriter: () => ({ write: async () => {}, close: async () => {}, abort: async () => {} }), + }), + incomingUnidirectionalStreams: { getReader() {} }, +}); + +const openedMux = async () => { + const mux = new StreamMux(session(), { emitPacket() {}, emitChunk() {}, onQueued() {}, onSent() {} }); + mux.peerCaps('{"streams":true}'); + mux.control({ type: 'stream', id: ID, name: 'blob', size: 1 }); + // The side stream opens on a later turn. + await new Promise((resolve) => setImmediate(resolve)); + return mux; +}; + +let sunk = 0; + +async function main() { + const mux = await openedMux(); + console.log('outbound: one chunk frame routed to its side stream'); + for (const [label, size] of SIZES) { + const frame = chunkEncode(ID, crypto.randomBytes(size)); + await bench(` (a) mux.chunk(frame), ${label}`, () => { + if (!mux.chunk(frame)) throw new Error('not routed'); + }); + } + const frame = chunkEncode(ID, crypto.randomBytes(1024)); + await bench(' (b) readId(frame) — the header read again', () => { + sunk += readId(frame).offset; + }); + await bench(' (b) 1 + frame[0] — the offset alone', () => { + sunk += 1 + frame[0]; + }); + + console.log('\ninbound: one read off a side stream, re-framed for the dispatcher'); + const header = idHeader(ID); + for (const [label, size] of SIZES) { + const bytes = crypto.randomBytes(size); + await bench(` (c) chunkEncode(id, bytes), ${label} — production`, () => { + sunk += chunkEncode(ID, bytes).length; + }); + await bench(` (d) cached header + payload, ${label}`, () => { + const out = new Uint8Array(header.length + bytes.length); + out.set(header, 0); + out.set(bytes, header.length); + sunk += out.length; + }); + } + + console.log('\nidle timer: one read re-arming it'); + let timer = null; + const fire = () => {}; + await bench(' (e) clearTimeout + setTimeout + unref — production', () => { + clearTimeout(timer); + timer = setTimeout(fire, 60_000); + timer.unref(); + }); + clearTimeout(timer); + let seen = 0; + await bench(' (f) performance.now() into a field', () => { + seen = performance.now(); + }); + sunk += seen; + if (sunk < 0) throw new Error('unreachable'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/bench/zlib-async.js b/bench/zlib-async.js new file mode 100644 index 0000000..73cd5a5 --- /dev/null +++ b/bench/zlib-async.js @@ -0,0 +1,86 @@ +'use strict'; + +// zlib's synchronous convenience calls against its threadpool ones, on the +// per-message codecs' own shapes (src/compression/native.js, +// src/compression/dictionary.js): what a hand-off costs on a message that +// takes microseconds, and where it starts paying for itself. Serial async +// is one call at a time — the latency a single message sees; ×16 keeps +// sixteen in flight — the throughput a burst gets from the four threadpool +// threads. Inflate is measured too, because it is the half that never +// earns the hand-off: eight times faster than deflate, so the fixed cost +// dominates it at every size a message can be. + +const zlib = require('node:zlib'); +const { performance } = require('node:perf_hooks'); + +const rows = (n) => + Buffer.from( + JSON.stringify({ + type: 'callback', + id: 'c1', + result: Array.from({ length: n }, (_, i) => ({ + id: i, + name: `row-${i}`, + tags: ['a', 'b'], + score: i * 1.5, + ts: '2026-09-19T09:50:45.014Z', + })), + }), + ); + +const call = (fn, input) => + new Promise((resolve, reject) => fn(input, (error, out) => (error ? reject(error) : resolve(out)))); + +const serial = async (fn, input, iterations) => { + const started = performance.now(); + for (let i = 0; i < iterations; i++) await call(fn, input); + return ((performance.now() - started) * 1000) / iterations; +}; + +const concurrent = (fn, input, iterations, width = 16) => + new Promise((resolve) => { + const started = performance.now(); + let inflight = 0; + let done = 0; + const pump = () => { + while (inflight < width && done + inflight < iterations) { + inflight++; + fn(input, () => { + inflight--; + done++; + if (done === iterations) resolve(((performance.now() - started) * 1000) / iterations); + else pump(); + }); + } + }; + pump(); + }); + +const sync = (fn, input, iterations) => { + const started = performance.now(); + for (let i = 0; i < iterations; i++) fn(input); + return ((performance.now() - started) * 1000) / iterations; +}; + +const us = (value) => `${value.toFixed(1).padStart(8)} µs`; + +const main = async () => { + console.log('deflate-raw, µs per message: sync | async one at a time | async ×16 in flight · inflate sync | async'); + for (const n of [3, 30, 300, 3000, 12000]) { + const input = rows(n); + const iterations = input.length > 200_000 ? 100 : input.length > 20_000 ? 1_000 : 10_000; + const deflated = zlib.deflateRawSync(input); + const line = [ + `${String(input.length).padStart(9)} B`, + us(sync(zlib.deflateRawSync, input, iterations)), + us(await serial(zlib.deflateRaw, input, iterations)), + us(await concurrent(zlib.deflateRaw, input, iterations)), + ' · ', + us(sync(zlib.inflateRawSync, deflated, iterations)), + us(await serial(zlib.inflateRaw, deflated, iterations)), + ]; + console.log(` ${line.join(' ')}`); + } +}; + +main(); diff --git a/broker.d.ts b/broker.d.ts new file mode 100644 index 0000000..6e675ea --- /dev/null +++ b/broker.d.ts @@ -0,0 +1,619 @@ +/** + * `@alexify/wrpc/broker` — broker-agnostic messaging for wrpc. + * + * A broker is described by CAPABILITIES; each adapter implements the subset + * natural to it. Every concrete broker lives behind its own subpath + * (`@alexify/wrpc/broker/redis`, `/nats`, `/amqp`, `/kafka`) and takes its + * client by injection — nothing here depends on a broker package. + * + * @experimental The API may change in a minor release until every adapter + * has shipped. + */ + +import type { + WrpcLogger, + Context, + SubscriptionOptions, + Tracked, + ConsumePolicy, + Server, + RpcServer, + Validator, +} from './index.js'; + +export type { ConsumePolicy } from './index.js'; +import type { Backplane } from './scaling.js'; +import type { EnvelopeEncryptionOptions } from './encryption.js'; + +export type { Backplane } from './scaling.js'; + +export type MessageHeaders = Record; + +/** A read position was refused: 400 malformed or beyond the tip, 410 gone. */ +export interface BrokerError extends Error { + code: number; + expose?: boolean; +} + +// --------------------------------------------------------------------------- +// log + +export interface LogEntry { + /** The resume token after this entry — what a feed yields as its event id. */ + id: string; + value: string; + headers: MessageHeaders; +} + +export interface LogReadOptions { + /** Entries strictly after this id; 410 when trimmed or foreign, 400 when malformed or past the tip. */ + after?: string | null; + /** Without `after`: only new entries (default) or everything retained. */ + from?: 'latest' | 'earliest'; + signal?: AbortSignal | null; +} + +export interface LogRead extends AsyncIterable { + /** Resolves once the read position is fixed. */ + readonly ready: Promise; +} + +/** An ordered, replayable journal — what a durable feed reads from. */ +export interface BrokerLog { + name?: string; + append( + topic: string, + value: string | Uint8Array, + options?: { headers?: MessageHeaders; key?: string }, + ): Promise; + read(topic: string, options?: LogReadOptions): LogRead; + /** Syntax only: the id when well-formed, null otherwise. */ + parseId(text: unknown): string | null; +} + +// --------------------------------------------------------------------------- +// queue + +export interface Delivery { + readonly id: string; + readonly body: string; + readonly headers: MessageHeaders; + /** 1-based, counted by the adapter. */ + readonly attempt: number; + readonly redelivered: boolean; + ack(): Promise; + /** Redelivered no sooner than `delay` ms, attempt + 1. */ + retry(options?: { delay?: number }): Promise; + /** Back to the queue, attempt unchanged. */ + release(): Promise; + /** + * To the consumer's dead-letter queue, or dropped when it has none. The + * reason is folded to one line of at most 512 characters (control + * characters become spaces): a header value on every broker. + */ + deadLetter(reason?: string): Promise; +} + +export interface ConsumeOptions { + /** The broker-side consumer group; defaults to the queue name. */ + group?: string; + /** Unsettled deliveries at once, dispatched concurrently. Default 16. */ + prefetch?: number; + deadLetter?: string | null; + signal?: AbortSignal | null; +} + +export interface QueueConsumer { + /** No new deliveries; the unsettled ones stay settleable and are not handed back. */ + pause(): Promise; + resume(): Promise; + /** No new deliveries; the unsettled ones are redelivered later. */ + stop(): Promise; + readonly healthy: boolean; +} + +/** At-least-once work distribution between competing consumers. */ +export interface BrokerQueue { + name?: string; + produce( + queue: string, + body: string | Uint8Array, + options?: { headers?: MessageHeaders; key?: string }, + ): Promise; + consume(queue: string, onDelivery: (delivery: Delivery) => unknown, options?: ConsumeOptions): Promise; +} + +// --------------------------------------------------------------------------- +// direct + +export interface DirectMessage { + body: string | Uint8Array; + headers: MessageHeaders; + correlationId: string | null; + replyTo: string | null; +} + +export interface DirectSendOptions { + headers?: MessageHeaders; + correlationId?: string; + replyTo?: string; + /** A hint: the broker may discard a message nobody took within it. */ + timeout?: number; +} + +/** + * What `direct.listen()` resolves with: the function that stops the listener. + * An adapter that knows may also say whether the listener is still + * receiving. + */ +export interface DirectStop { + (): Promise; + /** + * `false` while the adapter KNOWS this listener is deaf — its read is + * failing, its consumer was cancelled, its channel or connection is gone. + * Optional: absent means the adapter cannot tell, and reads as healthy. + */ + readonly healthy?: boolean; +} + +/** Addressable inboxes — the substrate of RPC over a broker. At-most-once. */ +export interface BrokerDirect { + name?: string; + inbox(): string; + listen( + address: string, + onMessage: (message: DirectMessage) => unknown, + options?: { group?: string | null }, + ): Promise; + send(address: string, body: string | Uint8Array, options?: DirectSendOptions): Promise; +} + +// --------------------------------------------------------------------------- + +export interface Broker { + readonly name: string; + readonly backplane?: Backplane; + readonly log?: BrokerLog; + readonly queue?: BrokerQueue; + readonly direct?: BrokerDirect; + close(): void | Promise; +} + +export declare function isBroker(value: unknown): value is Broker; +export declare function isBrokerLog(value: unknown): value is BrokerLog; +export declare function isBrokerQueue(value: unknown): value is BrokerQueue; +export declare function isBrokerDirect(value: unknown): value is BrokerDirect; +export declare function isBackplane(value: unknown): value is Backplane; + +export interface MemoryBrokerOptions { + /** + * Mints every id this adapter puts on the wire — in practice just the `epoch`, since message ids and inboxes are counters off it. Used + * VERBATIM — wrpc never truncates it, so a generator answering characters + * the broker refuses in a name fails at the driver, not here. + * Strict: a non-function, or a function that does not answer a non-empty + * string of at most 255 characters, is a TypeError at construction. + */ + generateId?: () => string; + /** Backplane channel namespace; default 'wrpc'. */ + prefix?: string; + /** Defaults to the global console; `false` silences the broker. */ + logger?: WrpcLogger | boolean; + /** Stamped into every log id; random per instance by default. */ + epoch?: string; + retention?: { maxEntries?: number }; +} + +/** + * The in-process broker: the reference implementation of every capability. + * Two RpcServers sharing one behave like two processes sharing a real broker. + */ +export declare class MemoryBroker implements Broker { + constructor(options?: MemoryBrokerOptions); + readonly name: 'memory'; + readonly epoch: string; + readonly backplane: Backplane; + readonly log: BrokerLog; + readonly queue: BrokerQueue; + readonly direct: BrokerDirect; + /** Drops all but the newest `keep` entries of a topic. */ + trim(topic: string, keep: number): void; + close(): void; +} + +export declare function createMemoryBroker(options?: MemoryBrokerOptions): MemoryBroker; + +// --------------------------------------------------------------------------- +// Durable feeds + +export interface GapInfo { + /** What the client holds: the id it sent, or the last token the feed handed it. */ + lastEventId: string | null | undefined; + /** 400 — malformed, forged or past the tip; 410 — history gone or from another log. */ + code: 400 | 410; +} + +export interface BrokerFeedOptions { + /** A fresh subscription (no lastEventId) reads new entries (default) or everything retained. */ + from?: 'latest' | 'earliest'; + /** How an entry's string value becomes the yielded value. Default 'json'. */ + decode?: 'json' | 'text' | ((text: string) => Value); + /** Reshape a value; answer undefined to skip the entry. */ + map?: (value: Value, entry: LogEntry, context: Context) => Mapped | undefined | Promise; + /** + * An unusable lastEventId, or a reader the retention overtook. Answer a + * snapshot (a value, an iterable, an async iterable, or nothing); the feed + * then continues with everything appended from the moment of the gap. + * Without it the subscription ends with the coded error. + */ + onGap?: ( + context: Context, + args: any, + info: GapInfo, + ) => unknown | Iterable | AsyncIterable | Promise; + /** HMAC-sign the yielded ids, refusing any the feed never issued. */ + secret?: string; + /** Longer peer-supplied ids are refused with 400. Default 512. */ + maxIdLength?: number; + /** + * Opens what a publisher's `encryption` sealed; an entry that does not + * open is skipped and logged `broker.feed.refused`. + * + * The publisher, the feeds and the consumers of a topic take the same + * option (`@alexify/wrpc/encryption`): the value AND its headers are + * sealed, bound to the topic, as base64 text since every log keeps a + * string; the partition `key` stays readable, the broker routes by it. + * Off by default; rolled out like `rooms.encryption`, except that a + * retired key stays in the ring until the backlog sealed under it has + * drained. No replay window: a log is read again. + */ + encryption?: EnvelopeEncryptionOptions | false | null; +} + +/** + * A subscription handler reading a broker log: every value is `tracked()` + * with the log's resume token, so a client re-subscribing with + * `lastEventId` resumes there — on any instance. + * + * feed: procedure.subscription({ access: 'session', handler: brokerFeed(broker, 'orders') }) + */ +export declare function brokerFeed( + broker: Broker | BrokerLog, + topic: string | ((context: Context, args: any) => string | Promise), + options?: BrokerFeedOptions, +): (context: Context, args: any, subscription: SubscriptionOptions) => AsyncGenerator | unknown>; + +// --------------------------------------------------------------------------- +// Consumers and publishers + +/** A binding-table entry: an override for a declared consumer, or a queue bound to an ordinary procedure. */ +export interface ConsumerBinding extends ConsumePolicy { + /** 'unit.vN/method' — only for a key that is not a declared consumer. */ + target?: string; + /** + * For a binding on a dead-letter queue under `encryption`: the queue its + * messages were sealed for (the seal binds a message to its queue), so a + * re-drive opens them. Default: the bound queue itself. + */ + sealedFor?: string | null; +} + +export interface DeadLetterInfo { + queue: string; + method: string; + /** The wire code the call failed with (400 for an undecodable body). */ + code: number; + error: Error | null; + delivery: Delivery; + /** + * What a sealed delivery held — its opened body (text) and headers — or + * null for a plaintext delivery and for a sealed one that did not open. + * The dead letter itself is forwarded as it arrived, sealed: this is the + * one place the plaintext is in hand. Do not log it whole. + */ + opened: { headers: Record; body: string } | null; +} + +/** + * Opens one sealed log entry or delivery by hand — a dead letter, a row + * read off a topic — under the keyring that sealed it (`topic`: the queue + * or topic it was sealed for). `{ headers, body, sealed }` with the body as + * text, or `{ refused }` with the reason. + */ +export declare function openSealedMessage( + encryption: EnvelopeEncryptionOptions, + message: { topic: string; headers: Record | null | undefined; body: string | Uint8Array }, +): { headers: Record; body: string; sealed: boolean } | { refused: string }; + +export interface AttachConsumersOptions { + /** Bind every declared `consumes` procedure, not only the table's. Default true. */ + auto?: boolean; + /** Called before a message is dead-lettered — for alerting. */ + onDeadLetter?: (info: DeadLetterInfo) => unknown; + logger?: WrpcLogger | boolean | null; + /** + * Clients kept per `identity.trust: 'token'` binding (LRU; a positive + * integer, default 128). An evicted client finishes the deliveries it + * holds and closes after the last one — nothing is released. + */ + tokenClients?: number; + /** + * How long a cached token client is trusted before the token is presented + * to the session store again (ms; default 60 000; `0` keeps a client for + * the life of the binding). A logout or a rotation takes effect within + * one ttl; `forget(token)` makes it immediate. A restore that failed is + * never cached. + */ + tokenTtl?: number; + /** + * Opens what a publisher's `encryption` sealed, before anything reads the + * delivery — its headers may carry the credential a binding restores a + * session from. A delivery that does not open is dead-lettered (400) and + * logged `broker.refused`. + */ + encryption?: EnvelopeEncryptionOptions | false | null; +} + +export interface ConsumerBindingInfo { + key: string; + queue: string; + group: string; + method: string; + healthy: boolean; +} + +export interface ConsumersHandle { + readonly bindings: Array; + readonly healthy: boolean; + /** Also triggered by the server's `'draining'`. */ + pause(): Promise; + resume(): Promise; + /** + * Drops the cached client of a token (the `identity.header` value as + * messages carry it, `Bearer …`) across every binding — the logout hook. + * The next delivery under it attaches afresh; a client mid-delivery + * finishes first. True when any binding held one. + */ + forget(token: string): boolean; + /** Also triggered by the server's close(). */ + stop(): Promise; +} + +/** + * Delivers broker queues into procedures, at least once, through the same + * pipeline a call takes. Keys: 'unit.vN/source' overrides a declared + * consumer; any other key is a queue bound to `target`. + */ +export declare function attachConsumers( + server: Server | RpcServer, + broker: Broker | BrokerQueue, + table?: Record, + options?: AttachConsumersOptions, +): Promise; + +export interface PublishedEvent { + /** Default '.'. */ + topic?: string; + /** Default 'log' when the broker has one, 'queue' otherwise. */ + to?: 'log' | 'queue'; + /** A partition/ordering key, or how to derive one from the payload. */ + key?: string | ((data: any) => string); + validate?: Validator; +} + +export interface Publisher { + /** Resolves with the log id for a log target, undefined for a queue. */ + publish( + name: string, + data: unknown, + options?: { headers?: MessageHeaders; key?: string }, + ): Promise; + readonly events: Array; +} + +/** Publishes declared `emits` events; each name must be declared unless `strict: false`. */ +export declare function createPublisher( + server: Server | RpcServer, + broker: Broker | { name?: string; close(): unknown; log?: BrokerLog; queue?: BrokerQueue }, + table: Record, + options?: { + strict?: boolean; + /** + * Seals what rests in the topic under a shared keyring + * (`@alexify/wrpc/encryption`): the value AND its headers, bound to the + * topic — base64 text, as every log keeps a string. The publisher, the + * feeds and the consumers of a topic take the same option; the partition + * `key` stays readable, the broker routes by it. Off by default; rolled + * out like `rooms.encryption`. No replay window: a log is read again. + */ + encryption?: EnvelopeEncryptionOptions | false | null; + }, +): Publisher; + +// --------------------------------------------------------------------------- +// RPC over a broker + +export interface BrokerRpcOptions { + /** Served at `wrpc.` unless `address` names the address outright. */ + service?: string; + address?: string; + /** A session silent this long is ended; keep above the client heartbeat. Default 90 000. */ + idleTimeout?: number; + /** Unconfirmed frames per session before write() reports backpressure. Default 1024. */ + highWaterMark?: number; + /** + * Sessions this instance holds at once (default 10 000; `0` for no + * limit). A `hello` past it is answered `bye` ("too many sessions") and + * counted into one `broker.rpc.capacity` line per sweep. + */ + maxSessions?: number; + /** false serves stateless requests only. Default true. */ + sessions?: boolean; + logger?: WrpcLogger | boolean | null; + /** + * Per-message compression on the binding (src/compression), off by + * default. A session's frames both ways once the client named the same + * codec on `hello` (answered on `welcome`); a stateless answer when the + * request named it — the request itself always travels plain. Node↔Node, + * so the codec must answer synchronously (the platform one does). + */ + compression?: boolean | import('./client.js').CompressionOptions; + /** + * Seals every frame of the binding under a keyring the service and its + * clients share (`@alexify/wrpc/encryption`) — body AND headers, so the + * `authorization` a client presents no longer rests in the broker; only + * `wrpc-kind`, `wrpc-seq` and the key id stay readable, and they are bound + * into the seal with the address and the correlation id. A frame that does + * not open is dropped and logged `broker.rpc.refused`, never answered. A + * sealed binding is also what `encryption.required` on the server accepts. + * + * A sealed `request` or `hello` carries the sender's clock inside the + * seal: one older than `maxSkew` (default 5 minutes) is refused as + * `stale`. The envelope's replay window is per sender and per process, + * and the service address is consumed by every instance, so behind more + * than one inject `replay` — a shared "seen once" memory, `seen(id, ttl)` + * as `createReplayCache()` of `@alexify/wrpc/encryption` answers it + * (`SET id 1 NX PX ttl` in Redis) — which refuses a frame another + * instance already took; one that cannot be asked serves nothing. + */ + encryption?: (EnvelopeEncryptionOptions & { maxSkew?: number; replay?: BrokerReplayMemory | null }) | false | null; + /** The largest inflated frame accepted (default 16 MiB); past it the session ends. */ + maxMessage?: number; +} + +/** A shared "seen once" memory: true for an id it was shown within `ttl` ms. */ +export interface BrokerReplayMemory { + seen(id: string, ttl: number): boolean | Promise; +} + +export interface BrokerRpcHandle { + /** The shared service address every instance consumes as a group. */ + readonly address: string; + /** This instance's own inbox, where its sessions' frames arrive. */ + readonly inbox: string; + readonly sessions: number; + /** + * `false` once stopped, and while either listener — the service address, + * this instance's inbox — says it is not receiving (`DirectStop.healthy`). + * A broker whose adapter reports nothing (memory, a custom one) is healthy + * until stopped. + */ + readonly healthy: boolean; + /** Also triggered by the server's close(); draining stops taking new work. */ + stop(): Promise; +} + +/** + * Serves wrpc over a broker's `direct` capability: stateless requests + * (answered like packet-mode HTTP by any instance) and full-protocol + * sessions (one instance each). Clients connect with + * `connect('broker://', { transport: 'broker', broker, mode })`. + */ +export declare function attachBrokerRpc( + server: Server | RpcServer, + broker: Broker | BrokerDirect, + options: BrokerRpcOptions, +): Promise; + +/** The client transport registered as `WrpcClient.transport.broker`. */ +export declare class ClientBrokerTransport { + constructor(url: string); + readonly url: string; + active: boolean; + persistent: boolean; + heartbeat: boolean; + mode: 'stateless' | 'session'; + /** + * Mints the session id and per-request correlation ids. Assigned by the + * owning `WrpcClient` from its own `generateId` option, the way `codec` + * and `log` are; set it yourself only when driving this transport + * standalone. The session id is the key the server holds this + * connection's frame state under. + */ + generateId: () => string; + open(options?: { + broker: Broker | BrokerDirect; + mode?: 'stateless' | 'session'; + address?: string; + requestTimeout?: number; + headers?: Record; + meta?: Record; + /** Per-message compression, off by default; on a session only once the server agreed on `welcome`. */ + compression?: boolean | import('./client.js').CompressionOptions; + /** The binding's sealing under a shared keyring — `{ keys, … }`, NOT the session object of `createEncryption()`. */ + encryption?: EnvelopeEncryptionOptions | false | null; + /** The largest inflated frame accepted (default 16 MiB). */ + maxMessage?: number; + }): Promise; + /** The codecs in effect — null until the two lists share one. */ + readonly compression: import('./client.js').NegotiatedCompression | null; + /** + * `false` is backpressure: `highWaterMark` frames are unconfirmed by the + * broker. `compress: false` sends this frame plain on a session that + * agreed on a codec. + */ + write(data: string | Uint8Array, options?: { compress?: boolean } | null): boolean | void; + close(): void; + terminate(): void; + on(event: string, listener: (...args: Array) => void): unknown; +} + +// --------------------------------------------------------------------------- +// Adapter building blocks + +export interface TailEntry { + value: string; + headers: MessageHeaders; + [position: string]: unknown; +} + +export interface TopicTailsOptions { + /** Starts one tail; resolves with the tip cursor once positioned. */ + live(topic: string, options: { signal: AbortSignal; onEntry(entry: Entry): void }): Promise; + /** Entries strictly after `after` (null: the oldest retained), at most `limit`. */ + range(topic: string, options: { after: Cursor | null; limit: number }): Promise>; + covered(cursor: Cursor, entry: Entry): boolean; + advance(cursor: Cursor | null, entry: Entry): Cursor; + highWaterMark?: number; + page?: number; + /** + * Hears of readers of `topic` that just fell `highWaterMark` entries + * behind its tail: their buffers were dropped and they catch up through + * `range()`. Called once per entry that made any reader fall, with how + * many did; never while everybody keeps up. A throw from it is ignored. + */ + onLag?: ((topic: string, readers: number) => void) | null; +} + +/** One live reader per topic, shared by every local read; see src/broker/tail.js. */ +export declare class TopicTails { + constructor(options: TopicTailsOptions); + readonly size: number; + read( + topic: string, + options?: { after?: Cursor | null; from?: 'latest' | 'earliest'; signal?: AbortSignal | null }, + ): AsyncIterable<{ id: Cursor; value: string; headers: MessageHeaders }> & { readonly ready: Promise }; + close(): void; +} + +export interface EncodeTokenOptions { + /** Tests ONE character; everything else is escaped. Default /[A-Za-z0-9_-]/. */ + safe?: RegExp; + /** Default '~'. */ + escape?: string; + /** Longer tokens are shortened with a SHA-256 digest. Default 200. */ + maxLength?: number; +} + +/** Maps an arbitrary name into one broker-safe token, injectively. */ +export declare function encodeToken(name: string, options?: EncodeTokenOptions): string; +export declare function toText(body: unknown): string; +export declare function toBytes(body: unknown): Uint8Array; +export declare function toHeaders(value: unknown): MessageHeaders; + +export interface RetryPolicy { + attempts: number; + backoff: { base: number; max: number; factor: number; jitter: boolean }; + retryOn: ReadonlyArray; +} + +export declare const DEFAULT_RETRY: Readonly; diff --git a/broker.js b/broker.js new file mode 100644 index 0000000..12fb525 --- /dev/null +++ b/broker.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('./src/broker/index.js'); diff --git a/broker/amqp.d.ts b/broker/amqp.d.ts new file mode 100644 index 0000000..96c574f --- /dev/null +++ b/broker/amqp.d.ts @@ -0,0 +1,39 @@ +/** + * `@alexify/wrpc/broker/amqp` — the RabbitMQ broker (AMQP 0-9-1): all four + * capabilities over an INJECTED amqplib connection (`amqplib` is a + * devDependency here, never a runtime one). + * + * @experimental Part of the `@alexify/wrpc/broker*` family. + */ + +import type { Broker } from '../broker.js'; +import type { WrpcLogger } from '../index.js'; + +/** The connection surface the adapter uses; an amqplib connection satisfies it. */ +export interface AmqpConnection { + createChannel(): Promise; + createConfirmChannel(): Promise; +} + +export interface AmqpBrokerOptions { + /** + * Mints every id this adapter puts on the wire — message ids and direct inbox queue names. Used + * VERBATIM — wrpc never truncates it, so a generator answering characters + * the broker refuses in a queue name fails at the driver, not here. + * Strict: a non-function, or a function that does not answer a non-empty + * string of at most 255 characters, is a TypeError at construction. + */ + generateId?: () => string; + connection: AmqpConnection; + /** Exchange, queue and routing-key namespace; default 'wrpc'. */ + prefix?: string; + logger?: WrpcLogger | boolean; + /** Work queues' `x-queue-type`; default 'quorum'. */ + queueType?: 'quorum' | 'classic'; + /** `x-expires` of a service group's shared queue, in ms. Default 60 000. */ + inboxTtl?: number; + /** `x-max-length-bytes` on a log's stream queue; 0 (default) never trims. */ + streamMaxBytes?: number; +} + +export declare function createAmqpBroker(options: AmqpBrokerOptions): Broker; diff --git a/broker/amqp.js b/broker/amqp.js new file mode 100644 index 0000000..c781461 --- /dev/null +++ b/broker/amqp.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('../src/broker/amqp/index.js'); diff --git a/broker/kafka.d.ts b/broker/kafka.d.ts new file mode 100644 index 0000000..979ccc0 --- /dev/null +++ b/broker/kafka.d.ts @@ -0,0 +1,65 @@ +/** + * `@alexify/wrpc/broker/kafka` — the Kafka broker: durable feeds and work + * queues over an INJECTED KafkaJS-shaped client (`kafkajs` or + * `@confluentinc/kafka-javascript`'s `.KafkaJS`), plus a caveated backplane. + * There is deliberately no `direct` capability — see the guide. + * + * @experimental Part of the `@alexify/wrpc/broker*` family. + */ + +import type { Broker } from '../broker.js'; +import type { WrpcLogger } from '../index.js'; + +/** The client surface the adapter uses; both KafkaJS-shaped clients satisfy it. */ +export interface KafkaClient { + producer(config?: unknown): any; + consumer(config?: unknown): any; + admin(config?: unknown): any; + /** Present on kafkajs' client only — how the flavor is detected. */ + logger?: () => unknown; +} + +export interface KafkaBrokerOptions { + /** + * Mints every id this adapter puts on the wire — the backplane and reader consumer group ids. Used + * VERBATIM — wrpc never truncates it, so a generator answering characters + * the broker refuses in a group id fails at the driver, not here. + * Strict: a non-function, or a function that does not answer a non-empty + * string of at most 255 characters, is a TypeError at construction. + */ + generateId?: () => string; + kafka: KafkaClient; + /** Detected from the client; force it when the detection is wrong. */ + flavor?: 'kafkajs' | 'confluent'; + /** Topic namespace; default 'wrpc'. */ + prefix?: string; + logger?: WrpcLogger | boolean; + /** Partitions for queue topics (the concurrency ceiling). Default 3. */ + partitions?: number; + /** Partitions for log topics; 1 (the default) keeps a feed globally ordered. */ + logPartitions?: number; + /** + * Replication factor of the topics the adapter creates. Default `-1`: the + * broker's own `default.replication.factor` (KIP-464, Kafka 2.4+). An + * existing topic keeps the factor it was created with. + */ + replicationFactor?: number; + backplane?: { topic?: string; partitions?: number }; + /** Cap on how long a retry's delay may wait in-process. Default 60 000. */ + maxRetryDelay?: number; + /** + * Catch-up pages of the log read at once (default 4). Each page is a + * consumer group of its own — a connection and a JoinGroup — so a resume + * storm queues here instead of opening hundreds together; identical + * pages (same topic, cursor and limit) are one read, shared. A positive + * integer. + */ + maxCatchUp?: number; +} + +/** The returned broker has `backplane`, `log` and `queue` — never `direct`. */ +export declare function createKafkaBroker(options: KafkaBrokerOptions): Broker; + +/** The resume token: `k1:=,…`, partitions sorted. */ +export declare function encodeVector(cursor: Record): string; +export declare function decodeVector(text: unknown): Record | null; diff --git a/broker/kafka.js b/broker/kafka.js new file mode 100644 index 0000000..eb6847c --- /dev/null +++ b/broker/kafka.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('../src/broker/kafka/index.js'); diff --git a/broker/nats.d.ts b/broker/nats.d.ts new file mode 100644 index 0000000..bc356da --- /dev/null +++ b/broker/nats.d.ts @@ -0,0 +1,60 @@ +/** + * `@alexify/wrpc/broker/nats` — the NATS broker: core subjects for the + * backplane and RPC, JetStream for durable feeds and queues. The connection + * and the JetStream factories are INJECTED (`@nats-io/transport-node` and + * `@nats-io/jetstream` are devDependencies here, never runtime ones). + * + * @experimental Part of the `@alexify/wrpc/broker*` family. + */ + +import type { Broker } from '../broker.js'; +import type { WrpcLogger } from '../index.js'; + +/** The connection surface the adapter uses; a nats.js connection satisfies it. */ +export interface NatsConnection { + publish(subject: string, data: string | Uint8Array, options?: { headers?: unknown; reply?: string }): void; + subscribe( + subject: string, + options?: { queue?: string; callback?: (error: Error | null, message: any) => void }, + ): { unsubscribe(): void }; + flush(): Promise; + isClosed?(): boolean; +} + +export interface NatsBrokerOptions { + /** + * Mints every id this adapter puts on the wire — the direct inbox subject, when `createInbox` is not injected. Used + * VERBATIM — wrpc never truncates it, so a generator answering characters + * the broker refuses in a subject fails at the driver, not here. + * Strict: a non-function, or a function that does not answer a non-empty + * string of at most 255 characters, is a TypeError at construction. + */ + generateId?: () => string; + nc: NatsConnection; + /** The `headers()` factory — a package export, not a method on the connection. */ + headers: () => any; + /** `jetstream(nc)`; omit it (and the manager) for a backplane + direct broker. */ + jetstream?: (nc: NatsConnection) => any; + /** `jetstreamManager(nc)`; comes with `jetstream`. */ + jetstreamManager?: (nc: NatsConnection) => Promise; + /** `createInbox()`; a uuid-based `_INBOX.` subject otherwise. */ + createInbox?: () => string; + /** Subject and stream-name namespace; default 'wrpc'. */ + prefix?: string; + logger?: WrpcLogger | boolean; + /** JetStream ack_wait for queue consumers, in ms. Default 30 000. */ + ackWait?: number; + /** + * `max_ack_pending` of a queue's durable consumer: the GROUP's cap on + * unacked messages, shared by every instance of the group. A consume + * binding's `prefetch` is each instance's own. Default: not set, so + * JetStream's own default applies. A durable that already exists keeps + * the value it was created with (logged `broker.nats.consumer.config`). + */ + maxAckPending?: number | null; + /** Extra stream configuration, merged into what the adapter creates. */ + stream?: { log?: Record; queue?: Record }; +} + +/** Without `jetstream`/`jetstreamManager` the broker has no `log` and no `queue`. */ +export declare function createNatsBroker(options: NatsBrokerOptions): Broker; diff --git a/broker/nats.js b/broker/nats.js new file mode 100644 index 0000000..20c7766 --- /dev/null +++ b/broker/nats.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('../src/broker/nats/index.js'); diff --git a/broker/redis.d.ts b/broker/redis.d.ts new file mode 100644 index 0000000..ac0fe97 --- /dev/null +++ b/broker/redis.d.ts @@ -0,0 +1,91 @@ +/** + * `@alexify/wrpc/broker/redis` — the Redis broker: all four capabilities + * over an INJECTED ioredis-shaped client. Valkey, KeyDB and Dragonfly speak + * the same commands and need no adapter of their own. + * + * @experimental Part of the `@alexify/wrpc/broker*` family. + */ + +import type { Broker } from '../broker.js'; +import type { WrpcLogger } from '../index.js'; + +/** + * The command surface the adapter uses; ioredis satisfies it as it is. + * + * The stream, sorted-set and `set` commands are typed as `(...args) => Promise` + * on purpose: ioredis declares each of them as a stack of overloads (one per + * argument spelling), and an overloaded method is not assignable to a single + * signature — the exact-argument spellings this interface used to carry made + * `createRedisBroker({ client: new Redis() })` a type error while it was the + * documented use. A tsd test holds the real `Redis` type against this one. + */ +export interface RedisBrokerClient { + xadd(...args: Array): Promise; + xrange(...args: Array): Promise; + xrevrange(...args: Array): Promise; + xread(...args: Array): Promise; + xreadgroup(...args: Array): Promise; + xgroup(...args: Array): Promise; + xack(...args: Array): Promise; + xdel(...args: Array): Promise; + xautoclaim(...args: Array): Promise; + xinfo(...args: Array): Promise; + zadd(...args: Array): Promise; + zrangebyscore(...args: Array): Promise; + zrem(...args: Array): Promise; + /** Prunes a service group's expired members; without it they expire with the key. */ + zremrangebyscore?(...args: Array): Promise; + publish(...args: Array): Promise | number; + subscribe(...args: Array): unknown; + unsubscribe?(...args: Array): unknown; + on(event: string, listener: (...args: Array) => void): unknown; + set(...args: Array): Promise; + exists(...args: Array): Promise; + del(...args: Array): Promise; + rpush(...args: Array): Promise; + blpop(...args: Array): Promise; + pexpire(...args: Array): Promise; + /** + * Runs a delayed retry's promotion (ZREM, then XADD) as one step. ioredis + * has it; a client without it — or a proxy that refuses scripts — takes + * the adapter's two-step path, which puts the entry back when the second + * step fails. + */ + eval?(...args: Array): Promise; + duplicate?(): RedisBrokerClient; + quit?(): Promise; + disconnect?(): void; +} + +export interface RedisBrokerOptions { + /** + * Mints every id this adapter puts on the wire — consumer names and direct inboxes. Used + * VERBATIM — wrpc never truncates it, so a generator answering characters + * the broker refuses in a consumer name or key fails at the driver, not here. + * Strict: a non-function, or a function that does not answer a non-empty + * string of at most 255 characters, is a TypeError at construction. + */ + generateId?: () => string; + /** The client every command runs on; never quit by close(). */ + client: RedisBrokerClient; + /** A second connection for subscriptions; `client.duplicate()` otherwise. */ + subscriber?: RedisBrokerClient; + /** Opens the extra connections blocking reads need; `client.duplicate()` otherwise. */ + connect?: () => RedisBrokerClient; + /** Key and channel namespace; default 'wrpc'. */ + prefix?: string; + logger?: WrpcLogger | boolean; + /** How long a blocking read parks before looping. Default 1000 ms. */ + blockMs?: number; + /** Idle time after which a stopped consumer's pending messages are claimed. Default 60 000 ms. */ + claimIdleMs?: number; + /** `XADD MAXLEN ~ n` on every log append; 0 (default) never trims. */ + maxLen?: number; + /** TTL of a group's delivery list and its presence key. Default 60 000 ms. */ + inboxTtl?: number; +} + +export declare function createRedisBroker(options: RedisBrokerOptions): Broker; + +/** Compares two stream ids (`-`) numerically. */ +export declare function compareIds(a: string, b: string): number; diff --git a/broker/redis.js b/broker/redis.js new file mode 100644 index 0000000..02cd61c --- /dev/null +++ b/broker/redis.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('../src/broker/redis/index.js'); diff --git a/client.d.ts b/client.d.ts index c5b3930..6f0d922 100644 --- a/client.d.ts +++ b/client.d.ts @@ -4,6 +4,8 @@ // re-exports all of it and adds the server surface on top; browser.d.ts // (the `browser` types condition) re-exports ONLY this. +import type { Encryption, EncryptionInfo } from './encryption.browser.js'; + /** * Structural view of a node Writable — what finalize()/pipe() need. Typed * structurally so this file stays free of node imports: a browser TS @@ -59,6 +61,83 @@ export type WrpcCodec = WrpcPacketCodec | { rest: WrpcRestCodec }; */ export declare function isCodec(value: unknown): value is WrpcCodec; +/** + * A per-message compression codec for the transports that have nothing + * under them (WebRTC data channels, WebTransport streams): `id` is what + * the two ends compare — a peer compresses only for a peer that named the + * same codec — and either method may answer a promise (a browser's + * CompressionStream can only). `decode` MUST stop at `maxOutput` bytes. + * The platform default is raw deflate (`'deflate-raw'`: node:zlib on + * Node, CompressionStream in a browser); `threshold` is the codec's own + * default for the size under which a message goes plain. + */ +export interface Compressor { + id: string; + threshold?: number; + /** + * The byte size from which `encode` answers a promise (the built-in + * codecs' `async` option), or null/absent for a synchronous codec. The + * Node↔Node carriers — the broker binding, the backplane envelopes, a + * Node WebSocket client — refuse a codec that declares it. + */ + readonly async?: number | null; + encode(bytes: Uint8Array): Uint8Array | Promise; + decode(bytes: Uint8Array, maxOutput: number): Uint8Array | Promise; +} +/** Structural check for the `Compressor` contract. */ +export declare function isCompressor(value: unknown): value is Compressor; + +/** + * A platform codec by name — the ids are CompressionStream's format names, + * so Node and a browser negotiate the same one. `'deflate-raw'` is what + * `compression: true` means and exists everywhere; `'zstd'` needs Node + * 22.15+ / 23.8+ (a TypeError otherwise) and `'brotli'`/`'zstd'` only some + * browsers (compression stays off in one that lacks the format). + */ +export type CompressionAlgorithm = 'deflate-raw' | 'brotli' | 'zstd'; + +/** + * The codecs in effect on a connection, by id: what this side sends with + * and what the peer does. The two may differ — each end sends with the + * first codec of its own list the other announced. + */ +export interface NegotiatedCompression { + readonly encode: string; + readonly decode: string; +} + +/** + * `compression: true | { codec, threshold, async }` — off by default. + * `true` takes the platform's raw deflate; `codec` names another platform + * codec (`'zstd'`, `'brotli'`) or injects one (`zstdCompressor({ level })`, + * `@alexify/wrpc/deflate` for a dictionary, your own); `threshold` is the byte size + * under which a message goes plain (the codec's own default, 1 KiB on Node + * and 4 KiB in a browser). `async` (Node, the platform codecs only — an + * injected codec takes it on its own factory) hands a message of + * `async.threshold` bytes or more (256 KiB) to zlib's threadpool instead + * of deflating it on the event loop: the hand-off costs ~20 µs a call, so + * below that size it only slows the message down (`bench/zlib-async.js`); + * inflate stays synchronous at every size. Accepted on the WebTransport + * and WebRTC carriers, which keep messages in order around a promise; + * the Node↔Node carriers refuse it. A platform with no native codec and + * nothing injected stays off. + */ +export interface CompressionOptions { + /** + * One codec, or a list in order of preference. Each end announces the + * ids it holds and a sender compresses with the FIRST codec of its own + * list the peer announced — so a list is the fallback (`['zstd', + * 'deflate-raw']` still compresses for a peer without zstd), the two + * directions choose independently, and a name the platform lacks is + * skipped in a list rather than refused. On the backplane, which + * negotiates nothing, an instance encodes with the head and decodes any + * codec on the list: a change of codec is a rollout, not an outage. + */ + codec?: CompressionAlgorithm | Compressor | ReadonlyArray; + threshold?: number; + async?: boolean | { threshold?: number }; +} + export class WrpcError extends Error { code: number; /** Structured issue lists the server attached; an optional wire field. */ @@ -101,7 +180,8 @@ export class WrpcReadable extends Emitter { export interface Transport { send(obj: object): void; - write(data: string | ArrayBufferView): void; + /** `false` means above the high-water mark: wait for 'drain'. `void` counts as accepted. */ + write(data: string | ArrayBufferView): boolean | void; } export class WrpcWritable extends Emitter { @@ -109,7 +189,11 @@ export class WrpcWritable extends Emitter { name: string; size: number; transport: Transport; - /** True once the transport closed: write() reports false, no 'drain' follows. */ + /** + * True once the transport closed under an unfinished stream: write() reports + * false, no 'drain' follows. A stream already ended (or terminated), with no + * 'drain' still owed, stops watching the transport and stays false. + */ readonly closed: boolean; constructor(id: string, name: string, size: number, transport: Transport); init(): void; @@ -120,6 +204,7 @@ export class WrpcWritable extends Emitter { export interface BlobUploader { id: string; + /** Feeds the blob into its stream. Once: a second call rejects — create another uploader. */ upload(): Promise; } @@ -142,18 +227,129 @@ export interface WrpcMethodInfo { } declare class ClientTransport extends Emitter { + /** + * @experimental How this transport carries `options.encryption`, checked + * for every candidate before anything opens — plaintext is never a + * fallback. `true`: a session transport (ws, wt), which runs the + * handshake inside `open()` and MUST have `encryption` set by the time it + * emits `'open'` — the client closes one that has not. `'request'`: each + * request is sealed on its own (http, sse), which needs an option made + * with a `serverKey`. `'keys'`: a keyring over a shared carrier (the + * broker transport). Absent or `false`: the option is refused. + */ + static encrypts?: boolean | 'request' | 'keys'; + /** @experimental The facts of the encrypted session once established — a `static encrypts = true` transport sets it before `'open'`. */ + encryption?: EncryptionInfo | null; + /** + * The protocol revision this connection speaks: 2 by default (the carriers + * 1.0 never had); a transport a 1.0 peer can be behind starts at 1 and + * raises it once its peer said 2. A packet holding bytes leaves as a + * framed message only at 2. + */ + revision: 1 | 2; url: string; active: boolean; + /** Stays open (WebSocket, port, SSE, data channel): carries subscriptions, cancel and streams. */ + persistent: boolean; + /** Opts into the client's app-level ping/pong; the client owns the timers. */ + heartbeat: boolean; constructor(url: string); open(options?: WrpcClientOptions): Promise; close(): void; + /** Synchronous, unconditional: flips `active` and emits 'close' without a peer handshake. */ + terminate(): void; send(obj: object): void; - write(data: string | ArrayBufferView): void; + /** Returns the flow-control signal when the wire has one (`false` = above the high-water mark, then 'drain'). */ + write(data: string | ArrayBufferView): boolean | void; + /** + * @experimental A packet as ONE unreliable datagram, where the wire has + * them: true when it went out, false when it could not (no datagrams, + * too large) — the core then sends it reliably. Only the `wt` transport + * implements it. + */ + writeUnreliable?(data: string): boolean; + /** @experimental The largest datagram the wire carries; 0 or absent when it carries none. */ + maxDatagramSize?: number; online(): void; offline(): void; } export type { ClientTransport }; +/** @experimental Delivery options of a fire-and-forget event. */ +export interface SendEventOptions { + /** + * Send as an unreliable datagram where the transport has them + * (WebTransport) — lossy and unordered, for state a later event + * supersedes: a cursor, a position. Reliable everywhere else, and where + * the packet does not fit in one datagram. + */ + unreliable?: boolean; +} + +/** @experimental The `wt` option of connect(), and ClientWtTransport's constructor options. */ +export interface WtTransportOptions { + /** `new WebTransport(url, init)` — an implementation; the global one by default. */ + WebTransport?: new (url: string, init?: object) => unknown; + serverCertificateHashes?: Array<{ algorithm: string; value: BufferSource | string }>; + congestionControl?: 'default' | 'throughput' | 'low-latency'; + allowPooling?: boolean; + requireUnreliable?: boolean; + /** Application protocols to offer (`WT-Available-Protocols`), where the browser supports them. */ + protocols?: Array; + /** Outbound bytes queued on the control stream before write() answers false (default 1 MiB). */ + highWaterMark?: number; + /** Queue level under which 'drain' fires (default 256 KiB). */ + lowWaterMark?: number; + /** + * Outbound bytes queued before the session is terminated for a server + * that never drains (default 64 MiB; 0 = off) — the cap behind the + * high-water mark. + */ + maxBackpressure?: number; + /** The largest inbound message accepted (default 16 MiB); past it the session is hung up. */ + maxMessage?: number; + /** + * How long `close()` waits for the control stream to take what was + * already written before it closes the session (ms, default 1000). + */ + closeTimeout?: number; + /** + * @experimental Per-message compression on the control stream, off by + * default; connect()'s own `compression` wins over this one. Negotiated + * through the capabilities message: on only once the server named the + * same codec. + */ + compression?: boolean | CompressionOptions; +} + +/** + * @experimental The WebTransport client transport — `transport: 'wt'`, + * registered in the base entry. One client-opened bidirectional stream (the + * control stream) carries every packet and stream chunk under a + * length-prefixed framing; the session comes from `globalThis.WebTransport` + * or `options.wt.WebTransport`. Declared headers and meta ride the connect + * URL exactly as on ws (the CONNECT request cannot carry them), and cookies + * do not travel at all — a WebTransport CONNECT sends no credentials, so + * sessions need a bearer or payload token transport. + */ +declare class ClientWtTransport extends ClientTransport { + constructor(url: string, options?: WtTransportOptions); + /** The WebTransport session spoken on; null before open() and after close. */ + readonly session: unknown; + /** The codecs in effect — null until the two lists share one. */ + readonly compression: NegotiatedCompression | null; + /** The facts of this session's encryption once established, or null. */ + encryption: EncryptionInfo | null; + /** The largest datagram the session carries; 0 when it carries none. */ + readonly maxDatagramSize: number; + /** Bytes handed to the session and not yet taken by it; 0 between sessions. */ + readonly bufferedAmount: number; + writeUnreliable(data: string): boolean; +} +// A type, not a value: the class is reached through `WrpcClient.transport.wt` +// and connect({ transport: 'wt' }), never imported (the barrels export none). +export type { ClientWtTransport }; + export class WrpcClient extends Emitter { static connections: Set; static isOnline: boolean; @@ -173,9 +369,22 @@ export class WrpcClient extends Emitter { static transport: { ws: new (url: string) => ClientTransport; http: new (url: string) => ClientTransport; - event: { + /** The worker transport; `connect(url, { worker })` builds one per client. */ + event: (new (url: string) => ClientTransport) & { + /** + * @deprecated A class-level singleton that `connect()` no longer uses — + * each `connect({ worker })` builds its own transport, so the one + * returned here belongs to no client. Use `new WrpcClient.transport.event(url)`. + */ getInstance(url: string): ClientTransport; }; + /** Registered by `@alexify/wrpc/webrtc`; `connect('webrtc:', { transport: 'webrtc', link })`. */ + webrtc?: new (url: string, options?: object) => ClientTransport; + /** + * @experimental WebTransport (HTTP/3), in the base entry: `connect(url, + * { transport: ['wt', 'ws'], wt: { serverCertificateHashes } })`. + */ + wt: typeof ClientWtTransport; /** Late registration — how the sse subpath (and tests) add transports. */ [name: string]: unknown; }; @@ -192,6 +401,16 @@ export class WrpcClient extends Emitter { */ api: TypedApi; readonly active: boolean; + /** @experimental The facts of this connection's encrypted session, or null when it is not one. */ + readonly encryption: EncryptionInfo | null; + /** + * The protocol revision of the current connection (protocol.md#versioning): + * 2 when the peer reads framed messages, so bytes travel as bytes; 1 when + * it is a 1.0 peer — or this client opted out with `attachments: false`, or + * the transport is SSE, which is text-only — and bytes travel as the JSON + * 1.0 made of them (`{ "0": 137, … }`). + */ + readonly revision: 1 | 2; constructor( url: string, @@ -225,7 +444,11 @@ export class WrpcClient extends Emitter { createBlobUploader(blob: Blob): BlobUploader; send(obj: object): void; /** Fire-and-forget event to the server; `name` is 'unit/event'. */ - sendEvent | (string & {})>(name: Name, data?: ClientSendData): void; + sendEvent | (string & {})>( + name: Name, + data?: ClientSendData, + options?: SendEventOptions | null, + ): void; /** * Registers the answer this client gives when the server asks `name` * ('unit/event') — the receiving half of the server's `client.ask()` and @@ -241,7 +464,8 @@ export class WrpcClient extends Emitter { unrespond(name: string): boolean; /** Sends whatever calls are waiting to be batched. Safe to call anytime. */ flush(): void; - write(data: string | ArrayBufferView): void; + /** The transport's flow-control signal, passed through (see ClientTransport.write). */ + write(data: string | ArrayBufferView): boolean | void; } /** @@ -679,7 +903,7 @@ export interface WrpcClientOptions { * takes over ('transport-fallback' fires) and only the last exhausting * emits 'reconnect-failed'. No default order — the list is yours. */ - transport?: 'ws' | 'http' | 'sse' | string | Array; + transport?: 'ws' | 'http' | 'sse' | 'webrtc' | 'wt' | 'broker' | string | Array; reconnect?: ReconnectOptions | false; /** * Presents this connection's credential. Awaited inside `open()` on the @@ -708,14 +932,15 @@ export interface WrpcClientOptions { /** * Connection-phase headers, re-evaluated on every open (function form * included) so a reconnect presents fresh values. They ride as REAL - * request headers on http/sse, as one `wrpc_h` query parameter on the - * browser ws connect URL (the WHATWG WebSocket constructor takes no - * headers — note the URL lands in proxy access logs), and in the - * `wrpc:connect` message on the worker transport. The server surfaces - * them on `client.meta.headers` / `context.meta.headers`, observed - * headers winning, reserved names (cookie, host, origin, sec-*, - * content-*, proxy-*, x-wrpc-*) dropped from the query path. Labels, - * never credentials on the ws leg. + * request headers on http/sse and on ws from Node; on ws from a browser — + * whose WebSocket constructor takes no headers — as a `wrpc.h.` + * subprotocol offer, the one handshake header a page controls (see + * `carrier`); and in the `wrpc:connect` message on the worker transport. + * The server surfaces them on `client.meta.headers` / + * `context.meta.headers`, observed headers winning, and drops the names a + * page could forge from a declared bag (cookie, host, origin, forwarded, + * via, x-real-ip, sec-*, content-*, proxy-*, x-wrpc-*, x-forwarded-*). + * A `Bearer` authorization rides as `wrpc.bearer.`. * * Keys are normalized to kebab-case (`xAppVersion` -> `x-app-version`), so * one spelling addresses a value whatever carrier brought it — and that is @@ -726,9 +951,10 @@ export interface WrpcClientOptions { headers?: Record | (() => Record | Promise>); /** * Connection-phase metadata — headers' unvalidated sibling. Re-evaluated - * on every open; rides the `x-wrpc-meta` request header on http/sse, the - * `wrpc_meta` connect-URL parameter on ws, and the `wrpc:connect` message - * on the worker transport. Lands on `client.meta.data` server-side. Never + * on every open; rides the `x-wrpc-meta` request header on http/sse and + * on ws from Node, a `wrpc.m.` subprotocol offer on ws from a + * browser (see `carrier`), and the `wrpc:connect` message on the worker + * transport. Lands on `client.meta.data` server-side. Never * runs through schema validation — see the per-call twin in CallOptions. * * Keys are kebab-normalized like `headers`; values keep their JSON types @@ -747,17 +973,46 @@ export interface WrpcClientOptions { * JSON-stringified, null/undefined drop the key), and cross-origin * callers must name each key in `cors.metaHeaders`. * - * The choice changes the wire only on http/sse; ws and the worker - * transport have no headers and keep the one query parameter. What it - * changes everywhere is the VALUES: `'prefixed'` flattens on every + * The choice changes the wire wherever real headers travel — http, sse, + * ws from Node; a browser ws token and the worker message stay one JSON + * bag. What it changes everywhere is the VALUES: `'prefixed'` flattens on every * transport, so the bag the server observes never depends on the carrier. */ metaFormat?: 'json' | 'prefixed'; + /** + * How the declared `headers`/`meta` leave on the **ws** handshake. + * + * `'auto'` (default): real request headers where the platform's WebSocket + * can set them (Node), subprotocol carrier tokens where it cannot (a + * browser: `Sec-WebSocket-Protocol: wrpc.v2, wrpc.v1, wrpc.h., + * wrpc.m.`). A server that answers `wrpc.v1` to that may be a 1.0 + * server, which reads no token: the client then dials once more with the + * query, and keeps it for that connection's reconnects. + * Either way the connect URL — which lands in access logs — stays clean. + * + * `'protocol'` forces the tokens. `'query'` sends the `wrpc_h` / + * `wrpc_meta` connect-URL parameters instead — for an intermediary that + * mangles `Sec-WebSocket-Protocol`. An empty `protocols` offer means the + * query too (a token needs a protocol the server can answer next to it), + * so `'protocol'` with `protocols: []` is a TypeError. + * + * The two tokens share ONE 2048-byte budget, headers first; an oversize + * bag is dropped with a `meta.oversize` warning, never the connection. + */ + carrier?: 'auto' | 'protocol' | 'query'; /** * Pluggable query-string serializer (qs and friends) for mapped REST * requests over the http transport — mirror of the server's option. */ querystring?: { stringify(query: object): string }; + /** + * Fetch implementation for the http/sse transports; defaults to the + * runtime's own global `fetch`. Lets a Node server inject undici's + * `fetch` bound to a tuned `Agent`/`Pool` (keep-alive, proxying, a + * caching interceptor) for server-to-server wrpc traffic, without wrpc + * ever depending on undici. Re-resolved on every open, like `headers`. + */ + fetch?: typeof globalThis.fetch; /** * Client-side pre-validation: an injected ajv-shaped compiler applied to * the introspected input schema parts, so a doomed call rejects locally @@ -770,6 +1025,14 @@ export interface WrpcClientOptions { * the optional `rest` section frames REST-leg BODIES (values, not * packets — binary allowed). Either half alone is valid. */ + /** + * Binary attachments: bytes in args and results travel as bytes in one + * frame and arrive as Uint8Arrays — on a connection that negotiated + * protocol revision 2 (see `revision`); a 1.0 server is sent JSON. On by + * default; `false` makes this client speak revision 1 to every server + * (it offers `wrpc.v1` alone); off under a packet `codec`. + */ + attachments?: boolean; codec?: | { encode(packet: unknown): string; @@ -781,7 +1044,66 @@ 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; + /** + * Instead of a link: a data channel the application negotiated itself, + * or a factory asked for one on every (re)open (`@alexify/wrpc/webrtc`). + */ + channel?: import('./webrtc.browser.js').ChannelSource; + /** With `channel`: the message size to fragment at (default 16 KiB). */ + maxMessageSize?: number; + /** + * @experimental Options of the `wt` transport (WebTransport over HTTP/3): + * the `WebTransportOptions` handed to the constructor, plus the + * implementation to construct with where `globalThis.WebTransport` is not + * one — a Node client over an injected implementation, a fake in tests. + * Without either, open() throws and a fallback list moves on. + */ + wt?: WtTransportOptions; + /** + * @experimental Per-message compression of what this client sends — + * WebTransport, WebRTC over `channel` or `link`, the broker binding, and + * the WebSocket from Node (where the built-in WebSocket only inflates; + * negotiated over the first ping/pong, a no-op in a browser). Off by + * default; on only once the peer named the same codec (a raw WebRTC + * channel has no handshake: both applications turn it on). + */ + compression?: boolean | CompressionOptions; + /** + * @experimental Session encryption, from `createEncryption()` of + * `@alexify/wrpc/encryption`: a Noise handshake inside `open()`, then + * every frame sealed. A client that has one never speaks plaintext — a + * transport that cannot carry it (checked up front, the fallback list + * included) is a TypeError, a server that does not answer is a failed + * connection. With `worker`, it belongs to the `WrpcClientProxy` instead. + * The `broker` transport takes the KEYRING form instead — `{ keys, … }`, + * shared with the service (`EnvelopeEncryptionOptions`). + */ + encryption?: Encryption | { keys: unknown; [option: string]: unknown } | null; + /** + * @experimental The `broker` transport (`@alexify/wrpc/broker`, Node): a + * broker with the `direct` capability, or the capability itself. Typed + * structurally here so this browser-safe file never references the Node + * subpath; `Broker` in `@alexify/wrpc/broker` is the precise type. + */ + broker?: { direct?: object; inbox?: () => string } | object; + /** With `transport: 'broker'`: request/response (default) or a full-protocol session. */ + mode?: 'stateless' | 'session'; + /** With `transport: 'broker'`: the service address; default `wrpc.`. */ + address?: string; + /** With `transport: 'broker'`: how long the broker may hold a request nobody took (ms). */ + requestTimeout?: number; + /** @experimental With `transport: 'broker'`: the largest inflated frame accepted (default 16 MiB). */ + maxMessage?: number; /** * Off by default, unlike the server: a client that printed on every * reconnect would be noise in a browser console nobody asked for. A logger @@ -805,16 +1127,30 @@ export interface WrpcClientOptions { */ generateId?: () => string; /** - * WebSocket subprotocols to offer. Defaults to ['wrpc.v1'], which the - * server echoes back as the wire revision; an empty array offers nothing - * (the pre-versioning handshake). See protocol.md#versioning. + * WebSocket subprotocols to offer. Defaults to ['wrpc.v2', 'wrpc.v1'] — + * the revisions this client speaks, newest first (['wrpc.v1'] alone under + * `attachments: false` or a packet codec) — of which the server selects + * one: a 1.0 server selects 'wrpc.v1' and is then spoken to as 1.0 was + * (see `revision`). An empty array offers nothing (the pre-versioning + * handshake, revision 1) — and with nothing offered a browser's declared + * bags fall back to the connect-URL query, see `carrier`. + * See protocol.md#versioning. */ protocols?: Array; 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; } @@ -892,15 +1228,34 @@ export interface WrpcLogWriter { export interface WrpcSpan { setAttribute?(key: string, value: unknown): unknown; - addEvent?(name: string, attributes?: Record): unknown; - recordException?(error: unknown): void; - setStatus?(status: { code: number; message?: string }): unknown; - end(): void; + // The optional parameters are `unknown`, not a narrower guess. A real + // OTel `Span.addEvent` takes `Attributes | TimeInput` there and a real + // `setStatus` takes a `SpanStatus`, and a structural view that describes + // them more narrowly than they are does not merely lose precision — it + // makes the genuine article UNASSIGNABLE, which is the one thing this + // view exists to allow. wrpc only ever calls these, never implements + // them, so nothing here needs the narrower shape. + addEvent?(name: string, attributes?: unknown, startTime?: unknown): unknown; + recordException?(error: unknown, time?: unknown): void; + setStatus?(status: unknown): unknown; + end(endTime?: unknown): void; } export interface WrpcTracer { startSpan?(name: string, options?: unknown): WrpcSpan; - startActiveSpan?(name: string, options: unknown, fn: (span: WrpcSpan) => T): T; + /** + * A UNION of the two shapes, because wrpc calls both and a tracer + * implements one: with a parent context when the implementation declares + * four parameters, without one otherwise (a 3-argument implementation + * handed four arguments never runs its callback at all, so the arity is + * checked, not assumed). Declaring only the 3-argument form let a + * hand-written parented tracer type-check and then break at runtime; an + * overload pair would have demanded both shapes from one implementation, + * which no real tracer provides. + */ + startActiveSpan?: + | ((name: string, options: unknown, fn: (span: WrpcSpan) => T) => T) + | ((name: string, options: unknown, parent: unknown, fn: (span: WrpcSpan) => T) => T); } export interface WrpcCounter { diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..28f4896 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,68 @@ +# Local infrastructure for the pieces of the test and bench suites that need +# a real broker. Every consumer of it is gated on an env var and skips +# without one, so `pnpm test` never needs any of this. +# +# pnpm redis:up +# REDIS_URL=redis://127.0.0.1:6379 node --test tests/scaling/redis.integration.test.js +# REDIS_URL=redis://127.0.0.1:6379 node bench/cluster-nodes.js +# pnpm redis:down +# +# The message-broker family (`@alexify/wrpc/broker*`) adds RabbitMQ, Kafka and +# NATS; `pnpm brokers:up` (docker compose up -d) starts all four: +# +# AMQP_URL=amqp://127.0.0.1:5672 (management UI on http://127.0.0.1:15672, guest/guest) +# KAFKA_BROKERS=127.0.0.1:9092 (single-node KRaft) +# NATS_URL=nats://127.0.0.1:4222 (JetStream enabled; monitoring on :8222) +# +# No volumes: nothing here is worth keeping between runs. +services: + redis: + image: redis:7.4-alpine + ports: + - '127.0.0.1:6379:6379' + command: ['redis-server', '--save', '', '--appendonly', 'no'] + + rabbitmq: + image: rabbitmq:4.1-management-alpine + ports: + - '127.0.0.1:5672:5672' + - '127.0.0.1:15672:15672' + healthcheck: + test: ['CMD', 'rabbitmq-diagnostics', '-q', 'ping'] + interval: 5s + timeout: 5s + retries: 20 + + kafka: + image: apache/kafka:3.9.1 + ports: + - '127.0.0.1:9092:9092' + environment: + KAFKA_NODE_ID: 1 + KAFKA_PROCESS_ROLES: broker,controller + KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://127.0.0.1:9092 + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT + KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 + KAFKA_NUM_PARTITIONS: 3 + + nats: + image: nats:2.11-alpine + ports: + - '127.0.0.1:4222:4222' + - '127.0.0.1:8222:8222' + # JetStream on, monitoring on 8222 — the one place this is said: CI's + # nats job runs THIS service (GitHub's `services:` cannot pass a command, + # so a service container there would run without JetStream and the + # log/queue suites would skip green). + command: ['-js', '-m', '8222'] + healthcheck: + test: ['CMD', 'wget', '-q', '-O', '-', 'http://127.0.0.1:8222/healthz'] + interval: 5s + timeout: 3s + retries: 10 diff --git a/deflate.d.ts b/deflate.d.ts new file mode 100644 index 0000000..9a94f62 --- /dev/null +++ b/deflate.d.ts @@ -0,0 +1,59 @@ +// Types for the `@alexify/wrpc/deflate` subpath: a per-message DEFLATE codec +// in plain JavaScript — the one way a browser compresses against a preset +// dictionary (CompressionStream takes none), and synchronously. + +import type { Compressor } from './client.js'; + +export interface DeflateCodecOptions { + /** + * The preset dictionary both ends hold — `buildDictionary(router)`, or + * any bytes. Without one the codec is plain raw deflate under the + * platform id (`'deflate-raw'`), and the platform codecs read it. + */ + dictionary?: Uint8Array | string | null; + /** The size under which a message goes plain: 64 B with a dictionary, 1 KiB without. */ + threshold?: number; + /** + * Hand messages of `nativeAbove` bytes and more to the platform's + * CompressionStream (dynamic Huffman, no dictionary — a large payload has + * its history inside itself; the output still inflates on a peer holding + * the dictionary). On by default in a browser, off in Node, where every + * carrier may need a synchronous answer; when on, `encode` answers a + * promise for those messages. + */ + native?: boolean; + /** Default 4096. */ + nativeAbove?: number; + /** The LZ77 hash-chain depth, 1 (quick) to 9 (exhaustive). Default 6. */ + level?: number; +} + +/** The codec: inject it as `compression: { codec }` wherever a codec goes. */ +export declare function createDeflateCodec( + options?: DeflateCodecOptions, +): Compressor & { readonly dictionary: Uint8Array | null }; + +/** + * Raw DEFLATE → bytes, synchronously: stored, fixed and dynamic blocks, + * `dictionary` preloading the window, `maxOutput` the cap past which a + * DeflateError('too-large') is thrown rather than a bigger allocation. + */ +export declare function inflateRaw( + input: ArrayBuffer | ArrayBufferView, + options?: { dictionary?: ArrayBuffer | ArrayBufferView | null; maxOutput?: number }, +): Uint8Array; + +/** + * Bytes → raw DEFLATE, synchronously, as one fixed-Huffman block (or stored + * blocks when smaller); `dictionary` as zlib's option, `level` the + * hash-chain depth. + */ +export declare function deflateRaw( + input: ArrayBuffer | ArrayBufferView, + options?: { dictionary?: ArrayBuffer | ArrayBufferView | null; level?: number }, +): Uint8Array; + +/** Every malformed input is one of these, with a code: truncated, huffman, stored, distance, block, too-large. */ +export declare class DeflateError extends Error { + readonly code: string; +} diff --git a/deflate.js b/deflate.js new file mode 100644 index 0000000..b18ffc5 --- /dev/null +++ b/deflate.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('./src/deflate/index.js'); diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 2366b6c..afb1dce 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -1,11 +1,11 @@ import { defineConfig } from 'vitepress'; import { withMermaid } from 'vitepress-plugin-mermaid'; -const ogTitle = 'wrpc — WebSocket RPC for Node.js and the browser'; +const ogTitle = 'wRPC — Web RPC for Node.js and the browser'; const ogDescription = - 'Fast, zero-dependency WebSocket-based RPC for Node.js and browsers: router and procedures, ' + - 'subscriptions with resume, rooms with a scaling backplane, binary streams with real backpressure, ' + - 'SSE, and a typed client — with no runtime dependencies.'; + 'wRPC (@alexify/wrpc) is a fast, zero-dependency Web RPC protocol for Node.js and browsers: one router ' + + 'over WebSocket, HTTP, SSE, WebTransport and WebRTC, plus Redis, NATS, RabbitMQ and Kafka — subscriptions ' + + 'with resume, rooms that scale, binary streams with real backpressure, compression, encryption and a typed client.'; const repo = 'https://github.com/Alexis-Technologies/wrpc'; const base = '/'; const hostname = 'https://wrpc.vercel.app/'; @@ -44,6 +44,24 @@ const keywords = [ 'pubsub', 'cluster', 'streams', + 'webrtc', + 'peer-to-peer', + 'data-channel', + 'webtransport', + 'http3', + 'quic', + 'message-broker', + 'broker', + 'kafka', + 'rabbitmq', + 'amqp', + 'nats', + 'jetstream', + 'redis-streams', + 'queue', + 'consumer', + 'event-driven', + 'durable-feed', ].join(', '); // schema.org structured data — helps search and AI engines understand the @@ -52,7 +70,7 @@ const jsonLd = { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: '@alexify/wrpc', - alternateName: 'wrpc', + alternateName: ['wRPC', 'Web RPC'], description: ogDescription, applicationCategory: 'DeveloperApplication', operatingSystem: 'Node.js >= 22, modern browsers', @@ -70,7 +88,7 @@ const jsonLd = { export default withMermaid({ ...defineConfig({ title: '@alexify/wrpc', - titleTemplate: ':title — wrpc', + titleTemplate: ':title — wRPC', description: ogDescription, lang: 'en-US', base, @@ -78,6 +96,37 @@ export default withMermaid({ lastUpdated: true, sitemap: { hostname }, + markdown: { + config: (md) => { + // VitePress builds a heading permalink's aria-label from the + // heading's RAW source, so a heading that carries a badge and an + // explicit id — `## WebRTC {#webrtc}` — + // was announced by a screen reader as its markup, tags and all. + // Rewritten once the anchors exist: the badge's text in + // parentheses, the id dropped, any other tag removed. + md.core.ruler.push('wrpc-permalink-label', (state) => { + for (const token of state.tokens) { + if (token.type !== 'inline' || !token.children) continue; + for (const child of token.children) { + if (child.type !== 'link_open' || child.attrGet('class') !== 'header-anchor') continue; + const label = child.attrGet('aria-label'); + if (label === null || !/[<{]/.test(label)) continue; + child.attrSet( + 'aria-label', + label + .replace(/\s*\{#[^}]*\}/g, '') + .replace(/]*\btext="([^"]*)"[^>]*>/g, '($1)') + .replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .replace(/\s+"$/, '"'), + ); + } + } + return false; + }); + }, + }, + head: [ ['link', { rel: 'icon', type: 'image/svg+xml', href: `${base}logo-mark.svg` }], ['link', { rel: 'icon', type: 'image/png', href: `${base}favicon.png` }], @@ -126,7 +175,7 @@ export default withMermaid({ { text: 'Reference', link: '/reference/protocol', activeMatch: '/reference/' }, { // Hand-synced with package.json "version" — part of the release checklist. - text: 'v1.0.0', + text: 'v2.0.0', items: [ { text: 'Changelog', link: `${repo}/blob/main/CHANGELOG.md` }, { text: 'npm', link: 'https://www.npmjs.com/package/@alexify/wrpc' }, @@ -142,7 +191,7 @@ export default withMermaid({ text: 'Introduction', items: [ { text: 'Getting Started', link: '/guide/getting-started' }, - { text: 'Why wrpc?', link: '/guide/why' }, + { text: 'Why wRPC?', link: '/guide/why' }, ], }, { @@ -162,11 +211,25 @@ export default withMermaid({ { text: 'Cluster', link: '/guide/cluster' }, ], }, + { + text: 'Message brokers', + items: [ + { text: 'Overview & contracts', link: '/guide/brokers' }, + { text: 'Durable feeds', link: '/guide/brokers/feeds' }, + { text: 'Queue consumers & publishing', link: '/guide/brokers/consumers' }, + { text: 'RPC over a broker', link: '/guide/brokers/rpc' }, + { text: 'Redis', link: '/guide/brokers/redis' }, + { text: 'NATS', link: '/guide/brokers/nats' }, + { text: 'RabbitMQ', link: '/guide/brokers/amqp' }, + { text: 'Kafka', link: '/guide/brokers/kafka' }, + ], + }, { text: 'Client', items: [ { text: 'Client', link: '/guide/client' }, { text: 'Typed client', link: '/guide/typed-client' }, + { text: 'Multiple backends', link: '/guide/multiple-backends' }, { text: 'Browser & bundling', link: '/guide/browser' }, { text: 'Codegen CLI', link: '/guide/cli' }, { text: 'TanStack Query', link: '/guide/query' }, @@ -176,6 +239,9 @@ export default withMermaid({ text: 'Transports & hosts', items: [ { text: 'Server-Sent Events', link: '/guide/sse' }, + { text: 'WebRTC', link: '/guide/webrtc' }, + { text: 'WebRTC: identity and trust', link: '/guide/webrtc-trust' }, + { text: 'WebTransport', link: '/guide/wt' }, { text: 'Wire codec', link: '/guide/codec' }, { text: 'uWebSockets.js', link: '/guide/adapters/uws' }, { text: 'Fastify', link: '/guide/adapters/fastify' }, @@ -186,9 +252,12 @@ export default withMermaid({ text: 'Operations', items: [ { text: 'Security', link: '/guide/security' }, + { text: 'Rate limiting & throttling', link: '/guide/rate-limiting' }, { text: 'Running in production', link: '/guide/production' }, { text: 'Testing', link: '/guide/testing' }, { text: 'Performance', link: '/guide/performance' }, + { text: 'Compression', link: '/guide/compression' }, + { text: 'Encryption', link: '/guide/encryption' }, { text: 'Logging', link: '/guide/logging' }, { text: 'OpenTelemetry', link: '/guide/telemetry' }, ], diff --git a/docs/.vitepress/theme/benchmarks.json b/docs/.vitepress/theme/benchmarks.json new file mode 100644 index 0000000..672cedf --- /dev/null +++ b/docs/.vitepress/theme/benchmarks.json @@ -0,0 +1,486 @@ +{ + "rpc": { + "title": "RPC round trips per second, over a WebSocket", + "source": "bench/rpc-comparison.js", + "note": "Node v24.14.1 · Apple M3 Max · loopback · one run (3 October 2026)", + "page": "docs/guide/performance.md", + "metrics": [ + { + "key": "pipelined", + "label": "small, 64 in flight", + "unit": "calls/s", + "better": "higher" + }, + { + "key": "small", + "label": "small, one at a time", + "unit": "calls/s", + "better": "higher" + }, + { + "key": "large", + "label": "10 KB, one at a time", + "unit": "calls/s", + "better": "higher" + } + ], + "rows": [ + { + "id": "wrpc", + "label": "wRPC — own WebSocket", + "kind": "wrpc", + "small": 25213, + "large": 13986, + "pipelined": 116704 + }, + { + "id": "wrpc-uws", + "label": "wRPC — uws engine", + "kind": "wrpc", + "small": 26042, + "large": 13801, + "pipelined": 116174 + }, + { + "id": "wrpc-batch", + "label": "wRPC — batch: true", + "kind": "wrpc", + "small": 27078, + "large": 14027, + "pipelined": 201075 + }, + { + "id": "socket.io", + "label": "socket.io (emitWithAck)", + "kind": "other", + "small": 26213, + "large": 10862, + "pipelined": 101455 + }, + { + "id": "trpc", + "label": "tRPC (wsLink)", + "kind": "other", + "small": 628, + "large": 596, + "pipelined": 23310 + }, + { + "id": "grpc", + "label": "gRPC (grpc-js, HTTP/2)", + "kind": "other", + "small": 8127, + "large": 7844, + "pipelined": 20921 + }, + { + "id": "ws", + "label": "ws — raw echo, no RPC", + "kind": "other", + "small": 34987, + "large": 12206, + "pipelined": 112487 + }, + { + "id": "uws", + "label": "uWebSockets.js — raw echo", + "kind": "other", + "small": 31567, + "large": 12226, + "pipelined": 160872 + }, + { + "id": "fastify-websocket", + "label": "@fastify/websocket — raw echo", + "kind": "other", + "small": 37334, + "large": 12746, + "pipelined": 112133 + }, + { + "id": "fastify-uws", + "label": "fastify-uws — raw echo", + "kind": "other", + "small": 31572, + "large": 8702, + "pipelined": 160046 + } + ] + }, + "http": { + "title": "HTTP requests per second, a JSON echo", + "source": "bench/http-comparison.js", + "note": "Node v24.14.1 · Apple M3 Max · autocannon, 100 connections × 10 pipelined, 10 s · loopback · one run", + "page": "docs/guide/performance.md", + "metrics": [ + { + "key": "requests", + "label": "requests per second", + "unit": "req/s", + "better": "higher" + }, + { + "key": "latency", + "label": "mean latency", + "unit": "ms", + "better": "lower", + "digits": 1 + }, + { + "key": "p99", + "label": "p99 latency", + "unit": "ms", + "better": "lower" + } + ], + "rows": [ + { + "id": "wrpc-rest", + "label": "wRPC — declared REST route", + "kind": "wrpc", + "requests": 62236, + "latency": 15.5, + "p99": 29 + }, + { + "id": "wrpc-packet", + "label": "wRPC — packet mode", + "kind": "wrpc", + "requests": 60927, + "latency": 15.9, + "p99": 32 + }, + { + "id": "fastify", + "label": "fastify", + "kind": "other", + "requests": 69187, + "latency": 13.9, + "p99": 30 + }, + { + "id": "express", + "label": "express", + "kind": "other", + "requests": 50567, + "latency": 19.3, + "p99": 36 + }, + { + "id": "trpc", + "label": "tRPC (standalone adapter)", + "kind": "other", + "requests": 16523, + "latency": 59.9, + "p99": 85 + }, + { + "id": "node-http", + "label": "node:http — no framework", + "kind": "other", + "requests": 104404, + "latency": 8.9, + "p99": 16 + } + ] + }, + "fanout": { + "title": "Room fan-out, emits per second", + "source": "bench/send-path.js", + "note": "Node v24.14.1 · Apple Silicon · 512-byte payload · one run", + "page": "docs/guide/performance.md", + "metrics": [ + { + "key": "rate", + "label": "emits per second", + "unit": "emits/s", + "better": "higher" + }, + { + "key": "throughput", + "label": "bytes written", + "unit": "MB/s", + "better": "higher" + } + ], + "rows": [ + { + "id": "room-50", + "label": "room ×50", + "kind": "wrpc", + "rate": 539105, + "throughput": 14910 + }, + { + "id": "room-200", + "label": "room ×200", + "kind": "wrpc", + "rate": 208685, + "throughput": 23086 + }, + { + "id": "deflate-50", + "label": "room ×50 + deflate", + "kind": "wrpc", + "rate": 102914, + "throughput": 339 + }, + { + "id": "deflate-200", + "label": "room ×200 + deflate", + "kind": "wrpc", + "rate": 66083, + "throughput": 870 + }, + { + "id": "deflate-windows", + "label": "room ×50 + deflate, two windows", + "kind": "wrpc", + "rate": 53152, + "throughput": 175 + } + ] + }, + "transport-calls": { + "title": "RPC round trips per second, by transport (Node)", + "source": "bench/rpc-comparison.js", + "note": "Node v24.14.1 · Apple M3 Max · loopback · the same run as the WebSocket comparison", + "page": "docs/guide/performance.md", + "metrics": [ + { + "key": "small", + "label": "small, one at a time", + "unit": "calls/s", + "better": "higher" + }, + { + "key": "large", + "label": "10 KB, one at a time", + "unit": "calls/s", + "better": "higher" + }, + { + "key": "pipelined", + "label": "small, 64 in flight", + "unit": "calls/s", + "better": "higher" + } + ], + "rows": [ + { + "id": "ws", + "label": "wRPC — WebSocket", + "kind": "wrpc", + "small": 25213, + "large": 13986, + "pipelined": 116704 + }, + { + "id": "wt", + "label": "wRPC — WebTransport", + "kind": "wrpc", + "small": 10873, + "large": 2351, + "pipelined": 17054 + }, + { + "id": "wt-raw", + "label": "WebTransport — raw echo", + "kind": "other", + "small": 12810, + "large": 2396, + "pipelined": 18646 + }, + { + "id": "webrtc", + "label": "wRPC — WebRTC", + "kind": "wrpc", + "small": 7694, + "large": 1417, + "pipelined": 13666 + }, + { + "id": "webrtc-raw", + "label": "WebRTC — raw echo", + "kind": "other", + "small": 8114, + "large": 1415, + "pipelined": 13782 + } + ] + }, + "transport-node": { + "title": "Opening, streaming and a call under load, by transport (Node)", + "source": "bench/transports.js", + "note": "Node v24.14.1 · Apple M3 Max · loopback · one run", + "page": "docs/guide/performance.md", + "metrics": [ + { + "key": "loadedP99", + "label": "call under upload load, p99", + "unit": "ms", + "better": "lower", + "digits": 1 + }, + { + "key": "loadedP50", + "label": "call under upload load, p50", + "unit": "ms", + "better": "lower", + "digits": 1 + }, + { + "key": "stream", + "label": "16 MiB up + down", + "unit": "MiB/s", + "better": "higher", + "digits": 1 + }, + { + "key": "open", + "label": "open + load + first call", + "unit": "ms", + "better": "lower", + "digits": 1 + } + ], + "rows": [ + { + "id": "ws", + "label": "wRPC — WebSocket", + "kind": "wrpc", + "open": 1.1, + "stream": 575, + "loadedP50": 15.2, + "loadedP99": 30.0 + }, + { + "id": "wt", + "label": "wRPC — WebTransport", + "kind": "wrpc", + "open": 2.6, + "stream": 44.5, + "loadedP50": 15.3, + "loadedP99": 18.0 + }, + { + "id": "webrtc", + "label": "wRPC — WebRTC", + "kind": "wrpc", + "open": 507, + "stream": 35.2, + "loadedP50": 84, + "loadedP99": 154 + }, + { + "id": "ws-bare", + "label": "ws — bare stream", + "kind": "other", + "stream": 545 + }, + { + "id": "wt-bare", + "label": "WebTransport — bare stream", + "kind": "other", + "stream": 52.9 + }, + { + "id": "webrtc-bare", + "label": "WebRTC — bare stream", + "kind": "other", + "stream": 38.9 + } + ] + }, + "transport-chrome": { + "title": "wRPC from Chrome, by transport", + "source": "pnpm bench:browser transports", + "note": "Chrome 154 · Node v24.14.1 server · Apple M3 Max · loopback · one run", + "page": "docs/guide/performance.md", + "metrics": [ + { + "key": "loadedP50", + "label": "call under upload load, p50", + "unit": "ms", + "better": "lower", + "digits": 1 + }, + { + "key": "loadedP99", + "label": "call under upload load, p99", + "unit": "ms", + "better": "lower", + "digits": 1 + }, + { + "key": "small", + "label": "small calls", + "unit": "calls/s", + "better": "higher" + }, + { + "key": "large", + "label": "10 KB calls", + "unit": "calls/s", + "better": "higher" + }, + { + "key": "pipelined", + "label": "small, 64 in flight", + "unit": "calls/s", + "better": "higher" + }, + { + "key": "stream", + "label": "16 MiB up + down", + "unit": "MiB/s", + "better": "higher" + }, + { + "key": "open", + "label": "open + first call", + "unit": "ms", + "better": "lower", + "digits": 1 + } + ], + "rows": [ + { + "id": "ws", + "label": "WebSocket", + "kind": "wrpc", + "open": 12.5, + "small": 10789, + "large": 6009, + "pipelined": 44769, + "stream": 330, + "loadedP50": 46.3, + "loadedP99": 54.5 + }, + { + "id": "wt", + "label": "WebTransport", + "kind": "wrpc", + "open": 12.0, + "small": 8848, + "large": 3202, + "pipelined": 23806, + "stream": 79, + "loadedP50": 9.0, + "loadedP99": 11.4 + }, + { + "id": "webrtc", + "label": "WebRTC (both peers in the page)", + "kind": "wrpc", + "open": 5.4, + "small": 5353, + "large": 1020, + "pipelined": 10400, + "stream": 14, + "loadedP50": 40, + "loadedP99": 53 + } + ] + } +} diff --git a/docs/.vitepress/theme/components/BenchChart.vue b/docs/.vitepress/theme/components/BenchChart.vue new file mode 100644 index 0000000..e56b01b --- /dev/null +++ b/docs/.vitepress/theme/components/BenchChart.vue @@ -0,0 +1,291 @@ + + + + + diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css index 9052f7d..8de232f 100644 --- a/docs/.vitepress/theme/custom.css +++ b/docs/.vitepress/theme/custom.css @@ -141,3 +141,18 @@ fill: var(--vp-c-bg) !important; color: var(--vp-c-text-1); } + +/* ─── Comparison tables (the why page): every column readable ────────── + Five projects side by side do not fit the content column; rather than + squeeze each cell to a word per line, give every column a floor and let + the table scroll inside itself (VitePress tables are display: block with + overflow-x: auto), as it already does at phone width. */ +.vp-doc .compare table th, +.vp-doc .compare table td { + min-width: 11rem; +} +.vp-doc .compare table th:first-child, +.vp-doc .compare table td:first-child { + min-width: 8rem; + font-weight: 600; +} diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index 2cf5522..c1f99bb 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -1,5 +1,6 @@ import DefaultTheme from 'vitepress/theme'; import type { Theme } from 'vitepress'; +import BenchChart from './components/BenchChart.vue'; import './custom.css'; // Extends the default VitePress theme with our brand styling (see custom.css). @@ -7,7 +8,10 @@ import './custom.css'; // layout and components stay the default theme, the look comes from CSS variables. export default { extends: DefaultTheme, - enhanceApp({ router }) { + enhanceApp({ app, router }) { + // in any page — registered before the SSR guard + // below, so the charts are in the pre-rendered HTML too. + app.component('BenchChart', BenchChart); // Vercel Web Analytics + Speed Insights. The framework-agnostic `inject` // entrypoints are used rather than the `/vue` components: those require // `vue-router`, which VitePress does not use (it ships its own router). diff --git a/docs/guide/adapters/express.md b/docs/guide/adapters/express.md index c57496d..b1c7a9e 100644 --- a/docs/guide/adapters/express.md +++ b/docs/guide/adapters/express.md @@ -45,7 +45,7 @@ middleware is an ordinary function the app mounts; nothing under `src/` ever ## Middleware semantics A request **outside** `basePath` is passed to `next()` rather than answered -with a 404 — wrpc composes with the rest of your app instead of swallowing its +with a 404 — wRPC composes with the rest of your app instead of swallowing its routes. That is the whole difference from the batteries-included [`Server`](../server), which owns every request that reaches it. @@ -109,4 +109,4 @@ await new Promise((resolve) => httpServer.close(resolve)); ``` The listener is yours, so closing it is yours too — `wrpc.close()` only -releases what wrpc opened. +releases what wRPC opened. diff --git a/docs/guide/adapters/fastify.md b/docs/guide/adapters/fastify.md index c362c91..22372bf 100644 --- a/docs/guide/adapters/fastify.md +++ b/docs/guide/adapters/fastify.md @@ -11,7 +11,7 @@ await fastify.listen({ port: 8000 }); That is the whole integration. The plugin registers the RPC HTTP routes under `basePath`, attaches a WebSocket engine to whatever fastify is running on, and decorates the instance with `fastify.wrpc` — the `RpcServer` — so handlers -outside wrpc can reach rooms and clients: +outside wRPC can reach rooms and clients: ```js fastify.get('/admin/announce', async (request, reply) => { @@ -56,7 +56,7 @@ Everything [`RpcServer`](../server#rpc-options) takes, plus: | `ws` | Forwarded to the engine's `attach()` — `path`, `protocols`, `verifyClient`, … | | `maxBodySize` | A per-route `bodyLimit` for the RPC routes. | -`logger` defaults to `fastify.log`. That is a pino, which wrpc detects as a +`logger` defaults to `fastify.log`. That is a pino, which wRPC detects as a [structured logger](../logging) and calls natively — so your RPC entries land in fastify's own stream, with its bindings, and no adapter in between. @@ -72,7 +72,7 @@ security regression the app never asked for. ## Hooks run first HTTP calls arrive through fastify's own routes, so the app's `onRequest`, -`preHandler`, authentication and error handling all run **before** wrpc sees +`preHandler`, authentication and error handling all run **before** wRPC sees the call. That is the point of registering as a plugin rather than mounting a second server: @@ -84,19 +84,29 @@ fastify.addHook('onRequest', async (request) => { await fastify.register(wrpcFastify, { router }); ``` -The routes registered are `POST {basePath}`, `{basePath}/:unit/:method` and -`{basePath}/events` — the SSE endpoint is a static segment on purpose, so -find-my-way prefers it over the parametric route it would otherwise fall into. - -A path outside `basePath` gets **fastify's** 404, not wrpc's error packet. That +The routes registered are `POST {basePath}`, `{basePath}/:unit/:method`, +`{basePath}/events` and `{basePath}/encryption-key` — the SSE endpoint and the +[key discovery](../encryption) path are static segments on purpose, so +find-my-way prefers them over the parametric route they would otherwise fall +into. + +Two request bodies fastify does not parse reach those routes as raw bytes: an +[attachments](../streams#attachments) frame (`application/octet-stream` — a call whose +arguments hold bytes, between two 2.x ends) and a sealed request +(`application/wrpc-sealed`). The plugin registers a buffer parser for each, +**unless the app already has one** — fastify would otherwise answer `415` +before wRPC saw the call. An app that parses `application/octet-stream` +itself keeps its parser, which must then hand the body over as a `Buffer`. + +A path outside `basePath` gets **fastify's** 404, not wRPC's error packet. That difference is the whole reason to compose as a plugin. ## Declarative REST routes {#declarative-rest-routes} A procedure with an [`http` mapping](../rest) becomes a **native fastify -route** under this plugin — not a proxy into the wrpc dispatcher. Fastify +route** under this plugin — not a proxy into the wRPC dispatcher. Fastify owns routing, schema validation, serialization and (through -@fastify/swagger) documentation; wrpc supplies the per-request `Context` +@fastify/swagger) documentation; wRPC supplies the per-request `Context` (session, rooms, client lifecycle) and runs the bare handler under the procedure's own queue/timeout semantics. The internal API over HTTP is therefore the **same endpoint external consumers hit**. @@ -113,9 +123,9 @@ const router = defineRouter({ }); ``` -`schema` is forwarded to `fastify.route.schema` verbatim (plus wrpc's +`schema` is forwarded to `fastify.route.schema` verbatim (plus wRPC's [default error responses](../rest#default-error-responses)), so swagger -sees everything. wrpc's lifecycle hooks map onto fastify's phases by name — +sees everything. wRPC's lifecycle hooks map onto fastify's phases by name — the naming was fastify's to begin with — with the payload differences documented in the adapter source. @@ -141,9 +151,21 @@ Serve binary REST from a core host (the node shell, express, uws — all Buffer-native), or drop the `http` mappings under this plugin; `codec.rest` without mappings registers normally. +### Response headers and caching on delegated routes + +A mapped route's static `http.headers`, the `context.http` seam +(`setHeader`, `status`) and its `http.cache` policy apply on the delegated +path too, onto fastify's own `reply` — with the same session rule as the +core hosts (`private, no-store` for anything session-bearing). `ETag` and +`304` are left to fastify: register `@fastify/etag` for them. See the +[REST guide](../rest#caching). So is compression: a delegated route answers +through fastify's reply, where `@fastify/compress` does that job — +[`http.compression`](../server#compression) covers the packet endpoint and +the conventional REST paths under the plugin, like on every other host. + ## Mirroring existing routes {#mirroring-existing-routes} -The reverse direction: your **existing fastify routes** become wrpc +The reverse direction: your **existing fastify routes** become wRPC procedures, without rewriting any of them. ```js diff --git a/docs/guide/adapters/uws.md b/docs/guide/adapters/uws.md index 2a1ec21..4205603 100644 --- a/docs/guide/adapters/uws.md +++ b/docs/guide/adapters/uws.md @@ -59,7 +59,7 @@ createUwsEngine({ | `maxPayloadLength` | 16 MiB | Largest inbound message. | | `maxBackpressure` | uws default | Outbound buffer cap. | | `closeOnBackpressureLimit` | uws default | Close instead of dropping. | -| `compression` | `null` | A uws compressor constant; `null` disables permessage-deflate. | +| `compression` | `null` | A uws compressor constant (e.g. `uws.SHARED_COMPRESSOR`); `null` disables permessage-deflate. | | `sendPingsAutomatically` | uws default | uws' own protocol ping. | | `maxBodySize` | 10 MiB | Cap for HTTP bodies the engine reads. | @@ -70,14 +70,22 @@ engine in three ways: ```js engine.capabilities; -// { backpressure: true, ping: false, deflate: Boolean(compression), cork: true, pause: false } +// { backpressure: true, ping: false, deflate: Boolean(compression), cork: true, pause: false, prepared: false } ``` - **`ping: false`** — uws owns peer liveness itself, through `idleTimeout` and - `sendPingsAutomatically`, so wrpc runs no protocol-ping loop over it. The + `sendPingsAutomatically`, so wRPC runs no protocol-ping loop over it. The client's [application-level heartbeat](../client#heartbeat) is unaffected and still works. -- **`deflate`** follows `compression`, which is off by default. +- **`deflate`** follows `compression`, which is off by default. Pass a uws + compressor constant (`uws.SHARED_COMPRESSOR` is the one that, like the + built-in engine, keeps no per-connection context); the adapter asks uws to + compress every outbound message, and `send(data, { compress: false })` — + `emit(name, data, { compress: false })` on a room — opts one out. +- **`prepared: false`** — uws frames and compresses inside its own `send()`, + so a room broadcast reaches each member through `send(text)` rather than + the built-in engine's shared frame. uws' topic `publish()` is the + equivalent seam; mapping rooms onto it is future work. - **`pause: false`** — uws exposes no socket-level pause, so receive-side flow control is missing: a fast uploader is not throttled by a slow [stream](../streams) consumer the way it is on the built-in engine. Outbound @@ -88,7 +96,7 @@ See [the engine reference](../../reference/engine) for the full contract. ## Backpressure uws' `send()` answers with one of three statuses, and the adapter maps them -onto the boolean contract every wrpc transport speaks: +onto the boolean contract every wRPC transport speaks: | uws status | Meaning | `WrpcSocket.send()` | | --- | --- | --- | @@ -103,6 +111,18 @@ forever for an answer that was thrown away. The adapter refuses to continue: it raises an error and terminates the socket, which the client sees as a disconnect and recovers from by reconnecting. +## Request header limit + +uWebSockets.js allows **4096 bytes for all the headers of a request**, where +node allows 16 KB — and a WebSocket handshake is a request. A browser client +carries its declared [`headers`/`meta`](../metadata#choosing-the-ws-carrier-carrier) +in `Sec-WebSocket-Protocol`, capped at 2048 bytes for the two bags together, +but a Bearer token rides next to them outside that budget: a large JWT, two +full bags and the browser's own headers can reach the limit, and uws then +answers 431 — which a browser reports as a bare close `1006`, on every +reconnect. Keep the bags small, or raise the limit with the runtime +environment variable `UWS_HTTP_MAX_HEADERS_SIZE` before uws is loaded. + ## Stability `uWebSockets.js` is distributed from git rather than npm and ships a native diff --git a/docs/guide/auth.md b/docs/guide/auth.md index f108ab1..426fd2b 100644 --- a/docs/guide/auth.md +++ b/docs/guide/auth.md @@ -1,6 +1,6 @@ # Authentication -wrpc's auth story is three seams and one subpath that pre-wires them. This +wRPC's auth story is three seams and one subpath that pre-wires them. This page is the map; the deep material lives where each seam is defined — [Sessions](./sessions) for the server half, the [client guide](./client#authenticating) for the hooks. @@ -35,10 +35,12 @@ Three client options make it work end to end: cookie is the byte-identical default. Two ready-made strategies ship in **`@alexify/wrpc/auth`**: -- **`bearerTransport()`** — `Authorization: Bearer ` on http/sse; on - browser ws the token rides a `wrpc.bearer.` **subprotocol offer**, +- **`bearerTransport()`** — `Authorization: Bearer ` on http/sse and + on ws from Node; on browser ws the token rides a `wrpc.bearer.` **subprotocol offer**, a real upgrade header, so it never lands in the connect URL or the access - logs that keep URLs. + logs that keep URLs. Where the query IS the carrier — `carrier: 'query'`, + or WebTransport — a declared `authorization` header is sent as asked and + the client logs `declared.exposed` once, because that URL will be kept. - **`payloadTransport({ field })`** — a field of the declared [`meta`](./metadata) bag, either `x-wrpc-meta` spelling. diff --git a/docs/guide/brokers.md b/docs/guide/brokers.md new file mode 100644 index 0000000..2d54d72 --- /dev/null +++ b/docs/guide/brokers.md @@ -0,0 +1,226 @@ +# Message brokers + +[Scaling](./scaling) uses a broker as a **backplane**: a fan-out that makes a +room span every instance, at most once. That is the right contract for +presence and typing indicators, and the wrong one for everything a broker is +usually deployed *for* — work that must not be lost, history a client resumes +from, services talking to each other without HTTP between them. + +`@alexify/wrpc/broker` is the broker-agnostic core for those. It describes a +broker by what it can do, and every wRPC feature built on brokers asks only for +the capability it needs: + +| Capability | Guarantee | What wRPC builds on it | +| --- | --- | --- | +| `backplane` | at-most-once fan-out | [rooms](./rooms) and the [cluster](./cluster) across instances | +| `log` | ordered, replayable | [durable subscription feeds](./brokers/feeds) that resume on any instance | +| `queue` | at-least-once, competing consumers | [procedures invoked from a queue](./brokers/consumers), with retry and dead-lettering | +| `direct` | addressable inboxes | [RPC between services](./brokers/rpc) over the broker itself | + +::: warning Experimental +The whole `@alexify/wrpc/broker*` family is `@experimental`: it may change in a +minor release until every adapter has shipped. See +[Stability](../reference/stability#experimental-carve-outs). +::: + +## No broker is a dependency + +Like the [Redis backplane](./scaling#redis), every adapter takes **your** +client and checks its shape; `@alexify/wrpc` installs no broker package and +never will. Each broker gets its own subpath, so an application that uses +Kafka does not load the NATS adapter: + +| Broker | Subpath | backplane | log | queue | direct | +| --- | --- | :---: | :---: | :---: | :---: | +| in-process | `@alexify/wrpc/broker` | ✓ | ✓ | ✓ | ✓ | +| [Redis](./brokers/redis) (Streams + pub/sub) | `@alexify/wrpc/broker/redis` | ✓ | ✓ | ✓ | ✓ | +| [NATS](./brokers/nats) + JetStream | `@alexify/wrpc/broker/nats` | ✓ | ✓ | ✓ | ✓ | +| [RabbitMQ](./brokers/amqp) (AMQP 0-9-1) | `@alexify/wrpc/broker/amqp` | ✓ | ✓ | ✓ | ✓ | +| [Kafka](./brokers/kafka) | `@alexify/wrpc/broker/kafka` | caveats | ✓ | ✓ | — | + +Kafka has no `direct`: consumer-group rebalances and a topic per instance make +it a poor carrier for request/response. Its backplane works, with costs the +Kafka page spells out. Brokers that speak one of these protocols need no +adapter of their own — Valkey, KeyDB and Dragonfly use the Redis one; +Redpanda and Azure Event Hubs' Kafka endpoint use the Kafka one. + +## Choosing one + +| If you… | Use | +| --- | --- | +| already run a Redis, and want feeds and queues without new infrastructure | [Redis](./brokers/redis) | +| want the lowest-latency RPC and a backplane that costs nothing | [NATS](./brokers/nats) (JetStream for the durable half) | +| need per-message TTLs, dead-letter exchanges and operator tooling | [RabbitMQ](./brokers/amqp) | +| keep long histories other systems replay, or already stream through Kafka | [Kafka](./brokers/kafka) — for feeds and queues; put the backplane elsewhere | +| are writing tests, or running one process | the in-process `MemoryBroker` below | + +Nothing stops you from using two: a Redis backplane with Kafka feeds is a +perfectly ordinary deployment, and each binding takes the broker it needs. + +## The in-process broker + +```js +const { MemoryBroker } = require('@alexify/wrpc/broker'); + +const broker = new MemoryBroker(); +const a = new RpcServer({ router, backplane: broker.backplane }); +const b = new RpcServer({ router, backplane: broker.backplane }); +``` + +`MemoryBroker` implements every capability in one process. It is the +reference implementation the contract tests are written against, and what +makes a multi-instance setup testable without infrastructure: two servers +sharing one `MemoryBroker` behave like two processes sharing a real broker. +Delivery is always deferred to a microtask, as a network broker's would be, +so code that accidentally relied on synchronous delivery fails in the test +suite and not after a deploy. + +| Option | Default | | +| --- | --- | --- | +| `retention.maxEntries` | `10000` | Entries kept per log topic; older ones are trimmed | +| `epoch` | random | Stamped into every log id, so an id from another broker is recognized as foreign | +| `prefix` | `'wrpc'` | Backplane channel namespace | +| `logger` | `console` | `false` silences it | + +## The contracts + +A broker is a plain object: `{ name, backplane?, log?, queue?, direct?, close() }`. +`isBroker(value)` and `isBrokerLog`/`isBrokerQueue`/`isBrokerDirect` are the +structural checks every entry point runs. + +### log + +```js +const id = await log.append('orders', JSON.stringify(order), { headers, key }); + +const read = log.read('orders', { after: lastEventId, signal }); +await read.ready; // the position is fixed from here on +for await (const { id, value, headers } of read) { /* … */ } +``` + +- `after` reads strictly after an id this log minted. An id older than the + retained history — or minted by another log — fails the read with **410**; + a malformed id, or one past the tip, with **400**. +- Without `after`, `from: 'latest'` (the default) reads only new entries and + `'earliest'` everything retained. +- `parseId(text)` checks an id's syntax. A `lastEventId` comes from the peer, + so it is refused before it reaches the broker. +- The `id` a read yields is the **resume token** after that entry. For a + single-sequence log it is the entry's own id; for Kafka it is a vector of + partition offsets. + +### queue + +```js +await queue.produce('invoices', body, { headers }); + +const consumer = await queue.consume('invoices', async (delivery) => { + try { + await handle(delivery.body); + await delivery.ack(); + } catch { + await delivery.retry({ delay: 1000 }); + } +}, { prefetch: 16, deadLetter: 'invoices.dlq' }); +``` + +- Consumers of one queue **compete**: each message reaches one of them, at + least once. `prefetch` bounds the unsettled deliveries a consumer holds, and + they run concurrently. +- A delivery settles once — the first of `ack()`, `retry({ delay })`, + `release()` or `deadLetter(reason)` wins: + +| Settlement | Effect | `attempt` | +| --- | --- | --- | +| `ack()` | done | — | +| `retry({ delay })` | redelivered after `delay` ms | + 1 | +| `release()` | back to the queue now (a draining node handing work over) | unchanged | +| `deadLetter(reason)` | moved to the `deadLetter` queue with `x-wrpc-dead-reason` (one line, at most 512 characters) and `x-wrpc-attempt` headers | — | + +- A handler that throws (or rejects) has settled nothing: the adapter + **retries** the delivery after a backoff (50 ms doubling to 1 s), attempt + + 1, and logs it — never a release, which would put the message back at the + head of the queue and spin it. `release()` is for draining and stopping. +- A settlement the **broker** refuses — a retry's copy it would not take, a + commit it would not accept — leaves the message available: the adapter + tries the settlement again, then hands the message back (a requeue on + RabbitMQ, a seek to its offset on Kafka) rather than moving past it. Logged + `broker..settle`; `healthy` is `false` on Kafka until a settlement + lands again. +- The attempt counter belongs to the adapter, not the broker: RabbitMQ 4 does + not count a requeue, so the adapters carry it in an `x-wrpc-attempt` header + where the broker has none. +- `stop()` takes no new deliveries; whatever was unsettled is redelivered + later. + +### direct + +```js +const stop = await direct.listen('svc.billing', onMessage, { group: 'svc.billing' }); +await direct.send('svc.billing', packet, { correlationId, replyTo: direct.inbox(), timeout: 5000 }); +``` + +- `inbox()` answers a unique, routable address. +- Listeners sharing a `group` compete; ungrouped listeners each receive every + message. Messages from one sender to one address arrive in order. +- Delivery is **at-most-once**. The RPC binding built on it numbers its frames + and treats a gap as a lost connection. `send` may reject with **503** when the + broker knows nobody listens, and may discard a message nobody took within + `timeout`. +- `stop.healthy` is optional: an adapter that knows a listener went deaf — a + failing read, a cancelled consumer, a closed channel — answers `false` + there, and the [RPC binding's `healthy`](./brokers/rpc#server-options) is + made of it. An adapter that cannot tell leaves it out. + +### backplane + +The same three methods [Scaling](./scaling#the-contract) documents. The +broker family adds one requirement the contract tests enforce: channel names +are **literal**. A room called `room:*` must never become a wildcard +subscription, and an adapter encodes names into whatever its broker allows. + +## Names the adapter mints + +Every adapter puts a few names of its own on the wire — a consumer name, a +reply inbox, a consumer group, a message id — and each takes a `generateId` +option to replace the default: + +```js +createRedisBroker({ client, generateId: () => myUlid() }); +``` + +Two things to know. The value is used **verbatim**: wRPC never truncates it, +because trimming an id would quietly weaken the uniqueness you chose it for. +And the alphabet is the broker's business, not wRPC's — a generator answering +characters Redis refuses in a consumer name, NATS in a subject or RabbitMQ in +a queue name fails at the driver, on connect, not here. Stick to +`[A-Za-z0-9_-]` unless you know the broker better than that. + +The option is strict: a bad generator is a `TypeError` at construction. See +[Identifiers](./production#identifiers) for the whole picture. + +## Writing an adapter + +The contracts are executable. `tests/broker/{backplane,log,queue,direct}Contract.js` +in the repository run against the memory broker, every adapter's in-repo fake, +and — in the broker CI jobs — a real server. A new adapter that passes them is +a working adapter. + +Two building blocks carry the parts every adapter would otherwise get wrong: + +- **`TopicTails`** keeps one live reader per topic per instance, shared by + every local read. A feed is read by many subscriptions at once, and a broker + consumer per subscription does not survive Kafka (a group join each) or Redis + (a blocked connection each). The adapter supplies `live` (a positioned tail) + and `range` (a catch-up page); `TopicTails` joins them without a gap or a + duplicate, and sends a reader that falls behind back to `range` so memory per + slow subscriber stays bounded. A `range` read straight off a store answers an + array, and a short page is the end; one that pages by *time* — a stream + reader, a consumer group's fetch, a pull with an expiry — answers + `{ entries, done }`, so a page cut short by a pause is never taken for the + tip. An optional `contiguous(cursor, entry)` lets a log with dense ids + refuse a page whose head skipped. +- **`encodeToken(name, { safe, escape, maxLength })`** maps an arbitrary name + into one token of a broker's alphabet, injectively, shortening past + `maxLength` with a digest (AMQP routing keys stop at 255 bytes, Kafka topic + names at 249 characters). diff --git a/docs/guide/brokers/amqp.md b/docs/guide/brokers/amqp.md new file mode 100644 index 0000000..837dd51 --- /dev/null +++ b/docs/guide/brokers/amqp.md @@ -0,0 +1,122 @@ +# RabbitMQ + +RabbitMQ covers all four [capabilities](../brokers) — with the richest queue +semantics of the four adapters (per-message TTL, dead-letter exchanges, +quorum queues) and the most opinions about how a queue should be declared. + +```js +const amqp = require('amqplib'); +const { createAmqpBroker } = require('@alexify/wrpc/broker/amqp'); + +const connection = await amqp.connect(process.env.AMQP_URL); +const broker = createAmqpBroker({ connection }); + +const server = new Server({ router, backplane: broker.backplane, port: 8000 }); +``` + +The connection is **injected**: `amqplib` is a devDependency of this +repository and never a runtime one, and `broker.close()` releases the +channels it opened but never your connection. + +| Option | Default | | +| --- | --- | --- | +| `connection` | — | An amqplib connection | +| `prefix` | `'wrpc'` | Exchange, queue and routing-key namespace | +| `queueType` | `'quorum'` | `x-queue-type` of work queues | +| `inboxTtl` | `60000` | `x-expires` of a service group's shared queue | +| `streamMaxBytes` | `0` | `x-max-length-bytes` on a log's stream queue | + +## What maps to what + +| Capability | RabbitMQ | +| --- | --- | +| `backplane` | a direct exchange; every instance has **one** exclusive auto-delete queue and one consumer channel for all of its rooms, bound and unbound per room by routing key — a room event fans out without a shared queue, and the channel count does not grow with the rooms | +| `log` | a **stream queue** (`x-queue-type: stream`); the delivery's `x-stream-offset` is the feed's resume token | +| `queue` | a quorum queue, prefetch per consumer channel, a TTL retry queue that dead-letters back into the main one, and a dead-letter queue for what is exhausted | +| `direct` | **one** direct exchange (`.direct`), the address as the routing key: an inbox binds an exclusive queue, a service group binds one durable queue its members compete over | + +The adapter declares exactly two exchanges, `.bp` and +`.direct`, however many rooms, addresses and inboxes it serves — the +permissions a RabbitMQ user needs are those two names plus the queues under +`.`. An address is whatever a peer puts in `replyTo`, one per client +inbox, so anything declared *per address* would grow with every client for as +long as the broker lived. + +## What RabbitMQ 4 changed, and how the adapter answers + +The [phase-0 spike](../brokers#writing-an-adapter) measured both of these +against a real 4.x server: + +- **`nack(requeue)` no longer counts a delivery.** `x-delivery-count` only + grows when a consumer *fails* (its channel or connection closes), so a + retry built on requeue would loop a poison message forever. The adapter + therefore carries the attempt in an `x-wrpc-attempt` header and a + `retry()` is a **republish** — through the TTL retry queue when it has to + wait. `release()` is the plain requeue, which is exactly right: it must + *not* count an attempt. +- **A transient non-exclusive queue is refused** with a CONNECTION-level + `541` that takes every channel on it down. Every shared queue the adapter + declares is durable, with `x-expires` where it should not outlive its + consumers. + +## Sharp edges + +- **A channel-level error closes that channel, not the broker.** A + declaration that does not match what the server holds (`406`, a changed + `streamMaxBytes`), a queue deleted under a reader (`404`), a node going + away: the adapter forgets the closed channel and opens another on the next + call, and a queue consumer re-consumes with a backoff (`healthy` is `false` + meanwhile, logged `broker.amqp.consumer.closed`). A closed **connection** + is different — nothing can be re-opened on it: every binding turns + unhealthy for good (`broker.amqp.connection`, once), and the fix is a new + connection with a new broker on it. +- **`log.append` costs an extra round trip.** AMQP 0-9-1 never reports the + offset a publish landed on (only the stream protocol does), so the append + reads the tip back to answer with a real id. A high-rate producer should + either ignore the receipt — readers yield the authoritative ids — or use a + broker whose publish answers with the position (Redis, NATS, Kafka). +- **A catch-up page ends at the tip, not at a pause.** A stream reader has + no "end", so a page reads the stream's last offset first and is complete + when it reaches it — a delivery that pauses (flow control, a slow link) + hands the page back incomplete, and the reader asks again from where it + stopped. An empty stream is given half a second to answer "last" before + it is taken for empty. +- **A trimmed offset is detected, not reported by the broker.** RabbitMQ + silently starts a stream reader at the oldest retained message; the + adapter notices that the first entry is past what was asked for and fails + the read with `410`, which [`onGap`](./feeds#gaps-and-snapshots) turns + into a snapshot. +- **One group per address.** Members of a group share one queue; two + different groups on one address would compete rather than each receive. + The RPC binding uses one group per service, which is the supported shape. +- **Retry delays share one TTL queue, and RabbitMQ expires only its head.** + Every `retry()` republishes to `.retry` with a per-message + `expiration`, and a message behind one with a longer delay waits for that + one to expire first. The **default** policy is exactly that mix — an + exponential backoff with full jitter, 1 s → 60 s — so on RabbitMQ a retry + due in 300 ms can sit behind one due in 8 s (the largest delay five + attempts produce), and behind one of up to `max` with more attempts. The + delays are a lower bound here, not a schedule. Where the timing matters, + make them equal, so that nothing can be behind anything later than + itself: + + ```js + await attachConsumers(server, broker, { + orders: { target: 'orders.v1/process', retry: { backoff: { base: 5000, max: 5000, factor: 1, jitter: false } } }, + }); + ``` + + The retry queue is a **classic** queue: durable, but not replicated — a + retry waiting there is lost with the node that holds it, where the work + queue and the dead-letter queue, both quorum, survive. The other adapters + delay a retry per message and have neither edge. + +## Running the tests + +`pnpm test` runs the AMQP suites against an in-repo fake RabbitMQ. Against a +real server: + +```bash +docker compose up -d rabbitmq +AMQP_URL=amqp://127.0.0.1:5672 node --test tests/broker/amqp.integration.test.js +``` diff --git a/docs/guide/brokers/consumers.md b/docs/guide/brokers/consumers.md new file mode 100644 index 0000000..dcc8a10 --- /dev/null +++ b/docs/guide/brokers/consumers.md @@ -0,0 +1,280 @@ +# Queue consumers + +A queue carries work that must not be lost: an order to charge, an email to +send, an event another service emitted. `attachConsumers` delivers a broker +queue's messages into router procedures, **at least once**, through the same +pipeline a call on any transport takes — hooks, validators, access, the procedure's +`queue` and `timeout`, telemetry — and turns the outcome into an ack, a retry, +or a dead letter. + +```js +const { defineRouter, procedure } = require('@alexify/wrpc'); +const { attachConsumers } = require('@alexify/wrpc/broker'); + +const router = defineRouter({ + 'billing.v1': { + consumes: { + 'orders.created': procedure({ + access: 'public', + input: OrderCreated, // a Standard Schema or a function + consume: { prefetch: 16 }, + handler: async (ctx, order) => { + await charge(order, { idempotencyKey: ctx.callMeta.messageId }); + }, + }), + }, + }, +}); + +const server = new Server({ router, port: 8000 }); +await server.listen(); +const consumers = await attachConsumers(server, broker); +``` + +## Declaring consumers + +`consumes` is a **reserved unit key**, next to [`on`](../router#units-and-versions) and +`hooks`. Each entry is a full procedure, keyed by the queue it drains, with an +optional `consume` policy. Two things set consumers apart from methods: + +- **No peer can call one.** They live apart from the unit's methods: a `call` + packet for `billing.v1/consumes.orders.created` — or `orders.created` — + answers `404`. +- **Introspection does not list them.** Which queues a server drains is + deployment topology, not a client contract; `router.consumers()` lists them + on the server side. + +`consume` is refused on a method or an event handler, where it could only be +silently ignored, and a subscription cannot consume. + +## Binding at attach time + +`attachConsumers(server, broker, table, options)` binds every declared consumer +(`auto: true`, the default) and takes a table for what varies per deployment: + +```js +await attachConsumers(server, broker, { + // Override a declared consumer: 'unit.vN/source' + 'billing.v1/orders.created': { queue: 'prod.orders.created', prefetch: 64 }, + // Bind an ORDINARY procedure to a queue: any other key, with a target + 'audit.events': { target: 'audit.v1/record', args: (body, headers) => ({ line: body }) }, +}); +``` + +The whole table is resolved before any consumer starts: an unknown key, a +missing target, a subscription target or a `prefetch` above the server's +`maxCalls` fails the attach instead of leaving half the consumers running. + +| Policy field | Default | | +| --- | --- | --- | +| `queue` | the `consumes` key (or table key) | The broker-side queue or topic | +| `group` | the queue | The consumer group, where the broker needs one | +| `prefetch` | `16` | Messages held at once, processed concurrently; at most `maxCalls` | +| `retry` | 5 attempts, 1 s → 60 s full-jitter backoff | `{ attempts, backoff: { base, max, factor, jitter }, retryOn }`, or `false`. On RabbitMQ the delays are a lower bound — see [its sharp edges](./amqp#sharp-edges) | +| `deadLetter` | `'.dlq'` | Where exhausted and refused messages go; `false` drops them | +| `identity` | `{ trust: 'none' }` | Who the procedure runs as — see below | +| `meta` | `[]` | Message headers copied into `ctx.callMeta` | +| `args` | `JSON.parse(body)` | `(body, headers, delivery) => args` | + +## What a message becomes + +Every delivery is an ordinary call packet dispatched by the core: + +- `ctx.callMeta` carries `messageId`, `attempt` (1-based) and `queue`, plus the + headers `meta` allows. +- A `traceparent`/`tracestate` message header — the W3C names, what any + producer writes — continues the producer's trace: the call span is a + `CONSUMER` span with `messaging.*` attributes (see [Publishing](#publishing)). + Under [encryption](../encryption#brokers) the headers travel inside the + sealed message, so a party without the keys — a tracing sidecar reading + the topic — sees no trace context. +- The consumer's client reports `ctx.client.transportKind === 'broker'`. It is + a request/response carrier: it runs calls, is not counted among connected + clients, and receives no broadcasts. + +The outcome decides the settlement: + +| Outcome | Settlement | +| --- | --- | +| success | **ack** | +| `408`, `429`, `500`, `503` (`retryOn`) | **retry** after backoff, until `attempts` — then **dead letter** | +| `400`, `403`, `404`, `422`, `501`, anything else | **dead letter** at once | +| `503` while the server drains | **release**, attempt not counted | +| body `args` cannot parse | **dead letter** (`400`) | +| a [sealed](../encryption#brokers) delivery under a key id this service does not hold (`kid`) | **retry** like a `503` — a rotation in progress — then **dead letter** | +| a sealed delivery that does not open, or plaintext where none is accepted | **dead letter** (`400`), logged `broker.refused` | + +`500` is retried here although [Errors](../../reference/errors#which-codes-are-worth-retrying) +says not to retry one from a client: a queue's `500` is usually a dependency +that fell over, and at-least-once delivery already demands an idempotent +handler. Narrow `retryOn` where that is not true. + +A dead letter lands on the `deadLetter` queue with `x-wrpc-dead-reason` +(`" "`, folded to one line of at most 512 characters — a +header value on every broker) and `x-wrpc-attempt` headers, and +`onDeadLetter` (an `attachConsumers` option) runs first — the place for an +alert, and where the whole `error` is still available. + +::: warning At least once means duplicates +A consumer can see a message twice: a crash between the handler's side effect +and the ack, a redelivery after `release`, a broker failover. Make handlers +idempotent — `ctx.callMeta.messageId` is a stable key to deduplicate on. +::: + +## Draining a dead-letter queue + +A dead letter is the message as it was, plus two headers: `x-wrpc-dead-reason` +(why — `" "`) and `x-wrpc-attempt` (how many attempts it had). +Nothing consumes the dead-letter queue by itself; what you do with it is one +of three things. + +- **Know it happened.** `onDeadLetter(info)` runs before the message is + moved and has the whole `error`; the binding logs `broker.dead` (`queue`, + `id`, `code`, `attempt`) and counts the delivery in + `wrpc.broker.deliveries` with `wrpc.broker.outcome: dead`. Alert on either — an empty dead-letter queue + nobody watches is indistinguishable from a full one. +- **Look at it.** A binding on the dead-letter queue is an ordinary + consumer: `'orders.dlq': { target: 'ops.v1/review', retry: false, + deadLetter: false }` hands each one to a procedure that files it. Give it + `deadLetter: false` (or another queue) — its default would be + `orders.dlq.dlq`. +- **Send it round again** once the cause is fixed. Do it by **publishing a + new message** — from that review procedure, through + [`createPublisher`](#publishing) — and the attempts start from one. Do + *not* move the stored message back as it is (a shovel, `rabbitmqadmin`, + `XADD` of the same fields): it still carries `x-wrpc-attempt`, the adapter + reads the attempt from that header, and the first failure sends it + straight back to the dead-letter queue. A tool that must move messages + verbatim has to drop `x-wrpc-attempt`, `x-wrpc-redelivered` and + `x-wrpc-dead-reason` on the way. + +## Dead letters under encryption + +A dead letter is forwarded **as it arrived**: under +[`encryption`](../encryption#brokers) that is the sealed message, its +`wrpc-sealed` key id still on it, so whoever reads the dead-letter queue +reads nothing — the same promise the work queue makes. Two ways in: + +- `onDeadLetter` receives `opened` — the delivery's body and headers as the + binding opened them — for the alert or the audit row. It is the one place + the plaintext is in hand; log the id, the code and what you need, not the + whole body. +- A binding on the dead-letter queue (a re-drive, a quarantine review) names + the queue the messages were sealed for with `sealedFor`, because the seal + binds a message to its queue: + +```js +await attachConsumers(server, broker, { + 'orders.dlq': { target: 'ops.v1/review', sealedFor: 'orders' }, +}, { encryption: { keys } }); +``` + +For a script, `openSealedMessage(encryption, { topic, headers, body })` opens +one message by hand under the same option and answers `{ headers, body }` — +or `{ refused }` with the reason. + +## Identity + +| `identity.trust` | The procedure runs with | +| --- | --- | +| `'none'` (default) | no session — a consumer whose procedure is `access: 'session'` refuses to bind | +| `'service'` | one pseudo-session for the binding: `{ token, state: { consumer: queue } }`, or `identity.session` | +| `'token'` | the session a bearer token in `identity.header` (default `authorization`) restores through the configured [token carrier](../auth) | + +Token mode keeps one client per distinct token (`tokenClients`, LRU, default +128, a positive integer). A client evicted from that cache finishes the +deliveries it still holds and closes after the last one — an eviction never +releases work back to the broker; the cost of a cache too small for the +distinct tokens in flight is a session restore per message, logged +`broker.evict`. A message without a token runs anonymous, so a session +procedure refuses it with `403` — which dead-letters. A token the session +store could not be asked about (it threw) is a `503` instead, which the +binding's `retry` takes like any other. + +A cached client is trusted for `tokenTtl` (default 60 s, `0` for the life of +the binding); past that the token is presented to the session store again, so +a logout or a rotation takes effect within one ttl. `consumers.forget(value)` +— the header value as messages carry it, `Bearer …` — makes it immediate, +which is what a logout handler calls. A restore that did not happen (the store +was down, the token names no session) is never cached as "anonymous": the next +message under that token tries the store again. + +## Draining and shutdown + +A server that starts [draining](../production#graceful-shutdown) emits +`'draining'`, and every binding **pauses**: no new messages are taken, the ones +already held finish and are acked. `server.close()` emits `'close'`, and every +binding stops; a message whose call was cut off by the close is released back +to the queue for another instance. The handle drives the same by hand: + +```js +await consumers.pause(); +await consumers.resume(); +await consumers.stop(); +consumers.healthy; // false once a consumer stopped or failed +consumers.bindings; // [{ key, queue, group, method, healthy }] +``` + +Wire `healthy` into readiness next to `server.rpc.healthy` — knowing what it +covers, which is the adapter's consumer and no more: + +| Broker | `healthy` is `false` when | +| --- | --- | +| in-process, Redis | the binding stopped, or the read loop is retrying after a failed read | +| NATS | the binding stopped, the pull loop is retrying, or the connection closed | +| RabbitMQ | the consumer's channel closed (`false` while it re-opens with a backoff), and for good once the connection closed | +| Kafka, `kafkajs` | the consumer crashed (`broker.kafka.crash`), until it rejoins its group; a join that times out (more instances than partitions) stays healthy | +| Kafka, `@confluentinc/kafka-javascript` | the binding stopped — the facade reports no crash events, so a crashed consumer looks healthy | + +A message the broker holds but nobody consumes — a queue nobody bound — is +not what `healthy` sees; that is the broker's own lag metric. + +## Publishing + +`emits` stays what it is — a declaration of a unit's outbound events for +[`wrpc types`](../cli). `createPublisher` publishes those events to a broker, +by name, so a typo cannot quietly create a topic nobody consumes: + +```js +const { createPublisher } = require('@alexify/wrpc/broker'); + +const router = defineRouter({ + 'orders.v1': { + emits: { created: { data: { id: 'string', total: 'number' } } }, + place: procedure({ + handler: async (ctx, order) => { + const saved = await save(order); + await publisher.publish('orders.v1/created', saved); + return saved; + }, + }), + }, +}); + +const publisher = createPublisher(server, broker, { + 'orders.v1/created': { to: 'queue', topic: 'orders.created', key: (order) => order.id, validate: OrderCreated }, +}); +``` + +| Field | Default | | +| --- | --- | --- | +| `topic` | `'.'` | Queue or log topic | +| `to` | `'log'` if the broker has one, else `'queue'` | A log feeds [durable feeds](./feeds); a queue feeds consumers | +| `key` | — | A string or `(data) => string`: the partition/ordering key | +| `validate` | — | A function or Standard Schema; a refusal rejects with `400` and nothing is published | + +An event not declared in the unit's `emits` is refused when the publisher is +created (`strict: false` lifts that). `publish` resolves with the log id for a +log target. + +Each publish is a `PRODUCER` span, and the active trace context rides in the +message headers as `traceparent`/`tracestate` — so a trace starts at the WebSocket call that placed the +order, continues through the publish, and ends in the consumer that charged it. +`wrpc.broker.published` counts publishes by outcome, `wrpc.broker.deliveries` +consumed messages by settlement. + +::: tip Publishing is not transactional +`publish` after a database write can fail after the write succeeded. When the +two must agree, write the event to an outbox table in the same transaction and +publish from the outbox. +::: diff --git a/docs/guide/brokers/feeds.md b/docs/guide/brokers/feeds.md new file mode 100644 index 0000000..57e143e --- /dev/null +++ b/docs/guide/brokers/feeds.md @@ -0,0 +1,155 @@ +# Durable feeds + +A [subscription](../subscriptions) resumes from `lastEventId`, but only as far +back as the server remembers — and `createEventLog()` remembers in one +process's memory. A client that reconnects to **another** instance, or to a +restarted one, presents a foreign epoch and gets a snapshot instead of what it +missed. + +A broker log does not have that limit: it is shared by every instance and it +already keeps positions. `brokerFeed` turns one into a subscription handler. + +```js +const { defineRouter, procedure } = require('@alexify/wrpc'); +const { brokerFeed } = require('@alexify/wrpc/broker'); + +const router = defineRouter({ + orders: { + feed: procedure.subscription({ + access: 'session', + handler: brokerFeed(broker, 'orders'), + }), + }, +}); +``` + +Every value is [`tracked()`](../subscriptions#resuming) with the log's resume +token. The client stores it as `lastEventId` and sends it back when it +re-subscribes after a reconnect — to whichever instance it reached — and the +feed continues from there, without a gap and without a duplicate: + +```mermaid +sequenceDiagram + autonumber + participant C as client + participant A as instance A + participant L as broker log + participant B as instance B + C->>A: subscribe orders/feed + L-->>A: entries 1, 2 + A-->>C: data (eventId 2) + Note over C,A: connection lost + L-->>L: entries 3, 4 appended + C->>B: subscribe orders/feed, lastEventId 2 + B->>L: read after 2 + L-->>B: 3, 4, then live + B-->>C: data 3, 4, … +``` + +Appending is plain `log.append` — or a [publisher](./consumers#publishing) +— from anywhere that holds the broker: + +```js +await broker.log.append('orders', JSON.stringify(order)); +``` + +## Options + +| Option | Default | | +| --- | --- | --- | +| `from` | `'latest'` | What a **fresh** subscription reads: new entries only, or `'earliest'` for everything retained | +| `decode` | `'json'` | `'json'`, `'text'`, or `(text) => value` | +| `map` | — | `(value, entry, context) => value`; answer `undefined` to skip an entry | +| `onGap` | — | `(context, args, { lastEventId, code }) => snapshot` — see below | +| `secret` | — | HMAC-sign the ids the feed hands out | +| `maxIdLength` | `512` | Longer `lastEventId`s are refused with `400` | + +The topic may depend on the caller — a function of the context and the +arguments. Authorization stays where it always is: the procedure's `access`, +and whatever the resolver checks before answering. + +```js +handler: brokerFeed(broker, (ctx, { tenant }) => { + if (!ctx.session.state.tenants.includes(tenant)) throw Object.assign(new Error('Forbidden'), { code: 403 }); + return `orders.${tenant}`; +}, { + map: (order) => (order.internal ? undefined : order), +}), +``` + +An entry that does not decode is logged as `broker.feed.decode` and skipped; one bad +entry does not end every subscriber's feed. Under +[`encryption`](../encryption#brokers) an entry that does not open is skipped +the same way, logged `broker.feed.refused` — at `warn` once per reason per ten +seconds with the running `count`, at `debug` in between, and summarized when +the feed ends — and counted in `wrpc.broker.refused`. + +## Gaps and snapshots + +A `lastEventId` the log cannot resume from ends the subscription with a coded +error: + +| Code | When | +| --- | --- | +| `400` | Malformed, longer than `maxIdLength`, not signed by `secret`, or past the end of the log | +| `410` | Older than the retained history, or minted by another log | + +A `410` also happens **mid-stream**: a reader that stopped pulling while the +retention overtook it. With `onGap`, both become a snapshot instead — a value, +an iterable, an async iterable, or nothing — after which the feed continues: + +```js +handler: brokerFeed(broker, 'orders', { + onGap: async (ctx, args, { code }) => loadRecentOrders(ctx.session.state.user), +}), +``` + +The live read is positioned **before** `onGap` runs, so an entry appended while +the snapshot is being assembled is delivered after it rather than lost. +Snapshot values are not tracked, so a client that disconnects before the +first live entry resumes with its old id and gets the snapshot again — the +same behaviour as [`EventLog.since()`](../subscriptions#resuming) answering +`null`. + +## Signed ids + +`lastEventId` comes from the peer. The feed checks its length and syntax before +it reaches the broker, but a well-formed id is still a position the client +chose — `0`, say, to replay a whole topic. With `secret`, every id the feed +hands out carries an HMAC and an id it never issued is refused: + +```js +handler: brokerFeed(broker, 'orders', { secret: process.env.FEED_SECRET }), +``` + +Every instance serving the feed needs the same secret. Rotating it invalidates +the ids clients hold: pair a rotation with `onGap`. An id is bound to the +topic it was issued for, so one secret may serve every feed of a deployment +— a token from `orders` positions nobody on `invoices`. + +## One broker read per topic, not per subscriber + +A thousand browsers subscribed to one feed on one instance share **one** live +read of the topic: the adapters build on [`TopicTails`](../brokers#writing-an-adapter), +which fans one tail out to every local reader and catches a resuming reader up +through a range read. A subscriber that stops reading does not grow memory +without bound — past a high-water mark it drops its buffer and catches up from +the log when it reads again. + +What the feed does with an entry is shared the same way where it does not +depend on the subscriber: a sealed entry is opened once and its resume token +signed once for every live subscriber of the topic on the instance, whatever +their number (`bench/feed-fanout.js`: under a microsecond per subscriber with +`secret` and `encryption` both on, against 0.7 µs with neither). What stays +per subscriber is `decode` — each gets a value of its own, never one object +shared between connections — your `map`, and the packet itself: its JSON and +the write to the socket. A subscriber catching up reads its own pages and +opens them itself; it joins the shared read at the tip. + +A live read the broker ends on its own — a consumer it cancelled, a channel +it closed, a consumer that crashed — is replaced by a fresh one, and every +subscriber catches up from its own position, so a dead tail loses nothing +(`broker..tail` in the log). Only a broker that cannot start the fresh +read either, after five tries with a backoff, ends the subscriptions with a +`503`; a client then resubscribes with its `lastEventId` as after any +reconnect. diff --git a/docs/guide/brokers/kafka.md b/docs/guide/brokers/kafka.md new file mode 100644 index 0000000..c9eaf1d --- /dev/null +++ b/docs/guide/brokers/kafka.md @@ -0,0 +1,170 @@ +# Kafka + +Kafka is the durable-log broker: feeds and work queues that keep history for +days, replayable by any consumer. wRPC uses it for `log` and `queue`, offers +a `backplane` with real costs, and refuses `direct` outright. + +```js +const { Kafka } = require('@confluentinc/kafka-javascript').KafkaJS; +const { createKafkaBroker } = require('@alexify/wrpc/broker/kafka'); + +const kafka = new Kafka({ kafkaJS: { brokers: process.env.KAFKA_BROKERS.split(',') } }); +const broker = createKafkaBroker({ kafka }); +``` + +`kafkajs` works the same way — `new Kafka({ brokers })` — and the adapter +detects which client it was handed. Both are **injected**: neither is a +runtime dependency of wRPC. + +::: tip Which client +`@confluentinc/kafka-javascript` (librdkafka, actively released) is the one +to reach for. `kafkajs` is pure JavaScript and still widely deployed, but its +last release was 2.2.4 in February 2023 — it is not deprecated, just +inactive, and it emits timer warnings on current Node versions. The adapter +supports both and CI exercises both. +::: + +| Option | Default | | +| --- | --- | --- | +| `kafka` | — | The client | +| `flavor` | detected | `'kafkajs'` or `'confluent'` when detection is wrong | +| `prefix` | `'wrpc'` | Topic namespace | +| `partitions` | `3` | Partitions for **queue** topics — the concurrency ceiling | +| `logPartitions` | `1` | Partitions for **log** topics; one keeps a feed globally ordered | +| `replicationFactor` | `-1` | Replication factor of the topics the adapter creates; `-1` is the broker's `default.replication.factor` (Kafka 2.4+) | +| `backplane.topic` / `.partitions` | `.backplane` / `1` | The backplane topic | +| `maxRetryDelay` | `60000` | Cap on how long a retry's delay waits in-process | +| `maxCatchUp` | `4` | Catch-up pages of a feed read at once — each is a consumer group of its own; the rest wait their turn | + +## What maps to what + +| Capability | Kafka | +| --- | --- | +| `log` | a topic per feed; the resume token is a **vector** of partition offsets (`k1:

=,…`) | +| `queue` | a topic per queue, one consumer group, manual commits, `partitionsConsumedConcurrently` as the prefetch | +| `backplane` | one topic, the channel in a header, a unique consumer group per instance — **with caveats** | +| `direct` | — | + +## No RPC over Kafka + +`attachBrokerRpc` refuses a Kafka broker: a request/response carrier needs an +addressable inbox per instance and low, predictable latency, and Kafka gives +neither — a topic per instance plus a rebalance pause on every join is the +opposite of what an RPC caller wants. Use Redis, NATS or RabbitMQ for +[RPC over a broker](./rpc), alongside Kafka for feeds and queues. + +## Topics + +The adapter creates the topics it needs on first use — one per feed, one per +queue, one for the backplane — and logs each creation once +(`broker.kafka.topic`, with the partition count and replication factor). It +never alters a topic that exists: the partition count and the replication +factor stay what the topic was created with, whoever created it. + +Replication is the cluster's to decide. The default `replicationFactor` of +`-1` asks for the broker's `default.replication.factor`, so a production +cluster configured for three replicas and `min.insync.replicas=2` gets +exactly that, and a single-node development broker gets one. Pass a number +only when the topics wRPC creates should differ from the cluster's default, +or create them ahead of time (IaC, `kafka-topics.sh`) with the settings +you want — the adapter will find and use them. + +## The backplane's caveats + +It works, and the contract tests run against it, but know what you are +buying: + +- **An instance is deaf until its group joins.** A fresh consumer group + reading `latest` never sees what was published before the join. Rooms are + at-most-once anyway, but the silent window is seconds, not milliseconds — + and the broker's own `group.initial.rebalance.delay.ms` (3 s by default) + is most of it. Set it to `0` for wRPC's groups. +- **Every instance reads every envelope.** Filtering is local (the channel is + a header), so a big cluster pays fan-out bandwidth a Redis or NATS + backplane does not. +- **Groups pile up.** One per instance, per boot; `close()` deletes the ones + it created, but a crashed instance leaves one behind until + `offsets.retention.minutes`. +- **1 MiB payload cap** by default, which a large `fetchClients` answer can + exceed. + +If Redis or NATS is available, put the backplane there and keep Kafka for +what it is good at. + +## Feeds + +A feed topic is **single-partition by default**: a durable feed that +reordered itself under load would be a subtle, permanent bug. Set +`logPartitions` higher when throughput matters more than global order — the +resume token is a vector precisely so a multi-partition feed still resumes +exactly, and per-key order still holds. + +Readers pin their position by **seeking to a watermark captured before the +join**: a fresh group resolves `latest` at its first fetch, which can land +after the next append. Without the seek, the first entries of a live feed +would vanish now and then — a race the phase-0 spike caught. + +A catch-up page is complete only once every partition it wanted reached the +high watermark read before the page started; its window opens after the +group joined and the seek landed, so a slow rebalance cannot cut a page short +and pass it off as the tip. What retention already took is not waited for. + +Every reader is its own consumer group — a live tail's for as long as it +runs, a catch-up page's for the page — deleted the moment the reader is +done (a page's group used to wait for `close()`; a failed read used to leave +its consumer joined). The price of a resume is therefore a **group join per +page** of catch-up, which on a broker with the default +`group.initial.rebalance.delay.ms` (3 s) is three seconds per 256 entries: +set it to `0` on a broker that serves feeds, or size the page. A queue's +group is durable and is never deleted by wRPC: its committed offsets are the +queue's progress. + +A resume **storm** — a deploy, and every client of the instance that went +resumes at once — is bounded two ways. Readers asking for the same page +(the same topic, the same resume token, the same page size: a room that +lost one instance) share **one** read. And no more than `maxCatchUp` pages +(4) are read at the same time; the rest wait, first come first served, +so the coordinator sees four group joins at a time rather than a thousand. +The price is latency under the storm — a queue instead of a stampede — +which is the trade to make: raise `maxCatchUp` on a cluster that takes +more. + +## Queues + +- **No nack.** A `retry()` is a republish carrying `x-wrpc-attempt`, and the + delay waits in-process (capped by `maxRetryDelay`), in steps of three + seconds that each end in a heartbeat, so a long wait does not look like a + dead member to the group coordinator. The original message stays + **uncommitted** until the copy is written, so a crash mid-wait redelivers + rather than loses — at-least-once holds. A heartbeat that fails mid-wait is + a rebalance: the message is left to the partition's new owner + (`broker.kafka.rebalanced`), neither republished nor committed here. +- **A refused settlement seeks back.** A copy the producer cannot write or a + commit the group will not take is tried three times; then the consumer + seeks the partition back to that offset and reports `broker.kafka.settle` + — `healthy` is `false` until a settlement lands again, and nothing is ever + committed past the message. +- **Ordering is lost on retry.** The republished copy goes to the end of a + partition. Where per-key order matters, produce with a key and accept that + a retried message trails its siblings. +- **Concurrency comes from partitions.** Within one partition Kafka is + strictly sequential; `prefetch` becomes `partitionsConsumedConcurrently`, + so a queue that wants 16 concurrent handlers needs at least 16 partitions. + The confluent client spins its workers up once the group has assigned it + partitions and fetches one message at a time until it has measured the + handler, so the first messages after a slow join (the broker's default + 3 s rebalance delay) run with less concurrency than asked for; it settles + within a second. +- **`redelivered` is best-effort.** A rebalance simply rewinds an uncommitted + offset, and Kafka carries no delivery counter — the flag is true only for + messages the adapter itself re-published. + +## Running the tests + +`pnpm test` runs the Kafka suites against an in-repo fake, in **both** client +shapes. Against a real broker: + +```bash +docker compose up -d kafka +KAFKA_BROKERS=127.0.0.1:9092 node --test tests/broker/kafka.integration.test.js +``` diff --git a/docs/guide/brokers/nats.md b/docs/guide/brokers/nats.md new file mode 100644 index 0000000..2c73ef1 --- /dev/null +++ b/docs/guide/brokers/nats.md @@ -0,0 +1,100 @@ +# NATS + +NATS is the most natural fit of the four: core subjects are a backplane and a +request/reply fabric by design, and JetStream adds the durable half — feeds +and work queues — without a second system. + +```js +const { connect, headers, createInbox } = require('@nats-io/transport-node'); +const { jetstream, jetstreamManager } = require('@nats-io/jetstream'); +const { createNatsBroker } = require('@alexify/wrpc/broker/nats'); + +const nc = await connect({ servers: process.env.NATS_URL }); +const broker = createNatsBroker({ nc, headers, createInbox, jetstream, jetstreamManager }); + +const server = new Server({ router, backplane: broker.backplane, port: 8000 }); +``` + +`headers` is a **package export**, not a method on the connection — that is +why it is injected separately. Leave `jetstream`/`jetstreamManager` out and +you get a backplane + RPC broker with no `log` and no `queue`; everything +else works unchanged. + +| Option | Default | | +| --- | --- | --- | +| `nc` | — | The connection; `close()` never drains it — its lifetime is yours | +| `headers` | — | The `headers()` factory | +| `jetstream`, `jetstreamManager` | — | Both, or neither | +| `createInbox` | a uuid subject | The `createInbox()` export | +| `prefix` | `'wrpc'` | Subject and stream-name namespace | +| `ackWait` | `30000` | JetStream ack window for queue consumers (ms) | +| `maxAckPending` | JetStream's default | `max_ack_pending` of a queue's durable consumer: the **group's** cap on unacked messages, shared by every instance. `prefetch` is each instance's own | +| `stream` | `{}` | Extra stream config, e.g. `{ queue: { storage: 'memory' } }` | + +## What maps to what + +| Capability | NATS | +| --- | --- | +| `backplane` | core subjects: `PUB`/`SUB`, at-most-once, no persistence | +| `log` | one JetStream stream per topic; the message **sequence** is the feed's resume token | +| `queue` | a work-queue stream with one durable pull consumer per group; `prefetch` is counted per instance, `maxAckPending` caps the group | +| `direct` | core subjects: plain subscriptions for inboxes, **queue groups** for a service address, native reply subjects | + +## Names become one subject token + +A room, a topic, a queue and an address are application strings; a NATS +subject is a dot-separated hierarchy where `*` and `>` are wildcards. So every +name is encoded into exactly **one** token: a room called `room:*` subscribes +to that room and to nothing else, and `orders.eu` cannot collide with a +subject someone else meant. Stream names get the same treatment (`.` is +illegal in one). + +## Sharp edges + +- **A JetStream sequence carries no epoch.** An id from another stream looks + like a future position in this one, so a feed answers `400` (past the tip) + where a log with an epoch would say `410`. Sign the ids + ([`brokerFeed({ secret })`](./feeds#signed-ids)) when clients may send ids + from elsewhere. +- **A catch-up page stops at the stream's last sequence**, read before the + pull, instead of parking for the fetch's expiry once the stream has nothing + more — and a page the expiry cut short is asked for again, never taken for + the tip. +- **Retention is yours to choose.** The adapter creates a log stream with the + server's defaults; pass `stream.log` (`max_msgs`, `max_age`, `storage`) for + something else. A feed resuming past a purge answers `410`, which `onGap` + turns into a snapshot. +- **`release()` costs a republish.** JetStream's `nak` always counts a + delivery, and a release must not, so the adapter re-publishes the message + with its attempt carried in a header and terminates the original. `retry()` + is the cheap path — a plain `nak(delay)`. +- **A slow handler keeps its lease.** While a delivery is in flight the + adapter calls `working()` every third of `ackWait`, and it stops the moment + the consumer **stops** — so a consumer that stops hands its messages back + after `ackWait` rather than holding them forever. A **paused** consumer (a + draining node) keeps the leases of what it still holds until those + settle. +- **A durable consumer keeps the configuration it was created with.** The + group's `max_ack_pending` and `ack_wait` are set when the first instance + creates the durable; an instance that binds later with other values is + served with the old ones and logs `broker.nats.consumer.config` once — + update the consumer (`nats consumer edit`) to change them. +- **A reader's consumer is deleted when the read is done** — a catch-up page's + after the page, a live tail's when the tail stops — and reaped by the server + 30 seconds after a reader that died without saying so. +- **Core NATS never queues.** A `direct.send` to an address nobody listens on + is dropped by the server, as core NATS always does: the caller learns from + its own timeout rather than a `503`. +- **Payloads are capped** (1 MiB by default). A wRPC call or a cluster + `fetchClients` answer above it is refused by the server — raise + `max_payload` or keep the answers small. + +## Running the tests + +`pnpm test` runs the NATS suites against an in-repo fake NATS + JetStream. +Against a real server: + +```bash +docker compose up -d nats +NATS_URL=nats://127.0.0.1:4222 node --test tests/broker/nats.integration.test.js +``` diff --git a/docs/guide/brokers/redis.md b/docs/guide/brokers/redis.md new file mode 100644 index 0000000..9b8584b --- /dev/null +++ b/docs/guide/brokers/redis.md @@ -0,0 +1,117 @@ +# Redis + +Redis covers all four [capabilities](../brokers): pub/sub for the backplane, +Streams for durable feeds and queues, and pub/sub plus a list for RPC. It is +the least new infrastructure of any option here — most deployments already +have one. + +```js +const Redis = require('ioredis'); +const { createRedisBroker } = require('@alexify/wrpc/broker/redis'); + +const broker = createRedisBroker({ client: new Redis(process.env.REDIS_URL) }); + +const server = new Server({ router, backplane: broker.backplane, port: 8000 }); +``` + +The client is **injected**, as everywhere in wRPC: `ioredis` is a +devDependency of this repository and never a runtime one. Anything with the +same command surface works — Valkey, KeyDB and Dragonfly need no adapter of +their own. + +| Option | Default | | +| --- | --- | --- | +| `client` | — | Runs every command; **never** quit by `close()` | +| `subscriber` | `client.duplicate()` | The connection subscriptions use | +| `connect` | `client.duplicate()` | Opens the extra connections blocking reads need | +| `prefix` | `'wrpc'` | Key and channel namespace | +| `blockMs` | `1000` | How long a blocking read parks before looping | +| `claimIdleMs` | `60000` | Idle time after which a stopped consumer's messages are claimed | +| `maxLen` | `0` | `XADD MAXLEN ~ n` on every log append; `0` never trims | +| `inboxTtl` | `60000` | TTL of an RPC group's delivery list and presence key | + +## What maps to what + +| Capability | Redis | +| --- | --- | +| `backplane` | `PUBLISH` / `SUBSCRIBE` (the same adapter [`@alexify/wrpc/scaling`](../scaling#redis) ships) | +| `log` | Streams: `XADD`, `XRANGE` for catch-up, one blocking `XREAD` per instance for the tail | +| `queue` | Streams + consumer groups: `XREADGROUP`, `XACK`+`XDEL`, `XAUTOCLAIM` for what a stopped consumer held, a sorted set for delayed retries | +| `direct` | `PUBLISH` for inboxes; a list (`RPUSH`/`BLPOP`) for a competing group | + +Stream ids (`-`) are the feed resume tokens, so a subscriber resumes +exactly where it stopped, on any instance. + +## Connections + +Redis blocks a whole connection while it waits, so the adapter opens more than +the one you inject: + +- one for the shared log tail (all topics multiplexed into a single + `XREAD BLOCK`); +- one per queue consumer (`XREADGROUP BLOCK`); +- one per RPC service group (`BLPOP`); +- one for subscriptions, shared by the backplane and by RPC inboxes. + +It opens them with `client.duplicate()` (or your `connect`) and quits only the +ones it opened. Budget accordingly on a managed Redis with a connection cap. + +## Trimming and retention + +A log topic is a stream that grows until something trims it. Set `maxLen` for +an approximate cap per append, or run `XTRIM` on your own schedule — the +[feed](./feeds) answers `410` (and calls `onGap`) when a client resumes from +an id the trim has passed. + +A queue's stream, by contrast, trims itself: an acked, retried or +dead-lettered message is `XDEL`ed, so a queue that keeps up stays small. A +delayed retry rides a sorted set until it is due, which any instance may +promote — `ZREM` then `XADD` as one `EVAL`, so an entry either leaves the set +and lands on the stream or does neither. A client without `eval`, or a proxy +that refuses scripts, gets the two steps apart, with the entry put back on +the set when the stream refuses it. + +## Keys + +| Key | Holds | +| --- | --- | +| `:log:` | a log topic's stream | +| `:q:{}` | a queue's stream (consumer groups on it) | +| `:q:{}:delayed` | the queue's delayed retries (a sorted set, score = due time) | +| `:inbox:

` | an RPC inbox's pub/sub channel | +| `:inbox:
:list` | a service group's delivery list | +| `:inbox:
:members` | the group's live members (a sorted set, score = lease expiry) | + +The braces are a Redis Cluster **hash tag**: a queue's stream and its delayed +set hash to one slot, which is what lets the promotion script touch both. +Names are [encoded](../brokers#writing-an-adapter) into Redis' alphabet +first, at most 200 characters. + +## Sharp edges + +- **A consumer group starts at the beginning.** The first consumer of a queue + creates its group at `0`, so work produced before it existed is delivered. + The group's position then lives in Redis and survives restarts. +- **One RPC group per address.** Competing listeners share one list, so two + different groups on one address would compete rather than each receive. The + binding uses one group per service, which is exactly the supported shape. +- **A message for a group nobody serves waits on the list** until its TTL + (`inboxTtl`, shortened by an RPC request's own timeout) expires. With no + listener at all, `send` refuses with `503` instead — the group's members set + is how the sender can tell: each listener holds a lease there (renewed every + `inboxTtl / 3`), and the group is present while any lease is live. Leases + are timestamps by the listener's clock, so the instances' clocks must agree + within `inboxTtl`. +- **Pub/sub is at-most-once**, which is what the backplane's + [loss detection](../scaling#loss-detection) exists for. Feeds and queues are + Streams and do not share that property. + +## Running the tests + +`pnpm test` runs the Redis suites against an in-repo fake. Against a real +server: + +```bash +pnpm redis:up +REDIS_URL=redis://127.0.0.1:6379 node --test tests/broker/redis.integration.test.js +``` diff --git a/docs/guide/brokers/rpc.md b/docs/guide/brokers/rpc.md new file mode 100644 index 0000000..6b039e6 --- /dev/null +++ b/docs/guide/brokers/rpc.md @@ -0,0 +1,212 @@ +# RPC over a broker + +Services that already share a broker can call each other through it, with no +HTTP or WebSocket between them and no service discovery: every instance of a +service listens on one address, the broker load-balances, and a caller only +needs the broker and the service's name. + +```js +// the billing service +const { attachBrokerRpc } = require('@alexify/wrpc/broker'); +await attachBrokerRpc(server, broker, { service: 'billing' }); + +// a caller +const { connect } = require('@alexify/wrpc'); +require('@alexify/wrpc/broker'); // registers the 'broker' transport + +const billing = await connect('broker://billing', { transport: 'broker', broker }); +await billing.load('invoices'); +await billing.api.invoices.create({ orderId }); +``` + +The packets on the wire are ordinary wRPC packets — the same router, hooks, +sessions, validators and telemetry serve a WebSocket client and a broker one +side by side. The carrier is specified in +[the protocol reference](../../reference/protocol#broker-binding). + +::: warning Experimental +Part of the [`@alexify/wrpc/broker`](../brokers) family, which may change in a +minor release until every adapter has shipped. Needs a broker with the +`direct` capability: the in-process, Redis, NATS and RabbitMQ adapters have +one; Kafka does not. +::: + +## Two modes + +| `mode` | Like | Carries | Instances | +| --- | --- | --- | --- | +| `'stateless'` (default) | the `http` transport | calls and batches | any instance answers any call | +| `'session'` | the `ws` transport | calls, events, subscriptions, cancellation, streams | one instance per session, found at connect | + +**Stateless** is the one to reach for between services: nothing to keep alive, +nothing to lose when an instance goes away, and a request is handled on the +server exactly like a packet-mode HTTP POST — sessions come from its headers, +batches work, and a subscription is refused with `400`. + +**Session** is the whole protocol. The client's `hello` reaches one instance, +which answers with its own inbox; every later frame goes there, numbered in +both directions. It is what you want when a service subscribes to another's +feed, or streams a file to it. + +```js +const reports = await connect('broker://reports', { + transport: 'broker', + broker, + mode: 'session', + headers: { authorization: `Bearer ${serviceToken}` }, +}); +for await (const row of reports.api.export.rows.iterate({ since })) handle(row); +``` + +## What happens when an instance goes away + +A session lives on one instance, so losing it is a lost connection, and the +client's ordinary [reconnect](../client#reconnecting) takes it from there: + +- An instance that **closes** (`server.close()`) says `bye` to every session it + holds; the client reconnects, its `hello` is taken by another instance, and + subscriptions resume from `lastEventId`. +- An instance that **dies** says nothing. The client's heartbeat stops getting + `pong`s and the client reconnects the same way. +- A **frame the broker dropped** shows up as a gap in the frame numbers, on + whichever side receives the next one. The session is treated as lost rather + than continuing with a call nobody will ever answer. +- A client that dies holds a server-side session until `idleTimeout` + (default 90 s, three client heartbeats) ends it. + +Stateless requests have none of this: a request nobody took fails its calls +with `503` when the broker can tell (NATS, the in-process broker), or with +the call timeout otherwise. + +## What the broker's ACL is the boundary for + +The binding trusts the broker to deliver a message to the address it names +and nobody else. A stateless `response` is matched to its `request` by the +correlation id on the client's **own inbox**: whoever can publish to that +inbox can answer a request, and whoever can publish to the service address +can make one — the same trust a socket places in the network. A session id is +chosen by the client (`generateId`), names the session in every frame, and is +never handed to a second sender: a `hello` naming a session that has seen a +frame, or coming from another inbox, is ignored. Where the broker's ACL is +not enough — a shared cluster, a topic others can read or write — +[`encryption`](../encryption#brokers) binds every frame to its address, kind, +correlation id and sequence number, so a frame does not open anywhere else. + +## Draining + +A server that starts [draining](../production#graceful-shutdown) stops +consuming the service address — new requests and new sessions go to the +other instances — while the sessions already here keep running until the +close. + +## Server options + +| Option | Default | | +| --- | --- | --- | +| `service` | — | Served at `wrpc.` | +| `address` | — | The service address outright, instead of `service` | +| `idleTimeout` | `90000` | A session silent this long is ended | +| `highWaterMark` | `1024` | Unconfirmed frames per session before `write()` reports backpressure | +| `maxSessions` | `10000` | Sessions one instance holds at once; a `hello` past it gets `bye` (`too many sessions`), reported as `broker.rpc.capacity`. `0` lifts the cap | +| `sessions` | `true` | `false` serves stateless requests only | +| `compression` | off | Per-message deflate on the binding — see [Compression](#compression) | +| `maxMessage` | 16 MiB | The largest inflated frame accepted; past it the session ends | + +`attachBrokerRpc` resolves with `{ address, inbox, sessions, healthy, stop() }` +and stops by itself when the server closes. + +Wire `healthy` into readiness next to `server.rpc.healthy`. It is `false` once +the binding stopped, and while either of its two listeners — the shared +service address, this instance's own inbox — says it is not receiving: + +| Broker | A listener is not receiving when | +| --- | --- | +| Redis | the service's `BLPOP` is failing (`broker.redis.blpop`) or its presence lease cannot be renewed (`broker.redis.presence`), until the next one works; the listener's connection reports itself down (`status` on ioredis, `isReady` on node-redis) | +| NATS | the subscription reported an error (`broker.nats.subscription`); the connection is closed | +| RabbitMQ | the server cancelled the consumer (`broker.amqp.cancelled`); its channel closed; the connection is lost | +| memory, a custom broker | never — the adapter reports nothing, and that reads as healthy | + +A Redis listener recovers by itself and turns healthy again. A NATS +subscription that erred and a RabbitMQ listener that lost its consumer or +channel do **not** come back: `healthy` stays `false`, which is the signal to +restart the instance — the binding does not re-listen on its own. What +`healthy` cannot see is a broker that accepts the listener and delivers +nothing: that is the client's timeout, and the broker's own metrics. + +## Client options + +`connect('broker://', { transport: 'broker', ... })` takes the usual +[client options](../client) plus: + +| Option | Default | | +| --- | --- | --- | +| `broker` | — | A broker with `direct`, or the capability | +| `mode` | `'stateless'` | `'session'` for the full protocol | +| `address` | `wrpc.` | Where the service listens | +| `requestTimeout` | `30000` | How long the broker may hold a request nobody took | +| `compression` | off | Per-message deflate — connect()'s own option, shared with the socket transports | +| `maxMessage` | 16 MiB | The largest inflated frame accepted | + +`headers` and `meta` travel on every stateless request and on a session's +`hello`, where the server reads them as a WebSocket upgrade's. + +## Compression {#compression} + +A broker carries the bytes it is handed — and NATS and Kafka cap a message +at 1 MiB, Redis' `direct` wraps a binary body in base64. Per-message +compression is **off by default**, like every compression knob in wRPC, +and negotiated so a client and a service can be upgraded in any order: + +```js +await attachBrokerRpc(server, broker, { service: 'billing', compression: true }); +const billing = await connect('broker://billing', { transport: 'broker', broker, compression: true }); +``` + +- A **session** names its codecs on `hello` (`wrpc-enc: zstd,deflate-raw`, + in its [order of preference](../compression#list)); a server that holds + one of them answers its own list on `welcome`, and from then on every frame + past the threshold (1 KiB) travels compressed in both directions — + packets, events, subscription values and stream chunks alike — with + `wrpc-enc` naming the codec on the frames that are. `{ compress: false }` on an emit + sends that one plain. A server without the option answers no `wrpc-enc` + and the session runs plain. +- A **stateless request** lists the codecs it accepts and travels plain + itself (the client cannot know which instance takes it); the answer + comes back compressed — with the instance's first codec on that list — + when the body is past the threshold — the shape of HTTP's `Accept-Encoding`. +- A frame marked compressed that the receiver cannot inflate (no codec + agreed, another codec, a body past `maxMessage`) ends the session like a + sequence gap, and the client reconnects. + +Node↔Node by construction, so the codec must answer synchronously: the +platform codec (raw deflate through `node:zlib`) does; `{ codec }` injects +another — `'zstd'` for large answers, the router +[dictionary](../compression#dictionary) for small ones, best as a +[list](../compression#list) with `'deflate-raw'` behind it — and a +promise-answering one is refused at construction. What it costs, +`bench/broker.js` over the in-process `MemoryBroker`: a session call +answering a 9 KB result runs at 8,273/sec plain and 6,029/sec compressed — +about 45 µs per round trip for the deflate and the inflate, against ~10× +fewer bytes through the broker. On a real broker the bytes are the part +that costs; the [WebTransport page](../wt#compression) has the codec's own +numbers, since the seam is shared. + +A session's id is what its client speaks under, so it is a credential: log +lines and the transport's `source` carry a 12-character fingerprint of it, +enough to correlate a session across lines and never enough to speak on it. + +## Tracing across the broker + +Both halves are instrumented by the ordinary paths, not by anything special +to this transport: a stateless request reaches the server through +`handleHttpCall` and a session through `attach`, and the client's own call +bracket runs whatever carrier is underneath. So a call over a broker produces +a CLIENT span on one side and a SERVER span on the other, joined into one +trace by the `tp`/`ts` fields the packet already carries. + +Pass [`telemetry`](../telemetry) to both ends and the broker hop is visible +the same way a WebSocket one is — with `wrpc.transport` naming the carrier. + +The session id doubles as the correlation id, and the server holds each +session's frame state under it, so it is worth minting with a generator of +known strength — see [Identifiers](../production#identifiers). diff --git a/docs/guide/browser.md b/docs/guide/browser.md index 9e16ab1..9578270 100644 --- a/docs/guide/browser.md +++ b/docs/guide/browser.md @@ -27,6 +27,7 @@ Two mechanisms, both declared in `package.json`: | --- | --- | --- | | `index.js` | `browser.js` | The browser barrel excludes the server half entirely. | | `sse.js` | `sse.browser.js` | A browser needs the [SSE](./sse) client transport, never the channel registry. | +| `webrtc.js` | `webrtc.browser.js` | A browser peer needs the [WebRTC](./webrtc) peer, link and signaler client, never the server-side signaling unit. | | `src/chunks.js` | `src/chunks.browser.js` | `Buffer` vs `TextEncoder`/`TextDecoder` for [binary framing](./streams). | | `src/runtime/node.js` | `src/runtime/browser.js` | `node:crypto` vs `globalThis.crypto` for id generation. | @@ -72,17 +73,23 @@ 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 | 82.3 KB | **27.7 KB** | 28.0 KB | +| `@alexify/wrpc/sse` — browser | 85.5 KB | **28.8 KB** | 29.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` — node | 158.2 KB | 52.2 KB | — | +| `@alexify/wrpc/auth` | 3.9 KB | **1.8 KB** | 2.0 KB | +| `@alexify/wrpc/deflate` | 10.7 KB | **4.4 KB** | 5.0 KB | +| `@alexify/wrpc/encryption` — browser | 31.5 KB | **11.5 KB** | 12.0 KB | +| `@alexify/wrpc/webrtc` — browser | 179.2 KB | **58.3 KB** | 59.0 KB | +| `@alexify/wrpc` — node | 300.4 KB | 101.0 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. The SSE entry is the browser entry **plus** the SSE transport — you pay the -extra ~1 KB only if you import it. +extra kilobyte only if you import it. [`@alexify/wrpc/deflate`](./compression#deflate) +and [`@alexify/wrpc/encryption`](./encryption) sit outside every other entry: +only a page that imports them pays for them. The [WebRTC](./webrtc) entry is a client +**and** a server (a peer serves a router), which is what its budget buys. [`@alexify/wrpc/query`](./query) requires nothing at all (that is what keeps it at 1 KB); it takes the client and your `QueryClient` by injection. @@ -96,20 +103,24 @@ comment saying why. | Transport | Use it when | | --- | --- | -| `ws` (default) | Normal. Full duplex, binary streams, everything on this site. | +| `ws` (default) | Normal. Full duplex, binary streams, every feature a connection carries. | | `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`. | +| `wt` | [WebTransport](./wt) over HTTP/3 (experimental): binary streams that do not block each other or the calls, unreliable events; `['wt', 'ws']` falls back where the browser has none. In the main entry — nothing to import. | +| `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 @@ -120,8 +131,40 @@ await proxy.open(); const client = await WrpcClient.connect(url, { worker: navigator.serviceWorker.controller }); ``` -The packets are identical on both hops, so nothing above the transport changes. -See [Client → Service Workers](./client#service-workers). +```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 +— [binary attachments](./streams#attachments) included: a frame crosses the +port as bytes and is routed by the packet inside it, an answer to the tab +that asked, an event to every tab. +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 reaches most of the mobile web now (Chrome and Firefox for +Android, recent Safari on iOS), but Samsung Internet and Opera Mobile still +don't ship it — feature-detect (`typeof SharedWorker !== 'undefined'`) and +fall back to a direct connection rather than assuming it. The proxy releases +a closed tab's port on the page transport's goodbye (`close()` on the +client) and on the `MessagePort` `close` event; a tab that vanishes without +either, on an engine that never fires the event, keeps its entry until the +worker goes — as it always has for a Service Worker. A release cancels what +the tab was still waiting for upstream (its calls, its subscriptions), and +an answer arriving for a tab that left is dropped, never handed to the +others; events and server-opened streams still reach every tab. +::: 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/cli.md b/docs/guide/cli.md index fc8cea4..6983e16 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -46,7 +46,7 @@ wrpc types http://localhost:8000/api --out api.d.ts --schema api.static.js wrpc types http://localhost:8000/api --out - # stdout, for piping ``` -`--package` matters in a monorepo that re-exports wrpc under its own name: the +`--package` matters in a monorepo that re-exports wRPC under its own name: the generated file imports `SubscriptionContract` from wherever you say. `--schema` writes a second artifact from the same fetch: the raw diff --git a/docs/guide/client.md b/docs/guide/client.md index 1e14b83..4192745 100644 --- a/docs/guide/client.md +++ b/docs/guide/client.md @@ -22,17 +22,45 @@ variable — call through `client.api` each time. ## Transports -The URL scheme picks the transport; `options.transport` overrides it. - -| Transport | Scheme | Calls | Events, subscriptions, cancel | Binary streams | -| --- | --- | --- | --- | --- | -| `ws` | `ws:` / `wss:` | ✅ | ✅ | ✅ | -| `http` | `http:` / `https:` | ✅ | ❌ (code 400) | ❌ | -| `sse` | `http:` / `https:` | ✅ | ✅ | ❌ | -| `event` | — | ✅ | ✅ | ✅ | - -`sse` has to be registered before it can be named — see [Server-Sent -Events](./sse): +One router answers on every transport below — the same procedures, rooms and +sessions, whichever one a client arrives on. An `http:`/`https:` URL means +`http` and any other means `ws`; `options.transport` names any other +transport, or an ordered fallback list. + + + +| Transport | Chosen by | Calls | Events, subscriptions, cancel | Binary streams | Bytes in a packet | Compression | Encryption | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `ws` | `ws:` / `wss:` URL | ✅ | ✅ | ✅ | ✅ | ✅ | Noise | +| `http` | `http:` / `https:` URL | ✅ | ❌ (code 400) | ❌ | ✅ | ✅ | HPKE | +| `sse` | `transport: 'sse'` | ✅ | ✅ | ❌ | ❌ | ✅ | HPKE | +| `wt` | `transport: 'wt'` | ✅ | ✅ | ✅ | ✅ | ✅ | Noise | +| `webrtc` | `transport: 'webrtc'` | ✅ | ✅ | ✅ | ✅ | ✅ | DTLS | +| `event` | `worker` | ✅ | ✅ | ✅ | ✅ | — | — | +| `broker`, stateless | `transport: 'broker'` | ✅ | ❌ (code 400) | ❌ | ❌ | ✅ | sealed | +| `broker`, session | `transport: 'broker'`, `mode: 'session'` | ✅ | ✅ | ✅ | ✅ | ✅ | sealed | + +- **Bytes in a packet** are [binary attachments](./streams#attachments): a + `Uint8Array` in arguments, a result or an event arrives as bytes. Where the + cell is ❌ — SSE and a stateless broker request, both [revision + 1](../reference/protocol#versioning) — bytes travel as the JSON objects 1.0 + made of them. +- **Compression** is off everywhere until both ends turn it on — + [Compression](./compression) has the knob for each wire. A worker port never + leaves the process, so there is nothing to compress. +- **Encryption** is the layer _above_ TLS, opt-in and experimental: a + [Noise session](./encryption#session) on `ws` and `wt`, HPKE per request on + `http` and `sse`, [sealed broker messages](./encryption#brokers). A WebRTC + data channel is already DTLS end to end; a worker port never leaves the + process — give `encryption` to the `WrpcClientProxy` in the worker instead. + +`wt` is [WebTransport](./wt) (experimental), in the base entry so that +`transport: ['wt', 'ws']` — WebTransport where the browser has it, a WebSocket +otherwise — needs no import. `webrtc` is a [WebRTC](./webrtc) data channel to a +peer, `broker` is [RPC over a message broker](./brokers/rpc) between services +(experimental), `event` is a [worker](#workers). `sse` has to be registered +before it can be named — see [Server-Sent Events](./sse): ```js require('@alexify/wrpc/sse'); @@ -42,6 +70,41 @@ const client = await WrpcClient.connect('https://host/api', { transport: 'sse' } Anything HTTP cannot carry is an **error**, not a silent no-op: asking for a subscription over HTTP is answered with code `400` rather than hanging. +::: tip Compression is the server's decision — and, in Node, one-directional +`ws` frames are compressed only when the server enables `perMessageDeflate` +(off by default). A browser then compresses both directions itself; a Node +client does not — its built-in `WebSocket` inflates but never deflates, so +client→server frames from Node are sent as-is unless the client's own +`compression` option is on and the server accepts it +([the Node client's frames](./server#node-client-frames)). See +[performance](./performance#compression-is-off-by-default). +::: + +### Injecting `fetch` (http/sse) + +The `http` and `sse` transports call `fetch` for every request; `options.fetch` +lets you hand in your own implementation instead of the runtime's global one — +re-resolved on every open, like `headers`. The intended use is a Node process +that talks wRPC to another wRPC server (a microservice calling a sibling +service) and wants undici's connection pooling, proxying, or caching tuned for +that traffic, without wRPC depending on undici itself: + +```js +const { Agent, fetch: undiciFetch } = require('undici'); + +const agent = new Agent({ keepAliveTimeout: 10_000, connections: 128 }); +const client = await WrpcClient.connect('http://internal-service/api', { + transport: 'http', + fetch: (url, init) => undiciFetch(url, { ...init, dispatcher: agent }), +}); +``` + +This is **not** a way to reach arbitrary third-party REST APIs through wRPC — +the http/sse transports only ever call the one connected wRPC server (packet +POSTs, or a [mapped REST leg](./rest) against that same server's own base +URL). Calling another service's API is still a plain, direct `fetch`/undici +call; `options.fetch` only tunes the transport wRPC itself uses. + ## Options ```js @@ -62,15 +125,17 @@ await WrpcClient.connect(url, { | `heartbeat` | `{ interval: 30000, timeout: 10000 }` | `false` disables it. | | `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. | +| `transport` | from the URL | `'ws'`, `'http'`, `'sse'`, `'wt'`, `'webrtc'`, `'broker'` or anything registered — or an ordered fallback list such as `['wt', 'ws']`. See [Transports](#transports). | +| `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). | | `meta` | — | Connection-phase metadata (unvalidated); per-call twin via `{ meta }` / `withMeta()` — see [Metadata](./metadata). | +| `carrier` | `'auto'` | ws only — how `headers`/`meta` leave on the handshake: real headers from Node, subprotocol tokens from a browser; `'protocol'` / `'query'` force one — see [Choosing the ws carrier](./metadata#choosing-the-ws-carrier-carrier). | +| `fetch` | global `fetch` | http/sse only — see [Injecting `fetch`](#injecting-fetch-http-sse). | | `random` | `Math.random` | Jitter source; injectable so tests can pin the schedule. | -| `generateId` | uuid v4 | Packet/subscription/stream ids — bring your own (cuid/ulid/a test counter). Correlation ids, not secrets; stream ids must stay within 255 UTF-8 bytes. | -| `protocols` | `['wrpc.v1']` | WebSocket subprotocols to offer; the server echoes the wire revision back. `[]` offers nothing. | +| `generateId` | uuid v4 | Packet/subscription/stream ids, and the [broker transport's](./brokers/rpc) session and correlation ids — bring your own (cuid/ulid/a test counter). Correlation ids, not secrets; every id must stay within 255 UTF-8 bytes. | +| `protocols` | `['wrpc.v2', 'wrpc.v1']` | WebSocket subprotocols to offer: the [protocol revisions](../reference/protocol#versioning) this client speaks, newest first, of which the server selects one — a 1.0 server selects `wrpc.v1`, and is then spoken to as 1.0 was (`client.revision` reads `1`). `['wrpc.v1']` alone under `attachments: false`. `[]` offers nothing — revision 1, and a browser's declared bags go through the connect-URL query, since a carrier token needs a protocol the server can answer. | | `logger` | off | A Console or pino-shaped logger; observes errors in addition to the `'error'` event. | | `telemetry` | off | OTel tracer/meter/api — see [Telemetry](./telemetry). | @@ -198,7 +263,11 @@ The hook is awaited in two places: - **Inside `open()` on the first connect** — `await connect(...)` resolves an already-authenticated client. A throw here rejects `connect()` and leaves - nothing behind (no reconnect timer, no registered connection). + nothing behind (no reconnect timer, no registered connection) — with a + [transport list](#transport-fallback) too: the hook's refusal is the + application's verdict on the connection, not a transport failure, so no + further candidate is tried behind its back. Only a candidate that never + opened hands over to the next. - **On every reconnect, BEFORE the restore** — the subscriptions are re-opened and the units re-loaded only after the hook resolved, so a `session`-gated feed resumes instead of being refused with a terminal 403. @@ -269,7 +338,10 @@ const client = await WrpcClient.connect('wss://host/api', { There is deliberately **no default order** — the list is yours. Every name is validated up front (a fallback that fails at fall-back time is a fallback nobody tested), and `'event'` cannot appear in one (it is selected through -`worker`, not by URL). +`worker`, not by URL). Neither can `'webrtc'` in practice: that transport, +registered by [`@alexify/wrpc/webrtc`](./webrtc), speaks over a `link` (or a +raw data `channel`) given in the options rather than to a URL, and `WrpcPeer` +constructs it for you. The semantics: @@ -288,6 +360,10 @@ The semantics: in the list if feeds matter: it carries events, subscriptions and cancel (everything but binary streams). +A fallback list is several ways to reach **one** backend. Talking to several +*different* backends at once — a REST service, a realtime one, a local +worker — is a separate concern; see [Multiple backends](./multiple-backends). + ## Heartbeat A browser `WebSocket` exposes no protocol-level ping, so a connection that died @@ -299,9 +375,12 @@ The client therefore sends an application-level `{ type: 'ping' }` every `interval` and expects a `{ type: 'pong' }` within `timeout`; a miss emits `heartbeat-timeout` and forces a reconnect. -Each transport decides whether it wants one: the WebSocket and -[SSE](./sse) transports do, and the plain HTTP transport does not — a -request/response transport has no connection to keep alive. +Each transport decides whether it wants one: the persistent ones — the +WebSocket, [SSE](./sse), [WebTransport](./wt), a [WebRTC](./webrtc) data channel +and a [broker session](./brokers/rpc) — do; plain HTTP and a stateless broker +request do not, since a request/response transport has no connection to keep +alive. Behind a [worker](#workers), the `WrpcClientProxy` keeps the heartbeat +with the server, not the page. ## Events @@ -352,10 +431,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'); @@ -364,15 +444,35 @@ 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 — two +workers from one page are two independent clients; 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. + +This is also how a purely local backend — one fronting `IndexedDB`, say — +joins a client that otherwise talks to remote services over `ws`/`http`/`wt`; +see [Multiple backends](./multiple-backends#a-local-backend-behind-a-worker) +for the combined picture, including a sharp edge in `getInstance`'s +per-page singleton worth knowing about before you reach for two worker +targets from one page. ## In a browser bundle @@ -382,6 +482,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 ~28 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/cluster.md b/docs/guide/cluster.md index 004abf3..699a176 100644 --- a/docs/guide/cluster.md +++ b/docs/guide/cluster.md @@ -47,7 +47,7 @@ sequenceDiagram Both are held for the life of the process: `cluster`, which every instance subscribes to (presence, wide requests, commands), and `inst:`, one instance's own inbox for answers and addressed commands. The envelopes -are an implementation detail of wrpc's cluster layer, not part of the frozen +are an implementation detail of wRPC's cluster layer, not part of the frozen [wire protocol](../reference/protocol#cluster-channels) — they never reach a client connection. @@ -78,7 +78,10 @@ from "same process, merge them". | `requestTimeout` | `2000` ms | Backstop for `fetchClients`/`ask`; resolves `incomplete`, never rejects. | | `rooms` | all | Which rooms replicate: an array, predicate or RegExp. See the cardinality note below. | | `maxFetch` | `1000` | Per-node ceiling on one `fetchClients` reply; a node over it answers its first `maxFetch` descriptors and the result carries `truncated: true` (never silent). `0` disables. | -| `secret` | — | Opt-in HMAC-SHA256 envelope authentication — see [Trusting the backplane](#trusting-the-backplane). | +| `secret` | — | Opt-in HMAC-SHA256 envelope authentication, with replay protection — see [Trusting the backplane](#trusting-the-backplane). | +| `replay` | `'strict'` | Under `secret`: what to do with a signed envelope that carries no counter, which is what a 1.x node sends. `'strict'` refuses it; `'accept'` lets it through during a rolling upgrade from 1.x. | +| `maxSkew` | `30000` ms | Under `secret`: how far an envelope's clock may sit from this node's, both ways. | +| `compression` | off | Deflate the cluster envelopes this node publishes, after signing — the same marker and two-step rollout rule as [`rooms.compression`](./scaling#compression); an unreadable envelope logs `cluster.encoded`. | `cluster: false` opts out of the cluster layer entirely: presence, commands and asks degrade to their local halves while the [rooms @@ -108,6 +111,50 @@ message is dropped and logged (`cluster.unsigned` / `cluster.badsig`). Room *events* travel on separate channels and are not signed — the secret guards the command surface, the broker ACL guards the rest. +A signature alone says a command **was written by a node holding the +secret** — not when, and not where. So under `secret` every envelope also +carries, inside what is signed, the sender's **counter**, the **channel** +it was published on and the sender's **clock**, and a receiver refuses +(`cluster.replay`, with a `reason`): + +| `reason` | What arrived | +| --- | --- | +| `seq` | A counter this node already accepted from that sender, or one older than its window of 1024 — a copy of an earlier envelope | +| `channel` | An envelope on a channel other than the one it was signed for — an addressed command moved to another instance's inbox, or onto `cluster` | +| `stale` | A clock further than `maxSkew` from this node's, or an envelope of a **previous life** of a sender whose restarted process this node already follows | +| `unsequenced` | A signed envelope with no counter at all — a 1.x node's | + +So a party that can read the backplane and write to it (a compromised +broker, a client with a wider ACL than intended) cannot run a `disconnect` +again, move a `join` to another node, or bring a dead process' presence +back. Two things follow for operations: + +- **Clocks.** The nodes of a cluster keep their clocks within `maxSkew` + (30 s by default) — a node outside it is refused by the others as `stale` + and never joins. It is not silent about it: the `cluster.replay` line + carries the `skew` in ms, and after three refusals in a row the cluster + emits `'degraded'` with `{ instance, reason: 'skew', skew }` — and + `'recovered'` with `{ instance }` once one of its envelopes is heard. The window is also the one bound on what a node that + was *not listening* can be fed: a counter window only remembers what + this process heard, so a freshly booted node accepts an envelope up to + `maxSkew` old that it has not seen. Lower it where the clocks allow. +- **Upgrading from 1.x.** A 1.x node signs but does not count, so a 2.0 + node refuses it and the two halves of a mixed cluster do not see each + other. Deploy 2.0 with `cluster: { secret, replay: 'accept' }` while any + 1.x node is left — that waives the counter of a node that has none, and + nothing else — then drop the option. (A 1.x node reads a 2.0 envelope as + it always did: the new fields are inside the bytes it verifies.) + +The refusal is logged once per sender and reason each `presenceTimeout` +(`debug` in between — a copy can be published in a loop) and always +counted, as `wrpc.cluster.verifications` with outcome `replay`. + +What `secret` does not do is hide anything: the envelope is still +readable JSON on the broker. Where the backplane itself is in the threat +model, add `cluster: { encryption }` — the sealed frame has its own +counter window and is bound to its channel as well — see +[Encryption](./encryption#backplane). + ### Health `cluster.healthy` (and the aggregate `server.rpc.healthy`) is `false` while @@ -140,8 +187,8 @@ A descriptor is deliberately small and serializable: | `id` | The client id, instance-prefixed (`.`). | | `instance` | Which node holds the connection. | | `rooms` | Its room memberships on that node. | -| `data` | `client.data` — the application's own bag; wrpc never reads it. | -| `transport` | `'ws'`, `'http'`, `'sse'` or `'event'`. | +| `data` | `client.data` — the application's own bag; wRPC never reads it. | +| `transport` | `'ws'`, `'sse'`, `'event'`, `'wt'`, `'webrtc'` or `'broker'` — the kinds that hold a connection. | | `session` | Whether a [session](./sessions) is attached — never the session itself. | Only **persistent** clients are enumerated: a per-request HTTP client is not a @@ -175,6 +222,34 @@ Commands are fire-and-forget with the backplane's at-most-once delivery: they are the right tool for "kick these connections", and the wrong one for anything that must be exactly-once. +### One event to one client + +```js +server.sendTo(clientId, 'chat/dm', { text: 'hi' }); // here, or on the node its id names +server.sendTo(clientId, 'signaling/signal', payload, { room: 'lobby' }); // only while it is still in `room` +``` + +`server.sendTo` is the id-addressed counterpart of `to(room).emit`: a local +id is delivered directly, a foreign one becomes an addressed `event` command +on that node's channel (`server.cluster.send` is the same call without the +local short-cut). `room` bounds the delivery to a client still in that room — +a relay tied to a membership must not outlive it. It returns `true` when the +event was delivered locally or handed to the backplane and `false` when it is +known undeliverable (no such local client, a per-request HTTP client, not in +`room`, a foreign id with no backplane). The remote leg is at-most-once, +like every command. + +**Bytes cross as bytes.** A `Uint8Array` (or `Buffer`, typed array, +`ArrayBuffer`) anywhere in `data` reaches the other instance as a +`Uint8Array`, exactly as `to(room).emit` carries it: the command envelope +leaves as a binary [attachments frame](../reference/protocol#cluster-channels) +instead of JSON — signed, compressed and sealed like any other. That is +what makes `sendTo` the 1:1 relay of an [end-to-end sealed +payload](./encryption#end-to-end). The same holds for `cluster.sendEvent`, +for the question and the answers of `cluster.ask`, and for the answers a +broadcast `ask` collects from other instances. `attachments: false` keeps +all of it the JSON of 1.0. + ## Node-to-node messaging ```js @@ -185,7 +260,7 @@ server.cluster.respond('stats', async () => ({ load: cpu() })); const { answers, errors, incomplete } = await server.cluster.ask('stats'); ``` -As everywhere in wrpc, `emit` is the local `Emitter` emit and the wire send is +As everywhere in wRPC, `emit` is the local `Emitter` emit and the wire send is `sendEvent`. One responder per name — two answers to one question are ambiguous, so a duplicate `respond()` throws. A node **without** a responder contributes an entry in `errors`, not silence. diff --git a/docs/guide/codec.md b/docs/guide/codec.md index 6e91766..c37da7d 100644 --- a/docs/guide/codec.md +++ b/docs/guide/codec.md @@ -1,8 +1,9 @@ # Wire codec -By default every wrpc packet travels as JSON. The `codec` option replaces -that framing with your own — superjson, devalue, an encrypting wrapper — -injected on **both** sides: +By default every wRPC packet travels as JSON. The `codec` option replaces +that framing with your own — superjson, devalue — injected on **both** sides +(for encryption, see [Encryption](./encryption): a codec is synchronous and +text-only, so it cannot reach WebCrypto and does not cover stream chunks): ```js const codec = { @@ -19,12 +20,14 @@ const client = await WrpcClient.connect(url, { codec }); ``` The shape is structural — `isCodec(value)` is the check the options run — -and wrpc imports nothing: the codec is yours. +and wRPC imports nothing: the codec is yours. ## Scope -The codec frames **wrpc packets**: WebSocket frames, packet-mode HTTP -(`POST {basePath}`, batches included), SSE `data:` payloads, worker ports. +The codec frames **wRPC packets**: WebSocket frames, packet-mode HTTP +(`POST {basePath}`, batches included), SSE `data:` payloads, worker ports, +WebTransport sessions, WebRTC data channels (`client: { codec }` on a +[peer](./webrtc#options)) and the [broker binding](./brokers/rpc). It deliberately does **not** touch: @@ -46,6 +49,10 @@ indistinguishable from a stream chunk on a WebSocket, and could not ride SSE at all. REST bodies have no such collision, which is exactly what the `rest` section below exists for. +A packet codec owns the wire, so [binary attachments](./streams#attachments) +are off under one: bytes in a packet are the codec's to carry (msgpack does +natively), and the default frame is not written. + ## REST bodies: codec.rest {#rest-bodies-codec-rest} The optional `rest` section of the same codec object re-frames **REST @@ -81,7 +88,7 @@ follow: negotiation. - **The core hosts serve it natively** — the node shell, express and uws pass bodies through as Buffers untouched. Express 4 with a global - `express.json()` would consume JSON-typed bodies before wrpc reads them; + `express.json()` would consume JSON-typed bodies before wRPC reads them; a binary `contentType` is unaffected. - **The fastify adapter refuses `codec.rest` next to delegated REST routes** (procedures with `http` mappings): delegation exists *for* @@ -90,7 +97,7 @@ follow: the mappings under the plugin. `codec.rest` without mappings registers normally. - Like the packet codec, `codec.rest` is an opt-in framing **outside** the - frozen 1.0 interop promise. + protocol's interop promise. ## The rules that follow @@ -108,7 +115,7 @@ follow: - **Content-Type**: packet-mode HTTP requests and responses carry `codec.contentType` when set. Under the [fastify adapter](./adapters/fastify) a non-JSON content type needs an app-side `addContentTypeParser` (as raw - text), or fastify rejects the body before wrpc sees it. -- The frozen [1.0 protocol](../reference/protocol) is JSON: a codec is an + text), or fastify rejects the body before wRPC sees it. +- The [protocol](../reference/protocol) is JSON: a codec is an opt-in framing **outside** that interop promise. Two peers you control, one codec — that is the contract. diff --git a/docs/guide/compression.md b/docs/guide/compression.md new file mode 100644 index 0000000..e3f4297 --- /dev/null +++ b/docs/guide/compression.md @@ -0,0 +1,364 @@ +# Compression + +Nothing in wRPC compresses anything by default — on any transport, in any +direction. That is a choice, not a gap: a deflate per message is CPU spent +for every peer to save bytes only some of them need, and the server's first +commitment is the cost per frame. This page is the map of where the knobs +are, what each one buys, and the two things they share — the codec seam and +the router dictionary. + +## Where the knobs are + +| Wire | Knob | Negotiated by | Page | +| --- | --- | --- | --- | +| WebSocket, server → client (and both ways from a browser) | `perMessageDeflate` on the engine, `contextTakeover`, `async` | the upgrade handshake (RFC 7692) | [performance](./performance#compression-is-off-by-default) | +| WebSocket, client → server from Node | `compression` on the client and `compression` on the server | the first ping/pong | [server](./server#node-client-frames) | +| HTTP, packet mode and REST | `http: { compression }`, `encodings` for Brotli / zstd / your own | `Accept-Encoding`, the server's order (a header past 256 bytes counts as absent) | [server](./server#compression) | +| Server-Sent Events | `sse: { compression }` | the opening GET's `Accept-Encoding`, per response | [SSE](./sse#compression) | +| WebTransport | `compression` on both ends | the capabilities message | [WebTransport](./wt#compression) | +| WebRTC | `compression` on both peers | the description signal (`caps`) — a raw channel by agreement | [WebRTC](./webrtc#compression) | +| The broker binding | `compression` on both ends | `hello`/`welcome`; a stateless request lists what it accepts | [broker RPC](./brokers/rpc#compression) | +| The rooms backplane, the cluster channels | `rooms: { compression }`, `cluster: { compression }` | nothing — the marker names the codec; [a list](#list) makes the rollout lossless | [scaling](./scaling#compression) | + +Under [session encryption](./encryption#session) compression happens +*inside* the sealed frame — ciphertext does not compress, so +`permessage-deflate` is switched off for such a connection — and +compress-then-encrypt leaks length: send a message that mixes a secret with +attacker-influenced data with `compress: false`. + +Every one of them is off until you turn it on, and every one is negotiated +where the wire allows it: a lone end is served plain. The +[protocol reference](../reference/protocol#compression) has the wire forms. + +## One codec, or a list {#list} + +`codec` takes one codec or a list in order of preference. Each end announces +the ids it holds, and **a sender compresses with the first codec of its own +list the other end announced**: + +```js +// A Node service that prefers zstd, serving Node clients that may be older +// and browsers whose CompressionStream may not have it: +attachBrokerRpc(server, broker, { compression: { codec: ['zstd', 'deflate-raw'] } }); +new WrpcPeer({ router, signaler, compression: { codec: ['zstd', 'brotli', 'deflate-raw'] } }); +``` + +That makes the list the fallback — a peer without zstd is served deflate +instead of plain — and lets the two directions differ: a server answers in +zstd a browser that sends deflate, and `transport.compression` shows both +(`{ encode: 'zstd', decode: 'deflate-raw' }`). A name the platform lacks +(zstd before Node 22.15, a format this browser has not) is **skipped in a +list** and refused when it is the only thing asked for. Lists that share +nothing leave the wire plain; nothing hangs up. + +The backplane negotiates nothing, so there the list means: **encode with the +head, decode anything on it**. A change of codec is then a rollout without a +lost envelope — every instance lists both (`['deflate-raw', 'zstd']`), then +the order is swapped (`['zstd', 'deflate-raw']`), then the old one is dropped. +Over a raw WebRTC data channel, which has no handshake either, both ends use +the head of their list. + +## What it costs, what it buys + +`bench/message-compression.js` and `bench/http-compression.js`, one message +at a time on Node (`node:zlib`): + +| Message | one-shot deflate | rate | +| --- | ---: | ---: | +| 108 B event | 1.1× | 129K/sec | +| 1.4 KB callback | 6.3× | 105K/sec | +| 24 KB callback | 13.7× | 35K/sec | +| SSE tick, one gzip member flushed per event | 7.8× | 90K/sec | + +The first row is why every per-message knob has a threshold (1 KiB): a +small message barely shrinks without history to lean on. There are two +ways to give it history. On a WebSocket, [`contextTakeover`](../reference/wire-format#context-takeover) +keeps a zlib window per connection (10.6× on a repeated shape, for ~160 KiB +per direction per connection). Everywhere else — and with no state per +connection — a **dictionary**. + +### When a codec fails + +A message is never lost to compression: a codec that throws on the way out +sends the message plain, and one that cannot read what came in refuses that +frame and answers it. Which also means nothing looks broken — the traffic +is just several times what it was. So the server says it: + +- `compression.failed` — a warn, **once** per carrier, direction and codec + (`carrier`: `http`, `sse`, `wt`, `rooms`, `cluster`, `broker`; `direction`: + `encode` or `decode`; `codec`; `code` and `err`), from the first failure + on. A dictionary that stopped matching shows up here as `decode` failures + on one side. +- `wrpc.compression.failures` — the counter, every time, by + `wrpc.compression.carrier` and `wrpc.compression.direction`; alert on its + rate rather than on the line. +- On a WebSocket the refused frame has its own line, `frame.refused`, with + the `codec` and the `code`: `ERR_BUFFER_TOO_LARGE` is `maxMessage`, + anything else the bytes. + +## Which algorithm {#algorithm} + +Deflate is what `true` means everywhere, and that is a measured choice, not +an inherited one. `bench/algorithms.js`, one callback packet at a time +through `node:zlib` — bytes out and encode time; deflate 3 is the platform +codec's default, and every decode is 4–30 µs and decides nothing: + +| Plain | deflate 3 | deflate 6 | Brotli 4 | Brotli 11 | zstd 1 | +| --- | ---: | ---: | ---: | ---: | ---: | +| 361 B | 199 B · 11 µs | 199 B · 11 µs | 195 B · 18 µs | 160 B · 706 µs | 203 B · 7 µs | +| 1.8 KB | 385 B · 12 µs | 376 B · 16 µs | 334 B · 27 µs | 296 B · 2.2 ms | 396 B · 10 µs | +| 27 KB | 3,351 B · 46 µs | 3,196 B · 118 µs | 2,601 B · 93 µs | 1,748 B · **33 ms** | 2,848 B · 36 µs | +| 255 KB | 30.3 KB · 411 µs | 28.0 KB · 1.3 ms | 23.6 KB · 995 µs | 15.2 KB · **351 ms** | 26.7 KB · 283 µs | + +What follows from it: + +- **Up to ~2 KB — where RPC messages live — the three are level**, within a + few bytes and microseconds. Deflate is also the one format every + `CompressionStream`, every Node and the WebSocket extension have, and the + one the [router dictionary](#dictionary) works best with (a 150 B event: + 59 B with a deflate dictionary, 76 B with a zstd one). So it stays the + default, and a browser-facing service needs nothing else. +- **Deflate's knee is level 3**, the platform codec's default: zlib's levels + 1–3 are its fast strategy and 4+ the lazy one, so level 4 is both slower + *and* larger than 3, and level 6 buys 5% at 27 KB for 2.5× the CPU. + `deflateCompressor({ level: 6 })` when the bytes matter more. +- **From ~16 KB, zstd** is a third of deflate-6's cost and 10% smaller: the + pick for Node↔Node wires with large answers — the broker binding, the + backplane, a Node WebSocket client — as `codec: ['zstd', 'deflate-raw']`, + so an older Node still compresses. +- **Brotli 4 is the smallest at deflate-6's price**: the pick when the link + is the cost (mobile, metered egress). Never zlib's own default, quality + 11 — it is an archive setting, milliseconds a message — which is why + `'brotli'` and `brotliCompressor()` exist instead of two lines of zlib. +- **SSE stays gzip**, and [says why](./sse#compression): flushed per event + the other two save nothing and hold 2–3× the memory per open response. +- **`perMessageDeflate` stays deflate** because RFC 7692 is the only + WebSocket extension a browser implements; there is no algorithm to choose + there, only `level`, `contextTakeover` and `async`. + +LZ4, Snappy and the rest are not in `node:zlib`, and wRPC installs nothing — +but [the seam](#codec) takes them: wrap the package you chose in `id`, +`encode`, `decode` and put it first in the list. + +```js +const lz4 = require('lz4'); // your dependency, not wrpc's + +const lz4Codec = { + id: 'lz4', + threshold: 512, + // The inflated size goes in front: LZ4's block format does not carry it. + encode: (bytes) => { + const out = Buffer.allocUnsafe(4 + lz4.encodeBound(bytes.length)); + out.writeUInt32LE(bytes.length, 0); + return out.subarray(0, 4 + lz4.encodeBlock(bytes, out, 4)); + }, + // `decode` MUST refuse BEFORE it inflates — the cap is what bounds a + // compression bomb, and checking `out.length` afterwards is too late, the + // allocation has happened. The size read from the peer's bytes is only a + // claim: what bounds the work is the buffer the decoder is handed. + decode: (bytes, maxOutput) => { + const view = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const size = view.readUInt32LE(0); + if (size > maxOutput) throw new RangeError('inflated message exceeds the cap'); + const out = Buffer.allocUnsafe(size); + if (lz4.decodeBlock(view.subarray(4), out) !== size) throw new RangeError('lz4: bad block'); + return out; + }, +}; + +attachBrokerRpc(server, broker, { compression: { codec: [lz4Codec, 'deflate-raw'] } }); +``` + +## On the loop, or on the threadpool {#async} + +Every Node codec in wRPC deflates **synchronously** by default, and that is +a measured choice, not an oversight. `node:zlib` has two APIs: the +convenience calls that block the event loop for the length of the deflate, +and the callback ones that hand the work to libuv's threadpool. The hand-off +costs a fixed ~20 µs per call — a queue, a context switch, a copy back — +and a message that takes microseconds to compress does not amortize it +(`bench/zlib-async.js`): + +| Message | deflate, sync | deflate, threadpool (one at a time) | inflate, sync | inflate, threadpool | +| --- | ---: | ---: | ---: | ---: | +| 291 B | 8.6 µs | 28.4 µs | 3.4 µs | 22.3 µs | +| 2.6 KB | 15.9 µs | 35.8 µs | 4.3 µs | 22.8 µs | +| 27 KB | 100 µs | 120 µs | 19 µs | 44 µs | +| 276 KB | 970 µs | 1,007 µs | 148 µs | 259 µs | +| 1.1 MB | 4.3 ms | 3.8 ms | 578 µs | 1,136 µs | + +Below ~256 KB the threadpool only makes the message **slower**; at 256 KB +the two are level, and past it the loop is the thing being bought — a +1 MB result deflates in 4 ms, which is 4 ms during which no other +connection is served, and fifty peers' worth of it is 200 ms. **Inflate +never earns the hand-off**: it is eight times faster than deflate, so the +fixed cost wins at every size up to the 16 MiB cap. That is why every +knob has the same shape — synchronous, with an opt-in `async` threshold +for the encode side only: + +| Wire | The knob | Default threshold | +| --- | --- | --- | +| WebSocket, permessage-deflate | `perMessageDeflate: { async: { threshold } }` — [performance](./performance#compression-modes). Lenient by design: an unusable `threshold` falls back to the default without a TypeError, because the `./ws` engine's option predates the strict normalizer and must not import it | 256 KiB | +| HTTP | `http: { compression: { async } }` | 256 KiB | +| Server-Sent Events | none — a gzip stream already runs its writes off the loop | — | +| WebTransport, WebRTC | `compression: { async }` on the platform codec, `dictionaryCompressor(dict, { async })` on the dictionary one | 256 KiB | +| The broker binding, the backplane, a Node WebSocket client | none — refused: these carriers have no ordering queue to hide a promise behind | — | + +```js +// A host that answers megabyte results over data channels: those go to +// the threadpool, everything smaller stays on the loop. +new WrpcPeer({ router, signaler, compression: { async: true } }); +acceptSessions(server, sessions, { compression: { async: { threshold: 128 * 1024 } } }); +``` + +The WebTransport and WebRTC transports keep messages in order around the +promise (the same queue a browser's `CompressionStream` already needs), so +a small event sent after a large result still leaves after it. With +sixteen large messages in flight the four threadpool threads bring a +276 KB deflate to 242 µs of loop time each — a burst is where the option +pays; a single large message merely stops blocking. In a browser the +question does not arise: `CompressionStream` is asynchronous by +construction, and the pure-JS codec of `@alexify/wrpc/deflate` hands +messages past `nativeAbove` (4 KiB) to it for the same reason. + +## The router dictionary {#dictionary} + +One-shot deflate sees every field name and every method target for the +first time, in every message. A preset dictionary is those strings sitting +in the window before the first byte, and your router already knows them: + +```js +const { buildDictionary, dictionaryCompressor } = require('@alexify/wrpc'); + +const dictionary = buildDictionary(router); // ~0.5–32 KiB, deterministic +const codec = dictionaryCompressor(dictionary); + +await attachBrokerRpc(server, broker, { service: 'billing', compression: { codec } }); +new Server({ router, compression: { codec }, rooms: { compression: { codec } } }); +``` + +`buildDictionary` reads the router's introspection — field names from +`signature` and `schema`, every `unit/method` target and `unit/event` name, +then the packet skeletons every message starts with — ordered least to most +frequent (zlib finds recent bytes cheapest and looks at the last 32 KiB), +and cut from the front past the cap. Two instances of the same router +build the same bytes, and the codec's `id` carries their hash, so the +negotiation on each wire compares dictionaries, not just codecs: an +instance whose router differs — a rolling deploy — names another id, and +that pair stays plain rather than corrupt. `bench/dictionary.js`, on a +546 B dictionary from a small router: + +| Message | no history | with the dictionary | +| --- | ---: | ---: | +| 108 B event | 99 B (1.1×) | **55 B (2.0×)** | +| 84 B call | 77 B (1.0×) | **30 B (2.6×)** | +| 1.1 KB callback | 201 B (5.5×) | **160 B (6.9×)** | +| 27 KB callback | 1892 B (14.3×) | 1824 B (14.9×) | + +Throughput is the same (118K/sec against 111K on the event), so the +dictionary codec's threshold is 64 B rather than 1 KiB: small messages are +what it is for; a large one has all the history it needs inside itself. + +Where it works with the platform codec: every Node↔Node carrier — the +broker binding, the backplane envelopes, a Node WebSocket client's frames, +a Node peer on WebRTC or WebTransport. A **browser** has only +`CompressionStream`, which takes no dictionary — which is what the next +section is for. On a WebSocket from a browser the dictionary is impossible +in principle: the browser's own permessage-deflate does the compressing, +and `contextTakeover` is the tool instead. + +## The dictionary in a browser: `@alexify/wrpc/deflate` {#deflate} + +A DEFLATE codec in plain JavaScript, on its own subpath so a page that +does not inject it never loads a byte of it (4.4 KB min+gzip when it does): + +```js +import { createDeflateCodec } from '@alexify/wrpc/deflate'; + +// `dictionary`: the same bytes the server built — served by the app, or +// built here from the same router by a browser peer. +const codec = createDeflateCodec({ dictionary }); +const client = await connect(url, { transport: ['wt', 'ws'], compression: { codec } }); +new WrpcPeer({ router, signaler, compression: { codec: createDeflateCodec({ dictionary: buildDictionary(router) }) } }); +``` + +Its `id` is the Node dictionary codec's for the same bytes, so a browser +peer on this codec and a Node peer on `node:zlib` negotiate with each +other; without a dictionary it is the platform id, and the platform codecs +read it. The inflater is complete — stored, fixed and dynamic blocks, +whatever zlib or a `CompressionStream` on the other end chose — takes the +dictionary, and is capped like every decoder here. A dynamic block costs +what its header says — the code lengths it declares — plus a root +decoding table of fixed width and the sub-tables a prefix-free code +bounds by itself, never a table sized by the longest code a block claims: +a stream of blocks that declare 15-bit codes and emit nothing buys no +128 KB table per block, so `maxOutput` is not the only cap. The encoder is +deliberately simple: LZ77 against the dictionary, written as one +**fixed-Huffman** block, because on the messages this exists for a dynamic +tree costs more than it saves. `bench/deflate-js.js`: + +| Message | the codec, dictionary | zlib, dictionary | `CompressionStream` | +| --- | ---: | ---: | ---: | +| 108 B event | **51 B**, 700K/sec | 51 B, 128K/sec | 97 B, 22K/sec | +| 2 KB callback | 263 B, 68K/sec | 204 B, 68K/sec | 250 B, 17K/sec | +| 28 KB callback | 2645 B, 5.3K/sec | 1824 B, 11.5K/sec | 1892 B, 7.4K/sec | + +On the small message fixed codes produce *the same bytes* as zlib's dynamic +ones, five times faster than zlib's one-shot API — and inflating it back +runs at a million a second. A codec keeps its window and the dictionary's +hash chains between messages, so a message costs what the message costs: +the same 2 KB callback is 13 µs against a 4 KB dictionary and against a +32 KiB one (the one-shot `deflateRaw`, which hashes the dictionary on every +call, takes 32 and 116). That state is about 0.2 MB plus five bytes per +dictionary byte, built on the first message — make **one codec per page or +process**, not one per connection. Past a couple of kilobytes fixed codes +fall 30–45% behind, so the codec is a hybrid: from `nativeAbove` (4 KiB) +up, a message goes to the platform's `CompressionStream` — dynamic +Huffman, no dictionary, which a large payload does not need — and the +output still inflates on a peer holding the dictionary. That hand-off is +asynchronous and on by default in a browser only; in Node the codec +answers synchronously whatever the size, so it works on the Node↔Node +carriers too (where `dictionaryCompressor` on `node:zlib` is the faster +choice anyway). + +Tests are the main body of that subpath, not the codec: an interop matrix +both ways against `node:zlib` at every level and strategy and against the +platform streams, a fuzz corpus, and every malformed input answered with a +coded `DeflateError` rather than a wrong byte. + +## The codec seam {#codec} + +Every per-message knob takes `{ codec }`: anything with an `id`, +`encode(bytes)` and `decode(bytes, maxOutput)` — `isCompressor` is the +structural check, exported from the main entry. `codec` also takes the +**name** of a platform codec — the ids are `CompressionStream`'s format +names, so a Node peer and a browser peer negotiate the same one: + +| `codec` | Node | Browser | Default level | +| --- | --- | --- | --- | +| `'deflate-raw'` — what `compression: true` means | every Node | every `CompressionStream` | zlib 3 | +| `'brotli'` | every Node | some (the constructor answers) | quality 4 | +| `'zstd'` | 22.15+ / 23.8+, a `TypeError` before | some | 1 | + +```js +const { zstdCompressor, brotliCompressor } = require('@alexify/wrpc'); + +attachBrokerRpc(server, broker, { compression: { codec: 'zstd' } }); +new RpcServer({ router, backplane, rooms: { compression: { codec: zstdCompressor({ level: 3 }) } } }); +``` + +The factories (`deflateCompressor({ level })`, `brotliCompressor({ quality })`, +`zstdCompressor({ level })`, Node only) are for another level, threshold or +`async` than the name takes; an id names the format, never the level, so two +ends on different levels still negotiate. The defaults are measured +(`bench/algorithms.js`), not zlib's — and for Brotli that matters: zlib's own +default is quality 11, which takes **33 ms** on a 27 KB answer where +quality 4 takes 93 µs. In a browser a format the platform lacks leaves +compression off rather than throwing. The dictionary codec above is another +injection; yours is another still. +Either method may answer a promise on the socket transports (a +`CompressionStream` can only), and the transport keeps messages in order +around it; the Node↔Node carriers require a synchronous answer and say so +at construction. `decode` must stop at `maxOutput` bytes — that cap is what +bounds a compression bomb to the size a plain message is already bounded +at. diff --git a/docs/guide/encryption.md b/docs/guide/encryption.md new file mode 100644 index 0000000..4f8f899 --- /dev/null +++ b/docs/guide/encryption.md @@ -0,0 +1,479 @@ +# Encryption + +wRPC encrypts nothing by default, and the first thing to turn on is not on +this page: **TLS**. `protocol: 'https'` with a `key` and `cert`, or a proxy +that terminates it — `wss://` and `https://` are what protect a connection +from the network, and nothing here replaces them. A page served over HTTPS +cannot even open a `ws://` socket. + +This page is for where TLS **ends before the data does**. + +::: warning Experimental +The whole of `@alexify/wrpc/encryption` — the subpath, every `encryption` +option it feeds and the wire formats — is `@experimental`: it may change in a +minor release until the formats have been reviewed against real deployments. +See [Stability](../reference/stability#experimental-carve-outs). +::: + +## Do you need it? + +| Where the data goes | What TLS leaves readable | The knob | +| --- | --- | --- | +| The rooms backplane and the cluster channels (Redis) | every room event, presence delta, `sendTo` payload and `fetchClients` reply, as JSON — to the operator, a `MONITOR` session, a neighbour on a shared instance | [`rooms.encryption`, `cluster.encryption`](#backplane) | +| A message broker (Kafka, RabbitMQ, NATS, Redis Streams) | every RPC packet and published event, **at rest for the topic's retention** — and the bearer token in a client's headers | [`encryption` on every broker binding](#brokers) | +| A shared session store | each session's state, keyed by the bearer token itself | [`sealedStore()`](#sessions) | +| A TLS terminator you do not run — a CDN, a corporate proxy, a body-logging balancer | everything, from that box inward | [session encryption](#session) | +| `ws://` where no certificate can be had — a LAN device, two services on a private network | everything | [session encryption](#session) | +| The server itself, for a payload it only relays | the payload | [end-to-end helpers](#end-to-end) | + +If none of those rows is yours — a browser talking `wss://` to a server you +run — you do not need this, and it would only cost you: CPU per message, 16 +to 60 bytes of overhead, asynchronous WebCrypto in the page, and the shared +frame of every broadcast. + +Everything below is **off by default and stays off** until you turn it on, +on both ends, like [compression](./compression). + +```js +const encryption = require('@alexify/wrpc/encryption'); +``` + +## What it is built from + +Only what the platform already has — `node:crypto` on a server, WebCrypto in +a page — under their standard names, checked against the published test +vectors. wRPC adds no cryptography of its own and depends on no package. + +| Piece | Standard | Notes | +| --- | --- | --- | +| AEAD | **AES-256-GCM** (the default), ChaCha20-Poly1305 | ChaCha20-Poly1305 is Node-only: no browser's WebCrypto has it. Nonces are always counters, never random. | +| Key agreement | **X25519** (RFC 7748) | Chrome 133, Firefox 130, Safari 17, Node 22. | +| Key derivation | **HKDF-SHA256** (RFC 5869) | one label per use; a configured key is never used directly. | +| Session handshake | **Noise** — `Noise_{NN,NK,XX,NNpsk0}_25519_{AESGCM,ChaChaPoly}_SHA256` | the framework behind WireGuard and WhatsApp; verified against the cacophony vectors. | +| Per-request sealing | **HPKE** (RFC 9180), base / psk / auth modes | the construction of Oblivious HTTP (RFC 9458); verified against the RFC's vectors. | + +What wRPC does **not** build in, and takes by injection instead: + +| You want | Inject | +| --- | --- | +| XChaCha20-Poly1305 / NaCl / libsodium, AES-GCM-SIV, AEGIS | a [`Cipher`](#contracts), on the keyring layers and in HPKE (a Noise session names a built-in) | +| Post-quantum key exchange — ML-KEM, X-Wing, a hybrid | a [`Kem`](#contracts) for HPKE. Node 24.7+ has ML-KEM natively; no browser's WebCrypto does yet, which is why it is not a built-in. | +| Keys that live in a KMS or Vault | a [key provider](#keys) | +| Double Ratchet, MLS, JWE | nothing — they produce bytes, and wRPC [carries bytes](#end-to-end) | + +Deliberately absent: AES-CBC with a separate MAC, AES-CTR without one, RSA +for sessions. + +## Keys {#keys} + +Every symmetric use takes the same `keys` option: 32 bytes as a +`Uint8Array`, 64 hex characters or base64. + +```js +const { generateKey } = require('@alexify/wrpc/encryption'); +// once, into your secret store: +Buffer.from(generateKey()).toString('base64url'); +``` + +**Rotation** is a ring. The key id travels in the clear beside the +ciphertext and *selects* the key — nothing is ever tried until it fits: + +```js +keys: { current: 'k2', ring: { k1: process.env.KEY_1, k2: process.env.KEY_2 } } +``` + +Add the new key everywhere, make it `current` everywhere, drop the old one — +three deploys, no flag day. A key id is 1–32 of `A-Z a-z 0-9 . _ -`. + +On a **broker log or queue** the third deploy waits. A backplane message is +gone the moment it is delivered, but what a publisher sealed under the old key +stays in the topic for its retention and in the queue until it is consumed — +retries and dead letters included — and a service that dropped that key can +open none of it. Keep a retired key in `ring` for at least the topic's +retention plus the longest retry backoff, until the backlog sealed under it +has drained, and only then drop it; a key you dropped too early goes back +into the ring, no harm done. + +A **provider** puts the keys somewhere else. Both methods are synchronous — +they are read where a backplane message is opened, and that path cannot +wait — so unwrap your data keys at boot and refresh them on your own clock: + +```js +keys: { current: () => vault.currentKid, get: (kid) => vault.keys.get(kid) ?? null } +``` + +A provider is asked every time — `get(kid)` once per backplane message, +handshake or sealed request, ahead of anything derived from the key and +remembered — so a kid it stops answering is refused **from that lookup on**, +without a restart: the lever for a key that leaked. A kid names one key; +putting other bytes under a kid already in use is not a rotation. + +## The backplane {#backplane} + +```js +new Server({ + router, + backplane, + rooms: { encryption: { keys: process.env.WRPC_ROOMS_KEY } }, + cluster: { secret, encryption: { keys: process.env.WRPC_CLUSTER_KEY } }, +}); +``` + +Redis carries `wrpc-sealed::` and its operator reads nothing — +not the payload, not the event name, not the room list. About 3.5 µs per +1 KB envelope each way. The full story — what it binds, the three-deploy +rollout, what it cannot hide (the channel name *is* the room name) — is on +the [scaling page](./scaling#encryption). + +## Brokers {#brokers} + +A broker keeps what it carries. The same option, on every binding: + +```js +const encryption = { keys: process.env.WRPC_BROKER_KEY }; + +await attachBrokerRpc(server, broker, { service: 'billing', encryption }); +const client = await connect('broker://billing', { transport: 'broker', broker, encryption }); + +createPublisher(server, broker, table, { encryption }); +brokerFeed(broker, 'orders', { encryption }); +await attachConsumers(server, broker, {}, { encryption }); +``` + +**The headers move inside with the body** — so the `authorization` a client +presents, which used to ride as a plaintext broker header on every request, +no longer rests in the topic. What stays readable is what the broker routes +by (`wrpc-kind`, `wrpc-seq`, a partition `key`), and the first two are bound +into the seal. A message that does not open is dropped (RPC), skipped (a +feed — `broker.feed.refused`, one warning per reason per ten seconds with the +count) or dead-lettered with `400` (a consumer) — logged, never answered, +and counted in `wrpc.broker.refused` by reason. One reason is transient: a +message under a key id this service does not hold (`kid`) is what a +rotation in progress looks like, so a consumer retries it like a `503`, to +the binding's `attempts`, before dead-lettering. +Rolled out like the backplane: `{ keys, seal: false, acceptPlaintext: true }`, +then `{ keys, acceptPlaintext: true }`, then `{ keys }` — and a key is +[dropped from the ring](#keys) only once the backlog sealed under it has +drained, which a backplane never made you wait for. + +This is a shared key: every service that holds it reads every message. + +The RPC binding has one more concern the log and queue layers do not: the +service address is consumed by every instance, and the envelope's replay +window lives in one process, so a captured `request` replayed later reaches +an instance that never saw it. A sealed `request` or `hello` therefore +carries the sender's clock inside the seal — refused past +`encryption.maxSkew` (five minutes) as `stale` — and behind more than one +instance you inject `encryption.replay`, a shared "seen once" memory with the +same `seen(id, ttl)` method `createReplayCache()` has (`SET id 1 NX PX ttl` +in Redis). A memory that cannot be asked serves nothing. + +The built-in memory is bounded, and the bound is a capacity to size: it must +hold every sealed request of the last `2 · maxSkew`, so +`max ≥ rps × 2·maxSkew/1000` — the default (100 000 entries, five minutes +of skew) holds about 167 requests a second. Past it, full of live entries, +it **refuses** (`503`, said once per ten seconds as +`encryption.replay.overflow`) rather than forget an entry and accept its +request twice; `encryption: { replay: { max, overflow: 'evict' } }` sizes it +and trades that for availability if you would rather. A traffic that +outgrows one process's memory is the case for the shared memory above. + +The memory is filled by **anyone who holds the public key** — and the key is +public by design (`GET /encryption-key`). A sealed request is +checked fresh before anything else is known about its sender, so one client +can fill the memory and the next is refused: put a rate limit per client +address in front of the server (the proxy's, or the framework's), sized +under `max / (2·maxSkew)`. A client refused this way hears `503`, never the +`409` of a stale or replayed request, whose message points at the device's +clock. + +## Sessions at rest {#sessions} + +```js +const { sealedStore } = require('@alexify/wrpc/encryption'); + +sessions: { store: sealedStore(createRedisSessionStore({ client }), { keys }) } +``` + +Rows are keyed by an HMAC of the token and hold the state sealed; a rotation +signs nobody out. See [Sessions](./sessions#sealed). + +## Session encryption {#session} + +For a client and a server with something in between that you do not trust. + +```js +// server +const server = new Server({ router, encryption: { keys: process.env.WRPC_KEY } }); +await server.listen(); +console.log(await server.rpc.encryptionKey()); // "0:pQ3…:9fX…" — public; ship it with the client +``` + +```js +// client +const { createEncryption } = require('@alexify/wrpc/encryption'); + +const client = await connect(url, { + encryption: createEncryption({ serverKey: '0:pQ3…:9fX…' }), +}); +``` + +The client **pins** the server's key: only the holder of the matching +private key can finish the handshake, whatever certificate the TLS in between +presented. From then on every frame of the connection is sealed — packets, +stream chunks, attachments, compressed frames alike. + +| Transport | How | +| --- | --- | +| WebSocket, WebTransport | a Noise handshake inside `open()` (one round trip, ~0.4 ms of CPU under NK, the default; ~0.3 under NN), then a key per direction, counted nonces, a rekey every 2²⁰ messages, a new handshake on every reconnect | +| HTTP, SSE | no connection to hold a session, so **each request** is sealed to the server key with HPKE, and the answer under a key exported from the same context (~0.2 ms). The real method, path, headers and status are inside — except `Cookie` and `Set-Cookie`, which stay on the outer request and response (script cannot set an HttpOnly cookie, so the browser has to see it) — and an observer sees `POST ` and `200`. An SSE stream comes back sealed frame by frame, the channel id included. | +| WebRTC | nothing to add — a data channel is already DTLS end to end, and [assertions](./webrtc-trust) bind identity to it | +| worker (`event`) | nothing to add — the port never leaves the process; give `encryption` to the `WrpcClientProxy` in the worker | + +### Patterns + +| Pattern | Who is authenticated | Use it for | +| --- | --- | --- | +| `NK` (the default) | the server, by the pinned key | a browser or an app talking to your server — what TLS gives, minus the CA | +| `XX` | both: the client has a `staticKey`, and the server's `authorize(peer)` sees it | devices and services with an identity of their own | +| `NNpsk0` | whoever holds the pre-shared key | two Node processes and a secret, no PKI | +| `NN` | nobody | never by default: it has to be named on both sides | + +```js +// XX — mutual +createEncryption({ pattern: 'XX', staticKey: deviceSeed, serverKey }); +new Server({ router, encryption: { keys, authorize: (peer) => allowed.has(hex(peer.remoteStatic)) } }); +``` + +A client names its protocol and the server holds it or closes the +connection. **Nothing is negotiated down** — a reply of "try this instead" +would be the downgrade. + +### Rekeying + +Every 2²⁰ messages in a direction, both ends replace that direction's key +(Noise `REKEY`): a key that leaks later opens less. The interval is +`rekeyAfter`, on `createEncryption()` and on the server's `encryption`, +and it is a **per-deployment constant, not a negotiated one** — it travels +in no handshake message, so a client and a server that disagree find out at +the first rekey: exactly that many messages in, one end rekeys and the +other does not, and the next frame is a plain decrypt failure +(`encryption.refused`, close `1002`). Leave the default on both ends +unless they deploy together; a client can read its own `rekeyAfter` to +compare with what the server was built with. A server's value applies to +every client it serves. + +### Before the handshake + +What the server sends to a client whose handshake has not finished is held, +in order, and sealed once it has. Direct sends — an `onConnect` hook's +events, an answer — are held up to 256, past which the connection is closed +(`1008`, reason `queue`): a server that says that much to a client that +cannot hear yet is not worth the memory. Room and broadcast events are held +up to 256 as well, but past that they are **dropped for that client only**, +counted, and said once when its handshake completes +(`encryption.queue.dropped`): a busy room must not cost every connecting +client its connection. A client is a member of nothing before `open()` +resolves anyway — a broadcast during its handshake is best effort. + +### `required` + +```js +encryption: { keys, required: true } +``` + +Without it, encryption is optional per client: a plaintext client still +connects, and `context.client.encryption` tells the two apart. With it +nothing plaintext is served on any transport — sockets close `1008`, HTTP +answers `426` (the fastify adapter's native REST routes included: a +plaintext request there is refused before a client is added), and a +transport the core cannot see into (`attach`, the broker binding) must be +one that seals: `attachConsumers` refuses to bind at all without +`encryption` and dead-letters a plaintext delivery its `acceptPlaintext` +let through, `attachBrokerRpc` serves sealed sessions only, and +`attachChannel` vouches for a data channel (DTLS end to end) unless told +`encrypted: false`. `rpc.encryptionRequired` is the flag a binding of your +own reads. + +### Bind your credentials to the channel + +Both ends hold `client.encryption.handshakeHash` — unique to the handshake, +identical on both sides. Send `HMAC(token, handshakeHash)` instead of the +token and a credential relayed onto another connection is worthless there: + +```js +authenticate: async (client) => { + const proof = await hmac(token, client.encryption.handshakeHash); + await client.call('auth/prove', { proof }); +}, +``` + +And move credentials *into* the channel: whatever the upgrade request +carried — the URL, headers, a `wrpc.bearer.` subprotocol token, a cookie — +was sent before any handshake and is as readable as ever. + +Under a terminator you do not trust, that rules the **cookie** out as the +session credential: it stays on the outer request by construction, on +every transport — a sealed HTTP request carries it outside the seal too, +because script cannot set an HttpOnly cookie — so the party the +encryption exists to keep out reads it on every call. Use +[`bearerAuth()` with `bearerTransport()`](./auth) (or +`payloadTransport()`): the token is sent through `authenticate`, after +the handshake, on a WebSocket, and inside the sealed request on HTTP. A +server built with `encryption` and the cookie transport says so once, at +construction (`encryption.ambient-session`). + +### Rotating the server key + +`Server.encryption.keys` is the same ring, with one difference in who holds +the other half: a client **pinned** a key id. Make the new key `current` — +`encryptionKey()` and the discovery endpoint publish it from then on — and +keep the old one on the ring for as long as a client pinned to it is out +there: a mobile app nobody updated, a bundle in a cache. Dropping it is not +housekeeping, it is the moment those clients stop connecting (a session +handshake is closed `1008`, a sealed request answered `400`; the client's +error is `ENCRYPTION_REFUSED` and says the pinned key may be retired). + +A key that **leaked** is the opposite case: take it off the ring now — with +a provider that takes effect at once, on the next handshake and the next +request — and get the new bundle to the clients out of band, in a release. +Sessions already established run on under their own keys; close them if +the leak is recent enough to matter (`server.rpc.cluster.disconnect({})`). + +### Discovering the key + +`GET /encryption-key` answers `{ "key": "" }`, and +`fetchServerKey(url)` reads it. That is **trust on first use**: it is only as +trustworthy as the connection it came over. Ship the bundle with the client +wherever you can — a Node service, a mobile app, a build-time constant — and +turn the endpoint off with `discovery: false`. + +### What it costs + +Measured by `bench/encryption.js` on one core: + +| | | +| --- | --- | +| Seal or open a 1 KB packet, Node | ~3.6 µs (the AEAD itself is 2.4 µs) | +| The same through WebCrypto | ~14 µs — asynchronous, which is why the Node half uses `node:crypto` | +| A Noise handshake, both ends | ~0.4 ms under NK (the default, one more DH each side), ~0.3 ms under NN | +| A 64 KB stream chunk sealed | ~27 µs — the inner frame is a copy of the bytes, then the seal | +| A sealed HTTP request, both ends | ~0.2 ms | +| **A broadcast to N sealed clients** | **N seals.** Plain wRPC builds one frame for the whole fan-out; under session encryption every recipient has its own key and its own nonce. What the recipients of one emit do share is the plaintext they seal, built once. 10 000 recipients × 1 KB ≈ 32 ms per emit, ≈ 99 ms at 16 KB — the AEAD alone is 26 ms at 1 KB; the counter nonce and the header are the rest (`bench/encryption.js`, the sealed fan-out rows; the shared frame of a plain fan-out is 6 µs). | + +On a WebSocket it also ends `permessage-deflate` for that connection — +ciphertext does not compress — so server→client compression is gone; +client→server [message compression](./compression) still applies, *inside* +the sealed frame. + +::: warning Compression and secrets +Compress-then-encrypt leaks length. When one message holds both a secret and +something an attacker can influence, the size tells them about the secret +(CRIME, BREACH). Send such messages with `compress: false`. +::: + +## End to end {#end-to-end} + +For a payload the **server** should not read — a chat message it only relays. + +```js +const { createIdentity, createSealer, createOpener } = require('@alexify/wrpc/encryption'); + +const me = await createIdentity(); // keep me.seed; publish me.publicKey +const sealer = createSealer({ recipientPublicKey: bob.publicKey, senderKey: me.keyPair, info: 'room:lobby' }); +await client.api.chat.relay({ sealed: await sealer.seal('hello') }); +``` + +```js +// the server: an ordinary handler, forwarding bytes it cannot open +relay: procedure({ handler: async (ctx, { sealed }) => ctx.server.to('lobby').except(ctx.client).emit('chat/message', { sealed }) }), +``` + +```js +const opener = createOpener({ keyPair: bob.keyPair, senderPublicKey: me.publicKey, info: 'room:lobby' }); +client.api.chat.on('message', async ({ sealed }) => render(await opener.open(sealed))); +``` + +`sealed` is a `Uint8Array`, and wRPC carries bytes as they are — through the +call, the room broadcast, and across the backplane to members on other +instances; a 1:1 message relayed with `server.sendTo(id, …)` crosses to the +instance that id lives on the same way. With `senderKey` the recipient learns *who* sealed it; a message +from anybody else does not open. + +::: warning What this is not +Not a messaging protocol: no forward secrecy for the recipient (a stolen seed +opens everything ever sealed to it — and, with `senderKey`, lets its holder +**forge** messages to that recipient from anyone, since the recipient's key +is on both sides of the authentication), no group key, no replay memory. For +those, run Double Ratchet or MLS and hand wRPC their bytes. **Whoever hands +out the public keys is in the middle**: a server that answers "Bob's key" +can answer with its own and read everything — pin keys, show a fingerprint +users compare, or exchange them out of band; TOFU is the least you do. And +**in a browser it protects against a server that reads, not one that serves +the page a different script** — the origin that ships your code can ship +other code. +::: + +The seed exists for portability — the same identity again, on another +device, from 32 bytes. A browser that never needs that keeps no seed at +all: `x25519().generateKeyPair()` answers a pair whose private half is a +non-extractable `CryptoKey`, storable in IndexedDB as it is, and +`createOpener({ keyPair })` / `senderKey: keyPair` take it directly. + +## Bring your own {#contracts} + +Everything is a structural contract, as a `Compressor` or a `Backplane` is: + +```js +// A Cipher — XChaCha20-Poly1305 from @noble/ciphers, say +const cipher = { + id: 'xchacha20-poly1305', keyLength: 32, nonceLength: 24, tagLength: 16, + key: (raw) => ({ + seal: (nonce, plaintext, aad) => xchacha20poly1305(raw, nonce, aad).encrypt(plaintext), + open: (nonce, sealed, aad) => xchacha20poly1305(raw, nonce, aad).decrypt(sealed), + }), +}; + +rooms: { encryption: { keys, cipher } } // the keyring layers: must answer synchronously on a backplane or a broker +createHpke({ kem, kdf, cipher }); // your own HPKE — may answer promises +``` + +From `key(raw)` on the bytes are the cipher's: wRPC neither reuses nor +wipes them, so keeping the reference, as above, is fine. A **Noise +session** is the one place an injected cipher has nowhere to go for now: +the server's `encryption.ciphers` is a list of the two built-in names, so a +`Cipher` handed to `createEncryption` meets nothing on the other side — +name `'aes-256-gcm'` or `'chacha20-poly1305'` there. + +A **client transport** of your own says how it carries the option with a +static: `static encrypts = true` for a session transport — it runs +`options.encryption.secure(link)` inside `open()` and has +`this.encryption` set before it emits `'open'`; the client closes one that +says `'open'` without it, before a hook or a packet goes out — or +`'request'` for one that seals each request through +`options.encryption.fetch`, which exists only for an option made with a +`serverKey` (checked for every fallback candidate before the first is +tried). Without the static the option is refused for that transport: +plaintext is never what a client that encrypts falls back to. + +`isCipher`, `isDh`, `isKem` and `isKeyProvider` are the checks the options +run. A `Kem` (`createHpke({ kem, kdf, cipher })`) is where ML-KEM or a hybrid +goes; a `Dh` is what Noise and DHKEM run over. + +## What it does not protect + +- **Metadata.** Sizes, timing, who talks to whom, the Redis channel name + (which is the room name), a broker's partition key, a key id. `padTo` is + not a thing yet; pad in your payload if length is a secret. +- **A compromised end.** Every instance holding a backplane key reads every + room. A session key lives in the process that uses it. +- **Replay, everywhere.** A session counts its messages and a sealed request + is accepted once — per process, unless you inject a shared `replay` memory. + A backplane window is per sender, in memory: empty after a restart, and + gone with a sender forgotten past `maxSenders` (1024) — a sealed envelope + carries no time, so that is the whole memory (a signed cluster adds a + clock, see [Trusting the backplane](./cluster#trusting-the-backplane)). + A log is *meant* to be re-read: make consumers idempotent. +- **A browser from its own origin.** Said above; worth saying twice. + +The wire formats are in the [protocol reference](../reference/protocol#session-encryption). diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 5c47ba6..0404a86 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -1,30 +1,38 @@ # Getting Started -`@alexify/wrpc` is a WebSocket-based RPC protocol for Node.js and browsers: you +wRPC (`@alexify/wrpc`) is a Web RPC protocol for Node.js and browsers: you declare a **router** of procedures on the server, and the client builds a typed object out of it at runtime. Calls, server → client events, subscriptions, -rooms and binary streams all ride on one connection — and the package has -**no runtime dependencies at all**. +rooms and binary streams ride on whichever transport the client picks — a +WebSocket, HTTP, Server-Sent Events, WebTransport, a WebRTC data channel or a +message broker — and the package has **no runtime dependencies at all**. ## How it fits together -One connection carries every feature on this site, and every box on the server -side is replaceable without touching your handlers: +One router serves every feature on this site, over every transport, and every +box on the server side is replaceable without touching your handlers: ```mermaid flowchart LR subgraph C["browser or Node.js"] direction TB app["your code
client.api.chat.send()"] --> cl["WrpcClient"] - cl --> ctr["transport
ws · http · sse · event"] + cl --> ctr["transport
ws · http · sse · wt
webrtc · broker · event"] end ctr <==> eng + ctr <==> h3 + ctr <==> dc + ctr <==> mq[("message broker")] + mq <==> bk curl["curl · partner
GET /v1/orders/:id"] --> eng subgraph S["Node.js server"] direction TB eng["engine
node · uWebSockets.js"] --> tr["ServerTransport"] + h3["HTTP/3 host
WebTransport"] --> tr + dc["data channel
WebRTC"] --> tr + bk["broker binding"] --> tr eng --> rest["REST trie
declared http routes"] rest --> disp tr --> disp["dispatcher"] @@ -33,15 +41,18 @@ flowchart LR disp -.-> st["sessions · rooms · cluster"] end - st -.-> bp[("backplane
Redis or memory")] + st -.-> bp[("backplane
Redis · NATS · RabbitMQ
Kafka · memory")] ``` -The client talks to an **engine** over a transport; the engine hands raw frames -to a `ServerTransport`, the **dispatcher** turns them into packets, and the -**router** decides which procedure runs. Swap the engine ([uWebSockets.js](./adapters/uws)), -swap the transport ([SSE](./sse)), or mount the whole thing inside -[Fastify](./adapters/fastify) or [Express](./adapters/express) — the router and -the wire protocol do not change. +The client reaches the server over a transport: a WebSocket, HTTP or SSE +through the **engine**, [WebTransport](./wt) through an injected HTTP/3 host, a +[WebRTC](./webrtc) data channel, or a [message broker](./brokers/rpc). Each +hands its frames to a `ServerTransport`, the **dispatcher** turns them into +packets, and the **router** decides which procedure runs. Swap the engine +([uWebSockets.js](./adapters/uws)), add a transport, or mount the whole thing +inside [Fastify](./adapters/fastify) or [Express](./adapters/express) — the +router and the wire protocol do not change. [Client › +Transports](./client#transports) has what each transport carries. ## Installation @@ -87,9 +98,10 @@ await server.listen(); server on `'http'` behind it is just as valid. ::: -That server answers on two paths under `basePath` (`/api` by default): -a WebSocket upgrade, and plain HTTP. See [Server](./server) for everything the -shell takes. +That server answers under `basePath` (`/api` by default): a WebSocket +upgrade, plain HTTP and Server-Sent Events. WebTransport, WebRTC and broker +clients reach the same router through their own hosts. See [Server](./server) +for everything the shell takes. ## Your first client @@ -254,9 +266,15 @@ live in [Typed client](./typed-client). | move files over the connection | [Binary streams](./streams) | | tune reconnect, batching, heartbeat | [Client](./client) | | run inside fastify / express / uWebSockets.js | [Adapters](./adapters/fastify) | +| pick a transport, or several | [Client › Transports](./client#transports) · [Multiple backends](./multiple-backends) | | serve realtime where WebSockets can't go | [Server-Sent Events](./sse) | +| run over HTTP/3, with streams that do not block each other | [WebTransport](./wt) | +| connect browsers to each other, with no server in the data path | [WebRTC](./webrtc) · [identity and trust](./webrtc-trust) | +| call between services, or feed clients from a durable log | [Message brokers](./brokers) | | span more than one process | [Cluster](./cluster) | | carry per-call metadata | [Metadata](./metadata) | | cache and invalidate in React | [TanStack Query](./query) | +| save bandwidth, or seal what TLS does not cover | [Compression](./compression) · [Encryption](./encryption) | +| get ready for production | [Security](./security) · [Running in production](./production) · [Rate limiting](./rate-limiting) | | see calls in your traces | [OpenTelemetry](./telemetry) | | know exactly what goes over the wire | [Wire protocol](../reference/protocol) | diff --git a/docs/guide/hooks.md b/docs/guide/hooks.md index 14d38a1..cc088b9 100644 --- a/docs/guide/hooks.md +++ b/docs/guide/hooks.md @@ -157,6 +157,10 @@ Keyed by the `Client` (a `WeakMap`, so state dies with the connection); key by session token or a header instead when limits should survive reconnects. The built-in `maxCalls` cap bounds *concurrency* per connection; this bounds *rate* — production deployments usually want both. +See [Rate limiting & throttling](./rate-limiting) for per-procedure limits +via `meta` and how this same hook covers every transport (WebSocket, HTTP, +SSE, WebTransport, WebRTC, a worker port, the broker binding and queue +consumers) uniformly. ## Recipe: subscription quotas diff --git a/docs/guide/logging.md b/docs/guide/logging.md index 35c122c..26bec80 100644 --- a/docs/guide/logging.md +++ b/docs/guide/logging.md @@ -11,12 +11,12 @@ const server = new Server({ router, logger: pino(), port: 8000, protocol: 'http' ``` That is the whole configuration. There are no levels to set, no destination to -choose and no format to pick — those are your logger's job, and wrpc does not +choose and no format to pick — those are your logger's job, and wRPC does not duplicate them. ::: info The logger is injected, never depended on `@alexify/wrpc` has no dependencies and never will. Your logger is duck-typed: -wrpc looks at which methods it has, not at what it is. `pino` is a +wRPC looks at which methods it has, not at what it is. `pino` is a devDependency here purely so the tests have something real to write into. ::: @@ -61,7 +61,7 @@ present, holds the Error itself. ### Bindings -wrpc builds child loggers so you do not have to correlate by hand: +wRPC builds child loggers so you do not have to correlate by hand: | Binding | Scope | | ------- | ----- | @@ -89,6 +89,288 @@ const send = procedure({ The entry it writes already carries `callId` and `peer`, so a single call is greppable end to end without threading an id through your own code. +## Event catalogue + +`event` is the field to build alerts on, so here is what wRPC emits, by +component: every `event` name in the source is in one of the tables below — +a test keeps it so, and a new line without a row fails the build. Handlers +add their own through `context.log`; those are yours to catalogue. + +The first table is the request path and the engines; the ones after it are +[rooms, backplane and cluster](#rooms-backplane-and-cluster), +[sessions and SSE](#sessions-and-sse), [brokers](#brokers), +[WebRTC](#webrtc), [the server's own life](#lifecycle-and-hooks) and +[the client](#client-events). + +| `event` | Level | Means | +| ------- | ----- | ----- | +| `rpc.error` | error/debug | An error answer went out. `error` when nothing else said why — a handler that threw (`err` carries the stack), a stream or chunk that could not be taken; `debug` under every refusal that has its own line above, so one refusal is one alert | +| `call.unknown` | warn | A call for a method this router does not have — a stale client, or a typo | +| `call.duplicate` | warn | Two in-flight calls with one id: a `generateId` that repeats, or a retry that reused one | +| `call.capacity` | debug | `maxCalls` reached on one connection | +| `stream.capacity` | debug | `maxStreams` reached on one connection: a `stream` packet answered `429` | +| `call.draining` | info | Refused because the server is shutting down | +| `packet.malformed` | warn | A frame that would not parse — one line per frame, whatever it was | +| `packet.unknown` | warn | Valid JSON that is not a packet — version skew, or somebody else's client | +| `revision.peer` | debug | A connection settled on [revision 1](../reference/protocol#versioning) — a 1.0 peer, or one that reads no frames (`transport`; on a WebSocket the selected `protocol`, on a worker port the revision the page `named`). Per connection, so debug: the count is `wrpc.server.connections` by `wrpc.revision` | +| `revision.mismatch` | warn, then debug | A WebSocket engine composed by hand selected `wrpc.v2` (`protocol`) for a server that sends and reads no framed messages (`attachments: false`, a packet codec): the client will send a frame this server refuses. The built-in shells narrow their engine to `wrpc.v1` themselves — pass `protocols: ['wrpc.v1']` to yours. A warn for the first connection, debug for every one after it | +| `batch.refused` | debug | A batch outside `1..maxBatch` | +| `subscribe.refused` | warn | A subscription refused before it started (`code`; the peer's `id`, clipped to 128 characters) | +| `ws.frame`, `ws.protocol`, `ws.invalid-utf8` | warn | A peer's frame violated the protocol; the connection was closed | +| `ws.upgrade` | error | The node engine's upgrade handling threw (`err`) — a `verifyClient`, `handleProtocols` or deflate `filter` of the application, or a `'connection'` listener; the socket was answered `500`. Emitted as `'error'` on the `WebsocketServer` too, when something listens | +| `ws.too-big`, `ws.overflow`, `ws.backpressure` | warn | A configured limit closed the connection — the entry names the limit | +| `ws.inflate`, `ws.deflate` | warn | permessage-deflate failed on the way in (or the inflated message was too big), or on the way out; the connection was closed | +| `ws.close.dropped` | warn | A graceful `close()` waited `closeTimeout` for messages still queued behind a compress in flight (context takeover, `async`) and gave up: `frames` and `bytes` never left, the Close frame did | +| `compression.failed` | warn | A per-message or HTTP/SSE codec threw (`carrier`: `http`, `sse`, `wt`, `rooms`, `cluster`, `broker`; `direction`: `encode` — the message left plain — or `decode` — the frame was refused; `codec`, `code`, `err`). **Once** per carrier, direction and codec for the life of the server; every failure is counted in `wrpc.compression.failures` | +| `frame.refused` | warn | A framed message from a socket peer that was not taken: a kind nobody negotiated (`kind`, `negotiated`), or one that did not inflate (`reason: 'inflate'`, the `codec`, and the `code` — `ERR_BUFFER_TOO_LARGE` is `maxMessage`, anything else the bytes). Answered with an id-less `400`; the connection lives | +| `socket.error` | debug | The engine socket under a client raised `error`; the connection is closed. The engine's own line (`ws.*`, `uws.dropped`) already said what happened | +| `uws.dropped` | error | uWebSockets.js discarded an outbound frame: a hole in the stream | +| `session.destroy`, `session.save`, `session.touch` | error | The session store rejected an operation | +| `session.evict` | warn | Live sessions dropped for capacity — signed-in users signed out | +| `session.corrupt` | warn | A stored session would not parse. **Never carries the token** | +| `backplane.gap`, `backplane.redis` | warn/error | Envelopes lost between instances; a backplane client failed | +| `backplane.keys`, `cluster.keys`, `session.keys` | error | The key provider threw while a sealed envelope, command or session row was being opened (`err`, and the `kid` when it is one) — this side's failure, not a refusal of the message | +| `backplane.open`, `cluster.open`, `session.open` | warn | A sealed envelope, command or row did not open (`reason`: `kid`, `open`, `replay`, `format`, `codec`, plus the `kid` when it is one). On the backplane and the cluster: a warn once per reason and channel in ten seconds, debug in between — as is `*.unsealed` | +| `session.adopt` | info | A plaintext session row was read under `acceptPlaintext` and re-written sealed — count these down before turning the option off | +| `backplane.seal`, `cluster.seal` | error | A sealing envelope could not seal (a keyring without its current key); the event stayed local, nothing left in the clear | +| `cluster.unsigned`, `cluster.badsig`, `cluster.verify` | warn/error | The three ways envelope authentication fails. `unsigned` and `badsig` are a warn once per sender and reason each `presenceTimeout`, debug in between, like `cluster.replay` | +| `cluster.replay` | warn, then debug | A correctly signed envelope refused all the same (`from`, `channel`, `reason`): `seq` — a counter already accepted, `channel` — signed for another channel, `stale` — outside `maxSkew` (with the `skew` in ms), or a previous life of a restarted sender, `unsequenced` — no counter at all, a 1.x node (`replay: 'accept'` during the upgrade). One warn per sender and reason each `presenceTimeout`, debug in between | +| `cluster.unsequenced` | info, then debug | An envelope with no counter — a 1.x node's — accepted under `cluster: { replay: 'accept' }` (`from`): once per sender each `presenceTimeout`. When no line has come for a while, every node is 2.x and the option can go | +| `cluster.bytes` | warn | Bytes in what a `Cluster` constructed by hand — without the envelope an `RpcServer` injects — was asked to send to another instance (`type`, `name`): nothing left, rather than the object JSON makes of bytes. Never logged by a server's own cluster, which carries them as a binary envelope | +| `encryption.refused` | warn/error | A session handshake or a sealed request refused — ONE line, with `kind` (`ws`, `wt`, `http`), `reason` and, on a socket, the `peer`. A peer's doing is warn: `plaintext`, `handshake`, `protocol` (with the name when it is shaped like one, else `nameLength`), `kid` (the kid when it is one, else `kidLength`), `authorize`, `timeout`, `queue`, `crypto`; on http `format`, `open`, `stale`, `replay`. This side's failure is error, with `err`: `keys` (the key provider threw), `hook` (`authorize` threw), `replay-store` | +| `encryption.established` | debug | A session handshake completed (`protocol`, `kind`, `peer`) | +| `encryption.replay`, `encryption.unwrap` | error | The shared replay store could not be asked (the request was refused `503`); a primitive threw while unwrapping a sealed request (`500`) | +| `encryption.ambient-session` | warn | The server has `encryption` and the cookie session transport: a cookie stays on the outer request, readable by the terminator the encryption keeps out — use `bearerTransport()`/`payloadTransport()`. Once, at construction | +| `encryption.queue.dropped` | warn | Room/broadcast events dropped for one client while its handshake was still running (`count`), past the 256 held for it — said once when the handshake completes; the connection lived | +| `encryption.replay.overflow` | warn | The built-in replay memory is full of live entries: sealed requests are refused `503` until some expire (`refused` since the last line, one line per ten seconds) — size `replay.max` for the traffic, or share the memory | +| `broker.dead` | warn | A message exhausted its retries | +| `broker.evict` | warn | The per-token client cache is thrashing: more distinct tokens in flight than `tokenClients`, a session restore per message. The evicted client finishes what it holds, then closes | +| `broker.amqp.consumer.closed`, `broker.amqp.cancelled`, `broker.amqp.consumer.reopen`, `broker.amqp.direct.reopen` | error | RabbitMQ closed a consumer's channel, or cancelled the consumer (its queue deleted, its node gone) — a queue consumer's, a direct listener's (`address`) or the backplane's; each re-opens with a backoff, each failed attempt logged `consumer.reopen` / `direct.reopen` with its `attempt` (the backplane: `backplane.rebind`) | +| `broker.amqp.backplane.rebind` | warn/error | The backplane's consumer channel closed under live rooms: `warn` once it is back with every room bound again (`channels`, `attempt`), `error` per failed attempt. Events published meanwhile were lost — the rooms layer sees a gap | +| `broker.amqp.tail`, `broker.kafka.tail`, `broker.nats.tail` | error | A feed's live read ended on its own (a consumer cancelled, a channel closed, a crash); the subscribers move to a fresh read and catch up from their positions | +| `broker.kafka.settle`, `broker.amqp.settle` | error | The broker refused a settlement three times (`action`, `id`, `attempt`); the message was handed back — a seek to its offset on Kafka (`healthy` false until a settlement lands), a requeue on RabbitMQ — never committed or acked past | +| `broker.kafka.rebalanced` | info | A retry's in-process wait ended in a failed heartbeat: the group is rebalancing, and the message is the new owner's to fetch — no copy published, nothing committed | +| `broker.nats.consumer.config` | warn | A queue's durable consumer already existed with another `ackWait`/`maxAckPending`; the old values stay until the consumer is updated | +| `broker.kafka.topic` | info | The adapter created a topic (`topic`, `partitions`, `replicationFactor`) — once per topic, never for one that already existed | +| `broker.kafka.crash` | error | A kafkajs consumer's fetch loop died (`restart` says whether it rejoins by itself); the binding is unhealthy until it rejoins | +| `broker.kafka.join-timeout` | info | A queue consumer got no partition within the join window — more instances than partitions, most likely; it stays healthy | +| `broker.amqp.connection` | error | The injected RabbitMQ connection closed: every binding on this broker is unhealthy and stays so — open a new connection and a new broker on it | +| `broker.rpc.refused` | warn | A sealed RPC frame that did not open (`reason`: `unsealed`, `kid`, `open`, `format`, `replay`), one older than `maxSkew` (`stale`), or a plaintext frame on a session a sealed hello opened (`downgrade`) — dropped, never answered | +| `broker.rpc.replay` | error | The shared replay memory could not be asked; the frame was not served | +| `broker.rpc.session.end` | warn/info/debug | A broker RPC session ended (`session` is a fingerprint, `reason`): `warn` when the broker lost or mangled a frame — `sequence gap: expected N, got M` seen here, `the client saw a gap` when the frame was lost on this side's way out and the client's goodbye said so, `undecodable frame` — `info` for `idle`, `debug` for a routine `bye`, `replaced`, `send failed` or `server closing`. The same ends are counted, by a closed set of reasons, in `wrpc.broker.rpc.session.ends` | +| `broker.rpc.session.unknown` | debug | A frame for a session this instance does not hold (it restarted, or the session idled out); the client was told to reconnect. Debug: any participant can send these in a loop | +| `broker.rpc.send`, `broker.rpc.reply` | warn | The broker refused to carry a frame to a session's inbox, or a reply to a stateless request | +| `broker.rpc.capacity` | warn | Hellos refused at `maxSessions` since the last sweep (`refused`, `sessions`, `max`) — one line per sweep, not per hello | +| `broker.refused` | warn/error | A sealed delivery a consumer could not open (`reason`: `unsealed`, `kid`, `open`, `format`); `kid` is retried, the rest dead-letter. `keys` — the key provider threw — is an error line with `err` | +| `broker.feed.refused` | warn/error/debug/info | A sealed entry a feed could not open: `warn` once per reason per ten seconds with the running `count` (`error` with `err` when the reason is `keys`), `debug` in between, an `info` summary when the feed ends. An entry the live subscribers of a topic share is reported by the one that opened it, not by each | +| `broker.feed.resume` | warn/debug | A resume token was refused. `reason: 'signature'` means it was **tampered with** | +| `broker.feed.gap` | info | A subscriber fell behind retention; the snapshot hook ran | +| `broker.tail.lag` | info | Readers of a log topic (`topic`, `readers`) fell `highWaterMark` entries behind its live tail: their buffers were dropped and they catch up through the broker's range read — slow subscribers, and range load on the broker. Once per fall, not per entry | +| `wt.attach`, `wt.source` | error | A WebTransport session could not be attached (`verify` threw, the source died) | +| `wt.onError` | error | The `onError` handed to `acceptSessions` threw itself (`err`) — said here instead of rejecting `done` | +| `wt.refused` | warn | A session refused before attach: `status` 403 (`verify` said no) or 408 (no control stream, or `ready`/`verify` not settled, within `acceptTimeout`) | +| `wt.accept.saturated` | warn | `maxPending` sessions were still in their handshake; the next was refused 503 — said once per episode | +| `wt.backpressure` | warn | A session's queue passed `maxBackpressure` — a client that does not read — and it was terminated (`buffered`, `max`): the WebTransport `ws.backpressure` | +| `wt.violation`, `wt.idle`, `wt.session.error` | warn | A peer's frame that could not be read (`code`; closed 1002), a peer silent past `idleTimeout` (terminated), the session's own failure reported by the host | +| `wt.mux.refused`, `wt.mux.fallback` | warn / info | A side stream the peer opened for an id it never named, past the cap or unannounced (cancelled unread); the host granted no side stream, so every chunk of this session rides the control stream — once per session | +| `wt.datagram.dropped` | warn | The session had not taken the 64 datagrams before this one, so it was dropped rather than queued — **once** per session; the total is `dropped` on that session's `wt.close` line | +| `wt.close` | debug | The session ended (`code`, the peer's `reason` clipped) — the line to grep for a code | +| `rtc.channel.error` | warn | A framing error from the peer on a raw data channel attached with `attachChannel` (`peer`, `err`); the channel was closed | +| `mesh.dial` | debug | A mesh edge never formed | +| `mesh.unreachable` | warn | A member still in the room could not be linked again and the mesh's re-dial has backed off to its slowest pace (`room`, `peer`, `attempts`) — once per outage; it keeps dialling | +| `signaling.undeliverable` | debug | A signal for a peer the relay no longer has (`to`, `room`, `type`) — a trickled candidate that crossed its `leave`, a routine race | +| `rtc.signal.overflow` | warn | A peer sent more signals than are held for it — candidates before its description (256), or anything while `accept()` still thinks (64); the rest are dropped, said once | +| `http.refused` | warn | An HTTP request the core refused before any call existed (`code`, and the `path` or `method`): 404 for a path that is not the RPC's, 403 for a packet request that is not a POST | +| `http.failed` | error | Something threw while an HTTP request was being routed (`err`): the request was answered `500` — or left as it was when an answer or a stream had already started. A bug, or a peer's input nothing refused first | +| `cors.refused` | warn | A request from an `origin` the `cors` option does not allow | +| `subscribe.end` | warn/debug | A subscription ended (`id`, `method`): `warn` with the `code` when it died on the server's side, `debug` for a completion or an unsubscribe | +| `subscription.return` | warn | A subscription handler's own cleanup (`finally`, `return()`) threw after the stream had ended | +| `subscription.aborted` | debug | A subscription handler threw after the peer had already unsubscribed or gone; the error reaches nobody else | +| `stream.terminate` | error | A stream's `terminate()` threw while a closing client's streams were being torn down | +| `call.ok` | debug | A call answered without an error (`method`, `id`) — the narration line, built only when a logger takes debug | +| `rpc.warn` | warn | A warning about one connection that has no name of its own — what `client.warn(message)` writes when it is given no `event` | +| `encryption.handshake` | debug | A sealed socket's handshake step failed inside the library; the refusal that follows (`encryption.refused`) is the line to alert on | +| `encryption.respond` | error | A sealed HTTP answer could not be sealed (`err`); the request got a bare `500` | + +### Rooms, backplane and cluster {#rooms-backplane-and-cluster} + +| `event` | Level | Means | +| ------- | ----- | ----- | +| `broadcast.serialize`, `broadcast.send` | error | A broadcast's data could not be serialized (nobody received it), or one recipient's socket threw while the fan-out went on to the others | +| `backplane.serialize` | error | A room event could not be serialized for the backplane: delivered locally, lost for the other instances | +| `backplane.publish`, `backplane.subscribe`, `backplane.unsubscribe` | error | The backplane rejected a publish, a channel subscribe (`attempt`; retried with a backoff, `healthy` is false meanwhile) or an unsubscribe | +| `backplane.recovered`, `cluster.recovered` | warn | A subscribe that had been failing went through (`channel`, `attempt`). Whatever was published in between is lost — the gap is the news | +| `backplane.deliver`, `backplane.handler` | error | An envelope from another instance could not be delivered to local sockets; a subscriber of the in-process `MemoryBackplane` threw | +| `backplane.encoded`, `cluster.encoded` | warn | A compressed envelope (`wrpc-enc:`) reached an instance whose `compression` does not hold that codec — a rollout that changed the codec in one step | +| `backplane.sealed`, `cluster.sealed` | warn | A sealed envelope reached an instance with no `encryption` keys: it reads none of the fleet's events until it is given them. `cluster.sealed` (and `cluster.encoded`) is a warn once per channel each `presenceTimeout`, debug in between | +| `backplane.unsealed`, `cluster.unsealed` | warn | A plaintext envelope reached an instance that seals and does not `acceptPlaintext` — an instance left behind by the rollout, or somebody publishing by hand | +| `backplane.bytes` | warn | Bytes in an event that a rooms registry wired by hand — without the envelope an `RpcServer` injects — was asked to send across instances (`name`): delivered locally only | +| `rooms.option` | warn | An unknown key under `rooms` (`key`) — a typo that would otherwise be a silently ignored option | +| `cluster.join` | info | An instance joined the cluster (`instance`) | +| `cluster.evict` | warn | An instance went silent past `presenceTimeout` and was dropped from presence (`instance`, `reason: 'timeout'`) — partitioned, or dead without a goodbye. A routine goodbye is not logged | +| `cluster.fetch.incomplete`, `cluster.fetch.truncated` | warn | `fetchClients` returned less than the fleet has (`received`): an instance did not answer in time, or one answered only its first `maxFetch` clients | +| `cluster.serialize`, `cluster.publish`, `cluster.subscribe`, `cluster.unsubscribe` | error | A cluster envelope could not be serialized (`type`), published, or its channel subscribed (`attempt`; retried) or unsubscribed | +| `cluster.command`, `cluster.serve` | error | A command from another instance threw while it was applied here (`op`); a request from another instance — a `fetch`, an ask — threw while it was served | +| `cluster.listener` | error | An application listener of a cluster event (`name`) threw | + +### Sessions and SSE {#sessions-and-sse} + +| `event` | Level | Means | +| ------- | ----- | ----- | +| `session.restore` | error | The session store threw while a connection's session was being restored (`err`); the client continues without a session, and a `session` procedure answers it `503` (`Session store unavailable`) rather than `403` — what a broker consumer retries | +| `session.unsealed` | warn | A row under a sealed store's key that is not a sealed row of that `kid` — written by something else. Read as a missing session; **never carries the token** | +| `session.migrate` | warn | A stale copy of a session row — under an older key, or a sealed one while `seal: false` — could not be deleted (`err`); the session itself was written | +| `sse.refused` | warn | A channel request refused (`code`, `reason`): an unknown or expired channel, a wrong channel secret, a cap (`maxChannels`, `maxChannelsPerAddress`) | +| `sse.gap` | warn | A reconnecting stream asked for events the replay buffer no longer holds (`channel`, `requested`): event loss, told to the client — which logs the same name when it hears it | +| `sse.expired` | debug | A channel nobody re-attached to within `retention` was dropped | +| `sse.supersede`, `sse.close` | error | Ending a stream a newer one replaced, or closing a channel, threw | + +### Brokers {#brokers} + +The broker bindings and adapters are in the first table above where a line +is one to alert on; these are the rest. Every adapter line carries `err` and, +where it has one, the `queue`, `channel`, `topic` or `address`. + +| `event` | Level | Means | +| ------- | ----- | ----- | +| `broker.ack`, `broker.retry`, `broker.release` | debug | What a delivery's outcome became (`queue`, `method`, `code`, `attempt`, `delay`) — the narration next to `broker.dead`, built only when a logger takes debug | +| `broker.dispatch` | error | A procedure threw outside the call pipeline while a delivery was dispatched (`queue`, `method`, `id`); the delivery is settled by its code all the same | +| `broker.settle` | error | The broker refused the settlement of a delivery (`queue`); the message comes back by redelivery — a duplicate, not a loss | +| `broker.attach` | error | A delivery's client could not be attached (`queue`) — a session store that threw; the delivery is retried, then dead-lettered | +| `broker.onDeadLetter` | error | The `onDeadLetter` hook threw | +| `broker.feed.decode` | warn | A log entry that did not decode (`topic`, `id`); skipped, the feed goes on | +| `broker.rpc.hello.duplicate` | debug | A second `hello` for a session that is live, or from another inbox (`session` fingerprint, `live`); ignored — the session stays its owner's | +| `broker.delivery`, `broker.listener` | error | `MemoryBroker`: a queue handler or a direct listener threw; the delivery is retried after the crash backoff | +| `broker.redis.delivery`, `broker.nats.delivery`, `broker.amqp.delivery`, `broker.kafka.delivery` | error | A queue handler threw or rejected (`queue`): it settled nothing, so the delivery is retried after a backoff, attempt + 1 | +| `broker.redis.listener`, `broker.nats.listener`, `broker.amqp.listener` | error | A direct listener's handler threw or rejected | +| `broker.nats.handler`, `broker.amqp.handler`, `broker.kafka.handler` | error | A backplane subscriber threw (`channel`) | +| `broker.redis.read`, `broker.nats.consume` | error | A queue consumer's read failed (`queue`): the consumer is **unhealthy** and retries with a backoff — the line that says a consumer is not consuming | +| `broker.redis.settle`, `broker.nats.settle` | error | A settlement failed (`queue`); the message is redelivered by the broker | +| `broker.redis.sweep` | error | Promoting delayed retries, or reclaiming entries a dead consumer held, failed (`queue`); tried again on the next sweep | +| `broker.redis.blpop`, `broker.redis.presence` | error | A direct service listener's blocking read, or its presence lease, failed (`address`): the listener is **unhealthy** — `attachBrokerRpc().healthy` is false — until the next one works | +| `broker.nats.subscription` | error | A NATS subscription reported an error (`channel` or `address`): it delivers nothing more, and a direct listener on it is unhealthy for good | +| `broker.redis.tail` | error | The log's live read failed; retried, the subscribers catch up from their positions (the Redis twin of `broker.*.tail` above) | +| `broker.redis.pubsub`, `broker.redis.decode` | error | The pub/sub connection raised an error; a direct message that could not be decoded (`channel`) was dropped | +| `broker.redis.quit`, `broker.kafka.disconnect` | error | Closing a connection the adapter opened itself threw | +| `broker.nats.publish`, `broker.amqp.publish`, `broker.kafka.publish` | error | A backplane publish failed (`channel`): that event is lost for the other instances | +| `broker.amqp.channel` | error | A channel raised an error (the close that follows is what the consumers react to) | +| `broker.amqp.unbind` | error | Unbinding a room nobody is in any more failed (`channel`) | +| `broker.kafka.pause`, `broker.kafka.resume` | error | Pausing or resuming a queue consumer threw (`queue`) | +| `broker.nats.consumer.info` | debug | A durable consumer's configuration could not be read back to compare it | +| `broker.kafka.partitions`, `broker.kafka.metadata` | debug | A topic exists with another partition count than configured (`expected`, `actual`); its metadata could not be read | +| `broker.kafka.seek`, `broker.kafka.groups` | debug | A reader's seek to its start position still failed on the last of five tries; a reader's throwaway consumer groups could not be deleted (`groups`) and are left for the broker to expire | + +### WebRTC {#webrtc} + +Written through the peer's own `logger` — in a browser, off unless you pass +one — and bound to the remote `peer`. + +| `event` | Level | Means | +| ------- | ----- | ----- | +| `rtc.link.state` | debug | A link changed state (`state`, `previous`) — the narration to turn on when a connection misbehaves | +| `rtc.ice.failed`, `rtc.ice.restarted` | warn / info | The connection failed and an ICE restart was started; the restart brought it back | +| `rtc.link.failed` | warn | A link failed (`reason`) — a dial or an ICE restart that did not complete; the peer link above it decides whether to redial | +| `rtc.link.error` | error | Something threw inside the link (`origin`: the dial, a negotiation step, a channel) | +| `rtc.signal.goodbye` | debug | A knock or an offer (`type`) from a peer this side said goodbye to less than 5 s ago, dropped: the other side's redial, begun before the goodbye reached it | +| `rtc.signal.malformed`, `rtc.signal.unknown` | warn | A signal that is not an object, or of a `type` this peer does not know — version skew, or a relay passing on something else | +| `rtc.peer.redial`, `rtc.peer.gave-up` | info / warn | A dead link is being dialled again (`attempt`, `delay`); the redial budget ran out (`attempts`) and the link is closed | +| `rtc.peer.refused` | warn | A peer was refused before its description was applied (`peer`, `reason`: the assertion's code — `signature`, `fingerprint`, `expired`, … — or `assertion`) | +| `rtc.peer.incarnation` | info | A known peer id came back as another instance (`previous`, `instance`): the old link is dropped for the new one | +| `rtc.peer.reset`, `rtc.peer.replaced` | warn | The signaling connection was re-established (`changed` says whether this peer's id did — if so every link is dropped); a newer connection took this peer's id, and this one closed | +| `rtc.peer.duplicate` | error | A second link was about to be made to a peer that has a live one (`state`) — a bug guard: the existing link is kept | +| `rtc.peer.error`, `rtc.peer.unhandled` | error | A link's client or host threw (`peer`); an error nobody listens for (`'error'` has no listener on the peer) | +| `rtc.peer.client` | debug | The link's client reported an error while the link was down — its reconnect attempts fail by design then | +| `mesh.leave.stale` | debug | A `leave` naming another incarnation of a member than the one linked; ignored | + +### Lifecycle and hooks {#lifecycle-and-hooks} + +| `event` | Level | Means | +| ------- | ----- | ----- | +| `listen`, `listen.retry` | info / warn | The server is listening (`port`); the address was in use and the bind is tried again | +| `close` | error | The HTTP server's `close()` reported an error. (The client logs `close` at info: the connection ended) | +| `hook.error` | warn | An observational router hook — `onResponse`, `onError`, `onDisconnect` and the like — threw (`phase`, `err`): reported and contained, what it observed is unaffected | +| `onConnect.stalled` | warn | An `onConnect` hook has not settled within the stall window (`peer`, `ms`): the client's calls are waiting on it | +| `listener.close`, `listener.attach`, `listener.detach` | error | An application listener of a client's `close`, or of a peer host's `attach`/`detach`, threw | + +### Client events + +The client writes through its own `logger` — **off by default**, so none of +these appear until you pass one (see [below](#errors-are-observed-not-swallowed)). +They are what a "connection that works but is missing something" looks like: + +| `event` | Level | Means | +| ------- | ----- | ----- | +| `meta.oversize` | warn | A declared bag (headers, meta, or the per-call aggregate under batching) did not fit `metaMaxBytes` on its carrier (`carrier`, `bytes`) and was **not sent** — the server would have dropped it whole | +| `declared.unsendable` | warn | A declared header could not travel on this carrier at all (a value the carrier's grammar refuses) | +| `declared.exposed` | warn | Declared headers were put in the connect URL's query, where access logs keep them: a credential (`key: 'authorization'`) because `carrier: 'query'` (or WebTransport) left no other carrier, or every declared header (`keys`) when the client redialled with the query on its own (`handshake.requery`) — an `x-api-key` belongs in the `authorization` header a server reads, never in a declared bag | +| `handshake.requery` | warn | The server answered `wrpc.v1` to a handshake that offered `wrpc.v2` and carried `headers`/`meta` as subprotocol tokens — possibly a 1.0 server, which reads them from the connect URL only. The client dialled again with the query carrier, and keeps it for every later reconnect; `carrier: 'query'` up front saves the extra handshake, `carrier: 'protocol'` forbids the query | +| `handshake.ambiguous` | warn | A browser client that offered `wrpc.v1` alone (`attachments: false`, or its own `protocols`) carried `headers`/`meta` as subprotocol tokens and was answered `wrpc.v1`: a 2.x server read them, a 1.0 server did not, and the answer is the same — so there was no redial. Once per transport. Against a 1.0 server set `carrier: 'query'` | +| `handshake.fallback` | warn | The Node WebSocket constructor refused the headers init bag (`reason` is the error's name, never the message: undici repeats a header value in it); the browser carriers were used instead | +| `transport.fallback` | warn | A transport of the list could not connect; the next one was tried | +| `authenticate.failed`, `refresh.failed`, `restore.failed` | warn | The `authenticate` hook, the `refresh` hook, or the re-`load()`/re-subscribe after a reconnect failed | +| `reconnecting`, `reconnect.failed` | info/warn | A reconnect attempt (`attempt`, `delay`), and the one that gave up | +| `open`, `reconnected` | info | The connection opened (`url`) — for the first time, or again after `attempts` reconnect attempts | +| `close` | info | The connection ended (`url`); a reconnect, if any, follows as `reconnecting` | +| `sse.gap` | warn | The server said this channel's replay buffer no longer holds what was missed: event loss, not a routine reconnection | +| `heartbeat.timeout` | warn | No `pong` within the heartbeat window; the connection is terminated and reconnects | +| `subscription.refresh` | warn | The credential `refresh` run for a subscription's retry failed (`id`, `err`): the subscription ends with the refusal the feed earned — the twin of `refresh.failed` on the call side | +| `encryption.failed` | warn | The session handshake did not complete, or a frame did not open; the connection is closed — never a fallback to plaintext | + +An error the client has no caller to hand to is **escalated**: logged at +`error` under an `event` that names where it came from, and emitted as +`'error'` (or printed, when nobody listens). Those names: + +| `event` | Means | +| ------- | ----- | +| `transport.error` | The transport itself raised an error | +| `message` | Handling an inbound message threw — a malformed frame, a handler of yours behind it | +| `batch.flush`, `batch.dispatch` | Writing a batch of calls threw (its calls are failed, not left pending); handling one answer of a batch threw | +| `heartbeat.ping`, `heartbeat.terminate` | Sending a ping, or terminating the connection after a missed pong, threw | +| `reconnect.open`, `fallback.open`, `online.open` | An `open()` nobody awaits was rejected: a reconnect attempt, the next transport of the list, the re-open when the browser came back online | +| `reconnect.restore`, `reconnect.afterOpen` | What runs after a reconnect — the re-`load()` and re-subscribe, the `authenticate` hook — failed | +| `subscription.error` | A subscription ended with an error and had no `onError` to tell | +| `subscription.listener` | A subscription's own `onEnd`/release callback threw | +| `listener.open`, `listener.close`, `listener.drain`, `listener.reconnecting`, `listener.reconnect-failed`, `listener.heartbeat-timeout`, `listener.authenticate-failed`, `listener.refresh-failed`, `listener.restore-failed`, `listener.transport-fallback` | A listener of yours for that client event threw | +| `client.error` | An escalated error with no more specific origin | + +### Why some refusals are `debug` + +`call.capacity` and `batch.refused` are reachable by any peer, in a loop, +without authenticating. Logging those at `warn` turns a refused flood into a +log-pipeline flood — a worse outage than the one being prevented. They go to +`debug`, which a Console writer drops outright and a structured logger's own +level decides. The codes that mean something is genuinely wrong stay at +`warn`. + +The `rpc.error` line that follows a refusal — the answer going out — is +`debug` for the same reason: the refusal's own line is the alert, at the +level chosen for it, and a second one at `error` would have put every 429 +back on the pager. And the strings a peer chose — a method, a packet id, a +packet type — are clipped to 128 characters before they become a field, so +a log line is never as long as `maxPayload` allows a packet to be. + +## What is deliberately not logged + +Four places write no lines on purpose, and should stay that way: + +- `src/query/` and `src/auth/` **require nothing** — that is what keeps them + around a kilobyte and browser-safe. Pulling the logger in would break a + stated invariant for a handful of lines. +- Stream chunking and WebRTC framing run **per chunk and per frame**, under a + bundle-size budget. A line there is a firehose and bytes nobody asked for. +- The telemetry writers swallow every error they meet. That is not an + oversight: telemetry must never be the reason a request fails, and a writer + that logged its own failures would need a logger, which would need a + failure path of its own. + +Credentials never appear in an entry. A session token, a bearer token and a +resume token are all refused-by-name in the code: `session.corrupt` logs that +a row failed to parse, `broker.feed.resume` logs the *reason* and the length, +and neither carries the value. A broker RPC session id — a credential too — +appears only as a 12-character fingerprint (`broker.rpc.session.*`, and the +transport's `source`), and the client's `handshake.fallback` carries the +error's name, not the error: undici repeats a header's value in its message. + ## Turning it off ```js @@ -103,7 +385,7 @@ the per-connection and per-call bindings cost nothing when logging is off. The [fastify plugin](./adapters/fastify) uses `fastify.log` unless you pass `logger` explicitly. Since that is a pino, it goes in as a structured logger -and your wrpc entries land in the same stream as fastify's own, with the same +and your wRPC entries land in the same stream as fastify's own, with the same request ids. ## Failures diff --git a/docs/guide/metadata.md b/docs/guide/metadata.md index b492425..69f27a9 100644 --- a/docs/guide/metadata.md +++ b/docs/guide/metadata.md @@ -1,6 +1,6 @@ # Connection & call metadata -Three different things answer to the word "meta" in wrpc. They are deliberately +Three different things answer to the word "meta" in wRPC. They are deliberately separate channels with separate contracts: | Channel | Phase | Validated? | Read as | @@ -59,7 +59,7 @@ every carrier, and it is the spelling to write in and `userID` both reduce to `user-id`; the last one written wins. Normalize at the source rather than relying on the difference. -**An external caller must write kebab themselves.** wrpc can only normalize +**An external caller must write kebab themselves.** wRPC can only normalize what its own client produced. By the time a hand-written `-H 'x-wrpc-meta-userId: 1'` reaches the server, HTTP has already lowercased it to `userid` and the word boundary is gone for good — no transform can @@ -82,60 +82,152 @@ travel depends on the transport: | --- | --- | | `http` | real request headers, on every packet POST and REST leg | | `sse` | real request headers, on the stream GET and every POST | -| `ws` | **one query parameter** (`wrpc_h`) on the connect URL | +| `ws` from Node | real request headers on the upgrade | +| `ws` from a browser | a **subprotocol offer**, `wrpc.h.`, in `Sec-WebSocket-Protocol` | | `event` (worker) | a field in the `wrpc:connect` message | +| `wt` | the connect URL's query (`wrpc_h` / `wrpc_meta`) — WebTransport has neither headers nor subprotocols | +| `broker` | message headers, on every stateless request and on a session's `hello` ([broker RPC](./brokers/rpc)) | +| `webrtc` | no request to carry them: over a raw channel the side that attaches it declares what it knows, `attachChannel(rpc, channel, { headers, data })`; over a `WrpcPeer` link, a peer's `data` is what it joined the signaling room with ([WebRTC](./webrtc#your-own-connection)) | + +The browser row exists because a page cannot set a header on a WebSocket +handshake — not with the `WebSocket` constructor (its second argument is the +subprotocol list and nothing else), not with `fetch` (`Upgrade`, +`Connection` and `Sec-WebSocket-Key` are forbidden header names), and not +with a library: `ws` refuses to load in a browser, and socket.io's +`extraHeaders` reach only its polling requests there. The one handshake +header a page does control is the subprotocol offer, so that is what carries +the bag: -The ws exception exists because the WHATWG `WebSocket` constructor takes no -headers — in the browser by specification, and in Node because the client -uses the same `globalThis.WebSocket`. The server compensates with its read -order: **observed upgrade headers first, the query only for names they do -not carry** — so a custom transport that *can* set real upgrade headers -(registered via `WrpcClient.transport`) needs no query at all, and the query -can never override a real header. +``` +Sec-WebSocket-Protocol: wrpc.v2, wrpc.v1, wrpc.h.eyJ4LWFwcC12ZXJzaW9uIjoiMS4yLjMifQ +``` + +The server selects `wrpc.v2`, reads the token, and never selects or echoes +it. (A 1.0 server reads no token — it took the bag from the connect URL — and +answers `wrpc.v1`; the client then dials once more with the query, so the +labels arrive there too — and in the URL every access log keeps, which the +client says with `declared.exposed`. `carrier: 'query'` saves that second +handshake.) + +::: warning A `wrpc.v1` answer is ambiguous +A 2.x server that sends no frames (`attachments: false`, a packet codec) +answers `wrpc.v1` too — and does read the tokens. The redial cannot tell the +two apart, so behind such a server a page still dials twice and still puts +its labels in the URL. The other way round, a page that offers `wrpc.v1` +alone — its own `attachments: false` — is never redialled: against a 2.x +server the tokens were read, against a 1.0 server they were not, and the +client can only say so (`handshake.ambiguous`, once). Either way the answer +is to choose the carrier: `carrier: 'query'` for a 1.0 server, +`carrier: 'protocol'` to keep the labels out of URLs. +::: +Node's built-in `WebSocket` has no such limit — it takes +`{ protocols, headers }` — so from Node the same option is simply real +headers, and the server needs nothing special to read them. Either way the +connect URL stays clean, and the server's read order is unchanged: +**observed upgrade headers first, a declaration only for names they do not +carry.** + +### Choosing the ws carrier: `carrier` -The query path is sanitized server-side, and every rule is a refusal, never +| `carrier` | Node | Browser | +| --- | --- | --- | +| `'auto'` (default) | real headers | subprotocol tokens | +| `'protocol'` | subprotocol tokens | subprotocol tokens | +| `'query'` | `wrpc_h` / `wrpc_meta` on the connect URL | the same | + +`'query'` is the escape hatch for an intermediary that strips or rewrites +`Sec-WebSocket-Protocol`; it is also what happens under `protocols: []`, +because a token needs a protocol the server can answer next to it (a client +fails a handshake whose offers all went unanswered). WebTransport has neither +headers nor subprotocols and always uses the query. + +A declaration is sanitized server-side, and every rule is a refusal, never an error — an oversize or malformed label leaves the connection with no label, not without a connection: -- capped on the **encoded** length (`metaMaxBytes`, default 2048). On ws the - cap is measured over the **whole connect-URL query**, so application query - parameters share the budget with the declared bags; the client refuses an - oversize bag with a `meta.oversize` warning instead of sending what the - server would silently drop whole; +- capped on the **encoded** length (`metaMaxBytes`, default 2048). The two + subprotocol tokens (`headers` + `meta`) share **one** budget, headers + first; the query is measured whole, so application query parameters share + it. Encoded means **after base64url**: a token is a third longer than the + JSON it carries, so the default holds about 1.5 KB of JSON for headers and + meta together — a large value (a JWT) belongs in [`bearerAuth`](./auth), + whose Bearer rides outside the budget. The client applies the same cap and + refuses an oversize bag with a `meta.oversize` warning instead of sending + what the server would drop — **visible only with a `logger`**, which is off + by default on the client: a bag that never arrives is a silent client until + you turn it on (`meta.oversize`, `declared.unsendable`, `declared.exposed`, + `handshake.fallback` are the lines to look for). + Mind the host too: a handshake is an HTTP request, and uWebSockets.js + allows **4096 bytes for all request headers** (`UWS_HTTP_MAX_HEADERS_SIZE`) + where node allows 16 KB — a Bearer token rides outside the budget, so a + large JWT plus two full bags can reach that limit; - a flat `string → string` map only; names normalized to [kebab-case](#key-casing); -- reserved names dropped: `cookie`, `host`, `origin`, and the `sec-`, - `content-`, `proxy-`, `x-wrpc-` prefixes. On http/sse `fetch` itself - refuses to send these, so the deny list exists exactly for the query path - — without it a peer could spoof `cookie` through the URL; +- reserved names dropped: `cookie`, `host`, `origin`, `forwarded`, `via`, + `x-real-ip`, `x-client-ip`, `true-client-ip`, `cf-connecting-ip`, + `fastly-client-ip`, `fly-client-ip`, `remote-user`, and the `sec-`, + `content-`, `proxy-`, `x-wrpc-`, `x-forwarded-`, `x-auth-request-` + (oauth2-proxy), `x-amzn-oidc-` (ALB), `x-goog-authenticated-user-` / + `x-goog-iap-` (IAP) and `x-ms-client-principal` (Azure) prefixes — the + names an identity-aware proxy sets about the user it authenticated, exact, + so an application's own `x-auth-token` is not caught. A name that is not a + header name at all (a space in it) is dropped too, and `_` counts as `-` + against the list — `remote_user` is `remote-user` to nginx, CGI and the + frameworks that fold one into the other. The list is about a + hostile **page**: it controls exactly the connect URL and the subprotocol + offers while the victim's cookie rides along by itself, so without the list + it could forge a `cookie`, an `origin`, or the address a rate limiter + reads when no proxy has set one. Real request headers are not filtered — + `fetch` already refuses the dangerous ones on http/sse, and a peer outside + a browser can send anything regardless. Beside the list, an **allowlist** + narrows what may be declared at all: `new Server({ declaredHeaders: + ['authorization'] })` keeps only those names from a declaration (`cookie` + is never declarable, allowlisted or not). And `context.meta.declared` + names which of `meta.headers` came from the declaration — a label the + client attached, as opposed to a header the connection carried — so a + handler that must not trust a label can tell; - an own `__proto__` key never carried over. -::: warning The ws form lands in logs -The connect URL — query included — ends up in proxy access logs and the -browser's network panel. A device id belongs there; a token does not. For -credentials, use the [`authenticate` hook](./client#authenticating) and the -session, where the ws leg's default carrier is the cookie precisely because -of this. +::: warning Labels, not secrets +Neither default carrier touches the connect URL, which is what proxy access +logs and the browser's network panel keep — only `carrier: 'query'` and +`protocols: []` do — or the redial above — and the client warns +(`declared.exposed`) when an `authorization` header ends up there, and names +every declared header when the redial put them there. A subprotocol token is still a request +header anyone on the path can read and some proxies can log, so treat the +bags as labels. For credentials use [`bearerAuth`](./auth) — its token rides +as `wrpc.bearer.` from a browser and as a real `Authorization` header +from Node — or the [`authenticate` hook](./client#authenticating) and the +session cookie. ::: ### Gating the handshake Declared headers arrive with the upgrade request, so `verifyClient` can -refuse a client **before** the connection exists: +refuse a client **before** the connection exists. No `Client` exists yet, so +the gate reads the request through `readHandshake` — the same function the +server runs a moment later, whichever carrier the client used: ```js +const { Server, readHandshake } = require('@alexify/wrpc'); + const server = new Server({ router, ws: { verifyClient: ({ req }) => { - const declared = new URL(req.url, 'http://x').searchParams.get('wrpc_h'); - const headers = declared ? JSON.parse(declared) : {}; - return supported(headers['x-app-version'] ?? req.headers['x-app-version']); + const { headers, meta } = readHandshake(req); + return supported(headers['x-app-version']) && !banned(meta['device-id']); }, }, }); ``` +`headers` is what `context.meta.headers` will be — declared names under the +observed ones, kebab-cased, reserved names dropped — and `meta` is what +`context.meta.data` will be. Both are peer-controlled labels: gate on them, +do not authenticate with them. Pass `{ metaMaxBytes }` when the server sets +its own. + For per-procedure requirements, declare [`schema.headers`](./rest#the-schema-option) instead — it validates `context.meta.headers` with the injected ajv and answers 400 with `/headers`-prefixed issues. @@ -176,9 +268,12 @@ writes them in its own `onRequest`. const client = await connect(url, { meta: { v: pkg.version, locale } }); ``` -Carried by the `x-wrpc-meta` request header (http/sse; percent-encoded -JSON), the `wrpc_meta` connect-URL parameter (ws), or the `wrpc:connect` -message (worker). +Carried by the `x-wrpc-meta` request header (http/sse, and ws from Node; +percent-encoded JSON), a `wrpc.m.` subprotocol offer (ws from a +browser — [the same carrier rules](#choosing-the-ws-carrier-carrier) as +`headers`, under the same shared budget), the `wrpc:connect` message +(worker), the connect URL's `wrpc_meta` query (wt), or a message header on +the broker binding. ### Choosing a spelling: `metaFormat` @@ -189,7 +284,8 @@ const client = await connect(url, { meta: { userId, locale }, metaFormat: 'prefi | | `'json'` (default) | `'prefixed'` | | --- | --- | --- | | http / sse wire | one `x-wrpc-meta` header | `x-wrpc-meta-user-id: 7`, one per key | -| ws / worker wire | one `wrpc_meta` parameter | **unchanged** — one `wrpc_meta` parameter | +| ws from Node | one `x-wrpc-meta` header | `x-wrpc-meta-user-id: 7`, one per key | +| ws from a browser / worker wire | one JSON bag (`wrpc.m.` token, `wrpc:connect` field) | **unchanged** — one JSON bag | | Value types | JSON (numbers stay numbers) | strings on **every** transport | | CORS | one stable allowlist name | one entry per key — see [`cors.metaHeaders`](./server#cors) | @@ -225,7 +321,7 @@ onRequest: async (context) => { A REST caller (curl, another service) passes the same thing over headers — for a per-request client the connection *is* the call, so they double as -`context.callMeta`. Both spellings are accepted, the same two the wrpc +`context.callMeta`. Both spellings are accepted, the same two the wRPC client chooses between with [`metaFormat`](#choosing-a-spelling-metaformat): ```bash @@ -276,9 +372,10 @@ a handler; read `context.meta.data` when you want what the request as a whole was labelled with. If the aggregate would exceed `metaMaxBytes`, the client drops it with a -`meta.oversize` warning and sends the connection bag alone. That refusal is -deliberate: the server's own cap discards the *entire* bag, which would look -like metadata that silently stopped arriving. +`meta.oversize` warning — through its `logger`, off by default — and sends +the connection bag alone. That refusal is deliberate: the server's own cap +discards the *entire* bag, which would look like metadata that silently +stopped arriving. Sanitizing is shared with the connection phase: a plain object or nothing, capped by `metaMaxBytes` on the serialized size, own `__proto__` dropped diff --git a/docs/guide/multiple-backends.md b/docs/guide/multiple-backends.md new file mode 100644 index 0000000..675b934 --- /dev/null +++ b/docs/guide/multiple-backends.md @@ -0,0 +1,158 @@ +# Multiple backends + +An app talking to several independent backends — a REST-ish service, a +realtime one, an edge service reachable only over WebTransport, plus a local +worker fronting IndexedDB — does not need a different hand-rolled client for +each. It needs one `WrpcClient` **instance per backend**. + +```mermaid +flowchart LR + App["Your app"] + App -->|"connect(url, { transport: 'http' })"| A["WrpcClient A"] + App -->|"connect(url)"| B["WrpcClient B"] + App -->|"connect(url, { transport: ['wt','ws'] })"| C["WrpcClient C"] + App -->|"connect(url, { worker })"| D["WrpcClient D"] + A -->|http| SvcA["Service A"] + B -->|ws| SvcB["Service B"] + C -->|wt / ws fallback| SvcC["Service C"] + D -->|MessagePort| W["SharedWorker"] + W -->|wRPC, event transport| SvcD["Local server"] + W -.-> IDB[("IndexedDB")] +``` + +## The model: one instance, one connection, one backend + +`WrpcClient.connect()` (the `connect()` alias too) always resolves **one** +transport for **one** URL and returns **one** client +([Client](./client#transports)). There is no multi-endpoint client and no +built-in router that dispatches a call to "whichever backend owns this unit" — +composing several backends means holding onto several independent +`WrpcClient` values, exactly as you would hold onto several `fetch` base URLs +today. + +That is a feature, not a gap to work around: each instance carries its own +transport, its own [reconnect backoff](./client#reconnecting), its own +[heartbeat](./client#heartbeat), and its own +[`authenticate`/`refresh`](./client#authenticating) hooks. A service that +needs a bearer token and one that doesn't never have to share a code path, +and a slow reconnect on one backend never blocks calls to another. + +```js +const { connect } = require('@alexify/wrpc'); + +const svcA = await connect('https://svc-a.example.com/api', { transport: 'http' }); +const svcB = await connect('wss://svc-b.example.com/api'); // ws: the wss: default +const svcC = await connect('https://svc-c.example.com:4433/api', { + transport: ['wt', 'ws'], // WebTransport, falling back to ws + wt: { serverCertificateHashes: [{ algorithm: 'sha-256', value: hash }] }, +}); + +await Promise.all([svcA.load('orders'), svcB.load('chat'), svcC.load('feed')]); +``` + +::: tip Not the same thing as transport fallback +`transport: ['wt', 'ws']` on `svcC` is [transport +fallback](./client#transport-fallback) — several ways to reach **one** +backend, tried in order. It is unrelated to running `svcA`/`svcB`/`svcC` side +by side, which is just three separate `connect()` calls. Don't reach for a +fallback list to talk to three different services; reach for three clients. +::: + +## Typed, one contract per instance + +Each `connect()` call takes its own type argument, so three backends +with three unrelated [contracts](./typed-client) coexist without conflict: + +```ts +import { connect } from '@alexify/wrpc'; +import type { ServiceAApi } from './contracts/service-a'; +import type { ServiceBApi } from './contracts/service-b'; +import type { ServiceCApi } from './contracts/service-c'; + +export const svcA = connect('https://svc-a.example.com/api', { transport: 'http' }); +export const svcB = connect('wss://svc-b.example.com/api'); +export const svcC = connect('https://svc-c.example.com:4433/api', { transport: ['wt', 'ws'] }); +``` + +A module per backend, exporting the pending client, is enough structure: +consumers `await` the export they need and the module caches the promise — +no registry or DI container required. Generate each contract independently +with the [codegen CLI](./cli) — `npx wrpc types https://svc-a.example.com/api +--out contracts/service-a.d.ts` — one run per backend, since each is its own +running server with its own `system/introspect`. + +If these clients feed [`@alexify/wrpc/query`](./query), give each its own +`prefix` so their cache keys never collide in one `QueryClient`: + +```js +const qa = createQueryUtils(await svcA, { queryClient, prefix: ['svcA'] }); +const qb = createQueryUtils(await svcB, { queryClient, prefix: ['svcB'] }); +``` + +## A local backend behind a worker + +The [`event` transport](./client#workers) is what makes a fourth kind of +backend possible: one that isn't across the network at all, but behind a +`SharedWorker` (or a Service Worker) living on the same page. The worker +holds one real connection and every tab reaches it over a `MessagePort` — +useful for a backend backed by `IndexedDB`, where you want exactly one +writer, not one per tab. + +```js +// idb-worker.js — runs inside the SharedWorker +const { WrpcClientProxy } = require('@alexify/wrpc'); + +const proxy = new WrpcClientProxy({ url: 'ws://localhost:4000/api', callTimeout: 7000 }); +await proxy.open(); +``` + +```js +// the page — one more client, alongside svcA/svcB/svcC. The URL is not used +// to open anything here (the worker already knows where to connect) — it +// only labels the client, so any string that names the backend will do. +const shared = new SharedWorker('/idb-worker.js', { name: 'wrpc-idb' }); +const svcD = await connect('local:idb', { worker: shared }); +``` + +::: warning `WrpcClientProxy` relays; it does not host your router +The proxy is a forwarder: it opens its own `WrpcClient` to a real +[`RpcServer`](./server) and shuttles packets between that connection and +every page's `MessagePort`. `RpcServer`/`attachPort` require Node built-ins +(`node:crypto` for [cluster](./cluster) auth, among others) and are not part +of the [browser bundle](./browser) — a `SharedWorker` cannot run one +in-process. If "local IndexedDB" means logic that never leaves the browser +(no server process behind it), keep that worker as a plain +`postMessage`/`IndexedDB` module; reach for the `event` transport when there +*is* a real wRPC server the worker should hold one shared connection to — +typically `localhost` in a desktop shell (Electron, Tauri) or during +development. +::: + +Each `connect({ worker })` opens its **own** `MessageChannel`, so one page +can reach several worker-backed backends at once — each client has its own +port and its own lifecycle, and closing one leaves the others connected: + +```js +const idb = new SharedWorker('/idb-worker.js', { name: 'wrpc-idb' }); +const files = new SharedWorker('/files-worker.js', { name: 'wrpc-files' }); +const svcD = await connect('local:idb', { worker: idb }); +const svcE = await connect('local:files', { worker: files }); +``` + +## Independent lifecycles, one exception + +Reconnection, heartbeats and auth are per-instance — a dropped `svcB` +reconnects on its own schedule without touching `svcA` or `svcC`. The one +shared surface is connectivity: [`WrpcClient.offline()` / +`WrpcClient.online()`](./client#offline-and-online) act on +`WrpcClient.connections`, the process-wide set of every live client, +so a browser `offline`/`online` event (wired up by +`WrpcClient.initialize()`) suspends and resumes **all four** backends +together — which is normally exactly what you want when the machine itself +lost its network. + +Tear down the same way you set up — one `close()` per instance: + +```js +await Promise.all([svcA.close(), svcB.close(), svcC.close(), svcD.close()]); +``` diff --git a/docs/guide/performance.md b/docs/guide/performance.md index e673365..2daa6e3 100644 --- a/docs/guide/performance.md +++ b/docs/guide/performance.md @@ -14,22 +14,96 @@ measurement is a new file and nothing else. Nothing in CI runs it: there is no stored baseline, and a benchmark that gates a pipeline on a noisy laptop number is worse than no benchmark. +## The short version + +Skip to whichever section you actually need; this is the two-minute read +for everyone else — what wins outright, what's behind and expected to be, +and which knobs are worth turning for your traffic shape. + +**Wins outright, no configuration needed:** + +- **Broadcasting.** A room's update is serialized, framed and — once a peer + has negotiated it — compressed **once per emit**, not once per member. + That's why fan-out throughput climbs with room size instead of collapsing + under it, and why compressed fan-out is ~34× what a naive + per-recipient implementation manages — see [Fan-out](#fan-out). +- **Payloads that aren't toy-sized.** At 10 KB, wRPC is at or ahead of + every raw WebSocket library in the table below — the send path never + re-encodes or re-copies what it already built. +- **Surviving an instance loss.** With a shared session store and any + pub/sub as a backplane, 50 clients whose instance just died reconnect, + re-authenticate and rejoin their room within single-digit milliseconds — + no sticky load balancer, no manual failover. See + [Across instances](#across-instances). +- **A call that does not queue behind an upload.** From Chrome, a small call + made while uploads saturate the connection answers in 9 ms over + WebTransport against 46 ms over a WebSocket — each binary stream has a QUIC + stream of its own. And on every transport the stack sets the ceiling, not + wRPC: over WebTransport and WebRTC in Node, wRPC's calls run at 85–100% of a + raw echo over the same stack. See [Across transports](#across-transports). +- **Against RPC frameworks.** With 64 calls in flight wRPC answers 117K calls + a second — 1.15× socket.io, 5.0× tRPC, 5.6× gRPC through `@grpc/grpc-js` — + and 201K with client batching on. See [Against other stacks](#against-other-stacks). + +**Behind, and expected to be:** + +- **A single tiny call against a raw echo.** A socket with no router, no + access check and no correlation ID was always going to win that race: + the raw echoes answer 1.25–1.48× as many. Paying that for real request + handling is the trade for getting one, not a defect — see + [Against other stacks](#against-other-stacks). +- **Plain HTTP against fastify.** wRPC's HTTP paths answer 0.88–0.90× of + fastify's requests on a JSON echo, and 1.2× express's. Every request pays for + the RPC path and for wRPC's default headers. See [Over HTTP](#over-http). +- **One call at a time in the browser.** The client's deadline scheduler + earns its keep once many calls are in flight; a lone awaited call + doesn't exercise it either way. +- **WebTransport and WebRTC in Node.** Their stacks are native bindings + into userspace QUIC and SCTP, and on loopback they move a fraction of what + Node's WebSocket does — use them for what only they do, not for + throughput. See [Across transports](#across-transports). + +**Leave these as they are by default:** + +| Setting | Default | Turn it on when | +| --- | --- | --- | +| shared-frame broadcast path | always on | never — it's the fix for the fan-out cost, not a tradeoff to opt into | +| `perMessageDeflate` | off | the peer is bandwidth-bound (mobile, metered, cross-region) and frames are past a kilobyte — [Compression is off by default](#compression-is-off-by-default) has the numbers. Off because a default would spend CPU on every frame of every peer to save bytes only some of them need | +| `contextTakeover` | off | you repeatedly broadcast the **same shape** (price ticks, presence deltas) to bandwidth-constrained clients — ~10× the compression ratio, for ~160 KiB of zlib state per direction per connection | +| `async: { threshold }` | off | large broadcasts (≥256 KB payloads) would otherwise stall the event loop for the length of the burst | +| client `batch: true` | off | you fire many RPC calls back to back — bulk hydration, chart backfills — where it buys 72% more pipelined throughput below (an engine swap buys nothing on that path), at the cost of a single call waiting on a flush | +| [uws engine](./adapters/uws) | Node's own engine | you're CPU-bound on the WS engine at very high concurrency and can take a native dependency | + +The rest of this page is where every one of those numbers comes from. + ## Against other stacks -`bench/rpc-comparison.js` puts wrpc's **full RPC path** (router, validation, +`bench/rpc-comparison.js` puts wRPC's **full RPC path** (router, validation, context, callback correlation) next to raw WebSocket echoes with no RPC layer -at all, and next to two frameworks that do the same job wrpc does. +at all, and next to three frameworks that do the same job wRPC does: +socket.io and tRPC over a WebSocket, and gRPC — `@grpc/grpc-js`, unary over +HTTP/2, the service loaded from a `.proto` at runtime. Three wRPC rows: the +default (own engine, one frame per call), the same RPC path over the +[uws engine](./adapters/uws), and the own engine with client +[batching](./client#batching) on. -| Stack | small payload | 10 KB payload | small ×64 batched | -| --- | ---: | ---: | ---: | -| **wrpc** — own WS + RPC dispatch | 26,438 | **13,587** | 101,219 | -| `ws` — raw echo, no RPC | 35,635 | 12,107 | 108,955 | -| uWebSockets.js — raw echo, no RPC | 30,196 | 12,169 | 158,567 | -| `@fastify/websocket` — raw echo, no RPC | 34,888 | 12,307 | 110,184 | -| socket.io — framework RPC via `emitWithAck` | 25,254 | 11,128 | 99,472 | -| tRPC — framework RPC over `wsLink` | 661¹ | 587¹ | 23,023 | + -ops/sec, higher is better. +| Stack | small payload | 10 KB payload | small ×64 in flight | +| --- | ---: | ---: | ---: | +| **wRPC** — own WS + RPC dispatch | 25,213 | **13,986** | 116,704 | +| **wRPC** — uws engine + RPC dispatch | 26,042 | **13,801** | 116,174 | +| **wRPC** — own WS, `batch: true` | 27,078 | **14,027** | **201,075** | +| `ws` — raw echo, no RPC | 34,987 | 12,206 | 112,487 | +| uWebSockets.js — raw echo, no RPC | 31,567 | 12,226 | 160,872 | +| `@fastify/websocket` — raw echo, no RPC | 37,334 | 12,746 | 112,133 | +| fastify-uws — raw echo, no RPC | 31,572 | 8,702 | 160,046 | +| socket.io — framework RPC via `emitWithAck` | 26,213 | 10,862 | 101,455 | +| tRPC — framework RPC over `wsLink` | 628¹ | 596¹ | 23,310 | +| gRPC — `@grpc/grpc-js` unary over HTTP/2 | 8,127 | 7,844² | 20,921 | + +ops/sec, higher is better; one run, one machine (Node v24.14.1, Apple M3 +Max), so read rows against each other rather than against an earlier table. ¹ tRPC's sequential number is a **client-side flush-timer artifact, not throughput**: its per-call latency measures a near-constant ~1.5 ms @@ -39,59 +113,431 @@ pipelined column shows what the same stack does when many calls share a flush. Compare tRPC on the batched column, where its per-call machinery amortizes. +² gRPC encodes protobuf, not JSON: its 10 KB payload is a string field +copied as bytes, which is why that column barely moves from the small one. +Every other row pays JSON for it. + Read it honestly: -- **A raw echo is the ceiling, not a competitor.** `ws` at 1.35× on small - payloads is what a socket costs with no router, no access check and no - correlation on top. wrpc paying 26% for all of that is the trade. -- **On real payloads the gap closes and inverts.** At 10 KB, wrpc is the - fastest entry in the table — including the raw echoes — because the - send path avoids re-encoding and re-copying what it already has. -- **Against frameworks doing the same job**, wrpc is level with socket.io on - a single call and the fastest measured stack at 10 KB. The tRPC rows read - through footnote ¹: its sequential number is a client flush-timer - artifact, and the honest comparison is the batched column — where wrpc is - still ~4.4× ahead. tRPC's type story is excellent and unaffected by any - of this. -- **Batching changes the ranking**, and a uWebSockets.js-backed stack wins it — - which is exactly why the [uws engine](./adapters/uws) is a supported swap. +- **A raw echo is the ceiling, not a competitor.** The raw echoes answer + 1.25–1.48× as many single small calls as wRPC's default row: that is what a + socket costs with no router, no access check and no correlation on top. + Read the small-payload column as one cluster, not a ladder — the three + wRPC rows (25.2K–27.1K) and socket.io (26.2K) sit within 8% of each other + this run, socket.io 4% ahead of the default row and behind the batched one. +- **On real payloads wRPC moves ahead.** At 10 KB every wRPC row (13.8K–14.0K) + is ahead of every raw echo — the fastest, `@fastify/websocket`, at 12.7K — + and 29% ahead of socket.io: the send path never re-encodes or re-copies + what it already built. +- **Batching, not the engine, closes the pipelined gap.** With 64 calls in + flight wRPC runs at 117K on its own engine and 116K on the uws engine, + against 161K for a raw uws echo: swapping the JavaScript WebSocket engine + for the native one buys nothing on this path this run, and the distance to + the raw echo is the RPC layer plus the client. Client batching — 64 calls + sharing frames — buys 72%, to 201K, past every raw echo in the table. Both + are one option away; neither is the default, because a single call should + not wait for a flush. +- **Against frameworks doing the same job**, wRPC is 15% ahead of socket.io + with 64 calls in flight and 29% at 10 KB, and level with it on single small + calls. tRPC reads through footnote ¹: on the pipelined column wRPC is 5.0× + ahead, and tRPC's type story is excellent and unaffected by any of this. + gRPC through `@grpc/grpc-js` answers 8.1K small calls a second and 20.9K + with 64 in flight — wRPC is 3.1× and 5.6× that — and closes most of the gap + at 10 KB (footnote ²). Connect and gRPC's other implementations were not + measured. + +Step back from the individual rows and the question this table actually +answers is: is the RPC layer the bottleneck? On every payload size +measured, no. Tens of thousands of calls per second, locally, with no +network in the loop, is a number a deployed system will rarely see even a +fraction of — real traffic is bounded by client concurrency, database +round-trips and network RTT long before a request queue backs up waiting +on wRPC's own dispatch. Read this table as proof the floor is high enough +to stop worrying about, not as a number to chase in production. + +## Over HTTP + +`bench/http-comparison.js` loads one JSON echo per stack the way +[fastify/benchmarks](https://github.com/fastify/benchmarks) loads Node +frameworks — autocannon, 100 connections, 10 pipelined requests each, 10 s, +every server in a process of its own — and puts wRPC's two HTTP paths next to +fastify, express, tRPC's standalone adapter and `node:http` with no framework +at all: + +```bash +node bench/http-comparison.js # WRPC_BENCH_DURATION=30 for longer runs +``` + + + +| Stack | requests/s | mean latency | p99 latency | +| --- | ---: | ---: | ---: | +| **wRPC** — declared REST route | 62,236 | 15.5 ms | 29 ms | +| **wRPC** — packet mode | 60,927 | 15.9 ms | 32 ms | +| fastify | 69,187 | 13.9 ms | 30 ms | +| express | 50,567 | 19.3 ms | 36 ms | +| tRPC — standalone HTTP adapter | 16,523 | 59.9 ms | 85 ms | +| `node:http` — no framework | 104,404 | 8.9 ms | 16 ms | + +One run, one machine (Node v24.14.1, Apple M3 Max), loopback — the load +generator shares the machine with the server, as it does in +fastify/benchmarks. + +Read it honestly: + +- **Plain HTTP is not where wRPC is fastest.** Its two HTTP paths answer + about 61,000 requests a second: 0.88–0.90× fastify, 1.2× express, 3.7× + tRPC's adapter. Each request pays what a WebSocket call pays once per + connection — parsing, routing, a server-side client with its id, its + metadata and its session restore — on top of the RPC path itself + (dispatcher, access check, hooks, a `Context`). +- **The default headers are a tenth of the ceiling.** Every answer carries + CORS, `strict-transport-security`, `x-content-type-options` and + `wrpc-version` — 338 bytes a bare fastify or express route does not send. + Building them is ~20 ns (`bench/cors-headers.js`); `node:http` validating + and writing them on every answer costs it about a tenth of its requests + (`bench/http-headers.js`, the ceiling row answering with and without them). + They are security defaults and stay on. +- **What the core costs on its own.** `bench/http-call.js` drives + `handleHttpCall` with a stub in place of the socket: about 320,000 requests + a second on either path, so ~3 µs of a request is wRPC's routing, client and + dispatch with node's HTTP parser, header writing and socket taken out. +- **The two paths cost the same.** A declared REST route (plain JSON in and + out) and packet mode (the call packet a WebSocket carries, as one POST) + are within 3% of each other: the REST bridge's trie is not where the time + goes. +- **Which to reach for.** For traffic that is mostly RPC, a persistent + transport is the better carrier — the WebSocket rows above. For an + HTTP-first API, the [fastify adapter](./adapters/fastify) serves wRPC's + procedures from the fastify instance that already serves the rest of it. ## Fan-out `bench/send-path.js` — one room, N members, 512-byte payload: + + | Scenario | rate | throughput | | --- | ---: | ---: | -| `sendText` 200 B | 6,877,224/sec | 1338 MB/s | -| `sendText` 4 KB | 775,007/sec | 3030 MB/s | -| room fan-out ×50 | 99,487/sec | 2751 MB/s | -| room fan-out ×200 | 29,197/sec | 3230 MB/s | -| room fan-out ×50 **+ deflate** | 2,776/sec | 9 MB/s | - -Fan-out encodes the frame **once** and writes the same buffer to every member; -throughput keeps climbing with the member count because the per-recipient cost -is a write, not a serialize. - -::: warning permessage-deflate costs 36× on fan-out -Compression is per-connection by construction — the same payload has to be -compressed once per recipient, and the deflate context makes it expensive. -`sendText 200 B` drops from 6.9 M/s to 206 K/s with deflate on. Enable it for -bandwidth-bound clients, not for chatty in-datacenter fan-out. +| `sendText` 200 B | 6,466,955/sec | 1258 MB/s | +| `sendText` 4 KB | 1,853,364/sec | 7247 MB/s | +| `sendText` 64 KB | 63,693/sec | 3981 MB/s | +| room fan-out ×50 | 539,105/sec | 14910 MB/s | +| room fan-out ×200 | 208,685/sec | 23086 MB/s | +| room fan-out ×50 **+ deflate** | 102,914/sec | 339 MB/s | +| room fan-out ×200 **+ deflate** | 66,083/sec | 870 MB/s | +| room fan-out ×50 + deflate, windows 10/15 | 53,152/sec | 175 MB/s | + +A broadcast is serialized **once**, and on the built-in engine it is also +framed once: `Broadcast.emit` hands every recipient one shared message, the +first `Connection` to write it encodes the frame into the message's cache +slot, and every later recipient writes that same buffer +(`Connection.sendPrepared`). The per-recipient cost is a socket write, which +is why throughput keeps climbing with the member count. + +::: tip permessage-deflate is compressed once per emit, not once per member +The compressed bytes depend only on the payload and the peer's negotiated +window (both directions are pinned to no context takeover — see +[wire format](../reference/wire-format#permessage-deflate)), so the shared +message caches one deflated frame **per distinct window** and a room of 200 +costs one `deflateRaw` for the whole fan-out. A fleet that negotiates two +window sizes pays two (the `windows 10/15` row). The remaining gap to the +uncompressed rows is that one deflate; before the shared frame it was +**one per recipient** and fan-out ×50 ran at 2,806/sec. ::: +What that means for a real room: a 200-member broadcast with compression on +costs one `deflateRaw` call and 200 socket writes, and a single instance +clears that over 60,000 times a second. No legitimate application pushes +updates to one room at that rate — a live chat, a presence feed, a stock +ticker are all in the tens-per-second range at most — so in a deployed +system the ceiling that matters moves to client-side rendering and network +egress bandwidth, not this code path. The number is headroom, not a target. + +Unicast sends are one contiguous buffer up to 16 KiB — header and payload +written together, the text utf8-encoded straight from a scratch buffer — and +a separate header + payload write above it, where the copy would cost more +than the write it saves (the 64 KB row). On the server every write of one +event-loop turn is corked and flushed on the next tick, so a batch of N +answers leaves in one `writev`. + +Compression is still worth choosing per peer and per message: enable it for +bandwidth-bound clients with the handshake `filter`, and skip it for a +message that is already compressed or latency-critical with +`emit(name, data, { compress: false })` — see [rooms](./rooms#compression). + +## In the browser + +`pnpm bench:browser` runs `bench/browser/calls.js` in the installed Chrome +(through `playwright-core`, a devDependency — no browser download): the +client's call path, id to settle, over an in-page echo on a `MessageChannel` +so no network and no server code is in the number. + +| Scenario (Chrome 152) | ops/sec | +| --- | ---: | +| call echo, 1 in flight | 100,336 | +| call echo, 64 in flight | 121,487 | +| call echo, 1024 in flight | 114,242 | +| call echo, 64 in flight, per-call `timeout` | 121,308 | + +The pipelined rows are what the client's deadline scheduler bought: one +bucketed timer per client instead of a `setTimeout` and three closures per +call took 64 in flight from 110,442 to 121,487 and 1024 from 102,562 to +114,242, with a single awaited call unchanged. `WRPC_ROOT=` points +the runner at another checkout for a before/after. + +Read the 1024-in-flight row as a stress test, not a target: a real UI +rarely has more than a handful of calls genuinely in flight from one user +action. What the scheduler actually buys day to day is that a burst of +calls — a dashboard hydrating a dozen panels on load — produces one timer +and one deque entry each, not a dozen independent `setTimeout`s and their +closures; the throughput numbers are evidence that the design doesn't cost +anything, not the reason it exists. + +## Across transports {#across-transports} + +Everything above runs over a WebSocket. The same `Server` and the same client +also run over [WebTransport](./wt) and a [WebRTC](./webrtc) data channel, and +both of those have a **native stack** under them — Node has neither in its +standard library — so the first question is whose cost a number is. +`bench/rpc-comparison.js` answers it the way it does for the WebSocket: next +to wRPC over each stack, a raw echo over the same stack with no RPC layer. +`bench/transports.js` adds what an echo cannot show — opening, a stream, a +call under load — and `pnpm bench:browser transports` does it all from +Chrome, over the browser's own WebSocket, WebTransport and WebRTC. + +```bash +node scripts/wt-cert.js certs # the 14-day certificate WebTransport needs +WRPC_WT=fails WRPC_RTC=node-datachannel node bench/rpc-comparison.js +WRPC_WT=fails WRPC_RTC=node-datachannel node bench/transports.js +WRPC_WT=fails pnpm bench:browser transports +``` + +The stacks are the ones the integration tests use: +[`@fails-components/webtransport`](https://github.com/fails-components/webtransport) +— Google's libquiche behind a native binding — for the HTTP/3 host and the +Node client, and [`node-datachannel`](https://github.com/murat-dogan/node-datachannel) +— libdatachannel — for both peers of a loopback pair. Without the variables +those rows are skipped with the reason, and `pnpm bench` stays +self-contained. Everything here is **loopback**: no packet loss and no round +trip to speak of, so what QUIC does about loss, and what a 0-RTT handshake +saves on a real network, are not in these numbers. + +### In Node + +Calls — the same run as [Against other stacks](#against-other-stacks) +(ops/sec, higher is better): + + + +| Stack | small payload | 10 KB payload | small ×64 in flight | +| --- | ---: | ---: | ---: | +| **wRPC** — own WebSocket | 25,213 | 13,986 | 116,704 | +| **wRPC** — WebTransport (libquiche) | 10,873 | 2,351 | 17,054 | +| WebTransport — raw echo, no RPC | 12,810 | 2,396 | 18,646 | +| **wRPC** — WebRTC data channel (libdatachannel) | 7,694 | 1,417 | 13,666 | +| WebRTC data channel — raw echo, no RPC | 8,114 | 1,415 | 13,782 | + +Opening, streaming, and a call under load (`bench/transports.js`, one run): + + + +| Transport | open + load + first call, p50 | 16 MiB up + 16 MiB down | the same bytes, bare stack | small call, idle p50 | small call under upload load, p50 / p99 | +| --- | ---: | ---: | ---: | ---: | ---: | +| WebSocket | 1.1 ms | 575 MiB/s | 545 MiB/s | 0.09 ms | 15.2 / 30.0 ms | +| WebTransport | 2.6 ms | 44.5 MiB/s | 52.9 MiB/s | 0.09 ms | 15.3 / 18.0 ms | +| WebRTC | 507 ms¹ | 35.2 MiB/s | 38.9 MiB/s | 0.14 ms | 84 / 154 ms | + +¹ 506 ms of it is the pair's own offer/answer, ICE and DTLS in +libdatachannel, timed alone; wRPC's share of opening is about a millisecond. +In Chrome the same pair opens in a few milliseconds (below). + +Read it honestly: + +- **On every transport, the stack sets the ceiling — not wRPC.** Over the + same libquiche session, wRPC's calls run at 85% of a raw echo's on small + payloads, 98% at 10 KB and 91% with 64 in flight; over the same data + channel at 95%, 100% and 99%. A wRPC stream moves 84% of what the bare WebTransport stream does, + 90% of the bare data channel, and 105% of the bare `ws` package. +- **In Node, both native stacks are far behind the WebSocket.** A third to a + half of the small calls, a sixth to a tenth of the 10 KB ones, a thirteenth to a + sixteenth of the stream throughput: a binding into a userspace QUIC or SCTP stack, against + TCP on loopback, where the kernel is at its best. Reach for them in Node + for what only they do — a peer-to-peer link, datagrams, a client whose + only way in is HTTP/3 — not for throughput. +- **Under load, the Node rows do not isolate head-of-line blocking.** A call + under a saturating upload has a lower p99 on WebTransport (18 ms) than on + the WebSocket (30 ms), but the WebSocket is moving about ten times the + bytes meanwhile. The browser rows below compare like with like. + +### In Chrome + +`pnpm bench:browser transports` — Chrome 154 against the same Node `Server` +(the WebRTC row is a pair inside the page, with a `PeerHost` at the far end): + + + +| Transport | open + first call | small | 10 KB | small ×64 | 16 MiB up + down | small call under upload load, p50 / p99 | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| WebSocket | 12.5 ms | 10,789 | 6,009 | 44,769 | 330 MiB/s | 46.3 / 54.5 ms | +| WebTransport | 12.0 ms | 8,848 | 3,202 | 23,806 | 79 MiB/s | **9.0 / 11.4 ms** | +| WebRTC² | 5.4 ms | 5,353 | 1,020 | 10,400 | 14 MiB/s | 40 / 53 ms | + +calls in ops/sec; ² both peers in one page, so one renderer runs both +ends' SCTP and DTLS, and the far end is a `PeerHost` in that page rather than +the Node server — a lower bound on a real link, not a measurement of one. + +- **A call does not wait behind an upload on WebTransport.** While uploads + keep the connection busy, a small call answers in 9 ms at the median over + WebTransport and in 46 ms over the WebSocket — five times faster, with no + packet loss involved. Over the WebSocket the call is queued in one TCP + stream behind whatever the upload has buffered; over WebTransport every + binary stream has a [QUIC stream of its own](./wt#streams-without-head-of-line-blocking) + and the call waits behind none of them. The WebSocket moves the upload + about four times faster; if what you need is a lot of bytes on one quiet + connection, that is the one to use. +- **Opening is not faster on loopback.** A WebTransport session and a + WebSocket both open in about 12 ms here; the round trips QUIC saves are + network round trips, and loopback has none. +- **Calls are cheaper over the WebSocket** — 1.2× on small payloads and + 1.9× at 10 KB in Chrome; 2.3× and 5.9× in Node, where WebTransport's stack + is a binding rather than the browser's own. + +## Compression is off by default + +Nothing in wRPC compresses anything unless you ask — on any transport; the +[compression guide](./compression) is the map of every knob and of the +router dictionary they can share. On the WebSocket: `perMessageDeflate` +on the built-in engine and `compression` on the [uws engine](./adapters/uws) +are both off, and the `contextTakeover` / `async` rows above are refinements +of a knob that has to be on first. That is a choice, not an oversight — +wRPC's first commitment is the cost per frame, and the same +`bench/deflate-context.js` that measures the modes below prices the knob +itself: + +| One-shot deflate (the default mode, once enabled) | rate | ratio | +| --- | ---: | ---: | +| repeated 90 B JSON event | 108,461/sec | 1.1× | +| 4 KB JSON | 93,419/sec | 3.1× | +| 32 KB JSON | 17,744/sec | 3.9× | + +Read the first row as the reason for the default: a small event costs a +deflate call (~9 µs here) to lose a tenth of its bytes. The other two are the +reason to turn it on for the right peer — a 4 KB call frame is a third of +its size on the wire, and every frame above the 1 KiB `threshold` pays that +once. Below the threshold nothing is compressed even when the option is on. + +Turn it on where the bytes are worth the CPU, and only there: + +```js +// The batteries-included server — `ws` is forwarded to the engine. +new Server({ + router, + ws: { + perMessageDeflate: { + threshold: 1024, + // Per peer: a browser on a slow link, not a service next door. + filter: (req) => req.headers['x-forwarded-proto'] !== undefined, + }, + }, +}); + +// The engine directly, or uws with its own compressor constant. +createNodeEngine({ perMessageDeflate: true }); +createUwsEngine({ uws, compression: uws.SHARED_COMPRESSOR }); +``` + +Per message, `{ compress: false }` on an emit skips a frame that is already +compressed or latency-critical — see [rooms](./rooms#compression). The full +option set is on the [wire format](../reference/wire-format#permessage-deflate) +page. + +Two honest limits of what the knob covers: + +- **It compresses the WebSocket only.** The HTTP side has its own knobs, + off too: [`http.compression`](./server#compression) for packet-mode and + REST answers, [`sse.compression`](./sse#compression) for the event stream + (`bench/http-compression.js`: a 1.6 KB answer 5.5× at 77K/sec, a repeated + SSE tick 7.8×), and [`compression`](./wt#compression) on WebTransport and + [WebRTC](./webrtc#compression) — per message, negotiated, off unless both + ends turn it on (`bench/message-compression.js`: a 1.4 KB callback 6.3× at + 105K/sec) — and on the [broker binding](./brokers/rpc#compression) and the + [backplane envelopes](./scaling#compression), both Node↔Node. +- **A Node client never compresses what it sends — on its own.** Node's + built-in `WebSocket` offers `permessage-deflate` on the handshake but only + ever inflates: every frame it sends leaves with `RSV1` clear, whatever + the server negotiated. Server→client frames compress as usual; a browser + compresses both directions itself. The way out is the client's own + [`compression`](./server#node-client-frames) option against a server + that accepts it: per-message frames the server inflates, negotiated over + the first ping/pong, off on both ends by default. + +## Compression modes + +`bench/deflate-context.js` — the stateless default, context takeover and the +async threadpool path, on the traffic each is for: + +| Scenario | rate | ratio / loop delay | +| --- | ---: | --- | +| repeated JSON event, one-shot (default) | 108,461/sec | 1.1× | +| repeated JSON event, **context takeover** | 49,898/sec | **10.6×** | +| 4 KB JSON, one-shot / async | 93,419 / 31,690 | 3.1× | +| 32 KB JSON, one-shot / async | 17,744 / 12,650 | 3.9× | +| 256 KB JSON, one-shot / async | 1,909 / 1,818 | 3.8× | +| 252 KB burst ×100, one-shot | 317/sec | loop blocked **316 ms** | +| 252 KB burst ×100, **async** | 1,253/sec | loop delay 2 ms | + +Context takeover is the ratio knob: a repeated event shape compresses ten +times better against its own history, for ~160 KiB of zlib state per +direction per connection and an asynchronous, queued write path. Async is +the event-loop knob: below its threshold the threadpool hand-off costs +throughput, at 256 KB it is free, and on a burst — a fan-out's worth of +large frames issued in one turn — it is the difference between a loop +stalled for a third of a second and one that never notices. Both are +opt-in; see [wire format](../reference/wire-format#permessage-deflate). + +Put a scale on the ratio: the stateless default already compresses generic +JSON about as well as deflate ever will without history to lean on — that +1.1× is normal, not a bug. Context takeover's 10.6× only shows up because +the traffic in that row is the same shape repeated (a price tick, a +presence delta) — each message looks almost like the last one, and that's +exactly what a persistent zlib window is good at. Spend the ~160 KiB per +connection on it when the shape really does repeat and the client is +bandwidth-constrained (mobile, metered); spend it on arbitrary one-off +payloads and you've paid rent on memory for a discount that never arrives. + +The event-loop numbers are the more universal case: any server that +broadcasts large payloads to many recipients in one turn — that's what a +fan-out *is* — will eventually issue a burst of compressions in one tick. +Synchronous deflate on a 252 KB burst held the loop for a third of a +second, during which every other connection on the process goes +unserved — not slow, unresponsive. `async: { threshold }` is the +difference between that and a loop that never notices, and it's the one +knob on this page worth defaulting toward once your payloads regularly +cross the threshold, rather than waiting for a stall to prove it. + ## The receive path `bench/parser-throughput.js`: | Scenario | MB/s | msgs/sec | | --- | ---: | ---: | -| 16 MiB binary message, 16 KiB segments | 3,084 | 192 | -| 200 B text frames ×50K, 64 KiB segments | 661 | 3,468,803 | +| 16 MiB binary message, 16 KiB segments | 3,093 | 193 | +| 200 B text frames ×50K, 64 KiB segments | 671 | 3,516,734 | The large-message number is what `SegmentQueue` exists for: buffering fragmented reads with O(n) total work instead of re-concatenating a growing buffer on every segment. See [wire format](../reference/wire-format#receive-path-performance). +3 GB/s of reassembly throughput is well past what a single gigabit network +interface can even deliver, and comfortably past most 10-gigabit links too +— a fragmented multi-megabyte upload will hit the network as its ceiling +long before it hits this code. What the O(n) behavior actually buys is the +absence of a cliff: a naive re-concatenating buffer gets quadratically +slower as a message grows, and the failure mode isn't "a bit slower", it's +"fine at 1 MB, a stall at 100 MB". `SegmentQueue` exists so that graph +stays flat. + ## Batching crosses over at 16 `bench/batch-ordering.js` compares two ways of restoring order in a @@ -99,14 +545,25 @@ buffer on every segment. See [wire format](../reference/wire-format#receive-path | batch size | linear scan | id-indexed | winner | | ---: | ---: | ---: | --- | -| 4 | 5,285,606/s | 4,296,103/s | scan 1.23× | -| 8 | 2,230,358/s | 2,122,554/s | scan 1.05× | -| 16 | 939,014/s | 1,103,773/s | indexed 1.18× | -| 64 | 96,936/s | 237,578/s | indexed 2.45× | -| 128 | 31,160/s | 121,834/s | indexed 3.91× | +| 4 | 5,435,327/s | 4,349,645/s | scan 1.25× | +| 8 | 2,231,887/s | 1,866,820/s | scan 1.20× | +| 16 | 929,481/s | 992,604/s | indexed 1.07× | +| 64 | 95,175/s | 233,888/s | indexed 2.46× | +| 128 | 31,226/s | 125,089/s | indexed 4.01× | Which is why the client's default `batch.maxSize` is 16: the shape changes -right there. +right there. The server's own switch from the scan to the index sits at 12 +(`ServerHttpTransport`, for the HTTP batch reply) — between the last size +where the scan still wins and the first where the index does; both numbers +come from this one bench, and re-running it is the way to move either. + +Most request patterns never see this trade at all: a UI queues a handful +of calls at once, not dozens, so the linear scan — simpler, and the faster +choice below 16 — is what the common case actually runs. The index earns +its keep specifically when a burst gets genuinely large: bulk imports, +chart backfills, a page hydrating many widgets' data at once. If your +traffic never bursts past a dozen or so calls, this section is trivia, not +a tuning target. ## Cluster operations @@ -114,26 +571,73 @@ right there. | Operation | rate | | --- | ---: | -| `cluster.count()` | 65,797,861/sec | -| `cluster.presence()` | 23,858,163/sec | -| join + leave with presence deltas | 449,324/sec | -| room fan-out ×100 emit | 18,096/sec | -| room fan-out ×100 ask, send side | 7,204/sec | +| `cluster.count()` | 66,706,691/sec | +| `cluster.presence()` | 24,135,036/sec | +| join + leave with presence deltas | 440,433/sec | +| room fan-out ×100 emit | 203,231/sec | +| room fan-out ×100 ask, send side | 7,158/sec | `count()` and `presence()` are local map reads by design — that is the whole point of [replicating presence](./cluster#presence-replicated-read-locally) instead of requesting it. +Tens of millions of reads per second is not a number an application will +ever approach — it means these two calls simply leave the "is this fast +enough" conversation entirely. Check room size on every incoming message, +poll presence from an admin dashboard every second, call `count()` inside +a hot loop: none of it registers, because the alternative this replaces — +a network round-trip to ask another process — is the one with a real, +visible cost, and that's the comparison that actually matters here. + +## Across instances + +The paper's numbers were one machine, one process. `bench/cluster-nodes.js` +is the multi-node stand: N wRPC processes over one Redis (backplane and +session store, `pnpm redis:up`), M clients spread across them, and every +scenario crossing a real broker between real processes: + +```bash +pnpm redis:up +REDIS_URL=redis://127.0.0.1:6379 node bench/cluster-nodes.js # WRPC_NODES, WRPC_CLIENTS to size it +``` + +| Scenario (4 instances, 200 clients) | result | +| --- | --- | +| cross-instance emit, one at a time, measured at the farthest member | 173/sec — delivery latency p50 3 ms, p99 6 ms | +| presence: 200 concurrent joins, then leaves | count agrees on another instance after 6 ms / 7 ms | +| broadcast `ask` across 4 instances | 259/sec, 200 of 200 answers every time | +| an instance dies, its 50 clients reconnect elsewhere | 50/50 sessions restored with no sticky routing, presence converged in 6 ms, 0 backplane gaps | + +The emit row is a latency measurement (one emit, wait for the remotest +client, repeat), not a throughput one — the in-process fan-out numbers +above are the throughput side. The last row is the [affinity +table](./scaling#affinity) made concrete: with a shared session store and a +backplane, an instance can vanish and nothing needs a balancer's help. The +stand found one bug on its first run: a cluster node whose `sync` answer was +lost (at-most-once, again) never asked again and kept a stale presence view +for the life of the process — now it retries after two presence intervals. + +Put a human scale on the milliseconds: a browser repaints roughly every +16 ms at 60 fps, so a 3–6 ms cross-instance delivery and a 6–7 ms presence +convergence both land inside a single frame — a user who just joined on +instance B is visible to a client on instance A before the eye could +register two separate updates. That's the concrete version of the +[affinity table](./scaling#affinity)'s claim that ws/wt connections don't +need sticky routing: losing an instance isn't invisible in the sense of +"nothing happens" — 50 clients really do drop and reconnect — it's +invisible in the sense that nothing happens *slowly enough for a person to +notice*. + ## Why the code looks the way it does Two benchmarks exist to justify code shape rather than to advertise a number. `bench/dispatch.js` measures the packet-type branch three ways. With the branch cost isolated, a 9-branch `if`/`else if` chain is linear (165 M ops/s at the -first branch, 68 M at the last) and a `switch` is flatter (105 M → 69 M). But +first branch, 69 M at the last) and a `switch` is flatter (101 M → 69 M). But put a real handler call in the loop and `switch` and the chain come out level -(63.4 vs 68.8 M ops/s) while a **table of handler functions loses ~25%** -(51.7 M ops/s) — V8 compiles a string `switch` into comparisons it can inline +(54.5 vs 55.1 M ops/s) while a **table of handler functions loses ~24%** +(41.7 M ops/s) — V8 compiles a string `switch` into comparisons it can inline through, and cannot inline through a function *value*. `Object.freeze` and `Map` do not close the gap. So the dispatch branches stay branches. @@ -141,10 +645,10 @@ through, and cannot inline through a function *value*. `Object.freeze` and | Scenario | ops/sec | | --- | ---: | -| emit fire-and-forget — 1 sync listener | 64,514,129 | -| emit awaited — 0 listeners | 15,170,971 | -| emit awaited — 1 sync listener | 14,128,520 | -| emit awaited — 2 sync listeners | 3,140,608 | +| emit fire-and-forget — 1 sync listener | 64,118,679 | +| emit awaited — 0 listeners | 16,173,603 | +| emit awaited — 1 sync listener | 14,519,086 | +| emit awaited — 2 sync listeners | 3,067,063 | The cliff between one and two listeners is the awaited multi-listener path allocating a snapshot so a listener may `off()` itself mid-emit. Single-listener @@ -161,8 +665,23 @@ it is a ratchet, and it runs in CI's lint job. See [Browser & bundling](./browser#bundle-size) for the table and what is in each entry. +The main entry's budget is 28 KB min+gzip ([the measured table](./browser#bundle-size)), and that is the *whole* client — +calls, subscriptions, streams, reconnection with backoff, auth hooks — not +a core that then needs a realtime add-on and a query-binding add-on layered +on top of it. It is small enough to arrive in the same round trip as your +app shell, which is the actual reason the budget is a hard CI failure +rather than a suggestion: a slow leak of a few hundred bytes per feature is +invisible in any one PR and very visible three years later. + ## Memory, under load `pnpm test:perf` streams 1 GiB through a [binary stream](./streams) and fails if RSS grows with it. It is deliberately not in CI — it is slow and memory-sensitive — so run it by hand after touching the stream path. + +The property being guarded is boring on purpose: a stream carrying more +data than fits comfortably in RAM should not accumulate RAM. The failure +mode this catches — a forgotten backpressure check quietly buffering an +entire upload in memory — is the kind of bug that passes every functional +test and then takes a process down under real traffic, weeks after the +change that caused it shipped. diff --git a/docs/guide/production.md b/docs/guide/production.md index 26d4092..08d561d 100644 --- a/docs/guide/production.md +++ b/docs/guide/production.md @@ -28,8 +28,10 @@ What that does, in order: 1. **Intake stops.** The listener closes, so the next health check fails while in-flight work is still finishing. -2. **`rpc.draining` flips true.** New calls answer `503` — the code that tells - a well-behaved client to reconnect somewhere else. +2. **`rpc.draining` flips true** and `rpc` emits `'draining'` once. New calls + answer `503` — the code that tells a well-behaved client to reconnect + somewhere else — and anything that pulls work on its own (a + [broker](./brokers) consumer) stops fetching. 3. **In-flight calls get up to `drain` ms.** It resolves early the moment nothing is in flight. 4. **Every peer gets a `1001` "going away" close frame**, before the core @@ -74,18 +76,19 @@ const server = new Server({ }); ``` -`instanceId` defaults to a fresh UUID per process. Setting it to something your -orchestrator already knows makes [cluster](./cluster) presence, descriptors and -logs readable — `node-1` instead of a UUID. +`instanceId` defaults to an id minted by `generateId`. Setting it to something +your orchestrator already knows makes [cluster](./cluster) presence, +descriptors and logs readable — `node-1` instead of a UUID. It must not contain a `.`, because a client id is `.` -and the dot is the separator an addressed command splits on. Restart detection -does not depend on the id being fresh: every boot carries a new **epoch**. +and the dot is the separator an addressed command splits on. That rule applies +to a generated id too: a `generateId` answering `a.b` is refused the same way a +hand-passed `instanceId` would be. Restart detection does not depend on the id +being fresh: every boot carries a new **epoch**. -## Client and packet ids +## Identifiers -`generateId` replaces the default UUID everywhere ids are minted — client ids, -call ids, stream ids: +`generateId` replaces the default UUID everywhere ids are minted: ```js let counter = 0; @@ -94,19 +97,48 @@ const generateId = () => `${counter++}`; // dense, short, per process new Server({ router, generateId, instanceId: 'node-1' }); ``` -Two rules: it must return a **string of at most 255 characters**, and ids must -be unique within an instance — the `instanceId` prefix is what makes them -unique across the cluster. Short ids measurably shrink per-packet bytes on +Two rules: it must return a **non-empty string of at most 255 characters**, and +ids must be unique within an instance — the `instanceId` prefix is what makes +them unique across the cluster. Short ids measurably shrink per-packet bytes on chatty connections; UUIDs are the safe default when you have not thought about it. -The client takes the same option for its own call ids. +The option is checked once, at construction, by calling it. That first id is +not thrown away — it becomes the `instanceId` when you did not pass one — so a +counter-based generator still starts where you expect. + +### What each generator owns + +| Id | Generator | Notes | +| -- | --------- | ----- | +| `instanceId`, client ids, context uuids, server stream ids, REST packet ids | `generateId` on the server | One option covers all of them | +| Cluster boot epoch | `generateId` on the server | Fresh per boot; that is the point | +| [SSE](./sse) channel id | `generateId` on the server | Server-minted and never read from the request. An *identifier*, not a credential: the channel's secret is drawn by the server separately, so a counter is fine here | +| Packet, subscription and stream ids on the client | `generateId` on the [client](./client) | Also the [broker transport's](./brokers/rpc) session and correlation ids | +| Peer ids and the signaling `instance` | `generateId` on [`wrpcSignaler`](./webrtc) / `PeerHost` | | +| Consumer names, inboxes, message ids, group ids | `generateId` on each [broker adapter](./brokers) | Used verbatim — wRPC never truncates it | +| **Session token** | **`sessions.generateToken`** | A credential, not a correlation id. Deliberately a separate option so that widening one never widens the other | +| Subscription event ids | *not pluggable* | Monotonic by design: resume depends on their order | +| Rooms/event-log epochs | `rooms.epoch`, or random per boot | Short by design — an epoch prefixes every event id | + +### When a generator is rejected + +A generator that is not a function, or that answers something other than a +non-empty string of at most 255 characters, is a `TypeError` at construction — +on every option that takes one, `Server`/`RpcServer`, the client and `PeerHost` +included. (Those three shipped in 1.0 ignoring a bad value, so 1.x could only +report it through the logger under `event: 'options.generateId'` and fall back +to the default; 2.0 makes them throw like the options added since.) + +The one-shot check cannot see a generator that only *sometimes* misbehaves, so +stream ids are re-checked every time one is minted. ## Behind a proxy - **TLS.** Either run `protocol: 'https'` with `key`/`cert`, or terminate TLS - at the proxy and run `'http'` behind it. Both are normal. -- **Idle timeouts.** wrpc's own app-level + at the proxy and run `'http'` behind it. Both are normal. If the box that terminates + it is not yours, see [session encryption](./encryption#session). +- **Idle timeouts.** wRPC's own app-level [heartbeat](./client#heartbeat) is 30 s by default; keep the proxy's idle timeout above it or the proxy will close connections the client believes are healthy. @@ -114,6 +146,16 @@ The client takes the same option for its own call ids. WebSockets. This is the single most common "it works locally" failure. - **Buffering.** SSE needs the proxy's response buffering **off** for `{basePath}/events`, or frames arrive in clumps. +- **WebTransport is UDP.** Its sessions reach the HTTP/3 host on a UDP port, + not through the HTTP/1.1 proxy in front of the WebSocket: the load balancer + needs a UDP (QUIC) listener for it, or the client's `['wt', 'ws']` fallback + quietly lands every page on the WebSocket. See [Hosting](./wt#hosting). +- **Compression.** Nothing is compressed unless you turn it on, on any + wire — a deliberate [performance choice](./performance#compression-is-off-by-default); + [Compression](./compression) has the knob for each. For the WebSocket + (`ws.perMessageDeflate`), a proxy that terminates WebSockets itself has to + forward the `Sec-WebSocket-Extensions` offer, or the negotiation quietly + ends at the proxy and no frame is ever compressed. ## Sticky routing @@ -139,7 +181,7 @@ The engine limits decide when a slow peer stops being your problem: | Option | What it does | | --- | --- | -| `maxBackpressure` | Outbound bytes buffered for one socket before it is dropped. | +| `maxBackpressure` | Outbound bytes buffered for one connection before it is dropped — the engine's for a socket, `attachSession`'s for a WebTransport session, a `WrpcPeer`'s `host` (or `attachChannel`) for a data channel. | | `maxBuffer` | Inbound assembly buffer per connection. | | `fragmentThreshold` | Above this, an outbound message is sent fragmented. | | `maxPayload` | Largest inbound message (16 MiB default). | @@ -155,6 +197,9 @@ carries backpressure all the way into TCP; see [Binary streams](./streams#backpr - [ ] `instanceId` comes from the orchestrator - [ ] Readiness probe hits the bound port; `server.address()` is how it is read - [ ] Proxy forwards upgrades, idle timeout > heartbeat, SSE buffering off +- [ ] Compression decided — it is off by default; `perMessageDeflate` with a `filter` for the peers that need it, `compression` on the other wires +- [ ] [Encryption](./encryption) decided for whatever leaves the process past TLS — the backplane, a broker, the session store +- [ ] WebTransport, if used: a UDP listener for the HTTP/3 host, and a certificate a browser accepts - [ ] Sticky routing on the session cookie if SSE or event logs are used - [ ] A [logger](./logging) is injected — the default writes to `console` - [ ] `introspection` decided; per-connection limits reviewed diff --git a/docs/guide/query.md b/docs/guide/query.md index ea2ffb1..8e89cd1 100644 --- a/docs/guide/query.md +++ b/docs/guide/query.md @@ -1,6 +1,6 @@ # TanStack Query -`@alexify/wrpc/query` binds a wrpc client to [TanStack +`@alexify/wrpc/query` binds a wRPC client to [TanStack Query](https://tanstack.com/query). Not hooks — **option factories**, in the shape tRPC v11 settled on: @@ -15,7 +15,7 @@ useQuery(wq.queryOptions(['chat', 'list'], { room: 'a' })); `queryOptions()` hands back the plain `{ queryKey, queryFn }` object you spread into your own `useQuery`. So one file serves React Query, Solid Query, Svelte Query, Vue Query and query-core alike — and imports **none** of them. Together -with the wrpc client being injected too, that is what keeps this subpath at +with the wRPC client being injected too, that is what keeps this subpath at ~1 KB min+gzip and free of runtime dependencies. ## Setup @@ -106,7 +106,7 @@ useEffect(() => { | `lastEventId` | — | Where to resume from. | | `onData` / `onError` / `onEnd` | — | The usual [subscription callbacks](./subscriptions#when-callbacks-fire). | -It returns the wrpc `Subscription` handle: call `unsubscribe()` on teardown. +It returns the wRPC `Subscription` handle: call `unsubscribe()` on teardown. One call opens one subscription, and nothing here de-duplicates two callers of the same feed. diff --git a/docs/guide/rate-limiting.md b/docs/guide/rate-limiting.md new file mode 100644 index 0000000..3cb7de2 --- /dev/null +++ b/docs/guide/rate-limiting.md @@ -0,0 +1,246 @@ +# Rate limiting & throttling + +wRPC ships **no rate limiter** — consistent with the [zero-dependency +guarantee](./why): a token bucket is a dozen lines, and shipping one would +mean picking a policy (sliding window? fixed window? per-IP? per-session?) +every app disagrees with. What wRPC ships instead is a single seam — +[`onRequest`](./hooks) — that runs for **every** call, event and subscription +attempt on **every** transport (WebSocket, HTTP, SSE, WebTransport, WebRTC, a +worker port, the broker binding and queue consumers), before the handler and before a slow bucket check can be bypassed by picking +a different transport. + +This page is the map: what is capped out of the box, how to write a limiter +once and have it apply everywhere, and — because half of wRPC's transports +never pass through a host HTTP framework at all — exactly where a package +like `@fastify/rate-limit` or `express-rate-limit` can and cannot see the +traffic. + +## What is already capped (not a rate limiter) + +These bound *how much state one connection can hold open*, not *how often it +may call* — see [Security → Resource limits](./security#resource-limits) for +the full table: + +| Limit | Bounds | +| --- | --- | +| `maxCalls` | In-flight calls per connection (`429` past it) | +| `maxSubscriptions` | Live subscriptions per connection | +| `maxBatch` | Packets in one [batch frame](./client#batching) | +| `sse.maxChannelsPerAddress` | Open SSE channels per address (`429` past it) | + +A connection that opens one call at a time, waits for the reply, and repeats +as fast as the network allows is unbounded by every one of these — that is +what an actual rate limiter is for. + +## The mechanism: `onRequest` + +`onRequest` fires once per accepted packet, **before** session restore and +the access check, and the target procedure is already resolved — so +`context.procedure` and `context.method` are available inside it. It is +plain code, not a policy DSL: + +```js +const buckets = new WeakMap(); + +const rateLimit = (limit = 50, windowMs = 1000) => async (context) => { + const key = context.client; // or context.session — survives a reconnect + let bucket = buckets.get(key); + const now = Date.now(); + if (!bucket || now - bucket.start >= windowMs) { + bucket = { start: now, used: 0 }; + buckets.set(key, bucket); + } + if (++bucket.used > limit) { + const error = new Error('Rate limit exceeded'); + error.code = 429; + error.expose = true; + throw error; + } +}; + +const router = defineRouter(units, { hooks: { onRequest: rateLimit(50) } }); +``` + +See [Hooks](./hooks#the-pipeline) for the full phase list and how router, +unit and procedure hooks flatten into one pipeline. + +### Different limits for different procedures + +`onRequest` can only be registered at router or unit level (it runs before a +procedure-level hook would apply), but by the time it runs +`context.procedure` — including `context.procedure.meta` — is already set. +One global hook reading per-procedure config is the idiomatic way to vary +limits without registering a hook per procedure: + +```js +const router = defineRouter( + { + auth: { + login: procedure({ + access: 'public', + meta: { rateLimit: { limit: 5, windowMs: 60_000 } }, // brute-force guard + handler: loginHandler, + }), + }, + search: { + query: procedure({ + meta: { rateLimit: { limit: 200, windowMs: 1000 } }, + handler: searchHandler, + }), + }, + }, + { hooks: { onRequest: rateLimitFromMeta } }, // reads context.procedure.meta.rateLimit +); +``` + +A unit-level hook (`hooks: { onRequest: ... }` inside a unit's own +definition) works too when the limit is the same for every procedure in that +unit but different from the router's default — see +[Hooks → Three levels, one flat pipeline](./hooks#three-levels-one-flat-pipeline). + +### Subscription quotas + +Subscriptions are gated on open, not per value — the value loop is the +hottest path in the library and stays hook-free by design. Use +`onSubscribe`/`onUnsubscribe` instead; `onUnsubscribe` fires on **every** +ending (completion, error, unsubscribe, disconnect), so a quota cannot leak. +See [Hooks → Recipe: subscription quotas](./hooks#recipe-subscription-quotas). + +## Per-transport: where a host framework's limiter can (and cannot) see traffic + +`onRequest` is transport-agnostic by construction — the dispatcher does not +know or care whether the packet arrived as a WebSocket frame, an HTTP body, +an SSE POST, a WebTransport stream frame, a WebTransport datagram, a +WebRTC data-channel message or a broker message. A framework-level limiter (`@fastify/rate-limit`, +`express-rate-limit`, a custom uws hook, …) is different: it only sees what +passes through the host framework's own request/response cycle, and **that +differs sharply per transport**. + +| Transport | Every message is a host-framework request? | A framework limiter sees | +| --- | --- | --- | +| HTTP (packet mode) & declarative REST | Yes — each call is its own request | Every call | +| SSE | Inbound only — each client→server call is a POST; the server→client stream is one long-lived GET | Every inbound call, none of the outbound push | +| WebSocket | Only the upgrade, once per connection | The handshake, never a message after it | +| WebTransport | No — the session lives on a separate HTTP/3 host the framework never runs | Nothing, not even the handshake | +| WebRTC (data channel) | No — peer-to-peer after signaling | Nothing | +| WebRTC signaling | Only if the signaling unit is itself served over HTTP/WS through a host framework | The signaling exchange, not the data channel it sets up | +| Worker port (`attachPort`) | No — a `MessagePort` inside one process | Nothing | +| [Broker binding](./brokers/rpc) and [queue consumers](./brokers/consumers) | No — messages come off the broker, never through the host | Nothing | + +### HTTP and declarative REST + +Both the generic RPC endpoint (`{basePath}/:unit/:method`) and procedures +with an `http` mapping are registered as **real routes** on the host +framework — [fastify's "upgrade-through-router" design](./adapters/fastify), +express's ordinary middleware chain, or your own uws/bare-`node:http` +plumbing. A framework-level limiter therefore sees every one of these calls +individually, exactly like it would for any other route. + +```js +// Fastify +const fastify = Fastify(); +await fastify.register(require('@fastify/rate-limit'), { max: 100, timeWindow: '1 minute' }); +await fastify.register(wrpcFastify, { router }); +``` + +```js +// Express — an ordinary middleware in front of wrpc.handler +app.use(require('express-rate-limit')({ windowMs: 60_000, max: 100 })); +app.use(wrpc.handler); +``` + +One caveat on the fastify REST bridge: the routes it registers for +`http`-mapped procedures carry a fixed `config: { wrpc: true }`, not merged +with a procedure's own `meta` — so `@fastify/rate-limit`'s per-route +`config.rateLimit` override is not wired through automatically. Either +compute `max` from `request.url` in the plugin's own options, or handle +per-procedure limits with the `meta`-driven `onRequest` hook above, which +works identically for REST and packet-mode calls. + +The batteries-included [`Server`](./server) owns its whole `node:http` +listener rather than composing as middleware — multiple `'request'` +listeners on a bare `http.Server` all fire for every request with no way to +short-circuit, so a third-party HTTP limiter does not compose cleanly there. +Use the `express` or `fastify` adapter instead if you need one, or rely on +the `onRequest` hook alone (it covers `Server` just as well). + +### Server-Sent Events + +Each client→server call over [SSE](./sse) is a real `POST` to +`rpc.eventsPath`, registered as a host-framework route the same way as any +other HTTP call — a framework limiter applies to it. The server→client +direction is a single long-lived `GET` per channel: a framework limiter +counts that connection **once**, not once per event pushed down it, so it +cannot throttle how fast the server (or a subscription/room broadcast) emits +into an open channel — that is application logic, not a rate-limiting +concern (you control what you push). Connection-count pressure on that axis +is `sse.maxChannelsPerAddress`, not a limiter (see +[Security → SSE channels](./security#sse-channels)). + +### WebSocket + +Only the upgrade request touches the host framework, once per connection — +`@fastify/rate-limit` or `express-rate-limit` registered in front of it can +throttle *new connections*, never the messages sent over one already open. +Every call/event/subscribe packet on an established socket goes straight to +the dispatcher, so `onRequest` is the only layer that sees them. + +### WebTransport + +[WebTransport](./wt) sessions are more isolated than WebSockets, not less: +the `CONNECT` handshake is accepted by an entirely separate, injected HTTP/3 +host (`Http3Server`, `quico`, …) on its own port — the fastify/express +instance never runs it and has no visibility into it, not even for the +initial session. `verify` passed to `attachSession`/`acceptSessions` is the +WebTransport analogue of `verifyClient`: a one-time admission gate at +session accept, not a rate limiter. + +Once accepted, the session is attached with `RpcServer.attachSocket()` — +exactly like a WebSocket — and every message on the control stream **and** +every unreliable datagram (`sendEvent(..., { unreliable: true })`) is +emitted through the identical socket contract the dispatcher reads. So +`onRequest` covers WebTransport calls, events and inbound datagrams the same +way it covers WebSocket packets, with nothing transport-specific to write. + +### WebRTC + +A [WebRTC](./webrtc) data channel is peer-to-peer once connected — there is +no HTTP framework anywhere in that path for a limiter to attach to. The host +side runs on a `PeerHost`, composed from the same `src/rpc/` router and +dispatcher an `RpcServer` uses, so the same `onRequest` hook (and +`meta`-based per-procedure limits) applies unchanged. + +The signaling exchange that negotiates the connection is a separate concern: +it is itself ordinary wRPC procedures (`createSignalingHooks()`, relayed +through `RpcServer.sendTo`). If your signaling server is served over +HTTP/WebSocket through a host framework, a framework-level limiter sees the +signaling traffic — offers, answers, ICE candidates — but never the data +channel traffic that results from it, which stays on `onRequest` alone. + +## Combining both layers + +The pattern that covers every transport: a framework-level limiter in front +of whatever HTTP surface exists (protects connection/handshake churn and +plain HTTP/REST calls cheaply, before wRPC even runs), plus one +`meta`-driven `onRequest` hook for per-message throttling that applies +uniformly regardless of which transport the client is actually using: + +```js +await fastify.register(require('@fastify/rate-limit'), { max: 300, timeWindow: '1 minute' }); +await fastify.register(wrpcFastify, { + router: defineRouter(units, { hooks: { onRequest: rateLimitFromMeta } }), +}); +``` + +A client that exhausts its WebSocket budget and reconnects, falls back from +`wt` to `ws`, or moves from packet-mode HTTP to REST still hits the same +`onRequest` bucket — the framework-level limiter is defense in depth for the +surfaces it can see, not the source of truth. + +## See also + +- [Hooks](./hooks) — the full phase pipeline and per-level registration +- [Security → Resource limits](./security#resource-limits) — the caps that + exist without any hook +- [Server-Sent Events](./sse), [WebTransport](./wt), [WebRTC](./webrtc) — + per-transport detail diff --git a/docs/guide/rest.md b/docs/guide/rest.md index 7aa4641..1980ede 100644 --- a/docs/guide/rest.md +++ b/docs/guide/rest.md @@ -2,7 +2,7 @@ One procedure, two audiences. A procedure that declares an `http` mapping is addressable as a **real REST endpoint** — proper verb, proper path, proper -status — while staying an ordinary wrpc procedure on every other transport: +status — while staying an ordinary wRPC procedure on every other transport: ```js const { defineRouter, procedure } = require('@alexify/wrpc'); @@ -29,8 +29,8 @@ curl -X POST https://host/api/projects/42 -H 'content-type: application/json' -d ``` The response is the **plain result** with status `201` — external REST -semantics, no callback envelope. The same procedure called over WebSocket is -`projects/create` with the same `{ params, query, body }` args object: the +semantics, no callback envelope. The same procedure called over a WebSocket +— or any other transport — is `projects/create` with the same `{ params, query, body }` args object: the mapping only defines how an HTTP request is unpacked. ## The `http` option @@ -40,6 +40,8 @@ mapping only defines how an HTTP request is unpacked. | `method` | `GET`, `HEAD`, `POST`, `PUT`, `PATCH` or `DELETE`. | | `path` | Relative to the server's `basePath`. Segments are static or `:name`. | | `status` | Success status; default `200`. `204` discards the result body by contract. | +| `headers` | Static response headers on every answer of this route — see [Response headers](#response-headers). | +| `cache` | The cache policy of a successful response — see [Caching](#caching). GET/HEAD only. | Validated when the procedure is built: a typo'd verb, a wildcard segment or a status outside 200–599 throws immediately. Subscriptions cannot carry a @@ -55,6 +57,101 @@ procedures. A known path hit with the wrong verb answers **405** with an The conventional `ANY {basePath}/:unit/:method` mode keeps working underneath as a fallback, unchanged, callback envelopes and all. +## Response headers {#response-headers} + +Two seams reach the HTTP response of a mapped route. Statically, on the +mapping — every answer of the route carries them, results and errors alike: + +```js +export: procedure({ + access: 'public', + http: { method: 'GET', path: '/export', headers: { 'Content-Disposition': 'attachment; filename="rows.json"' } }, + handler: async () => rows(), +}), +``` + +Dynamically, from the handler (or any hook), through `context.http`: + +```js +handler: async (ctx, { params }) => { + ctx.http.setHeader('X-Request-Id', ctx.uuid); + ctx.http.status(202); // this response's success status + return queue(params.id); +}, +``` + +`context.http` is `{ method, url, headers, setHeader(name, value), +status(code) }` — the request line and headers to read, the response to +shape. It is **`null` everywhere else**: on a WebSocket or WebTransport call, +on SSE, on a worker port, and on packet-mode `POST {basePath}`, where one +response answers a whole batch and no single call may speak for it — write +handlers that check it, or keep the HTTP-only ones on mapped routes. The +transport-owned names (`Content-Length`, `Set-Cookie`, `wrpc-version`, +`Transfer-Encoding`, `Connection`) are refused, at build time for `headers` +and with a throw for `setHeader`; so is a `setHeader` after the answer was +written. `HEAD` is served by the `GET` route, headers included, body stripped +by the host. A `Content-Encoding` set here is honoured by +[`http.compression`](./server#compression): a route that ships pre-encoded +bytes is never re-encoded. + +Under the [fastify adapter](./adapters/fastify#declarative-rest-routes) both +seams map onto fastify's own `reply`. + +## Caching {#caching} + +A public read behind a CDN is the one thing a single WebSocket connection +cannot give you — a mapped route can. `cache` declares how a shared cache +may treat a **successful** response: + +```js +catalog: procedure({ + access: 'public', + http: { + method: 'GET', + path: '/catalog', + cache: { maxAge: 60, public: true, staleWhileRevalidate: 30 }, + }, + handler: async () => listCatalog(), +}), +``` + +| Field | Meaning | +| --- | --- | +| `maxAge` | Seconds, required. | +| `public` | `public` in `Cache-Control` (a CDN may store it); default `false` → `private` (browser only). | +| `staleWhileRevalidate` | Seconds; adds `stale-while-revalidate`. | +| `etag` | A weak `ETag` over the body and `304 Not Modified` on a matching `If-None-Match`; default `true`. | + +What a hit answers: + +``` +HTTP/1.1 200 OK +Cache-Control: public, max-age=60, stale-while-revalidate=30 +ETag: W/"5YgP…" +``` + +and a `GET` with `If-None-Match: W/"5YgP…"` (or the bare tag, or a list) +answers `304` with the same `ETag` and `Cache-Control` and no body. `HEAD` +gets the same headers. + +**The session rule.** The policy is decided once the session is known, and +it is the same function for every host: the declared `Cache-Control` goes +out only for a **public** procedure on a request that **restored no +session** and **set no cookie**. Anything session-bearing — a `session` +procedure, a public one reached with a valid session cookie, a public one +whose handler called `startSession` — answers `Cache-Control: private, +no-store` and no `ETag`, whatever the route declared. One shared cache +serving one user's answer to another is the failure this exists to stop, +and it is not a discipline each handler has to remember. Error responses +carry no cache headers at all. + +Two things to know: the `ETag` is a SHA-1 of the body per response (cold, +but a cost); and under a [`codec.rest`](./codec#rest-bodies-codec-rest) +whose bytes are not deterministic for equal values (a nonce, a random field +order) the tag never matches, so `304` never happens and the hash is wasted +— set `etag: false` there. Under the fastify adapter `Cache-Control` follows +the same rule and `ETag`/`304` are fastify's business (`@fastify/etag`). + ## Versioning By default a [versioned unit](./router#units-and-versions)'s declared path @@ -95,7 +192,7 @@ The rules: The shape is `fastify.route.schema`, verbatim: `params`, `querystring` (or its alias `query` — interchangeable), `body`, `headers`, `response` keyed by status code, plus any passthrough keys (`tags`, `summary`, `security`, -`operationId`, …) that wrpc never interprets but forwards to hosts — +`operationId`, …) that wRPC never interprets but forwards to hosts — which is what makes swagger documentation free under the [fastify adapter](./adapters/fastify#declarative-rest-routes). @@ -115,7 +212,7 @@ exclusive** on one procedure. How schemas become validation is the ### Default error responses -wrpc documents its own lifecycle errors in the effective `response` — what a +wRPC documents its own lifecycle errors in the effective `response` — what a host and swagger see — derived from the procedure's options: | Status | When it is documented | diff --git a/docs/guide/rooms.md b/docs/guide/rooms.md index 3ed4bfb..02fa426 100644 --- a/docs/guide/rooms.md +++ b/docs/guide/rooms.md @@ -184,6 +184,7 @@ sessions: | `client.drain()` | Resolves when the transport drained — or when it closed. | | `client.persistent` | `false` on HTTP: no events, no subscriptions, no streams. | | `client.binary` | `false` on a text-only transport ([SSE](./sse)). | +| `client.revision` | The [protocol revision](../reference/protocol#versioning) this connection speaks: `2` when the peer reads framed messages, so bytes arrive as `Uint8Array`s; `1` for a 1.0 peer, one that opted out, and every SSE channel — bytes then arrive as the JSON 1.0 made of them. | | `client.session` | The [session](./sessions), or `null`. | | `client.close()` / `destroy()` | Close the transport / tear the client down. | @@ -191,6 +192,93 @@ sessions: same procedure can be reachable over HTTP for the call and skip the event-emitting half when it is. +## Compression + +A broadcast is serialized once, and on the built-in engine framed and +deflated once too — one shared frame per emit, per negotiated window, rather +than one deflate per member (the numbers are in +[performance](./performance#fan-out)). Compression itself is negotiated per +connection, and the server's `perMessageDeflate.filter(req)` chooses which +peers get it. Per message, opt out when the payload is already compressed +or latency matters more than bytes: + +```js +context.server.to('lobby').emit('media/chunk', base64Jpeg, { compress: false }); +context.client.sendEvent('game/tick', state, { compress: false }); +``` + +The same flag works on every wire with per-message compression — +WebTransport, WebRTC and the broker binding (see [Compression](./compression) +for the knob on each). It is ignored on connections that never negotiated +compression — which is every connection until both ends turn it on; it is +[off by default](./performance#compression-is-off-by-default). + +## Delivery, and what to do when it has to be guaranteed {#delivery} + +A room emit is a **fire-and-forget fan-out**: no id, no acknowledgement, +at-most-once across instances (the [backplane](./scaling#at-most-once-and-what-to-do-about-it) +reports what it lost, it does not replay it). That is the right contract for +presence, cursors, typing indicators and every other event a later one +supersedes. When a client must not miss anything — a chat history, an order +book's deltas — the replayable thing in wRPC is a **subscription**, not a +room, and three recipes cover the cases: + +**A broker-backed feed.** A broker (Kafka, RabbitMQ, NATS, a Redis stream) +guarantees delivery to your *server*, not to a browser: the last hop still +loses whatever was in flight during a reconnect unless the server replays +from where the client left off. The offset the broker already keeps is the +event id, and [`brokerFeed`](./brokers/feeds) is that subscription, resuming +on any instance: + +```js +const { brokerFeed } = require('@alexify/wrpc/broker'); + +feed: procedure.subscription({ + access: 'session', + handler: brokerFeed(broker, 'chat.lobby', { onGap: (ctx) => loadRecent(ctx) }), +}), +``` + +**A room-backed feed.** For events that originate in this process, keep an +[event log](./subscriptions#resuming) next to the room and serve the +subscription from it; the room emit stays for the members who only want +"now": + +```js +const log = createEventLog({ size: 1000 }); +const bus = new Emitter(); + +const post = (ctx, message) => { + const id = log.push(message); // one id for both audiences + ctx.server.to('chat').emit('chat/message', message); + bus.emit('message', tracked(id, message)); +}; + +history: procedure.subscription({ + handler: async function* (ctx, args, { lastEventId, signal }) { + const missed = log.since(lastEventId); + if (missed === null) yield { type: 'snapshot', items: await loadRecent() }; + else for (const item of missed) yield item; + for await (const item of createEventStream(bus, 'message', { signal })) yield item; + }, +}), +``` + +**An acknowledged emit.** When the sender needs to know the event *arrived*, +ask instead of emitting — [`ask()`](#asking-a-room) is the same event packet +with an id, and each client's answer is its acknowledgement: + +```js +const { answers, expected, incomplete } = await ctx.server.to('ops').ask('deploy/notice', payload); +if (incomplete || answers.length < expected) escalate(expected - answers.length); +``` + +What wRPC deliberately does not have is a per-room replay buffer with a +client that "rejoins from an id": it would duplicate subscriptions, and it +would need a new concept inside the [protocol's core](../reference/protocol#stability). +If you need a queue's guarantees, you need a queue — and a subscription is +how its offsets reach the browser. + ## Rooms and reconnects Membership is **per connection**: a reconnect is a NEW server-side client, diff --git a/docs/guide/router.md b/docs/guide/router.md index e9c1eea..539207e 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 @@ -84,13 +84,13 @@ The dispatcher's rule is exactly one line: a client with no session may call `access: 'public'` and nothing else. Any other string — `'admin'`, `'internal'` — behaves like `'session'` at that gate and is carried through introspection for your own layer to interpret. Fine-grained authorization is -not wrpc's job; do it in the handler, or with a policy engine. +not wRPC's job; do it in the handler, or with a policy engine. ### Validators An `input`/`output` validator is either a plain function or a [Standard Schema](https://standardschema.dev) object — which means Zod, Valibot, -ArkType and friends plug in **without wrpc depending on any of them**: +ArkType and friends plug in **without wRPC depending on any of them**: ```js const { z } = require('zod'); @@ -103,7 +103,7 @@ procedure({ ``` The third spelling is **declarative**: a fastify-shaped `schema` object plus -an injected compiler. wrpc imports no schema library — you pass your own +an injected compiler. wRPC imports no schema library — you pass your own ajv (and, optionally, a fast-json-stringify) and the router compiles every declared part **once**, when it is built: @@ -139,8 +139,8 @@ procedure({ - A schema with validation parts in a router **without** `validation.ajv` throws at build: a declaration nothing enforces would be an authorization bug in waiting. Under the [fastify adapter](./adapters/fastify) fastify - validates the delegated HTTP routes itself, but the same procedures over - WebSocket are wrpc's to validate — inject the ajv either way. + validates the delegated HTTP routes itself, but the same procedures over a + WebSocket, or any other transport, are wRPC's to validate — inject the ajv either way. - `schema` and `input`/`output` are mutually exclusive on one procedure. - The input parts travel through introspection, so a browser client with its own injected ajv (`WrpcClient.connect(url, { validation: { ajv } })`) @@ -234,8 +234,9 @@ On the wire that is the `method` string: `auth/signIn` or `auth.v1/signIn`. `on` is **reserved** inside a unit — it holds the unit's inbound event handlers, so no method may be called `on`. `hooks` is reserved too: the -unit's slice of the [lifecycle pipeline](./hooks). Neither is usable as a -method name: +unit's slice of the [lifecycle pipeline](./hooks), and so are `emits` (the +declared outbound events) and `consumes` (the unit's +[queue consumers](./brokers/consumers)). None is usable as a method name: ```js defineRouter({ diff --git a/docs/guide/scaling.md b/docs/guide/scaling.md index c0d9eaf..146436f 100644 --- a/docs/guide/scaling.md +++ b/docs/guide/scaling.md @@ -39,6 +39,91 @@ const backplane = { Each may return a promise. `isBackplane(value)` is the structural check `RpcServer` runs, exported so you can run it yourself. +### Compressing the envelopes {#compression} + +The message is always a string, so a room envelope carries its JSON as it +is — a 5 KB event is 5 KB on Redis, per instance that receives it. `rooms: +{ compression: true }` deflates every envelope this instance publishes +past the threshold (1 KiB) and carries it as base64 under a +`wrpc-enc:deflate-raw:` marker; a receiver with the same option inflates +it. **Off by default**, and — unlike the socket transports — with nothing +to negotiate against: an instance *without* the option cannot read such an +envelope, drops it and logs `backplane.encoded`. Roll it out in two steps +(every instance on a version that has the option, then the option on), +and the same for turning it off. Changing the codec needs no such gap: an +instance encodes with the head of [a list](./compression#list) and reads +anything on it, so list both everywhere (`codec: ['deflate-raw', 'zstd']`), +then swap the order. The codec must be synchronous; the +platform one is. `rooms.maxMessage` (16 MiB) caps an inflated envelope. +The [cluster layer](./cluster) has its own `cluster: { compression }`, +applied after signing. + +### Sealing the envelopes {#encryption} + +TLS to Redis protects the hop, not what Redis holds: every room event +crosses the backplane as readable JSON — payload, event name, room list — +for its operator, a `MONITOR` session, or a neighbour on a shared instance +to read. `rooms: { encryption: { keys } }` seals each envelope under a key +the backplane never sees: + +```js +const { generateKey } = require('@alexify/wrpc/encryption'); +// once, into your secret store: Buffer.from(generateKey()).toString('base64url') + +new Server({ + router, + backplane, + rooms: { encryption: { keys: process.env.WRPC_ROOMS_KEY } }, + cluster: { secret, encryption: { keys: process.env.WRPC_CLUSTER_KEY } }, +}); +``` + +**Off by default**, AES-256-GCM (or `cipher: 'chacha20-poly1305'`), about +3.5 µs per 1 KB envelope each way (13 µs at 16 KB — `bench/encryption.js`). It composes with `compression` — compress, +then seal, one base64. An envelope that does not open — another key, a +flipped bit, one moved from another room's channel, a replay — is dropped +and logged `backplane.open`; it is never delivered and never answered. + +The replay memory has edges, and they are worth knowing. A receiver keeps +one sliding window (`replayWindow`, 1024 counters) per **sender** — a key +id, a cipher and the salt a sealing process drew at boot — in memory: it is +empty after a restart, and at most `maxSenders` (1024) senders are +remembered, the least recently heard out first, so an instance that hears +more sealing processes than that forgets the quietest and would open its +envelopes again. Nothing in a sealed envelope says *when* it was sealed. Where a +replayed **command** matters, `cluster: { secret }` adds what the frame +lacks — a signed counter, channel and clock +([Trusting the backplane](./cluster#trusting-the-backplane)). + +Two things it does not hide: the **channel name**, which names the room, +and sizes and timing. And it is a fan-out under one shared key, not +end-to-end: every instance holds the key, and so reads every room. + +A backplane delivers at most once, so turning it on is **three deploys**, +never a flag day: + +| Deploy | `encryption` | Publishes | Accepts | +| --- | --- | --- | --- | +| 1 | `{ keys, seal: false, acceptPlaintext: true }` | plaintext | both | +| 2 | `{ keys, acceptPlaintext: true }` | sealed | both | +| 3 | `{ keys }` | sealed | sealed only — plaintext logs `backplane.unsealed` | + +Rotating a key is the same shape: `keys: { current: 'k1', ring: { k1, k2 } +}` everywhere, then `current: 'k2'`, then drop `k1`. The key id travels in +the clear beside the ciphertext; it selects the key, nothing is tried until +it fits. `keys` also takes a provider — `{ current(), get(kid) }`, both +synchronous — for keys that live in a KMS or Vault and are refreshed on +your own schedule. + +The cluster layer takes the same option. `cluster.secret` says a command +came from a node, once, on the channel it was published on — a signed +envelope carries its sender's counter, channel and clock, so a copy +replayed later or moved elsewhere is refused +([Trusting the backplane](./cluster#trusting-the-backplane)). +`cluster.encryption` hides what it says — presence, `sendTo` payloads, +`fetchClients` replies with their `client.data` — and refuses a plaintext +command outright. + ## Adapters ### Memory @@ -77,9 +162,23 @@ Redis connection cannot publish, which is why there are two. adapter created for itself through `duplicate()` is, since nothing else holds a reference to close it. +### A message broker + +Every adapter of the [broker family](./brokers) — Redis, NATS, RabbitMQ, +Kafka, and the in-process `MemoryBroker` — offers this contract as its +`backplane` capability, on the broker client you already inject for feeds, +consumers or RPC: + +```js +const server = new Server({ router, backplane: broker.backplane, port: 8000 }); +``` + +The broker pages say what each one costs as a backplane — Kafka's in +particular ([Kafka](./brokers/kafka)). + ### Something else -NATS, MQTT, Postgres `LISTEN/NOTIFY`, a cloud pub/sub — all three methods, all +MQTT, Postgres `LISTEN/NOTIFY`, a cloud pub/sub — all three methods, all strings. The rooms layer serializes its own envelope, so an adapter never needs to know the payload shape: @@ -132,6 +231,29 @@ A failing backplane is isolated: a publish that throws is logged, and local delivery happens either way. A broken Redis degrades a cluster to a set of independent instances rather than taking the room mechanism down. +## Loss detection {#loss-detection} + +Every envelope an instance publishes carries its boot `epoch` and a +per-channel `seq`. A receiver keeps the last `seq` it saw per (channel, +publisher) and, when the next one jumps, logs `backplane.gap` — `{ channel, +instance, missed }` — and adds `missed` to the `wrpc.server.backplane.gaps` +counter. Redis pub/sub is fire-and-forget: a subscriber connection that +dropped for 200 ms, or a client the broker disconnected for exceeding its +output buffer, loses envelopes **silently**; this is what makes the loss a +number on a dashboard instead of a bug report about a message nobody +received. It detects, it does not recover — at-most-once is still the +contract, below. A new epoch (the publisher restarted) resets the count; an +envelope from an instance that predates the fields is delivered untracked. + +The publisher's side of this is bounded: an instance keeps the counters of +at most `2 × rooms.maxTracked` channels (16384 by default, in two +generations), not of every room name it ever published to. A channel +evicted from the table and published to again starts a new count under a +suffixed epoch — `.` — which a receiver reads as a restart of +that channel, never as a gap. Raise `maxTracked` if an instance genuinely +publishes to more rooms than that at once and you want gaps on every one +of them detected. + ## At-most-once, and what to do about it Delivery is **at-most-once**, deliberately. A message published while an @@ -149,7 +271,38 @@ subscriptions are what let one catch up. The backplane carries room events, not sessions. Two instances behind a load balancer need a shared [session store](./sessions#stores) as well — the default `MemorySessionStore` lives in one process, so a client that reconnects to a -different instance would arrive anonymous. +different instance would arrive anonymous. `createRedisSessionStore` from +this same subpath is that store: + +```js +const { createRedisAdapter, createRedisSessionStore } = require('@alexify/wrpc/scaling'); +const Redis = require('ioredis'); +const redis = new Redis(url); + +new Server({ + router, + backplane: createRedisAdapter({ pub: redis }), + sessions: { store: createRedisSessionStore({ client: redis }) }, +}); +``` + +## Who needs sticky routing {#affinity} + +With a shared session store and a backplane, the honest answer is: almost +nothing. + +| Transport | Sticky routing? | Why | +| --- | --- | --- | +| WebSocket, WebTransport | **No** | The session comes from the shared store on reconnect; rooms and presence cross the backplane; the client id embeds the instance, so a cluster command finds it. | +| Packet-mode HTTP, REST | **No** | Stateless per request; the session token is on every request. | +| SSE | **Yes** | The channel — its replay buffer and its `Client` — lives on the instance that opened it; a misrouted POST answers `409`. | +| WebRTC | **No** | A data channel is peer to peer; only the signaling unit runs on a server, and it relays a signal with `sendTo`, which reaches a peer on another instance through the cluster. | +| Broker binding | **No** | A stateless request reaches any instance; a session is bound to the one that answered its `hello`, through the broker, not a balancer. | +| Subscriptions with `createEventLog` | No, but visible | The log is per-process; a resume against another instance presents a foreign epoch, `since()` answers `null`, the handler sends a snapshot. | + +Share: the session store, the backplane. Keep local: event logs, SSE +channels. Never pin on anything wRPC emits — the client id is a routing +address for the cluster, not a cookie for a balancer. ## The cluster layer diff --git a/docs/guide/security.md b/docs/guide/security.md index a7baa29..dd65a29 100644 --- a/docs/guide/security.md +++ b/docs/guide/security.md @@ -1,6 +1,6 @@ # Security -wrpc's defaults are convenient for local development and deliberately loud +wRPC's defaults are convenient for local development and deliberately loud about what has to change before production. This page collects the whole picture in one place: what is on by default, what you must configure, and which attacks the library already refuses on your behalf. @@ -30,13 +30,18 @@ keeping that input from becoming something else. ### Connection metadata [Declared headers and `meta`](./metadata) are peer-controlled **labels** — -never authorization inputs. wrpc holds that line structurally: both bags are +never authorization inputs. wRPC holds that line structurally: both bags are size-capped on the encoded input (`metaMaxBytes`), plain-object-checked, `__proto__`-stripped and frozen; the ws query path cannot spoof an observed header (observed always wins the merge, and reserved names — `cookie`, -`host`, `origin`, `sec-*`, `content-*`, `proxy-*`, `x-wrpc-*` — are dropped -from it outright); and every violation is a refusal that leaves the -connection unlabelled, never an error that leaks parsing internals. The one +`host`, `origin`, the forwarding and client-ip spellings, `sec-*`, +`content-*`, `proxy-*`, `x-wrpc-*`, and the exact namespaces identity-aware +proxies set about the user they authenticated (`x-auth-request-*`, +`x-amzn-oidc-*`, `x-goog-iap-*`, `x-ms-client-principal*`, `remote-user`) — +are dropped from it outright; `declaredHeaders: [...]` allowlists the rest, +and `context.meta.declared` names what came from a declaration); and every +violation is a refusal that leaves the connection unlabelled, never an +error that leaks parsing internals. The one rule the framework cannot enforce for you: never branch an access decision on `client.meta` — that is the session's job. @@ -100,7 +105,7 @@ defended rather than trusted: - **Declared metadata.** `JSON.parse` defines `"__proto__"` as an *own* data property and pollutes nothing by itself — but application code that spreads or `Object.assign`s [`client.meta`](./metadata) bags into a config - would carry the key along, so wrpc drops it during sanitizing, and both + would carry the key along, so wRPC drops it during sanitizing, and both bags are null-prototyped and frozen besides. The same reasoning covers the dispatch tables: any table keyed on peer-controlled @@ -179,18 +184,34 @@ the full code table. ## Subprotocol negotiation -The client offers `wrpc.v1`. A server can require it through `protocols` / +The client offers the [protocol revisions](../reference/protocol#versioning) +it speaks — `wrpc.v2, wrpc.v1` — and the server selects the newest; a 1.0 +peer on either end settles the connection on `wrpc.v1`, where bytes travel +as 1.0's JSON. A server can require a revision through `protocols` / `handleProtocols` on the [engine](../reference/engine#attach-options), where returning `false` rejects the handshake — useful when a shared listener must -tell wrpc clients apart from everything else pointed at the same port. +tell wRPC clients apart from everything else pointed at the same port. -## What wrpc does not do for you +The same header also carries data: a browser client rides its declared +`headers`/`meta` and a Bearer credential as `wrpc.h.`, `wrpc.m.` and +`wrpc.bearer.` offers ([why](./metadata#declared-headers-the-headers-client-option)). +Those tokens are taken out of `offered` before your `handleProtocols` runs +and out of `context.meta.headers`, and an engine never echoes one — a +selector like `(offered) => offered.at(-1)` cannot reflect a credential into +the response. They remain on the raw `req.headers`, so keep that header out +of access logs the way you would `Authorization`. + +## What wRPC does not do for you - **It is not a firewall.** Rate limiting beyond the per-connection caps is an - `onRequest` [hook](./hooks#recipe-a-rate-limit) plus your own bucket. + `onRequest` hook plus your own bucket — see + [Rate limiting & throttling](./rate-limiting). - **It does not authenticate.** `startSession` trusts whatever your login handler verified. -- **It does not encrypt.** Run `protocol: 'https'`, or terminate TLS at a proxy - and run `'http'` behind it. +- **It does not encrypt by default.** Run `protocol: 'https'`, or terminate TLS + at a proxy and run `'http'` behind it — that comes first, always. Where TLS + ends before the data does (a Redis backplane, a broker's log, a shared + session store, a TLS terminator you do not run, a relay that should not + read what it relays), [Encryption](./encryption) is the opt-in layer. - **It does not sanitize your data.** `input`/`output` validators are yours to - write; wrpc only runs them. + write; wRPC only runs them. diff --git a/docs/guide/server.md b/docs/guide/server.md index 6a29783..b32cb24 100644 --- a/docs/guide/server.md +++ b/docs/guide/server.md @@ -4,9 +4,11 @@ There are two server objects, and the difference matters: - **`RpcServer`** is the engine-agnostic core. It knows nothing about `node:http`, sockets or listening — you hand it a socket - (`attachSocket`), a `MessagePort` (`attachPort`), or an abstract HTTP call - (`handleHttpCall`), and it does the RPC. Every [adapter](./adapters/fastify) - is a thin shell around one. + (`attachSocket`), a `MessagePort` (`attachPort`), an abstract HTTP call + (`handleHttpCall`), or any persistent transport (`attach`) — which is how a + [WebTransport](./wt) session, a [WebRTC](./webrtc) data channel and the + [broker binding](./brokers/rpc) get in — and it does the RPC. Every + [adapter](./adapters/fastify) is a thin shell around one. - **`Server`** is the batteries-included shell: a `node:http(s)` listener plus a WebSocket engine, composed around an `RpcServer`. It is what you want unless you already have a framework owning the port. @@ -42,12 +44,17 @@ with every adapter; the network half belongs to the shell. | `cors` | `null` | See [CORS](#cors) below. | | `backplane` | `null` | Carries room events between instances — see [Scaling](./scaling). | | `instanceId` | a uuid | Identifies this instance on the backplane. | -| `generateId` | uuid v4 | Context uuids, server stream ids, REST packet ids — bring your own (cuid/ulid). | +| `generateId` | uuid v4 | Every id the server mints — `instanceId`, client ids, context uuids, stream ids, REST packet ids, SSE channel ids, the cluster epoch. Bring your own (cuid/ulid). See [Identifiers](./production#identifiers). | | `introspection` | `true` | `system/introspect` exposure: `true` public, `'session'` gated, `false` off. | | `maxBatch` | `128` | Packets accepted in one [batch frame](./client#batching). | | `maxSubscriptions` | `256` | Concurrent [subscriptions](./subscriptions) per client. | | `maxCalls` | `1000` | In-flight calls per client; past it a call answers `429`. | +| `maxStreams` | `256` | Binary streams a client may hold open — announced and not yet ended; past it a `stream` packet answers `429`. A peer decides how many it announces, and each holds memory until a handler reads it. | | `sse` | `{}` | [SSE](./sse) channel options, or `false` to remove the endpoint. | +| `http` | `{}` | The HTTP side's own options: `compression`, off by default — see [Compression](#compression). | +| `compression` | off | Accept per-message compressed frames from a Node WebSocket client that negotiated them — see [Compression](#compression). | +| `maxMessage` | 16 MiB | The largest inflated client frame accepted on a socket. | +| `attachments` | `true` | Bytes in args, results and events travel as [binary attachments](./streams#attachments) to every peer that negotiated [revision 2](../reference/protocol#versioning); `false` makes the server speak revision 1 — JSON, to everyone, with `wrpc.v1` selected and `wrpc-version: 1` answered. | | `logger` | `globalThis.console` | Where the server logs — a Console or a pino-shaped logger; `false` silences it. See [Logging](./logging). | | `telemetry` | `null` | OTel traces and metrics — see [Telemetry](./telemetry). | @@ -64,7 +71,7 @@ adapter shares. | `key` / `cert` / `SNICallback` | — | TLS material, forwarded to `https.createServer`. | | `nagle` | `true` | `false` sets `noDelay` on the listener. | | `engine` | `createNodeEngine()` | The WebSocket [engine](../reference/engine). | -| `ws` | `{}` | Forwarded to the engine's `attach()` — `path`, `protocols`, `verifyClient`, `perMessageDeflate`, … | +| `ws` | `{}` | Forwarded to the engine's `attach()` — `path`, `protocols`, `verifyClient`, `perMessageDeflate`, … Compression is **off** until you pass `perMessageDeflate` — see [performance](./performance#compression-is-off-by-default). | | `maxBodySize` | 10 MiB | Request-body cap in bytes for the built-in HTTP path. | | `retry` | `3` | `EADDRINUSE` bind attempts before giving up. | | `timeouts.bind` | `2000` | Milliseconds between those attempts. | @@ -160,6 +167,109 @@ socket to your server with the user's cookies attached. Configure `cors.origins` and the default `verifyClient` refuses a mismatched `Origin` outright. +## Compression {#compression} + +Off by default, like every compression knob in wRPC: a gzip per answer is +CPU spent for every peer to save bytes only some of them need. Turn it on +for the HTTP side with `http.compression`: + +```js +new Server({ + router, + http: { + compression: { + threshold: 1024, // bytes; smaller answers go plain + filter: (call) => !call.headers['x-internal'], // per request, optional + async: { threshold: 256 * 1024 }, // hand bodies this large to the threadpool + encodings: ['zstd', 'br', 'gzip'], // this server's order; the default is ['gzip'] + }, + }, +}); +``` + +`compression: true` takes the defaults — gzip alone. An answer is encoded +when the request's `Accept-Encoding` admits one of the server's codings, the +body is at or over `threshold`, +nothing upstream set a `Content-Encoding` already (a route's own +[`headers`](./rest#response-headers), a framework compression plugin), and +`filter(call)` — when given — returns `true`. The response then carries +`Content-Encoding`, `Vary: Accept-Encoding` (joined onto the CORS +`Vary: Origin`) and the encoded `Content-Length`. Packet-mode POSTs, batch +frames, REST results and errors all leave through the same funnel; a REST +route's [ETag](./rest#caching) is computed over the plain body, so a `304` is +the same validator whichever encoding was asked for, and a `204` or `304` is +never encoded. + +**Which coding** is `encodings`, the server's list in **its** order of +preference: the first one on it the request accepts is used. A weight of +zero refuses a coding and `*` covers the ones not named; other weights say +*acceptable*, not *preferred* — which acceptable coding costs this server +least is not the client's to know (nginx reads the header the same way). + +| `encodings` entry | Default level | On a 27 KB answer (`bench/algorithms.js`) | +| --- | --- | --- | +| `'gzip'` or `{ encoding: 'gzip', level, memLevel }` | zlib's 6 | 110 µs, 3,214 B — what every client accepts | +| `'br'` or `{ encoding: 'br', quality }` | 4 | 93 µs, 2,601 B — the smallest at gzip's cost | +| `'zstd'` or `{ encoding: 'zstd', level }` | 1 | 36 µs, 2,848 B — a third of the CPU; Node 22.15+ / 23.8+, a `TypeError` at construction before | +| `{ encoding, encode(bytes), createStream? }` | — | your own coding; `encode` may answer a promise, a failure answers the plain body | + +Under ~2 KB the three are within a few bytes and microseconds of each other, +so the list earns its place on large answers. zlib's own Brotli default is +quality 11 — **33 ms** on that answer — which is why the default here is 4. +Browsers announce `br` and `zstd` over HTTPS only, so plain-HTTP development +sees gzip whatever the list says. + +Nothing changes on the client: `fetch` sends `Accept-Encoding` and inflates +by itself, in browsers and in Node. The event stream has its own option, +[`sse.compression`](./sse#compression); the WebSocket has +[`perMessageDeflate`](./performance#compression-is-off-by-default). + +### The Node client's frames {#node-client-frames} + +`perMessageDeflate` compresses what the server sends; a **Node** client's +built-in `WebSocket` only ever inflates, so its uploads — a 4 KB call, a +stream chunk — arrive as they are. The server-level `compression` option +accepts per-message compressed frames from a Node client that asked for +them: the client sends `{ type: 'ping', enc: ['deflate-raw'] }` on open — +its codecs, in its [order of preference](./compression#list) — a server +answers with the first of them it holds as the `enc` of its `pong`, and +from then on the client sends every packet or chunk past the threshold as a binary +frame under a `0x00` marker (a stream chunk never starts with one) that +the server inflates before dispatch. Off on both ends by default; a lone +end stays plain. A browser never needs it — it compresses both directions +itself under `perMessageDeflate`. + +```js +new Server({ router, compression: true }); // accept them +const client = await connect('wss://host/api', { compression: true }); // send them (Node) +``` + +What it costs — `bench/http-compression.js`, one-shot gzip of a callback: + +| Answer | rate | ratio | +| --- | ---: | ---: | +| 295 B (2 rows) | 110,649/sec | 1.7× | +| 1.6 KB (12 rows) | 76,920/sec | 5.5× | +| 8.5 KB (64 rows) | 27,563/sec | 9.9× | +| 135 KB (1000 rows) | 2,056/sec | 12.0× | + +The first row is the reason for the 1 KiB threshold; the last one — half a +millisecond on the event loop — is what `async` is for. Under the fastify +adapter, [delegated REST routes](./adapters/fastify#response-headers-and-caching-on-delegated-routes) +answer through fastify's own reply and are `@fastify/compress`'s to encode; +the packet endpoint and conventional REST paths under the plugin follow this +option like any host. + +## Revisions {#revisions} + +`rpc.revision` is the newest [protocol revision](../reference/protocol#versioning) +this server speaks — `2`, or `1` under `attachments: false` or a packet +codec — and `context.client.revision` the one a connection settled on. They +are the questions an upgrade from 1.0 asks: who is still on revision 1 is the +`1` series of `wrpc.server.connections` by `wrpc.revision` +([telemetry](./telemetry#metrics)), and each such connection is a +`revision.peer` debug line ([logging](./logging)). + ## Lifecycle ```js @@ -187,7 +297,8 @@ 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 }); ``` @@ -200,7 +311,10 @@ framework request. ## Ports `attachPort(port)` speaks the protocol over a `MessagePort` instead of a -socket: JSON packets as strings, binary chunks as `Uint8Array`. That covers a +socket: JSON packets as strings, binary chunks as `Uint8Array`. A port has no +handshake, so the [revision](../reference/protocol#versioning) rides its first +`ping` — a wRPC client sends it by itself — and until then the port is sent +no framed message. That covers a worker thread, an embedded peer, or a test harness that wants a real client against a real server with no network in between. The `Server` shell wires it to a `'port'` event, so a host can hand ports in without reaching for @@ -213,4 +327,14 @@ 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 +client, whether this package knows the wire or not. The one it ships on top +of it is `attachChannel(rpc, dc, options)` from +[`@alexify/wrpc/webrtc`](./webrtc#your-own-connection) — a WebRTC data +channel the application negotiated itself, a browser reaching this server +peer to peer with `connect(url, { transport: 'webrtc', channel })`. Like a +port, a channel carries no request: the client starts with no session, and +what the application observed about the peer goes in `headers` / `data`. diff --git a/docs/guide/sessions.md b/docs/guide/sessions.md index ff49f2b..0041a28 100644 --- a/docs/guide/sessions.md +++ b/docs/guide/sessions.md @@ -53,7 +53,7 @@ These live on `context.client`: | `startSession(token?, data?)` | Creates a session **and** sends the cookie. What a login calls. | | `initializeSession(token?, data?)` | Creates it without touching cookies. | | `restoreSession(token)` | Loads one from the store. `false` if it is gone. | -| `finalizeSession()` | Deletes it from the store and drops it. `false` if there was none. | +| `finalizeSession()` | Deletes it from the store and drops it — and ends it on this instance's other connections that restored the same token. `false` if there was none. | `token` defaults to a freshly generated one; pass your own to adopt an existing identifier. @@ -61,7 +61,8 @@ identifier. ::: warning A login over WebSocket sets no cookie A `Set-Cookie` header needs a response, and an open socket has none. `startSession` therefore only emits the cookie on HTTP transports; over a -WebSocket it creates the session for that connection alone. If you want the +WebSocket — or any persistent transport: WebTransport, a data channel, a +broker session — it creates the session for that connection alone. If you want the session to survive a reconnect, log in over HTTP (the browser stores the cookie) and let the WebSocket upgrade restore it — which it does, from the same cookie. @@ -77,6 +78,23 @@ A dropped connection never deletes the session from the store. That is exactly what makes a reconnect cheap: sessions end through `finalizeSession()` or store-side expiry, and nothing else. +**A logout ends the session everywhere on this instance.** Every connection +that restored the same token — another tab, a second device holding the same +bearer token — holds its own copy of the session; `finalizeSession()` on one +marks the others ended, so their next call to a `session` procedure is a +`403`, as on the connection that logged out (a handler already running there +finishes with its `context.session`). **Another instance** has its copies in +its own memory and does not hear of it: a connection there keeps the session +until it reconnects — the restore then finds no row — or until your +application tells it, over the [cluster](./scaling) or the rooms backplane. A +store with short TTLs and `touch()` narrows that window. + +**A store that fails is not a missing session.** When the store throws while a +connection's token is restored (`session.restore` in the log), the connection +goes on without a session, and a `session` procedure answers it `503` — which +a [broker consumer](./brokers/consumers#identity) retries — rather than the +`403` of a token that names no session. + ## The lifecycle ```mermaid @@ -157,7 +175,7 @@ new Server({ router, sessions: { store } }); ``` Which is how Redis, a database table, or a signed-cookie store plug in -**without wrpc depending on any of them**. The default is +**without wRPC depending on any of them**. The default is `MemorySessionStore`, bounded on both axes: ```js @@ -172,7 +190,104 @@ new Server({ LRU eviction past `maxSessions`, expiry after `ttl` (`0` disables either). It is a real store, not a stub — but it lives in one process, so a second instance shares nothing with it. Anything running more than one process wants -an injected store. +a shared store, and one ships for Redis: + +```js +const { createRedisSessionStore } = require('@alexify/wrpc/scaling'); +const Redis = require('ioredis'); + +new Server({ + router, + sessions: { store: createRedisSessionStore({ client: new Redis(url), prefix: 'wrpc:session:', ttl: 24 * 3600_000 }) }, +}); +``` + +ioredis-shaped and injected, like the [backplane](./scaling#redis): `get`, +`set(key, value, 'PX', ttl)`, `del`, and `pexpire` for the sliding expiry +`restore()` performs (`touch`). State is stored as JSON under the prefix. +node-redis v4 spells the expiring set as `set(key, value, { PX })` — a +two-line wrapper adapts it. With a shared store, no instance owns a session +and a WebSocket client needs **no sticky routing** — see +[what stays per-instance](./scaling#what-stays-per-instance). + +### Sealing what rests in the store {#sealed} + +A shared store is a third party. The Redis store keeps each session's state +as JSON **under the token itself** — so a keyspace listing is a list of live +bearer credentials, and a dump is every user's state. `sealedStore` wraps +any store so that neither rests in it: + +```js +const { sealedStore } = require('@alexify/wrpc/encryption'); + +new Server({ + router, + sessions: { + store: sealedStore(createRedisSessionStore({ client: new Redis(url) }), { + keys: process.env.WRPC_SESSION_KEY, // 32 bytes: base64, hex or a Uint8Array + }), + }, +}); +``` + +A row is keyed by an HMAC of the token and holds the state sealed +(AES-256-GCM), with the row's own key as additional data — a row copied +into another session's slot does not open. A row that does not open is a +missing session and one `session.open` warning; the token is never logged. + +- **Rotation signs nobody out.** With `keys: { current: 'k2', ring: { k1, + k2 } }` a read that misses under `k2` finds the row under `k1` and moves + it. Drop `k1` once your longest session TTL has passed since it stopped + being current. +- **Adopting it over a store that already holds sessions:** `acceptPlaintext: + true` reads a row the unwrapped store wrote once, seals it and deletes the + plaintext. Turn it off after the same TTL — while it is on, the raw token + is a name the store still answers to, and only a session-shaped object + under it is taken for one (a sealed record put there is refused). +- **A fleet mid-rotation** — one instance writing under `k1`, another under + `k2` — keeps one row per token: a write removes the token's rows under + the other kids, and a migration deletes the old row only while it is + still the row that was read (a newer state written there meanwhile is + moved instead). +- It costs one extra `get` per *older* kid on a miss — an unknown token + included — so keep the ring short. + +Adopting it over a fleet that already holds sessions is **three deploys**, +because an instance on the previous deploy must still find every session +where it looks for it: + +| Deploy | `sealedStore(store, …)` | Writes | Reads | +| --- | --- | --- | --- | +| 1 | `{ keys, seal: false, acceptPlaintext: true }` | plaintext | both — a sealed row is read where it is, never moved | +| 2 | `{ keys, acceptPlaintext: true }` | sealed | both — a plaintext row is sealed on its first read | +| 3 | `{ keys }` | sealed | sealed only | + +Move on once every instance runs the deploy before, and leave deploy 2 on +for the longest session TTL. A key **provider** for the store must answer +`kids()` — a rotation is walked through it, and a provider without it would +keep every session under an older kid unreadable the moment `current` +moved (a `TypeError` at construction, not a mass logout later): + +```js +sealedStore(store, { + keys: { current: () => vault.current, get: (kid) => vault.get(kid), kids: () => vault.kids }, +}); +``` + +What it does not do: hide how many sessions exist or when they are touched, +or protect a session from someone holding the key — every instance does. + +Writes to `session.state` are coalesced: the assignments of one turn become +**one** `store.set` on a microtask (the initial state of `create()` is +written immediately), and a session that was finalized in the meantime is +not written back. Every write after the first is **conditional** — +`set(token, state, { create: false })` — and a store answering `false` to it +is saying the row is gone: a logout on another connection or instance landed +first, and this write must not undo it. The session ends there +(`session.save` with `reason: 'gone'`) instead of coming back, token and +all. The memory store, the Redis store (`SET … XX`) and `sealedStore` +refuse such a write; a custom store may ignore the option and write as +before. ## Tokens @@ -198,8 +313,9 @@ new Server({ router, sessions: { transport: myTransport } }); ``` Besides the raw `headers`/`url`, `read()` receives what the core already -parsed: `declared` — the merged declared+observed header bag (the ws -`wrpc_h` query included, capped on the configurable `metaMaxBytes`) — and +parsed: `declared` — the merged declared+observed header bag (whatever a +ws client declared, through the `wrpc.h.` subprotocol token or the `wrpc_h` +query, capped on the configurable `metaMaxBytes`) — and `meta`, the sanitized connection-metadata bag with **both** `x-wrpc-meta` spellings merged and keys kebab-normalized. Prefer them over re-parsing the wire: a strategy with its own parser can silently drift from the core's. @@ -216,14 +332,16 @@ new Server({ router, sessions: { transport: bearerTransport() } }); ``` - **`bearerTransport()`** reads `Authorization: Bearer ` — the real - header where the transport can send one (http/sse, curl); on browser ws, + header where the transport can send one (http/sse, curl, ws from Node); on browser ws, where the WebSocket constructor cannot set headers, the client offers the token as a **`wrpc.bearer.` subprotocol** next to the wire revision, so the credential travels as a real upgrade header and **never lands in the connect URL** (URLs end up in proxy access logs — see the [metadata caveat](./metadata#declared-headers-the-headers-client-option)). A token - outside the RFC 7230 token charset (spaces, `=` padding) cannot ride a - subprotocol and falls back to the declared-headers query, with a warning. + outside the RFC 7230 token charset (spaces, `/`, `=` padding) cannot ride + bare and travels inside the declared-headers token instead — base64url, so + still a header and still off the URL. Only `carrier: 'query'` or + `protocols: []` can put it in the query, and the client warns when it does. - **`payloadTransport({ field })`** reads a field of the client's declared `meta` — for apps that keep `authorization` semantics out of it. Both `x-wrpc-meta` spellings are read, the canonical JSON header and the diff --git a/docs/guide/sse.md b/docs/guide/sse.md index e0da24d..9c98a09 100644 --- a/docs/guide/sse.md +++ b/docs/guide/sse.md @@ -2,7 +2,7 @@ Some places will not give you a WebSocket: a serverless platform with no upgrade path, a corporate proxy that strips one, an edge runtime that only -speaks HTTP. `@alexify/wrpc/sse` is a full wrpc transport built out of nothing +speaks HTTP. `@alexify/wrpc/sse` is a full wRPC transport built out of nothing but HTTP requests — calls, events, subscriptions and cancellation all work, and the packets are identical. @@ -32,8 +32,8 @@ SSE is one-way, so a channel is two halves that find each other by id: ``` GET {basePath}/events opens a NEW channel -GET {basePath}/events x-wrpc-channel: re-attaches to an existing one -POST {basePath} x-wrpc-channel: client -> server +GET {basePath}/events x-wrpc-channel: . re-attaches to an existing one +POST {basePath} x-wrpc-channel: . client -> server ``` ```mermaid @@ -52,19 +52,26 @@ sequenceDiagram S-->>C: replay of what was missed, then live frames ``` -**The server mints the id** and hands it out exactly once, in the `ready` -frame that opens every stream — a client never proposes its own. The channel -is bound to the cookie identity of the GET that created it, and every -re-attach and POST must present the same one: a request naming a live id -without it is refused with `403`, so a leaked id (URLs end up in logs) is -not a bearer token for the channel's session. An id the server no longer -holds answers `409` — the built-in transport reacts by starting a fresh -channel and letting the client re-load and re-subscribe. +**The server mints the id and draws the secret**, and hands both out — as +one string, `.` — in the `ready` frame that opens every stream; +a client never proposes its own, and presents the string back as it got it +(which is why a 1.0 client, that knows of no secret, still holds one). +The id is whatever your `generateId` makes of it (a uuid, a cuid, a counter: +one option covers every id the server mints); the secret is 18 random bytes +from the server, and it is the credential: every re-attach and POST presents +it after the id in the one `x-wrpc-channel` header, and a request naming a +live id without it is refused with `409`, exactly like an id the server does +not hold — a guessed id learns nothing, and a leaked id (URLs end up in +logs) opens nothing. On top, the channel is bound to the cookie identity of +the GET that created it, and a request presenting the secret without that +identity is `403`. An id the server no longer holds answers `409` — the +built-in transport reacts by starting a fresh 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 @@ -90,6 +97,7 @@ new Server({ | `maxChannels` | `10000` | Live channels per server; past it a new GET is `503`. | | `maxChannelsPerAddress` | `100` | Live channels per remote address; past it `429`. **Behind a proxy this counts the proxy**, not your users — see the warning below. | | `clientAddress` | socket peer | `(call) => string` — what the per-address cap counts by. Inject a reader for your proxy's client header. | +| `compression` | off | `true` or `{ filter, encodings }` — gzip (by default) the stream for peers that accept it, one member per response, flushed after every event. See [Compression](#compression). | ::: warning Behind a load balancer, set `clientAddress` The per-address cap defaults to the TCP peer address. Behind nginx/ALB that @@ -98,12 +106,52 @@ is refused `429` while the node idles at 1% of `maxChannels`. Either inject `sse: { clientAddress: (call) => firstForwardedFor(call.headers) }` (trust your proxy's header only when the proxy is yours), set `maxChannelsPerAddress: 0`, or on the express adapter enable -`app.set('trust proxy', ...)` — its `req.ip` is what wrpc receives there. +`app.set('trust proxy', ...)` — its `req.ip` is what wRPC receives there. ::: `sse: false` removes the endpoint entirely. +## Compression {#compression} + +Off by default. `sse: { compression: true }` gzips the event stream for a +GET whose `Accept-Encoding` admits gzip — one gzip member for the life of +the response, **flushed after every event**, so nothing waits for a next +event and every event compresses against the stream's own history. The same +shape repeated is where it pays: a 125 B tick leaves as 16 B on the wire +(7.8×, `bench/http-compression.js`), the effect +[context takeover](../reference/wire-format#context-takeover) has on a +WebSocket, without the extra option. + +```js +new Server({ + router, + sse: { + compression: { + filter: (call) => call.headers['x-forwarded-proto'] !== undefined, // per GET, optional + encodings: [{ encoding: 'gzip', level: 6, memLevel: 8 }], // the default is ['gzip'] + }, + }, +}); +``` + +`encodings` is the same list [`http.compression`](./server#compression) +takes — `'br'`, `'zstd'`, your own coding with a `createStream()` — and gzip +is the default here **on purpose**: flushed per event, Brotli and zstd save +nothing on small events (23 and 18 B against gzip's 20) and hold 570 and +930 KB per open response against gzip's 320, which a smaller window does not +give back (`bench/algorithms.js`). Reach for another coding only for a stream +of large events. + +The client changes nothing: `fetch` inflates the stream incrementally, in +browsers and in Node, and the wRPC SSE client is that `fetch`. The decision +is per response — a re-attach that stops accepting the coding gets a plain stream +on the same channel, replay included. What it costs is one zlib deflate +state per live stream (~256 KiB at the defaults; `memLevel` lowers it) and a +deflate call per event (~11 µs). A proxy that buffers compressed responses +needs the same `X-Accel-Buffering: no` treatment as a plain stream; the +stream's `Cache-Control: no-transform` already asks it not to re-encode. + ## Replay is honest A reconnect with `Last-Event-ID` replays exactly what the buffer still @@ -132,12 +180,24 @@ oblivion. ## What it cannot do -**Binary streams.** SSE frames are text, so wrpc's binary streams are *refused* +- **Bytes as bytes.** Neither a binary stream nor a packet with + [binary attachments](./streams#attachments). An SSE channel speaks + [revision 1](../reference/protocol#versioning) to every client — the + stream's GET says nothing about which wRPC is asking, and a 1.0 client + reads it — so `client.revision` is `1` on both ends, and a `Buffer` or a + typed array in a call's arguments, a result or an event travels as the + JSON 1.0 made of it: `{ type: 'Buffer', data: [...] }`, `{ "0": 137, … }`. + A handler that takes bytes from SSE clients reads them back from that + shape; one that must receive `Uint8Array`s needs a transport that carries + bytes — a WebSocket, WebTransport or a WebRTC data channel. + +**Binary streams.** SSE frames are text, so wRPC's binary streams are *refused* on this transport rather than silently corrupted — `client.binary` is `false` -server-side, and `createStream`/`getStream` throw. Use a WebSocket for those. +server-side, and `createStream`/`getStream` throw. Use a WebSocket, +WebTransport or a WebRTC data channel for those. -That is the only functional difference. Calls, events, subscriptions with -resume, cancellation and batching all work. +Those two — bytes and streams — are the only functional differences. Calls, +events, subscriptions with resume, cancellation and batching all work. ## Not `EventSource` diff --git a/docs/guide/streams.md b/docs/guide/streams.md index 686215d..53481fa 100644 --- a/docs/guide/streams.md +++ b/docs/guide/streams.md @@ -1,7 +1,7 @@ # Binary streams Calls carry JSON. For bytes — a file upload, a video download, anything big -enough that buffering it would be a mistake — wrpc has a second framing: +enough that buffering it would be a mistake — wRPC has a second framing: **binary chunks**, interleaved on the same connection. Two objects, one on each end: @@ -30,6 +30,12 @@ await uploader.upload(); console.log(await receiving); ``` +`upload()` is paced by the transport — a chunk it did not accept waits for +the stream's `'drain'` — and rejects with a `503` `WrpcError` when the +connection closes before the last chunk went, instead of resolving with the +rest gone nowhere; a source that fails mid-way terminates the stream on the +server, which stops waiting for its end. + ```js // server receive: procedure({ @@ -155,6 +161,64 @@ A disconnect terminates every stream the client held. `readable.status` is `'active'`, `'closed'` or `'terminated'`; `bytesRead` is what has been consumed so far, against the announced `size`. +A connection holds at most [`maxStreams`](./server#rpc-options) streams at +once (256 by default): a stream is announced by the peer, and holds a +readable — with what was sent into it — until a handler reads it, so the +number is the peer's to choose and the cap the server's. Past it the +`stream` packet answers `429` (`stream.capacity` in the log, at debug). + +## Bytes inside a call, not as a stream {#attachments} + +A stream is for bytes that do not fit in memory or should not wait for +their end. Bytes that *do* fit — an avatar, a thumbnail, a signature, a +serialized model — belong in the call, and they can simply be put there: + +```js +await client.api.files.put({ name: 'avatar.png', body: pngBytes }); // a Uint8Array +const { thumbnail } = await client.api.files.get({ id }); // arrives as a Uint8Array +context.client.sendEvent('files/ready', { preview: bytes }); +``` + +Any typed array, `ArrayBuffer` or `DataView` anywhere in a packet's args, +result, event data or error details travels as **binary attachments**: the +packet's JSON goes out with `null` at each byte leaf and an index of where +the leaves were, the buffers follow in the same frame, and the other end +puts them back — as fresh `Uint8Array`s that own their bytes. No base64 +(a third more), and none of what `JSON.stringify` makes of a typed array +otherwise: a `{"0":137,"1":80,…}` object nine times the size that used to +arrive silently as a plain object. Every transport carries it — a binary +WebSocket frame, a WebTransport or data-channel message, a broker session frame, a +worker port, an HTTP body under `application/octet-stream` — except SSE, +which is text-only: it speaks [revision 1](./sse#what-it-cannot-do) to +every client, so bytes on it travel as the JSON objects 1.0 made of them. + +On by default between two 2.x ends. The frame is +[revision 2](../reference/protocol#versioning) of the protocol, negotiated +per connection, so a 1.0 peer is never sent one: its bytes arrive — and are +sent — as the plain objects 1.0 made of them, and `client.revision` (on the +client, and on `context.client` in a handler) says which kind of connection +this is. The cost is a walk of every outbound packet to find the bytes +(`bench/attachments.js`: tens of nanoseconds on a small callback, a few +microseconds on a 30 KB one); `attachments: false` skips it and makes that +end speak revision 1 — JSON, to everyone. Under a packet [codec](./codec) +it is off by itself — the codec owns the wire. A REST +result holding bytes needs `codec.rest` and answers `501` without it. A +room event with bytes crosses the [backplane](./scaling) too: its envelope +rides as the same frame, base64 under a `wrpc-bin:` marker (or inside the +sealed envelope, under `rooms.encryption`), and the members on another +instance receive bytes. + +Two things the walk leaves to JSON. An object with a `toJSON()` **is its +projection**: what `toJSON()` answers is what travels, and a typed array +kept behind it (a password hash on a user entity, say) is never lifted out +and sent — put the bytes in the projection if they should go. And a packet +with a cycle in it answers `false` to the walk and is handed to +`JSON.stringify`, whose "Converting circular structure" `TypeError` is the +one you would have seen anyway; the walk itself stays linear on a graph +with back-references (a parent every child points at), where a walk with +no memory of what it visited would take 12^16 steps. +The frame's layout is on the [protocol page](../reference/protocol#binary-chunks). + ## Chunk framing Each binary frame is one chunk of one stream: diff --git a/docs/guide/telemetry.md b/docs/guide/telemetry.md index 9902b22..149b78a 100644 --- a/docs/guide/telemetry.md +++ b/docs/guide/telemetry.md @@ -10,9 +10,11 @@ const server = new Server({ router, telemetry: { api }, port: 8000, protocol: 'h ``` Pass the same option to a client and one trace covers both sides of the wire -— the packet transports, the mapped **REST leg** (context rides real +— the packet transports (WebSocket, HTTP, SSE, a worker port, WebTransport, a +[WebRTC link](./webrtc#telemetry)), the mapped **REST leg** (context rides real `traceparent`/`tracestate` headers there), the fastify adapter's delegated -routes, and the cluster's node-to-node hop (context rides the backplane +routes, a [broker message](./brokers/consumers) (the same two headers on the +message), and the cluster's node-to-node hop (context rides the backplane envelope). ::: info OpenTelemetry is injected, never depended on @@ -29,14 +31,14 @@ telemetry: { api } // the @opentelemetry/api module telemetry: { tracer, meter } // instances you built ``` -With `{ api }`, wrpc derives its own tracer and meter so everything it emits +With `{ api }`, wRPC derives its own tracer and meter so everything it emits carries the `@alexify/wrpc` instrumentation scope, and it can reach the propagator — which is what [trace context](#trace-context) needs. With instances, either alone is fine: tracer-only gives spans, meter-only gives metrics. **Propagation is off** in this mode unless you also pass `propagation` (and `context`), because serializing W3C trace context without a -propagator is not something wrpc will hand-roll. +propagator is not something wRPC will hand-roll. Without a tracer and without a meter, telemetry is off and every recording path is a no-op. @@ -67,7 +69,7 @@ span and a duration sample exactly as a slow handler does. | `wrpc.status` | `ok`, `error`, `cancelled`, `timeout` | | `error.type` | the error's constructor name | | `wrpc.packet.type`, `wrpc.packet.id` | every span | -| `wrpc.transport` | `ws`, `http`, `sse`, `event` | +| `wrpc.transport` | `ws`, `http`, `sse`, `event`, `webrtc`, `wt`, `broker` | | `wrpc.persistent` | whether the connection stays open | | `wrpc.subscription.values` | values yielded | | `wrpc.subscription.terminal` | `complete`, `error`, `unsubscribed` | @@ -92,22 +94,62 @@ credential, not an identity, and the two do not share a switch. | `rpc.server.duration` | Histogram | `ms` | | `rpc.client.duration` | Histogram | `ms` | | `wrpc.server.calls` | Counter | `{call}` | -| `wrpc.server.connections` | UpDownCounter | `{connection}` | +| `wrpc.server.connections` | UpDownCounter | `{connection}` — live connections by `wrpc.transport` and `wrpc.revision` (`1` or `2`, the [protocol revision](../reference/protocol#versioning) the connection speaks): during an upgrade, the `1` series is who is still on 1.0 — or reads no frames. A connection whose revision is settled after it opened (a worker port's first ping, a WebTransport peer's capabilities) moves from one series to the other | | `wrpc.server.subscriptions` | UpDownCounter | `{subscription}` | | `wrpc.server.subscription.values` | Counter | `{value}` | | `wrpc.server.broadcasts` | Counter | `{event}` | | `wrpc.server.broadcast.recipients` | Histogram | `{client}` | | `wrpc.server.stream.bytes` | Counter | `By` | | `wrpc.server.backpressure` | Counter | `{event}` | +| `wrpc.server.backplane.gaps` | Counter | `{envelope}` — envelopes a publisher sent that this instance never received, by `wrpc.channel.kind` (`room` or `broadcast`; the room's name is on the `backplane.gap` log line, never a label — a room name is unbounded); see [scaling](./scaling#loss-detection) | | `wrpc.server.sessions` | Counter | `{operation}` | +| `wrpc.server.encryption` | Counter | `{handshake}` — session handshakes established and refusals, by `wrpc.kind` (`ws`, `wt`, `http`) and `wrpc.outcome` (`established`, or the refusal reason the `encryption.refused` line carries — a closed set) | | `wrpc.server.sse.channels` | UpDownCounter | `{channel}` | | `wrpc.server.sse.events` | Counter | `{event}` | | `wrpc.cluster.messages` | Counter | `{message}` | | `wrpc.cluster.requests` | Counter | `{request}` | | `wrpc.cluster.instances` | UpDownCounter | `{instance}` | +| `wrpc.rtc.links` | UpDownCounter | `{link}` | +| `wrpc.rtc.redials` | Counter | `{attempt}` | +| `wrpc.rtc.ice_restarts` | Counter | `{restart}` | +| `wrpc.rtc.closes` | Counter | `{link}` — peer links closed, by `wrpc.rtc.reason` (`goodbye`, `refused`, `gave-up`, `abandoned`, `unknown`) and `wrpc.rtc.side` (`local` / `remote`) | +| `wrpc.broker.deliveries` | Counter | `{message}` | +| `wrpc.broker.rpc.session.ends` | Counter | `{session}` — broker RPC sessions ended, by `wrpc.broker.reason`: `gap` and `peer_gap` (the broker lost a frame — seen by the server, or reported by the client), `undecodable`, `send_failed`, `idle`, `replaced`, `bye`, `closing`. Open sessions are `wrpc.server.connections` with `wrpc.transport: broker` | +| `wrpc.broker.published` | Counter | `{message}` | +| `wrpc.broker.refused` | Counter | `{message}` | | `wrpc.client.reconnects` | Counter | `{attempt}` | | `wrpc.client.refreshes` | Counter | `{run}` | | `wrpc.client.connections` | UpDownCounter | `{connection}` | +| `wrpc.client.heartbeat.rtt` | Histogram | `ms` | +| `wrpc.client.calls` | Counter | `{call}` | +| `wrpc.server.queue.depth` | UpDownCounter | `{call}` | +| `wrpc.server.queue.wait` | Histogram | `ms` | +| `wrpc.server.rooms` | UpDownCounter | `{room}` | +| `wrpc.cluster.verifications` | Counter | `{envelope}` — cluster envelopes refused under `cluster.secret`, by `wrpc.cluster.outcome`: `unsigned`, `badsig`, `error`, and `replay` (signed, but a repeat, on another channel, outside the clock window or uncounted) — and `unsequenced`, an envelope with no counter **accepted** under `replay: 'accept'`: a 1.x node still in the cluster. When that series stays flat, the option can go | +| `wrpc.compression.failures` | Counter | `{message}` — messages a compression codec threw on, by `wrpc.compression.carrier` (`ws`, `http`, `sse`, `wt`, `rooms`, `cluster`, `broker`) and `wrpc.compression.direction` (`encode`: sent plain; `decode`: refused). Recorded on the failure path only | +| `wrpc.rtc.assertions` | Counter | `{assertion}` — trust assertions verified, by `wrpc.rtc.outcome`: `ok`; `missing`, `signature`, `fingerprint`, `subject` are **substitution signals** (a peer presenting no token, another key's, a replayed one, or someone else's); `expired`, `kid`, `issuer`, `malformed` are operational (clock, rotation, configuration) | +| `wrpc.broker.delivery.attempts` | Histogram | `{attempt}` | + +The four `wrpc.rtc.*` instruments come from a [WebRTC peer](./webrtc): open +links by `wrpc.rtc.role` (`initiator` / `responder`), redials and knocks +after a link failed by role, ICE restarts by `wrpc.rtc.outcome` +(`requested`, `recovered`, `failed`), and closed links by reason and side — +a `refused` close is a peer a trust policy turned away, a `gave-up` one a +path that never came back. A peer's host half also records the +ordinary server spans and `wrpc.server.connections` under +`wrpc.transport: 'webrtc'`. + +The two `wrpc.broker.*` instruments come from the +[message-broker](./brokers) bindings: messages consumed into procedures by +`messaging.system` and `wrpc.broker.outcome` (`ack`, `retry`, `release`, +`dead`), and messages published by outcome (`ok`, `error`). +`wrpc.broker.refused` counts what a [sealed](./encryption#brokers) feed or +consumer could not open, by `messaging.system` and `wrpc.reason` (`unsealed`, +`kid`, `open`, `format`, `replay`) — a rising `kid` series during a rotation +is a fleet that dropped a key before the backlog under it drained. A consumed +message's call span is a `CONSUMER` span carrying `messaging.*` attributes, +parented on the trace context the message arrived with; a published one is a +`PRODUCER` span whose context rides in the message headers. `wrpc.server.sse.events` labels a closed kind set — `open`, `reattach`, `replay`, `gap`, `expired` — and the `gap`/`expired` series are **real event @@ -118,13 +160,81 @@ that keeps recovering stays visible. Early HTTP/SSE refusals that happen before any client exists (CORS 403, 404, 405, capacity 429/503) are counted on `wrpc.server.calls` under the `` target. +`wrpc.client.heartbeat.rtt` is the client's only true latency signal that +does not need a call to produce it: the app-level ping/pong is an exact round +trip, and it keeps reporting while the application is idle. A heartbeat that +timed out records no sample — there is no round trip to measure, and a value +invented from the timeout would say more about your configuration than about +the network. Nothing is recorded on this instrument for it: a timeout is the +client's `heartbeat.timeout` log line and, because the connection is then +terminated and dialled again, an `attempted` on `wrpc.client.reconnects`. + +`wrpc.server.sessions` labels six operations — `create`, `restore`, +`touch`, `destroy`, `evict`, `expire` — where it once only ever said +`restore`. `evict` is the one to alert on: unlike `expire` it discards +sessions that are still live, so it means signed-in users were signed out to +stay under `maxSessions`. + +`wrpc.stream.direction` finally has both values. `send` is recorded only by +the server: on the client the counter has nothing to feed and the bytes +would only cost the browser bundle. + +`wrpc.broker.delivery.attempts` is recorded when a message SETTLES — on an +ack or a dead-letter, never on a retry — so it is the distribution of how +many deliveries each message took, not a triangle counting the same message +once per attempt. + Metric attributes deliberately stay low-cardinality: the method, the status, the transport. Packet ids and peer addresses go on spans, never on a metric -series. +series. `wrpc.server.queue.depth` carries **no** attribute at all, per +procedure or otherwise: one series per procedure is a cardinality bomb, and +the question it answers — "is this server queueing?" — is a whole-process +one. + +## Queue saturation + +A call that waits for a concurrency slot spends that time inside +`rpc.server.duration`, which makes a saturated queue and a slow handler look +identical on a dashboard. They are opposite problems with opposite fixes: +one wants a bigger `queue`, the other wants the handler looked at. + +`wrpc.server.queue.wait` separates them. It is the time between a call +asking for a slot and getting one, sampled per queued call; +`wrpc.server.queue.depth` is how many are waiting right now. A rising +`wait` with a flat `rpc.server.duration` minus the wait is a capacity +problem; the reverse is a handler problem. + +Both are recorded only when telemetry is on — with no meter there is not even +a clock read on the path. + +## Units + +`rpc.server.duration` and `rpc.client.duration` are in **milliseconds**, +while current OpenTelemetry semantic conventions specify seconds for RPC +duration histograms. This is a deliberate, documented divergence rather than +an oversight. + +Changing the unit under the same metric name is the worst shape a breaking +change can take: nothing errors, no alert fires, and every existing +dashboard silently becomes wrong by a factor of 1000. The `@experimental` +note on the telemetry option covers *additions* to the metric set, not +redefining a series already in use. If wRPC moves to seconds it will be +under new instrument names, at a major version, with both published for one +release. + +## A note on the gauges + +The seven UpDownCounter-backed gauges (`connections`, `subscriptions`, +`sse.channels`, `rooms`, `rtc.links`, `cluster.instances`, `queue.depth`) +are incremented and decremented at lifecycle edges rather than observed from +a registry. A missed decrement therefore leaks for the life of the process. +The edges are latched where that risk is real, but if you see a gauge that +never returns to zero on an idle server, that is the mechanism — report it +rather than working around it. ## Trace context -wrpc is a wire protocol, so the OpenTelemetry context manager alone cannot +wRPC is a wire protocol, so the OpenTelemetry context manager alone cannot link the two sides — the caller is in another process. `call`, `subscribe` and `event` packets therefore carry two optional fields: @@ -141,9 +251,12 @@ receiver to start a root span; a peer that does not understand them ignores them like any other unknown field. The context is per **packet**, so each call in a [batch](./client#batching) keeps its own parent. -wrpc does not parse or serialize the W3C format — it hands the field to your -propagator, so whichever one you configured globally (W3C, B3, Jaeger) is what -runs. The full wire description is in the +wRPC does not parse or serialize the W3C format — it hands the field to your +propagator. The two fields carry exactly the `traceparent` and `tracestate` +keys, so the propagator has to be the W3C one: a B3 or Jaeger propagator's +headers have other names and are not carried. On a broker message the same +context rides under the W3C header names themselves. The full wire +description is in the [protocol reference](../reference/protocol#trace-context). ### Untrusted peers @@ -166,7 +279,7 @@ Every trace then starts on your side. The client injects trace context the same way. Without a `ZoneContextManager` or `StackContextManager` from the OpenTelemetry web SDK, `context.active()` returns the root — so the injected traceparent names the -span wrpc just created. Still correct, just not linked to the surrounding page +span wRPC just created. Still correct, just not linked to the surrounding page interaction. Register a web context manager if you want that link. ## Failures diff --git a/docs/guide/testing.md b/docs/guide/testing.md index cb3dfd6..6843241 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -1,6 +1,6 @@ # Testing -wrpc's own suite runs on `node --test` with no framework, and the patterns +wRPC's own suite runs on `node --test` with no framework, and the patterns below are the ones it uses. They work the same in Vitest or Jest — the only wrpc-specific parts are how you boot a server on a free port and how you make sure it is closed. @@ -122,14 +122,14 @@ Delivery is deferred to a microtask exactly like a real broker's, so a test that asserts immediately after an `emit()` will see nothing — `await` a tick, or use `waitFor`. -Reach for a real Redis only when you are testing the **adapter**; wrpc's own +Reach for a real Redis only when you are testing the **adapter**; wRPC's own Redis suite is skip-guarded on `REDIS_URL` and is not part of `pnpm test` for 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: @@ -152,7 +152,7 @@ part of the behaviour, boot a server; it costs a millisecond. If your suite covers a [host adapter](./adapters/fastify), make the framework optional so a machine without it **skips** rather than fails — the pattern -wrpc's own adapter tests use for `uWebSockets.js`, whose native binary does not +wRPC's own adapter tests use for `uWebSockets.js`, whose native binary does not build everywhere: ```js @@ -168,7 +168,7 @@ const uws = optional('uWebSockets.js'); test('uws engine', { skip: uws ? false : 'uWebSockets.js not installed' }, async (t) => { /* … */ }); ``` -## Running wrpc's own tests +## Running wRPC's own tests ```bash pnpm test # everything, recursive diff --git a/docs/guide/typed-client.md b/docs/guide/typed-client.md index 18fc7e9..42433e2 100644 --- a/docs/guide/typed-client.md +++ b/docs/guide/typed-client.md @@ -1,6 +1,6 @@ # Typed client -wrpc has **no TypeScript at runtime**. The typed client is types only: you +wRPC has **no TypeScript at runtime**. The typed client is types only: you declare the api once as an ordinary interface and thread it through `connect()`. Nothing is generated, nothing is checked at runtime — what you buy is autocompletion and a compile error on a typo. @@ -74,7 +74,7 @@ await client.api.math.ping(undefined, { signal }); // ✅ await client.api.math.ping({ signal }); // ❌ compile error ``` -**A member with two parameters is rejected.** A wrpc procedure receives exactly +**A member with two parameters is rejected.** A wRPC procedure receives exactly one args object, so anything else maps to `InvalidContractMember`, whose text the compiler quotes back at the call site: diff --git a/docs/guide/webrtc-trust.md b/docs/guide/webrtc-trust.md new file mode 100644 index 0000000..ea01aec --- /dev/null +++ b/docs/guide/webrtc-trust.md @@ -0,0 +1,179 @@ +# WebRTC: identity and trust + +Two questions every peer-to-peer link raises, and how the +[WebRTC layer](./webrtc) answers them: + +- **Who is this peer?** An id the application chose and can address — a + user id, a device id — that stays the same when the signaling connection + behind it comes and goes. That is [Identity](./webrtc#identity), decided + by the signaling server's `identity` strategy and carried as a stable + peer id with an `instance` per incarnation. +- **Can I believe it?** With the default `trust: 'link'`, "there is a link" + means "the signaling server admitted both of us" — and nothing more. A + peer that wants proof, end to end and without trusting the relay in + between, turns on **trust assertions**: the server signs a token for every + peer, bound to the certificate that peer dials with, and every other peer + verifies it with the server's public key before the link is allowed. + That is this page. + +## The model + +```mermaid +sequenceDiagram + participant A as peer A + participant S as signaling server + participant B as peer B + A->>A: createOffer → SDP with a=fingerprint:FA + A->>S: assert({ fingerprint: FA }) + S-->>A: JWS { sub: A, fp: FA, exp, ...claims } + A->>S: signal { description: offer, assertion } + S->>B: signal { from: A, description, assertion } + B->>B: verify(assertion): sub == A, fp == fingerprint in the offer + B->>B: setRemoteDescription(offer) — only now + B->>S: assert({ fingerprint: FB }) … answer + assertion → A + A->>A: verify, then setRemoteDescription(answer) + Note over A,B: DTLS proves each side holds the certificate its token names +``` + +An assertion is a [JWS](../reference/protocol#webrtc-assertions) — compact +serialization, `ES256`, the one algorithm every WebCrypto ships — whose +payload binds a **peer id** (`sub`) to the **fingerprint of the DTLS +certificate** (`fp`) of the peer connection it was issued for, with an +expiry and whatever claims the server adds. The binding is what makes it +more than a signed name: the fingerprint travels in the SDP the peer sends +(`a=fingerprint:`), the verifier checks the token names *that* fingerprint +— every `a=fingerprint:` line of the description, since DTLS binds to the +media-level one and a relay could append its own under the honest line — +and the DTLS handshake proves the far end holds the certificate. A token +copied from one peer cannot be replayed from another connection — the +identity binding of RFC 8827, without the identity provider. + +Verification happens in `WrpcPeer`, **before** a description reaches the +`RtcLink` and before your `accept` hook runs. A description with no token, +a bad signature, another peer's `sub`, a fingerprint that is not the +description's, an expired `exp` or an unexpected `iss` ends the link with a +goodbye naming `refused` as its reason and a `rtc.peer.refused` log line +naming which. + +## Server side + +```js +const { createSignalingUnit, createSignalingHooks, generateAssertionKeys } = require('@alexify/wrpc/webrtc'); + +const keys = await generateAssertionKeys({ kid: '2026-09' }); // or load a stable pair from your secret store + +defineRouter( + { + ...createSignalingUnit({ + identity: (context) => context.session.data.userId, + assertions: { + key: keys.privateKey, // a private EC P-256 JWK, or a { privateKey, publicKey } CryptoKey pair + ttl: 300, // seconds a token is valid (the default) + issuer: 'signaling.example', // the `iss` claim, when peers should insist on it + claims: (context) => ({ role: context.session.data.role }), // signed into every token + }, + }), + }, + { hooks: createSignalingHooks() }, +); +``` + +With `assertions` configured the unit gains two methods. `assert({ +fingerprint })` answers `{ assertion, iat, exp }` for the calling +connection's peer id — it runs your `claims` hook, refuses a replaced +connection with `409`, and never signs a fingerprint that does not look +like one. `keys()` answers `{ keys: [JWK] }` and is **`access: 'public'`** +on purpose: a peer fetches the keys before it has a session, and they are +public keys. + +Rotation is a `kid`: sign with a new key while `keys()` publishes both, +and a verifier that meets an unknown `kid` asks for the keys once more — +at most once per `refreshInterval` (30 s), so a flood of tokens under +unknown kids is not a flood on the keys endpoint; a `keys()` that fails is +left alone for a second (every verify meanwhile meets the same failure), +and a refresh that fails keeps the set that was — before it refuses. +**Revocation** is the other half: a set is asked again once it is `maxAge` +old (ten minutes by default, `assertions: { maxAge }`), so a key the +endpoint stopped publishing stops verifying within that time — before, a +known `kid` was never asked about again. The `iat`/`exp` in the answer are also how peers learn +the server's clock — each peer measures the offset from its own tokens +and checks every `exp` against the server's time, so two peers with +drifting clocks agree on what "expired" means (a minute of skew is +tolerated on top). + +## Peer side + +```js +const peer = new WrpcPeer({ + router, + signaler: wrpcSignaler(client, { identity: 'alice' }), + assertions: { issuer: 'signaling.example' }, // keys come from signaler.keys() + host: { trust: 'assertion' }, + accept: (from, room, { instance, claims }) => claims?.role !== 'banned', +}); +``` + +`assertions` turns both halves on. Outbound, every description this peer +sends — the offer of a dial, the answer to one, the offer of a redial — +first fetches a token for the certificate it declares: one round trip to +the signaling server per dial. Candidates that gather meanwhile wait their +turn behind the description they belong to. Inbound, every description is +verified as above; once a link's certificate is pinned, later descriptions +on the same pc (ICE restarts) are a string compare, and a redial onto a +fresh pc — a new certificate — is verified anew. + +`keys` defaults to the signaler's `keys()`; a static `keys: [jwk]` serves a +hand-rolled signaler, or a deployment that ships the public key with the +page. `issuer` makes an `iss` mandatory. + +The verified claims are the link's: `link.claims` (`sub`, `iat`, `exp`, +`fp`, `iss`, and whatever the server added), the third argument of +`accept` for an offer (a knock carries no description, so its claims arrive +with the answer — before the host half attaches), and, with +**`host: { trust: 'assertion' }`**, the session your handlers run with: + +```js +context.session; // { token: '', data: { ...rosterData, peer, room, claims } } +context.session.data.claims; // { sub: 'alice', role: 'host', exp, fp, iss } +``` + +Under `trust: 'assertion'` a `PeerHost` refuses to attach a peer without +verified claims, and `peer`, `room` and the claims are written **over** the +roster data in `session.data` (a `join` naming one of them in its `data` is +refused), so nothing a peer said about itself can shadow who it is or what +the server signed. `trust: 'link'` with `assertions` on is allowed +too — verification still runs, the claims are simply optional in the +session — and a peer **without** `assertions` ignores the tokens attached +to what it receives. Turn it on for everyone in a room or for nobody: a +peer with assertions refuses a description that carries none. + +## Your own signaler + +A [hand-rolled signaler](./webrtc#your-own) takes part by adding two +methods, checked structurally by `hasAssertions`: + +```ts +interface AssertingSignaler extends Signaler { + assert({ fingerprint }): Promise<{ assertion, iat?, exp? }>; // a token for one of MY certificates + keys?(): Promise>; // the server's public keys +} +``` + +Whatever issues the tokens — the wRPC unit, or any service that can sign a +JWS — must produce the [format](../reference/protocol#webrtc-assertions) +the verifier expects; `createAssertionIssuer` and `createAssertionVerifier` +are exported for both sides, and work in Node and in a page alike. + +## What this is not + +- **Not a session.** The claims are the signaling server's word about the + peer at issue time; a peer that is banned an hour later still holds its + open links. Revocation is the signaling layer's: drop the connection, + and the roster says so. +- **Not encryption of the relay.** Signaling still travels through the + server in the clear (over whatever TLS the transport has). Assertions + make a lying relay *detectable*; they do not hide anything from it. +- **Not a knock's proof.** A `connect` knock carries no description. The + initiator dials and asks the server for a token before anything is + proven — bounded to the peers the server admitted, which is the same + population `trust: 'link'` trusts outright. diff --git a/docs/guide/webrtc.md b/docs/guide/webrtc.md new file mode 100644 index 0000000..3ae7093 --- /dev/null +++ b/docs/guide/webrtc.md @@ -0,0 +1,617 @@ +# WebRTC + +Two browsers can talk wRPC to each other directly, with no server in the +data path. `@alexify/wrpc/webrtc` puts a full wRPC endpoint on each end of a +WebRTC data channel: every peer serves a router and calls the other's — calls, +events, ask/respond, subscriptions with resume, binary streams with +backpressure, heartbeat and reconnect — over the same packets a WebSocket +carries. A wRPC server is still involved once, for **signaling**: peers find +each other and exchange connection descriptions through it, and it can be +any wRPC server, on any transport. + +## Using it + +On the server, spread the built-in signaling unit into your router and add +its disconnect hook. Any wRPC server will do — the built-in `Server`, a +fastify or express host, a cluster of them: + +```js +const { Server, defineRouter } = require('@alexify/wrpc'); +const { createSignalingUnit, createSignalingHooks } = require('@alexify/wrpc/webrtc'); + +const router = defineRouter( + { ...createSignalingUnit(), ...myUnits }, + { hooks: createSignalingHooks() }, +); +new Server({ router, port: 8000 }).listen(); +``` + +In the browser, a peer is a router, a signaler over an ordinary client, and +the native `RTCPeerConnection`: + +```js +import { connect } from '@alexify/wrpc'; +import { WrpcPeer, wrpcSignaler, defineRouter, procedure } from '@alexify/wrpc/webrtc'; + +const router = defineRouter({ + chat: { + hello: procedure({ handler: async (context) => `hi, ${context.session.data.peer}` }), + }, +}); + +const client = await connect('wss://host/api'); +const peer = new WrpcPeer({ router, signaler: wrpcSignaler(client) }); + +const mesh = peer.join('lobby', { data: { name: 'ada' } }); +mesh.on('join', async ({ id, data }) => { + const link = mesh.link(id); + await link.load('chat'); + console.log(data.name, 'says', await link.api.chat.hello()); +}); +``` + +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 budget](./browser#bundle-size). + +### Node as a peer + +The core binds to **no** Node WebRTC package. It talks to a small +W3C-shaped structural contract (`RtcAdapter`: `createPeerConnection()` +returning something with `createDataChannel`, `createOffer`, +`setLocalDescription`, ...), which a browser satisfies natively and which +you satisfy in Node by injection. Anything W3C-shaped is one line: + +```js +const { createW3cAdapter, WrpcPeer } = require('@alexify/wrpc/webrtc'); +const peer = new WrpcPeer({ + router, + signaler: wrpcSignaler(client), + rtc: createW3cAdapter(require('node-datachannel/polyfill')), +}); +``` + +A library with its own event API (werift, say) needs a wrapper that presents +the contract — `isRtcAdapter`, `isRtcPeerConnection` and `isRtcDataChannel` +are exported so a wrapper can check itself. One rule a wrapper has to keep +that a structural check cannot see: `send(data)` **copies before it +returns**, and copies the view it was given — `Buffer.from(view)`, never +`Buffer.from(view.buffer)`. wRPC frames every fragment of a message into +one reused buffer; a `send` that keeps the view, or takes the whole +`ArrayBuffer` under it, delivers the last fragment in place of all of them. The repo runs the shared port +contract against node-datachannel by hand +(`WRPC_RTC=node-datachannel node --test tests/webrtc/node-datachannel.integration.test.js`); +it is a devDependency there and nowhere else. + +## How a link works + +One `RTCPeerConnection` per pair of peers, carrying **two negotiated data +channels** — one per client→host direction. That is what lets the ordinary +`WrpcClient` and the ordinary server dispatcher speak across the link +unchanged: each channel is a plain ordered wire with one client on one end +and one host on the other, so there is never any doubt whose callback an id +belongs to, and the "stream packet, then its chunks" ordering holds. + +```mermaid +sequenceDiagram + autonumber + participant A as peer A (lower id) + participant S as signaling server + participant B as peer B + A->>S: signaling/signal { to: B, description: offer } + S-->>B: signaling/signal { from: A, description: offer } + B->>S: signaling/signal { to: A, description: answer } + S-->>A: answer + A-->>B: ICE candidates (trickled the same way) + Note over A,B: one RTCPeerConnection, two negotiated channels + A->>B: channel 0 — A.WrpcClient → B.PeerHost + B->>A: channel 1 — B.WrpcClient → A.PeerHost +``` + +**Roles are decided by id order alone.** The peer with the lower id is the +*initiator*: it makes the offer, it restarts ICE, it redials after a failure. +The other is the polite responder of the +[perfect negotiation](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Perfect_negotiation) +pattern. `peer.connect(id)` works from either side — a responder that wants a +link sends the initiator a `connect` knock over signaling and the initiator +dials — so two peers can never glare on simultaneous offers, and a responder +recovering from a failed link asks the same way. + +Negotiated channels are never described in SDP, so **both peers must +configure the same channel ids**. The defaults are `0` and `1` with the +label `wrpc`; an application that keeps its own channels on the same +connection moves them with `channels: { initiator, responder, label }` — on +both sides, or the link never opens and fails on `connectTimeout`. + +### Framing + +A data channel has a message-size limit (16 KiB is the only value every +implementation agrees on; the negotiated `sctp.maxMessageSize` is used up to +a 256 KiB ceiling), and wRPC packets — a batch of calls, a 64 KiB stream +chunk — exceed it. Every message therefore travels as binary behind a +**one-byte header**: a kind bit (text packet or binary chunk), a FIN bit, a +deflate bit ([compression](#compression), off unless both ends turned it +on), five reserved bits. Fragments of one message are sent back to back on +the ordered channel, so no message id is needed. The +[protocol reference](../reference/protocol#webrtc) has the exact layout. + +## Symmetric peers + +A `PeerLink` is one connected peer, **both directions**: + +| Member | Which direction | What it is | +| --- | --- | --- | +| `link.remote` | this peer → the other | A `WrpcClient` over channel *mine*: `load()`, `api`, `call()`, `respond()` — everything a client does against a server. | +| `link.client` | the other → this peer | The server-side `Client` this router made for the peer: `send()`, `ask()`, `createStream()`, rooms — everything a server does to a client. | + +`link.api`, `link.load()`, `link.call()`, `link.respond()` delegate to +`remote`; `link.send()`, `link.ask()`, `link.createStream()` address the peer +through `client`. So "call the other peer" is `link.api.chat.hello()`, and +"push the other peer a file" is `link.createStream(name, size)`. + +Events keep the same two directions they have between a client and a +server. `link.remote.sendEvent('chat/ping', data)` reaches the other peer's +router — its unit's reserved `on` map. `link.send('chat/note', data)` (and +`mesh.broadcast`) goes host → remote and arrives on the other peer's +**unit emitter**, `link.api.chat.on('note', handler)` after `load()`, exactly +where a server's events reach a client. A peer that wants to *receive* +broadcasts therefore listens on each link's `api`, not in its router. + +Handlers run on a `PeerHost`: a router, the dispatcher and one `Client` per +attached peer — the pieces of `RpcServer` a peer needs, with no sessions, +cluster or HTTP, and browser-safe. It satisfies the same `ClientHost` +contract a handler sees as `context.server`, so `context.server.to(room)` +works on a peer exactly as on a server. + +### Trust + +wRPC procedures default to `access: 'session'`, and there is no session +manager in a browser. Under the default `trust: 'link'`, every attached +`Client` gets a frozen pseudo-session: + +```js +context.session; // { token: '', data: { ...rosterData, peer, room } } +context.session.data.peer; // who is calling — the link's id, never what the roster data said +``` + +`peer`, `room` (and `claims`, below) are written over the roster data, and +a `join` whose `data` names one of them is refused (400): what a peer says +about itself at `join` cannot shadow who it is. + +The reasoning: a link only exists because the signaling server admitted +both peers (its unit is `access: 'session'` by default, and it runs your +`authorize` hook), and because this peer's `accept` hook let the link in. +So "there is a link" already means "the server vouched for this peer", and +server routers move to a peer unchanged. `startSession` and friends are +refused with a coded `400` on a peer. `trust: 'none'` leaves `session` null, +in which case peer procedures must be `access: 'public'` and authorize +themselves in hooks from `context.meta.data.peer`. + +A peer that wants *proof* rather than the relay's word turns on +**trust assertions**: the signaling server signs a token per peer, bound to +the certificate that peer dials with, every other peer verifies it with the +server's public key before the link is allowed, and `trust: 'assertion'` +makes the verified claims the session (`context.session.data.claims`). The +model, the server and peer options and the wire format are on their own +page: [WebRTC: identity and trust](./webrtc-trust). + +## Signaling + +### The built-in unit + +`createSignalingUnit()` is a router fragment: `whoami`, `join`, `leave`, +`members`, and the inbound `signal` event, relayed with `RpcServer.sendTo`. +Every roster member, `join` and signal carries the peer's `id`, its +`instance` and its routable `address` (the signaling client id), so a relay +never resolves a peer id on the hot path; `leave` carries a `reason` +(`'left'`, `'disconnect'` or `'replaced'`). `members` answers only a +member of the room (403 otherwise), and a `join` whose `data` carries +`peer`, `room` or `claims` is refused (400) — those names are written by +the host half, not by the peer. + +| Option | Default | Meaning | +| --- | --- | --- | +| `name` | `'signaling'` | The unit name; the client helper must agree. | +| `access` | `'session'` | Applied to every method and the event. | +| `identity` | the client id | `(context, { proposed }) => id`: the peer id of a connection — see [Identity](#identity). | +| `duplicate` | `'replace'` | A second connection under a held id takes it over (the first hears `replaced`); `'refuse'` answers it `409`. | +| `authorize` | — | `(context, { action: 'join' \| 'signal', room, ... })`: return `false` to refuse with `403`, or throw a coded error of your own. | +| `limits` | `{ maxRooms: 32, maxDataBytes: 4096, maxSignalBytes: 65536, maxResolves: 4 }` | Per-connection ceilings: rooms joined at once (`429` past it), the JSON bytes of a join's `data` and of a relayed signal (`413`), and the room lookups in flight for signals to peers this instance does not hold (`503`; one lookup per room is shared by every signal waiting on it). `false` switches one off. | +| `relay` | `'room'` | A signal reaches `to` only while both peers share the room; `'any'` relays to any connected id. | +| `prefix` | `'rtc:'` | Signaling rooms live under it in the room registry, apart from your own rooms. | + +`createSignalingHooks()` returns the router-level `onDisconnect` that +announces a dropped signaling connection's leave (`reason: 'disconnect'`) to +the rooms it was in — unless a newer connection took its id meanwhile, in +which case the peer is still there and nothing is announced. + +### Identity + +By default a peer's id **is** its signaling client id: server-issued, so +nobody can claim another's, and instance-prefixed, so the relay is one +addressed cluster command. It changes on every reconnect, though, and it +says nothing about *who* the peer is. The `identity` strategy makes it the +application's: + +```js +createSignalingUnit({ + identity: (context, { proposed }) => context.session.data.userId, +}); +``` + +The hook runs once per connection, on `whoami` (or lazily on the first +`join`/`signal`), with the id the client proposed as one input: + +```js +const signaler = wrpcSignaler(client, { identity: 'alice' }); // or () => Promise +await peer.start(); // 'alice', if the server agrees +await peer.connect('bob'); +``` + +The default strategy ignores the proposal; yours may adopt it, map it, or +throw a coded error. Two things follow from a stable id: + +- **Links survive a signaling reconnect.** The signaler comes back, is the + same id again (`reset` with `id === previous`) and re-joins its rooms; the + `WrpcPeer` keeps every link, and a `Mesh` on the other side only marked + the member `away` for the interval — its link never needed signaling to + keep working. A `reset` under a *changed* id still closes them all. +- **Incarnations.** One id may come from two endpoints — a second tab, or a + tab reloaded before the server noticed the first socket die. Each + `wrpcSignaler` carries one `instance` (from its `generateId` option, a + uuid by default), stamped on everything the server relays about it. A + signal or roster entry for a known id under **another** instance means a + new endpoint: the stale link is abandoned without a goodbye (one would + land on the newcomer) and the peer relinks. Within one server instance + `duplicate` decides the clash at identification time: `'replace'` hands + the id to the newer connection and tells the older one `replaced` — its + `WrpcPeer` abandons its links, emits `'replaced'` and closes — while + `'refuse'` answers the newcomer `409`. Cluster-wide uniqueness is the + strategy's business (claim the id in your store and throw `409` on a + loss); rosters collapse a duplicate to the newest. + +`context.client.data.rtc` holds the connection's `{ id, instance, since, +rooms }`, which is what travels in cluster descriptors and builds the +roster on any node. + +### Your own + +`WrpcPeer` takes anything with the shape: + +```ts +interface Signaler { + readonly id: string | null; + readonly instance?: string | null; // this incarnation of the id (optional) + ready(): Promise; // this peer's id + send(to, message, { room?, address? }): void | Promise; + on('signal', ({ from, instance?, address?, room, message }) => void); // an inbound one + off(event, handler); +} +interface RosterSignaler extends Signaler { // what Mesh needs on top + join(room, data?): Promise>; // the other members + leave(room): Promise; + on('join' | 'leave' | 'reset' | 'replaced', handler); // leave: { id, reason? } +} +``` + +`instance`, `address` and `reason` are optional: a signaler that carries +none of them still works, and a peer then never mistakes a reconnect for a +new incarnation (or a dropped connection for a departure). + +A `message` is `{ type: 'description', description }`, `{ type: 'candidate', +candidate }`, `{ type: 'close', reason? }` or `{ type: 'connect' }` — opaque to the +signaler, which only moves it. `isSignaler` / `hasRoster` check the shape. +A hand-rolled one over socket.io, a hosted signaling service, or a +`MessagePort` between two tabs of one browser all qualify. + +## Your own connection + +Everything above sits on three levels, and you can enter at any of them: + +| Level | wRPC owns | You own | +| --- | --- | --- | +| `WrpcPeer` / `Mesh` | The peer connection, negotiation, ICE restart, redial, both directions, the roster | A router and a signaler | +| `RtcLink` | One `RTCPeerConnection`, perfect negotiation, ICE restart, two channels | Signaling and the decision to redial | +| **A data channel** | The wire: framing, packets, streams, heartbeat | The peer connection, signaling, recovery | + +The lowest level is the `event` transport's arrangement: you already have an +`RTCDataChannel` — negotiated by your own signaling, perhaps next to your +game's or media's channels on the same connection — and wRPC speaks on it. +On the client, `channel` takes the place of `link`: + +```js +import { connect } from '@alexify/wrpc'; +import '@alexify/wrpc/webrtc'; // registers the transport + +const pc = new RTCPeerConnection(config); +const dc = pc.createDataChannel('wrpc', { negotiated: true, id: 0 }); +// ... your offer/answer exchange ... +const client = await connect('webrtc:server', { transport: 'webrtc', channel: dc, reconnect: false }); +``` + +The other end is whoever holds the pair of that channel. A Node process +attaches it to an ordinary `RpcServer` — sessions, rooms, cluster and all — +the way it attaches a `MessagePort`: + +```js +import { attachChannel } from '@alexify/wrpc/webrtc'; +attachChannel(rpc, dc, { peer: 'browser-7', headers, data }); // the attachPort of WebRTC +``` + +A browser answers with a `PeerHost` over the host half of the transport: + +```js +import { PeerHost, RtcPeerTransport } from '@alexify/wrpc/webrtc'; +host.attach(new RtcPeerTransport(dc, { peer: 'other' }), { peer: 'other' }); +``` + +What the level does **not** do is what a link would: no ICE restart, no +redial. A static channel is one connection — when it closes, the client's +`open()` refuses it, so pass `reconnect: false` and rebuild the client when +you have a new one. To plug your own recovery into the client's reconnect +cycle instead, hand over a **factory**: every (re)open asks it for the next +channel, and subscriptions resume with their `lastEventId` as on any other +transport. + +```js +const client = await connect('webrtc:server', { + transport: 'webrtc', + channel: async () => { + const pc = await renegotiate(); // your signaling, again + return pc.createDataChannel('wrpc', { negotiated: true, id: 0 }); + }, +}); +``` + +Two more differences from the link level. The transport cannot see the peer +connection, so it fragments at the 16 KiB interop floor unless told +otherwise — pass `maxMessageSize: negotiateMessageSize(pc.sctp)` (on the +client through `connect()`'s options, on the server through +`attachChannel()`'s) once the connection is up to use what it really allows; +each side fragments independently, so the two need not agree. And a raw +channel carries no request, so `attachChannel` starts the client +with no session, exactly as `attachPort` does: the default `access: +'session'` answers 403 until the application establishes one, and what it +knows about the peer goes in `headers` / `data`, where handlers read it from +`context.meta`. (A `PeerHost` keeps its `trust: 'link'` pseudo-session either +way.) + +`RpcServer.attach(transport)` is the seam under `attachChannel`: any +persistent transport that announces inbound text as `'packet'` and bytes as +`'chunk'` events is a client, WebRTC or not. That is also why `attachChannel` +lives in this subpath and not on the server — the core knows no framing; +the function builds an `RtcPeerTransport` over the channel and hands it to +`attach`. + +## Mesh + +`peer.join(room)` links this peer with everyone in a signaling room, as +they come and go: + +```js +const mesh = peer.join('lobby', { data: { name: 'ada' } }); +mesh.on('join', ({ id, data }) => {}); // once the link is open +mesh.on('leave', ({ id }) => {}); + +mesh.broadcast('chat/note', { text }); // one event to every open member +const { answers, errors } = await mesh.ask('poll', { q }, { timeout: 2000 }); +mesh.respond('poll', async (data) => vote(data)); // members present and future +await mesh.leave(); +``` + +Every member link's host-side `Client` is kept in the room `mesh:` +on this peer's `PeerHost`, which is what makes `broadcast()` and `ask()` one +`Broadcast` fan-out — the payload is serialized, UTF-8-encoded and (under +compression) deflated **once** for every member, and only fragmented per +link, to that link's message size: 2 µs a recipient for a 16 KB compressed +event to 32 peers, where a deflate per link was 38 (`bench/rtc-fanout.js`) — +and `ask` aggregates +`{ answers, errors, expected, incomplete }` — rather than a loop over links. +A link shared by two meshes (the same two peers in two rooms) survives +leaving one of them. + +**An edge that is lost is made again.** A mesh remembers who the room holds, +and a member's link that ends by itself — its redial budget spent, a path +that never came back, a refusal — is dialled again while that member is +still in the room. A link an application ended is not: a `link.close()` on +either side is a goodbye, and neither end dials it again (the side that +heard the goodbye used to, which undid the close within half a second). The +goodbye travels through the signaling server and the channels close peer to +peer, so the other side may see its link fail first and start a redial: for +5 s the side that said goodbye ignores a knock or an offer from that +incarnation of the peer, until the goodbye has ended that redial — +`peer.connect(id)` from its own side is the way to dial it again sooner. The +dial comes after a second, backing off to a minute, then once a minute for as long as +it stays. There is no attempt count; a member that cannot be reached, or +refuses, costs one dial a minute. When the pace reaches that floor the mesh +says so once — `mesh.on('unreachable', ({ id, attempts }) => {})`, and +`mesh.unreachable` in the log — and `'join'` fires again when the edge is +back. `peer.join(room, { relink: { minDelay, maxDelay, jitter } })` sets the +pace, `relink: false` leaves a lost edge lost. Nothing is dialled for a +member that left the room, or one whose signaling had dropped (`away`) and +whose link then went too. + +## Failure and recovery + +Three layers, each owning one kind of failure: + +- **ICE**: when a network changes underfoot, the link restarts ICE (only the + initiator does, so the two sides never race) and the channels survive it. + A restart that does not reconnect within `restartTimeout` fails the link. +- **The link**: on `failed` the initiator redials with backoff and the + responder knocks with backoff — re-arming only once the initiator's + connect window has passed, so its `redial.retries` (5) are spent on real + attempts — after which the initiator's `PeerLink` closes with a goodbye + and a responder's gives up quietly (a goodbye from it would end the link + an initiator on a longer backoff was about to rebuild). A knock arriving + at an initiator whose link looks connected is the responder saying its + half is gone — a failure only one side saw — and rebuilds the link; so + does an offer under a new certificate arriving at a responder that never + noticed: a fresh `RTCPeerConnection` on one side is a fresh one on both. A redial is a fresh `RTCPeerConnection`: the host-side + `Client` is recreated, and the client-side `WrpcClient` runs its ordinary + reconnect cycle, whose `open()` simply waits for the link — so it + re-`load()`s its units and re-subscribes with `lastEventId` exactly as it + would on a WebSocket. +- **The heartbeat**: a path can die silently, with ICE none the wiser for a + while. The app-level ping/pong is on by default; a heartbeat timeout asks + the link for an ICE restart, and from there the layers above take over. + +A link that has given up is over — `connect()` makes a new one. In a +[mesh](#mesh) that is done for you: an edge to a member still in the room +is dialled again. + +`link.close()` (or `peer.close()`) is a goodbye: the other side is told, both +directions end, nobody redials. A goodbye says why — `{ type: 'close', +reason }` on the wire: `goodbye`, `refused` (a failed assertion, a `false` +from `accept()`) or `gave-up` — and both `PeerLink`s emit `'close'` with a +`{ reason, remote }` closure, kept on `link.closure`: `remote` says whether +the peer closed or this side did, and `reason` is the goodbye's, `abandoned` +for a close this side never sent (a stale incarnation, a responder giving +up), or `unknown` for one a newer peer named. `wrpc.rtc.closes` counts them +by the same two labels. + +## Options + +```js +new WrpcPeer({ + router, // what others can call; null makes a client-only peer + signaler, // required + rtc: createW3cAdapter(), // the RTC implementation (this is the default) + iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], // or a full `configuration` + channels: { initiator: 0, responder: 1, label: 'wrpc' }, // the same on both peers + client: { heartbeat: { interval: 30_000, timeout: 10_000 }, codec }, // every link's WrpcClient + host: { trust: 'link', maxCalls: 1000, highWaterMark: 1 << 20 }, // the PeerHost (its defaults), plus water marks + framing: { maxReassembly: 16 << 20, maxFragments: 16384 }, // per message: bytes, and fragments + compression: false, // per-message deflate on every link — see below + connectTimeout: 30_000, + restartTimeout: 15_000, + redial: { retries: 5, minDelay: 500, maxDelay: 10_000 }, + accept: async (from, room, { instance, claims }) => allowed(from), // gates incoming links + assertions: { issuer: 'signaling.example' }, // verify and present server-signed tokens; see identity and trust + telemetry: { api: otel }, // the peer's server half and its links; see below +}); +``` + +The link's `write()` answers `false` above `highWaterMark` (1 MiB of +`bufferedAmount`, the bytes a compression codec is still working on +included) and `drain` fires at `lowWaterMark` (256 KiB), so a stream +producer on either end sees the same backpressure it does on a socket — and +every `false` is followed by exactly one `drain`, whether the bytes waited +on the channel or in the codec. Both marks are positive integers, the low +one no higher than the high one (a `TypeError` otherwise); a `highWaterMark` +set below the default low mark pulls the low mark down with it. Behind the +marks sits `maxBackpressure` (64 MiB, `0` to switch it off): a write onto a +channel already past it — its buffer and the codec's pending bytes, as +WebTransport and the WebSocket engine count it, so one message larger than +the cap still goes on an empty channel — is refused and the channel closed, +locally, so a peer that never drains cannot hold the process's memory. Over a link that +is a redial; over a raw channel it is the end of the channel. A `send()` the +channel itself refuses is never thrown into your code: refused mid-message +it closes the channel too (the peer holds a message with no end, and nothing +after it would parse — code `desync` on the error), refused before the +first fragment it loses that one message and says so (code `send`). + +### Compression {#compression} + +Nothing compresses a data channel's payload for you — SCTP over DTLS +carries the bytes as they are (TLS 1.3 dropped compression) — so a link +carries exactly what wRPC hands it. Per-message compression is the answer, +**off by default** like every compression knob in wRPC: + +```js +new WrpcPeer({ router, signaler, compression: true }); +``` + +Every description this peer sends then names its codecs (`caps: { enc: +['deflate-raw'] }` in the signal, next to the assertion when there is one), +and a link compresses only once the two [lists](./compression#list) share a +codec, each direction with its sender's first choice — a peer without the +option is served plain, and nothing hangs up. A packet or chunk +at or over the threshold is compressed **before** fragmentation, the one +place it exists whole, and every fragment carries the compressed bit; +`{ compress: false }` on an emit sends that one plain. The threshold is the +codec's own default — 1 KiB on Node, 4 KiB in a browser, where the only +codec a page has is `CompressionStream`, ~6× the cost of zlib per call and +without a dictionary — and `{ codec, threshold }` injects another codec or +moves the line; the numbers are on the [WebTransport page](./wt#compression), +which shares the seam (`bench/message-compression.js`). On Node the +deflate is synchronous below 256 KB on purpose and `{ async }` hands +larger messages to zlib's threadpool — a host answering megabyte results +to many peers is the case; see [the compression guide](./compression#async). +Inbound, no more than four inflates run at once; the rest of a burst start +in their turn, in order. + +Over a [raw channel](#your-own-connection) there is no description to +announce in: `compression` on `attachChannel`, on `connect(url, { channel, +compression })` and on either transport is applied as given, so **both +applications turn it on, or neither** — a plain peer closes the channel on +the first flagged frame, exactly as it does on any reserved header bit. + +## Telemetry + +A peer is a server too, so it takes the same [telemetry](./telemetry) +injection a server does and emits the same things: a SERVER span for every +call, subscription and inbound event it answers (under `wrpc.transport: +'webrtc'`), and `wrpc.server.connections` for its links. The calling peer's +client half takes its own through `client.telemetry`, and with a propagator +injected on both the client span parents the server span across the +link — one trace end to end, exactly as between a client and a server. + +```js +new WrpcPeer({ + router, + signaler, + telemetry: { api: otel }, // the host half: spans, gauges, the rtc instruments + client: { telemetry: { api: otel } }, // each link's client half: CLIENT spans, reconnects +}); +``` + +Four instruments are the peer layer's own: `wrpc.rtc.links` (open links, +by `wrpc.rtc.role`), `wrpc.rtc.redials` (redials and knocks after a failure, +by role), `wrpc.rtc.ice_restarts` (by `wrpc.rtc.outcome`: `requested`, +`recovered`, `failed`) and `wrpc.rtc.closes` (by `wrpc.rtc.reason` — +`goodbye`, `refused`, `gave-up`, `abandoned`, `unknown` — and +`wrpc.rtc.side`: `local` / `remote`). The rate of redials and restarts is +what an operator alerts on; `refused` closes are the peers a trust policy +turned away, `gave-up` ones the paths that never came back. A client-only +peer (no router) still counts its links. + +## What it costs + +Measured end to end on loopback ([Across +transports](./performance#across-transports) has the tables and the +commands): + +- **wRPC adds little to the channel.** Over the same node-datachannel pair, + wRPC's calls run at 95–100% of a raw echo with no RPC layer, and its + streams at 90% of the bare channel. Opening a pair took 506 ms in + node-datachannel — its own ICE and DTLS; wRPC's share was under a + millisecond — and about 5 ms in Chrome. +- **The channel is not a WebSocket.** From Chrome, with both peers in one + page, a data channel carried about 5,000 small calls a second and 14 MiB/s + of stream; in Node, between a third (small calls) and a sixteenth + (streams) of what the WebSocket carries. A + peer-to-peer link is worth it for what it removes — the server from the + data path — not for throughput. + +## What it cannot do + +- **Receive-side backpressure.** A data channel has no `pause()`: a fast + peer streaming a large file to a slow consumer accumulates in the + receiving `WrpcReadable`. Size uploads accordingly, or ask before sending. +- **Real sessions.** A peer has no session store. `trust: 'link'` trusts + the relay's word, [trust assertions](./webrtc-trust) verify the server's + signed word about a peer at issue time — neither is revocable while a + link is open, other than by closing 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 + +A peer is a client **and** a server, so the webrtc browser entry is heavier +than the main one: the client core plus the router, dispatcher, per-peer +`Client`, rooms and `Broadcast`, the link, framing, peer, mesh and signaler +halves, the server telemetry writer, and the assertion verifier over +WebCrypto — see [the measured table](./browser#bundle-size) for what that costs against its budget in `pnpm size`. You +pay it only when you import the subpath; the main entry is untouched. diff --git a/docs/guide/why.md b/docs/guide/why.md index 00f38b3..a16cab9 100644 --- a/docs/guide/why.md +++ b/docs/guide/why.md @@ -1,59 +1,213 @@ -# Why wrpc? +# Why wRPC? -wrpc is a WebSocket-based RPC protocol with **no runtime dependencies at all** — -`package.json` has no `dependencies` field, and no `peerDependencies` or -`optionalDependencies` either, because those are dependencies with extra steps. +wRPC — Web RPC — is one router of procedures, served over every transport the +web platform has: a WebSocket, plain HTTP and REST, Server-Sent Events, +WebTransport over HTTP/3, and WebRTC data channels between browsers — and, +between services, Redis, NATS, RabbitMQ or Kafka. And it has **no runtime +dependencies at all**: `package.json` has no `dependencies` field, and no +`peerDependencies` or `optionalDependencies` either, because those are +dependencies with extra steps. That constraint is the design, not a boast. It is what forces every -integration — uWebSockets.js, Redis, Fastify, Express, TanStack Query, -OpenTelemetry, Pino — to be **injected** and duck-typed at the boundary rather -than imported. The integrations you do not use cost you nothing, and the ones -you do use run on your version, not ours. +integration — uWebSockets.js, Redis, Kafka, a WebRTC or HTTP/3 stack, +Fastify, Express, TanStack Query, OpenTelemetry, Pino — to be **injected** and +duck-typed at the boundary rather than imported. The integrations you do not +use cost you nothing, and the ones you do use run on your version, not ours. -## What you get on one connection +## One router, every transport + +A procedure is written once. Which wire a call arrived on is the client's +choice, not the handler's: + +```mermaid +flowchart LR + page["a page"] -- "WebSocket · HTTP · SSE" --> R + edge["a page with HTTP/3"] -- "WebTransport" --> R + curl["curl · a partner"] -- "REST" --> R + peer["another browser"] -- "WebRTC data channel" --> P["a peer's router"] + svc["another service"] -- "Redis · NATS · RabbitMQ · Kafka" --> R + R["one Router
procedures · rooms · sessions"] +``` + +The same `chat/send` answers a browser on a WebSocket, a page behind a proxy +that only speaks HTTP, a phone on HTTP/3, and a billing service on the other +end of a Kafka topic — with the same hooks, validators, access rules, rooms +and sessions in front of it. A client names a fallback list +(`transport: ['wt', 'ws']`), an app talks to [several +backends](./multiple-backends) at once, and two browsers can serve routers +to each other with no server in the data path. [Client › +Transports](./client#transports) is the matrix of what each one carries. + +## What you get, on whichever transport Most stacks make you choose a lane: an RPC layer *or* a realtime layer, typed -calls *or* binary transfer. wrpc puts them on one socket and one protocol: +calls *or* binary transfer, browser *or* service-to-service. wRPC puts them +behind one router and one protocol: | | | | --- | --- | | [Calls](./router) | Router of procedures, access levels, Standard Schema validators, timeouts, queues. | | [Events & rooms](./rooms) | Fire-and-forget in both directions, plus acks when you need an answer. | -| [Subscriptions](./subscriptions) | Async-generator feeds with real backpressure and **resume after reconnect**. | -| [Binary streams](./streams) | Upload and download interleaved with everything else, backpressure into TCP. | +| [Subscriptions](./subscriptions) | Async-generator feeds with real backpressure and **resume after reconnect** — on any instance, over a [broker feed](./brokers/feeds). | +| [Bytes](./streams) | Binary streams interleaved with everything else, backpressure into TCP; a `Uint8Array` inside a call travels as bytes. | | [Scaling](./scaling) & [cluster](./cluster) | Rooms across instances over any pub/sub; presence, introspection, commands. | | [REST](./rest) | Declare `http: { method, path, status }` on a procedure: real endpoints, `/vN` version paths, fastify schemas, OpenAPI via the CLI. | +| [Transports](./client#transports) | WebSocket, HTTP, SSE, a worker port — and [WebTransport](./wt), [WebRTC](./webrtc), a [message broker](./brokers/rpc). | +| [Message brokers](./brokers) | Durable feeds, queue consumers with retry and dead letters, RPC between services — Redis, NATS, RabbitMQ, Kafka, every client injected. | | [Hooks](./hooks) | Named lifecycle phases at router, unit and procedure level — no `(ctx, next)` middleware. | | [Sessions & auth](./sessions) | Cookie or bearer, pluggable stores and carriers, restored on reconnect; `authenticate`/`refresh` client hooks. | +| [Compression](./compression) | Off by default; deflate, Brotli or zstd per wire, negotiated as a preference list; a preset dictionary from the router. | +| [Encryption](./encryption) | Where TLS ends before the data does: sealed backplane and broker messages, a sealed session store, Noise sessions, HPKE per request, end-to-end helpers. | | [Types](./typed-client) | A contract you declare or [generate](./cli) — no build step, no TypeScript at runtime; typed server→client events. | +| [Protocol](../reference/protocol) | A versioned reference, revision 2, negotiated with a 1.0 peer per connection. | + +## Fast where it counts + +wRPC is not the fastest way to echo one message over a bare socket — +nothing with a router, an access check and a correlation ID between the +wire and your handler can be. It's fast at the things that are actually +hard to make fast: + +- **Broadcasting.** A room's update is serialized, framed and compressed + **once per emit**, not once per member, so throughput climbs with the + room instead of collapsing under it — compressed fan-out that used to + cost one `deflateRaw` per recipient now costs one per emit, a ~34× + difference on the same room. +- **Payloads that aren't toy-sized.** Bare-socket benchmarks favor tiny + messages; at 10 KB, wRPC is at or ahead of every raw WebSocket library + measured against it, because the send path never re-encodes or + re-copies what it already built. +- **A call that does not wait behind an upload.** Over WebTransport every + binary stream has a QUIC stream of its own: from Chrome, a small call made + while uploads saturate the connection answers in 9 ms at the median, against + 46 ms over a WebSocket. And on every transport the stack, not wRPC, sets the + ceiling — over WebTransport and WebRTC, wRPC's calls run at 85–100% of a raw + echo over the same stack. +- **Failure.** An instance can die with clients on it and, with a shared + session store and any pub/sub as a backplane, those clients reconnect, + re-authenticate and rejoin their rooms inside a single browser repaint + — no sticky load balancer, no manual failover choreography. + +None of that needed a flag. The knobs that trade memory or latency for +more — context takeover, async compression, client-side batching, the uws +engine — are opt-in on purpose: the right default for a chat app is not +the right default for a market-data feed, and a library that picks one +universal "fast" setting is guessing on your behalf. See +[Performance](./performance) for the full picture — the numbers, what they +mean in practice, [each transport](./performance#across-transports) measured, +and which knob to reach for and when. + +## Secure in layers + +TLS comes first and is not wRPC's to replace: `protocol: 'https'`, or a proxy +that terminates it. What wRPC adds sits on either side of it: + +- **Defaults that refuse.** Prototype-pollution defences on every + peer-controlled object, caps on what one connection may hold open, a deny + list for headers a peer may not declare, 5xx messages masked — + [Security](./security) is the checklist. +- **Encryption where TLS ends before the data does** — a backplane, a broker's + log, a session store, a proxy you do not run: sealed envelopes under a + rotating keyring, a canonical Noise handshake on WebSocket and WebTransport, + HPKE per request on HTTP and SSE, and end-to-end helpers for data no server + should read. Opt-in, experimental, platform crypto only, checked against + the published test vectors — [Encryption](./encryption). + +## Upgrade without a flag day + +The wire protocol is a [versioned reference](../reference/protocol), not an +implementation detail. Revision 2 — bytes that travel as bytes — is +negotiated per connection, so a 2.x client or server speaks revision 1 to a +1.0 peer with nothing to configure, and clients and servers upgrade in any +order. CI runs the published 1.0 against this tree, both ways, on every +change. Every section 2.0 added is badged `since 2.0` with a row saying what +an older peer does with it, and [Stability](../reference/stability) spells +out what 2.x promises — and which subpaths are still experimental. ## Against the alternatives -| | **wrpc** | **tRPC** | **Socket.IO** | +Five projects sit near wRPC, in two families: the frameworks a browser app +reaches for when it wants calls and realtime together, and the schema-first +RPC systems built around an IDL. Every cell below was checked against the +project's own documentation, source or npm entry in October 2026; the +sources are at the end of the section. "—" means the project documents +nothing of the kind, not that nobody has built it on top. + +::: info Which wRPC? +Two unrelated projects share the name. **wRPC by the Bytecode Alliance** is a +WIT-based RPC framework for WebAssembly components, in Rust and Go. This +site's **wRPC** is `@alexify/wrpc` — JavaScript and TypeScript, for Node.js +and the browser, one router over every web transport. **webrpc**, with an +`e`, is a third: schema-first code generation over HTTP and JSON. All three +are compared below. +::: + +### Realtime frameworks + +
+ +| | **wRPC** (`@alexify/wrpc`) | **tRPC** v11 | **Socket.IO** v4 | | --- | --- | --- | --- | -| Transport | WebSocket, HTTP, SSE (full duplex, resume), worker port | HTTP, WebSocket, SSE (subscriptions only, via `httpSubscriptionLink`) | WebSocket + long-poll fallback | -| Types | contract-first + codegen, no build step; typed server→client events | TypeScript inference | `ServerToClientEvents` maps (hand-written) | -| Realtime | subscriptions, events, rooms, acks | subscriptions | events, rooms, acks | -| HTTP/REST surface | declared per procedure: verbs, `/vN` paths, schemas, `--openapi` | third-party (`trpc-to-openapi`) | ❌ | -| Binary | streams with backpressure | ❌ | ❌ (messages only) | -| Scaling | rooms backplane (any pub/sub) | your own | adapters | -| Cluster ops | `fetchClients`, commands, presence (local read) | your own | `fetchSockets` (round-trip) | -| Observability | structured logging + OTel spans/metrics built in (SDK injected) | your own | your own | -| Cross-cutting | named lifecycle hooks | `(ctx, next)` middleware | middleware | -| Runtime deps | **0** | a few | several | -| Needs TypeScript | ❌ | effectively yes | ❌ | - -On throughput, wrpc is level with Socket.IO on a single call, faster than every -measured stack on 10 KB payloads, and within 26% of a **raw** `ws` echo that -does no RPC work at all — see [Performance](./performance) for the table, the -honest footnotes (tRPC's sequential row is a flush-timer artifact, not -throughput) and how to reproduce it. - -## When **not** to use wrpc +| Transports | WebSocket, HTTP + REST, SSE (full duplex, resume), WebTransport, WebRTC, worker port, message broker | HTTP (batching, JSONL streaming), SSE (subscriptions), WebSocket | WebSocket, HTTP long-polling; WebTransport since 4.7, opt-in | +| In the browser | official client, under 28 KB min+gzip (a CI budget) | official client | official client, 14.7 KB min+gzip (published) | +| Peer to peer | WebRTC data channels: symmetric peers, mesh, signaling | — | — (`socket.io-p2p`, last released 2016) | +| Types | contract-first + codegen, no build step; typed server→client events | inferred from the server's router | hand-written event-map generics | +| Realtime | subscriptions with resume, events both ways, rooms, acks | subscriptions with resume (`tracked()`); no rooms, no acks | events, rooms, broadcast, acks; no subscription primitive | +| HTTP/REST surface | declared per procedure: verbs, `/vN` paths, schemas, `--openapi` | GET/POST per procedure; OpenAPI via `@trpc/openapi` (alpha) or third-party `trpc-to-openapi` | — | +| Binary | bytes in any packet; streams with backpressure | binary inputs over `httpLink`; JSON answers | bytes in any event (1 MB cap by default); no streams | +| Scaling | rooms backplane over any pub/sub or broker | your own | adapters: Redis, Postgres, MongoDB, cluster, cloud queues | +| Message brokers | durable feeds, queue consumers, RPC between services (experimental) | — | a broadcast backplane only | +| Compression | off by default; deflate, Brotli or zstd per wire, negotiated | — (the host's middleware) | permessage-deflate off by default; HTTP compression on | +| Encryption beyond TLS | sealed envelopes, Noise sessions, HPKE, end-to-end helpers (experimental) | — | — | +| Observability | structured logging + OTel spans/metrics built in (SDK injected) | `onError`, `loggerLink` | `debug`, Admin UI; OTel through `@opentelemetry/instrumentation-socket.io` | +| Cross-cutting | named lifecycle hooks | `(opts) => opts.next()` middleware | `io.use` / `socket.use` middleware | +| Runtime deps | **0** | **0** (TypeScript as a peer) | 6 (server), 4 (client) | +| Needs TypeScript | ❌ | effectively yes (TypeScript ≥ 5.7) | ❌ | +| Protocol evolution | a versioned reference; the revision negotiated per connection, 1.0 ↔ 2.x tested both ways | no wire-compatibility promise documented | protocol revision 5; v3 and v4 interoperate, v2 with `allowEIO3` | + +
+ +### Schema-first RPC + +
+ +| | **wRPC** (`@alexify/wrpc`) | **gRPC / Connect** | **wRPC** (Bytecode Alliance) | **webrpc** | +| --- | --- | --- | --- | --- | +| Contract | a router in JavaScript; the client's contract declared, or generated from a running server | Protocol Buffers (`.proto`) | WIT (WebAssembly Interface Types) | RIDL or a JSON schema | +| Codegen | TypeScript `.d.ts` and OpenAPI from a running server (`wrpc types`) | gRPC: 13 languages; Connect: Go, TS/JS, Swift, Kotlin, Python, Dart | `wit-bindgen-wrpc` for Rust and Go | Go, TS, JS (client and server); Kotlin, Dart, Swift (clients); OpenAPI | +| Wire encoding | JSON packets + binary frames; a pluggable codec | binary protobuf (JSON on Connect) | component-model value encoding | JSON | +| Transports | WebSocket, HTTP + REST, SSE, WebTransport, WebRTC, worker port, message broker | gRPC: HTTP/2 (gRPC-Web through a proxy); Connect: HTTP/1.1, HTTP/2, HTTP/3 | TCP, Unix sockets, QUIC, WebTransport (release 0.17); WebSocket and HTTP on `main` | HTTP POST | +| In the browser | official client, under 28 KB min+gzip | gRPC-Web, Connect-Web: unary and server-streaming | `@bytecodealliance/wrpc` 0.0.0 on npm, types written by hand | generated TS/JS client over `fetch` | +| Streaming | subscriptions, events both ways, binary streams both ways | all four kinds; server-streaming only from a browser | `stream` / `future`, both directions | server-streaming (NDJSON), since v0.17 | +| Server push beyond streams | events, rooms, acks, resume after reconnect | — | — | — | +| Peer to peer | WebRTC data channels | — | — | — | +| Message brokers | feeds, consumers, RPC over Redis, NATS, RabbitMQ, Kafka (experimental) | — | a NATS transport in 0.17, removed on `main` | — | +| Protocol evolution | the revision negotiated per connection; the published 1.0 tested against 2.x | field numbers never reused; unknown fields tolerated | WIT package versions and `@since` gates; no wire policy stated | a schema `version` and a `Webrpc` header a server may check | +| JS runtime deps | **0** | `@connectrpc/connect`: 0, with `@bufbuild/protobuf` as a peer; `@grpc/grpc-js`: 2 | 0 | 0 (generated code over `fetch`) | +| Polyglot | JavaScript only | the point of it | Rust, Go and a JS package | Go, TS, JS, Kotlin, Dart, Swift | +| Maturity | 2.0; WebTransport, brokers and encryption experimental | gRPC: CNCF incubating; Connect: CNCF sandbox, stable | spec v0.0.1-draft.1, crate 0.17 | pre-1.0 (v0.47) | + +
+ +The short reading: the schema-first systems are built for **many languages +agreeing on one contract**, and wRPC is not — it is one language, both ends, +every transport a browser has. The realtime frameworks share wRPC's ground, +and differ in how much of it they cover: rooms and acks without +subscriptions or REST, or subscriptions and inference without rooms, +brokers or a transport beyond HTTP and the WebSocket. + +Sources, checked October 2026: tRPC — [links](https://trpc.io/docs/client/links), [subscriptions](https://trpc.io/docs/server/subscriptions), [non-JSON content](https://trpc.io/docs/server/non-json-content-types), [OpenAPI](https://trpc.io/docs/openapi), [middlewares](https://trpc.io/docs/server/middlewares), [npm](https://registry.npmjs.org/@trpc/server/latest); Socket.IO — [overview](https://socket.io/docs/v4/), [4.7.0](https://socket.io/docs/v4/changelog/4.7.0), [client installation](https://socket.io/docs/v4/client-installation/), [adapters](https://socket.io/docs/v4/adapter/), [server options](https://socket.io/docs/v4/server-options/), [protocol](https://socket.io/docs/v4/socket-io-protocol/), [npm](https://registry.npmjs.org/socket.io/latest); gRPC — [core concepts](https://grpc.io/docs/what-is-grpc/core-concepts/), [languages](https://grpc.io/docs/languages/), [gRPC-Web](https://github.com/grpc/grpc-web); Connect — [introduction](https://connectrpc.com/docs/introduction), [FAQ](https://connectrpc.com/docs/faq/); protobuf — [proto3 guide](https://protobuf.dev/programming-guides/proto3/); wRPC (Bytecode Alliance) — [repository](https://github.com/bytecodealliance/wrpc), [npm](https://www.npmjs.com/package/@bytecodealliance/wrpc); webrpc — [repository](https://github.com/webrpc/webrpc). + +See [Performance](./performance) for the receipts on speed — the comparison +table against raw sockets, socket.io and tRPC, the honest footnotes (tRPC's +sequential row is a flush-timer artifact, not throughput), each transport +measured, and how to reproduce every number on your own hardware. + +## When **not** to use wRPC An honest list is more useful than a feature grid. -- **You want an API wrpc does not shape.** A procedure can declare a real +- **You want an API wRPC does not shape.** A procedure can declare a real [REST mapping](./rest) — proper verb, path and status, `/vN` version paths, fastify-shaped schemas, `wrpc types --openapi` — but the URL surface follows your router, and content negotiation, HATEOAS and @@ -62,17 +216,27 @@ An honest list is more useful than a feature grid. reaching procedures, not an API design. If the HTTP contract itself is the product, design it first. - **Your stack is polyglot.** The [protocol](../reference/protocol) is - documented and small enough to reimplement, but the only implementation today - is JavaScript. gRPC exists for a reason. -- **You need guaranteed delivery.** Rooms are at-most-once fan-out, not a - queue. [Subscriptions](./subscriptions#resuming) with `tracked()` values and - an event log let a client catch up; if you need a broker's guarantees, use a - broker. + documented and small enough to reimplement, but the only implementation + today is JavaScript. For services in several languages, gRPC or Connect + (protobuf contracts, many languages) and webrpc (a schema, generated + clients and servers) are built for it; for WebAssembly components, the + Bytecode Alliance's wRPC is. +- **You need delivery guarantees from rooms.** Rooms are at-most-once fan-out, + not a queue. [Subscriptions](./subscriptions#resuming) with `tracked()` + values and an event log let a client catch up, and the + [broker family](./brokers) adds at-least-once queue consumers and durable + feeds (experimental) — but exactly-once processing, or a transaction across + a broker and a database, is the broker's and your application's to build. +- **You need audio or video.** wRPC's WebRTC layer moves data over data + channels; media tracks, an SFU and simulcast are a different stack. - **You want end-to-end type inference from server code.** tRPC's model — where the client's types come from the server's implementation with nothing written - twice — is genuinely nicer if your whole stack is TypeScript. wrpc's contract + twice — is genuinely nicer if your whole stack is TypeScript. wRPC's contract is declared or generated, deliberately, because it must also work for JavaScript users and across a network boundary the compiler cannot see. +- **You need every part frozen today.** WebTransport, the broker family and + encryption are `@experimental`: they may change in a minor release until + real deployments have reviewed them ([Stability](../reference/stability)). ## The other constraints worth knowing up front @@ -85,13 +249,12 @@ An honest list is more useful than a feature grid. written against `node:net` — and `@alexify/wrpc/engine` is the documented seam for a `Bun.serve`/`Deno.serve` engine, with `tests/engine/engineContract.js` as the conformance suite a contributor - would run. + would run. WebTransport and WebRTC have no Node implementation in the + standard library: in Node, inject one — an HTTP/3 host such as + `@fails-components/webtransport`, a WebRTC stack such as `node-datachannel`. - **One protocol implementation, both sides.** The browser build contains zero Node builtins, so the client talking to your server is the same code, not a reimplementation. -- **The wire protocol is frozen at 1.0.** An independent implementation written - against [the reference](../reference/protocol) keeps working for the life of - the major version. Convinced enough to try it? [Getting Started](./getting-started) is a working server and client in about thirty lines. diff --git a/docs/guide/wt.md b/docs/guide/wt.md new file mode 100644 index 0000000..183cf9a --- /dev/null +++ b/docs/guide/wt.md @@ -0,0 +1,399 @@ +# WebTransport + +WebTransport is the browser's API over HTTP/3 and QUIC: independent streams +with no head-of-line blocking between them, unreliable datagrams, and a +handshake with no TCP or TLS round trips to spare. `transport: 'wt'` runs +the ordinary wRPC client over it — the same packets, calls, events, +subscriptions with resume, binary streams with backpressure, heartbeat and +reconnect a WebSocket carries — with WebSocket as the fallback where +WebTransport is not available. On the server, `@alexify/wrpc/wt` attaches +the sessions a host implementation hands out to the same `Server` the +WebSocket clients are on: one router, one set of rooms, one cluster. + +::: warning Experimental +The whole feature is `@experimental` and may change in a **minor** release +(see [stability](../reference/stability#experimental-carve-outs)). Node has +no WebTransport of its own yet, so the server side is built on an injected +implementation — the reasons and the options are below. +::: + +## Using it + +On the client nothing has to be imported: the transport is in the base entry +so that a fallback list needs no extra bytes. + +```js +import { connect } from '@alexify/wrpc'; + +const client = await connect('https://host:4433/api', { + transport: ['wt', 'ws'], + wt: { serverCertificateHashes: [{ algorithm: 'sha-256', value: hash }] }, +}); +await client.load('chat'); +await client.api.chat.send({ text: 'hi' }); +``` + +`['wt', 'ws']` is an ordered fallback: a runtime without `WebTransport` (an +older browser, Node without an injected implementation) moves on to the +WebSocket at once, on the first connect, and a session that later exhausts +its reconnect budget does the same — `'transport-fallback'` fires either +way, and the application code above does not change. One thing the list +cannot do is change the host: it re-spells the *scheme* of the one URL per +candidate, so when the HTTP/3 host listens on a different port or origin +than the WebSocket (as in the repository's `examples/wt`), spell the +fallback by hand — a `transport: 'wt'` connect in a `try`, the WebSocket +connect in its `catch`. The `wt` options are +the `WebTransportOptions` of the constructor, plus `WebTransport` itself for +a runtime where the global one is not the implementation to use (a Node +client over `@fails-components/webtransport`'s, the fake in tests). + +On the server, the host implementation is yours to run; wRPC attaches what +it accepts: + +```js +const { Server } = require('@alexify/wrpc'); +const { bearerTransport } = require('@alexify/wrpc/auth'); +const { acceptSessions, failsRequestCallback } = require('@alexify/wrpc/wt'); +const { Http3Server } = await import('@fails-components/webtransport'); + +const server = new Server({ router, port: 8000, sessions: { transport: bearerTransport() } }); +await server.listen(); + +const h3 = new Http3Server({ port: 4433, host: '0.0.0.0', secret, cert, privKey }); +h3.setRequestCallback(failsRequestCallback); // accepts, and routes by pathname +h3.startServer(); +acceptSessions(server, h3.sessionStream('/api')); +``` + +The WebSocket engine keeps listening on TCP and the HTTP/3 host on UDP; a +client that arrived over either is a `Client` like any other, and +`client.transportKind` says which (`'wt'`). + +## How it works + +A WebTransport session is a bundle of QUIC streams and datagrams, not one +byte pipe like a WebSocket. wRPC uses **one** of them for everything: the +client opens a bidirectional stream — the _control stream_ — and every +packet and every stream chunk travels on it, in order, under a five-byte +header (length and kind; see the [wire format](../reference/wire-format#webtransport-stream-frames)). +That keeps the wire identical to the WebSocket one — a `stream` packet +precedes its first chunk, `ping`/`pong` run per direction, a subscription +resumes with `lastEventId` across a reconnect — and it is what lets the +fallback be invisible. [Streams of their own](#streams-without-head-of-line-blocking) +for binary transfers and datagrams for [unreliable events](#unreliable-events) +are additive capabilities on top, never a replacement of the packets. + +On the server, the session becomes a `WrpcSocket` — the engine port's socket +contract — and is attached exactly the way a WebSocket connection is +(`RpcServer.attachSocket`), so session restore, declared headers, receive-side +flow control and the server-side transport all apply unchanged. Outbound +backpressure counts the bytes the session has not taken yet — on the control +stream, on a binary stream's own WebTransport stream, and what waits for one +of those to open — so a `false` from `send()`/`write()` is always followed +by `'drain'`, whichever path answered it. Inbound, pausing simply stops +reading — the control stream and every binary stream's own WebTransport +stream alike — and QUIC's own flow control carries the pressure to the peer; +bytes arriving on any of them count as liveness for `idleTimeout`. + +Close codes carry over: the server's 1001 on shutdown and 1002 on a framing +violation arrive at the client as the session's `closeCode`, and a client +`close()` shows up on the server as the session ending. + +**A send before a `close()` is delivered**, as on a WebSocket — the callback +that ends a session, the event that says why a client is being +disconnected, a client's last `flush()`. Closing a WebTransport session +resets its streams and drops whatever they still hold, so a graceful +close ends the control stream first (which hands over what is queued) and +the session after it, with the code — `closeTimeout` (1 s) at most. The end +that receives the stream's end waits a moment for the session close that +follows, so the code it reports is the closer's. `terminate()` drops +everything at once, and a message still inside an asynchronous compressor +when `close()` is called is not waited for. + +## Streams without head-of-line blocking + +On a WebSocket, a 64 MiB upload's chunks queue in front of every callback +and event sent after them: one ordered byte pipe. A WebTransport session +has as many independent, ordered streams as it wants, and wRPC uses them +for what they are good at: once both ends have announced the capability +(each end's first message on the control stream says so), every binary +stream — a `createStream()` on either side — gets a unidirectional +WebTransport stream of its own. Its chunks travel there, its end is that +stream's FIN, a `terminate()` its reset, and the control stream keeps +carrying the calls, events and subscriptions beside it, unblocked. + +Nothing changes in application code: `WrpcReadable`/`WrpcWritable`, +backpressure and `getStream()` are the same, and a client whose transport +announced no such capability (an older peer, a wire codec in use — the +mapping reads stream packets, which only JSON allows) gets every chunk on +the control stream as before. So does one whose host never answers +`createUnidirectionalStream()` — a browser waits for stream credit, and a +host that grants none leaves it waiting: after five seconds that stream, and +every later one, rides the control stream instead. The ordering subtleties are the transport's +to handle and are spelled out in +[the protocol reference](../reference/protocol#webtransport-streams). + +## Unreliable events + +The one thing a WebSocket cannot do: deliver a message *at most once*, with +no retransmission and no head-of-line wait behind the message before it. +Position updates, cursor moves, telemetry samples — state where the latest +value is all that matters and a lost one is superseded by the next — ride +WebTransport datagrams when asked: + +```js +// client -> server +client.sendEvent('game/position', { x, y }, { unreliable: true }); + +// server -> one client, or a room +ctx.client.sendEvent('game/state', snapshot, { unreliable: true }); +server.to('match-7').emit('game/state', snapshot, { unreliable: true }); +``` + +The option is a hint about delivery, not a different API: on a WebSocket, +SSE or worker transport the same call sends the event reliably, so +application code is written once. A datagram carries at most +`maxDatagramSize` bytes (about 1200 on most paths) — an event that does not +fit goes reliably too, and a broadcast picks per recipient. Only events +qualify: `ask()` refuses `unreliable`, since an answer is expected. On a +cluster the flag crosses the backplane, so an instance delivering a remote +room's event does the same. The wire is documented +[here](../reference/protocol#webtransport-datagrams). + +A server never queues them: when a session has not taken the datagrams +already handed to it (64 of them — a congested path, on a host whose +datagram sink waits), a further one is **dropped**, and the send still +answers true. That is the contract of the option — the next position +supersedes this one — and the alternative, falling back to the control +stream, would park it behind the very congestion that caused the drop. +`wt.datagram.dropped` says so once per session. + +## Authentication without cookies + +A WebTransport `CONNECT` request is sent with credentials mode `omit`: **no +cookies, no `Authorization` header** — by specification, in every browser. +Two consequences: + +- The default cookie session transport cannot see a WebTransport client. + Configure a token transport — `bearerTransport()` or `payloadTransport()` + from [`@alexify/wrpc/auth`](./auth) — and the client's `bearerAuth()` (or + the `headers` option) presents the token. +- The browser constructor cannot set headers either, so declared + [headers and metadata](./metadata) ride the connect URL as the two reserved + query parameters — a browser WebSocket at least has its subprotocol offer + to carry them, WebTransport has nothing of the kind; the server reads + the real `CONNECT` headers (`origin`, `user-agent`) first and the query + only for names they lack. The caveat of the ws `carrier: 'query'` applies: the URL lands + in access logs — a device id belongs there, a secret should be short-lived. + +`verify` in `attachSession` is the `verifyClient` of this path: it sees the +request (`headers`, `url`, `remoteAddress`) and the session, and a `false` +closes it with 403. + +## Local development: certificates + +Browsers accept a WebTransport server on a self-signed certificate only +through `serverCertificateHashes`, with three constraints: the certificate +must be **ECDSA P-256**, valid for **at most 14 days**, and the hash is the +SHA-256 of its DER encoding. `node scripts/wt-cert.js` in the repository +generates one and prints the hash; for anything public, a certificate from a +real CA needs no hash at all. + +Two practical notes from the field: connect to `127.0.0.1`, not `localhost` +(a browser may resolve the latter to `::1` while the server bound IPv4), and +Firefox treats the hash as an _additional_ check rather than a replacement +for chain validation, so a self-signed certificate may still need a +development profile there. + +## Hosting + +Node has no WebTransport in its standard library. `node:quic` exists behind +a **compile-time** flag (`./configure --experimental-quic`; official binaries +do not carry it) and speaks raw QUIC and HTTP/3, but not the WebTransport +layer on top (the extended `CONNECT`, the session-bound streams and +datagrams). So `@alexify/wrpc/wt` binds to nothing and takes the session +from whatever the application runs: + +| Host | Kind | Adapter | +| ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`@fails-components/webtransport`](https://github.com/fails-components/webtransport) (+ `-transport-http3-quiche`) | native, Google's libquiche; prebuilt binaries (its install script must be allowed — `pnpm.onlyBuiltDependencies` under pnpm) | sessions are W3C-shaped and attach as-is; `fromFails(session)` reads the `CONNECT` request off `session.header`; the server needs `setRequestCallback(failsRequestCallback)` — its `sessionStream(path)` matches the request path literally, query included, and a wRPC client declares its headers in the query | +| [`quico`](https://github.com/colocohen/quico) | pure JavaScript HTTP/3 stack, beta | `fromQuico(req, res)` in the request handler; quico reports no session end, so pass `idleTimeout` (the client pings on its heartbeat) — and give a pure-JS stack a heartbeat timeout of a second, not a hundred milliseconds | + +```js +// quico: the request handler is where a session appears. +quico + .createServer({ key, cert }, (req, res) => { + if (req.headers[':protocol'] !== 'webtransport') return void res.end(); + const { session, ...meta } = fromQuico(req, res); + attachSession(server, session, { ...meta, idleTimeout: 60_000 }); + }) + .listen(4433); +``` + +Both are devDependencies of wRPC, used only by its guarded integration +tests — never runtime dependencies. Anything else with the W3C session shape +(`incomingBidirectionalStreams`, `createBidirectionalStream`, `closed`, +`close`, optionally `datagrams`) works too: `isWtSession` is the check. + +### Why not `node:quic` yet + +It was tried, on a Node built from `main` with `--experimental-quic` +(September 2026, v27.0.0-pre; official binaries and nightlies of every line, +v22 to v27, still ship without it — `process.features.quic` is `false` and +`node:quic` does not resolve). The module's built-in HTTP/3 can negotiate +`SETTINGS_ENABLE_CONNECT_PROTOCOL` (`application.enableConnectProtocol`) +and HTTP/3 datagrams, but that is where it stops: + +- a WebTransport client sends its `CONNECT` only after the server's + SETTINGS carried `SETTINGS_ENABLE_WEBTRANSPORT` / `SETTINGS_WT_MAX_SESSIONS`, + and `node:quic` has no way to send a setting nghttp3 does not know — a + real client (libquiche's) sits on a `node:quic` server for its handshake + timeout and gives up, before any `onheaders` fires; +- past that, WebTransport's own framing — request streams that start with + the `0x41` frame, unidirectional streams of type `0x54`, the + quarter-stream-id datagram prefix, the capsule protocol on the `CONNECT` + stream — is not passed through either, as the module's own documentation + says ("no built-in support for the `:protocol` pseudo-header, WebTransport + datagram demultiplexing, or capsule framing"). + +So a `node:quic` host is a change in Node, not an adapter in wRPC. The +session contract is where it will plug in once it exists, and nothing +above it will change. + +## Options + +```js +attachSession(server, session, { + headers, + url, + remoteAddress, // the CONNECT request, as the host saw it + verify: ({ headers, url, remoteAddress, session }) => boolean, + acceptTimeout: 10_000, // verify, the session's ready and the control stream, all within this (408 past it) + highWaterMark: 1024 * 1024, // outbound bytes queued before send() answers false + lowWaterMark: 256 * 1024, // 'drain' fires under this + maxBackpressure: 64 * 1024 * 1024, // queued bytes past which a peer that never drains is terminated (0 = off) + maxMessage: 16 * 1024 * 1024, // the largest inbound message; past it the peer is hung up (1002) + idleTimeout: 0, // ms without inbound data before the session is terminated (0 = off) + closeTimeout: 1000, // ms a graceful close() waits for the control stream to take what was sent + maxHeldStreams: 32, // inbound streams held for their open packet at once; a further one is cancelled unread + holdTimeout: 10_000, // ms a held stream waits for its open packet before it is cancelled (logged wt.mux.refused) + kind: 'wt', // what Client.transportKind reports + compression: false, // per-message deflate on the control stream — see below +}); + +acceptSessions(server, sessions, { + meta: fromFails, // reads the CONNECT request off each session + onClient: (client, session) => {}, // sessions attach concurrently: not necessarily in arrival order + onError: (error, session) => {}, + maxPending: 256, // sessions held in their handshake at once; the next is refused 503 (wt.accept.saturated) + ...attachSessionOptions, +}); + +connect(url, { + transport: ['wt', 'ws'], + wt: { + WebTransport, // the implementation; globalThis.WebTransport by default + serverCertificateHashes, + congestionControl, + allowPooling, + requireUnreliable, + protocols, + highWaterMark, + lowWaterMark, + maxBackpressure, // the same cap, against a server that never drains + maxMessage, + closeTimeout, // ms close() waits for what was written to leave (1000) + }, + compression: false, // per-message deflate, both ends must turn it on — see below +}); +``` + +## Compression {#compression} + +Nothing compresses a QUIC stream's payload for you — HTTP/3 compresses +headers (QPACK), never bodies — so a WebTransport session carries exactly +the bytes wRPC hands it. Per-message compression is the answer, **off by +default** like every compression knob in wRPC, and negotiated: each end +names its codecs in the [capabilities message](../reference/protocol#webtransport-streams) +and compresses — with the first of its own [list](./compression#list) the +other end holds — only once they share one, so a client +with the option talking to a server without it is served plain, and the +other way round. + +```js +// Server: one word for the platform codec (raw deflate through node:zlib). +acceptSessions(server, sessions, { compression: true }); + +// Client: connect()'s own option, or the `wt` bag's; both ends must agree. +connect(url, { transport: ['wt', 'ws'], compression: { threshold: 2048 } }); +``` + +A packet or a chunk at or over `threshold` bytes leaves as its compressed +kind (3 for a packet, 4 for a chunk) and is inflated before delivery; +smaller ones go as they are. The threshold is the codec's own default — +1 KiB on Node, 4 KiB in a browser, where the only codec a page has is +`CompressionStream`, ~6× the cost of zlib per call and without a +dictionary, so a small message barely shrinks. The codec is a structural +seam: `{ codec }` injects anything with an `id`, `encode(bytes)` and +`decode(bytes, maxOutput)` — the dictionary codec of `@alexify/wrpc/deflate` +once it exists, or your own — and either method may answer a promise; +messages stay in order around it, and no more than four inflates run at +once: past that the reader waits for them, and the bytes wait in the stream +under QUIC's flow control — a burst of compressed frames is not a burst of +parallel inflates. Per message, `{ compress: false }` on an emit sends that +one plain, as on a WebSocket. Chunks that ride their own +WebTransport stream (the [stream mux](#streams-without-head-of-line-blocking)) +and datagrams are never compressed. + +What it costs and buys, `bench/message-compression.js` (one message at a +time, node:zlib): a 108 B event 143K/sec for 1.1× — the reason for the +threshold — a 1.4 KB callback 84K/sec for 6.4×, a 24 KB one 12K/sec for +13.8×. Inflating is 3–6× cheaper than deflating at every size. A page's +`CompressionStream` runs the same ratios at 23K/sec on the small message +and 9K/sec at 24 KB. An inflated message past `maxMessage` is a protocol +error (1002), the same cap a plain one has. Deflating is synchronous on +Node by design — zlib's threadpool hand-off costs more than the deflate +below ~256 KB — and `compression: { async: true | { threshold } }` sends +messages that large to the threadpool instead, in order; the +[compression guide](./compression#async) has the numbers. + +## What it costs + +Measured end to end on loopback, next to a WebSocket on the same machine +([Across transports](./performance#across-transports) has the tables and +the commands): + +- **From Chrome**, a small call made while uploads keep the session busy + answers in **9 ms** at the median, against 46 ms over a WebSocket, whose + call waits in one TCP stream behind the upload's chunks. Plain calls are + 1.2–1.9× cheaper over the WebSocket, and it moves a single large upload + about four times faster. +- **In Node**, the stack sets the ceiling: wRPC's calls run at 85–98% of a + raw echo over the same libquiche session, and its streams at 84% of the + bare QUIC stream. That stack is a binding, and on loopback it moves a + fraction of what Node's WebSocket does — use WebTransport in Node for what + only it does, not for throughput. + +## What it cannot do + +- **Cookies.** See above — sessions need a token transport. +- **A subprotocol.** The revision offer a WebSocket makes (`wrpc.v2, + wrpc.v1`) has no carrier here yet (`WT-Available-Protocols` is young), and + needs none: 1.0 had no WebTransport, so a session always speaks + revision 2. +- **Serve HTTP.** The WebSocket engine still serves the REST and packet + routes over HTTP/1.1; the HTTP/3 host is the session's only job. +- **Run without an implementation.** There is no default host in Node; a + `transport: 'wt'` client in Node needs an injected `WebTransport` too. +- **Detect a vanished peer on its own.** A WebSocket engine pings; a + WebTransport host is expected to end the session when QUIC's idle timeout + fires. Where it does not, `idleTimeout` on the server side and the + client's heartbeat are the liveness signal. + +## Bundle size + +The client transport, its framing, the datagram path and the stream mux +are part of the main browser entry — a few kilobytes of its budget, the +price of a fallback list that needs no import; [the measured +table](./browser#bundle-size) has the number. The server half never ships +to a browser. diff --git a/docs/index.md b/docs/index.md index 83cd6d1..2e98a2c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,76 +1,150 @@ --- layout: home -title: wrpc -titleTemplate: WebSocket-based RPC protocol for Node.js and the browser +title: wRPC +titleTemplate: Web RPC for Node.js and the browser hero: - name: wrpc - text: Fast, zero-dependency WebSocket RPC - tagline: Router and procedures, subscriptions with resume, rooms that scale, and binary streams with real backpressure — for Node.js and browsers, with nothing in your lockfile. + name: wRPC + text: Fast, zero-dependency Web RPC + tagline: One router, every web transport — WebSocket, HTTP, SSE, WebTransport, WebRTC — and across Redis, NATS, RabbitMQ and Kafka. Nothing in your lockfile. image: src: /logo-mark.svg - alt: wrpc + alt: wRPC actions: - theme: brand text: Get Started link: /guide/getting-started - theme: alt - text: Why wrpc? + text: Why wRPC? link: /guide/why - theme: alt text: View on GitHub link: https://github.com/Alexis-Technologies/wrpc features: - - icon: 📭 - title: Zero dependencies - details: No runtime dependencies at all — no dependencies field, no peers, no optionals. Redis, uWebSockets.js, fastify, TanStack and OpenTelemetry are injected by you, never installed by wrpc. - link: /guide/scaling - linkText: How injection works + # Row 1 — one router, every web transport + - icon: ⚡ + title: WebTransport, with a fallback + details: HTTP/3 streams with no head-of-line blocking between them and unreliable datagrams for events that may drop — the same router and rooms as your WebSocket clients, and transport: ['wt', 'ws'] falls back where a browser has none. Experimental. + link: /guide/wt + linkText: Go HTTP/3 + - icon: 🤝 + title: Peer to peer over WebRTC + details: Two browsers serve routers to each other with no server in the data path — calls, events, subscriptions and streams over a data channel, a mesh for rooms, built-in signaling, and peer identity bound to the DTLS fingerprint. + link: /guide/webrtc + linkText: Connect peers + - icon: 📨 + title: Message brokers, injected + details: Redis, NATS, RabbitMQ or Kafka — durable feeds a client resumes on any instance, queue consumers that deliver into procedures with retry and dead-lettering, and RPC between services with no HTTP between them. Experimental. + link: /guide/brokers + linkText: Bring your broker + - icon: 🔌 + title: Runs where you already run + details: A batteries-included server, or plug into fastify, express, bare node:http or uWebSockets.js; the same protocol rides Server-Sent Events where WebSockets cannot go — with pino-style logging and OTel traces built in. + link: /guide/adapters/fastify + linkText: Adapters + # Row 2 — the RPC core - icon: 🧩 title: Router, procedures, hooks details: Declare units of procedures with access control, Standard Schema validators, timeouts and queues — and named lifecycle hooks at router, unit and procedure level instead of (ctx, next) middleware. link: /guide/router linkText: Write a router - - icon: 🌐 - title: Real REST, declared on the procedure - details: http: { method, path, status } turns a procedure into a proper endpoint: path params, /vN version paths, fastify-shaped schemas, and an OpenAPI document from wrpc types --openapi. - link: /guide/rest - linkText: Declare an endpoint - icon: 🔔 title: Subscriptions that resume - details: An async generator per feed, with real backpressure. Track values with an event id and a reconnecting client replays exactly what it missed. + details: An async generator per feed, with real backpressure. Track values with an event id and a reconnecting client replays exactly what it missed — on any instance, when the feed is a broker log. link: /guide/subscriptions linkText: Push a feed + - icon: 🧵 + title: Bytes as bytes + details: A Uint8Array in arguments, results and events travels as bytes, not as JSON digits; binary streams interleave uploads and downloads with backpressure that reaches all the way into TCP. 1 GiB streams at flat memory. + link: /guide/streams + linkText: Move bytes + - icon: 🧭 + title: Real REST, declared on the procedure + details: http: { method, path, status } turns a procedure into a proper endpoint: path params, /vN version paths, fastify-shaped schemas, and an OpenAPI document from wrpc types --openapi. + link: /guide/rest + linkText: Declare an endpoint + # Row 3 — scale and security - icon: 🛰️ title: Rooms and cluster in one - details: Join, leave and broadcast in one process; add any pub/sub with three methods and a room spans every instance — with presence read from a local map, fetchClients, addressed commands and node-to-node asks. + details: Join, leave and broadcast in one process; add any pub/sub — or your broker's backplane — and a room spans every instance, with presence read from a local map, fetchClients, addressed commands and node-to-node asks. link: /guide/cluster linkText: Go multi-instance - - icon: 🧵 - title: Binary streams - details: Upload and download bytes on the same connection, interleaved without head-of-line blocking, with backpressure that reaches all the way into TCP. 1 GiB streams at flat memory. - link: /guide/streams - linkText: Move bytes - - icon: 🧠 - title: Types without a build step - details: Declare the contract once and connect<Api>() types every call — server-push events included — or generate it from a running server with the wrpc types CLI. No TypeScript at runtime, ever. - link: /guide/typed-client - linkText: Type the client - icon: 🔐 title: Sessions and auth, batteries included details: Cookie sessions by default; bearer and payload token carriers, client token stores and an authenticate/refresh lifecycle that re-presents the credential before every reconnect restore. link: /guide/sessions linkText: Wire up auth - - icon: 🔌 - title: Runs where you already run - details: A batteries-included server, or plug into fastify, express, bare node:http or uWebSockets.js; the same protocol rides Server-Sent Events where WebSockets cannot go — with pino-style logging and OTel traces built in. - link: /guide/adapters/fastify - linkText: Adapters + - icon: 🛡️ + title: Encryption where TLS ends + details: Sealed backplane, cluster and broker messages under a rotating keyring, a session store that holds nothing readable, Noise sessions on WebSocket and WebTransport, HPKE per HTTP request, end-to-end helpers — platform crypto only. Experimental. + link: /guide/encryption + linkText: Seal what matters + - icon: 🗜️ + title: Compression, chosen per wire + details: Off by default, because its cost is paid on every frame. Turn it on where bytes are dear — deflate, Brotli or zstd, negotiated as a preference list on every transport and the backplane, with a preset dictionary built from your router. + link: /guide/compression + linkText: Pick a codec + # Row 4 — platform and DX + - icon: 📭 + title: Zero dependencies + details: No runtime dependencies at all — no dependencies field, no peers, no optionals. Redis, Kafka, uWebSockets.js, fastify, the WebRTC and HTTP/3 stacks, TanStack and OpenTelemetry are injected by you, never installed by wRPC. + link: /guide/why + linkText: Why it matters + - icon: 🧠 + title: Types without a build step + details: Declare the contract once and connect<Api>() types every call — server-push events included — or generate it from a running server with the wrpc types CLI. No TypeScript at runtime, ever. + link: /guide/typed-client + linkText: Type the client - 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, under 28 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 + - icon: 🔁 + title: Upgrade in any order + details: The wire protocol is a versioned reference. Revision 2 is negotiated per connection, so a 2.x client or server still speaks 1.0 to a 1.0 peer — and the published 1.0 is tested against this one, both ways. + link: /reference/protocol#versioning + linkText: Read the protocol --- + +## One router, every transport + +Every procedure on a wRPC server answers on every carrier below, with the +same rooms and sessions behind it — a browser on a WebSocket or on HTTP/3, a page behind a proxy that only +speaks HTTP, another browser over WebRTC, another service through Kafka. The +handler never knows which; the client picks per connection, with a fallback +list where it wants one, and an app can [talk to several +backends](/guide/multiple-backends) at once. + + + +| Transport | Reach for it when | Calls | Events & subscriptions | Binary streams | Compression | Encryption | +| --- | --- | --- | --- | --- | --- | --- | +| [WebSocket](/guide/client#transports) | the default — full duplex, from any browser or Node | ✅ | ✅ | ✅ | ✅ | Noise | +| [HTTP & REST](/guide/rest) | stateless calls, `curl`, partners, caches | ✅ | ❌ | ❌ | ✅ | HPKE | +| [Server-Sent Events](/guide/sse) | no upgrade path — serverless, strict proxies | ✅ | ✅ | ❌ | ✅ | HPKE | +| [WebTransport](/guide/wt) | HTTP/3 — streams without head-of-line blocking, datagrams | ✅ | ✅ | ✅ | ✅ | Noise | +| [WebRTC](/guide/webrtc) | browser to browser, no server in the data path | ✅ | ✅ | ✅ | ✅ | DTLS | +| [Worker port](/guide/client#workers) | one connection shared by every tab | ✅ | ✅ | ✅ | — | — | +| [Broker, stateless](/guide/brokers/rpc) | service to service — any instance answers | ✅ | ❌ | ❌ | ✅ | sealed | +| [Broker, session](/guide/brokers/rpc) | a long-lived conversation between services | ✅ | ✅ | ✅ | ✅ | sealed | + +Compression and encryption are off until you turn them on; encryption here is +the layer above TLS, never in place of it. The full matrix — how each +transport is chosen, and which carry bytes inside a packet — is in +[Client › Transports](/guide/client#transports). + +## Fast where it counts + +wRPC's whole call path — router, validation, access check, correlation — +against the frameworks that do the same job, one machine, over a WebSocket +(gRPC over HTTP/2). Switch the metric: wRPC leads with calls in flight and at +10 KB, and is level with socket.io one small call at a time. Over plain HTTP, +fastify answers about a tenth more requests than wRPC does, and express about +a fifth fewer — [Performance](/guide/performance) has that too, with raw +sockets, every transport, and how to reproduce each number. + + diff --git a/docs/public/llms.txt b/docs/public/llms.txt index 9ad4074..fb62feb 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -1,40 +1,57 @@ -# wrpc (@alexify/wrpc) +# wRPC (@alexify/wrpc) -> A fast, zero-dependency, WebSocket-based RPC protocol for Node.js and browsers. Router and procedures, subscriptions that resume after a reconnect, rooms that scale across processes, binary streams with backpressure that reaches into TCP, and a typed client with no build step. +> wRPC (Web RPC) is a fast, zero-dependency RPC protocol for Node.js and browsers: one router of procedures served over WebSocket, HTTP and REST, Server-Sent Events, WebTransport and WebRTC, and between services over Redis, NATS, RabbitMQ or Kafka. Subscriptions that resume after a reconnect, rooms that scale across processes, binary streams with backpressure that reaches into TCP, opt-in compression and encryption, and a typed client with no build step. Key facts: +- Name: written **wRPC** in prose, short for Web RPC. The npm package is `@alexify/wrpc`; the CLI is `wrpc`; wire names keep the lowercase spelling (`wrpc.v2`, `wrpc-version`, `x-wrpc-*`). +- Not to be confused with: wRPC by the Bytecode Alliance (a WIT-based RPC framework for WebAssembly components, Rust and Go) or webrpc (schema-first code generation over HTTP and JSON). They are unrelated projects. - 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: the browser entry is under 28 KB min+gzip (a budget CI enforces; 29 KB with the SSE transport, 2 KB for the TanStack Query bindings) — the measured table is at https://wrpc.vercel.app/guide/browser#bundle-size. - 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`. -- Hosts: batteries-included `Server`, or fastify / express / bare `node:http` / uWebSockets.js — every host injected, never depended on. +- Transports: WebSocket, plain HTTP (packet mode and declared REST), Server-Sent Events, WebTransport over HTTP/3 (experimental), WebRTC data channels between peers, a worker `MessagePort` (Service Worker or SharedWorker), and a message broker between services (experimental). One router serves all of them; the client picks one by URL scheme or option, with a fallback list such as `['wt', 'ws']`. +- Hosts: batteries-included `Server`, or fastify / express / bare `node:http` / uWebSockets.js — every host injected, never depended on. Message brokers (ioredis, NATS, amqplib, kafkajs), WebRTC stacks and HTTP/3 stacks are injected the same way. - 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. -- The wire protocol is frozen at 1.0: JSON packets (`call`, `callback`, `event`, `subscribe`, `data`, `end`, `unsubscribe`, `cancel`, `stream`, `ping`, `pong`) plus a binary chunk framing. +- Subpaths: `@alexify/wrpc`, `/ws`, `/engine`, `/uws`, `/fastify`, `/express`, `/scaling`, `/sse`, `/query`, `/auth`, `/deflate`, `/webrtc`, `/wt`, `/encryption`, `/broker` and `/broker/redis`, `/broker/nats`, `/broker/amqp`, `/broker/kafka` (the WebTransport, broker and encryption ones `@experimental`), plus a `wrpc types` codegen CLI. +- The wire protocol is documented at revision 2 (`wrpc.v2`) — revision 1 plus framed messages (bytes travel as bytes), negotiated per connection so a 2.x peer speaks `wrpc.v1` to a 1.0 peer with nothing to configure; its newer carrier conventions are badged `since 2.0` with a changes-since-1.0 table: JSON packets (`call`, `callback`, `event`, `subscribe`, `data`, `end`, `unsubscribe`, `cancel`, `stream`, `ping`, `pong`) plus a binary chunk framing. - Source: https://github.com/Alexis-Technologies/wrpc ## Introduction - [Getting Started](https://wrpc.vercel.app/guide/getting-started): Install, a first server and client, sessions, realtime and subscriptions, with an architecture diagram. -- [Why wrpc?](https://wrpc.vercel.app/guide/why): Positioning against tRPC and Socket.IO, what zero dependencies buys, and when NOT to use it. +- [Why wRPC?](https://wrpc.vercel.app/guide/why): One router over every transport, positioning against tRPC, Socket.IO, gRPC/Connect, Bytecode Alliance's wRPC and webrpc, what zero dependencies buys, and when NOT to use it. ## Server - [Server](https://wrpc.vercel.app/guide/server): `RpcServer` vs the batteries-included `Server`, options, what it serves over HTTP, CORS, lifecycle. - [Router & procedures](https://wrpc.vercel.app/guide/router): `defineRouter`/`procedure`, access levels, Standard Schema validators, timeouts, queues, versioned units, introspection. +- [Declarative REST](https://wrpc.vercel.app/guide/rest): A real REST endpoint declared on a procedure — verb, path, status, `/vN` version paths, schemas, OpenAPI. - [Hooks](https://wrpc.vercel.app/guide/hooks): Lifecycle phases in the fastify tradition; router, unit and procedure levels flattened into one pipeline. No `(ctx, next)` middleware. -- [Sessions](https://wrpc.vercel.app/guide/sessions): Token plus state store, cookies, the cross-site GET gate, pluggable stores. +- [Sessions](https://wrpc.vercel.app/guide/sessions): Token plus state store, cookies, the cross-site GET gate, pluggable stores, a sealed store. +- [Authentication](https://wrpc.vercel.app/guide/auth): Bearer and payload token carriers, client token stores, the authenticate/refresh lifecycle. +- [Metadata](https://wrpc.vercel.app/guide/metadata): Connection headers and metadata, per-call metadata, and how each transport carries them. - [Rooms & events](https://wrpc.vercel.app/guide/rooms): Events in both directions, join/leave, the chainable `Broadcast`, acks via `ask()`. - [Subscriptions](https://wrpc.vercel.app/guide/subscriptions): Async-generator feeds, backpressure, and resume through `tracked()` + `createEventLog()`. -- [Binary streams](https://wrpc.vercel.app/guide/streams): Interleaved upload/download with end-to-end backpressure. +- [Binary streams](https://wrpc.vercel.app/guide/streams): Interleaved upload/download with end-to-end backpressure, and binary attachments in arguments, results and events. - [Scaling](https://wrpc.vercel.app/guide/scaling): The three-method backplane contract, memory and Redis adapters, at-most-once delivery. - [Cluster](https://wrpc.vercel.app/guide/cluster): Replicated presence, `fetchClients`, addressed commands, node-to-node ask, and client acks. +## Message brokers + +- [Overview & contracts](https://wrpc.vercel.app/guide/brokers): The broker-agnostic core — backplane, log, queue and direct capabilities, every client injected (experimental). +- [Durable feeds](https://wrpc.vercel.app/guide/brokers/feeds): `brokerFeed` — a subscription over a broker log that resumes on any instance. +- [Queue consumers & publishing](https://wrpc.vercel.app/guide/brokers/consumers): `attachConsumers` — queue messages delivered into procedures at least once, with retry and dead-lettering; `createPublisher`. +- [RPC over a broker](https://wrpc.vercel.app/guide/brokers/rpc): Services calling each other through the broker, stateless or with a session, with no HTTP between them. +- [Redis](https://wrpc.vercel.app/guide/brokers/redis): Pub/sub, Streams and lists — all four capabilities. +- [NATS](https://wrpc.vercel.app/guide/brokers/nats): Core subjects plus JetStream. +- [RabbitMQ](https://wrpc.vercel.app/guide/brokers/amqp): Exchanges, quorum and stream queues, TTL retry and dead-letter exchanges. +- [Kafka](https://wrpc.vercel.app/guide/brokers/kafka): The durable-log broker; kafkajs or the Confluent client. + ## 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 and what each carries, 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. +- [Multiple backends](https://wrpc.vercel.app/guide/multiple-backends): One `WrpcClient` per backend, each on its own transport. - [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. - [TanStack Query](https://wrpc.vercel.app/guide/query): Option factories (not hooks) for React/Solid/Svelte/Vue Query. @@ -42,6 +59,10 @@ Key facts: ## Transports and hosts - [Server-Sent Events](https://wrpc.vercel.app/guide/sse): The full protocol over SSE, channel binding, replay, sticky routing, limitations. +- [WebRTC](https://wrpc.vercel.app/guide/webrtc): Peer-to-peer wRPC between browsers over data channels — symmetric peers, mesh, built-in or pluggable signaling. +- [WebRTC: identity and trust](https://wrpc.vercel.app/guide/webrtc-trust): App-level peer ids and server-signed assertions bound to the DTLS fingerprint. +- [WebTransport](https://wrpc.vercel.app/guide/wt): wRPC over HTTP/3 — streams without head-of-line blocking, unreliable events, WebSocket fallback, an injected HTTP/3 host (experimental). +- [Wire codec](https://wrpc.vercel.app/guide/codec): Replacing the JSON framing with your own codec on both ends. - [uWebSockets.js](https://wrpc.vercel.app/guide/adapters/uws): The standalone engine and what it changes. - [Fastify](https://wrpc.vercel.app/guide/adapters/fastify): The plugin, both backends, hook ordering, shutdown. - [Express](https://wrpc.vercel.app/guide/adapters/express): Middleware that owns no listener; driving upgrades by hand. @@ -49,15 +70,19 @@ Key facts: ## Operations - [Security](https://wrpc.vercel.app/guide/security): Trust boundary, the production checklist, prototype-pollution defences, resource limits, 5xx masking. +- [Rate limiting & throttling](https://wrpc.vercel.app/guide/rate-limiting): One `onRequest` seam for every transport, and what is capped out of the box. - [Running in production](https://wrpc.vercel.app/guide/production): Graceful drain and shutdown, health checks, proxies, sticky routing, backpressure tuning. - [Testing](https://wrpc.vercel.app/guide/testing): Boot patterns, `t.after` teardown, multi-instance tests over `MemoryBackplane`, testing without a network. -- [Performance](https://wrpc.vercel.app/guide/performance): Reproducible benchmarks against ws, uWebSockets.js, socket.io and tRPC; fan-out, parser and dispatch numbers. -- [Logging](https://wrpc.vercel.app/guide/logging): The injected structured logger and what it emits. +- [Performance](https://wrpc.vercel.app/guide/performance): Reproducible benchmarks against ws, uWebSockets.js, socket.io and tRPC; wRPC over WebSocket, WebTransport and WebRTC on real stacks, in Node and in Chrome; fan-out, parser and dispatch numbers. +- [Compression](https://wrpc.vercel.app/guide/compression): Off by default; deflate, Brotli or zstd per wire, negotiated as a preference list, and a preset dictionary from the router. +- [Encryption](https://wrpc.vercel.app/guide/encryption): Where TLS ends before the data does — sealed backplane and broker messages, a sealed session store, Noise sessions, HPKE per request, end-to-end helpers (experimental). +- [Logging](https://wrpc.vercel.app/guide/logging): The injected structured logger and every event it emits. - [OpenTelemetry](https://wrpc.vercel.app/guide/telemetry): Spans, metrics, trace-context propagation, privacy. ## Reference -- [Wire protocol](https://wrpc.vercel.app/reference/protocol): Every packet type, batching, trace context, introspection, sessions, rooms, SSE, reconnect. Frozen at 1.0. -- [Wire format](https://wrpc.vercel.app/reference/wire-format): Binary chunk framing and the from-scratch RFC 6455/7692 WebSocket engine. +- [Wire protocol](https://wrpc.vercel.app/reference/protocol): Every packet type, batching, trace context, introspection, sessions, rooms, every carrier, reconnect. Revision 2, negotiated with a 1.0 peer. +- [Wire format](https://wrpc.vercel.app/reference/wire-format): Binary chunk framing, data-channel and WebTransport frames, and the from-scratch RFC 6455/7692 WebSocket engine. - [Engine port](https://wrpc.vercel.app/reference/engine): The `Engine`/`WrpcSocket` contract, hosted vs standalone, writing your own. -- [Errors & close codes](https://wrpc.vercel.app/reference/errors): The full status-code table, which codes retry, and RFC 6455 close codes. +- [Errors & close codes](https://wrpc.vercel.app/reference/errors): The full status-code table, which codes retry, and the close codes. +- [Stability & deprecation](https://wrpc.vercel.app/reference/stability): What 2.x promises — semver spelled out, the `@experimental` carve-outs, and the wire protocol's own, stronger promise. diff --git a/docs/public/logo.png b/docs/public/logo.png index 735d686..f0b5112 100644 Binary files a/docs/public/logo.png and b/docs/public/logo.png differ diff --git a/docs/reference/engine.md b/docs/reference/engine.md index 584c7e9..63b4e85 100644 --- a/docs/reference/engine.md +++ b/docs/reference/engine.md @@ -5,6 +5,12 @@ above it — the router, clients, rooms, subscriptions, the wire protocol — is written against this contract and nothing else, which is what makes [uWebSockets.js](../guide/adapters/uws) a drop-in swap rather than a fork. +The engine is the WebSocket's way in, not the only one: a +[WebTransport](../guide/wt) session (`attachSession`), a +[WebRTC](../guide/webrtc) data channel (`attachChannel`) and the [broker +binding](../guide/brokers/rpc) (`attachBrokerRpc`) reach the same `RpcServer` +through `attach(transport)` and never pass through an engine. + ```js const { createNodeEngine, isEngine } = require('@alexify/wrpc/engine'); ``` @@ -79,11 +85,13 @@ is `null` and `server.address()` is the only way to read the bound address. | `server` | The node http(s) server. Required for hosted engines, absent otherwise. | | `path` | Restrict upgrades to this pathname. | | `verifyClient({ req, socket, head })` | Gate the handshake. `socket`/`head` are `null` for standalone engines. | -| `protocols` / `handleProtocols(offered, req)` | Subprotocol negotiation; `false` rejects the handshake. | -| `perMessageDeflate` | `true`, or `{ threshold }`. See [wire format](./wire-format#permessage-deflate). | +| `protocols` / `handleProtocols(offered, req)` | Subprotocol negotiation; `false` rejects the handshake. `offered` never holds wRPC's [carrier tokens](./protocol#connection-metadata) (`wrpc.h.`, `wrpc.m.`, `wrpc.bearer.`), and an engine must not echo one. | +| `perMessageDeflate` | `true`, or `{ threshold, filter, contextTakeover, async, … }`. See [wire format](./wire-format#permessage-deflate). | +| `coalesce` | Built-in engine: cork the writes of one event-loop turn into one flush. Default `true`. | | `pingInterval` | Protocol-ping interval for engines that own liveness. | | `maxBuffer` / `maxBackpressure` / `fragmentThreshold` / `closeTimeout` | Engine limits. | | `onHttpCall(call)` | Standalone engines only: the core's HTTP entry point. | +| `logger` | The structured logger the engine and its connections report through — the `Server` shell and the adapters pass their own. An engine built with its own `logger` keeps it. | It returns an `EngineConnectionSource` — an `EventEmitter` that emits `'connection'(socket, req)`. @@ -95,7 +103,8 @@ natively; an adapter normalizes its own socket to this shape. ```ts interface WrpcSocket extends EventEmitter { - send(data: string | Buffer): boolean; // false = above the high-water mark + send(data: string | Buffer, options?: { compress?: boolean }): boolean; // false = above the high-water mark + sendPrepared?(message: SharedMessage): boolean; // optional: capability `prepared` readonly bufferedAmount: number; readonly remoteAddress?: string; protocol?: string; @@ -109,16 +118,40 @@ interface WrpcSocket extends EventEmitter { Events: `'message'(data, isBinary)`, `'drain'`, `'ping'(payload)`, `'pong'(payload)`, `'close'(code, reason)`, `'error'(error)`. -Two rules the whole stack depends on: +Three rules the whole stack depends on: - **`send()` returns an honest boolean.** `false` means the buffer is above its - high-water mark and a `'drain'` will follow. That is what makes + high-water mark and a `'drain'` will follow. The built-in engine's + asynchronous deflate paths (context takeover, `async`) count their queued + bytes into that buffer, so the boolean covers them too. That is what makes [stream](../guide/streams#backpressure) and [subscription](../guide/subscriptions#backpressure) backpressure real rather than aspirational — an engine that always returned `true` would turn a slow consumer into unbounded server memory. - **A received payload may share memory with the receive buffer.** Copy it if you retain it past the listener call. +- **`protocol` is the selected subprotocol, and it decides the revision.** + The core reads `socket.protocol` once, when the socket is attached: + `wrpc.v2` is [revision 2](./protocol#versioning) and the connection + carries framed messages, anything else — `wrpc.v1`, `''`, a property the + engine never set — is revision 1, bytes as 1.0's JSON. An adapter that + forgets it is not refused; every client is served at revision 1, which + only the `wrpc.revision` attribute of `wrpc.server.connections` and the + `revision.peer` debug line show. + +Two optional extensions, both feature-detected by the core: + +- `send(data, { compress: false })` asks for this one message to go + uncompressed on a deflate-negotiated connection. A socket that ignores the + second argument simply compresses as usual. +- `sendPrepared(message)` is the fan-out path (capability `prepared`). A + room broadcast hands every recipient **one** `{ text, frames, compress }` + (`text` is the JSON, or the bytes of an attachments frame — a BINARY frame then) + object; the socket claims `frames` with its own cache — the encoded frame, + plus a deflated one per negotiated window — when it is `null`, reuses it + when it already holds that engine's cache, and falls back to `send(text)` + when another engine claimed it (a mixed room). The built-in engine + implements it; a socket without it receives `send(text)`. ### `EngineRequest` @@ -147,6 +180,7 @@ interface EngineCapabilities { deflate: boolean; cork: boolean; pause: boolean; + prepared: boolean; } ``` @@ -162,12 +196,14 @@ discovered. | `deflate` | ✅ (off by default) | follows `compression` | | `cork` | ✅ | ✅ | | `pause` | ✅ | ❌ — no socket-level pause | +| `prepared` | ✅ — `sendPrepared`, one frame per fan-out | ❌ — uws frames inside `send()` | ## The built-in engine ```js const { createNodeEngine } = require('@alexify/wrpc/engine'); +// Both are opt-in; permessage-deflate in particular is off until asked for. const engine = createNodeEngine({ perMessageDeflate: true, maxBackpressure: 1024 * 1024 }); new Server({ router, engine }); ``` diff --git a/docs/reference/errors.md b/docs/reference/errors.md index 2930bbb..d87e267 100644 --- a/docs/reference/errors.md +++ b/docs/reference/errors.md @@ -1,6 +1,6 @@ # Errors and close codes -wrpc reuses **HTTP status numbers** as its error vocabulary, on every transport. +wRPC reuses **HTTP status numbers** as its error vocabulary, on every transport. A code that reaches a WebSocket client means the same thing it would over HTTP — and in [REST mode](../guide/server#what-it-serves) it literally *is* the HTTP status. @@ -85,6 +85,7 @@ A code above `599` is reported as `500`. | `500` | Handler threw without a code; a subscription handler returned a non-iterable; an `output` validator rejected the result | router | | `501` | No responder registered for an [ask](../guide/rooms#asking-a-room); a binary stream attempted over [SSE](../guide/sse#what-it-cannot-do) | client, SSE | | `503` | Queue full, server [draining](../guide/production#graceful-shutdown), transport closed under an in-flight call, `maxChannels` reached | router, core, SSE | +| `400`, `409`, `426` (sealed) | A [sealed request](../guide/encryption#session) refused before it was read: not accepted (the pinned key may be retired), stale or replayed (a clock), or plaintext where encryption is `required`. Bare statuses, no body — the calls the request carried fail with the same code on the client, and the client emits `EncryptionRefusedError` (`code: 'ENCRYPTION_REFUSED'`, `status`); any other plaintext answer to a sealed request is a `503` | encryption | ::: tip 499 is not a failure It acknowledges a cancel the caller asked for. The client resolves the @@ -94,33 +95,41 @@ already-rejected call silently rather than surfacing it twice — you see the ## Which codes are worth retrying -| Code | Retry? | Why | -| --- | --- | --- | -| `400`, `403`, `404`, `501` | **No** | The same request will fail identically. | -| `408` | Careful | Only if the operation is idempotent — it may have completed. | -| `409` | Automatic | The SSE transport starts a fresh channel itself. | -| `429`, `503` | **Yes, with backoff** | Load or a rolling deploy; both are temporary by construction. | -| `499` | No | You caused it. | -| `500` | No, alert | A bug, not a condition. | +| Code | Retry? | Broker consumer | Why | +| --- | --- | --- | --- | +| `400`, `403`, `404`, `501` | **No** | dead letter | The same request will fail identically. | +| `408` | Careful | retry | Only if the operation is idempotent — it may have completed. | +| `409` | Automatic | — | The SSE transport starts a fresh channel itself. | +| `429`, `503` | **Yes, with backoff** | retry | Load or a rolling deploy; both are temporary by construction. | +| `499` | No | — | You caused it. | +| `500` | No, alert | retry, then dead letter | A bug, not a condition — but on a queue usually a dependency that fell over. | + +The **Broker consumer** column is the default `retryOn` policy of +[queue consumers](../guide/brokers/consumers#what-a-message-becomes): at-least-once +delivery already requires an idempotent handler, so a transient `408`/`500` is +retried with backoff up to the attempt limit before the message is dead-lettered. The client's own [reconnect](../guide/client#reconnecting) already applies truncated exponential backoff with full jitter to the *connection*. Per-call -retries are yours: wrpc never replays a call automatically, because it cannot +retries are yours: wRPC never replays a call automatically, because it cannot know whether yours is idempotent. ## WebSocket close codes Close codes are a different vocabulary — RFC 6455's, not HTTP's — and they end the *connection*, not one call. Exported as `CLOSE_CODES` from -[`@alexify/wrpc/ws`](./wire-format). +[`@alexify/wrpc/ws`](./wire-format). A [WebTransport](../guide/wt) session +ends with the same codes — the server's 1001 on shutdown, 1002 on a framing +violation — as its `closeCode`. -| Code | Name | When wrpc uses it | +| Code | Name | When wRPC uses it | | --- | --- | --- | | `1000` | Normal closure | `client.close()`, a clean goodbye. | | `1001` | Going away | The server is [shutting down](../guide/production#graceful-shutdown). Reconnect elsewhere. | -| `1002` | Protocol error | A frame violated RFC 6455 — bad opcode, bad continuation, reserved bit set. | +| `1002` | Protocol error | A frame violated RFC 6455 — bad opcode, bad continuation, reserved bit set. With reason `encryption`: a [session handshake](../guide/encryption#session) frame that did not verify or parse, or a sealed frame that did not open. | | `1006` | Abnormal closure | No close frame arrived. Never sent — it is what a local socket reports. | | `1007` | Invalid payload | A text frame that was not valid UTF-8. | +| `1008` | Policy violation | With reason `encryption`: the session handshake was refused by policy — plaintext where encryption is required, an unlisted protocol or key id, `authorize` said no, the handshake timed out, or the server had too much to say before it finished. | | `1009` | Message too big | Over `maxPayload`. | | `1011` | Internal error | The engine could not continue. | diff --git a/docs/reference/protocol.md b/docs/reference/protocol.md index 2feb6e5..b373c8f 100644 --- a/docs/reference/protocol.md +++ b/docs/reference/protocol.md @@ -2,67 +2,190 @@ 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, [Server-Sent +Events](#server-sent-events), a `MessagePort` to a worker (a Service Worker or +a SharedWorker), a [WebRTC data channel](#webrtc), a +[WebTransport](#webtransport) session or a [message broker](#broker-binding) +— the packets are identical on every one of them, which is what lets the same +client code work over any, and behind a worker. ## Stability -**This page is the frozen 1.0 protocol.** Everything below is what -`@alexify/wrpc@1.x` speaks, and an independent implementation written against -it will keep working for the life of the major version. - -What that promise covers, and what it does not: - -- **Packet types, their fields and their meanings do not change** in 1.x. A - new optional field may be added; an existing one will not change shape or - disappear. -- **Unknown packet types are answered with a `callback` carrying code 500**, - and unknown fields are ignored — which is what makes an additive change safe - for an older peer. -- **Error codes keep their meanings.** New codes may appear for new failure - modes. -- The **JavaScript API** on top of this is versioned by the package's own - semver and is a separate promise from the wire format. - -A change to any of the above is a major version, with the reasoning in the +**This page is the wire protocol at revision 2** — what `@alexify/wrpc@2.x` +speaks, next to revision 1, which it still speaks to a 1.0 peer +([Versioning](#versioning)). An independent implementation written against +it keeps working for the life of the major version. It has three tiers, and +the promise differs by tier: + +- **The core** — the [packets](#packets), their fields and their meanings, + the error codes, batch frames, binary chunks, + [introspection](#introspection), [sessions](#sessions) and + [reconnect](#reconnect) — **does not change** inside 2.x. A new optional + field may be added; an existing one will not change shape or disappear. + Unknown packet types are answered with a `callback` carrying code 500 and + unknown fields are ignored, which is what makes an additive change safe + for an older peer; error codes keep their meanings, and new codes may + appear for new failure modes. Revision 2 changed one thing in the core + 1.0 shipped: a binary frame whose first byte is `0x00` is a + [framed message](#binary-chunks), and a packet holding bytes travels as + one. No JSON packet changed — and because a 1.0 peer reads no framed + message, that one thing is what the revision marker negotiates. +- **The carrier conventions** — how a transport carries the core: + [connection metadata](#connection-metadata) and its carriers, the `enc` + negotiation on `ping`/`pong`, the [SSE](#server-sent-events) channel + handshake, the [rooms](#rooms) envelope, the [WebRTC](#webrtc) framing and + signaling messages, the [compression](#compression) rule. Additive within + 2.x under the same rule as the core. Most of them are new since 1.0 — + every `##` section 1.0 did not have carries a + — and none of them asks anything of a 1.0 client or server: the table + under [Changes since 1.0](#changes-since-1-0) says what happens when the + versions meet. What is left to configure is between the instances of one + fleet, which have no handshake to negotiate on. +- **The experimental sections** — [WebTransport](#webtransport), the + [broker binding](#broker-binding), [session encryption](#session-encryption) + with [sealed requests](#sealed-requests) and + [broker sealing](#broker-sealing) — describe revision 1 of a carrier or a + format that may change in a **minor**, with the change in the CHANGELOG; + each says so at its top, and its subpath is marked `@experimental` + ([stability](./stability#experimental-carve-outs)). + +The **JavaScript API** on top of this is versioned by the package's own +semver and is a separate promise from the wire format. A change to a stable +tier of this page is a major version, with the reasoning in the [CHANGELOG](https://github.com/Alexis-Technologies/wrpc/blob/main/CHANGELOG.md). ## Versioning -The wire carries a revision marker, negotiated as a WebSocket subprotocol: +The wire carries a revision marker. **Revision 1** (`wrpc.v1`) is what 1.0 +speaks. **Revision 2** (`wrpc.v2`) is revision 1 plus the +[framed messages](#binary-chunks): a packet whose byte values travel as +bytes. A 2.x peer speaks both, and the rule is one line — **a framed message +is sent only where the peer said it reads one**. Everywhere else a packet +holding bytes travels as the JSON 1.0 made of it (`{ "0": 137, … }` for a +typed array, `{ "type": "Buffer", "data": [ … ] }` for a Node `Buffer`), so +a 1.0 client and a 1.0 server keep working against 2.x with nothing to set. + +On a WebSocket the revision is negotiated as a subprotocol: ``` -Sec-WebSocket-Protocol: wrpc.v1 (client offer) -Sec-WebSocket-Protocol: wrpc.v1 (server echo) +Sec-WebSocket-Protocol: wrpc.v2, wrpc.v1 (client offer, newest first) +Sec-WebSocket-Protocol: wrpc.v2 (server selection) ``` -A wrpc client **offers** `wrpc.v1` by default; a wrpc server with no -app-configured `protocols`/`handleProtocols` **echoes** it back. Both sides -therefore know, before the first packet, which revision the other speaks — -and the selected name is on `connection.protocol` server-side. +A wRPC client **offers** the revisions it speaks; a wRPC server with no +app-configured `protocols`/`handleProtocols` **selects** the newest one +offered, wherever it sits in the list. Both sides therefore know, before the +first packet, which revision the connection speaks — the selected name is on +`connection.protocol` server-side, and the number on `client.revision` at +both ends. The rules that keep this compatible in every direction: -- A peer that offers **nothing** gets no subprotocol and both sides speak - this page as written — the pre-marker handshake stays valid forever. +- A **1.0 client** offers `wrpc.v1` alone, and gets it. A **1.0 server** + picks `wrpc.v1` out of a 2.x client's offer. Either way the connection + speaks revision 1. +- A peer that offers **nothing** gets no subprotocol and the connection + speaks revision 1 — the pre-marker handshake stays valid forever. +- An end that sends and reads no framed messages — `attachments: false`, or + an injected packet codec — has nothing of revision 2 to offer: it offers, + or selects, `wrpc.v1` alone. Two 2.x ends whose options disagree therefore + settle on revision 1 instead of one refusing the other's frames. The price + is that a `wrpc.v1` selection is **ambiguous**: it is what a 1.0 server + answers and what a 2.x server without frames answers, and nothing on the + handshake tells them apart. It matters only for what a 1.0 server does not + read — a browser's [carrier tokens](#connection-metadata) — and there a + client chooses its carrier rather than relying on the guess. - A server whose app configures its own `protocols` list takes over - negotiation entirely; offering `wrpc.v1` alongside app protocols is the - app's decision. -- A future `wrpc.v2` will be offered ALONGSIDE `wrpc.v1` - (`Sec-WebSocket-Protocol: wrpc.v2, wrpc.v1`), so an old server picks the - one it knows and nothing breaks. What `v2` may change is exactly what the - stability section above says `v1` never will. + negotiation entirely; the connection speaks revision 2 only when `wrpc.v2` + is what was selected. +- A future `wrpc.v3` will be offered ALONGSIDE the older names, so an old + server picks the one it knows and nothing breaks. - The name `wrpc.` is reserved as a prefix: applications must not mint their - own subprotocols under it. + own subprotocols under it. Besides revisions it holds the **carrier + tokens** of [connection metadata](#connection-metadata) — offers that are + read, never selected. + +The other carriers have no subprotocol, and each says the revision where it +can: -HTTP and SSE requests carry no subprotocol. Their marker is the reserved -**`wrpc-version`** header: every response echoes `wrpc-version: 1`, and a -request MAY send one — revision 1 accepts and ignores it, which is exactly -what reserves the negotiation seam inside the freeze (a future revision can -branch on it without breaking a v1 peer). Beyond that, the HTTP side stays -versioned by this page (additive changes only), which is safe because every -request/response pair is self-contained. +| Carrier | How the revision is said | +| --- | --- | +| WebSocket | the subprotocol, above | +| HTTP | the response header `wrpc-version`, and the request's `Accept` | +| SSE | nothing to say — always revision 1: the transport is text-only, carries no framed message, and a packet's bytes travel as JSON both ways | +| Worker port | `v` on the port's first `ping` and its `pong` | +| WebTransport | `f` in the capabilities message each end sends first ([below](#webtransport-streams)) | +| WebRTC | `f` in a description signal's `caps` ([below](#webrtc)), per half of the peer; over a raw channel, the applications' agreement | +| The broker binding | `wrpc-version` on a session's `hello` and `welcome` ([the binding](#broker-binding)); a stateless request is always revision 1 | + +**HTTP.** Every response carries **`wrpc-version`**: the newest revision the +server speaks — `2`, or `1` for a server that reads no framed messages, which +is also what 1.0 answers — named in `Access-Control-Expose-Headers` so a page +on another origin can read it. A client sends a request whose body is a +framed message only after a response said `2`; until then, and against a 1.0 +server always, its bytes travel as JSON. In the other direction a server +answers with a framed message only when the request's `Accept` names +`application/octet-stream`: a 1.0 client, `curl` and a browser's own `fetch` +name no such thing and read JSON. `Accept` rather than a request header of +wRPC's own because it is CORS-safelisted — a new request header needs a +preflight that a 1.0 server, or an application's own `cors.headers` list, +refuses. (A request MAY still send `wrpc-version`; it is accepted and +ignored.) Every request/response pair is self-contained, so a fleet behind +one address should agree: an instance that answers `2` promises that every +instance reads a frame. One that does not — a 1.0 instance still in the +pool, a rollback — refuses a frame before it reads a call out of it, with a +wRPC error packet and no `wrpc-version: 2`; a client that sent a frame and +is answered that way speaks revision 1 again and sends the same packets once +more as JSON, so the call costs a round trip and is not lost. (An answer +with no packet in it — a proxy's error page — changes nothing.) + +**Worker port.** A `MessagePort` has no handshake, and the two ends are +deployed apart — a tab that loaded before a release, a Service Worker that +outlives its pages. The page's first packet is a `ping` naming the revision +it speaks, and a 2.x end answers with its own: + +```json +{ "type": "ping", "v": 2 } +``` + +```json +{ "type": "pong", "v": 2 } +``` + +The port speaks the older of the two. A 1.0 end ignores the field and +answers a plain `pong` — revision 1 — and a 1.0 page sends no such ping, so +it is sent no framed message. Under a packet codec both are the codec's +encoding, like every other packet on the port (and the port is revision 1: +a codec owns the wire). The field means nothing on a transport that +settled the revision elsewhere. + +## Changes since 1.0 {#changes-since-1-0} + +What a 2.0 peer speaks that a 1.0 peer does not, section by section, and +what happens when the two meet. Between a client and a server nothing needs +setting: the revision is negotiated, and what is not negotiated is additive. +Two rows are **not additive**, both between the instances of one fleet — +where there is no handshake to negotiate on — and each says what to set +until every instance is upgraded; the +[CHANGELOG](https://github.com/Alexis-Technologies/wrpc/blob/main/CHANGELOG.md#migrating-from-10) +has the upgrade order. + +| Since 2.0 | Where | Meeting a 1.0 peer | Until every peer is 2.0 | +| --- | --- | --- | --- | +| **Revision 2**: framed messages of kind 1 — **binary attachments**, a packet whose byte values travel as bytes, on WebSocket, a worker port and packet-mode HTTP — never on SSE, which speaks revision 1 to every client, as 1.0 did | [Versioning](#versioning), [Binary chunks](#binary-chunks) | Negotiated: a connection with a 1.0 peer speaks revision 1 and carries no frame; its bytes travel as the `{ "0": … }` / `{ type: 'Buffer', data }` JSON of 1.0. | nothing | +| `wrpc-version: 2` on an HTTP response, and the `Accept` that asks for a framed answer | [Versioning](#versioning) | Additive: a 1.0 server answers `1` and is sent no frame; a 1.0 client sends no such `Accept` and is answered JSON. | nothing behind one address per version; a mixed fleet behind ONE address sets `attachments: false` on its 2.0 instances, so they answer `1` | +| `v` on a worker port's first `ping`/`pong` | [Versioning](#versioning) | Additive: a 1.0 end answers a plain `pong`, a 1.0 page sends no such ping — revision 1 either way. | nothing | +| **Framed messages** of kind 3 and 4 — a packet or a chunk compressed by a Node client — and the `enc` field of `ping`/`pong` that negotiates them | [Binary chunks](#binary-chunks), [Compression](#compression) | Negotiated: a 1.0 server ignores `enc` on the `ping` and answers a plain `pong`, so a 2.0 client never sends one; a 1.0 client never sends one. | nothing | +| The subprotocol **carrier tokens** `wrpc.h.` and `wrpc.m.` — a browser's declared headers and data on a WebSocket handshake — and a wider deny list of declared names (`wrpc.bearer.` is 1.0's own) | [Connection metadata](#connection-metadata) | A 1.0 server selects `wrpc.v1` and reads no token — so a 2.0 browser client that was answered `wrpc.v1` dials once more with the `wrpc_h`/`wrpc_meta` query 1.0 reads. A 2.0 server that sends no frames answers `wrpc.v1` too (and reads the tokens), so the answer is ambiguous: the redial happens there as well, and a client that offered `wrpc.v1` alone is never redialled. A 1.0 client never sends a token. | `carrier: 'query'` on a page that must reach a 1.0 server; `carrier: 'protocol'` to keep labels out of URLs | +| The **SSE channel secret**, presented as `x-wrpc-channel: .` | [Server-Sent Events](#server-sent-events) | Additive: the `ready` frame hands out the whole reference in the field 1.0 named `channel`, and a 1.0 client presents it back as it is. A 2.0 client against a 1.0 server presents the bare id that server gave it. | nothing | +| `epoch` and `seq` on the **rooms envelope** — loss detection between instances | [Rooms](#rooms) | Additive: a 1.0 instance ignores them, a 2.0 instance delivers a 1.0 instance's envelopes untracked. | nothing | +| The `wrpc-bin:` **rooms and cluster envelope** — an event (or a node-to-node question or answer) whose data holds bytes, as an attachments frame | [Rooms](#rooms), [Cluster channels](#cluster-channels) | **Not additive.** A backplane is a broadcast with no handshake: a 1.0 instance cannot parse the envelope and drops the event, without a log line. | `attachments: false` on the 2.0 instances keeps the envelope JSON, as in 1.0 | +| The `wrpc-enc:` and `wrpc-sealed:` **rooms and cluster envelopes** — compressed, encrypted | [Rooms](#rooms) | Opt-in on the publisher; a 1.0 instance drops what it cannot read. | turn `rooms.compression`, `cluster.compression` and the `encryption` options on only once every instance is 2.0 — the two-step rollout the section describes | +| `seq`, `ch` and `at` on a **signed cluster envelope** — the sender's counter, channel and clock, under `cluster.secret` | [Cluster channels](#cluster-channels) | **Not additive** one way. A 1.0 instance verifies a 2.0 envelope as before (the fields are inside the signed bytes) and applies it; a 2.0 instance refuses a 1.0 instance's envelope, which has no counter — the two halves of a mixed cluster stop seeing each other. Accepting one unasked would be a replay hole. Clusters without `secret` are unaffected. | `cluster: { replay: 'accept' }` on the 2.0 instances while a 1.0 instance is left | +| `headers` and `cache` on a procedure's `http` descriptor in **introspection** | [Introspection](#introspection) | Additive: unknown keys of a descriptor are ignored. | nothing | +| **WebRTC**: the data-channel framing, the signaling messages, the trust assertions | [WebRTC](#webrtc) | A new carrier: a 1.0 peer has no WebRTC transport and never meets it. | nothing | +| The **compression rule** — `enc` lists, a sender compresses with the first codec of its own list the peer announced | [Compression](#compression) | Governs only what two 2.0 ends negotiate. | nothing | +| **WebTransport**, the **broker binding**, **session encryption** with **sealed requests** and **broker sealing** — experimental | [WebTransport](#webtransport), [Broker binding](#broker-binding), [Session encryption](#session-encryption) | New carriers and formats, opt-in on both ends; a 1.0 peer never meets them. | nothing | ## Framing @@ -71,7 +194,9 @@ 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` | +| WebTransport | control stream, `KIND = 0` | control stream, `KIND = 1` | A transport that cannot stay open (plain HTTP) carries calls only: events, subscriptions, cancellation and streams need a persistent connection, and @@ -97,27 +222,88 @@ errors alike) MAY be re-framed — binary included — by an injected `codec.rest` section, under the same outside-the-promise terms. SSE is persistent but text-only, so it carries everything except binary -streams — see [Server-Sent Events](#server-sent-events) below. - -### Connection metadata - -A client may declare connection-phase metadata at connect time. This is a -transport-level convention, not a packet: real request headers where the -transport can send them (HTTP, SSE), and two reserved query parameters on -the WebSocket connect URL where it cannot — `wrpc_h` (declared headers) and -`wrpc_meta` (declared data), each a percent-encoded JSON object. A peer that -sends none is perfectly normal, and a server that ignores them is -conformant. A server that consumes them MUST treat them as untrusted labels: -size-capped, sanitized, and never able to override an observed request -header — wrpc's own implementation reads observed headers first and drops -reserved names (`cookie`, `host`, `origin`, `sec-*`, `content-*`, `proxy-*`, -`x-wrpc-*`) from the query path outright. The `x-wrpc-meta` request header -(percent-encoded JSON) carries per-request metadata for plain HTTP callers, -with `x-wrpc-meta-: ` accepted as an equivalent per-key -spelling — string values only, the JSON header winning a key collision. A -conformant client MAY emit either; on a transport without headers the -prefixed *mode* still travels as the `wrpc_meta` parameter, because the -guarantee is about the bag the server observes, not the wire. +streams — see [Server-Sent Events](#server-sent-events) below. A WebRTC data +channel is persistent and binary but caps the size of one message, so both +packets and chunks travel fragmented under a one-byte header — see +[WebRTC](#webrtc) below. A WebTransport session is persistent and binary +but its streams carry bytes with no message boundary, so packets and chunks +travel length-prefixed on one stream — see [WebTransport](#webtransport) +below. + +### Connection metadata {#connection-metadata} + +A client may declare connection-phase metadata at connect time: a bag of +**declared headers** and a bag of **declared data**. This is a +transport-level convention, not a packet, and it has one carrier per thing a +transport can actually send: + +| Carrier | Declared headers | Declared data | Who can send it | +| --- | --- | --- | --- | +| Real request headers | the headers themselves | `x-wrpc-meta` (percent-encoded JSON), or one `x-wrpc-meta-: ` per key | HTTP, SSE; a WebSocket client outside a browser | +| Subprotocol offers | `wrpc.h.` | `wrpc.m.` | a browser WebSocket | +| Connect-URL query | `wrpc_h` (percent-encoded JSON) | `wrpc_meta` (percent-encoded JSON) | any WebSocket client that opts into it; WebTransport | + +A browser's WebSocket constructor cannot set a request header and `fetch` +refuses to perform the upgrade by hand, so the one handshake header a page +controls is `Sec-WebSocket-Protocol`. A carrier token is the JSON object, +UTF-8, base64url **without padding** — a subprotocol name is an RFC 7230 +token, so neither raw JSON nor `=` is admissible: + +``` +Sec-WebSocket-Protocol: wrpc.v2, wrpc.v1, wrpc.h.eyJ4LXRlbmFudCI6ImFjbWUifQ, wrpc.m.eyJ1c2VySWQiOjd9 +``` + +Carrier tokens — `wrpc.h.`, `wrpc.m.` and the credential token +`wrpc.bearer.` — are **data riding the offer, never a protocol to +select**. A server MUST NOT echo one: the response would reflect a credential, +and the client did not ask for it. Because a client fails a handshake whose +offers all went unanswered, a client MUST offer a selectable protocol (a +revision — `wrpc.v2`, `wrpc.v1`) next to a carrier token, and MUST NOT emit +carrier tokens when it offers nothing else. wRPC's negotiators remove the +tokens from the offer before an application's `protocols`/`handleProtocols` +sees it. + +`wrpc.h.` and `wrpc.m.` are new since 1.0, which read the declared bags from +the connect-URL query only and ignores them (`wrpc.bearer.` is 1.0's own). +The revision is how a client finds out: a handshake that offered `wrpc.v2`, +carried a token and was answered `wrpc.v1` may have reached a 1.0 server, so +a wRPC client dials **once more** with the query carrier and keeps it for +that connection's later reconnects. A client told which carrier to use +(`carrier: 'protocol'` or `'query'`), or given its own `protocols`, is left +with that choice. + +The `x-wrpc-meta-` spelling carries string values only and the JSON +header wins a key collision. A conformant client MAY emit either; on a +carrier without headers the prefixed *mode* still travels as the JSON bag, +because the guarantee is about the bag the server observes, not the wire. + +A peer that declares nothing is perfectly normal, and a server that ignores +the declarations is conformant. A server that consumes them MUST treat them +as untrusted labels, whichever carrier brought them: + +- **Size-capped on the encoded input**, before any decoding. wRPC's default is + 2048 bytes: one budget for the two offers together (headers first), the + query measured whole, the `x-wrpc-meta` header on its own. Keep the budget + under the smallest request-header limit in the deployment — a WebSocket + handshake is an HTTP request, and uWebSockets.js allows 4096 bytes for + **all** of its headers where node allows 16 KB. +- **Refused, never fatal.** A malformed or oversize declaration leaves the + connection unlabelled; it does not close it. +- **A carrier is chosen, never merged.** For each bag: the real header, else + the offer, else the query. An offered token that is refused still silences + the query, so garbage cannot downgrade a connection to the other carrier. +- **Never able to override an observed request header.** Declared names only + add, and wRPC drops the names a hostile page could otherwise forge next to + a victim's cookie: `cookie`, `host`, `origin`, `forwarded`, `via`, + `x-real-ip`, `x-client-ip`, `true-client-ip`, `cf-connecting-ip`, + `fastly-client-ip`, `fly-client-ip`, `remote-user`, and everything under + `sec-`, `content-`, `proxy-`, `x-wrpc-` and `x-forwarded-` — and under the + prefixes an identity-aware proxy sets about the user it authenticated: + `x-auth-request-`, `x-amzn-oidc-`, `x-goog-authenticated-user-`, + `x-goog-iap-`, `x-ms-client-principal`. A declared name that is not an + RFC 9110 token is dropped, and `_` is read as `-` before the list is + checked. Real headers are not filtered — a peer that can send them + is not a page, and no deny list binds it. Keys of both declared bags are normalized to **kebab-case** (`userId` → `user-id`), so one spelling addresses a value whatever carrier @@ -140,7 +326,12 @@ a `callback` carrying code 500. ``` `method` is `unit/name` or `unit.vN/name` (`auth.v1/signIn`); the version defaults to `*`. -`id` correlates the answer and is generated by the caller. +`id` correlates the answer and is generated by the caller: a string of 1 to 255 +characters — the same bound a [chunk header](#binary-chunks) puts on a stream's id. A number is +taken too, because a 1.0 client with a counting `generateId` sent one. Any other +`id` — an object, an array, a longer string — is never echoed. Its packet is +refused with an id-less `500` (`packet.unknown`), on every packet type that +carries an id. `call`, `subscribe` and `event` packets may additionally carry an optional **`meta`** field — a plain JSON object of caller-declared, unvalidated @@ -186,7 +377,7 @@ are written for the caller — validation text, quota refusals — and travel verbatim. A 5xx message is a server internal: the peer receives the status line (`"Internal Server Error"`) and the exception text stays in the server log, correlated by the same packet `id`. A server-side error that WANTS its -message on the wire opts in with `error.expose = true`; the errors wrpc +message on the wire opts in with `error.expose = true`; the errors wRPC itself constructs (timeout, queue overflow, invalid output) are marked so. The error object MAY carry a third, optional field — `details` — with @@ -269,7 +460,14 @@ dropped NAT mapping, a suspended laptop, a proxy that stopped forwarding — looks perfectly open from JavaScript until the first call times out. Both sides answer a `ping` with a `pong` immediately; the client additionally measures the round trip and reconnects when the answer does not arrive -within its timeout. +within its timeout. On a WebRTC link each direction has its own client and +its own channel (see [WebRTC](#webrtc)), so each direction heartbeats on its +own channel. + +Two optional fields ride the pair, both ignored by a peer that does not know +them: `enc`, the [compression](#compression) offer of a Node WebSocket +client, and `v`, the revision a [worker port](#versioning) names on its +first ping. ### `subscribe` / `data` / `end` / `unsubscribe` — a stream of values @@ -357,9 +555,9 @@ one leaves the receiver to start a root span; a peer that does not understand one ignores it, like any other unknown field. The context is **per packet, not per frame**, so each call in a batch keeps its own parent. -An implementation is not required to parse these — wrpc itself does not. It +An implementation is not required to parse these — wRPC itself does not. It hands the field to whatever OpenTelemetry propagator the application -configured, which is also why a wrpc server only reads `tp` when it was given +configured, which is also why a wRPC server only reads `tp` when it was given the `@opentelemetry/api` module (or an explicit propagator) rather than a bare tracer. @@ -383,7 +581,54 @@ closes it. The payload travels as binary chunks in between. ### Binary chunks -Each binary frame is one chunk of one stream: +Each binary frame is one chunk of one stream — unless its first byte is +`0x00`, which no chunk has (a stream id is at least one byte long): such a +frame is a **framed message**, its second byte the kind. This is what +revision 2 adds to the core — revision 1 never sent an empty stream id +either, but did not reserve the byte, and a 1.0 peer reads such a frame as a +chunk of a stream it does not know: + +``` +0x00 kind payload + 1 binary attachments: u32 headerLen (big-endian), JSON `[packet, [[path, byteLength], …]]`, then the buffers back to back + 3 a packet, compressed with the codec negotiated on ping/pong (below); the receiver inflates, then reads it as a text frame + 4 a chunk, compressed likewise; the receiver inflates, then reads it as a binary chunk + other reserved — a 400 error packet +``` + +An **attachments frame** (kind 1) is a packet — or a batch array — whose +byte values travel as bytes: in the JSON every typed array, `ArrayBuffer` +or `DataView` is replaced by `null`, and the index names each one by its +path from the packet root (object keys and array indexes, the Jupyter +`buffer_paths` shape) with its length; the buffers follow in index order. +A receiver MUST refuse a frame whose paths name `__proto__`, `constructor` +or `prototype`, walk through a non-container, land on anything but `null`, +exceed 32 levels, or whose lengths do not add up to exactly the bytes that +follow — with a `400`, never a partial packet — and SHOULD copy the bytes +out of the frame rather than hold views into it. The same frame is the +body of a packet-mode HTTP request or response — and of a conventional +REST answer — under `Content-Type: application/octet-stream`. It is sent for +a packet that holds bytes **only where revision 2 was negotiated** +([Versioning](#versioning)): a WebSocket that selected `wrpc.v2`, an HTTP +exchange whose other side said it reads one, a worker port whose two ends +both named revision 2. An end that opted out (`attachments: false`) or +speaks a wire codec negotiates revision 1 in the first place; one that is +sent a frame anyway answers it as a malformed packet. SSE, being text-only, +speaks revision 1 — bytes as JSON, both ways — and refuses a frame on a +channel POST with `415`. Kind 2 is reserved. + +The negotiation is a `ping` whose `enc` is the client's codec ids in its +order of preference (a list; one id is a list of one), answered by a `pong` +whose `enc` is ONE id — the first of the client's list the server holds — +or by a plain `pong` when it holds none. Only the client compresses on this +wire, so that id is the codec of every kind 3 or 4 frame that follows. A +peer MAY send one only after such a `pong`; before that, or with a payload that does not inflate under the receiver's +cap, the frame is answered with an id-less `400` and the connection goes +on. Today only a Node client sends them (a browser's WebSocket compresses +both directions under permessage-deflate, and a server's own frames use +that extension too). + +Each ordinary binary frame is one chunk of one stream: ``` ┌────────┬──────────────────┬──────────────────────────┐ @@ -428,7 +673,7 @@ Each method carries: | `kind` | `'subscription'`, and **only** then — a client scaffolds a call unless told otherwise | | `meta` | The procedure's `meta`, when it is not empty. `meta.description` is the **one** home for prose — `wrpc types` turns it into a doc comment, so it does not also live in `signature` | | `signature` | An optional descriptor, below | -| `http` | The declarative REST mapping (`{ method, path, status? }`), when the procedure carries one. | +| `http` | The declarative REST mapping (`{ method, path, status?, headers?, cache? }`), when the procedure carries one. `headers` are the route's static response headers, `cache` its `{ maxAge, public, staleWhileRevalidate, etag }` policy — both additive, present only when declared. | A unit object may additionally carry two **reserved keys** — impossible to collide with a method, since both are reserved in the router definition too: @@ -446,8 +691,9 @@ callable one. Being an ordinary procedure, it is reachable over **every** transport: a WebSocket frame, a plain `POST {basePath}` with the packet as the body (which -is how the CLI asks, needing no socket), an SSE channel, or a worker port. It -also answers in REST mode at `{basePath}/system/introspect`. +is how the CLI asks, needing no socket), an SSE channel, a worker port, a +WebTransport session, a data channel or a broker request. It also answers in +REST mode at `{basePath}/system/introspect`. ### The `signature` descriptor @@ -527,11 +773,19 @@ With a backplane configured (`new Server({ backplane })`, see non-local emit is published as an envelope: ```json -{ "v": 1, "instance": "c0ffee…", "rooms": ["lobby"], "name": "chat/message", "data": { "text": "hi" } } +{ "v": 1, "instance": "c0ffee…", "epoch": "k3x9…", "seq": 42, "rooms": ["lobby"], "name": "chat/message", "data": { "text": "hi" } } ``` - `instance` identifies the publisher, and every instance drops its own envelopes — that is the echo suppression. +- `epoch` is the publisher's boot marker and `seq` its per-channel counter: + a receiver that sees `seq` jump within one epoch knows how many envelopes + the broker lost between them and reports the gap (`backplane.gap`, the + `wrpc.server.backplane.gaps` metric). Additive: an envelope without them + is delivered untracked. Detection only — the contract stays at-most-once. + A receiver compares epochs for equality only: a publisher whose counter + table rotated restarts an evicted channel's count under `.`, + and the receiver's cursor resets exactly as on a restart. - `rooms` is `null` for a broadcast to everyone. - An emit targeting **exactly one** room is published on that room's channel (`room:`), which only instances holding members subscribe to; @@ -543,6 +797,52 @@ Delivery is **at-most-once**. A message published while an instance is between subscriptions, or dropped by the broker, is gone: rooms are a fan-out mechanism, not a queue. +With `rooms: { compression }` on, an envelope past the threshold is +published as `wrpc-enc::` — a message that starts with `w` rather than `{`. There is +nothing to negotiate against on a fan-out, so the marker names the codec: +an instance encodes with the FIRST codec of its list and reads any codec on +it, and a receiver that holds no such codec drops the envelope (and logs). +A deployment turns compression on only once every instance can read it, +and changes codec the same way — every instance lists both, then the order +is swapped. The cluster channels below do the same under +`cluster: { compression }`, applied after the HMAC signature. + +An event whose data holds bytes cannot be a JSON envelope. It is published +as `wrpc-bin:` — the +[frame](#binary-chunks) a socket carries such a packet in, over the envelope +object instead — and delivered on the other instances as bytes. + +With `rooms: { encryption }` on, every envelope is published sealed: + +``` +wrpc-sealed:: +header = u8 version (1) ‖ u8 suite ‖ salt (16) ‖ u64 counter +frame = u8 flags ‖ [ u8 idLength ‖ codec id ] ‖ body flags bit 0: compressed, bit 1: body is a binary envelope +``` + +`kid` names the key of the shared keyring (1–32 of `A-Z a-z 0-9 . _ -`) and +travels in the clear; `suite` is `1` AES-256-GCM, `2` ChaCha20-Poly1305, +`255` a cipher both ends were handed. A sender draws `salt` once per +process, derives its own key — `HKDF-SHA256(key[kid], salt, "wrpc rooms v1" +‖ 0 ‖ kid ‖ 0 ‖ cipher id)` — and counts under it: the nonce is the counter, +64 bits big-endian at the end of the nonce, so no two messages share a +(key, nonce) pair, and the salt is drawn again after 2^32 messages. The +additional data is `"wrpc-sealed v1" ‖ 0 ‖ layer ‖ 0 ‖ kid ‖ 0 ‖ channel`, +so an envelope moved to another channel, another layer or another key id +does not open. Compression happens INSIDE the frame (compress, then seal) +and names its codec there; a `wrpc-enc:` marker never appears inside a +sealed envelope. A receiver keeps a sliding window of each sender's +counters and drops a repeat — a sender being a (kid, suite, salt), the +window in memory only: empty when the receiver starts, and dropped with +the least recently heard sender once more than `maxSenders` (1024) are +remembered. The +frame carries no time. An envelope that does not open is dropped and +logged, never answered. The cluster channels do the same under `cluster: { +encryption }` with the label `"wrpc cluster v1"`, after signing and +compression. What stays visible to the backplane: the channel name (and so +the room name), the kid, the sender's salt, message sizes and timing. + ### Cluster channels The cluster layer (`server.cluster` — presence, introspection, node-to-node @@ -557,41 +857,73 @@ command `cmd`, node event `e`). These envelopes are an implementation detail of `@alexify/wrpc`'s own cluster layer, not part of the frozen wire protocol clients speak — they never reach a client connection. +Under `cluster: { secret }` an envelope ends with `sig`, the hex +HMAC-SHA256 of the envelope's JSON text without it, and carries three +fields inside what is signed: `seq`, the publisher's counter (one per +process, across both channels, so a receiver sees gaps), `ch`, the channel +it was published on, and `at`, the publisher's clock in milliseconds. A +receiver verifies the signature, then refuses an envelope whose `ch` is not +the channel it arrived on, whose `at` is further than its `maxSkew` (30 s) +from its own clock, whose `seq` it already accepted from that `from` and +`epoch` (a sliding window of 1024), or whose `epoch` is a life of `from` +older than the one it follows. An envelope without `seq` is refused unless +the receiver runs `replay: 'accept'`. + +An envelope whose application payload holds bytes — the `data` of an +addressed event (`sendTo`) or a node event, of a question, or an answer's +`payload` — is published as the binary envelope of [Across +instances](#across-instances): `wrpc-bin:`, or the same frame inside the sealed one. Its `sig` is +the HMAC of the frame's bytes with `sig` absent — the receiver deletes +`sig` from the decoded envelope and encodes it again — so a signature made +over one form is not valid for the other. + ## Server-Sent Events SSE is one-way, so a channel is two halves that find each other by id: ``` -GET {basePath}/events opens a NEW channel -GET {basePath}/events x-wrpc-channel: re-attaches to an existing one -POST {basePath} x-wrpc-channel: client -> server +GET {basePath}/events opens a NEW channel +GET {basePath}/events x-wrpc-channel: . re-attaches to an existing one +POST {basePath} x-wrpc-channel: . client -> server ``` -**The channel id is minted by the server** and handed out exactly once, in -the `ready` frame that opens every stream: +**The channel id is minted by the server** — by the application's +`generateId`, so its format (a uuid, a cuid, a counter) is the application's +— and the **channel secret drawn by the server**: 18 random bytes as +base64url. Both are handed out, joined, in the `ready` frame that opens +every stream: ``` event: ready -data: {"channel":"b1f0…"} +data: {"channel":"b1f0….Kx9…"} ``` -A client cannot propose its own id: a GET naming an id the server does not -hold answers `409`, which is the client's signal to drop its channel state -and start a fresh one. (`?channel=` in the query string is accepted for -re-attach as well, but the header is preferred — URLs end up in proxy logs.) - -The channel is **bound to the identity that created it**: the session token -in the opening GET's cookie (or "anonymous" when it carries none). Every -re-attach and every POST must present the same cookie identity — a request -that names a live channel id without it is refused with `403`. Knowing an id -is never enough to act on someone else's channel. Channel creation is also -capped (`maxChannels`, `maxChannelsPerAddress`): past the caps a new GET -answers `503` or `429`. +ONE string, the **channel reference** — the secret after the id — which a +client treats as opaque and presents back whole in one header; the server +splits it at the LAST dot (the secret holds none; the id may). It rides the +field 1.0 named `channel` on purpose: a 1.0 client presents whatever that +field held, so it presents the secret without knowing there is one — and a +1.0 server's `channel` is the bare id, which a 2.x client presents just the +same. **The secret is the credential.** A client cannot propose its own id, and a GET or POST naming +an id without the channel's secret answers `409` — the same answer as for an +id the server does not hold, so a guessed id learns nothing; `409` is the +client's signal to drop its channel state and start a fresh one. +(`?channel=.` in the query string is accepted for re-attach as +well, but the header is preferred — URLs end up in proxy logs.) A value +without a dot presents no secret. + +On top, the channel is **bound to the identity that created it**: the +session token in the opening GET's cookie (or "anonymous" when it carries +none). Every re-attach and every POST must present the same cookie identity +— a request that presents the secret without it is refused with `403`. +Channel creation is also capped (`maxChannels`, `maxChannelsPerAddress`): +past the caps a new GET 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 @@ -603,8 +935,591 @@ missed instead of starting over — subscriptions and all. Comment frames Serverless-friendly by construction: no upgrade, no socket beyond the response body, nothing but HTTP in either direction. What it cannot carry is -binary — SSE frames are text, so wrpc's binary streams are refused on this -transport rather than silently corrupted. +binary — SSE frames are text, so wRPC's binary streams are refused on this +transport rather than silently corrupted, and a channel speaks revision 1 to +every client: a packet's bytes travel as the JSON 1.0 made of them, both +ways, exactly as between two 1.0 ends. + +## WebRTC {#webrtc} + +Two peers speak wRPC to each other over one `RTCPeerConnection` carrying +**two negotiated data channels**, one per direction of the protocol's +client → server relationship: + +``` +channel initiator (negotiated, id 0 by default) initiator's client → responder's host +channel responder (negotiated, id 1 by default) responder's client → initiator's host +``` + +The *initiator* is the peer whose id sorts first (plain string comparison); +it makes the offer, and it is the *impolite* side of +[perfect negotiation](https://w3c.github.io/webrtc-pc/#perfect-negotiation-example). +Both peers create both channels before the first offer, with the same ids +and the same label (`wrpc`) — negotiated channels are not described in the +SDP, so the two configurations must agree. The ids are configurable for an +application that keeps its own channels on the same connection. + +Because each channel is one ordinary client → server wire, every packet on +this page travels unchanged: a peer runs a client on the channel it +initiates and a dispatcher on the other, and nothing in a packet says which +peer is "the server". Signaling (descriptions and ICE candidates) is +application-level and reaches the peers through any channel the +application chooses — `@alexify/wrpc/webrtc` ships one over an ordinary +wRPC connection — and is not part of the wire either. + +What signaling carries is nonetheless fixed, so that peers behind different +signalers agree: `{ type: 'description', description, caps? }`, +`{ type: 'candidate', candidate }`, `{ type: 'connect' }` (a knock — the +non-initiator asking to be dialled) and `{ type: 'close', reason? }`, the +goodbye. Its `reason` is one of `goodbye` (the application ended the +link), `refused` (the sender would not have the peer: a failed +[trust assertion](#webrtc-assertions) or its `accept()` hook) and +`gave-up` (its redial budget ran out). A receiver MUST end the link on any +`close`, MUST read one without a `reason` as `goodbye`, and MUST report +one it does not know as `unknown` rather than refuse it — the set may grow. + +### Data-channel framing {#webrtc-framing} + +A data channel message has a size limit (16 KiB is the only size every +implementation supports; `sctp.maxMessageSize` reports what a pair actually +negotiated) and wRPC's batch frames and stream chunks are routinely larger. +So on a data channel **every message is binary** (the channel's +`binaryType` is `arraybuffer`), and a packet or chunk is sent as one or +more fragments, each under a one-byte header: + +``` +bit 0 KIND 0 = a wrpc packet (UTF-8 JSON — what a WebSocket text frame carries) + 1 = a binary stream chunk (a chunkEncode frame, see the wire-format page) +bit 1 FIN 1 = the last fragment of this message +bit 2 COMPRESSED 1 = the message is compressed with the sender's negotiated codec — only once negotiated +bit 3–7 reserved, MUST be 0 +``` + +- Fragments of one message are sent back to back on one ordered, reliable + channel, so there is no message id and no sequence number: a receiver + concatenates fragments until FIN. +- A fragment that is not the last MUST carry at least one payload byte, and + a receiver bounds how many fragments one message may arrive in (16 384 + by default — its reassembly cap in 1 KiB pieces, never fewer than 1024): + the byte cap alone would let a peer cost the receiver a buffered view per + byte, or per empty fragment. An empty continuation, or a fragment past + the count, is a protocol error like the ones below. +- The KIND and COMPRESSED bits of a continuation MUST equal those of the + message it continues; a set reserved bit, a COMPRESSED bit before the two + peers' lists shared a codec, a mismatched continuation, a text message that is + not valid UTF-8, a reassembly past the receiver's cap (16 MiB by default) + or a compressed message that does not inflate under that cap is a + protocol error, and the receiver closes the channel — the data-channel + analogue of a WebSocket `1002`. +- **Compression** is negotiated through signaling, since the channels have + no handshake: a `description` signal MAY carry `caps`, a JSON object whose + known keys are `enc` — the peer's codec ids in its order of preference + ([the rule](#compression)) — and `f`, the [revision](#versioning) of its + two halves as bits: `1` its host reads framed messages, `2` its client + does. A client half sends a frame only when the other peer's last + description set bit `1` (and it reads them itself); a host half only when + bit `2` was set. A peer that announces no `f` is spoken to at revision 1, + both ways. Over a raw channel there is nothing to announce through, so + the two applications agree — `attachments` alike on both ends, as with + compression. A peer MAY set the COMPRESSED bit only once + the other peer's last description announced a codec it holds; the payload + is then the output of the SENDER's chosen codec over the bytes the message + would otherwise carry, compressed before fragmentation and inflated after + reassembly. Over a channel the application negotiated itself there is + nothing to announce through: both ends use the first codec of their list, + and whether the bit is in use at all is the application's agreement. +- Fragment size is the negotiated `sctp.maxMessageSize` capped at 256 KiB, + and 16 KiB when nothing is reported. A peer MAY send smaller fragments. + A peer whose `a=max-message-size` is under 1 KiB is refused: the link + fails rather than fragment every packet into a thousand pieces. + +Everything above the header is exactly the WebSocket wire: the ordering rule +"a `stream` packet precedes the first chunk with its id" holds per channel, +`ping`/`pong` run per direction, and a subscription resumes with +`lastEventId` across a renegotiated connection the way it does across a +reconnected socket. + +### Trust assertions {#webrtc-assertions} + +Signaling is not part of the wire, but the token a signaling server may +attach to it is a format two parties written independently must agree on +— a peer verifying, and whoever issues (the wRPC unit or any service that +can sign a JWS). An assertion is a **JWS in compact serialization** +(RFC 7515): `base64url(header).base64url(payload).base64url(signature)`. + +``` +header { "alg": "ES256", "typ": "wrpc-rtc+jwt", "kid"?: string } +payload { "sub": string, the peer id, as the signaling layer names it + "fp": string, the DTLS certificate fingerprint: "sha-256 AB:CD:…" + "iat": number, seconds since the epoch + "exp": number, + "iss"?: string, + …any other claims } +``` + +- `alg` MUST be `ES256` (ECDSA over P-256 with SHA-256; the signature is + the raw 64-byte `r || s`, as JWS specifies). A verifier MUST refuse any + other `alg`, `none` included, and any `typ` but `wrpc-rtc+jwt`. +- `kid` selects the public key when the issuer publishes several; a + verifier configured with a single unlabelled key accepts any `kid`. +- **Binding.** An assertion travels inside a `description` signal, as its + `assertion` field, and is valid for that description only: `sub` MUST be + the peer id the signaling layer reports as the sender, and `fp` MUST be + the fingerprint the description's SDP declares — **every** + `a=fingerprint:` line of it, session- or media-level, of any algorithm, + MUST equal `fp` (algorithm, a space, colon-separated hex — compared after + normalization: algorithm in lower case, hex in upper case). DTLS binds to + the media-level line (RFC 8122 §5), so a description naming a second + certificate anywhere is one the relay may have edited, and a verifier + MUST refuse it. The DTLS handshake then proves the sender holds that + certificate. A verifier MUST apply the description only after the + assertion verified. +- `exp` MUST be in the future by the issuer's clock (a verifier SHOULD + allow a small skew and MAY learn the issuer's clock from tokens issued to + itself). `iss`, when the deployment sets one, MUST match. +- A token is at most 4 KiB. Anything else is a refusal; the reason is the + verifier's business, not the wire's — the link is simply closed. + +## WebTransport {#webtransport} + +A client speaks wRPC to a server over a WebTransport session (HTTP/3) the +way it does over a WebSocket: the packets are the same and travel in the +same order. **This section is experimental**: it describes revision 1 of +the carrier, may change in a minor release, and sits outside this page's +interoperability promise until it stabilizes — the way an injected wire +codec does. + +The client establishes the session (an extended `CONNECT` with `:protocol +webtransport`, per the WebTransport specification) and opens **one +bidirectional stream, the control stream**; the server treats the first +bidirectional stream the client opens as such, and a session that opens +none within the server's accept window is closed with code 408. Every +packet and every stream chunk, in both directions, travels on the control +stream. The session's datagrams carry unreliable events, and — once both +ends have announced it — a binary stream's chunks travel on a stream of +their own; both are described below. + +Connection metadata travels as on a browser WebSocket: the `CONNECT` +request's headers are the observed bag, and the `wrpc_h` / `wrpc_meta` +query parameters of the request path carry what the client declares. A +`CONNECT` carries no cookies (its credentials mode is `omit`), so a session +token is presented as a declared `authorization` header or a payload field +— what the bearer and payload session transports read. + +### Stream framing {#webtransport-framing} + +A QUIC stream is a byte stream with no message boundaries, so every message +on the control stream is prefixed with a five-byte header: + +``` +bytes 0–3 LENGTH payload byte length, unsigned, big-endian +byte 4 KIND 0 = a wrpc packet (UTF-8 JSON — what a WebSocket text frame carries) + 1 = a binary stream chunk (a chunkEncode frame, see the wire-format page) + 2 = a capabilities message (UTF-8 JSON) — see below + 3 = a packet, compressed with the sender's negotiated codec — only once `enc` was negotiated + 4 = a chunk, compressed — likewise + 5–255 reserved +``` + +- A message is one contiguous run of bytes; there is no fragmentation and + no FIN bit, because the stream is ordered and reliable. A receiver + buffers until LENGTH bytes have arrived, however the transport split them. +- A reserved KIND, a LENGTH past the receiver's cap (16 MiB by default) or a + packet that is not valid UTF-8 is a protocol error: the receiver closes + the session with code 1002 — the WebTransport analogue of a WebSocket + `1002`. +- Close codes are the WebSocket close codes carried in the session's + `closeCode`: a server closes with 1001 on shutdown and 1002 on a protocol + error; an application close is 1000 or 0. Ending the control stream (a + FIN) ends the connection, answered by a session close with 1000. + +Everything above the header is exactly the WebSocket wire: the ordering rule +"a `stream` packet precedes the first chunk with its id" holds, `ping`/`pong` +run per direction, and a subscription resumes with `lastEventId` across a +new session the way it does across a reconnected socket. + +### Capabilities and per-stream transport {#webtransport-streams} + +Each end's **first message** on the control stream MAY be a capabilities +message (KIND 2): a JSON object whose known keys are `streams` +(`{"streams":true}`), `enc` (the end's codec ids in its order of +preference, `["deflate-raw"]` for the platform default) and `f` (`1`: this +end reads framed messages — revision 2). Unknown keys are ignored; an end +that sends none, or `{}`, has announced nothing, and its peer treats it as a +revision-1 peer. + +**The revision** is settled here, as on a worker port: an end sends a +framed message only once the peer's capabilities carried `f`, and only when +it reads them itself — so a session speaks revision 2 when both ends do, +and an end with `attachments: false` or a packet codec announces no `f` +and is sent none. Until the peer's capabilities arrive, a packet holding +bytes leaves as the JSON 1.0 made of it, which every end reads. + +Once the two `enc` lists share a codec, either end MAY send a packet as +KIND 3 or a chunk as KIND 4: the payload is the output of the SENDER's codec +([the rule](#compression) — no frame names it) over the +bytes KIND 0 or 1 would have carried, and the receiver inflates it before +reading — a KIND 3 payload is UTF-8 JSON only after inflation. The choice is +per message and the sender's (a message under its threshold, or one the +codec did not shrink, goes as KIND 0 or 1 at any time). A compressed kind +before the lists shared a codec, a payload that does not inflate, or one +that inflates past the receiver's cap is a protocol error (1002). Datagrams +and chunks on their own streams are never compressed. + +When **both** ends announced `streams`, a sender MAY carry a binary +stream's chunks on a **unidirectional WebTransport stream of their own** +instead of the control stream: + +- The `stream` packet that opens the wRPC stream (`{ type: 'stream', id, + name, size }`) stays on the control stream — its order against the call + that names the id is what the control stream guarantees. +- The unidirectional stream opens with the chunk header — one byte of id + length, then the id (exactly the prefix of a `chunkEncode` frame) — once, + and then carries the payload bytes of every chunk of that id, in order, + with no per-chunk header. Its FIN is the stream's `end`; a RESET (an + aborted stream) is its `terminate`. The sender MUST NOT also send the + `end` or `terminate` packet for such a stream on the control stream. +- A QUIC stream is ordered only against itself. A receiver MUST therefore + hold chunks that arrive before their `stream` packet has been read from + the control stream, and MUST deliver the synthesized `end` or `terminate` + only after the unidirectional stream has ended — never on the strength + of the control stream alone. +- The choice is per stream and the sender's: a stream whose chunks were + sent on the control stream ends with an `end` packet there, exactly as in + revision 1, and a receiver accepts both forms at any time. + +### Datagrams {#webtransport-datagrams} + +A session's datagrams carry **unreliable events**: a packet a sender chose +to deliver at most once, unordered, for state a later packet supersedes (a +cursor, a position). A datagram is one whole message under a one-byte +header — the KIND byte alone, since a datagram announces its own length: + +``` +byte 0 KIND 0 = a wrpc packet (UTF-8 JSON); other values reserved +bytes 1… the packet +``` + +- Only `event` packets without an `id` MAY be sent as datagrams: a call, a + callback, a stream packet, a chunk or an ask expects an order or an + answer that a datagram cannot promise. A receiver handles a datagram's + packet exactly as it would the same packet from the control stream. +- A sender MUST NOT split a packet over datagrams: a packet larger than + the session's `maxDatagramSize` goes on the control stream instead. The + choice is the sender's alone and invisible to the receiver; a peer with + no datagrams sends every event on the control stream. +- A receiver ignores a datagram it cannot read (a reserved KIND, invalid + UTF-8, an empty one) — a lost datagram is the norm, and an unreadable + one is not worth a hangup. +- A sender SHOULD drop, not queue, a datagram its session is not ready + for: a datagram that waits is a stale one. A dropped datagram is + reported to the application as sent — it is not retried on the control + stream, where it would wait behind the same congestion. + +## Broker binding {#broker-binding} + +A client speaks wRPC to a server **through a message broker** — RabbitMQ, +NATS, Redis — instead of over a socket: the packets are unmodified wRPC +packets, carried as broker messages with a few headers around them. **This +section is experimental**: it describes revision 1 of the carrier, may change +in a minor release, and sits outside this page's interoperability promise +until it stabilizes, like the WebTransport carrier above. + +The broker supplies addressable inboxes with at-most-once delivery, per-sender +ordering and optional competing groups (the `direct` capability, +[Message brokers](../guide/brokers#direct)). A message carries a body, string +headers, a `correlationId` and a `replyTo` address. The binding's own headers +all start with `wrpc-`; a peer's connection headers (`authorization`, +`x-wrpc-meta`, …) ride next to them, lower-cased, and any peer-supplied +`wrpc-*` name is dropped by the receiver. + +| Header | Values | +| --- | --- | +| `wrpc-kind` | `request`, `response`, `hello`, `welcome`, `packet`, `chunk`, `bye` | +| `wrpc-seq` | a session frame's number, from `1`, per direction | +| `wrpc-inbox` | on `welcome`: the address the session's frames go to | +| `wrpc-reason` | on `bye`: why, for logs | +| `wrpc-enc` | on a `request`, `hello` or `welcome`: the sender's codec ids, joined by commas, in its order of preference (`zstd,deflate-raw`); on a `response`, `packet` or `chunk`: the ONE codec its body is the output of — the sender's first that the other end listed. A `welcome` carries it only when the lists share a codec. A marked frame a receiver cannot inflate ends the session. | +| `wrpc-version` | on a `hello`: `2` when the client reads framed messages; on a `welcome`: `2` when the service does. A session sends a frame only to a side that said `2` ([revision](#versioning)); a stateless request is revision 1 both ways — its body is read as text and its answer is JSON | + +Every server instance consumes one **service address** — `wrpc.` by +default — as members of one competing group, so each message addressed to the +service reaches exactly one instance. + +### Stateless requests {#broker-stateless} + +``` +client --request {correlationId, replyTo = client inbox}--> service address +client <--response {correlationId}------------------------- the instance that took it +``` + +The `request` body is exactly what a packet-mode HTTP POST carries — one +packet or a batch frame — and the `response` body exactly what its answer +carries. The request's peer headers are that POST's headers: session tokens, +declared metadata and trace context are read from them the same way. Any +instance answers any request; nothing binds a client to one. A request that +cannot carry its answer on a later frame — a `subscribe`, a `cancel`, an +`event` — is refused as it is on HTTP, with code `400`. + +### Sessions {#broker-sessions} + +``` +client --hello {correlationId = session id, replyTo = client inbox}--> service address +client <--welcome {wrpc-inbox = instance inbox}------------------------ the instance that took it +client --packet | chunk {wrpc-seq, correlationId}--> instance inbox +client <--packet | chunk {wrpc-seq, correlationId}-- the instance +either --bye {correlationId}--> the other +``` + +- The `session id` is chosen by the client and names the session in every + later message, in both directions (`correlationId`). The `hello`'s peer + headers are the session's connection metadata, as a WebSocket upgrade's + headers are. +- A `packet` body is one packet or a batch frame, UTF-8; a `chunk` body is a + binary stream chunk (a `chunkEncode` frame, see the + [wire format](./wire-format)). Everything a WebSocket carries — calls, + events, subscriptions, cancellation, streams — travels this way. +- Frames are numbered from `1` in each direction. A receiver that sees a + number other than the next one treats the session as **lost**: the carrier + is at-most-once, and a missing frame is a missing packet nobody would ever + answer. The server ends the session; the client closes and reconnects. +- A frame for a session the instance does not hold (it restarted, or the + session idled out) is answered with a `bye` to its `replyTo`, and the client + reconnects. +- The server ends a session nothing arrived on for its idle timeout. The + client's app-level heartbeat (`ping`/`pong`) is what keeps a live session + inside it. +- A server that refuses sessions answers `hello` with `bye`. + +A client that reconnects says `hello` again and may be welcomed by another +instance; its subscriptions resume with `lastEventId` as on any reconnect. + +### Sealed frames and events {#broker-sealing} + +A broker keeps what it carries — for a topic's whole retention, for whoever +can read it or its backups. With `encryption: { keys }` on the binding (the +service and its clients share the keyring, as instances share +`rooms.encryption`) every message is sealed with the +[backplane envelope's frame](#across-instances): + +``` +headers wrpc-sealed: + wrpc-kind, wrpc-seq on an RPC frame +body sealed( u32 headerLength ‖ JSON(headers) ‖ body ) +``` + +The HEADERS move inside with the body — a client's `authorization` and +`x-wrpc-meta`, the trace context, `wrpc-enc`, `wrpc-inbox`, `wrpc-reason` — +so a bearer token no longer rests in the broker. An RPC frame is bound to +`address ‖ 0 ‖ kind ‖ 0 ‖ correlation id ‖ 0 ‖ seq` (label +`"wrpc broker-rpc v1"`): the readable `wrpc-kind` and `wrpc-seq` cannot be +rewritten, a frame does not open in another conversation, and a per-sender +window drops a replay. A published event is bound to its **topic** (label +`"wrpc broker-log v1"`), carried as base64 text — a log is only promised to +keep a string — and has no replay window: a log is read again and a delivery +redelivered, by design. The partition `key` stays readable; the broker +routes by it. A message that does not open is dropped (RPC), skipped (a +feed) or dead-lettered with `400` (a consumer), logged, and never answered. +A session opened by a sealed `hello` — welcomed by a sealed `welcome` — takes +sealed frames only from then on, whatever `acceptPlaintext` says: a +plaintext `packet`, `chunk` or `bye` naming it is a downgrade, dropped on +either end without consuming a sequence number. + +A sealed `request` and a sealed `hello` — the frames that reach the +competing group — carry `wrpc-t`, the sender's clock in milliseconds since +the epoch, **inside** the seal. A receiver MUST refuse one whose clock is +further than its skew allowance (five minutes by default) from its own: the +envelope's per-sender replay window lives in one process, and a captured +frame replayed to another instance would otherwise be new to it. A receiver +MAY consult a memory shared by the instances (keyed by the envelope's own +header — key id, sender salt, counter) to refuse the replay within that +allowance too. + +## Compression {#compression} + +Every transport carries plain bytes unless the application turns compression +on; nothing negotiates it by default. Where it exists it is the carrier's own +mechanism, never a field of a wRPC packet: + +| Transport | Mechanism | Negotiated by | +| --- | --- | --- | +| WebSocket | RFC 7692 `permessage-deflate` (`perMessageDeflate` on the engine) | the upgrade handshake | +| HTTP, packet mode and REST | `Content-Encoding` on the response — `gzip` by default, `br`, `zstd` or an application's coding by `http.compression.encodings` | the request's `Accept-Encoding` against the server's list, in the server's order; the response carries `Vary: Accept-Encoding` | +| Server-Sent Events | `Content-Encoding` on the stream — one encoder for the response (for gzip, one member), flushed after every event (`sse.compression`) | the opening GET's `Accept-Encoding`, per response | +| WebTransport | per message, KIND 3/4 on the control stream (`compression` on both ends) | the `enc` list of the capabilities message | +| WebSocket, client → server from Node | per message, framed binary (`0x00 03` / `0x00 04`) above the extension (`compression` on both ends) | `{ type: 'ping', enc: [ids] }` from the client, answered by `{ type: 'pong', enc: id }` — the first of them the server holds | +| WebRTC | per message, the COMPRESSED bit of the data-channel header (`compression` on both peers) | the `caps.enc` list of the description signal; a raw channel by the application's agreement | +| The broker binding | per frame, `wrpc-enc` on the frame (`compression` on both ends) | the `wrpc-enc` lists of `hello`/`welcome` for a session; a stateless request lists what it accepts and only the answer is compressed | +| The rooms backplane, the cluster channels | the whole envelope, base64 under a `wrpc-enc::` marker (`rooms.compression`, `cluster.compression`) | none — the marker names the codec; every instance must hold it, in a two-step rollout | + +**Choosing the codec.** A codec is named by an id — `deflate-raw` (raw +DEFLATE, RFC 1951, no zlib or gzip wrapper), `brotli` (RFC 7932), `zstd` +(RFC 8878), `deflate-raw+dict:` for a preset dictionary, or an +application's own; an id holds no comma and no whitespace. Wherever two ends +negotiate, each announces the ids it can **decode**, in its order of +preference, and a sender compresses with **the first codec of its own list +the other end announced**. The receiver holds both lists, so it knows which +codec that is: no frame names it (the broker binding's frames do, its +header being there anyway), the two directions choose independently — a +server may answer in `zstd` a client that sends `deflate-raw` — and there is +no tie to break. Lists that share nothing, or an end that announced +nothing, leave the wire plain. A receiver looks at no more than the first 16 +ids of a peer's list and ignores entries that are not strings. + +An HTTP response is encoded only when its body is at or over the configured +threshold and nothing upstream already set a `Content-Encoding`; a `204` and +a `304` never are. A REST route's weak ETag is computed over the plain body, +so it is the same validator whichever encoding the peer asked for. On an +event stream the encoding is a property of one response: a re-attach +negotiates it again, and a replay goes out in whatever the new response +negotiated. + +## Session encryption {#session-encryption} + +**This section is experimental** — this one, [Sealed requests](#sealed-requests) +under it and [Broker sealing](#broker-sealing): they describe revision 1 of +each format, may change in a minor release, and sit outside this page's +interoperability promise until they stabilize, the way an injected wire codec +does. The subpath is marked `@experimental` whole +([Stability](./stability#experimental-carve-outs)). + +Opt-in (`@alexify/wrpc/encryption`), on the persistent transports, and never +in place of TLS. A client announces it with `wrpc_e=1` in the connect URL — +the server may be the first to send, so it has to know the mode before any +frame. The flag is not a secret: a client configured to encrypt never +accepts plaintext, so stripping it buys a refusal. + +Every frame of such a connection is a binary +[framed message](#binary-chunks): + +``` +handshake, first 00 05 ‖ u8 version (1) ‖ u8 nameLength ‖ protocol name + ‖ u8 kidLength ‖ kid ‖ noise message +handshake, later 00 05 ‖ noise message +sealed 00 06 ‖ AEAD( u8 inner ‖ payload ) inner: 0 text, 1 bytes +``` + +The handshake is the [Noise Protocol Framework](https://noiseprotocol.org/noise.html) +(revision 34) under its canonical names — +`Noise__25519__SHA256`, patterns `NN`, `NK` +(the default: the client pinned the server's static key), `XX` (mutual) and +`NNpsk0`. The first message **names** the protocol and the key id of the +server static it was written for; the server holds it or closes the +connection. Nothing is negotiated back — a reply of "try this instead" would +be a downgrade channel. The prologue is +`"wrpc.v1" ‖ 0 ‖ transport kind ‖ 0 ‖ hello header`, so a header rewritten in +flight, or a handshake replayed onto another kind of connection, fails the +handshake. (That `wrpc.v1` is the label of this framing, fixed with its own +version byte — not the [revision](#versioning) the connection negotiated.) + +After it, each direction is a Noise CipherState: the nonce is the message +counter (four zero bytes, then 64 bits — big-endian for AESGCM, little-endian +for ChaChaPoly), the additional data is empty, and both ends rekey +(`REKEY`, Noise §11.3) every 2^20 messages. The interval is a +per-deployment constant (`rekeyAfter`, 2^20 by default), not negotiated — +no handshake message carries it — and two ends that disagree fail at the +first rekey, as a frame that does not open. Text packets, stream chunks and +the other framed kinds all travel as the payload of a sealed frame, so +compression happens inside it. A frame that does not open — altered, +replayed, out of order — closes the connection: `1002` for anything that did +not verify or parse, `1008` for a refusal of policy (an unlisted protocol, an +unknown kid, `authorize`, the handshake timeout, plaintext under `required`). +The close reason is always `encryption`; which check it was is in the +server's log only. + +A server's static keys are derived from one configured secret per kid — +`X25519(HKDF-SHA256(secret, "wrpc noise static v1"))`, and a second pair +under `"wrpc hpke static v1"` for the per-request binding — and published as +the bundle `::` (base64url), which is what a +client pins. + +On **WebTransport** the same frames ride the control stream as `KIND_BINARY` +messages, after the capabilities exchange, with `wt` as the transport kind +in the prologue. A sealed session announces no `streams` capability and +uses no datagrams — binary streams fall back to chunks on the control stream +and an `unreliable` event is sent reliably — because a stream or a datagram +of its own would be a way around the channel; the carrier's own per-message +compression is left off as well, since what it would compress is ciphertext. + +### Sealed requests {#sealed-requests} + +A request is not a connection, so the **HTTP** transport seals each one on +its own with [HPKE](https://www.rfc-editor.org/rfc/rfc9180) (base mode, +`DHKEM(X25519, HKDF-SHA256)`, `HKDF-SHA256`, and `AES-256-GCM` or +`ChaCha20Poly1305`) to the server's `hpke` static key — the construction of +Oblivious HTTP ([RFC 9458](https://www.rfc-editor.org/rfc/rfc9458) §4), +without its relay: + +``` +POST Content-Type: application/wrpc-sealed +request u8 version (1) ‖ u8 aead id ‖ u8 kidLength ‖ kid ‖ enc (32) ‖ ct +plaintext u32 headerLength ‖ JSON { m, u, h, t } ‖ body +response nonce (32) ‖ AEAD( u32 headerLength ‖ JSON { s, h } ‖ body ) +``` + +The real request is inside — method `m`, path and query `u`, headers `h`, the +sender's clock `t` in ms — so an observer sees one endpoint being POSTed to, +and a `GET` REST route travels as that POST too. The inner headers are laid +over the outer request's, with three exceptions a server MUST keep: what +the connection or a proxy in front of it says about the sender (`host`, +`origin`, `forwarded`, `via`, `x-forwarded-*`, `x-real-ip`, the CDNs' +client-ip spellings, `sec-*`) and what an identity-aware proxy says about +the user (`remote-user` and the `x-auth-request-*`, `x-amzn-oidc-*`, +`x-goog-authenticated-user-*`, `x-goog-iap-*`, `x-ms-client-principal*` +names — the same as on the [handshake](#connection-metadata)) is never +taken from inside — its absence is a fact too, so an inner one is dropped +whether or not the outer request carried it; the framing names (`content-length`, `transfer-encoding`, +`connection`) are the outer request's; and an outer `cookie` wins over an +inner one. `headerLength` above 16 KiB is refused. HPKE `info` is +`"wrpc http v1" ‖ 0 ‖` the request prefix (version, AEAD id, kid). The answer +is sealed under a key both ends export from the same context: +`secret = Export("wrpc http response", Nk)`, +`prk = Extract(enc ‖ nonce, secret)`, then `Expand(prk, "key")` and +`Expand(prk, "nonce")`. The outer status is always `200`; the real status `s` +and headers `h` are inside, except `Set-Cookie`, which stays on the outer +response because script cannot set an `HttpOnly` cookie. + +HPKE has no replay protection, and the party this exists for is exactly the +one who could replay: a request whose `t` is further than `maxSkew` (5 min) +from the server's clock, or whose `enc` was already accepted, is refused +`409`. Anything that does not parse or open is refused `400`. A refusal is +never sealed and never detailed — and a client that encrypts treats ANY +response that is not `application/wrpc-sealed` as an error, whatever its +status. + +**Server-Sent Events** ride the same binding: the stream request and every +channel POST are sealed requests — so the channel id and `Last-Event-ID` +travel inside — and the stream comes back as + +``` +Content-Type: text/event-stream; wrpc-sealed=1; n= +data: +``` + +one opaque `data:` event per chunk of the real stream, control frames +(`ready`, `gap`, the heartbeat comment) included. The key is +`Expand(Extract(enc ‖ n, Export("wrpc sse stream", Nk)), "key", Nk)`: the +secret only the two ends of the request that opened THIS stream can +export, salted with the request's `enc` and 32 bytes `n` the SERVER draws +per stream and carries in the `Content-Type` parameter (the one response +header a cross-origin page can always read; a stream without a 32-byte +`n` MUST be refused). The frame nonce is the counter from zero. So a +re-attached stream has a new key and replays under it, and so does the +same open request replayed onto another instance, whose replay memory is +its own — without `n` the two streams would count from zero under one key. +A frame that is dropped, +reordered or altered errors the stream, which the client sees as a broken +connection and re-attaches from. The type stays `text/event-stream` so that +intermediaries treat it as one; an HTTP content coding is never applied to a +sealed stream or a sealed answer. + +`GET /encryption-key` answers `{ "key": "" }` — the one +plaintext answer under `required` (`discovery: false` removes it). It is +trust on first use. + +What stays outside the sealed channel is everything the upgrade carried: +the URL, real headers, the `wrpc.bearer.` / `wrpc.h.` / `wrpc.m.` subprotocol +tokens, cookies. Under session encryption a credential belongs in +`authenticate` or a per-call `meta`, not in the handshake request. ## Reconnect diff --git a/docs/reference/stability.md b/docs/reference/stability.md index 2868aed..5663135 100644 --- a/docs/reference/stability.md +++ b/docs/reference/stability.md @@ -1,6 +1,6 @@ # Stability & deprecation -What "1.x" promises, in one place — the consumer-facing copy of the policy +What 2.x promises, in one place — the consumer-facing copy of the policy [CONTRIBUTING.md](https://github.com/Alexis-Technologies/wrpc/blob/main/CONTRIBUTING.md#stability-and-deprecation) holds for contributors. @@ -8,8 +8,11 @@ holds for contributors. **The `exports` subpaths are the public API.** Everything reachable through `@alexify/wrpc` and its subpaths (`/ws`, `/engine`, `/uws`, `/fastify`, -`/express`, `/scaling`, `/sse`, `/query`, `/auth`), as typed by the -hand-maintained root `.d.ts` files, is stable under semver. Deep imports +`/express`, `/scaling`, `/sse`, `/query`, `/auth`, `/deflate`, `/webrtc`, +`/wt`, `/encryption`, `/broker`, `/broker/redis`, `/broker/nats`, +`/broker/amqp`, `/broker/kafka`), as typed by the hand-maintained root +`.d.ts` files, is stable under semver — except where a subpath is listed +under the carve-outs below. Deep imports into `src/` are **not** addressable and not supported: the module layout may change in any release (and has — `rpc/core.js` split twice already). @@ -24,20 +27,46 @@ may change in any release (and has — `rpc/core.js` split twice already). ## `@experimental` carve-outs -Two areas are marked `@experimental` in the `.d.ts` files and may change in +Five areas are marked `@experimental` in the `.d.ts` files and may change in a **minor** (described in the CHANGELOG): - the **telemetry** writer shapes and metric set (the `telemetry` option, and the `RpcServer#otel` getter the fastify adapter brackets delegated routes with) — the signals will keep improving; - the **engine port** internals beyond the documented `WrpcSocket` contract - (`capabilities` in particular). + (`capabilities` in particular); +- **WebTransport**, whole: the `wt` client transport and its `wt` connect + option, the `@alexify/wrpc/wt` subpath, and the + [control-stream framing](./protocol#webtransport) it speaks — Node has no + WebTransport of its own yet, and the carrier will follow what lands. +- the **message-broker family**, whole: `@alexify/wrpc/broker` and every + `@alexify/wrpc/broker/*` adapter subpath, their capability contracts, the + broker metrics, the `broker` client transport and its + [broker binding](./protocol#broker-binding) — until every adapter has shipped + ([Message brokers](../guide/brokers)). +- **application-level encryption**, whole: the `@alexify/wrpc/encryption` + subpath and every `encryption` option it feeds — the server's and the + client's session encryption, `rooms.encryption`/`cluster.encryption`, the + broker bindings', `sealedStore` — with `Client.encryption`, + `RpcServer.encryptionKey()`/`encryptionRequired` and `attach({ encrypted })`, + and the [session encryption](./protocol#session-encryption), + [sealed requests](./protocol#sealed-requests) and + [broker sealing](./protocol#broker-sealing) wire formats — until the + formats have been reviewed against real deployments + ([Encryption](../guide/encryption)). TLS is not in this list: it is the + platform's. ## The wire protocol's own, stronger promise -The [wire protocol](./protocol#stability) is **frozen at 1.0**: packet -shapes never break inside a major, new fields are additive and ignorable, -and the revision is named on the wire — the `wrpc.v1` subprotocol on -WebSocket, the reserved `wrpc-version` response header on HTTP. An -independent implementation written against the reference keeps working for -the life of the major version. +The [wire protocol](./protocol#stability) is documented at **revision 2**, +in three tiers: the packet core, which never breaks inside a major — 2.0 +added one thing to it, the framed messages that carry bytes as bytes, and +that is what the revision named on the wire negotiates: `wrpc.v2` on a +WebSocket and `wrpc-version: 2` on HTTP between two 2.x ends, `wrpc.v1` +with a 1.0 peer, [with nothing to configure](./protocol#versioning); the +carrier conventions, additive under the same rule, of which the ones a 1.0 +peer never saw are badged `since 2.0` and listed with what happens when the +versions meet under [Changes since 1.0](./protocol#changes-since-1-0); and +the experimental sections, which follow the carve-outs above. An independent +implementation written against the reference keeps working for the life of +the major version. diff --git a/docs/reference/wire-format.md b/docs/reference/wire-format.md index 83c707b..1c52e80 100644 --- a/docs/reference/wire-format.md +++ b/docs/reference/wire-format.md @@ -1,8 +1,10 @@ # Wire format [The protocol reference](./protocol) describes the **packets**. This page -describes what carries them: the binary chunk framing wrpc defines, and the -from-scratch RFC 6455 WebSocket implementation the default engine is built on. +describes what carries them: the binary chunk framing wRPC defines, the frames +a [WebRTC data channel](#data-channel-frames) and a [WebTransport +stream](#webtransport-stream-frames) wrap them in, and the from-scratch +RFC 6455 WebSocket implementation the default engine is built on. Most applications never need this page. Read it if you are implementing an [engine](./engine), speaking the protocol from another language, or debugging @@ -10,6 +12,14 @@ something at the frame level. ## Binary chunks +::: tip A binary frame whose first byte is `0x00` is not a chunk +A stream id is at least one byte long, so `0x00` never begins a chunk: it +begins a *framed message* — a packet carrying [binary attachments](../guide/streams#attachments), +or a packet a Node client compressed. The [protocol page](./protocol#binary-chunks) +has the kinds, and framed messages are [revision 2](./protocol#versioning): +none is sent to a peer that negotiated revision 1. +::: + JSON packets travel as WebSocket **text** frames. Stream payloads travel as **binary** frames, and each one is exactly one chunk of one stream: @@ -40,6 +50,86 @@ const { id, payload: bytes } = chunkDecode(frame); The Node build uses `Buffer`, the browser build `TextEncoder`/`TextDecoder` — swapped through the package's `browser` field, so the same code works in both. +## Data-channel frames + +On a WebRTC data channel (the `@alexify/wrpc/webrtc` transport) both packets +and chunks travel as **binary** messages, because a channel message has a +size limit and a packet or a chunk may not fit in one. Each message is one +fragment, prefixed by a single header byte: + +``` +┌────────────┬────────────────────────────────────────┐ +│ 1 byte │ fragment payload │ +│ KIND|FIN │ UTF-8 JSON (KIND 0) or chunk (KIND 1) │ +└────────────┴────────────────────────────────────────┘ +bit 0 KIND, bit 1 FIN (last fragment), bit 2 COMPRESSED (the message body is +under the codec the two descriptions' `caps` agreed on), bits 3–7 reserved (0) +``` + +A packet under the limit is one message: header `0b11`, then the JSON. A +64 KiB stream chunk at a 16 KiB limit is five messages: four with header +`0b01` and a final `0b11`, the payloads concatenated being exactly the chunk +frame above (id length, id, payload). The receiver concatenates until FIN — +the channel is ordered and reliable, so no message id is needed — and then +hands a KIND 0 message to the packet parser and a KIND 1 message to +`chunkDecode`. The rules and the error cases are in +[the protocol reference](./protocol#webrtc-framing). + +```js +const { FrameEncoder, FrameDecoder, KIND_TEXT } = require('@alexify/wrpc/webrtc'); + +const encoder = new FrameEncoder(16 * 1024); // the negotiated maxMessageSize +encoder.encodeText('{"type":"ping"}', (frame) => channel.send(frame)); +const decoder = new FrameDecoder(); +const message = decoder.push(event.data); // null until FIN; then { kind, data } +``` + +The frame handed to the sink is a view over a buffer the encoder reuses for +the next fragment — hand it to `send()`, which copies, and never keep it. + +## WebTransport stream frames + +On a WebTransport session (`transport: 'wt'`, `@alexify/wrpc/wt`) every packet +and chunk travels on one bidirectional stream, the control stream. A QUIC +stream carries bytes with no message boundary, so each message is +length-prefixed: + +``` +┌──────────────────┬────────┬────────────────────────────────────────┐ +│ 4 bytes │ 1 byte │ payload │ +│ LENGTH (BE u32) │ KIND │ UTF-8 JSON (KIND 0) or chunk (KIND 1) │ +KIND: 0 text packet · 1 chunk · 2 capabilities (the first frame each way) · +3 compressed text · 4 compressed chunk (under the negotiated codec) +└──────────────────┴────────┴────────────────────────────────────────┘ +``` + +A `{"type":"ping"}` packet is 20 bytes: `00 00 00 0F 00` and then the 15 +bytes of JSON. A 64 KiB stream chunk is one message of 65 541 bytes, however +many reads it takes to arrive; the receiver hands a KIND 0 message to the +packet parser and a KIND 1 message to `chunkDecode`. The rules and the error +cases are in [the protocol reference](./protocol#webtransport-framing). + +```js +const { frame, frameText, StreamParser, KIND_BINARY } = require('@alexify/wrpc/wt'); + +writer.write(frameText('{"type":"ping"}')); // a fresh frame — safe to queue +writer.write(frame(KIND_BINARY, chunkEncode(id, bytes))); +const parser = new StreamParser({ onMessage: (kind, data) => {} }); +for await (const read of readable) parser.push(read); // onMessage once per message +``` + +Unlike the data-channel encoder, the frames here are fresh buffers: a WHATWG +writer takes its chunk by reference and may process it after `write()` +returns, so a reused scratch buffer would corrupt what is still queued. + +Two more shapes ride a session. A **datagram** (an unreliable event) is one +whole packet under the KIND byte alone, `00` then the JSON — see +[the protocol reference](./protocol#webtransport-datagrams). A **binary +stream on its own unidirectional stream** opens with the chunk header — +`idLen`, then the id — and then carries raw payload bytes to its FIN; the +receiver rebuilds `chunkEncode` frames from them, one per read +([the protocol reference](./protocol#webtransport-streams)). + ## The WebSocket engine `@alexify/wrpc/ws` publishes the implementation itself. It is **not** in the @@ -64,7 +154,8 @@ wss.on('connection', (connection, req) => { | `path` | — | Restrict upgrades to this pathname. | | `verifyClient({ req, socket, head })` | — | Gate the handshake. | | `protocols` / `handleProtocols(offered, req)` | — | Subprotocol negotiation; `false` rejects the handshake. | -| `perMessageDeflate` | off | `true` or `{ threshold }`. | +| `perMessageDeflate` | off | `true` or `{ threshold, filter, contextTakeover, level, memLevel, async }`. | +| `coalesce` | `true` | Cork every write of one event-loop turn and flush on the next tick, one `writev` per burst. | | `pingInterval` | `10000` | Protocol-ping interval; a peer that misses one is terminated. | | `maxBuffer` | 100 MiB | Largest inbound message. | | `maxBackpressure` | `maxBuffer` (100 MiB) | Outbound cap; a connection past it is **terminated** (the peer observes `1006`). | @@ -111,6 +202,14 @@ uws engine's default outbound ceiling is its 16 MiB `maxPayload`; set are being consumed, the RPC layer stops reading from the socket, so the pressure reaches the sender through TCP. +Server connections **coalesce** writes (`coalesce: true`): the first write of +an event-loop turn corks the socket and the next tick uncorks it, so the N +answers to a batch or a burst of events leave in one `writev` instead of N +syscalls — the idiom `node:http` uses. The boolean stays honest: a corked +write still reports the buffered length against the high-water mark, and +`'drain'` is unchanged. A bare `Connection` defaults to `coalesce: false`, +so a write is on the socket the moment `send()` returns. + ### Payload ownership A received payload may be a view into the receive buffer. **Copy it if you @@ -155,22 +254,135 @@ Two things that matter at volume, both measured by `bench/`: RFC 7692 compression, over `node:zlib`, **off by default**: ```js -new WebsocketServer({ server, perMessageDeflate: { threshold: 1024 } }); +new WebsocketServer({ + server, + perMessageDeflate: { + threshold: 1024, + // Per connection: compress for a browser on a slow link, not for a + // service in the same datacenter. Declines the offer, so the peer + // learns it from the handshake. + filter: (req) => req.headers['x-forwarded-proto'] !== undefined, + }, +}); ``` Messages below `threshold` (1 KiB by default) are sent uncompressed — below it, -compression costs more than it saves. The negotiated response always asks for -`server_no_context_takeover` and `client_no_context_takeover`, which trades -some ratio for bounded per-connection memory: context takeover keeps a zlib -window alive per peer, and thousands of idle connections each holding one is a -worse problem than a slightly larger frame. +compression costs more than it saves. `filter(req)` decides per upgrade +request whether the peer's offer is accepted at all. By default the +negotiated response asks for `server_no_context_takeover` and +`client_no_context_takeover`, which trades some ratio for bounded +per-connection memory: context takeover keeps a zlib window alive per peer, +and thousands of idle connections each holding one is a worse problem than a +slightly larger frame. + +The option is off by default on purpose: a deflate per frame is CPU spent for +every peer to save bytes only some of them need, and the server's first +commitment is the cost per frame. The peers that need it are usually +identifiable at the handshake, which is what `filter(req)` is for — see +[when compression is worth it](../guide/performance#compression-is-off-by-default). + +The algorithm is not a choice here. RFC 7692 is the only WebSocket extension +a browser implements, so this wire is DEFLATE whatever else is available; +the knobs are `level`, `contextTakeover` and `async` (`level` −1..9 and +`memLevel` 1..9 apply to every deflate the server makes — unicast, fan-out, +threadpool and context alike — and are refused at construction otherwise; +the default stays zlib's 6, measured in `bench/send-path.js` at levels 1, 3 +and 6 beside `bench/algorithms.js`). Every other wire takes +a codec by name or by injection — Brotli, zstd, your own — and +[the compression guide](../guide/compression#algorithm) has the numbers that +decide between them. A Node client's own frames are such a wire: its +`compression` option rides above the extension and takes any codec. + +::: warning A Node client never sends a compressed frame +Node's built-in `WebSocket` offers `permessage-deflate` on the upgrade and +inflates what it receives, but never deflates what it sends — every frame it +emits has `RSV1` clear, whatever the server accepted. Compression on a +Node↔Node link is therefore server→client only — unless the client's +`compression` option and the server's agree on per-message frames above +the extension ([the Node client's frames](../guide/server#node-client-frames)). +Browsers compress both directions. +::: + +### Context takeover + +`contextTakeover: 'server' | 'client' | true` opts a direction into a live +zlib stream per connection: `'server'` keeps a deflate context for what +this side sends, `'client'` an inflate context for what the peer sends, +`true` both. A message may then reference the bytes of the ones before it, +which on repetitive traffic — the same JSON shape every tick — is a much +smaller frame (`bench/deflate-context.js`). What it costs: + +- **Memory per connection, per direction**: the window (`1 << windowBits`, + 32 KiB at 15) plus, for deflate, `1 << (memLevel + 9)` of hash state — + ~160 KiB at the defaults, tunable with `level` and `memLevel`. +- **An asynchronous write path.** zlib has no synchronous API on a live + stream, so a takeover connection compresses through `write()` + + `flush()` off the event loop and keeps an ordering queue: every write + issued while a deflate is in flight — compressed or not — waits behind + it, so frames still leave in send order. Inbound messages are inflated + the same way and delivered in arrival order. +- **No shared fan-out frame** for that connection: its bytes depend on its + own history, so a room broadcast compresses per recipient for takeover + members (they still share the serialized text). The stateless default is + what lets a room of 200 pay one deflate. + +A peer's own `server_no_context_takeover` / `client_no_context_takeover` +request is always honoured (RFC 7692 7.1.1.1), so the accepted state can be +narrower than the option: the [`Connection`](#connection) reports it as +`serverTakeover` / `clientTakeover` on its negotiated params. + +### Async deflate + +`async: { threshold }` moves the deflate and inflate of messages at or over +`threshold` bytes (256 KiB by default; `{}` takes it) to zlib's threadpool +API, through the same ordering queues — for a peer that receives large +payloads without stalling every other connection on the loop. Below the +threshold a synchronous call is cheaper than the hand-off (a quarter of the +sync throughput at 4 KB, half at 32 KB, level at 256 KB — +`bench/deflate-context.js`); above it the loop is what the hand-off buys. A fan-out over +the threshold still deflates **once**: the first recipient starts the job +and every later one waits on the same frame. + +On both paths `bufferedAmount` counts the bytes waiting in the queue, so +`send()` returns `false` and `'drain'` follows exactly as for a socket above +its high-water mark — the backpressure contract holds, it just has one more +place to hold bytes. + +Closing while messages wait there: `close()` **delivers** what `send()` +already accepted — the Close frame takes the queue's tail, nothing new is +accepted meanwhile, and `closeTimeout` bounds the whole close; if the +queue has not drained by then it is dropped, said once as +`ws.close.dropped` (`frames`, `bytes`), and the Close goes out. So +`send(x); close()` delivers `x` with or without takeover. `terminate()` +drops the queue at once, as does a close the connection makes on its own +(a protocol violation, a limit) and a Close the peer sent first. `server_max_window_bits` is honoured when the client asks for it (8–15). +Because no context spans messages, the compressed bytes of a message depend +only on the payload and the window size. That is what lets a room broadcast +share one deflated frame per distinct window across all its recipients +(`Connection.sendPrepared`, see [performance](../guide/performance#fan-out)) +instead of deflating once per member. + +Per message, `send(data, { compress: false })` — and `emit(name, data, +{ compress: false })` on a room — sends uncompressed past the threshold: for +a payload that is already compressed, or one where latency matters more than +bytes. + When deflate is negotiated, `RSV1` becomes a legal bit on data frames and the parser is told so through `allowedRsv` — an unnegotiated RSV bit is still a protocol error. +Inbound, the inflate queue has backpressure of its own: at most 32 messages +(4 on the threadpool path, which inflates them in parallel) or `maxPayload` +of compressed bytes are in flight per connection, past which the socket is +paused and the frame loop stops until enough landed — a peer that sent +thousands of compressed frames in one segment used to have every one of +them inflating at once, each holding its output. The pause is the engine's, +apart from the application's `pause()`; `isPaused` reports both, so the +heartbeat does not take a held connection for dead. + ## Conformance The receive path is covered by a large RFC 6455 conformance suite in diff --git a/encryption.browser.d.ts b/encryption.browser.d.ts new file mode 100644 index 0000000..829efbb --- /dev/null +++ b/encryption.browser.d.ts @@ -0,0 +1,420 @@ +// Types for the browser half of `@alexify/wrpc/encryption` +// (`encryption.browser.js`): the primitives — AEAD, X25519, HKDF, the +// keyring — and the structural contracts an application injects its own +// through. Everything here runs on both platforms; encryption.d.ts +// re-exports this file and adds what only a Node process does. +// +// @experimental The whole subpath may change in a minor (see +// docs/reference/stability.md) — every declaration below included. + +/** Any byte view `crypto.subtle` and `node:crypto` both take. */ +export type Bytes = Uint8Array; + +/** + * The one failure `open` has. A wrong key, a wrong nonce, a flipped bit and + * a truncated message are indistinguishable on purpose. + */ +export declare class OpenError extends Error { + readonly code: 'open'; +} + +/** + * A sealed request (http, sse) the server answered in plaintext — a client + * that encrypts reads none. `status` is the outer, unauthenticated status; + * the calls the request carried fail with it only for the closed set the + * sealing layer vouches for (400: the pinned key may be retired; 409: stale + * or replayed — check the device's clock; 426: encryption required), and + * with 503 otherwise. Emitted as `'error'` on the client. + */ +export declare class EncryptionRefusedError extends Error { + readonly code: 'ENCRYPTION_REFUSED'; + readonly status: number; +} + +/** One key of a `Cipher`, ready to use. Either method may answer a promise. */ +export interface CipherKey { + /** → ciphertext ‖ tag. A `(key, nonce)` pair must never seal two messages. */ + seal(nonce: Bytes, plaintext: Bytes, aad?: Bytes | null): Bytes | Promise; + /** → plaintext; throws (or rejects with) `OpenError`. */ + open(nonce: Bytes, sealed: Bytes, aad?: Bytes | null): Bytes | Promise; +} + +/** + * An AEAD, structurally — the platform's (`aead()`), or an injected one: + * XChaCha20-Poly1305, AES-GCM-SIV, AEGIS, a hardware module. `key()` is + * where the expensive half happens once: a non-extractable `CryptoKey` in a + * browser (hence the promise), a `KeyObject` on Node. + */ +export interface Cipher { + /** What the two ends compare — never a level or an implementation name. */ + readonly id: string; + readonly keyLength: number; + readonly nonceLength: number; + readonly tagLength: number; + /** From this call on `raw` is the cipher's: wrpc neither reuses nor wipes it, so keeping the reference is fine. */ + key(raw: Bytes): CipherKey | Promise; +} + +export declare function isCipher(value: unknown): value is Cipher; +export declare function isCipherKey(value: unknown): value is CipherKey; + +/** The platform AEADs. `'chacha20-poly1305'` exists on Node only. */ +export type CipherAlgorithm = 'aes-256-gcm' | 'chacha20-poly1305'; + +export declare const ALGORITHMS: ReadonlyArray; + +export interface AeadOptions { + /** Default `'aes-256-gcm'` — the one AEAD every browser has. */ + algorithm?: CipherAlgorithm; + /** Answer `null` instead of throwing when this platform lacks the algorithm. */ + optional?: boolean; + /** WebCrypto; defaults to `globalThis.crypto.subtle`. Read by the browser half only. */ + subtle?: SubtleCrypto; +} + +/** + * A platform AEAD by name: synchronous over `node:crypto` on Node, + * promise-answering over `crypto.subtle` in a browser. + */ +export declare function aead(options: AeadOptions & { optional: true }): Cipher | null; +export declare function aead(options?: AeadOptions): Cipher; + +/** Opaque: a `CryptoKey` in a browser, a `KeyObject` on Node, or an injected implementation's own. */ +export type PrivateKey = CryptoKey | object; + +export interface KeyPair { + /** The bytes that travel. */ + publicKey: Bytes; + privateKey: PrivateKey; +} + +/** + * A Diffie-Hellman function in the shape Noise and HPKE's DHKEM consume — + * the platform's X25519 (`x25519()`), or an injected one. + */ +export interface Dh { + /** The name Noise gives the function: `'25519'`. */ + readonly id: string; + readonly publicLength: number; + generateKeyPair(): Promise; + /** A key pair from a configured private key — a server's static one. */ + keyPair(seed: Bytes): Promise; + /** Rejects for a public key that yields the all-zero secret. */ + dh(privateKey: PrivateKey, publicKey: Bytes): Promise; +} + +export declare function isDh(value: unknown): value is Dh; + +/** + * X25519 (RFC 7748): `node:crypto` KeyObjects on Node, `crypto.subtle` in a + * browser (Chrome 133, Firefox 130, Safari 17) — where a private key is a + * non-extractable `CryptoKey`. `subtle` is read by the browser half only. + */ +export declare function x25519(options?: { subtle?: SubtleCrypto }): Dh; + +/** SHA-256, HMAC and HKDF (RFC 5869), Extract and Expand apart. */ +export interface Kdf { + readonly id: 'SHA256'; + readonly hashLength: 32; + hash(data: Bytes): Promise; + hmac(key: Bytes, data: Bytes): Promise; + extract(salt: Bytes, ikm: Bytes): Promise; + expand(prk: Bytes, info: Bytes, length: number): Promise; + /** `expand(extract(salt, ikm), info, length)`. */ + derive(ikm: Bytes, salt: Bytes, info: Bytes, length: number): Promise; +} + +export declare function createKdf(options?: { subtle?: SubtleCrypto }): Kdf; + +/** 32 bytes: a `Uint8Array`, 64 hex characters, or base64 / base64url. */ +export type KeyMaterial = Bytes | string; + +/** + * The keyring's injected form — a KMS or Vault client that unwrapped its + * data keys at boot. Both methods are SYNCHRONOUS: they are read where a + * backplane envelope is opened, and that path cannot wait. + */ +export interface KeyProvider { + current(): string; + get(kid: string): Bytes | null; + /** Every kid held, newest first — what a sealed store walks after a rotation. */ + kids?(): ReadonlyArray; +} + +export declare function isKeyProvider(value: unknown): value is KeyProvider; + +/** + * `keys` wherever wrpc seals under a shared key: one key (filed under kid + * `'0'`), a ring with its current kid, or a provider. A kid is 1-32 of + * `A-Z a-z 0-9 . _ -` and travels in the clear. + */ +export type KeysOption = KeyMaterial | { current: string; ring: Readonly> } | KeyProvider; + +export interface Keyring { + readonly current: string; + readonly kids: ReadonlyArray; + get(kid: string): Bytes | null; +} + +/** Strict: a TypeError at construction, never a message that silently does not open. */ +export declare function normalizeKeys(value: KeysOption, name?: string): Keyring; + +export declare function isKid(value: unknown): value is string; + +/** A fresh 32-byte key from the platform's CSPRNG. */ +export declare function generateKey(options?: { crypto?: Pick }): Bytes; + +export declare function toBase64Url(bytes: Bytes): string; +/** base64 or base64url, padded or not; `null` when it is neither. */ +export declare function fromBase64(text: string): Bytes | null; +/** Constant-time equality of two byte strings. */ +export declare function equal(a: Bytes, b: Bytes): boolean; + +/** The one-round-trip Noise patterns a session may run. */ +export type NoisePattern = 'NN' | 'NK' | 'XX' | 'NNpsk0'; + +export declare const PATTERN_NAMES: ReadonlyArray; + +/** + * What both ends of an encrypted session know about it once the handshake + * is done — `client.encryption` on a `WrpcClient`, and on the server's + * `Client`. `handshakeHash` is unique to the handshake and identical on + * both ends: bind a credential to it (send `HMAC(token, handshakeHash)`) + * and a credential relayed onto another connection is worthless there. + */ +export interface EncryptionInfo { + /** The Noise protocol name, e.g. `'Noise_NK_25519_AESGCM_SHA256'`. */ + readonly protocol: string; + readonly pattern: NoisePattern; + /** `'AESGCM'`, `'ChaChaPoly'`, or an injected cipher's id. */ + readonly cipher: string; + /** The server key id the client pinned; `''` for NN and NNpsk0. */ + readonly kid: string; + /** The peer's static public key: the pinned one (NK), the one that arrived (XX), null otherwise. */ + readonly remoteStatic: Bytes | null; + readonly handshakeHash: Bytes; +} + +/** A server's public key bundle — `"::"`, or the same as an object. */ +export type ServerKey = string | { kid: string; noise: Bytes | string; hpke: Bytes | string }; + +export declare function parseBundle(value: ServerKey, name?: string): { kid: string; noise: Bytes; hpke: Bytes }; + +export interface CreateEncryptionOptions { + /** + * The server's key bundle, from `rpc.encryptionKey()` — what this client + * PINS: only the holder of the matching private key can finish the + * handshake. With it the pattern defaults to `'NK'`. + */ + serverKey?: ServerKey | null; + /** + * `'NK'` (the default with a `serverKey`), `'XX'` (mutual — needs + * `staticKey`), `'NN'` (anonymous: encrypted, nobody authenticated) or + * `'NNpsk0'` (needs `psk`). `'NN'` is never a default: it has to be named. + */ + pattern?: NoisePattern; + /** XX: this client's long-lived private key, 32 bytes — its identity to the server's `authorize`. */ + staticKey?: Bytes | null; + /** XX without a `serverKey`: decides whether the key that answered is trusted. Must answer `true`. */ + verifyServer?: ((publicKey: Bytes) => boolean | Promise) | null; + /** NNpsk0: the pre-shared key, 32 bytes. */ + psk?: Bytes | null; + /** Default `'aes-256-gcm'` — the one cipher a browser has. */ + cipher?: CipherAlgorithm | Cipher; + dh?: Dh; + /** + * Messages per direction between deterministic rekeys (Noise `REKEY`, + * §11.3). Default 2^20 — the same constant on both ends. It is NOT + * negotiated and not in the handshake: a client and a server that + * disagree fail at the first rekey, as a plain decrypt error after exactly + * that many messages (`encryption.refused`, close 1002). Leave the + * default unless both ends deploy together. + */ + rekeyAfter?: number; + /** Default 10000 ms. */ + handshakeTimeout?: number; +} + +/** The transport's side of the seam `Encryption.secure()` is handed. */ +export interface EncryptionLink { + /** `'ws'`, `'wt'` — bound into the handshake, so one cannot be replayed onto the other. */ + kind: string; + write(bytes: Bytes): void; + deliver(message: string | Bytes): void; + fail(error: Error): void; +} + +export interface EncryptedConnection { + readonly ready: Promise; + send(data: string | Bytes | ArrayBuffer): void; + receive(data: string | Bytes | ArrayBuffer): void; + /** + * The transport is gone (a close, a terminate): nothing more is written + * or delivered, and a pending `ready` rejects now rather than at the + * handshake timeout. Does not call `link.fail`. + */ + cancel(error?: Error): void; +} + +/** + * What a client is handed as `encryption`. A client that has one NEVER + * speaks plaintext: a server that does not answer the handshake, a key that + * is not the pinned one, a transport that cannot carry it — each is a + * failed connection, never a fallback. + */ +export interface Encryption { + /** The connect-URL parameter that announces the mode: `'wrpc_e'`. */ + readonly param: string; + readonly protocol: string; + readonly pattern: NoisePattern; + /** The rekey interval this end will use — check it against the server's, it is not negotiated. */ + readonly rekeyAfter: number; + secure(link: EncryptionLink): EncryptedConnection; + /** + * The per-request half, for a transport with no connection to hold a + * session (http, sse): wraps a `fetch` so that every request is sealed to + * the pinned server key (HPKE) and POSTed to `endpoint`, and answers a real + * `Response`. Null without a `serverKey`, or under a cipher HPKE has no + * registered id for. + */ + readonly fetch: ((fetch: typeof globalThis.fetch, endpoint: string) => typeof globalThis.fetch) | null; +} + +/** + * The server's key bundle from its discovery endpoint, `GET + * /encryption-key`. TRUST ON FIRST USE: only as trustworthy as the + * connection it came over — ship the bundle with the client where you can. + */ +export declare function fetchServerKey(url: string, options?: { fetch?: typeof globalThis.fetch }): Promise; + +/** A key-encapsulation mechanism, structurally — DHKEM over a `Dh`, or an injected one (ML-KEM, a hybrid). */ +export interface Kem { + /** The registered HPKE KEM id (`0x0020` for DHKEM(X25519, HKDF-SHA256)). */ + readonly id: number; + readonly publicLength: number; + readonly encLength: number; + readonly secretLength: number; + generateKeyPair(): Promise; + keyPair(seed: Bytes): Promise; + encap( + recipientPublicKey: Bytes, + ephemeral?: KeyPair | null, + sender?: KeyPair | null, + ): Promise<{ sharedSecret: Bytes; enc: Bytes }>; + decap(enc: Bytes, recipient: KeyPair, senderPublicKey?: Bytes | null): Promise; +} + +export declare function isKem(value: unknown): value is Kem; +export declare function dhKem(dh: Dh, kdf: Kdf, id?: number): Kem & { deriveKeyPair(ikm: Bytes): Promise }; + +export interface HpkeOptions { + /** What the message is FOR: one sealed for one purpose does not open for another. */ + info?: Bytes; + /** Auth mode, on the sender: its static key pair — the recipient learns WHICH key sealed the message. */ + senderKey?: KeyPair | null; + /** Auth mode, on the recipient: the sender it expects. A message anyone else sealed does not open. */ + senderPublicKey?: Bytes | null; + /** psk mode: the recipient also learns the sender held this key. With `pskId`. */ + psk?: Bytes | null; + pskId?: Bytes | null; +} + +export interface HpkeContext { + seal(aad: Bytes | null, plaintext: Bytes): Bytes | Promise; + open(aad: Bytes | null, sealed: Bytes): Bytes | Promise; + /** A secret both ends derive and nobody else: `length` bytes bound to `context`. */ + export(context: Bytes, length: number): Promise; +} + +/** HPKE (RFC 9180): base, psk, auth and auth_psk modes, HKDF-SHA256. */ +export declare function createHpke(options: { kem: Kem; kdf: Kdf; cipher: Cipher }): { + readonly suite: Bytes; + readonly aeadId: number; + readonly encLength: number; + setupSender(recipientPublicKey: Bytes, options?: HpkeOptions): Promise<{ enc: Bytes; context: HpkeContext }>; + setupRecipient(enc: Bytes, recipient: KeyPair, options?: HpkeOptions): Promise; +}; + +export declare function createEncryption(options?: CreateEncryptionOptions): Encryption; +export declare function isEncryption(value: unknown): value is Encryption; + +export interface NoiseHandshake { + readonly writing: boolean; + readonly done: boolean; + readonly remoteStatic: Bytes | null; + write(payload?: Bytes): Promise; + read(message: Bytes): Promise; + finish(): Promise<{ send: unknown; receive: unknown; handshakeHash: Bytes; remoteStatic: Bytes | null }>; +} + +export interface NoiseHandshakeOptions { + prologue?: Bytes; + staticKey?: KeyPair; + remoteStatic?: Bytes; + psk?: Bytes; + /** Messages between deterministic rekeys; 0 never. Both ends of a handshake must use the same value — see `createEncryption`. */ + rekeyAfter?: number; +} + +/** One Noise protocol over injected primitives — the canonical name is `name`. */ +export declare function createNoise(options: { pattern: NoisePattern; dh: Dh; cipher: Cipher; kdf: Kdf }): { + readonly name: string; + readonly pattern: NoisePattern; + readonly cipher: string; + initiator(options?: NoiseHandshakeOptions): Promise; + responder(options?: NoiseHandshakeOptions): Promise; +}; + +/** + * An end-to-end identity. The SEED is the secret — 32 bytes to keep wherever + * this client keeps secrets, and the same identity again from the same seed + * (that portability is what it is for; a browser that never needs it keeps + * a non-extractable `x25519().generateKeyPair()` in IndexedDB instead and + * hands that `KeyPair` to `createOpener`/`senderKey` directly); + * `publicKey` is what others seal to and verify against — and whoever + * distributes it is in a position to substitute it: pin, compare, or + * exchange out of band. + */ +export interface Identity { + readonly seed: Bytes; + readonly publicKey: Bytes; + readonly keyPair: KeyPair; +} + +export interface E2eePrimitives { + cipher?: Cipher; + dh?: Dh; +} + +export declare function createIdentity( + seed?: Bytes | null, + options?: E2eePrimitives & { crypto?: Pick }, +): Promise; + +/** + * Seals one message to one recipient (HPKE, a fresh context per message): + * `enc ‖ ciphertext`, bytes wrpc carries as they are through a server that + * cannot read them. With `senderKey` the recipient can check who sealed it. + * Not a messaging protocol: no forward secrecy for the recipient, no group + * key, no replay memory — run Double Ratchet or MLS over the same bytes for + * those. In a browser it protects against a server that READS, not one that + * serves the page a different script. + */ +export declare function createSealer( + options: E2eePrimitives & { + recipientPublicKey: Bytes; + senderKey?: KeyPair | null; + /** What the messages are for — a room, a conversation id. */ + info?: Bytes | string | null; + }, +): { seal(data: Bytes | string, aad?: Bytes | string | null): Promise }; + +export declare function createOpener( + options: E2eePrimitives & { + /** This client's identity `keyPair`, or a pair from `x25519().generateKeyPair()`. A stolen one opens everything ever sealed to it, and can forge what `senderPublicKey` would have vouched for. */ + keyPair: KeyPair; + /** A message this identity did not seal does not open. Without it, anyone who knows the public key could have sent it. */ + senderPublicKey?: Bytes | null; + info?: Bytes | string | null; + }, +): { open(sealed: Bytes, aad?: Bytes | string | null): Promise }; diff --git a/encryption.browser.js b/encryption.browser.js new file mode 100644 index 0000000..589d171 --- /dev/null +++ b/encryption.browser.js @@ -0,0 +1,5 @@ +'use strict'; + +// The browser half of the './encryption' subpath: the primitives and the +// client halves — the envelopes and the sealed store are a server's. +module.exports = require('./src/encryption/browser.js'); diff --git a/encryption.d.ts b/encryption.d.ts new file mode 100644 index 0000000..0f722dd --- /dev/null +++ b/encryption.d.ts @@ -0,0 +1,193 @@ +// Types for the `@alexify/wrpc/encryption` subpath on Node: the primitives +// of the browser half (the AEADs synchronous over node:crypto here), plus +// what only a Node process does as it lands. + +/** + * `@alexify/wrpc/encryption` — application-level encryption, opt-in and + * never in place of TLS. + * + * @experimental The whole subpath may change in a minor (see + * docs/reference/stability.md): these types, the `encryption` options they + * feed on the server, the client, rooms, the cluster, the broker bindings + * and the session store, and the wire formats of docs/reference/protocol.md + * `#session-encryption`, `#sealed-requests` and `#broker-sealing`. + */ +export * from './encryption.browser.js'; + +import type { + Cipher, + CipherAlgorithm, + EncryptionInfo, + KeyProvider, + KeysOption, + NoisePattern, +} from './encryption.browser.js'; +import type { SessionStore } from './rpc.js'; +import type { WrpcLogger } from './client.js'; + +/** + * `encryption` on a Node↔Node carrier — `rooms.encryption`, + * `cluster.encryption`: every envelope sealed under a shared keyring, so + * the backplane and whoever operates it carry ciphertext. Off by default. + * + * A pub/sub backplane delivers at most once, so the switch is a rollout of + * three deploys, never a flag day: `{ seal: false, acceptPlaintext: true }` + * (every instance learns to open), `{ acceptPlaintext: true }` (every + * instance seals), then neither (plaintext is refused). + */ +export interface EnvelopeEncryptionOptions { + /** One key, a ring with its current kid, or a provider. The kid travels in the clear. */ + keys: KeysOption; + /** + * Default `'aes-256-gcm'`. Either built-in is opened whichever one this + * instance seals with, so changing it is a config change, not a rollout; + * an injected `Cipher` must answer synchronously and be the same on every + * instance. + */ + cipher?: CipherAlgorithm | Cipher; + /** Default true. `false` opens sealed envelopes but still publishes plaintext. */ + seal?: boolean; + /** Default false: an envelope that is not sealed is dropped and logged (`*.unsealed`). */ + acceptPlaintext?: boolean; + /** + * The anti-replay window kept per sender, in messages: a repeated or + * too-old counter is dropped and logged. Default 1024; `false` disables + * it. Kept in memory, per sender, so it starts empty with the process — + * and with the sender's entry, see `maxSenders`. + */ + replayWindow?: number | false; + /** + * How many senders — a (key id, cipher, salt) each, one per sealing + * process — this instance remembers: the derived key and the replay + * window. Default 1024, oldest out first; a forgotten sender is derived + * again at its next message with an EMPTY window. Raise it where the + * senders are many (every client process of a broker RPC address). + */ + maxSenders?: number; +} + +/** A provider for the sealed store must answer `kids()`: a rotation is walked through it. */ +export type SealedStoreKeys = Exclude | (KeyProvider & { kids(): ReadonlyArray }); + +export interface SealedStoreOptions { + /** One key, a ring with its current kid, or a provider that answers `kids()` (a TypeError otherwise). */ + keys: SealedStoreKeys; + /** + * Whether writes are sealed. `false` is the first of the three deploys + * over a store that already holds sessions: every instance reads sealed + * rows already, none writes them yet, so a rollback finds every session + * where it always was. Default true. + */ + seal?: boolean; + /** Default `'aes-256-gcm'`; an injected `Cipher` must answer synchronously. */ + cipher?: CipherAlgorithm | Cipher; + /** + * The first deploy over a store that already holds sessions: a row the + * unwrapped store wrote is read once, sealed, and its plaintext deleted. + * Default false. Turn it off again once the longest session TTL passed. + */ + acceptPlaintext?: boolean; + /** `session.unsealed`, `session.open`, `session.migrate` warnings. The token is never logged. */ + logger?: WrpcLogger | boolean; +} + +/** + * A session store that holds nothing readable — neither a session's state + * nor its token. Rows are keyed by an HMAC of the token and hold the state + * sealed, with the row's key as additional data: a row copied into another + * session's slot does not open. A key rotation signs nobody out — a row + * found under an older kid moves to the current one on its next read, so a + * kid can be dropped once the longest session TTL passed since it stopped + * being current. `touch` is forwarded when the wrapped store has one. + * + * sessions: { store: sealedStore(createRedisSessionStore({ client }), { keys }) } + */ +export declare function sealedStore(store: SessionStore, options: SealedStoreOptions): SessionStore; + +/** + * `encryption` on a server: session encryption of its persistent + * connections (WebSocket, WebTransport) — a Noise handshake, then every + * frame sealed. A client opts in with `createEncryption()`; with `required` + * nothing plaintext is accepted on any transport. Off by default, and never + * a substitute for TLS: it is for the TLS terminator you do not trust, and + * for `ws://` where no certificate can be had. + * + * It costs every broadcast its single shared frame: each recipient has its + * own key, so an emit to N sealed clients is N seals (bench/encryption.js). + */ +export interface ServerEncryptionOptions { + /** + * One SECRET per key id — the static key pairs are derived from it. The + * current kid is what `encryptionKey()` publishes; a client names the kid + * it pinned, so an old pin works for as long as its key is on the ring. + */ + keys: KeysOption; + /** Refuse everything plaintext: sockets close 1008, HTTP answers 426, `attach()` must be told `encrypted`. Default false. */ + required?: boolean; + /** Default `['NK', 'XX']`. `'NN'` (anonymous) and `'NNpsk0'` have to be listed to be accepted. */ + patterns?: ReadonlyArray; + /** Default both. A protocol a client names that is not on these lists is refused — never negotiated down. */ + ciphers?: ReadonlyArray; + /** NNpsk0: the pre-shared key. */ + psk?: Bytes | string | null; + /** + * Runs once the handshake is done, before any call: answer `false` to + * close the connection (1008). Under XX `peer.remoteStatic` is the + * client's authenticated public key. + */ + authorize?: ((peer: EncryptionInfo) => boolean | void | Promise) | null; + /** Default 10000 ms. */ + handshakeTimeout?: number; + /** + * Messages per direction between deterministic rekeys (Noise `REKEY`, + * §11.3). Default 2^20 — the same constant on both ends. It is NOT + * negotiated and not in the handshake: a client and a server that + * disagree fail at the first rekey, as a plain decrypt error after exactly + * that many messages (`encryption.refused`, close 1002). Leave the + * default unless both ends deploy together. + */ + rekeyAfter?: number; + /** + * The per-request binding (http, sse): how far a sender's clock may be + * from this server's, in ms — a sealed request outside it is refused + * (409), and one inside it is accepted ONCE. Default 300000. + */ + maxSkew?: number; + /** + * The "accepted once" memory. In process by default — behind a balancer a + * replay can land on another instance, so inject a shared one: `seen(id, + * ttl)` answers true for an id it was shown within `ttl` ms (Redis: `SET + * id 1 NX PX ttl`). + */ + /** + * The "seen once" memory of sealed requests: a shared one (`seen` — Redis + * `SET id 1 NX PX ttl` behind more than one instance), or the built-in + * cache's knobs. It must hold every request of the last 2·maxSkew, so + * `max ≥ rps × 2·maxSkew/1000`; full of live entries it refuses (409, + * `encryption.replay.overflow` once per ten seconds) unless `overflow: + * 'evict'` trades "accepted once" for availability. + */ + replay?: + | { seen(id: string, ttl: number): boolean | Promise } + | { max?: number; overflow?: 'refuse' | 'evict' } + | null; + /** Serve the public key bundle at `GET /encryption-key`. Default true; it is trust on first use. */ + discovery?: boolean; +} + +/** + * The default `replay` memory: bounded, in process. Full of live entries it + * answers `true` (`overflow: 'refuse'`, the default) or forgets the oldest + * (`'evict'`); `onOverflow` hears of each id that met the cap. + */ +export declare function createReplayCache(options?: { + max?: number; + now?: () => number; + overflow?: 'refuse' | 'evict'; + onOverflow?: () => void; +}): { + seen(id: string, ttl: number): boolean; + readonly size: number; +}; + +type Bytes = Uint8Array; diff --git a/encryption.js b/encryption.js new file mode 100644 index 0000000..88c00a2 --- /dev/null +++ b/encryption.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('./src/encryption/index.js'); diff --git a/engine.d.ts b/engine.d.ts index de358da..f270f4a 100644 --- a/engine.d.ts +++ b/engine.d.ts @@ -3,7 +3,7 @@ import { IncomingMessage, Server as HttpServer } from 'node:http'; import { Server as HttpsServer } from 'node:https'; import type { Duplex } from 'node:stream'; import type { PerMessageDeflateOptions } from './ws.js'; -import type { HttpCall } from './index.js'; +import type { HttpCall, SharedMessage, WrpcLogger } from './index.js'; /** * The upgrade request an engine hands to `verifyClient`, `handleProtocols` @@ -37,10 +37,29 @@ export interface EngineVerifyClientInfo { * 'pong'(payload), 'close'(code, reason), 'error'(error). */ export interface WrpcSocket extends EventEmitter { - /** Returns false when the socket buffer is above its high-water mark. */ - send(data: string | Buffer): boolean; + /** + * Returns false when the socket buffer is above its high-water mark. + * `options.compress === false` asks for this one message to go + * uncompressed on a deflate-negotiated connection; sockets that predate + * the option ignore it. + */ + send(data: string | Buffer, options?: { compress?: boolean } | null): boolean; + /** + * Optional (capability `prepared`): the fan-out path. Writes one message + * shared by every recipient of a broadcast, claiming or reusing its + * `frames` slot so the wire bytes are built once per emit, not once per + * member. Same boolean as send(). + */ + sendPrepared?(message: SharedMessage): boolean; readonly bufferedAmount: number; readonly remoteAddress?: string; + /** + * The subprotocol the handshake selected — and so the protocol revision: + * the core reads it once at attach, `'wrpc.v2'` is revision 2, anything + * else (`'wrpc.v1'`, `''`, a missing property) revision 1, where no framed + * message is sent. An engine that does not set it serves every client at + * revision 1. + */ protocol?: string; close(code?: number, reason?: string): void; terminate(): void; @@ -56,6 +75,8 @@ export interface EngineCapabilities { deflate: boolean; cork: boolean; pause: boolean; + /** True when sockets implement sendPrepared (shared fan-out frames). */ + prepared: boolean; } export interface EngineAttachOptions { @@ -77,6 +98,14 @@ export interface EngineAttachOptions { maxBackpressure?: number; fragmentThreshold?: number; closeTimeout?: number; + /** Coalesce every write of one event-loop turn into one flush (built-in engine). Default true. */ + coalesce?: boolean; + /** + * The structured logger the engine and its connections report through + * (framing faults, dropped frames) — what the `Server` shell and the + * adapters pass. An engine built with its own `logger` keeps it. + */ + logger?: WrpcLogger | boolean; /** * Standalone engines only: the core's HTTP entry point, invoked with the * same abstract call description RpcServer.handleHttpCall consumes. diff --git a/examples/syncom-mesh/README.md b/examples/syncom-mesh/README.md new file mode 100644 index 0000000..430091e --- /dev/null +++ b/examples/syncom-mesh/README.md @@ -0,0 +1,38 @@ +# wrpc mesh example + +Every tab is a wrpc peer: it serves a router and calls the routers of every +other tab, over WebRTC data channels — the server only signals. The wrpc +equivalent of a "who is in the room" demo, with the traffic itself peer to +peer. + +```bash +node build.js # bundles the browser client + webrtc peer into public/wrpc.browser.js +node server.js # starts the signaling server on http://127.0.0.1:8000 +``` + +Then open `public/index.html` directly in two or more tabs (no static file +server needed — it connects out to the signaling server over WebSocket and +to the other tabs over WebRTC). Each tab joins the `lobby` mesh; the list +shows every link and its state, **Broadcast** sends an event to every peer, +**Ask everyone** collects an answer from each, and **Send 1 MiB** streams a +blob to each peer over its data channel, with backpressure. Close a tab and +the others see it leave. + +Identity and trust are the server's: a tab proposes its name as its peer id +(`?name=ada` in the URL, else a random one) and the server's `identity` +strategy adopts it, so the id survives a signaling reconnect — restart +`server.js` and watch the links stay up. Every peer also gets a signed +**assertion** for the certificate it dials with, and the others verify it +against the server's public key (`assertions: {}` on the peer, `trust: +'assertion'` on its host): the `hello` answer shows the claims the handler +ran with. Open the same name in a second tab and the first is `replaced`. + +All tabs run on one machine, so `iceServers` is empty; across machines give +`WrpcPeer` a STUN (and, behind symmetric NATs, a TURN) server. + +This example is not published with the package (`package.json#files` is an +explicit allowlist that omits `examples/`) — it requires the package's own +`node_modules` (for `esbuild`) and loads the library by relative path. In +your own app the two imports are `@alexify/wrpc` and `@alexify/wrpc/webrtc`, +bundled by your own tooling per [Browser & bundling](../../docs/guide/browser.md); +the full story is in the [WebRTC guide](../../docs/guide/webrtc.md). diff --git a/examples/syncom-mesh/build.js b/examples/syncom-mesh/build.js new file mode 100644 index 0000000..5113807 --- /dev/null +++ b/examples/syncom-mesh/build.js @@ -0,0 +1,17 @@ +'use strict'; + +// wrpc has no build step of its own and ships CommonJS; a browser page still +// needs a bundler to consume it (docs/guide/browser.md), so this is that +// bundler for the example only — esbuild is already a root devDependency. +const path = require('node:path'); +const esbuild = require(require.resolve('esbuild', { paths: [path.join(__dirname, '..', '..')] })); + +esbuild.buildSync({ + entryPoints: [path.join(__dirname, 'entry.js')], + bundle: true, + format: 'esm', + platform: 'browser', + outfile: path.join(__dirname, 'public', 'wrpc.browser.js'), +}); + +console.log('built public/wrpc.browser.js'); diff --git a/examples/syncom-mesh/entry.js b/examples/syncom-mesh/entry.js new file mode 100644 index 0000000..731bc93 --- /dev/null +++ b/examples/syncom-mesh/entry.js @@ -0,0 +1,6 @@ +'use strict'; + +// The one module the page imports: the client (`connect`) from the main +// browser entry and the peer half of the webrtc subpath, bundled together +// by build.js the way a real app's bundler would resolve the two imports. +module.exports = { ...require('../../browser.js'), ...require('../../webrtc.browser.js') }; diff --git a/examples/syncom-mesh/public/index.html b/examples/syncom-mesh/public/index.html new file mode 100644 index 0000000..334d3ce --- /dev/null +++ b/examples/syncom-mesh/public/index.html @@ -0,0 +1,130 @@ + + +wrpc mesh example + +

wrpc mesh

+

me: connecting… (the name is the peer id — open the same name twice to see replaced)

+

peers in lobby:

+
    +

    + + + + +

    +
      + diff --git a/examples/syncom-mesh/server.js b/examples/syncom-mesh/server.js new file mode 100644 index 0000000..9814a96 --- /dev/null +++ b/examples/syncom-mesh/server.js @@ -0,0 +1,38 @@ +'use strict'; + +// The signaling server: any wrpc server with the built-in signaling unit. +// It never sees the peers' traffic — only descriptions and candidates while +// a link is (re)negotiated, and the roster of who is in which room. Here it +// also decides who a peer IS (the name the tab proposes; a real server would +// take it from the session) and vouches for it: every peer gets a signed +// assertion for the certificate it dials with, and the other peers verify +// it against the server's public key before they let the link in. + +const { Server, defineRouter } = require('../../index.js'); +const { createSignalingUnit, createSignalingHooks, generateAssertionKeys } = require('../../webrtc.js'); + +const main = async () => { + // A fresh key pair per boot is fine for a demo; a deployment loads a + // stable one from its secret store so a restart does not break open links. + const keys = await generateAssertionKeys(); + const router = defineRouter( + { + ...createSignalingUnit({ + access: 'public', + identity: (_context, { proposed }) => proposed ?? _context.client.id, + assertions: { key: keys.privateKey, ttl: 300, issuer: 'syncom-mesh', claims: () => ({ demo: true }) }, + }), + }, + { hooks: createSignalingHooks() }, + ); + const server = new Server({ router, host: '127.0.0.1', port: 8000, protocol: 'http' }); + await server.listen(); + console.log('wrpc signaling server listening on http://127.0.0.1:8000/api'); + console.log(`assertion key ${keys.kid}`); + console.log('run `node build.js` once, then open public/index.html in two or more tabs'); +}; + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/examples/wt/README.md b/examples/wt/README.md new file mode 100644 index 0000000..1e5be05 --- /dev/null +++ b/examples/wt/README.md @@ -0,0 +1,26 @@ +# wrpc over WebTransport + +One `Server`, two doors: the WebSocket on TCP and an HTTP/3 host on UDP. The +page tries WebTransport first and falls back to the WebSocket by hand — a +`try`/`catch` around two `connect()` calls — because a transport list can +only re-spell the scheme of ONE url, and here the two doors are two ports. +A tab on each ends up in the same room. + +```bash +node scripts/wt-cert.js certs # a 13-day ECDSA P-256 certificate + its hash (browsers require both) +node examples/wt/build.js # bundles the browser client into public/wrpc.browser.js +node examples/wt/server.js # http://127.0.0.1:8001 for the page, tcp/8000 for the wrpc Server, udp/4433 for HTTP/3 +``` + +Open `http://127.0.0.1:8001/` in Chrome (or any browser with WebTransport — +Baseline since Safari 26.4); the status line names the transport that won. +Use `127.0.0.1`, not `localhost`. The hash the page hands to +`serverCertificateHashes` comes from `/cert-hash.json`, so a regenerated +certificate needs only a reload; after 14 days a browser refuses it — run +the cert script again. + +The HTTP/3 host is [`@fails-components/webtransport`](https://github.com/fails-components/webtransport), +a devDependency of this repository (its native binary needs the install +script pnpm runs for it — see `package.json#pnpm.onlyBuiltDependencies`). +The package itself depends on nothing: see [the guide](../../docs/guide/wt.md) +for the session contract and the quico alternative. diff --git a/examples/wt/build.js b/examples/wt/build.js new file mode 100644 index 0000000..1455869 --- /dev/null +++ b/examples/wt/build.js @@ -0,0 +1,20 @@ +'use strict'; + +// Bundles the browser client for the page — the base entry, which already +// carries the `wt` transport. Same shape as examples/chat/build.js. + +const path = require('node:path'); +const esbuild = require('esbuild'); + +esbuild + .build({ + entryPoints: [path.join(__dirname, '../../browser.js')], + bundle: true, + format: 'iife', + globalName: 'wrpc', + platform: 'browser', + minify: true, + outfile: path.join(__dirname, 'public/wrpc.browser.js'), + logLevel: 'info', + }) + .catch(() => process.exit(1)); diff --git a/examples/wt/public/index.html b/examples/wt/public/index.html new file mode 100644 index 0000000..562833f --- /dev/null +++ b/examples/wt/public/index.html @@ -0,0 +1,75 @@ + + +wrpc over WebTransport + +

      wrpc over WebTransport

      +

      Transport: connecting…

      +
      +
      + + diff --git a/examples/wt/server.js b/examples/wt/server.js new file mode 100644 index 0000000..1dd1da7 --- /dev/null +++ b/examples/wt/server.js @@ -0,0 +1,112 @@ +'use strict'; + +// A wrpc server on WebSocket AND WebTransport at once: the ordinary Server +// on TCP (HTTP/1.1 for the page and the REST routes, WebSocket for the +// fallback), an HTTP/3 host on UDP for `transport: 'wt'` — both attached to +// ONE RpcServer, so a WebTransport tab and a WebSocket tab share the room. +// +// node scripts/wt-cert.js certs # once: a 13-day ECDSA certificate + its hash +// node examples/wt/build.js # bundles the browser client +// node examples/wt/server.js # http://127.0.0.1:8001 — open it in Chrome +// +// The HTTP/3 host is @fails-components/webtransport (a devDependency of the +// repository, never of the package); swap the `acceptSessions` line for +// fromQuico() in a quico handler to run the pure-JS stack instead. + +const fs = require('node:fs'); +const http = require('node:http'); +const path = require('node:path'); + +const { Server, defineRouter, procedure } = require('../../index.js'); +const { acceptSessions, failsRequestCallback } = require('../../wt.js'); +const { bearerTransport } = require('../../auth.js'); + +const certsDir = path.resolve(process.env.WRPC_WT_CERTS ?? 'certs'); +const cert = fs.readFileSync(path.join(certsDir, 'wt-cert.pem'), 'utf8'); +const privKey = fs.readFileSync(path.join(certsDir, 'wt-key.pem'), 'utf8'); +const info = JSON.parse(fs.readFileSync(path.join(certsDir, 'wt-cert.json'), 'utf8')); + +const HTTP_PORT = 8000; // the wrpc Server: WebSocket + REST +const H3_PORT = 4433; // the HTTP/3 host: WebTransport +const PAGE_PORT = 8001; // the page and its bundle, on a server of their own +const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'application/javascript; charset=utf-8' }; + +// The page: a static server of its own, so nothing sits in front of the +// wrpc Server's request handling. It also hands the page the certificate +// hash and the ports. +const servePage = () => + new Promise((resolve) => { + const server = http.createServer((req, res) => { + const pathname = new URL(req.url, 'http://x').pathname; + if (pathname === '/cert-hash.json') { + res.writeHead(200, { 'content-type': 'application/json' }); + return void res.end(JSON.stringify({ hash: info.hash, h3Port: H3_PORT, wsPort: HTTP_PORT })); + } + const root = path.join(__dirname, 'public'); + const file = path.join(root, pathname === '/' ? 'index.html' : pathname); + if (!file.startsWith(root) || !fs.existsSync(file)) { + res.writeHead(404); + return void res.end(); + } + res.writeHead(200, { 'content-type': MIME[path.extname(file)] ?? 'application/octet-stream' }); + res.end(fs.readFileSync(file)); + }); + server.listen(PAGE_PORT, '127.0.0.1', () => resolve(server)); + }); + +const router = defineRouter({ + chat: { + join: procedure({ + access: 'public', + handler: async (ctx, { name }) => { + ctx.client.join('lobby'); + ctx.server.to('lobby').emit('chat/joined', { name, transport: ctx.client.transportKind }); + return { transport: ctx.client.transportKind, clients: ctx.server.clients.size }; + }, + }), + say: procedure({ + access: 'public', + handler: async (ctx, { name, text }) => void ctx.server.to('lobby').emit('chat/message', { name, text }), + }), + }, +}); + +const main = async () => { + const { Http3Server, quicheLoaded } = await import('@fails-components/webtransport'); + await quicheLoaded; + + const server = new Server({ router, host: '127.0.0.1', port: HTTP_PORT, sessions: { transport: bearerTransport() } }); + await server.listen(); + const page = await servePage(); + + const h3 = new Http3Server({ port: H3_PORT, host: '127.0.0.1', secret: 'changeit', cert, privKey }); + // sessionStream() matches the request path literally, query included; the + // wrpc client puts its declared headers in the query, so route by pathname. + h3.setRequestCallback(failsRequestCallback); + h3.startServer(); + await h3.ready; + const acceptor = acceptSessions(server, h3.sessionStream(server.rpc.basePath), { + onError: (error) => console.error('wt session refused', error), + }); + + console.log(`page http://127.0.0.1:${PAGE_PORT}/`); + console.log(`websocket ws://127.0.0.1:${HTTP_PORT}${server.rpc.basePath}`); + console.log( + `webtransport https://127.0.0.1:${H3_PORT}${server.rpc.basePath} (cert ${info.hash}, until ${info.notAfter})`, + ); + + const stop = async () => { + await acceptor.stop(); + await h3.stopServer(); + await server.close(); + page.close(); + process.exit(0); + }; + process.once('SIGINT', stop); + process.once('SIGTERM', stop); +}; + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/index.d.ts b/index.d.ts index 2655fd2..61cde0c 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,11 +1,22 @@ -import { - IncomingMessage, - Server as HttpServer, - ServerResponse, -} from 'node:http'; +import { IncomingMessage, Server as HttpServer, ServerResponse } from 'node:http'; import type { Connection } from './ws.js'; import type { Engine, EngineConnectionSource, WrpcSocket, EngineAttachOptions } from './engine.js'; import type { Backplane } from './scaling.js'; +import type { EnvelopeEncryptionOptions, ServerEncryptionOptions } from './encryption.js'; +import type { + Broadcast, + Client, + ClientMeta, + Context, + ErrorOptions, + EventName, + Procedure, + RoomRegistry, + Router, + SessionManager, + SessionsOptions, + procedure, +} from './rpc.js'; import { Emitter, WrpcError, @@ -15,573 +26,91 @@ import { WrpcLogWriter, WrpcTelemetryOptions, WrpcCodec, + CompressionOptions, + Compressor, } from './client.js'; // The browser-safe half of the surface lives in client.d.ts (which is what -// the `browser` types condition serves); this file is that plus the server. +// the `browser` types condition serves); the engine-agnostic server core +// (routers, sessions, rooms, Client, Context) in rpc.d.ts, shared with the +// WebRTC peer types; this file is both plus the Node server. export * from './client.js'; - -// --------------------------------------------------------------------------- -// Router / procedures - -export type EventName = PropertyKey; - -export interface State { - [key: string]: unknown; -} - -/** - * A validator is a plain `(value) => value | throws` function (returning - * undefined keeps the original value) or a Standard Schema object. - */ -export type Validator = - | ((value: T) => T | undefined | Promise) - | { '~standard': { validate(value: unknown): unknown } }; +export * from './rpc.js'; /** - * One handler signature for both kinds, so a bare function still gets its - * parameters contextually typed. A call ignores the third argument; a - * subscription (an async generator) reads `lastEventId` and `signal` from it. + * The dictionary codec (raw deflate through node:zlib with a preset + * dictionary), for `compression: { codec }` on any Node↔Node carrier. Its + * `id` carries the dictionary's hash: two ends compress against the same + * bytes or, when their routers differ, not at all. `threshold` defaults to + * 64 B — with the history preloaded, small messages are what it is for. + * `async` (`true` or `{ threshold }`, 256 KiB) hands a message that large + * to zlib's threadpool and answers a promise for it — for the WebTransport + * and WebRTC carriers; the Node↔Node ones refuse a codec that declares it. + * A browser needs the pure-JS codec of `@alexify/wrpc/deflate` instead. */ -export type ProcedureHandler = ( - context: Context, - args: any, - subscription: SubscriptionOptions, -) => unknown | Promise | AsyncIterable; - -/** A handler that answers with a stream of values. */ -export type SubscriptionHandler = ProcedureHandler; - -export interface QueueOptions { - concurrency: number; - size?: number; - timeout?: number; +export declare function dictionaryCompressor( + dictionary: Uint8Array | string, + options?: { level?: number; threshold?: number; async?: boolean | { threshold?: number } }, +): Compressor & { readonly dictionary: Uint8Array; readonly async: number | null }; + +/** What every platform-codec factory takes beside its level. */ +export interface PlatformCodecOptions { + /** The byte size under which a message goes plain (1 KiB). */ + threshold?: number; + /** Hand an encode of this many bytes (256 KiB) to zlib's threadpool — WebTransport and WebRTC only. */ + async?: boolean | { threshold?: number }; } - -/** - * One node of a {@link Signature}: a type name (`'string'`, `'number[]'`, - * `'string|null'`), a field map whose keys may end in `?`, or a one-element - * array meaning "an array of that". Deliberately closed — it crosses the wire - * and ends up in a file someone compiles, so `wrpc types` renders anything it - * does not recognise as `unknown`. See docs/reference/protocol.md. - */ -export type SignatureShape = string | [SignatureShape] | { [field: string]: SignatureShape }; - /** - * What a procedure looks like, for codegen. Not validation — `input`/`output` - * are what enforce anything. + * The platform codecs with their knobs out (Node only) — for another level + * than `codec: ''` takes. An id names the format, never the level, so + * two ends on different levels still negotiate. Defaults are measured + * (`bench/algorithms.js`): deflate 3, Brotli quality 4, zstd 1. */ -export interface Signature { - args?: SignatureShape; - /** A call's result. Ignored on a subscription, which yields `data`. */ - returns?: SignatureShape; - /** A subscription's value. Ignored on a call, which answers `returns`. */ - data?: SignatureShape; -} +export declare function deflateCompressor( + options?: PlatformCodecOptions & { /** zlib level, -1..9 (3). */ level?: number }, +): Compressor & { readonly async: number | null }; +/** Brotli. zlib's own default quality, 11, costs milliseconds a message — hence 4. */ +export declare function brotliCompressor( + options?: PlatformCodecOptions & { /** 0..11 (4). */ quality?: number }, +): Compressor & { readonly async: number | null }; +/** Zstandard. Throws a TypeError where node:zlib has none (before Node 22.15 / 23.8). */ +export declare function zstdCompressor( + options?: PlatformCodecOptions & { /** 1..22 (1). */ level?: number }, +): Compressor & { readonly async: number | null }; // --------------------------------------------------------------------------- -// Subscriptions - -/** A value labelled with the id a resuming client will send back. */ -export interface Tracked { - id: string; - data: T; -} - -/** Labels one yielded value so a reconnect can resume after it. */ -export declare function tracked(eventId: string | number, data: T): Tracked; - -export declare function isTracked(value: unknown): value is Tracked; - -/** - * A bounded replay buffer. `since()` answers with what a client missed — - * or `null` when the id has fallen out of the buffer, so a caller can - * choose between a snapshot and an error instead of silently skipping a gap. - */ -export declare class EventLog { - /** Which log incarnation mints this log's ids (the `.` prefix). */ - readonly epoch: string; - constructor(options?: { size?: number; start?: number }); - readonly size: number; - readonly length: number; - readonly lastEventId: string | null; - push(data: T): string; - since(lastEventId?: string | null): Array> | null; - clear(): void; -} - -/** - * Ids are `.`: random epoch per instance by default, so a - * lastEventId from another process (or a restart) is a foreign epoch and - * since() answers null — an honest "cannot resume" — instead of a numeric - * coincidence. A persisted/shared log passes its own stable `epoch`. - */ -export declare function createEventLog(options?: { - size?: number; - start?: number; - epoch?: string; -}): EventLog; - -export interface SubscriptionOptions { - /** What the client says it last saw; undefined on a fresh subscribe. */ - lastEventId?: string; - /** Aborted on unsubscribe or disconnect. Honour it, or the feed leaks. */ - signal: AbortSignal; -} - +// The ws handshake -/** - * A lifecycle hook: named phases, fastify-style, with no `next`. A hook - * runs and either returns (the pipeline continues) or throws an error whose - * numeric `code` becomes the wire code. "After" is a later phase, not code - * after a next() call. The payload depends on the phase: the packet for - * onRequest/onSend/onResponse/onSubscribe, the args for - * preValidation/preHandler, the result for preSerialization (returning a - * value replaces it), the error for onError/onTimeout, the terminal packet - * for onUnsubscribe. - */ -export type Hook = (context: Context, payload: unknown) => unknown | Promise; -/** - * Connection lifecycle hook; observational and contained. `onConnect` - * receives null; `onDisconnect` receives `{ rooms }` — a snapshot of the - * client's rooms taken before destroy() emptied the registry. - */ -export type ConnectionHook = (client: Client, payload: { rooms: Set } | null) => unknown | Promise; - -/** The phases a router (or a unit's reserved `hooks` key) may register. */ -export interface RouterHooks { - onRequest?: Hook | Array; - preValidation?: Hook | Array; - preHandler?: Hook | Array; - preSerialization?: Hook | Array; - onSend?: Hook | Array; - onResponse?: Hook | Array; - onError?: Hook | Array; - onTimeout?: Hook | Array; - onSubscribe?: Hook | Array; - onUnsubscribe?: Hook | Array; - /** Router-level only. */ - onConnect?: ConnectionHook | Array; - /** Router-level only. */ - onDisconnect?: ConnectionHook | Array; -} - -/** The subset a unit's reserved `hooks` key accepts (no connection phases). */ -export type UnitHooks = Omit; - -/** A declarative REST mapping: this procedure IS `method path` under basePath. */ -export interface HttpRoute { - method: 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; - /** Relative to the server's basePath; segments are static or `:name`. */ - path: string; - /** Success status; 204 discards the result body by contract. */ - status?: number; -} - -/** - * The fastify.route.schema shape: the parts wrpc understands plus any - * passthrough keys (tags, summary, security, ...) forwarded to hosts - * verbatim. `query` and `querystring` are interchangeable spellings. - */ -export interface ProcedureSchema { - params?: object; - querystring?: object; - /** Alias of `querystring`; setting both to different objects throws. */ - query?: object; - body?: object; - headers?: object; - /** Keyed by status code; `false` removes a default wrpc error entry. */ - response?: Record; - [passthrough: string]: unknown; -} - -export interface ProcedureOptions { - handler: ProcedureHandler; - access?: 'public' | 'session'; - input?: Validator; - output?: Validator; - /** Maps this procedure onto a real REST endpoint. Calls only. */ - http?: HttpRoute; - /** Declarative validation/serialization/docs; excludes input/output. */ - schema?: ProcedureSchema; - /** Milliseconds; the call fails with code 408 when exceeded. */ - timeout?: number; - /** Concurrency limit; overflow/starvation fails with code 503. */ - queue?: QueueOptions; - meta?: Record; - /** Descriptor consumed by `wrpc types`; see {@link Signature}. */ - signature?: Signature; - /** Inferred from an async generator handler; rarely written by hand. */ - kind?: 'call' | 'subscription'; - /** This procedure's own slice of the pipeline. */ - preValidation?: Hook | Array; - preHandler?: Hook | Array; - preSerialization?: Hook | Array; - onError?: Hook | Array; -} - -/** Same as ProcedureOptions minus the three a stream cannot mean. */ -export type SubscriptionProcedureOptions = Omit; - -export declare class Procedure { - handler: ProcedureHandler; - access: string; - input: Validator | null; - output: Validator | null; - timeout: number; - meta: Record; - signature: Signature | null; - http: HttpRoute | null; - /** Normalized: `query` folded into `querystring`. */ - schema: ProcedureSchema | null; - kind: 'call' | 'subscription'; - readonly subscription: boolean; - constructor(options: ProcedureOptions); - invoke(context: Context, args: unknown, hooks?: Readonly>>): Promise; +/** What a peer declared on an upgrade request, merged the way `attachSocket` merges it. */ +export interface DeclaredHandshake { /** - * The host-delegated entry: queue/timeout semantics without hooks or - * validators — the host (a fastify route) already ran its own. + * The header bag a procedure will see: the declared names (kebab-cased, + * reserved names dropped) UNDER the observed ones, with wrpc's carrier + * tokens taken out of `sec-websocket-protocol`. */ - invokeBare(context: Context, args: unknown): Promise; - /** The value stream behind `{type:'subscribe'}`. */ - subscribe( - context: Context, - args: unknown, - options?: Partial, - hooks?: Readonly>>, - ): AsyncIterableIterator; -} - -export interface ProcedureFactory { - (options: ProcedureOptions | ProcedureHandler): Procedure; - /** - * Explicit spelling for a subscription — redundant when the handler is an - * async generator (which is detected), required when it is a plain - * function returning an async iterable. - */ - subscription(options: SubscriptionProcedureOptions | SubscriptionHandler): Procedure; -} - -export declare const procedure: ProcedureFactory; - -export type MethodDefinition = Procedure | ProcedureHandler | ProcedureOptions; - -/** - * Inbound (client -> server) event handlers. An event is a call that never - * answers, so handlers are procedures too: access, input validation and - * queueing all work the same way. - */ -export type EventsDefinition = Record; - -/** - * A unit's methods, plus two reserved keys: `on` holds its inbound event - * handlers and `hooks` its slice of the lifecycle pipeline. Neither is - * usable as a method name. - */ -export interface UnitDefinition { - on?: EventsDefinition; - hooks?: UnitHooks; - [method: string]: MethodDefinition | EventsDefinition | UnitHooks | undefined; + headers: Record | undefined>; + /** The sanitized connection-metadata bag; empty when nothing was declared. */ + meta: Readonly>; + /** The names in `headers` that came from the declaration and stand (not shadowed by an observed header). */ + declared: ReadonlyArray; } /** - * Unit keys are 'unit' or 'unit.vN' ('auth.v1'); method values are procedures, - * bare handler functions, or procedure option objects. + * Reads the declared `headers`/`meta` of an upgrade request from whichever + * carrier the client used — real headers (a Node client), the `wrpc.h.` / + * `wrpc.m.` subprotocol offers (a browser), or the `wrpc_h` / `wrpc_meta` + * query (`carrier: 'query'`, WebTransport). For a `verifyClient` gate, which + * runs before any `Client` exists; PEER-CONTROLLED, exactly like the result + * on `context.meta`. Malformed or oversize input is refused, never thrown. */ -export type RouterDefinition = Record; - -export interface MethodInfo { - access: string; - /** Present only on subscriptions; a client scaffolds a call otherwise. */ - kind?: 'subscription'; - meta?: Record; - signature?: Signature; - /** The declarative REST mapping, when the procedure carries one. */ - http?: HttpRoute; - /** Input schema parts (params/querystring/body) for client pre-validation. */ - schema?: { params?: object; querystring?: object; body?: object }; -} - -/** - * Injected JSON Schema compilers, structural: `ajv` is anything with - * compile(schema) -> validateFn (ajv-shaped: boolean answer, `.errors` on - * failure), `serializer` anything with compile(schema) -> (value) -> string - * (fast-json-stringify-shaped). wrpc imports neither. - */ -export interface ValidationOptions { - ajv?: { compile(schema: object): (value: unknown) => boolean }; - serializer?: { compile(schema: object): (value: unknown) => string }; -} - -/** What the injected compilers produced for one procedure. */ -export interface CompiledArtifacts { - input?: Validator; - output?: Validator; - serialize?: (value: unknown) => string; -} - -/** - * Router-level REST options. `version: 'path'` maps a versioned unit's - * declared routes under a `/vN` prefix (`auth.v1` + `/auth/signIn` → - * `/v1/auth/signIn`); a function receives the version token (`'v1'`) and the - * declared path and returns the effective path. The default version stays - * unprefixed, and `proc.http` (the declaration) is never mutated. - */ -export interface RestOptions { - version?: 'path' | ((version: string, path: string) => string); -} - -export declare class Router { - constructor( - definition?: RouterDefinition, - options?: { hooks?: RouterHooks; validation?: ValidationOptions; rest?: RestOptions }, - ); - /** - * Adds a unit after construction (how the fastify mirror lands units - * discovered at onReady). Refuses an already-registered unit key. - */ - addUnit(unitKey: string, definition: UnitDefinition): this; - /** Adds a router-level hook after construction. Returns the router. */ - addHook(name: keyof RouterHooks, fn: Hook | ConnectionHook): this; - /** The flattened pipeline for one procedure (router + unit + procedure). */ - hooksFor(proc: Procedure): Readonly>>; - /** The compiled { input?, output?, serialize? } for one procedure. */ - compiledFor(proc: Procedure): CompiledArtifacts | null; - /** True when any procedure compiled a response serializer. */ - readonly hasSerializers: boolean; - /** Router-level connection lifecycle hooks, consumed by RpcServer. */ - readonly connectionHooks: { onConnect: ReadonlyArray; onDisconnect: ReadonlyArray }; - getProcedure( - unit: string, - version: string | undefined, - method: string, - ): Procedure | null; - /** Handler for an inbound `{ type: 'event' }` packet, if the unit declares one. */ - getEventHandler( - unit: string, - version: string | undefined, - name: string, - ): Procedure | null; - introspect(units?: Array | null, options?: { schemas?: boolean }): Record>; - /** True when at least one procedure declares an `http` mapping. */ - readonly hasRestRoutes: boolean; - /** - * Matches a verb + decoded path segments against the REST table: null - * (unknown path), `{ allowed }` (known path, wrong verb — a 405), or the - * full route. - */ - matchRest( - method: string, - segments: Array, - ): - | { proc: Procedure; unitKey: string; methodName: string; params: Record; http: HttpRoute } - | { allowed: Array } - | null; - /** Every declared REST route — what a host adapter registers natively. */ - restRoutes(): Array<{ unitKey: string; methodName: string; proc: Procedure; http: HttpRoute }>; - /** Returns a NEW router; on collision the other router's procedure wins. */ - merge(other: Router): Router; -} - -/** - * The fastify-shaped schema a host receives: the user's declaration with - * wrpc's own lifecycle error statuses documented underneath (overridable, - * removable with `false`). - */ -export declare function effectiveSchema(proc: Procedure): ProcedureSchema; - -export declare function defineRouter( - definition: RouterDefinition, - options?: { hooks?: RouterHooks; validation?: ValidationOptions; rest?: RestOptions }, -): Router; - -// --------------------------------------------------------------------------- -// Sessions - -/** Structural store contract — anything with this shape plugs in. */ -export interface SessionStore { - /** - * Optional sliding expiry: called on restore, because restoring IS active - * use. A store without it keeps absolute TTLs — a valid policy too. - */ - touch?(token: string): Promise | void; - get(token: string): Promise; - set(token: string, data: State): Promise; - delete(token: string): Promise; -} - -export interface MemorySessionStoreOptions { - /** LRU cap; 0 disables it. Default 10000. */ - maxSessions?: number; - /** Entry lifetime in ms; 0 disables expiry. Default 24h. */ - ttl?: number; - now?: () => number; -} - -/** - * Bounded in-memory store (LRU + TTL). Sessions outlive their connection, - * so production deployments should inject a real store instead. - */ -export declare class MemorySessionStore implements SessionStore { - touch(token: string): Promise; - constructor(options?: MemorySessionStoreOptions); - readonly size: number; - get(token: string): Promise; - set(token: string, data: State): Promise; - delete(token: string): Promise; -} - -export declare class Session { - token: string; - state: State; - constructor(token: string, data: State, save?: (data: State) => void); -} - -export interface CookieOptions { - name?: string; - path?: string; - httpOnly?: boolean; - secure?: boolean; - sameSite?: 'Strict' | 'Lax' | 'None'; - maxAge?: number | null; -} - -/** - * Where the session token lives on the wire — injected like a codec or a - * logger, checked structurally (`isTokenTransport`). The cookie default - * keeps the behaviour wrpc always had; a bearer or payload strategy is one - * object. - */ -export interface TokenTransport { - /** The token this request presents, or null. */ - read(request: { headers?: Record | undefined>; url?: string }): string | null; - /** - * A Set-Cookie-style response header value to stamp (HTTP transports - * only), or null when the carrier cannot stamp one — a bearer strategy - * returns null and the signIn handler hands tokens back in its result. - */ - write(token: string): string | null; - /** Optional: the deleting stamp. */ - clear?(): string | null; - /** - * True when the BROWSER attaches the credential without script (a - * cookie) — which is what the safe-method CSRF rule exists for. A - * non-ambient carrier is exempt from that rule. - */ - ambient?: boolean; -} - -export declare function isTokenTransport(value: unknown): value is TokenTransport; - -export interface SessionsOptions { - store?: SessionStore; - cookie?: CookieOptions; - generateToken?: () => string; - /** The injected token carrier; the cookie default is byte-identical. */ - transport?: TokenTransport; -} - -declare class SessionManager { - store: SessionStore; - generateToken: () => string; - cookie: CookieOptions & { name: string; path: string }; - /** The active token carrier (the cookie default unless injected). */ - transport: TokenTransport; - create(token?: string, data?: State): Session; - restore(token: string): Promise; - destroy(token: string): Promise; - cookieHeader(token: string): string; - cookieDeleteHeader(): string; - readToken(cookies: Record): string | null; -} -export type { SessionManager }; - -export function createProxy( - data: T, - save?: (data: T) => void, -): T; - -// --------------------------------------------------------------------------- -// Rooms - -/** - * Named groups of clients, on top of the ordinary `{ type: 'event' }` - * packets. The registry owns both directions — which clients a room holds - * and which rooms a client joined — so a disconnect only has to call - * `leaveAll`. - */ -export declare class RoomRegistry { - constructor(options?: { - /** Fires when a room gains its first member (backplane subscribe). */ - onSubscribe?: (room: string) => void; - /** Fires when a room loses its last member (backplane unsubscribe). */ - onUnsubscribe?: (room: string) => void; - /** Fires on EVERY successful join — the cluster's presence deltas. */ - onJoin?: (room: string, client: Client) => void; - /** Fires on EVERY successful leave. */ - onLeave?: (room: string, client: Client) => void; - }); - /** Number of non-empty rooms. */ - readonly size: number; - list(): Array; - members(room: string): Set; - count(room: string): number; - has(room: string): boolean; - roomsOf(client: Client): Set; - /** True when the client was not already a member. */ - join(client: Client, room: string): boolean; - leave(client: Client, room: string): boolean; - leaveAll(client: Client): void; - clear(): void; -} - -/** - * An immutable, chainable delivery target: every modifier returns a NEW - * Broadcast, so a stored `server.to('chat')` cannot be mutated by a later - * `.except()` elsewhere. - */ -export declare class Broadcast { - /** - * Union, not intersection: `to('a').to('b')` reaches either room. - * `to()` with no rooms narrows to NOBODY — a computed room list that came - * back empty must not fall back to every connected client. - */ - to(...rooms: Array): Broadcast; - except(...clients: Array): Broadcast; - /** Suppresses the backplane publish; the event stays on this instance. */ - local(): Broadcast; - /** The targeted rooms, or null when the target is every client. */ - readonly rooms: Array | null; - /** - * Sends `{ type: 'event', name, data }` and returns how many clients - * received it LOCALLY — remote instances are reached through the - * backplane, whose delivery this number says nothing about. - */ - emit(name: string, data?: unknown): number; - /** - * Emits to every matching client — every instance's members included, - * unless `local()` — and waits for each one's answer (registered - * client-side with `client.respond(name, fn)`). Never rejects: - * per-client failures are collected in `errors`. - */ - ask(name: string, data?: unknown, options?: AskOptions): Promise; -} - -export interface AskOptions { - /** Per-client answer timeout in ms; default 7000. */ - timeout?: number; -} - -export interface AskResult { - /** Values the responders returned, in settlement order. */ - answers: Array; - /** Per-client failures: 501 no responder, 408 timeout, 503 disconnect. */ - errors: Array<{ message: string; code: number; details?: unknown }>; - /** How many clients were asked, cluster-wide. */ - expected: number; - /** True when a remote instance never answered inside the timeout. */ - incomplete: boolean; -} +export declare function readHandshake( + req: { headers?: Record | undefined>; url?: string }, + options?: { + metaMaxBytes?: number; + log?: { warn(record: Record): void }; + declaredHeaders?: ReadonlyArray | null; + }, +): DeclaredHandshake; // --------------------------------------------------------------------------- // Cluster @@ -631,12 +160,54 @@ export interface ClusterOptions { * Opt-in HMAC-SHA256 envelope authentication: with the same secret on * every node, an unsigned or mis-signed cluster message is dropped and * logged — "can publish on the broker" stops being "can command every - * node". Room events travel unsigned; ACL the broker for those. + * node". Room events travel unsigned; ACL the broker for those. A signed + * envelope also carries the sender's counter, channel and clock, so a + * copy replayed later or moved to another channel is refused too (see + * `replay`). */ secret?: string; + /** + * What a node under `secret` does with a signed envelope that carries no + * counter — the one a 1.x node sends. `'strict'` (the default) refuses + * it, logged as `cluster.replay`; `'accept'` lets it through for the + * length of a rolling upgrade from 1.x and still checks every envelope + * that does carry one. Anything else is a `TypeError`. + */ + replay?: 'strict' | 'accept'; + /** + * How far a signed envelope's clock may sit from this node's, in ms, both + * ways — default 30000. It bounds what a node that was not listening can + * be replayed, so the nodes of a cluster keep their clocks within it. + */ + maxSkew?: number; + /** + * Compresses the cluster envelopes this node publishes, after signing — + * the same marker, rollout rule and synchronous-codec requirement as + * `rooms.compression`; an unreadable envelope logs `cluster.encoded`. + */ + compression?: boolean | CompressionOptions; + /** The largest inflated envelope accepted (default 16 MiB). */ + maxMessage?: number; + /** + * @experimental (`@alexify/wrpc/encryption`, whole — may change in a minor.) + * Seals the cluster envelopes — presence, commands, `sendTo` payloads + * (bytes included), + * asks, `fetchClients` replies — under a shared keyring, after signing + * and compression (`@alexify/wrpc/encryption`). `secret` authenticates a + * node; this hides what it says, and refuses a plaintext command too. + * Same rollout and log events as `rooms.encryption`, prefixed `cluster.`. + */ + encryption?: EnvelopeEncryptionOptions | false | null; } export interface RoomsOptions { + /** + * The producer-restart marker every backplane envelope carries, so a + * receiver can tell "this instance restarted, its sequence began again" + * from a real gap. Random per boot by default, which is what you want + * unless a deployment pins it deliberately across restarts. + */ + epoch?: string; /** * How long an emptied room's backplane channel stays subscribed, in ms — * the grace window that absorbs reconnect churn for single-member rooms @@ -644,6 +215,38 @@ export interface RoomsOptions { * bounce. Default 5000; `0` unsubscribes immediately. */ linger?: number; + /** + * How many channels' publish counters (the `seq` of loss detection) this + * instance keeps, in two generations — at most twice this many, not every + * room name it ever published to. A channel evicted and published to + * again starts a new count under a suffixed epoch (`.`), which + * a receiver reads as a restart, never as a gap. Default 16384. + */ + maxTracked?: number; + /** + * Compresses every room envelope this instance publishes past the + * threshold (src/compression), off by default. A string carrier, so a + * compressed envelope rides as base64 under a `wrpc-enc::` marker — + * and there is no negotiation: an instance without the option drops such + * an envelope and logs `backplane.encoded`. Roll it out in two steps + * (deploy the version, then turn it on). The codec must be synchronous. + */ + compression?: boolean | CompressionOptions; + /** The largest inflated envelope accepted (default 16 MiB). */ + maxMessage?: number; + /** + * @experimental (`@alexify/wrpc/encryption`, whole — may change in a minor.) + * Seals every room envelope this instance publishes under a shared + * keyring (`@alexify/wrpc/encryption`), after compression: the backplane + * carries `wrpc-sealed::` and its operator reads nothing — + * not the payload, not the event name, not the room list (the CHANNEL + * name still names the room). Off by default; a three-deploy rollout. An + * envelope that does not open is dropped and logged `backplane.open` + * (unknown kid, another key, a moved or replayed envelope), plaintext + * where none is accepted `backplane.unsealed`, and an instance without + * the keys logs `backplane.sealed`. + */ + encryption?: EnvelopeEncryptionOptions | false | null; } export interface ClusterAskResult { @@ -673,7 +276,9 @@ export declare class Cluster extends Emitter { /** * False while a channel subscribe is failing and being retried: the node * can publish but cannot hear. 'degraded'/'recovered' fire on the - * transitions — wire them to a readiness probe. + * transitions — wire them to a readiness probe. They also fire, with + * `{ instance, reason: 'skew', skew }` and `{ instance }`, for a node whose + * clock is outside `maxSkew`: refused three times in a row, then heard. */ readonly healthy: boolean; /** Cluster-wide membership of `room`: a local sum, no network. */ @@ -699,6 +304,12 @@ export declare class Cluster extends Emitter { join(target: string | ClusterSelector, ...rooms: Array): void; leave(target: string | ClusterSelector, ...rooms: Array): void; disconnect(target: string | ClusterSelector): void; + /** + * One event to ONE client by id — an addressed command, so only the + * instance its id names hears it. `room` narrows delivery to a client + * still in that room. Fire-and-forget, at-most-once. + */ + send(clientId: string, name: string, data?: unknown, options?: { room?: string }): void; /** Fire-and-forget to every OTHER node's `cluster.on(name, ...)`. */ sendEvent(name: string, data?: unknown): void; /** The LOCAL Emitter emit — remote nodes are reached by sendEvent. */ @@ -744,173 +355,6 @@ export interface CorsOptions { methods?: string; } -export interface ErrorOptions { - id?: string; - error?: Error; -} - -/** - * What the peer presented when the connection was made. Frozen. `headers` - * and `data` are PEER-CONTROLLED — labels for logs, metrics and feature - * gates, never an authorization input: authorization is the session's job. - */ -export interface ClientMeta { - /** Client-declared metadata (the client's `meta` option); `{}` when none. */ - readonly data: Readonly>; - /** Request/upgrade headers; `{}` on a worker port. */ - readonly headers: Readonly | undefined>>; - /** The request/upgrade URL with its query string; `''` on a worker port. */ - readonly url: string; - readonly remoteAddress: string; - /** The negotiated WebSocket subprotocol; `''` off ws. */ - readonly protocol: string; -} - -export declare class Context { - client: Client; - uuid: string; - state: Record; - /** - * Aborted when the caller cancels, unsubscribes, or disconnects. A handler - * that awaits anything long-lived should pass it along; one that ignores - * it runs to completion and has its result dropped. - */ - readonly signal: AbortSignal | null; - readonly session: Session | null; - /** - * The server this call arrived on — how a handler reaches rooms - * (`context.server.to(room).emit(...)`) without closing over a server - * that could not exist before the router it was built from. - */ - readonly server: RpcServer | null; - /** - * A child of the connection's logger bound to this call's uuid — the - * documented way to log from a handler: `context.log.info(...)`. - */ - readonly log: WrpcLogWriter; - /** - * The wire target of this invocation — `'unit/name'` or `'unit.vN/name'` - * for calls and subscriptions, the event name verbatim for inbound - * events; null on a context built without a target. - */ - readonly method: string | null; - /** The procedure (or event handler) resolved for this invocation. */ - readonly procedure: Procedure | null; - /** The connection's presented metadata — `client.meta`, mirrored. */ - readonly meta: ClientMeta; - /** - * The caller's per-invocation metadata (the packet's optional `meta` - * field, sanitized: plain object, size-capped, `__proto__` dropped, - * frozen). A frozen EMPTY object when the packet carried none — never - * null, so `context.callMeta.idem` needs no `?.`. Deliberately outside - * schema validation; a label, never an authorization input. - */ - readonly callMeta: Readonly>; - constructor( - client: Client, - signal?: AbortSignal | null, - target?: { method?: string; procedure?: Procedure; callMeta?: Record | null } | null, - ); -} - -export class Client extends Emitter { - /** - * Instance-prefixed (`.`), so the id itself - * addresses the instance holding the connection — what lets a cluster - * command for one client travel as one message to one node. - */ - readonly id: string; - /** - * The application's bag, carried by cluster descriptors (fetchClients). - * wrpc itself never reads it — socket.io's `socket.data`. - */ - data: Record; - source: string; - session: Session | null; - /** True for transports that stay open (WebSocket, worker port). */ - readonly persistent: boolean; - /** The RpcServer this client belongs to; null for a standalone Client. */ - readonly server: RpcServer | null; - /** - * What the peer presented when the connection was made (headers, url, - * negotiated subprotocol, client-declared metadata). Frozen; the - * peer-controlled parts are labels, never authorization inputs. - */ - readonly meta: ClientMeta; - /** Settles once the cookie-based session restore (if any) finished. */ - sessionReady: Promise; - /** - * What dispatch gates on: the session restore PLUS the settled onConnect - * hooks. Two promises on purpose — an onConnect hook may `await - * client.sessionReady`, so folding the hooks into that same promise would - * make such a hook wait for itself. Never rejects. - */ - ready: Promise; - streams: Map; - /** In-flight calls, by id — what `{type:'cancel'}` reaches. */ - calls: Map; - /** Live subscriptions, by id — what `{type:'unsubscribe'}` reaches. */ - subscriptions: Map; - maxSubscriptions: number; - maxCalls: number; - /** Context uuids and server-side stream ids; injectable via RpcServerOptions. */ - generateId: () => string; - /** The connection-scoped log writer (peer binding included). */ - readonly log: WrpcLogWriter; - /** The server's telemetry writer; disabled-shaped when unconfigured. */ - readonly otel: object; - /** 'ws' | 'http' | 'sse' | 'event' — a metric attribute and a log field. */ - readonly transportKind: string; - /** False on a text-only transport (SSE), where binary streams cannot go. */ - readonly binary: boolean; - /** Resolves when the transport drained, or when it closed. */ - drain(): Promise; - error(code: number, options?: ErrorOptions): void; - /** Returns false when the transport is above its high-water mark. */ - send(obj: object, options?: { code?: number; method?: string }): boolean; - /** - * Writes an ALREADY-serialized packet — the fan-out seam: a broadcast - * stringifies once and hands every recipient the same text. - */ - sendRaw(text: string): boolean; - createContext(signal?: AbortSignal | null): Context; - /** The LOCAL Emitter emit — nothing reaches the wire; that is sendEvent. */ - emit(name: EventName, data?: unknown): Promise; - /** Sends a `{type:'event'}` packet to this peer; `name` is 'unit/event'. */ - sendEvent(name: string, data?: unknown): void; - /** - * A call in the other direction: sends `{type:'event', name, data, id}` - * and resolves with what the peer's responder returns (registered - * client-side with `client.respond(name, fn)`). Rejects with 408 on - * timeout, 503 when the connection drops, 501 when the peer has no - * responder. - */ - ask(name: string, data?: unknown, options?: AskOptions): Promise; - /** - * The bookkeeping half of ask(), for a caller that writes the packet - * itself (Broadcast.ask's encode-once fan-out). - */ - expectAnswer(id: string, timeout?: number): Promise; - /** Routes an inbound `callback` to its pending ask; false when none. */ - settleAnswer(packet: { id: string; result?: unknown; error?: { message: string; code: number; details?: unknown } }): boolean; - /** Diagnostics for inbound packets with no id to answer on. */ - warn(message: string): void; - /** Joins a room; false when already a member. */ - join(room: string): boolean; - leave(room: string): boolean; - in(room: string): boolean; - /** The rooms this client is in — a copy, safe to iterate while leaving. */ - readonly rooms: Set; - getStream(id: string): WrpcReadable | WrpcWritable; - createStream(name: string, size: number): WrpcWritable; - initializeSession(token?: string, data?: State): boolean; - finalizeSession(): Promise; - startSession(token?: string, data?: State): boolean; - restoreSession(token: string): Promise; - close(): void; - destroy(): void; -} - /** Abstract HTTP call description consumed by RpcServer.handleHttpCall. */ export interface HttpCall { method: string; @@ -918,11 +362,7 @@ export interface HttpCall { headers: Record; body?: string | Buffer | null; remoteAddress?: string; - respond(response: { - status: number; - headers: Record>; - body?: Buffer; - }): void; + respond(response: { status: number; headers: Record>; body?: Buffer }): void; /** * Registers a listener for a request that ends without a response, so * the core can evict its client. Adapters should wire it to their @@ -959,13 +399,24 @@ export interface RpcServerOptions { */ backplane?: Backplane | null; /** - * Identifies this instance on the backplane; a uuid by default. Must not - * contain '.' — it prefixes every client id (`.`). + * Identifies this instance on the backplane; minted by `generateId` when + * omitted. Must not contain '.' — it prefixes every client id + * (`.`), and a `generateId` that returns one is refused + * by the same rule. */ instanceId?: string; /** - * Context uuids, server-side stream ids and synthetic REST packet ids; - * uuid v4 unless the app brings its own. Correlation ids, not secrets. + * Every id this server mints: the `instanceId` above, client ids, context + * uuids, server-side stream ids, synthetic REST packet ids, SSE channel + * ids and the cluster's boot epoch. uuid v4 unless the app brings its own + * (cuid/ulid/a test counter). Correlation ids, not secrets — a session + * token has its own generator, `sessions.generateToken`. + * + * Validated once at construction: it must be a function answering a + * non-empty string of at most 255 characters (the binary chunk header's + * own limit). The check consumes one id, which becomes the `instanceId` + * rather than being discarded. A bad value is a TypeError at construction + * (1.x reported it through the logger and fell back to uuid v4). */ generateId?: () => string; /** @@ -973,18 +424,46 @@ export interface RpcServerOptions { * `'session'` gates it behind a session, `false` leaves the API surface * unadvertised. A router defining its own introspect always wins. */ - introspection?: - | boolean - | 'session' - | { access?: boolean | 'session'; schemas?: boolean }; + introspection?: boolean | 'session' | { access?: boolean | 'session'; schemas?: boolean }; /** Packets accepted in one batch frame; default 128. */ maxBatch?: number; /** Concurrent subscriptions per client; default 256. */ maxSubscriptions?: number; /** In-flight calls per client; past it a call answers 429. Default 1000. */ maxCalls?: number; + /** Binary streams a peer may hold open on one connection; past it a `stream` packet answers 429. Default 256. */ + maxStreams?: number; /** SSE channel options, or `false` to remove the events endpoint. */ sse?: import('./sse.js').SseOptions | false; + /** The HTTP side's own options: `compression`, off by default. */ + http?: { compression?: boolean | HttpCompressionOptions }; + /** + * Accept per-message compressed frames from a Node WebSocket client that + * negotiated them (its own `compression` option, agreed over the first + * ping/pong) — the direction permessage-deflate cannot cover, since + * Node's built-in WebSocket only inflates. Off by default; a browser + * client is untouched. The codec must answer synchronously. + */ + compression?: boolean | CompressionOptions; + /** The largest inflated client frame accepted on a socket (default 16 MiB). */ + maxMessage?: number; + /** + * @experimental Session encryption of the persistent connections + * (`@alexify/wrpc/encryption`): a Noise handshake, then every frame + * sealed; `required` refuses plaintext on every transport. Off by default. + */ + encryption?: ServerEncryptionOptions | false | null; + /** + * Binary attachments: raw bytes (typed arrays, ArrayBuffers) anywhere in + * a packet's args, result, data or error details travel as bytes in one + * binary frame, and arrive as Uint8Arrays — instead of the plain objects + * JSON makes of them — to every peer that negotiated protocol revision 2 + * (protocol.md#versioning); a 1.0 client is sent JSON. On by default; + * `false` makes this server speak revision 1 to everyone (`wrpc.v1` + * selected, `wrpc-version: 1` answered). Off by itself under a packet + * `codec`, which owns the wire. SSE refuses them explicitly (501/415). + */ + attachments?: boolean; /** * Presence/request tuning for the cluster layer, or `false` to opt out: * presence, commands and asks then degrade to their local halves while @@ -1007,11 +486,20 @@ export interface RpcServerOptions { codec?: WrpcCodec; /** * Cap on peer-declared metadata, measured on the ENCODED input: the ws - * `wrpc_h` connect-URL parameter and the per-packet `meta` field. Over - * the cap the label is refused (a warn is logged), never the connection. - * Default 2048. + * handshake carriers (the `wrpc.h.` + `wrpc.m.` subprotocol offers share + * ONE budget; the connect-URL query is measured whole), the `x-wrpc-meta` + * header and the per-packet `meta` field. Over the cap the label is + * refused (a warn is logged), never the connection. Default 2048. */ metaMaxBytes?: number; + /** + * Opt-in allowlist of the header names a ws handshake may DECLARE (the + * subprotocol token or query carriers), beside the deny list that always + * applies — `['authorization']` for a deployment whose handlers read + * nothing else from a declaration. `cookie` is never declarable. Default + * null: everything the deny list allows. + */ + declaredHeaders?: ReadonlyArray | null; } // The codec types (WrpcCodec, WrpcPacketCodec, WrpcRestCodec) and the @@ -1052,20 +540,91 @@ export declare class RpcServer extends Emitter { constructor(options: RpcServerOptions); /** The local client with this id; undefined when not on this instance. */ getClient(id: string): Client | undefined; + /** + * One event to one client by id, here or on the instance its id names + * (via the cluster). `room` narrows delivery to a client still in that + * room. True when delivered locally or handed to the backplane; false + * when known undeliverable. Bytes in `data` reach a foreign id as bytes + * — a binary envelope, as a room event's do. + */ + sendTo(clientId: string, name: string, data?: unknown, options?: { room?: string }): boolean; /** Everyone in any of `rooms`, each client once; with no rooms, nobody. */ to(...rooms: Array): Broadcast; /** Everyone connected, minus `clients`. */ except(...clients: Array): Broadcast; /** Everyone connected; returns the number of LOCAL recipients. */ broadcast(name: string, data?: unknown): number; + /** + * `meta` is the handshake as the host saw it: `url` carries the declared + * `wrpc_h`/`wrpc_meta` query, `kind` names the wire for logs and metrics + * when the socket is not a WebSocket (`'wt'` from `@alexify/wrpc/wt`). + */ attachSocket( socket: WrpcSocket | Connection, - meta?: { headers?: Record; remoteAddress?: string }, + meta?: { headers?: Record; url?: string; remoteAddress?: string; kind?: string }, ): Client; - attachPort(port: MessagePort): Client; + /** + * Speaks the protocol over a `MessagePort`. `meta` is what a consumer + * took from the page's `wrpc:connect` message, if it hands it over: + * `headers` (declared) — plus `v`, the protocol revision the page speaks, + * for a consumer that knows it some other way: a wrpc page's `wrpc:connect` + * carries no `v`. Without it the port starts at revision 1 and the page + * names its own on its first ping (protocol.md#versioning). + */ + attachPort(port: MessagePort, meta?: { headers?: Record; v?: number } | null): Client; + /** + * Any persistent transport announcing its inbound traffic as 'packet' + * (text) and 'chunk' (bytes) events — the seam a wire the core never heard + * of plugs into (`attachChannel` in `@alexify/wrpc/webrtc` does). `meta` is + * what the application observed about the connection, if anything. + * + * Identity, one of: `session` — a pseudo-session the host vouches for, + * in place before the onConnect hooks run; `request` — what the peer + * presented, restored through the configured token carrier (a bearer + * token in a broker message's headers restores a real session). + */ + attach(transport: InboundTransport, options?: AttachOptions): Client; + /** + * @experimental The public key bundle of the current `encryption` key — + * what a client pins as `createEncryption({ serverKey })`. Safe to + * publish; resolves null when encryption is off. + */ + encryptionKey(): Promise; handleHttpCall(call: HttpCall): Promise; matchPath(pathname: string): { mode: 'packet' | 'rest'; rest?: string } | null; - /** True while drain() runs: new calls are refused with 503. */ + /** + * The per-connection caps every attached client gets — a frozen copy. + * `compression` is the socket-side per-message compression as normalized + * (what a Node ws client may negotiate on ping/pong), or null when off; + * `attachments` is whether binary attachments are on (`false` under + * `attachments: false`, and under a packet `codec`, which owns the wire). + */ + readonly limits: Readonly<{ + maxBatch: number; + maxSubscriptions: number; + maxCalls: number; + maxStreams: number; + compression: NormalizedCompression | null; + attachments: boolean; + }>; + /** + * @experimental Whether `encryption.required` is on: what a binding built + * on `attach` (the broker consumers, a raw data channel) reads to vouch + * for its transport, or refuse to attach, before a delivery arrives. + */ + readonly encryptionRequired: boolean; + /** + * The newest protocol revision this server speaks (protocol.md#versioning): + * 2, or 1 when it sends and reads no framed messages (`attachments: false`, + * a packet codec). A shell composing its own engine narrows the WebSocket + * negotiation to `protocols: ['wrpc.v1']` when this reads 1. + */ + readonly revision: 1 | 2; + /** + * True while drain() runs: new calls are refused with 503. Draining is + * announced once as a `'draining'` event, so a binding that pulls work + * on its own (a broker consumer) stops fetching. + */ readonly draining: boolean; /** * False while a backplane channel subscribe is failing and being retried @@ -1079,12 +638,28 @@ export declare class RpcServer extends Emitter { * with it). @experimental — the writer's shape may change in a minor. */ readonly otel: unknown; + /** + * @experimental The same seam for a compression codec that threw on a + * carrier attached from outside (a WebTransport session, a broker + * binding): counted every time (`wrpc.compression.failures`) and logged + * once per carrier, direction and codec as `compression.failed`. + */ + readonly compressionFailed: (carrier: string, direction: 'encode' | 'decode', codec: string, error: unknown) => void; + /** + * The normalized log writer — not the `logger` that was passed in. For + * hosts and attachers that need somewhere to report a failure which never + * reaches a `Client`: a framework adapter, a WebTransport session. + * Re-wrapping a writer is free, so passing this straight into another + * component's `logger` option is the intended use. + */ + readonly log: WrpcLogWriter; /** * The graceful half of a shutdown: refuse new calls (503) and wait up to * `timeout` ms for in-flight ones to settle. Subscriptions are not waited * for — a live feed has no natural end. Resolves early when idle. */ drain(timeout?: number): Promise; + /** Emits `'close'` first, then tears every client, channel and binding down. */ close(): Promise; } @@ -1123,6 +698,8 @@ export class Server extends Emitter { readonly cluster: Cluster; /** The local client with this id; undefined when not on this instance. */ getClient(id: string): Client | undefined; + /** One event to one client by id, here or on another instance — forwarded to the core. */ + sendTo(clientId: string, name: string, data?: unknown, options?: { room?: string }): boolean; listen(): Promise; /** * With `drain` (ms): stop intake, let in-flight calls settle up to the @@ -1136,6 +713,133 @@ export class Server extends Emitter { export interface TransportOptions { headers?: Record; + /** The normalized `http.compression` option; the core passes its own. */ + compression?: Readonly<{ + threshold: number; + filter: ((call: HttpCall) => boolean) | null; + async: { threshold: number } | null; + /** The codings, in the server's order — what `encodings` resolved to. */ + encoders: ReadonlyArray<{ + readonly token: string; + encode(body: Uint8Array): Uint8Array | Promise; + }>; + }> | null; +} + +/** + * `Content-Encoding` for packet-mode and REST answers — `http: { + * compression }`, off by default, gzip unless `encodings` says otherwise. + * A response is encoded when the request's `Accept-Encoding` admits one of + * the server's codings, the body is at or over `threshold`, nothing upstream set a + * `Content-Encoding`, and `filter` (when given) says yes; it then carries + * `Content-Encoding: gzip` and `Vary: Accept-Encoding`. Nothing changes on + * the client: `fetch` inflates by itself. + */ +/** + * One `Content-Encoding` the server may answer in: a built-in by name, a + * built-in with its own knobs, or an application's own coding. Levels + * default to the measured ones (`bench/algorithms.js`) — Brotli quality 4, + * zstd level 1 — never zlib's Brotli default of 11, which costs + * milliseconds a response. `'zstd'` is a TypeError at construction where + * node:zlib has none (before Node 22.15 / 23.8). + */ +export type HttpEncoding = + | 'gzip' + | 'br' + | 'zstd' + | { encoding: 'gzip'; /** zlib level, -1..9. */ level?: number; /** zlib memLevel, 1..9. */ memLevel?: number } + | { encoding: 'br'; /** 0..11 (4). */ quality?: number } + | { encoding: 'zstd'; /** 1..22 (1). */ level?: number } + | CustomHttpEncoding; + +/** + * An application's own coding. `encoding` is the `Content-Encoding` token; + * `encode` takes the whole body and may answer a promise (a throw or a + * rejection answers the plain body instead). `createStream` — a Node + * Transform that emits every write's output without waiting for more — is + * what an SSE response needs; without it the coding serves one-shot + * answers only and `sse.compression` refuses it. + */ +export interface CustomHttpEncoding { + encoding: string; + encode(body: Uint8Array): Uint8Array | Promise; + createStream?(): NodeJS.ReadWriteStream; +} + +export interface HttpCompressionOptions { + /** Bytes; smaller bodies go plain. Default 1024. */ + threshold?: number; + /** Per request: compress this answer at all? Runs after the cheaper checks. */ + filter?: (call: HttpCall) => boolean; + /** + * The codings this server answers in, in ITS order of preference — the + * first one the request's `Accept-Encoding` admits is used (a weight of + * zero refuses a coding; other weights say acceptable, not preferred, as + * in nginx). Default `['gzip']`. `['zstd', 'br', 'gzip']` earns its place + * on answers from ~16 KB; browsers announce `br` and `zstd` over HTTPS only. + */ + encodings?: ReadonlyArray; + /** + * Bodies at or over `threshold` bytes are encoded on zlib's threadpool and + * the response is written from the callback — the same shape as + * `perMessageDeflate.async`. Default threshold 256 KiB; `{}` or `true` + * takes it. Off by default; a custom coding decides that for itself. + */ + async?: boolean | { threshold?: number }; +} + +/** A `compression` option after normalization: the list in order of preference, and its head. */ +export interface NormalizedCompression { + /** The head of the list — what a carrier with no negotiation encodes with. */ + readonly codec: Compressor; + readonly id: string; + readonly threshold: number; + readonly codecs: ReadonlyArray>; + readonly ids: ReadonlyArray; +} + +export type AttachOptions = { + /** + * What the application observed about the connection — a transport + * carries no request of its own. Any subset of a `ClientMeta`: it is + * normalized like every other entry point's (frozen, `headers` a + * null-prototype bag, the rest defaulted), so `{ headers }` written by + * hand and a full meta read the same on `client.meta`. + */ + meta?: Partial | null; + /** + * false: a request/response carrier (a broker consumer binding) — calls + * only, and not counted among connected clients. Default true. + */ + persistent?: boolean; + /** + * @experimental Vouches for a wire the core cannot see into: under + * `encryption.required`, `attach()` throws without it. A WebRTC data + * channel is (DTLS, end to end); a broker binding is when it seals its + * own frames. + */ + encrypted?: boolean; +} & ( + | { session?: null; request?: null } + | { session: { token?: string; state?: Record; [key: string]: unknown }; request?: null } + | { + session?: null; + request: { headers?: Record; url?: string; remoteAddress?: string }; + } +); + +/** What RpcServer.attach() accepts: persistent, and announcing 'packet'/'chunk'. */ +export interface InboundTransport extends Emitter { + kind?: string; + source?: string; + /** Truthy for a persistent transport; ignored (and cleared) with `persistent: false`. */ + connection?: unknown; + write(data: string | Uint8Array): boolean; + /** A packet to the peer (`ServerTransport.send`): the backpressure boolean. */ + send(obj: object, code?: number): boolean; + /** An error packet to the peer (`ServerTransport.error`). */ + error(code?: number, options?: ErrorOptions): boolean; + close(): void; } export class ServerTransport extends Emitter { @@ -1145,10 +849,31 @@ export class ServerTransport extends Emitter { event: typeof ServerEventTransport; }; source: string; + /** 'http' | 'ws' | 'event' | 'sse' | 'webrtc' | 'wt' — what `Client.transportKind` reports. */ + kind: string; + /** Set on transports that stay open; `Client.persistent` is its truthiness. */ + connection?: unknown; + /** False on a text-only transport, where binary streams are refused. */ + binary?: boolean; + /** + * The protocol revision this connection speaks (protocol.md#versioning): + * 2 until whoever attaches the transport lowers it for a peer that reads + * no framed messages — `setRevision`. + */ + revision: 1 | 2; + /** False while a packet holding bytes leaves as JSON — revision 1, or the server's `attachments: false`. */ + attachments?: boolean; constructor(source: string); + /** Sets the revision and `attachments` with it; emits 'revision' with the one that was left. */ + setRevision(revision: 1 | 2): void; error(code?: number, options?: ErrorOptions): boolean; /** Returns the transport's backpressure signal (false = above the mark). */ send(obj: object, code?: number): boolean; + /** The raw write every send() ends in; returns the same backpressure signal. */ + write(data: string | Uint8Array): boolean; + /** @experimental A packet as one datagram where the connection has them (WebTransport); false otherwise. */ + writeUnreliable?(text: string): boolean; + close(): void; } declare class ServerHttpTransport extends ServerTransport { @@ -1167,10 +892,7 @@ export type { ServerHttpTransport }; declare class ServerWsTransport extends ServerTransport { connection: WrpcSocket | Connection; - constructor( - connection: WrpcSocket | Connection, - meta?: { remoteAddress?: string }, - ); + constructor(connection: WrpcSocket | Connection, meta?: { remoteAddress?: string }); write(data: string | Buffer): boolean; close(): void; } @@ -1178,15 +900,20 @@ export type { ServerWsTransport }; declare class ServerEventTransport extends ServerTransport { port: MessagePort; + /** Set to the transport itself: a port stays open, so the Client is persistent. */ + connection: ServerEventTransport; + /** The newest revision the server behind the port speaks; `attachPort` lowers it for a server that sends no frames. */ + max: 1 | 2; constructor(port: MessagePort); - write(data: string | Buffer): void; + /** The page named revision `v` on its first ping: the port speaks the older of the two; answers this end's `max`. */ + negotiate(v: unknown): 1 | 2; + write(data: string | Buffer): boolean; close(): void; } export type { ServerEventTransport }; -/** Per-request response headers: security defaults + CORS for `origin`. */ -export function buildHeaders( - cors?: CorsOptions | null, - origin?: string, -): Record; - +/** + * Per-request response headers: security defaults + CORS for `origin`, and + * `wrpc-version: 2` when `revision` (the server's newest, default 1) is 2. + */ +export function buildHeaders(cors?: CorsOptions | null, origin?: string, revision?: 1 | 2): Record; diff --git a/package.json b/package.json index 3e96c7c..cd8ea7a 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,21 @@ { "name": "@alexify/wrpc", - "version": "1.0.0", - "description": "Fast, zero-dependency, WebSocket-based RPC protocol for Node.js and browsers: one router over WebSocket, HTTP, SSE and REST - typed client, subscriptions, rooms, cluster and binary streams", + "version": "2.0.0", + "description": "wRPC (Web RPC): a fast, zero-dependency RPC protocol for Node.js and browsers - one router over WebSocket, HTTP, SSE, REST, WebTransport and WebRTC, plus message brokers - typed client, subscriptions, rooms, cluster, binary streams, compression and encryption", "main": "index.js", "browser": { "./index.js": "./browser.js", "./sse.js": "./sse.browser.js", + "./webrtc.js": "./webrtc.browser.js", + "./encryption.js": "./encryption.browser.js", "./src/chunks.js": "./src/chunks.browser.js", - "./src/runtime/node.js": "./src/runtime/browser.js" + "./src/runtime/node.js": "./src/runtime/browser.js", + "./src/compression/native.js": "./src/compression/native.browser.js", + "./src/client/wsCompression.js": "./src/client/wsCompression.browser.js", + "./src/client/wsHandshake.js": "./src/client/wsHandshake.browser.js", + "./src/encryption/aead.js": "./src/encryption/aead.browser.js", + "./src/encryption/dh.js": "./src/encryption/dh.browser.js", + "./src/encryption/hkdf.js": "./src/encryption/hkdf.browser.js" }, "imports": { "#ws": "./src/websocket/ws.js" @@ -46,6 +54,26 @@ "types": "./scaling.d.ts", "default": "./scaling.js" }, + "./broker": { + "types": "./broker.d.ts", + "default": "./broker.js" + }, + "./broker/redis": { + "types": "./broker/redis.d.ts", + "default": "./broker/redis.js" + }, + "./broker/nats": { + "types": "./broker/nats.d.ts", + "default": "./broker/nats.js" + }, + "./broker/amqp": { + "types": "./broker/amqp.d.ts", + "default": "./broker/amqp.js" + }, + "./broker/kafka": { + "types": "./broker/kafka.d.ts", + "default": "./broker/kafka.js" + }, "./sse": { "browser": { "types": "./sse.browser.d.ts", @@ -62,6 +90,30 @@ "types": "./auth.d.ts", "default": "./auth.js" }, + "./deflate": { + "types": "./deflate.d.ts", + "default": "./deflate.js" + }, + "./encryption": { + "browser": { + "types": "./encryption.browser.d.ts", + "default": "./encryption.browser.js" + }, + "types": "./encryption.d.ts", + "default": "./encryption.js" + }, + "./webrtc": { + "browser": { + "types": "./webrtc.browser.d.ts", + "default": "./webrtc.browser.js" + }, + "types": "./webrtc.d.ts", + "default": "./webrtc.js" + }, + "./wt": { + "types": "./wt.d.ts", + "default": "./wt.js" + }, "./package.json": "./package.json" }, "bin": { @@ -73,12 +125,19 @@ "scripts": { "test": "node --test", "test:coverage": "c8 --all --include 'src/**' --check-coverage --lines 95 --statements 95 --branches 90 --functions 95 --reporter text --reporter lcov node --test", + "test:ci": "c8 --all --include 'src/**' --check-coverage --lines 95 --statements 95 --branches 90 --functions 95 --reporter text --reporter lcov node --test --test-timeout=300000 --test-force-exit", + "test:coverage:floors": "node scripts/coverage-floors.js", "test:types": "tsd", "test:perf": "node tests/perf/stream-memory.perf.js", "bench": "node bench/run-all.js", - "lint": "oxlint src tests scripts bench bin", - "format": "oxfmt src tests scripts bench bin", - "format:check": "oxfmt --check src tests scripts bench bin", + "bench:browser": "node scripts/bench-browser.js", + "redis:up": "docker compose up -d redis", + "redis:down": "docker compose down", + "brokers:up": "docker compose up -d", + "brokers:down": "docker compose down", + "lint": "oxlint src tests scripts bench bin examples", + "format": "oxfmt src tests scripts bench bin examples", + "format:check": "oxfmt --check src tests scripts bench bin examples", "size": "node scripts/size.js", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", @@ -121,7 +180,25 @@ "subscriptions", "pubsub", "cluster", - "streams" + "streams", + "webrtc", + "peer-to-peer", + "data-channel", + "webtransport", + "http3", + "quic", + "message-broker", + "broker", + "kafka", + "rabbitmq", + "amqp", + "nats", + "jetstream", + "redis-streams", + "queue", + "consumer", + "event-driven", + "durable-feed" ], "author": "Alex Dolid ", "license": "MIT", @@ -130,7 +207,14 @@ }, "homepage": "https://wrpc.vercel.app", "devDependencies": { + "@confluentinc/kafka-javascript": "^1.10.1", + "@fails-components/webtransport": "^1.6.8", + "@fails-components/webtransport-transport-http3-quiche": "^1.6.8", "@fastify/websocket": "^11.3.0", + "@grpc/grpc-js": "^1.14.5", + "@grpc/proto-loader": "^0.8.1", + "@nats-io/jetstream": "^3.4.0", + "@nats-io/transport-node": "^3.4.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/context-async-hooks": "^2.10.0", "@opentelemetry/core": "^2.10.0", @@ -142,16 +226,22 @@ "@types/node": "^22.10.0", "@vercel/analytics": "^2.0.1", "@vercel/speed-insights": "^2.0.0", + "amqplib": "^2.0.1", + "autocannon": "^8.0.0", "c8": "^10.1.3", "esbuild": "^0.28.1", "express": "^5.2.1", "fastify": "^5.11.3", "fastify-uws": "^2.0.0", "ioredis": "^6.0.0", + "kafkajs": "^2.2.4", "mermaid": "^11.16.0", + "node-datachannel": "^0.33.4", "oxfmt": "^0.62.0", "oxlint": "^1.77.0", "pino": "^10.3.1", + "playwright-core": "^1.63.0", + "quico": "^0.4.0", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", "tsd": "^0.33.0", @@ -159,6 +249,7 @@ "uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.69.0", "vitepress": "^1.6.4", "vitepress-plugin-mermaid": "^2.0.17", + "wrpc-v1": "npm:@alexify/wrpc@1.0.0", "ws": "^8.21.3" }, "engines": { @@ -186,6 +277,9 @@ "express.d.ts", "scaling.js", "scaling.d.ts", + "broker.js", + "broker.d.ts", + "broker/", "sse.js", "sse.browser.js", "sse.d.ts", @@ -194,6 +288,19 @@ "query.d.ts", "auth.js", "auth.d.ts", + "deflate.js", + "deflate.d.ts", + "encryption.js", + "encryption.browser.js", + "encryption.d.ts", + "encryption.browser.d.ts", + "rpc.d.ts", + "webrtc.js", + "webrtc.browser.js", + "webrtc.d.ts", + "webrtc.browser.d.ts", + "wt.js", + "wt.d.ts", "bin/", "src/", "LICENSE", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b7fc1f..6259ee2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,30 @@ importers: .: devDependencies: + '@confluentinc/kafka-javascript': + specifier: ^1.10.1 + version: 1.10.1 + '@fails-components/webtransport': + specifier: ^1.6.8 + version: 1.6.8 + '@fails-components/webtransport-transport-http3-quiche': + specifier: ^1.6.8 + version: 1.6.8 '@fastify/websocket': specifier: ^11.3.0 version: 11.3.0 + '@grpc/grpc-js': + specifier: ^1.14.5 + version: 1.14.5 + '@grpc/proto-loader': + specifier: ^0.8.1 + version: 0.8.1 + '@nats-io/jetstream': + specifier: ^3.4.0 + version: 3.4.0 + '@nats-io/transport-node': + specifier: ^3.4.0 + version: 3.4.0 '@opentelemetry/api': specifier: ^1.9.1 version: 1.9.1 @@ -44,6 +65,12 @@ importers: '@vercel/speed-insights': specifier: ^2.0.0 version: 2.0.0(vue@3.5.41(typescript@7.0.2)) + amqplib: + specifier: ^2.0.1 + version: 2.0.1 + autocannon: + specifier: ^8.0.0 + version: 8.0.0 c8: specifier: ^10.1.3 version: 10.1.3 @@ -62,9 +89,15 @@ importers: ioredis: specifier: ^6.0.0 version: 6.0.0 + kafkajs: + specifier: ^2.2.4 + version: 2.2.4 mermaid: specifier: ^11.16.0 version: 11.16.1 + node-datachannel: + specifier: ^0.33.4 + version: 0.33.4 oxfmt: specifier: ^0.62.0 version: 0.62.0 @@ -74,6 +107,12 @@ importers: pino: specifier: ^10.3.1 version: 10.3.1 + playwright-core: + specifier: ^1.63.0 + version: 1.63.0 + quico: + specifier: ^0.4.0 + version: 0.4.0 socket.io: specifier: ^4.8.3 version: 4.8.3 @@ -95,12 +134,20 @@ importers: vitepress-plugin-mermaid: specifier: ^2.0.17 version: 2.0.17(mermaid@11.16.1)(vitepress@1.6.4(@algolia/client-search@5.56.0)(@types/node@22.20.1)(postcss@8.5.26)(search-insights@2.17.3)(typescript@7.0.2)) + wrpc-v1: + specifier: npm:@alexify/wrpc@1.0.0 + version: '@alexify/wrpc@1.0.0' ws: specifier: ^8.21.3 version: 8.21.3 packages: + '@alexify/wrpc@1.0.0': + resolution: {integrity: sha512-aFfPWCVgui1i8RPjxZLNFj3v3bUvymcvge2TSIFn+bL4CiSsC6aGFtXcitU+OBTKZ+4+tv+7Ve8rgSkI6+KQxA==} + engines: {node: '>=22'} + hasBin: true + '@algolia/abtesting@1.22.0': resolution: {integrity: sha512-BFR6zNowNKcY7Ou7TaJc9QWexES4YKPbmf/OTFofpdsdhz4x6q0lbxp3duO0EHnyrN7rE4ba/TSXuY+BDGu4+g==} engines: {node: '>= 14.0.0'} @@ -180,6 +227,9 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@assemblyscript/loader@0.19.23': + resolution: {integrity: sha512-ulkCYfFbYj01ie1MDOyxv2F6SpRN1TOj7fQxbP07D6HmeR+gr2JLSmINKjga2emB+b1L2KGrFKBTc+e00p54nw==} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -214,6 +264,14 @@ packages: '@chevrotain/types@11.1.2': resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + + '@confluentinc/kafka-javascript@1.10.1': + resolution: {integrity: sha512-KVePUBn21EqCVP9mfxzEuM3SYJHHEF7X+iiC2e43QQgDSyEaksInmCGooT3w5q+cNAdkSCrK71j0ICfqoSqZLw==} + engines: {node: '>=18.0.0'} + '@docsearch/css@3.8.2': resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==} @@ -531,6 +589,14 @@ packages: cpu: [x64] os: [win32] + '@fails-components/webtransport-transport-http3-quiche@1.6.8': + resolution: {integrity: sha512-UOcDhjzQEll2sYC2nGBtklTkKCdx442LVPP9jxp09ZU12qYTG65pjPBC38PbZAJEoIwkBP85h7o2fuIcN2W73Q==} + engines: {node: '>=20'} + + '@fails-components/webtransport@1.6.8': + resolution: {integrity: sha512-y/NYw6oQddqK9f4RWdxyyoAz2K2TdAuV+xKFVsUTITrMdyMJdYiU8cFdMzhyIvgRbfPSKgVXSkfuzH6PsiAIQA==} + engines: {node: '>=20'} + '@fastify/ajv-compiler@4.0.6': resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==} @@ -552,6 +618,15 @@ packages: '@fastify/websocket@11.3.0': resolution: {integrity: sha512-g89ag4BCcD9YP5wBZXixzoLnuf5j89p/sXFcfpCiv2pdEkYYukBEoK3heVzqsp0EAtszVDc2BBZG0KZqeAShIA==} + '@grpc/grpc-js@1.14.5': + resolution: {integrity: sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.1': + resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==} + engines: {node: '>=6'} + hasBin: true + '@iconify-json/simple-icons@1.2.93': resolution: {integrity: sha512-/XhANjfGYOuqvSR3TmUnkQkINvQ4GVjVuukvymRbxtVFBvIq/yiXJqCDycKcQPT401OYT9H2vIY6ihAlz1QIAw==} @@ -568,6 +643,10 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@istanbuljs/schema@0.1.6': resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} engines: {node: '>=8'} @@ -586,12 +665,23 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + + '@mapbox/node-pre-gyp@2.0.3': + resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==} + engines: {node: '>=18'} + hasBin: true + '@mermaid-js/mermaid-mindmap@9.3.0': resolution: {integrity: sha512-IhtYSVBBRYviH1Ehu8gk69pMDF8DSRqXBRDMWrEfHoaMruHeaP2DXA3PBnuwsMaCdPQhlUUcy/7DBLAEIXvCAw==} '@mermaid-js/parser@1.2.0': resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} + '@minimistjs/subarg@1.0.0': + resolution: {integrity: sha512-Q/ONBiM2zNeYUy0mVSO44mWWKYM3UHuEK43PKIOzJCbvUnPoMH1K+gk3cf1kgnCVJFlWmddahQQCmrmBGlk9jQ==} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} engines: {node: ^22.20 || ^24.12 || >=25} @@ -599,6 +689,73 @@ packages: os: [linux] libc: [glibc] + '@nats-io/jetstream@3.4.0': + resolution: {integrity: sha512-GzHQodNJ942+R5LRb8PuZ5ugVWVWMRiufxUYLLVWkXKfwDXYN+Owo0d7L/b9O7BPyrbYD7jQWAC6+ZVuXa9Gyw==} + + '@nats-io/nats-core@3.4.0': + resolution: {integrity: sha512-QMDM86EUNm+wudlKC67HLar/KHHQUvJGLfb4jbahje3pUk3K9afeck9fwsxBZF0eUFox6T/TA6m/t+lQqf+QsQ==} + + '@nats-io/nkeys@2.0.3': + resolution: {integrity: sha512-JVt56GuE6Z89KUkI4TXUbSI9fmIfAmk6PMPknijmuL72GcD+UgIomTcRWiNvvJKxA01sBbmIPStqJs5cMRBC3A==} + engines: {node: '>=18.0.0'} + + '@nats-io/nuid@3.0.0': + resolution: {integrity: sha512-QbXZDrxmYlrn5AD06gYcUTmEHwxn96HBQMIk7XTjDlEcx6FPzVBBPjp4AMRh1lEv6B4iJ6Xb/Uz61JugPXFRQg==} + engines: {node: '>= 22.x'} + + '@nats-io/transport-node@3.4.0': + resolution: {integrity: sha512-hH7u7ejIBTFEJIZ8rIcMrHJI6wl+HhpO5sVFs1+ppmXa8RuB2+Lh1+UwTzZ5xTNNm1TKcRkYy+2qCV56qp8RxA==} + engines: {node: '>= 18.0.0'} + + '@node-datachannel/android-arm64@0.33.4': + resolution: {integrity: sha512-Kd1K+drJlxdGmZYBlzh5HHX2E7les99eTWlt7WldZgvJmcGf2z9L/bnUVn4zbhNm2q/MtNZ2tPX2bd+fGlDDpQ==} + cpu: [arm64] + os: [android] + + '@node-datachannel/darwin-arm64@0.33.4': + resolution: {integrity: sha512-bkIA+IbModz/NUmbVYTSWj8dZvkiab+cU4zOa6rhfKsNcW3EkdD2/DDoJjUrRrw+LbEZ40V7NuAKUlMNFDXFsw==} + cpu: [arm64] + os: [darwin] + + '@node-datachannel/darwin-x64@0.33.4': + resolution: {integrity: sha512-+dLnwiwC3GdWqW0OQShZcSFYrx3hRx0+7U85swoiP3iiRqtDUARME6AIAmW3glpP0vmus1jv5KhPCy/Q+jAvqw==} + cpu: [x64] + os: [darwin] + + '@node-datachannel/linux-arm64-gnu@0.33.4': + resolution: {integrity: sha512-rR4yjwxpI1miLSrruLGcfR9yfw0Qebw+Twt+u+lKsL9SwmXxTm5pJhUAa9RU+c35oZ98IyAxGFjRwwq85BTbog==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@node-datachannel/linux-arm64-musl@0.33.4': + resolution: {integrity: sha512-MR4o1F0aREj/AtbNlXL20YiNx8/pDI8L/5B4Vr9ldiEDottD0IOdCd0khB8fpr5Np2CW7WwU9QG+uto00QGVdw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@node-datachannel/linux-x64-gnu@0.33.4': + resolution: {integrity: sha512-xN0x3lcQ87qwOAmf1dBLVWrYY40LQ7pM4Y9wuNjP+EtN4IwiXqrhPdgupzGAElxna+n2NgBG0gT60dZpgoYU2Q==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@node-datachannel/linux-x64-musl@0.33.4': + resolution: {integrity: sha512-FWqJEjL7TqOPmsqdfwmG/BUwOXQN7MfpEU8kzw6ePbglUbwgdc3976Ls9BCBUXyPgEJyLo8YE1LJyWAlbXWyvA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@node-datachannel/win32-arm64-msvc@0.33.4': + resolution: {integrity: sha512-TeDV2tSPm+qAnF6MIPlLFTIlobSYLwOVe3/wQl+8hD/q3cEPTQi7vByFaQQ5lRULKKdnjKRR5iDMsxMilqbtXA==} + cpu: [arm64] + os: [win32] + + '@node-datachannel/win32-x64-msvc@0.33.4': + resolution: {integrity: sha512-eU0w/9ykrNaA5vWs5gjVs3Qxa45jbQ8mYoMyh5OWnRCE5GzXKxHCpfhFUnO+jGqi0KxkyP0hSA6fiwXXkirk6g==} + cpu: [x64] + os: [win32] + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -906,6 +1063,33 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.2': + resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + '@rollup/rollup-android-arm-eabi@4.62.4': resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==} cpu: [arm] @@ -1190,6 +1374,9 @@ packages: '@types/d3@7.4.3': resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/eslint@7.29.0': resolution: {integrity: sha512-VNcvioYDH8/FxaeTKkM4/TiTwt6pBV9E3OfGmvaw8tPl0rrHCJ4Ll15HRT+pMiFAf/MLQvAzC+6RzUMEL9Ceng==} @@ -1223,6 +1410,9 @@ packages: '@types/minimist@1.2.5': resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@22.20.1': resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} @@ -1515,6 +1705,10 @@ packages: '@vueuse/shared@12.8.2': resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + abbrev@3.0.1: + resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} + engines: {node: ^18.17.0 || >=20.5.0} + abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} @@ -1526,6 +1720,10 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -1541,6 +1739,10 @@ packages: resolution: {integrity: sha512-PrqppUmhT4ENdas2pH9caE7efUcxy6EcSFhWzosiVuQBzu2tQ5yLTI6jwomT/1cuBnivzGfxiJCqDNN9FRRh+Q==} engines: {node: '>= 14.0.0'} + amqplib@2.0.1: + resolution: {integrity: sha512-a3P2MgfCf9nzVis12VxWEn0dS6hcqve7dlEAhXDtIWR27BlhtMkILOc+H9aeHjDi6i6r94dYKc2Kx2OFe3avvg==} + engines: {node: '>=18'} + ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -1565,6 +1767,9 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} @@ -1573,10 +1778,17 @@ packages: resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==} engines: {node: '>=0.10.0'} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} + autocannon@8.0.0: + resolution: {integrity: sha512-fMMcWc2JPFcUaqHeR6+PbmEpTxCrPZyBUM95oG4w3ngJ8NfBNas/ZXA+pTHXLqJ0UlFVTcy05GC25WxKx/M20A==} + hasBin: true + avvio@9.3.0: resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==} @@ -1603,13 +1815,22 @@ packages: bare-abort-controller: optional: true + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + base64id@2.0.0: resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} engines: {node: ^4.5.0 || >= 5.9} + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + birpc@2.9.0: resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + body-parser@2.3.0: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} @@ -1625,6 +1846,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -1662,12 +1886,26 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + char-spinner@1.0.1: + resolution: {integrity: sha512-acv43vqJ0+N0rD+Uw3pDHSxP30FHrywu2NO6/wBaHChJIizpDeBUd6NjqhNhy9LGaEAhZAXn46QzmlAvIWd16g==} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} character-entities-legacy@3.0.0: resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -1676,6 +1914,11 @@ packages: resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} engines: {node: '>=0.10.0'} + cmake-js@8.0.0: + resolution: {integrity: sha512-YbUP88RDwCvoQkZhRtGURYm9RIpWdtvZuhT87fKNoLjk8kIFIFeARpKfuZQGdwfH99GZpUmqSfcDrK62X7lTgg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1683,6 +1926,14 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -1694,6 +1945,10 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -1735,6 +1990,9 @@ packages: cose-base@2.2.0: resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + cross-argv@2.0.0: + resolution: {integrity: sha512-YIaY9TR5Nxeb8SMdtrU8asWVM4jqJDNDYlKV21LxtYcfNJhp1kEsgSa6qXwXgzN0WQWGODps0+TlGp2xQSHwOg==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1918,9 +2176,21 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + delaunator@5.1.0: resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} @@ -1933,6 +2203,10 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -2006,6 +2280,10 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + es-toolkit@1.50.0: resolution: {integrity: sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==} @@ -2046,6 +2324,10 @@ packages: events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -2090,6 +2372,9 @@ packages: fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -2110,6 +2395,10 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + flat-ranges@2.1.0: + resolution: {integrity: sha512-9hW+jDXdlFV6uzpOKFi8dWweF7nw7Vu6AxiFEHFScvx3SWmHUh5fHEJV1FF3HPZbY0iJSJnM4XWZq/xS5EZ5kQ==} + engines: {node: '>=10'} + focus-trap@7.8.0: resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} @@ -2117,6 +2406,10 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -2125,6 +2418,13 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs-extra@11.4.0: + resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==} + engines: {node: '>=14.14'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2145,6 +2445,9 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -2162,6 +2465,9 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} @@ -2169,6 +2475,9 @@ packages: resolution: {integrity: sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==} engines: {node: '>=6'} + has-async-hooks@1.0.0: + resolution: {integrity: sha512-YF0VPGjkxr7AyyQQNykX8zK4PvtEDsUJAPqwu06UFz1lb6EvI53sPh5H1kWxg8NXI5LsfRCZ8uX9NkYDZBb/mw==} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2177,6 +2486,10 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -2187,6 +2500,13 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hdr-histogram-js@3.0.1: + resolution: {integrity: sha512-l3GSdZL1Jr1C0kyb461tUjEdrRPZr8Qry7jByltf5JGrA0xvqOSrxRBfcrJqqV/AMEtqqhHhC6w8HW0gn76tRQ==} + engines: {node: '>=14'} + + hdr-histogram-percentiles-obj@3.0.0: + resolution: {integrity: sha512-7kIufnBqdsBGcSZLPJwqHT3yhk1QTsSlFsVD3kx5ixH/AlgBs9yM1q6DPhXZ8f8gtdqgh7N7/5btRLpQsS2gHw==} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -2207,6 +2527,16 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-parser-js@0.5.10: + resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + hyperid@3.3.0: + resolution: {integrity: sha512-7qhCVT4MJIoEsNcbhglhdmBKb09QtcmJNiIQGq7js/Khf5FtQQ9bzcAuloeqBeee7XD7JqDeve9KNlQya5tSGQ==} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -2215,6 +2545,9 @@ packages: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -2229,6 +2562,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + internmap@1.0.1: resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} @@ -2293,6 +2629,10 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -2319,6 +2659,10 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -2328,6 +2672,13 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + kafkajs@2.2.4: + resolution: {integrity: sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==} + engines: {node: '>=14.0.0'} + katex@0.16.47: resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} hasBin: true @@ -2345,6 +2696,10 @@ packages: layout-base@2.0.1: resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + lemon-tls@0.6.0: + resolution: {integrity: sha512-sR0MrGrqnJ0uTVjMX6q2liq/D6eDp1xlR+6GOs06u0U/xDrwvd80MR6KZYZ/PMfpPZ9JRJxhGpJFiIHC5Qxr2w==} + engines: {node: '>=16.0.0'} + light-my-request@6.6.0: resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} @@ -2362,10 +2717,25 @@ packages: lodash-es@4.18.1: resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.chunk@4.2.0: + resolution: {integrity: sha512-ZzydJKfUHJwHa+hF5X66zLFCBrWn5GeF28OHEr4WVWtNDXlQ/IjWKPBiikqKo2ne0+v6JgCgJ0GzJp8k8bHC7w==} + + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + + lodash.flatten@4.4.0: + resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + log-symbols@4.1.0: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -2380,6 +2750,9 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + manage-path@2.0.0: + resolution: {integrity: sha512-NJhyB+PJYTpxhxZJ3lecIGgh4kwIY2RAh44XvAz9UlqthlQwtPBf62uBVR8XaD8CRuSjQ6TnZH2lNJkbLPZM2A==} + map-obj@1.0.1: resolution: {integrity: sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==} engines: {node: '>=0.10.0'} @@ -2457,6 +2830,10 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -2473,6 +2850,9 @@ packages: resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==} engines: {node: '>= 6'} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -2480,17 +2860,30 @@ packages: minisearch@7.2.0: resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nan@2.29.0: + resolution: {integrity: sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==} + nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + negotiator@0.6.3: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} @@ -2499,9 +2892,37 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + node-abi@3.96.0: + resolution: {integrity: sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==} + engines: {node: '>=10'} + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-api-headers@1.9.0: + resolution: {integrity: sha512-2oNILP4jXwRB4ywnYKjVk1YyJ96n2D4EOVJO6S3oYZ5PtbJrw3Yt9TpAuX3nBLMuzn74rnfGQrv13pS9vC+YiA==} + + node-datachannel@0.33.4: + resolution: {integrity: sha512-qiUrdh8vY9XhglEnx91HK0N2JEAAu8qTZl0fcR3yQd0fFrdO39NwDeJD/KpG+WKUb9/o4p2UwnP+/HftA/xMbQ==} + engines: {node: '>=18.20.0'} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + non-layered-tidy-tree-layout@2.0.2: resolution: {integrity: sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==} + nopt@8.1.0: + resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + normalize-package-data@2.5.0: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} @@ -2525,6 +2946,10 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} + on-net-listen@1.1.2: + resolution: {integrity: sha512-y1HRYy8s/RlcBvDUwKXSmkODMdx4KSuIvloCnQYJ2LdBBC1asY4HtfhXwe3UWknLakATZDnbzht2Ijw3M1EqFg==} + engines: {node: '>=9.4.0 || ^8.9.4'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2583,6 +3008,9 @@ packages: package-manager-detector@1.8.0: resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} @@ -2636,6 +3064,11 @@ packages: resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} hasBin: true + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + plur@4.0.0: resolution: {integrity: sha512-4UGewrYgqDFw9vV6zNV+ADmPAUAfJPKtGvb/VdpQAx25X5f3xXdGdyOEVFwkl8Hl/tl7+xbeHqSEM+D5/TirUg==} engines: {node: '>=10'} @@ -2658,6 +3091,16 @@ packages: preact-render-to-string: optional: true + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + pretty-bytes@5.6.0: + resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} + engines: {node: '>=6'} + pretty-format@29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2668,13 +3111,24 @@ packages: process-warning@5.1.0: resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + protobufjs@7.6.6: + resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==} + engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + qs@6.15.3: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} @@ -2689,6 +3143,10 @@ packages: resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==} engines: {node: '>=8'} + quico@0.4.0: + resolution: {integrity: sha512-QKffsBejCn4s0ovWAlCDJkZrStkr46hDDaAV6gelsVFJ5w0Ha9AbwJVAyJ27tWUEQEABty3V5Z/sJ+zCX05DvA==} + engines: {node: '>=18.0.0'} + range-parser@1.3.0: resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} engines: {node: '>= 0.6'} @@ -2697,6 +3155,10 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -2736,6 +3198,9 @@ packages: regex@6.1.0: resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + reinterval@1.1.0: + resolution: {integrity: sha512-QIRet3SYrGp0HUHO88jVskiG6seqUGC5iAG7AwI/BV4ypGcuqk9Du6YQBUOUqm9c8pw1eyLoIaONifRua1lsEQ==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -2753,6 +3218,9 @@ packages: resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} engines: {node: '>=10'} + retimer@3.0.0: + resolution: {integrity: sha512-WKE0j11Pa0ZJI5YIk0nflGI7SQsfl2ljihVy7ogh7DeQSeYAUi0ubZ/yEueGtDfUPk6GH5LRw1hBdLq4IwUBWA==} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -2855,6 +3323,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -2943,6 +3417,10 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + stylis@4.4.0: resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} @@ -2965,6 +3443,17 @@ packages: tabbable@6.5.0: resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} + engines: {node: '>=18'} + test-exclude@7.0.2: resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} engines: {node: '>=18'} @@ -2976,6 +3465,10 @@ packages: resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} engines: {node: '>=20'} + timestring@6.0.0: + resolution: {integrity: sha512-wMctrWD2HZZLuIlchlkE2dfXJh7J2KDI9Dwl+2abPYg0mswQHfOAyQW3jJg1pY5VfttSINZuKcXoB3FGypVklA==} + engines: {node: '>=8'} + tinyexec@1.3.0: resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} engines: {node: '>=18'} @@ -2996,6 +3489,9 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -3012,6 +3508,12 @@ packages: engines: {node: '>=14.16'} hasBin: true + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + type-fest@0.18.1: resolution: {integrity: sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==} engines: {node: '>=10'} @@ -3063,17 +3565,32 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + url-join@4.0.1: + resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid-parse@1.1.0: + resolution: {integrity: sha512-OdmXxA8rDsQ7YpNVbKSJkNzTw2I+S5WsbMDnCtIWSQaosNAcWtFuI/YK1TjzUI6nbkgiqEyh8gWngfcv8Asd9A==} + uuid@14.0.1: resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + v8-to-istanbul@9.3.0: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} @@ -3148,11 +3665,22 @@ packages: typescript: optional: true + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + which@6.0.1: + resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -3187,6 +3715,10 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -3208,6 +3740,8 @@ packages: snapshots: + '@alexify/wrpc@1.0.0': {} + '@algolia/abtesting@1.22.0': dependencies: '@algolia/client-common': 5.56.0 @@ -3325,6 +3859,8 @@ snapshots: package-manager-detector: 1.8.0 tinyexec: 1.3.0 + '@assemblyscript/loader@0.19.23': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -3353,6 +3889,20 @@ snapshots: '@chevrotain/types@11.1.2': {} + '@colors/colors@1.5.0': + optional: true + + '@confluentinc/kafka-javascript@1.10.1': + dependencies: + '@mapbox/node-pre-gyp': 2.0.3 + bindings: 1.5.0 + js-yaml: 4.3.2 + nan: 2.29.0 + tar: 7.5.22 + transitivePeerDependencies: + - encoding + - supports-color + '@docsearch/css@3.8.2': {} '@docsearch/js@3.8.2(@algolia/client-search@5.56.0)(search-insights@2.17.3)': @@ -3525,6 +4075,25 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true + '@fails-components/webtransport-transport-http3-quiche@1.6.8': + dependencies: + '@types/debug': 4.1.13 + bindings: 1.5.0 + cmake-js: 8.0.0 + debug: 4.4.3 + node-addon-api: 7.1.1 + prebuild-install: 7.1.3 + transitivePeerDependencies: + - supports-color + + '@fails-components/webtransport@1.6.8': + dependencies: + '@types/debug': 4.1.13 + bindings: 1.5.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@fastify/ajv-compiler@4.0.6': dependencies: ajv: 8.20.0 @@ -3557,6 +4126,18 @@ snapshots: - bufferutil - utf-8-validate + '@grpc/grpc-js@1.14.5': + dependencies: + '@grpc/proto-loader': 0.8.1 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.1': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.6 + yargs: 17.7.3 + '@iconify-json/simple-icons@1.2.93': dependencies: '@iconify/types': 2.0.0 @@ -3580,6 +4161,10 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + '@istanbuljs/schema@0.1.6': {} '@jest/schemas@29.6.3': @@ -3595,6 +4180,21 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-sdsl/ordered-map@4.4.2': {} + + '@mapbox/node-pre-gyp@2.0.3': + dependencies: + consola: 3.4.2 + detect-libc: 2.1.2 + https-proxy-agent: 7.0.6 + node-fetch: 2.7.0 + nopt: 8.1.0 + semver: 7.8.5 + tar: 7.5.22 + transitivePeerDependencies: + - encoding + - supports-color + '@mermaid-js/mermaid-mindmap@9.3.0': dependencies: '@braintree/sanitize-url': 6.0.4 @@ -3610,9 +4210,61 @@ snapshots: dependencies: '@chevrotain/types': 11.1.2 + '@minimistjs/subarg@1.0.0': + dependencies: + minimist: 1.2.8 + '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true + '@nats-io/jetstream@3.4.0': + dependencies: + '@nats-io/nats-core': 3.4.0 + + '@nats-io/nats-core@3.4.0': + dependencies: + '@nats-io/nkeys': 2.0.3 + '@nats-io/nuid': 3.0.0 + + '@nats-io/nkeys@2.0.3': + dependencies: + tweetnacl: 1.0.3 + + '@nats-io/nuid@3.0.0': {} + + '@nats-io/transport-node@3.4.0': + dependencies: + '@nats-io/nats-core': 3.4.0 + '@nats-io/nkeys': 2.0.3 + '@nats-io/nuid': 3.0.0 + + '@node-datachannel/android-arm64@0.33.4': + optional: true + + '@node-datachannel/darwin-arm64@0.33.4': + optional: true + + '@node-datachannel/darwin-x64@0.33.4': + optional: true + + '@node-datachannel/linux-arm64-gnu@0.33.4': + optional: true + + '@node-datachannel/linux-arm64-musl@0.33.4': + optional: true + + '@node-datachannel/linux-x64-gnu@0.33.4': + optional: true + + '@node-datachannel/linux-x64-musl@0.33.4': + optional: true + + '@node-datachannel/win32-arm64-msvc@0.33.4': + optional: true + + '@node-datachannel/win32-x64-msvc@0.33.4': + optional: true + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3784,6 +4436,26 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.2': {} + '@rollup/rollup-android-arm-eabi@4.62.4': optional: true @@ -4037,6 +4709,10 @@ snapshots: '@types/d3-transition': 3.0.9 '@types/d3-zoom': 3.0.8 + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + '@types/eslint@7.29.0': dependencies: '@types/estree': 1.0.9 @@ -4069,6 +4745,8 @@ snapshots: '@types/minimist@1.2.5': {} + '@types/ms@2.1.0': {} + '@types/node@22.20.1': dependencies: undici-types: 6.21.0 @@ -4265,6 +4943,8 @@ snapshots: transitivePeerDependencies: - typescript + abbrev@3.0.1: {} + abstract-logging@2.0.1: {} accepts@1.3.8: @@ -4277,6 +4957,8 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 + agent-base@7.1.4: {} + ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -4305,6 +4987,8 @@ snapshots: '@algolia/requester-fetch': 5.56.0 '@algolia/requester-node-http': 5.56.0 + amqplib@2.0.1: {} + ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -4321,12 +5005,42 @@ snapshots: ansi-styles@6.2.3: {} + argparse@2.0.1: {} + array-union@2.1.0: {} arrify@1.0.1: {} + asynckit@0.4.0: {} + atomic-sleep@1.0.0: {} + autocannon@8.0.0: + dependencies: + '@minimistjs/subarg': 1.0.0 + chalk: 4.1.2 + char-spinner: 1.0.1 + cli-table3: 0.6.5 + color-support: 1.1.3 + cross-argv: 2.0.0 + form-data: 4.0.6 + has-async-hooks: 1.0.0 + hdr-histogram-js: 3.0.1 + hdr-histogram-percentiles-obj: 3.0.0 + http-parser-js: 0.5.10 + hyperid: 3.3.0 + lodash.chunk: 4.2.0 + lodash.clonedeep: 4.5.0 + lodash.flatten: 4.4.0 + manage-path: 2.0.0 + on-net-listen: 1.1.2 + pretty-bytes: 5.6.0 + progress: 2.0.3 + reinterval: 1.1.0 + retimer: 3.0.0 + semver: 7.8.5 + timestring: 6.0.0 + avvio@9.3.0: dependencies: '@fastify/error': 4.2.0 @@ -4340,10 +5054,22 @@ snapshots: bare-events@2.9.1: {} + base64-js@1.5.1: {} + base64id@2.0.0: {} + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + birpc@2.9.0: {} + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + body-parser@2.3.0: dependencies: bytes: 3.1.2 @@ -4370,6 +5096,11 @@ snapshots: dependencies: fill-range: 7.1.1 + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + bytes@3.1.2: {} c8@10.1.3: @@ -4411,10 +5142,22 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + char-spinner@1.0.1: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} + chownr@1.1.4: {} + + chownr@3.0.0: {} + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -4423,18 +5166,40 @@ snapshots: cluster-key-slot@1.1.1: {} + cmake-js@8.0.0: + dependencies: + debug: 4.4.3 + fs-extra: 11.4.0 + node-api-headers: 1.9.0 + rc: 1.2.8 + semver: 7.8.5 + tar: 7.5.22 + url-join: 4.0.1 + which: 6.0.1 + yargs: 17.7.3 + transitivePeerDependencies: + - supports-color + color-convert@2.0.1: dependencies: color-name: 1.1.4 color-name@1.1.4: {} + color-support@1.1.3: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} commander@7.2.0: {} commander@8.3.0: {} + consola@3.4.2: {} + content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -4466,6 +5231,8 @@ snapshots: dependencies: layout-base: 2.0.1 + cross-argv@2.0.0: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -4671,16 +5438,26 @@ snapshots: decamelize@1.2.0: {} + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + delaunator@5.1.0: dependencies: robust-predicates: 3.0.3 + delayed-stream@1.0.0: {} + denque@2.1.0: {} depd@2.0.0: {} dequal@2.0.3: {} + detect-libc@2.1.2: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -4769,6 +5546,13 @@ snapshots: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + es-toolkit@1.50.0: {} esbuild@0.21.5: @@ -4855,6 +5639,8 @@ snapshots: transitivePeerDependencies: - bare-abort-controller + expand-template@2.0.3: {} + express@5.2.1: dependencies: accepts: 2.0.0 @@ -4958,6 +5744,8 @@ snapshots: dependencies: reusify: 1.1.0 + file-uri-to-path@1.0.0: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -4989,6 +5777,8 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + flat-ranges@2.1.0: {} + focus-trap@7.8.0: dependencies: tabbable: 6.5.0 @@ -4998,10 +5788,26 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + forwarded@0.2.0: {} fresh@2.0.0: {} + fs-constants@1.0.0: {} + + fs-extra@11.4.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fsevents@2.3.3: optional: true @@ -5027,6 +5833,8 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 + github-from-package@0.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -5051,14 +5859,22 @@ snapshots: gopd@1.2.0: {} + graceful-fs@4.2.11: {} + hachure-fill@0.5.2: {} hard-rejection@2.1.0: {} + has-async-hooks@1.0.0: {} + has-flag@4.0.0: {} has-symbols@1.1.0: {} + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -5081,6 +5897,14 @@ snapshots: dependencies: '@types/hast': 3.0.5 + hdr-histogram-js@3.0.1: + dependencies: + '@assemblyscript/loader': 0.19.23 + base64-js: 1.5.1 + pako: 1.0.11 + + hdr-histogram-percentiles-obj@3.0.0: {} + hookable@5.5.3: {} hosted-git-info@2.8.9: {} @@ -5101,6 +5925,21 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-parser-js@0.5.10: {} + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + hyperid@3.3.0: + dependencies: + buffer: 5.7.1 + uuid: 8.3.2 + uuid-parse: 1.1.0 + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -5109,6 +5948,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + ignore@5.3.2: {} import-meta-resolve@4.2.0: {} @@ -5117,6 +5958,8 @@ snapshots: inherits@2.0.4: {} + ini@1.3.8: {} + internmap@1.0.1: {} internmap@2.0.3: {} @@ -5164,6 +6007,8 @@ snapshots: isexe@2.0.0: {} + isexe@4.0.0: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -5194,6 +6039,10 @@ snapshots: js-tokens@4.0.0: {} + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + json-parse-even-better-errors@2.3.1: {} json-schema-ref-resolver@3.0.0: @@ -5202,6 +6051,14 @@ snapshots: json-schema-traverse@1.0.0: {} + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + kafkajs@2.2.4: {} + katex@0.16.47: dependencies: commander: 8.3.0 @@ -5214,6 +6071,8 @@ snapshots: layout-base@2.0.1: {} + lemon-tls@0.6.0: {} + light-my-request@6.6.0: dependencies: cookie: 1.1.1 @@ -5232,11 +6091,21 @@ snapshots: lodash-es@4.18.1: {} + lodash.camelcase@4.3.0: {} + + lodash.chunk@4.2.0: {} + + lodash.clonedeep@4.5.0: {} + + lodash.flatten@4.4.0: {} + log-symbols@4.1.0: dependencies: chalk: 4.1.2 is-unicode-supported: 0.1.0 + long@5.3.2: {} + lru-cache@10.4.3: {} lru-cache@6.0.0: @@ -5251,6 +6120,8 @@ snapshots: dependencies: semver: 7.8.5 + manage-path@2.0.0: {} + map-obj@1.0.1: {} map-obj@4.3.0: {} @@ -5352,6 +6223,8 @@ snapshots: dependencies: mime-db: 1.54.0 + mimic-response@3.1.0: {} + min-indent@1.0.1: {} minimatch@10.2.6: @@ -5368,23 +6241,65 @@ snapshots: is-plain-obj: 1.1.0 kind-of: 6.0.3 + minimist@1.2.8: {} + minipass@7.1.3: {} minisearch@7.2.0: {} + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + mitt@3.0.1: {} + mkdirp-classic@0.5.3: {} + ms@2.1.3: {} + nan@2.29.0: {} + nanoid@3.3.18: {} + napi-build-utils@2.0.0: {} + negotiator@0.6.3: {} negotiator@1.0.0: {} + node-abi@3.96.0: + dependencies: + semver: 7.8.5 + + node-addon-api@7.1.1: {} + + node-api-headers@1.9.0: {} + + node-datachannel@0.33.4: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + '@node-datachannel/android-arm64': 0.33.4 + '@node-datachannel/darwin-arm64': 0.33.4 + '@node-datachannel/darwin-x64': 0.33.4 + '@node-datachannel/linux-arm64-gnu': 0.33.4 + '@node-datachannel/linux-arm64-musl': 0.33.4 + '@node-datachannel/linux-x64-gnu': 0.33.4 + '@node-datachannel/linux-x64-musl': 0.33.4 + '@node-datachannel/win32-arm64-msvc': 0.33.4 + '@node-datachannel/win32-x64-msvc': 0.33.4 + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + non-layered-tidy-tree-layout@2.0.2: optional: true + nopt@8.1.0: + dependencies: + abbrev: 3.0.1 + normalize-package-data@2.5.0: dependencies: hosted-git-info: 2.8.9 @@ -5409,6 +6324,8 @@ snapshots: dependencies: ee-first: 1.1.1 + on-net-listen@1.1.2: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -5487,6 +6404,8 @@ snapshots: package-manager-detector@1.8.0: {} + pako@1.0.11: {} + parse-json@5.2.0: dependencies: '@babel/code-frame': 7.29.7 @@ -5539,6 +6458,8 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.2.0 + playwright-core@1.63.0: {} + plur@4.0.0: dependencies: irregular-plurals: 3.5.0 @@ -5558,6 +6479,23 @@ snapshots: preact@10.29.8: {} + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.96.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.5 + tunnel-agent: 0.6.0 + + pretty-bytes@5.6.0: {} + pretty-format@29.7.0: dependencies: '@jest/schemas': 29.6.3 @@ -5568,13 +6506,34 @@ snapshots: process-warning@5.1.0: {} + progress@2.0.3: {} + property-information@7.2.0: {} + protobufjs@7.6.6: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.2 + '@types/node': 22.20.1 + long: 5.3.2 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + qs@6.15.3: dependencies: es-define-property: 1.0.1 @@ -5586,6 +6545,11 @@ snapshots: quick-lru@4.0.1: {} + quico@0.4.0: + dependencies: + flat-ranges: 2.1.0 + lemon-tls: 0.6.0 + range-parser@1.3.0: {} raw-body@3.0.2: @@ -5595,6 +6559,13 @@ snapshots: iconv-lite: 0.7.3 unpipe: 1.0.0 + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + react-is@18.3.1: {} read-pkg-up@7.0.1: @@ -5637,6 +6608,8 @@ snapshots: dependencies: regex-utilities: 2.3.0 + reinterval@1.1.0: {} + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -5650,6 +6623,8 @@ snapshots: ret@0.5.0: {} + retimer@3.0.0: {} + reusify@1.1.0: {} rfdc@1.4.1: {} @@ -5805,6 +6780,14 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + slash@3.0.0: {} socket.io-adapter@2.5.8: @@ -5922,6 +6905,8 @@ snapshots: dependencies: min-indent: 1.0.1 + strip-json-comments@2.0.1: {} + stylis@4.4.0: {} superjson@2.2.6: @@ -5941,6 +6926,29 @@ snapshots: tabbable@6.5.0: {} + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar@7.5.22: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + test-exclude@7.0.2: dependencies: '@istanbuljs/schema': 0.1.6 @@ -5957,6 +6965,8 @@ snapshots: dependencies: real-require: 1.0.0 + timestring@6.0.0: {} + tinyexec@1.3.0: {} tinypool@2.1.0: {} @@ -5969,6 +6979,8 @@ snapshots: toidentifier@1.0.1: {} + tr46@0.0.3: {} + trim-lines@3.0.1: {} trim-newlines@3.0.1: {} @@ -5985,6 +6997,12 @@ snapshots: path-exists: 4.0.0 read-pkg-up: 7.0.1 + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + tweetnacl@1.0.3: {} + type-fest@0.18.1: {} type-fest@0.21.3: {} @@ -6051,12 +7069,20 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + universalify@2.0.1: {} + unpipe@1.0.0: {} + url-join@4.0.1: {} + util-deprecate@1.0.2: {} + uuid-parse@1.1.0: {} + uuid@14.0.1: {} + uuid@8.3.2: {} + v8-to-istanbul@9.3.0: dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -6156,10 +7182,21 @@ snapshots: optionalDependencies: typescript: 7.0.2 + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which@2.0.2: dependencies: isexe: 2.0.0 + which@6.0.1: + dependencies: + isexe: 4.0.0 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -6182,6 +7219,8 @@ snapshots: yallist@4.0.0: {} + yallist@5.0.0: {} + yargs-parser@20.2.9: {} yargs-parser@21.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5ed0b5a..c1588c0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,10 @@ allowBuilds: esbuild: true + # The Kafka adapter's integration suite runs against both KafkaJS-shaped + # clients; this one is a native librdkafka binding. + "@confluentinc/kafka-javascript": true + "@fails-components/webtransport-transport-http3-quiche": true + # @grpc/proto-loader's protobufjs ships a postinstall that only checks its + # own CLI's optional dependencies; the gRPC row of bench/rpc-comparison.js + # (its only user) needs nothing from it. Denied on purpose, not forgotten. + protobufjs: false diff --git a/query.d.ts b/query.d.ts index d9826d5..110b8ad 100644 --- a/query.d.ts +++ b/query.d.ts @@ -23,7 +23,7 @@ import type { UntypedApi, WrpcClient, WrpcError, -} from './index.js'; +} from './client.js'; /** Units of a contract, as string keys. */ export type ApiUnits = Extract; diff --git a/rpc.d.ts b/rpc.d.ts new file mode 100644 index 0000000..ea94e3a --- /dev/null +++ b/rpc.d.ts @@ -0,0 +1,961 @@ +// The engine-agnostic server core — routers and procedures, subscriptions, +// sessions, rooms, the per-connection Client and per-call Context — as +// types. Everything here is node-free: it is what index.d.ts (the Node +// surface) and webrtc.browser.d.ts (a browser peer serving a router over a +// data channel) share, exactly as client.d.ts is shared by the two halves of +// the client. There is no rpc.js at runtime: the names below ship through +// the main barrel and, for a browser peer, through @alexify/wrpc/webrtc. +import type { EncryptionInfo } from './encryption.browser.js'; +import { Emitter, WrpcError, WrpcReadable, WrpcWritable, WrpcLogWriter, WrpcLogger, WrpcCodec } from './client.js'; + +// --------------------------------------------------------------------------- +// Router / procedures + +export type EventName = PropertyKey; + +/** Per-message send options on the server side: `Client.sendEvent`, `Client.sendRaw` and the broadcast `emit`. */ +export interface ServerSendOptions { + /** A datagram where the transport has them (WebTransport): lossy, unordered; reliable elsewhere. */ + unreliable?: boolean; + /** Send uncompressed even when permessage-deflate was negotiated and the size clears the threshold. */ + compress?: boolean; +} + +export type BroadcastEmitOptions = ServerSendOptions; + +/** + * One message shared by every recipient of a broadcast: the serialized + * packet plus an engine-owned cache slot. An engine claims `frames` when it + * is null and reuses it when it already holds that engine's own cache; a + * slot claimed by a different engine (a mixed room) means "use `text`". + * Application code never builds one — `Broadcast.emit` does. + */ +export interface SharedMessage { + text: string; + frames: unknown | null; + compress: boolean; +} + +export interface State { + [key: string]: unknown; +} + +/** + * A validator is a plain `(value) => value | throws` function (returning + * undefined keeps the original value) or a Standard Schema object. + */ +export type Validator = + | ((value: T) => T | undefined | Promise) + | { '~standard': { validate(value: unknown): unknown } }; + +/** + * One handler signature for both kinds, so a bare function still gets its + * parameters contextually typed. A call ignores the third argument; a + * subscription (an async generator) reads `lastEventId` and `signal` from it. + */ +export type ProcedureHandler = ( + context: Context, + args: any, + subscription: SubscriptionOptions, +) => unknown | Promise | AsyncIterable; + +/** A handler that answers with a stream of values. */ +export type SubscriptionHandler = ProcedureHandler; + +export interface QueueOptions { + concurrency: number; + size?: number; + timeout?: number; +} + +/** + * One node of a {@link Signature}: a type name (`'string'`, `'number[]'`, + * `'string|null'`), a field map whose keys may end in `?`, or a one-element + * array meaning "an array of that". Deliberately closed — it crosses the wire + * and ends up in a file someone compiles, so `wrpc types` renders anything it + * does not recognise as `unknown`. See docs/reference/protocol.md. + */ +export type SignatureShape = string | [SignatureShape] | { [field: string]: SignatureShape }; + +/** + * What a procedure looks like, for codegen. Not validation — `input`/`output` + * are what enforce anything. + */ +export interface Signature { + args?: SignatureShape; + /** A call's result. Ignored on a subscription, which yields `data`. */ + returns?: SignatureShape; + /** A subscription's value. Ignored on a call, which answers `returns`. */ + data?: SignatureShape; +} + +// --------------------------------------------------------------------------- +// Subscriptions + +/** A value labelled with the id a resuming client will send back. */ +export interface Tracked { + id: string; + data: T; +} + +/** Labels one yielded value so a reconnect can resume after it. */ +export declare function tracked(eventId: string | number, data: T): Tracked; + +export declare function isTracked(value: unknown): value is Tracked; + +/** + * A bounded replay buffer. `since()` answers with what a client missed — + * or `null` when the id has fallen out of the buffer, so a caller can + * choose between a snapshot and an error instead of silently skipping a gap. + */ +export declare class EventLog { + /** Which log incarnation mints this log's ids (the `.` prefix). */ + readonly epoch: string; + constructor(options?: { size?: number; start?: number }); + readonly size: number; + readonly length: number; + readonly lastEventId: string | null; + push(data: T): string; + since(lastEventId?: string | null): Array> | null; + clear(): void; +} + +/** + * Ids are `.`: random epoch per instance by default, so a + * lastEventId from another process (or a restart) is a foreign epoch and + * since() answers null — an honest "cannot resume" — instead of a numeric + * coincidence. A persisted/shared log passes its own stable `epoch`. + */ +export declare function createEventLog(options?: { + size?: number; + start?: number; + epoch?: string; +}): EventLog; + +export interface SubscriptionOptions { + /** What the client says it last saw; undefined on a fresh subscribe. */ + lastEventId?: string; + /** Aborted on unsubscribe or disconnect. Honour it, or the feed leaks. */ + signal: AbortSignal; +} + +/** + * A lifecycle hook: named phases, fastify-style, with no `next`. A hook + * runs and either returns (the pipeline continues) or throws an error whose + * numeric `code` becomes the wire code. "After" is a later phase, not code + * after a next() call. The payload depends on the phase: the packet for + * onRequest/onSend/onResponse/onSubscribe, the args for + * preValidation/preHandler, the result for preSerialization (returning a + * value replaces it), the error for onError/onTimeout, the terminal packet + * for onUnsubscribe. + */ +export type Hook = (context: Context, payload: unknown) => unknown | Promise; +/** + * Connection lifecycle hook; observational and contained. `onConnect` + * receives null; `onDisconnect` receives `{ rooms }` — a snapshot of the + * client's rooms taken before destroy() emptied the registry. + */ +export type ConnectionHook = (client: Client, payload: { rooms: Set } | null) => unknown | Promise; + +/** The phases a router (or a unit's reserved `hooks` key) may register. */ +export interface RouterHooks { + onRequest?: Hook | Array; + preValidation?: Hook | Array; + preHandler?: Hook | Array; + preSerialization?: Hook | Array; + onSend?: Hook | Array; + onResponse?: Hook | Array; + onError?: Hook | Array; + onTimeout?: Hook | Array; + onSubscribe?: Hook | Array; + onUnsubscribe?: Hook | Array; + /** Router-level only. */ + onConnect?: ConnectionHook | Array; + /** Router-level only. */ + onDisconnect?: ConnectionHook | Array; +} + +/** The subset a unit's reserved `hooks` key accepts (no connection phases). */ +export type UnitHooks = Omit; + +/** A declarative REST mapping: this procedure IS `method path` under basePath. */ +/** How a shared cache may treat a route's successful responses. GET/HEAD only. */ +export interface HttpCache { + /** Seconds. */ + maxAge: number; + /** `public` in Cache-Control (a CDN may store it); default false → `private`. */ + public?: boolean; + /** Seconds; adds `stale-while-revalidate`. */ + staleWhileRevalidate?: number; + /** A weak ETag over the body and 304 on a matching If-None-Match; default true. */ + etag?: boolean; +} + +export interface HttpRoute { + method: 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + /** Relative to the server's basePath; segments are static or `:name`. */ + path: string; + /** Success status; 204 discards the result body by contract. */ + status?: number; + /** Static response headers on every answer of this route; transport-owned names are refused. */ + headers?: Record; + /** + * The cache policy of a successful response. Emitted as declared only + * for a public procedure on a request that restored no session and set + * no cookie; anything session-bearing answers `private, no-store`. + */ + cache?: HttpCache; +} + +/** + * The HTTP response seam a REST handler reaches as `context.http`: the + * request line and headers, `setHeader()` onto this very response (refused + * once it is sent, and for transport-owned names) and `status()` for its + * success status. Null on every non-REST transport and on packet-mode HTTP. + */ +export interface HttpReply { + readonly method: string; + readonly url: string; + readonly headers: Record | undefined>; + setHeader(name: string, value: string | number): void; + status(code: number): void; +} + +/** + * The fastify.route.schema shape: the parts wrpc understands plus any + * passthrough keys (tags, summary, security, ...) forwarded to hosts + * verbatim. `query` and `querystring` are interchangeable spellings. + */ +export interface ProcedureSchema { + params?: object; + querystring?: object; + /** Alias of `querystring`; setting both to different objects throws. */ + query?: object; + body?: object; + headers?: object; + /** Keyed by status code; `false` removes a default wrpc error entry. */ + response?: Record; + [passthrough: string]: unknown; +} + +export interface ProcedureOptions { + handler: ProcedureHandler; + access?: 'public' | 'session'; + input?: Validator; + output?: Validator; + /** Maps this procedure onto a real REST endpoint. Calls only. */ + http?: HttpRoute; + /** Declarative validation/serialization/docs; excludes input/output. */ + schema?: ProcedureSchema; + /** Milliseconds; the call fails with code 408 when exceeded. */ + timeout?: number; + /** Concurrency limit; overflow/starvation fails with code 503. */ + queue?: QueueOptions; + meta?: Record; + /** Descriptor consumed by `wrpc types`; see {@link Signature}. */ + signature?: Signature; + /** Inferred from an async generator handler; rarely written by hand. */ + kind?: 'call' | 'subscription'; + /** This procedure's own slice of the pipeline. */ + preValidation?: Hook | Array; + preHandler?: Hook | Array; + preSerialization?: Hook | Array; + onError?: Hook | Array; + /** + * The delivery policy of a queue consumer. Valid only on a procedure + * inside a unit's `consumes` block; see `@alexify/wrpc/broker`. + * @experimental + */ + consume?: ConsumePolicy; +} + +/** + * How a broker binding delivers a queue's messages into a consumer + * procedure. Every field may be overridden at attach time + * (`attachConsumers(server, broker, { 'unit.v1/source': { ... } })`). + * @experimental + */ +export interface ConsumePolicy { + /** The broker-side queue/topic; defaults to the `consumes` key. */ + queue?: string; + /** The consumer group where the broker needs one; defaults to the queue. */ + group?: string; + /** Unsettled messages held at once; at most the server's maxCalls. Default 16. */ + prefetch?: number; + /** false disables retries (dead-letter on the first failure). */ + retry?: + | false + | { + attempts?: number; + backoff?: { base?: number; max?: number; factor?: number; jitter?: boolean }; + retryOn?: Array; + }; + /** Where exhausted or refused messages go; default `.dlq`, false drops them. */ + deadLetter?: string | false; + /** + * Who the consumer calls as. 'none' (default): no session — a procedure + * with `access: 'session'` refuses to bind. 'service': one pseudo-session + * for the binding. 'token': the session a message header's bearer token + * restores. + */ + identity?: { + trust: 'none' | 'service' | 'token'; + session?: { token?: string; state?: Record }; + header?: string; + }; + /** Message headers copied into `context.callMeta` (lower-case names). */ + meta?: Array; + /** How a message body becomes the procedure's args; default JSON.parse. */ + args?: (body: string, headers: Record) => unknown; +} + +/** Same as ProcedureOptions minus the three a stream cannot mean. */ +export type SubscriptionProcedureOptions = Omit; + +export declare class Procedure { + handler: ProcedureHandler; + access: string; + input: Validator | null; + output: Validator | null; + timeout: number; + meta: Record; + signature: Signature | null; + http: HttpRoute | null; + /** Normalized: `query` folded into `querystring`. */ + schema: ProcedureSchema | null; + kind: 'call' | 'subscription'; + readonly subscription: boolean; + /** The consumer policy, frozen; null outside a `consumes` block. */ + readonly consume: Readonly | null; + constructor(options: ProcedureOptions); + invoke(context: Context, args: unknown, hooks?: Readonly>>): Promise; + /** + * The host-delegated entry: queue/timeout semantics without hooks or + * validators — the host (a fastify route) already ran its own. + */ + invokeBare(context: Context, args: unknown): Promise; + /** The value stream behind `{type:'subscribe'}`. */ + subscribe( + context: Context, + args: unknown, + options?: Partial, + hooks?: Readonly>>, + ): AsyncIterableIterator; +} + +export interface ProcedureFactory { + (options: ProcedureOptions | ProcedureHandler): Procedure; + /** + * Explicit spelling for a subscription — redundant when the handler is an + * async generator (which is detected), required when it is a plain + * function returning an async iterable. + */ + subscription(options: SubscriptionProcedureOptions | SubscriptionHandler): Procedure; +} + +export declare const procedure: ProcedureFactory; + +export type MethodDefinition = Procedure | ProcedureHandler | ProcedureOptions; + +/** + * Inbound (client -> server) event handlers. An event is a call that never + * answers, so handlers are procedures too: access, input validation and + * queueing all work the same way. + */ +export type EventsDefinition = Record; + +/** + * A unit's methods, plus two reserved keys: `on` holds its inbound event + * handlers and `hooks` its slice of the lifecycle pipeline. Neither is + * usable as a method name. + */ +export interface UnitDefinition { + on?: EventsDefinition; + hooks?: UnitHooks; + /** + * Queue consumers: procedures a broker binding invokes per delivered + * message, keyed by source queue. Unreachable by a call packet, and not + * part of introspection. @experimental + */ + consumes?: EventsDefinition; + [method: string]: MethodDefinition | EventsDefinition | UnitHooks | undefined; +} + +/** + * Unit keys are 'unit' or 'unit.vN' ('auth.v1'); method values are procedures, + * bare handler functions, or procedure option objects. + */ +export type RouterDefinition = Record; + +export interface MethodInfo { + access: string; + /** Present only on subscriptions; a client scaffolds a call otherwise. */ + kind?: 'subscription'; + meta?: Record; + signature?: Signature; + /** The declarative REST mapping, when the procedure carries one. */ + http?: HttpRoute; + /** Input schema parts (params/querystring/body) for client pre-validation. */ + schema?: { params?: object; querystring?: object; body?: object }; +} + +/** + * Injected JSON Schema compilers, structural: `ajv` is anything with + * compile(schema) -> validateFn (ajv-shaped: boolean answer, `.errors` on + * failure), `serializer` anything with compile(schema) -> (value) -> string + * (fast-json-stringify-shaped). wrpc imports neither. + */ +export interface ValidationOptions { + ajv?: { compile(schema: object): (value: unknown) => boolean }; + serializer?: { compile(schema: object): (value: unknown) => string }; +} + +/** What the injected compilers produced for one procedure. */ +export interface CompiledArtifacts { + input?: Validator; + output?: Validator; + serialize?: (value: unknown) => string; +} + +/** + * Router-level REST options. `version: 'path'` maps a versioned unit's + * declared routes under a `/vN` prefix (`auth.v1` + `/auth/signIn` → + * `/v1/auth/signIn`); a function receives the version token (`'v1'`) and the + * declared path and returns the effective path. The default version stays + * unprefixed, and `proc.http` (the declaration) is never mutated. + */ +export interface RestOptions { + version?: 'path' | ((version: string, path: string) => string); +} + +export declare class Router { + constructor( + definition?: RouterDefinition, + options?: { hooks?: RouterHooks; validation?: ValidationOptions; rest?: RestOptions }, + ); + /** + * Adds a unit after construction (how the fastify mirror lands units + * discovered at onReady). Refuses an already-registered unit key. + */ + addUnit(unitKey: string, definition: UnitDefinition): this; + /** Adds a router-level hook after construction. Returns the router. */ + addHook(name: keyof RouterHooks, fn: Hook | ConnectionHook): this; + /** The flattened pipeline for one procedure (router + unit + procedure). */ + hooksFor(proc: Procedure): Readonly>>; + /** The compiled { input?, output?, serialize? } for one procedure. */ + compiledFor(proc: Procedure): CompiledArtifacts | null; + /** True when any procedure compiled a response serializer. */ + readonly hasSerializers: boolean; + /** Router-level connection lifecycle hooks, consumed by RpcServer. */ + readonly connectionHooks: { onConnect: ReadonlyArray; onDisconnect: ReadonlyArray }; + getProcedure(unit: string, version: string | undefined, method: string): Procedure | null; + /** Handler for an inbound `{ type: 'event' }` packet, if the unit declares one. */ + getEventHandler(unit: string, version: string | undefined, name: string): Procedure | null; + /** A unit's queue-consumer procedure, if it declares one. @experimental */ + getConsumer(unit: string, version: string | undefined, name: string): Procedure | null; + /** Every declared queue consumer. @experimental */ + consumers(): Array<{ unitKey: string; unit: string; version: string; name: string; procedure: Procedure }>; + introspect(units?: Array | null, options?: { schemas?: boolean }): Record>; + /** True when at least one procedure declares an `http` mapping. */ + readonly hasRestRoutes: boolean; + /** + * Matches a verb + decoded path segments against the REST table: null + * (unknown path), `{ allowed }` (known path, wrong verb — a 405), or the + * full route. + */ + matchRest( + method: string, + segments: Array, + ): + | { proc: Procedure; unitKey: string; methodName: string; params: Record; http: HttpRoute } + | { allowed: Array } + | null; + /** Every declared REST route — what a host adapter registers natively. */ + restRoutes(): Array<{ unitKey: string; methodName: string; proc: Procedure; http: HttpRoute }>; + /** Returns a NEW router; on collision the other router's procedure wins. */ + merge(other: Router): Router; +} + +/** + * The fastify-shaped schema a host receives: the user's declaration with + * wrpc's own lifecycle error statuses documented underneath (overridable, + * removable with `false`). + */ +export declare function effectiveSchema(proc: Procedure): ProcedureSchema; + +export declare function defineRouter( + definition: RouterDefinition, + options?: { hooks?: RouterHooks; validation?: ValidationOptions; rest?: RestOptions }, +): Router; + +// --------------------------------------------------------------------------- +// Sessions + +/** Structural store contract — anything with this shape plugs in. */ +export interface SessionStore { + /** + * Optional sliding expiry: called on restore, because restoring IS active + * use. A store without it keeps absolute TTLs — a valid policy too. + */ + touch?(token: string): Promise | void; + get(token: string): Promise; + /** + * `create` is true for the write that makes the row and false for every + * write after it; a store MAY answer `false` to an update of a row that + * is gone, which ends the session instead of resurrecting it. A store + * that ignores the option writes as before. + */ + set(token: string, data: State, options?: { create: boolean }): Promise; + delete(token: string): Promise; +} + +export interface MemorySessionStoreOptions { + /** LRU cap; 0 disables it. Default 10000. */ + maxSessions?: number; + /** Entry lifetime in ms; 0 disables expiry. Default 24h. */ + ttl?: number; + now?: () => number; +} + +/** + * Bounded in-memory store (LRU + TTL). Sessions outlive their connection, + * so production deployments should inject a real store instead. + */ +export declare class MemorySessionStore implements SessionStore { + touch(token: string): Promise; + constructor(options?: MemorySessionStoreOptions); + readonly size: number; + get(token: string): Promise; + set(token: string, data: State): Promise; + delete(token: string): Promise; +} + +export declare class Session { + token: string; + state: State; + constructor(token: string, data: State, save?: (data: State) => void); + /** True once finalized: a save still queued for it does not land. */ + readonly ended: boolean; + /** Marks the session finalized (Client.finalizeSession calls it). */ + end(): void; +} + +export interface CookieOptions { + name?: string; + path?: string; + httpOnly?: boolean; + secure?: boolean; + sameSite?: 'Strict' | 'Lax' | 'None'; + maxAge?: number | null; +} + +/** + * Where the session token lives on the wire — injected like a codec or a + * logger, checked structurally (`isTokenTransport`). The cookie default + * keeps the behaviour wrpc always had; a bearer or payload strategy is one + * object. + */ +export interface TokenTransport { + /** The token this request presents, or null. */ + read(request: { headers?: Record | undefined>; url?: string }): string | null; + /** + * A Set-Cookie-style response header value to stamp (HTTP transports + * only), or null when the carrier cannot stamp one — a bearer strategy + * returns null and the signIn handler hands tokens back in its result. + */ + write(token: string): string | null; + /** Optional: the deleting stamp. */ + clear?(): string | null; + /** + * True when the BROWSER attaches the credential without script (a + * cookie) — which is what the safe-method CSRF rule exists for. A + * non-ambient carrier is exempt from that rule. + */ + ambient?: boolean; +} + +export declare function isTokenTransport(value: unknown): value is TokenTransport; + +export interface SessionsOptions { + store?: SessionStore; + cookie?: CookieOptions; + generateToken?: () => string; + /** The injected token carrier; the cookie default is byte-identical. */ + transport?: TokenTransport; +} + +declare class SessionManager { + store: SessionStore; + generateToken: () => string; + cookie: CookieOptions & { name: string; path: string }; + /** The active token carrier (the cookie default unless injected). */ + transport: TokenTransport; + create(token?: string, data?: State): Session; + restore(token: string): Promise; + destroy(token: string): Promise; + cookieHeader(token: string): string; + cookieDeleteHeader(): string; + readToken(cookies: Record): string | null; +} +export type { SessionManager }; + +export function createProxy(data: T, save?: (data: T) => void): T; + +// --------------------------------------------------------------------------- +// Rooms + +/** + * Named groups of clients, on top of the ordinary `{ type: 'event' }` + * packets. The registry owns both directions — which clients a room holds + * and which rooms a client joined — so a disconnect only has to call + * `leaveAll`. + */ +export declare class RoomRegistry { + constructor(options?: { + /** Fires when a room gains its first member (backplane subscribe). */ + onSubscribe?: (room: string) => void; + /** Fires when a room loses its last member (backplane unsubscribe). */ + onUnsubscribe?: (room: string) => void; + /** Fires on EVERY successful join — the cluster's presence deltas. */ + onJoin?: (room: string, client: Client) => void; + /** Fires on EVERY successful leave. */ + onLeave?: (room: string, client: Client) => void; + }); + /** Number of non-empty rooms. */ + readonly size: number; + list(): Array; + members(room: string): Set; + count(room: string): number; + has(room: string): boolean; + roomsOf(client: Client): Set; + /** True when the client was not already a member. */ + join(client: Client, room: string): boolean; + leave(client: Client, room: string): boolean; + leaveAll(client: Client): void; + clear(): void; +} + +/** + * An immutable, chainable delivery target: every modifier returns a NEW + * Broadcast, so a stored `server.to('chat')` cannot be mutated by a later + * `.except()` elsewhere. + */ +export declare class Broadcast { + /** + * Union, not intersection: `to('a').to('b')` reaches either room. + * `to()` with no rooms narrows to NOBODY — a computed room list that came + * back empty must not fall back to every connected client. + */ + to(...rooms: Array): Broadcast; + except(...clients: Array): Broadcast; + /** Suppresses the backplane publish; the event stays on this instance. */ + local(): Broadcast; + /** The targeted rooms, or null when the target is every client. */ + readonly rooms: Array | null; + /** + * Sends `{ type: 'event', name, data }` and returns how many clients + * received it LOCALLY — remote instances are reached through the + * backplane, whose delivery this number says nothing about. + */ + /** + * `unreliable: true`: a datagram to every recipient whose transport has + * them, reliable to the rest; the flag crosses the backplane. + * `compress: false`: sent uncompressed to every recipient that + * negotiated permessage-deflate. The packet is serialized — and, on the + * built-in engine, framed and deflated — ONCE for the whole fan-out. + */ + emit(name: string, data?: unknown, options?: BroadcastEmitOptions | null): number; + /** + * Emits to every matching client — every instance's members included, + * unless `local()` — and waits for each one's answer (registered + * client-side with `client.respond(name, fn)`). Never rejects: + * per-client failures are collected in `errors`. + */ + ask(name: string, data?: unknown, options?: AskOptions): Promise; +} + +export interface AskOptions { + /** Per-client answer timeout in ms; default 7000. */ + timeout?: number; +} + +export interface AskResult { + /** Values the responders returned, in settlement order. */ + answers: Array; + /** Per-client failures: 501 no responder, 408 timeout, 503 disconnect. */ + errors: Array<{ message: string; code: number; details?: unknown }>; + /** How many clients were asked, cluster-wide. */ + expected: number; + /** True when a remote instance never answered inside the timeout. */ + incomplete: boolean; +} + +/** + * What a handler reaches through `context.server` — the part of a host + * that addresses connected clients. `RpcServer` is one (it adds sessions, + * the cluster, `sendTo`; narrow with `instanceof RpcServer` to reach them); + * a WebRTC `PeerHost` is another, which is why this is a contract and not + * the server class. + */ +export interface ClientHost { + readonly router: Router; + readonly rooms: RoomRegistry; + readonly instanceId: string; + /** The connected client with this id; undefined when not on this host. */ + getClient(id: string): Client | undefined; + /** Chainable, immutable targeting: `to('a').except(c).emit(name, data)`. */ + to(...rooms: Array): Broadcast; + except(...clients: Array): Broadcast; + /** Every connected client; returns how many received it locally. */ + broadcast(name: string, data?: unknown): number; +} + +export interface ErrorOptions { + id?: string; + error?: Error; +} + +/** + * What the peer presented when the connection was made. Frozen. `headers` + * and `data` are PEER-CONTROLLED — labels for logs, metrics and feature + * gates, never an authorization input: authorization is the session's job. + */ +export interface ClientMeta { + /** Client-declared metadata (the client's `meta` option); `{}` when none. */ + readonly data: Readonly>; + /** Request/upgrade headers; `{}` on a worker port. */ + readonly headers: Readonly | undefined>>; + /** + * The names in `headers` the peer DECLARED on a ws handshake (a label it + * attached through a carrier — subprotocol token, query — rather than a + * header the connection carried); `[]` elsewhere. + */ + readonly declared: ReadonlyArray; + /** The request/upgrade URL with its query string; `''` on a worker port. */ + readonly url: string; + readonly remoteAddress: string; + /** The negotiated WebSocket subprotocol; `''` off ws. */ + readonly protocol: string; +} + +export declare class Context { + client: Client; + uuid: string; + state: Record; + /** + * Aborted when the caller cancels, unsubscribes, or disconnects. A handler + * that awaits anything long-lived should pass it along; one that ignores + * it runs to completion and has its result dropped. + */ + readonly signal: AbortSignal | null; + readonly session: Session | null; + /** The REST response seam; null except on a REST call (see HttpReply). */ + readonly http: HttpReply | null; + /** + * The server this call arrived on — how a handler reaches rooms + * (`context.server.to(room).emit(...)`) without closing over a server + * that could not exist before the router it was built from. + */ + readonly server: ClientHost | null; + /** + * A child of the connection's logger bound to this call's uuid — the + * documented way to log from a handler: `context.log.info(...)`. + */ + readonly log: WrpcLogWriter; + /** + * The wire target of this invocation — `'unit/name'` or `'unit.vN/name'` + * for calls and subscriptions, the event name verbatim for inbound + * events; null on a context built without a target. + */ + readonly method: string | null; + /** The procedure (or event handler) resolved for this invocation. */ + readonly procedure: Procedure | null; + /** The connection's presented metadata — `client.meta`, mirrored. */ + readonly meta: ClientMeta; + /** + * The caller's per-invocation metadata (the packet's optional `meta` + * field, sanitized: plain object, size-capped, `__proto__` dropped, + * frozen). A frozen EMPTY object when the packet carried none — never + * null, so `context.callMeta.idem` needs no `?.`. Deliberately outside + * schema validation; a label, never an authorization input. + */ + readonly callMeta: Readonly>; + constructor( + client: Client, + signal?: AbortSignal | null, + target?: { method?: string; procedure?: Procedure; callMeta?: Record | null } | null, + ); +} + +export class Client extends Emitter { + /** + * Instance-prefixed (`.`), so the id itself + * addresses the instance holding the connection — what lets a cluster + * command for one client travel as one message to one node. + */ + readonly id: string; + /** + * The application's bag, carried by cluster descriptors (fetchClients). + * wrpc itself never reads it — socket.io's `socket.data`. + */ + data: Record; + source: string; + session: Session | null; + /** True for transports that stay open (WebSocket, worker port). */ + readonly persistent: boolean; + /** + * The protocol revision this connection speaks (protocol.md#versioning): + * 2 when the peer reads framed messages, 1 for a 1.0 peer — whose bytes + * arrive, and are sent, as the JSON 1.0 made of them — and on every SSE + * channel, which is text-only. + */ + readonly revision: 1 | 2; + /** + * Whether bytes in a packet leave this connection as an attachments frame: + * false at revision 1, and under the server's `attachments: false` or a + * packet codec. + */ + readonly attachments: boolean; + /** The host this client belongs to (an RpcServer, a PeerHost); null for a standalone Client. */ + readonly server: ClientHost | null; + /** + * What the peer presented when the connection was made (headers, url, + * negotiated subprotocol, client-declared metadata). Frozen; the + * peer-controlled parts are labels, never authorization inputs. + */ + readonly meta: ClientMeta; + /** Settles once the cookie-based session restore (if any) finished. */ + sessionReady: Promise; + /** + * What dispatch gates on: the session restore PLUS the settled onConnect + * hooks. Two promises on purpose — an onConnect hook may `await + * client.sessionReady`, so folding the hooks into that same promise would + * make such a hook wait for itself. Never rejects. + */ + ready: Promise; + /** True once `ready` has resolved — what lets the dispatcher skip the await per call. */ + readonly isReady: boolean; + /** The REST response seam (Context.http); null except on a REST call. */ + http: HttpReply | null; + /** + * The streams the PEER announced here, by id — what `getStream()` answers + * and `maxStreams` caps. A `createStream()` writable is not kept in it. + */ + streams: Map; + /** In-flight calls, by id — what `{type:'cancel'}` reaches. */ + calls: Map; + /** Live subscriptions, by id — what `{type:'unsubscribe'}` reaches. */ + subscriptions: Map; + maxSubscriptions: number; + maxCalls: number; + /** Binary streams the peer may hold open here; past it a `stream` packet answers 429. */ + maxStreams: number; + /** True when the session store could not be asked for this client's token: a session procedure answers 503, not 403. */ + sessionUnavailable: boolean; + /** Context uuids and server-side stream ids; injectable via RpcServerOptions. */ + generateId: () => string; + /** The connection-scoped log writer (peer binding included). */ + readonly log: WrpcLogWriter; + /** The server's telemetry writer; disabled-shaped when unconfigured. */ + readonly otel: object; + /** 'ws' | 'http' | 'sse' | 'event' — a metric attribute and a log field. */ + readonly transportKind: string; + /** The per-message compression the peer negotiated for its own frames (a Node ws client's `compression`), or null. */ + readonly compression: { readonly id: string; readonly threshold: number } | null; + /** + * @experimental (`@alexify/wrpc/encryption`, whole — may change in a minor.) + * The facts of a session-encrypted connection once its handshake is done, + * null on one that is not. An `onConnect` hook that awaits + * `client.sessionReady` reads it: `remoteStatic` is the client's + * authenticated key under XX. + */ + readonly encryption: EncryptionInfo | null; + /** False on a text-only transport (SSE), where binary streams cannot go. */ + readonly binary: boolean; + /** Resolves when the transport drained, or when it closed. */ + drain(): Promise; + error(code: number, options?: ErrorOptions): void; + /** + * Returns false when the transport is above its high-water mark. + * `compress: false` sends this packet uncompressed on a transport that + * negotiated permessage-deflate (ignored elsewhere). + */ + send(obj: object, options?: { code?: number; method?: string; compress?: boolean }): boolean; + /** + * Writes an ALREADY-serialized packet. With `unreliable: true`, as a + * datagram where the transport can; reliably otherwise. `compress: false` + * as on sendEvent. + */ + sendRaw(text: string, options?: ServerSendOptions | null): boolean; + /** + * The fan-out seam: one `SharedMessage` for every recipient of a + * broadcast. The text is serialized once; a transport with a + * prepared-frame path (the built-in engine) encodes and deflates it once + * into `frames` for every recipient after the first. Transports without + * it write the text. Returns the backpressure signal. + */ + sendShared(message: SharedMessage, options?: ServerSendOptions | null): boolean; + /** + * The context a call runs under. The runtime also takes the call's target + * as a second argument and `send()` a pre-serialized text — both are the + * dispatcher's own (internal), deliberately left out of this signature. + */ + createContext(signal?: AbortSignal | null): Context; + /** The LOCAL Emitter emit — nothing reaches the wire; that is sendEvent. */ + emit(name: EventName, data?: unknown): Promise; + /** Sends a `{type:'event'}` packet to this peer; `name` is 'unit/event'. */ + /** + * `unreliable: true` sends the event as a datagram where the transport + * has them (WebTransport) — lossy and unordered — and reliably everywhere + * else; the application code is the same either way. + */ + sendEvent(name: string, data?: unknown, options?: ServerSendOptions | null): void; + /** + * A call in the other direction: sends `{type:'event', name, data, id}` + * and resolves with what the peer's responder returns (registered + * client-side with `client.respond(name, fn)`). Rejects with 408 on + * timeout, 503 when the connection drops, 501 when the peer has no + * responder. + */ + ask(name: string, data?: unknown, options?: AskOptions): Promise; + /** + * The bookkeeping half of ask(), for a caller that writes the packet + * itself (Broadcast.ask's encode-once fan-out). + */ + expectAnswer(id: string, timeout?: number): Promise; + /** Routes an inbound `callback` to its pending ask; false when none. */ + settleAnswer(packet: { + id: string; + result?: unknown; + error?: { message: string; code: number; details?: unknown }; + }): boolean; + /** Diagnostics for inbound packets with no id to answer on. */ + warn(message: string): void; + /** Joins a room; false when already a member. */ + join(room: string): boolean; + leave(room: string): boolean; + in(room: string): boolean; + /** The rooms this client is in — a copy, safe to iterate while leaving. */ + readonly rooms: Set; + getStream(id: string): WrpcReadable; + createStream(name: string, size: number): WrpcWritable; + initializeSession(token?: string, data?: State): boolean; + finalizeSession(): Promise; + startSession(token?: string, data?: State): boolean; + restoreSession(token: string): Promise; + close(): void; + destroy(): void; +} + +/** + * A preset dictionary for the per-message codecs, built from what `router` + * declares — field names from signatures and schemas, every `unit/method` + * target and `unit/event` name, the packet skeletons — least to most + * frequent, at most `limit` bytes (32 KiB, what zlib looks at), the front + * cut past it. Deterministic for a router definition, so a fleet agrees on + * the id `dictionaryCompressor` derives. Browser-safe. + */ +export declare function buildDictionary( + router: { introspect(units?: unknown, options?: object): object }, + options?: { limit?: number }, +): Uint8Array; diff --git a/scaling.d.ts b/scaling.d.ts index d78deda..ab8b382 100644 --- a/scaling.d.ts +++ b/scaling.d.ts @@ -11,7 +11,7 @@ * fan-out mechanism, not a queue. */ -import type { WrpcLogger } from './index.js'; +import type { WrpcLogger, SessionStore } from './index.js'; /** Removes one subscription; safe to call more than once. */ export type Unsubscribe = () => void | Promise; @@ -108,3 +108,41 @@ export declare function createRedisAdapter( export declare function isBackplane(value: unknown): value is Backplane; export declare const DEFAULT_PREFIX: string; + +/** + * A Redis session store for `sessions: { store }`, ioredis-shaped and + * injected: `get(key)`, `set(key, value, 'PX', ttl)`, `del(key)` and, + * for sliding expiry, `pexpire(key, ttl)`. node-redis v4 needs a two-line + * wrapper for its `set(key, value, { PX })` spelling. + */ +export interface RedisSessionClient { + get(key: string): Promise; + /** `(...args)` on purpose: ioredis declares `set` as a stack of overloads, which no single signature is assignable from. */ + set(...args: Array): Promise; + del(...args: Array): Promise; + pexpire?(...args: Array): Promise; +} + +export interface RedisSessionStoreOptions { + /** + * Where a corrupt stored row reports. This store sits below + * `SessionManager`, which never sees an entry it rejected, so a row that + * fails to parse — a user silently signed out — would otherwise be + * invisible from above. The session token is never logged. + */ + logger?: WrpcLogger | boolean; + client: RedisSessionClient; + /** Key prefix; default 'wrpc:session:'. */ + prefix?: string; + /** Milliseconds; default 24h; 0 = no expiry. */ + ttl?: number; +} + +/** The store `createRedisSessionStore` returns: the core's SessionStore contract, `touch` present when the client has pexpire and ttl > 0. */ +export interface RedisSessionStore extends SessionStore { + readonly name: string; +} + +/** A session store over an injected Redis client: what lets ws/wt run without sticky routing. */ +export declare function createRedisSessionStore(options: RedisSessionStoreOptions): RedisSessionStore; +export declare const DEFAULT_SESSION_PREFIX: string; diff --git a/scripts/autobahn/echo-server.js b/scripts/autobahn/echo-server.js index e71e6be..2ecf2ed 100644 --- a/scripts/autobahn/echo-server.js +++ b/scripts/autobahn/echo-server.js @@ -31,11 +31,17 @@ const server = http.createServer((req, res) => { res.end('WebSocket upgrade required'); }); +// AUTOBAHN_DEFLATE=takeover runs the suite with context takeover in both +// directions plus async deflate over 1 KiB — the queued, asynchronous +// paths — instead of the default stateless one-shot compression. +const perMessageDeflate = + process.env.AUTOBAHN_DEFLATE === 'takeover' ? { contextTakeover: true, async: { threshold: 1024 } } : true; + const wss = new WebsocketServer({ server, pingInterval: PING_INTERVAL, maxBuffer: MAX_BUFFER, - perMessageDeflate: true, + perMessageDeflate, }); wss.on('connection', (ws) => { diff --git a/scripts/autobahn/run.js b/scripts/autobahn/run.js index 01deb0e..4bd5acb 100644 --- a/scripts/autobahn/run.js +++ b/scripts/autobahn/run.js @@ -12,6 +12,14 @@ * timing cases are deliberately not excluded — non-strict results there are * acceptable. * + * A run that STOPPED EARLY fails too. The fuzzing client gives up on the + * whole suite when one connect to the echo server times out (an emulated + * amd64 image on a busy Docker VM does that), and it still writes a report + * — of the cases it got to. Grading that report alone called 262 of 517 + * cases "all passed". So the client's output is read as it goes: a lost + * connection, or a report holding fewer cases than were started, is a + * failed run, and the verdict says how many cases it stands on. + * * Path scheme (one consistent scheme, container-side paths in the config): * scripts/autobahn -> /config (base fuzzingclient.json, for reference/manual runs) * scripts/autobahn/reports -> /reports (outdir AND the generated effective config) @@ -85,13 +93,38 @@ const runDocker = () => args.push('-v', `${REPORTS_DIR}:/reports`); args.push(IMAGE, 'wstest', '-m', 'fuzzingclient', '-s', `/reports/${GENERATED_CONFIG}`); console.log(`> docker ${args.join(' ')}`); - const child = spawn('docker', args, { stdio: 'inherit' }); + const child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] }); + // Passed through as it arrives, and read line by line for the two things + // the report cannot say: how many cases were started, and whether the + // client lost the server on the way. + let started = 0; + let lost = null; + const scan = (stream, out) => { + let rest = ''; + stream.setEncoding('utf8'); + stream.on('data', (chunk) => { + out.write(chunk); + const lines = (rest + chunk).split('\n'); + rest = lines.pop(); + for (const line of lines) { + if (line.startsWith('Running test case ID')) started++; + else if (lost === null && /^Connection to \S+ failed/.test(line)) lost = line.trim(); + } + }); + }; + scan(child.stdout, process.stdout); + scan(child.stderr, process.stderr); child.once('error', (error) => { reject(new Error(`failed to start docker (is it installed and running?): ${error.message}`)); }); child.once('exit', (code) => { - if (code === 0) resolve(); - else reject(new Error(`docker exited with code ${code}`)); + if (code !== 0) return void reject(new Error(`docker exited with code ${code}`)); + if (lost !== null) { + return void reject( + new Error(`the fuzzing client stopped after ${started} cases — ${lost} The report is PARTIAL; run it again.`), + ); + } + resolve(started); }); }); @@ -102,14 +135,16 @@ const collectFailures = () => { } const index = JSON.parse(fs.readFileSync(indexPath, 'utf8')); const failures = []; + let graded = 0; for (const agent of Object.values(index)) { for (const [caseId, result] of Object.entries(agent)) { + graded++; if (FAIL_BEHAVIORS.has(result.behavior) || FAIL_BEHAVIORS.has(result.behaviorClose)) { failures.push(`${caseId} (behavior: ${result.behavior}, close: ${result.behaviorClose})`); } } } - return failures; + return { failures, graded }; }; const main = async () => { @@ -118,14 +153,17 @@ const main = async () => { writeEffectiveConfig(); const echo = await startEchoServer(); try { - await runDocker(); - const failures = collectFailures(); + const started = await runDocker(); + const { failures, graded } = collectFailures(); + if (graded === 0 || graded < started) { + throw new Error(`the report holds ${graded} cases of the ${started} that were started`); + } if (failures.length > 0) { - console.error(`Autobahn: ${failures.length} failing case(s):`); + console.error(`Autobahn: ${failures.length} of ${graded} cases failing:`); for (const failure of failures) console.error(` ${failure}`); process.exitCode = 1; } else { - console.log(`Autobahn: all cases passed (report: ${path.join(REPORTS_DIR, 'index.html')})`); + console.log(`Autobahn: all ${graded} cases passed (report: ${path.join(REPORTS_DIR, 'index.html')})`); } } finally { echo.kill('SIGTERM'); diff --git a/scripts/bench-browser.js b/scripts/bench-browser.js new file mode 100644 index 0000000..b6a2e4b --- /dev/null +++ b/scripts/bench-browser.js @@ -0,0 +1,183 @@ +'use strict'; + +// Runs a browser benchmark in a real Chrome. Bundles the browser entry with +// esbuild (a devDependency, as in scripts/size.js), drives the installed +// Google Chrome through playwright-core (devDependency; no browser download — +// `channel: 'chrome'`), and prints what the page measured. Two suites: +// +// pnpm bench:browser bench/browser/calls.js — the client's +// call path over an in-page echo +// pnpm bench:browser transports bench/browser/transports.js — the client +// over Chrome's own WebSocket, WebTransport +// and WebRTC, against a Node Server +// +// WRPC_ROOT=/path/to/another/checkout pnpm bench:browser # a before/after +// +// The transports suite serves its page from http://127.0.0.1 (WebTransport +// needs a secure context; a setContent page is about:blank). Its WebTransport +// row needs the HTTP/3 host the integration tests use — `node +// scripts/wt-cert.js certs` and WRPC_WT=fails — and is skipped without it; +// the WebSocket and WebRTC rows need nothing. +// +// Nothing in CI runs it: a browser benchmark on a laptop is a shape, not a +// gate (docs/guide/performance.md). + +const http = require('node:http'); +const path = require('node:path'); +const fs = require('node:fs'); + +const esbuild = require('esbuild'); +const { chromium } = require('playwright-core'); + +const HERE = path.resolve(__dirname, '..'); +const ROOT = process.env.WRPC_ROOT ? path.resolve(process.env.WRPC_ROOT) : HERE; +const SUITE = process.argv[2] ?? 'calls'; + +const bundle = async (stdin) => { + const result = await esbuild.build({ + ...(stdin + ? { stdin: { contents: stdin, resolveDir: ROOT, sourcefile: 'bench-entry.js' } } + : { entryPoints: [path.join(ROOT, 'browser.js')] }), + bundle: true, + platform: 'browser', + conditions: ['browser'], + format: 'iife', + globalName: 'wrpc', + write: false, + logLevel: 'silent', + }); + return result.outputFiles[0].text; +}; + +const launch = () => chromium.launch({ channel: process.env.WRPC_BROWSER_CHANNEL ?? 'chrome', headless: true }); + +const forwardErrors = (tab) => + tab.on('console', (message) => { + if (message.type() === 'error') console.error(`[page] ${message.text()}`); + }); + +const calls = async () => { + const [library, page] = [await bundle(), fs.readFileSync(path.join(HERE, 'bench', 'browser', 'calls.js'), 'utf8')]; + const browser = await launch(); + try { + const tab = await browser.newPage(); + forwardErrors(tab); + await tab.setContent('wrpc bench'); + await tab.addScriptTag({ content: library }); + await tab.addScriptTag({ content: page }); + const version = await browser.version(); + console.log(`Browser call-path benchmark — Chrome ${version}, entry ${path.relative(HERE, ROOT) || '.'}\n`); + const results = await tab.evaluate(() => globalThis.__wrpcBench()); + for (const { name, opsPerSec } of results) { + console.log(` ${name.padEnd(46)}${opsPerSec.toLocaleString('en-US').padStart(14)} ops/sec`); + } + console.log(); + } finally { + await browser.close(); + } +}; + +const transports = async () => { + const { createWrpcServer } = require('../bench/support/wrpc-echo.js'); + const { api } = require('../bench/support/transport-api.js'); + const real = require('../bench/support/real-stacks.js'); + // One bundle, so the WebRTC barrel registers its transport into the same + // WrpcClient the page connects with. + const library = await bundle( + "module.exports = { ...require('./browser.js'), webrtc: require('./webrtc.browser.js') };", + ); + const page = fs.readFileSync(path.join(HERE, 'bench', 'browser', 'transports.js'), 'utf8'); + + const { server, port } = await createWrpcServer(api); + const wtSkipped = real.wtSkip(); + const wt = wtSkipped ? null : await real.bootWt(server); + const files = { + '/': [ + 'text/html', + 'wrpc transports bench', + ], + '/wrpc.js': ['text/javascript', library], + '/page.js': ['text/javascript', page], + }; + const site = http.createServer((req, res) => { + const file = files[req.url]; + if (!file) return void res.writeHead(404).end(); + res.writeHead(200, { 'content-type': file[0] }).end(file[1]); + }); + await new Promise((resolve) => site.listen(0, '127.0.0.1', resolve)); + + const browser = await launch(); + try { + const tab = await browser.newPage(); + forwardErrors(tab); + await tab.goto(`http://127.0.0.1:${site.address().port}/`); + const version = await browser.version(); + console.log( + `Browser transport benchmark — Chrome ${version}, Node ${process.version}, entry ${path.relative(HERE, ROOT) || '.'}\n`, + ); + if (wtSkipped) console.log(`wt: skipped — ${wtSkipped}\n`); + const config = { + // WRPC_BENCH_KINDS=ws,webrtc narrows the run. + kinds: (process.env.WRPC_BENCH_KINDS?.split(',') ?? ['ws', 'wt', 'webrtc']).filter((kind) => kind !== 'wt' || wt), + wsUrl: `ws://127.0.0.1:${port}/`, + wtUrl: wt?.url, + wtHash: wt ? JSON.parse(fs.readFileSync(path.join(HERE, 'certs', 'wt-cert.json'), 'utf8')).hash : null, + }; + const results = await tab.evaluate((given) => globalThis.__wrpcTransports(given), config); + const header = [ + 'transport', + 'first call', + 'small', + '10 KB', + 'small ×64', + 'stream MiB/s', + 'loaded p50', + 'loaded p99', + ]; + const rows = []; + for (const r of results) { + if (r.error) { + console.error(`${r.kind}: ${r.error}`); + continue; + } + rows.push([ + r.kind, + `${r.firstCall} ms`, + r.small.toLocaleString('en-US'), + r.large.toLocaleString('en-US'), + r.pipelined.toLocaleString('en-US'), + String(r.streamMiBps), + `${r.loaded.p50} ms`, + `${r.loaded.p99} ms`, + ]); + } + const widths = header.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i].length))); + const line = (cells) => cells.map((cell, i) => cell.padStart(widths[i])).join(' '); + console.log(` ${line(header)}`); + for (const row of rows) console.log(` ${line(row)}`); + console.log('\n calls in ops/sec; "loaded" is a small call while 16 MiB uploads keep the connection busy\n'); + } finally { + await browser.close(); + site.close(); + await wt?.stop(); + await server.close(); + } +}; + +const SUITES = { calls, transports }; + +if (!Object.hasOwn(SUITES, SUITE)) { + console.error(`unknown suite "${SUITE}" — one of: ${Object.keys(SUITES).join(', ')}`); + process.exitCode = 1; +} else { + SUITES[SUITE]() + .then(() => { + // An HTTP/3 host can hold a native thread past its stop. + setTimeout(() => process.exit(), 2000).unref(); + }) + .catch((error) => { + console.error(error); + process.exitCode = 1; + setTimeout(() => process.exit(1), 2000).unref(); + }); +} diff --git a/scripts/brand.js b/scripts/brand.js index f1fdff5..eea6655 100644 --- a/scripts/brand.js +++ b/scripts/brand.js @@ -56,8 +56,8 @@ const ogCard = (body) => ` ${body} - wrpc - Fast, zero-dependency WebSocket RPC + wRPC + Fast, zero-dependency Web RPC Node.js and the browser · 0 runtime dependencies diff --git a/scripts/coverage-floors.js b/scripts/coverage-floors.js new file mode 100644 index 0000000..d4a5cd1 --- /dev/null +++ b/scripts/coverage-floors.js @@ -0,0 +1,70 @@ +'use strict'; + +// Per-file coverage floors for the directories that are new in 2.0. +// +// `pnpm test:coverage` checks the GLOBAL thresholds (95 lines, 90 branches, +// 95 functions), and a global threshold is an average: a new adapter at 80% +// branches hides behind three hundred well-covered files. The directories +// below are held per FILE instead — each file in them must stay at or above +// its directory's floor. +// +// Only the new directories: the 1.0 files that sit below these numbers +// (client/proxy.js, adapters/express.js, …) predate the rule, and a floor +// low enough to admit them would hold nothing. The floors start a little +// under what each directory's weakest file measured when they were set +// (the margin is run-to-run noise in timing-dependent branches) and move +// one way: UP, as the weakest file improves — never down to let a change in. +// +// Reads the coverage the last c8 run left in coverage/tmp and reruns +// nothing, so it goes right after test:coverage / test:ci: +// +// pnpm test:coverage && pnpm test:coverage:floors + +const { spawnSync } = require('node:child_process'); + +// Measured 2026-10-01 (weakest file per directory, lines / branches / functions): +// broker 96.9 / 82.2 / 86.7 (rpc/client.js) +// webtransport 96.4 / 90.8 / 88.9 (index.js, socket.js) +// encryption 97.4 / 89.9 / 85.2 (httpServer.js, http.js, server.js) +// webrtc 96.7 / 89.2 / 94.4 (host.js, mesh.js) +// compression 99.0 / 94.7 / 100 (index.js, dictionary.js) +// deflate 98.8 / 91.0 / 94.1 (inflate.js, deflate.js) +const FLOORS = [ + { include: 'src/broker/**', lines: 95, branches: 80, functions: 85 }, + { + include: 'src/webtransport/**', + // The one exception: the adapter over the optional pure-JS QUIC stack is + // exercised against a fake and, by hand, against the real package + // (76% branches) — its failure paths are the library's to produce. + exclude: ['src/webtransport/quico.js'], + lines: 95, + branches: 89, + functions: 87, + }, + { include: 'src/encryption/**', lines: 96, branches: 88, functions: 84 }, + { include: 'src/webrtc/**', lines: 95, branches: 87, functions: 93 }, + { include: 'src/compression/**', lines: 98, branches: 93, functions: 95 }, + { include: 'src/deflate/**', lines: 97, branches: 89, functions: 92 }, +]; + +const c8 = require.resolve('c8/bin/c8.js'); + +let failed = false; +for (const { include, exclude = [], lines, branches, functions } of FLOORS) { + const args = [c8, 'check-coverage', '--per-file', '--all', '--include', include]; + for (const pattern of exclude) args.push('--exclude', pattern); + // Statements follow lines in this codebase (one statement per line, by + // the formatter), so they share a floor. + args.push('--lines', String(lines), '--statements', String(lines)); + args.push('--branches', String(branches), '--functions', String(functions)); + const result = spawnSync(process.execPath, args, { encoding: 'utf8' }); + const ok = result.status === 0; + console.log( + `${ok ? 'ok ' : 'FAIL'} ${include.padEnd(24)} lines ${lines} branches ${branches} functions ${functions}`, + ); + if (!ok) { + failed = true; + process.stdout.write(`${result.stdout}${result.stderr}`); + } +} +if (failed) process.exitCode = 1; diff --git a/scripts/size.js b/scripts/size.js index a7a78a9..a72e35f 100644 --- a/scripts/size.js +++ b/scripts/size.js @@ -49,7 +49,66 @@ 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. + // 16 -> 17 for the WebTransport client transport (`transport: 'wt'`, in + // the base entry so the `['wt', 'ws']` fallback list needs no import) and + // its stream framing — measured together at +1.8 KB min+gzip (16.9); the + // connect-URL builder it shares with ws moved into the core in the same + // change, so the ws leg paid nothing twice. + // 17 -> 19 for the rest of WebTransport: unreliable events over datagrams + // and binary streams on their own WebTransport streams (the stream mux, + // capabilities negotiation) — measured together at +1.5 KB (18.4). + // 19 -> 20 for the call-path work behind the paper's 26 % figure: the + // bucketed deadline scheduler (one timer for every call in flight instead + // of a setTimeout + three closures per call), the synchronous `callback` + // fast path and the per-message `compress` option — +0.6 KB (measured + // 19.0), earned by bench/browser/calls.js and bench/bench.js. + // 20 -> 22 for per-message compression on the WebTransport client + // (src/compression: the structural codec seam, the Sequencer that keeps an + // asynchronous CompressionStream's output in order, the KIND 3/4 framing + // and the caps negotiation) — +1.3 KB, measured 20.8 against 19.5. Off by + // default; the bytes are the seam every browser transport shares. + // 22 -> 23 for binary attachments (src/attachments.js: the hasBytes walk, + // the frame encoder/decoder, and the client's send/receive frame + // classification — a Uint8Array in args or data travels as bytes instead + // of a `{"0":1,…}` object nine times the size) — +1.2 KB, measured 22.4 + // against 21.2. On by default because it is a correctness fix, priced in + // bench/attachments.js; `attachments: false` skips the walk. + // 23 -> 24 for the choice of compression algorithm: `codec` as a name, a + // Compressor or a preference LIST of them, the negotiation that gives + // each direction its own codec (`{ encode, decode }` over two id lists, + // bounded against a peer's junk), and the platform half that asks + // CompressionStream which formats it has — +0.4 KB, measured 23.1 + // against 22.7. What it buys is the fallback: a peer without zstd is + // served deflate instead of plain. + // 24 -> 25 for the session-encryption SEAM — not the encryption: the ws and wt + // transports run an injected handshake inside open() and hand every + // frame to it afterwards, the client refuses up front a transport that + // cannot carry `options.encryption`, and the worker proxy forwards the + // option — +0.6 KB, measured 24.2 against 23.6. Noise, the AEADs and the + // rest live in @alexify/wrpc/encryption, and only a page that injects + // them pays for them. + // 25 -> 26 for the 2.0 security review's fixes on the client half: the + // attachments walk's toJSON and cycle guards, the SSE channel secret, the + // worker proxy routing frames, `cancel()` on the Noise handshake, and the + // WebTransport mux holding an inbound stream no further than its first + // read until its open packet passes (capped, timed) — +1.0 KB, measured + // 25.2 against 24.2. + // 26 -> 27 for the observability pass on the client half: the typed + // ENCRYPTION_REFUSED error (its status predicate in the client core) and + // the WebTransport mux's fallback callback, which the wt client bundles + // (+0.1 KB — measured 26,638 B against a 26,624 B budget: over by 14, + // found by the size run, not shaved away to fit). + // 27 -> 28 for protocol revision 2 on the client half (protocol.md + // #versioning): the `wrpc.v2, wrpc.v1` offer and the revision a connection + // negotiated, the one redial with the query carrier against a server that + // answered `wrpc.v1`, the `Accept` that asks an HTTP server for a frame + // and the `wrpc-version` that says it may be sent one, the worker port's + // revision on its first ping — what lets a 2.x client talk to a 1.0 peer + // without a flag (the http leg crossed it: 27,692 B against 27,648). + { label: 'main entry — browser (@alexify/wrpc)', entry: 'browser.js', platform: 'browser', budget: 28 }, { 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' }, @@ -57,6 +116,13 @@ const ENTRIES = [ { label: 'fastify adapter (@alexify/wrpc/fastify)', entry: 'fastify.js', platform: 'node' }, { label: 'express adapter (@alexify/wrpc/express)', entry: 'express.js', platform: 'node' }, { label: 'rooms backplane (@alexify/wrpc/scaling)', entry: 'scaling.js', platform: 'node' }, + // The broker family is Node-only: brokers are reached from servers, never + // from a browser bundle. + { label: 'broker core (@alexify/wrpc/broker)', entry: 'broker.js', platform: 'node' }, + { label: 'redis broker (@alexify/wrpc/broker/redis)', entry: 'broker/redis.js', platform: 'node' }, + { label: 'nats broker (@alexify/wrpc/broker/nats)', entry: 'broker/nats.js', platform: 'node' }, + { label: 'amqp broker (@alexify/wrpc/broker/amqp)', entry: 'broker/amqp.js', platform: 'node' }, + { label: 'kafka broker (@alexify/wrpc/broker/kafka)', entry: 'broker/kafka.js', platform: 'node' }, // 12 -> 13 alongside the main-entry raise: the sse entry bundles the same // client core, so the REST-bridge bytes land here too. 13 -> 14 with the // main entry's static-introspection raise, for the same reason; 14 -> 15 @@ -64,13 +130,133 @@ 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. + // 17 -> 18 with the main entry's WebTransport raise, for the same reason (measured 17.8); + // 18 -> 20 with its datagram + stream-mux raise (measured 19.2). + // 20 -> 21 with the main entry's call-path raise (measured 19.9). + // 21 -> 23 with the main entry's compression raise (measured 21.7). + // 23 -> 24 with the main entry's attachments raise, plus the sse client's + // explicit refusal of bytes (measured 23.4 against 22.0). + // 24 -> 25 with the main entry's codec-list raise (measured 24.1 against 23.6). + // 25 -> 26 with the main entry's session-encryption seam (measured 24.9 against 24.5). + // 26 -> 27 with the main entry's 2.0 review raise, plus the SSE channel + // secret the client carries after the id (measured 26.2 against 25.7). + // 27 -> 28: this entry is the main one plus the SSE client (~1 KB), and + // the main entry's own 26 -> 27 raise never reached it — it sat 11 bytes + // under. The client core checking by deed that a session transport sealed + // what it opened (+0.1 KB, measured 27,768 B against 27,648) is what crossed it. + // 28 -> 29 with the main entry's revision-2 raise (measured 28,735 B + // against 28,672). + { label: 'sse — browser (@alexify/wrpc/sse)', entry: 'sse.browser.js', platform: 'browser', budget: 29 }, { 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 // deliberately OUTSIDE the main entry so only apps that opt into the // strategies pay for them. { label: 'auth strategies (@alexify/wrpc/auth)', entry: 'auth.js', platform: 'browser', budget: 2 }, + // The pure-JS DEFLATE codec: inflate (stored, fixed, dynamic) and a + // fixed-Huffman encoder against a preset dictionary — browser-reachable + // by design, and deliberately OUTSIDE every other entry so only a page + // that injects it pays for it. 5 -> 4: it takes the dictionary id from + // the src/compression/ids.js leaf instead of the whole negotiation + // (measured 3.8 against 4.5) — the ratchet, turned the other way. + // deflate: 4 → 5 for the two-level decoding table (the root's width no + // longer follows the longest code a block declares) — ~100 B of bound. + { label: 'deflate codec (@alexify/wrpc/deflate)', entry: 'deflate.js', platform: 'browser', budget: 5 }, + // Encryption: the AEAD, X25519, HKDF and keyring primitives over + // crypto.subtle — browser-reachable, and OUTSIDE every other entry like the + // deflate codec: the base entry carries an injection seam, the page that + // injects pays for the rest. Set from the measurement when the row landed + // (3.1). 4 -> 9 for the session: the Noise handshake (NN, NK, XX, NNpsk0), + // the sealed framing and `createEncryption` — measured 7.8. 9 -> 11 for + // HPKE (RFC 9180 base + psk, DHKEM, the exporter) and the sealed `fetch` of + // the http transport — measured 9.9. 11 -> 12 for the sealed event stream + // (sse), HPKE's auth modes and the end-to-end helpers — measured 10.8. + { + label: 'encryption — browser (@alexify/wrpc/encryption)', + entry: 'encryption.browser.js', + platform: 'browser', + budget: 12, + }, + { label: 'encryption — node (@alexify/wrpc/encryption)', entry: 'encryption.js', platform: 'node' }, + // A peer is a client AND a server: the webrtc browser entry bundles the + // client core plus the router, dispatcher, per-peer Client, rooms and + // Broadcast (what makes a mesh broadcast/ask one fan-out: serialized, + // utf8-encoded and compressed once, fragmented per link), + // plus the link, framing, peer, mesh and signaler halves — measured at + // 38.3 KB when the row landed. 40 -> 41 for the server telemetry writer: + // a peer answers calls, so it emits the server spans and gauges a server + // does (+1.9 KB, the whole of src/telemetry/server.js). 41 -> 42 for stable + // identity: the signaler's instance/address bookkeeping, the peer's + // incarnation check and the mesh's away set (+0.7 KB, measured 41.4). + // 42 -> 45 for trust assertions: the JWS verifier over crypto.subtle + // (base64url, the SDP fingerprint parser, key lookup and rotation) plus + // the peer's per-link verify/stamp chains and the host's trust + // 'assertion' (+2.6 KB, measured 44.0) — the cryptographic layer the + // peer-to-peer trust model rests on, a deliberate spend. + // 45 -> 46 for the shared fan-out message (`Client.sendShared`, the + // `compress` option, `isReady`, the lazy Context uuid) — the rpc leaves + // this entry bundles for PeerHost (+0.5 KB, measured 45.0). + // 46 -> 47 for the message-broker telemetry seams (the CONSUMER span kind + // a host-built client picks, withMessagingSpan, the wrpc.broker.* + // instruments): they live in telemetry/server.js, which PeerHost bundles + // (+0.3 KB, measured 46.2 against 45.9). + // 47 -> 48 for the dispatcher's six refused-call log lines and the mesh + // dial's. They had been silent while their subscription twin logged, so + // half of what a dispatcher rejects was invisible to an operator; the + // bytes are the entry objects on those branches (+0.5 KB, measured 47.0 + // against 46.5, which is the budget exactly and too thin to leave). + // 48 -> 50 for per-message compression on the data channels: the + // src/compression seam (shared with the main entry's wt client), the + // ChannelCodec's two Sequencers and compress-before-fragmentation, the + // DEFLATE header bit, the link's caps in the description signal and + // WrpcPeer's option (+1.7 KB, measured 49.2 against 47.5). Off by + // default; both peers must name the codec before a byte changes. + // 50 -> 51 for `buildDictionary` in the browser barrel: a browser peer + // builds the same router dictionary a Node peer does, for the pure-JS + // dictionary codec (+0.9 KB, measured 50.3 against 49.4 — the packet + // skeleton strings are most of it). + // 51 -> 53 for binary attachments on both halves this entry bundles: the + // client transport's frames and, through PeerHost, the server-side + // encoder in serverTransport.js and the dispatcher's frame routing + // (+1.5 KB, measured 51.8 against 50.3). 53 -> 54 for the 2.0 security + // review's fixes on the peer half: roster data written under + // peer/room/claims, the fragment count cap and the 1 KiB message-size + // floor, every a=fingerprint line compared, the stale-leave incarnation + // check, the attachments walk's toJSON/cycle guards and the seal-failure + // path of rooms (+0.6 KB, measured 53.4 against 52.8). + // 54 → 55 (2026-09-27): the channel codec owns the 'drain' after a false + // (bytes waiting in an asynchronous codec never cross the channel's own + // threshold), the water marks and maxBackpressure are validated, and a + // consumer that never drains is disconnected at the cap — ~0.5 KB of + // backpressure correctness the wt/ws carriers already had. + // 55 → 56 (2026-09-27): redial correctness — one link per peer under a + // racing connect(), a responder that gives up quietly and re-arms after + // the connect window, a knock or a new-certificate offer that rebuilds a + // link only one side saw fail, and no unhandled rejection from a signal, + // a join or a dial (measured 55.1). + // 56 -> 57 for the goodbye's reason: the closed set on the wire, the + // { reason, remote } closure RtcLink keeps and both 'close' events carry, + // abandon() as a real method, and the wrpc.rtc.closes counter in the + // telemetry writer (+0.3 KB, measured 56.2 against 55.9). + // 57 -> 58: a mesh that dials a lost edge again (the roster, the backoff, + // 'unreachable' — +0.5 KB) and a fan-out that prepares its message once + // for every link — the UTF-8 and the deflated body shared, only the + // fragments per link (+0.2 KB; bench/rtc-fanout.js: 37.7 -> 2.2 µs a + // recipient compressed, 10.3 -> 1.7 plain, at 16 KB over 32 links). + // Measured 58,498 B against 58,368. + // 58 -> 59 (2026-10-03, review №3): the revision of each half in a + // description's caps (`f` — two peers whose `attachments` disagreed sent + // each other frames the other refused), an ended session refused by the + // dispatcher's gates, `maxStreams` in the host, and the assertion + // verifier's backoff after a failed key load and `maxAge` for a revoked + // key. Measured 59,436 B against 59,392. + { label: 'webrtc — browser (@alexify/wrpc/webrtc)', entry: 'webrtc.browser.js', platform: 'browser', budget: 59 }, + { label: 'webrtc — node (@alexify/wrpc/webrtc)', entry: 'webrtc.js', platform: 'node' }, + // The server half of WebTransport (session contract, socket shim, host + // adapters); the client transport is in the main entry, so this never + // reaches a browser. + { label: 'webtransport — node (@alexify/wrpc/wt)', entry: 'wt.js', platform: 'node' }, ]; // A browser entry has to be self-contained: no node builtins, and no packages diff --git a/scripts/wt-cert.js b/scripts/wt-cert.js new file mode 100644 index 0000000..511d9ce --- /dev/null +++ b/scripts/wt-cert.js @@ -0,0 +1,80 @@ +'use strict'; + +// A certificate a browser accepts for WebTransport through +// serverCertificateHashes: ECDSA P-256, valid for at most 14 days, hashed +// as the SHA-256 of its DER. Made with the openssl CLI (Node's crypto can +// sign but not issue an X.509), written next to each other, the hash +// printed in the two spellings a page and a Node client want. +// +// node scripts/wt-cert.js [dir] [--host 127.0.0.1] [--days 13] +// +// Writes /wt-key.pem, /wt-cert.pem and /wt-cert.json +// ({ hash, algorithm, notAfter }); dir defaults to ./certs. Used by +// examples/wt and the WRPC_WT= integration tests; never by the package. + +const { execFileSync } = require('node:child_process'); +const { createHash, X509Certificate } = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); + +const args = process.argv.slice(2); +const option = (name, fallback) => { + const at = args.indexOf(name); + return at === -1 ? fallback : args[at + 1]; +}; +const dir = path.resolve( + args.find((arg, i) => !arg.startsWith('--') && (i === 0 || !args[i - 1].startsWith('--'))) ?? 'certs', +); +const host = option('--host', '127.0.0.1'); +const days = Number(option('--days', '13')); +if (!Number.isInteger(days) || days < 1 || days > 14) { + console.error('--days must be 1..14: a browser refuses a longer-lived certificate through serverCertificateHashes'); + process.exit(1); +} + +fs.mkdirSync(dir, { recursive: true }); +const keyPath = path.join(dir, 'wt-key.pem'); +const certPath = path.join(dir, 'wt-cert.pem'); +const infoPath = path.join(dir, 'wt-cert.json'); + +execFileSync( + 'openssl', + [ + 'req', + '-x509', + '-nodes', + '-newkey', + 'ec', + '-pkeyopt', + 'ec_paramgen_curve:prime256v1', + '-keyout', + keyPath, + '-out', + certPath, + '-days', + String(days), + '-subj', + `/CN=${host}`, + '-addext', + `subjectAltName=${/^[\d.]+$|^[0-9a-f:]+$/i.test(host) ? 'IP' : 'DNS'}:${host}`, + ], + { stdio: ['ignore', 'ignore', 'inherit'] }, +); + +const x509 = new X509Certificate(fs.readFileSync(certPath)); +const hash = createHash('sha256').update(x509.raw).digest(); +const info = { + algorithm: 'sha-256', + hash: hash.toString('base64'), + hex: hash.toString('hex').match(/../g).join(':').toUpperCase(), + host, + notAfter: x509.validTo, +}; +fs.writeFileSync(infoPath, `${JSON.stringify(info, null, 2)}\n`); + +console.log(`key ${keyPath}`); +console.log(`cert ${certPath} (valid until ${info.notAfter})`); +console.log(`hash ${info.hash}`); +console.log( + `\n new WebTransport(url, { serverCertificateHashes: [{ algorithm: 'sha-256', value: Uint8Array.fromBase64('${info.hash}') }] })`, +); diff --git a/src/adapters/common.js b/src/adapters/common.js index 1e08ff3..abae060 100644 --- a/src/adapters/common.js +++ b/src/adapters/common.js @@ -1,5 +1,9 @@ 'use strict'; +const { destroy: destroyStream } = require('node:stream'); + +const { WRPC_V1 } = require('../wire.js'); + // The streaming half of the abstract HTTP call, implemented once for every // host that hands over a node ServerResponse (the built-in shell, express // and fastify all do). Returning null means the response is already gone. @@ -22,9 +26,8 @@ const nodeStream = }; }; -const http = require('node:http'); - const { isOriginAllowed } = require('../transport.js'); +const { STATUS_CODES } = require('../status.js'); // Shared plumbing for everything that turns a host framework's request into // the abstract call description RpcServer.handleHttpCall consumes: @@ -58,20 +61,68 @@ const respondBodyError = (respond, error) => { respond({ status: 400, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(packet) }); }; -const receiveBody = async (stream, limit = MAX_BODY_SIZE) => { +const prematureClose = () => { + const error = new Error('Premature close'); + error.code = 'ERR_STREAM_PREMATURE_CLOSE'; + return error; +}; + +// Listeners on the request, not `for await`: the async iterator builds an +// end-of-stream watcher and a paused-mode reader around every request — +// ~760k against ~1.07M one-chunk bodies/s (bench/http-call.js), and a +// twentieth of a REST call's CPU under bench/http-comparison.js's load. +// Same outcomes as the loop it replaces: the body (null when empty), the +// stream's own error, a premature close, and — past the limit — the stream +// destroyed and the request refused. The error listener stays once the body +// is settled, as the loop's did: a late error is swallowed, never thrown. +const receiveBody = (stream, limit = MAX_BODY_SIZE) => { if (!Number.isSafeInteger(limit) || limit < 0) { - throw new TypeError('Body size limit must be a non-negative safe integer'); - } - const chunks = []; - let size = 0; - for await (const chunk of stream) { - size += chunk.length; - if (size > limit) throw new Error('Body size limit exceeded'); - chunks.push(chunk); + return Promise.reject(new TypeError('Body size limit must be a non-negative safe integer')); } - if (chunks.length === 0) return null; - if (chunks.length === 1) return chunks[0]; - return Buffer.concat(chunks, size); + if (stream.errored) return Promise.reject(stream.errored); + if (stream.readableEnded) return Promise.resolve(null); + if (stream.destroyed) return Promise.reject(prematureClose()); + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + let settled = false; + const settle = () => { + settled = true; + stream.off('data', onData); + stream.off('end', onEnd); + stream.off('close', onClose); + }; + const onData = (chunk) => { + size += chunk.length; + if (size <= limit) return void chunks.push(chunk); + settle(); + // node's own stream destroyer, as the loop's return called it: a + // server request is let go of its socket first, so the 400 still + // reaches the peer over a connection that stays usable. + destroyStream(stream); + reject(new Error('Body size limit exceeded')); + }; + const onEnd = () => { + settle(); + resolve(chunks.length === 0 ? null : chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, size)); + }; + const onError = (error) => { + if (settled) return; + settle(); + reject(error); + }; + const onClose = () => { + settle(); + reject(prematureClose()); + }; + stream.on('data', onData); + stream.on('end', onEnd); + stream.on('error', onError); + stream.on('close', onClose); + // A 'data' listener does not restart a stream something upstream + // paused by hand; the loop read it regardless, and so does this. + if (stream.readableFlowing === false) stream.resume(); + }); }; // Frameworks that own body parsing (fastify, express + express.json()) hand @@ -89,7 +140,7 @@ const normalizeBody = (body) => { } }; -const statusLine = (code) => `${code} ${http.STATUS_CODES[code] ?? 'Unknown'}`; +const statusLine = (code) => `${code} ${STATUS_CODES[code] ?? 'Unknown'}`; // The core answers with `Set-Cookie` as an array and Content-Length as a // number; frameworks want strings and repeated header lines. @@ -106,6 +157,16 @@ const eachHeader = (headers, visit) => { } }; +// What a shell adds to its engine's attach options so the WebSocket +// negotiation agrees with the core (protocol.md#versioning). An engine +// selects the newest revision offered, and knows nothing of the server +// above it: a server that sends and reads no framed messages (`attachments: +// false`, a packet codec — `rpc.revision` 1) narrows it to `wrpc.v1`, so a +// 2.x client does not send a frame it would be refused. An application that +// configured its own `protocols`/`handleProtocols` owns the negotiation. +const revisionProtocols = (rpc, ws = {}) => + rpc.revision === 1 && !ws.protocols && !ws.handleProtocols ? { protocols: [WRPC_V1] } : null; + module.exports = { MAX_BODY_SIZE, receiveBody, @@ -115,5 +176,6 @@ module.exports = { nodeStream, getPathname, createUpgradeGate, + revisionProtocols, respondBodyError, }; diff --git a/src/adapters/express.js b/src/adapters/express.js index 048a0c8..42c01b8 100644 --- a/src/adapters/express.js +++ b/src/adapters/express.js @@ -8,6 +8,7 @@ const { nodeStream, getPathname, createUpgradeGate, + revisionProtocols, respondBodyError, MAX_BODY_SIZE, } = require('./common.js'); @@ -40,7 +41,12 @@ const createWrpc = (options = {}) => { // No `server`: this engine is driven by hand from the app's own 'upgrade' // listener (see `upgrade` below). - const source = engine.attach({ ...ws, verifyClient: createUpgradeGate({ rpc, cors, ws }) }); + const source = engine.attach({ + logger: rpc.log, + ...ws, + ...revisionProtocols(rpc, ws), + verifyClient: createUpgradeGate({ rpc, cors, ws }), + }); source.on('connection', (socket, req) => { rpc.attachSocket(socket, { headers: req.headers, diff --git a/src/adapters/fastify.js b/src/adapters/fastify.js index f31d953..c9b3599 100644 --- a/src/adapters/fastify.js +++ b/src/adapters/fastify.js @@ -9,7 +9,8 @@ const { effectiveSchema } = require('../rpc/router.js'); const { publicErrorMessage, publicErrorDetails } = require('../transport.js'); const { createNodeEngine, isEngine } = require('../engine/index.js'); const { createUwsEngine } = require('./uws.js'); -const { normalizeBody, eachHeader, nodeStream, createUpgradeGate } = require('./common.js'); +const { normalizeBody, eachHeader, nodeStream, createUpgradeGate, revisionProtocols } = require('./common.js'); +const { cacheHeadersFor, RESERVED_HEADERS, checkHeader } = require('../rpc/rest.js'); const { setupMirror } = require('./mirror.js'); // Fastify plugin. One plugin, two backends, picked by looking at what @@ -137,12 +138,19 @@ const registerRestRoutes = (fastify, rpc, options) => { // Arity matters to fastify: an async hook with a third parameter is // read as callback-style and refused — so the payload-less phases get // two-parameter wrappers, and only the payload phases take three. + // A request the core refused a context to (426 under + // encryption.required, thrown from `init`) still runs fastify's + // onSend/onError/onResponse phases for the refusal itself: those see + // no context and leave the payload as it is, rather than throwing the + // same refusal a second time into the error reply. + const settled = (request, target) => contextOf(request, target).catch(() => null); const wrap = (list, payloadOf) => { const wrapped = []; for (const hook of list) { wrapped.push(async (request, reply) => { - const { context } = await contextOf(request, rpcTarget); - return void (await hook(context, payloadOf(request))); + const resolved = await settled(request, rpcTarget); + if (resolved === null) return; + return void (await hook(resolved.context, payloadOf(request))); }); } return wrapped; @@ -151,8 +159,9 @@ const registerRestRoutes = (fastify, rpc, options) => { const wrapped = []; for (const hook of list) { wrapped.push(async (request, reply, payload) => { - const { context } = await contextOf(request, rpcTarget); - return void (await hook(context, payload)); + const resolved = await settled(request, rpcTarget); + if (resolved === null) return; + return void (await hook(resolved.context, payload)); }); } return wrapped; @@ -164,15 +173,35 @@ const registerRestRoutes = (fastify, rpc, options) => { const wrapped = []; for (const hook of list) { wrapped.push(async (request, reply, payload) => { - const { context } = await contextOf(request, rpcTarget); - const replaced = await hook(context, payload); + const resolved = await settled(request, rpcTarget); + if (resolved === null) return payload; + const replaced = await hook(resolved.context, payload); return replaced === undefined ? payload : replaced; }); } return wrapped; }; const init = async (request, reply) => { - const { release } = await contextOf(request, rpcTarget); + const { client, release } = await contextOf(request, rpcTarget); + // The same `context.http` seam the core hosts give a REST handler, + // onto fastify's own reply. + client.http = { + method: request.method, + url: request.url, + headers: request.headers, + setHeader: (name, value) => { + if (RESERVED_HEADERS.has(String(name).toLowerCase())) { + throw new TypeError(`setHeader: '${name}' is owned by the transport`); + } + // The same rule the core's seam applies: fastify over uws writes + // what it is given. + const text = String(value); + checkHeader(name, text, 'setHeader'); + reply.header(name, text); + }, + status: (code) => void reply.code(code), + }; + if (http.headers) eachHeader(http.headers, (name, value) => reply.header(name, value)); // The response's close is the eviction signal, whether the reply was // sent, hijacked or the peer vanished. reply.raw?.on?.('close', release); @@ -240,7 +269,17 @@ const registerRestRoutes = (fastify, rpc, options) => { // A login that called startSession queued its cookie on the // transport nothing will flush — copy it onto the real reply. if (transport.pendingCookies.length > 0) reply.header('set-cookie', transport.pendingCookies); - reply.code(status); + // The route's cache policy, decided with the session known. ETag + // and 304 are fastify's business (@fastify/etag) on this path. + const policy = http.cache + ? cacheHeadersFor(http.cache, { + access: proc.access, + session: Boolean(client.session), + cookies: transport.pendingCookies.length > 0, + }) + : null; + if (policy !== null) reply.header('cache-control', policy.control); + if (reply.statusCode === 200) reply.code(status); // 204 promises "no content": the result is discarded by contract. if (status === 204) return reply.send(); return result === undefined ? null : result; @@ -332,11 +371,26 @@ const wrpcFastify = async (fastify, options = {}) => { // handler runs. `maxBodySize` only narrows that per route; left unset, the // app's limit stands, because a plugin silently RAISING the host's body // limit would be a security regression the app never asked for. - // The SSE endpoint is a static segment, so find-my-way prefers it over + // The key-discovery path of session encryption is a single static + // segment the parametric route would never match (the core answers 404 + // for it when encryption is off). The SSE endpoint is a static segment, so find-my-way prefers it over // the parametric '/:unit/:method' it would otherwise fall into — where the // core never sees it as an events request. + // A sealed request (@alexify/wrpc/encryption) is opaque bytes under its + // own content type — which fastify would answer 415 before this handler + // ran. Registered once, and only if the app has not claimed it. + // The same holds for a call whose arguments hold bytes: it arrives as an + // attachments frame under `application/octet-stream`, which fastify + // parses no more than the sealed type — two 2.x ends were answered 415. + for (const type of ['application/wrpc-sealed', 'application/octet-stream']) { + if (typeof fastify.hasContentTypeParser === 'function' && !fastify.hasContentTypeParser(type)) { + fastify.addContentTypeParser(type, { parseAs: 'buffer' }, (_request, body, done) => done(null, body)); + } + } const routes = - base === '' ? ['/', rpc.eventsPath, '/:unit/:method'] : [base, rpc.eventsPath, `${base}/:unit/:method`]; + base === '' + ? ['/', rpc.eventsPath, '/encryption-key', '/:unit/:method'] + : [base, rpc.eventsPath, `${base}/encryption-key`, `${base}/:unit/:method`]; for (const url of routes) { fastify.route({ method: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], @@ -364,8 +418,12 @@ const wrpcFastify = async (fastify, options = {}) => { // ---- WebSocket: engine attach ------------------------------------------ const source = engine.attach({ + // The engine reports through the server's writer, as the Server shell + // arranges; `ws.logger` overrides. + logger: rpc.log, ...(engine.standalone ? {} : { server: fastify.server }), ...ws, + ...revisionProtocols(rpc, ws), verifyClient: createUpgradeGate({ rpc, cors, ws }), }); source.on('connection', (socket, req) => { diff --git a/src/adapters/uws.js b/src/adapters/uws.js index 43465ca..b5d51e9 100644 --- a/src/adapters/uws.js +++ b/src/adapters/uws.js @@ -1,6 +1,8 @@ 'use strict'; const { EventEmitter } = require('node:events'); +const { createLoggerWriter } = require('../logging.js'); +const { WRPC_V1, WRPC_V2, CARRIER_PROTOCOL } = require('../wire.js'); const { MAX_BODY_SIZE, statusLine, eachHeader } = require('./common.js'); @@ -45,25 +47,25 @@ const createUpgradeRequest = (path, query, headers, remoteAddress) => ({ socket: { remoteAddress }, }); -// The protocol revision marker; must match the built-in engine's. -const WRPC_PROTOCOL = 'wrpc.v1'; - // Mirrors WebsocketServer's negotiation: `false` from handleProtocols // rejects the handshake, anything else selects (or declines) a subprotocol, -// and with no app configuration the wrpc revision is echoed when offered. +// and with no app configuration the newest wrpc revision offered is echoed. +// Carrier tokens (wire.js) are data riding the offer: removed before the +// application sees the list, and never echoed back. const negotiateProtocol = (header, { protocols, handleProtocols }, request) => { if (!header) return ''; const offered = header .split(',') .map((token) => token.trim()) - .filter(Boolean); + .filter((token) => token.length > 0 && !CARRIER_PROTOCOL.test(token)); if (handleProtocols) { const selected = handleProtocols(offered, request); if (selected === false) return false; - return selected || ''; + return typeof selected === 'string' && !CARRIER_PROTOCOL.test(selected) ? selected : ''; } if (protocols) return offered.find((name) => protocols.includes(name)) ?? ''; - if (offered.includes(WRPC_PROTOCOL)) return WRPC_PROTOCOL; + if (offered.includes(WRPC_V2)) return WRPC_V2; + if (offered.includes(WRPC_V1)) return WRPC_V1; return ''; }; @@ -78,11 +80,14 @@ class UwsSocket extends EventEmitter { #ws; #closed = false; - constructor(ws, { remoteAddress = '', protocol = '' } = {}) { + constructor(ws, { remoteAddress = '', protocol = '', log = null } = {}) { super(); this.#ws = ws; this.remoteAddress = remoteAddress; this.protocol = protocol; + // A writer, already normalized by the engine — this socket reports the + // one failure nothing above it can see (uws dropping a frame). + this.log = log; } get closed() { @@ -98,12 +103,17 @@ class UwsSocket extends EventEmitter { } } - send(data) { + // The third uws argument asks for permessage-deflate on this message; + // it defaults to false in uws, so without it a compressor configured on + // the engine never compressed a single outbound frame. `compress: false` + // is the per-message opt-out the built-in engine honours too. + send(data, options = null) { if (this.#closed) return false; const isBinary = typeof data !== 'string'; + const compress = options === null || options.compress !== false; let status; try { - status = this.#ws.send(data, isBinary); + status = this.#ws.send(data, isBinary, compress); } catch { // Raced a close between the guard and the call. return false; @@ -112,7 +122,12 @@ class UwsSocket extends EventEmitter { // uws silently discarded the message (maxBackpressure hit with // closeOnBackpressureLimit off). A hole in the frame stream corrupts // the RPC protocol, so fail loudly instead of continuing. - this.emit('error', new Error('uws dropped an outgoing message: backpressure limit exceeded')); + const error = new Error('uws dropped an outgoing message: backpressure limit exceeded'); + // Logged as well as emitted: 'error' on a socket frequently has no + // listener, and this is a connection dying from a limit the operator + // configured — exactly the thing they need to see to raise it. + this.log?.error({ err: error, event: 'uws.dropped', bytes: this.bufferedAmount }); + this.emit('error', error); this.terminate(); return false; } @@ -161,7 +176,14 @@ const createUwsEngine = (engineOptions = {}) => { compression = null, sendPingsAutomatically = true, maxBodySize = MAX_BODY_SIZE, + // Silent by default and not `globalThis.console`: an engine is usually + // built by the `Server` shell, which hands its own writer to attach() + // (below), and a standalone one printing uninvited would be a surprise. + // Left undefined here so attach() can tell "not configured" from + // `logger: false`. + logger, } = engineOptions; + let log = createLoggerWriter(logger ?? false).child({ component: 'uws' }); if (!providedApp && (typeof uws !== 'object' || uws === null || typeof uws.App !== 'function')) { throw new TypeError( @@ -236,7 +258,7 @@ const createUwsEngine = (engineOptions = {}) => { open(ws) { const data = ws.getUserData(); - const socket = new UwsSocket(ws, data); + const socket = new UwsSocket(ws, { ...data, log }); data.socket = socket; sockets.add(socket); source.emit('connection', socket, data.request); @@ -352,6 +374,9 @@ const createUwsEngine = (engineOptions = {}) => { // No receive-side flow control: uws exposes no socket-level pause, so // a fast uploader is not throttled by a slow stream consumer. pause: false, + // No prepared-frame path: uws frames (and compresses) inside send(). + // Its topic publish() is the equivalent seam — a different design. + prepared: false, }, app, @@ -361,6 +386,13 @@ const createUwsEngine = (engineOptions = {}) => { if (attached) throw new Error('createUwsEngine: this engine is already attached'); attached = true; const { path = '/*', onHttpCall } = attachOptions; + // The writer the Server shell (or an adapter) hands down, as the + // node engine takes it: an engine built without a logger reports + // through it — a dropped frame used to go nowhere, whatever the + // shell was told. `logger: false` on the engine stays silent. + if (logger === undefined && attachOptions.logger !== undefined) { + log = createLoggerWriter(attachOptions.logger).child({ component: 'uws' }); + } app.ws(path, behavior(attachOptions)); // Without onHttpCall the host framework (fastify) owns HTTP routing // and we only take over the upgrade path. diff --git a/src/attachments.js b/src/attachments.js new file mode 100644 index 0000000..ac9a7ed --- /dev/null +++ b/src/attachments.js @@ -0,0 +1,275 @@ +'use strict'; + +// Binary attachments: raw bytes anywhere in a packet — a Uint8Array in a +// call's args, an ArrayBuffer in a result, a typed array in an event's +// data — carried as bytes, in one binary frame, instead of what +// JSON.stringify makes of them (a `{"0":1,"1":2,…}` object nine times the +// size that arrives as a plain object, silently). The packet's JSON is +// written with `null` at every byte leaf and an index of where the leaves +// were; the buffers follow, back to back: +// +// 0x00 0x01 u32 headerLen JSON [packet, [[path, byteLength], …]] buffers… +// +// The 0x00 first byte is what a stream chunk never has (its first byte is +// an id length, at least 1), and kind 0x01 is this frame's (src/wire.js). +// Paths — the Jupyter `buffer_paths` shape — name the leaf from the packet +// root, so nothing in the user's data can collide with a placeholder. On +// the way in every buffer is COPIED out of the frame: a WebSocket engine +// hands over zero-copy views into its socket segments (segments.js), and a +// value that lives in `args` past the next frame must own its bytes. +// +// Browser-budgeted: this file lands in the main entry through the client +// transport's send path. `hasBytes` is the per-packet cost of the feature +// — a walk of the packet on every send, measured in bench/attachments.js — +// and `attachments: false` on either end skips it. + +const { FRAME_MARK, FRAME_ATTACHMENTS } = require('./wire.js'); + +const MAX_DEPTH = 32; +const HEADER_BYTES = 6; +const encoder = new TextEncoder(); +// fatal: a header that is not UTF-8 is a malformed frame, not U+FFFD soup. +const decoder = new TextDecoder('utf-8', { fatal: true }); + +const isBytes = (value) => value instanceof Uint8Array || ArrayBuffer.isView(value) || value instanceof ArrayBuffer; + +// An object with toJSON() IS its projection: JSON.stringify serializes what +// toJSON answers, never the fields — so a domain object keeping a Buffer +// behind toJSON() (a password hash, say) must not have the Buffer lifted +// out from under it and sent. Checked AFTER isBytes: a Buffer has a toJSON +// of its own. The projection is never called here (acks.test.js pins one +// call per fan-out); it travels as JSON, as it always did. +const isOpaque = (value) => typeof value.toJSON === 'function'; + +const NONE = 0; +const BYTES = 1; +const DEEP = 2; +const CYCLE = 3; + +// The fast walk keeps no memory of what it visited — that is what keeps it +// at the cost bench/attachments.js measures (rows(400): 17 µs, 160 ns on +// a 233 B packet; the ancestor stack below is an `includes` per container) +// — and what makes a graph with back-references (parent <-> children) +// exponential in it: 8^16 paths before the depth cap. So it hands over the +// moment it reaches MAX_DEPTH: a tree that deep is rare, a graph with a +// cycle gets there at once, and either way the slow walk answers (the +// 8-child graph of the bench: 0.5 µs). +const fast = (value, depth) => { + if (typeof value !== 'object' || value === null) return NONE; + if (isBytes(value)) return BYTES; + if (isOpaque(value)) return NONE; + if (depth >= MAX_DEPTH) return DEEP; + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + const found = fast(value[i], depth + 1); + if (found !== NONE) return found; + } + return NONE; + } + for (const key in value) { + const found = fast(value[key], depth + 1); + if (found !== NONE) return found; + } + return NONE; +}; + +// The slow walk keeps its ancestors: a value met again on the way down is +// a cycle, and the whole packet is then left to JSON.stringify, whose +// "Converting circular structure" TypeError is the one the caller saw +// before attachments existed. Bounded by depth × nodes, not paths. +const slow = (value, stack) => { + if (typeof value !== 'object' || value === null) return NONE; + if (isBytes(value)) return BYTES; + if (isOpaque(value)) return NONE; + if (stack.includes(value)) return CYCLE; + if (stack.length >= MAX_DEPTH) return NONE; + stack.push(value); + let found = NONE; + if (Array.isArray(value)) { + for (let i = 0; i < value.length && found === NONE; i++) found = slow(value[i], stack); + } else { + for (const key in value) { + found = slow(value[key], stack); + if (found !== NONE) break; + } + } + stack.pop(); + return found; +}; + +/** + * Whether `value` holds bytes anywhere (a typed array, a DataView, an + * ArrayBuffer) within 32 levels — what decides between JSON and a frame. + * An object with toJSON() is opaque (its projection is what travels), and + * a circular packet answers false: JSON.stringify's own TypeError follows. + */ +const hasBytes = (value) => { + const found = fast(value, 0); + if (found !== DEEP) return found === BYTES; + return slow(value, []) === BYTES; +}; + +const asUint8 = (value) => { + if (value instanceof Uint8Array) return value; + if (value instanceof ArrayBuffer) return new Uint8Array(value); + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); +}; + +/** + * The frame for a packet (or a batch array) that holds bytes. The packet is + * not mutated: containers on the way to a byte leaf are copied, everything + * else is shared. + */ +const encodeAttachments = (packet, maxDepth = MAX_DEPTH) => { + const index = []; + const buffers = []; + // ONE mutable path, copied only at a byte leaf — a `path.concat(key)` + // per node allocated an array per container visited: 414 → 137 µs on + // rows(400) + 1 KB, 1.57 → 1.11 µs on a tiny event (bench/attachments.js). + // The copy at the leaf is load-bearing: the index would otherwise hold + // one array, rewritten by every step after. + const path = []; + // The ancestors of the value being stripped: a cycle is JSON's own + // TypeError here, not an exponential walk that throws it later. + const stack = []; + let total = 0; + const strip = (value) => { + if (typeof value !== 'object' || value === null) return value; + if (isBytes(value)) { + const bytes = asUint8(value); + index.push([path.slice(), bytes.length]); + buffers.push(bytes); + total += bytes.length; + return null; + } + if (isOpaque(value)) return value; + if (stack.includes(value)) throw new TypeError('Converting circular structure to JSON'); + if (stack.length >= maxDepth) return value; + stack.push(value); + let copy = null; + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + const item = value[i]; + path.push(i); + const next = strip(item); + path.pop(); + if (next !== item) { + if (copy === null) copy = value.slice(); + copy[i] = next; + } + } + } else { + // Own keys only, as JSON.stringify serializes them. + for (const key in value) { + if (!Object.hasOwn(value, key)) continue; + const item = value[key]; + path.push(key); + const next = strip(item); + path.pop(); + if (next !== item) { + if (copy === null) copy = { ...value }; + copy[key] = next; + } + } + } + stack.pop(); + return copy === null ? value : copy; + }; + const stripped = strip(packet); + const header = encoder.encode(JSON.stringify([stripped, index])); + const frame = new Uint8Array(HEADER_BYTES + header.length + total); + frame[0] = FRAME_MARK; + frame[1] = FRAME_ATTACHMENTS; + frame[2] = header.length >>> 24; + frame[3] = (header.length >>> 16) & 255; + frame[4] = (header.length >>> 8) & 255; + frame[5] = header.length & 255; + frame.set(header, HEADER_BYTES); + let offset = HEADER_BYTES + header.length; + for (let i = 0; i < buffers.length; i++) { + frame.set(buffers[i], offset); + offset += buffers[i].length; + } + return frame; +}; + +/** Whether `bytes` is an attachments frame by its first two bytes. */ +const isAttachmentsFrame = (bytes) => + bytes.length >= HEADER_BYTES && bytes[0] === FRAME_MARK && bytes[1] === FRAME_ATTACHMENTS; + +const malformed = (what) => new TypeError(`attachments: ${what}`); + +// A path key: an array index, or an object key. `__proto__` is refused +// outright; every other key is written only where the parsed packet +// already holds an own `null` placeholder (Object.hasOwn below), which is +// what keeps a frame from reaching a prototype through `constructor` or +// anything else — the placeholder must have been created by JSON.parse. +const validKey = (key) => + typeof key === 'number' ? Number.isInteger(key) && key >= 0 : typeof key === 'string' && key !== '__proto__'; + +/** + * The packet (or batch array) a frame carries, its byte leaves restored as + * fresh Uint8Arrays — copies, never views into `bytes`. Every malformed + * frame is a TypeError: a header that is not `[packet, index]`, a path + * that names a forbidden key, walks through a non-container or lands on + * anything but the `null` placeholder, or byte lengths that do not add up + * to exactly what follows the header. + */ +const decodeAttachments = (bytes, maxDepth = MAX_DEPTH) => { + if (!isAttachmentsFrame(bytes)) throw malformed('not an attachments frame'); + const headerLen = ((bytes[2] << 24) >>> 0) + (bytes[3] << 16) + (bytes[4] << 8) + bytes[5]; + if (HEADER_BYTES + headerLen > bytes.length) throw malformed('header past the end of the frame'); + let header; + try { + header = JSON.parse(decoder.decode(bytes.subarray(HEADER_BYTES, HEADER_BYTES + headerLen))); + } catch { + throw malformed('header is not JSON'); + } + if (!Array.isArray(header) || header.length !== 2) throw malformed('header is not [packet, index]'); + const packet = header[0]; + const index = header[1]; + if (!Array.isArray(index)) throw malformed('index is not an array'); + let offset = HEADER_BYTES + headerLen; + for (let i = 0; i < index.length; i++) { + const entry = index[i]; + if (!Array.isArray(entry) || entry.length !== 2) throw malformed('index entry is not [path, length]'); + const path = entry[0]; + const length = entry[1]; + if (!Array.isArray(path) || path.length === 0 || path.length > maxDepth) throw malformed('bad path'); + if (!Number.isInteger(length) || length < 0 || offset + length > bytes.length) throw malformed('bad length'); + let target = packet; + for (let j = 0; j < path.length - 1; j++) { + const key = path[j]; + if (!validKey(key)) throw malformed('forbidden key in path'); + if (typeof target !== 'object' || target === null) throw malformed('path through a non-container'); + if (!Object.hasOwn(target, key)) throw malformed('path names a missing key'); + target = target[key]; + } + const last = path[path.length - 1]; + if (!validKey(last)) throw malformed('forbidden key in path'); + if (typeof target !== 'object' || target === null) throw malformed('path through a non-container'); + if (!Object.hasOwn(target, last)) throw malformed('path names a missing key'); + if (target[last] !== null) throw malformed('placeholder is not null'); + // A copy, by construction: the constructor over a typed array + // allocates — the frame may be a view into a socket buffer. + target[last] = new Uint8Array(bytes.subarray(offset, offset + length)); + offset += length; + } + if (offset !== bytes.length) throw malformed('byte length mismatch'); + return packet; +}; + +// A backplane envelope wraps the packet — or a command's args — at most two +// levels deeper than a packet holds its data: its frame reaches that much +// further, so bytes a direct send delivers are delivered across instances +// too (at 31 levels they arrived as `{"0":…}` there). +const ENVELOPE_DEPTH = MAX_DEPTH + 2; + +module.exports = { + hasBytes, + encodeAttachments, + decodeAttachments, + isAttachmentsFrame, + FRAME_ATTACHMENTS, + ENVELOPE_DEPTH, +}; diff --git a/src/auth/index.js b/src/auth/index.js index 6adc7d1..b6d24d3 100644 --- a/src/auth/index.js +++ b/src/auth/index.js @@ -11,7 +11,7 @@ // src/wire.js — the import-free wire-name constants (esbuild inlines them, // so the subpath stays ~1 KB) — one file serves both sides, like ./query. -const { META_PARAM, META_HEADER, META_PREFIX } = require('../wire.js'); +const { META_PARAM, META_HEADER, META_PREFIX, BEARER_PROTOCOL } = require('../wire.js'); // --------------------------------------------------------------------------- // Client-side token stores. The contract is three functions, sync or async @@ -182,8 +182,7 @@ const declaredFromUrl = (url, param) => { // an Authorization header, and the connect URL lands in proxy access logs — // but RFC 6455 lets the client OFFER subprotocols, which travel as a real // upgrade header. The ws transport offers `wrpc.bearer.` next to the -// wire revision; this is where the server reads it back. -const SUBPROTOCOL_PREFIX = 'wrpc.bearer.'; +// wire revision; this is where the server reads it back (BEARER_PROTOCOL). /** * Session token in `Authorization: ` — the observed header @@ -204,7 +203,7 @@ const bearerTransport = ({ scheme = 'Bearer' } = {}) => { if (typeof offered === 'string' && offered.length > 0) { for (const part of offered.split(',')) { const name = part.trim(); - if (name.startsWith(SUBPROTOCOL_PREFIX)) return name.slice(SUBPROTOCOL_PREFIX.length); + if (name.startsWith(BEARER_PROTOCOL)) return name.slice(BEARER_PROTOCOL.length); } } // The bag the core already parsed and capped (wrpc_h included) — this diff --git a/src/broker/adapter.js b/src/broker/adapter.js new file mode 100644 index 0000000..17fe83c --- /dev/null +++ b/src/broker/adapter.js @@ -0,0 +1,157 @@ +'use strict'; + +// What the broker adapters share, word for word: the header names a +// delivery carries, the two shapes of a `log.read` that is refused or +// guarded, the checks every `consume`/`listen` opens with, the id factory, +// and what happens to a delivery whose handler threw. Each of these was +// four copies (Redis, NATS, AMQP, Kafka) that had to be edited together. +// +// Deliberately NOT here: the delivery object itself. How a message is +// settled is where the brokers really differ — Redis releases its slot in a +// `finally`, NATS stops its keepalive before the work, Kafka awaits the +// handler to keep a partition in order, AMQP settles on the channel the +// delivery arrived on — and a shared `createDelivery` would be a table of +// exceptions. +// +// Node-only, like everything under src/broker/. Not in src/wire.js: that one +// is bundled for the browser, on a byte budget. + +const { generateUUID } = require('../runtime/node.js'); +const { resolveGenerateId } = require('../utils.js'); +const { crashDelay, positiveInteger } = require('./retry.js'); + +// Carried by a delivery an adapter re-published: the attempt it is on (the +// counter is the ADAPTER's — RabbitMQ 4 does not count a requeue, Kafka has +// no counter at all), whether it is a copy the adapter put back, and — on a +// dead-letter queue — why it was given up on. +const ATTEMPT_HEADER = 'x-wrpc-attempt'; +const REDELIVERED_HEADER = 'x-wrpc-redelivered'; +const DEAD_REASON_HEADER = 'x-wrpc-dead-reason'; +// A direct message's body was a string ('1') or bytes ('0'), where the +// broker itself carries bytes only. +const TEXT_HEADER = 'wrpc-text'; + +const DEFAULT_PREFETCH = 16; + +/** + * The id factory of an adapter. An injected `generateId` is used VERBATIM + * for every id the adapter mints — never truncated. Trimming a user's id + * would quietly weaken the uniqueness they chose it for, and all wrpc knows + * about their generator is that it answers a string. The cost is that a + * generator answering characters a broker refuses in a consumer name, + * subject or queue name fails at the driver, not here. + * + * Strict: a new option, so a bad generator is refused at construction + * rather than producing a name the broker rejects at connect time. + * `shortName` is for the names a broker repeats in every log line and + * metric label it emits: the DEFAULT stays short, an injected generator is + * used whole. + */ +const idFactory = (generateId, label) => { + if (generateId === null) return { nextId: generateUUID, shortName: () => generateUUID().slice(0, 8) }; + const nextId = resolveGenerateId(generateId, label).generate; + return { nextId, shortName: nextId }; +}; + +/** + * A `log.read` refused before it started (a malformed id): `ready` rejects + * — handled from birth, a read nobody awaits is not an unhandled rejection — + * and so does every `next()`. + */ +const failedRead = (error) => { + const rejected = Promise.reject(error); + rejected.catch(() => {}); + return { + ready: rejected, + [Symbol.asyncIterator]: () => ({ + next: () => Promise.reject(error), + return: () => Promise.resolve({ value: undefined, done: true }), + }), + }; +}; + +/** + * A read behind a `guard` — the promise of a position check (is the id past + * the tip? below the retention?) started alongside it — and, with `mapId`, + * handing out its ids in the adapter's public spelling (a string for a + * sequence, the encoded vector for Kafka). + * + * `ready` waits for both. The first `next()` awaits the guard, and a guard + * that refuses RETURNS the inner iterator before it throws: a refused + * position must not leave this reader on the shared tail, and an iterator + * whose next() rejects is never closed by a for-await loop. + */ +const guardedRead = (inner, { guard = null, mapId = null } = {}) => { + const ready = guard === null ? inner.ready : Promise.all([guard, inner.ready]).then(() => undefined); + ready.catch(() => {}); // surfaced through the iteration; never unhandled + return { + ready, + [Symbol.asyncIterator]: () => { + const iterator = inner[Symbol.asyncIterator](); + let verified = guard === null; + return { + next: async () => { + if (!verified) { + try { + await guard; + } catch (error) { + await iterator.return?.(); + throw error; + } + verified = true; + } + const result = await iterator.next(); + if (mapId === null || result.done) return result; + return { done: false, value: { ...result.value, id: mapId(result.value.id) } }; + }, + return: (value) => iterator.return?.(value) ?? Promise.resolve({ value, done: true }), + }; + }, + }; +}; + +/** What every `queue.consume` checks first; `label` is `' queue.consume'`. */ +const checkConsume = (label, onDelivery, prefetch, deadLetter) => { + if (typeof onDelivery !== 'function') throw new TypeError(`${label}: onDelivery must be a function`); + if (!positiveInteger(prefetch)) throw new TypeError(`${label}: prefetch must be a positive integer`); + if (deadLetter !== null && (typeof deadLetter !== 'string' || deadLetter.length === 0)) { + throw new TypeError(`${label}: deadLetter must be a queue name or null`); + } +}; + +/** What every `direct.listen` checks first; `label` is `' direct.listen'`. */ +const checkListen = (label, address, onMessage) => { + if (typeof onMessage !== 'function') throw new TypeError(`${label}: onMessage must be a function`); + if (typeof address !== 'string' || address.length === 0) { + throw new TypeError(`${label}: address must be a non-empty string`); + } +}; + +/** + * Hands a delivery to its handler. One that throws or rejects settled + * nothing: reported (`report(event, error, { queue })`), and retried after a + * backoff, attempt + 1 — the delivery contract (port.js), not a release to + * the head, which a handler that always throws would spin hot. + */ +const runDelivery = (onDelivery, delivery, report, event, queue) => { + Promise.resolve() + .then(() => onDelivery(delivery)) + .catch((error) => { + report(event, error, { queue }); + void delivery.retry({ delay: crashDelay(delivery.attempt) }); + }); +}; + +module.exports = { + ATTEMPT_HEADER, + REDELIVERED_HEADER, + DEAD_REASON_HEADER, + TEXT_HEADER, + DEFAULT_PREFETCH, + idFactory, + failedRead, + guardedRead, + checkConsume, + checkListen, + runDelivery, +}; diff --git a/src/broker/amqp/index.js b/src/broker/amqp/index.js new file mode 100644 index 0000000..43a5011 --- /dev/null +++ b/src/broker/amqp/index.js @@ -0,0 +1,1107 @@ +'use strict'; + +// The RabbitMQ broker (AMQP 0-9-1): all four capabilities over an INJECTED +// amqplib connection (`amqplib` is a devDependency here, never a runtime +// one). +// +// const amqp = require('amqplib'); +// const broker = createAmqpBroker({ connection: await amqp.connect(url) }); +// +// Capability map: +// backplane a direct exchange; each instance binds its own exclusive +// queue per channel — fan-out without a shared queue +// log a STREAM queue (`x-queue-type: stream`); the stream offset is +// the feed's resume token +// queue a quorum queue per name, prefetch per consumer channel, a TTL +// retry queue that dead-letters back for delays, a dead-letter +// queue for what is exhausted +// direct ONE direct exchange, the address as the routing key; an +// exclusive queue per inbox, a durable shared queue per +// service group, `mandatory` + basic.return for "nobody there" +// +// Two RabbitMQ 4 behaviours shape this adapter (both measured in the +// phase-0 spike): +// - `nack(requeue)` does NOT count a delivery attempt, so the attempt lives +// in an `x-wrpc-attempt` header and a retry is a republish; +// - a transient non-exclusive queue is refused with a CONNECTION-level 541, +// so every shared queue here is durable. + +const { createLoggerWriter } = require('../../logging.js'); +const { backoffDelay } = require('../../utils.js'); +const { TopicTails } = require('../tail.js'); +const { codedError, toBytes, toHeaders, reasonText, encodeToken } = require('../ids.js'); +const { positiveInteger } = require('../retry.js'); +const { withHealth } = require('../port.js'); +const { + ATTEMPT_HEADER, + REDELIVERED_HEADER, + DEAD_REASON_HEADER, + TEXT_HEADER, + DEFAULT_PREFETCH, + idFactory, + failedRead, + guardedRead, + checkConsume, + checkListen, + runDelivery, +} = require('../adapter.js'); + +const DEFAULT_PREFIX = 'wrpc'; +const DEFAULT_INBOX_TTL = 60_000; +const DEFAULT_QUEUE_TYPE = 'quorum'; +const OFFSET = /^\d{1,19}$/; + +const STREAM_OFFSET = 'x-stream-offset'; + +const isFunction = (value) => typeof value === 'function'; +const name = (prefix, kind, value) => `${prefix}.${kind}.${encodeToken(value, { maxLength: 180 })}`; +const sleep = (ms) => + new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + if (isFunction(timer.unref)) timer.unref(); + }); +// A consumer's channel is re-opened on this schedule after the server +// closed it; a lost CONNECTION stops the loop (nothing to re-open on). +const REOPEN_BACKOFF = Object.freeze({ minDelay: 200, maxDelay: 5000, factor: 2, jitter: true }); +// A settlement that has to PUBLISH (a retry's copy, a dead letter) and was +// refused is tried again on this schedule; when it still fails, the +// message goes back to the queue with a requeue — at least once, attempt +// untouched — rather than being acked away or left unacked on a channel +// that may close later. +const SETTLE_ATTEMPTS = 3; +const SETTLE_BACKOFF = Object.freeze({ minDelay: 100, maxDelay: 1000, factor: 2, jitter: false }); +// A catch-up page over a stream queue: a stream reader has no "end", so a +// page is complete when it reaches the tip read BEFORE the reader started +// — never when the deliveries merely paused. `RANGE_IDLE` is how long a +// pause may last before the page is handed back incomplete (TopicTails +// asks again from the cursor), `RANGE_TIMEOUT` the most one page waits +// overall, `RANGE_TIP_TIMEOUT` how long an EMPTY stream is given to +// answer "last" before it is taken for empty. +const RANGE_IDLE = 250; +const RANGE_TIMEOUT = 10_000; +const RANGE_TIP_TIMEOUT = 500; + +const createAmqpBroker = (options = {}) => { + const { + connection, + prefix = DEFAULT_PREFIX, + logger = globalThis.console, + queueType = DEFAULT_QUEUE_TYPE, + inboxTtl = DEFAULT_INBOX_TTL, + streamMaxBytes = 0, + generateId = null, + } = options; + // Ids: an injected generator is used verbatim — see idFactory. + const { nextId } = idFactory(generateId, 'createAmqpBroker'); + if (!connection || !isFunction(connection.createChannel) || !isFunction(connection.createConfirmChannel)) { + throw new TypeError('createAmqpBroker: options.connection must be an amqplib connection'); + } + // Strict, at construction: these become queue ARGUMENTS, and RabbitMQ + // refuses a redeclaration with different ones with a channel-level error + // long after the typo — a `queueType` it does not know, an `inboxTtl` + // read as a string. + if (queueType !== 'quorum' && queueType !== 'classic') { + throw new TypeError("createAmqpBroker: options.queueType must be 'quorum' or 'classic'"); + } + if (!positiveInteger(inboxTtl)) { + throw new TypeError('createAmqpBroker: options.inboxTtl must be a positive integer of milliseconds'); + } + if (!Number.isInteger(streamMaxBytes) || streamMaxBytes < 0) { + throw new TypeError('createAmqpBroker: options.streamMaxBytes must be a non-negative integer (0 for unbounded)'); + } + const log = createLoggerWriter(logger).child({ component: 'broker', broker: 'amqp' }); + const report = (event, error, extra = {}) => log.error({ err: error, event, ...extra }); + let closed = false; + // The injected connection went away: every channel with it, and nothing + // here can open another — the connection is the caller's to reopen, with + // a new broker on it. Set once, reported once, and what every retry loop + // and health getter reads. + let lost = false; + const channels = new Set(); + + // `onClose` hears the channel close, with the error that closed it when + // the server did (a channel-level error arrives first, the close after). + const openChannel = async (confirm = false, onClose = null) => { + const channel = confirm ? await connection.createConfirmChannel() : await connection.createChannel(); + // A channel-level error (a mismatched queue declaration, a missing + // queue) closes the channel, not the process. + let failure = null; + channel.on?.('error', (error) => { + failure = error; + report('broker.amqp.channel', error); + }); + channel.on?.('close', () => { + channels.delete(channel); + if (onClose !== null) onClose(failure); + }); + channels.add(channel); + return channel; + }; + + const closeChannel = async (channel) => { + channels.delete(channel); + try { + await channel.close(); + } catch { + // Already closed by the broker, or the connection went with it. + } + }; + + // A lazily opened channel memoized as a PROMISE, not as a resolved + // channel — two concurrent callers must share it, since a publisher + // spread over two channels loses AMQP's ordering guarantee, which is + // per channel — and FORGOTTEN the moment the channel closes. It used to + // be kept: a channel-level error (a mismatched declaration, a missing + // queue) closed the channel, the memo went on handing out the corpse, + // and every later declaration, publish and send failed with "channel + // closed" until the broker itself was closed. + const memoChannel = (confirm, { setup = null, onClose = null } = {}) => { + let memo = null; + const get = () => { + if (memo !== null) return memo; + const pending = openChannel(confirm, (error) => { + if (memo === pending) memo = null; + if (onClose !== null) onClose(error); + }).then(async (channel) => { + if (setup !== null) await setup(channel); + return channel; + }); + memo = pending; + pending.catch(() => { + if (memo === pending) memo = null; + }); + return pending; + }; + get.reset = () => { + memo = null; + }; + return get; + }; + + const topology = memoChannel(false); + const publisher = memoChannel(true); + + // Publishes and waits for the broker's confirm: what makes an append or a + // produce a promise the caller can trust. + const confirmPublish = async (exchange, routingKey, body, options = {}) => { + const channel = await publisher(); + await new Promise((resolve, reject) => { + channel.publish(exchange, routingKey, Buffer.from(toBytes(body)), options, (error) => { + if (error) reject(error); + else resolve(); + }); + }); + }; + + const headersOf = (message) => { + const bag = toHeaders(message?.properties?.headers ?? null); + return bag; + }; + + // --------------------------------------------------------------------- + // backplane + + // The two exchanges this adapter declares — the backplane's and the + // direct one — memoized as promises; a failed declaration is forgotten, + // so the next caller tries again. Nothing is declared per address. + const exchanges = new Map(); + const ensureExchange = (exchangeName, type) => { + let pending = exchanges.get(exchangeName); + if (pending) return pending; + pending = (async () => { + const channel = await topology(); + await channel.assertExchange(exchangeName, type, { durable: true }); + return exchangeName; + })(); + exchanges.set(exchangeName, pending); + pending.catch(() => exchanges.delete(exchangeName)); + return pending; + }; + + const exchange = `${prefix}.bp`; + + // One consumer channel and ONE exclusive queue for the whole backplane, + // bound and unbound by routing key as rooms come and go. It used to be a + // channel and a queue per subscription — and a room is a subscription — + // so a server with a few hundred rooms ran into RabbitMQ's channel_max + // (2047 by default, lower behind many proxies), after which every new + // room failed to subscribe. The channel count is constant now, and the + // same idea as src/scaling/redis.js: one connection, many channels. + const handlers = new Map(); // routing key -> Set + const bound = new Set(); // routing keys bound on the current queue + const chains = new Map(); // routing key -> its last bind/unbind, so they run in order + let consumerQueue = null; + let rebinding = false; + const consuming = memoChannel(false, { + setup: async (consumerChannel) => { + await ensureExchange(exchange, 'direct'); + // Exclusive and auto-delete: this instance's own copy of the traffic, + // gone the moment it disconnects. + const { queue } = await consumerChannel.assertQueue('', { exclusive: true, autoDelete: true }); + consumerQueue = queue; + bound.clear(); + await consumerChannel.consume( + queue, + (message) => { + // null = the server cancelled this consumer (its queue deleted, + // its node gone). The channel stays open and hears nothing — this + // used to return, as if it closed, and the backplane went deaf + // without a line. Closed here, so onClose rebinds on a fresh one. + if (message === null) { + report('broker.amqp.cancelled', new Error('consumer cancelled'), { queue: 'backplane' }); + void closeChannel(consumerChannel); + return; + } + const set = handlers.get(message.fields?.routingKey); + if (set === undefined) return; + const text = message.content.toString(); + // Handlers are synchronous (a RoomsBackplane's), so the copy is + // what lets one unsubscribe from inside its own callback. + for (const handler of Array.from(set)) { + try { + handler(text); + } catch (error) { + report('broker.amqp.handler', error, { channel: message.fields?.routingKey }); + } + } + }, + { noAck: true }, + ); + }, + onClose: () => { + consumerQueue = null; + bound.clear(); + if (closed || lost || handlers.size === 0) return; + void rebind(); + }, + }); + + // Binds or unbinds one routing key to match `handlers`, serialized per + // key: AMQP answers bind and unbind in order on one channel, so a + // subscribe racing an unsubscribe on the same room settles as the last + // caller asked. + const reconcile = (key) => { + const step = async () => { + const want = handlers.has(key); + if (want === bound.has(key)) return; + const consumerChannel = await consuming(); + const queue = consumerQueue; + if (queue === null) throw new Error('backplane channel closed'); + if (want) await consumerChannel.bindQueue(queue, exchange, key); + else await consumerChannel.unbindQueue(queue, exchange, key); + // The queue may have gone while the bind was in flight: onClose + // cleared `bound`, and the rebind will do this key again. + if (consumerQueue !== queue) return; + if (want) bound.add(key); + else bound.delete(key); + }; + const next = (chains.get(key) ?? Promise.resolve()).then(step, step); + chains.set(key, next); + next + .finally(() => { + if (chains.get(key) === next) chains.delete(key); + }) + .catch(() => {}); + return next; + }; + + // The consumer channel closed under live subscriptions (a node went + // away, the server cancelled the consumer): a fresh channel, a fresh + // queue, every key bound again — with a backoff, and one line when it + // is back. Events published meanwhile are lost, which the rooms layer + // sees as a sequence gap (a backplane is at-most-once). + const rebind = async () => { + if (rebinding) return; + rebinding = true; + try { + for (let attempt = 0; ; attempt++) { + if (closed || lost || handlers.size === 0) return; + await sleep(backoffDelay({ ...REOPEN_BACKOFF, attempt })); + if (closed || lost || handlers.size === 0) return; + try { + await consuming(); + await Promise.all(Array.from(handlers.keys(), (key) => reconcile(key))); + log.warn({ event: 'broker.amqp.backplane.rebind', channels: handlers.size, attempt: attempt + 1 }); + return; + } catch (error) { + report('broker.amqp.backplane.rebind', error, { attempt: attempt + 1 }); + } + } + } finally { + rebinding = false; + } + }; + + const backplane = { + name: 'amqp', + publish(channel, message) { + if (closed) return; + void (async () => { + try { + await ensureExchange(exchange, 'direct'); + await confirmPublish(exchange, encodeToken(channel, { maxLength: 180 }), message, { + contentType: 'application/json', + }); + } catch (error) { + report('broker.amqp.publish', error, { channel }); + } + })(); + }, + subscribe(channel, handler) { + if (!isFunction(handler)) throw new TypeError('amqp backplane.subscribe: handler must be a function'); + const key = encodeToken(channel, { maxLength: 180 }); + let set = handlers.get(key); + if (set === undefined) { + set = new Set(); + handlers.set(key, set); + } + set.add(handler); + let active = true; + const unsubscribe = async () => { + if (!active) return; + active = false; + const current = handlers.get(key); + if (current === undefined) return; + current.delete(handler); + if (current.size > 0) return; + handlers.delete(key); + if (closed || lost) return; + await reconcile(key).catch((error) => report('broker.amqp.unbind', error, { channel })); + }; + return reconcile(key).then( + () => unsubscribe, + (error) => { + set.delete(handler); + if (set.size === 0) handlers.delete(key); + throw error; + }, + ); + }, + close() { + // Channels are closed by the broker's own close(). + }, + }; + + // --------------------------------------------------------------------- + // log — a stream queue, the offset as the id + + const logQueue = (topic) => name(prefix, 'log', topic); + const ensuredLogs = new Map(); + const ensureLog = (topic) => { + const queue = logQueue(topic); + let pending = ensuredLogs.get(queue); + if (pending) return pending; + pending = (async () => { + const channel = await topology(); + const args = { 'x-queue-type': 'stream' }; + if (streamMaxBytes > 0) args['x-max-length-bytes'] = streamMaxBytes; + await channel.assertQueue(queue, { durable: true, arguments: args }); + return queue; + })(); + ensuredLogs.set(queue, pending); + pending.catch(() => ensuredLogs.delete(queue)); + return pending; + }; + + const offsetOf = (message) => Number(message.properties?.headers?.[STREAM_OFFSET] ?? -1); + + // One stream consumer, reading from `from` and handing every message to + // `onMessage` until `stop()`. Streams need a prefetch, so each reader gets + // its own channel. + // `onEnd(error)` hears the reader die on its own — the server cancelled + // the consumer (the stream deleted, its node gone) or closed the channel + // — once, and never after the returned stop(). + const openStreamReader = async (topic, from, onMessage, onEnd = null) => { + const queue = await ensureLog(topic); + let stopped = false; + const ended = (error) => { + if (stopped || onEnd === null) return; + stopped = true; + onEnd(error); + }; + const channel = await openChannel(false, (error) => ended(error ?? new Error('channel closed'))); + await channel.prefetch(64); + const { consumerTag } = await channel.consume( + queue, + (message) => { + if (message === null) return void ended(new Error('consumer cancelled')); + channel.ack(message); + onMessage(message); + }, + { noAck: false, arguments: { 'x-stream-offset': from } }, + ); + return async () => { + stopped = true; + try { + await channel.cancel(consumerTag); + } catch { + // Already cancelled with the channel. + } + await closeChannel(channel); + }; + }; + + const entryOf = (message) => { + const headers = headersOf(message); + // The offset is the entry's id, not one of its headers. + delete headers[STREAM_OFFSET]; + return { offset: offsetOf(message), value: message.content.toString(), headers }; + }; + + const tails = new TopicTails({ + // Readers that fell behind the tail and are catching up through range(): + // info, not a fault — but the first thing to look at when a feed is slow. + onLag: (topic, readers) => log.info({ event: 'broker.tail.lag', topic, readers }), + live: async (topic, { signal, onEntry, onEnd }) => { + // The tip BEFORE the reader starts: `next` then guarantees the reader + // sees everything appended from here on and nothing before it. (The + // count is exact until something truncates the stream, and an + // under-reported tip only means a live entry is not filtered — never + // that one is dropped.) + const queue = await ensureLog(topic); + const channel = await topology(); + let messageCount; + try { + ({ messageCount } = await channel.checkQueue(queue)); + } catch (error) { + // The stream is gone under the memo (deleted by hand): forgotten, + // so the next read declares it again instead of failing forever. + ensuredLogs.delete(queue); + throw error; + } + // A tail that died on its own (the node it lived on went away, the + // stream was deleted) used to leave every subscription of the topic + // frozen for good; now the readers move to a fresh tail. + const stop = await openStreamReader( + topic, + 'next', + (message) => onEntry(entryOf(message)), + (error) => { + report('broker.amqp.tail', error, { topic }); + onEnd(error); + }, + ); + signal.addEventListener('abort', () => void stop(), { once: true }); + return messageCount > 0 ? messageCount - 1 : null; + }, + range: async (topic, { after, limit }) => { + const start = after === null || after === undefined ? -1 : after; + // The tip FIRST, then the page: it is complete once it reaches that + // offset, whatever the timing of the deliveries. "Nothing for 50 ms" + // used to be the end of a page, and a broker that paused for 60 ms + // handed back a page called complete with entries still behind it — + // which the reader then joined the live tail past. + const tip = await tipOffset(topic, RANGE_TIP_TIMEOUT); + if (tip === null || start >= tip) return { entries: [], done: true }; + const entries = []; + let last = start; + let failure = null; + await new Promise((resolve, reject) => { + let settled = false; + let idle = null; + let stopReader = null; + const finish = async () => { + if (settled) return; + settled = true; + clearTimeout(idle); + clearTimeout(overall); + await stopReader?.(); + resolve(); + }; + const rearm = () => { + clearTimeout(idle); + idle = setTimeout(() => void finish(), RANGE_IDLE); + if (isFunction(idle.unref)) idle.unref(); + }; + const overall = setTimeout(() => void finish(), RANGE_TIMEOUT); + if (isFunction(overall.unref)) overall.unref(); + openStreamReader(topic, start + 1, (message) => { + if (settled) return; + const entry = entryOf(message); + // RabbitMQ silently starts a stream reader at the OLDEST retained + // message when the requested offset is gone: a first entry past + // the one asked for is history the retention took, not a page. + if (entries.length === 0 && start >= 0 && entry.offset > start + 1) { + failure = codedError('Event history was trimmed past this id', 410); + return void finish(); + } + entries.push(entry); + last = entry.offset; + if (entries.length >= limit || last >= tip) return void finish(); + rearm(); + }).then((stop) => { + stopReader = stop; + if (settled) void stop(); + else rearm(); + }, reject); + }); + if (failure !== null) throw failure; + return { entries, done: last >= tip }; + }, + // Stream offsets are dense: the entry after `n` is `n + 1`, and a + // page whose head is further along came from a reader that skipped. + contiguous: (cursor, entry) => entry.offset === cursor + 1, + covered: (cursor, entry) => entry.offset <= cursor, + advance: (_cursor, entry) => entry.offset, + }); + + const parseId = (text) => (typeof text === 'string' && OFFSET.test(text) ? text : null); + + // An offset is a number inside and a string outside. + const stringifyIds = (inner) => guardedRead(inner, { mapId: String }); + + const read = (topic, options = {}) => { + const { after = null, from = 'latest', signal = null } = options; + if (from !== 'latest' && from !== 'earliest') { + throw new TypeError("amqp log.read: from must be 'latest' or 'earliest'"); + } + if (after === null || after === undefined) { + return stringifyIds(tails.read(topic, from === 'earliest' ? { after: -1, signal } : { from, signal })); + } + if (parseId(after) === null) return failedRead(codedError('Malformed event id', 400)); + const position = Number(after); + const inner = tails.read(topic, { after: position, signal }); + // RabbitMQ silently starts a stream reader at the OLDEST retained + // message when the requested offset is gone, so the gap shows up as a + // first entry further along than asked for. + const wrapped = { + ready: inner.ready, + [Symbol.asyncIterator]: () => { + const iterator = inner[Symbol.asyncIterator](); + let first = true; + return { + next: async () => { + const result = await iterator.next(); + if (result.done) return result; + if (first) { + first = false; + if (result.value.id > position + 1) { + await iterator.return?.(); + throw codedError('Event history was trimmed past this id', 410); + } + } + return result; + }, + return: (value) => iterator.return?.(value) ?? Promise.resolve({ value, done: true }), + }; + }, + }; + return stringifyIds(wrapped); + }; + + // AMQP 0-9-1 does not report the offset a publish landed on — only the + // stream protocol does — and `messageCount` lags a stream badly. So the + // append reads the tip back: one message from `last`, cancelled at once. + // That is an extra round trip per append, which is why a high-rate + // producer should publish through a `log` on a broker that answers with + // the offset (Redis, NATS, Kafka) or ignore the receipt and let readers + // yield the authoritative ids. + const tipOffset = async (topic, timeout = 2000) => { + const queue = await ensureLog(topic); + const channel = await openChannel(); + try { + await channel.prefetch(1); + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve(null), timeout); + if (isFunction(timer.unref)) timer.unref(); + channel + .consume( + queue, + (message) => { + if (message === null) return; + clearTimeout(timer); + channel.ack(message); + resolve(offsetOf(message)); + }, + { noAck: false, arguments: { 'x-stream-offset': 'last' } }, + ) + .catch(reject); + }); + } finally { + await closeChannel(channel); + } + }; + + const append = async (topic, value, { headers = null } = {}) => { + if (closed || lost) throw codedError(closed ? 'Broker is closed' : 'Broker connection lost', 503); + const queue = await ensureLog(topic); + await confirmPublish('', queue, value, { persistent: true, headers: toHeaders(headers) }); + const offset = await tipOffset(topic); + return String(offset === null ? 0 : offset); + }; + + // --------------------------------------------------------------------- + // queue + + const workQueue = (queue) => name(prefix, 'q', queue); + const retryQueue = (queue) => `${workQueue(queue)}.retry`; + const ensuredQueues = new Map(); + + const ensureQueue = (queue, deadLetter = null) => { + const key = `${queue}|${deadLetter ?? ''}`; + let pending = ensuredQueues.get(key); + if (pending) return pending; + pending = (async () => { + const channel = await topology(); + const main = workQueue(queue); + const args = { 'x-queue-type': queueType }; + if (deadLetter) { + await channel.assertQueue(workQueue(deadLetter), { + durable: true, + arguments: { 'x-queue-type': queueType }, + }); + } + await channel.assertQueue(main, { durable: true, arguments: args }); + // The retry queue has no consumers: a message expires and is + // dead-lettered back into the main queue, which is the delay. + await channel.assertQueue(retryQueue(queue), { + durable: true, + arguments: { + 'x-queue-type': 'classic', + 'x-dead-letter-exchange': '', + 'x-dead-letter-routing-key': main, + }, + }); + return main; + })(); + ensuredQueues.set(key, pending); + pending.catch(() => ensuredQueues.delete(key)); + return pending; + }; + + const produce = async (queue, body, { headers = null } = {}) => { + if (closed || lost) throw codedError(closed ? 'Broker is closed' : 'Broker connection lost', 503); + const main = await ensureQueue(queue); + await confirmPublish('', main, body, { persistent: true, headers: toHeaders(headers) }); + }; + + const consume = async (queue, onDelivery, options = {}) => { + if (closed || lost) throw codedError(closed ? 'Broker is closed' : 'Broker connection lost', 503); + const { prefetch = DEFAULT_PREFETCH, deadLetter = null, signal = null } = options; + checkConsume('amqp queue.consume', onDelivery, prefetch, deadLetter); + const main = await ensureQueue(queue, deadLetter); + const key = `${queue}|${deadLetter ?? ''}`; + // The consumer's channel is not for life: a channel-level error — the + // queue deleted under it, a declaration mismatch, the node it lived on + // going away — closes the channel, and the consumer used to sit on the + // corpse forever, `healthy` still true, taking nothing. It re-opens + // with a backoff instead; every delivery settles on the channel it + // arrived on, whichever that was. + // `paused`: the application's pause(), which a re-open must respect — a + // channel the server closed under a paused consumer came back consuming. + const state = { running: true, healthy: true, paused: false, tag: null, channel: null, reopening: false }; + const attach = async () => { + let opened = null; + opened = await openChannel(false, (error) => void onChannelClosed(opened, error)); + await opened.prefetch(prefetch); + state.channel = opened; + return opened; + }; + + const dispatch = (channel, message) => { + if (message === null) { + // The server cancelled this consumer: the queue was deleted, or the + // node it lived on went away. The channel is still open and useless + // — it goes, and the consumer comes back on a fresh one with the + // queue declared again. + if (state.channel !== channel) return; + state.healthy = false; + state.tag = null; + state.channel = null; + report('broker.amqp.cancelled', new Error('consumer cancelled'), { queue }); + ensuredQueues.delete(key); + void closeChannel(channel); + if (state.running && !closed && !lost) void reopen(); + return; + } + const headers = headersOf(message); + const attempt = Number(headers[ATTEMPT_HEADER] ?? '1') || 1; + let settled = false; + // `publish`: the copy a settlement writes before it acks (a retry, a + // dead letter). A refused publish used to be swallowed after one log + // line with the original still unacked — which the channel's eventual + // close handed back, or a channel that lived on never did. Tried + // again, then handed back on purpose. The ack (`work`) runs ONCE after + // it: retried together, an ack refused by a consumer channel that had + // died ran the publish again — three copies of one delivery — under a + // `channel.closed` amqplib does not have. A channel is gone when it is + // no longer this consumer's (onChannelClosed). + const finish = async (work, publish = null) => { + if (settled) return; + settled = true; + if (publish !== null) { + for (let round = 0; ; round++) { + try { + await publish(); + break; + } catch (error) { + if (round < SETTLE_ATTEMPTS - 1 && state.channel === channel) { + await sleep(backoffDelay({ ...SETTLE_BACKOFF, attempt: round })); + continue; + } + report('broker.amqp.settle', error, { queue, attempt, round: round + 1 }); + try { + // Back in line for another consumer, attempt untouched (a + // requeue does not count one on RabbitMQ 4): at least once. + channel.nack(message, false, true); + } catch { + // The channel is gone; its unacked messages are already back. + } + return; + } + } + } + try { + await work(); + } catch (error) { + // A channel gone after the copy was written hands the original + // back itself: one more delivery, never one more copy. + report('broker.amqp.settle', error, { queue, attempt, round: 1 }); + } + }; + const ack = () => channel.ack(message); + const carry = (extra) => ({ ...headers, [REDELIVERED_HEADER]: '1', ...extra }); + const delivery = Object.freeze({ + id: String(message.properties?.messageId ?? message.fields?.deliveryTag ?? ''), + body: message.content.toString(), + headers, + attempt, + redelivered: message.fields?.redelivered === true || headers[REDELIVERED_HEADER] === '1', + ack: () => finish(ack), + // A requeue would NOT count the attempt on RabbitMQ 4, so a retry is + // a republish — through the TTL queue when it must wait. + retry: ({ delay = 0 } = {}) => + finish(ack, async () => { + const headersOut = carry({ [ATTEMPT_HEADER]: String(attempt + 1) }); + if (delay > 0) { + await confirmPublish('', retryQueue(queue), message.content, { + persistent: true, + headers: headersOut, + expiration: String(Math.round(delay)), + }); + } else { + await confirmPublish('', main, message.content, { persistent: true, headers: headersOut }); + } + }), + // Exactly what a requeue means on RabbitMQ 4: back in line, attempt + // untouched. + release: () => finish(() => channel.nack(message, false, true)), + deadLetter: (reason = '') => + finish(ack, async () => { + if (!deadLetter) return; + await confirmPublish('', workQueue(deadLetter), message.content, { + persistent: true, + headers: { + ...headers, + [DEAD_REASON_HEADER]: reasonText(reason), + [ATTEMPT_HEADER]: String(attempt), + }, + }); + }), + }); + runDelivery(onDelivery, delivery, report, 'broker.amqp.delivery', queue); + }; + + const start = async (channel) => { + const { consumerTag } = await channel.consume(main, (message) => dispatch(channel, message), { noAck: false }); + state.tag = consumerTag; + state.healthy = true; + }; + const reopen = async () => { + if (state.reopening) return; + state.reopening = true; + try { + // `closed` and `lost` flip from outside this loop, between its awaits. + for (let attempt = 0; ; attempt++) { + if (!state.running || closed || lost) return; + await sleep(backoffDelay({ ...REOPEN_BACKOFF, attempt })); + if (!state.running || closed || lost) return; + try { + await ensureQueue(queue, deadLetter); + const channel = await attach(); + if (!state.paused) await start(channel); + else state.healthy = true; + return; + } catch (error) { + report('broker.amqp.consumer.reopen', error, { queue, attempt: attempt + 1 }); + } + } + } finally { + state.reopening = false; + } + }; + const onChannelClosed = (channel, error) => { + if (state.channel !== channel) return; + state.channel = null; + state.tag = null; + if (!state.running || closed || lost) return; + state.healthy = false; + report('broker.amqp.consumer.closed', error ?? new Error('channel closed'), { queue }); + // A queue that is gone is declared again on the way back. + if (error?.code === 404) ensuredQueues.delete(key); + void reopen(); + }; + await start(await attach()); + + const cancel = async () => { + if (state.tag === null || state.channel === null) return; + const tag = state.tag; + state.tag = null; + try { + await state.channel.cancel(tag); + } catch { + // The channel is gone; its unacked messages are already back. + } + }; + const stop = async () => { + if (!state.running) return; + state.running = false; + await cancel(); + // Closing the channel returns whatever was unacked to the queue. + if (state.channel !== null) await closeChannel(state.channel); + }; + if (signal) signal.addEventListener('abort', () => void stop(), { once: true }); + return { + stop, + // basic.cancel keeps the channel, so the messages this consumer holds + // stay ackable — which is what a draining node needs. + pause: async () => { + state.paused = true; + await cancel(); + }, + resume: async () => { + state.paused = false; + if (!state.running || state.tag !== null) return; + // Closed while paused: the re-open (running, or about to) starts it. + if (state.channel === null) return void (await reopen()); + await start(state.channel); + }, + get healthy() { + return state.running && state.healthy && !closed && !lost; + }, + }; + }; + + // --------------------------------------------------------------------- + // direct + + // ONE direct exchange for every address, the address as the routing key: + // plain listeners each bind their own exclusive queue (everyone + // receives), a group binds ONE durable queue (its members compete), and + // `mandatory` reports "nobody bound to that key" as a basic.return — the + // 503 an RPC caller wants. It used to be a durable fanout exchange PER + // address, and an address is whatever a peer puts in `replyTo` — one per + // client inbox — so a service's topology grew by one exchange per client + // for as long as the broker lived (a durable exchange is never deleted + // by itself). Two other shapes were weighed and refused: the default + // exchange `''` routes by queue NAME, so several ungrouped listeners on + // one address, each on its own exclusive queue, could not all receive + // (the direct contract); and an `autoDelete` exchange per address goes + // away with its last binding, after which a publish to it is a + // channel-level 404 on the shared confirm channel, not a basic.return. + const directExchange = `${prefix}.direct`; + const routingKeyOf = (address) => encodeToken(address, { maxLength: 180 }); + const groupQueue = (address, group) => `${name(prefix, 'direct', address)}.${encodeToken(group, { maxLength: 60 })}`; + + const returned = new Set(); + // One memoized confirm channel for every send: two concurrent sends must + // share it, or their publishes could interleave across two — and a + // channel is what AMQP orders messages on. + const directing = memoChannel(true, { + // `mandatory` + basic.return is how a sender learns that nobody is + // there — an unroutable message comes back before its confirm. + setup: (channel) => { + channel.on?.('return', (message) => { + const id = message.properties?.messageId; + if (id) returned.add(id); + }); + }, + // Publishing to an exchange somebody deleted closes the channel with a + // 404: declared again on the next send. + onClose: (error) => { + if (error?.code === 404) exchanges.delete(directExchange); + }, + }); + + // The exchange must exist before a mandatory publish: publishing to a + // missing one is a channel-level 404, not a basic.return. + const ensureDirect = () => ensureExchange(directExchange, 'direct'); + + const listen = async (address, onMessage, { group = null } = {}) => { + if (closed || lost) throw codedError(closed ? 'Broker is closed' : 'Broker connection lost', 503); + checkListen('amqp direct.listen', address, onMessage); + const grouped = group !== null && group !== undefined; + const exchangeName = await ensureDirect(); + const deliver = (message) => { + const headers = headersOf(message); + const text = headers[TEXT_HEADER] === '1'; + delete headers[TEXT_HEADER]; + try { + const result = onMessage({ + body: text ? message.content.toString() : new Uint8Array(message.content), + headers, + correlationId: message.properties?.correlationId ?? null, + replyTo: message.properties?.replyTo ?? null, + }); + if (result && isFunction(result.catch)) result.catch((error) => report('broker.amqp.listener', error)); + } catch (error) { + report('broker.amqp.listener', error, { address }); + } + }; + // The consumer as it stands — its channel, queue and tag — or null while + // it is down. A cancel by the server (the queue deleted, its node gone) + // or a closed channel used to be the end of the listener: `healthy` + // false, and a send to the address taken and delivered nowhere. It comes + // back now, as a queue consumer does: a fresh channel, the queue declared + // and bound again, with a backoff. + let current = null; + let stopped = false; + let reopening = false; + const open = async () => { + let channel = null; + channel = await openChannel(false, () => { + if (current === null || current.channel !== channel) return; + current = null; + if (!stopped && !closed && !lost) void again(); + }); + try { + // RabbitMQ 4 refuses a transient non-exclusive queue, so a group's + // shared queue is durable and expires when nobody consumes it. + const queue = grouped + ? ( + await channel.assertQueue(groupQueue(address, group), { + durable: true, + arguments: { 'x-expires': inboxTtl, 'x-queue-type': 'classic' }, + }) + ).queue + : (await channel.assertQueue('', { exclusive: true, autoDelete: true })).queue; + await channel.bindQueue(queue, exchangeName, routingKeyOf(address)); + const { consumerTag } = await channel.consume( + queue, + (message) => { + if (message !== null) return void deliver(message); + // The channel stays open and hears nothing: closed, so its close + // brings the consumer back. + report('broker.amqp.cancelled', new Error('consumer cancelled'), { address }); + void closeChannel(channel); + }, + { noAck: true }, + ); + current = { channel, queue, tag: consumerTag }; + } catch (error) { + // Not left open behind a failed attempt: a re-open that keeps failing + // would hold one channel per try. + void closeChannel(channel); + throw error; + } + }; + const again = async () => { + if (reopening) return; + reopening = true; + try { + for (let attempt = 0; ; attempt++) { + if (stopped || closed || lost) return; + await sleep(backoffDelay({ ...REOPEN_BACKOFF, attempt })); + if (stopped || closed || lost) return; + try { + await open(); + return; + } catch (error) { + report('broker.amqp.direct.reopen', error, { address, attempt: attempt + 1 }); + } + } + } finally { + reopening = false; + } + }; + await open(); + const stop = async () => { + stopped = true; + const live = current; + current = null; + if (live === null) return; + // An inbox's own queue is unbound FIRST, so a publish racing this stop + // gets a basic.return (nobody there) rather than a nack from a queue + // being deleted under it. A group's queue is shared and stays bound. + if (!grouped) await live.channel.unbindQueue(live.queue, exchangeName, routingKeyOf(address)).catch(() => {}); + try { + await live.channel.cancel(live.tag); + } catch { + // Already cancelled. + } + await closeChannel(live.channel); + }; + // What `stop.healthy` answers: a consumer is in place right now. + return withHealth(stop, () => current !== null && !stopped && !closed && !lost); + }; + + const send = async (address, body, { headers = null, correlationId = null, replyTo = null, timeout } = {}) => { + if (closed || lost) throw codedError(closed ? 'Broker is closed' : 'Broker connection lost', 503); + const [channel, exchangeName] = await Promise.all([directing(), ensureDirect()]); + const text = typeof body === 'string'; + const messageId = nextId(); + const properties = { + headers: { ...toHeaders(headers), [TEXT_HEADER]: text ? '1' : '0' }, + messageId, + persistent: false, + mandatory: true, + }; + if (correlationId !== null && correlationId !== undefined) properties.correlationId = String(correlationId); + if (replyTo !== null && replyTo !== undefined) properties.replyTo = replyTo; + // A request nobody takes in time is dropped by the broker rather than + // answered late. + if (timeout > 0) properties.expiration = String(Math.round(timeout)); + await new Promise((resolve, reject) => { + channel.publish(exchangeName, routingKeyOf(address), Buffer.from(toBytes(body)), properties, (error) => { + // A nack: the broker took nothing — the queue it routed to was + // being deleted under a listener that just stopped, an overflow, a + // node on its way out. Whichever, the message reached nobody, which + // is the 503 the RPC binding retries or fails on. + if (error) return void reject(codedError(`Broker refused the message for ${address}: ${error.message}`, 503)); + if (returned.delete(messageId)) return void reject(codedError(`No listener at ${address}`, 503)); + resolve(); + }); + }); + }; + + // --------------------------------------------------------------------- + + const close = async () => { + if (closed) return; + closed = true; + tails.close(); + ensuredLogs.clear(); + ensuredQueues.clear(); + exchanges.clear(); + for (const channel of Array.from(channels)) await closeChannel(channel); + topology.reset(); + publisher.reset(); + directing.reset(); + consuming.reset(); + handlers.clear(); + bound.clear(); + // The CONNECTION is injected: closing it is the caller's business. + }; + + connection.on?.('close', () => { + if (closed || lost) return; + lost = true; + report('broker.amqp.connection', new Error('connection closed')); + // Every memo is a corpse now, and every consumer's re-open loop reads + // `lost` and stops: there is nothing to re-open on. + exchanges.clear(); + ensuredLogs.clear(); + ensuredQueues.clear(); + }); + + return { + name: 'amqp', + backplane, + log: Object.freeze({ name: 'amqp', append, read, parseId }), + queue: Object.freeze({ name: 'amqp', produce, consume }), + direct: Object.freeze({ name: 'amqp', inbox: () => `${prefix}.inbox.${nextId()}`, listen, send }), + close, + }; +}; + +module.exports = { createAmqpBroker }; diff --git a/src/broker/consumers.js b/src/broker/consumers.js new file mode 100644 index 0000000..d8ee0f0 --- /dev/null +++ b/src/broker/consumers.js @@ -0,0 +1,640 @@ +'use strict'; + +// Queue consumers: broker messages delivered into router procedures, +// at least once. +// +// Each binding owns one long-lived client attached to the RpcServer as a +// request/response carrier (`attach({ persistent: false })`), and every +// delivery becomes an ordinary call packet dispatched through handleRpc — +// so the consumer runs the SAME pipeline a WebSocket call does: onRequest, +// the ready gate, access, preValidation, validators, preHandler, the +// procedure's queue and timeout, onSend/onError/onTimeout, telemetry. The +// outcome decides the settlement through retry.js: ack, retry with +// backoff, release (a draining node), or the dead-letter queue. +// +// Consumer procedures come from two places: +// - a unit's reserved `consumes` block (the static declaration; unreachable +// by any call packet), bound automatically; +// - the binding table, which overrides a declared consumer's policy +// ('unit.v1/source') or binds an ordinary procedure to a queue +// (`{ target: 'unit.v1/method' }`) — deployment topology, not code. +// +// Nothing about the dispatcher changed for this: handleRpc takes the router +// as a parameter, and each binding hands it a one-procedure VIEW whose hooks +// and compiled validators are the real router's. + +const { Emitter } = require('../utils.js'); +const { handleRpc, parseTarget } = require('../rpc/dispatcher.js'); +const { TRACEPARENT, TRACESTATE, SPAN_KIND_CONSUMER } = require('../telemetry/shared.js'); +const { createLoggerWriter } = require('../logging.js'); +const { capabilityOf, brokerName } = require('./port.js'); +const { normalizeRetry, decide } = require('./retry.js'); +const { rpcOf } = require('./host.js'); +const { toText, reasonText } = require('./ids.js'); +const { createBrokerSealing } = require('./sealing.js'); + +// The binding's own refusals: fixed text, safe on a dead letter's outer +// header under sealing — unlike a handler's message (settle). +const OWN_REFUSALS = new WeakSet(); +const refusal = (message) => { + const error = new Error(message); + OWN_REFUSALS.add(error); + return error; +}; + +const DEFAULT_PREFETCH = 16; +const DEFAULT_TOKEN_CLIENTS = 128; +const DEFAULT_TOKEN_TTL = 60_000; +const DEFAULT_VERSION = '*'; +const TRUST = new Set(['none', 'service', 'token']); + +// The outbound half of a consumer client: it never writes to a wire, it +// settles the delivery waiting on the call id. +class ConsumerTransport extends Emitter { + kind = 'broker'; + connection = true; // cleared by attach({ persistent: false }) + #pending = new Map(); // call id -> resolve + #closed = false; + + constructor(source) { + super({ maxListeners: Number.MAX_SAFE_INTEGER }); + this.source = source; + } + + expect(id) { + return new Promise((resolve) => { + if (this.#closed) return void resolve({ code: 503, error: null, closed: true }); + this.#pending.set(id, resolve); + }); + } + + settle(id, outcome) { + const resolve = this.#pending.get(id); + if (!resolve) return; + this.#pending.delete(id); + resolve(outcome); + } + + /** Deliveries whose call has not answered yet. */ + get pending() { + return this.#pending.size; + } + + send(packet) { + if (packet?.type === 'callback' && !packet.error) this.settle(packet.id, { code: null, error: null }); + return true; + } + + // The original error, before wireError masks it: what a dead-letter + // reason and the log want. + error(code, { id = '', error = null } = {}) { + this.settle(id, { code, error }); + return true; + } + + write() { + return true; + } + + close() { + if (this.#closed) return; + this.#closed = true; + // A call the close aborted never answers: its delivery is released + // rather than left waiting forever. + for (const resolve of this.#pending.values()) resolve({ code: 503, error: null, closed: true }); + this.#pending.clear(); + this.emit('close'); + } +} + +const labelOf = (unitKey, name) => `${unitKey}/${name}`; + +const parseJson = (body) => JSON.parse(body); + +const normalizePolicy = (policy, { label, queue: defaultQueue, maxCalls }) => { + if (policy !== undefined && policy !== null && (typeof policy !== 'object' || Array.isArray(policy))) { + throw new TypeError(`${label}: the binding must be an object`); + } + const { + queue = defaultQueue, + group = null, + prefetch = DEFAULT_PREFETCH, + retry, + deadLetter, + sealedFor = null, + identity = { trust: 'none' }, + meta = [], + args = parseJson, + } = policy ?? {}; + if (typeof queue !== 'string' || queue.length === 0) { + throw new TypeError(`${label}: queue must be a non-empty string`); + } + if (group !== null && (typeof group !== 'string' || group.length === 0)) { + throw new TypeError(`${label}: group must be a non-empty string`); + } + if (!Number.isInteger(prefetch) || prefetch <= 0) { + throw new TypeError(`${label}: prefetch must be a positive integer`); + } + // One client runs every delivery of the binding: past maxCalls the + // dispatcher would answer 429 to messages the broker handed us. + if (prefetch > maxCalls) { + throw new TypeError(`${label}: prefetch ${prefetch} exceeds the server's maxCalls ${maxCalls}`); + } + const dead = deadLetter === undefined ? `${queue}.dlq` : deadLetter; + if (dead !== false && dead !== null && (typeof dead !== 'string' || dead.length === 0)) { + throw new TypeError(`${label}: deadLetter must be a queue name or false`); + } + // A binding on a dead-letter queue: the messages there were sealed for the + // queue they came from (the seal binds body to queue), so that is the + // context they open under. + if (sealedFor !== null && (typeof sealedFor !== 'string' || sealedFor.length === 0)) { + throw new TypeError(`${label}: sealedFor must be the name of the queue the messages were sealed for`); + } + if (!identity || typeof identity !== 'object' || !TRUST.has(identity.trust)) { + throw new TypeError(`${label}: identity.trust must be 'none', 'service' or 'token'`); + } + if (!Array.isArray(meta) || !meta.every((name) => typeof name === 'string')) { + throw new TypeError(`${label}: meta must be an array of header names`); + } + if (typeof args !== 'function') throw new TypeError(`${label}: args must be a function`); + return { + queue, + group: group ?? queue, + prefetch, + retry: normalizeRetry(retry, label), + deadLetter: dead || null, + sealedFor, + identity: { trust: identity.trust, session: identity.session ?? null, header: identity.header ?? 'authorization' }, + meta: meta.map((name) => name.toLowerCase()), + args, + }; +}; + +/** + * Binds broker queues to procedures. Resolves once every consumer is live. + * + * const consumers = await attachConsumers(server, broker, { + * 'billing.v1/orders.created': { prefetch: 32 }, // override a declared consumer + * 'audit.events': { target: 'audit.v1/record' }, // bind an ordinary procedure + * }); + */ +const attachConsumers = async (server, broker, table = {}, options = {}) => { + const rpc = rpcOf(server, 'attachConsumers'); + const queue = capabilityOf(broker, 'queue', 'attachConsumers'); + const system = brokerName(queue) === 'custom' ? brokerName(broker) : brokerName(queue); + if (typeof table !== 'object' || table === null || Array.isArray(table)) { + throw new TypeError('attachConsumers: the binding table must be an object'); + } + const { + auto = true, + onDeadLetter = null, + logger = null, + tokenClients = DEFAULT_TOKEN_CLIENTS, + tokenTtl = DEFAULT_TOKEN_TTL, + encryption = null, + } = options; + // The consuming half of a publisher's `encryption`: a delivery is opened + // before anything reads it — its headers carry the credential a binding + // may restore a session from — bound to the queue it was produced to. No + // replay window: a redelivery is the same message, on purpose. + const sealing = createBrokerSealing(encryption, 'attachConsumers: options', { + layer: 'broker-log', + replay: false, + text: true, + }); + if (onDeadLetter !== null && typeof onDeadLetter !== 'function') { + throw new TypeError('attachConsumers: onDeadLetter must be a function'); + } + // `tokenClients: 0` used to evict every client the moment a second token + // arrived, and a string compared as a number never evicted at all. + if (!Number.isInteger(tokenClients) || tokenClients <= 0) { + throw new TypeError('attachConsumers: tokenClients must be a positive integer'); + } + // A cached token client is presented to the session store again once it + // is this old, so a logout or a rotation takes effect within one ttl; 0 + // keeps a client for the life of the binding (`forget()` stays immediate). + if (!Number.isInteger(tokenTtl) || tokenTtl < 0) { + throw new TypeError('attachConsumers: tokenTtl must be a non-negative integer of milliseconds (0 disables it)'); + } + // A server that serves sealed transports only: a binding that does not + // seal would attach clients it refuses every delivery on — and used to + // attach them anyway, with every delivery settling nowhere. + if (rpc.encryptionRequired === true && sealing === null) { + throw new TypeError( + 'attachConsumers: the server requires encryption — bind with { encryption: { keys } } (the sealed log)', + ); + } + const router = rpc.router; + // The server's writer, not the console: a pino-configured server used to + // see a dead letter on the raw console, without `event`, `queue` or `id`. + // `logger: false` still silences it. + const log = createLoggerWriter(logger ?? rpc.log).child({ component: 'broker', broker: system }); + const { maxCalls } = rpc.limits; + + // Resolve every binding BEFORE starting any: a typo in the table must + // fail the attach, not leave half the consumers running. + const plans = []; + const declared = new Map(); + for (const entry of router.consumers()) declared.set(labelOf(entry.unitKey, entry.name), entry); + for (const key of Object.keys(table)) { + const override = table[key] ?? {}; + const label = `attachConsumers: '${key}'`; + if (typeof override !== 'object' || Array.isArray(override)) { + throw new TypeError(`${label}: the binding must be an object`); + } + const entry = declared.get(key); + if (entry) { + if (override.target !== undefined) throw new TypeError(`${label}: a declared consumer cannot be retargeted`); + plans.push({ key, entry, policy: { ...entry.procedure.consume, ...override } }); + continue; + } + if (typeof override.target !== 'string') { + throw new TypeError(`${label}: neither a declared consumer (unit/source) nor a binding with a target`); + } + const { unit, version, name } = parseTarget(override.target); + const proc = router.getProcedure(unit, version, name); + if (!proc) throw new TypeError(`${label}: target '${override.target}' is not a procedure`); + if (proc.subscription) throw new TypeError(`${label}: target '${override.target}' is a subscription`); + const unitKey = version === DEFAULT_VERSION ? unit : `${unit}.${version}`; + const { target, ...policy } = override; + plans.push({ key, entry: { unitKey, name, procedure: proc }, policy, queue: key, method: target }); + } + if (auto) { + for (const [key, entry] of declared) { + if (!Object.hasOwn(table, key)) plans.push({ key, entry, policy: entry.procedure.consume ?? {} }); + } + } + + const bindings = []; + for (const plan of plans) { + const { key, entry } = plan; + const label = `attachConsumers: '${key}'`; + const policy = normalizePolicy(plan.policy, { label, queue: plan.queue ?? entry.name, maxCalls }); + if (entry.procedure.access !== 'public' && policy.identity.trust === 'none') { + throw new TypeError( + `${label}: the procedure requires a session, so the binding needs identity.trust 'service' or 'token'`, + ); + } + bindings.push({ + key, + method: plan.method ?? labelOf(entry.unitKey, `consumes.${entry.name}`), + procedure: entry.procedure, + policy, + }); + } + + const handles = bindings.map((binding) => + bindConsumer({ rpc, queue, system, binding, log, onDeadLetter, tokenClients, tokenTtl, sealing }), + ); + try { + await Promise.all(handles.map((handle) => handle.start())); + } catch (error) { + await Promise.all(handles.map((handle) => handle.stop())); + throw error; + } + + const onDraining = () => { + for (const handle of handles) void handle.pause(); + }; + let stopped = false; + const stop = async () => { + if (stopped) return; + stopped = true; + rpc.off('draining', onDraining); + rpc.off('close', onClose); + await Promise.all(handles.map((handle) => handle.stop())); + }; + const onClose = () => void stop(); + rpc.on('draining', onDraining); + rpc.on('close', onClose); + return { + get bindings() { + return handles.map((handle) => handle.describe()); + }, + get healthy() { + return handles.every((handle) => handle.healthy); + }, + pause: () => Promise.all(handles.map((handle) => handle.pause())).then(() => undefined), + resume: () => Promise.all(handles.map((handle) => handle.resume())).then(() => undefined), + // The logout hook: the next delivery under this header value attaches + // afresh, whatever `tokenTtl` says. The value as messages carry it. + forget: (token) => { + if (typeof token !== 'string') throw new TypeError('ConsumersHandle.forget: token must be a string'); + let found = false; + for (const handle of handles) found = handle.forget(token) || found; + return found; + }, + stop, + }; +}; + +// One binding: its clients, its broker consumer, the per-delivery dispatch. +const bindConsumer = ({ rpc, queue, system, binding, log, onDeadLetter, tokenClients, tokenTtl, sealing }) => { + const { policy, procedure, method } = binding; + const router = rpc.router; + // The one-procedure router view handleRpc dispatches against. + const view = { + getProcedure: () => procedure, + hooksFor: (proc) => router.hooksFor(proc), + compiledFor: (proc) => router.compiledFor(proc), + }; + const spanAttributes = { + 'messaging.system': system, + 'messaging.destination.name': policy.queue, + 'messaging.consumer.group.name': policy.group, + 'messaging.operation.type': 'process', + }; + let consumer = null; + let stopped = false; + + const attachClient = (identity) => { + const transport = new ConsumerTransport(`broker:${policy.queue}`); + // Vouched for at the binding, not per delivery: a sealing binding + // opens every delivery before it dispatches (a plaintext one under + // `required` is dead-lettered below), so its clients are encrypted ones. + const client = rpc.attach(transport, { persistent: false, encrypted: sealing !== null, ...identity }); + client.spanKind = SPAN_KIND_CONSUMER; + client.spanAttributes = spanAttributes; + return { client, transport }; + }; + + let shared = null; // the 'none' / 'service' client + const tokens = new Map(); // token -> { client, transport, retired }, LRU by insertion + // A client dropped from the cache outlives its last delivery, not longer. + // Closing it at once settled every call it still held as released — the + // broker redelivered them, so a cache merely wrapping around under more + // distinct tokens than `tokenClients` turned into duplicated work, and + // under prefetch > tokenClients into a livelock where no delivery ever + // finished. Retired instead: the delivery that finds it so closes it after + // its own settlement (see onDelivery). + const retire = (attached) => { + if (attached.transport.pending === 0) attached.transport.close(); + else attached.retired = true; + }; + const forget = (token) => { + const cached = tokens.get(token); + if (!cached) return false; + tokens.delete(token); + retire(cached); + return true; + }; + const clientFor = (headers) => { + if (policy.identity.trust !== 'token') { + if (!shared) { + const session = + policy.identity.trust === 'service' + ? (policy.identity.session ?? { token: `consumer:${policy.queue}`, state: { consumer: policy.queue } }) + : null; + shared = attachClient(session ? { session } : {}); + } + return shared; + } + const token = headers[policy.identity.header] ?? ''; + const cached = tokens.get(token); + if (cached) { + // '' is the anonymous client: there is nothing to re-validate. + if (token === '' || tokenTtl === 0 || Date.now() - cached.attachedAt < tokenTtl) { + tokens.delete(token); + tokens.set(token, cached); + return cached; + } + // Past its ttl: the token goes to the session store again, so a + // logout or a rotation since the first delivery is what this one sees. + tokens.delete(token); + retire(cached); + } + const attached = { + ...attachClient({ request: { headers: { authorization: token } } }), + retired: false, + attachedAt: Date.now(), + }; + tokens.set(token, attached); + // A restore that did not happen — the store was down, the token names + // no session — used to be cached as "this token is anonymous", and every + // later delivery under it was refused 403 until an eviction. Dropped + // instead: the next delivery presents the token to the store again. + if (token !== '') { + void attached.client.sessionReady.then((restored) => { + if (restored === true || tokens.get(token) !== attached) return; + tokens.delete(token); + retire(attached); + }); + } + if (tokens.size > tokenClients) { + const [oldest, evicted] = tokens.entries().next().value; + tokens.delete(oldest); + // Not free even so — the next message with that token attaches and + // restores its session again — and a cache thrashing at its ceiling + // did it silently. The token is NOT logged: it is a credential. + log.warn({ event: 'broker.evict', queue: policy.queue, cached: tokens.size, max: tokenClients }); + retire(evicted); + } + return attached; + }; + + const settle = async (delivery, decision, code, error, unsealed = null) => { + const { action, delay } = decision; + rpc.otel.recordBrokerDelivery(system, action); + // Only on a settlement that ENDS the message: recording on a retry too + // would count the same message once per attempt and turn the histogram + // into a triangle rather than a distribution. + if (action === 'ack' || action === 'dead') rpc.otel.recordBrokerAttempts(system, delivery.attempt ?? 1); + // Only the terminal outcome used to log, so a queue that was retrying + // itself in a circle looked identical to one that was healthy. Debug, + // because this is one line per message: a Console writer drops it and a + // structured logger's own level decides. + if (log.debugEnabled && action !== 'dead') { + log.debug({ event: `broker.${action}`, queue: policy.queue, method, code, attempt: delivery.attempt, delay }); + } + if (action === 'ack') return delivery.ack(); + if (action === 'retry') return delivery.retry({ delay }); + if (action === 'release') return delivery.release(); + // One line, bounded: a header value on every broker (a validator's + // multi-line message used to make NATS refuse the dead-letter publish, + // and the message came back forever). Under sealing, the code alone: the + // dead letter's body stays sealed, but this header rides OUTSIDE the + // seal, and a handler's message is whatever it wrote — the data the + // seal was for sat in the broker in plaintext beside a sealed body. The + // whole reason is on the `broker.dead` line and in `onDeadLetter`. + const reason = + sealing === null || OWN_REFUSALS.has(error) + ? reasonText(`${code}${error?.message ? ` ${error.message}` : ''}`) + : String(code); + // `err` alongside the code: the code says a message was dead-lettered, + // the error says why, and only the code was ever recorded. + log.warn({ + event: 'broker.dead', + queue: policy.queue, + method, + code, + attempt: delivery.attempt, + id: delivery.id, + err: error, + }); + if (onDeadLetter) { + try { + // `opened`: what a sealed delivery held — the dead letter itself is + // forwarded as it arrived, sealed, so this is where an operator + // reads it (and must not log it whole). + await onDeadLetter({ queue: policy.queue, method, code, error, delivery, opened: unsealed }); + } catch (hookError) { + log.error({ event: 'broker.onDeadLetter', err: hookError }); + } + } + return delivery.deadLetter(reason); + }; + + const onDelivery = async (delivery) => { + if (stopped || rpc.draining) return void (await settle(delivery, { action: 'release', delay: 0 })); + let { body, headers } = delivery; + let unsealed = null; + if (sealing !== null) { + const opened = sealing.open(policy.sealedFor ?? policy.queue, delivery); + if (opened.refused !== undefined) { + rpc.otel.recordBrokerRefusal(system, opened.refused); + // `keys`: a provider that threw — this side's failure, at error + // with the err; the rest is a refusal of the message, at warn. + log[opened.error === undefined ? 'warn' : 'error']({ + ...(opened.error === undefined ? {} : { err: opened.error }), + event: 'broker.refused', + queue: policy.queue, + reason: opened.refused, + id: delivery.id, + attempt: delivery.attempt, + }); + // A key id this service does not hold is what a rotation in progress + // looks like — a fleet where this instance has not received the new + // key yet, or dropped the old one a moment early — and a key provider + // that threw is this side's own blip (it used to dead-letter the + // message on its first failure): both are retried like a 503, to the + // binding's attempts, before they dead-letter. Everything else + // (plaintext where none is accepted, a body that does not open, a + // malformed one) will not change on a retry: dead at once, with the + // reason in the log only. + if (opened.refused === 'kid' || opened.refused === 'keys') { + const decision = decide({ + code: 503, + attempt: delivery.attempt, + retry: policy.retry, + draining: rpc.draining, + }); + const why = opened.refused === 'kid' ? 'unknown key id' : 'the key provider failed'; + return void (await settle(delivery, decision, 503, refusal(`Sealed delivery refused: ${why}`))); + } + return void (await settle(delivery, { action: 'dead', delay: 0 }, 400, refusal('Sealed delivery refused'))); + } + if (opened.sealed) { + body = toText(opened.body); + headers = opened.headers; + unsealed = { body, headers }; + } else if (rpc.encryptionRequired === true) { + // acceptPlaintext let it through the opener; the server's rule is + // stricter than the rollout's, and a plaintext delivery is not + // served — parity with `unsealed`. + log.warn({ event: 'broker.refused', queue: policy.queue, reason: 'plaintext' }); + return void (await settle(delivery, { action: 'dead', delay: 0 }, 400, refusal('Plaintext delivery refused'))); + } + } + let args; + try { + args = policy.args(body, headers, delivery); + } catch (error) { + return void (await settle(delivery, { action: 'dead', delay: 0 }, 400, error, unsealed)); + } + // attach() may refuse (a transport the server will not serve, a bad + // identity): a bounded retry, then dead — never a delivery that settles + // nowhere. + let attached; + try { + attached = clientFor(headers); + } catch (error) { + log.error({ err: error, event: 'broker.attach', queue: policy.queue }); + const decision = decide({ code: 500, attempt: delivery.attempt, retry: policy.retry, draining: rpc.draining }); + return void (await settle(delivery, decision, 500, error, unsealed)); + } + const { client, transport } = attached; + const id = client.generateId(); + const meta = { messageId: delivery.id, attempt: delivery.attempt, queue: policy.queue }; + for (const name of policy.meta) { + const value = headers[name]; + if (value !== undefined) meta[name] = value; + } + const packet = { type: 'call', id, method, args, meta }; + // The W3C header names first — what a producer that is not wrpc, and + // wrpc's publisher, write — then the packet field names a message from + // an earlier publisher carried. `trustRemoteContext: false` still cuts + // either off at the span. + const traceparent = headers.traceparent ?? headers.tp; + const tracestate = headers.tracestate ?? headers.ts; + if (typeof traceparent === 'string') packet[TRACEPARENT] = traceparent; + if (typeof tracestate === 'string') packet[TRACESTATE] = tracestate; + const outcome = transport.expect(id); + handleRpc(client, packet, view).catch((error) => { + // The dispatcher itself threw, not the handler: settled as a 500 so + // the delivery is not stuck, but the throw was going nowhere. The + // settlement below only sees a code. + log.error({ err: error, event: 'broker.dispatch', queue: policy.queue, method, id: delivery.id }); + transport.settle(id, { code: 500, error }); + }); + const { code, error, closed } = await outcome; + try { + const decision = closed + ? { action: 'release', delay: 0 } + : decide({ code, attempt: delivery.attempt, retry: policy.retry, draining: rpc.draining }); + await settle(delivery, decision, code, error, unsealed); + } finally { + // Retired while this delivery was in flight (evicted from the cache): + // closed once its last call has settled — here, after settle(), never + // inside send()/settle(), where close() would destroy the Client + // re-entrantly under the dispatcher that is still answering. + if (attached.retired && transport.pending === 0) transport.close(); + } + }; + + const start = async () => { + consumer = await queue.consume( + policy.queue, + (delivery) => + onDelivery(delivery).catch((error) => { + // A settlement the broker refused: the message comes back on its + // own (redelivery), so this is a log line, not a loss. + log.error({ event: 'broker.settle', queue: policy.queue, err: error }); + }), + { group: policy.group, prefetch: policy.prefetch, deadLetter: policy.deadLetter }, + ); + if (stopped) await consumer.stop(); + }; + + const stop = async () => { + if (stopped) return; + stopped = true; + if (consumer) await consumer.stop(); + if (shared) shared.transport.close(); + for (const attached of tokens.values()) attached.transport.close(); + tokens.clear(); + }; + + return { + start, + stop, + forget, + pause: async () => { + if (consumer && !stopped) await consumer.pause(); + }, + resume: async () => { + if (consumer && !stopped) await consumer.resume(); + }, + get healthy() { + return !stopped && consumer !== null && consumer.healthy; + }, + describe: () => ({ + key: binding.key, + queue: policy.queue, + group: policy.group, + method, + healthy: !stopped && consumer !== null && consumer.healthy, + }), + }; +}; + +module.exports = { attachConsumers, ConsumerTransport }; diff --git a/src/broker/feed.js b/src/broker/feed.js new file mode 100644 index 0000000..e23cc39 --- /dev/null +++ b/src/broker/feed.js @@ -0,0 +1,285 @@ +'use strict'; + +// A durable subscription feed: `procedure.subscription({ handler: brokerFeed(...) })`. +// +// The "broker-backed feed" recipe of docs/guide/rooms.md#delivery, made +// first-class. A broker log guarantees delivery to the SERVER; the last hop +// to a browser still loses whatever was in flight during a reconnect unless +// the server replays from where the client left off — and the broker +// already keeps that position. So every value is `tracked()` with the log's +// resume token, and a re-subscribe carrying `lastEventId` resumes there, on +// whichever instance the client reconnected to. +// +// `lastEventId` is peer-controlled. It is length-capped, syntax-checked by +// the log's own parseId before it reaches the broker, and — with `secret` — +// HMAC-signed, so a client cannot replay a topic from an offset it made up. +// It is a POSITION, never a permission: who may read the topic is the +// procedure's `access` (and the topic resolver's) business. + +const { tracked } = require('../rpc/subscriptions.js'); +const { capabilityOf, brokerName } = require('./port.js'); +const { codedError, signId, openId, toText } = require('./ids.js'); +const { createBrokerSealing } = require('./sealing.js'); + +const DEFAULT_MAX_ID_LENGTH = 512; +// A sealed entry the feed cannot open is logged at warn once per reason per +// this interval, with the running count, and at debug in between: a topic +// holding a thousand entries under a key this service dropped is one line +// per subscriber, not a thousand. +const REFUSAL_INTERVAL = 10_000; +// Resume tokens remembered per feed: a page of catch-up, which is as far +// apart as the readers of one tail drift before they are lagging. +const SIGNED_CAP = 256; + +const DECODERS = Object.freeze({ + json: (text) => JSON.parse(text), + text: (text) => text, +}); + +const isIterable = (value) => + value !== null && + typeof value === 'object' && + (typeof value[Symbol.asyncIterator] === 'function' || typeof value[Symbol.iterator] === 'function'); + +const brokerFeed = (broker, topic, options = {}) => { + const log = capabilityOf(broker, 'log', 'brokerFeed'); + const system = brokerName(log) === 'custom' ? brokerName(broker) : brokerName(log); + if (typeof topic !== 'function' && (typeof topic !== 'string' || topic.length === 0)) { + throw new TypeError('brokerFeed: topic must be a non-empty string or a (context, args) => topic function'); + } + const { + from = 'latest', + decode = 'json', + map = null, + onGap = null, + secret = null, + maxIdLength = DEFAULT_MAX_ID_LENGTH, + encryption = null, + } = options; + // The reading half of a publisher's `encryption`: entries are opened + // before they are decoded, bound to the topic they are read from. No + // replay window — a log is read again, by design. + const sealing = createBrokerSealing(encryption, 'brokerFeed: options', { + layer: 'broker-log', + replay: false, + text: true, + }); + if (from !== 'latest' && from !== 'earliest') { + throw new TypeError("brokerFeed: from must be 'latest' or 'earliest'"); + } + const decodeValue = typeof decode === 'function' ? decode : DECODERS[decode]; + if (typeof decodeValue !== 'function') { + throw new TypeError("brokerFeed: decode must be 'json', 'text' or a function"); + } + if (map !== null && typeof map !== 'function') throw new TypeError('brokerFeed: map must be a function'); + if (onGap !== null && typeof onGap !== 'function') throw new TypeError('brokerFeed: onGap must be a function'); + if (secret !== null && (typeof secret !== 'string' || secret.length === 0)) { + throw new TypeError('brokerFeed: secret must be a non-empty string'); + } + if (!Number.isInteger(maxIdLength) || maxIdLength <= 0) { + throw new TypeError('brokerFeed: maxIdLength must be a positive integer'); + } + + // The resume position a peer-supplied id stands for, or the refusal code. + // `reason` separates the three ways it can be refused, because they mean + // very different things to an operator: `length` and `syntax` are a stale + // or garbled client, while `signature` is a token that was TAMPERED with + // — someone trying to read a topic from an offset they made up. That last + // one was indistinguishable from the other two, and silent. + // A signed id is bound to the topic it was issued for: a token from one + // feed does not position a reader on another that shares the secret. + const position = (lastEventId, name) => { + if (lastEventId === undefined || lastEventId === null) return { after: null, code: null, reason: null }; + if (typeof lastEventId !== 'string' || lastEventId.length > maxIdLength) { + return { after: null, code: 400, reason: 'length' }; + } + const id = secret === null ? lastEventId : openId(secret, lastEventId, name); + if (id === null) return { after: null, code: 400, reason: 'signature' }; + if (log.parseId(id) === null) return { after: null, code: 400, reason: 'syntax' }; + return { after: id, code: null, reason: null }; + }; + + // What does not depend on the subscriber is done once per ENTRY. A topic's + // local subscriptions all read one tail (TopicTails), which hands every + // reader the same entry — and each of them used to open it and sign its + // id again. bench/feed-fanout.js, 1000 subscribers, per reader-entry: + // secret 2.34 -> 0.84 µs, encryption 4.67 -> 0.71 µs (0.2 KB) and + // 5.71 -> 0.74 µs (2 KB), both 6.69 -> 0.91 µs and 7.94 -> 0.88 µs — + // against 0.7 µs for a feed with neither. + + // The resume token: one HMAC per (topic, id), a bounded FIFO. The topic is + // in the key because it is in the MAC. + const signatures = new Map(); + const signed = (name, id) => { + const key = `${name}\0${id}`; + let token = signatures.get(key); + if (token === undefined) { + token = signId(secret, id, name); + if (signatures.size >= SIGNED_CAP) signatures.delete(signatures.keys().next().value); + signatures.set(key, token); + } + return token; + }; + + // The opened entry, remembered by the entry's own `headers` object — the + // one thing a live tail shares between its readers, and gone with the + // entry (a WeakMap). A catch-up page, or a log that copies per reader + // (MemoryBroker), brings objects of its own: a miss, opened as before. + // `value` and `name` are compared because an adapter may hand one constant + // header bag to many entries. Opening is a pure function of (topic, kid, + // body) here — the feed's sealer has no replay window. A refusal is + // remembered too, and REPORTED by the reader that found it: one line and + // one count per entry, not one per subscriber. The opened headers are + // shared the way a plaintext entry's always were. + const openings = new WeakMap(); + const open = (name, raw, refuse) => { + const headers = raw.headers; + const shared = headers !== null && typeof headers === 'object'; + if (shared) { + const hit = openings.get(headers); + if (hit !== undefined && hit.value === raw.value && hit.name === name) return hit.entry; + } + const opened = sealing.open(name, { headers, body: raw.value }); + let entry = raw; + if (opened.refused !== undefined) { + refuse(raw.id, opened.refused, opened.error); + // Skipped like an undecodable entry — and never yielded as it is. + entry = null; + } else if (opened.sealed) { + entry = { headers: opened.headers, value: toText(opened.body) }; + } else { + // Plaintext, accepted: nothing was computed, nothing to remember. + return raw; + } + if (shared) openings.set(headers, { name, value: raw.value, entry }); + return entry; + }; + + return async function* feed(context, args, { lastEventId = null, signal: outer = null } = {}) { + const name = typeof topic === 'function' ? await topic(context, args) : topic; + if (typeof name !== 'string' || name.length === 0) { + throw codedError('brokerFeed: the topic resolver must answer a non-empty string', 500); + } + let { after, code, reason } = position(lastEventId, name); + let resumedFrom = lastEventId; + if (reason !== null) { + // The id itself is peer-controlled text and is NOT logged; its length + // and the reason are what an operator can act on. + const level = reason === 'signature' ? 'warn' : 'debug'; + context?.log?.[level]({ event: 'broker.feed.resume', topic: name, reason, length: lastEventId.length }); + } + // Every read this feed opens is scoped to the feed: a read left behind + // — the gap read a snapshot ran under, a read a refused position ended + // — is aborted when the feed ends, not when the subscriber's own signal + // (which a pump may never fire) lets it go. + const scope = new AbortController(); + const signal = scope.signal; + const onAbort = () => scope.abort(); + outer?.addEventListener('abort', onAbort, { once: true }); + if (outer?.aborted) scope.abort(); + // Refusals of this subscriber's read, counted per reason (see + // REFUSAL_INTERVAL); the metric counts every one. + const refusals = new Map(); + const refuse = (id, reason, error = undefined) => { + context?.otel?.recordBrokerRefusal(system, reason); + const now = Date.now(); + let tally = refusals.get(reason); + if (tally === undefined) { + tally = { count: 0, since: now, logged: 0 }; + refusals.set(reason, tally); + } + tally.count++; + const loud = tally.count === 1 || now - tally.since >= REFUSAL_INTERVAL; + if (loud) { + tally.since = now; + tally.logged = tally.count; + } + // A provider that threw (`keys`, with the err) is this side's failure + // and an error line where a refusal of the entry would be a warn. + const level = !loud ? 'debug' : error === undefined ? 'warn' : 'error'; + context?.log?.[level]({ + ...(error === undefined ? {} : { err: error }), + event: 'broker.feed.refused', + topic: name, + id, + reason, + count: tally.count, + }); + }; + try { + for (;;) { + if (signal.aborted) return; + let read; + if (code !== null) { + if (!onGap) { + throw codedError(code === 400 ? 'Invalid event id' : 'Event history is no longer available', code); + } + // A subscriber that fell behind retention, or one resuming from a + // position this log no longer has: real event loss, answered with a + // snapshot. Worth a line — it is the signal retention is tuned from. + context?.log?.info({ event: 'broker.feed.gap', topic: name, code }); + // Positioned BEFORE the snapshot is built: whatever is appended + // while the application assembles it is read afterwards, not lost. + read = log.read(name, { from: 'latest', signal }); + await read.ready; + const snapshot = await onGap(context, args, { lastEventId: resumedFrom, code }); + if (isIterable(snapshot)) yield* snapshot; + else if (snapshot !== undefined) yield snapshot; + code = null; + } else { + read = log.read(name, after !== null ? { after, signal } : { from, signal }); + } + let delivered = false; + try { + for await (const raw of read) { + delivered = true; + after = raw.id; + let entry = raw; + if (sealing !== null) { + const opened = open(name, raw, refuse); + if (opened === null) continue; + if (opened !== raw) entry = { id: raw.id, headers: opened.headers, value: opened.value }; + } + let value; + try { + value = decodeValue(entry.value); + } catch (error) { + // One undecodable entry must not end every subscriber's feed. + context?.log?.warn({ event: 'broker.feed.decode', topic: name, id: entry.id, err: error }); + continue; + } + if (map !== null) { + value = await map(value, entry, context); + if (value === undefined) continue; + } + yield tracked(secret === null ? entry.id : signed(name, entry.id), value); + } + return; + } catch (error) { + // A position the log refuses — at the start (a stale or forged id) + // or mid-stream (a reader the retention overtook) — becomes a gap + // the application can answer with a snapshot. + const refused = error?.code === 410 || (error?.code === 400 && !delivered); + if (!refused || signal.aborted) throw error; + code = error.code; + // What the client itself holds: its own id at the start, the last + // token this feed handed it mid-stream. + resumedFrom = after === null ? lastEventId : secret === null ? after : signed(name, after); + after = null; + } + } + } finally { + outer?.removeEventListener('abort', onAbort); + scope.abort(); + // What the debug lines held since the last warn, so a feed that ended + // between two intervals still accounts for every refusal. + for (const [reason, tally] of refusals) { + if (tally.count > tally.logged) { + context?.log?.info({ event: 'broker.feed.refused', topic: name, reason, count: tally.count, summary: true }); + } + } + } + }; +}; + +module.exports = { brokerFeed }; diff --git a/src/broker/host.js b/src/broker/host.js new file mode 100644 index 0000000..49678e4 --- /dev/null +++ b/src/broker/host.js @@ -0,0 +1,14 @@ +'use strict'; + +// What every broker binding needs from the server it binds to. + +/** The RpcServer behind a `Server` (its `.rpc`) or an RpcServer itself. */ +const rpcOf = (server, label) => { + const rpc = server?.rpc ?? server; + if (!rpc || typeof rpc.attach !== 'function' || !rpc.router || typeof rpc.router.getProcedure !== 'function') { + throw new TypeError(`${label}: a Server or an RpcServer is required`); + } + return rpc; +}; + +module.exports = { rpcOf }; diff --git a/src/broker/ids.js b/src/broker/ids.js new file mode 100644 index 0000000..3dae361 --- /dev/null +++ b/src/broker/ids.js @@ -0,0 +1,135 @@ +'use strict'; + +// Names and ids at the broker boundary. Two jobs, both security-relevant: +// +// 1. Channel, queue and topic names are application strings (a room is any +// non-empty string), and every broker has its own alphabet. Worse, some +// alphabets have METACHARACTERS: NATS accepts `room:*` as a subject and +// subscribes to every room (tests in the phase-0 spike). encodeToken maps +// an arbitrary name into one safe token, reversibly and injectively. +// +// 2. A subscription's `lastEventId` comes back from the peer. signId/openId +// let a feed refuse ids it never issued (a client asking to replay a +// whole topic from offset 0, or an offset it made up). + +const { createHmac, createHash, timingSafeEqual } = require('node:crypto'); + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +// Coded like the router's errors: `expose` because the message is written +// for the caller. +const codedError = (message, code) => { + const error = new Error(message); + error.code = code; + error.expose = true; + return error; +}; + +const toText = (body) => { + if (typeof body === 'string') return body; + if (body instanceof Uint8Array) return decoder.decode(body); + if (ArrayBuffer.isView(body)) return decoder.decode(new Uint8Array(body.buffer, body.byteOffset, body.byteLength)); + if (body instanceof ArrayBuffer) return decoder.decode(new Uint8Array(body)); + return body === undefined || body === null ? '' : String(body); +}; + +const toBytes = (body) => { + if (body instanceof Uint8Array) return body; + if (typeof body === 'string') return encoder.encode(body); + if (ArrayBuffer.isView(body)) return new Uint8Array(body.buffer, body.byteOffset, body.byteLength); + if (body instanceof ArrayBuffer) return new Uint8Array(body); + return encoder.encode(body === undefined || body === null ? '' : String(body)); +}; + +// Headers normalized to a null-prototype string map: brokers hand back +// Buffers (Kafka), arrays (AMQP tables) or their own header objects, and a +// peer-controlled key like `__proto__` must stay a plain entry. +const toHeaders = (value) => { + const headers = Object.create(null); + if (!value || typeof value !== 'object') return headers; + for (const key of Object.keys(value)) { + const entry = value[key]; + if (entry === undefined || entry === null) continue; + headers[key] = typeof entry === 'string' ? entry : toText(Array.isArray(entry) ? entry[0] : entry); + } + return headers; +}; + +// A dead-letter reason is written into a broker header, and a header is a +// line: NATS refuses a value holding CR or LF outright (nats-core's +// validHeaderValue), so a handler error whose message spanned two lines — a +// validator listing its failures — made deadLetter() reject, the settlement +// never landed, and the message came back forever. One bounded line +// whatever came in: every run of control characters (CR/LF, the separators +// JSON does not escape, NUL) becomes a space. +// eslint-disable-next-line no-control-regex +const CONTROL = /[\x00-\x1f\x7f\u2028\u2029]+/g; +const reasonText = (value, max = 512) => { + const text = (value === undefined || value === null ? '' : String(value)).replace(CONTROL, ' ').trim(); + return text.length > max ? text.slice(0, max) : text; +}; + +const HEX = '0123456789ABCDEF'; + +/** + * Encodes `name` into a single token of the alphabet `safe` (a RegExp that + * tests ONE character), escaping every other UTF-8 byte as `XX`. + * The escape character itself is always escaped, which is what makes the + * mapping injective. Past `maxLength` characters the token is shortened to + * a prefix plus a SHA-256 digest — still injective in practice, and within + * the broker's length limit (AMQP routing keys: 255 bytes, Kafka topics: + * 249 characters). + */ +const encodeToken = (name, { safe = /[A-Za-z0-9_-]/, escape = '~', maxLength = 200 } = {}) => { + const text = String(name); + let out = ''; + for (const char of text) { + if (char !== escape && char.length === 1 && safe.test(char)) { + out += char; + continue; + } + for (const byte of encoder.encode(char)) out += `${escape}${HEX[byte >> 4]}${HEX[byte & 15]}`; + } + if (out.length <= maxLength) return out; + const digest = createHash('sha256') + .update(text) + .digest('base64url') + .replace(/[^A-Za-z0-9]/g, ''); + const keep = Math.max(0, maxLength - digest.length - 2); + return `${out.slice(0, keep)}${escape}${escape}${digest}`.slice(0, maxLength); +}; + +// Signed ids: `!`. The mac is base64url (no `!`), so the split on +// the LAST `!` is unambiguous whatever the id itself contains. +const SIGN_SEPARATOR = '!'; + +// The MAC binds the id to its SCOPE — the topic the feed issued it for — +// under a versioned, length-prefixed layout (injective: no scope/id pair +// can spell another): one secret may serve every feed of a deployment +// without a token issued by one feed positioning a reader on another. +const MAC_PREFIX = 'wrpc feed v1\0'; + +const mac = (secret, id, scope) => + createHmac('sha256', secret).update(`${MAC_PREFIX}${scope.length}:${scope}\0${id}`).digest('base64url').slice(0, 32); + +const signId = (secret, id, scope = '') => `${id}${SIGN_SEPARATOR}${mac(secret, id, scope)}`; + +/** The id inside a signed one, or null when the signature does not verify for `scope`. */ +const openId = (secret, signed, scope = '') => { + if (typeof signed !== 'string') return null; + const at = signed.lastIndexOf(SIGN_SEPARATOR); + if (at <= 0) return null; + const id = signed.slice(0, at); + const given = Buffer.from(signed.slice(at + 1)); + const expected = Buffer.from(mac(secret, id, scope)); + if (given.length !== expected.length || !timingSafeEqual(given, expected)) return null; + return id; +}; + +// A session id is a credential (whoever knows it may speak on the +// session): what a log line or a transport's `source` carries is this +// fingerprint of it — enough to correlate, useless to replay. +const fingerprint = (id) => createHash('sha256').update(String(id)).digest('hex').slice(0, 12); + +module.exports = { codedError, toText, toBytes, toHeaders, reasonText, encodeToken, signId, openId, fingerprint }; diff --git a/src/broker/index.js b/src/broker/index.js new file mode 100644 index 0000000..0cc7835 --- /dev/null +++ b/src/broker/index.js @@ -0,0 +1,67 @@ +'use strict'; + +// @alexify/wrpc/broker — the broker-agnostic core: the capability contracts +// (port.js), the in-process reference broker, and the building blocks an +// adapter for another broker reuses. Every concrete broker lives behind its +// own subpath (@alexify/wrpc/broker/redis, /nats, /amqp, /kafka) and takes +// its client by injection; nothing here requires a broker package. +// +// Experimental: the API may change in a minor release until every adapter +// has shipped (see docs/reference/stability.md). + +const { isBroker, isBrokerLog, isBrokerQueue, isBrokerDirect, isBackplane } = require('./port.js'); +const { MemoryBroker, createMemoryBroker } = require('./memory.js'); +const { TopicTails } = require('./tail.js'); +const { encodeToken, toText, toBytes, toHeaders } = require('./ids.js'); +const { DEFAULT_RETRY } = require('./retry.js'); +const { brokerFeed } = require('./feed.js'); +const { attachConsumers } = require('./consumers.js'); +const { createPublisher } = require('./publisher.js'); +const { attachBrokerRpc } = require('./rpc/server.js'); +const { createBrokerSealing } = require('./sealing.js'); +// Registers WrpcClient.transport.broker as a side effect. +const { ClientBrokerTransport } = require('./rpc/client.js'); + +/** + * Opens one sealed log entry or delivery by hand — a dead letter, a row + * read straight off a topic — under the keyring that sealed it: the same + * `encryption` option the bindings take, `topic` the queue or topic the + * message was sealed for. Answers `{ headers, body, sealed }` with the body + * as text, or `{ refused }` with the reason. + */ +const openSealedMessage = (encryption, { topic, headers, body } = {}) => { + const sealing = createBrokerSealing(encryption, 'openSealedMessage', { + layer: 'broker-log', + replay: false, + text: true, + }); + if (sealing === null) throw new TypeError('openSealedMessage: encryption is required'); + if (typeof topic !== 'string' || topic.length === 0) { + throw new TypeError('openSealedMessage: topic must be the queue or topic the message was sealed for'); + } + const opened = sealing.open(topic, { headers: toHeaders(headers), body }); + if (opened.refused !== undefined) return opened; + return { headers: opened.headers, body: toText(opened.body), sealed: opened.sealed }; +}; + +module.exports = { + brokerFeed, + attachConsumers, + createPublisher, + attachBrokerRpc, + openSealedMessage, + ClientBrokerTransport, + MemoryBroker, + createMemoryBroker, + isBroker, + isBrokerLog, + isBrokerQueue, + isBrokerDirect, + isBackplane, + TopicTails, + encodeToken, + toText, + toBytes, + toHeaders, + DEFAULT_RETRY, +}; diff --git a/src/broker/kafka/index.js b/src/broker/kafka/index.js new file mode 100644 index 0000000..60d2a17 --- /dev/null +++ b/src/broker/kafka/index.js @@ -0,0 +1,862 @@ +'use strict'; + +// The Kafka broker: log and queue over an INJECTED KafkaJS-shaped client, +// plus a backplane with costs the guide spells out. There is deliberately +// NO `direct` capability — consumer-group rebalances and a topic per +// instance make Kafka a poor carrier for request/response, and refusing is +// better than a binding that limps. +// +// const { Kafka } = require('@confluentinc/kafka-javascript').KafkaJS; +// const broker = createKafkaBroker({ kafka: new Kafka({ kafkaJS: { brokers } }) }); +// +// Capability map: +// backplane one topic, the channel in a header, a UNIQUE consumer group +// per instance (so every instance sees everything) — caveated +// log a topic per log; the resume token is a VECTOR of partition +// offsets (`k1:

      =,…`), which is why the id a read yields +// is the advanced cursor rather than one message's offset +// queue a topic per queue, one consumer group, manual commits; a +// retry is a republish carrying `x-wrpc-attempt` (Kafka has no +// nack), and an exhausted message goes to a dead-letter topic + +const { createLoggerWriter } = require('../../logging.js'); +const { backoffDelay } = require('../../utils.js'); +const { TopicTails } = require('../tail.js'); +const { codedError, toText, toHeaders, reasonText, encodeToken } = require('../ids.js'); +const { crashDelay } = require('../retry.js'); +const { + ATTEMPT_HEADER, + REDELIVERED_HEADER, + DEAD_REASON_HEADER, + DEFAULT_PREFETCH, + idFactory, + failedRead, + guardedRead, + checkConsume, +} = require('../adapter.js'); +const { + detectFlavor, + consumerConfig, + producerConfig, + subscribeArgs, + runArgs, + metadataTopics, + joinWatcher, + healthWatcher, +} = require('./shape.js'); + +const DEFAULT_PREFIX = 'wrpc'; +const DEFAULT_PARTITIONS = 3; +const DEFAULT_MAX_RETRY_DELAY = 60_000; +const DEFAULT_MAX_CATCH_UP = 4; +// A settlement the broker refused (a leader election, a producer that +// cannot reach it) is tried again on this schedule before the consumer +// gives the message back; and a retry's in-process delay is cut into +// steps of this length, each ending in a heartbeat, so a long wait does +// not look like a dead member to the group coordinator. +const SETTLE_ATTEMPTS = 3; +const SETTLE_BACKOFF = Object.freeze({ minDelay: 100, maxDelay: 1000, factor: 2, jitter: false }); +const HEARTBEAT_STEP = 3000; +const CHANNEL_HEADER = 'wrpc-channel'; +const VECTOR = /^k1:(\d{1,5}=\d{1,19})(,\d{1,5}=\d{1,19})*$/; + +const isFunction = (value) => typeof value === 'function'; + +// A Kafka topic name: letters, digits, dot, underscore and dash only. +const topicName = (prefix, kind, value) => + `${prefix}.${kind}.${encodeToken(value, { safe: /[A-Za-z0-9_-]/, escape: '_', maxLength: 120 })}`; + +// The resume token: every partition this reader has passed, sorted. +const encodeVector = (cursor) => { + const parts = Object.keys(cursor) + .map(Number) + .sort((a, b) => a - b) + .map((partition) => `${partition}=${cursor[partition]}`); + return `k1:${parts.join(',')}`; +}; + +const decodeVector = (text) => { + if (typeof text !== 'string' || !VECTOR.test(text)) return null; + const cursor = {}; + for (const pair of text.slice(3).split(',')) { + const [partition, offset] = pair.split('='); + cursor[Number(partition)] = Number(offset); + } + return cursor; +}; + +const headersOf = (message) => { + const bag = {}; + for (const [key, value] of Object.entries(message.headers ?? {})) { + if (value === undefined || value === null) continue; + bag[key] = Buffer.isBuffer(value) ? value.toString() : String(value); + } + return toHeaders(bag); +}; + +const createKafkaBroker = (options = {}) => { + const { + kafka, + flavor: flavorOption = null, + prefix = DEFAULT_PREFIX, + logger = globalThis.console, + partitions = DEFAULT_PARTITIONS, + // A log's order is per PARTITION, so a feed topic is single-partition + // unless the application says otherwise — a durable feed that reordered + // itself under load would be a subtle, permanent bug. + logPartitions = 1, + // -1 is the broker's own `default.replication.factor` (KIP-464, Kafka + // 2.4+): a topic the adapter creates is as replicated as the cluster + // says, not a single-replica one because a library said 1. + replicationFactor = -1, + backplane: backplaneOptions = {}, + maxRetryDelay = DEFAULT_MAX_RETRY_DELAY, + // Catch-up pages read at once. Each is a consumer group of its own (the + // KafkaJS shape has no manual assignment): a connection, a JoinGroup, a + // rebalance of nobody — seconds of a coordinator's time. A resume storm + // after a deploy used to open hundreds of them together. + maxCatchUp = DEFAULT_MAX_CATCH_UP, + generateId = null, + } = options; + // Ids: an injected generator is used verbatim — see idFactory. + const { shortName } = idFactory(generateId, 'createKafkaBroker'); + if (!kafka || !isFunction(kafka.producer) || !isFunction(kafka.consumer) || !isFunction(kafka.admin)) { + throw new TypeError('createKafkaBroker: options.kafka must be a KafkaJS-shaped client (producer/consumer/admin)'); + } + if (flavorOption !== null && flavorOption !== 'kafkajs' && flavorOption !== 'confluent') { + throw new TypeError("createKafkaBroker: options.flavor must be 'kafkajs', 'confluent' or omitted"); + } + if (!Number.isInteger(partitions) || partitions <= 0) { + throw new TypeError('createKafkaBroker: options.partitions must be a positive integer'); + } + if (!Number.isInteger(logPartitions) || logPartitions <= 0) { + throw new TypeError('createKafkaBroker: options.logPartitions must be a positive integer'); + } + // -1 is the broker's own `default.replication.factor` (KIP-464); a string + // from the environment would be sent to createTopics as it is. + if (!Number.isInteger(replicationFactor) || (replicationFactor !== -1 && replicationFactor < 1)) { + throw new TypeError( + 'createKafkaBroker: options.replicationFactor must be a positive integer, or -1 for the broker default', + ); + } + if (!Number.isInteger(maxCatchUp) || maxCatchUp <= 0) { + throw new TypeError('createKafkaBroker: options.maxCatchUp must be a positive integer'); + } + if (!Number.isInteger(maxRetryDelay) || maxRetryDelay < 0) { + throw new TypeError('createKafkaBroker: options.maxRetryDelay must be a non-negative integer of milliseconds'); + } + const flavor = flavorOption ?? detectFlavor(kafka); + const log = createLoggerWriter(logger).child({ component: 'broker', broker: 'kafka' }); + const report = (event, error, extra = {}) => log.error({ err: error, event, ...extra }); + let closed = false; + const consumers = new Set(); + // The groups this broker made for ITSELF — a backplane instance's, a + // reader's — by the consumer that holds them: deleted the moment that + // consumer is closed (an empty group otherwise lingers until + // offsets.retention), and swept in one call on close(). A queue's durable + // group is never among them: its committed offsets ARE the queue's + // progress, and deleting it on the last instance's clean restart used to + // redeliver the whole retention. + const groups = new Map(); // consumer -> groupId, ephemeral groups only + + let adminClient = null; + const admin = () => { + if (!adminClient) { + adminClient = (async () => { + const client = kafka.admin(); + await client.connect(); + return client; + })(); + adminClient.catch(() => { + adminClient = null; + }); + } + return adminClient; + }; + + let producerClient = null; + const producer = () => { + if (!producerClient) { + producerClient = (async () => { + const client = kafka.producer(producerConfig(flavor, { acks: -1 })); + await client.connect(); + return client; + })(); + producerClient.catch(() => { + producerClient = null; + }); + } + return producerClient; + }; + + const ensured = new Map(); + const ensureTopic = (topic, numPartitions = partitions) => { + let pending = ensured.get(topic); + if (pending) return pending; + pending = (async () => { + const client = await admin(); + let created = false; + try { + // kafkajs answers false for a topic that already exists, the + // confluent facade throws: both are "already there". + created = (await client.createTopics({ topics: [{ topic, numPartitions, replicationFactor }] })) !== false; + } catch (error) { + // Already there (another instance, or a previous run). + if (!/already exists|TOPIC_ALREADY_EXISTS/i.test(String(error?.message))) throw error; + } + if (created) { + // Once per topic, at creation: what the cluster now holds is the + // adapter's doing, and an operator sizing replication wants to know. + log.info({ event: 'broker.kafka.topic', topic, partitions: numPartitions, replicationFactor }); + return topic; + } + // An existing topic keeps the partition count — and the replication + // factor — it was created with, which is worth saying out loud for a + // FEED: more partitions than the adapter would have made means its + // order is per partition, not global. + try { + const metadata = metadataTopics(await client.fetchTopicMetadata({ topics: [topic] })); + const actual = metadata[0]?.partitions?.length; + if (actual !== undefined && actual !== numPartitions) { + log.debug({ event: 'broker.kafka.partitions', topic, expected: numPartitions, actual }); + } + } catch (error) { + log.debug({ event: 'broker.kafka.metadata', topic, err: error }); + } + return topic; + })(); + ensured.set(topic, pending); + pending.catch(() => ensured.delete(topic)); + return pending; + }; + + const send = async (topic, value, { headers = null, key = null } = {}) => { + const client = await producer(); + const message = { value: toText(value) }; + if (headers) message.headers = toHeaders(headers); + if (key !== null && key !== undefined) message.key = String(key); + const [result] = await client.send({ topic, messages: [message] }); + return result; + }; + + // A consumer that this broker owns: tracked so close() disconnects it. + // `ephemeral` says the group is this broker's own (deleted with the + // consumer); a queue's durable group is opened without it. + const openConsumer = async (groupId, config = {}, { ephemeral = false } = {}) => { + const consumer = kafka.consumer(consumerConfig(flavor, { groupId, ...config })); + await consumer.connect(); + consumers.add(consumer); + if (ephemeral) groups.set(consumer, groupId); + return consumer; + }; + + // A group the broker cannot delete yet (a member still joined — the + // broker's NON_EMPTY_GROUP — or an admin that cannot be asked) lingers + // until offsets.retention, as every group used to: a debug line, no more. + const dropGroups = async (ids) => { + if (ids.length === 0) return; + try { + const client = await admin(); + await client.deleteGroups(ids); + } catch (error) { + log.debug({ err: error, event: 'broker.kafka.groups', groups: ids }); + } + }; + + const closeConsumer = async (consumer) => { + consumers.delete(consumer); + try { + await consumer.disconnect(); + } catch (error) { + report('broker.kafka.disconnect', error); + } + // Its group goes with it, right away — a reader's is done the moment + // its page or tail is, and used to wait for close(). During close() + // itself the sweep below deletes what is left in ONE call. + const groupId = groups.get(consumer); + if (groupId !== undefined && !closed) { + groups.delete(consumer); + void dropGroups([groupId]); + } + }; + + // --------------------------------------------------------------------- + // backplane + + const backplaneTopic = backplaneOptions.topic ?? `${prefix}.backplane`; + // Publishes are CHAINED: a backplane's envelopes carry a per-channel + // sequence, and two sends in flight at once on a non-idempotent producer + // can land out of order — which the receiver would report as a gap. + let publishChain = Promise.resolve(); + const backplanePartitions = backplaneOptions.partitions ?? 1; + const handlers = new Map(); // channel -> Set + let backplaneConsumer = null; + + const startBackplane = async () => { + if (backplaneConsumer) return backplaneConsumer; + backplaneConsumer = (async () => { + await ensureTopic(backplaneTopic, backplanePartitions); + // A group per INSTANCE: every instance must see every envelope, which + // is exactly what a shared group would prevent. + const consumer = await openConsumer(`${prefix}-bp-${shortName()}`, { fromBeginning: false }, { ephemeral: true }); + await consumer.subscribe(subscribeArgs(flavor, [backplaneTopic], false)); + // Registered BEFORE run(): kafkajs' join event fires once. + const joined = joinWatcher(flavor, consumer); + await consumer.run( + runArgs(flavor, { + concurrency: 1, + eachMessage: async ({ message }) => { + const channel = headersOf(message)[CHANNEL_HEADER]; + const set = channel === undefined ? null : handlers.get(channel); + if (!set) return; + const text = message.value === null ? '' : message.value.toString(); + for (const handler of Array.from(set)) { + try { + handler(text); + } catch (error) { + report('broker.kafka.handler', error, { channel }); + } + } + }, + }), + ); + // Only now is this instance actually listening. + await joined; + return consumer; + })(); + backplaneConsumer.catch(() => { + backplaneConsumer = null; + }); + return backplaneConsumer; + }; + + const backplane = { + name: 'kafka', + publish(channel, message) { + if (closed) return; + publishChain = publishChain + .then(() => ensureTopic(backplaneTopic, backplanePartitions)) + .then(() => send(backplaneTopic, message, { headers: { [CHANNEL_HEADER]: channel } })) + .catch((error) => report('broker.kafka.publish', error, { channel })); + }, + subscribe(channel, handler) { + if (!isFunction(handler)) throw new TypeError('kafka backplane.subscribe: handler must be a function'); + let set = handlers.get(channel); + if (!set) { + set = new Set(); + handlers.set(channel, set); + } + set.add(handler); + // Resolved once the group is JOINED: a publish after this is seen. + return startBackplane().then(() => async () => { + const current = handlers.get(channel); + if (!current || !current.delete(handler)) return; + if (current.size === 0) handlers.delete(channel); + }); + }, + close() { + handlers.clear(); + }, + }; + + // --------------------------------------------------------------------- + // log + + const logTopic = (topic) => topicName(prefix, 'log', topic); + + const watermarks = async (topic) => { + const client = await admin(); + const rows = await client.fetchTopicOffsets(logTopic(topic)); + const low = {}; + const high = {}; + for (const row of rows) { + low[row.partition] = Number(row.low); + high[row.partition] = Number(row.high); + } + return { low, high }; + }; + + const readerGroup = () => `${prefix}-read-${shortName()}`; + + // A seek right after a join can still land before the group is + // initialized; the readers filter by offset anyway, so a failure here is + // a slower read, never a wrong one. + const seekAll = async (consumer, topic, offsets) => { + for (const [partition, offset] of Object.entries(offsets)) { + for (let attempt = 0; attempt < 5; attempt++) { + try { + consumer.seek({ topic, partition: Number(partition), offset: String(offset) }); + break; + } catch (error) { + if (attempt === 4) log.debug({ event: 'broker.kafka.seek', err: error, topic }); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } + } + }; + + const tails = new TopicTails({ + // Readers that fell behind the tail and are catching up through range(): + // info, not a fault — but the first thing to look at when a feed is slow. + onLag: (topic, readers) => log.info({ event: 'broker.tail.lag', topic, readers }), + live: async (topic, { signal, onEntry, onEnd }) => { + const name = await ensureTopic(logTopic(topic), logPartitions); + const { high } = await watermarks(topic); + const consumer = await openConsumer(readerGroup(), { fromBeginning: false }, { ephemeral: true }); + // A crash of the tail's consumer is the end of the tail, whether or + // not kafkajs restarts it: its group is ephemeral and commits + // nothing, so a restarted consumer resolves `latest` afresh and + // whatever was appended meanwhile is gone from it. The readers move + // to a fresh tail and catch up from their own cursors instead. + let unwatch = () => {}; + unwatch = healthWatcher(flavor, consumer, { + onDown: (error) => { + unwatch(); + report('broker.kafka.tail', error ?? new Error('consumer crashed'), { topic }); + void closeConsumer(consumer); + onEnd(error ?? new Error('consumer crashed')); + }, + onUp: () => {}, + }); + // Everything after the open under one catch: a subscribe or run that + // fails used to leave the consumer connected and its group behind — + // one per failed read, for the life of the broker. + try { + await consumer.subscribe(subscribeArgs(flavor, [name], false)); + const joined = joinWatcher(flavor, consumer); + await consumer.run( + runArgs(flavor, { + concurrency: 1, + eachMessage: async ({ partition, message }) => { + // Defensive: a reader positioned by the group rather than by the + // seek above could see what the tip already covered. + if (Number(message.offset) < (high[partition] ?? 0)) return; + onEntry({ + partition, + offset: Number(message.offset), + value: message.value === null ? '' : message.value.toString(), + headers: headersOf(message), + }); + }, + }), + ); + await joined; + // A fresh group resolves `latest` at its FIRST FETCH, which can be + // after the next append — so the position is pinned to the watermark + // captured above instead of left to that race. + await seekAll(consumer, name, high); + } catch (error) { + unwatch(); + await closeConsumer(consumer); + throw error; + } + signal.addEventListener( + 'abort', + () => { + unwatch(); + void closeConsumer(consumer); + }, + { once: true }, + ); + // The tip as a vector: every partition's next offset minus one. + const cursor = {}; + let any = false; + for (const [partition, offset] of Object.entries(high)) { + cursor[Number(partition)] = offset - 1; + if (offset > 0) any = true; + } + return any ? cursor : null; + }, + // Two bounds on what a resume storm asks of the cluster. Identical pages + // — the same topic, cursor and limit, which is what a room of clients + // that all lost the same instance ask for — are ONE read, shared (the + // tail only reads a page, so nobody needs a copy). And no more than + // `maxCatchUp` pages are read at once; the rest wait their turn, first + // come first served. + range: (topic, { after, limit }) => { + const key = `${topic}\0${after === null || after === undefined ? '' : encodeVector(after)}\0${limit}`; + let page = pages.get(key); + if (page === undefined) { + page = catchUp(() => readPage(topic, after, limit)).finally(() => pages.delete(key)); + pages.set(key, page); + } + return page; + }, + covered: (cursor, entry) => entry.offset <= (cursor?.[entry.partition] ?? -1), + advance: (cursor, entry) => ({ ...(cursor ?? {}), [entry.partition]: entry.offset }), + }); + + // Pages in flight, by what they read; and the turnstile in front of them. + const pages = new Map(); + let catching = 0; + const waiting = []; + const catchUp = async (read) => { + if (catching < maxCatchUp) catching++; + else await new Promise((resolve) => waiting.push(resolve)); + try { + // Woken by close(): nothing is opened on a broker that is gone. + if (closed) throw codedError('Broker is closed', 503); + return await read(); + } finally { + // The slot goes straight to the next in line, whatever this page did + // — a page that failed must not strand the ones behind it. + const next = waiting.shift(); + if (next === undefined) catching--; + else next(); + } + }; + + const readPage = async (topic, after, limit) => { + const name = await ensureTopic(logTopic(topic), logPartitions); + const { low, high } = await watermarks(topic); + // Per partition: where the page starts (never below the low + // watermark — what retention took cannot be waited for) and the + // offset it must reach to be complete. + const wanted = {}; + const target = {}; + let pending = 0; + for (const [key, tip] of Object.entries(high)) { + const partition = Number(key); + const asked = after === null || after === undefined ? 0 : (after[partition] ?? -1) + 1; + const from = Math.max(asked, low[partition] ?? 0); + if (from < tip) { + wanted[partition] = from; + target[partition] = tip - 1; + pending += tip - from; + } + } + if (pending === 0) return { entries: [], done: true }; + const entries = []; + const consumer = await openConsumer(readerGroup(), { fromBeginning: true }, { ephemeral: true }); + let timer = null; + // Closed whatever happens — a subscribe that fails, a run that + // rejects, the page that completes: a page's consumer and group are + // done with the page, and used to outlive a failure. + try { + await consumer.subscribe(subscribeArgs(flavor, [name], true)); + const joined = joinWatcher(flavor, consumer); + await new Promise((resolve, reject) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + resolve(); + }; + consumer + .run( + runArgs(flavor, { + concurrency: 1, + eachMessage: async ({ partition, message }) => { + const offset = Number(message.offset); + const from = wanted[partition]; + if (from === undefined || offset < from) return; + entries.push({ + partition, + offset, + value: message.value === null ? '' : message.value.toString(), + headers: headersOf(message), + }); + if (entries.length >= limit || entries.length >= pending) finish(); + }, + }), + ) + .then(async () => { + // The page's window opens once the group is joined and the + // seek landed: a window that opened before the join — a slow + // rebalance eats seconds — closed on a page that had not + // started, and the short page was taken for the tip. + await joined; + await seekAll(consumer, name, wanted); + timer = setTimeout(finish, 10_000); + if (isFunction(timer.unref)) timer.unref(); + }) + .catch(reject); + }); + } finally { + clearTimeout(timer); + await closeConsumer(consumer); + } + entries.sort((a, b) => (a.partition === b.partition ? a.offset - b.offset : a.partition - b.partition)); + const page = entries.slice(0, limit); + // Complete when every wanted partition reached its tip WITHIN the + // page — what was fetched beyond `limit` is not handed over. + const reached = {}; + for (const entry of page) reached[entry.partition] = entry.offset; + const done = Object.keys(target).every((partition) => (reached[partition] ?? -1) >= target[partition]); + return { entries: page, done }; + }; + + const parseId = (text) => (decodeVector(text) === null ? null : text); + + // A cursor is a vector of partition offsets inside and its text outside. + const encodeIds = (inner, guard) => guardedRead(inner, { guard, mapId: encodeVector }); + + const read = (topic, options = {}) => { + const { after = null, from = 'latest', signal = null } = options; + if (from !== 'latest' && from !== 'earliest') { + throw new TypeError("kafka log.read: from must be 'latest' or 'earliest'"); + } + if (after === null || after === undefined) { + return encodeIds(tails.read(topic, from === 'earliest' ? { after: {}, signal } : { from, signal }), null); + } + const cursor = decodeVector(after); + if (cursor === null) return failedRead(codedError('Malformed event id', 400)); + const guard = (async () => { + const { low, high } = await watermarks(topic); + for (const [partition, offset] of Object.entries(cursor)) { + const tip = high[Number(partition)]; + if (tip === undefined || offset + 1 > tip) throw codedError('Event id is beyond the end of the log', 400); + if (offset + 1 < (low[Number(partition)] ?? 0)) { + throw codedError('Event history was trimmed past this id', 410); + } + } + })(); + guard.catch(() => {}); + return encodeIds(tails.read(topic, { after: cursor, signal }), guard); + }; + + const append = async (topic, value, { headers = null, key = null } = {}) => { + if (closed) throw codedError('Broker is closed', 503); + const name = await ensureTopic(logTopic(topic), logPartitions); + const result = await send(name, value, { headers, key }); + const offset = Number(result.baseOffset ?? result.offset ?? 0); + return encodeVector({ [result.partition]: offset }); + }; + + // --------------------------------------------------------------------- + // queue + + const queueTopic = (queue) => topicName(prefix, 'q', queue); + + const produce = async (queue, body, { headers = null, key = null } = {}) => { + if (closed) throw codedError('Broker is closed', 503); + const topic = await ensureTopic(queueTopic(queue)); + await send(topic, body, { headers, key }); + }; + + const consume = async (queue, onDelivery, options = {}) => { + if (closed) throw codedError('Broker is closed', 503); + const { group = queue, prefetch = DEFAULT_PREFETCH, deadLetter = null, signal = null } = options; + checkConsume('kafka queue.consume', onDelivery, prefetch, deadLetter); + const topic = await ensureTopic(queueTopic(queue)); + if (deadLetter) await ensureTopic(queueTopic(deadLetter)); + const groupId = encodeToken(group, { safe: /[A-Za-z0-9_-]/, escape: '_', maxLength: 120 }); + const consumer = await openConsumer(groupId, { fromBeginning: true }); + await consumer.subscribe(subscribeArgs(flavor, [topic], true)); + const state = { running: true, paused: false, healthy: true }; + // `healthy` used to be true from the first run() to stop(), whatever the + // consumer went through: a crashed kafkajs consumer (a broker gone, a + // rebalance that failed) reported a binding that consumed nothing as + // fine, and readiness kept the instance in rotation. The watcher is + // registered BEFORE run(), like the join watcher — kafkajs' events are + // missed by a listener that comes after. + const unwatch = healthWatcher(flavor, consumer, { + onDown: (error, restart) => { + state.healthy = false; + report('broker.kafka.crash', error ?? new Error('consumer crashed'), { queue, restart: restart === true }); + }, + onUp: () => { + if (state.running) state.healthy = true; + }, + }); + + const commit = (partition, offset) => + consumer.commitOffsets([{ topic, partition, offset: String(Number(offset) + 1) }]); + + const handle = async ({ partition, message, heartbeat }) => { + if (!state.running) return; + const headers = headersOf(message); + const attempt = Number(headers[ATTEMPT_HEADER] ?? '1') || 1; + const body = message.value === null ? '' : message.value.toString(); + const id = `${partition}:${message.offset}`; + let settled = false; + // A settlement the broker refused used to be swallowed after one log + // line, and the loop went on: the next message's commit moved the + // group's offset past this one, and it was gone. Now the settlement + // is tried again (SETTLE_ATTEMPTS), and when it still fails the + // consumer seeks BACK to this offset — the message is fetched and + // handed over again, attempt unchanged, and nothing commits past it. + // A heartbeat that failed mid-wait means the group is rebalancing: + // this member no longer owns the partition, the message will be + // fetched by whoever does, and neither a republish nor a commit is + // this member's to make. + const finish = async (action, work) => { + if (settled) return; + settled = true; + for (let round = 0; ; round++) { + try { + await work(); + state.healthy = true; + return; + } catch (error) { + if (error?.rebalanced === true) { + log.info({ event: 'broker.kafka.rebalanced', queue, id, action }); + return; + } + if (round < SETTLE_ATTEMPTS - 1 && state.running) { + await new Promise((resolve) => setTimeout(resolve, backoffDelay({ ...SETTLE_BACKOFF, attempt: round }))); + continue; + } + state.healthy = false; + report('broker.kafka.settle', error, { queue, id, action, attempt, partition }); + try { + consumer.seek({ topic, partition, offset: String(message.offset) }); + } catch (seekError) { + report('broker.kafka.seek', seekError, { queue, id }); + } + return; + } + } + }; + const beat = async () => { + if (!isFunction(heartbeat)) return; + try { + await heartbeat(); + } catch (error) { + throw Object.assign(error, { rebalanced: true }); + } + }; + const republish = async (extra, delay = 0) => { + // Kafka has no nack and no server-side delay: a retry is a new + // message, and the wait happens here. The ORIGINAL stays uncommitted + // until the copy is written, so a crash mid-wait redelivers rather + // than loses. The wait is cut into heartbeat-sized steps: a member + // silent for the whole delay looks dead to the coordinator. + let remaining = Math.min(delay, maxRetryDelay); + while (remaining > 0) { + const step = Math.min(remaining, HEARTBEAT_STEP); + await new Promise((resolve) => setTimeout(resolve, step)); + remaining -= step; + await beat(); + } + await send(topic, body, { headers: { ...headers, ...extra, [REDELIVERED_HEADER]: '1' } }); + }; + const delivery = Object.freeze({ + id, + body, + headers, + attempt, + redelivered: headers[REDELIVERED_HEADER] === '1', + ack: () => finish('ack', () => commit(partition, message.offset)), + retry: ({ delay = 0 } = {}) => + finish('retry', async () => { + await republish({ [ATTEMPT_HEADER]: String(attempt + 1) }, delay); + await commit(partition, message.offset); + }), + release: () => + finish('release', async () => { + await republish({ [ATTEMPT_HEADER]: String(attempt) }); + await commit(partition, message.offset); + }), + deadLetter: (reason = '') => + finish('dead', async () => { + if (deadLetter) { + await send(queueTopic(deadLetter), body, { + headers: { + ...headers, + [DEAD_REASON_HEADER]: reasonText(reason), + [ATTEMPT_HEADER]: String(attempt), + }, + }); + } + await commit(partition, message.offset); + }), + }); + // eachMessage is sequential per partition: the handler is awaited so + // Kafka's own ordering guarantee is not broken. Concurrency comes from + // partitions (partitionsConsumedConcurrently). + try { + await onDelivery(delivery); + } catch (error) { + // Settled nothing: retried after a backoff, attempt + 1 — the + // delivery contract (port.js), not a release to the head. + report('broker.kafka.delivery', error, { queue }); + await delivery.retry({ delay: crashDelay(attempt) }); + } + }; + + const ready = joinWatcher(flavor, consumer); + await consumer.run(runArgs(flavor, { concurrency: prefetch, eachMessage: handle })); + // A join that never came within the window is NOT a failure: a group + // with more members than partitions leaves some with nothing, and this + // consumer is one of them until a rebalance says otherwise. Said once, + // and the binding stays healthy — a readiness that flipped here would + // crash-loop the fourth instance of a three-partition queue. + if (!(await ready)) log.info({ event: 'broker.kafka.join-timeout', queue, group: groupId }); + + const stop = async () => { + if (!state.running) return; + state.running = false; + unwatch(); + await closeConsumer(consumer); + }; + if (signal) signal.addEventListener('abort', () => void stop(), { once: true }); + return { + stop, + pause: async () => { + state.paused = true; + try { + consumer.pause([{ topic }]); + } catch (error) { + report('broker.kafka.pause', error, { queue }); + } + }, + resume: async () => { + state.paused = false; + try { + consumer.resume([{ topic }]); + } catch (error) { + report('broker.kafka.resume', error, { queue }); + } + }, + get healthy() { + return state.running && state.healthy && !closed; + }, + }; + }; + + // --------------------------------------------------------------------- + + const close = async () => { + if (closed) return; + closed = true; + tails.close(); + // Pages waiting for their turn are let through, to fail as closed. + for (const next of waiting.splice(0)) next(); + handlers.clear(); + ensured.clear(); + for (const consumer of Array.from(consumers)) await closeConsumer(consumer); + // The ephemeral groups still held — the backplane instance's, a tail's + // — in one call, once every member left; a queue's durable group is not + // this broker's to delete. + const lingering = Array.from(groups.values()); + groups.clear(); + if (producerClient) { + const client = await producerClient.catch(() => null); + producerClient = null; + if (client) await client.disconnect().catch((error) => report('broker.kafka.disconnect', error)); + } + if (adminClient) { + const client = await adminClient.catch(() => null); + adminClient = null; + if (client) { + if (lingering.length > 0) { + await client + .deleteGroups(lingering) + .catch((error) => log.debug({ err: error, event: 'broker.kafka.groups', groups: lingering })); + } + await client.disconnect().catch((error) => report('broker.kafka.disconnect', error)); + } + } + }; + + return { + name: 'kafka', + backplane, + log: Object.freeze({ name: 'kafka', append, read, parseId }), + queue: Object.freeze({ name: 'kafka', produce, consume }), + close, + }; +}; + +module.exports = { createKafkaBroker, encodeVector, decodeVector }; diff --git a/src/broker/kafka/shape.js b/src/broker/kafka/shape.js new file mode 100644 index 0000000..62ccdfd --- /dev/null +++ b/src/broker/kafka/shape.js @@ -0,0 +1,135 @@ +'use strict'; + +// The two KafkaJS-shaped clients differ in CONFIG, not in method names, and +// the differences are exactly the ones the phase-0 spike measured: +// +// kafkajs (2.2.4, no release since 2023-02-27 — not deprecated, but +// inactive): `new Kafka({ brokers })`, `kafka.consumer({ groupId })`, +// `fromBeginning` on subscribe(), `autoCommit` on run(), a GROUP_JOIN +// event, `fetchTopicMetadata` -> { topics }. +// @confluentinc/kafka-javascript (.KafkaJS, librdkafka, actively +// released): every KafkaJS option nests under `kafkaJS`, the join +// signal is polling `consumer.assignment()`, `consumer.events` THROWS +// "Not implemented", `fetchTopicMetadata` -> a bare array. +// +// One module normalizes both so the adapter never branches. + +const isFunction = (value) => typeof value === 'function'; + +/** kafkajs' client exposes `logger()`; the confluent facade does not. */ +const detectFlavor = (kafka) => (isFunction(kafka.logger) ? 'kafkajs' : 'confluent'); + +const consumerConfig = (flavor, { groupId, fromBeginning = false, autoCommit = false, ...rest }) => + flavor === 'confluent' ? { kafkaJS: { groupId, fromBeginning, autoCommit, ...rest } } : { groupId, ...rest }; + +const producerConfig = (flavor, options = {}) => + flavor === 'confluent' ? { kafkaJS: { ...options } } : { ...options }; + +const subscribeArgs = (flavor, topics, fromBeginning) => + flavor === 'confluent' ? { topics } : { topics, fromBeginning }; + +const runArgs = (flavor, { eachMessage, concurrency }) => { + const args = { eachMessage }; + if (concurrency > 1) args.partitionsConsumedConcurrently = concurrency; + if (flavor !== 'confluent') args.autoCommit = false; + return args; +}; + +/** kafkajs answers `{ topics: [...] }`, confluent the bare array. */ +const metadataTopics = (result) => (Array.isArray(result) ? result : (result?.topics ?? [])); + +/** + * Watches for this consumer JOINING its group — the moment from which its + * subscription is live. It must be called BEFORE run(): kafkajs' GROUP_JOIN + * fires once, and a listener registered afterwards never sees it. + * + * A fresh group reading `latest` loses everything published before the + * join, which is what makes the Kafka backplane's caveat real (see the + * guide). + */ +const eventsOf = (flavor, consumer) => { + if (flavor === 'confluent') return null; + try { + return consumer.events ?? null; + } catch { + return null; // the confluent facade throws from this getter + } +}; + +// kafkajs' `consumer.on` answers a remover; an EventEmitter-shaped client +// answers itself and has `off`. +const listen = (consumer, event, handler) => { + const remove = consumer.on(event, handler); + return () => { + if (isFunction(remove)) remove(); + else if (isFunction(consumer.off)) consumer.off(event, handler); + }; +}; + +/** + * Resolves `true` once the consumer JOINED its group, `false` when the + * timeout passed first — an empty assignment is a legitimate state (more + * instances than partitions), which the caller decides how to read. + */ +const joinWatcher = (flavor, consumer, { timeout = 30_000, step = 20 } = {}) => { + const events = eventsOf(flavor, consumer); + if (events?.GROUP_JOIN && isFunction(consumer.on)) { + return new Promise((resolve) => { + const stop = listen(consumer, events.GROUP_JOIN, () => { + stop(); + clearTimeout(timer); + resolve(true); + }); + const timer = setTimeout(() => { + stop(); + resolve(false); + }, timeout); + if (isFunction(timer.unref)) timer.unref(); + }); + } + // The confluent facade has no join event: its assignment is the signal. + return (async () => { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + try { + if (isFunction(consumer.assignment) && consumer.assignment().length > 0) return true; + } catch { + // Not implemented on this client. + } + await new Promise((resolve) => setTimeout(resolve, step)); + } + return false; + })(); +}; + +/** + * Watches a consumer's health after run(): kafkajs announces a CRASH (with + * `restart` when it will rejoin by itself) and a GROUP_JOIN when it is + * back. The confluent facade has neither — `consumer.events` throws — so + * there `healthy` cannot follow a crash, which the guide says. Answers a + * `stop()`. + */ +const healthWatcher = (flavor, consumer, { onDown, onUp }) => { + const events = eventsOf(flavor, consumer); + if (!events?.CRASH || !isFunction(consumer.on)) return () => {}; + const stops = [ + listen(consumer, events.CRASH, (event) => + onDown(event?.payload?.error ?? event?.error, event?.payload?.restart ?? event?.restart), + ), + ]; + if (events.GROUP_JOIN) stops.push(listen(consumer, events.GROUP_JOIN, () => onUp())); + return () => { + for (const stop of stops) stop(); + }; +}; + +module.exports = { + detectFlavor, + consumerConfig, + producerConfig, + subscribeArgs, + runArgs, + metadataTopics, + joinWatcher, + healthWatcher, +}; diff --git a/src/broker/memory.js b/src/broker/memory.js new file mode 100644 index 0000000..3ae2b1d --- /dev/null +++ b/src/broker/memory.js @@ -0,0 +1,492 @@ +'use strict'; + +// The in-process broker: the reference implementation of every capability +// in port.js, and what makes feeds, consumers and RPC-over-a-broker +// testable (and genuinely usable inside one process) without standing up +// infrastructure — two RpcServers sharing one MemoryBroker behave like two +// processes sharing a real one. +// +// Delivery is deferred to a microtask everywhere, like a real broker's: +// code that accidentally relied on synchronous delivery would break the +// moment Redis or NATS is swapped in. + +const { MemoryBackplane, DEFAULT_PREFIX } = require('../scaling/memory.js'); +const { createLoggerWriter } = require('../logging.js'); +const { generateUUID } = require('../runtime/node.js'); +const { resolveGenerateId } = require('../utils.js'); + +// The default for names that go on the wire to a broker: short, because a +// consumer name or an inbox is repeated in every log line and metric label +// the broker itself emits, and alphabet-safe for all four brokers. +// +// An injected `generateId` is used VERBATIM — never truncated. Trimming a +// user's id would quietly weaken the uniqueness they chose it for, and the +// only thing wrpc knows about their generator is that it answers a string. +// The cost is that a generator answering characters a broker refuses in a +// consumer name, subject or queue name fails at the driver, not here. +const shortId = () => generateUUID().replace(/-/g, '').slice(0, 12); +const { codedError, toText, toHeaders, reasonText } = require('./ids.js'); +const { crashDelay } = require('./retry.js'); +const { ATTEMPT_HEADER, DEAD_REASON_HEADER, DEFAULT_PREFETCH, checkConsume } = require('./adapter.js'); + +const DEFAULT_LOG_ENTRIES = 10_000; +const MAX_ID_LENGTH = 128; +const LOG_ID = /^[A-Za-z0-9_-]{1,64}\.\d{1,16}$/; + +const requireName = (value, what, label) => { + if (typeof value !== 'string' || value.length === 0) { + throw new TypeError(`${label}: ${what} must be a non-empty string`); + } +}; + +const copyBody = (body) => (body instanceof Uint8Array ? body.slice() : typeof body === 'string' ? body : toText(body)); + +class MemoryBroker { + #closed = false; + #epoch; + #maxEntries; + #log; + #backplane; + #topics = new Map(); // topic -> { entries, head, first, next, waiters } + #queues = new Map(); // queue -> { ready, consumers, cursor, timers } + #addresses = new Map(); // address -> { plain: Set, groups: Map(group -> { members, cursor }) } + #counter = 0; + + constructor({ + prefix = DEFAULT_PREFIX, + logger = globalThis.console, + epoch = null, + generateId = null, + retention = {}, + } = {}) { + // The epoch is the only id this broker mints at random (message ids and + // inboxes are counters off it), so `generateId` feeds exactly that. + this.#epoch = + epoch === null + ? generateId === null + ? shortId() + : resolveGenerateId(generateId, 'MemoryBroker').first + : String(epoch); + if (!/^[A-Za-z0-9_-]{1,64}$/.test(this.#epoch)) { + throw new TypeError('MemoryBroker: epoch must be 1-64 characters of [A-Za-z0-9_-]'); + } + const maxEntries = retention.maxEntries ?? DEFAULT_LOG_ENTRIES; + if (!Number.isInteger(maxEntries) || maxEntries <= 0) { + throw new TypeError('MemoryBroker: retention.maxEntries must be a positive integer'); + } + this.#maxEntries = maxEntries; + this.#log = createLoggerWriter(logger).child({ component: 'broker', broker: 'memory' }); + this.#backplane = new MemoryBackplane({ prefix, logger }); + // Capability objects are built once: `broker.log === broker.log`, and + // each carries the broker's name for metric labels. + this.log = Object.freeze({ + name: 'memory', + append: (topic, value, options) => this.#append(topic, value, options), + read: (topic, options) => this.#read(topic, options), + parseId: (text) => this.#parseId(text), + }); + this.queue = Object.freeze({ + name: 'memory', + produce: (queue, body, options) => this.#produce(queue, body, options), + consume: (queue, onDelivery, options) => this.#consume(queue, onDelivery, options), + }); + this.direct = Object.freeze({ + name: 'memory', + inbox: () => `_inbox.${this.#epoch}.${++this.#counter}`, + listen: (address, onMessage, options) => this.#listen(address, onMessage, options), + send: (address, body, options) => this.#send(address, body, options), + }); + } + + get name() { + return 'memory'; + } + + get backplane() { + return this.#backplane; + } + + /** The epoch stamped into every log id this broker mints. */ + get epoch() { + return this.#epoch; + } + + // ------------------------------------------------------------------------- + // log + + #topic(topic) { + let state = this.#topics.get(topic); + if (!state) { + state = { entries: [], head: 0, first: 1, next: 1, waiters: new Set() }; + this.#topics.set(topic, state); + } + return state; + } + + #append(topic, value, { headers = null } = {}) { + requireName(topic, 'topic', 'MemoryBroker.log.append'); + if (this.#closed) return Promise.reject(codedError('Broker is closed', 503)); + const state = this.#topic(topic); + const n = state.next++; + const id = `${this.#epoch}.${n}`; + state.entries.push({ id, value: toText(value), headers: toHeaders(headers) }); + const retained = state.entries.length - state.head; + if (retained > this.#maxEntries) { + const excess = retained - this.#maxEntries; + state.head += excess; + state.first += excess; + this.#compact(state); + } + if (state.waiters.size > 0) { + const waiters = Array.from(state.waiters); + state.waiters.clear(); + queueMicrotask(() => { + for (const wake of waiters) wake(); + }); + } + return Promise.resolve(id); + } + + // A head index instead of shift(): trimming at the cap would otherwise be + // O(retained) per append. The dead prefix is dropped once it dominates the + // array — from the cap and from trim() alike; trim() used to move the head + // only, so a topic trimmed by hand kept every trimmed entry alive. + #compact(state) { + if (state.head > 1024 && state.head * 2 > state.entries.length) { + state.entries = state.entries.slice(state.head); + state.head = 0; + } + } + + /** Drops all but the newest `keep` entries of a topic — XTRIM's analogue. */ + trim(topic, keep) { + const state = this.#topics.get(topic); + if (!state || !Number.isInteger(keep) || keep < 0) return; + const excess = state.entries.length - state.head - keep; + if (excess <= 0) return; + state.head += excess; + state.first += excess; + this.#compact(state); + } + + #parseId(text) { + if (typeof text !== 'string' || text.length > MAX_ID_LENGTH || !LOG_ID.test(text)) return null; + return text; + } + + #read(topic, { after = null, from = 'latest', signal = null } = {}) { + requireName(topic, 'topic', 'MemoryBroker.log.read'); + if (from !== 'latest' && from !== 'earliest') { + throw new TypeError("MemoryBroker.log.read: from must be 'latest' or 'earliest'"); + } + const state = this.#topic(topic); + let position = 0; + let failure = null; + if (after !== null && after !== undefined) { + const id = this.#parseId(after); + if (id === null) failure = codedError('Malformed event id', 400); + else { + const dot = id.lastIndexOf('.'); + const n = Number(id.slice(dot + 1)); + if (id.slice(0, dot) !== this.#epoch) failure = codedError('Event id belongs to another log', 410); + else if (n >= state.next) failure = codedError('Event id is beyond the end of the log', 400); + else if (n + 1 < state.first) failure = codedError('Event history was trimmed past this id', 410); + else position = n + 1; + } + } else { + position = from === 'earliest' ? state.first : state.next; + } + const broker = this; + const iterate = async function* () { + if (failure) throw failure; + for (;;) { + if (signal?.aborted || broker.#closed) return; + if (position < state.first) throw codedError('Event history was trimmed past this reader', 410); + if (position < state.next) { + const entry = state.entries[state.head + (position - state.first)]; + position++; + yield { id: entry.id, value: entry.value, headers: { ...entry.headers } }; + continue; + } + await broker.#wait(state, signal); + } + }; + return { ready: Promise.resolve(), [Symbol.asyncIterator]: () => iterate() }; + } + + #wait(state, signal) { + return new Promise((resolve) => { + const wake = () => { + state.waiters.delete(wake); + signal?.removeEventListener('abort', wake); + resolve(); + }; + state.waiters.add(wake); + signal?.addEventListener('abort', wake, { once: true }); + }); + } + + // ------------------------------------------------------------------------- + // queue + + #queueState(queue) { + let state = this.#queues.get(queue); + if (!state) { + state = { ready: [], consumers: [], cursor: 0, timers: new Set() }; + this.#queues.set(queue, state); + } + return state; + } + + #enqueue(queue, body, headers) { + const state = this.#queueState(queue); + state.ready.push({ + id: `${this.#epoch}.m${++this.#counter}`, + body: toText(body), + headers: toHeaders(headers), + attempt: 1, + redelivered: false, + }); + this.#pump(state); + } + + #produce(queue, body, { headers = null } = {}) { + requireName(queue, 'queue', 'MemoryBroker.queue.produce'); + if (this.#closed) return Promise.reject(codedError('Broker is closed', 503)); + this.#enqueue(queue, body, headers); + return Promise.resolve(); + } + + #consume(queue, onDelivery, { prefetch = DEFAULT_PREFETCH, deadLetter = null, signal = null } = {}) { + requireName(queue, 'queue', 'MemoryBroker.queue.consume'); + checkConsume('MemoryBroker.queue.consume', onDelivery, prefetch, deadLetter); + if (this.#closed) return Promise.reject(codedError('Broker is closed', 503)); + const state = this.#queueState(queue); + const consumer = { onDelivery, prefetch, deadLetter, inflight: new Set(), active: true, paused: false }; + state.consumers.push(consumer); + const stop = () => { + if (!consumer.active) return Promise.resolve(); + consumer.active = false; + const index = state.consumers.indexOf(consumer); + if (index >= 0) state.consumers.splice(index, 1); + // Unsettled work goes back to the FRONT, in its original order, and + // counts as redelivered — a late ack from the stopped consumer is a + // no-op, exactly as it is on a broker that already reassigned it. + const orphans = Array.from(consumer.inflight); + consumer.inflight.clear(); + for (let i = orphans.length - 1; i >= 0; i--) { + const delivery = orphans[i]; + delivery.settled = true; + delivery.message.redelivered = true; + state.ready.unshift(delivery.message); + } + this.#pump(state); + return Promise.resolve(); + }; + signal?.addEventListener('abort', () => void stop(), { once: true }); + queueMicrotask(() => this.#pump(state)); + const broker = this; + return Promise.resolve({ + stop, + // Paused: no new deliveries, but the unsettled ones stay settleable — + // a draining node finishes what it holds instead of handing it back. + pause: () => { + consumer.paused = true; + return Promise.resolve(); + }, + resume: () => { + consumer.paused = false; + broker.#pump(state); + return Promise.resolve(); + }, + get healthy() { + return consumer.active && !broker.#closed; + }, + }); + } + + // Hands ready messages to consumers with spare prefetch, round-robin. + #pump(state) { + if (this.#closed) return; + while (state.ready.length > 0) { + const consumer = this.#nextConsumer(state); + if (!consumer) return; + this.#dispatch(state, consumer, state.ready.shift()); + } + } + + #nextConsumer(state) { + const { consumers } = state; + for (let i = 0; i < consumers.length; i++) { + const consumer = consumers[(state.cursor + i) % consumers.length]; + if (consumer.active && !consumer.paused && consumer.inflight.size < consumer.prefetch) { + state.cursor = (state.cursor + i + 1) % consumers.length; + return consumer; + } + } + return null; + } + + #dispatch(state, consumer, message) { + const broker = this; + const record = { message, settled: false }; + const settle = (apply) => { + if (record.settled || broker.#closed) return Promise.resolve(); + record.settled = true; + consumer.inflight.delete(record); + apply(); + broker.#pump(state); + return Promise.resolve(); + }; + const delivery = Object.freeze({ + id: message.id, + body: message.body, + headers: { ...message.headers }, + attempt: message.attempt, + redelivered: message.redelivered, + ack: () => settle(() => {}), + retry: ({ delay = 0 } = {}) => + settle(() => { + message.attempt++; + message.redelivered = true; + if (!(delay > 0)) return void state.ready.push(message); + const timer = setTimeout(() => { + state.timers.delete(timer); + state.ready.push(message); + broker.#pump(state); + }, delay); + state.timers.add(timer); + }), + release: () => + settle(() => { + message.redelivered = true; + state.ready.unshift(message); + }), + deadLetter: (reason = '') => + settle(() => { + if (consumer.deadLetter === null) return; + broker.#enqueue(consumer.deadLetter, message.body, { + ...message.headers, + [DEAD_REASON_HEADER]: reasonText(reason), + [ATTEMPT_HEADER]: String(message.attempt), + }); + }), + }); + consumer.inflight.add(record); + queueMicrotask(() => { + if (record.settled) return; + let result; + try { + result = consumer.onDelivery(delivery); + } catch (error) { + result = Promise.reject(error); + } + // A handler that throws has not settled anything: the message comes + // back rather than disappearing with the exception — as a RETRY after + // a backoff, attempt + 1. It used to be released, back at the head and + // re-dispatched on the next microtask, so a handler that always threw + // spun this loop without ever yielding to a timer. + Promise.resolve(result).catch((error) => { + broker.#log.error({ err: error, event: 'broker.delivery' }); + void delivery.retry({ delay: crashDelay(message.attempt) }); + }); + }); + } + + // ------------------------------------------------------------------------- + // direct + + #listen(address, onMessage, { group = null } = {}) { + requireName(address, 'address', 'MemoryBroker.direct.listen'); + if (typeof onMessage !== 'function') { + throw new TypeError('MemoryBroker.direct.listen: onMessage must be a function'); + } + if (this.#closed) return Promise.reject(codedError('Broker is closed', 503)); + let entry = this.#addresses.get(address); + if (!entry) { + entry = { plain: new Set(), groups: new Map() }; + this.#addresses.set(address, entry); + } + const listener = { onMessage }; + if (group === null || group === undefined) entry.plain.add(listener); + else { + let members = entry.groups.get(group); + if (!members) { + members = { list: [], cursor: 0 }; + entry.groups.set(group, members); + } + members.list.push(listener); + } + const stop = () => { + const current = this.#addresses.get(address); + if (!current) return Promise.resolve(); + current.plain.delete(listener); + for (const [name, members] of current.groups) { + const index = members.list.indexOf(listener); + if (index >= 0) members.list.splice(index, 1); + if (members.list.length === 0) current.groups.delete(name); + } + if (current.plain.size === 0 && current.groups.size === 0) this.#addresses.delete(address); + return Promise.resolve(); + }; + return Promise.resolve(stop); + } + + #send(address, body, { headers = null, correlationId = null, replyTo = null } = {}) { + requireName(address, 'address', 'MemoryBroker.direct.send'); + if (this.#closed) return Promise.reject(codedError('Broker is closed', 503)); + const entry = this.#addresses.get(address); + if (!entry) return Promise.reject(codedError(`No listener at ${address}`, 503)); + const targets = Array.from(entry.plain); + for (const members of entry.groups.values()) { + targets.push(members.list[members.cursor % members.list.length]); + members.cursor = (members.cursor + 1) % members.list.length; + } + const payload = copyBody(body); + const normalized = toHeaders(headers); + for (const listener of targets) { + const message = { + body: payload, + headers: { ...normalized }, + correlationId: correlationId ?? null, + replyTo: replyTo ?? null, + }; + queueMicrotask(() => { + if (this.#closed) return; + try { + const result = listener.onMessage(message); + if (result && typeof result.catch === 'function') { + result.catch((error) => this.#log.error({ err: error, event: 'broker.listener', address })); + } + } catch (error) { + this.#log.error({ err: error, event: 'broker.listener', address }); + } + }); + } + return Promise.resolve(); + } + + // ------------------------------------------------------------------------- + + close() { + if (this.#closed) return; + this.#closed = true; + this.#backplane.close(); + for (const state of this.#topics.values()) { + const waiters = Array.from(state.waiters); + state.waiters.clear(); + for (const wake of waiters) wake(); + } + for (const state of this.#queues.values()) { + for (const timer of state.timers) clearTimeout(timer); + state.timers.clear(); + for (const consumer of state.consumers) consumer.active = false; + state.consumers.length = 0; + } + this.#addresses.clear(); + } +} + +const createMemoryBroker = (options) => new MemoryBroker(options); + +module.exports = { MemoryBroker, createMemoryBroker }; diff --git a/src/broker/nats/index.js b/src/broker/nats/index.js new file mode 100644 index 0000000..d7b121e --- /dev/null +++ b/src/broker/nats/index.js @@ -0,0 +1,681 @@ +'use strict'; + +// The NATS broker: all four capabilities over an INJECTED nats.js +// connection (`@nats-io/transport-node` and, for logs and queues, +// `@nats-io/jetstream` — devDependencies here, never runtime ones). +// +// const { connect, headers } = require('@nats-io/transport-node'); +// const { jetstream, jetstreamManager } = require('@nats-io/jetstream'); +// const broker = createNatsBroker({ +// nc: await connect({ servers }), +// headers, // a PACKAGE export, not a method on nc +// jetstream, +// jetstreamManager, // omit both to get backplane + direct only +// }); +// +// Capability map: +// backplane core subjects — publish/subscribe, at-most-once +// log a JetStream stream per topic; the message sequence IS the +// feed's resume token +// queue a work-queue stream with a durable pull consumer per group +// direct core subjects: plain subscriptions for inboxes, queue groups +// for a service address — request/reply is what NATS is FOR +// +// Every application name (a room, a topic, a queue, an address) becomes ONE +// subject token: `.` would silently mean another subject and `*`/`>` are +// wildcards — a room called `room:*` must never subscribe to every room. + +const { createLoggerWriter } = require('../../logging.js'); +const { TopicTails } = require('../tail.js'); +const { codedError, toText, toBytes, toHeaders, reasonText, encodeToken } = require('../ids.js'); +const { positiveInteger } = require('../retry.js'); +const { withHealth } = require('../port.js'); +const { + ATTEMPT_HEADER, + REDELIVERED_HEADER, + DEAD_REASON_HEADER, + TEXT_HEADER, + DEFAULT_PREFETCH, + idFactory, + failedRead, + guardedRead, + checkConsume, + checkListen, + runDelivery, +} = require('../adapter.js'); + +const DEFAULT_PREFIX = 'wrpc'; +const DEFAULT_ACK_WAIT = 30_000; +const DEFAULT_FETCH_EXPIRES = 2_000; +// A reader's ephemeral consumer is reaped by the server this long after its +// last activity — the backstop for a process that died mid-read. +const EPHEMERAL_INACTIVE_MS = 30_000; +const SEQUENCE = /^\d{1,19}$/; + +const CORRELATION_HEADER = 'wrpc-correlation'; + +const MILLIS = 1_000_000; // JetStream durations are nanoseconds + +const isFunction = (value) => typeof value === 'function'; + +// One subject token, always: see the header comment. +const token = (name) => encodeToken(name, { safe: /[A-Za-z0-9_-]/, escape: '~', maxLength: 120 }); +// A stream NAME may not contain `.`, spaces or wildcards either. +const streamName = (prefix, kind, name) => `${prefix}_${kind}_${token(name)}`.replace(/~/g, '_'); + +const createNatsBroker = (options = {}) => { + const { + nc, + headers: headersFactory, + jetstream = null, + jetstreamManager = null, + createInbox = null, + prefix = DEFAULT_PREFIX, + logger = globalThis.console, + ackWait = DEFAULT_ACK_WAIT, + maxAckPending = null, + stream: streamConfig = {}, + generateId = null, + } = options; + // Ids: an injected generator is used verbatim — see idFactory. + const { nextId } = idFactory(generateId, 'createNatsBroker'); + if (!nc || !isFunction(nc.publish) || !isFunction(nc.subscribe)) { + throw new TypeError('createNatsBroker: options.nc must be a NATS connection (publish/subscribe/...)'); + } + if (!isFunction(headersFactory)) { + throw new TypeError('createNatsBroker: options.headers must be the nats headers() factory (a package export)'); + } + if ((jetstream === null) !== (jetstreamManager === null)) { + throw new TypeError('createNatsBroker: options.jetstream and options.jetstreamManager come together'); + } + if (jetstream !== null && (!isFunction(jetstream) || !isFunction(jetstreamManager))) { + throw new TypeError('createNatsBroker: options.jetstream/jetstreamManager must be the nats JetStream factories'); + } + // Strict, at construction: `ackWait` becomes nanoseconds in a consumer + // config, and a string from the environment multiplied is NaN — a + // consumer the server refuses at the first consume(), not here. + if (!positiveInteger(ackWait)) { + throw new TypeError('createNatsBroker: options.ackWait must be a positive integer of milliseconds'); + } + // The GROUP's cap on unacked messages, set on the durable consumer every + // member of the group shares; null leaves JetStream's own default. It + // used to be each consume()'s prefetch — the last consumer to bind set + // it for the whole group, and a fleet of four with prefetch 16 held 16 + // between them, not 64. + if (maxAckPending !== null && !positiveInteger(maxAckPending)) { + throw new TypeError('createNatsBroker: options.maxAckPending must be a positive integer or null'); + } + const log = createLoggerWriter(logger).child({ component: 'broker', broker: 'nats' }); + const report = (event, error, extra = {}) => log.error({ err: error, event, ...extra }); + let closed = false; + // The queue consumers alive on this broker, so close() stops them: a + // consumer left pulling on a closed broker kept its keepalives and its + // pump running against a connection the caller was about to drain. + const consumers = new Set(); + + const encodeHeaders = (bag, extra = null) => { + const merged = { ...toHeaders(bag), ...(extra ?? {}) }; + const names = Object.keys(merged); + if (names.length === 0) return undefined; + const carrier = headersFactory(); + for (const name of names) carrier.set(name, merged[name]); + return carrier; + }; + + const decodeHeaders = (carrier) => { + const bag = Object.create(null); + if (!carrier || !isFunction(carrier.keys)) return bag; + for (const name of carrier.keys()) bag[name] = carrier.get(name); + return bag; + }; + + // --------------------------------------------------------------------- + // backplane + + const channelSubject = (channel) => `${prefix}.bp.${token(channel)}`; + + const backplane = { + name: 'nats', + publish(channel, message) { + if (closed) return; + try { + nc.publish(channelSubject(channel), message); + } catch (error) { + report('broker.nats.publish', error, { channel }); + } + }, + subscribe(channel, handler) { + if (!isFunction(handler)) throw new TypeError('nats backplane.subscribe: handler must be a function'); + const subscription = nc.subscribe(channelSubject(channel), { + callback: (error, message) => { + if (error) return void report('broker.nats.subscription', error, { channel }); + try { + handler(toText(message.data)); + } catch (failure) { + report('broker.nats.handler', failure, { channel }); + } + }, + }); + // Resolved once the SUBSCRIBE reached the server: a publish after + // this is delivered, which is what the backplane's start() needs. + return nc.flush().then(() => async () => { + subscription.unsubscribe(); + await nc.flush().catch(() => {}); + }); + }, + close() { + // The connection is the caller's; subscriptions die with it. + }, + }; + + // --------------------------------------------------------------------- + // JetStream plumbing (log + queue) + + let js = null; + let jsm = null; + const managers = async () => { + if (!js) js = jetstream(nc); + if (!jsm) jsm = await jetstreamManager(nc); + return { js, jsm }; + }; + + const ensured = new Map(); // stream name -> Promise + const ensureStream = (name, subject, config) => { + let pending = ensured.get(name); + if (pending) return pending; + pending = (async () => { + const { jsm: manager } = await managers(); + try { + await manager.streams.info(name); + } catch { + try { + await manager.streams.add({ name, subjects: [subject], ...config }); + } catch (error) { + // Another instance created it between the info and the add. + if (!/already in use|exists/i.test(String(error?.message))) throw error; + } + } + return name; + })(); + ensured.set(name, pending); + pending.catch(() => ensured.delete(name)); + return pending; + }; + + const logStream = (topic) => ({ + name: streamName(prefix, 'log', topic), + subject: `${prefix}.log.${token(topic)}`, + config: { ...streamConfig.log }, + }); + + const queueStream = (name) => ({ + name: streamName(prefix, 'q', name), + subject: `${prefix}.q.${token(name)}`, + // A work queue: an acked message leaves the stream, so a queue that + // keeps up stays small. + config: { retention: 'workqueue', ...streamConfig.queue }, + }); + + // --------------------------------------------------------------------- + // log + + const streamState = async (topic) => { + const { name, subject, config } = logStream(topic); + await ensureStream(name, subject, config); + const { jsm: manager } = await managers(); + const info = await manager.streams.info(name); + return { name, subject, first: Number(info.state.first_seq), last: Number(info.state.last_seq) }; + }; + + const entryOf = (message) => ({ + seq: Number(message.seq), + id: String(message.seq), + value: toText(message.data), + headers: decodeHeaders(message.headers), + }); + + // A reader's consumer is the reader's: deleted when the page or the tail + // is done, and — should the process die first — reaped by the server + // after `inactive_threshold`. They used to be left behind, one per page + // of catch-up and one per tail, until the server's own default reaping. + // In MILLISECONDS: `consumers.get()` takes ordered-consumer options and + // converts them to the server's nanoseconds itself — this used to hand it + // nanoseconds, and the server was told 347 days instead of 30 s. + const ephemeral = (config) => ({ + ...config, + deliver_policy: 'by_start_sequence', + inactive_threshold: EPHEMERAL_INACTIVE_MS, + }); + const discard = async (consumer) => { + if (!isFunction(consumer?.delete)) return; + try { + await consumer.delete(); + } catch { + // Already gone (the server reaped it), or the connection is closing. + } + }; + + const tails = new TopicTails({ + // Readers that fell behind the tail and are catching up through range(): + // info, not a fault — but the first thing to look at when a feed is slow. + onLag: (topic, readers) => log.info({ event: 'broker.tail.lag', topic, readers }), + live: async (topic, { signal, onEntry, onEnd }) => { + const { js: stream } = await managers(); + const { name } = await streamState(topic); + const state = await streamState(topic); + const consumer = await stream.consumers.get(name, ephemeral({ opt_start_seq: state.last + 1 })); + const messages = await consumer.consume({ max_messages: 256 }); + signal.addEventListener( + 'abort', + () => { + void messages + .close() + .catch(() => {}) + .then(() => discard(consumer)); + }, + { once: true }, + ); + void (async () => { + let failure = null; + try { + for await (const message of messages) { + message.ack(); + if (signal.aborted) break; + onEntry(entryOf(message)); + } + } catch (error) { + failure = error; + if (!signal.aborted && !closed) report('broker.nats.tail', error, { topic }); + } + // The pull ended without being asked to — the server's doing (the + // consumer deleted, the stream gone, the connection dropped): the + // readers move to a fresh tail instead of waiting on this one. + if (!signal.aborted && !closed) onEnd(failure ?? new Error('consume ended')); + })(); + return state.last > 0 ? state.last : null; + }, + range: async (topic, { after, limit }) => { + const { js: stream } = await managers(); + // The tip first: a fetch parks for its whole expiry once the stream + // has nothing more, and a page cut short by that expiry is not the + // end — the sequence says where the end is. + const { name, last } = await streamState(topic); + const start = (after ?? 0) + 1; + if (start > last) return { entries: [], done: true }; + const consumer = await stream.consumers.get(name, ephemeral({ opt_start_seq: start })); + const entries = []; + let seq = start - 1; + try { + const batch = await consumer.fetch({ max_messages: limit, expires: DEFAULT_FETCH_EXPIRES }); + for await (const message of batch) { + message.ack(); + const entry = entryOf(message); + entries.push(entry); + seq = entry.seq; + if (entries.length >= limit || seq >= last) break; + } + } finally { + await discard(consumer); + } + return { entries, done: seq >= last }; + }, + covered: (cursor, entry) => entry.seq <= cursor, + advance: (_cursor, entry) => entry.seq, + }); + + const parseId = (text) => (typeof text === 'string' && SEQUENCE.test(text) ? text : null); + + // A sequence is a number inside and a string outside. + const wrapRead = (inner, checked) => guardedRead(inner, { guard: checked, mapId: String }); + + const read = (topic, options = {}) => { + const { after = null, from = 'latest', signal = null } = options; + if (from !== 'latest' && from !== 'earliest') { + throw new TypeError("nats log.read: from must be 'latest' or 'earliest'"); + } + if (after === null || after === undefined) { + if (from === 'latest') return wrapRead(tails.read(topic, { from, signal }), Promise.resolve()); + // 'earliest': every retained message, then the live tail. + const earliest = (async () => { + const state = await streamState(topic); + return Math.max(0, state.first - 1); + })(); + earliest.catch(() => {}); + return wrapRead( + tails.read(topic, { after: 0, signal }), + earliest.then(() => undefined), + ); + } + if (parseId(after) === null) return failedRead(codedError('Malformed event id', 400)); + const position = Number(after); + const checked = (async () => { + const state = await streamState(topic); + if (position > state.last) throw codedError('Event id is beyond the end of the log', 400); + if (state.first > 0 && position + 1 < state.first) { + throw codedError('Event history was trimmed past this id', 410); + } + })(); + checked.catch(() => {}); + return wrapRead(tails.read(topic, { after: position, signal }), checked); + }; + + const append = async (topic, value, { headers = null } = {}) => { + if (closed) throw codedError('Broker is closed', 503); + const { js: stream } = await managers(); + const { name, subject, config } = logStream(topic); + await ensureStream(name, subject, config); + const ack = await stream.publish(subject, toText(value), { headers: encodeHeaders(headers) }); + return String(ack.seq); + }; + + // --------------------------------------------------------------------- + // queue + + const produce = async (name, body, { headers = null } = {}) => { + if (closed) throw codedError('Broker is closed', 503); + const { js: stream } = await managers(); + const { name: streamId, subject, config } = queueStream(name); + await ensureStream(streamId, subject, config); + await stream.publish(subject, toText(body), { headers: encodeHeaders(headers) }); + }; + + const consume = async (name, onDelivery, options = {}) => { + if (closed) throw codedError('Broker is closed', 503); + const { group = name, prefetch = DEFAULT_PREFETCH, deadLetter = null, signal = null } = options; + checkConsume('nats queue.consume', onDelivery, prefetch, deadLetter); + const { js: stream, jsm: manager } = await managers(); + const { name: streamId, subject, config } = queueStream(name); + await ensureStream(streamId, subject, config); + const durable = token(group); + const wanted = { durable_name: durable, ack_policy: 'explicit', ack_wait: ackWait * MILLIS }; + if (maxAckPending !== null) wanted.max_ack_pending = maxAckPending; + try { + await manager.consumers.add(streamId, wanted); + } catch (error) { + if (!/already exists|in use/i.test(String(error?.message))) throw error; + // The durable was created earlier — by another instance, or by an + // older build that set max_ack_pending to its prefetch — and keeps + // that configuration until someone updates it: said once, since a + // group capped at 16 by a consumer long gone is a puzzle otherwise. + if (isFunction(manager.consumers?.info)) { + try { + const { config: actual = {} } = await manager.consumers.info(streamId, durable); + const drift = {}; + if (actual.ack_wait !== undefined && actual.ack_wait !== wanted.ack_wait) { + drift.ackWait = actual.ack_wait / MILLIS; + } + if (wanted.max_ack_pending !== undefined && actual.max_ack_pending !== wanted.max_ack_pending) { + drift.maxAckPending = actual.max_ack_pending; + } + if (Object.keys(drift).length > 0) { + log.warn({ event: 'broker.nats.consumer.config', queue: name, durable, ...drift }); + } + } catch (infoError) { + log.debug({ event: 'broker.nats.consumer.info', err: infoError, queue: name }); + } + } + } + const consumer = await stream.consumers.get(streamId, durable); + // `keepalives`: seq -> the `working()` keepalive of a delivery still in + // flight. A consumer that STOPS must let its leases expire, or the + // messages it held would never be redelivered to anyone; a PAUSED one + // keeps them (see `halt`). + // `inflight`/`wake`: the prefetch is THIS consumer's, counted here — + // the durable's max_ack_pending is the group's, shared by every member. + const state = { + running: true, + paused: false, + healthy: true, + messages: null, + keepalives: new Map(), + inflight: 0, + wake: null, + }; + + const dispatch = (message) => { + state.inflight++; + const headers = decodeHeaders(message.headers); + const base = Number(headers[ATTEMPT_HEADER] ?? '0'); + const deliveries = Number(message.info?.deliveryCount ?? 1); + // A message this adapter re-published carries its attempt; further + // naks of the SAME message add JetStream's own redeliveries. + const attempt = base > 0 ? base + deliveries - 1 : deliveries; + let settled = false; + // A handler slower than ack_wait would see its message redelivered + // underneath it; `working()` is JetStream's "still on it". + // A third of the window, never a fixed floor: the floor of one second + // it used to have let a lease shorter than two seconds expire before + // the first keepalive — the redelivery it was there to prevent. + const keepalive = setInterval( + () => { + try { + message.working(); + } catch { + // The message is settled or the consumer is gone. + } + }, + Math.max(100, Math.floor(ackWait / 3)), + ); + if (isFunction(keepalive.unref)) keepalive.unref(); + state.keepalives.set(message.seq, keepalive); + const finish = async (work) => { + if (settled) return; + settled = true; + clearInterval(keepalive); + state.keepalives.delete(message.seq); + try { + await work(); + } catch (error) { + report('broker.nats.settle', error, { queue: name }); + } finally { + state.inflight--; + const wake = state.wake; + state.wake = null; + wake?.(); + } + }; + const republish = async (extra) => { + const carried = { ...headers, ...extra }; + await stream.publish(subject, message.data, { headers: encodeHeaders(carried) }); + }; + const delivery = Object.freeze({ + id: String(message.seq), + body: toText(message.data), + headers, + attempt, + // JetStream's own redelivery (a nak, an expired ack_wait) OR a copy + // this adapter re-published for a release. + redelivered: deliveries > 1 || headers[REDELIVERED_HEADER] === '1', + ack: () => finish(() => message.ack()), + // nak() carries the delay AND counts the attempt — no republish. + retry: ({ delay = 0 } = {}) => finish(() => message.nak(delay > 0 ? delay : undefined)), + // A release must NOT count an attempt, and JetStream has no such + // signal: the message is re-published with its attempt carried over + // and the original terminated. + release: () => + finish(async () => { + await republish({ [ATTEMPT_HEADER]: String(attempt), [REDELIVERED_HEADER]: '1' }); + message.term(); + }), + deadLetter: (reason = '') => + finish(async () => { + if (deadLetter) { + const dlq = queueStream(deadLetter); + await ensureStream(dlq.name, dlq.subject, dlq.config); + await stream.publish(dlq.subject, message.data, { + headers: encodeHeaders({ + ...headers, + [DEAD_REASON_HEADER]: reasonText(reason), + [ATTEMPT_HEADER]: String(attempt), + }), + }); + } + message.term(); + }), + }); + runDelivery(onDelivery, delivery, report, 'broker.nats.delivery', name); + }; + + // The iterator is sequential, so the handler is never awaited here: + // concurrency is the adapter's job, and max_ack_pending is the cap. + const pump = async () => { + for (;;) { + if (!state.running || state.paused || closed) return; + let messages; + try { + messages = await consumer.consume({ max_messages: prefetch }); + state.messages = messages; + state.healthy = true; + for await (const message of messages) { + // At capacity: the pull waits for a settlement before it takes + // the next message — the local prefetch, whatever the group's + // cap is (JetStream redelivers what waits past ack_wait, so a + // message held here unacked is not a message lost). + while (state.inflight >= prefetch && state.running && !state.paused) { + await new Promise((resolve) => { + state.wake = resolve; + }); + } + if (!state.running || state.paused) break; + dispatch(message); + } + } catch (error) { + if (!state.running || closed) return; + state.healthy = false; + report('broker.nats.consume', error, { queue: name }); + await new Promise((resolve) => setTimeout(resolve, 200)); + } + } + }; + void pump(); + + // `leases`: whether the keepalives of in-flight deliveries stop too. + // A PAUSE keeps them — a draining node holds its messages until they + // settle, and used to let their leases lapse, so JetStream redelivered + // them to a healthy member while this one was still working on them. + // A STOP lets them go: the messages are meant to be handed back. + const halt = async ({ leases }) => { + const messages = state.messages; + state.messages = null; + if (leases) { + for (const keepalive of state.keepalives.values()) clearInterval(keepalive); + state.keepalives.clear(); + } + const wake = state.wake; + state.wake = null; + wake?.(); + if (messages) await messages.close().catch(() => {}); + }; + const stop = async () => { + if (!state.running) return; + state.running = false; + consumers.delete(stop); + await halt({ leases: true }); + }; + consumers.add(stop); + if (signal) signal.addEventListener('abort', () => void stop(), { once: true }); + return { + stop, + pause: async () => { + state.paused = true; + await halt({ leases: false }); + }, + resume: async () => { + if (!state.paused) return; + state.paused = false; + void pump(); + }, + get healthy() { + return state.running && state.healthy && !closed && !nc.isClosed?.(); + }, + }; + }; + + // --------------------------------------------------------------------- + // direct + + const addressSubject = (address) => `${prefix}.direct.${token(address)}`; + + const inbox = () => { + const name = isFunction(createInbox) ? createInbox() : `_INBOX.${nextId()}`; + return name; + }; + + const listen = async (address, onMessage, { group = null } = {}) => { + if (closed) throw codedError('Broker is closed', 503); + checkListen('nats direct.listen', address, onMessage); + // What `stop.healthy` answers: a subscription that reported an error + // (a permissions violation, a slow-consumer drop) delivers nothing more. + let listening = true; + const subscription = nc.subscribe(addressSubject(address), { + queue: group === null || group === undefined ? undefined : token(group), + callback: (error, message) => { + if (error) { + listening = false; + return void report('broker.nats.subscription', error, { address }); + } + const headers = decodeHeaders(message.headers); + const text = headers[TEXT_HEADER] === '1'; + delete headers[TEXT_HEADER]; + const correlationId = headers[CORRELATION_HEADER] ?? null; + delete headers[CORRELATION_HEADER]; + try { + const result = onMessage({ + body: text ? toText(message.data) : new Uint8Array(message.data), + headers, + correlationId, + replyTo: message.reply ?? null, + }); + if (result && isFunction(result.catch)) result.catch((failure) => report('broker.nats.listener', failure)); + } catch (failure) { + report('broker.nats.listener', failure, { address }); + } + }, + }); + await nc.flush(); + const stop = async () => { + listening = false; + subscription.unsubscribe(); + await nc.flush().catch(() => {}); + }; + return withHealth(stop, () => listening && !closed && !nc.isClosed?.()); + }; + + const send = async (address, body, { headers = null, correlationId = null, replyTo = null } = {}) => { + if (closed) throw codedError('Broker is closed', 503); + const text = typeof body === 'string'; + const extra = { [TEXT_HEADER]: text ? '1' : '0' }; + if (correlationId !== null && correlationId !== undefined) extra[CORRELATION_HEADER] = String(correlationId); + const payload = text ? toText(body) : toBytes(body); + nc.publish(addressSubject(address), payload, { + headers: encodeHeaders(headers, extra), + // NATS carries the reply address natively — that is what it is for. + reply: replyTo === null || replyTo === undefined ? undefined : replyTo, + }); + }; + + // --------------------------------------------------------------------- + + const close = async () => { + if (closed) return; + closed = true; + tails.close(); + ensured.clear(); + await Promise.all(Array.from(consumers, (stop) => stop())); + // The connection is INJECTED: draining or closing it is the caller's. + }; + + const broker = { + name: 'nats', + backplane, + direct: Object.freeze({ name: 'nats', inbox, listen, send }), + close, + }; + if (jetstream !== null) { + broker.log = Object.freeze({ name: 'nats', append, read, parseId }); + broker.queue = Object.freeze({ name: 'nats', produce, consume }); + } + return broker; +}; + +module.exports = { createNatsBroker }; diff --git a/src/broker/port.js b/src/broker/port.js new file mode 100644 index 0000000..fe33c73 --- /dev/null +++ b/src/broker/port.js @@ -0,0 +1,157 @@ +'use strict'; + +// The broker port: what wrpc asks of a message broker, split into four +// CAPABILITIES so every broker implements the subset natural to it. An +// adapter is a plain object — nothing here is a base class: +// +// { name, backplane?, log?, queue?, direct?, close() } +// +// The contracts, precisely (tests/broker/*Contract.js is their executable +// form, run against the memory reference and every adapter): +// +// backplane — the rooms/cluster fan-out, unchanged from @alexify/wrpc/scaling: +// publish(channel, message) -> void | Promise +// subscribe(channel, handler) -> unsubscribe | Promise +// close() -> void | Promise +// Every subscriber of a channel receives each message, at most once. +// Channel names are arbitrary strings: an adapter encodes them into +// whatever its broker allows, and `room:*` must never mean a wildcard. +// +// log — an ordered, replayable journal (what a durable subscription feed +// reads from): +// append(topic, value, { headers, key }) -> Promise +// read(topic, { after, from, signal }) -> ReadIterable +// ReadIterable: AsyncIterable<{ id, value, headers }> & { ready: Promise } +// - `ready` resolves once the read position is fixed; entries appended +// after that are guaranteed to be read. +// - `after` (an id this log minted): entries strictly after it. An id +// older than retained history, or minted by another log, makes the +// iteration throw a coded 410; an id beyond the tip, a coded 400. +// - without `after`: `from: 'latest'` (default) reads only new entries, +// `'earliest'` everything retained. +// - `signal` aborting, or breaking out of the loop, ends it cleanly; a +// read never iterated, or returned before its first next(), holds +// nothing once its signal aborts or return() was called. +// parseId(text) -> id | null +// Syntax only — `lastEventId` is peer-controlled, and garbage must be +// refused before it reaches the broker. +// Ids are opaque strings; values and header values are strings. +// +// queue — at-least-once work distribution: +// produce(queue, body, { headers, key }) -> Promise +// consume(queue, onDelivery, { group, prefetch, deadLetter, signal }) +// -> Promise +// Consumer: { pause(), resume(), stop(), readonly healthy } +// - pause(): no new deliveries; unsettled ones stay settleable and are +// NOT handed back — what a draining node wants. resume() undoes it. +// - consumers of one queue compete: a message reaches one of them. +// `group` names the broker-side consumer group where one is needed +// (Kafka, JetStream, Redis) and defaults to the queue name; one queue +// is consumed by one group. +// - at most `prefetch` unsettled deliveries per consumer, dispatched +// CONCURRENTLY (the adapter drives it, not a sequential loop). +// - messages produced before any consumer exists are retained. +// delivery: { id, body, headers, attempt, redelivered, +// ack(), retry({ delay }), release(), deadLetter(reason) } +// - the first settlement wins; later ones are no-ops. +// - retry: redelivered no sooner than `delay` ms, attempt + 1. The +// attempt counter is the ADAPTER's (RabbitMQ 4 does not count a +// requeue), carried in `x-wrpc-attempt` where the broker has none. +// - release: back to the queue, attempt unchanged (a draining node +// handing work to a healthy one). +// - a handler that throws or rejects has settled nothing: the adapter +// RETRIES the delivery after a backoff (attempt + 1, `crashDelay` in +// retry.js) — never a release, which would put it back at the head +// and spin a queue whose handler always throws. +// - a settlement the BROKER refuses (a retry's copy or a dead letter it +// would not take, a commit it would not accept) leaves the message +// available: the adapter tries the settlement again, then hands the +// message back (a requeue, a seek to its offset) — it never moves +// past it, and never acks it away. Logged `broker..settle`. +// - deadLetter: to the `deadLetter` queue with `x-wrpc-dead-reason` and +// `x-wrpc-attempt` headers, or dropped when none is configured. The +// reason is folded to ONE line of at most 512 characters (control +// characters become spaces) — a header value on every broker; the +// adapter folds it, whoever called it. +// - stop(): no new deliveries; the unsettled ones are redelivered later. +// +// direct — addressable inboxes, the substrate of RPC over a broker: +// inbox() -> address (sync) +// listen(address, onMessage, { group }) -> Promise +// - resolves once live: a message sent after it is received. +// - `stop.healthy` (optional, a getter on the returned function): false +// while the adapter KNOWS the listener is not receiving — its read +// loop is retrying after an error, its channel or the connection is +// gone. Absent means the adapter cannot tell, and reads as true. Not +// part of the structural check: what the RPC binding's `healthy` +// is made of, where an adapter has it to give. +// - listeners without a group each receive every message; listeners +// sharing a group compete, one of them per message. +// - messages from one sender to one address arrive in send order. +// send(address, body, { headers, correlationId, replyTo, timeout }) +// -> Promise +// - at-most-once: the RPC binding numbers its frames and treats a gap +// as a closed connection. MAY reject with a coded 503 when the broker +// knows nobody listens; `timeout` lets it discard a message nobody +// took in time. +// message: { body, headers, correlationId, replyTo } — body is a string +// or a Uint8Array, as sent. + +const { isBackplane } = require('../scaling/index.js'); + +const isObject = (value) => typeof value === 'object' && value !== null; +const hasFunctions = (value, names) => isObject(value) && names.every((name) => typeof value[name] === 'function'); + +const isBrokerLog = (value) => hasFunctions(value, ['append', 'read', 'parseId']); +const isBrokerQueue = (value) => hasFunctions(value, ['produce', 'consume']); +const isBrokerDirect = (value) => hasFunctions(value, ['inbox', 'listen', 'send']); + +// A listener's stop() that also answers `healthy` (see the direct contract). +const withHealth = (stop, healthy) => Object.defineProperty(stop, 'healthy', { get: healthy, enumerable: true }); + +const CAPABILITIES = Object.freeze({ + backplane: isBackplane, + log: isBrokerLog, + queue: isBrokerQueue, + direct: isBrokerDirect, +}); + +// A broker exposes at least one capability, each one well-shaped when +// present, and a close(). A present-but-malformed capability is a broken +// adapter, not a missing feature — refused rather than silently skipped. +const isBroker = (value) => { + if (!hasFunctions(value, ['close'])) return false; + let present = 0; + for (const name in CAPABILITIES) { + if (value[name] === undefined || value[name] === null) continue; + if (!CAPABILITIES[name](value[name])) return false; + present++; + } + return present > 0; +}; + +// What every scenario entry point accepts: the whole broker, or the bare +// capability object (a hand-written log, a test double). +const capabilityOf = (value, name, label) => { + const check = CAPABILITIES[name]; + if (check(value)) return value; + if (isObject(value) && check(value[name])) return value[name]; + const what = isObject(value) && typeof value.name === 'string' ? `the '${value.name}' broker` : 'the given broker'; + throw new TypeError(`${label}: ${what} has no '${name}' capability`); +}; + +// The broker's name for metric labels and log fields; 'custom' for a bare +// capability nobody named. +const brokerName = (value) => + isObject(value) && typeof value.name === 'string' && value.name.length > 0 ? value.name : 'custom'; + +module.exports = { + isBroker, + isBrokerLog, + isBrokerQueue, + isBrokerDirect, + isBackplane, + capabilityOf, + brokerName, + withHealth, +}; diff --git a/src/broker/publisher.js b/src/broker/publisher.js new file mode 100644 index 0000000..f6db1e7 --- /dev/null +++ b/src/broker/publisher.js @@ -0,0 +1,140 @@ +'use strict'; + +// Publishing a unit's declared outbound events (`emits`) to a broker. +// +// `emits` stays a declaration — signature descriptors for `wrpc types`, +// no runtime behaviour — so a publisher is bound to it by NAME: every event +// it may publish must be declared, which is what keeps a typo from quietly +// creating a topic nobody consumes. The runtime checks (a validator per +// event) live in the publisher's own table, never inside `emits`, whose +// content has to stay JSON for introspection. +// +// Each publish is a PRODUCER span, and the active trace context rides in +// the message headers (`tp`/`ts`), so the consumer on the other side parents +// its CONSUMER span on it. + +const { runValidator } = require('../rpc/router.js'); +const { SPAN_KIND_PRODUCER, TRACEPARENT, TRACESTATE } = require('../telemetry/shared.js'); +const { capabilityOf, brokerName, isBrokerLog, isBrokerQueue } = require('./port.js'); +const { codedError } = require('./ids.js'); +const { createBrokerSealing } = require('./sealing.js'); +const { rpcOf } = require('./host.js'); + +const isValidatorShape = (value) => + typeof value === 'function' || (typeof value === 'object' && value !== null && '~standard' in value); + +const createPublisher = (server, broker, table = {}, options = {}) => { + const rpc = rpcOf(server, 'createPublisher'); + if (typeof table !== 'object' || table === null || Array.isArray(table)) { + throw new TypeError('createPublisher: the event table must be an object'); + } + const { strict = true, encryption = null } = options; + // Off by default. On, what rests in the topic is sealed under the shared + // keyring — the value AND its headers (the trace context, whatever the + // caller added), bound to the topic it was published to. The partition + // `key` stays readable: the broker routes by it. + const sealing = createBrokerSealing(encryption, 'createPublisher: options', { + layer: 'broker-log', + replay: false, + text: true, + }); + const hasLog = isBrokerLog(broker) || isBrokerLog(broker?.log); + const hasQueue = isBrokerQueue(broker) || isBrokerQueue(broker?.queue); + if (!hasLog && !hasQueue) { + throw new TypeError('createPublisher: the broker has neither a log nor a queue capability'); + } + const system = brokerName(broker); + const declared = rpc.router.introspect(null, { schemas: false }); + + const events = new Map(); + for (const name of Object.keys(table)) { + const label = `createPublisher: '${name}'`; + const slash = name.indexOf('/'); + if (slash <= 0 || slash === name.length - 1) throw new TypeError(`${label}: an event name is 'unit/event'`); + const unitKey = name.slice(0, slash); + const event = name.slice(slash + 1); + if (strict && !Object.hasOwn(declared[unitKey]?.emits ?? {}, event)) { + throw new TypeError(`${label}: the router declares no such event in ${unitKey}.emits`); + } + const entry = table[name] ?? {}; + const { topic = `${unitKey}.${event}`, to = hasLog ? 'log' : 'queue', key = null, validate = null } = entry; + if (typeof topic !== 'string' || topic.length === 0) { + throw new TypeError(`${label}: topic must be a non-empty string`); + } + if (to !== 'log' && to !== 'queue') throw new TypeError(`${label}: to must be 'log' or 'queue'`); + const target = capabilityOf(broker, to, label); + if (key !== null && typeof key !== 'string' && typeof key !== 'function') { + throw new TypeError(`${label}: key must be a string or a (data) => string function`); + } + if (validate !== null && !isValidatorShape(validate)) { + throw new TypeError(`${label}: validate must be a function or a Standard Schema`); + } + events.set(name, { topic, to, target, key, validate }); + } + + const publish = async (name, data, { headers = null, key: keyOverride = null } = {}) => { + const event = events.get(name); + if (!event) throw new TypeError(`createPublisher: '${name}' is not in this publisher's table`); + const { topic, to, target, validate } = event; + const otel = rpc.otel; + const attributes = { + 'messaging.system': system, + 'messaging.destination.name': topic, + 'messaging.operation.type': 'send', + 'wrpc.event.name': name, + }; + return otel.withMessagingSpan( + { name: `${topic} publish`, kind: SPAN_KIND_PRODUCER, attributes }, + async (handle) => { + try { + let value = data; + if (validate) { + try { + const checked = runValidator(validate, data); + value = checked && typeof checked.then === 'function' ? await checked : checked; + } catch (error) { + const refused = codedError(`Invalid ${name} payload: ${error.message}`, 400); + if (error.details !== undefined) refused.details = error.details; + throw refused; + } + } + const carried = headers ? { ...headers } : {}; + // The trace context rides under the W3C header names — a message + // header is a header, and a consumer that is not wrpc reads + // `traceparent`, not the packet field's `tp`. The injector writes + // the packet names; renamed here, at the one place a message is + // built. + const context = {}; + otel.inject(context); + if (context[TRACEPARENT] !== undefined) carried.traceparent = context[TRACEPARENT]; + if (context[TRACESTATE] !== undefined) carried.tracestate = context[TRACESTATE]; + const key = keyOverride ?? (typeof event.key === 'function' ? event.key(value) : event.key); + const plain = JSON.stringify(value); + const out = sealing === null ? { headers: carried, body: plain } : sealing.seal(topic, carried, plain); + const { body } = out; + const message = + key === null || key === undefined ? { headers: out.headers } : { headers: out.headers, key: String(key) }; + const result = + to === 'log' ? await target.append(topic, body, message) : await target.produce(topic, body, message); + otel.recordBrokerPublish(system, 'ok'); + return to === 'log' ? result : undefined; + } catch (error) { + otel.recordBrokerPublish(system, 'error'); + otel.recordError(handle, error, typeof error.code === 'number' ? error.code : 500); + throw error; + } finally { + otel.endSpan(handle); + } + }, + ); + }; + + return { + publish, + get events() { + return Array.from(events.keys()); + }, + }; +}; + +module.exports = { createPublisher }; diff --git a/src/broker/redis/index.js b/src/broker/redis/index.js new file mode 100644 index 0000000..09076aa --- /dev/null +++ b/src/broker/redis/index.js @@ -0,0 +1,795 @@ +'use strict'; + +// The Redis broker: all four capabilities over an INJECTED ioredis-shaped +// client (per the zero-dependency rule — `ioredis` is a devDependency of +// this repository and never a runtime one). Anything with the same command +// surface plugs in: ioredis, a node-redis wrapper, Valkey, KeyDB, Dragonfly. +// +// const Redis = require('ioredis'); +// const broker = createRedisBroker({ client: new Redis(url) }); +// +// Capability map: +// backplane PUBLISH / SUBSCRIBE (the scaling adapter) +// log Streams: XADD, XRANGE, XREAD BLOCK (one shared tail per +// instance, TopicTails) +// queue Streams + consumer groups: XREADGROUP, +// XACK + XDEL, XAUTOCLAIM for what a dead +// consumer held, a ZSET for delayed retries +// direct PUBLISH for inboxes, a list (BLMPOP) for +// competing groups +// +// Connections: the injected one runs every command; blocking reads +// (XREAD/XREADGROUP/BLMPOP) and subscriptions each need one of their own, so +// the adapter duplicates lazily — one for the log tail, one per queue +// consumer, one per direct group, one for pub/sub. `duplicate()` (or an +// injected `connect` factory) is what it duplicates with, and it quits only +// the connections it opened itself. + +const { createRedisAdapter } = require('../../scaling/redis.js'); +const { createLoggerWriter } = require('../../logging.js'); +const { TopicTails } = require('../tail.js'); +const { codedError, toText, toBytes, toHeaders, reasonText, encodeToken } = require('../ids.js'); +const { positiveInteger } = require('../retry.js'); +const { withHealth } = require('../port.js'); +const { + ATTEMPT_HEADER, + REDELIVERED_HEADER, + DEAD_REASON_HEADER, + DEFAULT_PREFETCH, + idFactory, + failedRead, + guardedRead, + checkConsume, + checkListen, + runDelivery, +} = require('../adapter.js'); + +const DEFAULT_PREFIX = 'wrpc'; +const DEFAULT_BLOCK_MS = 1000; +const DEFAULT_CLAIM_IDLE_MS = 60_000; +const DEFAULT_INBOX_TTL_MS = 60_000; +const STREAM_ID = /^\d{1,20}-\d{1,20}$/; + +const isFunction = (value) => typeof value === 'function'; + +// Whether an injected connection says it is up, in the two shapes there +// are: ioredis' `status` string and node-redis' `isReady`. One that says +// nothing is taken to be. +const connected = (connection) => { + if (typeof connection.status === 'string') return connection.status === 'ready' || connection.status === 'connect'; + return connection.isReady !== false; +}; + +// A stream id is `-`: compared numerically, part by part, because +// both halves outgrow Number's safe range in neither case but the STRING +// order is wrong ('10-0' < '9-0'). +const compareIds = (a, b) => { + const [ams, aseq] = a.split('-'); + const [bms, bseq] = b.split('-'); + if (ams.length !== bms.length) return ams.length < bms.length ? -1 : 1; + if (ams !== bms) return ams < bms ? -1 : 1; + if (aseq.length !== bseq.length) return aseq.length < bseq.length ? -1 : 1; + return aseq === bseq ? 0 : aseq < bseq ? -1 : 1; +}; + +// XADD field pairs: the value, and the headers as one JSON field. Two +// fields rather than one per header — a header name is application data and +// must never become a Redis field name pattern. +const encodeFields = (value, headers) => { + const fields = ['v', toText(value)]; + const bag = headers ? toHeaders(headers) : null; + if (bag && Object.keys(bag).length > 0) fields.push('h', JSON.stringify(bag)); + return fields; +}; + +const decodeEntry = (id, fields) => { + let value = ''; + let headers = null; + for (let i = 0; i < fields.length; i += 2) { + if (fields[i] === 'v') value = fields[i + 1]; + else if (fields[i] === 'h') headers = fields[i + 1]; + } + let parsed = null; + if (headers) { + try { + parsed = JSON.parse(headers); + } catch { + parsed = null; + } + } + return { id, value, headers: toHeaders(parsed) }; +}; + +const checkClient = (client, label) => { + if (!client || !isFunction(client.xadd) || !isFunction(client.publish) || !isFunction(client.xreadgroup)) { + throw new TypeError(`${label}: options.client must be an ioredis-shaped client (xadd/xreadgroup/publish/...)`); + } + return client; +}; + +const createRedisBroker = (options = {}) => { + const { + client, + subscriber = null, + connect = null, + prefix = DEFAULT_PREFIX, + logger = globalThis.console, + blockMs = DEFAULT_BLOCK_MS, + claimIdleMs = DEFAULT_CLAIM_IDLE_MS, + maxLen = 0, + inboxTtl = DEFAULT_INBOX_TTL_MS, + generateId = null, + } = options; + // Ids: an injected generator is used verbatim — see idFactory. + const { nextId, shortName } = idFactory(generateId, 'createRedisBroker'); + checkClient(client, 'createRedisBroker'); + // Strict numbers, refused here: `blockMs: 0` is `XREAD BLOCK 0` — forever — + // and no pause after a failed read, a hot loop; `claimIdleMs: 0` steals + // every other consumer's in-flight message; and a string read from an + // environment variable compares as a number in each of these places and + // does the wrong thing silently. + if (!positiveInteger(blockMs)) { + throw new TypeError('createRedisBroker: options.blockMs must be a positive integer of milliseconds'); + } + if (!positiveInteger(claimIdleMs)) { + throw new TypeError('createRedisBroker: options.claimIdleMs must be a positive integer of milliseconds'); + } + if (!Number.isInteger(maxLen) || maxLen < 0) { + throw new TypeError('createRedisBroker: options.maxLen must be a non-negative integer (0 for unbounded)'); + } + if (!positiveInteger(inboxTtl)) { + throw new TypeError('createRedisBroker: options.inboxTtl must be a positive integer of milliseconds'); + } + if (connect !== null && !isFunction(connect)) { + throw new TypeError('createRedisBroker: options.connect must be a function returning a new client'); + } + if (connect === null && !isFunction(client.duplicate)) { + throw new TypeError('createRedisBroker: options.client must support duplicate(), or pass options.connect'); + } + const log = createLoggerWriter(logger).child({ component: 'broker', broker: 'redis' }); + const owned = new Set(); + let closed = false; + + // Every blocking read and every subscription needs a connection of its + // own; they are opened on demand and quit by close() — an INJECTED client + // never is, its lifetime is the caller's. + const spawn = () => { + const created = connect ? connect() : client.duplicate(); + owned.add(created); + return created; + }; + + const key = (kind, name) => `${prefix}:${kind}:${encodeToken(name, { maxLength: 200 })}`; + const report = (event, error, extra = {}) => log.error({ err: error, event, ...extra }); + + // --------------------------------------------------------------------- + // log + + const streamKey = (topic) => key('log', topic); + + const tip = async (topic) => { + const rows = await client.xrevrange(streamKey(topic), '+', '-', 'COUNT', 1); + return rows.length > 0 ? rows[0][0] : null; + }; + + const firstId = async (topic) => { + try { + const info = await client.xinfo('STREAM', streamKey(topic)); + const at = info.indexOf('first-entry'); + const entry = at >= 0 ? info[at + 1] : null; + return entry ? entry[0] : null; + } catch { + return null; // no such key: an empty stream + } + }; + + const range = async (topic, { after, limit }) => { + const start = after === null || after === undefined ? '-' : `(${after}`; + const rows = await client.xrange(streamKey(topic), start, '+', 'COUNT', limit); + const entries = new Array(rows.length); + for (let i = 0; i < rows.length; i++) entries[i] = decodeEntry(rows[i][0], rows[i][1]); + return entries; + }; + + // One blocking XREAD loop per instance, multiplexed over every topic with + // a live reader. A blocked read cannot be interrupted, so a topic added or + // dropped takes effect within `blockMs` — which is why the default is a + // second rather than the minutes a single-topic reader could afford. + const tail = { + connection: null, + cursors: new Map(), // topic -> last id read + handlers: new Map(), // topic -> onEntry + running: false, + }; + + const pump = async () => { + if (tail.running) return; + tail.running = true; + try { + for (;;) { + if (closed || tail.handlers.size === 0) return; + const topics = Array.from(tail.handlers.keys()); + const keys = topics.map(streamKey); + const ids = topics.map((topic) => tail.cursors.get(topic) ?? '$'); + let rows; + try { + rows = await tail.connection.xread('COUNT', 512, 'BLOCK', blockMs, 'STREAMS', ...keys, ...ids); + } catch (error) { + if (closed) return; + report('broker.redis.tail', error); + await new Promise((resolve) => setTimeout(resolve, Math.min(blockMs, 1000))); + continue; + } + if (!rows) continue; + for (const [name, entries] of rows) { + const topic = topics[keys.indexOf(name)]; + const onEntry = tail.handlers.get(topic); + for (const [id, fields] of entries) { + tail.cursors.set(topic, id); + if (onEntry) onEntry(decodeEntry(id, fields)); + } + } + } + } finally { + tail.running = false; + } + }; + + const tails = new TopicTails({ + // Readers that fell behind the tail and are catching up through range(): + // info, not a fault — but the first thing to look at when a feed is slow. + onLag: (topic, readers) => log.info({ event: 'broker.tail.lag', topic, readers }), + live: async (topic, { signal, onEntry }) => { + if (!tail.connection) tail.connection = spawn(); + const from = (await tip(topic)) ?? '0-0'; + tail.cursors.set(topic, from); + tail.handlers.set(topic, onEntry); + signal.addEventListener( + 'abort', + () => { + tail.handlers.delete(topic); + tail.cursors.delete(topic); + }, + { once: true }, + ); + void pump(); + return from === '0-0' ? null : from; + }, + range, + covered: (cursor, entry) => compareIds(entry.id, cursor) <= 0, + advance: (_cursor, entry) => entry.id, + }); + + const parseId = (text) => (typeof text === 'string' && text.length <= 64 && STREAM_ID.test(text) ? text : null); + + const read = (topic, options = {}) => { + const { after = null, from = 'latest', signal = null } = options; + if (from !== 'latest' && from !== 'earliest') { + throw new TypeError("redis log.read: from must be 'latest' or 'earliest'"); + } + if (after === null || after === undefined) return tails.read(topic, { from, signal }); + if (parseId(after) === null) { + return failedRead(codedError('Malformed event id', 400)); + } + // The position is checked before the tail joins: an id past the tip is + // a client asking for a future, an id below the first retained one is a + // gap the retention created. + // Awaited by the iteration below AND by `ready`; a read nobody awaits + // must not raise an unhandled rejection. + const checked = (async () => { + const [last, first] = await Promise.all([tip(topic), firstId(topic)]); + if (last === null || compareIds(after, last) > 0) throw codedError('Event id is beyond the end of the log', 400); + if (first !== null && compareIds(after, first) < 0) { + throw codedError('Event history was trimmed past this id', 410); + } + })(); + checked.catch(() => {}); + return guardedRead(tails.read(topic, { after, signal }), { guard: checked }); + }; + + const append = async (topic, value, { headers = null } = {}) => { + if (closed) throw codedError('Broker is closed', 503); + const fields = encodeFields(value, headers); + if (maxLen > 0) return client.xadd(streamKey(topic), 'MAXLEN', '~', String(maxLen), '*', ...fields); + return client.xadd(streamKey(topic), '*', ...fields); + }; + + // --------------------------------------------------------------------- + // queue + + // A queue's stream and its delayed set live under ONE hash tag — the + // braces are what Redis Cluster hashes on — so the promotion of a delayed + // retry (a Lua script over both keys) runs on a cluster too. + const queueKey = (name) => `${prefix}:q:{${encodeToken(name, { maxLength: 200 })}}`; + const delayedKey = (name) => `${queueKey(name)}:delayed`; + // ZREM then XADD in one step: a delayed retry either leaves the set and + // lands on the stream, or does neither. Without EVAL (an injected client + // that lacks it, a proxy that refuses scripts) the two steps run apart, + // with the set entry put back when the second fails. + const PROMOTE = + "if redis.call('ZREM', KEYS[1], ARGV[1]) == 1 then return redis.call('XADD', KEYS[2], '*', unpack(ARGV, 2)) end return false"; + const canEval = isFunction(client.eval); + + const ensureGroup = async (name, group) => { + try { + await client.xgroup('CREATE', queueKey(name), group, '0', 'MKSTREAM'); + } catch (error) { + // BUSYGROUP: the group is already there, with its own position. + if (!/BUSYGROUP/.test(String(error?.message))) throw error; + } + }; + + const produce = async (name, body, { headers = null } = {}) => { + if (closed) throw codedError('Broker is closed', 503); + await client.xadd(queueKey(name), '*', ...encodeFields(body, headers)); + }; + + const consume = async (name, onDelivery, options = {}) => { + if (closed) throw codedError('Broker is closed', 503); + const { group = name, prefetch = DEFAULT_PREFETCH, deadLetter = null, signal = null } = options; + checkConsume('redis queue.consume', onDelivery, prefetch, deadLetter); + const consumerName = `wrpc-${shortName()}`; + const stream = queueKey(name); + await ensureGroup(name, group); + const connection = spawn(); + const state = { running: true, paused: false, inflight: 0, healthy: true, timer: null }; + // What this consumer is holding right now: XAUTOCLAIM would otherwise + // claim its own in-flight entries back and deliver them twice. + const held = new Set(); + // Entries XREADGROUP already moved into the pending list when a pause + // landed: holding them here (rather than dropping them for XAUTOCLAIM) + // is what makes resume() immediate. + const buffered = []; + + const settleDone = (id) => { + held.delete(id); + state.inflight--; + if (state.running && !state.paused) void loop(); + }; + + const requeue = async (message, headers) => { + // Streams have no "put it back": the copy carries the attempt count, + // and the original leaves the pending list for good. + await client.xadd(stream, '*', ...encodeFields(message.body, headers)); + }; + + const dispatch = (id, fields, { claimed = false } = {}) => { + const entry = decodeEntry(id, fields); + const attempt = Number(entry.headers[ATTEMPT_HEADER] ?? '1') || 1; + const message = { id, body: entry.value, headers: entry.headers }; + let settled = false; + const finish = async (work) => { + if (settled) return; + settled = true; + try { + await work(); + } catch (error) { + report('broker.redis.settle', error, { queue: name }); + } finally { + settleDone(id); + } + }; + const drop = async () => { + await client.xack(stream, group, id); + await client.xdel(stream, id); + }; + const copyHeaders = (extra) => { + const headers = { ...entry.headers, ...extra }; + delete headers[REDELIVERED_HEADER]; + return { ...headers, [REDELIVERED_HEADER]: '1' }; + }; + const delivery = Object.freeze({ + id, + body: entry.value, + headers: entry.headers, + attempt, + // Either a copy this adapter re-added, or an entry reclaimed from a + // consumer that stopped holding it. + redelivered: claimed || entry.headers[REDELIVERED_HEADER] === '1', + ack: () => finish(drop), + retry: ({ delay = 0 } = {}) => + finish(async () => { + const headers = copyHeaders({ [ATTEMPT_HEADER]: String(attempt + 1) }); + if (delay > 0) { + // The member carries the message id: two retries of one + // body and one header bag used to be ONE member of the set, + // and the second overwrote the first — a message gone. + await client.zadd( + delayedKey(name), + String(Date.now() + delay), + JSON.stringify({ id, body: message.body, headers }), + ); + } else await requeue(message, headers); + await drop(); + }), + release: () => + finish(async () => { + await requeue(message, copyHeaders({ [ATTEMPT_HEADER]: String(attempt) })); + await drop(); + }), + deadLetter: (reason = '') => + finish(async () => { + if (deadLetter) { + const headers = { + ...entry.headers, + [DEAD_REASON_HEADER]: reasonText(reason), + [ATTEMPT_HEADER]: String(attempt), + }; + await client.xadd(queueKey(deadLetter), '*', ...encodeFields(message.body, headers)); + } + await drop(); + }), + }); + state.inflight++; + held.add(id); + runDelivery(onDelivery, delivery, report, 'broker.redis.delivery', name); + }; + + // A delayed retry that left the set but never reached the stream, and + // could not be put back: held here for the next sweep, never dropped. + const stranded = []; + const promote = async (item, score) => { + const { body, headers } = JSON.parse(item); + const fields = encodeFields(body, headers); + if (canEval) { + try { + await client.eval(PROMOTE, 2, delayedKey(name), stream, item, ...fields); + return; + } catch (error) { + // CROSSSLOT (keys the tag did not keep together on an older + // layout) or NOSCRIPT policy: the two-step path below. + if (!/CROSSSLOT|NOSCRIPT|unknown command/i.test(String(error?.message))) throw error; + } + } + if (score === null) { + const removed = await client.zrem(delayedKey(name), item); + if (removed === 0) return; // another instance took it + } + try { + await client.xadd(stream, '*', ...fields); + } catch (error) { + // The claim is ours and the stream refused: back on the set, at its + // original time, so another sweep — anyone's — takes it; and if + // even that fails, kept in memory until the next sweep here. + const at = score ?? Date.now(); + try { + await client.zadd(delayedKey(name), String(at), item); + } catch { + stranded.push({ item, score: at }); + } + throw error; + } + }; + + // Delayed retries and whatever a dead consumer still holds. + const sweep = async () => { + if (!state.running || state.paused || closed) return; + try { + // What an earlier sweep took off the set but could not put on the + // stream, and could not put back either: tried again before + // anything new. + for (const { item, score } of stranded.splice(0)) await promote(item, score); + const due = await client.zrangebyscore(delayedKey(name), '-inf', String(Date.now()), 'LIMIT', 0, 64); + for (const item of due) await promote(item, null); + const [, claimed] = await client.xautoclaim(stream, group, consumerName, String(claimIdleMs), '0', 'COUNT', 16); + for (const row of claimed ?? []) { + if (!row || !row[1] || held.has(row[0])) continue; + if (state.paused || !state.running || state.inflight >= prefetch) break; + dispatch(row[0], row[1], { claimed: true }); + } + } catch (error) { + if (!closed) report('broker.redis.sweep', error, { queue: name }); + } + }; + + let reading = false; + const loop = async () => { + if (reading || !state.running || state.paused) return; + reading = true; + try { + for (;;) { + if (!state.running || state.paused || closed) return; + const capacity = prefetch - state.inflight; + if (capacity <= 0) return; + let rows; + try { + rows = await connection.xreadgroup( + 'GROUP', + group, + consumerName, + 'COUNT', + capacity, + 'BLOCK', + blockMs, + 'STREAMS', + stream, + '>', + ); + state.healthy = true; + } catch (error) { + if (closed || !state.running) return; + state.healthy = false; + report('broker.redis.read', error, { queue: name }); + await new Promise((resolve) => setTimeout(resolve, Math.min(blockMs, 1000))); + continue; + } + if (!rows) continue; + for (const [, entries] of rows) { + for (const [id, fields] of entries) { + if (state.paused || !state.running) buffered.push([id, fields]); + else dispatch(id, fields); + } + } + } + } finally { + reading = false; + } + }; + + const interval = Math.max(50, Math.min(claimIdleMs, blockMs)); + state.timer = setInterval(() => void sweep(), interval); + if (isFunction(state.timer.unref)) state.timer.unref(); + void loop(); + + const stop = async () => { + if (!state.running) return; + state.running = false; + stops.delete(stop); + clearInterval(state.timer); + if (owned.delete(connection)) await quit(connection, { force: true }); + // What this consumer still holds stays in the group's pending list; + // another consumer claims it through XAUTOCLAIM. + }; + stops.add(stop); + if (signal) signal.addEventListener('abort', () => void stop(), { once: true }); + return { + stop, + pause: async () => { + state.paused = true; + }, + resume: async () => { + state.paused = false; + while (buffered.length > 0 && state.inflight < prefetch) { + const [id, fields] = buffered.shift(); + dispatch(id, fields); + } + void loop(); + }, + get healthy() { + return state.running && state.healthy && !closed; + }, + }; + }; + + // --------------------------------------------------------------------- + // direct + + const inboxKey = (address) => key('inbox', address); + const listKey = (address) => `${inboxKey(address)}:list`; + // A group's live members: one sorted-set entry per listener, scored with + // the expiry of its lease. It used to be ONE presence string for the + // whole group, refreshed by every member and DELETED by whichever member + // stopped first — so the moment one instance of a service went away, a + // sender found "nobody there" (503) while the others were still taking + // work off the list. + const membersKey = (address) => `${inboxKey(address)}:members`; + // Everything close() has to stop: group listeners (a heartbeat, a lease) + // and queue consumers (a sweep timer). They used to outlive close(). + const stops = new Set(); + + const encodeMessage = (body, { headers, correlationId, replyTo }) => { + const binary = typeof body !== 'string'; + return JSON.stringify({ + b: binary ? Buffer.from(toBytes(body)).toString('base64') : toText(body), + n: binary ? 1 : 0, + h: headers ? toHeaders(headers) : null, + c: correlationId ?? null, + r: replyTo ?? null, + }); + }; + + const decodeMessage = (text) => { + const frame = JSON.parse(text); + return { + body: frame.n ? new Uint8Array(Buffer.from(frame.b, 'base64')) : frame.b, + headers: toHeaders(frame.h), + correlationId: frame.c ?? null, + replyTo: frame.r ?? null, + }; + }; + + let pubsub = null; // the one subscribed connection for plain listeners + const plain = new Map(); // channel -> Set + + const ensurePubsub = () => { + if (pubsub) return pubsub; + pubsub = subscriber ?? spawn(); + pubsub.on('message', (channel, message) => { + const handlers = plain.get(channel); + if (!handlers) return; + let decoded; + try { + decoded = decodeMessage(message); + } catch (error) { + return void report('broker.redis.decode', error, { channel }); + } + for (const handler of Array.from(handlers)) { + try { + const result = handler(decoded); + if (result && isFunction(result.catch)) result.catch((error) => report('broker.redis.listener', error)); + } catch (error) { + report('broker.redis.listener', error); + } + } + }); + pubsub.on('error', (error) => report('broker.redis.pubsub', error)); + return pubsub; + }; + + const listenPlain = async (address, onMessage) => { + const channel = inboxKey(address); + const connection = ensurePubsub(); + let handlers = plain.get(channel); + if (!handlers) { + handlers = new Set(); + plain.set(channel, handlers); + await connection.subscribe(channel); + } + handlers.add(onMessage); + const stop = async () => { + const current = plain.get(channel); + if (!current || !current.delete(onMessage) || current.size > 0) return; + plain.delete(channel); + if (isFunction(connection.unsubscribe)) await connection.unsubscribe(channel).catch(() => {}); + }; + // An inbox is a subscription: as alive as the connection it is on. + return withHealth(stop, () => !closed && connected(connection)); + }; + + // Competing listeners take from a list, which is what makes delivery + // exactly-one-of-them. A presence key (refreshed while the listener runs) + // is how a sender knows a group is there at all — without one, the list + // would grow for nobody. + const listenGroup = async (address, onMessage) => { + const list = listKey(address); + const members = membersKey(address); + const member = nextId(); + const connection = spawn(); + let running = true; + // What `stop.healthy` answers: false while the blocking read or the + // presence lease is failing — a member whose lease lapsed is sent + // nothing, and one whose read fails takes nothing. + let reading = true; + let leased = true; + // The lease: this member's score is when its presence expires, by this + // process's clock — the instances' clocks must agree within inboxTtl. + // The key itself expires when nobody refreshes it, and members whose + // lease ran out (a crashed listener) are pruned by whoever refreshes. + const lease = async () => { + const at = Date.now(); + await client.zadd(members, String(at + inboxTtl), member); + await client.pexpire(members, String(inboxTtl * 2)); + if (isFunction(client.zremrangebyscore)) await client.zremrangebyscore(members, '-inf', String(at - inboxTtl)); + }; + await lease(); + const beat = setInterval( + () => { + lease().then( + () => { + leased = true; + }, + (error) => { + leased = false; + report('broker.redis.presence', error, { address }); + }, + ); + }, + Math.max(1000, inboxTtl / 3), + ); + if (isFunction(beat.unref)) beat.unref(); + const loop = async () => { + for (;;) { + if (!running || closed) return; + let popped; + try { + popped = await connection.blpop(list, Math.max(1, Math.round(blockMs / 1000))); + reading = true; + } catch (error) { + if (!running || closed) return; + reading = false; + report('broker.redis.blpop', error, { address }); + await new Promise((resolve) => setTimeout(resolve, Math.min(blockMs, 1000))); + continue; + } + if (!popped) continue; + try { + const message = decodeMessage(popped[1]); + const result = onMessage(message); + if (result && isFunction(result.catch)) result.catch((error) => report('broker.redis.listener', error)); + } catch (error) { + report('broker.redis.listener', error, { address }); + } + } + }; + void loop(); + const stop = async () => { + if (!running) return; + running = false; + stops.delete(stop); + clearInterval(beat); + await client.zrem(members, member).catch(() => {}); + if (owned.delete(connection)) await quit(connection, { force: true }); + }; + stops.add(stop); + return withHealth(stop, () => running && reading && leased && !closed && connected(connection)); + }; + + const listen = async (address, onMessage, { group = null } = {}) => { + if (closed) throw codedError('Broker is closed', 503); + checkListen('redis direct.listen', address, onMessage); + return group === null || group === undefined ? listenPlain(address, onMessage) : listenGroup(address, onMessage); + }; + + const send = async (address, body, options = {}) => { + if (closed) throw codedError('Broker is closed', 503); + const payload = encodeMessage(body, options); + const channel = inboxKey(address); + const [subscribers, alive] = await Promise.all([ + client.publish(channel, payload), + client.zrangebyscore(membersKey(address), String(Date.now()), '+inf', 'LIMIT', 0, 1), + ]); + if (alive.length > 0) { + // A TTL so a group that died does not leave work piling up forever; + // the timeout hint (an RPC deadline) shortens it further. + const ttl = Math.max(1, Math.min(options.timeout ?? inboxTtl, inboxTtl)); + await client.rpush(listKey(address), payload); + await client.pexpire(listKey(address), String(ttl)); + return; + } + // Nobody at all: the fast 503 an RPC caller wants instead of a timeout. + if (subscribers === 0) throw codedError(`No listener at ${address}`, 503); + }; + + // --------------------------------------------------------------------- + + // A connection parked in a blocking read cannot answer QUIT until the + // block expires, so the ones this adapter blocks on are dropped instead. + const quit = async (connection, { force = false } = {}) => { + try { + if (force && isFunction(connection.disconnect)) return void connection.disconnect(); + if (isFunction(connection.quit)) await connection.quit(); + else if (isFunction(connection.disconnect)) connection.disconnect(); + } catch (error) { + report('broker.redis.quit', error); + } + }; + + const backplane = createRedisAdapter({ pub: client, sub: subscriber ?? undefined, prefix, logger }); + + const close = async () => { + if (closed) return; + closed = true; + tails.close(); + tail.handlers.clear(); + backplane.close(); + plain.clear(); + // Heartbeats, leases and sweep timers go with the broker: they used to + // keep firing against the injected client after close(). + for (const stop of Array.from(stops)) await stop(); + for (const connection of Array.from(owned)) { + owned.delete(connection); + await quit(connection, { force: connection === tail.connection }); + } + }; + + return { + name: 'redis', + backplane, + log: Object.freeze({ name: 'redis', append, read, parseId }), + queue: Object.freeze({ name: 'redis', produce, consume }), + direct: Object.freeze({ name: 'redis', inbox: () => `${prefix}.inbox.${nextId()}`, listen, send }), + close, + }; +}; + +module.exports = { createRedisBroker, compareIds }; diff --git a/src/broker/retry.js b/src/broker/retry.js new file mode 100644 index 0000000..6ab4dc0 --- /dev/null +++ b/src/broker/retry.js @@ -0,0 +1,67 @@ +'use strict'; + +// What a consumed message's outcome becomes: ack, retry (with a delay), +// release, or the dead-letter queue. One table for every broker, so a +// procedure's failure means the same thing on Redis and on Kafka. + +const { backoffDelay } = require('../utils.js'); + +// Transient by default — 500 included: a queue's 500 is usually a +// dependency that fell over (a database blip), and at-least-once already +// demands an idempotent handler. 408 is retried for the same reason. +const DEFAULT_RETRY_ON = Object.freeze([408, 429, 500, 503]); + +const DEFAULT_RETRY = Object.freeze({ + attempts: 5, + backoff: Object.freeze({ base: 1000, max: 60_000, factor: 2, jitter: true }), + retryOn: DEFAULT_RETRY_ON, +}); + +const positiveInteger = (value) => Number.isInteger(value) && value > 0; +const nonNegative = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0; + +const normalizeRetry = (options, label) => { + if (options === undefined || options === null) return DEFAULT_RETRY; + if (options === false) return Object.freeze({ ...DEFAULT_RETRY, attempts: 1 }); + if (typeof options !== 'object') throw new TypeError(`${label}: retry must be an object or false`); + const { attempts = DEFAULT_RETRY.attempts, backoff = {}, retryOn = DEFAULT_RETRY_ON } = options; + if (!positiveInteger(attempts)) throw new TypeError(`${label}: retry.attempts must be a positive integer`); + if (typeof backoff !== 'object' || backoff === null) { + throw new TypeError(`${label}: retry.backoff must be an object`); + } + const merged = { ...DEFAULT_RETRY.backoff, ...backoff }; + if (!nonNegative(merged.base) || !nonNegative(merged.max) || !(merged.factor > 0)) { + throw new TypeError(`${label}: retry.backoff needs non-negative base/max and a positive factor`); + } + if (!Array.isArray(retryOn) || !retryOn.every((code) => Number.isInteger(code))) { + throw new TypeError(`${label}: retry.retryOn must be an array of status codes`); + } + return Object.freeze({ attempts, backoff: Object.freeze(merged), retryOn: Object.freeze([...retryOn]) }); +}; + +/** + * `code` is null for a success. `attempt` is 1-based: attempt N failing + * with a retryable code is retried while N < attempts. + */ +const decide = ({ code, attempt, retry = DEFAULT_RETRY, draining = false, random = Math.random }) => { + if (code === null || code === undefined) return { action: 'ack', delay: 0 }; + // A node shutting down refuses new work with 503: that is not the + // message's fault, and burning one of its attempts on it would be. + if (draining && code === 503) return { action: 'release', delay: 0 }; + if (!retry.retryOn.includes(code)) return { action: 'dead', delay: 0 }; + if (attempt >= retry.attempts) return { action: 'dead', delay: 0 }; + const { base, max, factor, jitter } = retry.backoff; + const delay = backoffDelay({ attempt: attempt - 1, minDelay: base, maxDelay: max, factor, jitter, random }); + return { action: 'retry', delay }; +}; + +// The delay before a delivery whose HANDLER threw (or rejected) comes back. +// It settled nothing, so the adapter retries it — attempt + 1, after this +// backoff — rather than releasing it: a release puts the message back at the +// head, and a handler that always throws then spins the queue hot (the +// memory broker re-dispatched on a microtask and starved every timer in +// the process). Deterministic, so the contract tests can bound it. +const CRASH_BACKOFF = Object.freeze({ minDelay: 50, maxDelay: 1000, factor: 2, jitter: false }); +const crashDelay = (attempt) => backoffDelay({ ...CRASH_BACKOFF, attempt: Math.max(0, attempt - 1) }); + +module.exports = { DEFAULT_RETRY, DEFAULT_RETRY_ON, normalizeRetry, decide, crashDelay, positiveInteger, nonNegative }; diff --git a/src/broker/rpc/client.js b/src/broker/rpc/client.js new file mode 100644 index 0000000..066503b --- /dev/null +++ b/src/broker/rpc/client.js @@ -0,0 +1,336 @@ +'use strict'; + +// RPC over a broker, client side: `WrpcClient.transport.broker`, registered +// when @alexify/wrpc/broker is required (the sse/webrtc idiom). +// +// const client = await connect('broker://billing', { +// transport: 'broker', +// broker, // a broker with the `direct` capability +// mode: 'session', // or 'stateless' (the default) +// }); +// +// 'stateless' is request/response, like the http transport: any instance +// answers any call, batches included; no events, subscriptions or streams. +// 'session' is the full protocol with one instance, which the client +// reaches through the hello/welcome handshake; its heartbeat is what keeps +// the session inside the server's idle window, and a frame-sequence gap or +// a `bye` is a lost connection the ordinary reconnect cycle recovers from. + +const { WrpcClient, ClientTransport } = require('../../client/core.js'); +const { generateUUID } = require('../../runtime/node.js'); +const { capabilityOf } = require('../port.js'); +const { toBytes } = require('../ids.js'); +const { + HEADER_KIND, + HEADER_INBOX, + HEADER_ENC, + HEADER_VERSION, + KIND, + HEADER_REASON, + serviceAddress, + peerHeaders, + seqOf, + packetBody, + frameBody, + sessionFrame, + sealFrame, + openFrame, +} = require('./frames.js'); +const { createBrokerSealing } = require('../sealing.js'); +const { normalizeSyncCompression, headerNegotiator, maxMessageOf } = require('../../compression/sync.js'); + +const DEFAULT_REQUEST_TIMEOUT = 30_000; +const DEFAULT_HIGH_WATER_MARK = 1024; + +// broker://billing -> 'billing' +const serviceOf = (url) => { + const match = /^broker:\/\/([^/?#]+)/.exec(String(url)); + return match ? decodeURIComponent(match[1]) : null; +}; + +class ClientBrokerTransport extends ClientTransport { + // `options.encryption` here is the KEYRING form — `{ keys, … }`, shared + // with the service — not the session object of createEncryption(). + static encrypts = 'keys'; + + // Decided per open() from `mode`; the defaults describe stateless. + persistent = false; + heartbeat = false; + mode = 'stateless'; + + #direct = null; + #address = null; + #inbox = null; + #stopInbox = null; + #headers = {}; + #requestTimeout = DEFAULT_REQUEST_TIMEOUT; + #highWaterMark = DEFAULT_HIGH_WATER_MARK; + // session state + #session = null; + #remote = null; + #seq = 0; + #expectSeq = 1; + #unconfirmed = 0; + #welcome = null; + #generation = 0; + // Per-message compression: the option, and what the server agreed to on + // welcome (a session) — stateless answers are decided per request. + #compression = null; + // The binding's sealing under a shared keyring (../sealing.js), or null. + #sealing = null; + // Welcomed by a sealed frame: the session takes sealed frames only, so a + // plaintext bye or packet naming it (acceptPlaintext lets one through the + // opener) is a downgrade, dropped. + #sealedSession = false; + #active = null; + // This end reads framed messages: what its hello and requests say. + #frames = true; + #maxMessage; + // Correlation ids AND the session id. The session id is the key the server + // holds this connection's frame state under, so guessing one lets a sender + // inject frames into somebody else's session — which is why it is worth + // being able to replace the default with a generator of known strength. + // `generateId` is assigned by the owning WrpcClient (already resolved from + // its own option) the way `codec` and `log` are; a transport driven + // standalone falls back. + generateId = generateUUID; + + async open(options = {}) { + if (this.active) return; + const mode = options.mode ?? 'stateless'; + if (mode !== 'stateless' && mode !== 'session') { + throw new TypeError("broker transport: mode must be 'stateless' or 'session'"); + } + this.#direct = capabilityOf(options.broker, 'direct', 'broker transport'); + this.#address = serviceAddress(serviceOf(this.url), options.address, 'broker transport'); + this.#headers = peerHeaders(options.headers); + if (options.meta) this.#headers['x-wrpc-meta'] = JSON.stringify(options.meta); + this.#requestTimeout = options.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT; + this.#compression = normalizeSyncCompression(options.compression, 'broker transport: options'); + this.#maxMessage = maxMessageOf(options.maxMessage, 'broker transport'); + // `encryption: { keys }` — the binding's own sealing under a keyring the + // service shares (NOT the session object of createEncryption: there is + // no connection here to hold a handshake). Headers move inside with the + // body, so `authorization` no longer rests in the broker. + this.#sealing = createBrokerSealing(options.encryption, 'broker transport: options', { + layer: 'broker-rpc', + replay: true, + }); + this.#active = null; + // What every stateless request and the hello announce: the codecs an + // answer may come back in, in this end's order of preference. + if (this.#compression !== null) this.#headers[HEADER_ENC] = this.#compression.ids.join(','); + // Revision 1 until the service's welcome said 2, and only when this end + // reads frames itself; a stateless request stays at 1 (frames.js). + this.#frames = this.attachments !== false && !this.codec; + this.revision = 1; + this.mode = mode; + this.persistent = mode === 'session'; + this.heartbeat = mode === 'session'; + const generation = ++this.#generation; + this.#inbox = this.#direct.inbox(); + const stop = await this.#direct.listen(this.#inbox, (message) => this.#onMessage(message, generation)); + if (generation !== this.#generation) return void (await stop()); + this.#stopInbox = stop; + if (mode === 'session') { + try { + await this.#handshake(generation); + } catch (error) { + await this.#release(); + throw error; + } + } + this.active = true; + this.emit('open'); + } + + async #handshake(generation) { + this.#session = this.generateId(); + this.#seq = 0; + this.#expectSeq = 1; + this.#unconfirmed = 0; + this.#sealedSession = false; + const welcomed = new Promise((resolve, reject) => { + this.#welcome = { resolve, reject }; + }); + // Settled by a close() that may come after a failed hello already + // abandoned the await below: never an unhandled rejection. + welcomed.catch(() => {}); + const hello = sealFrame( + this.#sealing, + this.#address, + this.#session, + this.#frames + ? { ...this.#headers, [HEADER_KIND]: KIND.HELLO, [HEADER_VERSION]: '2' } + : { ...this.#headers, [HEADER_KIND]: KIND.HELLO }, + '', + ); + await this.#direct.send(this.#address, hello.body, { + headers: hello.headers, + correlationId: this.#session, + replyTo: this.#inbox, + timeout: this.#requestTimeout, + }); + // The client's connectTimeout races the whole open(); a terminate() + // meanwhile settles this through #welcome. + this.#remote = await welcomed; + if (generation !== this.#generation) throw new Error('Connection closed'); + } + + #onMessage(raw, generation) { + if (generation !== this.#generation) return; + const message = openFrame(this.#sealing, this.#inbox, raw); + // A frame that does not open is not the service's: dropped, and said so. + if (message.refused !== undefined) { + return void this.log?.warn({ event: 'broker.rpc.refused', reason: message.refused }); + } + const kind = message.headers?.[HEADER_KIND]; + if (this.mode === 'stateless') { + if (kind !== KIND.RESPONSE) return; + const body = frameBody(message, this.#compression, this.#maxMessage); + if (body === null) return void this.#escalate(new Error('broker transport: an answer that cannot be inflated')); + this.emit('message', packetBody(body)); + return; + } + if (message.correlationId !== this.#session) return; + if (kind === KIND.WELCOME) { + const remote = message.headers?.[HEADER_INBOX]; + const pending = this.#welcome; + this.#welcome = null; + this.#active = headerNegotiator(this.#compression)(message.headers?.[HEADER_ENC]); + this.#sealedSession = message.sealed === true; + this.revision = this.#frames && message.headers?.[HEADER_VERSION] === '2' ? 2 : 1; + if (pending && typeof remote === 'string' && remote.length > 0) pending.resolve(remote); + return; + } + if (this.#sealedSession && message.sealed !== true) { + return void this.log?.warn({ event: 'broker.rpc.refused', reason: 'downgrade', kind }); + } + if (kind === KIND.BYE) { + return void this.#lost(new Error(`Session ended: ${message.headers?.['wrpc-reason'] ?? 'bye'}`)); + } + if (kind !== KIND.PACKET && kind !== KIND.CHUNK) return; + const seq = seqOf(message.headers); + if (seq !== this.#expectSeq) { + return void this.#lost(new Error(`Session frame gap: expected ${this.#expectSeq}, got ${seq}`), true); + } + this.#expectSeq++; + const body = frameBody(message, this.#active === null ? null : this.#compression, this.#maxMessage); + if (body === null) return void this.#lost(new Error('Session frame cannot be inflated'), true); + if (kind === KIND.CHUNK) this.emit('message', toBytes(body)); + else this.emit('message', packetBody(body)); + } + + /** The codec ids in effect on the session — `{ encode, decode }` — or null. */ + get compression() { + const active = this.#active; + return active === null ? null : { encode: active.encode.id, decode: active.decode.id }; + } + + #escalate(error) { + if (this.listenerCount('error') === 0) return; + void this.emit('error', error).catch(() => {}); + } + + // The connection is gone: announced as a close the reconnect cycle + // handles. A gap also tells the server, whose session is now useless. + #lost(error, notify = false) { + if (this.#welcome) { + const pending = this.#welcome; + this.#welcome = null; + return void pending.reject(error); + } + if (!this.active) return; + // Told why: a gap seen HERE is a frame the broker lost on the server's + // way out, which the server cannot see for itself. + if (notify) this.#bye('gap'); + this.active = false; + this.#generation++; + void this.#release(); + this.emit('close', error); + } + + #bye(reason = null) { + if (!this.#remote || !this.#session) return; + const headers = + reason === null ? { [HEADER_KIND]: KIND.BYE } : { [HEADER_KIND]: KIND.BYE, [HEADER_REASON]: reason }; + const bye = sealFrame(this.#sealing, this.#remote, this.#session, headers, ''); + this.#direct.send(this.#remote, bye.body, { headers: bye.headers, correlationId: this.#session }).catch(() => {}); + } + + async #release() { + const stop = this.#stopInbox; + this.#stopInbox = null; + this.#remote = null; + if (stop) await stop().catch(() => {}); + } + + write(data, options = null) { + if (!this.active) throw new Error('Not connected'); + if (this.mode === 'stateless') return this.#request(data); + const seq = ++this.#seq; + this.#unconfirmed++; + const generation = this.#generation; + const frame = sessionFrame(this.#sealing, this.#remote, this.#session, seq, data, this.#active, options); + this.#direct + .send(this.#remote, frame.body, { + headers: frame.headers, + correlationId: this.#session, + replyTo: this.#inbox, + }) + .then( + () => this.#confirmed(), + (error) => { + this.#confirmed(); + if (generation === this.#generation) this.#lost(error); + }, + ); + return this.#unconfirmed < this.#highWaterMark; + } + + #confirmed() { + this.#unconfirmed--; + if (this.#unconfirmed === this.#highWaterMark - 1) this.emit('drain'); + } + + #request(data) { + const correlationId = this.generateId(); + const request = sealFrame( + this.#sealing, + this.#address, + correlationId, + { ...this.#headers, [HEADER_KIND]: KIND.REQUEST }, + data, + ); + this.#direct + .send(this.#address, request.body, { + headers: request.headers, + correlationId, + replyTo: this.#inbox, + timeout: this.#requestTimeout, + }) + // Nobody serves the address (a 503 the broker knows about) or the + // send failed outright: the calls this frame carried settle now. + .catch((error) => this.failPackets(data, typeof error?.code === 'number' ? error.code : 503)); + return true; + } + + close() { + this.#generation++; + if (this.#welcome) { + const pending = this.#welcome; + this.#welcome = null; + pending.reject(new Error('Connection closed')); + } + if (!this.active) return void this.#release(); + if (this.mode === 'session') this.#bye(); + this.active = false; + void this.#release(); + this.emit('close'); + } +} + +// Assigned INTO the registry, never replacing it (see src/client/core.js). +WrpcClient.transport.broker = ClientBrokerTransport; + +module.exports = { ClientBrokerTransport }; diff --git a/src/broker/rpc/frames.js b/src/broker/rpc/frames.js new file mode 100644 index 0000000..dab135e --- /dev/null +++ b/src/broker/rpc/frames.js @@ -0,0 +1,185 @@ +'use strict'; + +// The broker binding's framing: what rides in a `direct` message's headers +// around an unmodified wrpc packet. Documented in +// docs/reference/protocol.md#broker-binding. +// +// stateless client --request{correlationId, replyTo}--> service address (group) +// client <--response{correlationId}---------- the instance that took it +// +// session client --hello{correlationId: session, replyTo}--> service address (group) +// client <--welcome{wrpc-inbox}------------------- the instance that took it +// client --packet|chunk{wrpc-seq}--> that instance's inbox, both ways +// either side --bye--> the other +// +// A session's frames are numbered per direction from 1: the direct +// capability is at-most-once, and a gap is a lost connection, not a +// silently missing packet. + +const { toText, toBytes } = require('../ids.js'); +const { codecById, encodeIfSmaller, decodeOrNull } = require('../../compression/sync.js'); + +const HEADER_KIND = 'wrpc-kind'; +const HEADER_SEQ = 'wrpc-seq'; +const HEADER_INBOX = 'wrpc-inbox'; +const HEADER_REASON = 'wrpc-reason'; +// Per-message compression (src/compression): on a `request`, a `hello` and +// a `welcome`, the codecs the sender holds — ids joined by commas, in its +// order of preference; on a `response`, `packet` or `chunk`, the ONE codec +// its body IS compressed with. +const HEADER_ENC = 'wrpc-enc'; +// The sender's clock, in milliseconds since the epoch, INSIDE the seal of a +// `request` and a `hello` — the two frames that reach the group address. +// The envelope's replay window is per sender and per PROCESS, and the group +// is consumed by many: a captured frame replayed later reaches an instance +// whose window never saw it. The clock bounds how long that stays possible +// (the receiver's `maxSkew`); a shared replay memory closes the rest. +const HEADER_TIME = 'wrpc-t'; +// The protocol revision of a session (protocol.md#versioning): `2` on a +// `hello` when the client reads framed messages, on a `welcome` when the +// service does, and a frame goes only where the other side said 2 — two ends +// whose `attachments` disagreed used to send each other frames the other +// refused unread. A stateless request is revision 1 both ways: its body is +// read as text and its answer is JSON, so bytes travel as 1.0's JSON. +const HEADER_VERSION = 'wrpc-version'; +const RESERVED_PREFIX = 'wrpc-'; + +const KIND = Object.freeze({ + REQUEST: 'request', + RESPONSE: 'response', + HELLO: 'hello', + WELCOME: 'welcome', + PACKET: 'packet', + CHUNK: 'chunk', + BYE: 'bye', +}); + +const DEFAULT_PREFIX = 'wrpc'; + +/** The service address a name maps to: `wrpc.` unless given. */ +const serviceAddress = (service, address, label) => { + if (address !== undefined && address !== null) { + if (typeof address !== 'string' || address.length === 0) { + throw new TypeError(`${label}: address must be a non-empty string`); + } + return address; + } + if (typeof service !== 'string' || service.length === 0) { + throw new TypeError(`${label}: service must be a non-empty string`); + } + return `${DEFAULT_PREFIX}.${service}`; +}; + +// What a peer declared about itself (authorization, x-wrpc-meta, ...), with +// the binding's own header names removed — a peer cannot forge a frame kind +// or a sequence number through its connection headers — and lower-cased, +// the HTTP spelling every token carrier reads. +const peerHeaders = (headers) => { + const out = {}; + if (!headers) return out; + for (const key of Object.keys(headers)) { + const name = key.toLowerCase(); + if (name.startsWith(RESERVED_PREFIX)) continue; + const value = headers[key]; + if (value === undefined || value === null) continue; + out[name] = String(value); + } + return out; +}; + +const seqOf = (headers) => { + const value = Number(headers?.[HEADER_SEQ]); + return Number.isSafeInteger(value) && value > 0 ? value : 0; +}; + +// Sealed frames (../sealing.js): what a frame is bound to — where it was +// sent, what kind it is, whose conversation, which frame of it. The kind and +// the sequence number stay readable (the binding routes by them before it +// can open anything) and are part of this string, so neither can be +// rewritten; everything else a frame carried in its headers — the peer's +// `authorization`, `x-wrpc-meta`, the codec id, the inbox, the reason — is +// inside. +const contextOf = (address, kind, correlationId, seq) => + `${address}\0${kind ?? ''}\0${correlationId ?? ''}\0${seq ?? ''}`; + +const sealFrame = (sealing, address, correlationId, headers, body, now = Date.now) => { + if (sealing === null) return { headers, body }; + const inner = { ...headers }; + const kind = inner[HEADER_KIND]; + const seq = inner[HEADER_SEQ]; + delete inner[HEADER_KIND]; + delete inner[HEADER_SEQ]; + if (kind === KIND.REQUEST || kind === KIND.HELLO) inner[HEADER_TIME] = String(now()); + const outer = seq === undefined ? { [HEADER_KIND]: kind } : { [HEADER_KIND]: kind, [HEADER_SEQ]: seq }; + return sealing.seal(contextOf(address, kind, correlationId, seq), inner, body, outer); +}; + +/** The frame as it was before sealing, `{ refused: reason }`, or the message itself when nothing seals. */ +const openFrame = (sealing, address, message) => { + if (sealing === null) return message; + const kind = message.headers?.[HEADER_KIND]; + const seq = message.headers?.[HEADER_SEQ]; + const result = sealing.open(contextOf(address, kind, message.correlationId, seq), message); + if (result.refused !== undefined || result.sealed === false) return result.refused === undefined ? message : result; + const headers = { ...result.headers, [HEADER_KIND]: kind }; + if (seq !== undefined) headers[HEADER_SEQ] = seq; + return { headers, body: result.body, correlationId: message.correlationId, replyTo: message.replyTo, sealed: true }; +}; + +// A packet frame's body is text, a chunk frame's bytes — normalized here so +// neither side trusts the broker to have preserved the JS type. +const packetBody = (body) => toText(body); + +// A frame's body as sent, inflated when its `wrpc-enc` header names the +// codec it was compressed with — any codec of `local`, the list this end +// holds, passed only once the session agreed on something — or null: +// marked but nothing agreed, a codec not held, a body that does not +// inflate under the cap. `failed` hears of a codec that threw. ONE copy for +// both ends: the server's and the client's used to be two, and a rule +// changed in one of them is a session that ends as `undecodable frame`. +const frameBody = (message, local, maxMessage, failed = null) => { + const encoding = message.headers?.[HEADER_ENC]; + if (encoding === undefined || encoding === null || encoding === '') return message.body; + const entry = local === null || typeof encoding !== 'string' ? null : codecById(local, encoding); + return entry === null ? null : decodeOrNull(entry, toBytes(message.body), maxMessage, failed); +}; + +// A session frame as either end sends it: `packet` for text and `chunk` for +// bytes, numbered `seq`, the body compressed with what the session agreed +// on (`active`, or null) when that made it smaller — the codec then named +// in `wrpc-enc` — and sealed. `options.compress === false` sends this one +// plain. Per frame: nothing is allocated here that the two call sites did +// not allocate themselves (bench/broker.js, the rpc session rows, level +// before and after). +const sessionFrame = (sealing, address, session, seq, data, active, options, failed = null) => { + const binary = typeof data !== 'string'; + const headers = { [HEADER_KIND]: binary ? KIND.CHUNK : KIND.PACKET, [HEADER_SEQ]: String(seq) }; + let body = binary ? toBytes(data) : data; + if (active !== null && (options === null || options.compress !== false)) { + const encoded = encodeIfSmaller(active.encode, body, failed); + if (encoded !== null) { + body = encoded; + headers[HEADER_ENC] = active.encode.id; + } + } + return sealFrame(sealing, address, session, headers, body); +}; + +module.exports = { + HEADER_KIND, + HEADER_SEQ, + HEADER_INBOX, + HEADER_REASON, + HEADER_ENC, + HEADER_TIME, + HEADER_VERSION, + KIND, + serviceAddress, + peerHeaders, + seqOf, + packetBody, + frameBody, + sessionFrame, + sealFrame, + openFrame, +}; diff --git a/src/broker/rpc/server.js b/src/broker/rpc/server.js new file mode 100644 index 0000000..12b648c --- /dev/null +++ b/src/broker/rpc/server.js @@ -0,0 +1,528 @@ +'use strict'; + +// RPC over a broker, server side: `attachBrokerRpc(server, broker, { service })`. +// +// One service address, consumed as a competing group by every instance: +// +// - a `request` is answered exactly like a packet-mode HTTP POST — it IS +// one: handed to RpcServer.handleHttpCall with its peer headers, so +// batches, per-request clients, token-carrier sessions and meta behave +// identically to HTTP, with no affinity between instances; +// - a `hello` opens a SESSION on whichever instance took it: the full +// protocol (events, subscriptions, streams, cancellation) over this +// instance's own inbox, one client attached through RpcServer.attach with +// the hello's headers as its request. +// +// Sessions end on a `bye`, a frame-sequence gap, a send the broker refuses, +// or silence past `idleTimeout` (a client whose process vanished never says +// goodbye; its heartbeat is what keeps a live one inside the window). + +const { ServerTransport } = require('../../rpc/serverTransport.js'); +const { createLoggerWriter } = require('../../logging.js'); +const { capabilityOf, brokerName } = require('../port.js'); +const { toBytes, fingerprint } = require('../ids.js'); +const { rpcOf } = require('../host.js'); +const { + HEADER_KIND, + HEADER_INBOX, + HEADER_REASON, + HEADER_ENC, + HEADER_TIME, + HEADER_VERSION, + KIND, + serviceAddress, + peerHeaders, + seqOf, + packetBody, + frameBody, + sessionFrame, + sealFrame, + openFrame, +} = require('./frames.js'); +const { createBrokerSealing, HEADER_SEALED } = require('../sealing.js'); +const { HEADER_LENGTH } = require('../../encryption/envelope.js'); +const { DEFAULT_MAX_SKEW } = require('../../encryption/httpServer.js'); +const { + normalizeSyncCompression, + headerNegotiator, + maxMessageOf, + encodeIfSmaller, +} = require('../../compression/sync.js'); + +const DEFAULT_IDLE_TIMEOUT = 90_000; // 3x the client's 30 s heartbeat +const DEFAULT_HIGH_WATER_MARK = 1024; +// Sessions one instance holds at once: every `hello` that reaches the +// service address costs a Client, a transport and a table entry until +// `idleTimeout`, so without a ceiling one sender could grow an instance +// without bound. Past it a hello is answered `bye` — the sender may try +// another instance, the group spreads them. 0 lifts the cap. +const DEFAULT_MAX_SESSIONS = 10_000; + +// The server half of one session: what the core's Client writes to. The +// base supplies send()/error() over write(). +class BrokerSessionTransport extends ServerTransport { + kind = 'broker'; + connection = true; + #direct; + #peer; + #session; + #seq = 0; + #unconfirmed = 0; + #highWaterMark; + #closed = false; + #onFailure; + // What both ends agreed to on hello/welcome — `{ encode, decode }` — or null. + #compression; + // The binding's sealing (../sealing.js) or null: compress, then seal. + #sealing; + // A codec that threw on the way out — the frame left plain; said by the + // server's reporter. + #failed; + + constructor({ direct, peer, session, highWaterMark, onFailure, compression = null, sealing = null, failed = null }) { + super(`broker:${fingerprint(session)}`); + this.#direct = direct; + this.#peer = peer; + this.#session = session; + this.#highWaterMark = highWaterMark; + this.#onFailure = onFailure; + this.#compression = compression; + this.#sealing = sealing; + this.#failed = failed; + } + + /** The codec ids in effect on this session — `{ encode, decode }` — or null. */ + get compression() { + const active = this.#compression; + return active === null ? null : { encode: active.encode.id, decode: active.decode.id }; + } + + // Frames go out in call order (the direct contract keeps one sender's + // order); the broker's confirmation is what backpressure counts. + write(data) { + return this.#send(data, null); + } + + // A write with per-message options (`compress: false`) — what + // Client.sendRaw and a Broadcast use, as on a WebSocket. + writeWith(text, options) { + return this.#send(text, options); + } + + #send(data, options) { + if (this.#closed) return false; + const seq = ++this.#seq; + this.#unconfirmed++; + const frame = sessionFrame( + this.#sealing, + this.#peer, + this.#session, + seq, + data, + this.#compression, + options, + this.#failed, + ); + this.#direct.send(this.#peer, frame.body, { headers: frame.headers, correlationId: this.#session }).then( + () => this.#confirmed(), + (error) => { + this.#confirmed(); + this.#onFailure(error); + }, + ); + return this.#unconfirmed < this.#highWaterMark; + } + + // One frame left the broker's hands, delivered or refused: crossing back + // under the high-water mark is what a parked producer waits for. + #confirmed() { + this.#unconfirmed--; + if (this.#unconfirmed === this.#highWaterMark - 1) this.emit('drain'); + } + + bye(reason) { + if (this.#closed) return; + // Through the sealer like every other frame: a sealed client drops a + // plaintext goodbye as `unsealed` and would go on waiting for a session + // that is over. + const headers = { [HEADER_KIND]: KIND.BYE, [HEADER_REASON]: reason }; + const frame = sealFrame(this.#sealing, this.#peer, this.#session, headers, ''); + this.#direct.send(this.#peer, frame.body, { headers: frame.headers, correlationId: this.#session }).catch(() => {}); + } + + get closed() { + return this.#closed; + } + + close() { + if (this.#closed) return; + this.bye('closed'); + this.#closed = true; + this.emit('close'); + } + + // Ended by the peer or the broker: no goodbye to send. + drop() { + if (this.#closed) return; + this.#closed = true; + this.emit('close'); + } +} + +const attachBrokerRpc = async (server, broker, options = {}) => { + const rpc = rpcOf(server, 'attachBrokerRpc'); + const direct = capabilityOf(broker, 'direct', 'attachBrokerRpc'); + const { + service, + address: addressOption, + idleTimeout = DEFAULT_IDLE_TIMEOUT, + highWaterMark = DEFAULT_HIGH_WATER_MARK, + maxSessions = DEFAULT_MAX_SESSIONS, + sessions: allowSessions = true, + logger = null, + compression = null, + maxMessage, + encryption = null, + } = options; + const address = serviceAddress(service, addressOption, 'attachBrokerRpc'); + // Off by default. On, this end's list is announced back on `welcome` to a + // client whose `hello` list shares a codec with it, and a stateless answer + // is compressed with the first codec here that the request named — a + // client without the option is served plain. + const codec = normalizeSyncCompression(compression, 'attachBrokerRpc: options'); + const agree = headerNegotiator(codec); + const announce = codec === null ? '' : codec.ids.join(','); + const cap = maxMessageOf(maxMessage, 'attachBrokerRpc'); + // Off by default. On, every frame is sealed under the shared keyring — + // body AND headers, so the bearer token a client presents no longer rests + // in the broker — and a frame that is not is refused (`acceptPlaintext` + // for the rollout). A sealed frame is also what `encryption.required` on + // the server accepts from this binding. + const sealing = createBrokerSealing(encryption, 'attachBrokerRpc: options', { layer: 'broker-rpc', replay: true }); + // The group address is consumed by many instances, and the envelope's + // replay window lives in one process: a sealed `request` or `hello` + // carries the sender's clock inside the seal, refused past `maxSkew`, and + // `replay` — a shared "seen once" memory, `seen(id, ttl)` like the HTTP + // one — closes the window that leaves open. Both belong to the RPC + // binding only; the log and queue layers are re-read by design. + const maxSkew = encryption?.maxSkew ?? DEFAULT_MAX_SKEW; + const replay = encryption?.replay ?? null; + if (sealing !== null && !(Number.isFinite(maxSkew) && maxSkew > 0)) { + throw new TypeError('attachBrokerRpc: encryption.maxSkew must be a positive number of milliseconds'); + } + if (replay !== null && typeof replay?.seen !== 'function') { + throw new TypeError('attachBrokerRpc: encryption.replay must be { seen(id, ttl) } or null'); + } + if (!(Number.isFinite(idleTimeout) && idleTimeout > 0)) { + throw new TypeError('attachBrokerRpc: idleTimeout must be a positive number of milliseconds'); + } + if (!Number.isInteger(highWaterMark) || highWaterMark <= 0) { + throw new TypeError('attachBrokerRpc: highWaterMark must be a positive integer'); + } + if (!Number.isInteger(maxSessions) || maxSessions < 0) { + throw new TypeError('attachBrokerRpc: maxSessions must be a non-negative integer (0 for no limit)'); + } + const system = brokerName(broker) === 'custom' ? brokerName(direct) : brokerName(broker); + // The server's writer, not the console: a pino-configured server used to + // see this binding's lines on the raw console, without `event` or + // `session`. `logger: false` still silences it. + const log = createLoggerWriter(logger ?? rpc.log).child({ component: 'broker.rpc', broker: system }); + // A codec that threw: the frame leaves plain, or one that came in is + // refused — counted and said once by the server's reporter. One receiver + // per direction for the binding; the codec is the entry it is handed. + const encodeFailed = (error, entry) => rpc.compressionFailed('broker', 'encode', entry.id, error); + const decodeFailed = (error, entry) => rpc.compressionFailed('broker', 'decode', entry.id, error); + const inbox = direct.inbox(); + const sessions = new Map(); // session id -> { transport, client, expectSeq, lastSeen, peer, compression } + // Hellos refused at the cap since the last sweep: one line per sweep for + // however many, not one per hello — a sender at the cap is a flood. + let refused = 0; + const basePath = rpc.basePath || '/'; + + const reply = (message, headers, body) => { + const frame = sealFrame(sealing, message.replyTo, message.correlationId, headers, body); + return direct + .send(message.replyTo, frame.body, { headers: frame.headers, correlationId: message.correlationId }) + .catch((error) => { + log.warn({ event: 'broker.rpc.reply', err: error, to: message.replyTo }); + }); + }; + + // One inbound frame, opened — or null for one that was refused: logged + // with its reason, never answered (an answer would be an oracle, and the + // sender of a frame that does not open is not the peer anyway). + const opened = (at, message) => { + const frame = openFrame(sealing, at, message); + if (frame.refused === undefined) return frame; + log.warn({ event: 'broker.rpc.refused', reason: frame.refused, kind: message.headers?.[HEADER_KIND] }); + return null; + }; + + // Stateless: a packet-mode POST in all but carrier. The request itself + // travels plain (the client cannot know yet what this instance speaks); + // its `wrpc-enc` lists the codecs the answer may come back in. + const onRequest = (message) => { + if (typeof message.replyTo !== 'string' || message.replyTo.length === 0) return; + const headers = peerHeaders(message.headers); + const active = agree(message.headers?.[HEADER_ENC]); + void rpc.handleHttpCall({ + method: 'POST', + url: basePath, + headers, + body: packetBody(message.body), + remoteAddress: 'broker', + encrypted: message.sealed === true, + respond: ({ body }) => { + const text = typeof body === 'string' ? body : packetBody(body); + const encoded = active === null ? null : encodeIfSmaller(active.encode, text, encodeFailed); + if (encoded === null) return void reply(message, { [HEADER_KIND]: KIND.RESPONSE }, text); + reply(message, { [HEADER_KIND]: KIND.RESPONSE, [HEADER_ENC]: active.encode.id }, encoded); + }, + }); + }; + + // `level` is the reason's: the broker lost or mangled a frame (a + // sequence gap, an undecodable frame) is warn — something on the path is + // wrong; a client that went quiet is info; a goodbye, a replacement and + // this server's own closing are debug — the routine ends of a session. + // `reason` is one of a closed set — it is the label of + // `wrpc.broker.rpc.session.ends`: gap | undecodable | idle | send_failed | + // replaced | bye | peer_gap | closing. `detail` is the text of it (the + // sequence numbers of a gap), for the log line and the peer's `wrpc-reason`. + const endSession = (id, reason, { notify = true, level = 'debug', detail = reason } = {}) => { + const session = sessions.get(id); + if (!session) return; + sessions.delete(id); + if (notify) session.transport.bye(detail); + session.transport.drop(); + rpc.otel.recordBrokerSessionEnd(reason); + log[level]({ event: 'broker.rpc.session.end', session: fingerprint(id), reason: detail }); + }; + + const onHello = (message) => { + const id = message.correlationId; + if (typeof id !== 'string' || id.length === 0 || typeof message.replyTo !== 'string') return; + if (!allowSessions) { + return void reply(message, { [HEADER_KIND]: KIND.BYE, [HEADER_REASON]: 'sessions disabled' }, ''); + } + const existing = sessions.get(id); + if (existing === undefined && maxSessions > 0 && sessions.size >= maxSessions) { + refused++; + return void reply(message, { [HEADER_KIND]: KIND.BYE, [HEADER_REASON]: 'too many sessions' }, ''); + } + // A client re-saying hello with the same id (its welcome was lost) gets + // a fresh session — provided the old one has seen no frame yet and the + // hello comes from the same inbox. A LIVE session is never replaced by + // a hello: neither by its own client's stray one, nor by a sender who + // guessed its id, which used to end the session for the client that + // held it and hand its id to the guesser. + if (existing !== undefined) { + if (existing.expectSeq !== 1 || existing.peer !== message.replyTo) { + return void log.debug({ + event: 'broker.rpc.hello.duplicate', + session: fingerprint(id), + live: existing.expectSeq !== 1, + }); + } + endSession(id, 'replaced', { notify: false }); + } + const active = agree(message.headers?.[HEADER_ENC]); + const transport = new BrokerSessionTransport({ + direct, + peer: message.replyTo, + session: id, + highWaterMark, + compression: active, + sealing, + failed: encodeFailed, + onFailure: (error) => { + log.warn({ event: 'broker.rpc.send', err: error, session: fingerprint(id) }); + endSession(id, 'send_failed', { notify: false, detail: 'send failed' }); + }, + }); + // The client's revision, from its hello: a frame only to one that said 2. + if (message.headers?.[HEADER_VERSION] !== '2') transport.setRevision(1); + const session = { + transport, + client: null, + expectSeq: 1, + lastSeen: Date.now(), + compression: active, + peer: message.replyTo, + // Opened by a sealed hello: sealed frames only from here on. + sealed: message.sealed === true, + }; + sessions.set(id, session); + // The core's own close path (RpcServer.close, client.close()) removes it. + transport.once('close', () => { + if (sessions.get(id) === session) sessions.delete(id); + }); + try { + session.client = rpc.attach(transport, { + request: { headers: peerHeaders(message.headers), url: '', remoteAddress: 'broker' }, + encrypted: message.sealed === true, + }); + } catch (error) { + // `encryption.required` on the server, and a hello that was not sealed. + sessions.delete(id); + log.warn({ event: 'broker.rpc.refused', reason: 'plaintext', err: error }); + return void reply(message, { [HEADER_KIND]: KIND.BYE, [HEADER_REASON]: 'encryption required' }, ''); + } + const welcome = { [HEADER_KIND]: KIND.WELCOME, [HEADER_INBOX]: inbox }; + if (active !== null) welcome[HEADER_ENC] = announce; + if (rpc.revision === 2) welcome[HEADER_VERSION] = '2'; + void reply(message, welcome, ''); + }; + + const onFrame = (raw) => { + const message = opened(inbox, raw); + if (message === null) return; + const id = message.correlationId; + const session = typeof id === 'string' ? sessions.get(id) : undefined; + if (!session) { + // A frame for a session this instance does not hold (it restarted, or + // the session idled out): tell the client, which reconnects. + if (typeof id === 'string' && message.headers?.[HEADER_KIND] !== KIND.BYE && message.replyTo) { + // Debug, deliberately: any participant of the broker can send these + // in a loop, and the client's own reconnect is the recovery. + log.debug({ event: 'broker.rpc.session.unknown', session: fingerprint(id) }); + void reply(message, { [HEADER_KIND]: KIND.BYE, [HEADER_REASON]: 'unknown session' }, ''); + } + return; + } + const kind = message.headers?.[HEADER_KIND]; + // Under `acceptPlaintext` — the rollout — a plaintext frame with the + // right session id and sequence number used to walk into a session the + // sealed hello had opened: a downgrade, and a bye that way ended it. + // Dropped and named; the sequence is not consumed, so the real frame + // is still served. + if (session.sealed && message.sealed !== true) { + return void log.warn({ event: 'broker.rpc.refused', reason: 'downgrade', kind }); + } + if (kind === KIND.BYE) { + // The client left because IT saw a gap in what this side sent — the + // broker lost a frame on the way out, which nothing here can see. Its + // text is reduced to that one word, never logged as it came. + if (message.headers?.[HEADER_REASON] === 'gap') { + return void endSession(id, 'peer_gap', { notify: false, level: 'warn', detail: 'the client saw a gap' }); + } + return void endSession(id, 'bye', { notify: false }); + } + const seq = seqOf(message.headers); + if (seq !== session.expectSeq) { + const detail = `sequence gap: expected ${session.expectSeq}, got ${seq}`; + return void endSession(id, 'gap', { level: 'warn', detail }); + } + session.expectSeq++; + session.lastSeen = Date.now(); + // A frame marked compressed on a session that agreed to nothing, with + // another codec, or that does not inflate under the cap: the peer's + // protocol violation, and the session ends as on a sequence gap. + const body = frameBody(message, session.compression === null ? null : codec, cap, decodeFailed); + if (body === null) return void endSession(id, 'undecodable', { level: 'warn', detail: 'undecodable frame' }); + if (kind === KIND.CHUNK) session.transport.emit('chunk', toBytes(body)); + else session.transport.emit('packet', packetBody(body)); + }; + + const dispatch = (message) => { + const kind = message.headers?.[HEADER_KIND]; + if (kind === KIND.REQUEST) return void onRequest(message); + if (kind === KIND.HELLO) return void onHello(message); + }; + + // The envelope's own header — kid, sender salt, counter — names a sealed + // frame uniquely across the fleet: what the shared replay memory is keyed + // by, for 2·maxSkew (past that the clock refuses the frame anyway). + const replayId = (raw) => { + const bytes = toBytes(raw.body); + return `${raw.headers?.[HEADER_SEALED]}:${Buffer.from(bytes.subarray(2, HEADER_LENGTH)).toString('base64url')}`; + }; + + const onService = (raw) => { + const message = opened(address, raw); + if (message === null) return; + if (message.sealed !== true) return void dispatch(message); + const kind = message.headers?.[HEADER_KIND]; + const sent = Number(message.headers?.[HEADER_TIME]); + if (!(Number.isFinite(sent) && Math.abs(Date.now() - sent) <= maxSkew)) { + return void log.warn({ event: 'broker.rpc.refused', reason: 'stale', kind }); + } + if (replay === null) return void dispatch(message); + Promise.resolve() + .then(() => replay.seen(replayId(raw), 2 * maxSkew)) + .then( + (seen) => { + if (seen) log.warn({ event: 'broker.rpc.refused', reason: 'replay', kind }); + else dispatch(message); + }, + (error) => { + // Fail closed, like the HTTP replay store: a memory that cannot be + // asked serves nothing — never "probably not a replay". + log.error({ event: 'broker.rpc.replay', err: error, kind }); + }, + ); + }; + + let stopService = await direct.listen(address, onService, { group: address }); + let stopInbox; + try { + stopInbox = await direct.listen(inbox, onFrame); + } catch (error) { + await stopService(); + throw error; + } + + const sweep = setInterval( + () => { + const cutoff = Date.now() - idleTimeout; + for (const [id, session] of sessions) if (session.lastSeen < cutoff) endSession(id, 'idle', { level: 'info' }); + if (refused > 0) { + log.warn({ event: 'broker.rpc.capacity', refused, sessions: sessions.size, max: maxSessions }); + refused = 0; + } + }, + Math.max(50, Math.min(idleTimeout / 3, 10_000)), + ); + if (typeof sweep.unref === 'function') sweep.unref(); + + // Draining: take no new work from the shared address (the other instances + // do), keep serving the sessions already here until close. + const onDraining = () => { + const stop = stopService; + stopService = null; + if (stop) void stop(); + }; + let stopped = false; + const stop = async () => { + if (stopped) return; + stopped = true; + rpc.off('draining', onDraining); + rpc.off('close', onClose); + clearInterval(sweep); + if (stopService) await stopService(); + stopService = null; + for (const id of Array.from(sessions.keys())) endSession(id, 'closing', { detail: 'server closing' }); + await stopInbox(); + }; + const onClose = () => void stop(); + rpc.on('draining', onDraining); + rpc.on('close', onClose); + + return { + address, + inbox, + get sessions() { + return sessions.size; + }, + // Not stopped — and neither listener known to be deaf: a read loop + // retrying after an error, a channel or a connection that is gone. An + // adapter that cannot tell reads as healthy (`stop.healthy` is optional + // in the direct contract), as does the address once draining gave it up. + get healthy() { + return !stopped && stopService?.healthy !== false && stopInbox.healthy !== false; + }, + stop, + }; +}; + +module.exports = { attachBrokerRpc, BrokerSessionTransport }; diff --git a/src/broker/sealing.js b/src/broker/sealing.js new file mode 100644 index 0000000..8ad9a79 --- /dev/null +++ b/src/broker/sealing.js @@ -0,0 +1,111 @@ +'use strict'; + +// Sealed broker messages: the broker family's use of the keyring envelopes +// (src/encryption/envelope.js). A broker is a third party that KEEPS what it +// carries — a Kafka topic, a stream, a quorum queue hold every message for +// their retention, for whoever can read the topic or its backups — and until +// now that was every RPC packet, every published event, and the bearer token +// a client's headers carried. +// +// headers wrpc-sealed: + whatever the binding routes by +// body sealed( u32 headerLength ‖ JSON(headers) ‖ body ) +// +// The HEADERS move inside with the body: `authorization`, `x-wrpc-meta`, the +// trace context, the codec id. What stays outside is what a carrier or the +// binding needs before it can open anything — and that is bound into the +// additional data, so an outer header cannot be rewritten to make a sealed +// body mean something else. +// +// Node-only, synchronous, one shared keyring — the model of a backplane, not +// of a session: every service holding the key reads every message. + +const { normalizeEnvelopeEncryption, createEnvelopeSealer } = require('../encryption/envelope.js'); +const { toBytes, toHeaders } = require('./ids.js'); + +const HEADER_SEALED = 'wrpc-sealed'; + +// Headers go in as the string map every broker hands back (`toHeaders`), so +// what a consumer reads is the same whether the message was sealed or rode +// in plaintext — `{ n: 7 }` is `'7'` either way, never a number on one path. +const pack = (headers, body) => { + const json = Buffer.from(JSON.stringify(toHeaders(headers))); + const bytes = toBytes(body); + const out = Buffer.allocUnsafe(4 + json.length + bytes.length); + out.writeUInt32BE(json.length, 0); + json.copy(out, 4); + out.set(bytes, 4 + json.length); + return out; +}; + +const unpack = (bytes) => { + if (bytes.length < 4) return null; + const length = bytes.readUInt32BE(0); + if (4 + length > bytes.length) return null; + let headers; + try { + headers = JSON.parse(bytes.toString('utf8', 4, 4 + length)); + } catch { + return null; + } + if (typeof headers !== 'object' || headers === null || Array.isArray(headers)) return null; + // A null-prototype map, like every header bag the broker family hands out. + const safe = Object.create(null); + for (const name of Object.keys(headers)) if (typeof headers[name] === 'string') safe[name] = headers[name]; + return { headers: safe, body: bytes.subarray(4 + length) }; +}; + +/** + * `{ sealing, seal(context, headers, body, outer?), open(context, message) }` + * or null for off. `context` is what the message is bound to — a topic, or + * an address with its kind, correlation id and sequence — and must be the + * same string on both ends. `outer` are the headers that stay readable; + * `text` carries the sealed body as base64 for a carrier that keeps strings. + * + * `open` answers `{ headers, body, sealed }`; for a message that is refused + * it answers `{ refused: reason }` — 'unsealed' (plaintext where none is + * accepted), or why it did not open — and the caller decides what a refusal + * means on its carrier (a dropped frame, a dead letter, a skipped entry). + */ +const createBrokerSealing = (option, name, { layer, replay, text = false }) => { + const encryption = normalizeEnvelopeEncryption(option, name); + if (encryption === null) return null; + const sealer = createEnvelopeSealer({ + encryption: replay ? encryption : { ...encryption, replayWindow: 0 }, + layer, + // A service may well consume what it published. + echo: true, + }); + return { + sealing: encryption.seal, + seal(context, headers, body, outer = null) { + if (!encryption.seal) return { headers: toHeaders({ ...headers, ...outer }), body }; + const { kid, sealed } = sealer.seal(pack(headers ?? {}, body), context); + return { headers: { ...outer, [HEADER_SEALED]: kid }, body: text ? sealed.toString('base64') : sealed }; + }, + open(context, message) { + const kid = message.headers?.[HEADER_SEALED]; + if (kid === undefined || kid === null || kid === '') { + if (!encryption.acceptPlaintext) return { refused: 'unsealed' }; + return { headers: message.headers, body: message.body, sealed: false }; + } + let opened; + try { + // `text`: a log or a queue is only promised to keep a STRING as it + // was (a Redis stream field is one), so the sealed body rides base64. + const bytes = text ? Buffer.from(String(message.body), 'base64') : toBytes(message.body); + const view = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); + opened = unpack(sealer.open(String(kid), view, context)); + } catch (error) { + // A refusal has a reason; anything else — a key provider that threw + // — is this side's failure, handed to the caller with the error so + // it can say so at error level. + if (typeof error?.reason !== 'string') return { refused: 'keys', error }; + return { refused: error.reason }; + } + if (opened === null) return { refused: 'format' }; + return { headers: opened.headers, body: opened.body, sealed: true }; + }, + }; +}; + +module.exports = { createBrokerSealing, HEADER_SEALED }; diff --git a/src/broker/tail.js b/src/broker/tail.js new file mode 100644 index 0000000..c54475f --- /dev/null +++ b/src/broker/tail.js @@ -0,0 +1,372 @@ +'use strict'; + +// One live reader per topic per instance, shared by every local read of it. +// +// A durable feed is read by many subscriptions at once — one per connected +// browser — and a broker consumer per subscription does not survive +// contact with Kafka (a group join each) or Redis (a blocked connection +// each). So an adapter provides two primitives and this module does the +// rest: +// +// live(topic, { signal, onEntry, onEnd }) -> Promise +// Starts ONE tail. Resolves once positioned, with the cursor of the +// tip at that moment; afterwards calls onEntry(entry) for every entry +// appended past it, in order, until the signal aborts — or calls +// onEnd(error) once, when the tail died on its own (the broker +// cancelled the consumer, the channel closed, the consumer crashed): +// the readers then move to a fresh tail and catch up from their own +// cursors, so a dead tail loses nothing. A tail that ends without +// saying so used to freeze every subscription of the topic for good. +// range(topic, { after, limit }) -> Promise +// Entries strictly after the cursor `after` (null: the oldest +// retained), at most `limit`, in order. Throws coded 410/400 for an +// unusable cursor. A log read straight off its store (memory, Redis) +// answers an array, and a SHORT page is the end. A broker that pages +// by TIME — a stream reader with no "end" (AMQP), a consumer group's +// fetch (Kafka), a pull with an expiry (NATS) — answers `{ entries, +// done }`, where `done` says the page reached the tip as of the call: +// a page cut short by a pause is not the end, and it used to be taken +// for one, leaving whatever came after the pause unread by a reader +// that then joined the live tail past it. +// covered(cursor, entry) -> boolean (already delivered?) +// advance(cursor, entry) -> cursor (the resume token after it) +// contiguous?(cursor, entry) -> boolean (optional, dense ids only) +// Whether `entry` is the one right after `cursor`. A page whose head +// is not — a stream reader that skipped — is not yielded; the range is +// asked again from the cursor, a bounded number of times, then 503. +// +// A reader resuming from a cursor catches up through range() and then +// joins the shared tail; the live tail was positioned BEFORE the range +// read, so the two overlap instead of leaving a hole, and `covered` drops +// the overlap (verified against real Redis Streams in the phase-0 spike). +// A reader that falls behind the tail by more than `highWaterMark` entries +// stops buffering and catches up through range() again — memory per slow +// subscriber stays bounded. +// +// For single-sequence logs a cursor is simply the last entry id; for +// Kafka it is a vector of partition offsets, which is why the resume token +// yielded to the feed is the ADVANCED CURSOR, not the entry's own position. + +const { backoffDelay } = require('../utils.js'); +const { codedError } = require('./ids.js'); + +const DEFAULT_HIGH_WATER_MARK = 1024; +const DEFAULT_PAGE = 256; +// A tail that ENDED under its readers — the broker cancelled the consumer, +// its channel closed, the consumer crashed — is replaced by a fresh one, +// and each reader catches up from its own cursor through range() so the +// gap is closed. A broker that cannot start the fresh tail is asked again +// on this backoff, this many times, before the readers fail 503. +const MAX_REJOINS = 5; +const REJOIN_BACKOFF = Object.freeze({ minDelay: 200, maxDelay: 5000, factor: 2, jitter: true }); +// A catch-up that makes no progress — a page answered `done: false` with +// nothing in it, or one whose head does not follow the cursor — is asked +// again after this pause, this many times, before the read fails 503. +const STALL_DELAY = 50; +const MAX_STALLS = 10; + +const sleep = (ms) => + new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + if (typeof timer.unref === 'function') timer.unref(); + }); + +class TopicTails { + #live; + #range; + #covered; + #advance; + #contiguous; + #highWaterMark; + #page; + #onLag; + #tails = new Map(); // topic -> { readers, ready, current, controller, failure } + + constructor({ + live, + range, + covered, + advance, + contiguous = null, + highWaterMark = DEFAULT_HIGH_WATER_MARK, + page = DEFAULT_PAGE, + // `onLag(topic, readers)`: readers of a topic that fell `highWaterMark` + // entries behind its tail just had their buffers dropped and will + // catch up through range() instead — the one sign of subscribers that + // cannot keep up, and of range load on the broker, which used to pass + // without a trace. + onLag = null, + }) { + for (const [name, fn] of Object.entries({ live, range, covered, advance })) { + if (typeof fn !== 'function') throw new TypeError(`TopicTails: ${name} must be a function`); + } + if (contiguous !== null && typeof contiguous !== 'function') { + throw new TypeError('TopicTails: contiguous must be a function or null'); + } + if (onLag !== null && typeof onLag !== 'function') { + throw new TypeError('TopicTails: onLag must be a function or null'); + } + this.#onLag = onLag; + this.#live = live; + this.#range = range; + this.#covered = covered; + this.#advance = advance; + this.#contiguous = contiguous; + this.#highWaterMark = highWaterMark; + this.#page = page; + } + + /** Number of topics with a running tail — for tests and diagnostics. */ + get size() { + return this.#tails.size; + } + + #join(topic, reader) { + let tail = this.#tails.get(topic); + if (!tail) { + const controller = new AbortController(); + tail = { readers: new Set(), ready: null, current: null, positioned: false, controller, failure: null }; + const onEntry = (entry) => { + tail.current = this.#advance(tail.current, entry); + // push() answers true for a reader that JUST fell behind: nothing + // is counted, and nothing called, while everybody keeps up. + let lagged = 0; + for (const member of tail.readers) if (member.push(entry)) lagged++; + if (lagged > 0 && this.#onLag !== null) { + try { + this.#onLag(topic, lagged); + } catch { + // An observer's throw is not the tail's failure. + } + } + }; + // The tail died under its readers: forgotten, stopped, and every + // reader told to re-join (see read()). Before it was positioned, a + // failure is live()'s rejection, not an end. + const onEnd = () => { + if (!tail.positioned || this.#tails.get(topic) !== tail) return; + this.#tails.delete(topic); + tail.controller.abort(); + const members = Array.from(tail.readers); + tail.readers.clear(); + for (const member of members) member.rejoin(); + }; + tail.ready = Promise.resolve() + .then(() => this.#live(topic, { signal: controller.signal, onEntry, onEnd })) + .then( + (cursor) => { + tail.current = cursor ?? null; + tail.positioned = true; + // A 'latest' reader starts where the tail stood the moment it + // was READY — not where it stands at the reader's first next(): + // what is appended in between is in the reader's queue, and a + // start taken later would call it covered and drop it. + for (const member of tail.readers) member.start = tail.current; + }, + (error) => { + tail.failure = error; + // A failed tail is forgotten: the next read starts a fresh one + // instead of joining a corpse. + if (this.#tails.get(topic) === tail) this.#tails.delete(topic); + for (const member of tail.readers) member.fail(error); + }, + ); + this.#tails.set(topic, tail); + } + tail.readers.add(reader); + if (tail.positioned) reader.start = tail.current; + return tail; + } + + #leave(topic, tail, reader) { + tail.readers.delete(reader); + if (tail.readers.size > 0 || this.#tails.get(topic) !== tail) return; + this.#tails.delete(topic); + this.#stop(tail); + } + + // A tail still positioning is stopped once it is: an adapter's live() + // aborted in the middle of its own setup is a consumer half opened. + #stop(tail) { + if (tail.positioned || tail.failure !== null) return void tail.controller.abort(); + void tail.ready.then(() => tail.controller.abort()); + } + + read(topic, { after = null, from = 'latest', signal = null } = {}) { + const tails = this; + let wake = null; + const reader = { + queue: [], + lagging: false, + failure: null, + // Where a 'latest' read starts: the tail's cursor as of ready (#join). + start: null, + push(entry) { + if (reader.lagging) return false; + const lagged = reader.queue.length >= tails.#highWaterMark; + if (lagged) { + reader.lagging = true; + reader.queue.length = 0; + } else reader.queue.push(entry); + wake?.(); + return lagged; + }, + fail(error) { + reader.failure = error; + wake?.(); + }, + // The tail ended: joined afresh at the next turn of the loop, and + // caught up from the cursor — `lagging` is exactly that path. + moved: false, + rejoin() { + reader.moved = true; + reader.lagging = true; + wake?.(); + }, + }; + let tail = this.#join(topic, reader); + const catchUp = (after !== null && after !== undefined) || from === 'earliest'; + + // `entered`: the generator's body ran — and with it the `finally` that + // leaves the tail. A generator returned before its first next() never + // runs its body, so its place is released from the iterator instead. + let entered = false; + const iterate = async function* () { + entered = true; + const onAbort = () => wake?.(); + signal?.addEventListener('abort', onAbort, { once: true }); + try { + await tail.ready; + if (tail.failure) throw tail.failure; + let cursor = catchUp ? (after ?? null) : reader.start; + let pending = catchUp; + for (;;) { + if (signal?.aborted) return; + if (reader.failure) throw reader.failure; + if (pending || reader.lagging) { + reader.lagging = false; + pending = false; + if (reader.moved) { + reader.moved = false; + // A fresh tail, joined BEFORE the catch-up below so the two + // overlap the way a resume does. A broker that cannot start + // one is asked again on a backoff; then the read fails. + for (let attempt = 0; ; attempt++) { + tail = tails.#join(topic, reader); + await tail.ready.catch(() => {}); + if (tail.failure === null) break; + if (reader.failure === tail.failure) reader.failure = null; + if (signal?.aborted) return; + if (attempt >= MAX_REJOINS - 1) throw codedError('Log tail ended', 503); + await sleep(backoffDelay({ ...REJOIN_BACKOFF, attempt })); + } + } + // Page until the tip: that is "caught up as of now"; whatever + // arrived meanwhile is in the live queue. The tip is a short + // page for a store read directly, and what `done` says for a + // broker that pages by time (see the header). + let stalls = 0; + for (;;) { + const result = await tails.#range(topic, { after: cursor, limit: tails.#page }); + const page = Array.isArray(result) ? result : result.entries; + const done = Array.isArray(result) ? page.length < tails.#page : result.done === true; + let advanced = false; + let hole = false; + for (const entry of page) { + if (cursor !== null && tails.#covered(cursor, entry)) continue; + if (cursor !== null && tails.#contiguous !== null && !tails.#contiguous(cursor, entry)) { + hole = true; + break; + } + cursor = tails.#advance(cursor, entry); + advanced = true; + yield { id: cursor, value: entry.value, headers: entry.headers }; + if (signal?.aborted) return; + } + if (!hole && done) break; + if (advanced && !hole) { + stalls = 0; + continue; + } + // No progress: a hole at the head of the page, or a page the + // adapter called incomplete with nothing in it. Asked again + // after a pause, a bounded number of times. + if (++stalls > MAX_STALLS) { + throw codedError( + hole ? 'Log read fell behind a gap in the topic' : 'Log read could not reach the tip', + 503, + ); + } + await sleep(STALL_DELAY); + } + continue; + } + if (reader.queue.length > 0) { + const entry = reader.queue.shift(); + if (cursor !== null && tails.#covered(cursor, entry)) continue; + cursor = tails.#advance(cursor, entry); + yield { id: cursor, value: entry.value, headers: entry.headers }; + continue; + } + await new Promise((resolve) => { + wake = () => { + wake = null; + resolve(); + }; + if (signal?.aborted || reader.queue.length > 0 || reader.lagging || reader.failure) wake(); + }); + } + } finally { + signal?.removeEventListener('abort', onAbort); + tails.#leave(topic, tail, reader); + } + }; + + let started = false; + // A read that is never iterated still holds a place on the tail; its + // signal is how it lets go — until the body runs, whose `finally` does. + signal?.addEventListener('abort', () => entered || tails.#leave(topic, tail, reader), { once: true }); + const ready = tail.ready.then(() => { + if (tail.failure) throw tail.failure; + }); + ready.catch(() => {}); // surfaced through the iteration too; never unhandled + return { + ready, + [Symbol.asyncIterator]: () => { + // One iteration per read(): a second loop over the same reader + // would split its queue between two consumers. + if (started) throw codedError('A log read can be iterated once', 500); + started = true; + const iterator = iterate(); + // return()/throw() before the first next() end a generator without + // running its body: the reader's place on the tail is released here + // instead — a feed that closed its read at once used to hold a live + // reader for the life of the broker. `next` is not wrapped: hot path. + const release = () => { + if (!entered) tails.#leave(topic, tail, reader); + }; + const { return: end, throw: raise } = iterator; + iterator.return = (value) => { + release(); + return end.call(iterator, value); + }; + iterator.throw = (error) => { + release(); + return raise.call(iterator, error); + }; + return iterator; + }, + }; + } + + close() { + for (const tail of this.#tails.values()) { + this.#stop(tail); + // A reader parked on a closed broker used to wait forever. + for (const member of tail.readers) member.fail(codedError('Log closed', 503)); + } + this.#tails.clear(); + } +} + +module.exports = { TopicTails, DEFAULT_HIGH_WATER_MARK }; diff --git a/src/client.js b/src/client.js index 4920cb8..79054e9 100644 --- a/src/client.js +++ b/src/client.js @@ -1,12 +1,14 @@ 'use strict'; // The barrel the rest of the package (and the sse subpath) requires: the -// core, the built-in transports (their require registers them), and the -// Service Worker proxy. Splitting the modules changed no require path — +// core, the built-in transports (their require registers them — the +// WebTransport one included, so `transport: ['wt', 'ws']` needs no extra +// import), and the Service Worker proxy. Splitting the modules changed no require path — // this file kept the old one. const { WrpcClient, WrpcError, ClientTransport, isClientTransport, metaHeaders } = require('./client/core.js'); require('./client/transports.js'); +require('./client/webtransport.js'); const { WrpcClientProxy } = require('./client/proxy.js'); WrpcClient.initialize(); diff --git a/src/client/core.js b/src/client/core.js index eed739b..c65cb1b 100644 --- a/src/client/core.js +++ b/src/client/core.js @@ -6,13 +6,22 @@ // Worker proxy in ./proxy.js; ../client.js is the barrel that assembles // them, so require paths and the browser field are unchanged. -const { Emitter, jsonParse, isCodec, toKebab, backoffDelay, createEventStream } = require('../utils.js'); +const { + Emitter, + jsonParse, + isCodec, + toKebab, + backoffDelay, + createEventStream, + resolveGenerateId, +} = require('../utils.js'); const { generateUUID } = require('../runtime/node.js'); const { chunkDecode } = require('../chunks.js'); +const { hasBytes, encodeAttachments, decodeAttachments, isAttachmentsFrame } = require('../attachments.js'); const { WrpcReadable, WrpcWritable } = require('../streams.js'); const { createLoggerWriter } = require('../logging.js'); const { createClientTelemetry } = require('../telemetry/client.js'); -const { META_HEADER, META_PREFIX } = require('../wire.js'); +const { META_HEADER, META_PREFIX, HEADERS_PARAM, META_PARAM, WRPC_V1, WRPC_V2 } = require('../wire.js'); const CALL_TIMEOUT = 7 * 1000; @@ -28,9 +37,6 @@ const CONNECT_TIMEOUT_ERROR = { message: 'Connect timeout', code: 408 }; // other client-produced refusal, so `error.code` checks work here too. const REQUEST_TIMEOUT_ERROR = { message: 'Request timeout', code: 408 }; -// The wire revision this client speaks, offered as a WebSocket subprotocol. -const WRPC_PROTOCOL = 'wrpc.v1'; - // Monotonic where available; Date.now is the fallback for a host without it. const now = () => (typeof performance === 'object' ? performance.now() : Date.now()); const RECONNECT_TIMEOUT = 2 * 1000; @@ -230,6 +236,51 @@ const metaHeaders = (meta, prefixed) => { return out; }; +// Mirrors the server's metaMaxBytes default: past it the server drops the +// entire declared bag, so refusing here is the difference between a visible +// warning and a label that silently stopped arriving. +const META_MAX = 2048; + +// The connect URL of a transport whose constructor cannot set real headers +// — the WHATWG WebSocket by spec, a browser WebTransport too: connection- +// phase headers and metadata ride as ONE query parameter each. WebTransport +// has nothing else; ws uses it only by `carrier: 'query'` or under an empty +// offer (wsHandshake.browser.js). The server reads observed request headers +// first and these parameters only for names they do not carry. Loud caveat: the connect URL lands in proxy access logs — a +// device id belongs here, a secret does not. +const connectUrl = (url, headers, meta, log) => { + const at = url.indexOf('?'); + // ONE budget for the whole query, as the server measures it (the length + // of everything after '?', against metaMaxBytes — past it the server + // drops the ENTIRE bag): what the url already carries counts, and so does + // every separator. Two bags each within the cap used to be sent and + // dropped together on the side that could not see it. Headers first, + // meta after; what does not fit is refused loudly, and the connection + // works un-labelled. + let spent = at < 0 ? 0 : url.length - at - 1; + const params = []; + const declare = (param, bag) => { + const value = encodeURIComponent(JSON.stringify(bag)); + const bytes = (spent > 0 ? 1 : 0) + param.length + 1 + value.length; + if (spent + bytes > META_MAX) { + return void log?.warn({ event: 'meta.oversize', carrier: 'query', param, bytes: spent + bytes }); + } + spent += bytes; + params.push(`${param}=${value}`); + }; + if (headers) { + declare(HEADERS_PARAM, headers); + // Loud on purpose: the connect URL is what access logs keep. + for (const key in headers) { + if (key.toLowerCase() !== 'authorization') continue; + log?.warn({ event: 'declared.exposed', key: 'authorization', carrier: 'query' }); + break; + } + } + if (meta) declare(META_PARAM, meta); + return params.length > 0 ? `${url}${at < 0 ? '?' : '&'}${params.join('&')}` : url; +}; + // ws and http spell the same endpoint with different schemes; a fallback // list crosses that line, so the URL is re-spelled per candidate. const mapScheme = (url, name) => { @@ -244,8 +295,13 @@ 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; + // The protocol revision this connection speaks (protocol.md#versioning). + // 2 on the carriers 1.0 never had; a transport a 1.0 peer can be behind + // starts at 1 and raises it once its peer said 2 — only then does a + // packet with bytes leave as a frame. + revision = 2; constructor(url) { super(); @@ -255,8 +311,16 @@ class ClientTransport extends Emitter { send(obj) { // The codec is handed to the transport at bind time (client core). // `obj.meta` rides along so a transport with a header block can mirror - // it; a control packet has none, and ws/worker ignore the argument. - this.write(this.codec ? this.codec.encode(obj) : JSON.stringify(obj), obj?.meta); + // it; a control packet has none, and ws/worker ignore the argument. A + // packet holding bytes leaves as an attachments frame (attachments.js) + // unless the client opted out or the peer speaks revision 1, where the + // bytes are the JSON 1.0 made of them; a codec owns the wire and skips it. + const wire = this.codec + ? this.codec.encode(obj) + : this.attachments !== false && this.revision === 2 && hasBytes(obj) + ? encodeAttachments(obj) + : JSON.stringify(obj); + this.write(wire, obj?.meta); } // Drop the connection without waiting for a close handshake. The default @@ -281,14 +345,14 @@ class ClientTransport extends Emitter { // subscribe's terminal signal is an `end`, not a callback. failPackets(data, status) { let parsed = null; - if (this.codec) { - try { - parsed = this.codec.decode(data); - } catch { - parsed = null; - } - } else { - parsed = jsonParse(data); + try { + if (this.codec) parsed = this.codec.decode(data); + // A request with bytes left as an attachments frame: its calls are + // parked on their ids like any other's. + else if (typeof data === 'string') parsed = jsonParse(data); + else if (isAttachmentsFrame(data)) parsed = decodeAttachments(data); + } catch { + parsed = null; } if (!parsed) return; const packets = Array.isArray(parsed) ? parsed : [parsed]; @@ -387,6 +451,9 @@ class WrpcClient extends Emitter { #transportIndex = 0; #boundHandlers = null; #codec = null; + // Binary attachments (attachments.js): on by default, `attachments: + // false` sends every packet as JSON as revision 1 did. + #attachments = true; #codecRest = null; #reconnect = RECONNECT; #reconnectTimer = null; @@ -414,6 +481,7 @@ class WrpcClient extends Emitter { #metaFormat = 'json'; #random = Math.random; #generateId = generateUUID; + #pingSentAt = 0; #heartbeat = null; #pingTimer = null; #pongTimer = null; @@ -436,6 +504,26 @@ class WrpcClient extends Emitter { return this.#attempt; } + /** + * The facts of this connection's encrypted session — `{ protocol, pattern, + * cipher, kid, remoteStatic, handshakeHash }` — or null when it is not + * one. `handshakeHash` is unique to the handshake and the same on both + * ends: what an `authenticate` hook binds a credential to. + */ + get encryption() { + return this.#transport.encryption ?? null; + } + + /** + * The protocol revision of the current connection: 2 when the peer reads + * framed messages (bytes travel as bytes), 1 when it is a 1.0 peer — or + * this client opted out with `attachments: false` — and bytes travel as + * the JSON 1.0 made of them. + */ + get revision() { + return this.#transport.revision; + } + constructor(url, transport, options = {}) { super(); const { callTimeout, proxy, random, generateId, logger, telemetry, querystring, validation, codec } = options; @@ -517,6 +605,21 @@ class WrpcClient extends Emitter { } this.#metaFormat = metaFormat; } + const { carrier } = options; + // How the declared bags leave on the ws handshake. 'auto': real request + // headers where the platform's WebSocket can set them (Node), subprotocol + // carrier tokens where it cannot (a browser). 'protocol' forces the + // tokens, 'query' the connect-URL parameters — for an intermediary that + // mangles Sec-WebSocket-Protocol, at the price of labels in access logs. + if (carrier !== undefined) { + if (carrier !== 'auto' && carrier !== 'protocol' && carrier !== 'query') { + throw new TypeError("WrpcClient: options.carrier must be 'auto', 'protocol' or 'query'"); + } + // A token needs an offer the server can answer next to it. + if (carrier === 'protocol' && options.protocols?.length === 0) { + throw new TypeError("WrpcClient: carrier 'protocol' cannot ride an empty `protocols` offer"); + } + } // Off by default, unlike the server: a client that printed on every // reconnect would be noise in a browser console nobody asked for. this.#log = createLoggerWriter(logger); @@ -547,10 +650,15 @@ class WrpcClient extends Emitter { this.#codecRest = codec.rest ?? null; } if (proxy) this.#proxyPacket = proxy; + // A packet codec owns the wire, so attachments are off under one. + this.#attachments = options.attachments !== false && this.#codec === null; if (random) this.#random = random; // deterministic jitter in tests // Packet, subscription and stream ids; uuid v4 unless the app brings - // its own (cuid/ulid/a test counter). Correlation ids, not secrets. - if (typeof generateId === 'function') this.#generateId = generateId; + // its own (cuid/ulid/a test counter). Correlation ids, not secrets. A + // bad generator is a TypeError here, as on every other option since 2.0. + if (generateId !== undefined && generateId !== null) { + this.#generateId = resolveGenerateId(generateId, 'WrpcClient').generate; + } this.#reconnect = normalizeReconnect(options); this.#heartbeat = normalizeHeartbeat(options); this.#batch = normalizeBatch(options); @@ -565,19 +673,94 @@ class WrpcClient extends Emitter { // WrpcClient.connections before the transport was reached, and a failing // authenticate arms the reconnect timer — close() undoes both, so the // caller who never received the client has nothing running for it. + // The first connect walks the fallback list on its own: a candidate whose + // open() rejects outright — no WebTransport in this runtime, a refused + // upgrade — hands over to the next one at once, with no reconnect budget + // to burn first, because nothing was ever connected to recover. Only the + // last candidate's rejection is connect()'s. static async #openOrClose(client) { + for (;;) { + try { + await client.open(); + return client; + } catch (error) { + // A candidate that never opened hands over to the next. A hook that + // refused AFTER the transport opened is the application's verdict + // on the connection, not the transport's failure: connect() rejects + // — it used to advance with `#connected` still set from the first + // candidate, so the next one opened as a "reconnect", the hook ran + // unawaited, and connect() resolved unauthenticated. + const beforeOpen = !client.#connected; + client.#resetSession(); + if (beforeOpen && client.#advanceTransport()) continue; + client.close(); + throw error; + } + } + } + + // A client that was handed `encryption` never speaks plaintext: every + // transport it may use — the fallback candidates included, checked up + // front like their names — must be one that carries it (`static encrypts`). + static #checkEncryption(options, name, Transport) { + const { encryption } = options; + if (encryption === undefined || encryption === null) return; + // A transport over a shared carrier (the broker binding) seals under a + // keyring instead of a handshake, and says so: `encrypts = 'keys'`. + if (Transport.encrypts === 'keys' && typeof encryption === 'object' && encryption.keys !== undefined) return; + if (typeof encryption !== 'object' || typeof encryption.secure !== 'function') { + throw new TypeError("options.encryption must come from createEncryption() ('@alexify/wrpc/encryption')"); + } + // A transport that seals each request (http, sse) needs the server's + // key before the first one, which only some patterns have: said here, + // for every fallback candidate, not on the day ws is unreachable. + if (Transport.encrypts === 'request') { + if (typeof encryption.fetch === 'function') return; + throw new TypeError(`options.encryption has no serverKey to seal a request to — the ${name} transport needs one`); + } + if (Transport.encrypts !== true) { + throw new TypeError(`Transport '${name}' cannot carry options.encryption — and plaintext is not a fallback`); + } + } + + // The other half of `static encrypts = true`, checked by deed: a session + // transport asked to encrypt has `encryption` set by the time it says + // 'open'. A static is inherited, so a subclass with an open() of its own + // passes the check above and may still open a session nobody sealed — + // refused here, before 'open' is announced, a hook presents a credential + // or a packet leaves. Terminal (out of the reconnect cycle before the + // terminate reports its close): the next attempt would be the same. The + // connection is counted in so the close handler's count out balances, and + // the rejection is what open() re-raises — it awaits #opened. + #refuseUnsealed() { + const transport = this.#transport; + if (typeof this.#options.encryption?.secure !== 'function') return false; + if (transport.constructor.encrypts !== true || transport.encryption) return false; + WrpcClient.connections.delete(this); + this.#otel.recordConnection(1); try { - await client.open(); - } catch (error) { - client.close(); - throw error; + transport.terminate(); + } catch { + // Already dead. } - return client; + this.#resetSession(); + this.#opened = Promise.reject(new TypeError('The transport opened a session it did not encrypt')); + this.#opened.catch(() => {}); + return true; } static async connect(url, options = {}) { if (options.worker) { - const transport = WrpcClient.transport.event.getInstance(url); + // The port to a worker is in-process; the hop that leaves the machine + // is the one WrpcClientProxy opens, and that is where the option goes. + if (options.encryption) { + throw new TypeError('options.encryption belongs to the WrpcClientProxy in the worker, not to its page'); + } + // One transport per client, never the class-level getInstance(): a + // shared one made a second connect() to ANOTHER worker reuse the first + // MessageChannel, and one client's close() close the other's port. + const EventTransport = WrpcClient.transport.event; + const transport = new EventTransport(url); const client = new WrpcClient(url, transport, options); return WrpcClient.#openOrClose(client); } @@ -598,6 +781,7 @@ class WrpcClient extends Emitter { if (!isClientTransport(WrpcClient.transport[candidate])) { throw new TypeError(`Transport '${candidate}' does not satisfy the ClientTransport contract`); } + WrpcClient.#checkEncryption(options, candidate, WrpcClient.transport[candidate]); } } const name = Array.isArray(options.transport) @@ -610,6 +794,7 @@ class WrpcClient extends Emitter { if (!isClientTransport(Transport)) { throw new TypeError(`Transport '${name}' does not satisfy the ClientTransport contract`); } + WrpcClient.#checkEncryption(options, name, Transport); const transport = new Transport(mapScheme(url, name)); const client = new WrpcClient(url, transport, options); return WrpcClient.#openOrClose(client); @@ -634,12 +819,16 @@ class WrpcClient extends Emitter { if (this.#calls.size === 0 && this.#pending.length === 0) return; const entries = Array.from(this.#calls.values()); this.#calls.clear(); + this.#lanes.clear(); + if (this.#deadlineTimer !== null) { + clearTimeout(this.#deadlineTimer); + this.#deadlineTimer = null; + } this.#pending.length = 0; this.#pendingBytes = 0; this.#cancelled.clear(); for (const entry of entries) { - clearTimeout(entry.timeout); - entry.release(); + entry.release?.(); entry.reject(new WrpcError(CONNECTION_CLOSED_ERROR)); } } @@ -651,9 +840,7 @@ class WrpcClient extends Emitter { #failCall(id) { const entry = this.#calls.get(id); if (!entry) return; - this.#calls.delete(id); - clearTimeout(entry.timeout); - entry.release(); + this.#forget(entry, id); entry.reject(new WrpcError(CONNECTION_CLOSED_ERROR)); } @@ -684,13 +871,20 @@ class WrpcClient extends Emitter { // seam: a transport that refuses something (an oversize meta block) has // to be able to say so where the application can see it. if (this.#codec) this.#transport.codec = this.#codec; + if (!this.#attachments) this.#transport.attachments = false; this.#transport.log = this.#log; + // A transport that mints ids of its own (the broker transport's session + // and correlation ids) gets the generator this client already resolved, + // rather than re-resolving the same option in a stricter mode and + // disagreeing with it about what a bad value means. + this.#transport.generateId = this.#generateId; const bind = (event, handler) => { this.#boundHandlers ??= []; this.#boundHandlers.push([event, handler]); this.#transport.on(event, handler); }; bind('open', () => { + if (this.#refuseUnsealed()) return; clearTimeout(this.#reconnectTimer); this.#reconnectTimer = null; const attempts = this.#attempt; @@ -759,10 +953,25 @@ class WrpcClient extends Emitter { this.#escalate(error, 'transport.error'); }); + // The client IS the transport a WrpcWritable holds (createStream), so + // a flow-controlled transport's 'drain' has to be re-announced here or + // the producer parked on it never wakes. + bind('drain', () => { + this.emit('drain').catch((error) => this.#escalate(error, 'listener.drain')); + }); + bind('message', (data) => { const escalate = (error) => this.#escalate(error, 'message'); - if (typeof data === 'string') this.#handlePacket(data).catch(escalate); - else this.#handleBinary(data).catch(escalate); + if (typeof data === 'string') return void this.#handlePacket(data).catch(escalate); + // Bytes: a stream chunk, or — under the 0x00 marker no chunk has — an + // attachments frame carrying a packet. Classified synchronously, so + // a packet and the chunk behind it stay in wire order; the ws + // transport sets binaryType to 'arraybuffer' for exactly this. + const view = data instanceof Uint8Array ? data : data instanceof ArrayBuffer ? new Uint8Array(data) : null; + if (view !== null && this.#attachments && isAttachmentsFrame(view)) { + return void this.#handleFrame(view).catch(escalate); + } + this.#handleBinary(data).catch(escalate); }); } @@ -800,6 +1009,7 @@ class WrpcClient extends Emitter { // Same reasoning, same ordering, as the restore path below: put the // pre-open attempt count back BEFORE terminate() synchronously hands // it to the reconnect scheduler. + this.#unproven(); if (this.#attempt === 0) this.#attempt = attempts; if (this.active) this.#transport.terminate(); throw error; @@ -813,6 +1023,18 @@ class WrpcClient extends Emitter { if (reconnected) await this.#restore(attempts); } + // A connection that failed its credential or its restore never proved + // itself, so its stability window must not zero the attempt count while + // the close terminate() starts is still on its way: a ws transport still + // settling its open (a hook that throws synchronously runs inside the + // 'open' emit) can only begin a close handshake, and 'close' arrives when + // the peer answers — past stableAfter on a slow runner, and the backoff + // went back to minDelay every time. + #unproven() { + clearTimeout(this.#stableTimer); + this.#stableTimer = null; + } + async #restore(attempts) { // Each subscription is re-opened from the last eventId it saw, so the // server can replay what was missed instead of starting over. A feed @@ -833,6 +1055,7 @@ class WrpcClient extends Emitter { // and that is where the scheduler reads it. The guard covers the other // ordering: a socket that died on its own already advanced the // counter, and that number is the fresher one. + this.#unproven(); if (this.#attempt === 0) this.#attempt = attempts; // Still "open" from the transport's point of view — force the cycle. if (this.active) this.#transport.terminate(); @@ -964,6 +1187,9 @@ class WrpcClient extends Emitter { } catch (error) { return void this.#escalate(error, 'heartbeat.ping'); } + // Only when something is listening: `now()` on an idle client, every + // heartbeat interval forever, is not a cost telemetry-off should pay. + this.#pingSentAt = this.#otel.enabled ? now() : 0; this.#pongTimer = unref( setTimeout(() => { this.#pongTimer = null; @@ -976,6 +1202,13 @@ class WrpcClient extends Emitter { if (!this.#pongTimer) return; // unsolicited pong: nothing was waiting clearTimeout(this.#pongTimer); this.#pongTimer = null; + // AFTER the guard above, deliberately: an unsolicited pong has no ping + // to measure against and would record a garbage sample from whenever + // the last one happened to go out. + if (this.#pingSentAt > 0) { + this.#otel.recordHeartbeat('ok', now() - this.#pingSentAt); + this.#pingSentAt = 0; + } if (this.#heartbeat && this.active) this.#armPing(); } @@ -983,6 +1216,11 @@ class WrpcClient extends Emitter { // the normal reconnect path takes over. #onHeartbeatTimeout() { this.#log.warn({ event: 'heartbeat.timeout', url: this.url }); + // Counted, never timed: there is no round trip here, and inventing one + // from the timeout would put a spike into the latency percentiles that + // says more about the configured timeout than about the network. + this.#otel.recordHeartbeat('timeout'); + this.#pingSentAt = 0; // Like every lifecycle emit: a throwing listener on a documented event // must surface through #escalate, not as an unhandled rejection. this.emit('heartbeat-timeout').catch((error) => this.#escalate(error, 'listener.heartbeat-timeout')); @@ -1063,12 +1301,21 @@ class WrpcClient extends Emitter { await opened; } - close() { + // What close() and a failed first connect share: the timers, the pending + // authenticate hand-off, and the connected/attempt state — so the next + // open() is a first open, never a reconnect of a connection that was. + #resetSession() { clearTimeout(this.#reconnectTimer); this.#reconnectTimer = null; clearTimeout(this.#stableTimer); this.#stableTimer = null; this.#opened = null; + this.#connected = false; + this.#attempt = 0; + } + + close() { + this.#resetSession(); this.#stopHeartbeat(); // Anything still queued leaves before the socket does; a call whose // packet never shipped would otherwise wait out its whole timeout. @@ -1079,10 +1326,9 @@ class WrpcClient extends Emitter { // reads true by the time it is asked. const ended = Array.from(this.#subscriptions.values()); this.#subscriptions.clear(); - // An explicit close ends the session: a later open() is a fresh start, - // not a reconnect, so it must not replay 'reconnect'. - this.#connected = false; - this.#attempt = 0; + // An explicit close ends the session (#resetSession above): a later + // open() is a fresh start, not a reconnect, so it must not replay + // 'reconnect'. WrpcClient.connections.delete(this); // An explicit close settles everything in flight the same way a dropped // connection does — flushed packets included, since their answers can @@ -1115,8 +1361,12 @@ class WrpcClient extends Emitter { record.stream?.end(); } + // Returns the transport's backpressure signal (false = above its + // high-water mark) so a WrpcWritable can park on 'drain' instead of + // buffering without limit; a transport that reports nothing counts as + // accepted (undefined !== false). write(data) { - this.#transport.write(data); + return this.#transport.write(data); } send(data) { @@ -1127,6 +1377,12 @@ class WrpcClient extends Emitter { } #enqueue(packet) { + // A call holding bytes cannot join a text batch: what is pending + // leaves first, in order, and the call goes on its own frame. + if (this.#attachments && hasBytes(packet)) { + this.flush(); + return void this.#transport.send(packet); + } // Serialized ONCE: the same text is the size accounting and the wire // bytes — the old path stringified for the length, dropped the string, // and paid stringify again at flush. Sizes are UTF-16 code units @@ -1240,9 +1496,26 @@ class WrpcClient extends Emitter { const { name = 'blob', size } = blob; const consumer = this.createStream(name, size); const { id } = consumer; + // Paced by the transport — a chunk it did not accept waits for the + // stream's 'drain' — and a connection that closes mid-way is the 503 a + // call gets, not a resolve with the rest gone nowhere. A source that + // fails terminates the stream, so the server stops waiting for its end. + // Once: a second run wrote into the stream the first had ended and waited + // for a 'drain' that never comes. + let started = false; const upload = async () => { - for await (const chunk of blob.stream()) { - consumer.write(chunk); + if (started) throw new Error('upload() runs once — its stream was already used; create another uploader'); + started = true; + try { + for await (const chunk of blob.stream()) { + if (!consumer.write(chunk) && !consumer.closed) { + await new Promise((resolve) => consumer.once('drain', resolve)); + } + if (consumer.closed) throw new WrpcError(CONNECTION_CLOSED_ERROR); + } + } catch (error) { + consumer.terminate(); + throw error; } consumer.end(); }; @@ -1265,6 +1538,26 @@ class WrpcClient extends Emitter { if (this.#proxyPacket) return void this.#proxyPacket(data, null); throw new Error('Invalid JSON packet'); } + return this.#handleParsed(data, packet); + } + + // An attachments frame: its packet, bytes restored, handled exactly as + // the same packet parsed from text would be. + async #handleFrame(view) { + let packet; + try { + packet = decodeAttachments(view); + } catch (error) { + if (this.#proxyPacket) return void this.#proxyPacket(view, null); + throw error; + } + return this.#handleParsed(view, packet); + } + + async #handleParsed(data, packet) { + // The answer to a call is the commonest inbound packet: settle it here, + // before the batch and heartbeat checks and without the #dispatch hop. + if (packet.type === 'callback' && packet.id && !this.#proxyPacket) return void this.#settle(packet); // A batch frame answers several packets at once; each one is dispatched // exactly as it would have been on its own. if (Array.isArray(packet)) { @@ -1311,9 +1604,7 @@ class WrpcClient extends Emitter { // rejected the moment it aborted, so the ack is expected, not an error. if (!call && this.#cancelled.delete(id)) return; if (!call) throw new Error(`Callback ${id} not found`); - this.#calls.delete(id); - clearTimeout(call.timeout); - call.release?.(); + this.#forget(call, id); if (packet.error) return void call.reject(new WrpcError(packet.error)); call.resolve(packet.result); } @@ -1350,8 +1641,12 @@ class WrpcClient extends Emitter { .then(() => { if (this.active && this.#subscriptions.has(id)) this.#openSubscription(record); }) - .catch(() => { + .catch((error) => { // The refresh's own failure: deliver the refusal the feed earned. + // Logged like its call-side twin (`refresh.failed`): a feed dying + // for want of a credential looks, from the application, exactly + // like a feed with nothing to say. + this.#log.warn({ err: error, event: 'subscription.refresh', id }); if (this.#subscriptions.delete(id)) this.#failSubscription(record, packet.error); }); return; @@ -1548,15 +1843,29 @@ class WrpcClient extends Emitter { this.#unitMethods.set(unit, new Set(methodNames)); } - /** Sends a fire-and-forget event to the server; `name` is 'unit/event'. */ - sendEvent(name, data) { + /** + * Sends a fire-and-forget event to the server; `name` is 'unit/event'. + * `unreliable: true` lets a transport with datagrams (WebTransport) send + * it as one — lossy and unordered, for state a later event supersedes, + * a cursor or a position — and falls back to the ordinary reliable send + * where the transport has none or the packet does not fit in one. + */ + sendEvent(name, data, options = null) { const packet = { type: 'event', name, data }; // Fire-and-forget still deserves a parent: the server opens a CONSUMER // span for the event, and this is what links it to the caller's trace. this.#otel.inject(packet); + if (options?.unreliable === true && this.#sendUnreliable(packet)) return; this.send(packet); } + #sendUnreliable(packet) { + const transport = this.#transport; + if (typeof transport.writeUnreliable !== 'function' || !transport.active) return false; + const text = transport.codec ? transport.codec.encode(packet) : JSON.stringify(packet); + return transport.writeUnreliable(text) === true; + } + // `unit` is the introspection unit key — 'auth' or the pinned 'auth.v1' — // so the version already rides inside it; the wire target is 'unit/name'. #target(unit, methodName) { @@ -1812,46 +2121,127 @@ class WrpcClient extends Emitter { #dispatchCall(target, packet, id, signal, callTimeout) { return new Promise((resolve, reject) => { if (signal?.aborted) return void reject(new WrpcError(CANCELLED_ERROR)); - const timeout = setTimeout( - () => { - if (!this.#calls.has(id)) return; - this.#calls.delete(id); - this.#unqueue(id); - release(); - reject(new WrpcError(REQUEST_TIMEOUT_ERROR)); - }, - callTimeout > 0 ? callTimeout : this.#callTimeout, - ); - const onAbort = () => { - if (!this.#calls.has(id)) return; - this.#calls.delete(id); - clearTimeout(timeout); - // Still queued: drop it instead of racing a cancel ahead of the - // call. And a request/response transport cannot carry a cancel at - // all — sending one there only earns a 400 nobody can route. - if (!this.#unqueue(id) && this.active && this.#transport.persistent !== false) { - this.#cancelled.add(id); - this.send({ type: 'cancel', id }); - } - reject(new WrpcError(CANCELLED_ERROR)); - }; - const release = () => signal?.removeEventListener('abort', onAbort); - signal?.addEventListener('abort', onAbort, { once: true }); - this.#calls.set(id, { resolve, reject, timeout, release }); + const bucket = this.#armCall(id, callTimeout > 0 ? callTimeout : this.#callTimeout); + let release = null; + if (signal) { + const onAbort = () => { + const call = this.#calls.get(id); + if (!call) return; + this.#forget(call, id); + // Still queued: drop it instead of racing a cancel ahead of the + // call. And a request/response transport cannot carry a cancel at + // all — sending one there only earns a 400 nobody can route. + if (!this.#unqueue(id) && this.active && this.#transport.persistent !== false) { + this.#cancelled.add(id); + this.send({ type: 'cancel', id }); + } + reject(new WrpcError(CANCELLED_ERROR)); + }; + release = () => signal.removeEventListener('abort', onAbort); + signal.addEventListener('abort', onAbort, { once: true }); + } + const call = { resolve, reject, bucket, release }; + this.#calls.set(id, call); try { this.send(packet); } catch (error) { // A dead transport throws synchronously ('Not connected'). Reject // CODED like every other dead-connection path — and clean up now, - // or the armed timer would hold the entry for the full callTimeout. - this.#calls.delete(id); - clearTimeout(timeout); - release(); + // or the armed deadline would hold the entry for the full callTimeout. + this.#forget(call, id); reject(new WrpcError({ message: error?.message ?? 'Connection closed', code: 503 })); } }); } + // --- Call deadlines -------------------------------------------------- + // + // One timer for every call in flight instead of a setTimeout per call. + // Calls are bucketed by deadline, one lane per distinct timeout value + // (the default callTimeout is one lane; a per-call `timeout` gets its + // own), each lane quantized to a bucket width of 1/32 of its timeout: a + // call times out between its deadline and deadline + width — never + // early, and 3 % late at most. Within a lane deadlines are monotonic, so + // a lane's buckets stay ordered by insertion and the earliest is the + // first entry. Settling a call removes it from its bucket at once, so an + // idle client holds no timer (bench/browser/calls.js, bench/bench.js). + #lanes = new Map(); + #deadlineTimer = null; + #deadlineAt = 0; + + #armCall(id, ms) { + let lane = this.#lanes.get(ms); + if (lane === undefined) { + lane = { ms, width: Math.max(1, Math.floor(ms / 32)), buckets: new Map() }; + this.#lanes.set(ms, lane); + } + const end = Math.ceil((Date.now() + ms) / lane.width) * lane.width; + let bucket = lane.buckets.get(end); + if (bucket === undefined) { + bucket = new Set(); + bucket.lane = lane; + bucket.end = end; + lane.buckets.set(end, bucket); + } + bucket.add(id); + if (this.#deadlineTimer === null || end < this.#deadlineAt) this.#armDeadline(end); + return bucket; + } + + #armDeadline(at) { + if (this.#deadlineTimer !== null) clearTimeout(this.#deadlineTimer); + this.#deadlineAt = at; + this.#deadlineTimer = setTimeout(this.#onDeadline, Math.max(0, at - Date.now())); + } + + #onDeadline = () => { + this.#deadlineTimer = null; + const now = Date.now(); + let next = 0; + for (const lane of this.#lanes.values()) { + for (const bucket of lane.buckets.values()) { + if (bucket.end > now) { + if (next === 0 || bucket.end < next) next = bucket.end; + break; + } + lane.buckets.delete(bucket.end); + for (const id of bucket) this.#timeoutCall(id); + } + if (lane.buckets.size === 0) this.#lanes.delete(lane.ms); + } + if (next !== 0) this.#armDeadline(next); + }; + + #timeoutCall(id) { + const call = this.#calls.get(id); + if (!call) return; + this.#calls.delete(id); + this.#unqueue(id); + call.release?.(); + call.reject(new WrpcError(REQUEST_TIMEOUT_ERROR)); + } + + // Drops a call's bookkeeping: its map entry, its deadline bucket (and the + // lane and timer when they empty — an idle client keeps nothing armed) + // and its abort listener. + #forget(call, id) { + this.#calls.delete(id); + const { bucket } = call; + bucket.delete(id); + if (bucket.size === 0) { + const { lane } = bucket; + lane.buckets.delete(bucket.end); + if (lane.buckets.size === 0) { + this.#lanes.delete(lane.ms); + if (this.#lanes.size === 0 && this.#deadlineTimer !== null) { + clearTimeout(this.#deadlineTimer); + this.#deadlineTimer = null; + } + } + } + call.release?.(); + } + /** * Opens a subscription. `onData` receives every value; the returned handle * carries the last seen eventId and can stop it. The subscription is @@ -2015,15 +2405,29 @@ class WrpcClient extends Emitter { } } +// The status a transport hands the calls a refused SEALED request carried +// (src/encryption/http.js throws EncryptionRefusedError): only the closed +// set the sealing layer vouches for — a retired key, a clock, a required +// mode — because the outer status of anything else is unauthenticated. +// Here and not in encryption/http.js: the transports must not load HPKE. +const refusedStatus = (error) => + error?.code === 'ENCRYPTION_REFUSED' && (error.status === 400 || error.status === 409 || error.status === 426) + ? error.status + : 503; + module.exports = { + refusedStatus, WrpcClient, WrpcError, ClientTransport, isClientTransport, - WRPC_PROTOCOL, + WRPC_V1, + WRPC_V2, CALL_TIMEOUT, normalizeReconnect, metaHeaders, + connectUrl, + META_MAX, unref, toByteView, }; diff --git a/src/client/proxy.js b/src/client/proxy.js index 5290561..82a7416 100644 --- a/src/client/proxy.js +++ b/src/client/proxy.js @@ -1,25 +1,64 @@ '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'); +const { isAttachmentsFrame, decodeAttachments } = require('../attachments.js'); + +// The page's packet: JSON text, or an attachments frame carrying one (a +// call with bytes in its args). Anything else — bytes that are no frame, +// text that is no JSON — is null: not a packet. +const parsePacket = (data) => { + if (typeof data === 'string') return jsonParse(data); + const view = data instanceof Uint8Array ? data : data instanceof ArrayBuffer ? new Uint8Array(data) : null; + if (view === null || !isAttachmentsFrame(view)) return null; + try { + return decodeAttachments(view); + } catch { + return null; + } +}; class WrpcClientProxy extends Emitter { #ports = new Set(); + // The ports whose page reads framed messages — it said revision 2 on its + // first ping (protocol.md#versioning). A page that did not is a 1.0 page, + // or one that opted out: a frame from upstream reaches it as JSON. + #modern = new WeakSet(); #pending = new Map(); + // The ids of the pages' open subscriptions: what a port's release + // unsubscribes upstream, where a call is cancelled. + #feeds = new Set(); + // The ids of the pages' calls in flight: what a lost upstream answers. + #calls = new Set(); + // The connect in progress: pages whose first packets arrive together + // share it, where each used to open its own upstream socket. + #opening = null; #connection = null; #callTimeout = CALL_TIMEOUT; #reconnect = null; #heartbeat = undefined; #logger = undefined; #telemetry = undefined; + #encryption = 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, encryption } = options; if (callTimeout) this.#callTimeout = callTimeout; this.#reconnect = normalizeReconnect(options); this.#heartbeat = heartbeat; @@ -27,32 +66,80 @@ class WrpcClientProxy extends Emitter { // is silently lost on the connection it owns. this.#logger = logger; this.#telemetry = telemetry; + // The hop that leaves the machine is this worker's, so session + // encryption (createEncryption) is configured here, not on the page. + this.#encryption = encryption; + 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(); + }); + } + + open() { + this.#opening ??= this.#open().finally(() => { + this.#opening = null; }); + return this.#opening; } - async open() { + async #open() { if (this.#connection) { if (this.#connection.active) return; await this.#connection.open(); 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, heartbeat: this.#heartbeat, logger: this.#logger, telemetry: this.#telemetry, + encryption: this.#encryption, proxy: (data, packet) => this.#proxyPacket(data, packet), }; this.#connection = await WrpcClient.connect(url, options); + // What the pages were waiting for died with the connection, and the + // client above answers only its OWN calls — a page's went up as raw + // packets. Each page hears it now, where it used to wait out its + // callTimeout: a call its error, a subscription its end. A reconnect + // starts with none. + this.#connection.on('close', () => { + const error = { message: 'The worker lost its connection to the server', code: 503 }; + for (const [id, port] of this.#pending) { + if (this.#feeds.has(id)) port.postMessage(JSON.stringify({ type: 'end', id, error })); + else if (this.#calls.has(id)) port.postMessage(JSON.stringify({ type: 'callback', id, error })); + } + this.#pending.clear(); + this.#feeds.clear(); + this.#calls.clear(); + }); + } + + // A page is gone: its port, and everything it was waiting for. What is + // still running upstream for it is stopped — its subscriptions + // unsubscribed, its calls cancelled — instead of running for the life of + // the connection with nobody to hear the answer. + #release(port) { + if (!this.#ports.delete(port)) return; + const connection = this.#connection; + const live = connection !== null && connection.active; + for (const [id, pending] of this.#pending) { + if (pending !== port) continue; + this.#pending.delete(id); + this.#calls.delete(id); + const feed = this.#feeds.delete(id); + if (live) connection.send(feed ? { type: 'unsubscribe', id } : { type: 'cancel', id }); + } } close() { @@ -68,8 +155,18 @@ class WrpcClientProxy extends Emitter { if (!port) throw new Error('MessagePort not provided'); this.#ports.add(port); port.addEventListener('message', (messageEvent) => { - this.#handleMessage(messageEvent, port); + // The page's transport says goodbye before it closes its port: a + // MessagePort's own `close` is what older engines never fire. + if (messageEvent.data?.type === 'wrpc:close') return void this.#release(port); + // A failure to forward — nothing to connect to, a packet that is + // none — answers the caller when there is one, instead of leaving + // it to its callTimeout, and is never an unhandled rejection. + this.#handleMessage(messageEvent, port).catch((error) => this.#refuse(messageEvent.data, port, error)); }); + // 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.#release(port)); port.start(); return; } @@ -81,46 +178,87 @@ class WrpcClientProxy extends Emitter { async #handleMessage(event, port) { const { data } = event; if (data === undefined) throw new Error('Message data is undefined'); - await this.open(); - if (!this.#connection || !this.#connection.active) { - throw new Error('Not connected to server'); - } - const packet = jsonParse(data); + const packet = parsePacket(data); if (!packet) throw new Error('Invalid JSON packet'); // The worker is the page's peer: it answers the page's heartbeat itself - // rather than paying for a round trip to the server for every port. - if (packet.type === 'ping') return void port.postMessage(JSON.stringify({ type: 'pong' })); + // rather than paying for a round trip to the server for every port. A + // ping that names a revision is the page's first, and is answered with + // this end's: the proxy reads frames, whatever is upstream of it. + if (packet.type === 'ping') { + if (packet.v === undefined) return void port.postMessage('{"type":"pong"}'); + if (packet.v === 2) this.#modern.add(port); + return void port.postMessage('{"type":"pong","v":2}'); + } if (packet.type === 'pong') return; if (!packet.id) throw new Error('Invalid JSON packet'); + await this.open(); + if (!this.#connection || !this.#connection.active) { + throw new Error('Not connected to server'); + } this.#pending.set(packet.id, port); - this.#connection.write(data); + if (packet.type === 'subscribe') this.#feeds.add(packet.id); + else if (packet.type === 'call') this.#calls.add(packet.id); + // What the page wrote goes upstream as it is: a frame stays a frame — + // unless the server speaks revision 1 and reads none, where it leaves + // as the JSON of the packet it carries. + if (typeof data === 'string') return void this.#connection.write(data); + if (this.#connection.revision !== 2) return void this.#connection.write(JSON.stringify(packet)); + this.#connection.write(data instanceof ArrayBuffer ? new Uint8Array(data) : data); + } + + // The page's call could not be forwarded: it hears a coded error now + // rather than its callTimeout later. Anything without a caller is dropped. + #refuse(data, port, error) { + const packet = parsePacket(data); + if (!packet || packet.type !== 'call' || typeof packet.id !== 'string') return; + const code = Number.isInteger(error?.code) ? error.code : 503; + const message = typeof error?.message === 'string' ? error.message : 'Proxy could not forward the call'; + port.postMessage(JSON.stringify({ type: 'callback', id: packet.id, error: { message, code } })); } - // `packet` is the already-parsed form of `data` (the client parses once); - // it is null when the frame was not JSON at all. + // `packet` is the already-parsed form of `data` (the client parses once — + // for an attachments frame `data` is the frame and `packet` the packet it + // carries, routed exactly like its JSON twin); it is null when the frame + // was no packet at all, and a batch array is broadcast as it always was. #proxyPacket(data, packet) { - if (typeof data !== 'string') return void this.#broadcast(data); - const parsed = packet ?? jsonParse(data); - if (!parsed) return void this.#broadcast(data); + const parsed = packet ?? (typeof data === 'string' ? jsonParse(data) : null); + if (!parsed || Array.isArray(parsed)) return void this.#broadcast(data, parsed); const { type, id, status } = parsed; - if (type === 'event') return void this.#broadcast(data); + if (type === 'event') return void this.#broadcast(data, parsed); const port = this.#pending.get(id); - if (!port) return void this.#broadcast(data); - port.postMessage(data); + if (!port) { + // A server-opened stream's packets are for whichever page picks + // them up (its callback carried the id). Anything else with an id + // nobody waits for — the callback of a page that left, the data of + // a feed it unsubscribed — is nobody's, and used to go to every page. + if (type === 'stream') this.#broadcast(data, parsed); + return; + } + this.#post(port, data, parsed); // `end` is a subscription's terminal packet, so it releases its slot the // same way a callback does — otherwise every subscription a page opens // pins a port reference in the worker for the life of the connection. - if (type === 'callback' || type === 'end') return void this.#pending.delete(id); + if (type === 'callback' || type === 'end') { + this.#pending.delete(id); + this.#feeds.delete(id); + this.#calls.delete(id); + return; + } if (type !== 'stream') return; const streamDone = status === 'end' || status === 'terminate'; if (streamDone) this.#pending.delete(id); } - #broadcast(data, excludePort = null) { - for (const port of this.#ports) { - if (port === excludePort) continue; - port.postMessage(data); - } + // One message to one page. `parsed` is the packet `data` carries when it + // is a frame: a page that reads no frames is posted the packet's JSON — + // what a revision-1 server would have sent it. + #post(port, data, parsed) { + const framed = typeof data !== 'string' && parsed !== null && !this.#modern.has(port); + port.postMessage(framed ? JSON.stringify(parsed) : data); + } + + #broadcast(data, parsed = null) { + for (const port of this.#ports) this.#post(port, data, parsed); } } diff --git a/src/client/transports.js b/src/client/transports.js index 0f7921a..a744291 100644 --- a/src/client/transports.js +++ b/src/client/transports.js @@ -4,72 +4,84 @@ // exactly the way the SSE subpath registers its own — the registry is the // one seam every transport, built-in or not, goes through. -const { WrpcClient, ClientTransport, WRPC_PROTOCOL, metaHeaders } = require('./core.js'); -const { HEADERS_PARAM, META_PARAM } = require('../wire.js'); - -// Mirrors the server's metaMaxBytes default: past it the server drops the -// entire declared bag, so refusing here is the difference between a visible -// warning and a label that silently stopped arriving. -const META_MAX = 2048; +const { WrpcClient, ClientTransport, WRPC_V1, WRPC_V2, META_MAX, metaHeaders, refusedStatus } = require('./core.js'); const { jsonParse } = require('../utils.js'); +const { decodeAttachments } = require('../attachments.js'); +const { createWsCompression } = require('./wsCompression.js'); +const { openSocket } = require('./wsHandshake.js'); + const { WebSocket } = globalThis; class ClientWsTransport extends ClientTransport { + // Carries `options.encryption` (@alexify/wrpc/encryption): the handshake + // runs inside open(), and every frame after it is sealed. + static encrypts = true; + // The one transport that can die without saying so. heartbeat = true; + // Until the server selected `wrpc.v2`: a 1.0 server selects `wrpc.v1`. + revision = 1; #socket = null; #opening = null; + // Per-message compression of what THIS side sends (wsCompression.js — + // a stub in a browser): the offer goes out in a ping on open, and frames + // change only once the server's pong agreed. Re-negotiated per open. + #compression = null; + #negotiating = false; + // The encrypted session of this connection, from `options.encryption`: + // `{ ready, send, receive }`. Re-made per open — a reconnect is a new + // handshake and new keys. + #secure = null; + // Set once a server answered `wrpc.v1` to a handshake that carried the + // declared bags as subprotocol tokens: a 1.0 server reads them from the + // connect URL only, so every later open of this transport uses the query. + #requery = false; + // Said once: tokens rode an offer of `wrpc.v1` alone, and `wrpc.v1` came + // back — which a 1.0 server and a 2.x one without frames both answer. + #ambiguous = false; + + /** The session's facts once established (see WrpcClient#encryption), or null. */ + encryption = null; + + /** The compression codec id in effect on this side's frames, or null. */ + get compression() { + return this.#compression === null ? null : this.#compression.id; + } async open(options = {}) { if (this.active) return Promise.resolve(); if (this.#opening) return this.#opening; + // Under a wire codec a packet is not JSON, and the negotiation ping + // would not be either: the option is left off there. + this.#compression = this.codec ? null : createWsCompression(options.compression); + this.#negotiating = this.#compression !== null; const opening = new Promise((resolve, reject) => { - // The client OFFERS the protocol revision; the server echoes it (see - // protocol.md#versioning). `protocols` overrides the offer, and an - // empty array offers nothing — an escape hatch for a proxy that + // The client OFFERS the protocol revisions it speaks, newest first, and + // the server selects one (see protocol.md#versioning): a 1.0 server + // picks `wrpc.v1`, and this connection then speaks what 1.0 spoke. A + // client that sends no frames (`attachments: false`, a packet codec) + // has nothing of revision 2 to offer. `protocols` overrides the offer, + // and an empty array offers nothing — an escape hatch for a proxy that // mangles the header. The selected protocol lands on `this.protocol`. - let protocols = options.protocols ?? [WRPC_PROTOCOL]; - // The Authorization header is the ONE declared name that is a secret, - // and the connect URL lands in proxy access logs. RFC 6455 gives ws a - // header that survives the WHATWG constructor — the subprotocol offer - // — so a Bearer credential rides as `wrpc.bearer.` and is - // stripped from the wrpc_h bag (the server's bearer transport reads - // sec-websocket-protocol first). A token outside the RFC 7230 token - // charset cannot be a subprotocol name and falls back to the query, - // with the loud caveat below. - let bag = options.headers; - const auth = bag?.authorization; - const bearer = typeof auth === 'string' && auth.startsWith('Bearer ') ? auth.slice(7) : null; - if (bearer && /^[!#$%&'*+.^_`|~A-Za-z0-9-]+$/.test(bearer)) { - protocols = [...protocols, `wrpc.bearer.${bearer}`]; - bag = { ...bag }; - delete bag.authorization; - if (Object.keys(bag).length === 0) bag = null; - } - // Connection-phase headers ride as ONE query parameter: the WHATWG - // WebSocket constructor cannot set real headers, in the browser by - // spec and in Node because the client uses the same globalThis - // implementation. The server reads observed upgrade headers first and - // this parameter only for names they do not carry, so a transport - // that CAN send real headers needs no query at all. Loud caveat: the - // connect URL lands in proxy access logs — a device id belongs here, - // a secret does not. - const params = []; - // Capped like the http leg: past metaMaxBytes the server drops the - // ENTIRE bag (measured over the whole query), so sending it anyway - // would be a silent loss on the side that cannot see it. The refusal - // keeps the connection working, un-labelled, and says so. - const declare = (param, bag) => { - const value = encodeURIComponent(JSON.stringify(bag)); - const bytes = param.length + 1 + value.length; - if (bytes > META_MAX) return void this.log?.warn({ event: 'meta.oversize', param, bytes }); - params.push(`${param}=${value}`); - }; - if (bag) declare(HEADERS_PARAM, bag); - if (options.meta) declare(META_PARAM, options.meta); - const url = params.length > 0 ? `${this.url}${this.url.includes('?') ? '&' : '?'}${params.join('&')}` : this.url; - const socket = protocols.length > 0 ? new WebSocket(url, protocols) : new WebSocket(url); + // The declared headers/meta leave with the handshake: real request + // headers from Node, subprotocol carrier tokens from a browser, the + // connect-URL query only by `carrier: 'query'` — see wsHandshake.js + // and its browser half. + const { encryption = null } = options; + // Announced in the URL: the server may be the first to send, so it + // has to know the mode before any frame. Not a secret — stripping it + // gets a refusal, never plaintext. + const url = + encryption === null ? this.url : `${this.url}${this.url.includes('?') ? '&' : '?'}${encryption.param}=1`; + const offer = options.protocols ?? (this.attachments === false ? [WRPC_V1] : [WRPC_V2, WRPC_V1]); + // A copy the handshake may write to (`carried`), and where a transport + // that already met a revision-1 server asks for the query carrier. + const handshake = this.#requery ? { ...options, carrier: 'query' } : { ...options }; + const socket = openSocket(WebSocket, url, offer, handshake, this.log); + // Bytes arrive as ArrayBuffers, never Blobs: an attachments frame is + // classified synchronously on the way in, in order with the packets. + socket.binaryType = 'arraybuffer'; this.#socket = socket; const onClose = (error) => { // Scoped to the socket it was registered for. Both 'close' and @@ -78,6 +90,11 @@ class ClientWsTransport extends ClientTransport { // replacement a reconnect has already installed. if (this.#socket !== socket) return; this.#socket = null; + // A handshake still running is over with the socket: its ready() + // rejects now, not at the handshake timeout. + this.#secure?.cancel(error); + this.#secure = null; + this.encryption = null; if (this.#opening) { this.#opening = null; this.emit('error', error); @@ -87,18 +104,76 @@ class ClientWsTransport extends ClientTransport { this.active = false; this.emit('close', error); }; - const onOpen = () => { - this.protocol = socket.protocol || ''; + const established = () => { + // First on the wire, before the core's own open handler sends + // anything: the pong that answers it is what turns compression on. + if (this.#compression !== null) this.#send(this.#compression.offer); this.active = true; this.emit('open'); this.#opening = null; resolve(); }; + const onOpen = () => { + this.protocol = socket.protocol || ''; + this.revision = this.protocol === WRPC_V2 ? 2 : 1; + // Offered `wrpc.v2`, answered `wrpc.v1`: possibly a 1.0 server, and + // 1.0 reads the declared bags from the connect URL only — the + // tokens this handshake carried went unread. Dialled again, once, + // with the query 1.0 itself used; an application that chose its + // carrier, or its offer, is left with its choice. + if (handshake.carried && this.protocol === WRPC_V1 && (options.carrier ?? 'auto') === 'auto') { + if (offer.includes(WRPC_V2)) { + this.#requery = true; + this.log?.warn({ event: 'handshake.requery' }); + // Every declared header now rides the URL, not only a credential: + // an `x-api-key` lands in each access log on the way. + if (options.headers) { + this.log?.warn({ event: 'declared.exposed', keys: Object.keys(options.headers), carrier: 'query' }); + } + // Abandoned, not closed-and-reported: its close is nobody's now. + this.#socket = null; + socket.close(); + this.#opening = null; + return void this.open(options).then(resolve, reject); + } + // This end offered `wrpc.v1` alone (`attachments: false`, its own + // `protocols`): the answer cannot tell a 2.x server, which read the + // tokens, from a 1.0 one, which read none. No redial — but said. + if (!this.#ambiguous) { + this.#ambiguous = true; + this.log?.warn({ event: 'handshake.ambiguous' }); + } + } + if (encryption === null) return void established(); + // The handshake first: nothing of this connection leaves in the + // clear, the compression offer included. `connectTimeout` is racing + // open(), so it covers this too; a failure closes the socket, which + // is what rejects the pending open. + this.#secure = encryption.secure({ + kind: 'ws', + write: (bytes) => socket.send(bytes), + deliver: (data) => this.#deliver(data), + fail: (error) => { + this.log?.warn({ event: 'encryption.failed', err: error }); + socket.close(); + }, + }); + this.#secure.ready.then( + (info) => { + if (this.#socket !== socket) return; + this.encryption = info; + established(); + }, + () => {}, + ); + }; socket.addEventListener('open', onOpen, { once: true }); socket.addEventListener('close', onClose, { once: true }); socket.addEventListener('error', onClose, { once: true }); socket.addEventListener('message', ({ data }) => { - this.emit('message', data); + if (this.#socket !== socket) return; + if (this.#secure !== null) return void this.#secure.receive(data); + this.#deliver(data); }); }); this.#opening = opening; @@ -122,19 +197,57 @@ class ClientWsTransport extends ClientTransport { const socket = this.#socket; this.active = false; this.#socket = null; + this.#secure?.cancel(); + this.#secure = null; + this.encryption = null; this.emit('close'); socket?.close(); } + // One inbound message, decrypted already when the connection is sealed. + #deliver(data) { + // Only until the first pong answered the offer — a plain pong is a + // no, and either way nothing is inspected after that. + if (this.#negotiating && this.#compression.accept(data) !== null) this.#negotiating = false; + this.emit('message', data); + } + + // Compress, then seal: ciphertext does not compress. + #send(data) { + if (this.#secure !== null) return void this.#secure.send(data); + this.#socket.send(data); + } + write(data) { if (!this.active) throw new Error('Not connected'); - this.#socket.send(data); + if (this.#compression !== null) { + const frame = this.#compression.encode(data); + if (frame !== null) return void this.#send(frame); + } + this.#send(data); } } +// What a client that reads framed messages asks an HTTP server for: a 2.x +// server answers a result holding bytes as a frame only to a request that +// names it, and a 1.0 server ignores the header. CORS-safelisted, so it adds +// no preflight. +const ACCEPT_FRAMES = 'application/octet-stream, application/json'; + class ClientHttpTransport extends ClientTransport { + // Carries `options.encryption`: no session here, so every request is + // sealed to the pinned server key on its own (HPKE) — by wrapping fetch. + // Per request, not per session: checked up front against the option's + // `fetch` (see WrpcClient's #checkEncryption). + static encrypts = 'request'; + // One request, one response: nothing to cancel or subscribe on. persistent = false; + // No handshake to negotiate on: a server says the revision it speaks in + // the `wrpc-version` of every response, so this side sends a frame only + // after an answer said 2 — and a 1.0 server, which says 1, never gets one + // unless it shares an address with 2.x ones; see write(). + revision = 1; // Can carry procedure-mapped REST requests (client/core #restCall): a // call whose procedure declares `http` goes out as the same REST request // an external consumer would send, not as a packet POST. @@ -148,9 +261,28 @@ class ClientHttpTransport extends ClientTransport { meta = null; metaBag = null; prefixed = false; + // Injectable fetch, defaulted here (not just in open()) so a caller that + // uses this transport directly — request() without an open() — still + // gets the runtime's own fetch. Re-resolved on open() like headers/meta: + // lets a caller hand in undici's fetch bound to a tuned Agent/Pool + // (keep-alive, proxy, an undici-cache-interceptor store) without wrpc + // ever depending on undici. + fetch = globalThis.fetch; async open(options = {}) { this.headers = options.headers ?? null; + // Stored and always CALLED unbound (never as this.fetch(...)): native + // fetch throws "Illegal invocation" in browsers when invoked with a + // receiver other than the global object, which a plain method call + // would hand it. An injected fetch is called the same free-function way. + this.fetch = options.fetch ?? globalThis.fetch; + const { encryption = null } = options; + if (encryption !== null) { + if (typeof encryption.fetch !== 'function') { + throw new TypeError('options.encryption has no serverKey to seal a request to — the http transport needs one'); + } + this.fetch = encryption.fetch(this.fetch, this.url); + } // Built once per open, as a header BLOCK rather than a single encoded // value: which spelling it is (one canonical JSON header, or one header // per key) is the client's metaFormat choice, and every leg below just @@ -193,7 +325,8 @@ class ClientHttpTransport extends ClientTransport { 'Content-Type': rest?.contentType ?? 'application/json', }; const init = body === undefined ? { method, headers, signal } : { method, headers, body, signal }; - const res = await fetch(url, init); + const doFetch = this.fetch; + const res = await doFetch(url, init); if (rest) return { status: res.status, body: new Uint8Array(await res.arrayBuffer()) }; return { status: res.status, text: await res.text() }; } @@ -230,12 +363,39 @@ class ClientHttpTransport extends ClientTransport { // has one header block, so a key carried by several calls shows the last // value. Nothing is lost — each call's exact meta rides its own packet. const block = meta ? this.#requestMeta(meta) : this.meta; - const headers = { ...this.headers, ...block, 'Content-Type': this.codec?.contentType ?? 'application/json' }; + // An attachments frame is bytes and says so; a text body is the packet + // codec's type, JSON by default. + const contentType = + typeof data === 'string' ? (this.codec?.contentType ?? 'application/json') : 'application/octet-stream'; + const headers = { ...this.headers, ...block, 'Content-Type': contentType }; + // A client that reads frames says so; one that opted out, or speaks a + // packet codec, is answered in text as before. + if (this.attachments !== false && !this.codec) headers.Accept = ACCEPT_FRAMES; const options = { method: 'POST', headers, body: data }; + const doFetch = this.fetch; const send = async () => { try { - const res = await fetch(this.url, options); + const res = await doFetch(this.url, options); + // The server reads framed messages: from here on, bytes this side + // sends travel as bytes. + const modern = res.headers.get('wrpc-version') === '2'; + if (modern) this.revision = 2; + // A frame answer (a result holding bytes) is read as bytes and + // handed on as one; the core classifies it. + if (res.ok && res.headers.get('content-type') === 'application/octet-stream') { + return void this.emit('message', new Uint8Array(await res.arrayBuffer())); + } const text = await res.text(); + // A frame reached a server that does not say 2 — a 1.0 instance + // behind the same address as 2.x ones, a rollback — and wrpc + // answered it (a proxy's page has no packet in it, and says nothing + // about the server behind). That server refused the frame before + // reading a call out of it: this side speaks revision 1 again, and + // the same packets go once more as the JSON 1.0 reads. + if (!modern && typeof data !== 'string' && this.#decode(text) !== null) { + this.revision = 1; + return void this.write(JSON.stringify(decodeAttachments(data)), meta); + } // Error statuses normally still carry wrpc callback packets (the // server answers errors as JSON with the same code). Only when the // body is NOT wrpc's — a proxy's HTML 502, an empty body — are @@ -247,19 +407,48 @@ class ClientHttpTransport extends ClientTransport { this.failPackets(data, res.status); } catch (error) { this.emit('error', error); - this.failPackets(data, 503); + // A sealed request the server refused in plaintext names its status + // only for the closed set the sealing layer vouches for (400, 409, + // 426 — a retired key, a clock, a required mode); the outer status + // of anything else is unauthenticated and stays a 503. + this.failPackets(data, refusedStatus(error)); } }; send(); } } +const isPongText = (data) => typeof data === 'string' && data.startsWith('{"type":"pong"'); + +// What the other end wrote, through the codec — null for anything it does +// not decode (an injected codec may throw on a message it did not make). +const decodeQuietly = (codec, data) => { + try { + return codec.decode(data); + } catch { + return null; + } +}; + class ClientEventTransport extends ClientTransport { static instance = null; + // A port has no handshake, so the revision rides the first ping and its + // pong (protocol.md#versioning): until the other end — the worker proxy, + // or a server the port is attached to — said it reads framed messages, + // this side sends none. A 1.0 worker answers a plain pong. + revision = 1; + #port = null; #worker = null; + // Set by a pong that names a revision at all: a 2.x end, which also + // understands the goodbye a 1.0 proxy would throw on. + #modern = false; + // @deprecated Kept as the class-level singleton it always was, for code + // that still calls it — but connect() no longer does: each connect({ + // worker }) builds its own transport, so the one returned here belongs to + // no client. Use `new WrpcClient.transport.event(url)`. static getInstance(url) { if (ClientEventTransport.instance) { return ClientEventTransport.instance; @@ -272,12 +461,33 @@ 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; + this.revision = 1; + this.#modern = false; + // What this side reads: 2, or 1 under `attachments: false` / a codec. + const { codec } = this; + const mine = this.attachments !== false && !codec ? 2 : 1; + let negotiating = true; port1.addEventListener('message', ({ data }) => { if (data === undefined) return; + // Only until the first pong: it answers the ping below, and is the + // transport's — the core never asked for it. Under a packet codec it + // is the codec's, like everything the other end writes. + if (negotiating) { + const pong = codec ? decodeQuietly(codec, data) : isPongText(data) ? jsonParse(data) : null; + if (pong?.type === 'pong') { + negotiating = false; + this.#modern = pong.v !== undefined; + this.revision = pong.v === 2 && mine === 2 ? 2 : 1; + return; + } + } this.emit('message', data); }); port1.start(); @@ -289,12 +499,32 @@ class ClientEventTransport extends ClientTransport { if (options.headers) connect.headers = options.headers; if (options.meta) connect.meta = options.meta; this.#worker.postMessage(connect, [port2]); + // First on the port, before anything the core sends: by the time a + // `load()` is answered the revision is known. Not waited for — a call + // sent before the pong leaves as revision 1, which every end reads. A + // server under a packet codec decodes every message with it: a JSON + // ping was a malformed packet there, answered with an id-less 500. + const ping = { type: 'ping', v: mine }; + port1.postMessage(codec ? codec.encode(ping) : JSON.stringify(ping)); this.active = true; this.emit('open'); } 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; + // A goodbye first: the worker releases what this page was waiting for + // on it, where a MessagePort's own close event may never fire. Only to + // an end that named a revision: a 1.0 proxy reads every message as a + // packet and throws on this one. + try { + if (this.#modern) this.#port.postMessage({ type: 'wrpc:close' }); + } catch { + // Already closed. + } this.#port.close(); this.#port = null; this.emit('close'); diff --git a/src/client/webtransport.js b/src/client/webtransport.js new file mode 100644 index 0000000..8f01640 --- /dev/null +++ b/src/client/webtransport.js @@ -0,0 +1,332 @@ +'use strict'; + +// The WebTransport client transport — `transport: 'wt'`. In the base client +// entry next to ws/http/event rather than in the '@alexify/wrpc/wt' subpath, +// so that the fallback list a browser app wants, `transport: ['wt', 'ws']`, +// needs no extra import: the subpath is the SERVER half (the session +// contract, the socket shim, the host adapters), which a page never loads. +// +// One client-opened bidirectional stream — the control stream — carries +// every packet and every stream chunk under the length-prefixed framing of +// src/webtransport/framing.js: a QUIC stream is a byte stream, so a message +// needs a boundary, and a single ordered stream is what keeps the wire +// identical to a WebSocket's (a `stream` packet precedes its first chunk, +// ping/pong per direction, resume across reconnects). Datagrams and +// per-stream WebTransport streams are additive capabilities on top of it, +// never a replacement of the packets — see docs/guide/wt.md. +// +// The session comes from `globalThis.WebTransport` (a browser) or from +// `options.wt.WebTransport` (an injected implementation — a Node client +// over @fails-components/webtransport, a fake in tests). Where neither +// exists open() throws, and connect() moves on to the next candidate of a +// fallback list at once — that is the whole reason the list exists. + +const { WrpcClient, ClientTransport, connectUrl } = require('./core.js'); +const { normalizeCompression } = require('../compression/index.js'); +const { WtChannel, closeQuietly, normalizeBackpressure } = require('../webtransport/channel.js'); + +// The `WebTransportOptions` handed to the constructor as-is. +const INIT_KEYS = ['serverCertificateHashes', 'congestionControl', 'allowPooling', 'requireUnreliable', 'protocols']; + +const UNAVAILABLE = + 'WebTransport is not available here: pass options.wt.WebTransport (an implementation), ' + + "or name a fallback — transport: ['wt', 'ws']"; + +// The channel itself — framing, the capabilities exchange, compression, the +// stream mux, the datagrams, flow control — is src/webtransport/channel.js, +// shared with the server's socket. What is here is what makes it a client +// transport: the open lifecycle, one channel per session, the wire codec +// and the session-encryption seam. +class ClientWtTransport extends ClientTransport { + // Carries `options.encryption`: the handshake runs over the control + // stream inside open(), and every message after it is sealed. + static encrypts = true; + + // A session can die silently exactly like a socket (a NAT rebinding, a + // suspended tab): the client's app-level ping/pong is the detector, and + // the core owns every timer of it. + heartbeat = true; + persistent = true; + + #options; + #session = null; + #channel = null; + // Session encryption (`options.encryption`): `{ ready, send, receive }` + // for this session, null otherwise. Under it everything rides the control + // stream sealed — no per-stream transport, no datagrams, no compression + // of what is already ciphertext: each would be a way around the channel. + #secure = null; + + /** The session's facts once established (see WrpcClient#encryption), or null. */ + encryption = null; + + #opening = null; + // Bumped by terminate(): an open() that was waiting on the handshake when + // the core gave up must not come back to life on top of a later attempt. + #attempt = 0; + + /** + * `options` are the same `wt` options connect() takes, for a transport + * constructed by hand; the ones handed to open() win per open. + */ + constructor(url, options = {}) { + super(url); + this.#options = options; + // Said where the transport is built; the `wt` bag of a later open() is + // checked by the channel it opens. + normalizeBackpressure(options.maxBackpressure, 'wt transport: options'); + } + + /** The WebTransport session spoken on; null before open() and after close. */ + get session() { + return this.#session; + } + + /** Bytes handed to the session and not yet taken by it; 0 between sessions. */ + get bufferedAmount() { + return this.#channel === null ? 0 : this.#channel.bufferedAmount; + } + + /** + * The largest datagram the session carries, 0 when it carries none — + * what the core checks before sending an event unreliably. + */ + get maxDatagramSize() { + return this.#channel === null ? 0 : this.#channel.maxDatagramSize; + } + + async open(options = {}) { + if (this.active) return; + if (this.#opening) return this.#opening; + this.#opening = this.#open(options); + try { + await this.#opening; + } finally { + this.#opening = null; + } + } + + async #open(options) { + const wt = options.wt ? { ...this.#options, ...options.wt } : this.#options; + const WebTransport = wt.WebTransport ?? globalThis.WebTransport; + if (typeof WebTransport !== 'function') throw new Error(UNAVAILABLE); + const encryption = options.encryption ?? null; + // Per-message compression (src/compression): connect()'s `compression`, + // else the `wt` bag's — and none under encryption. + const compression = + encryption === null ? normalizeCompression(options.compression ?? wt.compression, 'wt transport: options') : null; + const attempt = ++this.#attempt; + const init = {}; + for (let i = 0; i < INIT_KEYS.length; i++) { + const key = INIT_KEYS[i]; + if (wt[key] !== undefined) init[key] = wt[key]; + } + // Announced in the URL, as on ws: the server may be the first to send. + // Before the declared bags, so it is counted in the one query budget. + let target = this.url; + if (encryption !== null) target += `${target.includes('?') ? '&' : '?'}${encryption.param}=1`; + target = connectUrl(target, options.headers, options.meta, this.log); + const session = new WebTransport(target, init); + this.#session = session; + // The session's own end — a peer close, a transport failure, our own + // close() — is one 'close' here; a failure while still opening is + // open()'s rejection instead (ready rejects too). + // A graceful end lets the channel deliver what it already read — an + // asynchronous codec may still be inflating it — before it is shut. + session.closed.then( + () => { + const settled = this.#channel?.settled() ?? null; + if (settled === null) this.#down(session); + else void settled.then(() => this.#down(session)); + }, + (error) => this.#down(session, error), + ); + try { + await session.ready; + if (attempt !== this.#attempt) throw new Error('Connection terminated'); + const stream = await session.createBidirectionalStream(); + if (attempt !== this.#attempt) throw new Error('Connection terminated'); + await this.#attach(session, stream, wt, compression, encryption); + if (attempt !== this.#attempt) throw new Error('Connection terminated'); + } catch (error) { + if (this.#session === session) { + this.#session = null; + closeQuietly(session); + } + throw error; + } + } + + #attach(session, stream, wt, compression, encryption) { + // Binary streams on their own WebTransport streams, and datagrams: not + // under a wire codec (the mux reads stream packets off the wire, which + // only JSON allows), and neither under encryption. + const plain = encryption === null; + // Frames go only to a server whose capabilities said it reads them — + // and from a client that reads them itself: revision 1 until then. + const frames = this.attachments !== false && !this.codec; + this.revision = 1; + const channel = new WtChannel( + session, + stream, + { + mux: plain && !this.codec, + datagrams: plain, + highWaterMark: wt.highWaterMark, + lowWaterMark: wt.lowWaterMark, + maxBackpressure: wt.maxBackpressure, + maxMessage: wt.maxMessage, + closeTimeout: wt.closeTimeout, + compression, + frames, + onFrames: (reads) => { + if (this.#channel === channel) this.revision = reads && frames ? 2 : 1; + }, + }, + (data) => { + // Sealed: every message is the channel's to open, from the first + // byte — the handshake answer arrives before this transport is + // active. + if (this.#secure !== null) return void this.#secure.receive(data); + if (this.active && this.#channel === channel) this.emit('message', data); + }, + () => void this.emit('drain').catch((error) => this.#escalate(error)), + // A message the server sent that cannot be read is its protocol + // violation: reported, and the session hung up. + (error) => { + this.#escalate(error); + this.#down(session); + closeQuietly(session); + }, + // Past maxBackpressure: a server that never drains. + (error) => { + this.#escalate(error); + this.terminate(); + }, + (error) => this.#escalate(error), + // The server ended the control stream — its graceful close: the + // session close follows, and is given a moment before this end hangs + // up itself. + () => { + this.#down(session); + channel.expectClose(); + }, + ); + this.#channel = channel; + const established = () => { + this.active = true; + // Announced before open() resolves — the core's 'open' handler runs + // synchronously here, which is the invariant every transport keeps. + this.emit('open'); + }; + if (plain) return void established(); + // The handshake, over the control stream, before anything else of this + // session: a failure hangs the session up, which is open()'s rejection. + const secure = encryption.secure({ + kind: 'wt', + write: (bytes) => void channel.send(bytes), + deliver: (data) => { + if (this.#session === session) this.emit('message', data); + }, + fail: (error) => { + this.log?.warn({ event: 'encryption.failed', err: error }); + closeQuietly(session); + }, + }); + this.#secure = secure; + return secure.ready.then((info) => { + if (this.#session !== session) throw new Error('Connection terminated'); + this.encryption = info; + established(); + }); + } + + /** A stream packet is seen before serialization: the mux may take it. */ + send(obj) { + if (this.#channel !== null && this.#channel.control(obj)) return; + super.send(obj); + } + + /** The codec ids in effect — `{ encode, decode }` — or null while the two lists share none. */ + get compression() { + return this.#channel === null ? null : this.#channel.compression; + } + + /** + * Sends a packet as ONE datagram — unreliable, unordered, at most + * maxDatagramSize bytes — and answers true when it went out; false when + * the session has no datagrams or the packet does not fit, which is the + * caller's cue to send it on the control stream instead. Never throws + * for a lost datagram: losing one is the point. + */ + writeUnreliable(data) { + return this.active && this.#channel.sendUnreliable(data); + } + + /** + * A string is a packet, bytes are a stream chunk. Throws when not + * connected (the core turns that into a coded 503); answers false above + * the high-water mark, after which 'drain' follows. + */ + write(data, options = null) { + if (!this.active) throw new Error('Not connected'); + const channel = this.#channel; + if (this.#secure === null) return channel.send(data, options); + if (channel.exceedsBackpressure()) return false; + this.#secure.send(data); + return channel.accepted(); + } + + /** + * A graceful goodbye: what was written reaches the server, then its + * `closed` settles with the close info — the control stream is closed + * first and the session after it, `closeTimeout` at most (channel.js). + */ + close() { + const session = this.#session; + if (!session) return; + const channel = this.#channel; + this.#down(session); + const info = { closeCode: 0, reason: '' }; + // Still opening: there is no stream to drain. + if (channel === null) closeQuietly(session, info); + else channel.finish(info); + } + + /** Reports the close now and drops the session; a handshake in flight is abandoned. */ + terminate() { + this.#attempt++; + const session = this.#session; + if (!session) return; + this.#down(session); + closeQuietly(session); + } + + #down(session, error) { + if (this.#session !== session) return; + this.#session = null; + this.#channel?.shut(); + this.#channel = null; + // A handshake still running is over with the session: open() rejects + // now, not at the handshake timeout. + this.#secure?.cancel(error); + this.#secure = null; + this.encryption = null; + if (error) this.#escalate(error); + if (!this.active) return; + this.active = false; + this.emit('close'); + } + + #escalate(error) { + // The core binds 'error' and routes it through its own escalation; a + // transport used bare (tests) may have no listener, and Emitter throws + // on an unheard 'error'. + if (this.listenerCount('error') === 0) return; + void this.emit('error', error).catch(() => {}); + } +} + +WrpcClient.transport.wt = ClientWtTransport; + +module.exports = { ClientWtTransport, UNAVAILABLE }; diff --git a/src/client/wsCompression.browser.js b/src/client/wsCompression.browser.js new file mode 100644 index 0000000..65b7c96 --- /dev/null +++ b/src/client/wsCompression.browser.js @@ -0,0 +1,11 @@ +'use strict'; + +// The browser half of the WebSocket client's per-message compression: a +// stub. A browser's WebSocket compresses both directions itself under +// permessage-deflate once the server enables it, so `compression` on the +// ws transport has nothing to add here — and the bundle carries none of +// the Node half (wsCompression.js). + +const createWsCompression = () => null; + +module.exports = { createWsCompression }; diff --git a/src/client/wsCompression.js b/src/client/wsCompression.js new file mode 100644 index 0000000..0f66cc0 --- /dev/null +++ b/src/client/wsCompression.js @@ -0,0 +1,60 @@ +'use strict'; + +// The Node half of the WebSocket client's per-message compression: Node's +// built-in WebSocket only ever inflates, so a Node client's uploads arrive +// as they are whatever the server negotiated. With `compression` on, the +// client offers its codecs — ids, in its order of preference — in a ping +// right at open, and once the server's pong names the first of them it +// holds, every packet or chunk past the threshold leaves as a binary frame +// under the 0x00 marker (src/wire.js) the server inflates. Only this side +// compresses here, so the pong names ONE id: the one to send with. The browser half (wsCompression.browser.js, swapped through +// package.json#browser) is a stub: a browser compresses both directions +// itself under permessage-deflate, and its bundle carries none of this. + +const { normalizeSyncCompression, codecById, encodeIfSmaller } = require('../compression/sync.js'); +const { FRAME_MARK, FRAME_PACKET_COMPRESSED, FRAME_CHUNK_COMPRESSED } = require('../wire.js'); +const { jsonParse } = require('../utils.js'); + +const PONG_PREFIX = '{"type":"pong"'; + +/** + * `{ offer, accept(text), encode(data) }` over the option, or null for off: + * `offer` is the ping to send on open; `accept` reads a pong and answers + * whether the server agreed (call it until it has answered once — a plain + * pong is a no); `encode` frames a packet (string) or chunk (bytes) past + * the threshold, or answers null to send it as it is. + */ +const createWsCompression = (option) => { + const local = normalizeSyncCompression(option, 'ws transport: options'); + if (local === null) return null; + let active = null; + return { + /** The codec the server agreed to, or null. */ + get id() { + return active === null ? null : active.id; + }, + offer: JSON.stringify({ type: 'ping', enc: local.ids }), + get active() { + return active !== null; + }, + accept(text) { + if (typeof text !== 'string' || !text.startsWith(PONG_PREFIX)) return null; + const packet = jsonParse(text); + if (!packet || packet.type !== 'pong') return null; + active = typeof packet.enc === 'string' ? codecById(local, packet.enc) : null; + return active !== null; + }, + encode(data) { + if (active === null) return null; + const out = encodeIfSmaller(active, data); + if (out === null) return null; + const frame = Buffer.allocUnsafe(2 + out.length); + frame[0] = FRAME_MARK; + frame[1] = typeof data === 'string' ? FRAME_PACKET_COMPRESSED : FRAME_CHUNK_COMPRESSED; + frame.set(out, 2); + return frame; + }, + }; +}; + +module.exports = { createWsCompression }; diff --git a/src/client/wsHandshake.browser.js b/src/client/wsHandshake.browser.js new file mode 100644 index 0000000..eb21f84 --- /dev/null +++ b/src/client/wsHandshake.browser.js @@ -0,0 +1,82 @@ +'use strict'; + +// The ws handshake of a client whose WebSocket constructor takes no headers +// — a browser, by specification; `fetch` refuses to perform the upgrade by +// hand, and no other API reaches the request. What a page DOES control is +// the subprotocol offer, a real handshake header: the declared bags ride it +// as `wrpc.h.` / `wrpc.m.` next to the revision, and +// the connect URL — which lands in proxy access logs — stays clean. The +// query carrier remains for `carrier: 'query'` (an intermediary that mangles +// Sec-WebSocket-Protocol) and for an empty offer. wsHandshake.js is the Node +// half, which sends real headers and falls back to this one. + +const { connectUrl, META_MAX } = require('./core.js'); +const { HEADERS_PROTOCOL, META_PROTOCOL, BEARER_PROTOCOL } = require('../wire.js'); + +// An offer is an RFC 7230 token: no raw JSON, no base64 '=' padding. +const TOKEN = /^[!#$%&'*+.^_`|~A-Za-z0-9-]+$/; + +// base64url of the UTF-8 text. The spread is bounded: the caller refuses a +// text longer than META_MAX before it gets here. +const encode = (text) => + btoa(String.fromCharCode(...new TextEncoder().encode(text))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + +const openSocket = (WebSocket, url, protocols, options, log) => { + let headers = options.headers ?? null; + let meta = options.meta ?? null; + let offer = protocols; + // Carrier tokens go only INTO an offer the server can answer: it never + // echoes one, and a client fails a handshake whose every offer went + // unanswered (Chrome closes 1006) — so `protocols: []`, the escape hatch + // for a proxy that mangles the header, means the query for everything. + if (offer.length > 0) { + // A Bearer credential rides bare, `wrpc.bearer.`: shorter than + // its base64 and outside the budget (a JWT alone can outgrow it). The + // server's bearer transport reads it from the offer. + const auth = headers?.authorization; + const bearer = typeof auth === 'string' && auth.startsWith('Bearer ') ? auth.slice(7) : ''; + if (TOKEN.test(bearer)) { + offer = [...offer, BEARER_PROTOCOL + bearer]; + headers = { ...headers }; + delete headers.authorization; + if (Object.keys(headers).length === 0) headers = null; + } + if (options.carrier !== 'query') { + // ONE budget for both tokens, spent headers first — the server + // measures them the same way, and it keeps the handshake clear of the + // smallest header limit around (uWebSockets.js: 4096 bytes for ALL + // request headers). A refused bag leaves the connection unlabelled and + // says so, instead of failing a handshake the application cannot see. + let budget = META_MAX; + for (const [prefix, bag] of [ + [HEADERS_PROTOCOL, headers], + [META_PROTOCOL, meta], + ]) { + if (!bag) continue; + // Measured twice: the text first, because base64 only grows it and + // an oversize bag is not worth encoding (nor safe to spread). + const text = JSON.stringify(bag); + const token = text.length > budget ? null : prefix + encode(text); + if (token === null || token.length > budget) { + log?.warn({ event: 'meta.oversize', carrier: 'protocol', bytes: (token ?? text).length }); + continue; + } + budget -= token.length; + offer = [...offer, token]; + // Told to the caller (the ws transport, which hands in an object of + // its own): a server that turns out to speak revision 1 may be a + // 1.0 server, which never read a carrier token. + options.carried = true; + } + headers = meta = null; + } + } + // connectUrl says so when a credential is about to ride the query. + const target = connectUrl(url, headers, meta, log); + return offer.length > 0 ? new WebSocket(target, offer) : new WebSocket(target); +}; + +module.exports = { openSocket }; diff --git a/src/client/wsHandshake.js b/src/client/wsHandshake.js new file mode 100644 index 0000000..860ec74 --- /dev/null +++ b/src/client/wsHandshake.js @@ -0,0 +1,47 @@ +'use strict'; + +// The ws handshake of a Node client: REAL request headers. The built-in +// WebSocket (undici) takes an init bag — `new WebSocket(url, { protocols, +// headers })` — so the declared headers travel as themselves and the declared +// meta as `x-wrpc-meta`, exactly as on http and sse: no query, no carrier +// token, no Bearer lift, and nothing for the server to decode. The browser +// half (swapped in through package.json#browser) holds the carriers a page +// is left with, and this half falls back to it. + +const { metaHeaders, META_MAX } = require('./core.js'); +const carriers = require('./wsHandshake.browser.js'); + +// Names the handshake itself owns: declared, they would corrupt it. +const UNSENDABLE = /^(?:host|connection|upgrade|content-length|transfer-encoding)$|^sec-websocket-/; + +const openSocket = (WebSocket, url, protocols, options, log) => { + if ((options.carrier ?? 'auto') !== 'auto') return carriers.openSocket(WebSocket, url, protocols, options, log); + let headers = null; + for (const name in options.headers) { + if (UNSENDABLE.test(name)) log?.warn({ event: 'declared.unsendable', key: name }); + else (headers ??= {})[name] = options.headers[name]; + } + if (options.meta) { + // Capped like the http leg (#requestMeta): past metaMaxBytes the server + // drops the header, a silent loss on the side that cannot see it. + const block = metaHeaders(options.meta, options.metaPrefixed === true); + let bytes = 0; + for (const key in block) bytes += key.length + String(block[key]).length; + if (bytes > META_MAX) log?.warn({ event: 'meta.oversize', bytes }); + else headers = { ...headers, ...block }; + } + if (headers === null) return carriers.openSocket(WebSocket, url, protocols, {}, log); + try { + return new WebSocket(url, { protocols, headers }); + } catch (error) { + // A runtime whose WebSocket has no init bag reads it as a subprotocol and + // throws (Deno), and undici throws on a name or value it will not send: + // either way the carriers still work, and a second throw is the caller's. + // The error's name only: undici repeats the offending header's VALUE + // in its message, and a bearer token is not for the log. + log?.warn({ event: 'handshake.fallback', reason: error?.name ?? 'Error' }); + return carriers.openSocket(WebSocket, url, protocols, options, log); + } +}; + +module.exports = { openSocket }; diff --git a/src/compression/codecs.js b/src/compression/codecs.js new file mode 100644 index 0000000..f234985 --- /dev/null +++ b/src/compression/codecs.js @@ -0,0 +1,39 @@ +'use strict'; + +// The platform codecs with their knobs out: `compression: { codec: 'zstd' }` +// takes the measured defaults of native.js, and these are for the caller +// who wants another level, threshold or `async` per codec — most usefully +// inside a preference list, where each entry carries its own. Node-only: +// a browser's CompressionStream has no level to turn. +// +// compression: { codec: [zstdCompressor({ level: 3 }), 'deflate-raw'] } +// +// An `id` names the FORMAT, never the level — any level's output is read +// by the same decoder — so two ends on different levels still negotiate. + +const { normalizeAsync } = require('./index.js'); +const { nativeCompressor } = require('./native.js'); + +const build = (name, algorithm, level, { threshold, async: asyncOption } = {}) => { + if (threshold !== undefined && !(Number.isInteger(threshold) && threshold >= 0)) { + throw new TypeError(`${name}: threshold must be a non-negative integer`); + } + const codec = nativeCompressor({ algorithm, level, async: normalizeAsync(asyncOption, name) }); + if (threshold !== undefined) codec.threshold = threshold; + return codec; +}; + +/** Raw deflate at zlib `level` (-1..9, default 3 — bench/algorithms.js). */ +const deflateCompressor = (options = {}) => build('deflateCompressor', 'deflate-raw', options.level, options); + +/** + * Brotli at `quality` (0..11, default 4). Past 5 the cost climbs faster + * than the bytes fall, and zlib's own default, 11, is milliseconds a + * message (bench/algorithms.js) — an archive setting, not a wire one. + */ +const brotliCompressor = (options = {}) => build('brotliCompressor', 'brotli', options.quality, options); + +/** Zstandard at `level` (1..22, default 1). A TypeError where node:zlib has none (before 22.15 / 23.8). */ +const zstdCompressor = (options = {}) => build('zstdCompressor', 'zstd', options.level, options); + +module.exports = { deflateCompressor, brotliCompressor, zstdCompressor }; diff --git a/src/compression/dictionary.js b/src/compression/dictionary.js new file mode 100644 index 0000000..7b3ce4b --- /dev/null +++ b/src/compression/dictionary.js @@ -0,0 +1,62 @@ +'use strict'; + +// The dictionary codec, Node half: raw deflate through node:zlib with a +// preset dictionary — the codec a Node↔Node carrier (the broker binding, +// the backplane envelopes, a Node ws client, a Node peer on WebRTC or +// WebTransport) injects as `compression: { codec }`. Its id carries the +// dictionary's hash, so two ends compress against the same bytes or not +// at all: an instance whose router differs (a rolling deploy) names +// another id, the negotiation fails, and the wire stays plain rather than +// corrupt. The browser half is the pure-JS codec of @alexify/wrpc/deflate +// — CompressionStream takes no dictionary. +// +// The threshold is lower than the plain codec's 1 KiB: with the history +// preloaded, a 90 B event compresses to a third (bench/dictionary.js), so +// small messages are exactly what the dictionary is for. + +const zlib = require('node:zlib'); + +const { dictionaryId, normalizeAsync, DICTIONARY_ID_PREFIX: ID_PREFIX } = require('./index.js'); +const { deflateEncoder } = require('./native.js'); + +const DEFAULT_THRESHOLD = 64; + +const toBuffer = (dictionary) => { + if (Buffer.isBuffer(dictionary)) return dictionary; + if (dictionary instanceof Uint8Array) return Buffer.from(dictionary.buffer, dictionary.byteOffset, dictionary.length); + if (typeof dictionary === 'string') return Buffer.from(dictionary); + throw new TypeError('dictionaryCompressor: dictionary must be bytes or a string'); +}; + +/** + * `{ id, threshold, dictionary, encode, decode }` — a Compressor over + * `dictionary` (bytes from `buildDictionary`, or any bytes both ends + * hold). `level` is zlib's; `threshold` the size under which a message + * goes plain (64 B); `async` (`true` or `{ threshold }`, 256 KiB) the size + * from which `encode` goes to zlib's threadpool and answers a promise — + * for the WebTransport and WebRTC carriers only, the same trade as the + * platform codec's (native.js, bench/zlib-async.js). + */ +const dictionaryCompressor = (dictionary, { level, threshold = DEFAULT_THRESHOLD, async: asyncOption } = {}) => { + const dict = toBuffer(dictionary); + if (dict.length === 0) throw new TypeError('dictionaryCompressor: dictionary must not be empty'); + if (!(Number.isInteger(threshold) && threshold >= 0)) { + throw new TypeError('dictionaryCompressor: threshold must be a non-negative integer'); + } + if (level !== undefined && !(Number.isInteger(level) && level >= -1 && level <= 9)) { + throw new TypeError('dictionaryCompressor: level must be an integer from -1 to 9'); + } + const async = normalizeAsync(asyncOption, 'dictionaryCompressor'); + const deflate = { dictionary: dict }; + if (level !== undefined) deflate.level = level; + return { + id: ID_PREFIX + dictionaryId(dict), + threshold, + dictionary: dict, + async, + encode: deflateEncoder(async, deflate), + decode: (bytes, maxOutput) => zlib.inflateRawSync(bytes, { dictionary: dict, maxOutputLength: maxOutput }), + }; +}; + +module.exports = { dictionaryCompressor, DICTIONARY_ID_PREFIX: ID_PREFIX }; diff --git a/src/compression/ids.js b/src/compression/ids.js new file mode 100644 index 0000000..bc30784 --- /dev/null +++ b/src/compression/ids.js @@ -0,0 +1,31 @@ +'use strict'; + +// The leaf the pure-JS codec of @alexify/wrpc/deflate needs from this +// directory and nothing more: a page that injects that codec pays for the +// id of its dictionary, not for the negotiation and the platform codecs +// (index.js re-exports all three). + +// A dictionary codec's id: the prefix plus `dictionaryId` of the bytes — +// shared by the Node zlib codec and the pure-JS one so the two negotiate. +const DICTIONARY_ID_PREFIX = 'deflate-raw+dict:'; + +const isPromise = (value) => value !== null && typeof value === 'object' && typeof value.then === 'function'; + +/** + * A short, deterministic id for dictionary bytes — FNV-1a over the bytes, + * 64 bits as 16 hex characters — what a dictionary codec's `id` carries so + * two ends compress against the same bytes or not at all. A fingerprint + * for negotiation, not a security property; it runs in a browser too, + * where a hash from crypto.subtle would be asynchronous. + */ +const dictionaryId = (bytes) => { + let h1 = 0x811c9dc5; + let h2 = 0x01000193 ^ bytes.length; + for (let i = 0; i < bytes.length; i++) { + h1 = Math.imul(h1 ^ bytes[i], 0x01000193) >>> 0; + h2 = Math.imul(h2 ^ bytes[i], 0x0100019d) >>> 0; + } + return h1.toString(16).padStart(8, '0') + h2.toString(16).padStart(8, '0'); +}; + +module.exports = { dictionaryId, DICTIONARY_ID_PREFIX, isPromise }; diff --git a/src/compression/index.js b/src/compression/index.js new file mode 100644 index 0000000..7ed8e3e --- /dev/null +++ b/src/compression/index.js @@ -0,0 +1,210 @@ +'use strict'; + +// Per-message compression for the transports that have nothing under them +// — a WebRTC data channel (SCTP over DTLS) and a WebTransport stream (QUIC) +// carry exactly the bytes handed to them. One message, one deflate: no +// context across messages, so a peer holds no window per connection and +// the frames of a fan-out stay shareable. +// +// Off by default, like every compression knob in wrpc. When on, each end +// announces the ids of the codecs it holds, in its order of preference — +// in the WebTransport capabilities message, in the WebRTC description +// signal, in a ws ping, in the broker's `wrpc-enc` header — and a sender +// compresses with the FIRST codec of ITS OWN list the peer announced. The +// receiver holds both lists, so it knows which one that is: no frame names +// its codec, the two directions choose independently (a Node server may +// answer in zstd a browser that sends deflate), and there is no tie to +// break. No codec in common, or a peer that announced nothing: plain +// frames, as before. +// +// The codec is a structural seam, checked by `isCompressor`: the platform +// default (native.js — node:zlib on Node, CompressionStream in a browser, +// swapped through package.json#browser) or an injected one — the +// dictionary codec of @alexify/wrpc/deflate, or anything else that answers +// `encode(bytes)` and `decode(bytes, maxOutput)`. Either may answer a +// promise: CompressionStream can only, and the Sequencer (src/sequencer.js) +// keeps messages in order around that. +// +// Browser-budgeted: this file lands in the main and webrtc browser entries +// through the client transports. Manual checks, no spread on a hot path. + +const { nativeCompressor, NATIVE_ID } = require('./native.js'); + +const { dictionaryId, DICTIONARY_ID_PREFIX, isPromise } = require('./ids.js'); + +// The ordering queue is a leaf of its own (the encryption entry needs it +// without this file's negotiation); it stays an export of this one. +const { Sequencer, INFLIGHT_LIMIT } = require('../sequencer.js'); + +const DEFAULT_THRESHOLD = 1024; + +/** + * `{ id, encode(bytes) -> bytes | Promise, decode(bytes, maxOutput) + * -> bytes | Promise, threshold? }`. `id` is what the two ends + * compare: a peer compresses only for a peer that named the same codec. + */ +const isCompressor = (value) => + typeof value === 'object' && + value !== null && + typeof value.encode === 'function' && + typeof value.decode === 'function' && + typeof value.id === 'string' && + value.id.length > 0; + +/** + * `compression: true | { codec, threshold, async }` into the frozen shape + * the transports read — `{ codec, id, threshold }` — or null for off, which + * is also what a platform with no native codec and nothing injected gets. + * `codec` is a Compressor, or the name of a platform one ('deflate-raw', + * 'brotli', 'zstd' — native.js). Strict on what IS given: a bad codec or + * threshold is a TypeError at construction, not a message that quietly + * went out plain. + */ +// The size from which the Node platform codec hands a message to zlib's +// threadpool when `async` is on: the hand-off costs a fixed ~20 µs per +// call and is level with the synchronous deflate at ~256 KB +// (bench/zlib-async.js) — the same default as permessage-deflate's and the +// HTTP encoder's `async`. +const DEFAULT_ASYNC_THRESHOLD = 256 * 1024; + +/** + * `async: true | { threshold }` into the byte threshold, `null` for off in + * every spelling of off. Shared by `compression.async` and the dictionary + * codec's own option. + */ +const normalizeAsync = (value, name) => { + if (value === undefined || value === null || value === false) return null; + const options = value === true ? {} : value; + if (typeof options !== 'object' || Array.isArray(options)) { + throw new TypeError(`${name}: async must be true, false or { threshold }`); + } + const threshold = options.threshold ?? DEFAULT_ASYNC_THRESHOLD; + if (!(Number.isInteger(threshold) && threshold > 0)) { + throw new TypeError(`${name}: async.threshold must be a positive integer`); + } + return threshold; +}; + +// One entry of the list: a Compressor, or the name of a platform codec. A +// name the platform lacks (zstd on an older Node, a format this browser's +// CompressionStream has not) is skipped in a LIST — that is what a list is +// for — and refused when it is the only thing asked for (native.js). +const resolveCodec = (value, async, optional, name) => { + if (typeof value === 'string') return nativeCompressor({ algorithm: value, async, optional }); + if (!isCompressor(value)) { + throw new TypeError( + `${name}: compression.codec must provide an id, encode(bytes) and decode(bytes, maxOutput), or name an algorithm`, + ); + } + // An id travels in a comma-separated header and a space-free marker. + if (/[,\s]/.test(value.id)) { + throw new TypeError(`${name}: compression.codec id must not contain a comma or whitespace`); + } + return value; +}; + +const normalizeCompression = (value, name) => { + if (value === undefined || value === null || value === false) return null; + const options = value === true ? {} : value; + if (typeof options !== 'object' || Array.isArray(options)) { + throw new TypeError(`${name}: compression must be true, false or an options object`); + } + // `async` shapes the platform codecs; an injected codec decides that for + // itself (the codec factories take the same option). + const async = normalizeAsync(options.async, `${name}: compression`); + const asked = options.codec ?? null; + const list = Array.isArray(asked); + if (list && asked.length === 0) throw new TypeError(`${name}: compression.codec must not be an empty list`); + const wanted = list ? asked : [asked ?? NATIVE_ID]; + if (async !== null && !wanted.some((entry) => typeof entry === 'string')) { + throw new TypeError(`${name}: compression.async applies to the platform codec — set it on the codec's factory`); + } + const override = options.threshold; + if (override !== undefined && !(Number.isInteger(override) && override >= 0)) { + throw new TypeError(`${name}: compression.threshold must be a non-negative integer`); + } + const codecs = []; + const ids = []; + for (const entry of wanted) { + const codec = resolveCodec(entry, async, list, name); + if (codec === null) continue; + if (ids.includes(codec.id)) throw new TypeError(`${name}: compression.codec names ${codec.id} twice`); + const threshold = override ?? codec.threshold ?? DEFAULT_THRESHOLD; + if (!(Number.isInteger(threshold) && threshold >= 0)) { + throw new TypeError(`${name}: compression.threshold must be a non-negative integer`); + } + ids.push(codec.id); + codecs.push(Object.freeze({ codec, id: codec.id, threshold })); + } + if (codecs.length === 0) return null; + // `codec` / `id` / `threshold` are the head's — what a carrier with no + // negotiation (a raw data channel, the backplane) encodes with. + const head = codecs[0]; + return Object.freeze({ + codec: head.codec, + id: head.id, + threshold: head.threshold, + codecs: Object.freeze(codecs), + ids: Object.freeze(ids), + }); +}; + +// A peer's list is peer-controlled: only so many entries are looked at, and +// an id is only ever COMPARED with ours — never a key into anything. +const MAX_PEER_IDS = 16; + +/** The entry of `local` named `id`, or null. A handful of codecs: a scan. */ +const codecById = (local, id) => { + const { codecs } = local; + for (let i = 0; i < codecs.length; i++) if (codecs[i].id === id) return codecs[i]; + return null; +}; + +const announced = (peer, count, id) => { + for (let i = 0; i < count; i++) if (peer[i] === id) return true; + return false; +}; + +/** + * What the two lists agree on, or null when they share nothing (or the + * peer announced nothing): `{ encode, decode }`, each an entry of `local` — + * `encode` the first of OUR codecs the peer announced, which is what we + * send with; `decode` the first of THE PEER's we hold, which by the same + * rule is what it sends with. `peer` is a list of ids, or one id. + */ +const negotiate = (local, peer) => { + if (local === null) return null; + const ids = typeof peer === 'string' ? [peer] : peer; + if (!Array.isArray(ids)) return null; + const count = Math.min(ids.length, MAX_PEER_IDS); + let decode = null; + for (let i = 0; i < count && decode === null; i++) { + if (typeof ids[i] === 'string') decode = codecById(local, ids[i]); + } + if (decode === null) return null; + const { codecs } = local; + let encode = decode; + for (let i = 0; i < codecs.length; i++) { + if (announced(ids, count, codecs[i].id)) { + encode = codecs[i]; + break; + } + } + return { encode, decode }; +}; + +module.exports = { + DEFAULT_THRESHOLD, + DEFAULT_ASYNC_THRESHOLD, + DICTIONARY_ID_PREFIX, + normalizeAsync, + isCompressor, + isPromise, + normalizeCompression, + negotiate, + codecById, + nativeCompressor, + dictionaryId, + Sequencer, + INFLIGHT_LIMIT, +}; diff --git a/src/compression/native.browser.js b/src/compression/native.browser.js new file mode 100644 index 0000000..9848c40 --- /dev/null +++ b/src/compression/native.browser.js @@ -0,0 +1,67 @@ +'use strict'; + +// The platform's own per-message codecs, browser half: CompressionStream / +// DecompressionStream, which is all a page has — asynchronous, no level, no +// flush control, no preset dictionary (a dictionary needs the codec of +// @alexify/wrpc/deflate, injected). 'deflate-raw' is in every one of them; +// 'brotli' and 'zstd' only in some, which the constructor answers — so a +// format this browser lacks is null, never a throw, and a preference list +// moves on to the next. Where the globals are missing the platform has no +// codec at all and compression stays off unless one is injected. +// +// The threshold is higher than Node's 1 KiB: a CompressionStream costs +// ~46 µs per message against zlib's 7.5 µs, and without a dictionary a +// small message barely shrinks — under 4 KiB the call is not worth it. + +const ID = 'deflate-raw'; + +// Writes `bytes` through one transform and collects what comes out. The +// write is NOT awaited before reading: a large input under the stream's +// own backpressure would otherwise wait for a reader that never starts. +const through = async (stream, bytes, maxOutput) => { + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + const written = writer.write(bytes).then(() => writer.close()); + written.catch(() => {}); + const chunks = []; + let total = 0; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + total += value.length; + if (total > maxOutput) { + reader.cancel().catch(() => {}); + throw new RangeError('inflated message exceeds the cap'); + } + chunks.push(value); + } + await written; + if (chunks.length === 1) return chunks[0]; + const out = new Uint8Array(total); + let offset = 0; + for (let i = 0; i < chunks.length; i++) { + out.set(chunks[i], offset); + offset += chunks[i].length; + } + return out; +}; + +const nativeCompressor = ({ algorithm = ID } = {}) => { + if (algorithm !== ID && algorithm !== 'brotli' && algorithm !== 'zstd') { + throw new TypeError(`compression: unknown algorithm ${JSON.stringify(algorithm)} — deflate-raw, brotli or zstd`); + } + try { + void new CompressionStream(algorithm); + void new DecompressionStream(algorithm); + } catch { + return null; + } + return { + id: algorithm, + threshold: 4096, + encode: (bytes) => through(new CompressionStream(algorithm), bytes, Infinity), + decode: (bytes, maxOutput) => through(new DecompressionStream(algorithm), bytes, maxOutput), + }; +}; + +module.exports = { nativeCompressor, NATIVE_ID: ID }; diff --git a/src/compression/native.js b/src/compression/native.js new file mode 100644 index 0000000..fc74651 --- /dev/null +++ b/src/compression/native.js @@ -0,0 +1,143 @@ +'use strict'; + +// The platform's own per-message codecs, Node half: what node:zlib ships, +// named as CompressionStream names its formats — 'deflate-raw', 'brotli', +// 'zstd' — so a Node peer and a browser peer negotiate the same id with no +// table between them. The browser half is native.browser.js, swapped in +// through package.json#browser. +// +// 'deflate-raw' is what `compression: true` means, and stays that: it is +// level with zstd in bytes and CPU up to ~2 KB, where RPC messages live, and +// it is the one format every CompressionStream and every Node has +// (bench/algorithms.js). The other two are a choice — `codec: 'zstd'` for +// Node↔Node answers of 16 KB and up (a third of deflate's cost at 27 KB, +// 10% smaller), `codec: 'brotli'` for the smallest bytes at deflate's old +// price — and zstd exists in node:zlib only since 22.15 / 23.8, which +// `hasZstd` answers rather than a version check. +// +// The default levels are measured, not zlib's (bench/algorithms.js): +// - deflate 3, the knee — zlib's levels 1–3 are its fast strategy, 4+ the +// lazy one: at 27 KB level 3 takes 46 µs for 3,351 B against level 6's +// 118 µs for 3,196 B, and level 4 is slower AND larger than 3; +// - Brotli quality 4 (93 µs / 2,601 B there). zlib's own default is +// quality 11 — 33 MILLISECONDS on the same message — which is why a +// Brotli codec is built here and not left to a caller's two lines; +// - zstd level 1, with the source size pledged (36 µs / 2,848 B; level 3 +// is slower and no smaller on JSON). +// +// Synchronous on purpose, and `async` is a threshold, not a switch. zlib's +// threadpool hand-off costs a fixed ~20 µs per call (bench/zlib-async.js): +// on a 300 B message the async deflate takes 28 µs to the sync one's 9, +// at 27 KB 120 to 100, and the two are level at ~256 KB — only past that +// is the event loop the thing being bought. Inflate never earns it: it is +// eight times faster than deflate, so the hand-off loses at every size up +// to the 16 MiB cap (1.1 MB: 578 µs sync, 1,136 async). So `decode` is +// always synchronous, `encode` hands a message of `async` bytes or more to +// the threadpool and answers a promise for it — which only the carriers +// with an ordering queue (WebTransport, WebRTC) can take; the Node↔Node +// carriers refuse a codec that declares `async` at construction. +// +// `maxOutput` is zlib's maxOutputLength: an inflate that would exceed it +// fails after at most one chunk past it, which is what bounds a +// compression bomb to the cap the transport already enforces on plain +// messages — for all three families alike. + +const zlib = require('node:zlib'); + +const ID = 'deflate-raw'; +const THRESHOLD = 1024; + +// [default, lowest, highest] of each family's level — Brotli calls it +// quality. Closed: a name outside it is a TypeError on both platforms. +const LEVELS = Object.freeze({ + __proto__: null, + 'deflate-raw': Object.freeze([3, -1, 9]), + brotli: Object.freeze([4, 0, 11]), + zstd: Object.freeze([1, 1, 22]), +}); + +/** Whether this zlib has Zstandard — node:zlib does since 22.15 / 23.8. */ +const hasZstd = (lib) => typeof lib.zstdCompressSync === 'function' && typeof lib.zstdDecompressSync === 'function'; + +const promised = (run, bytes, options) => + new Promise((resolve, reject) => { + run(bytes, options, (error, out) => (error ? reject(error) : resolve(out))); + }); + +/** + * An `encode` that is synchronous under `asyncAbove` bytes (or always, for + * null) and a promise from the threadpool at and past it. `optionsOf(bytes)` + * answers the zlib options of one call — Brotli's size hint and zstd's + * pledged size are per message. + */ +const thresholdEncoder = (sync, callback, optionsOf, asyncAbove) => { + if (asyncAbove === null) return (bytes) => sync(bytes, optionsOf(bytes)); + return (bytes) => + bytes.length >= asyncAbove ? promised(callback, bytes, optionsOf(bytes)) : sync(bytes, optionsOf(bytes)); +}; + +/** + * A raw-deflate `encode` over fixed zlib `options`. Shared with the + * dictionary codec, whose options carry the dictionary. + */ +const deflateEncoder = (asyncAbove, options = undefined) => + thresholdEncoder(zlib.deflateRawSync, zlib.deflateRaw, () => options, asyncAbove); + +const families = { + __proto__: null, + 'deflate-raw': (lib, level, async) => { + const options = { level }; + return { + encode: thresholdEncoder(lib.deflateRawSync, lib.deflateRaw, () => options, async), + decode: (bytes, maxOutput) => lib.inflateRawSync(bytes, { maxOutputLength: maxOutput }), + }; + }, + brotli: (lib, level, async) => { + const { BROTLI_PARAM_QUALITY, BROTLI_PARAM_MODE, BROTLI_MODE_TEXT, BROTLI_PARAM_SIZE_HINT } = lib.constants; + const optionsOf = (bytes) => ({ + params: { + [BROTLI_PARAM_QUALITY]: level, + [BROTLI_PARAM_MODE]: BROTLI_MODE_TEXT, + [BROTLI_PARAM_SIZE_HINT]: bytes.length, + }, + }); + return { + encode: thresholdEncoder(lib.brotliCompressSync, lib.brotliCompress, optionsOf, async), + decode: (bytes, maxOutput) => lib.brotliDecompressSync(bytes, { maxOutputLength: maxOutput }), + }; + }, + zstd: (lib, level, async) => { + const { ZSTD_c_compressionLevel } = lib.constants; + const optionsOf = (bytes) => ({ params: { [ZSTD_c_compressionLevel]: level }, pledgedSrcSize: bytes.length }); + return { + encode: thresholdEncoder(lib.zstdCompressSync, lib.zstdCompress, optionsOf, async), + decode: (bytes, maxOutput) => lib.zstdDecompressSync(bytes, { maxOutputLength: maxOutput }), + }; + }, +}; + +/** + * A platform codec. `algorithm` is 'deflate-raw' (default), 'brotli' or + * 'zstd'; `level` the family's own (Brotli's quality), its measured default + * when absent; `async` a normalized byte threshold or null. An algorithm + * this Node lacks (zstd before 22.15) is a TypeError — or null under + * `optional`, which is how a preference list skips it. `zlib` is the seam + * the tests answer both of those through. + */ +const nativeCompressor = ({ algorithm = ID, level, async = null, optional = false, zlib: lib = zlib } = {}) => { + if (typeof algorithm !== 'string' || !(algorithm in LEVELS)) { + throw new TypeError(`compression: unknown algorithm ${JSON.stringify(algorithm)} — deflate-raw, brotli or zstd`); + } + const [standard, lowest, highest] = LEVELS[algorithm]; + if (level !== undefined && !(Number.isInteger(level) && level >= lowest && level <= highest)) { + throw new TypeError(`compression: ${algorithm} level must be an integer from ${lowest} to ${highest}`); + } + if (algorithm === 'zstd' && !hasZstd(lib)) { + if (optional) return null; + throw new TypeError('compression: this Node has no zstd in node:zlib (22.15+ / 23.8+)'); + } + const { encode, decode } = families[algorithm](lib, level ?? standard, async); + return { id: algorithm, threshold: THRESHOLD, async, encode, decode }; +}; + +module.exports = { nativeCompressor, deflateEncoder, hasZstd, NATIVE_ID: ID, ALGORITHMS: Object.keys(LEVELS) }; diff --git a/src/compression/sync.js b/src/compression/sync.js new file mode 100644 index 0000000..1520469 --- /dev/null +++ b/src/compression/sync.js @@ -0,0 +1,194 @@ +'use strict'; + +// The Node-only helpers over the shared seam (index.js), for the carriers +// that are Node↔Node by construction — the broker binding and the rooms +// backplane / cluster envelopes. Two things set them apart from a socket +// transport: the codec must answer synchronously (a backplane handler and +// a session's frame sequence have no ordering queue to hide a promise +// behind, and every Node codec is synchronous anyway), and a backplane +// carries STRINGS, so a compressed envelope rides as base64 under a marker +// a receiver can tell from JSON at a glance. + +const { normalizeCompression, negotiate, codecById, isPromise } = require('./index.js'); + +// The inflated-size cap, the same 16 MiB the WebTransport and WebRTC +// carriers bound one message at. +const DEFAULT_MAX_MESSAGE = 16 * 1024 * 1024; +const EMPTY = new Uint8Array(0); + +/** + * `normalizeCompression`, then two checks at construction rather than at + * the first message, on EVERY codec of the list — any of them may be the + * one a peer's list selects: a codec that declares `async` (the built-in + * ones with that option — they answer synchronously on a small probe and a + * promise past the threshold), and a probe for one that answers a promise + * outright. + */ +const normalizeSyncCompression = (value, name) => { + const normalized = normalizeCompression(value, name); + if (normalized === null) return null; + for (const { codec } of normalized.codecs) { + if (codec.async !== undefined && codec.async !== null) { + throw new TypeError( + `${name}: compression.codec declares async — this carrier has no ordering queue for a promise`, + ); + } + if (isPromise(codec.encode(EMPTY))) { + throw new TypeError(`${name}: compression.codec must answer synchronously on this carrier`); + } + } + return normalized; +}; + +/** + * `negotiate` for a carrier whose list rides a header — the broker + * binding's `wrpc-enc`: ids joined by commas (an id holds none, + * normalizeCompression sees to that). A request names its list on every + * message, so the last header seen is remembered: a fleet of clients on + * one configuration costs one split, not one per request. + */ +const headerNegotiator = (local) => { + let lastHeader = null; + let lastAgreed = null; + return (header) => { + if (local === null || typeof header !== 'string' || header.length === 0) return null; + if (header === lastHeader) return lastAgreed; + lastHeader = header; + lastAgreed = negotiate(local, header.split(',')); + return lastAgreed; + }; +}; + +const maxMessageOf = (value, name) => { + if (value === undefined || value === null) return DEFAULT_MAX_MESSAGE; + if (!(Number.isInteger(value) && value > 0)) throw new TypeError(`${name}: maxMessage must be a positive integer`); + return value; +}; + +const asBuffer = (bytes) => + Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); + +/** + * `body` (text or bytes) compressed with `active` — one entry of the list, + * `{ codec, id, threshold }` — or null when it is under the threshold, the + * codec failed, or the output would not be smaller: the caller then sends + * it plain, unmarked. `onError(error, active)` hears the failure — the only + * one of the three that is not a decision (cold: called from the catch + * alone, so one receiver serves every codec of a list). + */ +const encodeIfSmaller = (active, body, onError = null) => { + const bytes = typeof body === 'string' ? Buffer.from(body) : body; + if (bytes.length < active.threshold) return null; + let out; + try { + out = active.codec.encode(bytes); + } catch (error) { + if (onError !== null) onError(error, active); + return null; + } + return out.length < bytes.length ? out : null; +}; + +/** + * The inflated bytes, or null when the codec refused them (the cap + * included); `onError(error, active)` hears why — `ERR_BUFFER_TOO_LARGE` is + * the cap, anything else the bytes. + */ +const decodeOrNull = (active, bytes, maxMessage, onError = null) => { + try { + return active.codec.decode(bytes, maxMessage); + } catch (error) { + if (onError !== null) onError(error, active); + return null; + } +}; + +// A codec that fails loses nothing: the message leaves plain, or a frame is +// refused and answered. Which is why nothing used to notice — a dictionary +// that stopped matching, a codec out of memory, and the traffic is several +// times what it was with not a line or a point to show for it. So every +// failure is COUNTED, and said once per (carrier, direction, codec) for the +// life of the reporter — one line a codec, not one a message. +const createFailureReporter = (log, otel = null) => { + const said = new Set(); + return (carrier, direction, codec, error) => { + otel?.recordCompressionFailure(carrier, direction); + const key = `${carrier}\0${direction}\0${codec}`; + if (said.has(key)) return; + said.add(key); + log.warn({ event: 'compression.failed', carrier, direction, codec, code: error?.code, err: error }); + }; +}; + +// A backplane message is a string. A compressed envelope is the JSON text +// (signed already, when the cluster signs) deflated and base64'd under +// `wrpc-enc::` — JSON never starts with a `w`, so the first character +// tells the two apart, and a receiver that holds no such codec, or none, +// knows it holds something it cannot read rather than garbage. +// +// Nothing is negotiated on a backplane, so the list means something else +// here: an instance ENCODES with the head of its list and DECODES any +// codec on it — the marker names which. That is what makes a change of +// codec a rollout without a lost message: first every instance lists both +// (`['deflate-raw', 'zstd']`), then the order is swapped. +const ENVELOPE_PREFIX = 'wrpc-enc:'; + +const isEncodedEnvelope = (message) => typeof message === 'string' && message.startsWith(ENVELOPE_PREFIX); + +/** + * `{ id, ids, encode(text) -> text, decode(text) -> text | null }` over the + * option, or null for off. `encode` leaves a message under the threshold + * (or one the codec would not shrink) as it is; `decode` answers a plain + * message unchanged, an encoded one inflated, and null for one it cannot + * read (a codec not on the list, a body that does not inflate under + * `maxMessage`). + */ +const createEnvelopeCodec = (option, name, maxMessage = DEFAULT_MAX_MESSAGE, failed = null) => { + const local = normalizeSyncCompression(option, name); + if (local === null) return null; + const head = local.codecs[0]; + // `failed(direction, codec id, error)`, when the caller wants to hear: + // one receiver per codec and direction, built here — never per message. + const hear = (direction, id) => (failed === null ? null : (error) => failed(direction, id, error)); + const encodeFailed = hear('encode', head.id); + // An id may hold a colon (`deflate-raw+dict:`), so a marker is + // matched whole, never parsed — longest first, so one id that is a prefix + // of another cannot claim its envelopes. + const markers = local.codecs + .map((entry) => ({ entry, marker: `${ENVELOPE_PREFIX}${entry.id}:`, failed: hear('decode', entry.id) })) + .sort((a, b) => b.marker.length - a.marker.length); + const headMarker = `${ENVELOPE_PREFIX}${head.id}:`; + return { + id: head.id, + ids: local.ids, + encode(text) { + const out = encodeIfSmaller(head, text, encodeFailed); + return out === null ? text : headMarker + asBuffer(out).toString('base64'); + }, + decode(message) { + if (!message.startsWith(ENVELOPE_PREFIX)) return message; + for (let i = 0; i < markers.length; i++) { + const { entry, marker, failed: decodeFailed } = markers[i]; + if (!message.startsWith(marker)) continue; + const out = decodeOrNull(entry, Buffer.from(message.slice(marker.length), 'base64'), maxMessage, decodeFailed); + return out === null ? null : asBuffer(out).toString(); + } + return null; + }, + }; +}; + +module.exports = { + DEFAULT_MAX_MESSAGE, + ENVELOPE_PREFIX, + normalizeSyncCompression, + negotiate, + codecById, + headerNegotiator, + maxMessageOf, + encodeIfSmaller, + decodeOrNull, + createFailureReporter, + isEncodedEnvelope, + createEnvelopeCodec, +}; diff --git a/src/contentEncoding.js b/src/contentEncoding.js new file mode 100644 index 0000000..7935ed1 --- /dev/null +++ b/src/contentEncoding.js @@ -0,0 +1,336 @@ +'use strict'; + +// `Content-Encoding` for the two HTTP-shaped transports — a one-shot encode +// of a packet-mode or REST response body, and a persistent encoder over an +// SSE stream flushed after every event. Both are opt-in (`http.compression`, +// `sse.compression`) and off by default: a deflate per response is CPU spent +// for every peer to save bytes only some of them need, and the server's +// first commitment is the cost per request. Node-only by construction — +// `src/transport.js` and `src/sse/server.js` are never bundled for the +// browser, and the client side needs nothing: `fetch` announces +// `Accept-Encoding` and inflates by itself, in browsers and in Node alike. +// +// WHICH coding is `encodings`, the server's list in its order of preference: +// the first one on it the request accepts is used. It defaults to gzip +// alone, and for an SSE stream that is also the recommendation +// (bench/algorithms.js): flushed per event, gzip, Brotli and zstd come to +// 20, 23 and 18 B an event, and the latter two hold 570 and 930 KB per open +// response against gzip's 320. For a one-shot answer the others earn their +// place from ~16 KB: at 27 KB zstd level 1 costs a third of gzip and is 10% +// smaller, Brotli quality 4 is 19% smaller at gzip's cost. The zlib-wrapped +// `deflate` token is not offered at all — it has a history of peers that +// read it as raw deflate. Browsers announce `br` and `zstd` over HTTPS only. +// +// The levels are the measured ones of src/compression/native.js, not +// zlib's: Brotli's own default, quality 11, takes 33 ms on that 27 KB body. + +const zlib = require('node:zlib'); + +const { hasZstd } = require('./compression/native.js'); +const { normalizeAsync } = require('./compression/index.js'); + +const DEFAULT_THRESHOLD = 1024; +// The threadpool hand-off costs a fixed amount per call: the same shape as +// permessage-deflate's `async`, with the same default (bench/deflate-context.js). +const DEFAULT_ASYNC_THRESHOLD = 256 * 1024; +const DEFAULT_BROTLI_QUALITY = 4; +const DEFAULT_ZSTD_LEVEL = 1; + +const integerIn = (value, low, high) => Number.isInteger(value) && value >= low && value <= high; +// RFC 9110 §5.6.2 `token` — what a content-coding is. +const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +// One coding, in the shape the transports read: +// token the Content-Encoding value +// encode(body) the whole body at once; a custom one may answer a promise +// encodeAsync(body, cb) the same through zlib's threadpool, or null +// createStream() a persistent encoder that emits each write's output +// as it is written (SSE), or null +const gzipEncoder = ({ level, memLevel }, name) => { + if (level !== undefined && !integerIn(level, -1, 9)) { + throw new TypeError(`${name}: gzip level must be an integer from -1 to 9`); + } + if (memLevel !== undefined && !integerIn(memLevel, 1, 9)) { + throw new TypeError(`${name}: gzip memLevel must be an integer from 1 to 9`); + } + const options = {}; + if (level !== undefined) options.level = level; + if (memLevel !== undefined) options.memLevel = memLevel; + return { + token: 'gzip', + encode: (body) => zlib.gzipSync(body, options), + encodeAsync: (body, callback) => zlib.gzip(body, options, callback), + createStream: () => zlib.createGzip({ ...options, flush: zlib.constants.Z_SYNC_FLUSH }), + }; +}; + +const brotliEncoder = ({ quality = DEFAULT_BROTLI_QUALITY }, name) => { + if (!integerIn(quality, 0, 11)) throw new TypeError(`${name}: br quality must be an integer from 0 to 11`); + const { BROTLI_PARAM_QUALITY, BROTLI_PARAM_MODE, BROTLI_MODE_TEXT, BROTLI_PARAM_SIZE_HINT } = zlib.constants; + const optionsOf = (body) => ({ + params: { + [BROTLI_PARAM_QUALITY]: quality, + [BROTLI_PARAM_MODE]: BROTLI_MODE_TEXT, + [BROTLI_PARAM_SIZE_HINT]: body.length, + }, + }); + return { + token: 'br', + encode: (body) => zlib.brotliCompressSync(body, optionsOf(body)), + encodeAsync: (body, callback) => zlib.brotliCompress(body, optionsOf(body), callback), + createStream: () => + zlib.createBrotliCompress({ + params: { [BROTLI_PARAM_QUALITY]: quality, [BROTLI_PARAM_MODE]: BROTLI_MODE_TEXT }, + flush: zlib.constants.BROTLI_OPERATION_FLUSH, + }), + }; +}; + +const zstdEncoder = ({ level = DEFAULT_ZSTD_LEVEL }, name, lib) => { + if (!integerIn(level, 1, 22)) throw new TypeError(`${name}: zstd level must be an integer from 1 to 22`); + if (!hasZstd(lib)) throw new TypeError(`${name}: this Node has no zstd in node:zlib (22.15+ / 23.8+)`); + const params = { [lib.constants.ZSTD_c_compressionLevel]: level }; + return { + token: 'zstd', + encode: (body) => lib.zstdCompressSync(body, { params, pledgedSrcSize: body.length }), + encodeAsync: (body, callback) => lib.zstdCompress(body, { params, pledgedSrcSize: body.length }, callback), + createStream: () => lib.createZstdCompress({ params, flush: lib.constants.ZSTD_e_flush }), + }; +}; + +// An application's own coding: `{ encoding, encode(bytes), createStream? }`. +// `encode` may answer a promise; `createStream` answers a Node Transform +// that emits every write's output without waiting for more (for a zlib +// class, its `flush` option) — without one the coding serves one-shot +// answers only, and an SSE option refuses it. +const customEncoder = (entry, name) => { + const { encoding, encode, createStream = null } = entry; + if (typeof encode !== 'function') throw new TypeError(`${name}: a custom encoding must provide encode(bytes)`); + if (createStream !== null && typeof createStream !== 'function') { + throw new TypeError(`${name}: a custom encoding's createStream must be a function`); + } + return { token: encoding.toLowerCase(), encode, encodeAsync: null, createStream }; +}; + +const toEncoder = (entry, name, lib) => { + const options = typeof entry === 'string' ? { encoding: entry } : entry; + if (typeof options !== 'object' || options === null || typeof options.encoding !== 'string') { + throw new TypeError(`${name}: an encoding is 'gzip', 'br', 'zstd' or an object with \`encoding\``); + } + if (!TOKEN.test(options.encoding)) throw new TypeError(`${name}: encoding must be an HTTP token`); + if (options.encode !== undefined) return customEncoder(options, name); + const token = options.encoding.toLowerCase(); + if (token === 'gzip') return gzipEncoder(options, name); + if (token === 'br') return brotliEncoder(options, name); + if (token === 'zstd') return zstdEncoder(options, name, lib); + throw new TypeError( + `${name}: unknown encoding ${JSON.stringify(options.encoding)} — gzip, br, zstd, or one with encode()`, + ); +}; + +// `compression: true | { threshold, filter, encodings, async }` into the +// frozen shape the transports read, or null for off. Strict: this option is +// new, so a bad value is a TypeError at construction rather than a response +// that quietly went out uncompressed. `streaming` is the SSE half asking +// for every coding to be able to stream. `zlib` is the seam the tests +// answer "a Node without zstd" through. +const normalizeCompression = (value, name, { streaming = false, zlib: lib = zlib } = {}) => { + if (value === undefined || value === null || value === false) return null; + const options = value === true ? {} : value; + if (typeof options !== 'object' || Array.isArray(options)) { + throw new TypeError(`${name}: compression must be true, false or an options object`); + } + const { threshold = DEFAULT_THRESHOLD, filter = null, encodings = ['gzip'], async: asyncOption = null } = options; + if (!(Number.isInteger(threshold) && threshold >= 0)) { + throw new TypeError(`${name}: compression.threshold must be a non-negative integer`); + } + if (filter !== null && typeof filter !== 'function') { + throw new TypeError(`${name}: compression.filter must be a function`); + } + if (options.level !== undefined || options.memLevel !== undefined) { + throw new TypeError( + `${name}: compression.level / memLevel belong to the coding — encodings: [{ encoding: 'gzip', level }]`, + ); + } + if (!Array.isArray(encodings) || encodings.length === 0 || encodings.length > 8) { + throw new TypeError(`${name}: compression.encodings must be a list of one to eight encodings`); + } + const encoders = encodings.map((entry) => toEncoder(entry, `${name}: compression.encodings`, lib)); + for (let i = 0; i < encoders.length; i++) { + const { token } = encoders[i]; + if (token === 'identity' || token === '*') throw new TypeError(`${name}: ${token} is not an encoding to apply`); + if (encoders.findIndex((other) => other.token === token) !== i) { + throw new TypeError(`${name}: compression.encodings names ${token} twice`); + } + if (streaming && encoders[i].createStream === null) { + throw new TypeError(`${name}: the ${token} encoding has no createStream() — an SSE response needs one`); + } + } + // The one strict normalizer every `async` knob shares (src/compression): + // `{ threshold: '64kb' }`, `{ threshold: 0 }` and `async: []` used to + // land silently on the 256 KiB default here. + const asyncThreshold = normalizeAsync(asyncOption, `${name}: compression`); + const async = asyncThreshold === null ? null : { threshold: asyncThreshold }; + return Object.freeze({ threshold, filter, async, encoders: Object.freeze(encoders) }); +}; + +// RFC 9110 §12.5.3: a comma-separated list of codings, each with an optional +// `;q=` weight; `*` stands for any coding not named. Answers the FIRST +// encoder of the server's list the request accepts — named with a weight +// above zero, or covered by such a wildcard and not itself refused. The +// server's order decides, as in nginx: a client's weights say what it +// accepts, and which acceptable coding costs this server least is not the +// client's to know. A hand-rolled scan rather than a regex: this runs per +// HTTP response once the option is on, and the header is peer-controlled, +// so no split-per-token array or Map on a value an attacker can make long +// (the per-token slice/trim/lowercase stays). Two bounds keep it honest: +// a header longer than MAX_ACCEPT_ENCODING is treated as absent — a real +// one is 20–100 bytes, and identity is always a correct answer (RFC 9110 +// §12.5.3) — and the search for the next ';' is remembered, so a header +// with no ';' at all is one pass, not a pass per token: before that, +// 8000 one-letter tokens cost 1.9 ms of CPU per request and 16 KB of +// commas 3.8 ms, unauthenticated (bench/http-compression.js "adversarial"; +// after: refused at the cap in under 0.1 µs, and 4.4 µs for the 256 B +// the cap lets through, 6.8 before). +// The encoders are a handful, so a bit each. +const MAX_ACCEPT_ENCODING = 256; + +const pickEncoding = (header, encoders) => { + if (typeof header !== 'string' || header.length === 0 || header.length > MAX_ACCEPT_ENCODING) return null; + const length = header.length; + const count = encoders.length; + let accepted = 0; + let refused = 0; + let wildcard = false; + let wildcardRefused = false; + let start = 0; + let semi = -1; + while (start <= length) { + let end = header.indexOf(',', start); + if (end < 0) end = length; + if (semi < start) { + semi = header.indexOf(';', start); + if (semi < 0) semi = length; + } + const tokenEnd = semi < end ? semi : end; + let token = header.slice(start, tokenEnd).trim().toLowerCase(); + const params = tokenEnd < end ? header.slice(tokenEnd + 1, end) : ''; + const zero = params.length > 0 && /^\s*q\s*=\s*0(?:\.0{0,3})?\s*$/i.test(params); + // A coding — the wildcard too — refused anywhere in the header stays + // refused, whatever names it again. + if (token === '*') { + if (zero) wildcardRefused = true; + else wildcard = true; + } else { + if (token === 'x-gzip') token = 'gzip'; + for (let i = 0; i < count; i++) { + if (encoders[i].token !== token) continue; + if (zero) refused |= 1 << i; + else accepted |= 1 << i; + break; + } + } + start = end + 1; + } + for (let i = 0; i < count; i++) { + const bit = 1 << i; + if ((accepted & bit) !== 0 && (refused & bit) === 0) return encoders[i]; + if (wildcard && !wildcardRefused && ((accepted | refused) & bit) === 0) return encoders[i]; + } + return null; +}; + +// The encoder a response is to be encoded with, or null: the peer accepts +// one of ours, nothing upstream already encoded the body (a route's own +// `Content-Encoding`, or a framework plugin's), and the per-request `filter` +// — when there is one — says yes. The header scan is case-insensitive +// because `headers` mixes the transport's own spelling with whatever +// `setHeader` was handed. +const chooseEncoding = (compression, call, headers) => { + const encoder = pickEncoding(call.headers?.['accept-encoding'], compression.encoders); + if (encoder === null) return null; + for (const name in headers) { + if (name.length === 16 && name.toLowerCase() === 'content-encoding') return null; + } + return compression.filter === null || compression.filter(call) === true ? encoder : null; +}; + +// A request header that decided the representation, added to `Vary` — +// joined onto what is there (the CORS `Vary: Origin`): a shared cache must +// key on every one of them. +const addVary = (headers, name) => { + const vary = headers['Vary']; + headers['Vary'] = vary ? `${vary}, ${name}` : name; +}; + +// `Vary: Accept-Encoding` on an encoded response. +const markEncoded = (headers, token) => { + headers['Content-Encoding'] = token; + addVary(headers, 'Accept-Encoding'); +}; + +// The SSE half: one encoder for the life of the response (for gzip, one +// member), every event flushed so it leaves the process the moment it is +// written — the stream's own history is what a later event compresses +// against, which is context takeover for free (bench/http-compression.js). +// Wraps the host's `{ write, end, onClose, onDrain }` writer with the same +// shape, so SseChannels needs to know nothing about it. +// +// Backpressure: the host's `write` answer is remembered from the last chunk +// zlib produced and reported on the next `write`, and its 'drain' is passed +// through — the signal moves one stage later, as it does for permessage- +// deflate's async path, and the subscription pump waits on it exactly as +// before. The encoder stream's own high-water mark counts too. +const encodedWriter = (writer, encoder, failed = null) => { + const stream = encoder.createStream(); + let writable = true; + let ended = false; + const end = () => { + if (ended) return; + ended = true; + try { + writer.end(); + } catch { + // The response died first; its close listener owns the cleanup. + } + }; + stream.on('data', (chunk) => { + try { + writable = writer.write(chunk) !== false; + } catch { + writable = false; + } + }); + stream.once('end', end); + // An encoder stream that failed ends the response — the client reconnects + // and resumes — and that is the one trace of it unless it is said. + stream.once('error', (error) => { + failed?.(error); + end(); + }); + writer.onClose?.(() => stream.destroy()); + return { + write: (text) => stream.write(text) && writable, + end: () => void stream.end(), + onClose: (listener) => writer.onClose?.(listener), + onDrain: (listener) => { + writer.onDrain?.(() => { + writable = true; + listener(); + }); + stream.on('drain', listener); + }, + }; +}; + +module.exports = { + MAX_ACCEPT_ENCODING, + DEFAULT_THRESHOLD, + DEFAULT_ASYNC_THRESHOLD, + normalizeCompression, + pickEncoding, + chooseEncoding, + addVary, + markEncoded, + encodedWriter, +}; diff --git a/src/deflate/deflate.js b/src/deflate/deflate.js new file mode 100644 index 0000000..a98e8b7 --- /dev/null +++ b/src/deflate/deflate.js @@ -0,0 +1,327 @@ +'use strict'; + +// RFC 1951 deflate in plain JavaScript, the small half of +// @alexify/wrpc/deflate: LZ77 over hash chains against a preset dictionary, +// written as ONE fixed-Huffman block. Measured before it was written +// (bench/deflate-js.js): on the messages this exists for — a few hundred +// bytes against a dictionary — fixed codes produce the same size as +// dynamic ones (58 B against 58 B on a 161 B event), because the dynamic +// tree costs more than it saves on so little input; past a couple of +// kilobytes dynamic wins by 26–39%, and that is where the codec hands the +// message to the platform's CompressionStream instead (index.js). So the +// encoder needs no tree construction at all, which is what keeps it ~200 +// lines. Falls back to stored blocks when fixed codes would not shrink the +// input, as zlib does. +// +// Browser-budgeted. `deflateRaw` is the one-shot form: it allocates its +// window and hash chains per call and hashes the dictionary again each +// time, so its cost grows with the dictionary, not only with the message. +// A codec does that work ONCE — `createDeflater` below keeps the window, +// the dictionary's chains and a scratch region, and a message costs what +// the message costs (bench/deflate-js.js, the "codec" rows). + +const { WINDOW, LENGTH_BASE, LENGTH_EXTRA, DIST_BASE, DIST_EXTRA, toBytes } = require('./inflate.js'); + +const MIN_MATCH = 3; +const MAX_MATCH = 258; +const HASH_BITS = 15; +const HASH_SIZE = 1 << HASH_BITS; +const HASH_MASK = HASH_SIZE - 1; +const STORED_MAX = 65535; +// How far down a hash chain a match is looked for, by level: 1 is a quick +// pass, 9 exhaustive, the default what pays on wrpc-sized messages. +const CHAIN_BY_LEVEL = [4, 4, 8, 16, 24, 32, 32, 64, 128, 256]; + +const reverse = (code, len) => { + let r = 0; + for (let i = 0; i < len; i++) { + r = (r << 1) | (code & 1); + code >>= 1; + } + return r; +}; + +// The fixed literal/length codes, bit-reversed for LSB-first writing: +// entry = (reversed code << 4) | length. +const LIT_CODES = (() => { + const codes = new Uint32Array(288); + for (let sym = 0; sym < 288; sym++) { + let code; + let len; + if (sym < 144) { + code = 0x30 + sym; + len = 8; + } else if (sym < 256) { + code = 0x190 + (sym - 144); + len = 9; + } else if (sym < 280) { + code = sym - 256; + len = 7; + } else { + code = 0xc0 + (sym - 280); + len = 8; + } + codes[sym] = (reverse(code, len) << 4) | len; + } + return codes; +})(); +const DIST_CODES = (() => { + const codes = new Uint32Array(30); + for (let sym = 0; sym < 30; sym++) codes[sym] = (reverse(sym, 5) << 4) | 5; + return codes; +})(); + +// length (3..258) -> code index 0..28, distance (1..32768) -> code 0..29: +// direct tables, built once, so emitting a match is two reads. +const LENGTH_CODE = (() => { + const table = new Uint8Array(MAX_MATCH + 1); + for (let i = 0; i < 29; i++) { + const base = LENGTH_BASE[i]; + const next = i === 28 ? MAX_MATCH + 1 : LENGTH_BASE[i + 1]; + for (let len = base; len < next; len++) table[len] = i; + } + table[MAX_MATCH] = 28; + return table; +})(); +const distCode = (distance) => { + // 30 codes: a short scan from the top is cheaper than a 32 KiB table. + let i = 29; + while (DIST_BASE[i] > distance) i--; + return i; +}; + +class BitWriter { + #out; + #len = 0; + #buf = 0; + #cnt = 0; + + constructor(size) { + this.#out = new Uint8Array(size); + } + + write(value, bits) { + this.#buf |= value << this.#cnt; + this.#cnt += bits; + while (this.#cnt >= 8) { + if (this.#len === this.#out.length) this.#grow(); + this.#out[this.#len++] = this.#buf & 0xff; + this.#buf >>>= 8; + this.#cnt -= 8; + } + } + + #grow() { + const grown = new Uint8Array(this.#out.length * 2); + grown.set(this.#out); + this.#out = grown; + } + + finish() { + if (this.#cnt > 0) { + if (this.#len === this.#out.length) this.#grow(); + this.#out[this.#len++] = this.#buf & 0xff; + } + return this.#out.subarray(0, this.#len); + } + + get length() { + return this.#len; + } +} + +// Stored blocks: the honest fallback for input fixed codes would not shrink. +const stored = (input) => { + const blocks = Math.max(1, Math.ceil(input.length / STORED_MAX)); + const out = new Uint8Array(input.length + blocks * 5); + let offset = 0; + let pos = 0; + for (let b = 0; b < blocks; b++) { + const len = Math.min(STORED_MAX, input.length - pos); + out[offset++] = b === blocks - 1 ? 1 : 0; + out[offset++] = len & 0xff; + out[offset++] = len >>> 8; + out[offset++] = ~len & 0xff; + out[offset++] = (~len >>> 8) & 0xff; + out.set(input.subarray(pos, pos + len), offset); + offset += len; + pos += len; + } + return out; +}; + +const hashAt = (win, i) => ((win[i] << 10) ^ (win[i + 1] << 5) ^ win[i + 2]) & HASH_MASK; + +// The dictionary's positions into the chains, so a match may reach back +// into it from the first input byte. The last two are left out — hashing +// them needs the bytes that follow, which are the message's — and that is +// what makes the result a property of the dictionary alone. +const hashDictionary = (win, head, prev, dictLen) => { + for (let i = 0; i + MIN_MATCH <= dictLen; i++) { + const h = hashAt(win, i); + prev[i] = head[h]; + head[h] = i; + } +}; + +// The LZ77 pass and the fixed-Huffman emission over `win[dictLen, end)`, +// with the dictionary's chains already in `head` and `prev`. ONE algorithm +// for both ways of preparing that state: per call (deflateRaw) and once per +// codec (createDeflater), which is why the two produce the same bytes. +// `touched`, when given, takes every hash slot this pass writes, so the +// caller can put `head` back; the number written is the pass's answer in +// `state.dirty`. +// +// What a prepared state relies on: `prev[pos]` for a message position is +// written before it is read (a position enters a chain only when it is +// inserted), and `win` is never read at or past `end` (`pos + MIN_MATCH <= +// end` guards the hash, `maxLen` the compare) — so nothing a previous +// message left behind is ever seen. +const pass = (state, dictLen, end, chain) => { + const { win, head, prev, touched } = state; + const writer = new BitWriter(Math.max(32, (end - dictLen) >>> 1)); + // BFINAL = 1, BTYPE = 01 (fixed Huffman). + writer.write(1, 1); + writer.write(1, 2); + let dirty = 0; + let pos = dictLen; + while (pos < end) { + let bestLen = 0; + let bestDist = 0; + if (pos + MIN_MATCH <= end) { + const h = hashAt(win, pos); + let cand = head[h]; + let depth = chain; + const maxLen = Math.min(MAX_MATCH, end - pos); + while (cand >= 0 && depth-- > 0) { + const dist = pos - cand; + if (dist > WINDOW) break; + if (win[cand + bestLen] === win[pos + bestLen] && win[cand] === win[pos]) { + let len = 0; + while (len < maxLen && win[cand + len] === win[pos + len]) len++; + if (len > bestLen) { + bestLen = len; + bestDist = dist; + if (len === maxLen) break; + } + } + cand = prev[cand]; + } + prev[pos] = head[h]; + head[h] = pos; + if (touched !== null) touched[dirty++] = h; + } + if (bestLen >= MIN_MATCH) { + const li = LENGTH_CODE[bestLen]; + const lit = LIT_CODES[257 + li]; + writer.write(lit >>> 4, lit & 15); + if (LENGTH_EXTRA[li] > 0) writer.write(bestLen - LENGTH_BASE[li], LENGTH_EXTRA[li]); + const di = distCode(bestDist); + const dc = DIST_CODES[di]; + writer.write(dc >>> 4, 5); + if (DIST_EXTRA[di] > 0) writer.write(bestDist - DIST_BASE[di], DIST_EXTRA[di]); + // The bytes inside the match join the chains too, or a later match + // could not start on them. + for (let i = pos + 1; i < pos + bestLen && i + MIN_MATCH <= end; i++) { + const h = hashAt(win, i); + prev[i] = head[h]; + head[h] = i; + if (touched !== null) touched[dirty++] = h; + } + pos += bestLen; + } else { + const lit = LIT_CODES[win[pos]]; + writer.write(lit >>> 4, lit & 15); + pos++; + } + } + state.dirty = dirty; + const eob = LIT_CODES[256]; + writer.write(eob >>> 4, eob & 15); + return writer.finish(); +}; + +// Fixed codes did not pay: stored blocks are exactly the input plus five +// bytes per 64 KiB. +const orStored = (out, data) => + out.length >= data.length + 5 * Math.max(1, Math.ceil(data.length / STORED_MAX)) ? stored(data) : out; + +const chainOf = (level) => CHAIN_BY_LEVEL[Math.max(1, Math.min(9, level | 0))]; + +/** + * Bytes → raw DEFLATE, synchronously, as one fixed-Huffman block (or + * stored blocks when that is smaller). `dictionary` preloads the window + * (its last 32 KiB) exactly as zlib's `dictionary` option does, so the + * output inflates on any inflater given the same bytes; `level` 1–9 is the + * hash-chain depth. + */ +const deflateRaw = (input, { dictionary = null, level = 6 } = {}) => { + const data = toBytes(input, 'deflate'); + const dict = dictionary === null ? null : toBytes(dictionary, 'deflate'); + const dictLen = dict === null ? 0 : Math.min(dict.length, WINDOW); + const total = dictLen + data.length; + const win = new Uint8Array(total); + if (dictLen > 0) win.set(dict.subarray(dict.length - dictLen), 0); + win.set(data, dictLen); + const head = new Int32Array(HASH_SIZE).fill(-1); + const prev = new Int32Array(total); + hashDictionary(win, head, prev, dictLen); + return orStored(pass({ win, head, prev, touched: null, dirty: 0 }, dictLen, total, chainOf(level)), data); +}; + +// The largest message a prepared state takes; a larger one goes through +// deflateRaw. A browser hands 4 KiB and more to CompressionStream anyway. +const SCRATCH = 8192; + +/** + * `encode(bytes) -> raw DEFLATE`, the same bytes deflateRaw answers for the + * same dictionary and level, without redoing the dictionary's share of the + * work on every message: the window with the dictionary in it, the hash + * heads and the dictionary's chains are built once — on the first message, + * so a codec nobody uses holds nothing — and a call copies the message in + * behind the dictionary, runs the pass, and remembers which hash slots it + * wrote. The NEXT call puts those slots back first (at the start, not the + * end: an exception in between then costs nothing), which is `head` exactly + * as the dictionary left it. + * + * bench/deflate-js.js, the "codec" rows against the one-shot ones: a 108 B + * event 10.9 → 1.4 µs (8.9 → 1.6 with no dictionary at all: most of a + * small message's cost was filling 128 KB of hash heads), a 2 KB callback + * against a 4 KB dictionary 32 → 13, against 32 KiB 116 → 13 — the + * dictionary's size no longer shows. + * + * About 0.2 MB plus five bytes per dictionary byte, per codec: build one + * for a process or a page, not one per connection. Not reentrant, and + * synchronous by construction. + */ +const createDeflater = (dictionary = null, level = 6) => { + const dict = dictionary === null ? null : toBytes(dictionary, 'deflate'); + const dictLen = dict === null ? 0 : Math.min(dict.length, WINDOW); + const chain = chainOf(level); + let state = null; + // What `head` holds for the dictionary alone; -1 everywhere without one. + let base = null; + const prepare = () => { + const win = new Uint8Array(dictLen + SCRATCH); + const head = new Int32Array(HASH_SIZE).fill(-1); + const prev = new Int32Array(dictLen + SCRATCH); + if (dictLen > 0) { + win.set(dict.subarray(dict.length - dictLen), 0); + hashDictionary(win, head, prev, dictLen); + base = head.slice(); + } + return { win, head, prev, touched: new Int32Array(SCRATCH), dirty: 0 }; + }; + return (input) => { + const data = toBytes(input, 'deflate'); + if (data.length > SCRATCH) return deflateRaw(data, { dictionary: dict, level }); + state ??= prepare(); + const { head, touched } = state; + for (let i = 0; i < state.dirty; i++) head[touched[i]] = base === null ? -1 : base[touched[i]]; + state.dirty = 0; + state.win.set(data, dictLen); + return orStored(pass(state, dictLen, dictLen + data.length, chain), data); + }; +}; + +module.exports = { deflateRaw, createDeflater }; diff --git a/src/deflate/index.js b/src/deflate/index.js new file mode 100644 index 0000000..307eddd --- /dev/null +++ b/src/deflate/index.js @@ -0,0 +1,113 @@ +'use strict'; + +// @alexify/wrpc/deflate — a per-message DEFLATE codec in plain JavaScript, +// for the one thing a browser cannot do otherwise: compress against a +// preset dictionary (CompressionStream takes none), and synchronously. +// Injected wherever a codec goes — `compression: { codec }` on the +// WebTransport or WebRTC client, a browser peer — and its own subpath, so +// a page that does not want it never loads a byte of it. +// +// The policy, from bench/deflate-js.js: small messages go through the own +// encoder (fixed Huffman + LZ77 against the dictionary — the whole win is +// there, and fixed codes cost nothing on so little input); past +// `nativeAbove` a message goes to the platform's CompressionStream where +// there is one, dynamic Huffman and no dictionary — a large payload has +// all the history it needs inside itself, and the output still inflates on +// a peer holding the dictionary, since a stream that never reaches back +// into it does not care. Decoding is always the own inflater: complete +// (stored, fixed, dynamic), dictionary-aware, capped. +// +// The id matches the Node dictionary codec's for the same bytes +// (`deflate-raw+dict:`), so a Node peer on node:zlib and a browser peer +// on this codec negotiate with each other; without a dictionary it is the +// platform id, and the platform codecs read it. + +const { inflateRaw, DeflateError, toBytes } = require('./inflate.js'); + +// A dictionary may be given as text (a router's, printed) as well as bytes. +const dictionaryBytes = (value) => { + if (typeof value === 'string') return new TextEncoder().encode(value); + try { + return toBytes(value, 'createDeflateCodec'); + } catch { + throw new TypeError('createDeflateCodec: dictionary must be bytes or a string'); + } +}; +const { deflateRaw, createDeflater } = require('./deflate.js'); +const { dictionaryId, DICTIONARY_ID_PREFIX } = require('../compression/ids.js'); + +const NATIVE_ID = 'deflate-raw'; +const DEFAULT_NATIVE_ABOVE = 4096; + +// The platform's CompressionStream, for the large messages — asynchronous. +const nativeEncode = async (bytes) => { + const stream = new CompressionStream('deflate-raw'); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + const written = writer.write(bytes).then(() => writer.close()); + written.catch(() => {}); + const chunks = []; + let total = 0; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + chunks.push(value); + total += value.length; + } + await written; + if (chunks.length === 1) return chunks[0]; + const out = new Uint8Array(total); + let offset = 0; + for (let i = 0; i < chunks.length; i++) { + out.set(chunks[i], offset); + offset += chunks[i].length; + } + return out; +}; + +// In Node the platform codec is node:zlib and every carrier there may need +// a synchronous answer, so the native hand-off is a browser default only. +const inBrowser = () => + typeof CompressionStream === 'function' && !(typeof process === 'object' && process?.versions?.node); + +/** + * `{ id, threshold, dictionary, encode, decode }` — a Compressor. + * `dictionary`: bytes both ends hold (`buildDictionary(router)`), or none + * for a plain raw-deflate codec. `threshold`: the size under which a + * message goes plain (64 B with a dictionary, 1 KiB without). `native`: + * hand messages of `nativeAbove` bytes (4 KiB) and more to + * CompressionStream — on by default in a browser, off in Node, and then + * `encode` may answer a promise for those. `level`: the hash-chain depth, + * 1–9. + */ +const createDeflateCodec = ({ + dictionary = null, + threshold, + native, + nativeAbove = DEFAULT_NATIVE_ABOVE, + level = 6, +} = {}) => { + const dict = dictionary === null || dictionary === undefined ? null : dictionaryBytes(dictionary); + if (dict !== null && dict.length === 0) throw new TypeError('createDeflateCodec: dictionary must not be empty'); + if (threshold !== undefined && !(Number.isInteger(threshold) && threshold >= 0)) { + throw new TypeError('createDeflateCodec: threshold must be a non-negative integer'); + } + if (!(Number.isInteger(nativeAbove) && nativeAbove > 0)) { + throw new TypeError('createDeflateCodec: nativeAbove must be a positive integer'); + } + if (!(Number.isInteger(level) && level >= 1 && level <= 9)) { + throw new TypeError('createDeflateCodec: level must be an integer from 1 to 9'); + } + const useNative = (native ?? inBrowser()) && typeof CompressionStream === 'function'; + const deflate = createDeflater(dict, level); + return { + id: dict === null ? NATIVE_ID : DICTIONARY_ID_PREFIX + dictionaryId(dict), + threshold: threshold ?? (dict === null ? 1024 : 64), + dictionary: dict, + // The dictionary's hash chains are this codec's, built once (deflate.js). + encode: (bytes) => (useNative && bytes.length >= nativeAbove ? nativeEncode(bytes) : deflate(bytes)), + decode: (bytes, maxOutput) => inflateRaw(bytes, { dictionary: dict, maxOutput }), + }; +}; + +module.exports = { createDeflateCodec, inflateRaw, deflateRaw, DeflateError }; diff --git a/src/deflate/inflate.js b/src/deflate/inflate.js new file mode 100644 index 0000000..e55a045 --- /dev/null +++ b/src/deflate/inflate.js @@ -0,0 +1,415 @@ +'use strict'; + +// RFC 1951 inflate in plain JavaScript — the half of @alexify/wrpc/deflate +// that has to be complete: whatever sits on the other end (node:zlib, a +// browser's CompressionStream, this codec) picks its own block types, so +// stored, fixed and dynamic Huffman blocks are all read here. What the +// platform's DecompressionStream cannot do is the reason this exists — take +// a preset dictionary, and answer synchronously. +// +// Decoding tables are built per tree, sized 1 << (longest code) and indexed +// by the bit-reversed code, so a symbol is one table read on the peeked +// bits rather than a bit-by-bit walk; the fixed tables are built once. The +// output buffer starts with the dictionary's tail in it, so a distance +// that reaches back into the dictionary is an ordinary copy, and grows by +// doubling up to `maxOutput` — the cap that bounds a compression bomb to +// the size a plain message is bounded at. Browser-budgeted: manual loops, +// typed arrays, no closures on the hot path. + +class DeflateError extends Error { + constructor(message, code) { + super(message); + this.name = 'DeflateError'; + this.code = code; + } +} + +const WINDOW = 32768; +const MAX_BITS = 15; + +// Length codes 257..285: base length and extra bits (RFC 1951 3.2.5). +const LENGTH_BASE = new Uint16Array([ + 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258, +]); +const LENGTH_EXTRA = new Uint8Array([ + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0, +]); +// Distance codes 0..29. +const DIST_BASE = new Uint16Array([ + 1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, + 8193, 12289, 16385, 24577, +]); +const DIST_EXTRA = new Uint8Array([ + 0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13, +]); +// The order code-length code lengths are transmitted in. +const CODE_LENGTH_ORDER = new Uint8Array([16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]); + +// The root table is ROOT bits wide whatever lengths a block declares; a +// code longer than that is decoded through a sub-table its root slot +// points at, sized for the longest code under that slot (zlib's two-level +// scheme). Without the cap a dynamic block cost a 1 << maxLen table — 128 +// KB, and as many writes, at 15 bits — and a stream of blocks that declare +// 15-bit codes and emit nothing is outside `maxOutput`'s reach; now a +// block costs its header (hlit + hdist) plus 2^ROOT root entries plus +// sub-tables that a prefix-free code bounds by itself (zlib's ENOUGH: 852 +// entries for the literal alphabet at a 9-bit root, fewer at a wider one). +// Twelve bits, not zlib's nine, BY MEASUREMENT (bench/deflate-js.js, +// medians of three): the sub-table path is a call out of the block loop, +// ~9 ns a symbol — "huffman-only 64 KB skewed", every rare symbol on it, +// 2,013 vs 1,847/sec before, the smaller root paying for the calls — and a +// real tree reaches 10–12 bits on a 28 KB JSON callback ("callback 28 KB" +// own inflate, dictionary: 13,980 vs 14,087/sec; "many dynamic blocks": +// 191 vs 188/sec). A 16 KB table per block is an eighth of what a 15-bit +// tree cost, and the flood shape costs that whatever it declares. +const ROOT = 12; +// The length field of a pointer entry. The bit buffer never holds 32 bits +// (a fill tops it up past what was asked, by less than a byte), so a +// pointer fails the hot path's one existing check — "the code fits in the +// buffer" — and the fast path is exactly what it was. It is that sensitive +// to #symbol's size: the sub-table lookup written inline in it measured +// −12% on the 28 KB callback and −5% on the 2 KB one, no sub-table hit. +const POINTER = 32; + +/** + * A decoding table over canonical code lengths: entry = (length << 16) | + * symbol at every index whose low `length` bits are the bit-reversed code, + * or 0 where no code lands. A root slot under which codes run longer than + * ROOT holds (POINTER + width) << 16 | offset instead: a sub-table of + * `width` more bits at `offset` in the same array, whose entries carry the + * REMAINING length. Over-subscribed lengths are refused; incomplete sets + * are allowed (a single-code distance tree is legal). + */ +const buildTable = (lengths, count) => { + let maxLen = 0; + for (let i = 0; i < count; i++) if (lengths[i] > maxLen) maxLen = lengths[i]; + if (maxLen === 0) return { table: null, bits: 0 }; + const blCount = new Uint16Array(MAX_BITS + 1); + for (let i = 0; i < count; i++) blCount[lengths[i]]++; + blCount[0] = 0; + let left = 1; + for (let len = 1; len <= MAX_BITS; len++) { + left = (left << 1) - blCount[len]; + if (left < 0) throw new DeflateError('over-subscribed Huffman code', 'huffman'); + } + const nextCode = new Uint16Array(MAX_BITS + 2); + let code = 0; + for (let len = 1; len <= MAX_BITS; len++) { + code = (code + blCount[len - 1]) << 1; + nextCode[len] = code; + } + const bits = maxLen < ROOT ? maxLen : ROOT; + const size = 1 << bits; + const mask = size - 1; + // Only a code longer than the root needs a first pass: the longest code + // under each root slot sizes that slot's sub-table, and the sub-tables + // are laid out after the root. One Uint16 holds both facts per slot — + // (start << 2) | width — because a typed array is not free to allocate + // and this runs three times per dynamic block: a width is at most + // MAX_BITS - ROOT = 3 (two bits), a start at most 4096 + 286 × 8 (the + // root, then a sub-table of at most eight entries per long code — + // fourteen). A block whose codes all fit the root — most messages — + // takes exactly the one pass it always did. + let total = 0; + let slots = null; + if (maxLen > bits) { + slots = new Uint16Array(size); + for (let sym = 0; sym < count; sym++) { + const len = lengths[sym]; + if (len === 0) continue; + const c = nextCode[len]++; + if (len <= bits) continue; + // The root slot is the code's first `bits` bits, reversed. + let t = c >> (len - bits); + let slot = 0; + for (let i = 0; i < bits; i++) { + slot = (slot << 1) | (t & 1); + t >>= 1; + } + const width = len - bits; + if (slots[slot] < width) slots[slot] = width; + } + for (let slot = 0; slot < size; slot++) { + const width = slots[slot]; + if (width === 0) continue; + slots[slot] = ((size + total) << 2) | width; + total += 1 << width; + } + code = 0; + for (let len = 1; len <= MAX_BITS; len++) { + code = (code + blCount[len - 1]) << 1; + nextCode[len] = code; + } + } + const table = new Uint32Array(size + total); + for (let sym = 0; sym < count; sym++) { + const len = lengths[sym]; + if (len === 0) continue; + let c = nextCode[len]++; + let reversed = 0; + for (let i = 0; i < len; i++) { + reversed = (reversed << 1) | (c & 1); + c >>= 1; + } + if (len <= bits) { + const entry = (len << 16) | sym; + const step = 1 << len; + for (let j = reversed; j < size; j += step) table[j] = entry; + continue; + } + // A prefix-free code puts no short code's fill on a long code's slot, + // so the pointer written here is never overwritten. + const packed = slots[reversed & mask]; + const width = packed & 3; + const start = packed >>> 2; + table[reversed & mask] = ((POINTER + width) << 16) | start; + const rest = len - bits; + const entry = (rest << 16) | sym; + const step = 1 << rest; + const end = 1 << width; + for (let j = reversed >>> bits; j < end; j += step) table[start + j] = entry; + } + return { table, bits }; +}; + +// The fixed trees (RFC 1951 3.2.6), built once. +const FIXED_LIT = (() => { + const lengths = new Uint8Array(288); + let i = 0; + for (; i < 144; i++) lengths[i] = 8; + for (; i < 256; i++) lengths[i] = 9; + for (; i < 280; i++) lengths[i] = 7; + for (; i < 288; i++) lengths[i] = 8; + return buildTable(lengths, 288); +})(); +const FIXED_DIST = (() => { + const lengths = new Uint8Array(30); + lengths.fill(5); + return buildTable(lengths, 30); +})(); + +class Inflater { + #input; + #pos = 0; + #bitBuf = 0; + #bitCnt = 0; + #out; + #outLen; + #start; // where the message begins in #out (after the dictionary) + #limit; // #start + maxOutput + + constructor(input, dictionary, maxOutput) { + this.#input = input; + const dictLen = dictionary === null ? 0 : Math.min(dictionary.length, WINDOW); + // Sized for the common case — a small message that inflates to a few + // times its input — and doubled as needed, never past the cap. + const guess = Math.min(maxOutput, Math.max(input.length * 4, 256)); + this.#out = new Uint8Array(dictLen + guess); + if (dictLen > 0) this.#out.set(dictionary.subarray(dictionary.length - dictLen), 0); + this.#outLen = dictLen; + this.#start = dictLen; + this.#limit = dictLen + maxOutput; + } + + // ---- bits + + #fill(n) { + const input = this.#input; + while (this.#bitCnt < n && this.#pos < input.length) { + this.#bitBuf |= input[this.#pos++] << this.#bitCnt; + this.#bitCnt += 8; + } + } + + #bits(n) { + if (this.#bitCnt < n) { + this.#fill(n); + if (this.#bitCnt < n) throw new DeflateError('unexpected end of data', 'truncated'); + } + const value = this.#bitBuf & ((1 << n) - 1); + this.#bitBuf >>>= n; + this.#bitCnt -= n; + return value; + } + + // One symbol from a table: peek the root's worth of bits (zero padded at + // the very end of the input), read the entry, consume the code's length + // — which has to fit in what was actually there. What does not fit is a + // pointer to a sub-table, or the end of the input: #slow tells them + // apart, off the path V8 inlines into the block loop. + #symbol(tree) { + const { table, bits } = tree; + if (table === null) throw new DeflateError('symbol from an empty code', 'huffman'); + if (this.#bitCnt < bits) this.#fill(bits); + const entry = table[this.#bitBuf & ((1 << bits) - 1)]; + if (entry === 0) throw new DeflateError('invalid Huffman code', 'huffman'); + const len = entry >>> 16; + if (len > this.#bitCnt) return this.#slow(table, entry, bits); + this.#bitBuf >>>= len; + this.#bitCnt -= len; + return entry & 0xffff; + } + + // A pointer: the long codes under one root slot take `width` more bits, + // read after the root's from the same buffer, into the sub-table the slot + // points at; its entry carries the length past the root's bits. Anything + // else that did not fit is the input ending inside a code. + #slow(table, pointer, bits) { + const width = (pointer >>> 16) - POINTER; + if (width < 0) throw new DeflateError('unexpected end of data', 'truncated'); + if (this.#bitCnt < bits + width) this.#fill(bits + width); + const entry = table[(pointer & 0xffff) + ((this.#bitBuf >>> bits) & ((1 << width) - 1))]; + if (entry === 0) throw new DeflateError('invalid Huffman code', 'huffman'); + const len = bits + (entry >>> 16); + if (len > this.#bitCnt) throw new DeflateError('unexpected end of data', 'truncated'); + this.#bitBuf >>>= len; + this.#bitCnt -= len; + return entry & 0xffff; + } + + // ---- output + + #room(n) { + const needed = this.#outLen + n; + if (needed > this.#limit) throw new DeflateError('inflated message exceeds the cap', 'too-large'); + if (needed <= this.#out.length) return; + let size = this.#out.length * 2; + while (size < needed) size *= 2; + if (size > this.#limit) size = this.#limit; + const grown = new Uint8Array(size); + grown.set(this.#out.subarray(0, this.#outLen)); + this.#out = grown; + } + + // ---- blocks + + #stored() { + // Drop the rest of the current byte, then LEN / NLEN as whole bytes. + const drop = this.#bitCnt & 7; + this.#bitBuf >>>= drop; + this.#bitCnt -= drop; + const len = this.#bits(16); + const nlen = this.#bits(16); + if ((len ^ 0xffff) !== nlen) throw new DeflateError('stored block length mismatch', 'stored'); + // Whatever whole bytes the bit buffer still holds come first. + this.#room(len); + let remaining = len; + while (remaining > 0 && this.#bitCnt >= 8) { + this.#out[this.#outLen++] = this.#bitBuf & 0xff; + this.#bitBuf >>>= 8; + this.#bitCnt -= 8; + remaining--; + } + if (this.#pos + remaining > this.#input.length) throw new DeflateError('unexpected end of data', 'truncated'); + this.#out.set(this.#input.subarray(this.#pos, this.#pos + remaining), this.#outLen); + this.#pos += remaining; + this.#outLen += remaining; + } + + #dynamicTrees() { + const hlit = this.#bits(5) + 257; + const hdist = this.#bits(5) + 1; + const hclen = this.#bits(4) + 4; + if (hlit > 286 || hdist > 30) throw new DeflateError('too many codes', 'huffman'); + const clLengths = new Uint8Array(19); + for (let i = 0; i < hclen; i++) clLengths[CODE_LENGTH_ORDER[i]] = this.#bits(3); + const clTree = buildTable(clLengths, 19); + const lengths = new Uint8Array(hlit + hdist); + let i = 0; + while (i < hlit + hdist) { + const sym = this.#symbol(clTree); + if (sym < 16) { + lengths[i++] = sym; + continue; + } + let repeat; + let value = 0; + if (sym === 16) { + if (i === 0) throw new DeflateError('repeat with no previous length', 'huffman'); + value = lengths[i - 1]; + repeat = 3 + this.#bits(2); + } else if (sym === 17) { + repeat = 3 + this.#bits(3); + } else { + repeat = 11 + this.#bits(7); + } + if (i + repeat > hlit + hdist) throw new DeflateError('too many code lengths', 'huffman'); + while (repeat-- > 0) lengths[i++] = value; + } + if (lengths[256] === 0) throw new DeflateError('no end-of-block code', 'huffman'); + const lit = buildTable(lengths.subarray(0, hlit), hlit); + const dist = buildTable(lengths.subarray(hlit), hdist); + return { lit, dist }; + } + + #codes(lit, dist) { + for (;;) { + const sym = this.#symbol(lit); + if (sym < 256) { + this.#room(1); + this.#out[this.#outLen++] = sym; + continue; + } + if (sym === 256) return; + const li = sym - 257; + if (li >= 29) throw new DeflateError('invalid length code', 'huffman'); + const length = LENGTH_BASE[li] + this.#bits(LENGTH_EXTRA[li]); + const di = this.#symbol(dist); + if (di >= 30) throw new DeflateError('invalid distance code', 'huffman'); + const distance = DIST_BASE[di] + this.#bits(DIST_EXTRA[di]); + if (distance > this.#outLen) throw new DeflateError('distance too far back', 'distance'); + this.#room(length); + const out = this.#out; + let from = this.#outLen - distance; + let to = this.#outLen; + // Overlapping copies are the point (a run), so byte by byte. + for (let n = 0; n < length; n++) out[to++] = out[from++]; + this.#outLen = to; + } + } + + run() { + let final; + do { + final = this.#bits(1); + const type = this.#bits(2); + if (type === 0) this.#stored(); + else if (type === 1) this.#codes(FIXED_LIT, FIXED_DIST); + else if (type === 2) { + const { lit, dist } = this.#dynamicTrees(); + this.#codes(lit, dist); + } else throw new DeflateError('reserved block type', 'block'); + } while (!final); + return this.#out.subarray(this.#start, this.#outLen); + } +} + +// Bytes in any of the three spellings, or a TypeError naming the caller — +// the one copy the directory shares. +const toBytes = (input, label = 'inflate') => { + if (input instanceof Uint8Array) return input; + if (input instanceof ArrayBuffer) return new Uint8Array(input); + if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength); + throw new TypeError(`${label}: expected bytes`); +}; + +/** + * Raw DEFLATE → bytes, synchronously. `dictionary` preloads the window (its + * last 32 KiB); `maxOutput` is the inflated-size cap — past it a + * DeflateError('too-large') rather than a bigger allocation. Every + * malformed input is a DeflateError with a code, never a wrong answer. + */ +const inflateRaw = (input, { dictionary = null, maxOutput = Infinity } = {}) => + new Inflater(toBytes(input), dictionary === null ? null : toBytes(dictionary), maxOutput).run(); + +module.exports = { + inflateRaw, + DeflateError, + buildTable, + toBytes, + LENGTH_BASE, + LENGTH_EXTRA, + DIST_BASE, + DIST_EXTRA, + WINDOW, +}; diff --git a/src/encryption/aead.browser.js b/src/encryption/aead.browser.js new file mode 100644 index 0000000..aee0232 --- /dev/null +++ b/src/encryption/aead.browser.js @@ -0,0 +1,69 @@ +'use strict'; + +// The platform's AEADs, browser half: crypto.subtle, which is all a page +// has — asynchronous, and AES-GCM only ('chacha20-poly1305' is a WICG +// proposal no browser ships; a page that wants it injects a Cipher). A key +// is imported once into a NON-EXTRACTABLE CryptoKey: script that runs later +// in the page can use it, never read it. +// +// `subtle` is a parameter, as in src/webrtc/assertions.js: the default is +// the platform's, and a page served over plain http has none — which is +// refused where the cipher is built, not at the first message. + +const { OpenError } = require('./contracts.js'); +const { requireSubtle } = require('./bytes.js'); + +const DEFAULT_ID = 'aes-256-gcm'; +const KEY_LENGTH = 32; +const NONCE_LENGTH = 12; +const TAG_LENGTH = 16; + +const ALGORITHMS = Object.freeze([DEFAULT_ID, 'chacha20-poly1305']); + +const EMPTY = new Uint8Array(0); + +const params = (nonce, aad) => { + if (nonce.length !== NONCE_LENGTH) throw new TypeError(`encryption: a nonce is ${NONCE_LENGTH} bytes`); + return { name: 'AES-GCM', iv: nonce, additionalData: aad ?? EMPTY, tagLength: TAG_LENGTH * 8 }; +}; + +const asBytes = (buffer) => new Uint8Array(buffer); + +const refuse = () => { + throw new OpenError(); +}; + +const cipherKey = async (subtle, raw) => { + if (!(raw instanceof Uint8Array) || raw.length !== KEY_LENGTH) { + throw new TypeError(`encryption: a key is ${KEY_LENGTH} bytes`); + } + const key = await subtle.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt']); + return { + seal: async (nonce, plaintext, aad) => asBytes(await subtle.encrypt(params(nonce, aad), key, plaintext)), + open: async (nonce, sealed, aad) => { + const algorithm = params(nonce, aad); + if (sealed.length < TAG_LENGTH) refuse(); + return subtle.decrypt(algorithm, key, sealed).then(asBytes, refuse); + }, + }; +}; + +const aead = ({ algorithm = DEFAULT_ID, optional = false, subtle = globalThis.crypto?.subtle } = {}) => { + if (!ALGORITHMS.includes(algorithm)) { + throw new TypeError(`encryption: unknown cipher ${JSON.stringify(algorithm)} — ${ALGORITHMS.join(' or ')}`); + } + if (algorithm !== DEFAULT_ID) { + if (optional) return null; + throw new TypeError(`encryption: ${algorithm} is not in this platform's WebCrypto — inject a Cipher`); + } + requireSubtle(subtle, 'encryption'); + return Object.freeze({ + id: algorithm, + keyLength: KEY_LENGTH, + nonceLength: NONCE_LENGTH, + tagLength: TAG_LENGTH, + key: (raw) => cipherKey(subtle, raw), + }); +}; + +module.exports = { aead, ALGORITHMS, DEFAULT_ID, KEY_LENGTH, NONCE_LENGTH, TAG_LENGTH }; diff --git a/src/encryption/aead.js b/src/encryption/aead.js new file mode 100644 index 0000000..61b08b6 --- /dev/null +++ b/src/encryption/aead.js @@ -0,0 +1,95 @@ +'use strict'; + +// The platform's AEADs, Node half: node:crypto, synchronously. A seal of +// 1 KB is 2.4 µs here against 14 µs through crypto.subtle on the same +// machine (bench/encryption.js) — subtle pays a threadpool hand-off per +// call, the argument bench/zlib-async.js makes for zlib — and the Node↔Node +// carriers (the backplane, the brokers) have no ordering queue to put a +// promise in. The browser half +// (aead.browser.js, swapped through package.json#browser) has +// crypto.subtle only, and so AES-GCM only. +// +// Ids are the cipher names both platforms' documentation uses. +// 'aes-256-gcm' is what `true` means wherever a cipher is chosen: the one +// AEAD every browser has, hardware-accelerated on anything recent. +// 'chacha20-poly1305' is Node-only — constant-time without AES +// instructions, the better choice between two Node processes on hardware +// that lacks them. + +const { createCipheriv, createDecipheriv, createSecretKey } = require('node:crypto'); +const { OpenError } = require('./contracts.js'); + +const DEFAULT_ID = 'aes-256-gcm'; +const KEY_LENGTH = 32; +const NONCE_LENGTH = 12; +const TAG_LENGTH = 16; +const TAG_OPTIONS = { authTagLength: TAG_LENGTH }; + +const ALGORITHMS = Object.freeze([DEFAULT_ID, 'chacha20-poly1305']); + +const checkKey = (raw) => { + if (!(raw instanceof Uint8Array) || raw.length !== KEY_LENGTH) { + throw new TypeError(`encryption: a key is ${KEY_LENGTH} bytes`); + } +}; + +const checkNonce = (nonce) => { + // GCM takes an IV of any length without complaint, and two ends that + // disagree on it would simply never open each other's messages. + if (nonce.length !== NONCE_LENGTH) throw new TypeError(`encryption: a nonce is ${NONCE_LENGTH} bytes`); +}; + +const cipherKey = (algorithm, raw) => { + checkKey(raw); + // A KeyObject is no faster than the raw bytes (bench/encryption.js); it + // is a COPY, so the caller may wipe what it passed in. + const key = createSecretKey(raw); + return { + seal(nonce, plaintext, aad) { + checkNonce(nonce); + const cipher = createCipheriv(algorithm, key, nonce, TAG_OPTIONS); + if (aad !== undefined && aad !== null) cipher.setAAD(aad); + return Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]); + }, + open(nonce, sealed, aad) { + checkNonce(nonce); + if (sealed.length < TAG_LENGTH) throw new OpenError(); + const split = sealed.length - TAG_LENGTH; + const decipher = createDecipheriv(algorithm, key, nonce, TAG_OPTIONS); + try { + decipher.setAuthTag(sealed.subarray(split)); + if (aad !== undefined && aad !== null) decipher.setAAD(aad); + // Both are stream modes: update() answers every byte and final() + // only verifies, so the body is returned as it came rather than + // copied through Buffer.concat — 4.7 µs against 5.9 on 16 KB + // (bench/encryption.js, the open rows). + const body = decipher.update(sealed.subarray(0, split)); + decipher.final(); + return body; + } catch { + throw new OpenError(); + } + }, + }; +}; + +/** + * A platform AEAD by name. An unknown name is a TypeError; one this + * platform lacks answers `null` under `optional` (a preference list moves + * on) and throws otherwise — Node has both, so that only happens in the + * browser half. + */ +const aead = ({ algorithm = DEFAULT_ID } = {}) => { + if (!ALGORITHMS.includes(algorithm)) { + throw new TypeError(`encryption: unknown cipher ${JSON.stringify(algorithm)} — ${ALGORITHMS.join(' or ')}`); + } + return Object.freeze({ + id: algorithm, + keyLength: KEY_LENGTH, + nonceLength: NONCE_LENGTH, + tagLength: TAG_LENGTH, + key: (raw) => cipherKey(algorithm, raw), + }); +}; + +module.exports = { aead, ALGORITHMS, DEFAULT_ID, KEY_LENGTH, NONCE_LENGTH, TAG_LENGTH }; diff --git a/src/encryption/browser.js b/src/encryption/browser.js new file mode 100644 index 0000000..e9c423c --- /dev/null +++ b/src/encryption/browser.js @@ -0,0 +1,55 @@ +'use strict'; + +// @alexify/wrpc/encryption, the half a browser gets — and the whole of the +// primitives: every file required here runs on both platforms (aead.js is +// swapped for aead.browser.js through package.json#browser). index.js adds +// what only a Node process does. +// +// Opt-in like every knob in wrpc, and never a substitute for TLS: `wss://` +// first. This is for where TLS ends before the data does — a backplane, a +// broker's log, a TLS-terminating proxy, a relay that should not read what +// it relays. See docs/guide/encryption.md. + +const { aead, ALGORITHMS } = require('./aead.js'); +const { EncryptionRefusedError } = require('./http.js'); +const { x25519 } = require('./dh.js'); +const { createKdf } = require('./hkdf.js'); +const { normalizeKeys, generateKey, isKid } = require('./keyring.js'); +const { OpenError, isCipher, isCipherKey, isDh, isKeyProvider } = require('./contracts.js'); +const { toBase64Url, fromBase64, equal } = require('./bytes.js'); +const { createEncryption, isEncryption, fetchServerKey } = require('./client.js'); +const { createHpke, dhKem, isKem } = require('./hpke.js'); +const { createIdentity, createSealer, createOpener } = require('./e2ee.js'); +const { createNoise, PATTERN_NAMES } = require('./noise.js'); +const { parseBundle } = require('./statics.js'); + +module.exports = { + createIdentity, + createSealer, + createOpener, + createEncryption, + isEncryption, + fetchServerKey, + createHpke, + dhKem, + isKem, + createNoise, + PATTERN_NAMES, + parseBundle, + aead, + ALGORITHMS, + x25519, + createKdf, + normalizeKeys, + generateKey, + isKid, + OpenError, + EncryptionRefusedError, + isCipher, + isCipherKey, + isDh, + isKeyProvider, + toBase64Url, + fromBase64, + equal, +}; diff --git a/src/encryption/bytes.js b/src/encryption/bytes.js new file mode 100644 index 0000000..b0b43a0 --- /dev/null +++ b/src/encryption/bytes.js @@ -0,0 +1,142 @@ +'use strict'; + +// Byte plumbing for @alexify/wrpc/encryption — browser-safe and require-free +// (no Buffer: this file is bundled for a page). Everything here is a cold +// path: key parsing, handshake assembly, labels. The per-message paths build +// their frames by hand where they live. + +const encoder = new TextEncoder(); + +const utf8 = (text) => encoder.encode(text); + +const isBytes = (value) => value instanceof Uint8Array; + +/** One array out of several; `concat()` is the empty array. */ +const concat = (...parts) => { + let total = 0; + for (let i = 0; i < parts.length; i++) total += parts[i].length; + const out = new Uint8Array(total); + let offset = 0; + for (let i = 0; i < parts.length; i++) { + out.set(parts[i], offset); + offset += parts[i].length; + } + return out; +}; + +/** + * Constant-time equality: the XOR of every pair is folded, so the time + * taken says nothing about WHERE two values differ. A length mismatch + * answers at once — lengths are public here (a tag, a key, a digest). + */ +const equal = (a, b) => { + if (a.length !== b.length) return false; + let folded = 0; + for (let i = 0; i < a.length; i++) folded |= a[i] ^ b[i]; + return folded === 0; +}; + +const isZero = (bytes) => { + let folded = 0; + for (let i = 0; i < bytes.length; i++) folded |= bytes[i]; + return folded === 0; +}; + +// base64url without Buffer — the spelling of src/webrtc/assertions.js: +// btoa/atob exist in every browser and in Node, the values are a key or a +// handshake message, and never `String.fromCharCode(...bytes)` (apply in +// disguise). +const toBase64Url = (bytes) => { + let binary = ''; + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +}; + +const BASE64 = /^[A-Za-z0-9+/_-]*={0,2}$/; + +/** base64 or base64url, padded or not → bytes; null when it is neither. */ +const fromBase64 = (text) => { + if (typeof text !== 'string' || !BASE64.test(text)) return null; + const bare = text.replace(/=+$/, ''); + if (bare.length % 4 === 1) return null; + let binary; + try { + binary = atob(bare.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (bare.length % 4)) % 4)); + } catch { + return null; + } + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +}; + +const HEX = /^(?:[0-9a-fA-F]{2})*$/; + +const fromHex = (text) => { + if (typeof text !== 'string' || !HEX.test(text)) return null; + const bytes = new Uint8Array(text.length / 2); + for (let i = 0; i < bytes.length; i++) bytes[i] = Number.parseInt(text.slice(i * 2, i * 2 + 2), 16); + return bytes; +}; + +const toHex = (bytes) => { + let text = ''; + for (let i = 0; i < bytes.length; i++) text += bytes[i].toString(16).padStart(2, '0'); + return text; +}; + +/** + * A 96-bit AEAD nonce from a message counter: four zero bytes, then the + * counter as 64 bits big-endian — the layout Noise gives AESGCM, used for + * every counter nonce in this package. A counter is a Number, exact to + * 2^53; every caller rekeys or reseeds long before that. + */ +const counterNonce = (counter, out = new Uint8Array(12)) => { + const high = Math.floor(counter / 0x100000000); + const low = counter >>> 0; + out[0] = out[1] = out[2] = out[3] = 0; + out[4] = high >>> 24; + out[5] = (high >>> 16) & 0xff; + out[6] = (high >>> 8) & 0xff; + out[7] = high & 0xff; + out[8] = low >>> 24; + out[9] = (low >>> 16) & 0xff; + out[10] = (low >>> 8) & 0xff; + out[11] = low & 0xff; + return out; +}; + +/** + * The platform's CSPRNG, or a refusal. `Math.random` is never a fallback + * here (src/runtime/browser.js has one, for correlation ids): a key or a + * salt from it would be a vulnerability that looks like a feature. + */ +const randomSource = (crypto = globalThis.crypto) => { + if (!crypto || typeof crypto.getRandomValues !== 'function') { + throw new TypeError('encryption: crypto.getRandomValues is required (a secure context in a browser)'); + } + return (length) => crypto.getRandomValues(new Uint8Array(length)); +}; + +/** `crypto.subtle`, or the refusal every WebCrypto-backed factory shares. */ +const requireSubtle = (subtle, name) => { + if (!subtle || typeof subtle.importKey !== 'function') { + throw new TypeError(`${name}: WebCrypto (crypto.subtle) is required — a secure context in a browser`); + } + return subtle; +}; + +module.exports = { + utf8, + isBytes, + concat, + equal, + isZero, + toBase64Url, + fromBase64, + fromHex, + toHex, + counterNonce, + randomSource, + requireSubtle, +}; diff --git a/src/encryption/client.js b/src/encryption/client.js new file mode 100644 index 0000000..5ffca1e --- /dev/null +++ b/src/encryption/client.js @@ -0,0 +1,265 @@ +'use strict'; + +// The client half of session encryption: `createEncryption(options)` builds +// the object a client is handed as its `encryption` option — +// +// const client = await connect(url, { encryption: createEncryption({ serverKey }) }); +// +// — the way @alexify/wrpc/deflate builds a codec: the base entry holds a +// seam of a few lines (src/client/transports.js), and only a page that +// encrypts carries the handshake. Browser-safe; every primitive is the +// platform's or injected. +// +// A client that was given this NEVER falls back to plaintext: a server that +// does not speak it, a handshake that fails, a transport that cannot carry +// it — each is a failed connection. That strictness is the anchor of the +// whole feature: the `wrpc_e` flag and key discovery travel in the clear +// and can be tampered with, but only into a refusal. + +const { aead } = require('./aead.js'); +const { x25519 } = require('./dh.js'); +const { createKdf } = require('./hkdf.js'); +const { isCipher, isDh } = require('./contracts.js'); +const { createNoise, PATTERN_NAMES } = require('./noise.js'); +const { parseBundle } = require('./statics.js'); +const { createHpke, dhKem, AEAD_IDS } = require('./hpke.js'); +const { sealedFetch } = require('./http.js'); +const { equal } = require('./bytes.js'); +const { Sequencer } = require('../sequencer.js'); +const { ENCRYPTION_PARAM } = require('../wire.js'); +const { + SecureChannel, + helloHeader, + prologueOf, + frame, + isFrame, + asBytes, + FRAME_HANDSHAKE, + DEFAULT_REKEY_AFTER, + DEFAULT_HANDSHAKE_TIMEOUT, +} = require('./session.js'); + +const resolveCipher = (value) => { + if (value === undefined || value === null) return aead(); + if (typeof value === 'string') return aead({ algorithm: value }); + if (!isCipher(value)) throw new TypeError('createEncryption: cipher must be a cipher name or a Cipher'); + return value; +}; + +const keyBytes = (value, name) => { + if (!(value instanceof Uint8Array) || value.length !== 32) { + throw new TypeError(`createEncryption: ${name} must be 32 bytes`); + } + return value; +}; + +const createEncryption = (options = {}) => { + const { + serverKey = null, + verifyServer = null, + staticKey = null, + psk = null, + rekeyAfter = DEFAULT_REKEY_AFTER, + handshakeTimeout = DEFAULT_HANDSHAKE_TIMEOUT, + } = options; + const pinned = serverKey === null ? null : parseBundle(serverKey, 'createEncryption: serverKey'); + const pattern = options.pattern ?? (pinned === null ? null : 'NK'); + if (!PATTERN_NAMES.includes(pattern)) { + // NN authenticates nobody, so it is never what a missing option means. + throw new TypeError(`createEncryption: a serverKey, or an explicit pattern — ${PATTERN_NAMES.join(', ')}`); + } + if (pattern === 'NK' && pinned === null) throw new TypeError('createEncryption: NK needs the serverKey it pins'); + if (pattern === 'XX') { + keyBytes(staticKey, 'staticKey (XX)'); + if (pinned === null && typeof verifyServer !== 'function') { + // Trust on first use is a decision, so it is spelled: verifyServer. + throw new TypeError('createEncryption: XX needs a serverKey to pin, or verifyServer(publicKey)'); + } + } + if (pattern === 'NNpsk0') keyBytes(psk, 'psk (NNpsk0)'); + if (!(Number.isInteger(rekeyAfter) && rekeyAfter >= 0)) { + throw new TypeError('createEncryption: rekeyAfter must be a non-negative integer'); + } + const dh = options.dh ?? x25519(); + if (!isDh(dh)) throw new TypeError('createEncryption: dh must be a Dh'); + const cipher = resolveCipher(options.cipher); + const kdf = createKdf(); + const noise = createNoise({ pattern, dh, cipher, kdf }); + // The per-request half, for the transports that have no connection to + // hold a session (http): HPKE to the pinned server key — which is why it + // exists only with a `serverKey`, and only for a cipher HPKE registers. + const fetch = + pinned === null || AEAD_IDS[cipher.id] === undefined + ? null + : sealedFetch({ hpke: createHpke({ kem: dhKem(dh, kdf), kdf, cipher }), kdf, cipher, serverKey: pinned }); + const kid = pinned === null ? '' : pinned.kid; + let identity = null; + + /** + * One connection. `link` is the transport's side of the seam: + * `{ kind, write(bytes), deliver(message), fail(error) }`. Answers + * `{ ready, send(data), receive(data) }` — `ready` resolves with what the + * application may read as `client.encryption`. + */ + const secure = (link) => { + let handshake = null; + let channel = null; + let failed = false; + const fail = (error) => { + if (failed) return; + failed = true; + clearTimeout(timer); + link.fail(error); + }; + const timer = setTimeout(() => abort(new Error('encryption: handshake timed out')), handshakeTimeout); + timer.unref?.(); + const outbound = new Sequencer(fail); + const inbound = new Sequencer(fail); + let resolveReady; + let rejectReady; + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + const abort = (error) => { + rejectReady(error); + fail(error); + }; + + // Who answered, checked BEFORE anything more is written: in XX the next + // message carries this client's own static key, and it is encrypted to + // whoever the responder is — an impostor must not get to read it. + const verifyPeer = async () => { + if (pinned !== null && !equal(handshake.remoteStatic ?? pinned.noise, pinned.noise)) { + throw new Error('encryption: the server key is not the pinned one'); + } + if (pinned === null && pattern === 'XX' && (await verifyServer(handshake.remoteStatic)) !== true) { + throw new Error('encryption: the server key was not accepted'); + } + }; + + const establish = async () => { + const done = await handshake.finish(); + channel = new SecureChannel(done); + clearTimeout(timer); + resolveReady( + Object.freeze({ + protocol: noise.name, + pattern, + cipher: noise.cipher, + kid, + remoteStatic: done.remoteStatic, + handshakeHash: done.handshakeHash, + }), + ); + }; + + const begin = async () => { + if (pattern === 'XX') identity ??= await dh.keyPair(staticKey); + const header = helloHeader(noise.name, kid); + handshake = await noise.initiator({ + prologue: prologueOf(link.kind, header), + remoteStatic: pattern === 'NK' ? pinned.noise : undefined, + staticKey: identity ?? undefined, + psk: psk ?? undefined, + rekeyAfter, + }); + link.write(frame(FRAME_HANDSHAKE, header, await handshake.write())); + }; + + // Nothing reaches the application after a frame that did not open. The + // frames behind it are already in the inbound queue when the cipher is + // asynchronous — subtle resolves them after the one that failed — and + // the queue delivers in order: without this gate they would land on + // the application AFTER the session was declared over. + const deliver = (message) => { + if (!failed) link.deliver(message); + }; + + const open = (bytes) => { + try { + inbound.push(channel.open(bytes), deliver, fail); + } catch (error) { + fail(error); + } + }; + + // One inbound frame while the handshake is still running. The steps are + // asynchronous and the server does not wait for them: it is established + // the moment it wrote its last message, and its first sealed frames can + // arrive while this side is still finishing — so frames are taken in + // order through `chain` until it has drained, and only then directly. + const step = async (bytes) => { + if (failed) return; + if (channel !== null) return void open(bytes); + if (!isFrame(bytes, FRAME_HANDSHAKE)) throw new Error('encryption: unexpected message during the handshake'); + await handshake.read(bytes.subarray(2)); + await verifyPeer(); + if (!handshake.done) link.write(frame(FRAME_HANDSHAKE, await handshake.write())); + await establish(); + }; + + let queued = 0; + let chain = begin(); + chain.catch(abort); + + return { + ready, + send(data) { + if (failed) return; + if (channel === null) throw new Error('encryption: not established'); + outbound.push(channel.seal(data), link.write, fail); + }, + /** + * The transport is gone — a close, a terminate: nothing more is + * written or delivered, and a pending `ready` rejects NOW rather + * than at the handshake timeout. Without `link.fail`: what is closed + * is not closed again. + */ + cancel(error) { + if (failed) return; + failed = true; + clearTimeout(timer); + rejectReady(error ?? new Error('Connection closed')); + }, + receive(data) { + if (failed) return; + // Text is plaintext: before the handshake it is a server that does + // not speak this, after it an injection. Either way, the end. + if (typeof data === 'string') return void abort(new Error('encryption: plaintext on an encrypted connection')); + const bytes = asBytes(data); + if (channel !== null && queued === 0) return void open(bytes); + queued++; + chain = chain.then(() => step(bytes)).then(() => void queued--); + chain.catch(abort); + }, + }; + }; + + // `rekeyAfter` is readable so a deployment can check it against the + // server's: the interval is not negotiated, and a mismatch is a decrypt + // failure at the first rekey, exactly that many messages in. + return Object.freeze({ param: ENCRYPTION_PARAM, protocol: noise.name, pattern, rekeyAfter, secure, fetch }); +}; + +/** + * The server's key bundle from its discovery endpoint — `GET + * /encryption-key`. TRUST ON FIRST USE: the answer is only as + * trustworthy as the connection it came over, so a client that can be + * shipped the bundle (a Node service, a mobile app, a build-time constant) + * should be, and this is for the ones that cannot. + */ +const fetchServerKey = async (url, { fetch = globalThis.fetch } = {}) => { + const base = String(url).replace(/^ws/, 'http').replace(/\/+$/, ''); + const response = await fetch(`${base}/encryption-key`); + if (!response.ok) throw new Error(`encryption: no key at ${base}/encryption-key (${response.status})`); + const { key } = await response.json(); + parseBundle(key, 'the discovered key'); + return key; +}; + +/** What the base entry checks before it trusts an `encryption` option. */ +const isEncryption = (value) => + typeof value === 'object' && value !== null && typeof value.secure === 'function' && typeof value.param === 'string'; + +module.exports = { createEncryption, isEncryption, fetchServerKey }; diff --git a/src/encryption/contracts.js b/src/encryption/contracts.js new file mode 100644 index 0000000..de27d56 --- /dev/null +++ b/src/encryption/contracts.js @@ -0,0 +1,75 @@ +'use strict'; + +// The structural seams of @alexify/wrpc/encryption. Like a Compressor or a +// Backplane, a primitive is whatever answers the right methods: the +// platform ones of this directory, or an injected one — XChaCha20 from +// @noble/ciphers, AES-GCM-SIV, AEGIS, a hardware module; ML-KEM or a hybrid +// behind the key-agreement shape. wrpc imports none of them. +// +// A Cipher is an AEAD: +// { id, keyLength, nonceLength, tagLength, key(raw) -> CipherKey | Promise } +// and a CipherKey is one key, ready to use: +// { seal(nonce, plaintext, aad) -> bytes | Promise // ciphertext ‖ tag +// open(nonce, sealed, aad) -> bytes | Promise } // throws / rejects +// `key()` exists because of the browser: crypto.subtle imports a key ONCE +// into a non-extractable CryptoKey (a promise), and a contract of +// `seal(rawKey, …)` would pay that import per message — half as much again +// (bench/encryption.js). On Node the prepared key saves nothing measurable; +// it is the same shape so one caller serves both. From key() on, `raw` +// belongs to the cipher: wrpc neither reuses nor wipes it, so a cipher may +// keep the reference (the guide's closure form) or copy it, as it likes. +// Either method may answer a promise; a carrier with no ordering queue +// refuses a cipher that does. +// +// A Dh is a Diffie-Hellman function in the shape Noise and HPKE's DHKEM +// consume, keys as bytes on the wire and opaque in memory: +// { id, publicLength, +// generateKeyPair() -> Promise<{ publicKey: bytes, privateKey }> +// keyPair(seed) -> Promise<{ publicKey: bytes, privateKey }> +// dh(privateKey, public) -> Promise } // rejects all-zero +// +// A key provider is the keyring's injected form — a KMS or Vault client +// that unwrapped its data keys at boot and refreshes them on its own clock: +// { current() -> kid, get(kid) -> 32 bytes | null } +// Both are SYNCHRONOUS: they are read where an envelope is opened, and a +// backplane's receive path cannot wait. + +// The one failure `open` has: a wrong key, a wrong nonce, a flipped bit and +// a truncated message are indistinguishable on purpose — whoever sent the +// bytes learns nothing from which it was. Callers answer it uniformly too. +class OpenError extends Error { + constructor() { + super('encryption: the message does not open'); + this.name = 'OpenError'; + this.code = 'open'; + } +} + +const isObject = (value) => typeof value === 'object' && value !== null; + +const isLength = (value) => Number.isInteger(value) && value > 0; + +const isCipher = (value) => + isObject(value) && + typeof value.id === 'string' && + value.id.length > 0 && + isLength(value.keyLength) && + isLength(value.nonceLength) && + isLength(value.tagLength) && + typeof value.key === 'function'; + +const isCipherKey = (value) => isObject(value) && typeof value.seal === 'function' && typeof value.open === 'function'; + +const isDh = (value) => + isObject(value) && + typeof value.id === 'string' && + value.id.length > 0 && + isLength(value.publicLength) && + typeof value.generateKeyPair === 'function' && + typeof value.keyPair === 'function' && + typeof value.dh === 'function'; + +const isKeyProvider = (value) => + isObject(value) && typeof value.current === 'function' && typeof value.get === 'function'; + +module.exports = { OpenError, isCipher, isCipherKey, isDh, isKeyProvider }; diff --git a/src/encryption/dh.browser.js b/src/encryption/dh.browser.js new file mode 100644 index 0000000..07ba0c1 --- /dev/null +++ b/src/encryption/dh.browser.js @@ -0,0 +1,77 @@ +'use strict'; + +// X25519 (RFC 7748), browser half: crypto.subtle, in the shape Noise and +// HPKE's DHKEM consume — Chrome 133, Firefox 130 and Safari 17 have it. The +// id is the name Noise gives the function. The Node half (dh.js, swapped +// through package.json#browser) is node:crypto: the same algorithm through +// subtle prints an ExperimentalWarning on the early Node 22 releases that +// `engines` still admits. +// +// Private keys stay opaque: a generated one is a non-extractable CryptoKey +// whose bytes never exist in script; one derived from a seed is imported +// and the PKCS#8 scratch copy wiped. Public keys are the 32 bytes that +// travel. + +const { requireSubtle, isZero } = require('./bytes.js'); + +const ALGORITHM = { name: 'X25519' }; +const LENGTH = 32; + +// RFC 8410: the PKCS#8 wrapping of a raw X25519 private key is this prefix +// and the 32 bytes — the only form crypto.subtle imports one in. +const PKCS8_PREFIX = Uint8Array.of( + ...[0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x04, 0x22, 0x04, 0x20], +); + +// The public key IS the function applied to the base point (RFC 7748 §6.1), +// which is how one is had from a seed without ever exporting the private key. +const BASE_POINT = new Uint8Array(LENGTH); +BASE_POINT[0] = 9; + +const x25519 = ({ subtle = globalThis.crypto?.subtle } = {}) => { + requireSubtle(subtle, 'encryption'); + + const dh = async (privateKey, publicKey) => { + if (!(publicKey instanceof Uint8Array) || publicKey.length !== LENGTH) { + throw new TypeError(`encryption: an X25519 public key is ${LENGTH} bytes`); + } + // A low-order point makes the output zero whatever the private key is + // (RFC 7748 §6.1) — an attacker's way to force a known secret. Some + // platforms throw for it, some answer the zeros; both end here, as one + // error that says nothing about which. + let shared = null; + try { + const peer = await subtle.importKey('raw', publicKey, ALGORITHM, false, []); + shared = new Uint8Array(await subtle.deriveBits({ name: 'X25519', public: peer }, privateKey, LENGTH * 8)); + } catch { + shared = null; + } + if (shared === null || isZero(shared)) throw new Error('encryption: invalid public key'); + return shared; + }; + + const generateKeyPair = async () => { + const pair = await subtle.generateKey(ALGORITHM, false, ['deriveBits']); + const publicKey = new Uint8Array(await subtle.exportKey('raw', pair.publicKey)); + return { publicKey, privateKey: pair.privateKey }; + }; + + const keyPair = async (seed) => { + if (!(seed instanceof Uint8Array) || seed.length !== LENGTH) { + throw new TypeError(`encryption: an X25519 private key is ${LENGTH} bytes`); + } + const pkcs8 = new Uint8Array(PKCS8_PREFIX.length + LENGTH); + pkcs8.set(PKCS8_PREFIX, 0); + pkcs8.set(seed, PKCS8_PREFIX.length); + try { + const privateKey = await subtle.importKey('pkcs8', pkcs8, ALGORITHM, false, ['deriveBits']); + return { publicKey: await dh(privateKey, BASE_POINT), privateKey }; + } finally { + pkcs8.fill(0); + } + }; + + return Object.freeze({ id: '25519', publicLength: LENGTH, generateKeyPair, keyPair, dh }); +}; + +module.exports = { x25519 }; diff --git a/src/encryption/dh.js b/src/encryption/dh.js new file mode 100644 index 0000000..0614d37 --- /dev/null +++ b/src/encryption/dh.js @@ -0,0 +1,76 @@ +'use strict'; + +// X25519 (RFC 7748), Node half: node:crypto KeyObjects, in the shape Noise +// and HPKE's DHKEM consume. The browser half (dh.browser.js, swapped through +// package.json#browser) is crypto.subtle, which Node has too — but through +// it X25519 prints an ExperimentalWarning on the early Node 22 releases +// `engines` admits (22.10 does, 22.23 does not), and a library does not get +// to write on its host's stderr. The two halves answer the same RFC 7748 +// vectors and each other (tests/encryption/dh.test.js). +// +// The methods answer promises although nothing here waits: one contract for +// both platforms, on a path that runs per handshake, never per message. + +const nodeCrypto = require('node:crypto'); +const { isZero } = require('./bytes.js'); + +const LENGTH = 32; + +// RFC 8410: a raw X25519 key in its DER wrapping is a fixed prefix and the +// 32 bytes — SubjectPublicKeyInfo for a public key, PKCS#8 for a private one. +const SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex'); +const PKCS8_PREFIX = Buffer.from('302e020100300506032b656e04220420', 'hex'); + +const x25519 = ({ crypto = nodeCrypto } = {}) => { + // createPublicKey derives the public half of a PRIVATE KeyObject and + // refuses a public one, so a generated pair's public key is exported as is. + const publicBytes = (key) => { + const publicKey = key.type === 'public' ? key : crypto.createPublicKey(key); + return Uint8Array.from(publicKey.export({ type: 'spki', format: 'der' }).subarray(SPKI_PREFIX.length)); + }; + + const dh = async (privateKey, publicKey) => { + if (!(publicKey instanceof Uint8Array) || publicKey.length !== LENGTH) { + throw new TypeError(`encryption: an X25519 public key is ${LENGTH} bytes`); + } + // A low-order point makes the output zero whatever the private key is + // (RFC 7748 §6.1) — an attacker's way to force a known secret. OpenSSL + // throws for it; an implementation that answered the zeros would end + // here as well, as one error that says nothing about which. + let shared = null; + try { + const peer = crypto.createPublicKey({ + key: Buffer.concat([SPKI_PREFIX, publicKey]), + format: 'der', + type: 'spki', + }); + shared = Uint8Array.from(crypto.diffieHellman({ privateKey, publicKey: peer })); + } catch { + shared = null; + } + if (shared === null || isZero(shared)) throw new Error('encryption: invalid public key'); + return shared; + }; + + const generateKeyPair = async () => { + const { privateKey, publicKey } = crypto.generateKeyPairSync('x25519'); + return { publicKey: publicBytes(publicKey), privateKey }; + }; + + const keyPair = async (seed) => { + if (!(seed instanceof Uint8Array) || seed.length !== LENGTH) { + throw new TypeError(`encryption: an X25519 private key is ${LENGTH} bytes`); + } + const pkcs8 = Buffer.concat([PKCS8_PREFIX, seed]); + try { + const privateKey = crypto.createPrivateKey({ key: pkcs8, format: 'der', type: 'pkcs8' }); + return { publicKey: publicBytes(privateKey), privateKey }; + } finally { + pkcs8.fill(0); + } + }; + + return Object.freeze({ id: '25519', publicLength: LENGTH, generateKeyPair, keyPair, dh }); +}; + +module.exports = { x25519 }; diff --git a/src/encryption/e2ee.js b/src/encryption/e2ee.js new file mode 100644 index 0000000..0bd0e73 --- /dev/null +++ b/src/encryption/e2ee.js @@ -0,0 +1,106 @@ +'use strict'; + +// End-to-end sealing between two CLIENTS, through a server that relays what +// it cannot read: one message, sealed to the recipient's public key with +// HPKE (hpke.js) — and, with a sender identity, in AUTH mode, so that +// "sealed to me" also says by whom. The sealed value is bytes; wrpc carries +// bytes as they are — in a call, an event, a room broadcast, across the +// backplane — so the relay is an ordinary handler that forwards `data`. +// +// sealed = enc (32) ‖ AEAD( plaintext ) +// +// What this is NOT: a messaging protocol. One key pair per identity, no +// forward secrecy on the recipient's side (a stolen identity key opens every +// message ever sealed to it), no group key, no replay memory — an +// application that needs those runs Double Ratchet or MLS over these same +// bytes. And in a browser it protects against a server that READS, not one +// that serves the page a different script: the origin that ships the code +// can ship other code. + +const { aead } = require('./aead.js'); +const { x25519 } = require('./dh.js'); +const { createKdf } = require('./hkdf.js'); +const { createHpke, dhKem } = require('./hpke.js'); +const { utf8, concat, randomSource } = require('./bytes.js'); + +const DEFAULT_INFO = utf8('wrpc e2ee v1'); +const KEY_LENGTH = 32; + +const suite = (options) => { + const kdf = createKdf(); + const kem = dhKem(options.dh ?? x25519(), kdf); + return { kem, hpke: createHpke({ kem, kdf, cipher: options.cipher ?? aead() }) }; +}; + +const asBytes = (data) => (typeof data === 'string' ? utf8(data) : data); + +const infoOf = (info) => (info === undefined || info === null ? DEFAULT_INFO : concat(DEFAULT_INFO, asBytes(info))); + +const checkKey = (value, name) => { + if (!(value instanceof Uint8Array) || value.length !== KEY_LENGTH) { + throw new TypeError(`e2ee: ${name} must be ${KEY_LENGTH} bytes`); + } + return value; +}; + +/** + * An identity: `{ seed, publicKey, keyPair }`. The SEED is the secret — 32 + * bytes to keep wherever this client keeps secrets (and the same identity + * again from the same seed: it exists for that portability; a browser that + * never needs it keeps a non-extractable `x25519().generateKeyPair()` in + * IndexedDB and hands the pair over directly); the public key is what + * others seal to and verify against; `keyPair` is what + * `createSealer`/`createOpener` take. + */ +const createIdentity = async (seed = null, options = {}) => { + const secret = seed === null ? randomSource(options.crypto)(KEY_LENGTH) : Uint8Array.from(checkKey(seed, 'seed')); + const keyPair = await suite(options).kem.keyPair(secret); + return Object.freeze({ seed: secret, publicKey: keyPair.publicKey, keyPair }); +}; + +/** + * `seal(data, aad?) -> Promise` to one recipient. With `senderKey` + * (an identity's `keyPair`) the recipient can check who sealed it. `info` + * says what the messages are FOR — a room, a conversation id — and one + * sealed for one purpose does not open for another. + */ +const createSealer = (options = {}) => { + const recipientPublicKey = checkKey(options.recipientPublicKey, 'recipientPublicKey'); + const { hpke } = suite(options); + const setup = { info: infoOf(options.info), senderKey: options.senderKey ?? null }; + return Object.freeze({ + async seal(data, aad = null) { + // A context per message: nothing to keep in step between two clients + // that may never be online together. + const { enc, context } = await hpke.setupSender(recipientPublicKey, setup); + return concat(enc, await context.seal(aad === null ? null : asBytes(aad), asBytes(data))); + }, + }); +}; + +/** + * `open(sealed, aad?) -> Promise` with this client's identity. With + * `senderPublicKey`, a message that identity did not seal does not open — + * without it, anyone who knows the public key could have sent it. + */ +const createOpener = (options = {}) => { + const { keyPair } = options; + if (!keyPair || !(keyPair.publicKey instanceof Uint8Array)) throw new TypeError('e2ee: keyPair must be an identity'); + const senderPublicKey = + options.senderPublicKey === undefined || options.senderPublicKey === null + ? null + : checkKey(options.senderPublicKey, 'senderPublicKey'); + const { hpke } = suite(options); + const setup = { info: infoOf(options.info), senderPublicKey }; + return Object.freeze({ + async open(sealed, aad = null) { + if (!(sealed instanceof Uint8Array) || sealed.length <= hpke.encLength) { + throw new TypeError('e2ee: a sealed message is bytes'); + } + const context = await hpke.setupRecipient(sealed.subarray(0, hpke.encLength), keyPair, setup); + return context.open(aad === null ? null : asBytes(aad), sealed.subarray(hpke.encLength)); + }, + }); +}; + +module.exports = { createIdentity, createSealer, createOpener }; diff --git a/src/encryption/envelope.js b/src/encryption/envelope.js new file mode 100644 index 0000000..0821afa --- /dev/null +++ b/src/encryption/envelope.js @@ -0,0 +1,339 @@ +'use strict'; + +// Sealed envelopes for the Node↔Node carriers — the rooms backplane, the +// cluster channels, the brokers: many senders, many receivers, one keyring, +// no handshake, no ordering across senders. Node-only, and synchronous end +// to end: a backplane's receive path cannot wait (src/compression/sync.js +// makes the same demand of a codec). +// +// u8 version (1) ‖ u8 suite ‖ salt16 ‖ u64 counter ‖ ciphertext ‖ tag +// +// A RANDOM 96-bit nonce is what this layout exists to avoid: GCM's bound +// for random nonces is 2^32 messages a key, which a busy fan-out reaches in +// hours. So every sender derives ITS OWN key — HKDF(master, salt) with a +// salt drawn once per process (and again after 2^32 messages) — and counts +// under it: a (key, nonce) pair cannot repeat without two processes +// drawing the same 16 random bytes. A receiver derives the same key from +// the salt it reads, once per sender (5.7 µs — bench/encryption.js), and +// keeps it. +// +// What the receiver keeps is inserted only AFTER a tag verified, so a flood +// of made-up salts costs an attacker's HKDF each and evicts nobody. The kid +// SELECTS the key and the suite byte the cipher — nothing is ever tried +// until it fits (a partitioning oracle against a non-committing AEAD). The +// additional data binds the layer, the kid and the CHANNEL: an envelope +// lifted from one room's channel does not open on another's. + +const crypto = require('node:crypto'); +const { aead } = require('./aead.js'); +const { isCipher, OpenError } = require('./contracts.js'); +const { normalizeKeys } = require('./keyring.js'); +const { isPromise } = require('../compression/ids.js'); + +const VERSION = 1; +const SALT_LENGTH = 16; +const HEADER_LENGTH = 2 + SALT_LENGTH + 8; +const SUITE_AES = 1; +const SUITE_CHACHA = 2; +// An injected cipher: both ends were handed the same one, so the byte only +// says "not a built-in". +const SUITE_INJECTED = 0xff; +const RESEED_AFTER = 0x100000000; +const DEFAULT_REPLAY_WINDOW = 1024; +const MAX_SENDERS = 1024; +// This process's own salts kept for skipping its echoes (see `mine`). +const MINE_KEPT = 16; + +const SUITES = Object.freeze({ __proto__: null, 'aes-256-gcm': SUITE_AES, 'chacha20-poly1305': SUITE_CHACHA }); + +// → { cipher, injected }: `injected` tells the sealer whose key bytes it +// may wipe (see `owned` in createEnvelopeSealer). +const resolveCipher = (value, name) => { + if (value === undefined || value === null || value === true) return { cipher: aead(), injected: false }; + if (typeof value === 'string') return { cipher: aead({ algorithm: value }), injected: false }; + if (!isCipher(value)) throw new TypeError(`${name}: cipher must be a cipher name or a Cipher`); + // The nonce is the message counter, 64 bits at its end. + if (value.nonceLength < 8) throw new TypeError(`${name}: cipher needs a nonce of 8 bytes or more`); + // Probed where it is built, like a codec in normalizeSyncCompression: a + // cipher over crypto.subtle answers promises, and nothing here can wait. + // A round trip under a random key, so a cipher that seals but cannot + // open — or opens anything — fails at boot, not in a log line later. + const synchronous = `${name}: cipher must answer synchronously on this carrier`; + const key = value.key(crypto.randomBytes(value.keyLength)); + if (isPromise(key)) throw new TypeError(synchronous); + const nonce = new Uint8Array(value.nonceLength); + const sealed = key.seal(nonce, Buffer.from('probe'), null); + if (isPromise(sealed)) throw new TypeError(synchronous); + let opened = null; + try { + opened = key.open(nonce, sealed, null); + } catch { + opened = null; + } + if (isPromise(opened)) throw new TypeError(synchronous); + if (opened === null || Buffer.from(opened).toString() !== 'probe') { + throw new TypeError(`${name}: cipher does not open what it sealed`); + } + return { cipher: value, injected: true }; +}; + +/** + * `encryption` of a Node↔Node carrier → frozen `{ keys, cipher, seal, + * acceptPlaintext, replayWindow, maxSenders }`, or null for off. `seal` and + * `acceptPlaintext` are the rollout: a pub/sub backplane delivers at most + * once, so switching every instance at the same instant is not an option — + * first every instance learns to OPEN (`seal: false, acceptPlaintext: + * true`), then every instance seals (`acceptPlaintext: true`), then the + * plaintext is refused (neither). + */ +const normalizeEnvelopeEncryption = (value, name) => { + if (value === undefined || value === null || value === false) return null; + if (typeof value !== 'object' || Array.isArray(value) || value.keys === undefined) { + throw new TypeError( + `${name}: encryption must be { keys, cipher?, seal?, acceptPlaintext?, replayWindow?, maxSenders? }`, + ); + } + const { + seal = true, + acceptPlaintext = false, + replayWindow = DEFAULT_REPLAY_WINDOW, + maxSenders = MAX_SENDERS, + } = value; + if (typeof seal !== 'boolean') throw new TypeError(`${name}: encryption.seal must be a boolean`); + if (typeof acceptPlaintext !== 'boolean') { + throw new TypeError(`${name}: encryption.acceptPlaintext must be a boolean`); + } + if (replayWindow !== false && !(Number.isInteger(replayWindow) && replayWindow > 0 && replayWindow <= 65536)) { + throw new TypeError(`${name}: encryption.replayWindow must be false or an integer from 1 to 65536`); + } + if (!(Number.isInteger(maxSenders) && maxSenders > 0 && maxSenders <= 1048576)) { + throw new TypeError(`${name}: encryption.maxSenders must be an integer from 1 to 1048576`); + } + if (!seal && !acceptPlaintext) { + throw new TypeError(`${name}: encryption with seal: false must accept plaintext — it sends nothing else`); + } + const { cipher, injected } = resolveCipher(value.cipher, `${name}: encryption`); + return Object.freeze({ + keys: normalizeKeys(value.keys, `${name}: encryption.keys`), + cipher, + injected, + seal, + acceptPlaintext, + replayWindow: replayWindow === false ? 0 : replayWindow, + maxSenders, + }); +}; + +/** + * A sliding anti-replay window over one sender's counters, as IPsec and + * DTLS keep: the highest counter seen and a ring of flags under it. A + * receiver hears only the channels it subscribed to, so GAPS are normal and + * cost nothing; what is refused is a counter seen before, or one older than + * the window. + */ +class ReplayWindow { + #seen; + #size; + #top = -1; + + constructor(size) { + this.#size = size; + this.#seen = new Uint8Array(size); + } + + /** True when `counter` is fresh — and marks it; call only after the tag verified. */ + accept(counter) { + const size = this.#size; + const top = this.#top; + if (counter > top) { + const advance = counter - top; + if (advance >= size) this.#seen.fill(0); + else for (let c = top + 1; c < counter; c++) this.#seen[c % size] = 0; + this.#seen[counter % size] = 1; + this.#top = counter; + return true; + } + if (top - counter >= size) return false; + if (this.#seen[counter % size] === 1) return false; + this.#seen[counter % size] = 1; + return true; + } +} + +const writeCounter = (buffer, offset, counter) => { + buffer.writeUInt32BE(Math.floor(counter / 0x100000000), offset); + buffer.writeUInt32BE(counter >>> 0, offset + 4); +}; + +const readCounter = (buffer, offset) => buffer.readUInt32BE(offset) * 0x100000000 + buffer.readUInt32BE(offset + 4); + +/** + * `{ seal(bytes, channel) -> { kid, sealed }, open(kid, sealed, channel) -> + * bytes }` for one layer. `open` throws OpenError for every refusal, with + * `reason` ('format', 'kid', 'open', 'replay') set for the SERVER's log — + * it never travels. An envelope sealed by this same sealer answers null: + * a pub/sub backplane echoes every publish to its publisher, which would + * otherwise decrypt and parse each of its own messages only to drop them. + * `echo: true` opens them like any other — a store reads back what it wrote. + */ +const createEnvelopeSealer = ({ encryption, layer, echo = false, randomBytes = crypto.randomBytes }) => { + const { keys, cipher, injected = false, replayWindow, maxSenders = MAX_SENDERS } = encryption; + const ciphers = new Map([[SUITES[cipher.id] ?? SUITE_INJECTED, cipher]]); + // The ciphers built HERE — whose key() copies the bytes into a KeyObject + // — are the ones whose subkey may be wiped after key(). An injected + // cipher may close over `raw` (the guide's form does) and OWNS it from + // key() on: wiping it left such a cipher sealing under all zeros, with + // nothing to notice — its own opener saw the same zeros. Decided by how + // the cipher arrived, never by its id: an injected one may name a suite. + const owned = new WeakSet(); + if (!injected) owned.add(cipher); + // A built-in suite is opened whichever one this instance seals with, so a + // change of cipher is a config change, not a rollout. + for (const id in SUITES) { + if (ciphers.has(SUITES[id])) continue; + const built = aead({ algorithm: id }); + ciphers.set(SUITES[id], built); + owned.add(built); + } + const suite = SUITES[cipher.id] ?? SUITE_INJECTED; + const label = `wrpc ${layer} v1`; + const aadPrefix = `wrpc-sealed v1\0${layer}\0`; + + const derive = (kid, suiteByte, salt) => { + const master = keys.get(kid); + if (master === null) return null; + const active = ciphers.get(suiteByte); + const info = Buffer.from(`${label}\0${kid}\0${active.id}`); + const subkey = Buffer.from(crypto.hkdfSync('sha256', master, salt, info, active.keyLength)); + const key = active.key(subkey); + if (owned.has(active)) subkey.fill(0); + return key; + }; + + const nonceOf = (active, counter) => { + const nonce = Buffer.alloc(active.nonceLength); + writeCounter(nonce, active.nonceLength - 8, counter); + return nonce; + }; + + // The sending half: one salt, one derived key, one counter — rebuilt when + // the current kid changes (a provider rotated) or the counter is spent. + let sender = null; + // The salts this process sealed under, to skip its own echoes: the last + // MINE_KEPT — an echo arrives within moments of its send, and the set + // grew by one salt every reseed and every rotation, for the life of the + // process. + const mine = new Set(); + + const senderFor = (kid) => { + if (sender !== null && sender.kid === kid && sender.counter < RESEED_AFTER) return sender; + const salt = randomBytes(SALT_LENGTH); + const key = derive(kid, suite, salt); + if (key === null) throw new Error(`encryption: the keyring does not hold its current key ${JSON.stringify(kid)}`); + const header = Buffer.alloc(HEADER_LENGTH); + header[0] = VERSION; + header[1] = suite; + salt.copy(header, 2); + mine.add(header.latin1Slice(1, 2 + SALT_LENGTH)); + if (mine.size > MINE_KEPT) mine.delete(mine.values().next().value); + sender = { kid, key, header, counter: 0 }; + return sender; + }; + + const seal = (bytes, channel) => { + const kid = keys.current; + const state = senderFor(kid); + const counter = state.counter++; + const body = state.key.seal(nonceOf(cipher, counter), bytes, Buffer.from(`${aadPrefix}${kid}\0${channel}`)); + const sealed = Buffer.allocUnsafe(HEADER_LENGTH + body.length); + state.header.copy(sealed, 0); + writeCounter(sealed, 2 + SALT_LENGTH, counter); + sealed.set(body, HEADER_LENGTH); + return { kid, sealed }; + }; + + // The receiving half: one entry per (kid, suite, sender salt), least + // recently opened first. The SUITE byte is part of the key because it is part of what the + // subkey was derived for: were a known salt looked up without it, a copy + // with the byte changed would be opened under the cached key by an + // instance that knows the sender and refused by one that does not. + // Senders are processes — and, on a broker's RPC address, every client + // process — so the map turns over with restarts and with them; a + // forgotten sender is derived again at its next message, with a FRESH + // replay window. That is what `maxSenders` bounds: memory on one side, + // how many live senders keep their window on the other. Least recently + // used, not oldest: a busy sender evicted by a newcomer came back with a + // fresh window, and a frame of its that had just been refused as a + // replay opened a second time. The touch is one delete and one set on a + // hit: 2.66 -> 2.71 µs at 64 B, 2.80 -> 2.88 at 1 KB (medians of five + // processes, the hit path the decode row of bench/encryption.js runs). + const senders = new Map(); + + const refuse = (reason) => { + const error = new OpenError(); + error.reason = reason; + throw error; + }; + + const remember = (id, entry) => { + if (senders.size >= maxSenders) senders.delete(senders.keys().next().value); + senders.set(id, entry); + }; + + const open = (kid, sealed, channel) => { + if (sealed.length < HEADER_LENGTH || sealed[0] !== VERSION) refuse('format'); + const active = ciphers.get(sealed[1]); + if (active === undefined || sealed.length < HEADER_LENGTH + active.tagLength) refuse('format'); + // suite ‖ salt: one slice, 17 bytes where it was 16. + const saltKey = sealed.latin1Slice(1, 2 + SALT_LENGTH); + if (!echo && mine.has(saltKey)) return null; + const counter = readCounter(sealed, 2 + SALT_LENGTH); + const id = `${kid}\0${saltKey}`; + let entry = senders.get(id); + const known = entry !== undefined; + if (!known) { + const key = derive(kid, sealed[1], sealed.subarray(2, 2 + SALT_LENGTH)); + if (key === null) refuse('kid'); + entry = { key, window: replayWindow === 0 ? null : new ReplayWindow(replayWindow) }; + } else if (keys.get(kid) === null) { + // The ring is asked for a KNOWN sender too: its salt is on the wire, + // so whoever holds a withdrawn key can seal under a salt this instance + // remembers — and the cached subkey would open it until the restart. + // One lookup a message, inside the noise of the open itself: 2.6 µs + // at 64 B before and after on a ring, +0.04 µs through a provider + // (medians of separate processes, the sealed row of + // bench/encryption.js). + senders.delete(id); + refuse('kid'); + } + let bytes; + try { + bytes = entry.key.open( + nonceOf(active, counter), + sealed.subarray(HEADER_LENGTH), + Buffer.from(`${aadPrefix}${kid}\0${channel}`), + ); + } catch { + refuse('open'); + } + // Only now is the counter the sender's own, and the salt worth keeping. + if (entry.window !== null && !entry.window.accept(counter)) refuse('replay'); + if (!known) remember(id, entry); + else { + senders.delete(id); + senders.set(id, entry); + } + return bytes; + }; + + return { seal, open }; +}; + +module.exports = { + normalizeEnvelopeEncryption, + createEnvelopeSealer, + ReplayWindow, + HEADER_LENGTH, + DEFAULT_REPLAY_WINDOW, + MAX_SENDERS, +}; diff --git a/src/encryption/hkdf.browser.js b/src/encryption/hkdf.browser.js new file mode 100644 index 0000000..030488b --- /dev/null +++ b/src/encryption/hkdf.browser.js @@ -0,0 +1,57 @@ +'use strict'; + +// SHA-256, HMAC and HKDF (RFC 5869), browser half: crypto.subtle, and so +// asynchronous. That is fine where this is used — a handshake, an HPKE +// context, a key schedule — never per message. The Node half (hkdf.js, +// swapped through package.json#browser) is node:crypto: the same HMACs +// without a threadpool hand-off each, which is most of what a handshake +// costs (bench/encryption.js). +// +// Extract and Expand are separate on purpose. subtle's own 'HKDF' algorithm +// only does the two together, and both consumers need them apart: Noise +// chains HMACs off a chaining key, HPKE's LabeledExtract / LabeledExpand +// feed different labels to each half. + +const { requireSubtle, concat } = require('./bytes.js'); + +const HASH_LENGTH = 32; +const HMAC = { name: 'HMAC', hash: 'SHA-256' }; +// RFC 5869 §2.2: an absent salt is HashLen zeros — and HMAC pads a short key +// with zeros anyway, so this is the empty key crypto.subtle refuses to import. +const ZEROS = new Uint8Array(HASH_LENGTH); + +const createKdf = ({ subtle = globalThis.crypto?.subtle } = {}) => { + requireSubtle(subtle, 'encryption'); + + const hash = async (data) => new Uint8Array(await subtle.digest('SHA-256', data)); + + const hmacKey = (key) => subtle.importKey('raw', key.length === 0 ? ZEROS : key, HMAC, false, ['sign']); + + const sign = async (key, data) => new Uint8Array(await subtle.sign('HMAC', key, data)); + + const hmac = async (key, data) => sign(await hmacKey(key), data); + + const extract = (salt, ikm) => hmac(salt, ikm); + + const expand = async (prk, info, length) => { + if (!Number.isInteger(length) || length < 1 || length > 255 * HASH_LENGTH) { + throw new RangeError('hkdf: length out of range'); + } + const key = await hmacKey(prk); + const out = new Uint8Array(length); + const counter = new Uint8Array(1); + let previous = new Uint8Array(0); + for (let offset = 0; offset < length; offset += HASH_LENGTH) { + counter[0]++; + previous = await sign(key, concat(previous, info, counter)); + out.set(offset + HASH_LENGTH > length ? previous.subarray(0, length - offset) : previous, offset); + } + return out; + }; + + const derive = async (ikm, salt, info, length) => expand(await extract(salt, ikm), info, length); + + return Object.freeze({ id: 'SHA256', hashLength: HASH_LENGTH, hash, hmac, extract, expand, derive }); +}; + +module.exports = { createKdf, HASH_LENGTH }; diff --git a/src/encryption/hkdf.js b/src/encryption/hkdf.js new file mode 100644 index 0000000..a4a2a40 --- /dev/null +++ b/src/encryption/hkdf.js @@ -0,0 +1,52 @@ +'use strict'; + +// SHA-256, HMAC and HKDF (RFC 5869), Node half: node:crypto. The browser +// half (hkdf.browser.js, swapped through package.json#browser) is +// crypto.subtle, which Node has too — but every HMAC through it is a +// threadpool hand-off, and a handshake or an HPKE key schedule is a dozen of +// them: a sealed HTTP request costs 209 µs for both ends with this file +// against 565 µs over subtle (bench/encryption.js). The methods answer +// promises although nothing here waits: one contract for both platforms. +// +// Extract and Expand are separate on purpose: Noise chains HMACs off a +// chaining key, HPKE's LabeledExtract / LabeledExpand feed different labels +// to each half. + +const crypto = require('node:crypto'); + +const HASH_LENGTH = 32; +// RFC 5869 §2.2: an absent salt is HashLen zeros. +const ZEROS = Buffer.alloc(HASH_LENGTH); + +const hmacOf = (key, data) => + crypto + .createHmac('sha256', key.length === 0 ? ZEROS : key) + .update(data) + .digest(); + +const expandOf = (prk, info, length) => { + if (!Number.isInteger(length) || length < 1 || length > 255 * HASH_LENGTH) { + throw new RangeError('hkdf: length out of range'); + } + const out = Buffer.alloc(length); + const key = prk.length === 0 ? ZEROS : prk; + let previous = ZEROS.subarray(0, 0); + for (let offset = 0, counter = 1; offset < length; offset += HASH_LENGTH, counter++) { + previous = crypto.createHmac('sha256', key).update(previous).update(info).update(Buffer.of(counter)).digest(); + previous.copy(out, offset, 0, Math.min(HASH_LENGTH, length - offset)); + } + return out; +}; + +const createKdf = () => + Object.freeze({ + id: 'SHA256', + hashLength: HASH_LENGTH, + hash: async (data) => crypto.createHash('sha256').update(data).digest(), + hmac: async (key, data) => hmacOf(key, data), + extract: async (salt, ikm) => hmacOf(salt, ikm), + expand: async (prk, info, length) => expandOf(prk, info, length), + derive: async (ikm, salt, info, length) => expandOf(hmacOf(salt, ikm), info, length), + }); + +module.exports = { createKdf, HASH_LENGTH }; diff --git a/src/encryption/hpke.js b/src/encryption/hpke.js new file mode 100644 index 0000000..4284f74 --- /dev/null +++ b/src/encryption/hpke.js @@ -0,0 +1,202 @@ +'use strict'; + +// HPKE (RFC 9180) — "encrypt this to a public key", the standard under MLS, +// Oblivious HTTP and ECH — base, psk, auth and auth_psk modes. One file +// for both platforms, composed from the contracts of this directory (a Dh, +// a Kdf, a Cipher) and checked against the RFC's vectors +// (tests/encryption/hpke.test.js). +// +// It is what a REQUEST needs where a session is what a connection needs: no +// round trip, one message sealed to the recipient's static key — and, from +// the same context, an EXPORTER, which is how the answer to that message is +// sealed without a second key exchange (the Oblivious HTTP construction, +// RFC 9458 §4.4). +// +// The KEM is a structural seam of its own (`isKem`): `dhKem(dh, kdf)` is +// DHKEM over the platform's X25519, and an application that needs ML-KEM or +// a hybrid injects one — wrpc ships none and depends on none. +// +// What HPKE does not give: forward secrecy on the recipient's side (the +// static key opens every message ever sent to it — rotate it), and replay +// protection (the binding that uses this adds its own). + +const { concat, utf8 } = require('./bytes.js'); + +const EMPTY = new Uint8Array(0); +const VERSION = utf8('HPKE-v1'); +const MODE_BASE = 0; +const MODE_PSK = 1; +// The auth modes: the recipient also learns WHICH static key sealed the +// message (§5.1.3) — what a relayed end-to-end payload needs, where "sealed +// to me" says nothing about who by. The +1 of a psk is the low bit. +const MODE_AUTH = 2; +const KDF_HKDF_SHA256 = 0x0001; +const KEM_X25519_HKDF_SHA256 = 0x0020; +const AEAD_IDS = Object.freeze({ __proto__: null, 'aes-256-gcm': 0x0002, 'chacha20-poly1305': 0x0003 }); + +const i2osp = (value, length) => { + const out = new Uint8Array(length); + for (let i = length - 1; i >= 0; i--) { + out[i] = value & 0xff; + value = Math.floor(value / 256); + } + return out; +}; + +// RFC 9180 §4: every KDF call is labelled with the suite it serves. +const labelled = (kdf, suite) => ({ + extract: (salt, label, ikm) => kdf.extract(salt, concat(VERSION, suite, utf8(label), ikm)), + expand: (prk, label, info, length) => + kdf.expand(prk, concat(i2osp(length, 2), VERSION, suite, utf8(label), info), length), +}); + +/** + * DHKEM(dh, HKDF-SHA256) — RFC 9180 §4.1. `id` defaults to the registered + * one for X25519; another Dh needs its own registered id. + */ +const dhKem = (dh, kdf, id = KEM_X25519_HKDF_SHA256) => { + const kem = labelled(kdf, concat(utf8('KEM'), i2osp(id, 2))); + const extractAndExpand = async (shared, context) => + kem.expand(await kem.extract(EMPTY, 'eae_prk', shared), 'shared_secret', context, 32); + return Object.freeze({ + id, + publicLength: dh.publicLength, + encLength: dh.publicLength, + secretLength: 32, + generateKeyPair: () => dh.generateKeyPair(), + keyPair: (seed) => dh.keyPair(seed), + /** DeriveKeyPair (§7.1.3) for X25519: a key pair from any key material. */ + async deriveKeyPair(ikm) { + const seed = await kem.expand(await kem.extract(EMPTY, 'dkp_prk', ikm), 'sk', EMPTY, 32); + return dh.keyPair(seed); + }, + /** + * → `{ sharedSecret, enc }`. With `sender` (a key pair) it is AuthEncap: + * the secret also depends on the sender's static key, so only its holder + * could have produced it. `ephemeral` fixes the ephemeral key pair — for + * test vectors, and nothing else. + */ + async encap(recipientPublicKey, ephemeral = null, sender = null) { + const pair = ephemeral ?? (await dh.generateKeyPair()); + const enc = pair.publicKey; + const first = await dh.dh(pair.privateKey, recipientPublicKey); + if (sender === null) { + return { sharedSecret: await extractAndExpand(first, concat(enc, recipientPublicKey)), enc }; + } + const second = await dh.dh(sender.privateKey, recipientPublicKey); + const context = concat(enc, recipientPublicKey, sender.publicKey); + return { sharedSecret: await extractAndExpand(concat(first, second), context), enc }; + }, + /** With `senderPublicKey` it is AuthDecap: a message the named sender did not seal does not open. */ + async decap(enc, recipient, senderPublicKey = null) { + const first = await dh.dh(recipient.privateKey, enc); + if (senderPublicKey === null) return extractAndExpand(first, concat(enc, recipient.publicKey)); + const second = await dh.dh(recipient.privateKey, senderPublicKey); + return extractAndExpand(concat(first, second), concat(enc, recipient.publicKey, senderPublicKey)); + }, + }); +}; + +const isKem = (value) => + typeof value === 'object' && + value !== null && + Number.isInteger(value.id) && + Number.isInteger(value.encLength) && + value.encLength > 0 && + typeof value.encap === 'function' && + typeof value.decap === 'function'; + +/** + * One direction of an HPKE context: `seal`/`open` with the sequence number + * as the nonce (XORed into the base nonce, §5.2), and `export`. The counter + * is taken synchronously, so calls made in order are sealed in order even + * over an asynchronous cipher. + */ +class HpkeContext { + #key; + #baseNonce; + #exporterSecret; + #expand; + #sequence = 0; + + constructor(key, baseNonce, exporterSecret, expand) { + this.#key = key; + this.#baseNonce = baseNonce; + this.#exporterSecret = exporterSecret; + this.#expand = expand; + } + + #nonce() { + if (this.#sequence >= Number.MAX_SAFE_INTEGER) throw new Error('hpke: the message counter is spent'); + const nonce = Uint8Array.from(this.#baseNonce); + const sequence = i2osp(this.#sequence++, nonce.length); + for (let i = 0; i < nonce.length; i++) nonce[i] ^= sequence[i]; + return nonce; + } + + seal(aad, plaintext) { + return this.#key.seal(this.#nonce(), plaintext, aad ?? EMPTY); + } + + open(aad, sealed) { + return this.#key.open(this.#nonce(), sealed, aad ?? EMPTY); + } + + /** A secret both ends can derive and nobody else: `length` bytes bound to `context`. */ + export(context, length) { + return this.#expand(this.#exporterSecret, 'sec', context, length); + } +} + +/** + * `{ suite, setupSender(recipientPublicKey, options), setupRecipient(enc, + * recipientKeyPair, options) }`. Options: `info` (bytes both ends agree on — + * what the message is FOR, so one sealed for one purpose does not open for + * another), `psk` + `pskId` for psk mode, where the recipient also learns + * that the sender held the pre-shared key, and `senderKey` (a key pair, on + * the sender) with `senderPublicKey` (on the recipient) for auth mode, where + * it learns WHICH static key sealed the message. + */ +const createHpke = ({ kem, kdf, cipher }) => { + if (!isKem(kem)) throw new TypeError('hpke: kem must be a Kem'); + const aeadId = AEAD_IDS[cipher.id]; + if (aeadId === undefined) throw new TypeError(`hpke: no registered AEAD id for ${JSON.stringify(cipher.id)}`); + const suite = concat(utf8('HPKE'), i2osp(kem.id, 2), i2osp(KDF_HKDF_SHA256, 2), i2osp(aeadId, 2)); + const { extract, expand } = labelled(kdf, suite); + + const schedule = async (sharedSecret, { info = EMPTY, psk = null, pskId = null } = {}, auth = false) => { + const hasPsk = psk !== null && psk !== undefined; + if (hasPsk !== (pskId !== null && pskId !== undefined)) throw new TypeError('hpke: psk and pskId go together'); + if (hasPsk && psk.length < 32) throw new TypeError('hpke: a psk is 32 bytes or more'); + const mode = (auth ? MODE_AUTH : MODE_BASE) + (hasPsk ? MODE_PSK : 0); + const [pskIdHash, infoHash] = await Promise.all([ + extract(EMPTY, 'psk_id_hash', hasPsk ? pskId : EMPTY), + extract(EMPTY, 'info_hash', info), + ]); + const context = concat(Uint8Array.of(mode), pskIdHash, infoHash); + const secret = await extract(sharedSecret, 'secret', hasPsk ? psk : EMPTY); + const [key, baseNonce, exporterSecret] = await Promise.all([ + expand(secret, 'key', context, cipher.keyLength), + expand(secret, 'base_nonce', context, cipher.nonceLength), + expand(secret, 'exp', context, kdf.hashLength), + ]); + return new HpkeContext(await cipher.key(key), baseNonce, exporterSecret, expand); + }; + + return Object.freeze({ + suite, + aeadId, + encLength: kem.encLength, + async setupSender(recipientPublicKey, options = {}) { + const sender = options.senderKey ?? null; + const { sharedSecret, enc } = await kem.encap(recipientPublicKey, options.ephemeral ?? null, sender); + return { enc, context: await schedule(sharedSecret, options, sender !== null) }; + }, + async setupRecipient(enc, recipient, options = {}) { + const senderPublicKey = options.senderPublicKey ?? null; + return schedule(await kem.decap(enc, recipient, senderPublicKey), options, senderPublicKey !== null); + }, + }); +}; + +module.exports = { createHpke, dhKem, isKem, AEAD_IDS, KEM_X25519_HKDF_SHA256 }; diff --git a/src/encryption/http.js b/src/encryption/http.js new file mode 100644 index 0000000..9fe065d --- /dev/null +++ b/src/encryption/http.js @@ -0,0 +1,248 @@ +'use strict'; + +// Encryption for a REQUEST — the HTTP transport, where there is no +// connection to hold a session: each request is sealed to the server's +// static key with HPKE (hpke.js), and the answer is sealed under a key both +// ends export from that same context. The construction is Oblivious HTTP's +// (RFC 9458 §4), without its relay. +// +// POST Content-Type: application/wrpc-sealed +// request u8 version (1) ‖ u8 aead id ‖ u8 kidLength ‖ kid ‖ enc ‖ ct +// plaintext u32 headerLength ‖ JSON { m, u, h, t } ‖ body +// response nonce (32) ‖ AEAD( u32 headerLength ‖ JSON { s, h } ‖ body ) +// +// The REAL request — method, URL, headers, body — is inside; what an +// observer sees is one endpoint being POSTed to. That is also what makes it +// uniform for the server: the core unwraps the call and carries on as if it +// had arrived in the clear, so packets, batches and REST all ride it. +// +// HPKE has no replay protection, and the party this exists for — whoever +// terminates the TLS — is exactly the one who could replay: so `t` (the +// sender's clock) must be fresh and an `enc` is accepted once. +// +// This file is the client half and what the two share — bundled for a page. +// The server half is httpServer.js, Node-only. + +const { concat, utf8, counterNonce, fromBase64, toBase64Url } = require('./bytes.js'); +const { OpenError } = require('./contracts.js'); + +const CONTENT_TYPE = 'application/wrpc-sealed'; +const VERSION = 1; +const RESPONSE_NONCE = 32; +const INFO = utf8('wrpc http v1\0'); +const RESPONSE_LABEL = utf8('wrpc http response'); +const STREAM_LABEL = utf8('wrpc sse stream'); +// A sealed event stream keeps the type every proxy knows how to treat (no +// buffering, no transformation) and says what it is in a parameter — the +// one response header a cross-origin page can always read. +const STREAM_TYPE = 'text/event-stream'; +const SEALED_STREAM_TYPE = `${STREAM_TYPE}; wrpc-sealed=1`; +const KEY_LABEL = utf8('key'); +const NONCE_LABEL = utf8('nonce'); + +const decoder = new TextDecoder('utf-8', { fatal: true }); + +const isSealedType = (value) => typeof value === 'string' && value.toLowerCase().startsWith(CONTENT_TYPE); + +const isSealedStream = (value) => + typeof value === 'string' && value.toLowerCase().startsWith(STREAM_TYPE) && value.includes('wrpc-sealed=1'); + +// `u32 headerLength ‖ JSON ‖ body` — one JSON header, then bytes as they are. +const pack = (header, body) => { + const json = utf8(JSON.stringify(header)); + const out = new Uint8Array(4 + json.length + body.length); + new DataView(out.buffer).setUint32(0, json.length); + out.set(json, 4); + out.set(body, 4 + json.length); + return out; +}; + +const unpack = (bytes) => { + if (bytes.length < 4) throw new OpenError(); + const length = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0); + if (4 + length > bytes.length) throw new OpenError(); + let header; + try { + header = JSON.parse(decoder.decode(bytes.subarray(4, 4 + length))); + } catch { + throw new OpenError(); + } + if (typeof header !== 'object' || header === null || Array.isArray(header)) throw new OpenError(); + return { header, body: bytes.subarray(4 + length) }; +}; + +const bodyBytes = (body) => { + if (body === undefined || body === null) return new Uint8Array(0); + if (typeof body === 'string') return utf8(body); + if (body instanceof Uint8Array) return body; + if (body instanceof ArrayBuffer) return new Uint8Array(body); + throw new TypeError('encryption: a sealed request body is a string or bytes'); +}; + +// The response key: derived from a secret only the two ends of THIS request +// can export, salted with the request's enc and a fresh nonce — RFC 9458 §4.4. +const responseKey = async ({ kdf, cipher }, context, enc, nonce) => { + const secret = await context.export(RESPONSE_LABEL, cipher.keyLength); + const prk = await kdf.extract(concat(enc, nonce), secret); + const [key, iv] = await Promise.all([ + kdf.expand(prk, KEY_LABEL, cipher.keyLength), + kdf.expand(prk, NONCE_LABEL, cipher.nonceLength), + ]); + return { key: await cipher.key(key), iv }; +}; + +// The key of a sealed event stream: the secret only the two ends of the +// request that opened THIS stream can export, salted with the request's enc +// and a nonce the SERVER draws per stream — carried as the `n` parameter of +// the stream's Content-Type — so every stream has its own key and counts +// its frames from zero. The salt is what makes a replayed open request (on +// another instance, whose replay memory is its own) a different key: the +// export alone is a function of the request, and two streams opened by the +// same bytes counted from zero under one key — a repeated (key, nonce). +const streamKey = async ({ kdf, cipher }, context, enc, nonce) => { + const secret = await context.export(STREAM_LABEL, cipher.keyLength); + const prk = await kdf.extract(concat(enc, nonce), secret); + return cipher.key(await kdf.expand(prk, KEY_LABEL, cipher.keyLength)); +}; + +const STREAM_NONCE_PARAM = /;\s*n=([A-Za-z0-9_-]+)/; + +/** The Content-Type of a sealed stream under `nonce`. */ +const sealedStreamType = (nonce) => `${SEALED_STREAM_TYPE}; n=${toBase64Url(nonce)}`; + +/** The server nonce a sealed stream's Content-Type carries — exactly RESPONSE_NONCE bytes — or null. */ +const streamNonce = (type) => { + const match = typeof type === 'string' ? STREAM_NONCE_PARAM.exec(type) : null; + const nonce = match === null ? null : fromBase64(match[1]); + return nonce !== null && nonce.length === RESPONSE_NONCE ? nonce : null; +}; + +/** + * A sealed event stream, opened: the outer body is `data: ` events, + * each the AEAD of one chunk of the REAL stream under a counter nonce; what + * comes out is that real stream's bytes, for an SSE parser that never knew. + * A frame that does not open — altered, dropped, reordered — errors the + * stream, which the transport sees as a broken connection. + */ +const openSealedStream = (body, key) => { + const reader = body.getReader(); + const text = new TextDecoder(); + let pending = ''; + let counter = 0; + return new ReadableStream({ + async pull(controller) { + for (;;) { + const end = pending.indexOf('\n\n'); + if (end !== -1) { + const event = pending.slice(0, end); + pending = pending.slice(end + 2); + // Anything but a data line is an intermediary's (a comment, a retry). + if (!event.startsWith('data: ')) continue; + const sealed = fromBase64(event.slice(6)); + if (sealed === null) throw new OpenError(); + return void controller.enqueue(await key.open(counterNonce(counter++), sealed, null)); + } + const { value, done } = await reader.read(); + if (done) return void controller.close(); + pending += text.decode(value, { stream: true }); + } + }, + cancel: (reason) => reader.cancel(reason), + }); +}; + +// The one way a sealed request fails that the application can act on: the +// server answered in plaintext, which a client that encrypts does not +// read. The outer status is unauthenticated (anything on the path could +// have written it), so only a closed set is named — and a transport maps +// only that set onto the calls it carried; anything else is a 503. +const REFUSAL_MESSAGES = { + __proto__: null, + 400: 'the sealed request was not accepted — the pinned server key may be retired', + 409: "the sealed request was refused as stale or replayed — check this device's clock", + 426: 'the server requires encryption and this request did not carry it', + 503: 'the server cannot vouch a sealed request fresh right now (its replay memory is full or out of reach) — retry later', +}; + +class EncryptionRefusedError extends Error { + constructor(status) { + super(`encryption: ${REFUSAL_MESSAGES[status] ?? `the server answered in plaintext (${status})`}`); + this.name = 'EncryptionRefusedError'; + this.code = 'ENCRYPTION_REFUSED'; + this.status = status; + } +} + +/** + * The client half: `sealedFetch({ hpke, kdf, cipher, serverKey })` + * answers `(fetch, endpoint) => fetch-shaped function`. The wrapper answers + * a real `Response`, so a transport built on fetch needs no other change. A + * response that is not a sealed one is an ERROR, whatever its status: a + * client that encrypts does not read plaintext. + */ +const sealedFetch = ({ hpke, kdf, cipher, serverKey, now = Date.now }) => { + const kid = utf8(serverKey.kid); + const prefix = concat(Uint8Array.of(VERSION, hpke.aeadId, kid.length), kid); + const info = concat(INFO, prefix); + return (baseFetch, endpoint) => { + const base = new URL(endpoint); + return async (url, init = {}) => { + const target = new URL(url, base); + const header = { + m: (init.method ?? 'GET').toUpperCase(), + u: target.pathname + target.search, + h: init.headers ?? {}, + t: now(), + }; + const { enc, context } = await hpke.setupSender(serverKey.hpke, { info }); + const sealed = await context.seal(null, pack(header, bodyBytes(init.body))); + const response = await baseFetch(base.href, { + method: 'POST', + headers: { 'Content-Type': CONTENT_TYPE }, + body: concat(prefix, enc, sealed), + signal: init.signal, + credentials: init.credentials, + }); + const type = response.headers.get('content-type'); + if (isSealedStream(type)) { + // A stream without its 32-byte nonce is not one this client opens. + const nonce = streamNonce(type); + if (nonce === null) throw new OpenError(); + const stream = openSealedStream(response.body, await streamKey({ kdf, cipher }, context, enc, nonce)); + return new Response(stream, { status: 200, headers: { 'Content-Type': STREAM_TYPE } }); + } + if (!isSealedType(type)) throw new EncryptionRefusedError(response.status); + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.length < RESPONSE_NONCE) throw new OpenError(); + const nonce = bytes.subarray(0, RESPONSE_NONCE); + const { key, iv } = await responseKey({ kdf, cipher }, context, enc, nonce); + const answer = unpack(await key.open(iv, bytes.subarray(RESPONSE_NONCE), null)); + const status = Number.isInteger(answer.header.s) ? answer.header.s : 0; + if (status < 200 || status > 599) throw new OpenError(); + // A body is not allowed on these statuses by the Response constructor. + const empty = status === 204 || status === 205 || status === 304; + return new Response(empty ? null : answer.body, { status, headers: answer.header.h ?? {} }); + }; + }; +}; + +module.exports = { + EncryptionRefusedError, + sealedFetch, + responseKey, + streamKey, + streamNonce, + sealedStreamType, + openSealedStream, + isSealedStream, + SEALED_STREAM_TYPE, + STREAM_TYPE, + pack, + unpack, + isSealedType, + decoder, + CONTENT_TYPE, + VERSION, + RESPONSE_NONCE, + INFO, +}; diff --git a/src/encryption/httpServer.js b/src/encryption/httpServer.js new file mode 100644 index 0000000..d3d92f9 --- /dev/null +++ b/src/encryption/httpServer.js @@ -0,0 +1,307 @@ +'use strict'; + +// The server half of per-request encryption (http.js has the format and the +// client half). Node-only, reached from the core: `unwrap` turns a sealed +// outer request into the real one — marked `encrypted`, its `respond` +// sealing the answer — and the core carries on as if it had arrived in the +// clear, so packets, batches and REST all ride it without knowing. +// +// HPKE has no replay protection, and the party this exists for — whoever +// terminates the TLS — is exactly the one who could replay: so `t` (the +// sender's clock) must be fresh, and an `enc` is accepted once. + +const { concat, toBase64Url, counterNonce } = require('./bytes.js'); +const { + responseKey, + streamKey, + sealedStreamType, + STREAM_TYPE, + pack, + unpack, + isSealedType, + decoder, + CONTENT_TYPE, + VERSION, + RESPONSE_NONCE, + INFO, +} = require('./http.js'); + +const DEFAULT_MAX_SKEW = 5 * 60 * 1000; +const DEFAULT_REPLAY_ENTRIES = 100000; +// The inner header (u32 length, JSON { m, u, h, t }) is bounded before it +// is parsed: 16 KiB holds any real request's headers, and whoever holds a +// key could otherwise hand JSON.parse a header of any size. +const MAX_INNER_HEADER = 16 * 1024; +// Hop-by-hop and framing names: the outer request's, never the inner's. +const FRAMING = /^(?:content-length|transfer-encoding|connection)$/; + +// Past `max` live entries the cache is FULL, and what happens then is a +// policy: 'refuse' (the default) answers true — seen, as a replay is — so +// nothing is served that cannot be vouched once; 'evict' forgets the +// oldest live entry to make room, which a flood turns into "accepted +// twice" for whatever it pushed out. Size it for the traffic: it must hold +// every request of the last 2·maxSkew, so max ≥ rps × 2·maxSkew/1000 — +// the default holds ~167 req/s at the default skew. +const OVERFLOW = ['refuse', 'evict']; +// One line per interval for a flood of overflow refusals, not one each. +const OVERFLOW_INTERVAL = 10_000; + +/** + * A bounded "seen once" memory: `seen(id, ttl)` answers true for an id it + * was shown within `ttl` ms. In memory and per process — behind a balancer, + * inject a shared one (`SET id 1 NX PX ttl` in Redis) with the same method. + * `onOverflow()` hears of every id refused (or evicted for) at the cap. + */ +const createReplayCache = ({ + max = DEFAULT_REPLAY_ENTRIES, + now = Date.now, + overflow = 'refuse', + onOverflow = null, +} = {}) => { + if (!Number.isInteger(max) || max <= 0) throw new TypeError('createReplayCache: max must be a positive integer'); + if (!OVERFLOW.includes(overflow)) throw new TypeError("createReplayCache: overflow must be 'refuse' or 'evict'"); + if (onOverflow !== null && typeof onOverflow !== 'function') { + throw new TypeError('createReplayCache: onOverflow must be a function'); + } + const entries = new Map(); + return { + seen(id, ttl) { + const time = now(); + // Known and live: a replay, whatever the fill — a live entry used to + // be evicted first to make room at the cap, and so accepted again. + const known = entries.get(id); + if (known !== undefined) { + if (known > time) return true; + entries.delete(id); + } + // Insertion order is time order, so the expired are at the front. + for (const [key, expires] of entries) { + if (expires > time) break; + entries.delete(key); + } + if (entries.size >= max) { + if (onOverflow !== null) onOverflow(); + if (overflow === 'refuse') return true; + entries.delete(entries.keys().next().value); + } + entries.set(id, time + ttl); + return false; + }, + get size() { + return entries.size; + }, + }; +}; + +/** + * The server half. `unwrap(call, outerHeaders)` answers the inner call — the + * real request, marked `encrypted`, whose `respond` seals — or null when it + * answered the outer one itself (an error: never sealed, never detailed). + * `refuse(call, outerHeaders, status, reason)` is how: one log line with the + * reason, a bare status on the wire. `reserved` is the core's list of the + * header names a sealed request may not declare for itself (rpc/reserved.js). + */ +const createHttpSealing = ({ + suites, + statics, + kdf, + maxSkew = DEFAULT_MAX_SKEW, + replay, + random, + log, + refuse, + reserved = null, + now = Date.now, +}) => { + // An injected memory (`seen`), or the options of the built-in one. Only + // the built-in reports its overflow: the flag is set inside seen(), read + // right after — it answers synchronously. + let overflowed = false; + const cache = + replay && typeof replay.seen === 'function' + ? replay + : createReplayCache({ + ...(replay ?? {}), + now, + onOverflow: () => { + overflowed = true; + }, + }); + let overflows = 0; + let overflowSaid = -Infinity; + + const unwrap = async (call, outerHeaders) => { + const raw = call.body; + if (!(raw instanceof Uint8Array) || raw.length < 4 || raw[0] !== VERSION) { + return void refuse(call, outerHeaders, 400, 'format'); + } + const suite = suites.get(raw[1]); + const kidEnd = 3 + raw[2]; + if (suite === undefined || kidEnd + suite.hpke.encLength >= raw.length) { + return void refuse(call, outerHeaders, 400, 'format'); + } + let kid; + try { + kid = decoder.decode(raw.subarray(3, kidEnd)); + } catch { + return void refuse(call, outerHeaders, 400, 'format'); + } + // The kid is public, so saying it is unknown tells nobody anything — + // and tells a client with a stale pin what to do about it. + const pending = statics(kid); + if (pending === null) return void refuse(call, outerHeaders, 400, 'kid'); + const recipient = (await pending).hpke; + const prefix = raw.subarray(0, kidEnd); + const enc = Uint8Array.from(raw.subarray(kidEnd, kidEnd + suite.hpke.encLength)); + let context; + let plain; + try { + context = await suite.hpke.setupRecipient(enc, recipient, { info: concat(INFO, prefix) }); + plain = await context.open(null, raw.subarray(kidEnd + suite.hpke.encLength)); + } catch { + return void refuse(call, outerHeaders, 400, 'open'); + } + // Bounded before it is parsed (MAX_INNER_HEADER), then parsed. + if (plain.length < 4 || new DataView(plain.buffer, plain.byteOffset, 4).getUint32(0) > MAX_INNER_HEADER) { + return void refuse(call, outerHeaders, 400, 'format'); + } + let request; + try { + request = unpack(plain); + } catch { + return void refuse(call, outerHeaders, 400, 'format'); + } + const { m: method, u: url, h: declared, t: sent } = request.header; + if (typeof method !== 'string' || typeof url !== 'string' || !url.startsWith('/') || !Number.isFinite(sent)) { + return void refuse(call, outerHeaders, 400, 'format'); + } + // Fresh, and once: what HPKE itself does not promise. + if (Math.abs(now() - sent) > maxSkew) return void refuse(call, outerHeaders, 409, 'stale'); + let replayed; + overflowed = false; + try { + replayed = await cache.seen(toBase64Url(enc), 2 * maxSkew); + } catch (error) { + // A shared replay memory that cannot be asked (Redis down) is a + // request that cannot be vouched fresh: refused, not served — and not + // an unhandled rejection per request. + log.error({ err: error, event: 'encryption.replay' }); + return void refuse(call, outerHeaders, 503, 'replay-store'); + } + if (replayed === true) { + if (!overflowed) return void refuse(call, outerHeaders, 409, 'replay'); + // The cache is full of live entries: nothing more can be vouched + // fresh, and each refusal is one line per interval, not one each — a + // flood past the cap is exactly when a line per request would drown + // the log. 503, not the 409 of a stale or replayed request: the + // sender did nothing wrong, and a 409 told it to check its clock. + overflows++; + const at = now(); + if (at - overflowSaid >= OVERFLOW_INTERVAL) { + overflowSaid = at; + log.warn({ event: 'encryption.replay.overflow', refused: overflows }); + overflows = 0; + } + return void refuse(call, outerHeaders, 503, 'replay-full', true); + } + + // The outer request's own headers stay underneath, and what the client + // declared inside wins — except for what the connection, or a proxy in + // front of it, says ABOUT the sender (`reserved`: the forwarded chain, + // the client-ip spellings, sec-*, host, origin): that is not the + // sender's to say, and its absence is a fact too, so a declared one is + // dropped whether or not the outer request carried it. A cookie is the + // outer request's when it has one — script cannot set an HttpOnly one, + // and a declared one must not stand in for it. + const headers = { ...call.headers }; + delete headers['content-type']; + delete headers['content-length']; + // What the outer request accepts is not what the inner answer may be: + // the client reads the answer's bytes as they are, and compressing what + // is about to be sealed is the outer layer's business, never the inner's. + delete headers['accept-encoding']; + if (typeof declared === 'object' && declared !== null) { + for (const name of Object.keys(declared)) { + if (typeof declared[name] !== 'string') continue; + const lower = name.toLowerCase(); + if (FRAMING.test(lower) || (reserved !== null && reserved.test(lower))) continue; + if (lower === 'cookie' && headers.cookie !== undefined) continue; + headers[lower] = declared[name]; + } + } + + const respond = ({ status, headers: inner = {}, body }) => { + // Set-Cookie has to be the outer response's: script cannot set an + // HttpOnly cookie, and that is the only kind a session should be. + const outer = { ...outerHeaders, 'Content-Type': CONTENT_TYPE, 'Cache-Control': 'no-store' }; + const sealedHeaders = {}; + for (const name of Object.keys(inner)) { + if (name.toLowerCase() === 'set-cookie') outer[name] = inner[name]; + else sealedHeaders[name] = Array.isArray(inner[name]) ? inner[name].join(', ') : String(inner[name]); + } + const nonce = random(RESPONSE_NONCE); + responseKey({ kdf, cipher: suite.cipher }, context, enc, nonce) + .then(async ({ key, iv }) => { + const sealed = await key.seal(iv, pack({ s: status, h: sealedHeaders }, body ?? new Uint8Array(0)), null); + const bytes = Buffer.concat([nonce, sealed]); + call.respond({ status: 200, headers: { ...outer, 'Content-Length': bytes.length }, body: bytes }); + }) + .catch((error) => { + log.error({ err: error, event: 'encryption.respond' }); + call.respond({ status: 500, headers: outerHeaders }); + }); + }; + + const inner = { + method: method.toUpperCase(), + url, + headers, + body: + request.body.length === 0 + ? null + : Buffer.from(request.body.buffer, request.body.byteOffset, request.body.length), + remoteAddress: call.remoteAddress, + encrypted: true, + respond, + }; + if (typeof call.onAbort === 'function') inner.onAbort = (listener) => call.onAbort(listener); + // An event stream (SSE): the key is exported now, because the core opens + // the stream synchronously — and only when the request asked for one. + if (typeof call.stream === 'function' && String(headers.accept ?? '').includes(STREAM_TYPE)) { + // Drawn here, per stream: the salt of the stream key, carried to the + // client in the Content-Type — a replay of this request onto another + // instance opens a stream under another key (http.js). + const nonce = random(RESPONSE_NONCE); + const key = await streamKey({ kdf, cipher: suite.cipher }, context, enc, nonce); + const type = sealedStreamType(nonce); + inner.stream = ({ headers: streamHeaders = {} }) => { + const outer = { ...outerHeaders }; + for (const name of Object.keys(streamHeaders)) { + const lower = name.toLowerCase(); + if (lower !== 'content-type' && lower !== 'content-encoding') outer[name] = streamHeaders[name]; + } + const writer = call.stream({ status: 200, headers: { ...outer, 'Content-Type': type } }); + if (!writer) return writer; + let counter = 0; + // Every chunk of the real stream — an event, the `ready` frame that + // hands out the channel id, a heartbeat comment — leaves as one + // opaque `data:` event under the next counter. + const sealedWriter = { + write: (chunk) => { + const sealed = key.seal(counterNonce(counter++), Buffer.from(chunk), null); + return writer.write(`data: ${sealed.toString('base64')}\n\n`); + }, + end: () => writer.end(), + }; + if (typeof writer.onClose === 'function') sealedWriter.onClose = (listener) => writer.onClose(listener); + if (typeof writer.onDrain === 'function') sealedWriter.onDrain = (listener) => writer.onDrain(listener); + return sealedWriter; + }; + } + return inner; + }; + + return { unwrap, isSealed: (call) => isSealedType(call.headers?.['content-type']) }; +}; + +module.exports = { createHttpSealing, createReplayCache, DEFAULT_MAX_SKEW, MAX_INNER_HEADER }; diff --git a/src/encryption/index.js b/src/encryption/index.js new file mode 100644 index 0000000..789da63 --- /dev/null +++ b/src/encryption/index.js @@ -0,0 +1,12 @@ +'use strict'; + +// @alexify/wrpc/encryption on Node: the primitives of browser.js (with the +// synchronous node:crypto AEADs in place of crypto.subtle's), plus — as +// they land — what only a Node process does: the backplane and broker +// envelopes, the sealed session store, the server halves of the session +// handshake and of HPKE. + +const { sealedStore } = require('./store.js'); +const { createReplayCache } = require('./httpServer.js'); + +module.exports = { ...require('./browser.js'), sealedStore, createReplayCache }; diff --git a/src/encryption/keyring.js b/src/encryption/keyring.js new file mode 100644 index 0000000..cae0e76 --- /dev/null +++ b/src/encryption/keyring.js @@ -0,0 +1,94 @@ +'use strict'; + +// Keys by id. Everything symmetric in @alexify/wrpc/encryption — a backplane +// envelope, a broker body, a sealed session — is sealed under the keyring's +// CURRENT key and names that key's id beside the ciphertext, so the ring can +// hold the previous keys for as long as messages sealed under them may still +// arrive. Rotation is then a deploy that adds a key, a deploy that makes it +// current, and a deploy that drops the old one — never a flag day. +// +// A kid travels in the clear and comes back from a peer, so it is a closed +// alphabet and the ring is a Map: `ring['__proto__']` must be a miss, not +// an object. A kid SELECTS the key; nothing here ever tries key after key +// on one message (a partitioning oracle against a non-committing AEAD). + +const { isKeyProvider } = require('./contracts.js'); +const { fromBase64, fromHex, randomSource } = require('./bytes.js'); + +const KEY_LENGTH = 32; +const KID = /^[A-Za-z0-9._-]{1,32}$/; +// What a single bare key is filed under. +const DEFAULT_KID = '0'; + +const isKid = (value) => typeof value === 'string' && KID.test(value); + +const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * 32 bytes from bytes (copied — the caller may wipe its own), 64 hex + * characters, or base64 / base64url. Hex first: 64 hex characters are + * valid base64 too, and would decode to 48 bytes of the wrong key. + */ +const parseKey = (value, name) => { + let bytes = null; + if (value instanceof Uint8Array) bytes = Uint8Array.from(value); + else if (typeof value === 'string') bytes = value.length === KEY_LENGTH * 2 ? fromHex(value) : fromBase64(value); + if (bytes === null || bytes.length !== KEY_LENGTH) { + throw new TypeError(`${name} must be ${KEY_LENGTH} bytes — a Uint8Array, 64 hex characters or base64`); + } + return bytes; +}; + +const checkedKid = (value, name) => { + if (!isKid(value)) throw new TypeError(`${name} must be a key id: 1-32 of A-Z a-z 0-9 . _ -`); + return value; +}; + +const fromProvider = (provider, name) => + Object.freeze({ + get current() { + return checkedKid(provider.current(), `${name}.current()`); + }, + get kids() { + const kids = typeof provider.kids === 'function' ? provider.kids() : null; + return Array.isArray(kids) ? kids.filter(isKid) : [this.current]; + }, + get(kid) { + if (!isKid(kid)) return null; + const key = provider.get(kid); + return key instanceof Uint8Array && key.length === KEY_LENGTH ? key : null; + }, + }); + +/** + * `keys` in every spelling → `{ current, kids, get(kid) }`: + * one key (filed under '0'), `{ current, ring: { kid: key } }`, or a + * provider `{ current(), get(kid), kids?() }`. Strict: a ring without its + * current key, a bad kid or a key of the wrong size is a TypeError at + * construction, not a message that silently never opens. + */ +const normalizeKeys = (value, name = 'keys') => { + if (isKeyProvider(value)) return fromProvider(value, name); + const ring = new Map(); + let current = DEFAULT_KID; + if (isObject(value) && !(value instanceof Uint8Array)) { + if (!isObject(value.ring)) throw new TypeError(`${name}.ring must be an object of kid → key`); + for (const kid of Object.keys(value.ring)) { + ring.set( + checkedKid(kid, `${name}.ring: ${JSON.stringify(kid)}`), + parseKey(value.ring[kid], `${name}.ring.${kid}`), + ); + } + current = checkedKid(value.current, `${name}.current`); + if (!ring.has(current)) throw new TypeError(`${name}.current names a key the ring does not hold`); + } else { + ring.set(current, parseKey(value, name)); + } + const kids = Object.freeze(Array.from(ring.keys())); + return Object.freeze({ current, kids, get: (kid) => ring.get(kid) ?? null }); +}; + +/** A fresh 32-byte key from the platform's CSPRNG. */ +const generateKey = ({ crypto = globalThis.crypto } = {}) => randomSource(crypto)(KEY_LENGTH); + +module.exports = { normalizeKeys, parseKey, generateKey, isKid, KEY_LENGTH, DEFAULT_KID }; diff --git a/src/encryption/noise.js b/src/encryption/noise.js new file mode 100644 index 0000000..ee7abfd --- /dev/null +++ b/src/encryption/noise.js @@ -0,0 +1,431 @@ +'use strict'; + +// The Noise Protocol Framework (revision 34), the part of it a wrpc session +// uses: the one-round-trip patterns NN, NK and XX, the psk0 modifier on NN, +// over X25519, AES-256-GCM or ChaCha20-Poly1305, and SHA-256 — canonical +// names, checked against the cacophony vectors (tests/encryption/noise.test.js). +// One file for both platforms: the primitives arrive as the structural +// contracts of contracts.js (a Dh, a Cipher, a Kdf), so every step that +// touches one is awaited — crypto.subtle answers promises — and an +// application injects its own the same way (ML-KEM is not a Dh; a hybrid +// handshake is a different `Handshake`, not a parameter of this one). +// +// NN -> e anonymous: encrypted, nobody authenticated +// <- e, ee +// NK <- s the client pinned the server's static key: +// ... the server is authenticated, the client is +// -> e, es anyone — what TLS gives a browser +// <- e, ee +// XX -> e both statics travel (the client's under +// <- e, ee, s, es encryption): mutual authentication, the +// -> s, se server learns WHO in `authorize` +// NNpsk0 -> psk, e NN under a pre-shared key: two Node +// <- e, ee processes with a secret and no PKI +// +// A handshake runs per handshake; what runs per message is a CipherState +// (the last class here), and it is written for that. + +const { concat, utf8, isZero } = require('./bytes.js'); +const { isPromise } = require('../compression/ids.js'); + +const EMPTY = new Uint8Array(0); +const KEY_LENGTH = 32; +const ZERO_KEY = new Uint8Array(KEY_LENGTH); +// Noise caps a HANDSHAKE message at 65535 bytes; wrpc's transport messages +// are bounded by `maxMessage` instead, and the cipher does not care. +const MAX_HANDSHAKE_MESSAGE = 65535; + +// Pre-messages and message patterns. `i`/`r` is who writes. +const PATTERNS = Object.freeze({ + __proto__: null, + NN: { pre: [], messages: [['e'], ['e', 'ee']] }, + NK: { + pre: ['rs'], + messages: [ + ['e', 'es'], + ['e', 'ee'], + ], + }, + XX: { pre: [], messages: [['e'], ['e', 'ee', 's', 'es'], ['s', 'se']] }, + NNpsk0: { + pre: [], + psk: true, + messages: [ + ['psk', 'e'], + ['e', 'ee'], + ], + }, +}); + +// The name a cipher has in a Noise protocol name, and the way ITS nonce +// holds the 64-bit counter: big-endian for AESGCM, little-endian for +// ChaChaPoly (Noise §12), after four zero bytes either way. An injected +// cipher names itself and gets the big-endian layout at the end of whatever +// nonce length it declared. +const CIPHER_NAMES = Object.freeze({ __proto__: null, 'aes-256-gcm': 'AESGCM', 'chacha20-poly1305': 'ChaChaPoly' }); + +const cipherName = (cipher) => CIPHER_NAMES[cipher.id] ?? cipher.id; + +const MAX_NONCE = Number.MAX_SAFE_INTEGER; + +const nonceOf = (cipher, counter) => { + const nonce = new Uint8Array(cipher.nonceLength); + const end = cipher.nonceLength; + const high = Math.floor(counter / 0x100000000); + const low = counter >>> 0; + if (cipher.id === 'chacha20-poly1305') { + nonce[4] = low & 0xff; + nonce[5] = (low >>> 8) & 0xff; + nonce[6] = (low >>> 16) & 0xff; + nonce[7] = low >>> 24; + nonce[8] = high & 0xff; + nonce[9] = (high >>> 8) & 0xff; + nonce[10] = (high >>> 16) & 0xff; + nonce[11] = high >>> 24; + return nonce; + } + nonce[end - 8] = high >>> 24; + nonce[end - 7] = (high >>> 16) & 0xff; + nonce[end - 6] = (high >>> 8) & 0xff; + nonce[end - 5] = high & 0xff; + nonce[end - 4] = low >>> 24; + nonce[end - 3] = (low >>> 16) & 0xff; + nonce[end - 2] = (low >>> 8) & 0xff; + nonce[end - 1] = low & 0xff; + return nonce; +}; + +// The nonce Noise reserves for REKEY: 2^64 - 1, which no message counter +// reaches — eight 0xff bytes in either byte order. +const rekeyNonce = (cipher) => { + const nonce = new Uint8Array(cipher.nonceLength); + nonce.fill(0xff, cipher.nonceLength - 8); + return nonce; +}; + +/** + * One direction of an established session: a key, a message counter as the + * nonce, and a deterministic rekey every `rekeyAfter` messages — both ends + * count the same messages, so nothing announces it. `encrypt`/`decrypt` + * answer bytes when the cipher does and a promise when it does (or while a + * rekey over crypto.subtle is still resolving); the COUNTER is taken + * synchronously either way, so calls made in order are sealed in order and + * a Sequencer only has to keep the results so. Which is also why it + * advances on a decrypt that FAILS — Noise §5.1 leaves n unchanged there + * — a deliberate departure: an asynchronous cipher answers after the next + * call took its nonce, and a failed open ends the session in every + * caller (client.js, server.js), so no later frame is opened under it. + */ +class CipherState { + #cipher; + #key; + #counter = 0; + #rekeyAfter; + #sinceRekey = 0; + + constructor(cipher, key, rekeyAfter = 0) { + this.#cipher = cipher; + this.#key = key; + this.#rekeyAfter = rekeyAfter; + } + + get counter() { + return this.#counter; + } + + #next() { + if (this.#counter >= MAX_NONCE) throw new Error('encryption: the message counter is spent'); + if (this.#rekeyAfter > 0 && this.#sinceRekey === this.#rekeyAfter) this.rekey(); + this.#sinceRekey++; + return nonceOf(this.#cipher, this.#counter++); + } + + encrypt(plaintext, ad = EMPTY) { + const nonce = this.#next(); + const key = this.#key; + return isPromise(key) ? key.then((k) => k.seal(nonce, plaintext, ad)) : key.seal(nonce, plaintext, ad); + } + + decrypt(sealed, ad = EMPTY) { + const nonce = this.#next(); + const key = this.#key; + return isPromise(key) ? key.then((k) => k.open(nonce, sealed, ad)) : key.open(nonce, sealed, ad); + } + + /** Noise §11.3: k = ENCRYPT(k, 2^64 - 1, "", zeros). One-way: the old key cannot be had from the new. */ + rekey() { + const cipher = this.#cipher; + const keyOf = (sealed) => cipher.key(sealed.subarray(0, KEY_LENGTH)); + const derive = (key) => { + const sealed = key.seal(rekeyNonce(cipher), ZERO_KEY, EMPTY); + return isPromise(sealed) ? sealed.then(keyOf) : keyOf(sealed); + }; + const current = this.#key; + const next = isPromise(current) ? current.then(derive) : derive(current); + this.#key = next; + this.#sinceRekey = 0; + // Back to the synchronous path once the promise has settled. + if (isPromise(next)) { + next.then( + (key) => { + if (this.#key === next) this.#key = key; + }, + () => {}, + ); + } + } +} + +class SymmetricState { + #kdf; + #cipher; + ck; + h; + #key = null; + #n = 0; + + constructor(kdf, cipher) { + this.#kdf = kdf; + this.#cipher = cipher; + } + + async initialize(name) { + const bytes = utf8(name); + this.h = + bytes.length <= this.#kdf.hashLength + ? concat(bytes, new Uint8Array(this.#kdf.hashLength - bytes.length)) + : await this.#kdf.hash(bytes); + this.ck = this.h; + } + + async mixHash(data) { + this.h = await this.#kdf.hash(concat(this.h, data)); + } + + async #hkdf(ikm, outputs) { + // Noise's HKDF is RFC 5869 with the chaining key as the salt and no info. + return this.#kdf.expand(await this.#kdf.extract(this.ck, ikm), EMPTY, outputs * this.#kdf.hashLength); + } + + async #setKey(bytes) { + this.#key = await this.#cipher.key(bytes.slice(0, KEY_LENGTH)); + this.#n = 0; + } + + async mixKey(ikm) { + const out = await this.#hkdf(ikm, 2); + this.ck = out.slice(0, 32); + await this.#setKey(out.subarray(32, 64)); + } + + async mixKeyAndHash(ikm) { + const out = await this.#hkdf(ikm, 3); + this.ck = out.slice(0, 32); + await this.mixHash(out.subarray(32, 64)); + await this.#setKey(out.subarray(64, 96)); + } + + async encryptAndHash(plaintext) { + const out = + this.#key === null ? plaintext : await this.#key.seal(nonceOf(this.#cipher, this.#n++), plaintext, this.h); + await this.mixHash(out); + return out; + } + + async decryptAndHash(sealed) { + const out = this.#key === null ? sealed : await this.#key.open(nonceOf(this.#cipher, this.#n++), sealed, this.h); + await this.mixHash(sealed); + return out; + } + + get keyed() { + return this.#key !== null; + } + + async split(rekeyAfter) { + const out = await this.#hkdf(EMPTY, 2); + const first = new CipherState(this.#cipher, await this.#cipher.key(out.slice(0, 32)), rekeyAfter); + const second = new CipherState(this.#cipher, await this.#cipher.key(out.slice(32, 64)), rekeyAfter); + return [first, second]; + } +} + +class HandshakeState { + #symmetric; + #dh; + #pattern; + #initiator; + #s; + #e = null; + #rs; + #re = null; + #psk; + #fixedEphemeral; + #index = 0; + #rekeyAfter; + #tagLength; + + constructor(noise, initiator, options) { + this.#symmetric = new SymmetricState(noise.kdf, noise.cipher); + this.#dh = noise.dh; + this.#pattern = PATTERNS[noise.pattern]; + this.#initiator = initiator; + this.#s = options.staticKey ?? null; + this.#rs = options.remoteStatic ?? null; + this.#psk = options.psk ?? null; + this.#fixedEphemeral = options.ephemeral ?? null; + this.#rekeyAfter = options.rekeyAfter ?? 0; + this.#tagLength = noise.cipher.tagLength; + } + + async initialize(name, prologue) { + await this.#symmetric.initialize(name); + await this.#symmetric.mixHash(prologue); + // The one pre-message in use: the responder's static, known to both. + if (this.#pattern.pre.includes('rs')) { + await this.#symmetric.mixHash(this.#initiator ? this.#rs : this.#s.publicKey); + } + return this; + } + + /** True while it is this side's turn to write. */ + get writing() { + return !this.done && (this.#index % 2 === 0) === this.#initiator; + } + + get done() { + return this.#index >= this.#pattern.messages.length; + } + + /** The peer's static public key, once it has arrived (XX) or as pinned (NK). */ + get remoteStatic() { + return this.#rs; + } + + // A DH token names the initiator's key, then the responder's: 'es' is the + // initiator's ephemeral with the responder's static, whoever computes it. + async #dhToken(token) { + const localKind = this.#initiator ? token[0] : token[1]; + const remoteKind = this.#initiator ? token[1] : token[0]; + const local = localKind === 'e' ? this.#e : this.#s; + const remote = remoteKind === 'e' ? this.#re : this.#rs; + await this.#symmetric.mixKey(await this.#dh.dh(local.privateKey, remote)); + } + + async write(payload = EMPTY) { + if (!this.writing) throw new Error("encryption: not this side's turn to write"); + const parts = []; + for (const token of this.#pattern.messages[this.#index]) { + if (token === 'e') { + this.#e = this.#fixedEphemeral + ? await this.#dh.keyPair(this.#fixedEphemeral) + : await this.#dh.generateKeyPair(); + parts.push(this.#e.publicKey); + await this.#symmetric.mixHash(this.#e.publicKey); + if (this.#pattern.psk) await this.#symmetric.mixKey(this.#e.publicKey); + } else if (token === 's') { + parts.push(await this.#symmetric.encryptAndHash(this.#s.publicKey)); + } else if (token === 'psk') { + await this.#symmetric.mixKeyAndHash(this.#psk); + } else { + await this.#dhToken(token); + } + } + parts.push(await this.#symmetric.encryptAndHash(payload)); + this.#index++; + const message = concat(...parts); + if (message.length > MAX_HANDSHAKE_MESSAGE) throw new RangeError('encryption: handshake message too long'); + return message; + } + + async read(message) { + if (this.done || this.writing) throw new Error("encryption: not this side's turn to read"); + if (!(message instanceof Uint8Array) || message.length > MAX_HANDSHAKE_MESSAGE) { + throw new Error('encryption: malformed handshake message'); + } + const length = this.#dh.publicLength; + let offset = 0; + const take = (size) => { + if (offset + size > message.length) throw new Error('encryption: malformed handshake message'); + const part = message.subarray(offset, offset + size); + offset += size; + return part; + }; + for (const token of this.#pattern.messages[this.#index]) { + if (token === 'e') { + this.#re = Uint8Array.from(take(length)); + if (isZero(this.#re)) throw new Error('encryption: malformed handshake message'); + await this.#symmetric.mixHash(this.#re); + if (this.#pattern.psk) await this.#symmetric.mixKey(this.#re); + } else if (token === 's') { + const size = this.#symmetric.keyed ? length + this.#tagLength : length; + this.#rs = Uint8Array.from(await this.#symmetric.decryptAndHash(take(size))); + } else if (token === 'psk') { + await this.#symmetric.mixKeyAndHash(this.#psk); + } else { + await this.#dhToken(token); + } + } + const payload = await this.#symmetric.decryptAndHash(message.subarray(offset)); + this.#index++; + return payload; + } + + /** + * After the last message: `{ send, receive, handshakeHash, remoteStatic }`. + * The hash is unique to this handshake and both sides hold the same one — + * what an application binds a credential to (channel binding). + */ + async finish() { + if (!this.done) throw new Error('encryption: the handshake is not finished'); + const [first, second] = await this.#symmetric.split(this.#rekeyAfter); + return { + send: this.#initiator ? first : second, + receive: this.#initiator ? second : first, + handshakeHash: this.#symmetric.h, + remoteStatic: this.#rs, + }; + } +} + +const checkKey = (value, name) => { + if (!(value instanceof Uint8Array) || value.length !== KEY_LENGTH) { + throw new TypeError(`noise: ${name} must be 32 bytes`); + } +}; + +/** + * `{ name, pattern, initiator(options), responder(options) }` for one Noise + * protocol. Options: `prologue` (bytes both sides must agree on — anything + * negotiated before the handshake goes here, so tampering with it fails the + * handshake), `staticKey` (a Dh key pair: the responder's in NK, both in + * XX), `remoteStatic` (the initiator's pinned server key in NK), `psk` + * (NNpsk0), `rekeyAfter`. `ephemeral` fixes the ephemeral key — for test + * vectors, and nothing else. + */ +const createNoise = ({ pattern, dh, cipher, kdf }) => { + if (!(pattern in PATTERNS)) { + throw new TypeError(`noise: unknown pattern ${JSON.stringify(pattern)} — ${Object.keys(PATTERNS).join(', ')}`); + } + const name = `Noise_${pattern}_${dh.id}_${cipherName(cipher)}_${kdf.id}`; + const noise = { pattern, dh, cipher, kdf }; + const start = (initiator, options = {}) => { + const needsStatic = pattern === 'XX' || (pattern === 'NK' && !initiator); + if (needsStatic && !options.staticKey) throw new TypeError(`noise: ${pattern} needs a staticKey on this side`); + if (pattern === 'NK' && initiator) checkKey(options.remoteStatic, 'remoteStatic'); + if (PATTERNS[pattern].psk) checkKey(options.psk, 'psk'); + return new HandshakeState(noise, initiator, options).initialize(name, options.prologue ?? EMPTY); + }; + return Object.freeze({ + name, + pattern, + /** The cipher as the protocol name spells it: 'AESGCM', 'ChaChaPoly', or an injected cipher's id. */ + cipher: cipherName(cipher), + initiator: (options) => start(true, options), + responder: (options) => start(false, options), + }); +}; + +const PATTERN_NAMES = Object.freeze(Object.keys(PATTERNS)); + +module.exports = { createNoise, CipherState, PATTERN_NAMES, cipherName, MAX_HANDSHAKE_MESSAGE }; diff --git a/src/encryption/server.js b/src/encryption/server.js new file mode 100644 index 0000000..aad1fab --- /dev/null +++ b/src/encryption/server.js @@ -0,0 +1,508 @@ +'use strict'; + +// The server half of session encryption. Node-only, and deliberately NOT a +// change to the dispatcher: `SealedSocket` wraps the engine's socket and is +// what RpcServer.attachSocket is handed instead. It runs the handshake, +// then re-announces every decrypted message exactly as an engine socket +// would — ('message', data, isBinary) — so the framed kinds, compression, +// attachments and stream chunks below it work unchanged, and compression +// lands inside the sealed frame (compress, then seal) by construction. One +// wrapper serves the built-in engine, uWebSockets.js and WebTransport. +// +// What it costs, and the bench that says so (bench/encryption.js): every +// recipient of a broadcast has its own key, so the single prepared frame a +// fan-out shares cannot be used — an emit to N clients is N seals, each +// under its own nonce. What `sendPrepared` does share is the PLAINTEXT +// inner frame, built once on the shared message instead of once per +// recipient (the fan-out rows of the bench). permessage-deflate is told to +// leave the frames alone: ciphertext does not compress. + +const { EventEmitter } = require('node:events'); +const { aead, ALGORITHMS } = require('./aead.js'); +const { x25519 } = require('./dh.js'); +const { createKdf } = require('./hkdf.js'); +const { normalizeKeys, parseKey, isKid } = require('./keyring.js'); +const { createNoise, PATTERN_NAMES } = require('./noise.js'); +const { deriveStatics, formatBundle } = require('./statics.js'); +const { createHpke, dhKem, AEAD_IDS } = require('./hpke.js'); +const { createHttpSealing, DEFAULT_MAX_SKEW } = require('./httpServer.js'); +const { randomSource } = require('./bytes.js'); +const { Sequencer } = require('../sequencer.js'); +const { ENCRYPTION_PARAM } = require('../wire.js'); +const { + SecureChannel, + innerOf, + parseHello, + prologueOf, + frame, + isFrame, + FRAME_HANDSHAKE, + DEFAULT_REKEY_AFTER, + DEFAULT_HANDSHAKE_TIMEOUT, +} = require('./session.js'); + +const DEFAULT_PATTERNS = Object.freeze(['NK', 'XX']); +// What may pile up behind an unfinished handshake: an onConnect hook or a +// broadcast can send before the peer has said anything. +const MAX_QUEUED = 256; +// What a Noise protocol name looks like — the shape a log line may carry. +const PROTOCOL_NAME = /^Noise_[A-Za-z0-9]+_[A-Za-z0-9]+_[A-Za-z0-9-]+_[A-Za-z0-9]+$/; +const NO_COMPRESS = Object.freeze({ compress: false }); +const CLOSE_POLICY = 1008; +const CLOSE_PROTOCOL = 1002; + +const listOf = (value, allowed, fallback, name) => { + if (value === undefined || value === null) return fallback; + if (!Array.isArray(value) || value.length === 0 || !value.every((entry) => allowed.includes(entry))) { + throw new TypeError(`${name} must be a non-empty list of ${allowed.join(', ')}`); + } + return Object.freeze([...new Set(value)]); +}; + +/** + * `encryption` of an RpcServer → the normalized option, or null for off: + * `{ keys, required, patterns, ciphers, psk, authorize, handshakeTimeout, + * rekeyAfter, statics(kid), bundle() }`. `keys` is the usual keyring — one + * secret per kid, from which the static key pairs are derived (statics.js); + * the CURRENT kid is what discovery publishes, and a client names the kid it + * pinned, so an old pin keeps working while its key is on the ring. + */ +const normalizeServerEncryption = (value, name) => { + if (value === undefined || value === null || value === false) return null; + if (typeof value !== 'object' || Array.isArray(value) || value.keys === undefined) { + throw new TypeError(`${name}: encryption must be { keys, required?, patterns?, ciphers?, psk?, authorize?, … }`); + } + const { + required = false, + authorize = null, + psk = null, + handshakeTimeout = DEFAULT_HANDSHAKE_TIMEOUT, + rekeyAfter = DEFAULT_REKEY_AFTER, + maxSkew = DEFAULT_MAX_SKEW, + replay = null, + discovery = true, + } = value; + if (!(Number.isInteger(maxSkew) && maxSkew > 0)) { + throw new TypeError(`${name}: encryption.maxSkew must be a positive integer (ms)`); + } + // A shared memory (`seen`), or the built-in cache's own knobs. + if (replay !== null && (typeof replay !== 'object' || Array.isArray(replay))) { + throw new TypeError(`${name}: encryption.replay must be { seen(id, ttl) } or { max, overflow }`); + } + if (replay !== null && replay.seen !== undefined && typeof replay.seen !== 'function') { + throw new TypeError(`${name}: encryption.replay must be { seen(id, ttl) } or { max, overflow }`); + } + if (replay !== null && typeof replay.seen !== 'function') { + const { max, overflow } = replay; + if (max !== undefined && !(Number.isInteger(max) && max > 0)) { + throw new TypeError(`${name}: encryption.replay.max must be a positive integer`); + } + if (overflow !== undefined && overflow !== 'refuse' && overflow !== 'evict') { + throw new TypeError(`${name}: encryption.replay.overflow must be 'refuse' or 'evict'`); + } + } + if (typeof discovery !== 'boolean') throw new TypeError(`${name}: encryption.discovery must be a boolean`); + if (typeof required !== 'boolean') throw new TypeError(`${name}: encryption.required must be a boolean`); + if (authorize !== null && typeof authorize !== 'function') { + throw new TypeError(`${name}: encryption.authorize must be a function`); + } + if (!(Number.isInteger(handshakeTimeout) && handshakeTimeout > 0)) { + throw new TypeError(`${name}: encryption.handshakeTimeout must be a positive integer`); + } + if (!(Number.isInteger(rekeyAfter) && rekeyAfter >= 0)) { + throw new TypeError(`${name}: encryption.rekeyAfter must be a non-negative integer`); + } + const patterns = listOf(value.patterns, PATTERN_NAMES, DEFAULT_PATTERNS, `${name}: encryption.patterns`); + const ciphers = listOf(value.ciphers, ALGORITHMS, ALGORITHMS, `${name}: encryption.ciphers`); + const sharedKey = psk === null ? null : parseKey(psk, `${name}: encryption.psk`); + if (patterns.includes('NNpsk0') && sharedKey === null) { + throw new TypeError(`${name}: encryption.patterns lists NNpsk0, which needs encryption.psk`); + } + const keys = normalizeKeys(value.keys, `${name}: encryption.keys`); + const dh = x25519(); + const kdf = createKdf(); + // One Noise object per protocol name a client may send, by that name: a + // name arrives from the peer and is looked up, never parsed. + const protocols = new Map(); + for (const pattern of patterns) { + for (const id of ciphers) { + const noise = createNoise({ pattern, dh, cipher: aead({ algorithm: id }), kdf }); + protocols.set(noise.name, noise); + } + } + // The per-request binding's suites, by the AEAD id a request names. + const kem = dhKem(dh, kdf); + const suites = new Map(); + for (const id of ciphers) { + const cipher = aead({ algorithm: id }); + suites.set(AEAD_IDS[id], { cipher, hpke: createHpke({ kem, kdf, cipher }) }); + } + // kid → promise of the derived static key pairs, derived once — but the + // RING is asked first, every time: a kid a live provider no longer answers + // (a key withdrawn because it leaked) stops finishing handshakes and + // opening requests with that lookup, not at the next restart. One + // synchronous get per handshake or sealed request, never per packet. (A + // kid names ONE key: bytes replaced under the same kid are not seen.) + const derived = new Map(); + const statics = (kid) => { + const secret = keys.get(kid); + if (secret === null) { + derived.delete(kid); + return null; + } + let pending = derived.get(kid); + if (pending === undefined) { + pending = deriveStatics(secret, { dh, kdf }); + derived.set(kid, pending); + // A derivation that failed is not the answer for this kid forever: + // forgotten, so the next hello derives again (and the cached + // rejection is not an unhandled one when nobody is waiting on it). + pending.catch(() => { + if (derived.get(kid) === pending) derived.delete(kid); + }); + } + return pending; + }; + return Object.freeze({ + keys, + required, + patterns, + ciphers, + psk: sharedKey, + authorize, + handshakeTimeout, + rekeyAfter, + protocols, + statics, + discovery, + /** The per-request (HPKE) half, built by the core with its logger and its way of refusing. */ + http: ({ log, refuse, reserved = null }) => + createHttpSealing({ + suites, + statics, + kdf, + maxSkew, + replay: replay ?? undefined, + random: randomSource(), + log, + refuse, + reserved, + }), + /** The public bundle of the current key — what a client pins, safe to publish. */ + bundle: async () => formatBundle(keys.current, await statics(keys.current)), + }); +}; + +/** True when the connect URL announces session encryption. */ +const wantsEncryption = (url) => { + if (typeof url !== 'string') return false; + const query = url.indexOf('?'); + if (query === -1) return false; + return new URLSearchParams(url.slice(query + 1)).get(ENCRYPTION_PARAM) === '1'; +}; + +const asBuffer = (bytes) => + Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); + +/** + * An engine socket under session encryption. `ready` resolves with the + * session's facts — `{ protocol, pattern, cipher, kid, remoteStatic, + * handshakeHash }` — and never rejects: a connection that fails or is + * refused resolves `null` after the socket was closed, so whoever awaits it + * (the session restore) needs no error path of its own. + */ +class SealedSocket extends EventEmitter { + #socket; + #encryption; + #kind; + #log; + #channel = null; + #handshake = null; + #noise = null; + #kid = ''; + #outbound; + #inbound; + #queue = []; + // The queue holds direct sends and shared (broadcast) messages in one + // order, counted apart: a direct flood before the handshake closes the + // connection, a broadcast flood drops for THIS client and is said once. + #queuedShared = 0; + #droppedShared = 0; + #pending = 0; + #chain = Promise.resolve(); + #dead = false; + #record; + #timer; + #resolve; + + constructor(socket, { encryption, kind = 'ws', log, refuse = null, record = null }) { + super(); + this.#socket = socket; + this.#encryption = encryption; + this.#kind = kind; + this.#log = log; + this.#record = record; + this.ready = new Promise((resolve) => { + this.#resolve = resolve; + }); + const violation = () => this.#end(CLOSE_PROTOCOL, 'crypto'); + this.#outbound = new Sequencer(violation); + this.#inbound = new Sequencer(violation); + socket.on('close', (...args) => { + this.#dead = true; + clearTimeout(this.#timer); + this.#resolve(null); + this.emit('close', ...args); + }); + socket.on('drain', () => this.emit('drain')); + socket.on('error', (error) => this.emit('error', error)); + if (refuse !== null) return void this.#end(CLOSE_POLICY, refuse); + this.#timer = setTimeout(() => this.#end(CLOSE_POLICY, 'timeout'), encryption.handshakeTimeout); + if (typeof this.#timer.unref === 'function') this.#timer.unref(); + socket.on('message', (data, isBinary) => this.#onMessage(data, isBinary)); + } + + get remoteAddress() { + return this.#socket.remoteAddress; + } + + get protocol() { + return this.#socket.protocol; + } + + get bufferedAmount() { + return this.#socket.bufferedAmount ?? 0; + } + + // One reason on the wire for every failure — which check it was is for + // this log only. 1008 is policy (refused, timed out, not allowed), 1002 + // anything that did not verify, open or parse. + // ONE line per refusal, `encryption.refused`, at the level the reason + // deserves: a peer's doing is warn; a failure on this side — a key + // provider that threw, an authorize hook that threw — is error, with the + // err. `extra` carries what is safe to say (a kid that IS a kid, a + // protocol name that IS one; else their lengths — peer text stays out). + #end(code, reason, extra = null, level = 'warn') { + if (this.#dead) return; + this.#dead = true; + clearTimeout(this.#timer); + this.#queue.length = 0; + this.#queuedShared = 0; + this.#droppedShared = 0; + this.#log[level]({ ...extra, event: 'encryption.refused', reason, kind: this.#kind }); + this.#record?.(reason); + this.#resolve(null); + if (typeof this.#socket.close === 'function') this.#socket.close(code, 'encryption'); + else this.#socket.terminate(); + } + + #onMessage(data, isBinary) { + if (this.#dead) return; + if (!isBinary) return void this.#end(CLOSE_PROTOCOL, 'plaintext'); + const bytes = asBuffer(data); + // Established and nothing of the handshake still in flight: the hot path. + if (this.#channel !== null && this.#pending === 0) return void this.#open(bytes); + this.#pending++; + this.#chain = this.#chain + .then(() => this.#step(bytes)) + .then( + () => void this.#pending--, + (error) => { + this.#log.debug?.({ event: 'encryption.handshake', err: error }); + this.#end(CLOSE_PROTOCOL, 'handshake'); + }, + ); + } + + #open(bytes) { + let message; + try { + message = this.#channel.open(bytes); + } catch { + return void this.#end(CLOSE_PROTOCOL, 'crypto'); + } + this.#inbound.push( + message, + (value) => { + if (!this.#dead) this.emit('message', value, typeof value !== 'string'); + }, + () => this.#end(CLOSE_PROTOCOL, 'crypto'), + ); + } + + async #step(bytes) { + if (this.#dead) return; + if (this.#channel !== null) return void this.#open(bytes); + if (this.#handshake === null) return this.#hello(bytes); + if (!isFrame(bytes, FRAME_HANDSHAKE)) throw new Error('not a handshake frame'); + await this.#handshake.read(bytes.subarray(2)); + await this.#establish(); + } + + async #hello(bytes) { + const hello = parseHello(bytes); + if (hello === null) throw new Error('malformed hello'); + const encryption = this.#encryption; + const noise = encryption.protocols.get(hello.name); + // Refused, never negotiated: answering with what WOULD be accepted is + // the downgrade this design does not have. The name goes in the line + // only when it is shaped like one; anything else is its length. + if (noise === undefined) { + const named = PROTOCOL_NAME.test(hello.name) && hello.name.length <= 64; + return void this.#end( + CLOSE_POLICY, + 'protocol', + named ? { protocol: hello.name } : { nameLength: hello.name.length }, + ); + } + // NN and NNpsk0 name no server key; the others name the one they pinned. + const usesStatic = noise.pattern === 'NK' || noise.pattern === 'XX'; + const kid = usesStatic && hello.kid === '' ? encryption.keys.current : hello.kid; + let statics = null; + if (usesStatic) { + // A key provider that throws is this side's failure, not the peer's. + try { + statics = await encryption.statics(kid); + } catch (error) { + return void this.#end(CLOSE_POLICY, 'keys', { err: error, ...(isKid(kid) ? { kid } : {}) }, 'error'); + } + if (statics === null) { + return void this.#end(CLOSE_POLICY, 'kid', isKid(kid) ? { kid } : { kidLength: kid.length }); + } + } + this.#noise = noise; + this.#kid = usesStatic ? kid : ''; + this.#handshake = await noise.responder({ + prologue: prologueOf(this.#kind, hello.header), + staticKey: statics === null ? undefined : statics.noise, + psk: encryption.psk ?? undefined, + rekeyAfter: encryption.rekeyAfter, + }); + await this.#handshake.read(hello.message); + this.#socket.send(asBuffer(frame(FRAME_HANDSHAKE, await this.#handshake.write())), NO_COMPRESS); + if (this.#handshake.done) await this.#establish(); + } + + async #establish() { + const done = await this.#handshake.finish(); + const noise = this.#noise; + const info = Object.freeze({ + protocol: noise.name, + pattern: noise.pattern, + cipher: noise.cipher, + kid: this.#kid, + remoteStatic: done.remoteStatic, + handshakeHash: done.handshakeHash, + }); + const { authorize } = this.#encryption; + if (authorize !== null) { + let allowed; + try { + allowed = await authorize(info); + } catch (error) { + // The hook threw: this side's failure, said as one — not a peer's + // 'handshake' at debug, which is where it used to land. + return void this.#end(CLOSE_POLICY, 'hook', { err: error }, 'error'); + } + if (allowed === false) return void this.#end(CLOSE_POLICY, 'authorize'); + } + if (this.#dead) return; + clearTimeout(this.#timer); + this.#channel = new SecureChannel(done); + this.#handshake = null; + this.#log.debug?.({ event: 'encryption.established', protocol: noise.name, kind: this.#kind }); + this.#record?.('established'); + const queue = this.#queue; + this.#queue = []; + this.#queuedShared = 0; + for (let i = 0; i < queue.length; i++) this.send(queue[i]); + if (this.#droppedShared > 0) { + this.#log.warn({ event: 'encryption.queue.dropped', count: this.#droppedShared, kind: this.#kind }); + this.#droppedShared = 0; + } + this.#resolve(info); + } + + // A message prepared once for a fan-out (Broadcast.emit): the engine's + // sendPrepared would write its frames as they are, which a sealed + // connection cannot — every recipient seals its own copy. Before the + // handshake it is held like a direct send, but under its own bound: a + // busy room must not cost a connecting client its connection, so past + // the bound the rest is dropped for this client, counted, and said once + // when the handshake completes. A client is a member of nothing before + // open() resolves; a broadcast during its handshake is best effort. + // + // Established, what the recipients of one emit share is the plaintext + // INNER frame — the kind byte and the text's UTF-8, or the bytes — built + // by the first sealed recipient into the message's `inner` slot. The seal + // stays one per recipient, its own nonce and ciphertext; what is saved is + // the byteLength + alloc + write of the same string N times: 3.6 → 3.2 µs + // a recipient at 1 KB, 14.5 → 9.9 at 16 KB (bench/encryption.js, the + // sealed fan-out rows). The cipher does not write to its plaintext, and a server + // session takes built-in ciphers only. + sendPrepared(message) { + if (this.#dead) return false; + if (this.#channel !== null) { + const inner = (message.inner ??= innerOf(message.text)); + return this.#push(this.#channel.sealInner(inner)); + } + if (this.#queuedShared >= MAX_QUEUED) { + this.#droppedShared++; + return false; + } + this.#queuedShared++; + this.#queue.push(message.text); + return true; + } + + send(data) { + if (this.#dead) return false; + if (this.#channel === null) { + // Sent before the peer finished the handshake — an onConnect hook's + // event, an answer: held, in order, up to a bound; past it the + // connection is not worth the memory. Shared messages are counted + // apart (sendPrepared). + if (this.#queue.length - this.#queuedShared >= MAX_QUEUED) { + this.#end(CLOSE_POLICY, 'queue'); + return false; + } + this.#queue.push(data); + return true; + } + return this.#push(this.#channel.seal(data)); + } + + // One sealed frame — a value, or a promise of one — onto the wire in the + // order it was sealed in. Answers the backpressure signal when the cipher + // is synchronous (node:crypto is), true otherwise. + #push(sealed) { + let sent = true; + this.#outbound.push( + sealed, + (frame) => { + if (!this.#dead) sent = this.#socket.send(asBuffer(frame), NO_COMPRESS); + }, + () => this.#end(CLOSE_PROTOCOL, 'crypto'), + ); + return sent; + } + + close(code, reason) { + if (typeof this.#socket.close === 'function') this.#socket.close(code, reason); + else this.#socket.terminate(); + } + + terminate() { + this.#socket.terminate(); + } + + pause() { + if (typeof this.#socket.pause === 'function') this.#socket.pause(); + } + + resume() { + if (typeof this.#socket.resume === 'function') this.#socket.resume(); + } +} + +module.exports = { normalizeServerEncryption, wantsEncryption, SealedSocket, DEFAULT_PATTERNS, MAX_QUEUED }; diff --git a/src/encryption/session.js b/src/encryption/session.js new file mode 100644 index 0000000..a33de84 --- /dev/null +++ b/src/encryption/session.js @@ -0,0 +1,171 @@ +'use strict'; + +// A wrpc session over a finished Noise handshake — the framing both ends +// share, on both platforms (no Buffer: this is bundled for a page). +// +// handshake, first 00 05 ‖ u8 version (1) ‖ u8 nameLength ‖ protocol name +// ‖ u8 kidLength ‖ kid ‖ noise message +// handshake, later 00 05 ‖ noise message +// sealed 00 06 ‖ AEAD( u8 inner ‖ payload ) inner: 0 text, 1 bytes +// +// The first message NAMES the protocol — pattern, DH, cipher, hash — and the +// key id of the server static it was written for; nothing is negotiated +// back. Noise has no negotiation on purpose: the name initializes the +// handshake hash, and the whole header is fed to the PROLOGUE with the wire +// revision and the transport kind, so a header rewritten in flight, or a +// handshake replayed onto another kind of connection, fails the handshake +// rather than weakening it. A server that does not hold what was named +// refuses; it never answers "try this instead" — that reply would be the +// downgrade. +// +// Everything after the handshake rides sealed: text packets, stream chunks +// and the framed kinds of wire.js alike, as the payload of a kind-6 frame. +// Compression therefore happens INSIDE (compress, then seal). + +const { FRAME_MARK, FRAME_HANDSHAKE, FRAME_SEALED } = require('../wire.js'); +const { concat, utf8 } = require('./bytes.js'); +const { isPromise } = require('../compression/ids.js'); + +const HELLO_VERSION = 1; +const INNER_TEXT = 0; +const INNER_BYTES = 1; +const DEFAULT_REKEY_AFTER = 1 << 20; +const DEFAULT_HANDSHAKE_TIMEOUT = 10000; + +const decoder = new TextDecoder('utf-8', { fatal: true }); +const encoder = new TextEncoder(); + +/** The part of the first handshake message before the Noise bytes — and of the prologue. */ +const helloHeader = (name, kid) => { + const nameBytes = utf8(name); + const kidBytes = utf8(kid); + if (nameBytes.length > 255 || kidBytes.length > 255) throw new TypeError('encryption: protocol name or kid too long'); + return concat(Uint8Array.of(HELLO_VERSION, nameBytes.length), nameBytes, Uint8Array.of(kidBytes.length), kidBytes); +}; + +// The label of THIS framing, bound into the handshake — not the revision of +// the packet core a connection negotiated (wire.js): the session format has +// its own version byte and changes on its own, so a core revision must not +// change the bytes two peers hash here. +const PROLOGUE_LABEL = 'wrpc.v1'; + +const prologueOf = (kind, header) => concat(utf8(`${PROLOGUE_LABEL}\0${kind}\0`), header); + +const isFrame = (bytes, kind) => bytes.length >= 2 && bytes[0] === FRAME_MARK && bytes[1] === kind; + +const frame = (kind, ...parts) => concat(Uint8Array.of(FRAME_MARK, kind), ...parts); + +/** `{ name, kid, header, message }` from a first handshake frame, or null. */ +const parseHello = (bytes) => { + if (!isFrame(bytes, FRAME_HANDSHAKE) || bytes.length < 5 || bytes[2] !== HELLO_VERSION) return null; + const nameEnd = 4 + bytes[3]; + if (nameEnd >= bytes.length) return null; + const kidEnd = nameEnd + 1 + bytes[nameEnd]; + if (kidEnd > bytes.length) return null; + try { + return { + name: decoder.decode(bytes.subarray(4, nameEnd)), + kid: decoder.decode(bytes.subarray(nameEnd + 1, kidEnd)), + header: bytes.subarray(2, kidEnd), + message: bytes.subarray(kidEnd), + }; + } catch { + return null; + } +}; + +const asBytes = (data) => { + if (data instanceof Uint8Array) return data; + if (data instanceof ArrayBuffer) return new Uint8Array(data); + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); +}; + +// Node's own string writer, where there is one: it sizes the buffer exactly, +// where encodeInto needs room for three bytes per UTF-16 unit — 4.8 µs +// against 10.7 on a 16 KB packet, 0.4 against 1.0 on 1 KB +// (bench/encryption.js, the inner-frame rows). A `typeof` guard, so a +// browser bundle neither needs nor polyfills it. +const NodeBuffer = typeof Buffer === 'undefined' ? null : Buffer; + +// One message as the plaintext of a sealed frame: the inner kind, then the +// text as UTF-8 or the bytes as they are. +const innerOf = (data, buffer = NodeBuffer) => { + if (typeof data === 'string' && buffer !== null) { + const inner = buffer.allocUnsafe(1 + buffer.byteLength(data)); + inner[0] = INNER_TEXT; + inner.write(data, 1); + return inner; + } + if (typeof data === 'string') { + // encodeInto writes at most three bytes per UTF-16 unit. + const scratch = new Uint8Array(1 + data.length * 3); + scratch[0] = INNER_TEXT; + return scratch.subarray(0, 1 + encoder.encodeInto(data, scratch.subarray(1)).written); + } + const bytes = asBytes(data); + const inner = new Uint8Array(1 + bytes.length); + inner[0] = INNER_BYTES; + inner.set(bytes, 1); + return inner; +}; + +const sealedFrame = (sealed) => { + const out = new Uint8Array(2 + sealed.length); + out[0] = FRAME_MARK; + out[1] = FRAME_SEALED; + out.set(sealed, 2); + return out; +}; + +// A sealed frame's plaintext back into what was sent: a string, or bytes. +const messageOf = (inner) => { + if (inner.length === 0 || inner[0] > INNER_BYTES) throw new Error('encryption: malformed sealed message'); + return inner[0] === INNER_TEXT ? decoder.decode(inner.subarray(1)) : inner.subarray(1); +}; + +/** + * The two directions of an established session. `seal(data)` answers the + * frame to put on the wire and `open(frame)` the message that was sent — + * each a plain value when the cipher is synchronous (node:crypto) and a + * promise when it is not (crypto.subtle); the nonce is taken when the call + * is made, so the CALLER keeps the results in call order (a Sequencer). + */ +class SecureChannel { + #send; + #receive; + + constructor({ send, receive }) { + this.#send = send; + this.#receive = receive; + } + + seal(data) { + return this.sealInner(innerOf(data)); + } + + sealInner(inner) { + const sealed = this.#send.encrypt(inner); + return isPromise(sealed) ? sealed.then(sealedFrame) : sealedFrame(sealed); + } + + open(bytes) { + if (!isFrame(bytes, FRAME_SEALED)) throw new Error('encryption: not a sealed frame'); + const inner = this.#receive.decrypt(bytes.subarray(2)); + return isPromise(inner) ? inner.then(messageOf) : messageOf(inner); + } +} + +module.exports = { + SecureChannel, + innerOf, + helloHeader, + prologueOf, + parseHello, + frame, + isFrame, + asBytes, + FRAME_HANDSHAKE, + FRAME_SEALED, + DEFAULT_REKEY_AFTER, + DEFAULT_HANDSHAKE_TIMEOUT, +}; diff --git a/src/encryption/statics.js b/src/encryption/statics.js new file mode 100644 index 0000000..e2b88d4 --- /dev/null +++ b/src/encryption/statics.js @@ -0,0 +1,63 @@ +'use strict'; + +// A server's long-lived keys, from ONE configured secret per key id. The +// session handshake (Noise) and the per-request one (HPKE) must never share +// a private key — two protocols over one key is how a proof about either +// stops holding — so each is derived under its own label, and an operator +// still stores, rotates and pins a single 32-byte value. +// +// noise static = X25519( HKDF(secret, "wrpc noise static v1") ) +// hpke static = X25519( HKDF(secret, "wrpc hpke static v1") ) +// +// What a client pins is the BUNDLE, public and safe to publish: +// +// :: +// +// One file for both platforms: a Node server derives, a client only parses. + +const { utf8, toBase64Url, fromBase64 } = require('./bytes.js'); +const { isKid } = require('./keyring.js'); + +const EMPTY = new Uint8Array(0); +const NOISE_LABEL = utf8('wrpc noise static v1'); +const HPKE_LABEL = utf8('wrpc hpke static v1'); + +/** `{ noise, hpke }` key pairs for one keyring secret. */ +const deriveStatics = async (secret, { dh, kdf }) => { + const [noiseSeed, hpkeSeed] = await Promise.all([ + kdf.derive(secret, EMPTY, NOISE_LABEL, 32), + kdf.derive(secret, EMPTY, HPKE_LABEL, 32), + ]); + try { + const [noise, hpke] = await Promise.all([dh.keyPair(noiseSeed), dh.keyPair(hpkeSeed)]); + return { noise, hpke }; + } finally { + noiseSeed.fill(0); + hpkeSeed.fill(0); + } +}; + +const formatBundle = (kid, statics) => + `${kid}:${toBase64Url(statics.noise.publicKey)}:${toBase64Url(statics.hpke.publicKey)}`; + +/** `{ kid, noise, hpke }` (public keys as bytes) from a bundle string or the same object. */ +const parseBundle = (value, name = 'serverKey') => { + let kid; + let noise; + let hpke; + if (typeof value === 'string') { + const parts = value.split(':'); + if (parts.length === 3) [kid, noise, hpke] = [parts[0], fromBase64(parts[1]), fromBase64(parts[2])]; + } else if (typeof value === 'object' && value !== null) { + ({ kid, noise, hpke } = value); + if (typeof noise === 'string') noise = fromBase64(noise); + if (typeof hpke === 'string') hpke = fromBase64(hpke); + } + const isKey = (key) => key instanceof Uint8Array && key.length === 32; + if (!isKid(kid) || !isKey(noise) || !isKey(hpke)) { + throw new TypeError(`encryption: ${name} must be a key bundle — "::"`); + } + return { kid, noise, hpke }; +}; + +module.exports = { deriveStatics, formatBundle, parseBundle }; diff --git a/src/encryption/store.js b/src/encryption/store.js new file mode 100644 index 0000000..acaad62 --- /dev/null +++ b/src/encryption/store.js @@ -0,0 +1,251 @@ +'use strict'; + +// A session store that holds nothing readable. `sessions: { store }` takes +// any `{ get, set, delete, touch? }`; this wraps one so that what rests in +// it — Redis, a table, a dump of either — is neither the session's state +// nor its token: +// +// key = base64url( HMAC-SHA256( index key, token ) ) +// value = { v: 1, kid, s: base64( sealed JSON ) } +// +// The token is a bearer credential and today it IS the Redis key, so a +// keyspace listing is a list of live sessions. An HMAC of it finds the row +// without resting the credential; the state is sealed with the same frame +// the backplane envelopes use (envelope.js — a key per writing process, a +// counter nonce), with the ROW'S KEY as additional data, so a row copied +// into another session's slot does not open there. +// +// Rotation must not sign everybody out, and the index key rotates with the +// rest: a read misses under the current kid, finds the row under an older +// one, and moves it — so a kid can be dropped once the longest session TTL +// has passed since it stopped being current. `acceptPlaintext` is the same +// idea for the first deploy: a row the unwrapped store wrote is read once, +// sealed, and its plaintext deleted. + +const crypto = require('node:crypto'); +const { normalizeEnvelopeEncryption, createEnvelopeSealer } = require('./envelope.js'); +const { isKeyProvider, OpenError } = require('./contracts.js'); +const { createLoggerWriter } = require('../logging.js'); + +const isFunction = (value) => typeof value === 'function'; +const CREATE = Object.freeze({ create: true }); +const UPDATE = Object.freeze({ create: false }); + +const isRecord = (value) => + typeof value === 'object' && + value !== null && + value.v === 1 && + typeof value.kid === 'string' && + typeof value.s === 'string'; + +// What a session's state is: an object, never an array or a scalar — and +// never a sealed record, which is a row of this store under the wrong +// name, not a session. +const isState = (value) => typeof value === 'object' && value !== null && !Array.isArray(value) && !isRecord(value); + +const sealedStore = (store, options = {}) => { + if (!store || !isFunction(store.get) || !isFunction(store.set) || !isFunction(store.delete)) { + throw new TypeError('sealedStore: store must be a session store with get(token), set(token, data), delete(token)'); + } + const { acceptPlaintext = false, seal = true, logger = globalThis.console } = options; + if (typeof acceptPlaintext !== 'boolean') { + throw new TypeError('sealedStore: options.acceptPlaintext must be a boolean'); + } + if (typeof seal !== 'boolean') throw new TypeError('sealedStore: options.seal must be a boolean'); + // A rotation is walked through kids(): a provider without it would keep + // every session under an older kid unreadable — a mass logout the + // moment `current` moved. Refused here, not discovered at the rotation. + if (isKeyProvider(options.keys) && typeof options.keys.kids !== 'function') { + throw new TypeError('sealedStore: a key provider must implement kids() — the kids a rotation is walked through'); + } + const encryption = normalizeEnvelopeEncryption( + { keys: options.keys, cipher: options.cipher, replayWindow: false }, + 'sealedStore: options', + ); + const { keys } = encryption; + const sealer = createEnvelopeSealer({ encryption, layer: 'store', echo: true }); + const log = createLoggerWriter(logger).child({ component: 'sessions', store: 'sealed' }); + + // kid → the key tokens are indexed under, derived once. + const indexKeys = new Map(); + const slot = (kid, token) => { + let indexKey = indexKeys.get(kid); + if (indexKey === undefined) { + const master = keys.get(kid); + if (master === null) return null; + indexKey = Buffer.from(crypto.hkdfSync('sha256', master, Buffer.alloc(0), `wrpc store-index v1\0${kid}`, 32)); + indexKeys.set(kid, indexKey); + } + return crypto.createHmac('sha256', indexKey).update(String(token)).digest('base64url'); + }; + + const sealedKeys = (token) => { + const current = keys.current; + const found = [[current, slot(current, token)]]; + const kids = keys.kids; + for (let i = 0; i < kids.length; i++) { + if (kids[i] !== current) found.push([kids[i], slot(kids[i], token)]); + } + return found; + }; + + // Under the current kid; with `prune`, the token's slots under every + // other kid go too — one row per token, so a fleet mid-rotation (one + // instance writing under k1, another under k2) converges on the latest + // write instead of each reading its own kid's stale row. + const write = async (token, data, prune, create = true) => { + // seal: false — the first of the three deploys (sessions guide): every + // instance reads sealed rows already, none writes them yet, so a + // rollback finds every session where it always was. The token's sealed + // slots go, best effort: one row per token. + if (!seal) { + if ((await store.set(token, data, create ? CREATE : UPDATE)) === false) return false; + const slots = sealedKeys(token); + for (let i = 0; i < slots.length; i++) { + if (slots[i][1] === null) continue; + try { + await store.delete(slots[i][1]); + } catch (error) { + log.warn({ err: error, event: 'session.migrate' }); + } + } + return; + } + const kid = keys.current; + const key = slot(kid, token); + if (key === null) throw new Error(`sealedStore: the keyring does not hold its current key ${JSON.stringify(kid)}`); + const { sealed } = sealer.seal(Buffer.from(JSON.stringify(data)), key); + let options = create ? CREATE : UPDATE; + if (!create) { + // An update that finds no row under the current kid: the row may + // still live under an older kid (or, adopting, in plaintext) — then + // this write is its move — or be gone, which is a false to answer. + const slots = sealedKeys(token); + let found = false; + for (let i = 1; i < slots.length && !found; i++) { + found = slots[i][1] !== null && (await store.get(slots[i][1])) != null; + } + if (!found && acceptPlaintext) found = (await store.get(token)) != null; + if (found) options = CREATE; + } + if ((await store.set(key, { v: 1, kid, s: sealed.toString('base64') }, options)) === false) return false; + if (!prune) return true; + const kids = keys.kids; + if (kids.length < 2) return; + for (let i = 0; i < kids.length; i++) { + if (kids[i] === kid) continue; + const other = slot(kids[i], token); + if (other === null) continue; + try { + await store.delete(other); + } catch (error) { + log.warn({ err: error, event: 'session.migrate' }); + } + } + if (acceptPlaintext) await store.delete(token); + return true; + }; + + // The state of one row, or null — a row that does not open is a missing + // session and one log line, never a thrown request. The token is not + // logged: it is the credential. + const read = (kid, key, row) => { + if (!isRecord(row) || row.kid !== kid) return void log.warn({ event: 'session.unsealed', kid }); + try { + const data = JSON.parse(sealer.open(kid, Buffer.from(row.s, 'base64'), key).toString()); + if (typeof data === 'object' && data !== null && !Array.isArray(data)) return data; + } catch (error) { + // A refusal (OpenError) or a row that is not JSON is a missing + // session at warn; a key provider that threw is this side's failure, + // at error with the err. The token is never on either line. + if (error instanceof OpenError || error instanceof SyntaxError) { + return void log.warn({ event: 'session.open', kid, reason: error.reason ?? 'format' }); + } + return void log.error({ err: error, event: 'session.keys', kid }); + } + return void log.warn({ event: 'session.open', kid, reason: 'format' }); + }; + + // Moved under the current key, best effort: the old row is still good for + // as long as its kid is, so a failure here costs a retry at the next read. + // The stale row is deleted only if it is still the row that was read: + // another instance may have written a newer state there meanwhile, in + // which case THAT is the state to move — once; a second change is the + // next read's. + const migrate = async (token, data, stale, kid, seen) => { + try { + await write(token, data, false); + const row = await store.get(stale); + if (row === null || row === undefined) return; + if (seen !== null && isRecord(row) && row.s !== seen) { + // Written since it was read — an instance still on the old kid: + // that state is the one to move. Once; a second change is the next + // read's, and the stale row stays for it. + const fresh = read(kid, stale, row); + if (fresh === undefined) return; + await write(token, fresh, false); + const again = await store.get(stale); + if (!isRecord(again) || again.s !== row.s) return; + } + await store.delete(stale); + } catch (error) { + log.warn({ err: error, event: 'session.migrate' }); + } + }; + + const wrapped = { + name: `sealed(${store.name ?? 'store'})`, + async get(token) { + const slots = sealedKeys(token); + for (let i = 0; i < slots.length; i++) { + const [kid, key] = slots[i]; + if (key === null) continue; + const row = await store.get(key); + if (row === null || row === undefined) continue; + const data = read(kid, key, row); + if (data === undefined) return null; + // Not sealing yet: a sealed row is read where it is, never moved. + if (i > 0 && seal) await migrate(token, data, key, kid, row.s); + return data; + } + if (!acceptPlaintext && seal) return null; + const plain = await store.get(token); + if (plain === null || plain === undefined) return null; + // A row under the raw token that is not a session: a sealed record + // moved there — the row's name is a credential while acceptPlaintext + // is on, and this is what someone holding one would try — or some + // other value. Neither is adopted, migrated or deleted. + if (!isState(plain)) { + log.warn({ event: 'session.unsealed', kid: null }); + return null; + } + if (seal) { + await migrate(token, plain, token, null, null); + // Said, once per row: what acceptPlaintext is for, and what an + // operator counts down to zero before turning it off. + log.info({ event: 'session.adopt' }); + } + return plain; + }, + // Async on purpose: a keyring without its current key throws in write() + // before the store is reached, and a SessionStore's set() answers a + // rejection, never a throw — the session manager's flush counts on it. + set: async (token, data, options) => write(token, data, true, options?.create !== false), + async delete(token) { + const slots = sealedKeys(token); + for (let i = 0; i < slots.length; i++) if (slots[i][1] !== null) await store.delete(slots[i][1]); + if (acceptPlaintext || !seal) await store.delete(token); + }, + }; + if (isFunction(store.touch)) { + wrapped.touch = async (token) => { + const key = slot(keys.current, token); + if (key !== null) await store.touch(key); + // The row may still be plaintext, on either side of the rollout. + if (acceptPlaintext || !seal) await store.touch(token); + }; + } + return wrapped; +}; + +module.exports = { sealedStore }; diff --git a/src/engine/node.js b/src/engine/node.js index 642cf8b..8c02ec8 100644 --- a/src/engine/node.js +++ b/src/engine/node.js @@ -18,10 +18,16 @@ const createNodeEngine = (engineOptions = {}) => { deflate: true, cork: true, pause: true, + // Connection.sendPrepared: a fan-out frame encoded (and deflated) + // once, written to every recipient. + prepared: true, }, // attachOptions: { server, path, verifyClient, protocols, // handleProtocols, perMessageDeflate, pingInterval, maxBuffer, - // maxPayload, maxBackpressure, fragmentThreshold, closeTimeout } + // maxPayload, maxBackpressure, fragmentThreshold, closeTimeout, + // coalesce, logger } + // `logger` rides the same spread as the rest: the Server shell passes + // its own writer, so framing failures report where the app expects. // Returns an EventEmitter with 'connection'(socket, req) events. attach(attachOptions) { wss = new WebsocketServer({ ...engineOptions, ...attachOptions }); diff --git a/src/index.browser.js b/src/index.browser.js index af793c2..669bbcd 100644 --- a/src/index.browser.js +++ b/src/index.browser.js @@ -1,12 +1,14 @@ 'use strict'; const { Emitter, createEventStream, EventStream, isCodec } = require('./utils.js'); +const { isCompressor } = require('./compression/index.js'); const { WrpcClient, WrpcClientProxy, WrpcError, connect } = require('./client.js'); const { WrpcReadable, WrpcWritable } = require('./streams.js'); const { chunkEncode, chunkDecode } = require('./chunks.browser.js'); module.exports = { isCodec, + isCompressor, Emitter, createEventStream, EventStream, diff --git a/src/index.js b/src/index.js index 57f1361..bc3f328 100644 --- a/src/index.js +++ b/src/index.js @@ -1,9 +1,14 @@ 'use strict'; const { Emitter, createEventStream, EventStream, isCodec } = require('./utils.js'); +const { isCompressor } = require('./compression/index.js'); +const { dictionaryCompressor } = require('./compression/dictionary.js'); +const { deflateCompressor, brotliCompressor, zstdCompressor } = require('./compression/codecs.js'); +const { buildDictionary } = require('./rpc/dictionary.js'); const { WrpcClient, WrpcClientProxy, WrpcError, connect } = require('./client.js'); const { Server } = require('./server.js'); const { RpcServer, Client, Context } = require('./rpc/core.js'); +const { readHandshake } = require('./rpc/handshake.js'); const { defineRouter, procedure, Router, Procedure, effectiveSchema } = require('./rpc/router.js'); const { RoomRegistry, Broadcast } = require('./rpc/rooms.js'); const { Cluster } = require('./rpc/cluster.js'); @@ -42,7 +47,14 @@ module.exports = { createEventStream, EventStream, isCodec, + isCompressor, + buildDictionary, + dictionaryCompressor, + deflateCompressor, + brotliCompressor, + zstdCompressor, isTokenTransport, + readHandshake, MemorySessionStore, ServerTransport, buildHeaders, diff --git a/src/logging.js b/src/logging.js index d5d20d5..24dd085 100644 --- a/src/logging.js +++ b/src/logging.js @@ -57,6 +57,7 @@ const noop = () => {}; const DISABLED = Object.freeze({ [LOG_WRITER]: true, enabled: false, + debugEnabled: false, // Returning `this` is what makes the per-connection and per-call children // free when logging is off: no allocation, no closure, no branch. child() { @@ -76,6 +77,10 @@ const createConsoleWriter = (sink) => { const writer = { [LOG_WRITER]: true, enabled: true, + // `debugEnabled` is the per-call hot path's guard: false here because + // debug is dropped below, true on a structured writer whose own level + // decides — so a call site can skip building the entry it would drop. + debugEnabled: false, child() { return this; }, @@ -107,6 +112,7 @@ const createStructuredWriter = (sink) => { const writer = { [LOG_WRITER]: true, enabled: true, + debugEnabled: true, child(bindings) { if (!hasMethod(sink, 'child')) return this; try { diff --git a/src/rpc/client.js b/src/rpc/client.js index c7f2ee6..cffe67b 100644 --- a/src/rpc/client.js +++ b/src/rpc/client.js @@ -5,15 +5,19 @@ // they reach the RpcServer only through the options bag #addClient builds, // so the dependency is one-way: core requires this file, never the reverse. -const http = require('node:http'); - const { Emitter, jsonParse } = require('../utils.js'); +const { STATUS_CODES } = require('../status.js'); const { generateUUID } = require('../runtime/node.js'); const { WrpcWritable } = require('../streams.js'); const { RoomRegistry } = require('./rooms.js'); const { DEFAULT_META_MAX } = require('./meta.js'); const { createLoggerWriter } = require('../logging.js'); -const { createServerTelemetry } = require('../telemetry/server.js'); +const { clip } = require('./errors.js'); +// The disabled shape only: a Client without an injected writer (a peer +// host, a standalone Client) must not pull the whole server facade into a +// browser bundle for it. +const { DISABLED: DISABLED_TELEMETRY } = require('../telemetry/shared.js'); +const { hasBytes, encodeAttachments } = require('../attachments.js'); // One peer holding thousands of open generators is a denial of service the // application never opted into; the cap is generous but present. @@ -21,6 +25,10 @@ const DEFAULT_MAX_SUBSCRIPTIONS = 256; // Same reasoning for in-flight calls: each holds a controller, a context and // possibly a queue slot until it settles. const DEFAULT_MAX_CALLS = 1000; +// And for the binary streams a peer announces: each holds a readable — and +// every chunk the peer sends into it — until a handler reads it, and the +// peer alone decides how many it opens. +const DEFAULT_MAX_STREAMS = 256; // How long client.ask() waits for the peer's answer. Mirrors the client's // default call timeout: an ask is a call travelling the other way. const DEFAULT_ASK_TIMEOUT = 7_000; @@ -39,11 +47,22 @@ const EMPTY_META = Object.freeze({ // The frozen snapshot of what the peer presented when the connection was // made. `headers` is copied, not adopted: the source object belongs to the -// host request and other code may still be reading (or mutating) it. -const buildMeta = ({ headers, url, remoteAddress, protocol, data } = {}) => +// host request and other code may still be reading (or mutating) it. The +// copy is a plain spread whose prototype is dropped AFTER: a `__proto__: +// null` literal is born in V8's dictionary mode, and with one built per HTTP +// request and alive until its answer, leaving that mode took handleHttpCall +// from 256k to 305k requests/s (bench/http-call.js). Null-prototyped and +// frozen either way. +// The names in `headers` a ws handshake DECLARED (a carrier token, the +// query) rather than carried — what readDeclared answers; `[]` on every +// other transport, so a handler reads `meta.declared.includes(name)` bare. +const NO_NAMES = Object.freeze([]); + +const buildMeta = ({ headers, url, remoteAddress, protocol, data, declared } = {}) => Object.freeze({ data: data ?? FROZEN_EMPTY, - headers: headers ? Object.freeze({ __proto__: null, ...headers }) : FROZEN_EMPTY, + headers: headers ? Object.freeze(Object.setPrototypeOf({ ...headers }, null)) : FROZEN_EMPTY, + declared: declared ?? NO_NAMES, url: url ?? '', remoteAddress: remoteAddress ?? '', protocol: protocol ?? '', @@ -58,10 +77,10 @@ const refusal = (message) => { class Context { #log = null; + #uuid = null; constructor(client, signal = null, target = null) { this.client = client; - this.uuid = client.generateId(); this.state = {}; // Aborted when the caller cancels, unsubscribes, or disconnects. A // handler that awaits anything long-lived should pass it along; one @@ -82,9 +101,25 @@ class Context { return this.client.session; } + /** + * The HTTP response seam on a REST call — `{ method, url, headers, + * setHeader(name, value), status(code) }` — and null on every other + * transport (ws, wt, sse, port) and on packet-mode HTTP, where one + * response answers a whole batch. + */ + get http() { + return this.client.http; + } + + // Minted on first read: a Context is allocated for every call, and a + // random id per call (crypto.randomUUID) is paid only by the handlers and + // hooks that correlate on it — the child logger below is the usual one. + get uuid() { + return (this.#uuid ??= this.client.generateId()); + } + // Bound lazily: a Context is allocated for every call, subscribe and - // inbound event, and most handlers never log. `uuid` is already there, so - // the correlation id exists whether or not anyone asks for the child. + // inbound event, and most handlers never log. get log() { return (this.#log ??= this.client.log.child({ callId: this.uuid })); } @@ -114,6 +149,19 @@ class Client extends Emitter { // server sent it. See ask()/expectAnswer()/settleAnswer(). #asks = new Map(); #ready = null; + #isReady = false; + /** The REST response seam (see Context.http); null except on a REST call. */ + http = null; + /** + * The per-message compression this connection's peer negotiated for ITS + * frames (a Node ws client's `compression`, agreed on ping/pong), or + * null. Set by the dispatcher, read by the core's frame path. + */ + compression = null; + // The facts of a session-encrypted connection once its handshake is done + // — `{ protocol, pattern, cipher, kid, remoteStatic, handshakeHash }` — + // and null on a connection that is not one (src/encryption/server.js). + encryption = null; constructor(transport, options = {}) { super(); @@ -125,6 +173,7 @@ class Client extends Emitter { otel, maxSubscriptions = DEFAULT_MAX_SUBSCRIPTIONS, maxCalls = DEFAULT_MAX_CALLS, + maxStreams = DEFAULT_MAX_STREAMS, generateId, codec = null, meta = null, @@ -146,7 +195,7 @@ class Client extends Emitter { // Connection-scoped, bound once: a connection lives for minutes, and the // binding hoists the peer id out of every line logged for it. this.#log = createLoggerWriter(log ?? globalThis.console).child({ peer: transport.source }); - this.#otel = otel ?? createServerTelemetry(null); + this.#otel = otel ?? DISABLED_TELEMETRY; this.source = transport.source; this.session = null; this.sessionReady = Promise.resolve(); @@ -156,8 +205,16 @@ class Client extends Emitter { this.subscriptions = new Map(); this.maxSubscriptions = maxSubscriptions; this.maxCalls = maxCalls; + this.maxStreams = maxStreams; + // Set when the session store could not be asked for this client's + // token (core #restoreToken): a session procedure answers 503, not 403. + this.sessionUnavailable = false; // Context uuids and server-side stream ids; uuid v4 unless the app // brings its own (cuid/ulid/a test counter) — see RpcServerOptions. + // A Client built by a host receives a generator that host already + // resolved and probed, so this is a trust rather than a second + // validation: probing here would cost one id per CONNECTION, which a + // connection storm would feel. A standalone Client falls back. this.generateId = typeof generateId === 'function' ? generateId : generateUUID; // Instance-prefixed, so the id IS the address: a cluster command for // this client goes straight to this instance's channel, no broadcast. @@ -168,12 +225,18 @@ class Client extends Emitter { this.data = {}; } - error(code, { id = '', error = null } = {}) { + // `level` is the line's: `error` by default — a handler that threw, a + // refusal nothing else reported — and `debug` from a site that already + // wrote its own event, so one refusal is ONE alert at the level that site + // chose (a 429 the dispatcher logs at debug used to come with an + // error-level twin here, which made the choice moot). The id is the + // peer's text, clipped for the line; the answer carries it whole. + error(code, { id = '', error = null, level = 'error' } = {}) { const httpCode = code <= 599 ? code : 500; - const status = http.STATUS_CODES[httpCode]; + const status = STATUS_CODES[httpCode]; const info = error ? error.stack : status || 'Unknown error'; this.#transport.error(code, { id, error }); - this.#log.error({ event: 'rpc.error', code, id, err: error }, `${this.source}\t${code}\t${info}`); + this.#log[level]({ event: 'rpc.error', code, id: clip(id), err: error }, `${this.source}\t${code}\t${info}`); } // What dispatch actually gates on: the session restore PLUS the settled @@ -188,6 +251,22 @@ class Client extends Emitter { set ready(value) { this.#ready = value; + this.#isReady = false; + // Settled-state flag for the hot path: once the gate opened, a call + // skips the `await` (and its microtask) instead of awaiting a promise + // that has already resolved. A rejected gate stays "not ready" so the + // await keeps surfacing the rejection. + Promise.resolve(value).then( + () => { + if (this.#ready === value) this.#isReady = true; + }, + () => {}, + ); + } + + /** True once `ready` has resolved — the dispatcher's cue to skip the await. */ + get isReady() { + return this.#isReady; } /** The connection-scoped writer, reached by the dispatcher and handlers. */ @@ -211,15 +290,27 @@ class Client extends Emitter { this.#log.warn({ event: 'rpc.warn', ...entry }, `${this.source}\t${message}`); } - /** Returns false when the transport is above its high-water mark. */ + /** + * Returns false when the transport is above its high-water mark. + * `compress: false` sends this packet uncompressed on a transport that + * negotiated permessage-deflate (ignored elsewhere). + */ send(obj, options = {}) { const { code, method, text } = options; - const flushed = this.#transport.send(obj, code, text); + const transport = this.#transport; + let flushed; + if (options.compress === false && typeof transport.writeWith === 'function') { + const codec = transport.codec; + flushed = transport.writeWith(codec ? codec.encode(obj) : (text ?? JSON.stringify(obj)), options); + } else { + flushed = transport.send(obj, code, text); + } // Debug on purpose: one line per successful call is a firehose. A // console logger drops debug outright; a structured logger's own level - // decides. Failures still log at error, unconditionally. - const isSuccessCallback = obj.type === 'callback' && !obj.error; - if (isSuccessCallback) { + // decides. Failures still log at error, unconditionally. The writer's + // `debugEnabled` flag is what keeps the entry object and the message + // string from being built for a writer that would drop them. + if (this.#log.debugEnabled && obj.type === 'callback' && !obj.error) { this.#log.debug({ event: 'call.ok', method, id: obj.id }, `${this.source}\tCALL\t${method}\tOK`); } return flushed; @@ -277,25 +368,107 @@ class Client extends Emitter { // works, and Client is substitutable for the Emitter it extends. The wire // send has its own name and always had it: `sendEvent`. - sendEvent(name, data) { + /** + * `unreliable: true` sends the event as a datagram where the transport + * has them (WebTransport) — lossy and unordered, for state a later event + * supersedes — and reliably everywhere else; the application code is the + * same either way. + */ + /** + * Whether bytes in a packet leave this connection as an attachments frame + * (the server's `attachments` option, off under a packet codec): what + * decides if a result is worth walking for bytes at all. + */ + get attachments() { + return this.#transport.attachments !== false; + } + + /** + * The protocol revision this connection speaks: 2 when the peer reads + * framed messages, 1 for a 1.0 peer (protocol.md#versioning) — a handler + * that must know which form the bytes it was sent arrived in reads it. + */ + get revision() { + return this.#transport.revision ?? 2; + } + + /** + * A ping that names a revision (protocol.md#versioning) — how a transport + * with no handshake of its own, a worker port, negotiates one. Returns + * the revision to answer with, or 0 on a transport that settled it + * elsewhere (a WebSocket's subprotocol), where the field is ignored. + */ + negotiateRevision(v) { + const transport = this.#transport; + if (typeof transport.negotiate !== 'function') return 0; + const answer = transport.negotiate(v); + if (transport.revision === 1) this.log.debug({ event: 'revision.peer', transport: this.transportKind, named: v }); + return answer; + } + + sendEvent(name, data, options = null) { const packet = { type: 'event', name, data }; if (!this.#transport.connection) { throw refusal(`Can't send wrpc event to http transport`); } + if (options !== null && (options.unreliable === true || options.compress === false)) { + const transport = this.#transport; + const codec = transport.codec; + const wire = codec + ? codec.encode(packet) + : transport.attachments !== false && hasBytes(packet) + ? encodeAttachments(packet) + : JSON.stringify(packet); + return void this.sendRaw(wire, options); + } this.send(packet); } /** - * Writes an ALREADY-serialized packet. The fan-out seam: a broadcast to N - * clients stringifies once and hands every recipient the same text, - * instead of paying JSON.stringify per client. Returns the transport's - * backpressure signal, like send(). + * Writes an ALREADY-serialized packet. Returns the transport's + * backpressure signal, like send(). `unreliable` and `compress` options + * as on sendEvent. */ - sendRaw(text) { - if (!this.#transport.connection) { + sendRaw(text, options = null) { + const transport = this.#transport; + if (!transport.connection) { throw refusal(`Can't send wrpc event to http transport`); } - return this.#transport.write(text); + if (options !== null) { + // A datagram where the transport can, the reliable write otherwise. + if (options.unreliable === true && typeof transport.writeUnreliable === 'function') { + if (transport.writeUnreliable(text)) return true; + } + if (options.compress === false && typeof transport.writeWith === 'function') { + return transport.writeWith(text, options); + } + } + return transport.write(text); + } + + /** + * The fan-out seam. `message` is `{ text, frames, inner, compress }` + * shared by every recipient of one broadcast: the text is serialized + * once, and a transport with the prepared-frame path (`writeShared`) + * encodes and deflates it once into `frames` for every recipient after + * the first, instead of paying utf8 + deflate + framing per member. + * `frames` belongs to the engine that filled it; `inner` is the + * plaintext a sealed socket seals per recipient, built once. Transports + * without the path write the text. Returns the backpressure signal. + */ + sendShared(message, options = null) { + const transport = this.#transport; + if (!transport.connection) { + throw refusal(`Can't send wrpc event to http transport`); + } + if (options !== null && options.unreliable === true && typeof transport.writeUnreliable === 'function') { + if (transport.writeUnreliable(message.text)) return true; + } + if (typeof transport.writeShared === 'function') return transport.writeShared(message); + if (message.compress === false && typeof transport.writeWith === 'function') { + return transport.writeWith(message.text, message); + } + return transport.write(message.text); } /** @@ -308,6 +481,8 @@ class Client extends Emitter { if (!this.#transport.connection) { throw refusal(`Can't send wrpc event to http transport`); } + // An ask expects an answer, and a datagram may never arrive. + if (options.unreliable) throw new TypeError('ask() cannot be unreliable: an answer is expected'); const id = this.generateId(); const packet = { type: 'event', name, data, id }; this.send(packet); @@ -392,41 +567,70 @@ class Client extends Emitter { if (typeof id !== 'string' || id.length === 0 || id.length > 255) { throw new TypeError('createStream: generateId must return a string of at most 255 characters'); } - const stream = new WrpcWritable(id, name, size, this.#transport); - this.streams.set(id, stream); - return stream; + // Not registered in `streams`: that map holds what the PEER announced — + // what getStream() answers, chunks are routed to and `maxStreams` caps. + // A writable kept there was never removed, so every download stayed for + // the connection's life and, past `maxStreams` of them, the peer's + // uploads were answered 429. It watches the transport's 'close' itself + // until it finishes. + return new WrpcWritable(id, name, size, this.#transport, this.#otel); + } + + // A host with no session manager (a browser-side peer host, a standalone + // Client) refuses the session calls with a coded error instead of a + // TypeError from inside a handler. + #requireSessions(method) { + if (this.#sessions) return this.#sessions; + throw refusal(`${method}: sessions are not available on this host`); } initializeSession(token, data = {}) { + const sessions = this.#requireSessions('initializeSession'); // Re-initializing the SAME token must not finalize first: with an async // store the fire-and-forget delete(token) could land after the new // set(token) and silently wipe the fresh session if (this.session && this.session.token !== token) void this.finalizeSession(); - this.session = this.#sessions.create(token, data); + this.session = sessions.create(token, data); return true; } async finalizeSession() { if (!this.session) return false; const { token } = this.session; + // A save queued in this turn must not land after the delete below. + if (typeof this.session.end === 'function') this.session.end(); this.session = null; - await this.#sessions.destroy(token); + // The other connections of this instance that restored the same token + // end with it: a logout in one tab used to leave the session working in + // the others until they reconnected. Ended, not removed — a handler + // still running there keeps its context — and refused from the next + // call on. Another instance learns it from the store, on its next restore. + const others = this.#server?.clients; + if (others) { + for (const other of others) { + if (other !== this && other.session?.token === token) other.session.end?.(); + } + } + // A session that never came from a store (a peer host's link identity) + // has nothing to destroy. + if (this.#sessions) await this.#sessions.destroy(token); return true; } startSession(token, data = {}) { + const sessions = this.#requireSessions('startSession'); this.initializeSession(token, data); if (!this.#transport.connection) { // The carrier stamps the response when it can (a cookie); a bearer // carrier answers null and the handler returns tokens in its result. - const header = this.#sessions.transport.write(this.session.token); + const header = sessions.transport.write(this.session.token); if (header) this.#transport.sendSessionCookie(header); } return true; } async restoreSession(token) { - const session = await this.#sessions.restore(token); + const session = await this.#requireSessions('restoreSession').restore(token); if (!session) return false; this.session = session; return true; @@ -444,12 +648,17 @@ class Client extends Emitter { this.#rooms.leaveAll(this); // A gone peer cannot receive an answer, so everything still running on // its behalf is told to stop — this is what runs a subscription - // handler's `finally`, releasing whatever it had open. - const disconnected = new Error('Client disconnected'); - for (const controller of this.calls.values()) controller.abort(disconnected); - for (const controller of this.subscriptions.values()) controller.abort(disconnected); - this.calls.clear(); - this.subscriptions.clear(); + // handler's `finally`, releasing whatever it had open. The reason is + // built only when there is someone to give it to: an Error captures a + // stack, and an HTTP client is destroyed once per request with nothing + // left in flight (bench/http-call.js). + if (this.calls.size > 0 || this.subscriptions.size > 0) { + const disconnected = new Error('Client disconnected'); + for (const controller of this.calls.values()) controller.abort(disconnected); + for (const controller of this.subscriptions.values()) controller.abort(disconnected); + this.calls.clear(); + this.subscriptions.clear(); + } // An answer can no longer arrive: whoever asked is settled NOW instead // of waiting out the ask timeout on a peer that is gone. if (this.#asks.size > 0) { @@ -481,6 +690,7 @@ module.exports = { Client, DEFAULT_MAX_SUBSCRIPTIONS, DEFAULT_MAX_CALLS, + DEFAULT_MAX_STREAMS, FROZEN_EMPTY, EMPTY_META, buildMeta, diff --git a/src/rpc/cluster.js b/src/rpc/cluster.js index 50fadea..bf003b8 100644 --- a/src/rpc/cluster.js +++ b/src/rpc/cluster.js @@ -6,6 +6,8 @@ const { Emitter, jsonParse } = require('../utils.js'); const { generateUUID } = require('../runtime/node.js'); const { createLoggerWriter } = require('../logging.js'); const { DISABLED, SPAN_KIND_CONSUMER } = require('../telemetry/shared.js'); +const { hasBytes, encodeAttachments, ENVELOPE_DEPTH } = require('../attachments.js'); +const { ReplayWindow, DEFAULT_REPLAY_WINDOW } = require('../encryption/envelope.js'); // Cluster: presence, introspection and node-to-node messaging across every // wrpc instance sharing a backplane. Built ON TOP of the pub/sub contract @@ -42,6 +44,10 @@ const DEFAULT_REQUEST_TIMEOUT = 2_000; // fan-ins used to be able to kill the requester's broker connection // (redis's client-output-buffer-limit) and take every room channel with it. const DEFAULT_MAX_FETCH = 1_000; +// How far a signed envelope's clock may sit from this node's — both ways. It +// is the one bound on what a node that was NOT listening can be replayed: a +// counter window only remembers what this process heard. +const DEFAULT_MAX_SKEW = 30_000; // The rooms an app opts into replicating: an array, a predicate or a // RegExp. Null replicates everything — fine for topic rooms, expensive for @@ -80,6 +86,11 @@ const instanceOfClientId = (id) => { return dot > 0 ? id.slice(0, dot) : null; }; +// Refusal keys remembered for rate-limiting their lines at once (#loud). +const MAX_REFUSED_KEYS = 1024; +// Refusals in a row for a sender's clock before it is called 'degraded'. +const STALE_RUN = 3; + class Cluster extends Emitter { #backplane; #otel; @@ -93,6 +104,21 @@ class Cluster extends Emitter { #maxFetch; #roomsFilter; #secret; + // Replay protection under `secret` (see #admit): this node's own counter, + // and per sender name the life it follows — { epoch, window, newest }. + #strict = true; + #maxSkew = DEFAULT_MAX_SKEW; + #seq = 0; + #guards = new Map(); + // `${from}\0${reason}` -> when it was last warned about: a replayed or + // unsequenced envelope repeats, and one warn a presence timeout says it. + #refused = new Map(); + // Senders refused as stale for their clock, by how many times in a row. + #skews = new Map(); + #envelope = null; + // False under `attachments: false`: bytes then stay the JSON 1.0 made of + // them on every leg, this one included. + #bytes = true; #generateId; // instance -> { epoch, lastSeen, clients, rooms: Map } #nodes = new Map(); @@ -111,6 +137,7 @@ class Cluster extends Emitter { * count(room), snapshot() -> { clients, rooms } * descriptors(sel) -> Array * join(sel, rooms) / leave(sel, rooms) / disconnect(sel) + * event(sel, name, data) — deliver one event to the selected client(s) * ask(rooms, name, data, timeout, onCount) -> Promise<{answers, errors}> */ constructor({ @@ -126,13 +153,17 @@ class Cluster extends Emitter { this.#backplane = backplane; this.#otel = otel ?? DISABLED; this.#instance = instance; - // The boot marker. instanceId may be STABLE across restarts ('node-1'); - // the epoch never is, which is how a receiver tells "restarted, replace - // its counters" from "same process, merge". - this.#epoch = generateUUID(); this.#local = local; this.#log = createLoggerWriter(log); + // RpcServer hands down a generator it already resolved and probed, so + // this is a trust, not a second validation — an id-per-connection server + // must not pay a probe per Cluster either. this.#generateId = typeof generateId === 'function' ? generateId : generateUUID; + // The boot marker. instanceId may be STABLE across restarts ('node-1'); + // the epoch never is, which is how a receiver tells "restarted, replace + // its counters" from "same process, merge". It comes from the same + // generator as every other id so one injection covers the whole server. + this.#epoch = this.#generateId(); const interval = options.presenceInterval > 0 ? options.presenceInterval : DEFAULT_PRESENCE_INTERVAL; this.#presenceInterval = interval; this.#presenceTimeout = options.presenceTimeout > 0 ? options.presenceTimeout : interval * 3; @@ -146,6 +177,22 @@ class Cluster extends Emitter { // AND holds the shared secret". Node-only by construction (node:crypto) // — this file never ships to a browser. this.#secret = typeof options.secret === 'string' && options.secret.length > 0 ? options.secret : null; + // What a signature alone does not say: WHEN and WHERE. `replay` is new + // in 2.0, so a value that is not one of its two is a TypeError rather + // than the lenient fallback the 1.0 options above keep. + const { replay = 'strict', maxSkew = DEFAULT_MAX_SKEW } = options; + if (replay !== 'strict' && replay !== 'accept') { + throw new TypeError("RpcServer: options.cluster.replay must be 'strict' or 'accept'"); + } + if (!(Number.isFinite(maxSkew) && maxSkew > 0)) { + throw new TypeError('RpcServer: options.cluster.maxSkew must be a positive number of milliseconds'); + } + this.#strict = replay === 'strict'; + this.#maxSkew = maxSkew; + // The envelope codec the core built from `cluster.compression`, or null — + // applied AFTER signing, so the signature is over the JSON text as ever. + this.#envelope = options.envelope ?? null; + this.#bytes = options.attachments !== false; } get instanceId() { @@ -207,13 +254,16 @@ class Cluster extends Emitter { request.settle(true); } this.#nodes.clear(); + this.#guards.clear(); + this.#refused.clear(); + this.#skews.clear(); } // Subscribes with capped-backoff RETRY on rejection: a rejected subscribe // used to be terminal and silent — the node kept publishing but could // never hear again, a half-connected state nothing surfaced. #subscribeTo(channel, attempt = 0) { - const handler = (message) => this.#receive(message); + const handler = (message) => this.#receive(message, channel); return Promise.resolve() .then(() => this.#backplane.subscribe(channel, handler)) .then( @@ -291,10 +341,20 @@ class Cluster extends Emitter { count++; } this.#post(CLUSTER_CHANNEL, { t: 'digest', clients, n: count, h: hash }); - const deadline = Date.now() - this.#presenceTimeout; + const now = Date.now(); + const deadline = now - this.#presenceTimeout; for (const [instance, node] of this.#nodes) { if (node.lastSeen < deadline) this.#evict(instance, 'timeout'); } + // A sender's replay guard outlives its presence record on purpose — an + // evicted node's envelopes are exactly what a replay would bring back — + // and is dropped once the clock alone refuses everything it remembers. + for (const [instance, guard] of this.#guards) { + if (now - guard.newest > this.#maxSkew) this.#guards.delete(instance); + } + for (const [key, warned] of this.#refused) { + if (warned < deadline) this.#refused.delete(key); + } } // True when the message left synchronously intact; false on a serialize @@ -302,27 +362,52 @@ class Cluster extends Emitter { // it; #request settles immediately on false instead of waiting out a // timeout for a question that never went anywhere. (An async publish // rejection still only logs — by then at-most-once already owns it.) - #post(channel, body) { + // + // `payload` is the part of the body an APPLICATION wrote — an event's + // data, a question's, an answer — and so the only part that can hold + // bytes. Presence and commands never do and are never walked; with bytes + // in it the envelope leaves as a binary one (see #serialize). + #post(channel, body, payload = undefined) { const envelope = { v: ENVELOPE_VERSION, from: this.#instance, epoch: this.#epoch, ...body }; + if (this.#secret !== null) { + // What the signature is about to cover, set AFTER the body so nothing + // in it can spell them: this process' counter, the channel the + // envelope is published on, and the sender's clock. A 1.x node verifies + // the HMAC over the whole re-serialized envelope, so the three are + // transparent to it. (`ts` is the trace state below, hence `at`.) + envelope.seq = ++this.#seq; + envelope.ch = channel; + envelope.at = Date.now(); + } // The active trace context rides the envelope as tp/ts (ignored by // receivers that predate it — the additive-fields rule): the cross-node // hop is where a trace is most valuable and used to be exactly where // context was dropped. this.#otel.inject(envelope); + const binary = this.#bytes && payload !== undefined && hasBytes(payload); + if (binary && typeof this.#envelope?.encodeFrame !== 'function') { + // A Cluster wired by hand, without the core's envelope: JSON would + // deliver the {"0":…} object it makes of bytes — a silently wrong + // delivery. Refused as undeliverable, and said. + this.#log.warn({ event: 'cluster.bytes', type: body.t, name: body.name ?? body.args?.name }); + return false; + } let message = null; try { - message = JSON.stringify(envelope); - if (this.#secret) { - // Signed over the serialized body, sig appended LAST: the receiver - // deletes `sig` from the parsed object and re-serializes — key - // order survives a JSON round trip, so the bytes match. - envelope.sig = crypto.createHmac('sha256', this.#secret).update(message).digest('hex'); - message = JSON.stringify(envelope); - } + message = binary ? this.#frame(envelope) : this.#text(envelope); } catch (error) { this.#log.error({ err: error, event: 'cluster.serialize', type: body.t }); return false; } + // A sealer that cannot seal (a keyring without its current key) is + // named as such, and nothing leaves: never plaintext across the wire. + try { + if (binary) message = this.#envelope.encodeFrame(message, channel); + else if (this.#envelope !== null) message = this.#envelope.encode(message, channel); + } catch (error) { + this.#log.error({ err: error, event: 'cluster.seal', type: body.t }); + return false; + } try { const result = this.#backplane.publish(channel, message); if (result && typeof result.catch === 'function') { @@ -336,6 +421,30 @@ class Cluster extends Emitter { return true; } + // The envelope as JSON text. Signed over the serialized body, sig appended + // LAST: the receiver deletes `sig` from the parsed object and re-serializes + // — key order survives a JSON round trip, so the bytes match. + #text(envelope) { + const message = JSON.stringify(envelope); + if (this.#secret === null) return message; + envelope.sig = crypto.createHmac('sha256', this.#secret).update(message).digest('hex'); + return JSON.stringify(envelope); + } + + // The envelope as a binary attachments frame — what an envelope holding + // bytes travels as, the frame a socket carries such a packet in. The + // signature is the same construction over the FRAME: the bytes of the + // envelope without `sig`, which the receiver gets back by deleting `sig` + // from what it decoded and encoding again (the encoder walks keys in + // order, as JSON does). A frame never equals a JSON text, so a signature + // made for one form cannot be presented under the other. + #frame(envelope) { + const frame = encodeAttachments(envelope, ENVELOPE_DEPTH); + if (this.#secret === null) return frame; + envelope.sig = crypto.createHmac('sha256', this.#secret).update(frame).digest('hex'); + return encodeAttachments(envelope, ENVELOPE_DEPTH); + } + // ----------------------------------------------------------------------- // Presence: replicated counters, local reads @@ -379,28 +488,137 @@ class Cluster extends Emitter { // Envelope authentication, the receiving half: a message without a valid // signature is dropped and logged. Constant-time compare — the signature // is the credential here. - #verify(envelope, from) { + #verify(envelope, from, binary) { const sig = envelope.sig; if (typeof sig !== 'string' || sig.length === 0) { - this.#log.warn({ event: 'cluster.unsigned', from }); + this.#log[this.#loud(`unsigned\0${from}`)]({ event: 'cluster.unsigned', from }); + this.#otel.recordClusterVerification('unsigned'); return false; } delete envelope.sig; let expected = null; try { - expected = crypto.createHmac('sha256', this.#secret).update(JSON.stringify(envelope)).digest('hex'); - } catch { + const signed = binary ? encodeAttachments(envelope, ENVELOPE_DEPTH) : JSON.stringify(envelope); + expected = crypto.createHmac('sha256', this.#secret).update(signed).digest('hex'); + } catch (error) { + // The third way verification fails, and the only one that was silent: + // an envelope that will not serialize again (a cycle a replicated + // payload picked up), or a secret the crypto layer refuses. Its + // siblings above and below both log, so an operator watching + // `cluster.*` saw two of three reasons a node went quiet. + this.#log.error({ err: error, event: 'cluster.verify', from }); + this.#otel.recordClusterVerification('error'); return false; } const a = Buffer.from(sig); const b = Buffer.from(expected); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { - this.#log.warn({ event: 'cluster.badsig', from }); + this.#log[this.#loud(`badsig\0${from}`)]({ event: 'cluster.badsig', from }); + this.#otel.recordClusterVerification('badsig'); return false; } return true; } + // Replay protection, the receiving half. It runs AFTER #verify, so every + // field read here was written by a node holding the secret; what the + // signature cannot say is that the envelope is being heard where and when + // it was published, and for the first time: + // + // ch the channel it was published on — a copy moved to another + // instance's inbox, or from an inbox to `cluster`, is refused; + // at the sender's clock, within `maxSkew` of this one — the bound on + // what a node that was not listening (a fresh boot) can be fed; + // seq the sender's counter, under a sliding window per life of that + // sender (a receiver hears two of its channels, so gaps are + // normal) — a repeat, or one older than the window, is refused; + // epoch another life of the same name is followed only when it is + // NEWER than everything accepted from the one it replaces, so an + // envelope of a dead process cannot bring that process back. + // + // An envelope with no counter is what a 1.x node sends: refused, unless + // `replay: 'accept'` says a rolling upgrade is under way. + #admit(envelope, from, channel) { + const { seq, ch, at, epoch } = envelope; + if (seq === undefined) return this.#strict ? this.#replayed(from, channel, 'unsequenced') : this.#unsequenced(from); + if (!Number.isSafeInteger(seq) || seq < 0 || typeof at !== 'number') return this.#replayed(from, channel, 'seq'); + if (ch !== channel) return this.#replayed(from, channel, 'channel'); + const skew = Date.now() - at; + if (Math.abs(skew) > this.#maxSkew) return this.#skewed(from, channel, skew); + let guard = this.#guards.get(from); + if (guard === undefined || guard.epoch !== epoch) { + if (guard !== undefined && at <= guard.newest) return this.#replayed(from, channel, 'stale'); + guard = { epoch, window: new ReplayWindow(DEFAULT_REPLAY_WINDOW), newest: at }; + this.#guards.set(from, guard); + } + if (!guard.window.accept(seq)) return this.#replayed(from, channel, 'seq'); + if (at > guard.newest) guard.newest = at; + if (this.#skews.has(from)) this.#settled(from); + return true; + } + + // A node whose clock is off by more than maxSkew is refused message after + // message, and was isolated in silence: one stale line a presence + // timeout, `healthy` true, `fetchClients` complete without it. The line + // says by how much now, and STALE_RUN refusals in a row are 'degraded' + // ({ instance, reason: 'skew', skew }) — 'recovered' once it is heard. + #skewed(from, channel, skew) { + const run = (this.#skews.get(from) ?? 0) + 1; + if (run <= STALE_RUN) { + if (!this.#skews.has(from) && this.#skews.size >= MAX_REFUSED_KEYS) this.#skews.clear(); + this.#skews.set(from, run); + } + if (run === STALE_RUN) { + void Promise.resolve(this.emit('degraded', { instance: from, reason: 'skew', skew })).catch((error) => + this.#log.error({ err: error, event: 'cluster.listener', name: 'degraded' }), + ); + } + return this.#replayed(from, channel, 'stale', { skew }); + } + + #settled(from) { + const run = this.#skews.get(from); + this.#skews.delete(from); + if (run < STALE_RUN) return; + void Promise.resolve(this.emit('recovered', { instance: from })).catch((error) => + this.#log.error({ err: error, event: 'cluster.listener', name: 'recovered' }), + ); + } + + // Always counted; warned once per sender and reason a presence timeout, + // debug in between — whoever replays one envelope can replay it in a loop. + #replayed(from, channel, reason, extra = null) { + this.#otel.recordClusterVerification('replay'); + this.#log[this.#loud(`${from}\0${reason}`)]({ event: 'cluster.replay', from, channel, reason, ...extra }); + return false; + } + + // Under `replay: 'accept'` an envelope with no counter — a 1.x node's — is + // let through, and was let through without a trace: counted, and said + // once per sender a presence timeout at info (debug in between), so the + // option can be dropped once the last such sender is gone. + #unsequenced(from) { + this.#otel.recordClusterVerification('unsequenced'); + const level = this.#loud(`unsequenced\0${from}`) === 'warn' ? 'info' : 'debug'; + this.#log[level]({ event: 'cluster.unsequenced', from }); + return true; + } + + // The level of a refusal's line: 'warn' once per key (a sender and a + // reason, a channel) a presence timeout, 'debug' in between — whoever + // can publish one refused envelope can publish it in a loop, and an + // unsigned or mis-signed one was a warn per message. Swept with the + // presence records, and capped: an unsigned envelope names whatever + // sender it likes, and each one was a new key. + #loud(key) { + const now = Date.now(); + const last = this.#refused.get(key); + if (last !== undefined && now - last <= this.#presenceTimeout) return 'debug'; + if (last === undefined && this.#refused.size >= MAX_REFUSED_KEYS) return 'debug'; + this.#refused.set(key, now); + return 'warn'; + } + #seen(from, epoch) { let node = this.#nodes.get(from); if (node && node.epoch !== epoch) { @@ -485,7 +703,7 @@ class Cluster extends Emitter { throw new TypeError('Event name must be a non-empty string'); } if (!this.#backplane || this.#closed) return; - this.#post(CLUSTER_CHANNEL, { t: 'e', name, data }); + this.#post(CLUSTER_CHANNEL, { t: 'e', name, data }, data); } /** @@ -571,48 +789,81 @@ class Cluster extends Emitter { * fire-and-forget with the backplane's at-most-once delivery. */ join(target, ...rooms) { - this.#command('join', target, rooms); + this.#command('join', target, { rooms }); } leave(target, ...rooms) { - this.#command('leave', target, rooms); + this.#command('leave', target, { rooms }); } disconnect(target) { - this.#command('disconnect', target, undefined); + this.#command('disconnect', target, {}); } - // The three room ops, in one place. Both call sites below used to spell this + /** + * One event to ONE client, wherever it is connected: the id names the + * instance, so this is an addressed command — one publish, one receiver + * — never a broadcast-and-filter. `room` narrows delivery to a client + * still in that room (a relay that must not outlive a membership). + * Fire-and-forget with the backplane's at-most-once delivery; a local + * id is applied directly. + */ + send(clientId, name, data, options = {}) { + if (typeof clientId !== 'string' || clientId.length === 0) { + throw new TypeError('Cluster.send: clientId must be a non-empty string'); + } + if (typeof name !== 'string' || name.length === 0) { + throw new TypeError('Event name must be a non-empty string'); + } + const room = typeof options.room === 'string' ? options.room : undefined; + return this.#command('event', clientId, { name, data }, room); + } + + // The command ops, in one place. Both call sites below used to spell this // chain out themselves and disagreed on the unknown-op case — one fell // through to disconnect, the other ignored it — so the default is now the // caller's to state: this returns false rather than picking one. - #applyOp(op, sel, rooms) { - if (op === 'join') this.#local.join(sel, rooms); - else if (op === 'leave') this.#local.leave(sel, rooms); + // `args` is the envelope's op-specific part: { rooms } for join/leave, + // { name, data } for event — the same keys on the wire, so a node that + // predates an op ignores it and one that knows it reads it (additive). + #applyOp(op, sel, args) { + if (op === 'join') this.#local.join(sel, args.rooms); + else if (op === 'leave') this.#local.leave(sel, args.rooms); else if (op === 'disconnect') this.#local.disconnect(sel); + else if (op === 'event') this.#local.event(sel, args.name, args.data); else return false; return true; } - #command(op, target, rooms) { + // `room`, when given, rides inside the selector: a client that left the + // room between send and delivery is not selected — a relay bounded by a + // membership must not outlive it. + #command(op, target, args, room = undefined) { const apply = (sel) => { - // `op` here is a literal from join()/leave()/disconnect(), never user - // input, so an unknown one is a bug in this file and should be loud. - if (!this.#applyOp(op, sel, rooms)) throw new Error(`Unknown cluster command op '${op}'`); + // `op` here is a literal from join()/leave()/disconnect()/send(), never + // user input, so an unknown one is a bug in this file and should be loud. + if (!this.#applyOp(op, sel, args)) throw new Error(`Unknown cluster command op '${op}'`); }; // An addressed command rides the target instance's own channel — one // publish, one receiver — instead of asking every node to filter. + // Answers whether the command was applied here or handed to the + // backplane: false is "known not to be delivered". if (typeof target === 'string') { - const sel = { id: target }; + const sel = room === undefined ? { id: target } : { id: target, room }; const instance = instanceOfClientId(target); - if (instance === this.#instance || instance === null) return void apply(sel); - if (!this.#backplane || this.#closed) return; - return void this.#post(instanceChannel(instance), { t: 'cmd', op, sel, rooms }); + if (instance === this.#instance || instance === null) { + apply(sel); + return true; + } + if (!this.#backplane || this.#closed) return false; + // Only an event carries what an application wrote (`data`). + return this.#post(instanceChannel(instance), { t: 'cmd', op, sel, ...args }, args.data); } const sel = target && typeof target === 'object' ? target : {}; apply(sel); - if (!this.#backplane || this.#closed) return; - this.#post(CLUSTER_CHANNEL, { t: 'cmd', op, sel, rooms }); + if (!this.#backplane || this.#closed) return true; + this.#post(CLUSTER_CHANNEL, { t: 'cmd', op, sel, ...args }); + return true; } /** @@ -671,7 +922,7 @@ class Cluster extends Emitter { // A question that never left — unserializable args, a synchronously // broken backplane — settles NOW: nobody will ever answer it, and // waiting out the full timeout would just park the caller. - if (!this.#post(CLUSTER_CHANNEL, { t: 'q', q: requestId, op, args })) { + if (!this.#post(CLUSTER_CHANNEL, { t: 'q', q: requestId, op, args }, args.data)) { this.#requests.delete(requestId); clearTimeout(request.timer); request.settle(true); @@ -709,7 +960,9 @@ class Cluster extends Emitter { // the requester to stop waiting, and a post-bye answer would arrive // from an instance the receiver just evicted. if (this.#closed) return; - this.#post(instanceChannel(from), { t: 'a', a: requestId, fin, payload }); + // A fetch reply is descriptors — this node's own JSON, a thousand of + // them — and is not walked; an answer is the application's. + this.#post(instanceChannel(from), { t: 'a', a: requestId, fin, payload }, op === 'fetch' ? undefined : payload); }; const run = () => { if (op === 'fetch') { @@ -764,13 +1017,28 @@ class Cluster extends Emitter { } } - #receive(message) { + #receive(message, channel) { if (this.#closed) return; - const envelope = typeof message === 'string' ? jsonParse(message) : message; + let text = message; + if (typeof message === 'string') { + if (this.#envelope !== null) text = this.#envelope.decode(message, channel); + else if (message.charCodeAt(0) === 119 && message.startsWith('wrpc-enc:')) text = null; + if (text === null) return void this.#log[this.#loud('encoded')]({ event: 'cluster.encoded' }); + // Refused by a sealing envelope, which reported why — or our own echo. + if (text === undefined) return; + // Still sealed: this node holds no keys, and says so. + if (typeof text === 'string' && text.charCodeAt(0) === 119 && text.startsWith('wrpc-sealed:')) { + return void this.#log[this.#loud(`sealed\0${channel}`)]({ event: 'cluster.sealed', channel }); + } + } + // A binary envelope (bytes in an event's data, a question or an answer) + // was decoded to its object by the envelope seam already. + const binary = typeof message === 'string' && typeof text !== 'string'; + const envelope = typeof text === 'string' ? jsonParse(text) : text; if (!envelope || typeof envelope !== 'object') return; const { from, epoch, t } = envelope; if (from === this.#instance || typeof from !== 'string' || from.length === 0) return; - if (this.#secret && !this.#verify(envelope, from)) return; + if (this.#secret && !(this.#verify(envelope, from, binary) && this.#admit(envelope, from, channel))) return; this.#otel.recordClusterMessage(typeof t === 'string' ? t : ''); if (t === 'bye') { // Only the life we actually track may say goodbye: a bye from a @@ -799,7 +1067,7 @@ class Cluster extends Emitter { this.#post(instanceChannel(from), { t: 'state', ...this.#snapshot() }); return; case 'state': - node.syncing = false; + node.syncing = 0; return void this.#applySnapshot(node, envelope); case 'digest': { const { clients, n, h } = envelope; @@ -813,9 +1081,15 @@ class Cluster extends Emitter { } if (count === n && hash === h) return; // the view is correct // One outstanding sync per node: the answering 'state' clears it, - // so a slow answer cannot stack requests every tick. - if (node.syncing) return; - node.syncing = true; + // so a slow answer cannot stack requests every tick. The wait is + // BOUNDED: the answer travels at-most-once too (our own inbox + // channel may not even be subscribed yet on the first digest), and + // a lost `state` used to leave `syncing` set forever — every later + // digest ignored, the view wrong until the node restarted. After + // two presence intervals without an answer the sync is asked again. + const now = Date.now(); + if (node.syncing && now - node.syncing < this.#presenceInterval * 2) return; + node.syncing = now; this.#post(instanceChannel(from), { t: 'sync' }); return; } @@ -840,19 +1114,21 @@ class Cluster extends Emitter { case 'q': return void this.#serve(envelope); case 'cmd': { - const { op, sel, rooms } = envelope; + const { op, sel, rooms, name, data } = envelope; // Wire-shape sanity on the most powerful envelope type: op a // string, sel a plain object, rooms (when present) an array of - // strings — a malformed command is dropped, never partially run. + // strings, an event's name a non-empty string — a malformed + // command is dropped, never partially run. if (typeof op !== 'string') return; if (sel !== undefined && (typeof sel !== 'object' || sel === null || Array.isArray(sel))) return; if (rooms !== undefined && (!Array.isArray(rooms) || rooms.some((room) => typeof room !== 'string'))) { return; } + if (op === 'event' && (typeof name !== 'string' || name.length === 0)) return; try { // `op` arrived from a peer: an unrecognised one is ignored, the same // way the switch's own default ignores an unrecognised envelope type. - this.#applyOp(op, sel ?? {}, rooms); + this.#applyOp(op, sel ?? {}, { rooms, name, data }); } catch (error) { this.#log.error({ err: error, event: 'cluster.command', op }); } diff --git a/src/rpc/core.js b/src/rpc/core.js index 45bd16a..ae22eb9 100644 --- a/src/rpc/core.js +++ b/src/rpc/core.js @@ -1,20 +1,41 @@ 'use strict'; -const { Emitter, jsonParse, isCodec } = require('../utils.js'); -const { generateUUID } = require('../runtime/node.js'); -const { ServerTransport, buildHeaders, isOriginAllowed } = require('../transport.js'); +const { Emitter, jsonParse, isCodec, resolveGenerateId } = require('../utils.js'); +const { ServerTransport, buildHeaders, isOriginAllowed, readsFrames } = require('../transport.js'); +const { hasTransportShape, isInboundTransport } = require('./serverTransport.js'); const { SessionManager } = require('./sessions.js'); const { defineRouter, procedure, runHooksSafe } = require('./router.js'); -const { RoomRegistry, Broadcast, RoomsBackplane } = require('./rooms.js'); -const { Cluster } = require('./cluster.js'); +const { RoomRegistry, Broadcast, RoomsBackplane, BROADCAST_CHANNEL } = require('./rooms.js'); +const { Cluster, instanceOfClientId } = require('./cluster.js'); const { SseChannels } = require('../sse/server.js'); +const { normalizeCompression, addVary } = require('../contentEncoding.js'); +const { + maxMessageOf, + normalizeSyncCompression, + decodeOrNull, + createFailureReporter, +} = require('../compression/sync.js'); +const { createEnvelope } = require('./envelope.js'); +const { normalizeServerEncryption, wantsEncryption, SealedSocket } = require('../encryption/server.js'); +const { + FRAME_MARK, + FRAME_ATTACHMENTS, + FRAME_PACKET_COMPRESSED, + FRAME_CHUNK_COMPRESSED, + WRPC_V2, +} = require('../wire.js'); +const { isAttachmentsFrame, decodeAttachments } = require('../attachments.js'); // The channel header from the import-free constants module, NOT from // sse/server.js: the string is shared, the implementation is not. const { CHANNEL_HEADER } = require('../wire.js'); +const { splitChannelRef } = require('../sse/constants.js'); const { isBackplane } = require('../scaling/index.js'); const { handleMessage, + handleParsed, handleBinary, + dispatchMessage, + dispatchBinary, handleRpc, split, parseParams, @@ -24,8 +45,17 @@ const { const { createLoggerWriter } = require('../logging.js'); const { createServerTelemetry } = require('../telemetry/server.js'); const { TRACEPARENT, TRACESTATE } = require('../telemetry/shared.js'); -const { Context, Client, DEFAULT_MAX_SUBSCRIPTIONS, DEFAULT_MAX_CALLS, buildMeta } = require('./client.js'); -const { DEFAULT_META_MAX, declaredHeaders, declaredData } = require('./meta.js'); +const { + Context, + Client, + DEFAULT_MAX_SUBSCRIPTIONS, + DEFAULT_MAX_CALLS, + DEFAULT_MAX_STREAMS, + buildMeta, +} = require('./client.js'); +const { DEFAULT_META_MAX, declaredData } = require('./meta.js'); +const { readDeclared, normalizeDeclaredHeaders } = require('./handshake.js'); +const { AMBIENT_HEADERS } = require('./reserved.js'); // After this long an unsettled onConnect chain logs a warning: a hook that // never resolves holds the client's dispatch (see #addClient), and the warn @@ -36,6 +66,18 @@ const ServerHttpTransport = ServerTransport.transport.http; const ServerWsTransport = ServerTransport.transport.ws; const ServerEventTransport = ServerTransport.transport.event; +// The transport a socket attached with `meta.kind` gets: a registered +// ServerWsTransport subclass under that name ('wt' from @alexify/wrpc/wt, +// whose require registers it), the WebSocket one otherwise. hasOwn, and +// only a ws-shaped class: the table also holds http and event. +const socketTransportFor = (kind) => { + if (typeof kind === 'string' && kind && Object.hasOwn(ServerTransport.transport, kind)) { + const Transport = ServerTransport.transport[kind]; + if (Transport === ServerWsTransport || Transport.prototype instanceof ServerWsTransport) return Transport; + } + return ServerWsTransport; +}; + // call joins the caller's trace exactly like a packet call does. const copyTraceHeaders = (headers, packet) => { const parent = headers?.traceparent; @@ -54,6 +96,8 @@ const DEFAULT_BASE_PATH = '/api'; // The options the core owns. Every shell and adapter funnels its own option // bag through here, so adding a core option cannot be silently dropped by // one of the four places that construct an RpcServer. +// The keys `options.rooms` knows; anything else is a warning (see #initRooms). +const ROOMS_OPTION_KEYS = ['epoch', 'linger', 'maxTracked', 'compression', 'encryption', 'maxMessage']; const RPC_OPTION_KEYS = [ 'router', 'sessions', @@ -68,12 +112,19 @@ const RPC_OPTION_KEYS = [ 'maxBatch', 'maxSubscriptions', 'maxCalls', + 'maxStreams', 'sse', + 'http', + 'compression', + 'maxMessage', + 'attachments', 'cluster', 'rooms', 'querystring', 'codec', 'metaMaxBytes', + 'declaredHeaders', + 'encryption', ]; const rpcOptions = (options = {}) => { @@ -111,11 +162,29 @@ class RpcServer extends Emitter { #limits; #generateId; #sse = null; + // The normalized `http.compression` option, or null (off, the default). + #compression = null; + #compressionFailed; + #httpFailed; + // The largest inflated client frame accepted on a socket (the Node ws + // client's per-message compression, negotiated on ping/pong). + #maxMessage; #querystring = null; #metaMax = DEFAULT_META_MAX; + #declared = null; + // Session encryption (@alexify/wrpc/encryption), normalized; null when off. + #encryption = null; + // Its per-request half: sealed HTTP calls unwrapped before routing. + #sealing = null; #codec = null; #codecOption = null; #restCodec = null; + // Binary attachments (src/attachments.js): on by default, `attachments: + // false` sends every packet as JSON as revision 1 did. + #attachments = true; + // `revision.mismatch` is a configuration error, the same on every + // connection: said once at warn, then at debug. + #mismatchSaid = false; #draining = false; #clients = new Set(); #byId = new Map(); @@ -130,34 +199,40 @@ class RpcServer extends Emitter { logger = globalThis.console, telemetry = null, backplane = null, - instanceId = generateUUID(), + instanceId = null, generateId = null, introspection = true, maxBatch = DEFAULT_MAX_BATCH, maxSubscriptions = DEFAULT_MAX_SUBSCRIPTIONS, maxCalls = DEFAULT_MAX_CALLS, + maxStreams = DEFAULT_MAX_STREAMS, sse = {}, + http = {}, + compression = null, + maxMessage, + attachments = true, cluster = {}, rooms = {}, querystring = null, codec = null, metaMaxBytes = DEFAULT_META_MAX, + declaredHeaders = null, + encryption, } = options; // The cap on peer-declared metadata (the ws wrpc_h query parameter and // the per-packet meta field), measured on the encoded input. this.#metaMax = Number.isInteger(metaMaxBytes) && metaMaxBytes > 0 ? metaMaxBytes : DEFAULT_META_MAX; + // Opt-in allowlist of the names a ws handshake may DECLARE (beside the + // deny list, which always applies): `['authorization']` for a deployment + // whose handlers read nothing else from a declaration. + this.#declared = normalizeDeclaredHeaders(declaredHeaders, 'RpcServer: options'); + this.#encryption = normalizeServerEncryption(encryption, 'RpcServer: options'); if (!router || typeof router.getProcedure !== 'function') { throw new TypeError('RpcServer: options.router (a Router from defineRouter) is required'); } if (backplane && !isBackplane(backplane)) { throw new TypeError('RpcServer: options.backplane does not implement the backplane contract'); } - // The dot separates the instance prefix from the rest of a client id - // (`.`), so an instance name carrying one - // would make every one of its client ids parse to the wrong address. - if (String(instanceId).includes('.')) { - throw new TypeError('RpcServer: options.instanceId must not contain "."'); - } // Pluggable query-string codec (qs and friends). Structural: anything // with parse(str) -> object. The default is the prototype-safe // URLSearchParams path in parseParams; an injected parser takes over @@ -170,23 +245,89 @@ class RpcServer extends Emitter { } this.#log = createLoggerWriter(logger); this.#otel = createServerTelemetry(telemetry); + if (this.#encryption !== null) { + const log = this.#log.child({ component: 'encryption' }); + this.#sealing = this.#encryption.http({ + log, + // What the connection says about the sender is not the sender's to + // declare inside a sealed request (reserved.js; handed over because + // src/encryption/ never requires src/rpc/). + reserved: AMBIENT_HEADERS, + // One bare status for every refusal; WHICH check it was is for the log. + // `quiet`: the sealing already said it, rate-limited (a replay cache + // full of live entries refuses a flood, and a line per request is + // the flood's second victim). + refuse: (call, headers, status, reason, quiet = false) => { + if (!quiet) log.warn({ event: 'encryption.refused', reason, kind: 'http' }); + this.#otel.recordEncryption(reason, 'http'); + this.#otel.recordCall(UNKNOWN_TARGET, 'error', status); + new ServerHttpTransport(call, { headers }).error(status); + }, + }); + } // Built once here rather than per broadcast or per channel: Broadcast is // constructed on every to()/except()/broadcast(). this.#roomsLog = this.#log.child({ component: 'rooms' }); this.#sseLog = this.#log.child({ component: 'sse' }); - this.#sessions = new SessionManager(sessions, this.#log.child({ component: 'sessions' })); + // One reporter for every carrier this core compresses on: a codec that + // threw is counted each time and said once (see createFailureReporter). + this.#compressionFailed = createFailureReporter(this.#log.child({ component: 'compression' }), this.#otel); + this.#httpFailed = (coding, error) => this.#compressionFailed('http', 'encode', coding, error); + this.#sessions = new SessionManager(sessions, this.#log.child({ component: 'sessions' }), this.#otel); + // Session encryption is for where TLS ends before the data does — and + // there a cookie is the wrong credential: it stays on the OUTER request + // by construction (script cannot set HttpOnly), on every transport, read + // by the very terminator the encryption exists to keep out. Said once, + // at construction; bearerTransport()/payloadTransport() carry the + // credential inside the channel. + if (this.#encryption !== null && this.#sessions.transport.ambient === true) { + this.#log.warn({ event: 'encryption.ambient-session' }); + } this.#cors = cors; this.#basePath = normalizeBasePath(basePath); - this.#instance = instanceId; - this.#generateId = typeof generateId === 'function' ? generateId : generateUUID; + // Resolved before #instance, because an omitted instanceId is minted BY + // the generator: a user who injected one gets it used for every id the + // server mints, the routing prefix included, not for all of them but one. + const ids = resolveGenerateId(generateId, 'RpcServer'); + this.#generateId = ids.generate; + this.#instance = instanceId ?? ids.first; + // The dot separates the instance prefix from the rest of a client id + // (`.`), so an instance name carrying one + // would make every one of its client ids parse to the wrong address. + // Checked after the mint, so a generator that answers a dotted id is + // refused by the same rule as a hand-passed one — and named as the + // option that actually produced it. + if (String(this.#instance).includes('.')) { + const source = instanceId === null ? 'generateId must not return' : 'options.instanceId must not contain'; + throw new TypeError(`RpcServer: ${source} "."`); + } this.#querystring = querystring; + if (typeof http !== 'object' || http === null) throw new TypeError('RpcServer: options.http must be an object'); + // gzip on packet-mode and REST answers for peers that ask for it — off + // unless the app turns it on; the SSE half has its own under `sse`. + this.#compression = normalizeCompression(http.compression, 'RpcServer: options.http'); // Two halves, two fields: #codec is the PACKET codec (ws/http/sse/worker // frames — a rest-only codec leaves packet mode JSON), #restCodec the // REST body codec. The raw option survives for the public getter. this.#codecOption = codec; this.#codec = codec && typeof codec.encode === 'function' && typeof codec.decode === 'function' ? codec : null; this.#restCodec = codec?.rest ?? null; - this.#limits = { maxBatch, maxSubscriptions, maxCalls }; + // `compression` here is the SOCKET side: accepting per-message + // compressed frames from a Node ws client that negotiated them. Off by + // default, like the http/sse/rooms/cluster halves; rides in the limits + // bag so the dispatcher's ping handler sees it. + this.#attachments = attachments !== false; + // A packet codec owns the wire: attachments are its business, not ours. + if (this.#codec) this.#attachments = false; + this.#limits = { + maxBatch, + maxSubscriptions, + maxCalls, + maxStreams, + compression: normalizeSyncCompression(compression, 'RpcServer: options'), + attachments: this.#attachments, + }; + this.#maxMessage = maxMessageOf(maxMessage, 'RpcServer'); this.#router = this.#withIntrospection(router, introspection); // A compiled fjs serializer emits JSON; a packet codec re-frames the // whole wire. Both at once would mean the serializer's output is thrown @@ -202,20 +343,24 @@ class RpcServer extends Emitter { // does — otherwise a browser holding a valid cookie starts the channel // anonymous and every `access: 'session'` procedure on it answers 403. const addClient = (transport, call) => { - const data = declaredData(call.headers, split(call.url ?? '', '?')[1], this.#metaMax, this.#sseLog); - return this.#addClient( - transport, - (client) => - this.#restoreToken(client, { headers: call.headers, url: call.url, declared: call.headers, meta: data }), - buildMeta({ headers: call.headers, data, url: call.url, remoteAddress: call.remoteAddress }), - ); + const { restore, meta } = this.#identify(call, false, this.#sseLog); + return this.#addClient(transport, restore, meta); }; // The identity a request presents, bound to a channel at creation and // required again on every re-attach and channel POST — the id alone // must never be enough to act as the channel's session. const channelKey = (headers) => this.#requestKey(headers); this.#sse = - sse === false ? null : new SseChannels({ ...sse, log: this.#sseLog, otel: this.#otel, addClient, channelKey }); + sse === false + ? null + : new SseChannels({ + generateId: this.#generateId, + ...sse, + log: this.#sseLog, + otel: this.#otel, + addClient, + channelKey, + }); } #initRooms(backplane, clusterOptions, roomsOptions = {}) { @@ -227,18 +372,74 @@ class RpcServer extends Emitter { // presence replication, commands and asks degrade to their local // halves, while the rooms backplane below is untouched. const enabled = clusterOptions !== false; + // Envelope compression on the backplane, per layer and off by default: + // a string carrier, so a compressed envelope rides as base64 under a + // marker — and unlike the socket carriers there is no negotiation, so + // every instance must run it (documented as a two-step rollout). + const clusterLog = this.#log.child({ component: 'cluster' }); + // `encryption` seals what compression left, per layer as well, under a + // shared keyring (./envelope.js): Redis and whoever operates it then + // carry ciphertext. Off by default; the rollout is three deploys. + const clusterEnvelope = enabled + ? createEnvelope({ + compression: clusterOptions?.compression, + encryption: clusterOptions?.encryption, + maxMessage: maxMessageOf(clusterOptions?.maxMessage, 'RpcServer: options.cluster'), + name: 'RpcServer: options.cluster', + layer: 'cluster', + event: 'cluster', + log: clusterLog, + failed: (direction, codec, error) => this.#compressionFailed('cluster', direction, codec, error), + }) + : null; const cluster = new Cluster({ backplane: enabled ? backplane : null, instance: this.#instance, local: this.#clusterOps(), - log: this.#log.child({ component: 'cluster' }), + log: clusterLog, otel: this.#otel, generateId: this.#generateId, - options: enabled ? clusterOptions : {}, + options: enabled ? { ...clusterOptions, envelope: clusterEnvelope, attachments: this.#attachments } : {}, }); this.#cluster = cluster; + // `onSubscribe`/`onUnsubscribe` fire on a room's FIRST member and its + // last, which is exactly where a live-room gauge moves — so the + // instrument rides the callbacks that already exist rather than costing + // the hot join/leave path anything. + const countRoom = (delta) => this.#otel.recordRooms(delta); + // `rooms` is a 1.0 option, so an unknown key is a warning, not a + // TypeError — except `rooms.backplane`, the one spelling that reads as + // if it worked (the backplane is a top-level option) and did nothing. + if (roomsOptions !== null && typeof roomsOptions === 'object') { + if (roomsOptions.backplane !== undefined) { + throw new TypeError( + 'RpcServer: options.rooms.backplane is not an option — pass the backplane as options.backplane', + ); + } + for (const key in roomsOptions) { + if (!ROOMS_OPTION_KEYS.includes(key)) this.#roomsLog.warn({ event: 'rooms.option', key }); + } + } + // Built before the no-backplane return, so `rooms.compression` and + // `rooms.encryption` are validated where they are written even on an + // instance that has no backplane to use them on (a codec name the + // platform lacks, a keyring that is not one) — the cluster options were + // already checked that way. + const envelope = createEnvelope({ + compression: roomsOptions?.compression, + encryption: roomsOptions?.encryption, + maxMessage: maxMessageOf(roomsOptions?.maxMessage, 'RpcServer: options.rooms'), + name: 'RpcServer: options.rooms', + layer: 'rooms', + event: 'backplane', + log: this.#roomsLog, + failed: (direction, codec, error) => this.#compressionFailed('rooms', direction, codec, error), + }); if (!backplane) { - this.#rooms = new RoomRegistry(); + this.#rooms = new RoomRegistry({ + onSubscribe: () => countRoom(1), + onUnsubscribe: () => countRoom(-1), + }); return; } const binder = new RoomsBackplane({ @@ -246,17 +447,41 @@ class RpcServer extends Emitter { instance: this.#instance, log: this.#roomsLog, linger: roomsOptions?.linger, + maxTracked: roomsOptions?.maxTracked, + envelope, + // The producer-restart marker a resume cursor is validated against. + // Accepted by RoomsBackplane all along; forwarded (and declared) only + // now, so a deployment that pins it across restarts finally can. + epoch: roomsOptions?.epoch, + // Loss made visible: a jump in a publisher's sequence is logged and + // counted, so a broker that drops envelopes shows up in dashboards + // instead of in a bug report about a message nobody received. + // The log line names the channel; the metric carries only its KIND + // — a room name is peer-chosen and unbounded, and a label with an + // unbounded set of values is a cardinality bomb in any metrics + // backend. Computed here, in the Node-only core: the writer is + // bundled into the webrtc browser entry. + onGap: ({ channel, instance, missed }) => { + this.#roomsLog.warn({ event: 'backplane.gap', channel, instance, missed }); + this.#otel.recordBackplaneGap(channel === BROADCAST_CHANNEL ? 'broadcast' : 'room', missed); + }, // A replayed event is delivered LOCALLY: publishing it again would // bounce it between instances forever. - deliver: (rooms, name, data) => { + deliver: (rooms, name, data, unreliable = false) => { const target = rooms ? this.#target().to(...rooms) : this.#target(); - target.local().emit(name, data); + target.local().emit(name, data, unreliable ? { unreliable: true } : null); }, }); this.#backplane = binder; this.#rooms = new RoomRegistry({ - onSubscribe: (room) => binder.joinRoom(room), - onUnsubscribe: (room) => binder.leaveRoom(room), + onSubscribe: (room) => { + countRoom(1); + binder.joinRoom(room); + }, + onUnsubscribe: (room) => { + countRoom(-1); + binder.leaveRoom(room); + }, // Every membership change is a presence delta; the periodic snapshot // corrects whatever the broker drops. onJoin: (room) => cluster.delta(room, 1), @@ -272,7 +497,11 @@ class RpcServer extends Emitter { const select = (sel = {}) => { if (typeof sel.id === 'string') { const client = this.#byId.get(sel.id); - return client ? [client] : []; + if (!client) return []; + // id AND room: the room is a condition on that one client, not a + // second population — an addressed relay bounded by a membership. + if (typeof sel.room === 'string' && !client.in(sel.room)) return []; + return [client]; } if (typeof sel.room === 'string') return Array.from(this.#rooms.members(sel.room)); // Persistent connections only: a per-request HTTP client is not a @@ -328,6 +557,11 @@ class RpcServer extends Emitter { disconnect: (sel) => { for (const client of select(sel)) client.close(); }, + // The local leg of Cluster.send / RpcServer.sendTo: a per-request HTTP + // client cannot receive one, and is skipped the way Broadcast skips it. + event: (sel, name, data) => { + for (const client of select(sel)) if (client.persistent) client.sendEvent(name, data); + }, // The remote leg of a broadcast ask: LOCAL delivery only — the // question already reached every other node as its own request. An // ARRAY of rooms is a narrowing even when empty (to() with no rooms @@ -351,6 +585,35 @@ class RpcServer extends Emitter { return this.#byId.get(id); } + /** + * One event to one client by id, on this instance or — through the + * cluster's addressed command — on the instance its id names. `room` + * narrows delivery to a client still in that room. Returns true when + * the event was delivered locally or handed to the backplane, false when + * it is known not to be deliverable (unknown local id, non-persistent + * client, not in `room`, or a foreign id with no backplane). Bytes in + * `data` cross to the other instance as bytes — a binary envelope, as a + * room event's do. + */ + sendTo(clientId, name, data, options = {}) { + if (typeof clientId !== 'string' || clientId.length === 0) { + throw new TypeError('sendTo: clientId must be a non-empty string'); + } + if (typeof name !== 'string' || name.length === 0) { + throw new TypeError('Event name must be a non-empty string'); + } + const room = typeof options.room === 'string' ? options.room : undefined; + const client = this.#byId.get(clientId); + if (client) { + if (!client.persistent || (room !== undefined && !client.in(room))) return false; + client.sendEvent(name, data); + return true; + } + const instance = instanceOfClientId(clientId); + if (instance === null || instance === this.#instance || !this.#backplane) return false; + return this.#cluster.send(clientId, name, data, { room }); + } + get router() { return this.#router; } @@ -372,11 +635,35 @@ class RpcServer extends Emitter { return this.#basePath; } + /** The per-connection caps every attached client gets — frozen. */ + get limits() { + return Object.freeze({ ...this.#limits }); + } + + /** + * Whether `encryption.required` is on: what a binding built on `attach` + * (the broker consumers, a raw data channel) reads to vouch for its + * transport — or refuse to attach at all — before a delivery arrives. + */ + get encryptionRequired() { + return this.#encryption?.required === true; + } + /** The injected codec option, verbatim — how an adapter inspects codec.rest. */ get codec() { return this.#codecOption; } + /** + * The newest protocol revision this server speaks (protocol.md#versioning): + * 2, or 1 when it sends and reads no framed messages (`attachments: false`, + * a packet codec). What a shell composing an engine reads to narrow the + * WebSocket negotiation, and what `wrpc-version` answers on HTTP. + */ + get revision() { + return this.#attachments ? 2 : 1; + } + get clients() { return new Set(this.#clients); } @@ -390,6 +677,25 @@ class RpcServer extends Emitter { return this.#otel; } + // The same seam for a codec that failed on a carrier attached from + // outside (a WebTransport session, a broker binding): + // `(carrier, direction, codec id, error)` — counted every time, said once + // per carrier, direction and codec. + get compressionFailed() { + return this.#compressionFailed; + } + + // The logging half of the same seam, and for the same reason: a framework + // adapter or an external attacher (a WebTransport session, an express + // handler) has somewhere to report a failure that never reaches a Client, + // rather than emitting an 'error' nobody listens for. This is the + // NORMALIZED writer, not the `logger` that was passed — re-wrapping one is + // free, so handing it straight back into another component's `logger` + // option is the intended use. + get log() { + return this.#log; + } + /** * A host-delegated REST route (the fastify adapter's native routes) runs * its procedure outside handleHttpCall: the host owns routing, validation @@ -400,25 +706,35 @@ class RpcServer extends Emitter { * #handleRest applies. */ async delegatedContext({ method = 'GET', headers = {}, remoteAddress = '', url = '' } = {}, target = null) { + // A host-delegated route is a plaintext request by construction — a + // sealed one is unwrapped by handleHttpCall and answered by the core's + // own REST — so under `required` it is refused here, BEFORE a client is + // added, the same 426 the core answers on its own surface. + if (this.#encryption?.required) { + this.#refusePlaintext('http'); + const error = new Error('Upgrade Required: this server answers sealed requests only'); + error.code = 426; + error.statusCode = 426; + error.expose = true; + throw error; + } const transport = new ServerHttpTransport({ headers, remoteAddress, respond: () => {} }, { headers: {} }); const verb = String(method).toUpperCase(); const safeMethod = verb === 'GET' || verb === 'HEAD'; - // The safe-method CSRF rule guards AMBIENT authority (a browser cookie - // attached without script). A non-ambient carrier — a bearer header the - // page's own code must set — has nothing to guard, so it restores on - // safe methods too. - const ambient = this.#sessions.transport.ambient === true; - const data = declaredData(headers, split(url ?? '', '?')[1], this.#metaMax, this.#log); - const restore = - !safeMethod || !ambient || this.#isSameOriginFetch(headers) - ? (c) => this.#restoreToken(c, { headers, url, declared: headers, meta: data }) - : null; - const client = this.#addClient(transport, restore, buildMeta({ headers, data, url, remoteAddress })); + const { restore, meta } = this.#identify({ headers, url, remoteAddress }, safeMethod); + const client = this.#addClient(transport, restore, meta); await client.ready; const context = client.createContext(null, target); return { client, context, transport, release: () => transport.emit('close') }; } + // One line and one metric for every plaintext request refused under + // `encryption.required`, whichever surface it arrived on. + #refusePlaintext(kind) { + this.#log.warn({ event: 'encryption.refused', reason: 'plaintext', kind }); + this.#otel.recordCall(UNKNOWN_TARGET, 'error', 426); + } + #target() { return new Broadcast({ registry: this.#rooms, @@ -428,6 +744,7 @@ class RpcServer extends Emitter { log: this.#roomsLog, otel: this.#otel, codec: this.#codec, + attachments: this.#attachments, }); } @@ -469,11 +786,20 @@ class RpcServer extends Emitter { return router.merge(system); } + // A connection or a request whose peer said nothing of revision 2: no + // framed message is sent to it. The per-transport flag is the one + // `attachments: false` sets server-wide, so the send paths check nothing + // new. + #speakRevision1(transport) { + transport.setRevision(1); + } + #addClient(transport, restore = null, meta = null) { // The transport encodes outbound packets; the Client decodes inbound // ones. One server-wide codec — which is what keeps the broadcast // fan-out single-encode. if (this.#codec) transport.codec = this.#codec; + if (!this.#attachments) transport.attachments = false; const options = { codec: this.#codec, sessions: this.#sessions, @@ -483,6 +809,7 @@ class RpcServer extends Emitter { otel: this.#otel, maxSubscriptions: this.#limits.maxSubscriptions, maxCalls: this.#limits.maxCalls, + maxStreams: this.#limits.maxStreams, generateId: this.#generateId, meta, metaMax: this.#metaMax, @@ -490,7 +817,13 @@ class RpcServer extends Emitter { const client = new Client(transport, options); this.#clients.add(client); this.#byId.set(client.id, client); - this.#otel.recordConnection(1, transport.kind); + this.#otel.recordConnection(1, transport.kind, transport.revision); + // A revision settled after attach (a port's ping, a WebTransport peer's + // capabilities) moves the connection between the two series. + transport.on('revision', (from) => { + this.#otel.recordConnection(-1, transport.kind, from); + this.#otel.recordConnection(1, transport.kind, transport.revision); + }); // Assigned BEFORE the hooks run: the documented recipe is // `onConnect: async (client) => { await client.sessionReady; ... }`, and // a hook that ran ahead of this assignment awaited the constructor's @@ -516,7 +849,14 @@ class RpcServer extends Emitter { if (typeof stall.unref === 'function') stall.unref(); void client.ready.then(() => clearTimeout(stall)); } - transport.once('close', () => { + // `on` and a flag, not `once`: a transport may say 'close' twice (an + // HTTP answer, then its response's own close), and a once-listener + // sends every emit down the Emitter's snapshot path — ~315 against ~75 + // ns, paid once per HTTP request (bench/http-call.js). + let closed = false; + transport.on('close', () => { + if (closed) return; + closed = true; // Snapshotted BEFORE destroy(): its first act is rooms.leaveAll(), so // by hook time the registry is empty — the payload is the only way a // disconnect hook learns which rooms the client was in. (client.rooms @@ -526,12 +866,37 @@ class RpcServer extends Emitter { client.destroy(); this.#clients.delete(client); this.#byId.delete(client.id); - this.#otel.recordConnection(-1, transport.kind); + this.#otel.recordConnection(-1, transport.kind, transport.revision); if (onDisconnect.length > 0) void runHooksSafe(onDisconnect, client, payload, this.#log, 'onDisconnect'); }); return client; } + // Who a request says it is — the three steps every request-shaped entry + // point takes (an SSE channel's GET, a packet POST, a REST call, a + // host-delegated route, `attach({ request })`), in ONE place: the declared + // connection data, the thunk that restores a session from the request's + // token, and the client's meta. They were five copies, and a field added + // to what a token carrier reads in four of them is a session restored + // differently on the fifth. (attachSocket is not one of them: a WebSocket + // handshake brings its declarations on other carriers — readDeclared.) + // + // `safeMethod`: the CSRF rule for a GET/HEAD. It guards AMBIENT authority + // (a browser cookie attached without script) and lives only here: such a + // request restores no session unless the browser says it is same-origin + // (#isSameOriginFetch). A non-ambient carrier — a bearer header the + // page's own code must set — has nothing to guard, so it restores on safe + // methods too. + #identify({ headers, url, remoteAddress }, safeMethod = false, log = this.#log) { + const data = declaredData(headers, split(url ?? '', '?')[1], this.#metaMax, log); + const guarded = safeMethod && this.#sessions.transport.ambient === true && !this.#isSameOriginFetch(headers); + return { + data, + restore: guarded ? null : (client) => this.#restoreToken(client, { headers, url, declared: headers, meta: data }), + meta: buildMeta({ headers, data, url, remoteAddress }), + }; + } + // The injected token carrier decides what "this request presents a // session" means: a cookie by default, an Authorization header or a // payload field when the app swapped the strategy (sessions.transport). @@ -551,23 +916,126 @@ class RpcServer extends Emitter { (error) => { this.#log.error({ err: error, event: 'session.restore' }); this.#otel.recordSession('restore', 'error'); + // Not "no session": the store could not be asked. A session + // procedure is then refused 503 — what a broker consumer retries — + // where a 403 sent the delivery straight to its dead-letter queue. + client.sessionUnavailable = true; return false; }, ); } + #isKeyPath(url) { + const [pathname] = split(url ?? '/', '?'); + return pathname === `${this.#basePath}/encryption-key`; + } + + // The socket a connection is attached through: a SealedSocket when the + // peer announced encryption, one that refuses when `required` and it did + // not, null for a plaintext connection that is allowed to be one. + #sealSocket(socket, meta) { + const encryption = this.#encryption; + if (encryption === null) return null; + // The peer rides the child bindings: a refused handshake names WHO, on + // a socket transport (an http refusal has the proxy's address instead, + // and carries none). The counter is the core's, handed in as a callback + // so src/encryption/ imports no telemetry. + const kind = typeof meta.kind === 'string' && meta.kind ? meta.kind : 'ws'; + const peer = meta.remoteAddress ?? socket.remoteAddress; + const log = this.#log.child( + typeof peer === 'string' && peer ? { component: 'encryption', peer } : { component: 'encryption' }, + ); + const record = (outcome) => this.#otel.recordEncryption(outcome, kind); + if (wantsEncryption(meta.url)) return new SealedSocket(socket, { encryption, kind, log, record }); + return encryption.required + ? new SealedSocket(socket, { encryption, kind, log, record, refuse: 'plaintext' }) + : null; + } + + /** + * The public key bundle of the current encryption key — what a client + * pins as `serverKey`. Safe to publish; null when encryption is off. + */ + encryptionKey() { + return this.#encryption === null ? Promise.resolve(null) : this.#encryption.bundle(); + } + attachSocket(socket, meta = {}) { - const transport = new ServerWsTransport(socket, meta); - // Declared-then-observed: the wrpc_h query can only add names the - // upgrade request did not carry (see declaredHeaders). - const declared = declaredHeaders(meta.url, this.#metaMax, this.#log); - const merged = declared ? { ...declared, ...meta.headers } : meta.headers; - const data = declaredData(meta.headers, split(meta.url ?? '', '?')[1], this.#metaMax, this.#log); + // Session encryption: a client announces it in the connect URL, because + // the server may be the first to send (an onConnect hook, a broadcast) + // and has to know the mode before any frame. The socket is then a + // SealedSocket — the handshake, and every message after it decrypted and + // re-announced as an engine socket would — and nothing below this line + // changes. The flag is not a secret and stripping it buys an attacker a + // refusal: a client configured to encrypt never accepts plaintext, and + // under `required` neither does this server. + const raw = socket; + const sealed = this.#sealSocket(socket, meta); + if (sealed !== null) socket = sealed; + const transport = new (socketTransportFor(meta.kind))(socket, meta); + // The revision this connection speaks (protocol.md#versioning). A + // WebSocket says it in the subprotocol the engine selected: a 1.0 client + // offers `wrpc.v1` alone, and is then sent no framed message — its bytes + // travel as the JSON 1.0 made of them, through the same per-transport + // flag `attachments: false` sets for everyone. WebTransport says it in + // the capabilities each end sends first (`f`): revision 1 until the + // client's arrive and say it reads frames — two 2.x ends whose + // `attachments` disagree used to send each other frames the other + // refused. + if (meta.kind === 'wt') { + this.#speakRevision1(transport); + const raise = (reads) => { + if (!reads) return void this.#log.debug({ event: 'revision.peer', transport: 'wt', peer: meta.remoteAddress }); + if (this.#attachments) transport.setRevision(2); + }; + if (typeof raw.peerFrames === 'boolean') raise(raw.peerFrames); + else raw.once?.('frames', raise); + } else if (socket.protocol !== WRPC_V2) { + this.#speakRevision1(transport); + // Who is still on 1.0 during an upgrade: per connection, at debug. + this.#log.debug({ + event: 'revision.peer', + transport: meta.kind ?? 'ws', + protocol: socket.protocol || null, + peer: meta.remoteAddress, + }); + } else if (!this.#attachments) { + // An engine composed by hand selected `wrpc.v2` for a server that + // sends no frames and reads none: the client will send one. The + // built-in shells narrow the engine to `wrpc.v1` (see `revision`). + const level = this.#mismatchSaid ? 'debug' : 'warn'; + this.#mismatchSaid = true; + this.#log[level]({ event: 'revision.mismatch', protocol: socket.protocol }); + } + // Declared-then-observed: whichever carrier brought the bags (subprotocol + // offers, the query, real headers), a declaration can only add names the + // upgrade request did not carry — see rpc/handshake.js, which a + // verifyClient gate reads through as well (readHandshake). + const { + headers: merged, + meta: data, + declared, + } = readDeclared(meta.headers, meta.url, this.#metaMax, this.#log, this.#declared); const client = this.#addClient( transport, - (c) => this.#restoreToken(c, { headers: meta.headers, url: meta.url, declared: merged, meta: data }), + // The session is restored once the handshake is done: the dispatcher + // awaits `client.ready`, so no call runs on a channel still in the + // clear, and an onConnect hook that awaits `client.sessionReady` reads + // `client.encryption` — the peer's static key under XX, the handshake + // hash to bind a credential to. + (c) => { + const restore = () => + this.#restoreToken(c, { headers: meta.headers, url: meta.url, declared: merged, meta: data }); + if (sealed === null) return restore(); + return sealed.ready.then((info) => { + if (info === null) return false; + c.encryption = info; + return restore(); + }); + }, buildMeta({ headers: merged, + declared, data, url: meta.url, remoteAddress: meta.remoteAddress ?? socket.remoteAddress, @@ -585,11 +1053,132 @@ class RpcServer extends Emitter { }; socket.on('message', (data, isBinary) => { if (!isBinary) return void handleMessage(client, data, this.#router, this.#limits); + let bytes = new Uint8Array(data); + // A framed message (wire.js): a compressed packet is dispatched from + // here, a compressed chunk falls through to the chunk path inflated; + // an attachments frame is the chunk path's to dispatch (handleBinary). + if (bytes[0] === FRAME_MARK && bytes[1] !== FRAME_ATTACHMENTS) { + bytes = this.#inflateFrame(client, bytes); + if (bytes === null) return; + } inflight++; if (inflight === 1 && typeof socket.pause === 'function') socket.pause(); - handleBinary(client, new Uint8Array(data)).then(done, done); + handleBinary(client, bytes, this.#router, this.#limits).then(done, done); }); - socket.on('error', () => transport.emit('close')); + // Debug, not warn: the built-in Connection already wrote its own line + // for what it saw; this is the client's side of the same event. + socket.on('error', (error) => { + client.log.debug({ event: 'socket.error', err: error }); + transport.emit('close'); + }); + return client; + } + + // A 0x00-marked binary frame from a socket peer: inflated with the codec + // the peer negotiated on ping/pong. Anything else — a frame before the + // negotiation, an unknown kind, a body that does not inflate under + // `maxMessage` — is answered with an id-less 400 and logged, never a + // hang-up: the connection itself is fine. Answers the inflated chunk for + // kind 4, null otherwise (a packet was dispatched, or the frame refused). + #inflateFrame(client, bytes) { + const kind = bytes[1]; + const active = client.compression; + if (active === null || (kind !== FRAME_PACKET_COMPRESSED && kind !== FRAME_CHUNK_COMPRESSED)) { + client.log.warn({ event: 'frame.refused', kind, negotiated: active !== null }); + client.error(400, { error: new Error('Unexpected framed message'), level: 'debug' }); + return null; + } + // Why it did not inflate goes on the line: ERR_BUFFER_TOO_LARGE is the + // `maxMessage` cap, anything else the bytes — garbage, or a dictionary + // the two ends do not share. + let cause = null; + const out = decodeOrNull(active, bytes.subarray(2), this.#maxMessage, (error) => void (cause = error)); + if (out === null) { + this.#otel.recordCompressionFailure(client.transportKind, 'decode'); + client.log.warn({ event: 'frame.refused', kind, reason: 'inflate', codec: active.id, code: cause?.code }); + client.error(400, { error: new Error('Framed message does not inflate'), level: 'debug' }); + return null; + } + if (kind === FRAME_CHUNK_COMPRESSED) return new Uint8Array(out.buffer, out.byteOffset, out.byteLength); + handleMessage(client, out, this.#router, this.#limits); + return null; + } + + /** + * Any persistent inbound transport announcing its traffic as 'packet' + * (text) and 'chunk' (bytes) events — the seam a wire this core never + * heard of plugs into (a WebRTC data channel does, through + * attachChannel in @alexify/wrpc/webrtc: the core knows no framing). + * `meta` is a buildMeta() result (rpc/client.js) or null: a transport + * carries no request, so whatever the application observed about the + * connection is handed over. + * + * Two ways to give the client an identity, mutually exclusive: + * - `session`: a pseudo-session object the host vouches for (a broker + * binding's service identity, the PeerHost `trust` pattern). Assigned + * before the onConnect hooks run, so a hook already sees it. + * - `request: { headers, url, remoteAddress }`: what the peer presented, + * restored through the configured token carrier exactly as + * attachSocket does — a bearer token riding a broker header restores a + * real session. + * + * `persistent: false` attaches a request/response carrier instead — a + * broker consumer binding, whose client runs calls but carries no events, + * streams or subscriptions and is not one of the "connected clients" + * broadcasts, presence and fetchClients count. Its transport's + * `connection` is cleared, which is exactly what Client.persistent reads. + */ + attach(transport, { meta: given = null, session = null, request = null, persistent = true, encrypted = false } = {}) { + // Under `encryption.required` a wire this core cannot see into has to + // be vouched for: a WebRTC data channel is (DTLS, end to end), a broker + // binding is when it seals its own frames. + if (this.#encryption?.required && encrypted !== true) { + throw new Error('RpcServer.attach: encryption is required, and this transport was not declared encrypted'); + } + if (persistent === false) { + if (!hasTransportShape(transport)) { + throw new TypeError('RpcServer.attach: a transport with write/send/error/close/on/once/off is required'); + } + transport.connection = null; + } else if (!isInboundTransport(transport)) { + throw new TypeError( + 'RpcServer.attach: a persistent transport (connection set) with write/send/error/close/on/once/off is required', + ); + } + if (session !== null && (typeof session !== 'object' || Array.isArray(session))) { + throw new TypeError('RpcServer.attach: options.session must be an object'); + } + if (session !== null && request !== null) { + throw new TypeError('RpcServer.attach: options.session and options.request are mutually exclusive'); + } + if (given !== null && (typeof given !== 'object' || Array.isArray(given))) { + throw new TypeError('RpcServer.attach: options.meta must be an object'); + } + // What the application observed is normalized like every other entry + // point's meta — frozen, its headers a null-prototype bag — whether it + // was built with buildMeta() or written by hand. + let meta = given === null ? null : buildMeta(given); + let restore = null; + if (session !== null) { + restore = (client) => { + client.session = session; + return Promise.resolve(true); + }; + } else if (request !== null) { + if (typeof request !== 'object') throw new TypeError('RpcServer.attach: options.request must be an object'); + const identity = this.#identify({ + headers: request.headers ?? {}, + url: request.url ?? '', + remoteAddress: request.remoteAddress, + }); + restore = identity.restore; + meta ??= identity.meta; + } + const client = this.#addClient(transport, restore, meta); + // Contained: a listener that threw would reject the transport's emit() + // — an unhandled rejection for whoever announced the packet. + transport.on('packet', (text) => dispatchMessage(client, text, this.#router, this.#limits)); + transport.on('chunk', (bytes) => dispatchBinary(client, bytes, this.#router, this.#limits)); return client; } @@ -599,11 +1188,26 @@ class RpcServer extends Emitter { // consumer that received declared headers in the 'wrpc:connect' message // may hand them over here. const client = this.#addClient(transport, null, meta?.headers ? buildMeta({ headers: meta.headers }) : null); + // The revision is the page's to name: on its first ping, or — for a + // consumer that hands the connect message over — in `meta.v`. Until + // then the port is sent no framed message, and a server that sends + // none itself never raises it. + if (!this.#attachments) transport.max = 1; + if (meta?.v !== undefined) transport.negotiate(meta.v); port.on('message', (data) => { - if (typeof data === 'string' || Buffer.isBuffer(data)) { - handleMessage(client, data, this.#router, this.#limits); - } else if (data instanceof Uint8Array) { - handleBinary(client, data); + // Same rule as the socket path: text is a packet, bytes are a stream + // chunk. A Buffer IS a Uint8Array, and checking Buffer.isBuffer first + // used to hand a binary chunk to the JSON parser. Anything else + // (a structured-clone of an object) is not on the wire and is dropped. + if (typeof data === 'string') { + dispatchMessage(client, data, this.#router, this.#limits); + } else if (ArrayBuffer.isView(data)) { + dispatchBinary( + client, + new Uint8Array(data.buffer, data.byteOffset, data.byteLength), + this.#router, + this.#limits, + ); } }); return client; @@ -625,9 +1229,24 @@ class RpcServer extends Emitter { // Packet-mode bodies speak the codec when one is configured; REST-mode // bodies stay JSON on purpose (curl and browsers are that mode's - // audience). Malformed input answers null either way. + // audience). Malformed input answers null either way. The body is parsed + // ONCE, here, and the same value is dispatched (handleParsed) — so this + // reads it exactly as handleMessage would: the frame checked before the + // codec seam, refused when the server sends none. #decodeBody(body) { - if (!this.#codec) return jsonParse(body); + const bytes = typeof body !== 'string' && body !== null && body !== undefined; + if (bytes && isAttachmentsFrame(body)) { + if (!this.#attachments) return null; + try { + return decodeAttachments(body); + } catch { + return null; + } + } + // The body's text handed over by hand: JSON.parse reaches a Buffer's + // toString() through ToPrimitive, ~100 ns slower on a call packet for + // the same string (bench/http-call.js). + if (!this.#codec) return jsonParse(bytes ? body.toString() : body); try { return this.#codec.decode(typeof body === 'string' ? body : String(body)); } catch { @@ -638,8 +1257,7 @@ class RpcServer extends Emitter { // A batch frame needs to be recognized BEFORE the transport exists: the // transport has to know how many answers to collect and in which order to // emit them, which only the request's own id list can tell it. - #batchIds(body) { - const packet = this.#decodeBody(body); + #batchIds(packet) { if (!Array.isArray(packet)) return null; if (packet.length === 0 || packet.length > this.#limits.maxBatch) return null; const ids = new Array(packet.length); @@ -675,37 +1293,86 @@ class RpcServer extends Emitter { // by a POST deliver its values down the peer's event stream. The POST // itself answers 202 — every reply travels on the stream. // - // The channel id alone is NOT enough: the POST must also present the - // cookie identity the channel was created under, or knowing an id (they - // ride in URLs and logs) would be a bearer token for someone else's + // The channel id alone is NOT enough: the POST must present the channel's + // secret (the `ready` frame's, after the id in the header) — the id is + // whatever the application's generator makes of it, a counter included — + // and, on top, the cookie identity the channel was created under, or + // knowing an id would be a bearer token for someone else's // session-carrying client. // // `headers` are the same CORS-bearing response headers every other HTTP // answer carries: without them a browser on another origin cannot read // this response at all, which makes cross-origin SSE impossible. - #handleChannelPost(call, channelId, headers) { + #handleChannelPost(call, channelRef, headers) { // Channel POSTs answer packet-mode bodies, so the packet codec's type. if (this.#codec?.contentType) headers = { ...headers, 'Content-Type': this.#codec.contentType }; - const channel = this.#sse.get(channelId); + const ref = splitChannelRef(channelRef); + const channel = ref === null ? null : this.#sse.get(ref.id); const respond = (status, packet) => { const body = Buffer.from(this.#codec ? this.#codec.encode(packet) : JSON.stringify(packet)); call.respond({ status, headers: { ...headers, 'Content-Length': body.length }, body }); }; - if (!channel) { + if (!channel || !this.#sse.holds(channel, ref.secret)) { // 409, matching the events endpoint: "this channel is gone" is the - // signal the client recovers from by starting a fresh channel. + // signal the client recovers from by starting a fresh channel — and + // the same answer for a live channel whose secret was not presented, + // so a guessed id learns nothing. return void respond(409, { type: 'callback', id: '', error: { message: 'Unknown channel', code: 409 } }); } if (!this.#sse.authorized(channel, call.headers)) { const error = { message: 'Channel belongs to another session', code: 403 }; return void respond(403, { type: 'callback', id: '', error }); } + // An attachments frame on a channel POST: SSE is text-only in both + // directions, and the refusal is explicit rather than a corrupt value. + if (typeof call.body !== 'string' && call.body !== null && isAttachmentsFrame(call.body)) { + const error = { message: 'Binary attachments need a WebSocket', code: 415 }; + return void respond(415, { type: 'callback', id: '', error }); + } handleMessage(channel.client, call.body, this.#router, this.#limits); call.respond({ status: 202, headers: { ...headers, 'Content-Length': 0 } }); } + // Every host hands a request here without a catch of its own (`void + // rpc.handleHttpCall(call)`), so whatever escapes the routing below — a + // peer's input no refusal anticipated — was an unhandled rejection and a + // request nobody answered. Caught once, here: logged, counted, answered 500 + // unless something already answered or started a stream. The call is + // copied so the answer can be known; one object per request, against an + // HTTP parse. async handleHttpCall(call) { - const headers = buildHeaders(this.#cors, call.headers?.origin); + let answered = false; + const { respond, stream } = call; + const guarded = { + ...call, + respond: (response) => { + answered = true; + return respond(response); + }, + stream: + typeof stream === 'function' + ? (options) => { + answered = true; + return stream(options); + } + : stream, + }; + try { + return await this.#routeHttpCall(guarded); + } catch (error) { + this.#log.error({ err: error, event: 'http.failed' }); + this.#otel.recordCall(UNKNOWN_TARGET, 'error', 500); + if (answered) return; + try { + new ServerHttpTransport(guarded, { headers: buildHeaders(this.#cors, call.headers?.origin) }).error(500); + } catch { + // The host cannot write either: nothing more to say. + } + } + } + + async #routeHttpCall(call) { + const headers = buildHeaders(this.#cors, call.headers?.origin, this.revision); if (call.method === 'OPTIONS') { return void call.respond({ status: 200, headers }); } @@ -724,6 +1391,37 @@ class RpcServer extends Emitter { this.#otel.recordCall(UNKNOWN_TARGET, 'error', 403); return void new ServerHttpTransport(call, { headers }).error(403); } + if (this.#encryption !== null) { + // The public key bundle a client pins, for whoever has no other way + // to learn it. Trust on first use: what travels here is only as + // trustworthy as the connection it travelled over. + if (this.#encryption.discovery && call.method === 'GET' && this.#isKeyPath(call.url)) { + const body = Buffer.from(JSON.stringify({ key: await this.#encryption.bundle() })); + const type = { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'Content-Length': body.length }; + return void call.respond({ status: 200, headers: { ...headers, ...type }, body }); + } + // A sealed request: the real one is inside. Unwrapped here, before + // anything is routed — from this line on it is an ordinary call, + // marked `encrypted`, whose respond() seals the answer. + if (this.#sealing.isSealed(call)) { + // unwrap refuses everything it recognizes itself; what it does not + // (a primitive that threw) is one request answered 500, not a + // rejection the transport never hears of. + try { + call = await this.#sealing.unwrap(call, headers); + } catch (error) { + this.#log.error({ err: error, event: 'encryption.unwrap' }); + this.#otel.recordCall(UNKNOWN_TARGET, 'error', 500); + return void new ServerHttpTransport(call, { headers }).error(500); + } + if (!call) return; + } + } + // Under `encryption.required` nothing plaintext is answered. + if (this.#encryption?.required && call.encrypted !== true) { + this.#refusePlaintext('http'); + return void new ServerHttpTransport(call, { headers }).error(426); + } // No Content-Type override here: which codec's type applies depends on // the MODE (packet vs REST), decided below — a blanket header would // advertise the packet framing on REST bodies it never framed. @@ -743,7 +1441,7 @@ class RpcServer extends Emitter { this.#otel.recordCall(UNKNOWN_TARGET, 'error', 404); return void new ServerHttpTransport(call, { headers }).error(404); } - if (match.mode === 'packet') return this.#handlePacketPost(call, headers, params); + if (match.mode === 'packet') return this.#handlePacketPost(call, headers); return this.#handleRest(call, match.rest, params, headers); } @@ -757,33 +1455,43 @@ class RpcServer extends Emitter { // may arrive by header (preferred — URLs end up in logs) or query param. #handleSseOpen(call, params, headers) { const query = this.#parseQuery(params); - const channelId = call.headers?.[CHANNEL_HEADER] || query.channel || null; + // `.` — the header, else the query (URLs end up in logs). + const ref = splitChannelRef(call.headers?.[CHANNEL_HEADER] || query.channel || ''); const lastEventId = call.headers?.['last-event-id'] ?? query.lastEventId ?? null; - this.#sse.open(call, { channelId, lastEventId, headers }); + this.#sse.open(call, { channelId: ref?.id ?? null, secret: ref?.secret ?? '', lastEventId, headers }); } // POST {basePath} — a JSON call packet (or a batch array) in the body. - async #handlePacketPost(call, headers, params) { + async #handlePacketPost(call, headers) { // Mode-aware: only packet-mode responses carry the packet codec's type. if (this.#codec?.contentType) headers['Content-Type'] = this.#codec.contentType; - const batch = call.method === 'POST' ? this.#batchIds(call.body) : null; - const transport = new ServerHttpTransport(call, { headers, batch }); + const packet = call.method === 'POST' ? this.#decodeBody(call.body) : null; + const batch = this.#batchIds(packet); + const transport = new ServerHttpTransport(call, { + headers, + batch, + compression: this.#compression, + failed: this.#httpFailed, + }); if (call.method !== 'POST') { this.#log.warn({ event: 'http.refused', code: 403, method: call.method }); this.#otel.recordCall(UNKNOWN_TARGET, 'error', 403); return void transport.error(403); } - const data = declaredData(call.headers, params, this.#metaMax, this.#log); - const client = this.#addClient( - transport, - (c) => this.#restoreToken(c, { headers: call.headers, url: call.url, declared: call.headers, meta: data }), - buildMeta({ headers: call.headers, data, url: call.url, remoteAddress: call.remoteAddress }), - ); + // Revision 1 unless the caller asked for a framed answer: a 1.0 client + // reads the body as JSON whatever its type says (protocol.md#versioning). + // The same call is a frame or JSON by its `Accept`, which a shared cache + // must therefore key on. Settled before the client exists, which counts + // the connection under its revision. + if (this.#attachments) addVary(headers, 'Accept'); + if (!readsFrames(call.headers)) this.#speakRevision1(transport); + const { restore, meta } = this.#identify(call); + const client = this.#addClient(transport, restore, meta); // An aborted or never-answered request must still evict the client: // the transport only self-closes when it writes a response. if (typeof call.onAbort === 'function') call.onAbort(() => transport.emit('close')); await client.ready; - return void handleMessage(client, call.body, this.#router, this.#limits); + return void handleParsed(client, packet, call.body, this.#router, this.#limits); } // ANY {basePath}/unit/method?args — REST mode, args from query + body. @@ -820,28 +1528,42 @@ class RpcServer extends Emitter { } const transport = new ServerHttpTransport(call, { headers, - rest: route ? { status: route.http.status, codec: restCodec } : null, + compression: this.#compression, + failed: this.#httpFailed, + rest: route + ? { + status: route.http.status, + codec: restCodec, + headers: route.http.headers, + cache: route.http.cache, + access: route.proc.access, + hasSession: () => Boolean(client?.session), + } + : null, }); const safeMethod = method === 'GET' || method === 'HEAD'; + // The conventional mode answers a callback envelope, as packet mode + // does: a framed one only for a caller that asked for it — to curl, a + // 1.0 client or a browser's fetch it stays the JSON 1.0 answered. A + // declared route's body is a plain value and has no frame either way. + // Settled before the client exists, which counts it under its revision. + if (route === null && this.#attachments) addVary(headers, 'Accept'); + if (route === null && !readsFrames(call.headers)) this.#speakRevision1(transport); // For a per-request client the connection IS the call, so the declared // data doubles as this call's meta: a curl caller passes x-wrpc-meta and // a hook reads context.callMeta, same as on ws. - const data = declaredData(call.headers, params, this.#metaMax, this.#log); - // Same ambient-only CSRF reasoning as delegatedContext above. - const restore = - !safeMethod || this.#sessions.transport.ambient !== true || this.#isSameOriginFetch(call.headers) - ? (c) => this.#restoreToken(c, { headers: call.headers, url: call.url, declared: call.headers, meta: data }) - : null; - const client = this.#addClient( - transport, - restore, - buildMeta({ headers: call.headers, data, url: call.url, remoteAddress: call.remoteAddress }), - ); + const { data, restore, meta } = this.#identify(call, safeMethod); + const client = this.#addClient(transport, restore, meta); // #addClient assigned the packet codec; REST bodies are not packet // frames, so it comes back off. The conventional mode's callback // envelope IS the body value, so the rest codec (when configured) // takes the packet codec's slot on the transport. transport.codec = restCodec; + // The response seam a REST handler reaches as `context.http`: the + // request line, and setHeader()/status() onto this very response. + // Null on every other transport, and on packet-mode HTTP, where one + // response answers a whole batch. + client.http = httpReply(call, method, transport); if (typeof call.onAbort === 'function') call.onAbort(() => transport.emit('close')); await client.ready; // A request body under a rest codec that fails to decode is the @@ -849,7 +1571,8 @@ class RpcServer extends Emitter { const decodeBody = (fallback) => { const raw = call.body; if (raw === undefined || raw === null || raw.length === 0) return fallback; - if (!restCodec) return jsonParse(raw) ?? fallback; + // Converted by hand, as in #decodeBody (bench/http-call.js). + if (!restCodec) return jsonParse(typeof raw === 'string' ? raw : raw.toString()) ?? fallback; return restCodec.decode(raw); }; const id = this.#generateId(); @@ -885,9 +1608,15 @@ class RpcServer extends Emitter { } // Splits and percent-decodes the path, then consults the router's trie. + // + // (httpReply lives below the class — see the module tail.) // A malformed escape answers 400 rather than throwing into the host. #matchDeclaredRoute(method, rest) { const raw = rest.split('/'); + // Nothing escaped: decodeURIComponent would hand every segment back + // unchanged, and never throws — ~380 -> ~35 ns on a three-segment path + // (bench/http-call.js). + if (!rest.includes('%')) return this.#router.matchRest(method, raw); const segments = new Array(raw.length); for (let i = 0; i < raw.length; i++) { try { @@ -931,7 +1660,13 @@ class RpcServer extends Emitter { */ async drain(timeout = 0) { if (!(timeout > 0)) return; - this.#draining = true; + // Announced once: a binding that pulls work on its own (a broker + // consumer) stops fetching here instead of taking messages only to + // answer them 503. + if (!this.#draining) { + this.#draining = true; + this.emit('draining'); + } const deadline = Date.now() + timeout; while (Date.now() < deadline) { let busy = 0; @@ -942,6 +1677,10 @@ class RpcServer extends Emitter { } async close() { + // Announced first, while everything still works: a binding that pulls + // work on its own (a broker consumer) stops fetching before its client + // is torn down under it. + this.emit('close'); if (this.#sse) this.#sse.close(); for (const client of this.#clients) client.close(); this.#clients.clear(); @@ -958,4 +1697,14 @@ class RpcServer extends Emitter { } } +// `context.http` on a REST call: what the handler may read about the +// request and set on the response before it is written. +const httpReply = (call, method, transport) => ({ + method, + url: call.url ?? '/', + headers: call.headers ?? {}, + setHeader: (name, value) => transport.setHeader(name, value), + status: (code) => transport.setStatus(code), +}); + module.exports = { RpcServer, Client, Context, rpcOptions, DEFAULT_MAX_SUBSCRIPTIONS, DEFAULT_MAX_CALLS }; diff --git a/src/rpc/dictionary.js b/src/rpc/dictionary.js new file mode 100644 index 0000000..f1e3581 --- /dev/null +++ b/src/rpc/dictionary.js @@ -0,0 +1,157 @@ +'use strict'; + +// A preset dictionary for the per-message codecs, built from what a router +// declares. One-shot deflate has no history to lean on: a 90 B event +// compresses to 79 B because every field name and every method target is +// seen for the first time, in every message. A preset dictionary IS that +// history — the strings a wrpc packet is made of, sitting in the window +// before the first byte — and a 169 B event becomes 76 B instead of 133 +// (bench/dictionary.js), with no state per connection and the frames of a +// fan-out still shareable. +// +// zlib matches against the LAST 32 KiB of a dictionary and finds recent +// bytes cheapest, so the content is ordered least to most frequent: the +// field names a router's signatures and schemas declare, then every +// `unit/method` target and `unit/event` name, then the packet skeletons +// every message starts with. Past the cap the FRONT is dropped, never the +// tail. Deterministic — sorted within each group — so two instances of +// the same router build the same bytes, and the codec id derived from them +// (dictionaryId) agrees across a fleet. +// +// Node-free and browser-safe (TextEncoder only): a browser peer host can +// build one for the pure-JS codec of @alexify/wrpc/deflate. + +const MAX_DICTIONARY = 32 * 1024; +const MAX_DEPTH = 8; + +// The JSON every packet type starts with, most frequent last. The event, +// callback and data skeletons are what a busy connection mostly carries; +// ping/pong close the list because a heartbeat is the most repeated +// message of all. +const SKELETONS = [ + '{"type":"stream","id":"', + '"name":"', + '"size":', + '{"type":"subscribe","id":"', + '"lastEventId":"', + '{"type":"unsubscribe","id":"', + '{"type":"cancel","id":"', + '{"type":"end","id":"', + '"error":{"message":"', + '"code":', + '"details":', + '"meta":{', + '"tp":"00-', + '"ts":"', + ',"unreliable":true', + '"eventId":"', + '{"type":"data","id":"', + '"data":', + '{"type":"event","name":"', + '"args":', + '"method":"', + '{"type":"call","id":"', + '"result":', + '{"type":"callback","id":"', + 'true', + 'false', + 'null', + '{"type":"ping"}', + '{"type":"pong"}', +]; + +// Field names a `signature` shape declares (protocol.md#the-signature-descriptor): +// a type name, a field map, or a one-element array — maps nest. +const shapeKeys = (shape, out, depth = 0) => { + if (depth > MAX_DEPTH || shape === null || typeof shape !== 'object') return; + if (Array.isArray(shape)) { + for (let i = 0; i < shape.length; i++) shapeKeys(shape[i], out, depth + 1); + return; + } + for (const key of Object.keys(shape)) { + out.add(key.endsWith('?') ? key.slice(0, -1) : key); + shapeKeys(shape[key], out, depth + 1); + } +}; + +// Field names a JSON-Schema-shaped part declares: `properties`, through +// `items` and the combinators. +const schemaKeys = (schema, out, depth = 0) => { + if (depth > MAX_DEPTH || schema === null || typeof schema !== 'object') return; + if (Array.isArray(schema)) { + for (let i = 0; i < schema.length; i++) schemaKeys(schema[i], out, depth + 1); + return; + } + const { properties } = schema; + if (properties !== null && typeof properties === 'object') { + for (const key of Object.keys(properties)) { + out.add(key); + schemaKeys(properties[key], out, depth + 1); + } + } + if (schema.items !== undefined) schemaKeys(schema.items, out, depth + 1); + if (schema.anyOf !== undefined) schemaKeys(schema.anyOf, out, depth + 1); + if (schema.oneOf !== undefined) schemaKeys(schema.oneOf, out, depth + 1); + if (schema.allOf !== undefined) schemaKeys(schema.allOf, out, depth + 1); +}; + +/** + * The dictionary bytes for `router` (anything with `introspect()`), at + * most `limit` (32 KiB by default — what zlib looks at). Deterministic for + * a given router definition. + */ +const buildDictionary = (router, { limit = MAX_DICTIONARY } = {}) => { + if (!router || typeof router.introspect !== 'function') { + throw new TypeError('buildDictionary: a Router (anything with introspect()) is required'); + } + if (!Number.isInteger(limit) || limit <= 0) throw new TypeError('buildDictionary: limit must be a positive integer'); + const info = router.introspect(null, { schemas: true }); + const keys = new Set(); + const targets = []; + const events = []; + for (const unit of Object.keys(info)) { + const methods = info[unit]; + for (const name of Object.keys(methods)) { + const entry = methods[name]; + if (name === 'on' || name === 'emits') { + // The unit's inbound handlers and declared outbound events, both + // named on the wire as `unit/event`. + for (const event of Object.keys(entry)) { + events.push(`${unit}/${event}`); + const declared = entry[event]; + if (declared && typeof declared === 'object') { + shapeKeys(declared.signature?.args, keys); + shapeKeys(declared.data, keys); + shapeKeys(declared.returns, keys); + } + } + continue; + } + targets.push(`${unit}/${name}`); + if (entry.signature) { + shapeKeys(entry.signature.args, keys); + shapeKeys(entry.signature.returns, keys); + shapeKeys(entry.signature.data, keys); + } + if (entry.schema) { + schemaKeys(entry.schema.params, keys); + schemaKeys(entry.schema.querystring, keys); + schemaKeys(entry.schema.body, keys); + } + } + } + const parts = []; + const sortedKeys = Array.from(keys).sort(); + for (let i = 0; i < sortedKeys.length; i++) parts.push(`"${sortedKeys[i]}":`); + targets.sort(); + for (let i = 0; i < targets.length; i++) parts.push(`"method":"${targets[i]}"`); + events.sort(); + for (let i = 0; i < events.length; i++) parts.push(`"name":"${events[i]}"`); + for (let i = 0; i < SKELETONS.length; i++) parts.push(SKELETONS[i]); + const bytes = new TextEncoder().encode(parts.join('')); + // The tail is the frequent end: cut the front. A cut inside a multi-byte + // character is harmless — a dictionary is bytes, not text. + return bytes.length > limit ? bytes.subarray(bytes.length - limit) : bytes; +}; + +module.exports = { buildDictionary, MAX_DICTIONARY }; diff --git a/src/rpc/dispatcher.js b/src/rpc/dispatcher.js index 2dd3eb1..3e52e09 100644 --- a/src/rpc/dispatcher.js +++ b/src/rpc/dispatcher.js @@ -3,9 +3,14 @@ const { jsonParse } = require('../utils.js'); const { WrpcReadable } = require('../streams.js'); const { chunkDecode } = require('../chunks.js'); +const { negotiate } = require('../compression/index.js'); +const { hasBytes, isAttachmentsFrame, decodeAttachments } = require('../attachments.js'); +const { FRAME_MARK } = require('../wire.js'); + +const EMPTY_OPTIONS = Object.freeze({}); const { runSubscription } = require('./subscriptions.js'); const { runHooks, runHooksSafe } = require('./router.js'); -const { publicErrorMessage, publicErrorDetails, wireError } = require('../transport.js'); +const { publicErrorMessage, publicErrorDetails, wireError, clip } = require('./errors.js'); const { SPAN_KIND_CONSUMER } = require('../telemetry/shared.js'); const DEFAULT_VERSION = '*'; @@ -79,7 +84,10 @@ const split = (s, separator) => { return [s.slice(0, i), s.slice(i + separator.length)]; }; -const parseParams = (params) => Object.fromEntries(new URLSearchParams(params)); +// No query string is the common REST call, and URLSearchParams + fromEntries +// cost it ~58 ns to parse nothing, against ~6 (bench/http-call.js); the +// answer is the same empty object either way. +const parseParams = (params) => (params ? Object.fromEntries(new URLSearchParams(params)) : {}); // 'unit/name', 'unit.vN/name' -> { unit, version, name }. // Split on the FIRST dot only: 'unit.v1.2/m' must look up version 'v1.2' @@ -92,30 +100,91 @@ const parseTarget = (target) => { return { unit, version, name }; }; +// A call refused before it ever reaches a handler. Its subscription twin +// (`refuse`, below) logged from the day it was written; these six did not, +// so half of what a dispatcher rejects was invisible to an operator — and +// the half that was invisible is the half that says "your client and my +// router disagree about what exists". +// +// The level is deliberately not uniform. 429 and the batch cap are reachable +// by any peer, in a loop, without authenticating: logging those at `warn` +// turns a refused flood into a log-pipeline flood, which is a worse outage +// than the one being prevented. They go to `debug` — dropped outright by a +// Console writer, left to its own level by a structured one. `503` is the +// operator's own doing (a draining server), so it is `info`. The codes that +// mean something is actually wrong stay at `warn`. +// +// Every refusal below is the ONE line for it: the `rpc.error` that follows +// (the answer going out) is written at debug, never a second alert at a +// level the site did not choose. The method and the id are the peer's text, +// clipped before they become a record. +// A session ended on another connection of this instance (finalizeSession +// there) is no session here either: the next call is refused, as after a +// logout on this one. +const hasSession = (client) => { + const { session } = client; + return session !== null && session !== undefined && session.ended !== true; +}; + +// No session because the store could not be asked is a 503 — retryable — +// not the 403 of a token that is not one. +const refusedFor = (client) => (client.sessionUnavailable === true ? 503 : 403); + +const refuseCall = (client, id, code, method, event) => { + const level = code === 429 ? 'debug' : code === 503 ? 'info' : 'warn'; + const shown = clip(method); + client.log[level]({ event, code, id: clip(id), method: shown }, `${client.source}\tCALL\t${shown}\t${code}`); + client.otel.recordCall(method, 'error', code); + client.error(code, { id, level: 'debug' }); +}; + +// An answered call is no longer in flight: released the moment its answer +// is written, not after. On HTTP the write IS the end of the connection — +// the transport closes and Client#destroy aborts whatever is still in +// `calls` — so a call still registered there was aborted after it had +// answered, at the cost of a stack-captured Error per request: releasing +// first took handleHttpCall from 85k to 235k requests/s on a declared REST +// route (bench/http-call.js, with the lazy reason in Client#destroy). On a +// socket it also stops a cancel that arrives while onResponse hooks run +// from answering an already-answered call a second time, with a 499. +const release = (client, id, controller) => { + if (client.calls.get(id) === controller) client.calls.delete(id); +}; + const handleRpc = async (client, packet, router) => { const { id, method, args } = packet; const { unit, version, name: methodName } = parseTarget(method); const proc = router.getProcedure(unit, version, methodName); if (!proc) { + // The target is UNKNOWN on the metric (an unbounded method string would + // be a cardinality bomb) but present in the log, where it is the whole + // point: this is what a stale client or a typo looks like. + const shown = clip(method); + client.log.warn( + { event: 'call.unknown', code: 404, id: clip(id), method: shown }, + `${client.source}\tCALL\t${shown}\t404`, + ); client.otel.recordCall(UNKNOWN_TARGET, 'error', 404); - return void client.error(404, { id }); + return void client.error(404, { id, level: 'debug' }); } if (client.calls.has(id)) { + // Two calls with one id: a generateId that repeats, or a client that + // retried without minting a fresh one. Either is a bug worth seeing. + const error = new Error(`Call ${id} is already in flight`); + client.log.warn({ event: 'call.duplicate', code: 400, id: clip(id), method: clip(method), err: error }); client.otel.recordCall(method, 'error', 400); - return void client.error(400, { id, error: new Error(`Call ${id} is already in flight`) }); + return void client.error(400, { id, error, level: 'debug' }); } // Subscriptions are capped, and calls have to be too: each in-flight call // holds a controller, a context and (with a queue) a semaphore slot, so an // unbounded burst from one connection is memory the app never agreed to. if (client.calls.size >= client.maxCalls) { - client.otel.recordCall(method, 'error', 429); - return void client.error(429, { id }); + return void refuseCall(client, id, 429, method, 'call.capacity'); } // A draining server finishes what it started and takes nothing new: the // 503 tells a well-behaved client to reconnect elsewhere. if (client.server?.draining) { - client.otel.recordCall(method, 'error', 503); - return void client.error(503, { id }); + return void refuseCall(client, id, 503, method, 'call.draining'); } // The controller is what `{type:'cancel'}` and a disconnect reach: the // handler sees it as ctx.signal, and once it is aborted the call has @@ -143,17 +212,20 @@ const handleRpc = async (client, packet, router) => { // The span covers the whole invocation including session wait, access // check, validation and the timeout race — an argument error deserves an // error span and a duration sample exactly as much as a slow handler does. - return client.otel.withSpan({ client, packet, target: method }, async (handle) => { + const run = async (handle) => { let status = 'ok'; let code; try { if (hooks.onRequest.length > 0) await runHooks(hooks.onRequest, context, packet); - await client.ready; + // One microtask hop per call skipped once the connection is ready — + // which is every call after the first (bench/bench.js). + if (!client.isReady) await client.ready; if (controller.signal.aborted) return void (status = 'cancelled'); - if (!client.session && proc.access !== 'public') { + if (!hasSession(client) && proc.access !== 'public') { status = 'error'; - code = 403; - return void client.error(403, { id }); + code = refusedFor(client); + release(client, id, controller); + return void client.error(code, { id }); } // The caller's per-call deadline, validated as a bounded positive // number — an additive packet field, absent on every packet that @@ -166,6 +238,7 @@ const handleRpc = async (client, packet, router) => { code = result.code; client.otel.recordError(handle, result, code); if (hooks.onError.length > 0) await runHooksSafe(hooks.onError, context, result, client.log, 'onError'); + release(client, id, controller); return void client.error(code, { id, error: result }); } const callback = { type: 'callback', id, result }; @@ -178,10 +251,16 @@ const handleRpc = async (client, packet, router) => { // fast-json-stringify — bench/serialize-callback.js). Correctness // gates: an onSend hook may have mutated the packet after the shape // the serializer was compiled for, so hooks win over the fast path. + // A result holding bytes leaves as an attachments frame, which the + // serializer's JSON cannot be: the walk runs only under a serializer, + // and only where a frame could leave at all (`attachments: false`, a + // packet codec) — the walk on a 400-row result costs about what the + // envelope surgery saves (bench/serialize-callback.js). const text = - compiled?.serialize && hooks.onSend.length === 0 + compiled?.serialize && hooks.onSend.length === 0 && (!client.attachments || !hasBytes(result)) ? `{"type":"callback","id":${JSON.stringify(id)},"result":${compiled.serialize(result)}}` : undefined; + release(client, id, controller); client.send(callback, { method, text }); if (hooks.onResponse.length > 0) { await runHooksSafe(hooks.onResponse, context, callback, client.log, 'onResponse'); @@ -196,15 +275,21 @@ const handleRpc = async (client, packet, router) => { await runHooksSafe(hooks.onTimeout, context, error, client.log, 'onTimeout'); } if (hooks.onError.length > 0) await runHooksSafe(hooks.onError, context, error, client.log, 'onError'); + release(client, id, controller); return void client.error(code, { id, error }); } finally { - if (client.calls.get(id) === controller) client.calls.delete(id); + release(client, id, controller); if (enabled) { client.otel.endSpan(handle, { 'rpc.wrpc.status_code': code, 'wrpc.status': status }); client.otel.recordCall(method, status, code, now() - started); } } - }); + }; + // With telemetry off the span options object and the writer's own wrapper + // are skipped, not just the no-op recorders — the client side guards its + // span the same way. + if (!enabled) return run(null); + return client.otel.withSpan({ client, packet, target: method }, run); }; // Cancellation is best-effort by nature: a handler that never looks at @@ -226,7 +311,10 @@ const refuse = (client, id, code, message, target, details) => { const error = { message, code }; if (details !== undefined) error.details = details; client.send({ type: 'end', id, error }); - client.warn(`SUBSCRIBE\t${id}\t${code}\t${message}`, { event: 'subscribe.refused', id, code }); + // The id is the peer's: bounded to 255 by the packet rule, bounded again + // for a log line, as every peer-supplied text in one is. + const said = clip(id); + client.warn(`SUBSCRIBE\t${said}\t${code}\t${message}`, { event: 'subscribe.refused', id: said, code }); client.otel.recordCall(target, 'error', code); }; @@ -254,14 +342,15 @@ const handleSubscribe = async (client, packet, router) => { // same turn has something to find. const controller = new AbortController(); client.subscriptions.set(id, controller); - await client.ready; + if (!client.isReady) await client.ready; if (controller.signal.aborted) { client.subscriptions.delete(id); return; } - if (!client.session && proc.access !== 'public') { + if (!hasSession(client) && proc.access !== 'public') { client.subscriptions.delete(id); - return void refuse(client, id, 403, 'Forbidden', method); + const code = refusedFor(client); + return void refuse(client, id, code, code === 503 ? 'Session store unavailable' : 'Forbidden', method); } const hooks = router.hooksFor(proc); const compiled = router.compiledFor(proc); @@ -351,6 +440,12 @@ const handleStream = async (client, packet) => { const valid = typeof name === 'string' && Number.isSafeInteger(size); if (!valid) throw new Error('Stream packet structure error'); if (stream) throw new Error(`Stream ${tag} is already initialized`); + // Capped like calls and subscriptions: an announced stream holds its + // readable until a handler reads it, and the peer decides how many. + if (client.streams.size >= client.maxStreams) { + client.log.debug({ event: 'stream.capacity', code: 429, max: client.maxStreams }); + return void client.error(429, { id, error: new Error('Too many open streams'), level: 'debug' }); + } { const stream = new WrpcReadable(id, name, size); client.streams.set(id, stream); @@ -360,7 +455,14 @@ const handleStream = async (client, packet) => { } }; -const handleBinary = async (client, data) => { +const handleBinary = async (client, data, router = null, options = EMPTY_OPTIONS) => { + // A 0x00 first byte is never a chunk (an id is at least one byte): an + // attachments frame is a packet with its bytes, dispatched as one. + if (data.length > 1 && data[0] === FRAME_MARK) { + if (router !== null && isAttachmentsFrame(data)) return void handleMessage(client, data, router, options); + client.log.warn({ event: 'frame.refused', kind: data[1] }); + return void client.error(400, { error: new Error('Unexpected framed message'), level: 'debug' }); + } const { id, payload } = chunkDecode(data); try { const upstream = client.streams.get(id); @@ -397,8 +499,8 @@ const handleEvent = async (client, packet, router) => { const { unit, version, name } = parseTarget(target); const handler = router.getEventHandler(unit, version, name); if (!handler) return void client.warn(`EVENT\t${target}\tno handler`); - await client.ready; - if (!client.session && handler.access !== 'public') { + if (!client.isReady) await client.ready; + if (!hasSession(client) && handler.access !== 'public') { return void client.warn(`EVENT\t${target}\tsession required`); } // Events run the invocation phases (preValidation/preHandler/onError); @@ -455,29 +557,55 @@ const needsConnection = (client, id, what) => { return true; }; -const handlePacket = (client, packet, router) => { +// A ping that names codecs (`enc`: its ids, in its order of preference) is +// a Node ws client offering per-message compression for its own frames. +// Only the client compresses on this wire, so the rule of src/compression +// reduces to one choice — the first codec of ITS list configured here +// (`options.compression`): the pong names that one id back and the client's +// marked binary frames are inflated with it from then on (core +// attachSocket); nothing in common, a plain pong, and the client stays +// plain. Only on a WebSocket: the other carriers negotiate their own way. +const negotiatePing = (client, enc, options) => { + const agreed = client.transportKind === 'ws' ? negotiate(options.compression ?? null, enc) : null; + const active = agreed === null ? null : agreed.decode; + client.compression = active; + client.send(active === null ? { type: 'pong' } : { type: 'pong', enc: active.id }); +}; + +// A packet id is the caller's correlation token, echoed back as it came: a +// string of at most 255 characters — what every generateId of this package +// answers, and all a chunk header has room for — or a number, which a 1.0 +// client whose own generateId counted sent and was answered on. Anything +// else used to be echoed too: an array nested 10 000 deep threw out of the +// answer's JSON.stringify, synchronously, where nothing caught it. +const MAX_PACKET_ID = 255; +const isPacketId = (id) => + typeof id === 'string' ? id.length !== 0 && id.length <= MAX_PACKET_ID : typeof id === 'number' && id !== 0; + +const handlePacket = (client, packet, router, options = EMPTY_OPTIONS) => { const { id, type, method, name } = packet; + const hasId = isPacketId(id); // The target is type-checked, not just truthiness-checked: a non-string // `method`/`name` would throw inside parseTarget, and these calls are not // awaited. A malformed packet has to fall through to the error below. - if (type === 'call' && id && typeof method === 'string') { + if (type === 'call' && hasId && typeof method === 'string') { return void handleRpc(client, packet, router).catch(contain(client, 'CALL')); - } else if (type === 'subscribe' && id && typeof method === 'string') { + } else if (type === 'subscribe' && hasId && typeof method === 'string') { if (!client.persistent) { return void refuse(client, id, 400, 'Subscriptions require a persistent connection', UNKNOWN_TARGET); } return void handleSubscribe(client, packet, router).catch(contain(client, 'SUBSCRIBE')); - } else if (type === 'unsubscribe' && id) { + } else if (type === 'unsubscribe' && hasId) { if (needsConnection(client, id, 'Subscriptions')) return; return void handleUnsubscribe(client, packet); - } else if (type === 'cancel' && id) { + } else if (type === 'cancel' && hasId) { if (needsConnection(client, id, 'Cancellation')) return; return void handleCancel(client, packet); - } else if (type === 'stream' && id) { + } else if (type === 'stream' && hasId) { return void handleStream(client, packet).catch(contain(client, 'STREAM')); } else if (type === 'event' && typeof name === 'string' && name) { return void handleEvent(client, packet, router).catch(contain(client, 'EVENT')); - } else if (type === 'callback' && typeof id === 'string' && id) { + } else if (type === 'callback' && hasId && typeof id === 'string') { // Asks only travel on persistent transports, so a callback on HTTP can // never match one — and it MUST still be answered, or the request (and // every other slot of its batch) would hang unanswered forever. @@ -488,13 +616,19 @@ const handlePacket = (client, packet, router) => { // The id is peer-controlled text: bounded and escaped before it becomes // a log line. if (!client.settleAnswer(packet)) { - const shown = id.length > 128 ? `${id.slice(0, 128)}…` : id; - client.warn(`ANSWER\t${JSON.stringify(shown)}\tno pending ask`); + client.warn(`ANSWER\t${JSON.stringify(clip(id))}\tno pending ask`); } return; } else if (type === 'ping') { // App-level heartbeat: a browser WebSocket cannot see protocol pings, // so liveness is measured with packets the client can observe. + if (packet.enc !== undefined) return void negotiatePing(client, packet.enc, options); + // A port's first ping names the revision its page speaks; the answer + // names this end's. Anywhere else the field means nothing. + if (packet.v !== undefined) { + const v = client.negotiateRevision(packet.v); + if (v !== 0) return void client.send({ type: 'pong', v }); + } return void client.send({ type: 'pong' }); } else if (type === 'pong' && client.persistent) { return; // answer to a server-initiated ping; liveness is the transport's @@ -502,43 +636,104 @@ const handlePacket = (client, packet, router) => { // The id travels with the refusal when the packet carried one: an HTTP // batch answers positionally, so an id-less error would lose this slot and // shift every answer after it. + // Structurally valid JSON that is not a packet this protocol has: a + // version skew, a proxy rewriting bodies, or somebody else's client + // pointed at this port. `handleMessage` already logs frames that do not + // parse; this is the other half of the same funnel, and was silent. const error = new Error('Packet structure error'); - client.error(500, { id: typeof id === 'string' ? id : '', error }); + client.log.warn({ event: 'packet.unknown', code: 500, type: typeof type === 'string' ? clip(type) : null }); + client.error(500, { id: hasId && typeof id === 'string' ? id : '', error, level: 'debug' }); }; // A JSON array is a batch frame: several packets in one message, each // answered on its own (on a request/response transport the answers come // back as an array in the same order — see ServerHttpTransport). +// An attachments frame's packet, or null when the server opted out or the +// frame is malformed — both land in the same 'packet.malformed' funnel. +const parseFrame = (data, options) => { + if (options.attachments === false) return null; + try { + return decodeAttachments(data); + } catch { + return null; + } +}; + const handleMessage = (client, data, router, options = {}) => { - const parsed = client.decodePacket ? client.decodePacket(data) : jsonParse(data); + // A frame is checked before the codec seam: `decodePacket` is the JSON + // (or codec) parse, and a frame is neither. + const parsed = + typeof data !== 'string' && isAttachmentsFrame(data) + ? parseFrame(data, options) + : client.decodePacket + ? client.decodePacket(data) + : jsonParse(data); + handleParsed(client, parsed, data, router, options); +}; + +// handleMessage past its parse, for a caller that already parsed the +// message — the HTTP packet path reads a batch's ids before the client +// exists, and parsing the body a second time here held packet mode at 214k +// requests/s against 245k parsed once (bench/http-call.js). `data` is the +// raw message, for the log line. +const handleParsed = (client, parsed, data, router, options = {}) => { // jsonParse answers null for both "malformed" and "the literal null", and // the `|| {}` below hides the difference. This is the single funnel every - // unparseable packet in the system passes through, so it is worth a line. - if (parsed === null) client.log.warn({ event: 'packet.malformed', bytes: data?.length ?? 0 }); + // unparseable packet in the system passes through, so it is worth a line + // — ONE line: the empty packet it used to fall through as would have been + // reported again as `packet.unknown`, and then a third time as the + // error-level answer. The answer is the same id-less 500. + if (parsed === null) { + client.log.warn({ event: 'packet.malformed', bytes: data?.length ?? 0 }); + return void client.error(500, { error: new Error('Packet structure error'), level: 'debug' }); + } const packet = parsed || {}; - if (!Array.isArray(packet)) return void handlePacket(client, packet, router); + if (!Array.isArray(packet)) return void handlePacket(client, packet, router, options); const { maxBatch = DEFAULT_MAX_BATCH } = options; if (packet.length === 0 || packet.length > maxBatch) { const error = new Error(`Batch size must be between 1 and ${maxBatch}`); - return void client.error(400, { error }); + // Debug for the same reason as the 429 above: any peer can send an + // oversize batch in a loop, and a warn per attempt would make the log + // the thing that falls over. + client.log.debug({ event: 'batch.refused', code: 400, size: packet.length, max: maxBatch }); + return void client.error(400, { error, level: 'debug' }); } for (const item of packet) { - handlePacket(client, item && typeof item === 'object' ? item : {}, router); + handlePacket(client, item && typeof item === 'object' ? item : {}, router, options); + } +}; + +// The two entry points for a transport that announces its traffic as +// 'packet'/'chunk' events (RpcServer.attach, PeerHost.attach, attachPort): +// what the handlers throw or reject with stays here — a listener that threw +// would reject the transport's emit(), which nobody awaits, and a rejected +// handleBinary used to be an unhandled rejection outright. +const dispatchMessage = (client, text, router, options) => { + try { + handleMessage(client, text, router, options); + } catch (error) { + contain(client, 'PACKET')(error); } }; +const dispatchBinary = (client, bytes, router, options) => + void handleBinary(client, bytes, router, options).catch(contain(client, 'CHUNK')); + module.exports = { sanitizeMeta, UNKNOWN_TARGET, handleRpc, handleStream, handleBinary, + dispatchMessage, + dispatchBinary, handleEvent, handleSubscribe, handleUnsubscribe, handleCancel, handlePacket, handleMessage, + handleParsed, isError, split, parseTarget, diff --git a/src/rpc/envelope.js b/src/rpc/envelope.js new file mode 100644 index 0000000..2622cb8 --- /dev/null +++ b/src/rpc/envelope.js @@ -0,0 +1,215 @@ +'use strict'; + +// What a backplane envelope goes through on its way out of the process and +// back in — the rooms backplane and the cluster channels both: compression, +// then sealing, behind the one seam those two classes already hold +// (`encode(text, channel) -> text`, `decode(message, channel) -> text`). +// Node-only and built by the core, so the browser-bundled rooms.js carries +// none of it. +// +// Without `encryption` this is createEnvelopeCodec — plus, always, the +// way an envelope whose data holds BYTES crosses a string carrier. With it the +// two compose INSIDE one frame rather than one marker inside another — a +// backplane carries strings, and base64 twice is 78 % overhead where once +// is 33 %: +// +// wrpc-sealed::base64( sealed( u8 flags ‖ [u8 idLength ‖ codec id] ‖ body ) ) +// flags: bit 0 compressed, bit 1 the body is an attachments frame +// +// Compress-then-encrypt, because ciphertext does not compress. The codec id +// travels inside, as the `wrpc-enc::` marker names it outside, so the +// codec list still means "encode with the head, decode any". +// +// `decode` answers the text, `null` for an encoded envelope it cannot read +// (the callers' own `*.encoded` warning), and `undefined` for one this file +// refused AND reported — the refusals of a sealed layer are its own events: +// `*.unsealed` (plaintext where none is accepted), `*.open` (a sealed +// envelope that does not open — unknown kid, another key, a flipped bit, a +// replay; the reason is for this log only and never travels). + +const { + createEnvelopeCodec, + normalizeSyncCompression, + encodeIfSmaller, + decodeOrNull, + codecById, + isEncodedEnvelope, +} = require('../compression/sync.js'); +const { normalizeEnvelopeEncryption, createEnvelopeSealer } = require('../encryption/envelope.js'); +const { isKid } = require('../encryption/keyring.js'); +const { encodeAttachments, decodeAttachments, ENVELOPE_DEPTH } = require('../attachments.js'); + +const SEALED_PREFIX = 'wrpc-sealed:'; +// An envelope whose event data holds BYTES: a backplane carries strings and +// JSON has no bytes, so the envelope rides as the binary attachments frame +// of src/attachments.js (its encoder is generic over any object), base64'd +// under this marker — or inside the sealed frame, flagged, when sealing. +const BINARY_PREFIX = 'wrpc-bin:'; +const FLAG_COMPRESSED = 1; +const FLAG_BINARY = 2; + +const isSealedEnvelope = (message) => typeof message === 'string' && message.startsWith(SEALED_PREFIX); + +const asBuffer = (bytes) => Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); + +// The envelope object an attachments frame carries, or null: a frame that +// does not decode is a peer's malformed message, never a throw in a handler. +const binaryEnvelope = (bytes) => { + try { + return decodeAttachments(bytes, ENVELOPE_DEPTH); + } catch { + return null; + } +}; + +// What every backplane gets, options or not: bytes across instances. `inner` +// is the text codec underneath (compression), or null. +const withBytes = (inner) => ({ + ...inner, + encode: inner === null ? (text) => text : (text) => inner.encode(text), + encodeBytes: (envelope) => BINARY_PREFIX + asBuffer(encodeAttachments(envelope, ENVELOPE_DEPTH)).toString('base64'), + // The same for a frame the caller already encoded — the cluster signs the + // frame's bytes, so it holds them before this is asked for anything. + encodeFrame: (frame) => BINARY_PREFIX + asBuffer(frame).toString('base64'), + decode(message) { + // JSON never starts with a `w`: one compare on the common path. + if (message.charCodeAt(0) !== 119) return message; + if (message.startsWith(BINARY_PREFIX)) { + return binaryEnvelope(Buffer.from(message.slice(BINARY_PREFIX.length), 'base64')); + } + if (inner !== null) return inner.decode(message); + // Compressed, and this instance holds no codec: unreadable, and known + // to be — the callers' `*.encoded` warning. + return isEncodedEnvelope(message) ? null : message; + }, +}); + +/** + * `{ encode(text, channel), decode(message, channel) }`, or null when + * neither option is on. `layer` separates the keys of the two users + * ('rooms', 'cluster'); `event` prefixes the log events ('backplane', + * 'cluster'). + */ +// A refusal is said at warn once per kind and channel in this long, debug in +// between: whoever can publish one envelope can publish it in a loop, and a +// line per message made the log the thing that fell over. Capped, as the +// channels are many. +const REFUSAL_INTERVAL = 10_000; +const MAX_REFUSAL_KEYS = 1024; + +const createEnvelope = ({ compression, encryption, maxMessage, name, layer, event, log, failed = null }) => { + const said = new Map(); + const level = (key) => { + const now = Date.now(); + const last = said.get(key); + if (last !== undefined && now - last < REFUSAL_INTERVAL) return 'debug'; + if (last === undefined && said.size >= MAX_REFUSAL_KEYS) said.clear(); + said.set(key, now); + return 'warn'; + }; + const sealing = normalizeEnvelopeEncryption(encryption, name); + if (sealing === null) return withBytes(createEnvelopeCodec(compression, name, maxMessage, failed)); + const codecs = normalizeSyncCompression(compression, name); + const head = codecs === null ? null : codecs.codecs[0]; + // `failed(direction, codec id, error)`: a codec that threw, said by the + // core's reporter. One receiver for the head, built once. + const encodeFailed = head === null || failed === null ? null : (error) => failed('encode', head.id, error); + // What an instance still mid-rollout sends, and what `seal: false` sends. + const plain = withBytes(createEnvelopeCodec(compression, name, maxMessage, failed)); + const sealer = createEnvelopeSealer({ encryption: sealing, layer }); + + // `text` is the JSON envelope, or — with `binary` — the attachments frame + // of an envelope that holds bytes. + const frame = (text, binary = 0) => { + const packed = head === null ? null : encodeIfSmaller(head, text, encodeFailed); + if (packed === null) { + const body = Buffer.allocUnsafe(1 + Buffer.byteLength(text)); + body[0] = binary; + if (typeof text === 'string') body.write(text, 1); + else body.set(text, 1); + return body; + } + const idLength = Buffer.byteLength(head.id); + const body = Buffer.allocUnsafe(2 + idLength + packed.length); + body[0] = FLAG_COMPRESSED | binary; + body[1] = idLength; + body.write(head.id, 2); + body.set(packed, 2 + idLength); + return body; + }; + + const unframe = (body) => { + if (body.length === 0) return null; + const binary = (body[0] & FLAG_BINARY) !== 0; + let bytes; + if ((body[0] & FLAG_COMPRESSED) === 0) bytes = body.subarray(1); + else { + if (codecs === null || body.length < 2) return null; + const end = 2 + body[1]; + const entry = end > body.length ? null : codecById(codecs, body.toString('utf8', 2, end)); + // Cold: only a compressed frame inside a sealed one gets here, and + // the receiver is only called when it does not inflate. + const heard = entry === null || failed === null ? null : (error) => failed('decode', entry.id, error); + const out = entry === null ? null : decodeOrNull(entry, body.subarray(end), maxMessage, heard); + if (out === null) return null; + bytes = asBuffer(out); + } + return binary ? binaryEnvelope(bytes) : bytes.toString(); + }; + + return { + sealed: true, + encode(text, channel) { + if (!sealing.seal) return plain.encode(text); + const { kid, sealed } = sealer.seal(frame(text), channel); + return `${SEALED_PREFIX}${kid}:${sealed.toString('base64')}`; + }, + encodeBytes(envelope, channel) { + return this.encodeFrame(encodeAttachments(envelope, ENVELOPE_DEPTH), channel); + }, + encodeFrame(bytes, channel) { + if (!sealing.seal) return plain.encodeFrame(bytes); + const { kid, sealed } = sealer.seal(frame(asBuffer(bytes), FLAG_BINARY), channel); + return `${SEALED_PREFIX}${kid}:${sealed.toString('base64')}`; + }, + decode(message, channel) { + if (!message.startsWith(SEALED_PREFIX)) { + if (sealing.acceptPlaintext) return plain.decode(message); + return void log[level(`unsealed\0${channel}`)]({ event: `${event}.unsealed`, channel }); + } + const colon = message.indexOf(':', SEALED_PREFIX.length); + const kid = colon === -1 ? '' : message.slice(SEALED_PREFIX.length, colon); + let body = null; + let reason = 'format'; + if (colon !== -1) { + try { + body = sealer.open(kid, Buffer.from(message.slice(colon + 1), 'base64'), channel); + // Our own publish, echoed back: nothing to read, nothing to report. + if (body === null) return undefined; + } catch (error) { + // A refusal has a reason (OpenError, or the sealer's); anything + // else is THIS side's failure — a key provider that threw — and + // is an error line with the err, not a refusal without a reason. + if (typeof error?.reason !== 'string') { + log.error({ err: error, event: `${event}.keys`, channel, ...(isKid(kid) ? { kid } : {}) }); + return undefined; + } + reason = error.reason; + } + } + const text = body === null ? null : unframe(body); + if (text !== null) return text; + // A frame that opened but names a codec this instance does not hold. + if (body !== null) reason = 'codec'; + // The kid goes on the line only when it is one (peer text stays out). + return void log[level(`open\0${reason}\0${channel}`)]({ + event: `${event}.open`, + channel, + reason, + ...(isKid(kid) ? { kid } : {}), + }); + }, + }; +}; + +module.exports = { createEnvelope, isSealedEnvelope, SEALED_PREFIX, BINARY_PREFIX }; diff --git a/src/rpc/errors.js b/src/rpc/errors.js new file mode 100644 index 0000000..6b80b07 --- /dev/null +++ b/src/rpc/errors.js @@ -0,0 +1,52 @@ +'use strict'; + +// The wire-error builders, shared by the dispatcher, the subscription pump +// and every ServerTransport. Split out of transport.js so the dispatcher's +// only dependency on that file is gone: transport.js carries the HTTP and +// WebSocket transports (Buffer, cookies), none of which a browser-side +// peer host needs, while these three functions are pure. + +const { STATUS_CODES } = require('../status.js'); + +// What the peer is told. 4xx messages are written for the caller +// (validation, quotas, refusals) and travel as-is; a 5xx message is a server +// internal — an uncaught exception's text can carry paths, queries or stack +// fragments — so the peer gets the status line and the details stay in the +// server log, unless the error opts in with `expose = true` (which the +// router's own coded errors do: their messages are part of the protocol). +// The packet id is the correlation: the same id is on the server log line. +const publicErrorMessage = (code, error) => { + const status = STATUS_CODES[code] || 'Unknown error'; + if (!error) return status; + if (code < 500 || error.expose === true) return error.message; + return status; +}; + +// `details` follows the exact same rule as the message: structured issue +// lists (validation paths, quota numbers) are part of the 4xx conversation, +// while a 5xx's internals stay in the log unless the error opts in. +const publicErrorDetails = (code, error) => { + if (!error || error.details === undefined) return undefined; + if (code < 500 || error.expose === true) return error.details; + return undefined; +}; + +// The one builder for the wire error object, so every packet that carries +// an error ({type:'callback'} and {type:'end'} alike) redacts identically. +// The `details` key is omitted entirely when there is nothing to say — +// an optional field, absent rather than null, per the protocol's +// additive-fields rule. +const wireError = (code, error) => { + const wire = { message: publicErrorMessage(code, error), code }; + const details = publicErrorDetails(code, error); + if (details !== undefined) wire.details = details; + return wire; +}; + +// A peer-controlled string on its way into a log line — a method, an id, +// a packet type: bounded before it becomes a record, because the line is +// the one place where "as long as maxPayload allows" is too long. Anything +// that is not a string passes through as it is. +const clip = (text, max = 128) => (typeof text === 'string' && text.length > max ? `${text.slice(0, max)}…` : text); + +module.exports = { publicErrorMessage, publicErrorDetails, wireError, clip }; diff --git a/src/rpc/handshake.js b/src/rpc/handshake.js new file mode 100644 index 0000000..abb5713 --- /dev/null +++ b/src/rpc/handshake.js @@ -0,0 +1,172 @@ +'use strict'; + +// What a peer DECLARED on a persistent handshake, from every carrier it may +// have used: the subprotocol offers of a browser WebSocket (`wrpc.h.`, +// `wrpc.m.`), the connect-URL parameters (`wrpc_h`, `wrpc_meta` — the +// `carrier: 'query'` opt-out, and WebTransport, which has neither headers nor +// subprotocols), and the real `x-wrpc-meta` headers a Node client sends. +// Node-only on purpose: the offers are base64url and rpc/meta.js, which this +// file feeds, is bundled into the WebRTC browser entry where no Buffer exists. +// The core and an application's `verifyClient` gate call the SAME function, +// so what a gate saw is what the connection gets. + +const { jsonParse, toKebab } = require('../utils.js'); +const { HEADERS_PARAM, HEADERS_PROTOCOL, META_PROTOCOL, CARRIER_PROTOCOL } = require('../wire.js'); +const { split } = require('./dispatcher.js'); +const { DEFAULT_META_MAX, declaredData } = require('./meta.js'); + +const PROTOCOL_HEADER = 'sec-websocket-protocol'; + +// PEER-CONTROLLED, whichever carrier brought it, so every step below is a +// refusal rather than a throw — an oversize or malformed label leaves the +// connection with no label, never without a connection. Observed upgrade +// headers always win the merge: a declaration can only ADD names the request +// did not carry, and the names it could spoof are dropped outright. The list +// is about a hostile PAGE, not a hostile process — a non-browser peer sets +// any real header it likes — and a page controls exactly the URL and the +// offers while the victim's cookie rides along by itself: hence the ambient +// credentials, the fetch-metadata and wrpc namespaces, and every name a +// deployment reads the caller's address from when no proxy has set it. +// The deny list lives in reserved.js, beside the sealed request's. +const { RESERVED_DECLARED } = require('./reserved.js'); + +const NO_LOG = { warn() {} }; + +// One sanitizer for both declared-header sources: a flat string map under +// kebab names — node lowercases observed header names, and schema.headers +// validation must see one casing convention, not two (kebab rather than a +// bare lowercase because `xAppVersion` would otherwise land as +// `xappversion`, a key nobody would write in a schema). The deny list is +// tested AFTER the transform: toKebab can only lowercase and insert hyphens, +// so it cannot turn a permitted name into a reserved one, but the honest +// order is to check the name that will actually be kept. +// `allow`: the server's allowlist of declarable names (a Set, lowercase), +// or null for every name the deny list does not refuse. +const sanitizeDeclared = (value, allow = null) => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; + let declared = null; + for (const key of Object.keys(value)) { + if (typeof value[key] !== 'string') continue; + const name = toKebab(key); + if (name === '__proto__' || RESERVED_DECLARED.test(name)) continue; + if (allow !== null && !allow.has(name)) continue; + (declared ??= { __proto__: null })[name] = value[key]; + } + return declared; +}; + +// The offers, split once. `headers`/`meta` are `undefined` when that token was +// not offered, `null` when it was and is refused, else the JSON text. ONE +// budget covers both tokens, measured on the encoded text — the string the +// peer controls — before any decoding work; the client spends it the same +// way (headers first), and it keeps a handshake clear of the smallest header +// limit in the fleet (uWebSockets.js: 4096 bytes for ALL request headers). +// `rest` is the offer an application may see: carrier tokens removed. +const carriedBags = (offer, limit, log) => { + if (typeof offer !== 'string' || !offer.includes('wrpc.')) return null; + const carried = { headers: undefined, meta: undefined, rest: [] }; + let budget = limit; + for (const part of offer.split(',')) { + const token = part.trim(); + if (token.length === 0) continue; + if (!CARRIER_PROTOCOL.test(token)) { + carried.rest.push(token); + continue; + } + const field = token.startsWith(HEADERS_PROTOCOL) ? 'headers' : token.startsWith(META_PROTOCOL) ? 'meta' : null; + // The first token of a kind wins; a Bearer token is the auth strategy's. + if (field === null || carried[field] !== undefined) continue; + if (token.length > budget) { + log.warn({ event: 'meta.oversize', carrier: 'protocol', bytes: token.length }); + carried[field] = null; + continue; + } + budget -= token.length; + // Lenient by design: Buffer decodes what it can of a malformed token, + // and the garbage then fails jsonParse — the intended refusal. + const prefix = field === 'headers' ? HEADERS_PROTOCOL : META_PROTOCOL; + carried[field] = Buffer.from(token.slice(prefix.length), 'base64url').toString('utf8'); + } + return carried; +}; + +const queryHeaders = (query, limit, log, allow) => { + if (!query) return null; + // Capped on the ENCODED length, before any decoding work. + if (query.length > limit) { + log.warn({ event: 'meta.oversize', carrier: 'query', bytes: query.length }); + return null; + } + const raw = new URLSearchParams(query).get(HEADERS_PARAM); + return raw ? sanitizeDeclared(jsonParse(raw), allow) : null; +}; + +const NO_NAMES = Object.freeze([]); + +// `{ headers, meta }` — the header bag a procedure will see (declared names +// UNDER the observed ones) and the sanitized connection-metadata bag. A +// carrier is chosen, never merged: an offered token, valid or refused, means +// the query is not consulted for that bag. The carrier tokens are taken out +// of the `sec-websocket-protocol` the application sees — the bag is what +// handlers log, and a Bearer credential has no business sitting in it twice. +// `declared` in the answer: the names that REALLY came from the peer's +// declaration and stand in the bag — not shadowed by an observed header — +// so a handler can tell a label the client attached from a header the +// connection carried; `allow` narrows what a declaration may name at all. +const readDeclared = (observed, url, limit, log, allow = null) => { + const carried = carriedBags(observed?.[PROTOCOL_HEADER], limit, log); + const query = split(url ?? '', '?')[1]; + const declared = + carried === null || carried.headers === undefined + ? queryHeaders(query, limit, log, allow) + : sanitizeDeclared(jsonParse(carried.headers), allow); + const meta = declaredData(observed, query, limit, log, carried === null ? undefined : carried.meta); + if (carried === null && declared === null) return { headers: observed, meta, declared: NO_NAMES }; + const headers = { ...declared, ...observed }; + if (carried !== null) { + if (carried.rest.length > 0) headers[PROTOCOL_HEADER] = carried.rest.join(', '); + else delete headers[PROTOCOL_HEADER]; + } + let names = NO_NAMES; + if (declared !== null) { + names = []; + for (const name in declared) if (!(name in observed)) names.push(name); + Object.freeze(names); + } + return { headers, meta, declared: names }; +}; + +/** + * What the peer declared on an upgrade request — for a `verifyClient` gate, + * which runs before any `Client` exists. The same read `attachSocket` does. + */ +const readHandshake = (req, { metaMaxBytes = DEFAULT_META_MAX, log = NO_LOG, declaredHeaders = null } = {}) => { + const allow = normalizeDeclaredHeaders(declaredHeaders, 'readHandshake'); + const { headers, meta, declared } = readDeclared(req?.headers ?? {}, req?.url, metaMaxBytes, log, allow); + return { headers, meta: meta ?? {}, declared }; +}; + +// The allowlist option in every spelling → a Set of lowercase names, or +// null for "everything the deny list allows". `cookie` is never declarable, +// allowlisted or not: it is the one name a hostile page most wants. +const normalizeDeclaredHeaders = (value, label) => { + if (value === null || value === undefined) return null; + if (!Array.isArray(value) || !value.every((name) => typeof name === 'string' && name.length > 0)) { + throw new TypeError(`${label}: declaredHeaders must be an array of header names, or null`); + } + const allow = new Set(); + for (const name of value) { + const lower = name.toLowerCase(); + if (lower !== 'cookie') allow.add(lower); + } + return allow; +}; + +module.exports = { + RESERVED_DECLARED, + sanitizeDeclared, + carriedBags, + readDeclared, + readHandshake, + normalizeDeclaredHeaders, +}; diff --git a/src/rpc/meta.js b/src/rpc/meta.js index a84611d..9d06975 100644 --- a/src/rpc/meta.js +++ b/src/rpc/meta.js @@ -1,61 +1,24 @@ 'use strict'; -// The connection-metadata parser: the declared-headers bag (the ws wrpc_h -// connect-URL parameter) and the connection-phase `meta` bag in both its -// spellings. Split out of rpc/core.js — this is a self-contained pure -// module the core, and nothing else, feeds raw requests through; the token -// transports receive its OUTPUT (see sessions.js), never re-run it. +// The connection-metadata parser: the connection-phase `meta` bag in every +// spelling it travels under. A self-contained pure module the core, and +// nothing else, feeds raw requests through; the token transports receive its +// OUTPUT (see sessions.js), never re-run it. Buffer-free: it is bundled into +// the WebRTC browser entry through PeerHost — the declared HEADERS bag and +// the base64url subprotocol carrier live in the Node-only rpc/handshake.js. const { jsonParse, toKebab } = require('../utils.js'); -const { HEADERS_PARAM, META_PARAM, META_HEADER, META_PREFIX } = require('../wire.js'); -const { sanitizeMeta, split } = require('./dispatcher.js'); +const { META_PARAM, META_HEADER, META_PREFIX } = require('../wire.js'); +const { sanitizeMeta } = require('./dispatcher.js'); -// Connection-phase headers on the ws leg: the WHATWG WebSocket constructor -// cannot set real upgrade headers, so the client carries declared ones as -// ONE query parameter on the connect URL. PEER-CONTROLLED, so every step -// below is a refusal rather than a throw — an oversize or malformed label -// leaves the connection with no label, never without a connection. Observed -// upgrade headers always win the merge: the query can only ADD names the -// request did not carry, and the reserved names it could spoof are dropped -// outright (on http/sse, fetch itself refuses to send them, so the deny -// list exists exactly for this query path). const DEFAULT_META_MAX = 2048; -const RESERVED_DECLARED = /^(?:cookie|host|origin)$|^(?:sec-|content-|proxy-|x-wrpc-)/; - -const declaredHeaders = (url, limit, log) => { - const query = split(url ?? '', '?')[1]; - if (!query) return null; - // Capped on the ENCODED length, before any decoding work — that is the - // string the peer actually controls. - if (query.length > limit) { - log.warn({ event: 'meta.oversize', bytes: query.length }); - return null; - } - const raw = new URLSearchParams(query).get(HEADERS_PARAM); - if (!raw) return null; - const value = jsonParse(raw); - if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; - let declared = null; - for (const key of Object.keys(value)) { - if (typeof value[key] !== 'string') continue; // a flat string map only - // Kebab-cased: node lowercases observed header names, and schema.headers - // validation must see one casing convention, not two. Kebab rather than a - // bare lowercase because `xAppVersion` would otherwise land as - // `xappversion` — a key nobody would write in a schema. - // Tested AFTER the transform: toKebab can only lowercase and insert - // hyphens, so it can never turn a permitted name into a reserved one, but - // the honest order is to check the name that will actually be kept. - const name = toKebab(key); - if (name === '__proto__' || RESERVED_DECLARED.test(name)) continue; - (declared ??= { __proto__: null })[name] = value[key]; - } - return declared; -}; // The connection-phase half of `meta` — the x-wrpc-meta request header // (http/sse; percent-encoded JSON, since header values must stay latin-1), // its per-key x-wrpc-meta- spelling, or the wrpc_meta connect-URL -// parameter (ws); the wire names themselves live in ../wire.js. Same +// parameter (the ws query carrier, WebTransport), or the `wrpc.m.` subprotocol +// offer of a browser WebSocket, which arrives here ALREADY DECODED as `carried` +// (see rpc/handshake.js); the wire names themselves live in ../wire.js. Same // sanitizer as the per-packet field, same refusal-not-throw discipline. // Unlike headers this bag is deliberately outside schema validation: it is // a label for cross-cutting hooks, not procedure input. @@ -101,7 +64,11 @@ const kebabKeys = (source, target) => { // was pure rework — and the URLSearchParams parse behind it is gated on a // substring probe, because the common REST/curl request carries no // wrpc_meta at all (bench/meta.js measures both). -const declaredData = (headers, query, limit, log) => { +// `carried` is `undefined` when no `wrpc.m.` token was offered, else its JSON +// text or `null` for a refused one. A carrier is chosen, never merged: the +// real header first (only a non-browser peer can send it), then the offer, +// and the query only when neither was used. +const declaredData = (headers, query, limit, log, carried) => { const prefixed = headers ? prefixedData(headers, limit) : null; let raw = null; const header = headers?.[META_HEADER]; @@ -115,6 +82,8 @@ const declaredData = (headers, query, limit, log) => { } catch { return sanitizeMeta(prefixed, limit); } + } else if (carried !== undefined) { + raw = carried; } else if (query && query.length <= limit && query.includes(META_PARAM)) { raw = new URLSearchParams(query).get(META_PARAM); } @@ -134,4 +103,4 @@ const declaredData = (headers, query, limit, log) => { // to carry tp/ts). Mapped onto the synthetic packet's tp/ts fields, the // ordinary extract path in the telemetry writer picks them up, so a REST -module.exports = { DEFAULT_META_MAX, RESERVED_DECLARED, declaredHeaders, declaredData }; +module.exports = { DEFAULT_META_MAX, declaredData }; diff --git a/src/rpc/reserved.js b/src/rpc/reserved.js new file mode 100644 index 0000000..1198e69 --- /dev/null +++ b/src/rpc/reserved.js @@ -0,0 +1,60 @@ +'use strict'; + +// Header names a peer may not DECLARE for itself, because they say what the +// connection — or a proxy in front of it — knows about the sender, and a +// declaration is the sender's word. Require-free: handshake.js (the ws +// carriers) and, through the core, src/encryption/httpServer.js (the +// headers inside a sealed request) share it — src/encryption/ never +// requires src/rpc/, so the core hands the list over. +// +// Two lists, because the two carriers differ in what they may carry. A +// sealed request carries the REAL request — its content-* and x-wrpc-* +// included — so its list is the AMBIENT facts alone: what a proxy says about +// the connection and, behind an identity-aware proxy, about the user. Their ABSENCE +// is a fact too: a declared one is dropped whether or not the outer +// request carried it. The ws handshake's list is the wider one: a +// declaration there rides beside the real headers and may not stand in for +// anything the handshake itself owns. + +// What a proxy, a CDN or a browser says about the CONNECTION: the +// forwarded chain, the client-ip spellings (Cloudflare, Akamai, Fastly, +// Fly), what a browser's fetch sets and script cannot (sec-*), the origin. +const CONNECTION_NAMES = + 'host|origin|forwarded|via|x-real-ip|x-client-ip|true-client-ip|cf-connecting-ip|fastly-client-ip|fly-client-ip'; +const CONNECTION_PREFIXES = 'sec-|proxy-|x-forwarded-'; + +// What an identity-aware proxy says about the USER it authenticated — an +// OAuth2 proxy (`x-auth-request-*`, not the wider `x-auth-`, which an +// application's own `x-auth-token` lives in), AWS ALB OIDC +// (`x-amzn-oidc-*`), Google IAP (`x-goog-authenticated-user-*`, +// `x-goog-iap-*`), Azure Easy Auth (`x-ms-client-principal*`), the +// `remote-user` of Apache/nginx auth modules. A page declaring one would be +// declaring who the proxy said it is — inside a sealed request as much as on +// a handshake, which is why both lists are built from these pieces: the +// sealed list once lacked every name here. +const IDENTITY_NAMES = 'remote-user'; +const IDENTITY_PREFIXES = 'x-auth-request-|x-amzn-oidc-|x-goog-authenticated-user-|x-goog-iap-|x-ms-client-principal'; + +// A header name is an RFC 9110 token. A declared name that is not one — a +// trailing or an embedded space — names nothing a real request carries, and +// used to slip past the anchored lists below as `x-real-ip `. And `_` is read +// as `-`: nginx's `underscores_in_headers`, CGI's `HTTP_REMOTE_USER` and the +// frameworks that fold one into the other would hand a declared `remote_user` +// to the application as the proxy's `remote-user`. +const TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; + +// What callers test a lowercase name against: true is "drop it". +const refusing = (list) => ({ test: (name) => !TOKEN.test(name) || list.test(name.replaceAll('_', '-')) }); + +const AMBIENT_HEADERS = refusing( + new RegExp(`^(?:${CONNECTION_NAMES}|${IDENTITY_NAMES})$|^(?:${CONNECTION_PREFIXES}|${IDENTITY_PREFIXES})`), +); + +// The handshake's list adds what the handshake itself owns. +const RESERVED_DECLARED = refusing( + new RegExp( + `^(?:cookie|${CONNECTION_NAMES}|${IDENTITY_NAMES})$|^(?:content-|x-wrpc-|${CONNECTION_PREFIXES}|${IDENTITY_PREFIXES})`, + ), +); + +module.exports = { AMBIENT_HEADERS, RESERVED_DECLARED }; diff --git a/src/rpc/rest.js b/src/rpc/rest.js index 069f498..2372e6b 100644 --- a/src/rpc/rest.js +++ b/src/rpc/rest.js @@ -16,13 +16,89 @@ const assignKey = (target, key, value) => { }; const HTTP_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE']; +// Response headers the transport owns — a route may not redeclare them. +const RESERVED_HEADERS = new Set(['content-length', 'set-cookie', 'wrpc-version', 'transfer-encoding', 'connection']); + +// The static response headers of a declared route: string -> string, with +// the transport-owned names refused up front. +// RFC 7230: a field name is a token, a field value holds no CR, LF or NUL — +// node's own rule for a value (`ERR_INVALID_CHAR`), mirrored at the seams a +// handler reaches (`http.headers`, `context.http.setHeader`, the fastify +// adapter's reply) so that an engine which does NOT check — uWebSockets.js +// writes what it is given — never sees a value that splits the response, +// and node's refusal is a TypeError where the value was set rather than a +// request left hanging by a response that was never written. +const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; +const HEADER_VALUE = /^[\t\x20-\x7e\x80-\xff]*$/; + +const checkHeader = (name, value, label) => { + if (typeof name !== 'string' || !HEADER_NAME.test(name)) { + throw new TypeError(`${label}: '${String(name)}' is not a header name`); + } + if (typeof value !== 'string' || !HEADER_VALUE.test(value)) { + throw new TypeError(`${label}: the value of '${name}' holds a character a header cannot carry`); + } +}; + +const normalizeHeaders = (headers) => { + if (headers === undefined || headers === null) return null; + if (typeof headers !== 'object' || Array.isArray(headers)) { + throw new TypeError('procedure() http.headers must be an object of header name -> value'); + } + const result = {}; + for (const name of Object.keys(headers)) { + const value = headers[name]; + if (typeof value !== 'string') throw new TypeError(`procedure() http.headers['${name}'] must be a string`); + if (RESERVED_HEADERS.has(name.toLowerCase())) { + throw new TypeError(`procedure() http.headers may not set '${name}': the transport owns it`); + } + checkHeader(name, value, 'procedure() http.headers'); + result[name] = value; + } + return Object.keys(result).length > 0 ? result : null; +}; + +// `http.cache` — how a shared cache (a CDN, a browser) may treat the +// route's successful responses. Only a safe method can be cacheable. +const normalizeCache = (cache, method) => { + if (cache === undefined || cache === null) return null; + if (typeof cache !== 'object' || Array.isArray(cache)) { + throw new TypeError('procedure() http.cache must be an object'); + } + if (method !== 'GET' && method !== 'HEAD') { + throw new TypeError('procedure() http.cache is only valid on GET and HEAD'); + } + const { maxAge, public: isPublic = false, staleWhileRevalidate = null, etag = true } = cache; + if (!(Number.isInteger(maxAge) && maxAge >= 0)) { + throw new TypeError('procedure() http.cache.maxAge must be an integer >= 0 (seconds)'); + } + if (staleWhileRevalidate !== null && !(Number.isInteger(staleWhileRevalidate) && staleWhileRevalidate >= 0)) { + throw new TypeError('procedure() http.cache.staleWhileRevalidate must be an integer >= 0 (seconds)'); + } + return { maxAge, public: isPublic === true, staleWhileRevalidate, etag: etag !== false }; +}; + +// The cache policy for ONE response, decided where the session is known. +// `public` is emitted only for a public procedure on a request that +// restored no session and set no cookie; anything session-bearing is +// `private, no-store` whatever the route asked for — one shared cache +// serving one user's answer to another is the failure this exists to stop. +// Returns { control, etag } or null for "no cache headers". +const cacheHeadersFor = (cache, { access, session, cookies }) => { + if (!cache) return null; + const bearing = access !== 'public' || session === true || cookies === true; + if (bearing) return { control: 'private, no-store', etag: false }; + let control = `${cache.public ? 'public' : 'private'}, max-age=${cache.maxAge}`; + if (cache.staleWhileRevalidate !== null) control += `, stale-while-revalidate=${cache.staleWhileRevalidate}`; + return { control, etag: cache.etag }; +}; const PARAM_SEGMENT = /^:[A-Za-z_$][\w$]*$/; const STATIC_SEGMENT = /^[^/:*]+$/; const normalizeHttp = (http) => { if (http === null || http === undefined) return null; if (typeof http !== 'object') throw new TypeError('procedure() http must be an object'); - const { method, path, status = null } = http; + const { method, path, status = null, headers, cache } = http; if (!HTTP_METHODS.includes(method)) { throw new TypeError(`procedure() http.method must be one of ${HTTP_METHODS.join(', ')}`); } @@ -38,7 +114,7 @@ const normalizeHttp = (http) => { if (status !== null && !(Number.isInteger(status) && status >= 200 && status <= 599)) { throw new TypeError('procedure() http.status must be an integer HTTP status (200-599)'); } - return { method, path, status }; + return { method, path, status, headers: normalizeHeaders(headers), cache: normalizeCache(cache, method) }; }; // Router-level REST options; today one strategy: how a versioned unit's @@ -56,6 +132,17 @@ const normalizeRestOptions = (rest) => { return { version }; }; +// The introspection view of an http mapping: the optional keys only when +// set, so a client or `wrpc types` sees `{ method, path, status? }` as +// before and `headers`/`cache` only on routes that declare them. +const publicHttp = (http) => { + const view = { method: http.method, path: http.path }; + if (http.status !== null && http.status !== undefined) view.status = http.status; + if (http.headers) view.headers = http.headers; + if (http.cache) view.cache = http.cache; + return view; +}; + // The version-aware view of a procedure's http mapping: with a rest.version // strategy, a versioned unit's declared path gains its '/vN' prefix here — // computed at build/introspection time, never by mutating proc.http @@ -161,7 +248,9 @@ const walk = (node, segments) => { */ const matchRestTrees = (trees, method, segments) => { if (!trees) return null; - const hit = walk(trees.get(method), segments); + // HEAD is answered by the GET route (RFC 9110 9.3.2): the host strips the + // body, the headers — the cache policy's included — are the GET's. + const hit = walk(trees.get(method), segments) ?? (method === 'HEAD' ? walk(trees.get('GET'), segments) : null); if (hit) { const params = {}; for (let i = 0; i < hit.route.paramNames.length; i++) { @@ -197,6 +286,10 @@ const collectRestRoutes = (units, restOptions) => { module.exports = { normalizeHttp, + publicHttp, + cacheHeadersFor, + RESERVED_HEADERS, + checkHeader, normalizeRestOptions, effectiveHttp, buildRestTrees, diff --git a/src/rpc/rooms.js b/src/rpc/rooms.js index bd7c8d4..008c626 100644 --- a/src/rpc/rooms.js +++ b/src/rpc/rooms.js @@ -2,6 +2,7 @@ const { jsonParse } = require('../utils.js'); const { createLoggerWriter } = require('../logging.js'); +const { hasBytes, encodeAttachments } = require('../attachments.js'); // Rooms: named groups of clients, layered on top of the existing // `{ type: 'event' }` packets — no new wire type is needed, a room broadcast @@ -147,6 +148,7 @@ class Broadcast { #log; #otel; #codec; + #attachments; constructor({ registry, @@ -156,6 +158,7 @@ class Broadcast { log = globalThis.console, otel = null, codec = null, + attachments = true, targets = null, excluded = null, localOnly = false, @@ -167,6 +170,7 @@ class Broadcast { this.#log = createLoggerWriter(log); this.#otel = otel; this.#codec = codec; + this.#attachments = attachments; this.#targets = targets; this.#excluded = excluded; this.#localOnly = localOnly; @@ -181,6 +185,7 @@ class Broadcast { log: this.#log, otel: this.#otel, codec: this.#codec, + attachments: this.#attachments, targets: this.#targets, excluded: this.#excluded, localOnly: this.#localOnly, @@ -233,10 +238,16 @@ class Broadcast { * returns how many received it LOCALLY — remote instances are reached * through the backplane, whose delivery this number says nothing about. */ - emit(name, data) { + /** + * `unreliable: true` sends to every recipient whose transport has + * datagrams as one (lossy, unordered), and reliably to the rest — and + * carries the flag across the backplane, so remote instances do the same. + */ + emit(name, data, options = null) { if (typeof name !== 'string' || name.length === 0) { throw new TypeError('Event name must be a non-empty string'); } + const unreliable = options?.unreliable === true; // Narrowed to no rooms at all: nobody here, nobody anywhere — so there // is nothing to publish either. if (this.#targets && this.#targets.length === 0) return 0; @@ -245,26 +256,59 @@ class Broadcast { // A payload that cannot serialize (circular) is reported, not thrown: // the broadcaster is usually far from whoever built the value. let text; + let binary = false; + const packet = { type: 'event', name, data }; try { // A configured codec is server-wide, so this stays a SINGLE encode - // for the whole fan-out — same property the JSON path has. - const packet = { type: 'event', name, data }; - text = this.#codec ? this.#codec.encode(packet) : JSON.stringify(packet); + // for the whole fan-out — same property the JSON path has. Bytes in + // the data make it ONE attachments frame for every recipient that + // reads frames. + if (this.#codec) text = this.#codec.encode(packet); + else if (this.#attachments !== false && hasBytes(packet)) { + binary = true; + text = encodeAttachments(packet); + } else text = JSON.stringify(packet); } catch (error) { this.#log.error({ err: error, event: 'broadcast.serialize', name }); return 0; } + // ONE shared message for the whole fan-out. `frames` is a slot the + // recipient's engine fills with the encoded (and, per negotiated + // window, deflated) frame on first use and every later recipient + // reuses — so the wire bytes, like the JSON, are built once per emit, + // not once per member. Engines without the seam read `text`. `inner` + // is the same idea for a SEALED recipient, which shares no wire bytes: + // the plaintext it seals, built once (src/encryption/server.js). Both + // slots exist from the start so the object keeps one shape. + const message = { text, frames: null, inner: null, compress: options === null || options.compress !== false }; + // The same event for a recipient that reads no frames — a 1.0 client on + // a revision-1 connection (protocol.md#versioning): the JSON 1.0 sent, + // built on the first such recipient and shared by the rest, so a mixed + // room costs one extra stringify per emit, not one per member + // (bench/broadcast-revisions.js). + let plain = null; let sent = 0; for (const client of this.#recipients()) { if (this.#excluded?.has(client)) continue; // HTTP clients cannot carry events; skipping beats throwing mid-fan-out. if (!client.persistent) continue; try { - const flushed = client.sendRaw(text); + let shared = message; + if (binary && !client.attachments) { + // Its own slots: `message.frames` may already hold the frame an + // earlier recipient's engine built from the binary form. + if (plain === null) { + plain = { text: JSON.stringify(packet), frames: null, inner: null, compress: message.compress }; + } + shared = plain; + } + const flushed = client.sendShared(shared, unreliable ? options : null); sent++; // Not silently discarded any more: a recipient above its high-water - // mark is visible in the metrics, and the engine's maxBackpressure - // cap is what disconnects one that never drains. + // mark is visible in the metrics, and every persistent carrier's + // maxBackpressure cap — the WebSocket engine's, the WebTransport + // socket's, a data channel's — is what disconnects one that never + // drains. if (flushed === false) this.#otel?.recordBackpressure(client.transportKind); } catch (error) { // One dead socket must not truncate the fan-out. @@ -273,7 +317,14 @@ class Broadcast { } const published = Boolean(!this.#localOnly && this.#publish); if (published) { - this.#publish({ rooms: this.#targets, name, data }); + // The flags ride only when set: the envelope stays what it was. An + // event whose data holds bytes crosses as a binary envelope — JSON has + // no bytes, and they would arrive as the plain objects the + // attachments frame exists to avoid. + const envelope = { rooms: this.#targets, name, data }; + if (unreliable) envelope.unreliable = true; + if (binary) envelope.binary = true; + this.#publish(envelope); } this.#otel?.recordBroadcast(name, sent, published); return sent; @@ -380,6 +431,8 @@ class Broadcast { // How long an emptied room's channel stays subscribed (ms): the grace // window that absorbs reconnect churn. 0 disables (`rooms: { linger: 0 }`). const DEFAULT_LINGER = 5_000; +// Channels whose publish counters are kept per generation (see #seq). +const DEFAULT_MAX_TRACKED = 16384; class RoomsBackplane { #backplane; @@ -389,17 +442,67 @@ class RoomsBackplane { #linger; #channels = new Map(); // channel -> { count, off, stale, timer, lingerTimer } #closed = false; + // Loss detection. Every envelope this instance publishes carries its boot + // `epoch` and a per-channel `seq`; a receiver keeps the last seq it saw + // per (channel, publisher) and reports a jump as a gap — the broker + // dropped something, or this instance was between subscriptions. At-most- + // once stays the contract; the loss just stops being silent. + #epoch; + // The per-channel counters, in two generations: a channel published to + // is looked up in `young` (one get, one increment — the hot path), then + // in `old` and moved; when `young` reaches `maxTracked` it becomes + // `old` and the previous `old` is dropped — so the table holds at most + // 2 × maxTracked channels, not every room name this instance ever + // published to (300k unique rooms held ~38 MiB). A channel published to + // again after eviction starts a new count under `.`: + // a receiver compares epochs for equality only, so it sees a restart + // (its cursor resets), never a false gap. Channels of the first + // generation carry the bare epoch — the wire is unchanged until a + // rotation ever happens. + #seq = new Map(); // channel -> { seq, epoch } + #seqOld = new Map(); + #rotation = 0; + #maxTracked; + #peers = new Map(); // channel -> Map + #onGap; // Channels whose subscribe FAILED and is being retried: while any are // pending, cross-instance delivery on them is dark and `healthy` is // false — what a readiness probe should drain the node on. #pending = 0; + // The envelope codec (src/compression/sync.js) the core built from + // `rooms.compression`, or null: `encode(text)` on the way out, `decode` + // on the way in. Injected, so this browser-bundled file carries none of + // zlib, base64 or the option parsing. + #envelope; - constructor({ backplane, instance, deliver, log = globalThis.console, linger = DEFAULT_LINGER }) { + constructor({ + backplane, + instance, + deliver, + log = globalThis.console, + linger = DEFAULT_LINGER, + epoch = Math.random().toString(36).slice(2, 10), + onGap = null, + envelope = null, + maxTracked = DEFAULT_MAX_TRACKED, + }) { + if (!Number.isInteger(maxTracked) || maxTracked < 1) { + throw new TypeError('RoomsBackplane: maxTracked must be a positive integer'); + } this.#backplane = backplane; this.#instance = instance; this.#deliver = deliver; this.#log = createLoggerWriter(log); this.#linger = linger > 0 ? linger : 0; + this.#maxTracked = maxTracked; + this.#epoch = String(epoch); + this.#onGap = typeof onGap === 'function' ? onGap : null; + this.#envelope = envelope; + } + + /** The boot marker stamped on every envelope this instance publishes. */ + get epoch() { + return this.#epoch; } get healthy() { @@ -437,7 +540,7 @@ class RoomsBackplane { // its channel — and on BROADCAST_CHANNEL, permanently deaf to every // server.broadcast() — while looking perfectly healthy. #subscribe(channel, record, attempt) { - const handler = (message) => this.#receive(message); + const handler = (message) => this.#receive(channel, message); Promise.resolve() .then(() => this.#backplane.subscribe(channel, handler)) .then( @@ -494,6 +597,7 @@ class RoomsBackplane { } #dispose(channel, record) { + this.#peers.delete(channel); this.#channels.delete(channel); if (record.timer) { clearTimeout(record.timer); @@ -522,19 +626,51 @@ class RoomsBackplane { this.release(roomChannel(room)); } - publish({ rooms, name, data }) { + publish({ rooms, name, data, unreliable = false, binary = false }) { if (this.#closed) return; - const envelope = { v: ENVELOPE_VERSION, instance: this.#instance, rooms: rooms ?? null, name, data }; + const single = rooms && rooms.length === 1; + const channel = single ? roomChannel(rooms[0]) : BROADCAST_CHANNEL; + const counter = this.#seq.get(channel) ?? this.#counter(channel); + const seq = ++counter.seq; + const envelope = { + v: ENVELOPE_VERSION, + instance: this.#instance, + epoch: counter.epoch, + seq, + rooms: rooms ?? null, + name, + data, + }; + // Additive: an older instance ignores the field and delivers reliably. + if (unreliable) envelope.unreliable = true; let message = null; try { - message = JSON.stringify(envelope); + if (binary) { + // Bytes in the data: the injected envelope carries them (the core's + // always can). Without one — a registry wired by hand — the + // cross-instance half is named, not silently corrupted. + if (typeof this.#envelope?.encodeBytes !== 'function') { + return void this.#log.warn({ event: 'backplane.bytes', name }); + } + message = this.#envelope.encodeBytes(envelope, channel); + } else { + message = JSON.stringify(envelope); + } } catch (error) { // Non-serializable payload: local delivery already happened, so this // is a cross-instance loss, not a lost event. return void this.#log.error({ err: error, event: 'backplane.serialize', name }); } - const single = rooms && rooms.length === 1; - const channel = single ? roomChannel(rooms[0]) : BROADCAST_CHANNEL; + // The channel rides along for a sealing envelope, which binds it. A + // sealer that cannot (a keyring without its current key) is named as + // such, and the event stays local: never plaintext across the wire. + if (!binary && this.#envelope !== null) { + try { + message = this.#envelope.encode(message, channel); + } catch (error) { + return void this.#log.error({ err: error, event: 'backplane.seal', name }); + } + } try { const result = this.#backplane.publish(channel, message); if (result && typeof result.catch === 'function') { @@ -546,27 +682,103 @@ class RoomsBackplane { } } - #receive(message) { + #receive(channel, message) { if (this.#closed) return; - const envelope = typeof message === 'string' ? jsonParse(message) : message; + let text = message; + if (typeof message === 'string') { + // An encoded envelope this instance cannot read — no codec, another + // instance's, a body that does not inflate: named, because the + // alternative is an event that silently never arrives, the + // rolling-deploy symptom this option's documentation warns about. + if (this.#envelope !== null) text = this.#envelope.decode(message, channel); + else if (message.charCodeAt(0) === 119 && message.startsWith('wrpc-enc:')) text = null; + if (text === null) return void this.#log.warn({ event: 'backplane.encoded', channel }); + // Refused by a sealing envelope, which reported why — or our own echo. + if (text === undefined) return; + // Still sealed: this instance holds no keys (with or without a + // compression codec, which passes through what is not its own), and + // says so — JSON never starts with a `w`, so the test is one compare. + if (typeof text === 'string' && text.charCodeAt(0) === 119 && text.startsWith('wrpc-sealed:')) { + return void this.#log.warn({ event: 'backplane.sealed', channel }); + } + } + const envelope = typeof text === 'string' ? jsonParse(text) : text; if (!envelope || typeof envelope !== 'object') return; // Echo suppression: every instance sees its own publishes. if (envelope.instance === this.#instance) return; const { rooms, name, data } = envelope; if (typeof name !== 'string' || name.length === 0) return; if (rooms !== null && rooms !== undefined && !Array.isArray(rooms)) return; + this.#track(channel, envelope); try { - this.#deliver(rooms ?? null, name, data); + this.#deliver(rooms ?? null, name, data, envelope.unreliable === true); } catch (error) { this.#log.error({ err: error, event: 'backplane.deliver', name }); } } + // Sequence tracking per (channel, publisher). A jump within one epoch is + // a gap and is reported once with the count of envelopes missed; a new + // epoch (the publisher restarted) resets the count rather than reporting + // its whole history as lost; an envelope without the fields (an older + // instance) is delivered untracked. Out-of-order arrival never regresses + // the cursor, so a late envelope is not reported as a second gap. + // The cold half of the counter lookup: a channel not in the young + // generation is taken over from the old one, or started — under the + // rotation's epoch when the table has ever rotated — and the young + // generation rotates first when it is full. + #counter(channel) { + let counter = this.#seqOld.get(channel); + if (counter !== undefined) this.#seqOld.delete(channel); + if (this.#seq.size >= this.#maxTracked) { + this.#seqOld = this.#seq; + this.#seq = new Map(); + this.#rotation++; + } + // Started AFTER the rotation, so a channel begun in generation n + // carries n: a count that restarts under the bare epoch would look + // late to a receiver holding the evicted count's cursor. + if (counter === undefined) { + counter = { seq: 0, epoch: this.#rotation === 0 ? this.#epoch : `${this.#epoch}.${this.#rotation}` }; + } + this.#seq.set(channel, counter); + return counter; + } + + #track(channel, envelope) { + const { instance, epoch, seq } = envelope; + if (typeof seq !== 'number' || typeof epoch !== 'string') return; + let peers = this.#peers.get(channel); + if (peers === undefined) { + peers = new Map(); + this.#peers.set(channel, peers); + } + const peer = peers.get(instance); + if (peer === undefined) return void peers.set(instance, { epoch, seq }); + if (peer.epoch !== epoch) { + peer.epoch = epoch; + peer.seq = seq; + return; + } + if (seq <= peer.seq) return; + const missed = seq - peer.seq - 1; + peer.seq = seq; + if (missed > 0 && this.#onGap !== null) { + try { + this.#onGap({ channel, instance, missed, seq }); + } catch (error) { + this.#log.error({ err: error, event: 'backplane.gap' }); + } + } + } + // Unsubscribes every channel this server holds. The backplane itself is // NOT closed: it was injected, so its lifetime belongs to the caller. close() { if (this.#closed) return; this.#closed = true; + this.#seq.clear(); + this.#seqOld.clear(); for (const [channel, record] of Array.from(this.#channels)) { if (!record.off) { record.stale = true; diff --git a/src/rpc/router.js b/src/rpc/router.js index d655d0e..db8ec6e 100644 --- a/src/rpc/router.js +++ b/src/rpc/router.js @@ -9,6 +9,7 @@ const { buildRestTrees, matchRestTrees, collectRestRoutes, + publicHttp, } = require('./rest.js'); const DEFAULT_VERSION = '*'; @@ -236,6 +237,7 @@ class Procedure { preHandler = null, preSerialization = null, onError = null, + consume = null, } = options; if (typeof handler !== 'function') { throw new TypeError('procedure() requires a handler function'); @@ -292,6 +294,16 @@ class Procedure { throw new TypeError('procedure.subscription() does not support http'); } this.semaphore = queue ? new Semaphore(queue) : null; + // A queue-consumer procedure's delivery policy (see the unit's reserved + // `consumes` key). Plain data, shape-checked here; the broker binding + // owns what the values mean. + if (consume !== null && (typeof consume !== 'object' || Array.isArray(consume))) { + throw new TypeError('procedure() consume must be an object'); + } + if (consume && (this.kind === SUBSCRIPTION || this.http)) { + throw new TypeError('procedure() consume cannot be combined with a subscription or an http mapping'); + } + this.consume = consume ? Object.freeze({ ...consume }) : null; } get subscription() { @@ -348,25 +360,49 @@ class Procedure { // semaphore and the deadline behave EXACTLY as in invoke() — the slot is // held until the handler settles — but hooks, access and validators are // the host's job, already run by the time this is called. + // Queue wait, measured once and in one place. Without it the time a call + // spends waiting for a slot is folded invisibly into `rpc.server.duration`, + // so a saturated queue and a slow handler look identical — and they are + // opposite problems with opposite fixes (raise the queue vs. fix the + // handler). Both `invoke` and `invokeBare` take this path, so the two + // cannot drift apart. + async #acquire(context) { + const otel = context.client?.otel; + // Nothing listening: no clock reads, no counter traffic. + const started = otel?.enabled ? Date.now() : 0; + if (started > 0) otel.recordQueue(1); + try { + // Abort-aware: a queued waiter whose caller cancelled or disconnected + // leaves the queue instead of taking a slot later. + await this.semaphore.enter(context.signal ?? null); + } catch (error) { + if (started > 0) otel.recordQueue(-1); + throw codedError(error.message, 503); + } + if (started > 0) otel.recordQueue(-1, Date.now() - started); + } + async invokeBare(context, args) { if (this.kind === SUBSCRIPTION) { throw codedError('This procedure is a subscription: use {type:"subscribe"}', 400); } const deadline = this.timeout > 0 ? Date.now() + this.timeout : 0; - if (this.semaphore) { - try { - await this.semaphore.enter(context.signal ?? null); - } catch (error) { - throw codedError(error.message, 503); - } - } + if (this.semaphore) await this.#acquire(context); let handlerStarted = false; try { if (deadline > 0 && Date.now() >= deadline) { throw codedError('Procedure timeout', 408); } handlerStarted = true; - const invocation = Promise.resolve().then(() => this.handler(context, args)); + // Called directly rather than through Promise.resolve().then(): the + // handler starts now instead of a microtask later, and a synchronous + // throw becomes the rejection it would have been (bench/bench.js). + let invocation; + try { + invocation = Promise.resolve(this.handler(context, args)); + } catch (error) { + invocation = Promise.reject(error); + } if (this.semaphore) { const release = () => this.semaphore.leave(); invocation.then(release, release); @@ -393,15 +429,7 @@ class Procedure { const cap = this.timeout > 0 ? this.timeout : 0; const limit = budget > 0 && (cap === 0 || budget < cap) ? budget : cap; const deadline = limit > 0 ? Date.now() + limit : 0; - if (this.semaphore) { - try { - // Abort-aware: a queued waiter whose caller cancelled or - // disconnected leaves the queue instead of taking a slot later. - await this.semaphore.enter(context.signal ?? null); - } catch (error) { - throw codedError(error.message, 503); - } - } + if (this.semaphore) await this.#acquire(context); if (deadline > 0 && Date.now() >= deadline) { throw codedError('Procedure timeout', 408); } @@ -420,7 +448,15 @@ class Procedure { } if (hooks.preHandler.length > 0) await runHooks(hooks.preHandler, context, args); handlerStarted = true; - const invocation = Promise.resolve().then(() => this.handler(context, args)); + // Called directly rather than through Promise.resolve().then(): the + // handler starts now instead of a microtask later, and a synchronous + // throw becomes the rejection it would have been (bench/bench.js). + let invocation; + try { + invocation = Promise.resolve(this.handler(context, args)); + } catch (error) { + invocation = Promise.reject(error); + } if (this.semaphore) { // The queue slot is held until the HANDLER settles: a timeout // rejects the caller but cannot cancel the handler, and freeing @@ -465,6 +501,13 @@ procedure.subscription = (options) => { return new Procedure({ ...options, kind: SUBSCRIPTION }); }; +// `consume` is a consumer's policy: on a callable method or an event handler +// it would silently do nothing, so it is refused where it cannot apply. +const rejectConsume = (proc, label) => { + if (proc.consume) throw new TypeError(`${label}: consume is only valid inside a unit's consumes block`); + return proc; +}; + const toProcedure = (value, unitKey, methodName) => { if (value instanceof Procedure) return value; if (typeof value === 'function') return new Procedure({ handler: value }); @@ -486,6 +529,11 @@ const HOOKS_KEY = 'hooks'; // the map travels through introspection so `wrpc types` can generate the // contract's `events` key, the same way `signature` types calls. const EMITS_KEY = 'emits'; +// The unit's QUEUE CONSUMERS: `consumes: { 'orders.created': procedure(...) }`. +// Each is a full Procedure (hooks, validators, access, queue, timeout) that +// a broker binding invokes per delivered message — and that no peer can +// call: they live apart from `methods`, so a `call` packet cannot reach one. +const CONSUMES_KEY = 'consumes'; // unit-level hooks may not carry the connection phases: a connection is not // scoped to a unit, so an onConnect there could never mean anything. @@ -711,6 +759,7 @@ class Router { for (const entry of versions.values()) { for (const proc of entry.methods.values()) this.#chains.set(proc, this.#chainFor(entry, proc)); for (const proc of entry.events.values()) this.#chains.set(proc, this.#chainFor(entry, proc)); + for (const proc of entry.consumes.values()) this.#chains.set(proc, this.#chainFor(entry, proc)); } } this.#rebuildRest(); @@ -734,6 +783,10 @@ class Router { const artifacts = this.#compileFor(proc, `${unitKey}/on.${eventName}`); if (artifacts) this.#compiled.set(proc, artifacts); } + for (const [consumerName, proc] of entry.consumes) { + const artifacts = this.#compileFor(proc, `${unitKey}/consumes.${consumerName}`); + if (artifacts) this.#compiled.set(proc, artifacts); + } } } } @@ -814,7 +867,7 @@ class Router { } let entry = versions.get(version); if (!entry) { - entry = { methods: new Map(), events: new Map(), emits: null, hooks: null }; + entry = { methods: new Map(), events: new Map(), consumes: new Map(), emits: null, hooks: null }; versions.set(version, entry); } for (const [name, value] of Object.entries(definition)) { @@ -822,6 +875,10 @@ class Router { this.#addEvents(unitKey, entry.events, value); continue; } + if (name === CONSUMES_KEY) { + this.#addConsumers(unitKey, entry.consumes, value); + continue; + } if (name === EMITS_KEY) { if (typeof value !== 'object' || value === null || Array.isArray(value)) { throw new TypeError(`Router definition ${unitKey}.emits must be an object of event descriptors`); @@ -834,7 +891,20 @@ class Router { entry.hooks = entry.hooks ? concatHooks(entry.hooks, unitHooks, INVOCATION_PHASES) : unitHooks; continue; } - entry.methods.set(name, toProcedure(value, unitKey, name)); + entry.methods.set(name, rejectConsume(toProcedure(value, unitKey, name), `${unitKey}/${name}`)); + } + } + + #addConsumers(unitKey, consumes, definition) { + if (typeof definition !== 'object' || definition === null || Array.isArray(definition)) { + throw new TypeError(`Router definition ${unitKey}.consumes must be an object of consumer procedures`); + } + for (const [name, value] of Object.entries(definition)) { + const proc = toProcedure(value, unitKey, `consumes.${name}`); + if (proc.subscription) { + throw new TypeError(`Router definition ${unitKey}.consumes.${name} cannot be a subscription`); + } + consumes.set(name, proc); } } @@ -845,7 +915,7 @@ class Router { throw new TypeError(`Router definition ${unitKey}.on must be an object of event handlers`); } for (const [name, value] of Object.entries(definition)) { - events.set(name, toProcedure(value, unitKey, `on.${name}`)); + events.set(name, rejectConsume(toProcedure(value, unitKey, `on.${name}`), `${unitKey}/on.${name}`)); } } @@ -859,6 +929,28 @@ class Router { return entry?.events.get(name) ?? null; } + /** A unit's queue-consumer procedure, or null. Never reachable by a call packet. */ + getConsumer(unit, version = DEFAULT_VERSION, name) { + const entry = this.#units.get(unit)?.get(version); + return entry?.consumes.get(name) ?? null; + } + + /** + * Every declared queue consumer — what a broker binding subscribes to. + * Cold path. Deliberately NOT part of introspect(): which queues a server + * drains is deployment topology, not a client contract. + */ + consumers() { + const list = []; + for (const [unit, versions] of this.#units) { + for (const [version, entry] of versions) { + const unitKey = version === DEFAULT_VERSION ? unit : `${unit}.${version}`; + for (const [name, proc] of entry.consumes) list.push({ unitKey, unit, version, name, procedure: proc }); + } + } + return list; + } + /** True when at least one procedure declares an `http` mapping. */ get hasRestRoutes() { return this.#rest !== null; @@ -900,7 +992,7 @@ class Router { // The REST mapping travels so clients (and codegen/OpenAPI // tooling) can address the same procedure as a plain endpoint — // effective, so a client's REST leg calls the versioned URL. - if (proc.http) info.http = effectiveHttp(proc.http, version, this.#restOptions); + if (proc.http) info.http = publicHttp(effectiveHttp(proc.http, version, this.#restOptions)); if (schemas && proc.schema) { const parts = {}; if (proc.schema.params !== undefined) parts.params = proc.schema.params; @@ -957,6 +1049,7 @@ class Router { const unitKey = version === DEFAULT_VERSION ? unit : `${unit}.${version}`; const definition = Object.fromEntries(entry.methods); if (entry.events.size > 0) definition[EVENTS_KEY] = Object.fromEntries(entry.events); + if (entry.consumes.size > 0) definition[CONSUMES_KEY] = Object.fromEntries(entry.consumes); if (entry.emits) definition[EMITS_KEY] = entry.emits; if (entry.hooks) definition[HOOKS_KEY] = entry.hooks; merged.#addUnit(unitKey, definition); @@ -1012,6 +1105,7 @@ const effectiveSchema = (proc) => { const defineRouter = (definition, options) => new Router(definition, options); module.exports = { + runValidator, Procedure, Router, procedure, diff --git a/src/rpc/serverTransport.js b/src/rpc/serverTransport.js new file mode 100644 index 0000000..ce1bb0b --- /dev/null +++ b/src/rpc/serverTransport.js @@ -0,0 +1,93 @@ +'use strict'; + +// The base every server-side transport extends: the outbound half of a +// connection as the dispatcher and Client see it — send(obj) → write(text), +// error(code) → a callback packet, and the 'close'/'drain' events a +// persistent one emits. On its own file so a transport that lives in a +// browser bundle (the WebRTC peer half) does not drag the HTTP and +// WebSocket transports, cookies and CORS in with it; src/transport.js +// re-exports it, so nothing else changed its require path. + +const { Emitter } = require('../utils.js'); +const { wireError } = require('./errors.js'); +const { hasBytes, encodeAttachments } = require('../attachments.js'); + +class ServerTransport extends Emitter { + // Which wire this is, for log entries and metric attributes. Subclasses + // override it; the base value covers a transport nobody labelled. + kind = 'unknown'; + + constructor(source) { + // No listener cap: transports are fan-out points — every backpressured + // outbound stream on the connection parks a once('drain'|'close') + // listener here, and the default cap of 10 would throw on the 11th + // concurrently stalled stream. + super({ maxListeners: Number.MAX_SAFE_INTEGER }); + this.source = source; + // The protocol revision this connection speaks (protocol.md#versioning): + // lowered to 1 — together with `attachments` — by whoever attaches a + // transport a 1.0 peer is behind. + this.revision = 2; + } + + // A change of the revision once a client stands on this transport — a + // port's first ping, a WebTransport peer's capabilities — goes through + // here, `attachments` with it: whoever counts connections by revision + // hears 'revision' with the one that was left. + setRevision(revision) { + const from = this.revision; + this.revision = revision; + this.attachments = revision === 2; + if (from !== revision) this.emit('revision', from); + } + + error(code = 500, { id = '', error = null } = {}) { + const packet = { type: 'callback', id, error: wireError(code, error) }; + return this.send(packet, code); + } + + // Returns the transport's backpressure signal (false = above the + // high-water mark) so a producer — a subscription pump, a stream — can + // wait for 'drain' instead of buffering without limit. + // + // `text` is the already-serialized form of `obj` when the dispatcher's + // compiled-serializer fast path built one (see handleRpc); passing both + // keeps the object available to the overrides that need it (batch + // collection, REST unwrapping) while the plain path skips a stringify. + send(obj, code = 200, text = null) { + // An injected codec (RpcServer options.codec, assigned per transport) + // re-frames every packet; it wins over precompiled `text` by + // construction — the server refuses codec + serializers up front. + if (this.codec) return this.write(this.codec.encode(obj), code); + // Precompiled text was built by the dispatcher on a packet it already + // knows holds no bytes; anything else is walked once here, and a + // packet with bytes leaves as an attachments frame (attachments.js) + // unless the server opted out. + if (text !== null && text !== undefined) return this.write(text, code); + if (this.attachments !== false && hasBytes(obj)) return this.write(encodeAttachments(obj), code); + return this.write(JSON.stringify(obj), code); + } +} + +// The shape of a transport a host can attach by itself — persistent +// (`connection` set) and announcing inbound traffic as 'packet' (text) and +// 'chunk' (bytes) events. What PeerHost.attach and RpcServer.attach both +// check; structural, so a transport need not extend ServerTransport. +// What a Client calls on its transport — write/send/error and close — and +// what the core listens on: on/once/off. Checked whole, so a transport +// missing send() or error() is refused at attach, not at the first packet +// that needed it. +const hasTransportShape = (transport) => + typeof transport === 'object' && + transport !== null && + typeof transport.write === 'function' && + typeof transport.send === 'function' && + typeof transport.error === 'function' && + typeof transport.close === 'function' && + typeof transport.on === 'function' && + typeof transport.once === 'function' && + typeof transport.off === 'function'; + +const isInboundTransport = (transport) => hasTransportShape(transport) && Boolean(transport.connection); + +module.exports = { ServerTransport, hasTransportShape, isInboundTransport }; diff --git a/src/rpc/sessions.js b/src/rpc/sessions.js index 6085049..c3c8780 100644 --- a/src/rpc/sessions.js +++ b/src/rpc/sessions.js @@ -3,6 +3,7 @@ const { generateUUID } = require('../runtime/node.js'); const { createLoggerWriter } = require('../logging.js'); const { parseCookies } = require('../transport.js'); +const { DISABLED: DISABLED_TELEMETRY } = require('../telemetry/shared.js'); // TokenTransport — where the session token lives on the wire, injected like // a codec or a logger and checked structurally: @@ -62,10 +63,24 @@ const createProxy = (data, save) => }); class Session { - constructor(token, data, save) { + #lifecycle; + + constructor(token, data, save, lifecycle = { ended: false }) { this.token = token; + this.#lifecycle = lifecycle; this.state = createProxy(data, save); } + + /** True once the session was finalized: no save of its state lands after this. */ + get ended() { + return this.#lifecycle.ended; + } + + // Called by finalizeSession before the store delete: a save still queued + // for this session (the microtask below) must not resurrect it. + end() { + this.#lifecycle.ended = true; + } } const DEFAULT_MAX_SESSIONS = 10000; @@ -80,15 +95,30 @@ const DEFAULT_SESSION_TTL = 24 * 60 * 60 * 1000; // 24h // so this store is bounded on both axes: entries expire after `ttl` and // the least-recently-used one is evicted past `maxSessions`. Production // deployments should inject a real store instead. +// The options a save passes to store.set(): the row's creation, then +// updates that must find it there. +const CREATE = Object.freeze({ create: true }); +const UPDATE = Object.freeze({ create: false }); + class MemorySessionStore { #sessions = new Map(); // token -> { data, expires } #maxSessions; #ttl; + #log; + #otel; - constructor({ maxSessions = DEFAULT_MAX_SESSIONS, ttl = DEFAULT_SESSION_TTL, now = Date.now } = {}) { + constructor({ + maxSessions = DEFAULT_MAX_SESSIONS, + ttl = DEFAULT_SESSION_TTL, + now = Date.now, + logger = false, + otel = null, + } = {}) { this.#maxSessions = maxSessions; this.#ttl = ttl; this.now = now; + this.#log = createLoggerWriter(logger); + this.#otel = otel ?? DISABLED_TELEMETRY; } get size() { @@ -112,10 +142,20 @@ class MemorySessionStore { return entry.data; } - async set(token, data) { + // `create: false` (every write after the first): a row that is gone — + // deleted, or expired — is not brought back; false says so. + async set(token, data, { create = true } = {}) { + if (!create) { + const entry = this.#sessions.get(token); + if (!entry || this.#expired(entry)) { + if (entry) this.#sessions.delete(token); + return false; + } + } this.#sessions.delete(token); this.#sessions.set(token, { data, expires: this.now() + this.#ttl }); this.#evict(); + return true; } async delete(token) { @@ -134,11 +174,23 @@ class MemorySessionStore { for (const [token, entry] of this.#sessions) { if (!this.#expired(entry)) break; // oldest first: stop at the first live one this.#sessions.delete(token); + this.#otel.recordSession('expire', 'ok'); } } + // Capacity eviction, unlike the TTL sweep above, throws away sessions + // that are still LIVE — someone signed in is signed out, with no error + // anywhere and no way to tell it from an ordinary expiry. One line per + // sweep carrying a count, never one per session: a store pinned at its + // ceiling evicts on every create, and a line each would be the flood. + let evicted = 0; while (this.#maxSessions > 0 && this.#sessions.size > this.#maxSessions) { const oldest = this.#sessions.keys().next().value; this.#sessions.delete(oldest); + this.#otel.recordSession('evict', 'ok'); + evicted++; + } + if (evicted > 0) { + this.#log.warn({ event: 'session.evict', evicted, max: this.#maxSessions }); } } } @@ -194,13 +246,18 @@ const buildCookie = (name, value, options) => { // by every Server in the process). class SessionManager { #log; + #otel; - constructor(options = {}, logger = globalThis.console) { - const { store = new MemorySessionStore(), generateToken = generateUUID, cookie = {}, transport } = options; - this.store = store; + constructor(options = {}, logger = globalThis.console, otel = null) { + const { store = null, generateToken = generateUUID, cookie = {}, transport } = options; + this.#log = createLoggerWriter(logger); + this.#otel = otel ?? DISABLED_TELEMETRY; + // The default store is built HERE so it inherits this writer: a store + // the application constructed is its own to configure, but the one wrpc + // makes on its behalf should report where everything else does. + this.store = store ?? new MemorySessionStore({ logger: this.#log, otel: this.#otel }); this.generateToken = generateToken; this.cookie = { ...DEFAULT_COOKIE, ...cookie }; - this.#log = createLoggerWriter(logger); // The injected token carrier; the cookie default keeps the behaviour // wrpc always had, byte for byte. if (transport !== undefined && !isTokenTransport(transport)) { @@ -209,18 +266,60 @@ class SessionManager { this.transport = transport ?? cookieTokenTransport(this); } - #saver(token) { - return (state) => { - Promise.resolve(this.store.set(token, state)).catch((error) => { - this.#log.error({ err: error, event: 'session.save' }); - }); + // One store write per turn, not one per assignment: a handler that sets + // three fields on `session.state` used to cost three round trips to a + // shared store. The first write schedules the save on a microtask, the + // rest of the turn rides along, and a session ended meanwhile is not + // written back — that would resurrect what finalizeSession just deleted. + // + // Every write but the one that creates the row is CONDITIONAL — + // `set(token, state, { create: false })` — and a store answering `false` + // to it is saying the row is gone: a logout on another connection or + // instance landed first, and this write must not undo it (an + // unconditional SET used to resurrect the session, token and all). The + // session ends here then. A store may ignore the option and answer as + // before; only the three built-in ones refuse. + #saver(token, lifecycle, create) { + let pending = false; + let first = create; + const flush = (state) => { + pending = false; + if (lifecycle.ended) return; + const options = first ? CREATE : UPDATE; + first = false; + // A store whose set() throws instead of rejecting would otherwise + // throw out of a microtask — the process, not the session. + let saved; + try { + saved = Promise.resolve(this.store.set(token, state, options)); + } catch (error) { + saved = Promise.reject(error); + } + saved.then( + (result) => { + if (result !== false || lifecycle.ended) return; + lifecycle.ended = true; + this.#log.warn({ event: 'session.save', reason: 'gone' }); + }, + (error) => { + this.#log.error({ err: error, event: 'session.save' }); + }, + ); + }; + return (state, now = false) => { + if (now) return void flush(state); + if (pending) return; + pending = true; + queueMicrotask(() => flush(state)); }; } create(token = this.generateToken(), data = {}) { - const save = this.#saver(token); - save(data); // persist the initial state so restore works immediately - return new Session(token, data, save); + const lifecycle = { ended: false }; + const save = this.#saver(token, lifecycle, true); + save(data, true); // persist the initial state NOW so restore works immediately + this.#otel.recordSession('create', 'ok'); + return new Session(token, data, save, lifecycle); } async restore(token) { @@ -231,15 +330,32 @@ class SessionManager { // Optional and fire-and-forget — a store without touch() keeps absolute // TTLs, which is a valid policy too. if (typeof this.store.touch === 'function') { - Promise.resolve(this.store.touch(token)).catch((error) => { - this.#log.error({ err: error, event: 'session.touch' }); - }); + Promise.resolve(this.store.touch(token)).then( + () => this.#otel.recordSession('touch', 'ok'), + (error) => { + this.#otel.recordSession('touch', 'error'); + this.#log.error({ err: error, event: 'session.touch' }); + }, + ); } - return new Session(token, data, this.#saver(token)); + const lifecycle = { ended: false }; + return new Session(token, data, this.#saver(token, lifecycle, false), lifecycle); } async destroy(token) { - await this.store.delete(token); + // Guarded, and not only for tidiness: `initializeSession` calls + // finalizeSession() through `void`, so a store.delete that rejects had + // nowhere to land and took the process down with an unhandled rejection + // — a Redis blip ending the server rather than one session. Logged + // instead: the in-memory half of the session is already gone, and a + // stored row that outlives it expires on its own TTL. + try { + await this.store.delete(token); + this.#otel.recordSession('destroy', 'ok'); + } catch (error) { + this.#otel.recordSession('destroy', 'error'); + this.#log.error({ err: error, event: 'session.destroy' }); + } } cookieHeader(token) { diff --git a/src/rpc/subscriptions.js b/src/rpc/subscriptions.js index a721799..bb07dce 100644 --- a/src/rpc/subscriptions.js +++ b/src/rpc/subscriptions.js @@ -26,7 +26,7 @@ // needs the same primitive to back `subscription.iterate()`, and it is no // more subscription-specific than Emitter is. const { EventStream, createEventStream } = require('../utils.js'); -const { publicErrorMessage } = require('../transport.js'); +const { publicErrorMessage } = require('./errors.js'); const TRACKED = Symbol.for('wrpc.tracked'); diff --git a/src/scaling/index.js b/src/scaling/index.js index 5c2115b..9e37114 100644 --- a/src/scaling/index.js +++ b/src/scaling/index.js @@ -2,6 +2,7 @@ const { MemoryBackplane, createMemoryBackplane, DEFAULT_PREFIX } = require('./memory.js'); const { createRedisAdapter } = require('./redis.js'); +const { createRedisSessionStore, DEFAULT_SESSION_PREFIX } = require('./sessions.js'); // The structural backplane contract — anything with this shape plugs into // `new Server({ backplane })` / `new RpcServer({ backplane })`: @@ -28,6 +29,8 @@ module.exports = { MemoryBackplane, createMemoryBackplane, createRedisAdapter, + createRedisSessionStore, + DEFAULT_SESSION_PREFIX, isBackplane, DEFAULT_PREFIX, }; diff --git a/src/scaling/redis.js b/src/scaling/redis.js index cc87b22..914678e 100644 --- a/src/scaling/redis.js +++ b/src/scaling/redis.js @@ -57,7 +57,10 @@ const createRedisAdapter = (options = {}) => { let closed = false; const log = createLoggerWriter(logger); - const report = (error) => void log.error({ err: error, component: 'redis' }); + // `event` is on every other entry in the package and is the field the + // logging guide tells operators to filter on; without it these lines + // could not be alerted on alongside the rest. + const report = (error) => void log.error({ err: error, event: 'backplane.redis', component: 'redis' }); const settle = (result) => { if (result && isFunction(result.catch)) result.catch(report); }; diff --git a/src/scaling/sessions.js b/src/scaling/sessions.js new file mode 100644 index 0000000..dd3e38a --- /dev/null +++ b/src/scaling/sessions.js @@ -0,0 +1,102 @@ +'use strict'; + +// A Redis session store, modelled on the ioredis API — `get(key)`, +// `set(key, value, 'PX', ttl)`, `del(key)` and `pexpire(key, ttl)` — for +// the `sessions: { store }` injection. The backplane carries room events +// between instances, not sessions: without a shared store a client that +// reconnects to another instance arrives anonymous, and the load balancer +// has to pin it (docs/guide/scaling.md). With this one, no instance owns a +// session and nothing needs pinning. +// +// Per the zero-dependency rule nothing is required here: the caller injects +// its own client and it is validated structurally, so anything with that +// shape plugs in. +// +// const { createRedisSessionStore } = require('@alexify/wrpc/scaling'); +// const Redis = require('ioredis'); +// new Server({ router, sessions: { store: createRedisSessionStore({ client: new Redis(url) }) } }); +// +// node-redis v4 spells the expiring set differently — `set(key, value, +// { PX: ttl, XX: true })` — so it needs a small wrapper that carries the +// conditions this store passes (PX, and XX for an update): +// const client = { get: (k) => redis.get(k), del: (k) => redis.del(k), +// set: (k, v, ...args) => redis.set(k, v, { +// ...(args[0] === 'PX' ? { PX: args[1] } : {}), ...(args.includes('XX') ? { XX: true } : {}) }), +// pexpire: (k, ttl) => redis.pExpire(k, ttl) }; +// +// State is stored as JSON: a session's state is the plain data the +// application assigns, which is JSON by construction (it crosses the +// Session proxy as own enumerable properties). + +const { createLoggerWriter } = require('../logging.js'); + +const DEFAULT_PREFIX = 'wrpc:session:'; +const DEFAULT_TTL = 24 * 60 * 60 * 1000; // 24h, like MemorySessionStore + +const isFunction = (value) => typeof value === 'function'; + +const checkClient = (client) => { + if (!client || !isFunction(client.get) || !isFunction(client.set) || !isFunction(client.del)) { + throw new TypeError( + 'createRedisSessionStore: options.client must be a Redis client with get(key), set(key, value, ...), del(key)', + ); + } + return client; +}; + +const createRedisSessionStore = (options = {}) => { + const { client, prefix = DEFAULT_PREFIX, ttl = DEFAULT_TTL, logger = globalThis.console } = options; + // This store sits BELOW SessionManager, which never sees a row it + // rejected, so a corrupt entry would otherwise be invisible from above. + const log = createLoggerWriter(logger).child({ component: 'sessions', store: 'redis' }); + checkClient(client); + if (typeof prefix !== 'string') throw new TypeError('createRedisSessionStore: options.prefix must be a string'); + if (!(Number.isFinite(ttl) && ttl >= 0)) throw new TypeError('createRedisSessionStore: options.ttl must be >= 0 ms'); + const key = (token) => prefix + token; + // Sliding expiry needs PEXPIRE; a client without it keeps absolute TTLs, + // which SessionManager treats as a valid policy (no touch()). + const sliding = ttl > 0 && isFunction(client.pexpire); + + const store = { + name: 'redis', + async get(token) { + const raw = await client.get(key(token)); + if (raw === null || raw === undefined) return null; + try { + const data = JSON.parse(raw); + if (typeof data === 'object' && data !== null && !Array.isArray(data)) return data; + log.warn({ event: 'session.corrupt', reason: 'not an object' }); + return null; + } catch (error) { + // A corrupt entry is a missing session, not a thrown request — but + // "missing" here means a signed-in user is silently signed out, and + // that is worth one line. The token is NOT logged: it is the + // credential, and a log pipeline is not where credentials go. + log.warn({ err: error, event: 'session.corrupt' }); + return null; + } + }, + // `create: false` (every write after the first) is SET … XX: a row that + // is gone — a logout on another instance — is not brought back, and + // Redis's null answer becomes the false the session manager reads. + async set(token, data, { create = true } = {}) { + const value = JSON.stringify(data); + const args = []; + if (ttl > 0) args.push('PX', ttl); + if (!create) args.push('XX'); + const result = await client.set(key(token), value, ...args); + return create || (result !== null && result !== undefined); + }, + async delete(token) { + await client.del(key(token)); + }, + }; + if (sliding) { + store.touch = async (token) => { + await client.pexpire(key(token), ttl); + }; + } + return store; +}; + +module.exports = { createRedisSessionStore, DEFAULT_SESSION_PREFIX: DEFAULT_PREFIX }; diff --git a/src/sequencer.js b/src/sequencer.js new file mode 100644 index 0000000..8edb8b6 --- /dev/null +++ b/src/sequencer.js @@ -0,0 +1,100 @@ +'use strict'; + +// A leaf on purpose: the per-message codecs (src/compression/) and the +// encryption of @alexify/wrpc/encryption both answer promises in a browser +// — CompressionStream and crypto.subtle can only — and both need their +// messages kept in order around that. An entry that wants the ordering +// should not pay for the other's negotiation, so the class lives here and +// compression/index.js re-exports it. + +const { isPromise } = require('./compression/ids.js'); + +const RESOLVED = Promise.resolve(); +const noop = () => {}; + +// How many asynchronous decodes a transport starts before it holds the +// rest back — a thunk in its slot, a paused reader (WebTransport): a burst +// of compressed frames used to start an inflate per frame at once. +const INFLIGHT_LIMIT = 4; + +/** + * Keeps messages in order around a codec that may answer asynchronously. + * `push(value, deliver, recover)`: `value` is the message, a promise of + * it, or a THUNK producing either — called at once with nothing in + * flight, in its slot otherwise, which is how a consumer starts no more + * work than it means to have in flight; `deliver` runs with the settled + * value in push order; `recover` runs with the error when the promise + * rejected or the thunk threw (send it plain, hang up). A push with + * nothing in flight and a plain value delivers synchronously — the common + * path costs no promise — and once something is in flight every later + * push waits behind it, plain or not, which is what keeps the wire + * ordered. Errors thrown by deliver/recover go to `onError`. + */ +class Sequencer { + #tail = null; + #pending = 0; + #onError; + + constructor(onError = null) { + this.#onError = onError; + } + + /** Pushes waiting on an earlier one; 0 when nothing is in flight. */ + get pending() { + return this.#pending; + } + + /** Settles once everything pushed so far has been delivered. */ + get idle() { + return this.#tail ?? RESOLVED; + } + + push(value, deliver, recover) { + let later = typeof value === 'function'; + if (later && this.#tail === null) { + // Nothing in flight: called now, and its answer takes the plain path. + try { + value = value(); + } catch (error) { + return void this.#recover(recover, error); + } + later = false; + } + const async = later || isPromise(value); + if (this.#tail === null && !async) return void deliver(value); + // A promise queued behind another: a rejection before its turn is not + // an unhandled one — it is handled in its slot, by recover. (With + // nothing in flight the slot runs now, and the handler is on in time.) + if (!later && async && this.#tail !== null) value.then(undefined, noop); + this.#pending++; + const run = () => { + if (!later) return async ? value.then(deliver, recover) : deliver(value); + let result; + try { + result = value(); + } catch (error) { + if (recover) return void recover(error); + throw error; + } + return isPromise(result) ? result.then(deliver, recover) : deliver(result); + }; + const previous = this.#tail; + const step = previous === null ? new Promise((resolve) => resolve(run())) : previous.then(run, run); + const settled = () => { + this.#pending--; + if (this.#tail === done) this.#tail = null; + }; + const done = step.then(settled, (error) => { + settled(); + if (this.#onError) this.#onError(error); + }); + this.#tail = done; + } + + #recover(recover, error) { + if (recover) recover(error); + else if (this.#onError) this.#onError(error); + } +} + +module.exports = { Sequencer, INFLIGHT_LIMIT }; diff --git a/src/server.js b/src/server.js index 52e7762..a73cac5 100644 --- a/src/server.js +++ b/src/server.js @@ -6,7 +6,14 @@ const https = require('node:https'); const { Emitter } = require('./utils.js'); const { RpcServer, rpcOptions } = require('./rpc/core.js'); const { createNodeEngine, isEngine } = require('./engine/index.js'); -const { receiveBody, nodeStream, createUpgradeGate, respondBodyError, MAX_BODY_SIZE } = require('./adapters/common.js'); +const { + receiveBody, + nodeStream, + createUpgradeGate, + revisionProtocols, + respondBodyError, + MAX_BODY_SIZE, +} = require('./adapters/common.js'); const { createLoggerWriter } = require('./logging.js'); const DEFAULT_LISTEN_RETRY = 3; @@ -82,6 +89,10 @@ class Server extends Emitter { return this.rpc.getClient(id); } + sendTo(clientId, name, data, options) { + return this.rpc.sendTo(clientId, name, data, options); + } + #onConnection(socket, req) { this.rpc.attachSocket(socket, { headers: req.headers, @@ -94,7 +105,12 @@ class Server extends Emitter { // and hands the core's HTTP entry point to the engine instead. #initStandalone(wsOptions, cors) { this.wsServer = this.#engine.attach({ + // The engine and the connections under it report through the same + // writer as everything else, so a framing failure or a dropped frame + // lands in the operator's stream rather than in an unlistened 'error'. + logger: this.#log, ...wsOptions, + ...revisionProtocols(this.rpc, wsOptions), verifyClient: createUpgradeGate({ rpc: this.rpc, cors, ws: wsOptions }), onHttpCall: (call) => this.rpc.handleHttpCall(call), }); @@ -117,7 +133,13 @@ class Server extends Emitter { }); const verifyClient = createUpgradeGate({ rpc: this.rpc, cors, ws: wsOptions }); - this.wsServer = this.#engine.attach({ server: this.httpServer, ...wsOptions, verifyClient }); + this.wsServer = this.#engine.attach({ + logger: this.#log, + server: this.httpServer, + ...wsOptions, + ...revisionProtocols(this.rpc, wsOptions), + verifyClient, + }); this.wsServer.on('connection', (socket, req) => { this.#onConnection(socket, req); }); diff --git a/src/sse/client.js b/src/sse/client.js index 83a69ef..c9c12f0 100644 --- a/src/sse/client.js +++ b/src/sse/client.js @@ -1,6 +1,7 @@ 'use strict'; const { WrpcClient, ClientTransport, metaHeaders } = require('../client.js'); +const { refusedStatus } = require('../client/core.js'); const { CHANNEL_HEADER } = require('./constants.js'); // The client half of the SSE transport. Browser-safe: `fetch`, streams and @@ -91,10 +92,23 @@ class SseParser { const joinUrl = (base, path) => (base.endsWith('/') ? base.slice(0, -1) : base) + path; class ClientSseTransport extends ClientTransport { + // Carries `options.encryption`, per request like http — see open(). + // Sealed per request, like http: needs the option's `fetch`. + static encrypts = 'request'; + // The stream can die without a close frame just like a socket can. heartbeat = true; + // Revision 1 against any server: an event stream carries no frame, so a + // packet holding bytes leaves as the JSON 1.0 made of it. + revision = 1; + #controller = null; + // The channel reference, exactly as the `ready` frame gave it: presented + // again on every POST and re-attach, forgotten with the channel. Opaque + // on purpose — a 2.x server puts the channel's secret after its id there + // (`.`), a 1.0 server the id alone, and this side has no + // reason to tell them apart. #channel = null; #lastEventId = null; #parser = null; @@ -104,6 +118,11 @@ class ClientSseTransport extends ClientTransport { // headers on both the stream GET and every packet POST. #headers = null; #meta = null; + // Injectable fetch, resolved per open like headers/meta. Always CALLED + // unbound (never as this.#fetch(...) via a stored `this`) — native fetch + // throws "Illegal invocation" in browsers when invoked with a receiver + // other than the global object, which a plain method call would hand it. + #fetch = null; get eventsUrl() { return joinUrl(this.url, '/events'); @@ -114,6 +133,17 @@ class ClientSseTransport extends ClientTransport { // A header BLOCK, not one encoded value: the client's metaFormat picks // the spelling, both legs below just spread the result. this.#meta = options.meta ? metaHeaders(options.meta, options.metaPrefixed) : null; + this.#fetch = options.fetch ?? globalThis.fetch; + // Session encryption has no session here: every request — the stream + // GET and each packet POST — is sealed on its own (HPKE), and the + // stream comes back sealed frame by frame, all inside a wrapped fetch. + const { encryption = null } = options; + if (encryption !== null) { + if (typeof encryption.fetch !== 'function') { + throw new TypeError('options.encryption has no serverKey to seal a request to — the sse transport needs one'); + } + this.#fetch = encryption.fetch(this.#fetch, this.url); + } if (this.active) return; if (this.#reading) return this.#reading; const opening = this.#open(true); @@ -135,7 +165,8 @@ class ClientSseTransport extends ClientTransport { // replays what this channel did not acknowledge. if (this.#channel !== null) headers[CHANNEL_HEADER] = this.#channel; if (this.#lastEventId !== null) headers['last-event-id'] = this.#lastEventId; - const response = await fetch(this.eventsUrl, { headers, signal: controller.signal, cache: 'no-store' }); + const doFetch = this.#fetch; + const response = await doFetch(this.eventsUrl, { headers, signal: controller.signal, cache: 'no-store' }); // 409: the channel this transport remembers no longer exists. Its // replay state is worthless now — drop it and start a fresh channel; // the WrpcClient above re-loads and re-subscribes on 'open'/'reconnect'. @@ -199,7 +230,8 @@ class ClientSseTransport extends ClientTransport { // client above re-loads and re-subscribes, and each subscription // resumes (or honestly refuses to) from its own lastEventId. Said out // loud: this is event loss, not routine reconnection. - this.log?.warn({ event: 'sse.gap', channel: this.#channel }); + // Without the channel: the reference holds its credential. + this.log?.warn({ event: 'sse.gap' }); this.#channel = null; this.#lastEventId = null; this.close(); @@ -207,7 +239,8 @@ class ClientSseTransport extends ClientTransport { } if (event.event === 'ready') { const ready = JSON.parse(event.data); - // The server mints the id; this frame is the only place it is learned. + // The server mints the reference; this frame is the only place it + // is learned. if (ready.channel) this.#channel = ready.channel; if (this.#onReady) { this.#onReady(); @@ -238,14 +271,18 @@ class ClientSseTransport extends ClientTransport { write(data) { if (!this.active || this.#channel === null) throw new Error('Not connected'); + // Only a binary packet codec gets here with bytes: revision 1 sends a + // packet's bytes as JSON. + if (typeof data !== 'string') throw new TypeError('SSE carries text only: a binary codec needs a WebSocket'); const headers = { ...this.#headers, ...this.#meta, 'Content-Type': this.codec?.contentType ?? 'application/json', [CHANNEL_HEADER]: this.#channel, }; + const doFetch = this.#fetch; const post = async () => { - const response = await fetch(this.url, { method: 'POST', headers, body: data }); + const response = await doFetch(this.url, { method: 'POST', headers, body: data }); // 202 is the expected answer: everything a call produces comes back // on the event stream, not in this response. if (response.status === 202) return void (await response.body?.cancel?.()); @@ -264,7 +301,11 @@ class ClientSseTransport extends ClientTransport { }; post().catch((error) => { this.emit('error', error); - this.failPackets(data, 503); + // A sealed POST refused in plaintext: the sealing layer's closed set + // of statuses (400, 409, 426) reaches the calls, a 409 from it never + // closes the channel (that branch above is the plain transport's + // "channel gone"), anything else is a 503. + this.failPackets(data, refusedStatus(error)); }); return true; } diff --git a/src/sse/constants.js b/src/sse/constants.js index b442597..73fc596 100644 --- a/src/sse/constants.js +++ b/src/sse/constants.js @@ -6,4 +6,23 @@ // The header a POST (or a re-attaching GET) names its channel with. const CHANNEL_HEADER = 'x-wrpc-channel'; -module.exports = { CHANNEL_HEADER }; +// What the header carries: `.` — the channel id the server +// minted (whatever the application's generator makes of it: a uuid, a +// cuid, a counter) and the secret it drew for the channel, both handed out +// in the `ready` frame. Split at the LAST dot: the secret is base64url and +// holds none, the id may hold anything. A value without a dot is an id +// alone — what a client that predates the secret sends — and presents no +// secret. One header rather than two, so a cross-origin deployment's +// `Access-Control-Allow-Headers` stays what it was. +const CHANNEL_SEPARATOR = '.'; + +const joinChannelRef = (id, secret) => (secret ? `${id}${CHANNEL_SEPARATOR}${secret}` : id); + +const splitChannelRef = (value) => { + if (typeof value !== 'string' || value.length === 0) return null; + const dot = value.lastIndexOf(CHANNEL_SEPARATOR); + if (dot === -1) return { id: value, secret: '' }; + return { id: value.slice(0, dot), secret: value.slice(dot + 1) }; +}; + +module.exports = { CHANNEL_HEADER, joinChannelRef, splitChannelRef }; diff --git a/src/sse/server.js b/src/sse/server.js index 2962db6..87b35ed 100644 --- a/src/sse/server.js +++ b/src/sse/server.js @@ -1,29 +1,44 @@ 'use strict'; +const { randomBytes, timingSafeEqual } = require('node:crypto'); const { ServerTransport } = require('../transport.js'); -const { generateUUID } = require('../runtime/node.js'); +const { resolveGenerateId } = require('../utils.js'); const { createLoggerWriter } = require('../logging.js'); const { UNKNOWN_TARGET } = require('../rpc/dispatcher.js'); +const { normalizeCompression, chooseEncoding, markEncoded, encodedWriter } = require('../contentEncoding.js'); +const { createFailureReporter } = require('../compression/sync.js'); // Server-Sent Events as a wrpc transport. // // SSE is one-way, so a channel is two halves that find each other by id: -// GET {basePath}/events?channel= the server -> client stream -// POST {basePath} + x-wrpc-channel: the client -> server direction +// GET {basePath}/events?channel=. the server -> client stream +// POST {basePath} + x-wrpc-channel: . the client -> server direction // Both halves belong to ONE server-side Client, which is what lets a // subscription started by a POST deliver its values down the GET stream. // The POST answers 202 with no body; every reply — callbacks included — // comes back on the stream, exactly like the Service Worker port transport. // +// The id IDENTIFIES the channel and comes from the application's +// generator (a uuid, a cuid, a counter — the format is its business, and +// one `generateId` covers every id the server mints). What AUTHORIZES a +// request to act on the channel is the secret: 18 random bytes the server +// draws per channel and hands out with the id in the `ready` frame, which +// every re-attach and POST must present again (constants.js has the wire +// form). A channel opened without a cookie or a bearer used to be held by +// its id alone — a guessable generator was a hijack. +// // Serverless-friendly by construction: no upgrade, no long-lived socket // beyond the response body, and nothing but HTTP in either direction. // // What it cannot do is binary: SSE frames are text, so wrpc's binary streams // are refused on this transport rather than silently corrupted. -const { CHANNEL_HEADER } = require('./constants.js'); +const { CHANNEL_HEADER, joinChannelRef } = require('./constants.js'); const DEFAULT_RETENTION = 30 * 1000; +// The channel secret: 18 bytes is 24 base64url characters, 144 bits — no +// padding, no dot, and beyond guessing at any request rate. +const SECRET_BYTES = 18; const DEFAULT_REPLAY = 100; // The replay buffer is per-channel memory held for `retention` even with no // stream attached — a byte budget caps it where a frame-count cap cannot @@ -63,6 +78,11 @@ class ServerSseTransport extends ServerTransport { super(remoteAddress); this.channelId = channelId; this.connection = this; // what Client.persistent checks + // Revision 1, to every client: an event stream carries text, and a 1.0 + // client reads it — with nothing on the GET to tell the two apart. A + // packet holding bytes travels as the JSON 1.0 made of it, both ways. + this.revision = 1; + this.attachments = false; } get attached() { @@ -128,8 +148,11 @@ class ServerSseTransport extends ServerTransport { // channel id alone is a bearer token for a session-carrying Client, and ids // travel in URLs, which proxies and logs keep. class SseChannel { - constructor({ id, key = '', client, transport, replay, replayBytes, retention }) { + constructor({ id, secret = '', key = '', client, transport, replay, replayBytes, retention }) { this.id = id; + // The credential (server-drawn); `key` is the identity the opening GET + // presented, a second lock for a channel opened with one. + this.secret = secret; this.key = key; this.client = client; this.transport = transport; @@ -207,9 +230,30 @@ class SseChannels { #clientAddress; #log; #otel; - - constructor({ addClient, channelKey = null, log = globalThis.console, otel = null, ...options } = {}) { + #generateId; + // The normalized `sse.compression` option, or null (off, the default): + // one gzip member per response, flushed after every event. + #compression = null; + // An encoder stream that failed, counted and said once per coding. + #failed; + + constructor({ + addClient, + channelKey = null, + log = globalThis.console, + otel = null, + generateId = null, + compression = null, + ...options + } = {}) { + this.#compression = normalizeCompression(compression, 'SseChannels: options', { streaming: true }); + this.#failed = createFailureReporter(createLoggerWriter(log), otel); this.#addClient = addClient; + // Strict, unlike the 1.0 options: this one is new, so a bad generator + // is a TypeError here rather than a channel id that fails later. + // The core passes its own, so one `generateId` on the server covers + // channel ids too. + this.#generateId = resolveGenerateId(generateId, 'SseChannels').generate; // Extracts the identity a request presents (the session token from its // cookie); injected by the core, which owns the SessionManager. Without // one every request presents the same identity and only the unguessable @@ -244,7 +288,22 @@ class SseChannels { return this.#channels.get(channelId) ?? null; } - /** True when `headers` present the identity the channel was created under. */ + /** + * True when `secret` is the channel's — the credential handed out in its + * `ready` frame. Constant-time, and a missing or wrong one is answered + * exactly like an unknown channel (409): the id's existence is not told. + */ + holds(channel, secret) { + if (typeof secret !== 'string' || secret.length === 0) return false; + // Lengths compared as BYTES: `timingSafeEqual` throws on a mismatch, and + // a peer's non-ASCII secret is as long in characters as the real one + // while longer in bytes. + const given = Buffer.from(secret); + const expected = Buffer.from(channel.secret); + return given.length === expected.length && timingSafeEqual(given, expected); + } + + /** True when `headers` present the identity the channel was created under (checked after `holds`). */ authorized(channel, requestHeaders) { return channel.key === this.#channelKey(requestHeaders ?? {}); } @@ -266,15 +325,16 @@ class SseChannels { * stream gets an honest 501 instead of a response that never arrives. * * Channel ids are SERVER-minted: a request without one gets a fresh - * channel and learns the id from the `ready` frame. A request naming an - * id re-attaches to that channel — 409 when it is unknown (expired, - * another instance, or a guess), 403 when the request's cookie does not - * present the identity the channel was created under. + * channel and learns the id and the secret from the `ready` frame. A + * request naming an id re-attaches to that channel — 409 when it is + * unknown (expired, another instance, a guess) or its `secret` is not the + * channel's, 403 when the request's cookie does not present the identity + * the channel was created under. * * `headers` are RESPONSE headers (CORS and the rest); the REQUEST headers a * new channel's client is built from come off `call` itself. */ - open(call, { channelId = null, lastEventId = null, headers = {} } = {}) { + open(call, { channelId = null, secret = '', lastEventId = null, headers = {} } = {}) { if (typeof call.stream !== 'function') { return void call.respond({ status: 501, @@ -285,9 +345,11 @@ class SseChannels { }); } const existing = channelId ? this.#channels.get(channelId) : null; - if (channelId && !existing) { + if (channelId && (!existing || !this.holds(existing, secret))) { // Distinguishable from every other refusal: the client reacts to 409 - // by dropping its stale channel state and starting a fresh one. + // by dropping its stale channel state and starting a fresh one. The + // same answer for a channel that exists but whose secret was not + // presented: a guessed id learns nothing. return void this.#refuse(call, headers, 409, 'Unknown channel'); } if (existing && !this.authorized(existing, call.headers)) { @@ -302,7 +364,14 @@ class SseChannels { clearTimeout(channel.timer); channel.timer = null; } - const writer = call.stream({ status: 200, headers: { ...headers, ...SSE_HEADERS } }); + // Encoded when the option is on and this GET asked for it: the decision + // is per response, so a re-attach from a peer that stopped accepting + // the coding gets a plain stream on the same channel. + const streamHeaders = { ...headers, ...SSE_HEADERS }; + const compression = this.#compression; + const encoder = compression === null ? null : chooseEncoding(compression, call, streamHeaders); + if (encoder !== null) markEncoded(streamHeaders, encoder.token); + let writer = call.stream({ status: 200, headers: streamHeaders }); if (!writer) { // The host could not open the stream. A channel created for it would // sit there with no writer, no retention timer and no close listener — @@ -310,6 +379,9 @@ class SseChannels { if (!existing) channel.transport.close(); return; } + if (encoder !== null) { + writer = encodedWriter(writer, encoder, (error) => this.#failed('sse', 'encode', encoder.token, error)); + } // Replacing a live writer: the superseded response is nobody's now, so // end it rather than leaking it open until a proxy times it out. const previous = channel.writer; @@ -325,10 +397,16 @@ class SseChannels { // The response's own high-water mark is the channel's backpressure, so // the subscription pump waits on it exactly as it does on a socket. writer.onDrain?.(() => channel.transport.emit('drain')); - // The client needs its channel id before it can POST anything, and this - // frame is the ONLY place the server hands the id out. + // The client needs its channel reference before it can POST anything, + // and this frame is the ONLY place the server hands it out (again on a + // re-attach: the frame's shape is one, and the client that re-attached + // already holds it). ONE opaque string — `.` — in the field + // 1.0 named `channel`: a 1.0 client presents whatever it was given + // there, so it presents the secret without knowing there is one, and + // the credential costs no revision of the protocol. writer.write(`retry: ${this.#options.retry}\n\n`); - writer.write(`event: ready\ndata: ${JSON.stringify({ channel: channel.id })}\n\n`); + const ref = joinChannelRef(channel.id, channel.secret); + writer.write(`event: ready\ndata: ${JSON.stringify({ channel: ref })}\n\n`); this.#otel?.recordSseEvent(existing ? 'reattach' : 'open'); if (existing && lastEventId !== null) { const outcome = channel.resume(lastEventId); @@ -359,11 +437,13 @@ class SseChannels { // The GET that opens a channel is the channel's only handshake, so its // headers are handed on: they carry the cookie the client's session is // restored from, exactly as an upgrade's headers do for a socket. The id - // is minted here — never taken from the request — so holding one proves - // the server said it, and the cookie's token is captured as the key every - // later request must present again. + // is minted here — never taken from the request — and the secret drawn + // here; holding the secret is what proves a later request is the + // channel's, and the cookie's token is captured as the key it must + // present again on top. #create(call) { - const channelId = generateUUID(); + const channelId = this.#generateId(); + const secret = randomBytes(SECRET_BYTES).toString('base64url'); // The capacity key comes from the seam; the transport keeps the raw // TCP peer, which is what client.meta reports. const address = this.#clientAddress(call); @@ -372,7 +452,7 @@ class SseChannels { // the client's meta (headers, url, remoteAddress) from it too. const client = this.#addClient(transport, call); const key = this.#channelKey(call.headers ?? {}); - const channel = new SseChannel({ id: channelId, key, client, transport, ...this.#options }); + const channel = new SseChannel({ id: channelId, secret, key, client, transport, ...this.#options }); this.#channels.set(channelId, channel); this.#byAddress.set(address, (this.#byAddress.get(address) ?? 0) + 1); // Every teardown path — retention timeout, close(), a dead response — diff --git a/src/status.js b/src/status.js new file mode 100644 index 0000000..9e45d94 --- /dev/null +++ b/src/status.js @@ -0,0 +1,80 @@ +'use strict'; + +// The HTTP reason phrases, as a plain frozen table instead of +// require('node:http').STATUS_CODES. Every module that only ever wanted the +// status LINE (transport.js, rpc/client.js, adapters/common.js) reads it from +// here, which is what lets the server-side dispatcher bundle for a browser: +// the peer-to-peer transport (src/webrtc/) runs the dispatcher in a page, +// and node:http was its only blocker. Deliberately NOT in src/runtime/ — the +// runtime pair ships in the main browser entry, whose byte budget is full; +// this file reaches a bundle only through the modules above. +// +// Generated from node:http's table (Node 24); tests/status.test.js keeps the +// two in sync. +const STATUS_CODES = Object.freeze({ + 100: 'Continue', + 101: 'Switching Protocols', + 102: 'Processing', + 103: 'Early Hints', + 200: 'OK', + 201: 'Created', + 202: 'Accepted', + 203: 'Non-Authoritative Information', + 204: 'No Content', + 205: 'Reset Content', + 206: 'Partial Content', + 207: 'Multi-Status', + 208: 'Already Reported', + 226: 'IM Used', + 300: 'Multiple Choices', + 301: 'Moved Permanently', + 302: 'Found', + 303: 'See Other', + 304: 'Not Modified', + 305: 'Use Proxy', + 307: 'Temporary Redirect', + 308: 'Permanent Redirect', + 400: 'Bad Request', + 401: 'Unauthorized', + 402: 'Payment Required', + 403: 'Forbidden', + 404: 'Not Found', + 405: 'Method Not Allowed', + 406: 'Not Acceptable', + 407: 'Proxy Authentication Required', + 408: 'Request Timeout', + 409: 'Conflict', + 410: 'Gone', + 411: 'Length Required', + 412: 'Precondition Failed', + 413: 'Payload Too Large', + 414: 'URI Too Long', + 415: 'Unsupported Media Type', + 416: 'Range Not Satisfiable', + 417: 'Expectation Failed', + 418: "I'm a Teapot", + 421: 'Misdirected Request', + 422: 'Unprocessable Entity', + 423: 'Locked', + 424: 'Failed Dependency', + 425: 'Too Early', + 426: 'Upgrade Required', + 428: 'Precondition Required', + 429: 'Too Many Requests', + 431: 'Request Header Fields Too Large', + 451: 'Unavailable For Legal Reasons', + 500: 'Internal Server Error', + 501: 'Not Implemented', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout', + 505: 'HTTP Version Not Supported', + 506: 'Variant Also Negotiates', + 507: 'Insufficient Storage', + 508: 'Loop Detected', + 509: 'Bandwidth Limit Exceeded', + 510: 'Not Extended', + 511: 'Network Authentication Required', +}); + +module.exports = { STATUS_CODES }; diff --git a/src/streams.js b/src/streams.js index e0abca8..2597dfe 100644 --- a/src/streams.js +++ b/src/streams.js @@ -141,25 +141,34 @@ class WrpcWritable extends Emitter { #waitingDrain = false; #closed = false; #ended = false; + #onClose = null; - constructor(id, name, size, transport) { + constructor(id, name, size, transport, otel = null) { super(); this.id = id; this.name = name; this.size = size; this.transport = transport; + // Null on the client, where there is no server-side byte counter to + // feed and the bytes would only cost the browser bundle. On the server + // it is the writer, so `wrpc.server.stream.bytes` finally has a `send` + // direction — the attribute existed from the start with exactly one + // value ever recorded, which made it a lie. + this.otel = otel; // Armed HERE, not lazily inside the backpressure branch: a stream that // never happened to stall used to learn nothing when the transport // closed — `closed` stayed false and a producer loop kept writing into // the void. Now every writable notices the disconnect, backpressured - // or not. + // or not — and lets go of the transport once finished (#detach). if (typeof transport.once === 'function') { - transport.once('close', () => { + this.#onClose = () => { + this.#onClose = null; if (this.#closed) return; this.#closed = true; this.#releaseDrain(); this.emit('close'); - }); + }; + transport.once('close', this.#onClose); } this.init(); } @@ -171,9 +180,21 @@ class WrpcWritable extends Emitter { #releaseDrain() { if (!this.#waitingDrain) return; this.#waitingDrain = false; + this.#detach(); this.emit('drain'); } + // The transport outlives its streams — a client's for every reconnect — + // so a listener left on it per stream piled up for the connection's whole + // life. Taken off once the stream is finished: ended, and no 'drain' still + // owed to a producer, which only the close listener could release on a + // transport that dies first. + #detach() { + if (!this.#ended || this.#waitingDrain || this.#onClose === null) return; + this.transport.off?.('close', this.#onClose); + this.#onClose = null; + } + init() { const { id, name, size } = this; const packet = { type: 'stream', id, name, size }; @@ -189,6 +210,7 @@ class WrpcWritable extends Emitter { write(data) { if (this.#closed || this.#ended) return false; const chunk = chunkEncode(this.id, data); + this.otel?.recordStreamBytes('send', chunk.byteLength ?? chunk.length); const accepted = this.transport.write(chunk) !== false; if (!accepted && !this.#waitingDrain && typeof this.transport.once === 'function') { this.#waitingDrain = true; @@ -204,6 +226,7 @@ class WrpcWritable extends Emitter { this.#ended = true; const packet = { type: 'stream', id: this.id, status: 'end' }; this.transport.send(packet); + this.#detach(); } terminate() { @@ -211,6 +234,7 @@ class WrpcWritable extends Emitter { this.#ended = true; const packet = { type: 'stream', id: this.id, status: 'terminate' }; this.transport.send(packet); + this.#detach(); } } diff --git a/src/telemetry/client.js b/src/telemetry/client.js index 466b96d..8ba1201 100644 --- a/src/telemetry/client.js +++ b/src/telemetry/client.js @@ -42,6 +42,8 @@ const createClientTelemetry = (telemetry) => { let duration = null; let reconnects = null; let connections = null; + let heartbeat = null; + let calls = null; if (hasMethod(meter, 'createHistogram') && hasMethod(meter, 'createCounter')) { try { duration = meter.createHistogram('rpc.client.duration', { @@ -52,9 +54,28 @@ const createClientTelemetry = (telemetry) => { unit: '{attempt}', description: 'Reconnect attempts, by outcome', }); + // The app-level ping/pong pair is an exact round trip the client + // already measures the ends of — the one true latency signal it has + // that does not need a call to produce it, and the only one that + // keeps reporting while the application is idle. + heartbeat = meter.createHistogram('wrpc.client.heartbeat.rtt', { + unit: 'ms', + description: 'Round-trip time of the app-level ping/pong', + }); + // `rpc.client.duration` alone could not answer "what fraction of my + // calls fail": a histogram records nothing for a call whose elapsed + // time is unknown, so error RATE was not derivable from it. + calls = meter.createCounter('wrpc.client.calls', { + unit: '{call}', + description: 'RPC calls made, by outcome', + }); } catch { + // Every instrument of this block, including the new ones: a meter + // whose factory throws must leave no half-initialized writer behind. duration = null; reconnects = null; + heartbeat = null; + calls = null; } } let refreshes = null; @@ -130,10 +151,22 @@ const createClientTelemetry = (telemetry) => { }, recordCall(target, status, elapsed) { + const attributes = { 'rpc.system': 'wrpc', ...targetAttributes(target), 'wrpc.status': status }; + try { + calls?.add(1, attributes); + } catch {} + if (elapsed === undefined) return; + try { + duration?.record(elapsed, attributes); + } catch {} + }, + + // `outcome` is 'ok' for a pong that answered our ping, 'timeout' for one + // that never came. A timeout records no sample: there is no round trip + // to measure, and a made-up one would poison the percentiles. + recordHeartbeat(outcome, rtt) { try { - if (elapsed !== undefined) { - duration?.record(elapsed, { 'rpc.system': 'wrpc', ...targetAttributes(target), 'wrpc.status': status }); - } + if (outcome === 'ok') heartbeat?.record(rtt); } catch {} }, diff --git a/src/telemetry/server.js b/src/telemetry/server.js index aac2f22..c7dc1cb 100644 --- a/src/telemetry/server.js +++ b/src/telemetry/server.js @@ -1,8 +1,9 @@ 'use strict'; // The server half: spans for calls, subscriptions and inbound events, plus -// the twelve instruments a server has anything to say about. Node-only — -// nothing browser-reachable requires this file. +// the instruments a server has anything to say about. Browser-reachable +// through PeerHost (the webrtc browser entry), so it counts against that +// entry's budget in scripts/size.js. const { SPAN_KIND_SERVER, @@ -36,6 +37,10 @@ const buildCallAttributes = (client, packet, target, includeIdentity) => { // A session TOKEN never appears at any setting: that is a credential, not // an identity, and the two must not share one switch. if (includeIdentity && client.source) attributes['network.peer.address'] = client.source; + // A host-built client (a broker binding) adds its own semantic + // attributes — `messaging.*` for a consumed message. + const extra = client.spanAttributes; + if (extra) for (const key in extra) attributes[key] = extra[key]; return attributes; }; @@ -68,12 +73,29 @@ const createServerTelemetry = (telemetry) => { let recipients = null; let streamBytes = null; let backpressure = null; + let backplaneGaps = null; let sessions = null; let sseChannels = null; let clusterMessages = null; let clusterRequests = null; let clusterInstances = null; let sseEvents = null; + let rtcLinks = null; + let rtcRedials = null; + let rtcRestarts = null; + let rtcCloses = null; + let brokerDeliveries = null; + let brokerPublished = null; + let brokerRefused = null; + let brokerAttempts = null; + let brokerSessionEnds = null; + let queueWait = null; + let clusterVerifications = null; + let rtcAssertions = null; + let encryption = null; + let compressionFailures = null; + let rooms = null; + let queueDepth = null; // Checked separately from the others: a meter with counters and histograms // but no up/down counter would otherwise disable every instrument here. @@ -105,6 +127,10 @@ const createServerTelemetry = (telemetry) => { unit: 'By', description: 'Binary stream bytes, by direction', }); + backplaneGaps = meter.createCounter('wrpc.server.backplane.gaps', { + unit: '{envelope}', + description: 'Backplane envelopes a publisher sent that this instance never received', + }); backpressure = meter.createCounter('wrpc.server.backpressure', { unit: '{event}', description: 'Times a producer parked waiting for the transport to drain', @@ -121,10 +147,71 @@ const createServerTelemetry = (telemetry) => { unit: '{request}', description: 'Cluster requests settled, by op and completeness', }); + rtcRedials = meter.createCounter('wrpc.rtc.redials', { + unit: '{attempt}', + description: 'Redials (initiator) and knocks (responder) after a peer link failed, by role', + }); + rtcRestarts = meter.createCounter('wrpc.rtc.ice_restarts', { + unit: '{restart}', + description: 'ICE restarts on peer links, by outcome', + }); + rtcCloses = meter.createCounter('wrpc.rtc.closes', { + unit: '{link}', + description: 'Peer links closed, by reason and by which side closed', + }); sseEvents = meter.createCounter('wrpc.server.sse.events', { unit: '{event}', description: 'SSE channel lifecycle events (open/reattach/replay/gap/expired)', }); + brokerDeliveries = meter.createCounter('wrpc.broker.deliveries', { + unit: '{message}', + description: 'Broker messages consumed into procedures, by broker and settlement', + }); + brokerPublished = meter.createCounter('wrpc.broker.published', { + unit: '{message}', + description: 'Messages published to a broker, by broker and outcome', + }); + // What a sealed topic refused, by reason: a rising `kid` series during + // a rotation is a fleet that dropped a key before its backlog drained. + brokerRefused = meter.createCounter('wrpc.broker.refused', { + unit: '{message}', + description: 'Sealed broker messages a feed or a consumer could not open, by broker and reason', + }); + // A histogram, never an attribute on the delivery counter: the attempt + // number is unbounded, and a counter series per value is a cardinality + // bomb. Redelivery depth was unmeasurable — the number was available + // on every delivery and simply never recorded. + brokerSessionEnds = meter.createCounter('wrpc.broker.rpc.session.ends', { + unit: '{session}', + description: 'Broker RPC sessions ended, by reason', + }); + brokerAttempts = meter.createHistogram('wrpc.broker.delivery.attempts', { + unit: '{attempt}', + description: 'How many deliveries a message took to settle', + }); + // Time a call spent waiting for a queue slot, which `rpc.server.duration` + // folds in invisibly — so a saturated queue is indistinguishable from + // a slow handler, which are opposite problems with opposite fixes. + queueWait = meter.createHistogram('wrpc.server.queue.wait', { + unit: 'ms', + description: 'Time a call waited for a concurrency slot', + }); + clusterVerifications = meter.createCounter('wrpc.cluster.verifications', { + unit: '{envelope}', + description: 'Backplane envelope authentication outcomes', + }); + rtcAssertions = meter.createCounter('wrpc.rtc.assertions', { + unit: '{assertion}', + description: 'Peer trust assertion verification outcomes', + }); + compressionFailures = meter.createCounter('wrpc.compression.failures', { + unit: '{message}', + description: 'Messages a compression codec failed on, by carrier and direction', + }); + encryption = meter.createCounter('wrpc.server.encryption', { + unit: '{handshake}', + description: 'Session handshakes established and sealed requests refused, by kind and outcome', + }); } catch { duration = null; calls = null; @@ -133,10 +220,23 @@ const createServerTelemetry = (telemetry) => { recipients = null; streamBytes = null; backpressure = null; + backplaneGaps = null; sessions = null; clusterMessages = null; clusterRequests = null; sseEvents = null; + rtcRedials = null; + rtcRestarts = null; + brokerDeliveries = null; + brokerPublished = null; + brokerRefused = null; + brokerAttempts = null; + brokerSessionEnds = null; + queueWait = null; + clusterVerifications = null; + rtcAssertions = null; + encryption = null; + compressionFailures = null; } } if (canGauge) { @@ -153,15 +253,33 @@ const createServerTelemetry = (telemetry) => { unit: '{channel}', description: 'Live SSE channels', }); + rtcLinks = meter.createUpDownCounter('wrpc.rtc.links', { + unit: '{link}', + description: 'Open peer links, by role', + }); clusterInstances = meter.createUpDownCounter('wrpc.cluster.instances', { unit: '{instance}', description: 'Peer instances this node currently sees on the backplane', }); + rooms = meter.createUpDownCounter('wrpc.server.rooms', { + unit: '{room}', + description: 'Rooms with at least one local member', + }); + // Deliberately carries NO per-procedure attribute: one series per + // procedure would be a cardinality bomb, and the question this answers + // — "is the server queueing?" — is a whole-process one. + queueDepth = meter.createUpDownCounter('wrpc.server.queue.depth', { + unit: '{call}', + description: 'Calls waiting for a concurrency slot', + }); } catch { connections = null; subscriptions = null; sseChannels = null; clusterInstances = null; + rtcLinks = null; + rooms = null; + queueDepth = null; } } @@ -188,11 +306,27 @@ const createServerTelemetry = (telemetry) => { * the caller's `finally`, which is the only place that knows the call * actually finished. */ - withSpan({ client, packet, target, kind = SPAN_KIND_SERVER, suffix = '' }, fn) { + withSpan({ client, packet, target, kind, suffix = '' }, fn) { const handle = { span: null, error: false }; if (!tracer) return fn(handle); const attributes = buildCallAttributes(client, packet, target, includeIdentity); - return startSpanWith(tracer, `${target}${suffix}`, { kind, attributes }, extract(packet), handle, fn); + // An explicit kind wins; otherwise the client's own (a broker consumer + // binding marks its client CONSUMER), SERVER for everything else. + const spanKind = kind ?? client.spanKind ?? SPAN_KIND_SERVER; + return startSpanWith(tracer, `${target}${suffix}`, { kind: spanKind, attributes }, extract(packet), handle, fn); + }, + + /** + * A span that is not a packet's: a message published to (PRODUCER) or + * taken from (CONSUMER) a broker outside the dispatcher. `carrier` is the + * packet-shaped `{ tp, ts }` a consumed message arrived with; `fn` + * receives the handle and runs with the span active, so an `inject` + * inside it writes this span's context into the outgoing headers. + */ + withMessagingSpan({ name, kind, attributes, carrier = null }, fn) { + const handle = { span: null, error: false }; + if (!tracer) return fn(handle); + return startSpanWith(tracer, name, { kind, attributes }, carrier ? extract(carrier) : null, handle, fn); }, recordError(handle, error, code) { @@ -214,9 +348,11 @@ const createServerTelemetry = (telemetry) => { } catch {} }, - recordConnection(delta, transport) { + // `revision` is the protocol revision the connection speaks (1 | 2): who + // is still on 1.0 during an upgrade is a count, not a grep. + recordConnection(delta, transport, revision) { try { - connections?.add(delta, { 'wrpc.transport': transport }); + connections?.add(delta, { 'wrpc.transport': transport, 'wrpc.revision': revision }); } catch {} }, @@ -245,12 +381,28 @@ const createServerTelemetry = (telemetry) => { } catch {} }, + // `kind` is 'room' or 'broadcast' — a closed set; the channel NAME is a + // room name, which only the log line carries (cardinality). + recordBackplaneGap(kind, missed) { + try { + backplaneGaps?.add(missed, { 'wrpc.channel.kind': kind }); + } catch {} + }, + recordBackpressure(transport) { try { backpressure?.add(1, { 'wrpc.transport': transport }); } catch {} }, + // A codec that threw — the message left plain, or the frame was refused. + // Called on the failure path only; nothing is counted per message. + recordCompressionFailure(carrier, direction) { + try { + compressionFailures?.add(1, { 'wrpc.compression.carrier': carrier, 'wrpc.compression.direction': direction }); + } catch {} + }, + recordSession(operation, result) { try { sessions?.add(1, { 'wrpc.session.op': operation, 'wrpc.session.result': result }); @@ -280,6 +432,107 @@ const createServerTelemetry = (telemetry) => { } catch {} }, + // The WebRTC peer layer. `role` is 'initiator' or 'responder', an + // ICE restart's outcome is 'requested', 'recovered' or 'failed'. + recordRtcLink(delta, role) { + try { + rtcLinks?.add(delta, { 'wrpc.rtc.role': role }); + } catch {} + }, + + recordRtcRedial(role) { + try { + rtcRedials?.add(1, { 'wrpc.rtc.role': role }); + } catch {} + }, + + recordRtcRestart(outcome) { + try { + rtcRestarts?.add(1, { 'wrpc.rtc.outcome': outcome }); + } catch {} + }, + + // A link's end. `reason` is what a goodbye may name (goodbye, refused, + // gave-up), 'abandoned' for a close this side never sent, or 'unknown' + // for one a newer peer named — a closed set; `remote` says whose. + recordRtcClose(reason, remote) { + try { + rtcCloses?.add(1, { 'wrpc.rtc.reason': reason, 'wrpc.rtc.side': remote ? 'remote' : 'local' }); + } catch {} + }, + + // `outcome` is a closed set: ack | retry | release | dead (consumed) and + // ok | error (published) — bounded labels, never a queue name a peer + // could multiply. + recordBrokerDelivery(system, outcome) { + try { + brokerDeliveries?.add(1, { 'messaging.system': system, 'wrpc.broker.outcome': outcome }); + } catch {} + }, + + // `reason` is a closed set (src/broker/rpc/server.js): the sequence + // numbers of a gap are the log line's, never a label. + recordBrokerSessionEnd(reason) { + try { + brokerSessionEnds?.add(1, { 'wrpc.broker.reason': reason }); + } catch {} + }, + + recordBrokerAttempts(system, attempts) { + try { + brokerAttempts?.record(attempts, { 'messaging.system': system }); + } catch {} + }, + + recordQueue(delta, waited) { + try { + queueDepth?.add(delta); + if (waited !== undefined) queueWait?.record(waited); + } catch {} + }, + + recordRooms(delta) { + try { + rooms?.add(delta); + } catch {} + }, + + recordClusterVerification(outcome) { + try { + clusterVerifications?.add(1, { 'wrpc.cluster.outcome': outcome }); + } catch {} + }, + + recordRtcAssertion(outcome) { + try { + rtcAssertions?.add(1, { 'wrpc.rtc.outcome': outcome }); + } catch {} + }, + + // `outcome` is 'established' or a refusal reason — a closed set the + // sealing layers spell (plaintext, handshake, protocol, kid, keys, + // hook, authorize, timeout, queue, crypto; format, open, stale, + // replay, replay-store on http); `kind` is ws, wt or http. + recordEncryption(outcome, kind) { + try { + encryption?.add(1, { 'wrpc.kind': kind, 'wrpc.outcome': outcome }); + } catch {} + }, + + recordBrokerPublish(system, outcome) { + try { + brokerPublished?.add(1, { 'messaging.system': system, 'wrpc.broker.outcome': outcome }); + } catch {} + }, + + // `reason` is the sealer's closed set (unsealed | kid | open | format | + // replay), never a topic or a message id. + recordBrokerRefusal(system, reason) { + try { + brokerRefused?.add(1, { 'messaging.system': system, 'wrpc.reason': reason }); + } catch {} + }, + recordClusterRequest(op, complete) { try { clusterRequests?.add(1, { 'wrpc.cluster.op': op, 'wrpc.cluster.complete': complete === true }); diff --git a/src/telemetry/shared.js b/src/telemetry/shared.js index 056e441..668506c 100644 --- a/src/telemetry/shared.js +++ b/src/telemetry/shared.js @@ -24,6 +24,7 @@ const SCOPE_NAME = '@alexify/wrpc'; // 3 PRODUCER, 4 CONSUMER. const SPAN_KIND_SERVER = 1; const SPAN_KIND_CLIENT = 2; +const SPAN_KIND_PRODUCER = 3; const SPAN_KIND_CONSUMER = 4; // The two packet fields that carry W3C trace context. Short on purpose: @@ -188,14 +189,34 @@ const DISABLED = Object.freeze({ recordBroadcast: noop, recordStreamBytes: noop, recordBackpressure: noop, + recordCompressionFailure: noop, + recordBackplaneGap: noop, recordSession: noop, recordSseChannel: noop, recordSseEvent: noop, recordClusterMessage: noop, recordClusterRequest: noop, recordClusterInstances: noop, + recordRtcLink: noop, + recordRtcRedial: noop, + recordRtcRestart: noop, + recordRtcClose: noop, + withMessagingSpan(_options, fn) { + return fn(null); + }, + recordBrokerDelivery: noop, + recordBrokerSessionEnd: noop, + recordBrokerAttempts: noop, + recordQueue: noop, + recordRooms: noop, + recordClusterVerification: noop, + recordRtcAssertion: noop, + recordEncryption: noop, + recordBrokerPublish: noop, + recordBrokerRefusal: noop, // Client-side members: one disabled writer serves both halves, so it has // to answer to everything either of them exposes. + recordHeartbeat: noop, recordReconnect: noop, recordRefresh: noop, inject: noop, @@ -211,6 +232,7 @@ module.exports = { SCOPE_NAME, SPAN_KIND_SERVER, SPAN_KIND_CLIENT, + SPAN_KIND_PRODUCER, SPAN_KIND_CONSUMER, TRACEPARENT, TRACESTATE, diff --git a/src/transport.js b/src/transport.js index 2e60e0b..0ac8005 100644 --- a/src/transport.js +++ b/src/transport.js @@ -1,9 +1,16 @@ 'use strict'; -const http = require('node:http'); +const crypto = require('node:crypto'); -const { Emitter, toKebab } = require('./utils.js'); +const { toKebab } = require('./utils.js'); +const { cacheHeadersFor, RESERVED_HEADERS, checkHeader } = require('./rpc/rest.js'); +const { STATUS_CODES } = require('./status.js'); +const { publicErrorMessage, publicErrorDetails, wireError } = require('./rpc/errors.js'); +const { ServerTransport } = require('./rpc/serverTransport.js'); const { META_HEADER, META_PREFIX, CHANNEL_HEADER } = require('./wire.js'); +const { chooseEncoding, markEncoded } = require('./contentEncoding.js'); +const { isPromise } = require('./compression/ids.js'); +const { hasBytes, encodeAttachments, isAttachmentsFrame } = require('./attachments.js'); // RFC 6265 permits '=' inside cookie values (base64, JWT) — split each // pair on the FIRST '=' only, or the value gets silently truncated. @@ -23,13 +30,25 @@ const SECURITY_HEADERS = { 'X-Content-Type-Options': 'nosniff', 'Strict-Transport-Security': 'max-age=31536000; includeSubdomains; preload', 'Content-Type': 'application/json', - // The HTTP side's version marker, echoed on every response — the ws - // subprotocol ladder's counterpart (protocol.md#versioning). A request - // MAY send `wrpc-version`; revision 1 accepts and ignores it, which is - // exactly what reserves the negotiation seam inside the 1.0 freeze. + // The HTTP side's version marker, on every response — the ws subprotocol + // ladder's counterpart (protocol.md#versioning): the newest revision the + // server speaks. 1 here, which is what 1.0 answers; a server that reads + // framed messages answers 2 through buildHeaders, and that is how an HTTP + // client learns it may send one. 'wrpc-version': '1', }; +// What an HTTP client that reads framed messages asks for, and what a +// server looks for before it answers with one. `Accept` rather than a +// header of wrpc's own: it is CORS-safelisted, so it costs no preflight a +// 1.0 server — or an application's own `cors.headers` list, which REPLACES +// the default — would refuse. +const FRAME_TYPE = 'application/octet-stream'; +const readsFrames = (headers) => { + const accept = headers?.accept; + return typeof accept === 'string' && accept.includes(FRAME_TYPE); +}; + const DEFAULT_CORS_METHODS = 'POST, GET, OPTIONS'; // `x-wrpc-channel` (which event stream a POST belongs to), `last-event-id` // (the SSE resume header) and `x-wrpc-meta` (per-request client metadata) @@ -76,12 +95,18 @@ const allowedHeaders = (cors) => { // headers?, metaHeaders?, methods? }. Without a `cors` option every origin is allowed // (wildcard, credentials-less) — the pre-F2 behavior. With `origins`, the // request origin is echoed back only when allowed, plus `Vary: Origin`. -const buildHeaders = (cors, origin) => { +// +// `revision` is the newest protocol revision the server speaks (RpcServer's +// `revision`); the marker is exposed because script on another origin reads +// only the response headers named so. +const buildHeaders = (cors, origin, revision = 1) => { const headers = { ...SECURITY_HEADERS, 'Access-Control-Allow-Methods': cors?.methods ?? DEFAULT_CORS_METHODS, 'Access-Control-Allow-Headers': allowedHeaders(cors), + 'Access-Control-Expose-Headers': 'wrpc-version', }; + if (revision === 2) headers['wrpc-version'] = '2'; if (!cors || !cors.origins) { headers['Access-Control-Allow-Origin'] = '*'; return headers; @@ -103,76 +128,19 @@ const isOriginAllowed = (cors, origin) => { return cors.origins.includes(origin); }; -// What the peer is told. 4xx messages are written for the caller -// (validation, quotas, refusals) and travel as-is; a 5xx message is a server -// internal — an uncaught exception's text can carry paths, queries or stack -// fragments — so the peer gets the status line and the details stay in the -// server log, unless the error opts in with `expose = true` (which the -// router's own coded errors do: their messages are part of the protocol). -// The packet id is the correlation: the same id is on the server log line. -const publicErrorMessage = (code, error) => { - const status = http.STATUS_CODES[code] || 'Unknown error'; - if (!error) return status; - if (code < 500 || error.expose === true) return error.message; - return status; -}; - -// `details` follows the exact same rule as the message: structured issue -// lists (validation paths, quota numbers) are part of the 4xx conversation, -// while a 5xx's internals stay in the log unless the error opts in. -const publicErrorDetails = (code, error) => { - if (!error || error.details === undefined) return undefined; - if (code < 500 || error.expose === true) return error.details; - return undefined; -}; - -// The one builder for the wire error object, so every packet that carries -// an error ({type:'callback'} and {type:'end'} alike) redacts identically. -// The `details` key is omitted entirely when there is nothing to say — -// an optional field, absent rather than null, per the protocol's -// additive-fields rule. -const wireError = (code, error) => { - const wire = { message: publicErrorMessage(code, error), code }; - const details = publicErrorDetails(code, error); - if (details !== undefined) wire.details = details; - return wire; -}; - -class ServerTransport extends Emitter { - // Which wire this is, for log entries and metric attributes. Subclasses - // override it; the base value covers a transport nobody labelled. - kind = 'unknown'; - - constructor(source) { - // No listener cap: transports are fan-out points — every backpressured - // outbound stream on the connection parks a once('drain'|'close') - // listener here, and the default cap of 10 would throw on the 11th - // concurrently stalled stream. - super({ maxListeners: Number.MAX_SAFE_INTEGER }); - this.source = source; - } - - error(code = 500, { id = '', error = null } = {}) { - const packet = { type: 'callback', id, error: wireError(code, error) }; - return this.send(packet, code); +// If-None-Match: a list of entity tags, weak or strong, or `*`. +const matchesEtag = (header, etag) => { + if (typeof header !== 'string' || header.length === 0) return false; + if (header.trim() === '*') return true; + const bare = etag.slice(2); // past the W/ + const parts = header.split(','); + for (let i = 0; i < parts.length; i++) { + let tag = parts[i].trim(); + if (tag.startsWith('W/')) tag = tag.slice(2); + if (tag === bare) return true; } - - // Returns the transport's backpressure signal (false = above the - // high-water mark) so a producer — a subscription pump, a stream — can - // wait for 'drain' instead of buffering without limit. - // - // `text` is the already-serialized form of `obj` when the dispatcher's - // compiled-serializer fast path built one (see handleRpc); passing both - // keeps the object available to the overrides that need it (batch - // collection, REST unwrapping) while the plain path skips a stringify. - send(obj, code = 200, text = null) { - // An injected codec (RpcServer options.codec, assigned per transport) - // re-frames every packet; it wins over precompiled `text` by - // construction — the server refuses codec + serializers up front. - if (this.codec) return this.write(this.codec.encode(obj), code); - return this.write(text ?? JSON.stringify(obj), code); - } -} + return false; +}; // Net-free HTTP transport over an abstract call description: // { method, url, headers, body?, remoteAddress?, respond({ status, headers, body }) }. @@ -190,13 +158,52 @@ class ServerHttpTransport extends ServerTransport { // `{ status }` carries the route's success status; null everywhere else. #rest = null; + #status = null; + // The normalized `http.compression` option (contentEncoding.js), or null: + // the core hands it to the transports it builds for real answers. + #compression = null; + #failed = null; + constructor(call, options = {}) { super(call.remoteAddress ?? ''); this.call = call; this.headers = options.headers ?? { ...SECURITY_HEADERS }; this.#respond = call.respond; + if (options.compression) this.#compression = options.compression; + // `failed(coding, error)`: the core's reporter for an encoder that threw. + if (options.failed) this.#failed = options.failed; if (Array.isArray(options.batch)) this.#batch = options.batch; - if (options.rest) this.#rest = options.rest; + if (options.rest) { + this.#rest = options.rest; + // A declared route's static response headers, applied once here so + // every answer — result or error — carries them. + if (options.rest.headers) Object.assign(this.headers, options.rest.headers); + } + } + + // The `context.http.setHeader` seam: one header onto this response, + // refused once the answer is written or for a transport-owned name. + setHeader(name, value) { + if (typeof name !== 'string' || name.length === 0) throw new TypeError('setHeader: name must be a string'); + if (RESERVED_HEADERS.has(name.toLowerCase())) throw new TypeError(`setHeader: '${name}' is owned by the transport`); + if (this.#responded) throw new Error('setHeader: the response was already sent'); + const text = String(value); + checkHeader(name, text, 'setHeader'); + // One header per name, whatever the spelling: a second call under + // another case replaces the first rather than sending both. + const lower = name.toLowerCase(); + for (const key in this.headers) { + if (key !== name && key.toLowerCase() === lower) delete this.headers[key]; + } + this.headers[name] = text; + } + + // The `context.http.status` seam: the success status of THIS response + // (a REST route's declared status is the default). Errors keep their code. + setStatus(code) { + if (!(Number.isInteger(code) && code >= 200 && code <= 599)) throw new TypeError('status: an integer 200-599'); + if (this.#responded) throw new Error('status: the response was already sent'); + this.#status = code; } get responded() { @@ -222,27 +229,51 @@ class ServerHttpTransport extends ServerTransport { if (obj.error) { return this.write(codec ? codec.encode(obj.error) : JSON.stringify(obj.error), obj.error.code ?? code); } - const status = this.#rest.status ?? 200; + // A REST body is a plain value with no frame to carry bytes in: a + // result holding them needs codec.rest, and says so instead of + // shipping the objects JSON makes of typed arrays. + if (codec === null && this.attachments !== false && hasBytes(obj.result)) { + const error = { message: 'Binary results on REST need codec.rest', code: 501 }; + return this.write(JSON.stringify(error), 501); + } + const status = this.#status ?? this.#rest.status ?? 200; // 204 promises "no content": the result (if any) is discarded on the // wire by contract, not by accident. if (status === 204) return this.write('', 204); // An undefined result travels as an encoded `null` — one documented // behaviour with and without a codec. - if (obj.result === undefined) return this.write(codec ? codec.encode(null) : 'null', status); - return this.write(codec ? codec.encode(obj.result) : JSON.stringify(obj.result), status); + const body = + obj.result === undefined + ? codec + ? codec.encode(null) + : 'null' + : codec + ? codec.encode(obj.result) + : JSON.stringify(obj.result); + return this.#writeCacheable(body, status); } if (!this.#batch) return super.send(obj, code, text); if (this.#responded) return true; this.#collected.push(obj); if (this.#collected.length < this.#batch.length) return true; const ordered = this.#ordered(); - return this.write(this.codec ? this.codec.encode(ordered) : JSON.stringify(ordered), 200); + return this.write(this.#encodeBatch(ordered), 200); + } + + // One batch answer: the codec's framing, an attachments frame when any + // answer holds bytes, JSON otherwise. + #encodeBatch(ordered) { + if (this.codec) return this.codec.encode(ordered); + if (this.attachments !== false && hasBytes(ordered)) return encodeAttachments(ordered); + return JSON.stringify(ordered); } // Building an id index makes this O(n), but the Map costs more than the - // quadratic it removes until the batch gets big: the linear scan wins 1.31x - // at 4 answers and 1.08x at 8, and loses 1.20x at 16 and 3.92x at 128. - // Re-run bench/batch-ordering.js before moving this. + // quadratic it removes until the batch gets big: the linear scan wins 1.23x + // at 4 answers and 1.05x at 8, and loses 1.18x at 16 and 3.91x at 128 + // (docs/guide/performance.md) — so the switch sits between 8 and 16; the + // client's default batch.maxSize of 16 is the first measured size where + // the index wins. Re-run bench/batch-ordering.js before moving either. static #INDEX_THRESHOLD = 12; #ordered() { @@ -287,13 +318,104 @@ class ServerHttpTransport extends ServerTransport { return answers; } + // A successful REST result under the route's cache policy: Cache-Control + // as the policy decides once the session is known, a weak ETag over the + // body, and 304 for a matching If-None-Match — HEAD and GET alike, since + // the host strips a HEAD body itself. + #writeCacheable(body, status) { + const rest = this.#rest; + const policy = + rest.cache && status >= 200 && status < 300 + ? cacheHeadersFor(rest.cache, { + access: rest.access, + session: rest.hasSession?.() === true, + cookies: this.#setCookies.length > 0, + }) + : null; + if (policy === null) return this.write(body, status); + this.headers['Cache-Control'] = policy.control; + if (!policy.etag) return this.write(body, status); + const bytes = Buffer.isBuffer(body) ? body : Buffer.from(body); + const etag = `W/"${crypto.createHash('sha1').update(bytes).digest('base64url')}"`; + this.headers['ETag'] = etag; + if (matchesEtag(this.call.headers?.['if-none-match'], etag)) return this.write('', 304); + return this.write(bytes, status); + } + + // The one funnel every HTTP answer leaves through — packet, batch, REST, + // error — which is what makes `Content-Encoding` a single decision: + // enabled, past the threshold, accepted by the peer, not already encoded + // upstream, admitted by the filter. Below the async threshold the encode is + // synchronous, like permessage-deflate's default; at or over it the body + // goes to zlib's threadpool and the response is written from the + // callback — `responded` is already true, so a second write and a close + // racing it are no-ops either way. write(data, httpCode = 200) { if (this.#responded) return true; this.#responded = true; - const body = Buffer.isBuffer(data) ? data : Buffer.from(data); - const headers = { ...this.headers, 'Content-Length': body.length }; + const body = Buffer.isBuffer(data) + ? data + : typeof data === 'string' + ? Buffer.from(data) + : Buffer.from(data.buffer, data.byteOffset, data.byteLength); + const headers = { ...this.headers }; + // An attachments frame is bytes, and says so — a client reads the body + // as bytes only under this type. + if (typeof data !== 'string' && isAttachmentsFrame(body)) headers['Content-Type'] = 'application/octet-stream'; if (this.#setCookies.length > 0) headers['Set-Cookie'] = this.#setCookies; - this.#respond({ status: httpCode, headers, body }); + const compression = this.#compression; + const encoder = + compression !== null && body.length >= compression.threshold + ? chooseEncoding(compression, this.call, headers) + : null; + if (encoder === null) return this.#answer(headers, body, httpCode); + markEncoded(headers, encoder.token); + // The encoder refused the body (out of memory is the realistic case): + // the plain bytes still answer, and honestly labelled — and the failure + // is said, since nothing else about the answer shows it. + const plain = (error) => { + this.#failed?.(encoder.token, error); + delete headers['Content-Encoding']; + return this.#answer(headers, body, httpCode); + }; + if (encoder.encodeAsync !== null && compression.async !== null && body.length >= compression.async.threshold) { + encoder.encodeAsync( + body, + (error, encoded) => void (error ? plain(error) : this.#answer(headers, encoded, httpCode)), + ); + return true; + } + let encoded; + try { + encoded = encoder.encode(body); + } catch (error) { + return plain(error); + } + // An application's own coding may answer a promise. + if (isPromise(encoded)) { + encoded.then((out) => this.#answer(headers, out, httpCode), plain); + return true; + } + return this.#answer(headers, encoded, httpCode); + } + + #answer(headers, body, httpCode) { + headers['Content-Length'] = body.length; + try { + this.#respond({ status: httpCode, headers, body }); + } catch (error) { + // The host refused to write the response (node: a header value it + // will not send — checked at every seam a handler reaches, but a + // `headers` option built by hand is not). Without this the request + // hung until the peer gave up and the client evicted nothing: a bare + // 500 in its place, and the close every answer promises. + try { + this.#respond({ status: 500, headers: { ...SECURITY_HEADERS, 'Content-Length': 0 }, body: Buffer.alloc(0) }); + } catch { + // The peer is gone, or the host is: nothing more to say. + } + if (this.listenerCount('error') > 0) this.emit('error', error); + } this.emit('close'); return true; } @@ -322,7 +444,7 @@ class ServerHttpTransport extends ServerTransport { close() { if (this.#responded) return; if (!this.#batch) return void this.error(503); - const message = http.STATUS_CODES[503]; + const message = STATUS_CODES[503]; const answered = new Set(); for (let i = 0; i < this.#collected.length; i++) answered.add(this.#collected[i].id); for (const id of this.#batch) { @@ -331,15 +453,19 @@ class ServerHttpTransport extends ServerTransport { this.#collected.push({ type: 'callback', id: typeof id === 'string' ? id : '', error: { message, code: 503 } }); } const ordered = this.#ordered(); - this.write(this.codec ? this.codec.encode(ordered) : JSON.stringify(ordered), 503); + this.write(this.#encodeBatch(ordered), 503); } } class ServerWsTransport extends ServerTransport { kind = 'ws'; + // `meta.kind` names the wire when a WrpcSocket-shaped connection is not a + // WebSocket — attachSession in @alexify/wrpc/wt passes 'wt' — so logs and + // metrics say which; the transport itself is the same either way. constructor(connection, meta = {}) { super(meta.remoteAddress ?? connection.remoteAddress ?? ''); + if (typeof meta.kind === 'string' && meta.kind) this.kind = meta.kind; this.connection = connection; connection.on('close', () => void this.emit('close')); connection.on('drain', () => void this.emit('drain')); @@ -352,6 +478,36 @@ class ServerWsTransport extends ServerTransport { return this.connection.send(data); } + // A write with per-message options (`compress: false`); the socket + // contract's second argument, ignored by sockets that predate it. + writeWith(text, options) { + return this.connection.send(text, options); + } + + // The fan-out seam: a shared `{ text, frames, compress }` message goes to + // the socket's prepared-frame path when it has one (the built-in engine), + // which encodes and deflates it once for every recipient; a socket + // without one (uws, WebTransport) gets the text as an ordinary send. + writeShared(message) { + const socket = this.connection; + // An attachments frame arrives as a Uint8Array; the engine speaks + // Buffers. Converted ONCE on the shared message, for every recipient. + const { text } = message; + if (typeof text !== 'string' && !Buffer.isBuffer(text)) { + message.text = Buffer.from(text.buffer, text.byteOffset, text.byteLength); + } + if (typeof socket.sendPrepared === 'function') return socket.sendPrepared(message); + return socket.send(message.text, message.compress === false ? message : null); + } + + // An event as a datagram where the socket has them (a WebTransport + // session): true when it went out, false when it could not — a WebSocket + // has no unreliable path, and Client.sendRaw then writes it reliably. + writeUnreliable(text) { + const socket = this.connection; + return typeof socket.sendUnreliable === 'function' && socket.sendUnreliable(text) === true; + } + // A graceful goodbye: the peer gets a close frame (1001 "going away") and // the chance to finish the handshake; the engine's closeTimeout destroys // one that never answers. Hard teardown stays spelled terminate(). @@ -370,9 +526,30 @@ class ServerEventTransport extends ServerTransport { constructor(port) { super('event transport'); this.port = port; + // A port stays open like a socket does — what Client.persistent + // checks (rpc/client.js), and so what admits events, subscriptions + // and streams. Without it a port-attached client was silently + // request/response only. + this.connection = this; + // A port has no handshake: until its page said — on its first ping, or + // in the connect message handed to attachPort — that it reads framed + // messages, it is sent none (protocol.md#versioning). A 1.0 page never + // says so. + this.revision = 1; + this.attachments = false; + // The newest revision the server behind this port speaks; attachPort + // lowers it for a server that sends no frames. + this.max = 2; port.on('close', () => void this.emit('close')); } + // The page named revision `v`: this connection speaks the older of the + // two, and the answer is this end's own — which is how the page learns it. + negotiate(v) { + this.setRevision(v === 2 && this.max === 2 ? 2 : 1); + return this.max; + } + write(data) { this.port.postMessage(data); return true; // a MessagePort has no backpressure to report @@ -392,6 +569,7 @@ ServerTransport.transport = { module.exports = { ServerTransport, buildHeaders, + readsFrames, isOriginAllowed, parseCookies, publicErrorMessage, diff --git a/src/utils.js b/src/utils.js index b70a284..acf0f95 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,5 +1,7 @@ 'use strict'; +const { generateUUID } = require('./runtime/node.js'); + // Every emit() answers with a promise, and a listener's SYNCHRONOUS throw has // to become a rejection: `void emitter.emit('close')` is the house style, and a // throw escaping it would land in a socket handler. The async-arrow wrapper on @@ -382,4 +384,61 @@ const KEBAB_ACRONYM = /([A-Z]+)([A-Z][a-z])/g; const KEBAB_BOUNDARY = /([a-z0-9])([A-Z])/g; const toKebab = (key) => key.replace(KEBAB_ACRONYM, '$1-$2').replace(KEBAB_BOUNDARY, '$1-$2').toLowerCase(); -module.exports = { Emitter, jsonParse, isCodec, toKebab, Semaphore, backoffDelay, EventStream, createEventStream }; +// One resolver for every `generateId` option in the package. The three +// call-site spellings this replaces had drifted — one threw, one fell back +// silently, one assigned only when the value happened to be a function — +// so a user who mistyped the option learned about it in three different +// ways, or in none. +// +// A bad generator is a TypeError at construction, on every option alike. In +// 1.x the three that shipped in 1.0 (`RpcServer`, `WrpcClient`, `PeerHost`) +// could not throw — they had silently ignored a bad value, and turning that +// into a throw inside a major is the kind of break the stability policy +// exists to prevent — so they logged `options.generateId` and fell back to +// uuid v4, with the promise that 2.0 would make them throw. 2.0 does. +// +// The generator is PROBED once here rather than trusted: one that answers a +// number or an empty string otherwise produces ids that fail much later, on +// the wire, as a malformed packet. The 255-character cap is the binary chunk +// header's own limit (see createStream in src/client/core.js), applied to +// every id so one generator cannot be valid for calls and invalid for +// streams. +// +// The probe's id comes back as `first` rather than being thrown away, and +// every caller that needs an id AT construction — a server's instanceId, a +// signaler's instance — uses it. That is what keeps the probe free: a +// counter-based generator still starts at 1, and "the instance is generated +// once" stays true. A caller with no such id discards `first` and pays one. +// +// Returning a pair rather than a self-replacing wrapper is deliberate: the +// generator runs per call packet, and a wrapper would put a branch on that +// path forever to save one id at boot. +const resolveGenerateId = (value, label) => { + const fail = (message) => { + throw new TypeError(`${label}: generateId ${message}`); + }; + if (value === null || value === undefined) return { generate: generateUUID, first: generateUUID() }; + if (typeof value !== 'function') return fail('must be a function'); + let first; + try { + first = value(); + } catch (error) { + return fail(`threw when called: ${error.message}`); + } + if (typeof first !== 'string' || first.length === 0 || first.length > 255) { + return fail('must return a non-empty string of at most 255 characters'); + } + return { generate: value, first }; +}; + +module.exports = { + Emitter, + jsonParse, + isCodec, + toKebab, + Semaphore, + backoffDelay, + EventStream, + createEventStream, + resolveGenerateId, +}; diff --git a/src/webrtc/assertionIssuer.js b/src/webrtc/assertionIssuer.js new file mode 100644 index 0000000..4173b1f --- /dev/null +++ b/src/webrtc/assertionIssuer.js @@ -0,0 +1,128 @@ +'use strict'; + +// The issuing half of trust assertions: what the signaling unit signs with. +// Node-only by placement (required from signaling.js, never from the +// browser barrel) though it too speaks WebCrypto only — a page never issues. +// Format and binding: see assertions.js. + +const { + ASSERTION_TYP, + ASSERTION_ALG, + ES256, + ES256_SIGN, + base64urlEncode, + isJwk, + publicJwk, +} = require('./assertions.js'); +const { generateUUID } = require('../runtime/node.js'); + +const DEFAULT_TTL = 300; +const RESERVED = ['iss', 'sub', 'iat', 'exp', 'fp']; + +const encoder = new TextEncoder(); + +const encodeJson = (value) => base64urlEncode(encoder.encode(JSON.stringify(value))); + +const isPrivateJwk = (value) => isJwk(value) && typeof value.d === 'string'; + +const isCryptoKey = (value) => typeof value === 'object' && value !== null && typeof value.type === 'string'; + +/** + * A fresh ES256 key pair as JWKs, `kid` attached to both — for a server's + * boot, a config script, a test. Keep the private one private. + */ +const generateAssertionKeys = async ({ kid = generateUUID(), subtle = globalThis.crypto?.subtle } = {}) => { + if (!subtle) throw new TypeError('generateAssertionKeys: WebCrypto (crypto.subtle) is required'); + const pair = await subtle.generateKey(ES256, true, ['sign', 'verify']); + // Without key_ops: the JWKs travel between an issuer and verifiers whose + // usage is decided at import, and publicKeys() strips it the same way. + const [{ key_ops: _sign, ...privateKey }, { key_ops: _verify, ...publicKey }] = await Promise.all([ + subtle.exportKey('jwk', pair.privateKey), + subtle.exportKey('jwk', pair.publicKey), + ]); + return { + kid, + privateKey: { ...privateKey, kid, alg: ASSERTION_ALG, use: 'sig' }, + publicKey: { ...publicKey, kid, alg: ASSERTION_ALG, use: 'sig' }, + }; +}; + +/** + * Signs assertions. `key` is a private EC P-256 JWK (generateAssertionKeys' + * `privateKey`) or a `{ privateKey, publicKey }` CryptoKey pair. Options: + * kid the key id put in the header (default: the JWK's `kid`, if any) + * ttl seconds an assertion is valid for (default 300) + * issuer the `iss` claim, when the verifiers expect one + * + * sign({ sub, fp, ...claims }) -> { assertion, iat, exp } + * publicKeys() -> [JWK] what verifiers need, for a keys() call + */ +const createAssertionIssuer = ({ + key, + kid, + ttl = DEFAULT_TTL, + issuer = null, + subtle = globalThis.crypto?.subtle, +} = {}) => { + if (!subtle || typeof subtle.sign !== 'function') { + throw new TypeError('createAssertionIssuer: WebCrypto (crypto.subtle) is required'); + } + if (!(Number.isInteger(ttl) && ttl > 0)) throw new TypeError('createAssertionIssuer: ttl must be a positive integer'); + if (issuer !== null && (typeof issuer !== 'string' || issuer.length === 0)) { + throw new TypeError('createAssertionIssuer: issuer must be a non-empty string'); + } + let privateKey; + let publicKey; + let keyId = kid ?? null; + if (isPrivateJwk(key)) { + keyId ??= typeof key.kid === 'string' ? key.kid : null; + privateKey = subtle.importKey('jwk', key, ES256, false, ['sign']); + publicKey = Promise.resolve({ + ...publicJwk(key), + ...(keyId ? { kid: keyId } : {}), + alg: ASSERTION_ALG, + use: 'sig', + }); + } else if (typeof key === 'object' && key !== null && isCryptoKey(key.privateKey) && isCryptoKey(key.publicKey)) { + privateKey = Promise.resolve(key.privateKey); + publicKey = subtle + .exportKey('jwk', key.publicKey) + .then((jwk) => ({ ...publicJwk(jwk), ...(keyId ? { kid: keyId } : {}), alg: ASSERTION_ALG, use: 'sig' })); + } else { + throw new TypeError( + 'createAssertionIssuer: key must be a private EC P-256 JWK or a { privateKey, publicKey } pair', + ); + } + if (keyId !== null && (typeof keyId !== 'string' || keyId.length === 0)) { + throw new TypeError('createAssertionIssuer: kid must be a non-empty string'); + } + // A key that never imports fails every sign() with the import's error, + // never as an unhandled rejection. + privateKey.catch(() => {}); + publicKey.catch(() => {}); + + const header = { alg: ASSERTION_ALG, typ: ASSERTION_TYP }; + if (keyId !== null) header.kid = keyId; + const encodedHeader = encodeJson(header); + + const sign = async (claims) => { + if (typeof claims !== 'object' || claims === null) throw new TypeError('sign: claims must be an object'); + if (typeof claims.sub !== 'string' || claims.sub.length === 0) throw new TypeError('sign: claims.sub is required'); + if (typeof claims.fp !== 'string' || claims.fp.length === 0) throw new TypeError('sign: claims.fp is required'); + const iat = Math.floor(Date.now() / 1000); + const exp = iat + ttl; + // The reserved claims come last: nothing custom may override them. + const payload = { ...claims, sub: claims.sub, iat, exp, fp: claims.fp }; + if (issuer !== null) payload.iss = issuer; + else delete payload.iss; + const signed = `${encodedHeader}.${encodeJson(payload)}`; + const signature = await subtle.sign(ES256_SIGN, await privateKey, encoder.encode(signed)); + return { assertion: `${signed}.${base64urlEncode(new Uint8Array(signature))}`, iat, exp }; + }; + + const publicKeys = async () => [await publicKey]; + + return { sign, publicKeys, kid: keyId, ttl }; +}; + +module.exports = { createAssertionIssuer, generateAssertionKeys, RESERVED_CLAIMS: Object.freeze(RESERVED) }; diff --git a/src/webrtc/assertions.js b/src/webrtc/assertions.js new file mode 100644 index 0000000..f6f0c7c --- /dev/null +++ b/src/webrtc/assertions.js @@ -0,0 +1,323 @@ +'use strict'; + +// Trust assertions: the token a signaling server issues to a peer and the +// other peers verify — browser-safe, require-free, over WebCrypto only. +// +// An assertion is a JWS in compact serialization, `header.payload.sig` in +// base64url, alg ES256 (ECDSA P-256 / SHA-256 — the one algorithm every +// WebCrypto ships; WebCrypto's raw r||s signature is exactly what JWS ES256 +// wants, so there is no DER to convert): +// +// header { alg: 'ES256', typ: 'wrpc-rtc+jwt', kid? } +// payload { iss?, sub: , iat, exp, fp: 'sha-256 AB:CD:…', ...claims } +// +// `fp` is the fingerprint of the DTLS certificate of the peer connection +// the token was issued for, as it appears in the SDP the peer sends +// (`a=fingerprint:`). The verifier compares it with the fingerprint in the +// description the assertion arrived with, and the DTLS handshake proves the +// remote end holds that certificate — so a token cannot be replayed from +// another connection, and the relay in between has nothing to lie about +// (the RFC 8827 identity binding, minus the IdP). Verification happens in +// WrpcPeer, before a description reaches the RtcLink. + +const TYP = 'wrpc-rtc+jwt'; +const ALG = 'ES256'; +const ES256 = Object.freeze({ name: 'ECDSA', namedCurve: 'P-256' }); +const ES256_SIGN = Object.freeze({ name: 'ECDSA', hash: 'SHA-256' }); +const MAX_TOKEN_LENGTH = 4096; +// The request round trip and a little clock drift the server's clock +// offset (see WrpcPeer) does not absorb. +const SKEW_MS = 60_000; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder('utf-8', { fatal: true }); + +class AssertionError extends Error { + constructor(message, code) { + super(message); + this.name = 'AssertionError'; + this.code = code; + } +} + +const refuse = (message, code) => { + throw new AssertionError(message, code); +}; + +// base64url without Buffer: btoa/atob exist in every browser and in Node +// since 16. Tokens are a few hundred bytes, so a string built byte by byte +// is fine (and never `String.fromCharCode(...bytes)` — apply in disguise). +const base64urlEncode = (bytes) => { + let binary = ''; + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +}; + +const BASE64URL = /^[A-Za-z0-9_-]*$/; + +const base64urlDecode = (text) => { + if (typeof text !== 'string' || !BASE64URL.test(text)) refuse('assertion: not base64url', 'malformed'); + const padded = text + '='.repeat((4 - (text.length % 4)) % 4); + let binary; + try { + binary = atob(padded.replace(/-/g, '+').replace(/_/g, '/')); + } catch { + refuse('assertion: not base64url', 'malformed'); + } + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +}; + +const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value); + +const parseJson = (bytes, what) => { + let value; + try { + value = JSON.parse(decoder.decode(bytes)); + } catch { + refuse(`assertion: ${what} is not JSON`, 'malformed'); + } + if (!isObject(value)) refuse(`assertion: ${what} is not an object`, 'malformed'); + return value; +}; + +/** True for anything shaped like a compact JWS of a sane size. */ +const isAssertion = (value) => + typeof value === 'string' && value.length > 0 && value.length <= MAX_TOKEN_LENGTH && value.split('.').length === 3; + +/** Splits a compact JWS into its parts; the header and payload parsed, the signed bytes kept. */ +const parseJws = (token) => { + if (!isAssertion(token)) refuse('assertion: not a compact JWS', 'malformed'); + const dot = token.indexOf('.'); + const second = token.indexOf('.', dot + 1); + const header = parseJson(base64urlDecode(token.slice(0, dot)), 'header'); + const payload = parseJson(base64urlDecode(token.slice(dot + 1, second)), 'payload'); + const signature = base64urlDecode(token.slice(second + 1)); + return { header, payload, signed: encoder.encode(token.slice(0, second)), signature }; +}; + +const FINGERPRINT = /^([A-Za-z0-9-]+) ((?:[0-9A-Fa-f]{2}:)+[0-9A-Fa-f]{2})$/; + +/** `'sha-256 ab:cd'` -> `'sha-256 AB:CD'`, or null when it is not a fingerprint. */ +const normalizeFingerprint = (value) => { + if (typeof value !== 'string' || value.length > 512) return null; + const match = FINGERPRINT.exec(value.trim()); + if (!match) return null; + return `${match[1].toLowerCase()} ${match[2].toUpperCase()}`; +}; + +const FINGERPRINT_LINE = /^a=fingerprint:([A-Za-z0-9-]+)[ \t]+((?:[0-9A-Fa-f]{2}:)+[0-9A-Fa-f]{2})[ \t]*$/gm; + +/** + * The ONE certificate fingerprint an SDP declares, normalized like + * normalizeFingerprint — or null when it declares none, or more than one. + * Every `a=fingerprint:` line counts, session- or media-level, whatever + * its algorithm: DTLS binds to the media-level line (RFC 8122 §5), so a + * description that names two certificates is one an on-path relay could + * have edited — the honest line first, its own where DTLS looks — and an + * assertion is for one certificate. + */ +const sdpFingerprint = (sdp) => { + if (typeof sdp !== 'string') return null; + FINGERPRINT_LINE.lastIndex = 0; + let found = null; + let match; + while ((match = FINGERPRINT_LINE.exec(sdp)) !== null) { + const fingerprint = `${match[1].toLowerCase()} ${match[2].toUpperCase()}`; + if (found === null) found = fingerprint; + else if (fingerprint !== found) return null; + } + return found; +}; + +const isJwk = (value) => isObject(value) && value.kty === 'EC' && value.crv === 'P-256'; + +const publicJwk = (jwk) => { + const { d: _d, key_ops: _ops, ...rest } = jwk; + return rest; +}; + +// How often a keys() function is asked again for a kid the set does not +// know: once per interval, however many tokens name unknown kids — a flood +// of them used to be a flood on the keys endpoint. 0 asks every time. +const DEFAULT_REFRESH_INTERVAL = 30_000; +// How long a set a keys() function answered is trusted before it is asked +// again, whatever the kids: a key the endpoint stopped publishing — revoked +// — used to be accepted for the life of the verifier. +const DEFAULT_MAX_AGE = 600_000; +// How long a keys() that failed is left alone: every verify meanwhile meets +// the same failure, where a burst of them used to be a burst of asks. +// Capped by refreshInterval, so 0 still asks every time. +const FAILED_BACKOFF = 1000; +const noop = () => {}; + +/** + * Verifies assertions against a set of public keys. `keys` is a JWK, an + * array of JWKs, or a function answering them (a signaler's `keys()`, say) + * — asked once, and once more when a `kid` is unknown, for rotation, at + * most once per `refreshInterval`, and again once the set is `maxAge` old. + * A keys() that failed is not asked again for a second (at most + * `refreshInterval`) — every verify meanwhile meets the same failure, + * where each used to ask; a refresh that fails keeps the set that was. + * + * verify(token, { from, sdp, now? }) -> claims + * + * checks the header (`typ`, `alg`), finds the key by `kid` (a lone key + * without one answers any), verifies the signature, then binds: `sub` must be `from`, + * `fp` must be the fingerprint in `sdp`, `exp` must be after `now` (with a + * minute of skew), `iss` must match when one is expected. Throws an + * AssertionError with a `code` — the reason a link was refused. + */ +const createAssertionVerifier = ({ + keys, + issuer = null, + subtle = globalThis.crypto?.subtle, + refreshInterval = DEFAULT_REFRESH_INTERVAL, + maxAge = DEFAULT_MAX_AGE, + clock = Date.now, +} = {}) => { + if (!subtle || typeof subtle.verify !== 'function') { + throw new TypeError('createAssertionVerifier: WebCrypto (crypto.subtle) is required'); + } + if (!Number.isInteger(refreshInterval) || refreshInterval < 0) { + throw new TypeError('createAssertionVerifier: refreshInterval must be a non-negative integer (ms)'); + } + if (!Number.isInteger(maxAge) || maxAge < 0) { + throw new TypeError('createAssertionVerifier: maxAge must be a non-negative integer (ms, 0 = never)'); + } + if (typeof clock !== 'function') throw new TypeError('createAssertionVerifier: clock must be a function'); + if (typeof keys !== 'function' && !isJwk(keys) && !(Array.isArray(keys) && keys.every(isJwk))) { + throw new TypeError('createAssertionVerifier: keys must be an EC P-256 JWK, an array of them, or a function'); + } + if (issuer !== null && (typeof issuer !== 'string' || issuer.length === 0)) { + throw new TypeError('createAssertionVerifier: issuer must be a non-empty string'); + } + let imported = new Map(); // kid -> Promise + // A lone key with no kid of its own is a wildcard: it answers any kid a + // token names. A labelled key answers its own only — so a kid the set + // does not know means a rotation to look up, not a key to try. + let wildcard = null; + // Whether a set was ever loaded, and the load in flight, which every + // verify waiting on one shares. Kept apart: a load that failed used to + // stay as "the" load, and every verify after it awaited the same + // rejection — one outage of the keys endpoint poisoned the verifier. + let ready = false; + let inflight = null; + let refreshedAt = -Infinity; + let loadedAt = -Infinity; + // The last load's failure, and when: asked again no sooner than + // FAILED_BACKOFF after it. + let failure = null; + let failedAt = -Infinity; + const mayAsk = () => failure === null || clock() - failedAt >= Math.min(FAILED_BACKOFF, refreshInterval); + + const load = () => + (inflight ??= (async () => { + const list = typeof keys === 'function' ? await keys() : Array.isArray(keys) ? keys : [keys]; + if (!Array.isArray(list) || list.length === 0 || !list.every(isJwk)) { + throw new TypeError('assertion keys: expected a non-empty array of EC P-256 JWKs'); + } + const fresh = new Map(); + for (const jwk of list) { + const kid = typeof jwk.kid === 'string' ? jwk.kid : ''; + const key = subtle.importKey('jwk', publicJwk(jwk), ES256, false, ['verify']); + // A JWK the platform refuses rejects here, and nobody awaits it + // until a token names its kid: handled then, never unhandled now. + key.catch(noop); + fresh.set(kid, key); + } + // Swapped whole, once it loaded: a refresh that fails keeps the set that was. + imported = fresh; + wildcard = list.length === 1 && typeof list[0].kid !== 'string' ? fresh.get('') : null; + ready = true; + loadedAt = clock(); + failure = null; + })() + .catch((error) => { + failure = error; + failedAt = clock(); + throw error; + }) + .finally(() => { + inflight = null; + })); + + const lookup = (kid) => imported.get(kid) ?? wildcard; + + const keyFor = async (kid) => { + if (!ready) { + if (!mayAsk()) throw failure; + await load(); + } else if (typeof keys === 'function' && maxAge > 0 && clock() - loadedAt >= maxAge && mayAsk()) { + await load().catch(noop); + } + const found = lookup(kid); + if (found) return found; + if (typeof keys === 'function') { + const now = clock(); + if ((refreshInterval === 0 || now - refreshedAt >= refreshInterval) && mayAsk()) { + refreshedAt = now; + await load(); + const refreshed = lookup(kid); + if (refreshed) return refreshed; + } + } + return refuse(`assertion: unknown key '${kid}'`, 'kid'); + }; + + const verify = async (token, { from, sdp, now = Date.now() } = {}) => { + const { header, payload, signed, signature } = parseJws(token); + if (header.typ !== TYP) refuse(`assertion: typ must be ${TYP}`, 'typ'); + if (header.alg !== ALG) refuse(`assertion: alg must be ${ALG}`, 'alg'); + const kid = header.kid === undefined ? '' : header.kid; + if (typeof kid !== 'string') refuse('assertion: kid must be a string', 'kid'); + let key; + try { + key = await keyFor(kid); + } catch (error) { + if (error instanceof AssertionError) throw error; + refuse(`assertion: keys unavailable (${error.message})`, 'kid'); + } + let valid = false; + try { + valid = await subtle.verify(ES256_SIGN, key, signature, signed); + } catch { + valid = false; + } + if (valid !== true) refuse('assertion: bad signature', 'signature'); + if (typeof payload.sub !== 'string' || payload.sub !== from) refuse('assertion: not about this peer', 'subject'); + const fingerprint = normalizeFingerprint(payload.fp); + if (fingerprint === null) refuse('assertion: no fingerprint', 'fingerprint'); + const declared = sdpFingerprint(sdp); + if (declared === null) refuse('assertion: the description declares no single fingerprint', 'fingerprint'); + if (declared !== fingerprint) refuse('assertion: fingerprint does not match the description', 'fingerprint'); + if (typeof payload.exp !== 'number' || !(payload.exp * 1000 + SKEW_MS > now)) { + refuse('assertion: expired', 'expired'); + } + if (issuer !== null && payload.iss !== issuer) refuse('assertion: unexpected issuer', 'issuer'); + return Object.freeze({ ...payload }); + }; + + return { verify }; +}; + +module.exports = { + AssertionError, + ASSERTION_TYP: TYP, + ASSERTION_ALG: ALG, + ES256, + ES256_SIGN, + MAX_TOKEN_LENGTH, + SKEW_MS, + base64urlEncode, + base64urlDecode, + isAssertion, + parseJws, + normalizeFingerprint, + sdpFingerprint, + isJwk, + publicJwk, + createAssertionVerifier, + DEFAULT_REFRESH_INTERVAL, +}; diff --git a/src/webrtc/browser.js b/src/webrtc/browser.js new file mode 100644 index 0000000..fb9fd93 --- /dev/null +++ b/src/webrtc/browser.js @@ -0,0 +1,62 @@ +'use strict'; + +// The browser barrel of @alexify/wrpc/webrtc: everything a page needs to be +// a peer. The Node barrel (index.js) adds the server-side signaling unit. +// Requiring transport.js registers `WrpcClient.transport.webrtc`. + +const { isRtcAdapter, isRtcPeerConnection, isRtcDataChannel, createW3cAdapter } = require('./port.js'); +const framing = require('./framing.js'); +const { RtcLink, normalizeChannels, DEFAULT_CHANNELS, MAX_CHANNEL_ID } = require('./link.js'); +const { ClientRtcTransport, RtcPeerTransport } = require('./transport.js'); +const { PeerHost } = require('./host.js'); +const signaler = require('./signaler.js'); +const assertions = require('./assertions.js'); +// A browser peer defines its router with these; the main browser entry +// leaves them out for its byte budget, and they are already in this bundle +// (the host dispatches over a Router). +const { defineRouter, procedure } = require('../rpc/router.js'); +const { tracked, createEventLog } = require('../rpc/subscriptions.js'); +const { WrpcPeer, PeerLink } = require('./peer.js'); +const { Mesh } = require('./mesh.js'); + +const { buildDictionary } = require('../rpc/dictionary.js'); + +module.exports = { + defineRouter, + buildDictionary, + procedure, + tracked, + createEventLog, + isRtcAdapter, + isRtcPeerConnection, + isRtcDataChannel, + createW3cAdapter, + RtcLink, + normalizeChannels, + DEFAULT_CHANNELS, + MAX_CHANNEL_ID, + ClientRtcTransport, + RtcPeerTransport, + PeerHost, + WrpcPeer, + PeerLink, + Mesh, + WrpcSignaler: signaler.WrpcSignaler, + wrpcSignaler: signaler.wrpcSignaler, + isSignaler: signaler.isSignaler, + hasRoster: signaler.hasRoster, + hasAssertions: signaler.hasAssertions, + isSignalMessage: signaler.isSignalMessage, + SIGNAL_MESSAGE_TYPES: signaler.SIGNAL_MESSAGE_TYPES, + createAssertionVerifier: assertions.createAssertionVerifier, + sdpFingerprint: assertions.sdpFingerprint, + normalizeFingerprint: assertions.normalizeFingerprint, + isAssertion: assertions.isAssertion, + AssertionError: assertions.AssertionError, + FrameEncoder: framing.FrameEncoder, + FrameDecoder: framing.FrameDecoder, + FramingError: framing.FramingError, + negotiateMessageSize: framing.negotiateMessageSize, + KIND_TEXT: framing.KIND_TEXT, + KIND_BINARY: framing.KIND_BINARY, +}; diff --git a/src/webrtc/framing.js b/src/webrtc/framing.js new file mode 100644 index 0000000..16ea6b2 --- /dev/null +++ b/src/webrtc/framing.js @@ -0,0 +1,339 @@ +'use strict'; + +// Framing for an RTCDataChannel: how wrpc packets and stream chunks ride a +// channel whose messages have a size limit. +// +// A WebSocket carries a JSON packet as one text frame and a stream chunk as +// one binary frame, however large. A data channel message is capped — +// 16 KiB is the only size every implementation agrees on, a modern browser +// negotiates more through `sctp.maxMessageSize` — and wrpc's batch frames +// and 64 KiB stream chunks are routinely over it. So every message is sent +// as binary (the channel's `binaryType` is set to 'arraybuffer'), split into +// fragments that fit, each with a one-byte header: +// +// bit 0 KIND 0 = a wrpc packet (UTF-8 JSON, what a WebSocket text frame carries) +// 1 = a binary stream chunk (a chunkEncode frame) +// bit 1 FIN 1 = the last fragment of this message +// bit 2 COMPRESSED 1 = the message is compressed (src/compression) — only once +// both ends negotiated it; reserved, and a protocol error, before +// bit 3-7 reserved, MUST be 0 — a set bit is a protocol error +// +// No message id, no sequence number: the channel is ordered and reliable, +// all fragments of one message are sent back to back, the KIND of a +// continuation must match the message it continues, and a fragment that +// is not the last carries at least one byte. The format is documented in +// docs/reference/protocol.md (WebRTC) and wire-format.md. +// +// This file is on the hot path (once per packet, once per chunk) and +// browser-budgeted: manual loops, no generators, no spread. + +const KIND_TEXT = 0; +const KIND_BINARY = 1; +const FLAG_FIN = 0b10; +const FLAG_COMPRESSED = 0b100; +const KIND_MASK = 0b01; +// What a continuation must repeat: the kind and the deflate flag. +const MESSAGE_MASK = KIND_MASK | FLAG_COMPRESSED; +const RESERVED_MASK = 0b11111000; +const HEADER_BYTES = 1; + +// The interop floor (RFC 8831 §6.6, what old Firefox honoured) — used when +// the implementation reports no size at all; and the ceiling above which +// one message would hold too much memory on the receiving side at once. +const MIN_MESSAGE_SIZE = 16 * 1024; +const MAX_MESSAGE_SIZE = 256 * 1024; +// Under this a peer's `a=max-message-size` is not a limit anyone needs +// (SCTP's own default is 64 KiB, the interop floor 16 KiB) but a demand +// for a fragment per few bytes — a send and a view per fragment, on both +// sides, for every packet: refused, and the link fails. +const MIN_NEGOTIABLE_SIZE = 1024; +// A peer that never sends FIN would otherwise grow the reassembly buffer +// without bound — and one sending a byte per fragment would cost a view +// object per byte long before the byte cap is near, so the fragment count +// is capped too: the byte cap in 1 KiB pieces (16 384 by default), never +// under 1024. +const DEFAULT_MAX_REASSEMBLY = 16 * 1024 * 1024; +const MIN_MAX_FRAGMENTS = 1024; +const defaultMaxFragments = (maxReassembly) => Math.max(MIN_MAX_FRAGMENTS, Math.ceil(maxReassembly / 1024)); + +class FramingError extends Error { + constructor(message, code) { + super(message); + this.name = 'FramingError'; + this.code = code; + } +} + +/** + * The message size to fragment at. `sctp.maxMessageSize` already reflects + * the REMOTE side's `a=max-message-size` (W3C §5.5), so it is the + * agreed-upon limit — capped at `ceiling`, and the interop floor when + * nothing usable is reported (no sctp, 0, 1, Infinity). A reported size + * UNDER the floor is honoured as-is: the floor is a fallback, never an + * override — down to MIN_NEGOTIABLE_SIZE, under which the peer's demand is + * refused with a FramingError ('message-size') for the link to fail on. + */ +const negotiateMessageSize = (sctp, ceiling = MAX_MESSAGE_SIZE) => { + const advertised = sctp !== null && sctp !== undefined ? sctp.maxMessageSize : 0; + const usable = typeof advertised === 'number' && Number.isFinite(advertised) && advertised > HEADER_BYTES; + if (!usable) return Math.min(MIN_MESSAGE_SIZE, ceiling); + const size = Math.floor(advertised); + if (size < MIN_NEGOTIABLE_SIZE) { + throw new FramingError(`peer max-message-size ${size} is under ${MIN_NEGOTIABLE_SIZE}`, 'message-size'); + } + return Math.min(size, ceiling); +}; + +// The `maxMessageSize` an application hands a raw-channel transport: the +// same floor and ceiling negotiation applies to a peer's advertisement, +// refused as a TypeError where the option is given rather than as a +// framing error at the first write (or a 256 KiB scratch buffer nobody +// asked for). Undefined is the default. +const checkMessageSize = (value, label) => { + if (value === undefined) return; + if (!Number.isInteger(value) || value < MIN_NEGOTIABLE_SIZE || value > MAX_MESSAGE_SIZE) { + throw new TypeError( + `${label}: maxMessageSize must be an integer between ${MIN_NEGOTIABLE_SIZE} and ${MAX_MESSAGE_SIZE}`, + ); + } +}; + +const TEXT_ENCODER = new TextEncoder(); +// fatal: an invalid byte sequence in a text frame is a protocol error, not +// a U+FFFD the JSON parser then trips over one layer up. +const TEXT_DECODER = new TextDecoder('utf-8', { fatal: true }); + +const toBytes = (input) => { + if (input instanceof Uint8Array) return input; + if (input instanceof ArrayBuffer) return new Uint8Array(input); + if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength); + throw new TypeError('framing: expected an ArrayBuffer or an ArrayBufferView'); +}; + +/** A packet's bytes as text — the decoder's own decode, for a packet inflated after decoding. */ +const decodeText = (bytes) => { + try { + return TEXT_DECODER.decode(bytes); + } catch { + throw new FramingError('invalid UTF-8 in a text frame', 'utf8'); + } +}; + +class FrameEncoder { + #room; + // One buffer per encoder, reused for every fragment: bench/rtc-framing.js + // measures a fresh Uint8Array per fragment at 3-7x LESS throughput (3.5M + // vs 25M frames/s at 64 B, 10K vs 34K at 1 MiB / 16 KiB fragments) — the + // allocation, not the copy, is the cost. The price is a contract — see + // encode(). + #scratch; + // Between the first fragment of a message and its last: a sink that + // threw while this is true left the peer a message with no end, and + // nothing sent after it would parse — the channel is done for. + partial = false; + + constructor(maxMessageSize) { + if (!Number.isInteger(maxMessageSize) || maxMessageSize <= HEADER_BYTES) { + throw new TypeError(`framing: maxMessageSize must be an integer above ${HEADER_BYTES}`); + } + this.maxMessageSize = maxMessageSize; + this.#room = maxMessageSize - HEADER_BYTES; + this.#scratch = new Uint8Array(maxMessageSize); + } + + /** + * Frames `bytes` as `kind`, handing each fragment to `sink` in order, + * synchronously — the caller sends them back to back, which is what makes + * the header's FIN bit enough. Returns the fragment count. + * + * CONTRACT: the frame handed to `sink` is a view over a buffer the NEXT + * fragment overwrites. Consume it inside the call — RTCDataChannel.send + * copies synchronously, which is the consumer this exists for — and never + * retain it; a sink that must keep it takes `frame.slice()`. + */ + encode(kind, bytes, sink) { + const total = bytes.length; + const room = this.#room; + const scratch = this.#scratch; + if (total <= room) { + scratch[0] = kind | FLAG_FIN; + scratch.set(bytes, HEADER_BYTES); + sink(scratch.subarray(0, total + HEADER_BYTES)); + return 1; + } + let offset = 0; + let count = 0; + this.partial = true; + while (offset < total) { + const size = total - offset < room ? total - offset : room; + scratch[0] = offset + size === total ? kind | FLAG_FIN : kind; + scratch.set(bytes.subarray(offset, offset + size), HEADER_BYTES); + sink(scratch.subarray(0, size + HEADER_BYTES)); + offset += size; + count++; + } + this.partial = false; + return count; + } + + /** + * A packet. UTF-8 is at most 3 bytes per UTF-16 code unit, so a short + * enough string is known to fit and is encoded straight into the scratch + * frame — no intermediate byte array (bench/rtc-framing.js: 8.3M vs 2M + * packets/s at 64 B; the common case is a packet well under the limit). + * Anything longer is encoded first and fragmented like bytes. Same sink + * contract as encode(). + */ + encodeText(text, sink) { + if (text.length * 3 <= this.#room) { + const scratch = this.#scratch; + const { written } = TEXT_ENCODER.encodeInto(text, scratch.subarray(HEADER_BYTES)); + scratch[0] = KIND_TEXT | FLAG_FIN; + sink(scratch.subarray(0, written + HEADER_BYTES)); + return 1; + } + return this.encode(KIND_TEXT, TEXT_ENCODER.encode(text), sink); + } +} + +// A fan-out's message, prepared once for every link it goes to: what fills +// the `frames` slot of the shared message (rpc/rooms.js) on a data channel, +// as PreparedFrames does on a WebSocket. A channel cannot share a FRAME — +// each link has its own message size, so its own fragments — but everything +// before the fragments is the same for every recipient: the UTF-8 of the +// text (or the attachments frame as it is), and under compression the body +// a codec makes of it, kept per codec id (per-message compression has no +// shared context, so one codec answers the same bytes for every link). +class SharedFrames { + constructor(text) { + const packet = typeof text === 'string'; + this.kind = packet ? KIND_TEXT : KIND_BINARY; + this.bytes = packet ? TEXT_ENCODER.encode(text) : text; + // Once a link compresses it: codec id -> the compressed body, a promise + // of it, or null — the codec threw, and the message goes plain. + this.bodies = null; + } +} + +class FrameDecoder { + // Whether the DEFLATE flag is accepted: set by the transport once both + // ends named the same codec, a reserved bit — a protocol error — before. + compressed = false; + + #maxReassembly; + #maxFragments; + #parts = null; + // The kind and flags of the message being reassembled (MESSAGE_MASK). + #kind = -1; + #size = 0; + + constructor({ maxReassembly = DEFAULT_MAX_REASSEMBLY, maxFragments = undefined } = {}) { + if (!Number.isInteger(maxReassembly) || maxReassembly <= 0) { + throw new TypeError('framing: maxReassembly must be a positive integer'); + } + if (maxFragments !== undefined && (!Number.isInteger(maxFragments) || maxFragments <= 0)) { + throw new TypeError('framing: maxFragments must be a positive integer'); + } + this.#maxReassembly = maxReassembly; + this.#maxFragments = maxFragments ?? defaultMaxFragments(maxReassembly); + } + + /** Bytes of the message being reassembled; 0 between messages. */ + get pending() { + return this.#size; + } + + /** + * Feeds one channel message. Returns `{ kind, data, compressed }` when it + * completes a message — `data` is a string for KIND_TEXT and a Uint8Array + * for KIND_BINARY, and for a DEFLATED message of either kind the bytes + * the codec produced, text only once the transport inflated them — or + * null while one is still being reassembled. A single-fragment binary + * message is answered as a view over the input (no copy): every channel + * message is a fresh buffer, so nothing aliases. Throws FramingError on a + * malformed frame; the decoder is reset so the caller can terminate + * cleanly. + */ + push(input) { + const frame = toBytes(input); + if (frame.length < HEADER_BYTES) throw this.#fail('empty frame', 'empty'); + const header = frame[0]; + if ((header & RESERVED_MASK) !== 0 || ((header & FLAG_COMPRESSED) !== 0 && !this.compressed)) { + throw this.#fail('reserved header bits set', 'reserved'); + } + const kind = header & MESSAGE_MASK; + const fin = (header & FLAG_FIN) !== 0; + const payload = frame.subarray(HEADER_BYTES); + // A fragment that continues carries something: an empty one costs the + // receiver a view and moves the byte cap not at all. + if (!fin && payload.length === 0) throw this.#fail('empty fragment', 'empty'); + if (this.#parts === null) { + if (fin) return this.#finish(kind, payload); + this.#parts = [payload]; + this.#kind = kind; + this.#size = payload.length; + this.#guard(); + return null; + } + if (kind !== this.#kind) throw this.#fail('continuation kind does not match the message', 'kind'); + this.#parts.push(payload); + this.#size += payload.length; + this.#guard(); + if (!fin) return null; + const parts = this.#parts; + const joined = new Uint8Array(this.#size); + let offset = 0; + for (let i = 0; i < parts.length; i++) { + joined.set(parts[i], offset); + offset += parts[i].length; + } + this.reset(); + return this.#finish(kind, joined); + } + + reset() { + this.#parts = null; + this.#kind = -1; + this.#size = 0; + } + + #guard() { + if (this.#size > this.#maxReassembly) throw this.#fail('message exceeds maxReassembly', 'too-large'); + if (this.#parts.length > this.#maxFragments) throw this.#fail('message exceeds maxFragments', 'fragments'); + } + + #fail(message, code) { + this.reset(); + return new FramingError(message, code); + } + + #finish(flags, bytes) { + const kind = flags & KIND_MASK; + if (flags !== kind) return { kind, data: bytes, compressed: true }; + if (kind === KIND_BINARY) return { kind, data: bytes, compressed: false }; + try { + return { kind, data: TEXT_DECODER.decode(bytes), compressed: false }; + } catch { + throw this.#fail('invalid UTF-8 in a text frame', 'utf8'); + } + } +} + +module.exports = { + KIND_TEXT, + KIND_BINARY, + FLAG_FIN, + FLAG_COMPRESSED, + HEADER_BYTES, + MIN_MESSAGE_SIZE, + MAX_MESSAGE_SIZE, + MIN_NEGOTIABLE_SIZE, + checkMessageSize, + DEFAULT_MAX_REASSEMBLY, + FramingError, + negotiateMessageSize, + decodeText, + FrameEncoder, + FrameDecoder, + SharedFrames, +}; diff --git a/src/webrtc/host.js b/src/webrtc/host.js new file mode 100644 index 0000000..04d8486 --- /dev/null +++ b/src/webrtc/host.js @@ -0,0 +1,291 @@ +'use strict'; + +// The server half of a wrpc peer: a router, a dispatcher and one Client +// per attached peer transport — the browser-safe subset of RpcServer, and +// deliberately not RpcServer itself. Sessions, the HTTP/REST/SSE surfaces +// and the cluster are dead weight in a page and drag node:crypto along; +// what a peer needs is exactly what is composed here from the same +// modules the server uses: Client (rpc/client.js), handleMessage / +// handleBinary (rpc/dispatcher.js), RoomRegistry + Broadcast +// (rpc/rooms.js) and the router's introspection. The ~60 lines that +// mirror RpcServer's #addClient / #withIntrospection / #target are a +// conscious duplication (see the plan in docs/guide/webrtc.md). +// +// Trust: a peer's procedures default to access 'session' like every wrpc +// procedure, and there is no session manager here. With `trust: 'link'` +// (the default) every attached client carries a frozen pseudo-session +// whose token is the peer id — a link only exists after signaling through +// a server that admitted both peers, plus the local accept() hook, so the +// link IS the authentication and handlers see `context.session.data.peer`. +// `trust: 'assertion'` is the same session, but attach() REQUIRES the +// peer's verified assertion claims (WrpcPeer checks the server-signed +// token against the link's DTLS certificate) and exposes them as +// `context.session.data.claims`: the server vouched, cryptographically, +// for exactly this endpoint. `trust: 'none'` leaves session null: only +// access 'public' procedures answer, and authorization is the +// application's, in hooks, from `context.meta.data.peer`. + +const { Emitter, isCodec, resolveGenerateId } = require('../utils.js'); +const { createLoggerWriter } = require('../logging.js'); +const { createServerTelemetry } = require('../telemetry/server.js'); +const { defineRouter, procedure, runHooksSafe } = require('../rpc/router.js'); +const { RoomRegistry, Broadcast } = require('../rpc/rooms.js'); +const { + Client, + DEFAULT_MAX_SUBSCRIPTIONS, + DEFAULT_MAX_CALLS, + DEFAULT_MAX_STREAMS, + buildMeta, +} = require('../rpc/client.js'); +const { dispatchMessage, dispatchBinary } = require('../rpc/dispatcher.js'); +const { DEFAULT_META_MAX } = require('../rpc/meta.js'); +const { isInboundTransport } = require('../rpc/serverTransport.js'); + +const TRUST = ['link', 'assertion', 'none']; +const ONCONNECT_STALL_MS = 5000; + +class PeerHost extends Emitter { + #otel; + #router; + #codec; + #log; + #roomsLog; + #rooms = new RoomRegistry(); + #clients = new Set(); + #byId = new Map(); + #limits; + #generateId; + #metaMax; + #trust; + #instance; + #attachments = true; + + constructor({ + router, + codec = null, + logger = false, + generateId = null, + introspection = true, + maxBatch = undefined, + maxSubscriptions = DEFAULT_MAX_SUBSCRIPTIONS, + maxCalls = DEFAULT_MAX_CALLS, + maxStreams = DEFAULT_MAX_STREAMS, + metaMaxBytes = DEFAULT_META_MAX, + trust = 'link', + instanceId = null, + telemetry = null, + attachments = true, + } = {}) { + super(); + if (!router || typeof router.getProcedure !== 'function') { + throw new TypeError('PeerHost: options.router (a Router from defineRouter) is required'); + } + if (codec !== null && !isCodec(codec)) { + throw new TypeError('PeerHost: options.codec must provide encode(packet)/decode(text)'); + } + if (!TRUST.includes(trust)) throw new TypeError(`PeerHost: options.trust must be one of ${TRUST.join(', ')}`); + if (instanceId !== null && (typeof instanceId !== 'string' || instanceId.includes('.') || instanceId === '')) { + throw new TypeError('PeerHost: options.instanceId must be a non-empty string without "."'); + } + this.#log = createLoggerWriter(logger); + // The same server-side writer RpcServer uses: spans for the calls this + // peer answers, the connection gauge, and the rtc instruments. + this.#otel = createServerTelemetry(telemetry); + this.#roomsLog = this.#log.child({ component: 'rooms' }); + this.#codec = codec && typeof codec.encode === 'function' && typeof codec.decode === 'function' ? codec : null; + const ids = resolveGenerateId(generateId, 'PeerHost'); + this.#generateId = ids.generate; + this.#metaMax = Number.isInteger(metaMaxBytes) && metaMaxBytes > 0 ? metaMaxBytes : DEFAULT_META_MAX; + // Binary attachments, off under a packet codec (which owns the wire). + this.#attachments = attachments !== false && !(codec && typeof codec.encode === 'function'); + this.#limits = { maxBatch, maxSubscriptions, maxCalls, maxStreams, attachments: this.#attachments }; + this.#trust = trust; + // The prefix of every client id here; a uuid has no '.', so the id + // parses like a server's would (instanceOfClientId). Minted by the + // generator, so an injected one owns this id like it owns the rest. + this.#instance = instanceId ?? ids.first; + if (String(this.#instance).includes('.')) { + const source = + instanceId === null || instanceId === undefined + ? 'generateId must not return' + : 'options.instanceId must not contain'; + throw new TypeError(`PeerHost: ${source} "."`); + } + this.#router = this.#withIntrospection(router, introspection); + if (this.#codec && this.#router.hasSerializers) { + throw new TypeError('PeerHost: options.codec and compiled response serializers are mutually exclusive'); + } + } + + get router() { + return this.#router; + } + + get rooms() { + return this.#rooms; + } + + /** A snapshot: safe to iterate while peers come and go. */ + get clients() { + return new Set(this.#clients); + } + + get instanceId() { + return this.#instance; + } + + get trust() { + return this.#trust; + } + + /** The telemetry writer (disabled-shaped when none was injected). */ + get otel() { + return this.#otel; + } + + getClient(id) { + return this.#byId.get(id); + } + + /** + * Attaches one peer: a persistent transport that announces inbound + * traffic as 'packet' (text) and 'chunk' (bytes) events — + * RtcPeerTransport, or anything shaped like it. `peer` is the remote id; + * `room` and `data` are what the peer said about itself at signaling + * time, `claims` what its verified assertion says — all frozen into the + * client's meta (and, under trust 'link'/'assertion', its session data). + * `peer`, `room` and `claims` are written OVER the roster data: nothing + * a peer said about itself can shadow who it is or what the server signed. + */ + attach(transport, { peer, room = null, data = null, claims = null } = {}) { + if (!isInboundTransport(transport)) { + throw new TypeError( + 'PeerHost.attach: a persistent transport (connection set) with write/send/error/close/on/once/off is required', + ); + } + if (typeof peer !== 'string' || peer.length === 0) { + throw new TypeError('PeerHost.attach: peer must be a non-empty string'); + } + if (claims !== null && (typeof claims !== 'object' || Array.isArray(claims))) { + throw new TypeError('PeerHost.attach: claims must be an object'); + } + if (this.#trust === 'assertion' && claims === null) { + throw new TypeError("PeerHost.attach: trust 'assertion' requires the peer's verified claims"); + } + if (this.#codec) transport.codec = this.#codec; + // Fields, not setRevision: a transport here is structural (attach). + if (!this.#attachments) { + transport.revision = 1; + transport.attachments = false; + } + // The roster data first — it is the peer's word — and the facts over it. + // `delete`, not `claims: undefined`: the key must be absent when there + // are none, whatever the roster data tried to put there. + const plain = data !== null && typeof data === 'object' && !Array.isArray(data); + const about = { __proto__: null, ...(plain ? data : {}), peer, room }; + if (claims === null) delete about.claims; + else about.claims = Object.freeze({ ...claims }); + Object.freeze(about); + const client = new Client(transport, { + codec: this.#codec, + sessions: null, + rooms: this.#rooms, + server: this, + log: this.#log, + otel: this.#otel, + maxSubscriptions: this.#limits.maxSubscriptions, + maxCalls: this.#limits.maxCalls, + maxStreams: this.#limits.maxStreams, + generateId: this.#generateId, + meta: buildMeta({ data: about, remoteAddress: peer }), + metaMax: this.#metaMax, + }); + if (this.#trust !== 'none') client.session = Object.freeze({ token: peer, data: about }); + this.#clients.add(client); + this.#byId.set(client.id, client); + this.#otel.recordConnection(1, transport.kind, transport.revision); + // Router-level connection hooks, ordered before dispatch exactly as + // RpcServer does it: client.ready gates the access check. + const { onConnect, onDisconnect } = this.#router.connectionHooks; + if (onConnect.length > 0) { + const hooks = runHooksSafe(onConnect, client, null, this.#log, 'onConnect'); + client.ready = client.sessionReady.then(() => hooks); + const stall = setTimeout(() => { + this.#log.warn({ event: 'onConnect.stalled', peer, ms: ONCONNECT_STALL_MS }); + }, ONCONNECT_STALL_MS); + if (typeof stall.unref === 'function') stall.unref(); + void client.ready.then(() => clearTimeout(stall)); + } + transport.on('packet', (text) => dispatchMessage(client, text, this.#router, this.#limits)); + transport.on('chunk', (bytes) => dispatchBinary(client, bytes, this.#router, this.#limits)); + transport.once('close', () => { + const payload = onDisconnect.length > 0 ? { rooms: client.rooms } : null; + client.destroy(); + this.#clients.delete(client); + this.#byId.delete(client.id); + this.#otel.recordConnection(-1, transport.kind, transport.revision); + if (onDisconnect.length > 0) void runHooksSafe(onDisconnect, client, payload, this.#log, 'onDisconnect'); + void this.emit('detach', client).catch((error) => this.#log.error({ err: error, event: 'listener.detach' })); + }); + void this.emit('attach', client).catch((error) => this.#log.error({ err: error, event: 'listener.attach' })); + return client; + } + + #target() { + return new Broadcast({ + registry: this.#rooms, + clients: () => this.#clients, + publish: null, + cluster: null, + log: this.#roomsLog, + otel: this.#otel, + codec: this.#codec, + attachments: this.#attachments, + }); + } + + /** The newest protocol revision this host speaks: 1 under `attachments: false` or a packet codec. */ + get revision() { + return this.#attachments ? 2 : 1; + } + + /** Everyone in any of `rooms`, each client once. */ + to(...rooms) { + return this.#target().to(...rooms); + } + + /** Every attached peer, minus `clients`. */ + except(...clients) { + return this.#target().except(...clients); + } + + /** Every attached peer; returns the recipient count. */ + broadcast(name, data) { + return this.#target().emit(name, data); + } + + /** Closes every attached peer's link. */ + close() { + for (const client of [...this.#clients]) client.close(); + } + + // Mirrors RpcServer: system/introspect mounted public unless the router + // brings its own; `introspection` is true | 'session' | false | { access, schemas }. + #withIntrospection(router, mode) { + const config = mode !== null && typeof mode === 'object' ? mode : { access: mode }; + const { access = true, schemas = true } = config; + if (access === false || mode === false) return router; + if (router.getProcedure('system', '*', 'introspect')) return router; + const system = defineRouter({ + system: { + introspect: procedure({ + access: access === 'session' ? 'session' : 'public', + handler: async (_context, units) => this.#router.introspect(units, { schemas }), + }), + }, + }); + return router.merge(system); + } +} + +module.exports = { PeerHost, isInboundTransport }; diff --git a/src/webrtc/ids.js b/src/webrtc/ids.js new file mode 100644 index 0000000..dc340bd --- /dev/null +++ b/src/webrtc/ids.js @@ -0,0 +1,29 @@ +'use strict'; + +// Peer ids and instances become Map keys, session tokens, log fields and +// URLs on every peer, and a roster holds them by the hundred: bounded the +// same way everywhere — the signaling unit refuses a longer one, the +// client half and WrpcPeer ignore it. Require-free, so the browser bundle +// and the Node-only unit share this one number — and the one other leaf +// helper the link layers share, `deferred`. + +const MAX_ID_LENGTH = 256; + +const isPeerId = (value, max = MAX_ID_LENGTH) => typeof value === 'string' && value.length > 0 && value.length <= max; + +// A promise with its two handles, for what is settled from elsewhere (a +// link's open, a peer link's ready). Handled from birth: one that rejects +// with nobody waiting — a dial that fails before anyone asked — must not be +// an unhandled rejection; whoever awaits it attaches their own handlers. +const deferred = () => { + let resolve = null; + let reject = null; + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + promise.catch(() => {}); + return { promise, resolve, reject }; +}; + +module.exports = { MAX_ID_LENGTH, isPeerId, deferred }; diff --git a/src/webrtc/index.js b/src/webrtc/index.js new file mode 100644 index 0000000..488d000 --- /dev/null +++ b/src/webrtc/index.js @@ -0,0 +1,66 @@ +'use strict'; + +// The Node barrel of @alexify/wrpc/webrtc: the browser surface plus what +// only a server needs — the signaling unit and its connection hooks, and +// attachChannel, the attachPort of WebRTC. + +const { buildMeta } = require('../rpc/client.js'); +const { createSignalingUnit, createSignalingHooks } = require('./signaling.js'); +const { createAssertionIssuer, generateAssertionKeys } = require('./assertionIssuer.js'); +const { RtcPeerTransport } = require('./transport.js'); + +/** + * A raw data channel the application negotiated itself, attached to an + * RpcServer (anything with `attach(transport, { meta })`) — the level under + * RtcLink and WrpcPeer. The core knows no framing, which is why this lives + * here and not on the server: it builds the host half of the transport + * over the channel and hands it to `server.attach`. No session at attach (a + * channel carries no request; what the application knows goes in + * `headers`/`data`), no ICE restart or redial (the peer connection is the + * application's). `peer` is the client's source; `maxMessageSize` is what + * the far side's channel accepts (the 16 KiB interop floor by default — + * negotiateMessageSize(pc.sctp) for more). A framing error from the peer + * is warned on the client's log as 'rtc.channel.error' and closes the channel. + */ +const attachChannel = (server, channel, options = {}) => { + if (typeof server?.attach !== 'function') throw new TypeError('attachChannel: a server with attach() is required'); + const { + peer, + headers, + data, + remoteAddress, + maxMessageSize, + framing, + highWaterMark, + lowWaterMark, + maxBackpressure, + compression, + // An RTCDataChannel is DTLS end to end, so under `encryption.required` + // the channel vouches for itself; `false` is for a channel the + // application knows to be relayed in the clear. + encrypted = true, + } = options; + let client = null; + const transport = new RtcPeerTransport(channel, { + peer, + maxMessageSize, + framing, + highWaterMark, + lowWaterMark, + maxBackpressure, + compression, + onError: (error) => client?.log.warn({ event: 'rtc.channel.error', peer: transport.source, err: error }), + }); + const observed = headers || data || remoteAddress ? buildMeta({ headers, data, remoteAddress }) : null; + client = server.attach(transport, { meta: observed, encrypted: encrypted === true }); + return client; +}; + +module.exports = { + ...require('./browser.js'), + createSignalingUnit, + createSignalingHooks, + createAssertionIssuer, + generateAssertionKeys, + attachChannel, +}; diff --git a/src/webrtc/link.js b/src/webrtc/link.js new file mode 100644 index 0000000..997a673 --- /dev/null +++ b/src/webrtc/link.js @@ -0,0 +1,727 @@ +'use strict'; + +// One WebRTC link between two wrpc peers: an RTCPeerConnection carrying two +// negotiated data channels — one per direction of the protocol's +// client → server relationship — negotiated through an injected signaling +// function, kept alive through ICE restarts, and re-dialled from scratch +// when a restart cannot save it. +// +// Roles are a function of the two ids, so both ends compute them without a +// message: the peer whose id sorts first is the INITIATOR — it makes the +// offer and is the impolite side of perfect negotiation +// (https://w3c.github.io/webrtc-pc/#perfect-negotiation-example); the +// other is polite. The initiator's client writes on the `initiator` +// channel, the responder's on the `responder` channel. +// +// Written against the port (src/webrtc/port.js), so it runs unchanged in a +// browser and over an injected Node implementation. Two things the port's +// contract suite taught about real implementations are honoured here: a pc +// may already be offering the moment a channel is created (libdatachannel), +// so the initiator offers explicitly and negotiationneeded is a guarded +// second trigger; and signalingState is not trusted after close() — +// the link keeps its own state. +// +// Everything asynchronous surfaces through events, never a thrown promise +// nobody awaits: 'state' on every transition, 'open' when both channels +// are open, 'close' ({ reason, remote }) once when the link is finished for +// good, 'error' for background failures. A missing 'error' listener downgrades to a log line +// (Emitter throws on an unheard 'error'). + +const { Emitter } = require('../utils.js'); +const { createLoggerWriter } = require('../logging.js'); +const { isRtcAdapter, isRtcPeerConnection } = require('./port.js'); +const { negotiateMessageSize, MIN_MESSAGE_SIZE } = require('./framing.js'); +const { sdpFingerprint } = require('./assertions.js'); +const { deferred } = require('./ids.js'); + +// Candidates held until the remote description arrives; past it they are +// dropped, said once per dial — a peer used to be able to fill memory +// with them before ever sending a description. +const MAX_PENDING_CANDIDATES = 256; + +// Negotiated data channels are not described in the SDP: both peers MUST +// be configured identically, or a channel never opens (it surfaces as the +// connect timeout). Configurable for an application keeping its own +// channels on the same connection. +const DEFAULT_CHANNELS = Object.freeze({ initiator: 0, responder: 1, label: 'wrpc' }); +const MAX_CHANNEL_ID = 65534; + +const DEFAULT_CONNECT_TIMEOUT = 30 * 1000; +const DEFAULT_RESTART_TIMEOUT = 15 * 1000; + +const STATES = ['new', 'connecting', 'connected', 'reconnecting', 'failed', 'closed']; + +// What a goodbye says on the wire — `{ type: 'close', reason }` — and what +// a closure reports: the application ended the link, the sender would not +// have the peer (a failed assertion, its accept() hook), or its redial +// budget ran out. A close naming none is a goodbye; one naming something +// else is 'unknown' — a closed set is what keeps the metric's label +// bounded. 'abandoned' never crosses the wire: it is this side closing +// without a word (a stale incarnation, a responder giving up). +const CLOSE_REASONS = Object.freeze(['goodbye', 'refused', 'gave-up']); +const closeReason = (value) => (value === undefined ? 'goodbye' : CLOSE_REASONS.includes(value) ? value : 'unknown'); + +const normalizeChannels = (channels = {}) => { + if (typeof channels !== 'object' || channels === null) throw new TypeError('channels must be an object'); + const { + initiator = DEFAULT_CHANNELS.initiator, + responder = DEFAULT_CHANNELS.responder, + label = DEFAULT_CHANNELS.label, + } = channels; + for (const [name, id] of [ + ['initiator', initiator], + ['responder', responder], + ]) { + if (!Number.isInteger(id) || id < 0 || id > MAX_CHANNEL_ID) { + throw new TypeError(`channels.${name} must be an integer in 0..${MAX_CHANNEL_ID}`); + } + } + if (initiator === responder) throw new TypeError('channels.initiator and channels.responder must differ'); + if (typeof label !== 'string' || label.length === 0) throw new TypeError('channels.label must be a non-empty string'); + return Object.freeze({ initiator, responder, label }); +}; + +// In a browser setTimeout returns a number and there is nothing to unref; +// in Node a link's timers must not keep a process alive on their own. +const unref = (timer) => { + if (timer && typeof timer.unref === 'function') timer.unref(); + return timer; +}; + +// What travels through signaling: plain data, never the platform object. +const describe = (description) => ({ type: description.type, sdp: description.sdp }); +const candidateJson = (candidate) => { + if (candidate === null || candidate === undefined) return null; + if (typeof candidate.toJSON === 'function') return candidate.toJSON(); + const { candidate: text, sdpMid = null, sdpMLineIndex = null, usernameFragment } = candidate; + return usernameFragment === undefined + ? { candidate: text, sdpMid, sdpMLineIndex } + : { candidate: text, sdpMid, sdpMLineIndex, usernameFragment }; +}; + +class RtcLink extends Emitter { + #localId; + #remoteId; + #adapter; + #configuration; + #signal; + #channels; + #connectTimeout; + #restartTimeout; + #log; + + #state = 'new'; + #pc = null; + #clientChannel = null; + #hostChannel = null; + #unbind = null; + #maxMessageSize = MIN_MESSAGE_SIZE; + + // Perfect negotiation bookkeeping, per pc. + #makingOffer = false; + #ignoreOffer = false; + #restarting = false; + #pendingCandidates = []; + #candidatesOverflowed = false; + + #opened = null; // { promise, resolve, reject } for the current dial + #connectTimer = null; + #restartTimer = null; + #closeSent = false; + #closure = null; // { reason, remote } once closed + // What this side announces in every description it sends (`caps`), and + // what the peer's last description announced — the negotiation the + // channels have no handshake of their own for (per-message compression). + #caps; + #peerCaps = null; + // The certificate the peer's last description declared: a new one is a + // new pc on the other side, whatever this side's pc thinks. + #remoteFingerprint = null; + + constructor({ + localId, + remoteId, + adapter, + signal, + configuration = {}, + channels = DEFAULT_CHANNELS, + connectTimeout = DEFAULT_CONNECT_TIMEOUT, + restartTimeout = DEFAULT_RESTART_TIMEOUT, + log = null, + caps = null, + }) { + super(); + if (caps !== null && (typeof caps !== 'object' || Array.isArray(caps))) { + throw new TypeError('caps must be an object or null'); + } + this.#caps = caps; + if (typeof localId !== 'string' || localId.length === 0) throw new TypeError('localId must be a non-empty string'); + if (typeof remoteId !== 'string' || remoteId.length === 0) { + throw new TypeError('remoteId must be a non-empty string'); + } + if (localId === remoteId) throw new TypeError('localId and remoteId must differ'); + if (!isRtcAdapter(adapter)) throw new TypeError('adapter must satisfy the RtcAdapter contract'); + if (typeof signal !== 'function') throw new TypeError('signal must be a function'); + this.#localId = localId; + this.#remoteId = remoteId; + this.#adapter = adapter; + this.#signal = signal; + this.#configuration = configuration; + this.#channels = normalizeChannels(channels); + this.#connectTimeout = connectTimeout; + this.#restartTimeout = restartTimeout; + this.#log = createLoggerWriter(log ?? globalThis.console).child({ peer: remoteId }); + } + + get localId() { + return this.#localId; + } + + get remoteId() { + return this.#remoteId; + } + + /** The peer whose id sorts first offers; it is also the impolite side. */ + get initiator() { + return this.#localId < this.#remoteId; + } + + get polite() { + return !this.initiator; + } + + get state() { + return this.#state; + } + + get channels() { + return this.#channels; + } + + get pc() { + return this.#pc; + } + + /** The channel MY client writes on (the peer's host reads it). */ + get clientChannel() { + return this.#clientChannel; + } + + /** The channel MY host reads (the peer's client writes on it). */ + get hostChannel() { + return this.#hostChannel; + } + + /** What the pair negotiated (framing splits at it); the floor until connected. */ + get maxMessageSize() { + return this.#maxMessageSize; + } + + /** What the peer's last description announced, or null before one arrived (or when it announced nothing). */ + get peerCaps() { + return this.#peerCaps; + } + + get open() { + return this.#state === 'connected'; + } + + /** Dials: creates the connection and both channels; the initiator offers. */ + start() { + if (this.#state !== 'new') throw new Error(`RtcLink.start(): already ${this.#state}`); + this.#dial('connecting'); + } + + /** + * A fresh connection after a failure: same ids, same roles, a new pc. + * Only from 'failed' — a link that is dialling or connected is left alone, + * so a responder that already re-dialled on the initiator's new offer is + * not torn down by its own owner's redial. + */ + redial() { + if (this.#state !== 'failed') return false; + this.#dial('reconnecting'); + return true; + } + + /** Resolves when both channels are open; rejects when this dial fails or the link closes. */ + waitOpen() { + if (this.#state === 'connected') return Promise.resolve(); + if (this.#state === 'closed') return Promise.reject(new Error('RtcLink is closed')); + if (this.#state === 'failed') return Promise.reject(new Error('RtcLink failed')); + if (this.#state === 'new') return Promise.reject(new Error('RtcLink not started')); + return this.#opened.promise; + } + + /** + * An ICE restart on the live connection (a NAT rebinding, a network + * switch): the channels survive it. Either side may ask; the initiator + * does so on its own when ICE fails. + */ + restart() { + const pc = this.#pc; + if (!pc || this.#state === 'closed' || this.#state === 'failed') return; + // One restart at a time: a caller asking again while one is under way + // (a heartbeat timing out on every reconnect over the dead path) must + // not re-arm the timer, or the restart never gets to fail. + if (this.#restarting) return; + this.#restarting = true; + this.#armRestartTimer(); + void this.emit('restart', { outcome: 'requested' }).catch((error) => this.#error(error, 'listener.restart')); + if (typeof pc.restartIce === 'function') { + pc.restartIce(); + // An implementation without negotiationneeded would leave restartIce + // pending forever; a polite peer waits for the initiator's offer, an + // initiator offers itself either way. + if (this.initiator) void this.#offer(); + } else { + void this.#offer({ iceRestart: true }); + } + } + + /** Why and by whom the link closed — `{ reason, remote }` — or null while it has not. */ + get closure() { + return this.#closure; + } + + /** From the signaler: a description, a candidate, or the peer's goodbye. */ + async receive(message) { + if (this.#state === 'closed') return; + if (typeof message !== 'object' || message === null) return void this.#log.warn({ event: 'rtc.signal.malformed' }); + const { type } = message; + if (type === 'close') return void this.#finish(closeReason(message.reason), false, true); + if (type === 'description') { + // Read before the description is applied, so the caps are known by + // the time the channels open and the transports attach. + const { caps } = message; + this.#peerCaps = typeof caps === 'object' && caps !== null && !Array.isArray(caps) ? caps : null; + return void (await this.#receiveDescription(message.description)); + } + if (type === 'candidate') return void (await this.#receiveCandidate(message.candidate)); + this.#log.warn({ event: 'rtc.signal.unknown', type }); + } + + /** Tells the peer why (a goodbye by default), closes the connection, emits 'close' once. */ + close(reason = 'goodbye') { + if (!CLOSE_REASONS.includes(reason)) { + throw new TypeError(`RtcLink.close: reason must be one of ${CLOSE_REASONS.join(', ')}`); + } + this.#finish(reason, true, false); + } + + /** + * Closes without telling the peer: after a signaling reset or a change of + * incarnation a goodbye would reach a stranger, and a responder giving up + * must not end the link its initiator may still rebuild. The peer learns + * through the roster or through ICE. + */ + abandon(reason = 'abandoned') { + if (reason !== 'abandoned' && !CLOSE_REASONS.includes(reason)) { + throw new TypeError(`RtcLink.abandon: reason must be 'abandoned' or one of ${CLOSE_REASONS.join(', ')}`); + } + this.#finish(reason, false, false); + } + + /** + * Fails a CONNECTED link from outside — the peer knocked on a link it + * lost while this side's pc never noticed — so the owner's redial cycle + * rebuilds it. A no-op, false, while dialling, failed or closed. + */ + fail(error) { + if (this.#state !== 'connected') return false; + this.#fail(error); + return true; + } + + // ---- dialling + + #dial(state) { + // A dial still in progress is replaced: whoever waited on it learns so. + const replaced = this.#opened; + this.#opened = null; + this.#teardownPc(); + if (replaced) replaced.reject(new Error('RtcLink re-dialled')); + const { initiator, responder, label } = this.#channels; + const mine = this.initiator ? initiator : responder; + const theirs = this.initiator ? responder : initiator; + let pc = null; + let clientChannel; + let hostChannel; + try { + pc = this.#adapter.createPeerConnection(this.#configuration); + if (!isRtcPeerConnection(pc)) { + throw new TypeError('adapter.createPeerConnection() did not return a peer connection'); + } + // Both channels exist before the first offer: the SDP needs an + // m=application section, and a negotiated channel is never announced. + clientChannel = pc.createDataChannel(label, { negotiated: true, id: mine, ordered: true }); + hostChannel = pc.createDataChannel(label, { negotiated: true, id: theirs, ordered: true }); + } catch (error) { + // No connection to be had (an adapter out of resources, a browser + // past its peer-connection cap): a first dial is the caller's to + // hear — start() throws — and a redial, made from a timer or a + // peer's offer, is reported and leaves the link failed, never an + // uncaught exception. + if (pc !== null) { + try { + pc.close(); + } catch { + // Already closed. + } + } + if (state === 'connecting') throw error; + this.#error(error, 'dial'); + return; + } + this.#pc = pc; + this.#makingOffer = false; + this.#ignoreOffer = false; + this.#restarting = false; + this.#pendingCandidates = []; + this.#candidatesOverflowed = false; + this.#maxMessageSize = MIN_MESSAGE_SIZE; + // A fresh pc has seen no remote yet: the first description on it sets + // the certificate, whatever the pc before it had seen. + this.#remoteFingerprint = null; + this.#opened = deferred(); + clientChannel.binaryType = 'arraybuffer'; + hostChannel.binaryType = 'arraybuffer'; + this.#clientChannel = clientChannel; + this.#hostChannel = hostChannel; + this.#bind(pc, clientChannel, hostChannel); + this.#setState(state); + this.#armConnectTimer(); + if (this.initiator) void this.#offer(); + } + + #bind(pc, clientChannel, hostChannel) { + const onNegotiationNeeded = () => { + if (this.#pc !== pc) return; + // The responder only offers for a restart it asked for itself; + // otherwise the initiator's offer is the one that counts. + if (!this.initiator && !this.#restarting) return; + void this.#offer(); + }; + const onIceCandidate = ({ candidate }) => { + if (this.#pc !== pc) return; + this.#send({ type: 'candidate', candidate: candidateJson(candidate) }); + }; + const onConnectionState = () => { + if (this.#pc !== pc) return; + this.#onConnectionState(pc); + }; + const onOpen = () => { + if (this.#pc !== pc) return; + if (clientChannel.readyState === 'open' && hostChannel.readyState === 'open') this.#onOpen(pc); + }; + const onChannelClose = (which) => () => { + if (this.#pc !== pc) return; + void this.emit('channel-close', { which }).catch((error) => this.#error(error, 'listener.channel-close')); + // A channel does not come back on its own: the link is down until a + // redial, whichever side closed it. + if (this.#state === 'connected' || this.#state === 'connecting' || this.#state === 'reconnecting') { + this.#fail(new Error(`data channel '${which}' closed`)); + } + }; + const onChannelError = (event) => { + if (this.#pc !== pc) return; + this.#error(event?.error ?? new Error('data channel error'), 'channel'); + }; + const onClientClose = onChannelClose('client'); + const onHostClose = onChannelClose('host'); + pc.addEventListener('negotiationneeded', onNegotiationNeeded); + pc.addEventListener('icecandidate', onIceCandidate); + pc.addEventListener('connectionstatechange', onConnectionState); + pc.addEventListener('iceconnectionstatechange', onConnectionState); + clientChannel.addEventListener('open', onOpen); + hostChannel.addEventListener('open', onOpen); + clientChannel.addEventListener('close', onClientClose); + hostChannel.addEventListener('close', onHostClose); + clientChannel.addEventListener('error', onChannelError); + hostChannel.addEventListener('error', onChannelError); + this.#unbind = () => { + pc.removeEventListener('negotiationneeded', onNegotiationNeeded); + pc.removeEventListener('icecandidate', onIceCandidate); + pc.removeEventListener('connectionstatechange', onConnectionState); + pc.removeEventListener('iceconnectionstatechange', onConnectionState); + clientChannel.removeEventListener('open', onOpen); + hostChannel.removeEventListener('open', onOpen); + clientChannel.removeEventListener('close', onClientClose); + hostChannel.removeEventListener('close', onHostClose); + clientChannel.removeEventListener('error', onChannelError); + hostChannel.removeEventListener('error', onChannelError); + }; + } + + #teardownPc() { + if (this.#unbind) this.#unbind(); + this.#unbind = null; + const pc = this.#pc; + this.#pc = null; + this.#clientChannel = null; + this.#hostChannel = null; + this.#clearTimers(); + if (pc) { + try { + pc.close(); + } catch { + // already closed by the implementation + } + } + } + + // ---- perfect negotiation + + async #offer(options = undefined) { + const pc = this.#pc; + if (!pc || this.#makingOffer) return; + this.#makingOffer = true; + try { + if (options?.iceRestart) { + const offer = await pc.createOffer({ iceRestart: true }); + if (this.#pc !== pc) return; + await pc.setLocalDescription(offer); + } else { + await pc.setLocalDescription(); + } + if (this.#pc !== pc) return; + await this.#send(this.#describe(pc)); + } catch (error) { + if (this.#pc === pc) this.#error(error, 'offer'); + } finally { + if (this.#pc === pc) this.#makingOffer = false; + } + } + + async #receiveDescription(description) { + if (typeof description !== 'object' || description === null || typeof description.type !== 'string') { + return void this.#log.warn({ event: 'rtc.signal.malformed', what: 'description' }); + } + const fingerprint = sdpFingerprint(description.sdp); + if (description.type === 'offer') { + // An offer for a link that failed (or never dialled on this side) is + // the initiator's redial arriving first: follow it onto a fresh pc. + // So is an offer under a NEW certificate on a link this side still + // sees as connected — the initiator redialled after a failure only + // it noticed, and a fresh pc cannot renegotiate the old one: it was + // never its own. (An ICE restart keeps the certificate.) + const changed = + fingerprint !== null && this.#remoteFingerprint !== null && fingerprint !== this.#remoteFingerprint; + if (this.#state === 'failed' || this.#pc === null || changed) { + if (this.#state === 'new' || this.#state === 'closed') return; + this.#dial('reconnecting'); + } + } + if (fingerprint !== null) this.#remoteFingerprint = fingerprint; + const pc = this.#pc; + if (!pc) return; + const offerCollision = description.type === 'offer' && (this.#makingOffer || pc.signalingState !== 'stable'); + this.#ignoreOffer = !this.polite && offerCollision; + if (this.#ignoreOffer) return; + try { + await pc.setRemoteDescription(description); + if (this.#pc !== pc) return; + if (description.type === 'offer') { + await pc.setLocalDescription(); + if (this.#pc !== pc) return; + await this.#send(this.#describe(pc)); + } + await this.#flushCandidates(pc); + } catch (error) { + if (this.#pc === pc) this.#error(error, 'description'); + } + } + + async #receiveCandidate(candidate) { + const pc = this.#pc; + if (!pc) return; + if (!pc.remoteDescription) { + if (this.#pendingCandidates.length < MAX_PENDING_CANDIDATES) this.#pendingCandidates.push(candidate); + else if (!this.#candidatesOverflowed) { + this.#candidatesOverflowed = true; + this.#log.warn({ event: 'rtc.signal.overflow', what: 'candidates', max: MAX_PENDING_CANDIDATES }); + } + return; + } + await this.#addCandidate(pc, candidate); + } + + async #flushCandidates(pc) { + const pending = this.#pendingCandidates; + this.#pendingCandidates = []; + for (let i = 0; i < pending.length; i++) { + if (this.#pc !== pc) return; + await this.#addCandidate(pc, pending[i]); + } + } + + async #addCandidate(pc, candidate) { + try { + await pc.addIceCandidate(candidate ?? null); + } catch (error) { + // A candidate for an offer we ignored, or an end-of-candidates an + // implementation refuses: both harmless, per the recipe. + if (candidate === null || candidate === undefined || this.#ignoreOffer) return; + if (this.#pc === pc) this.#error(error, 'candidate'); + } + } + + // ---- connection lifecycle + + #onConnectionState(pc) { + const ice = pc.iceConnectionState; + const connection = pc.connectionState; + if (ice === 'failed' || connection === 'failed') { + if (this.#restarting) return; // the restart timer decides + this.#log.warn({ event: 'rtc.ice.failed', state: this.#state }); + // Only the initiator restarts, so the two sides never race two + // restarts; the responder waits for it under the same timer. + if (this.initiator) this.restart(); + else { + this.#restarting = true; + this.#armRestartTimer(); + void this.emit('restart', { outcome: 'requested' }).catch((error) => this.#error(error, 'listener.restart')); + } + return; + } + if (connection === 'closed') { + if (this.#state !== 'closed' && this.#state !== 'failed') this.#fail(new Error('peer connection closed')); + return; + } + if ((ice === 'connected' || ice === 'completed' || connection === 'connected') && this.#restarting) { + this.#restarting = false; + this.#clearRestartTimer(); + this.#log.info({ event: 'rtc.ice.restarted' }); + void this.emit('restart', { outcome: 'recovered' }).catch((error) => this.#error(error, 'listener.restart')); + } + } + + #onOpen(pc) { + if (this.#state === 'connected') return; + // A peer demanding fragments under 1 KiB is refused here, before the + // link is 'connected': the failure runs the ordinary redial cycle. + let maxMessageSize; + try { + maxMessageSize = negotiateMessageSize(pc.sctp ?? null); + } catch (error) { + return void this.#fail(error); + } + this.#clearTimers(); + this.#restarting = false; + this.#maxMessageSize = maxMessageSize; + this.#setState('connected'); + const opened = this.#opened; + if (opened) opened.resolve(); + void this.emit('open').catch((error) => this.#error(error, 'listener.open')); + } + + #fail(error) { + if (this.#state === 'closed' || this.#state === 'failed') return; + this.#log.warn({ event: 'rtc.link.failed', reason: error.message }); + const opened = this.#opened; + this.#opened = null; + this.#teardownPc(); + this.#setState('failed'); + if (opened) opened.reject(error); + } + + #finish(reason, tell, remote) { + if (this.#state === 'closed') return; + const closure = Object.freeze({ reason, remote }); + this.#closure = closure; + if (tell && !this.#closeSent) { + this.#closeSent = true; + this.#send({ type: 'close', reason }); + } + const opened = this.#opened; + this.#opened = null; + this.#teardownPc(); + this.#setState('closed', closure); + if (opened) opened.reject(new Error('RtcLink is closed')); + void this.emit('close', closure).catch((error) => this.#error(error, 'listener.close')); + } + + // ---- timers + + #armConnectTimer() { + this.#clearConnectTimer(); + if (!(this.#connectTimeout > 0)) return; + this.#connectTimer = unref( + setTimeout(() => { + this.#connectTimer = null; + this.#fail(new Error(`RtcLink connect timeout after ${this.#connectTimeout} ms`)); + }, this.#connectTimeout), + ); + } + + #armRestartTimer() { + this.#clearRestartTimer(); + if (!(this.#restartTimeout > 0)) return; + this.#restartTimer = unref( + setTimeout(() => { + this.#restartTimer = null; + void this.emit('restart', { outcome: 'failed' }).catch((error) => this.#error(error, 'listener.restart')); + this.#fail(new Error(`ICE restart did not reconnect within ${this.#restartTimeout} ms`)); + }, this.#restartTimeout), + ); + } + + #clearConnectTimer() { + clearTimeout(this.#connectTimer); + this.#connectTimer = null; + } + + #clearRestartTimer() { + clearTimeout(this.#restartTimer); + this.#restartTimer = null; + } + + #clearTimers() { + this.#clearConnectTimer(); + this.#clearRestartTimer(); + } + + // ---- plumbing + + // A description signal, with this side's caps when it has any — every + // description, so a redial's fresh pc and an ICE restart announce them + // again and the peer never reads a stale answer. + #describe(pc) { + const message = { type: 'description', description: describe(pc.localDescription) }; + if (this.#caps !== null) message.caps = this.#caps; + return message; + } + + #setState(state, extra = null) { + const previous = this.#state; + if (previous === state) return; + this.#state = state; + this.#log.debug({ event: 'rtc.link.state', state, previous, ...extra }); + void this.emit('state', state).catch((error) => this.#error(error, 'listener.state')); + } + + #send(message) { + let result; + try { + result = this.#signal(message); + } catch (error) { + this.#error(error, 'signal'); + return Promise.resolve(); + } + if (result && typeof result.then === 'function') { + return result.then( + () => undefined, + (error) => this.#error(error, 'signal'), + ); + } + return Promise.resolve(); + } + + #error(error, origin) { + this.#log.error({ event: 'rtc.link.error', origin, err: error }); + if (this.listenerCount('error') === 0) return; + void this.emit('error', error).catch(() => {}); + } +} + +module.exports = { RtcLink, normalizeChannels, DEFAULT_CHANNELS, MAX_CHANNEL_ID, STATES, CLOSE_REASONS }; diff --git a/src/webrtc/mesh.js b/src/webrtc/mesh.js new file mode 100644 index 0000000..85ac66c --- /dev/null +++ b/src/webrtc/mesh.js @@ -0,0 +1,360 @@ +'use strict'; + +// Mesh: everyone in a signaling room, linked to everyone. Joining hands +// the roster to the peer, which links with each member — from either side, +// since connect() knocks when this side is not the initiator — and a +// member arriving later is linked as it joins. Each open link's host-side +// Client is kept in the room `mesh:` on this peer's PeerHost, which +// is what makes broadcast() and ask() one Broadcast fan-out rather than a +// loop over links: the payload is serialized once, encoded to UTF-8 once +// and, under compression, deflated once per codec; only the fragmenting to +// each link's message size is per link (ChannelCodec.sendShared). +// +// A member whose signaling connection dropped (`leave` with reason +// 'disconnect') is only `away`: its link never needed signaling to keep +// working, and under a stable identity the member re-joins as the same +// incarnation moments later. The link is kept until the member leaves for +// real, comes back as another incarnation (the peer relinks), or the link +// itself fails through the ordinary redial cycle. +// +// A link that gives up — its redial budget spent, a peer that said goodbye +// to the link but not to the room, an accept() that refused — used to be +// the end of that edge: #ensure only ran on a join, so two peers both still +// in the room stayed unlinked for good, and broadcast()/ask() quietly went +// around the member. The mesh keeps the ROSTER now, and an edge to a member +// still on it is dialled again, at a pace that backs off to `maxDelay` and +// stays there for as long as the member is in the room. + +const { Emitter, backoffDelay } = require('../utils.js'); +const { hasRoster } = require('./signaler.js'); +const { isPeerId } = require('./ids.js'); + +// How an edge to a member still in the room is dialled again once its link +// is gone: full-jitter backoff from a second to a minute, and then every +// minute — a refusal comes back as a close like any other, so the pace has +// a floor rather than a count. `false` never dials again. +const RELINK = { minDelay: 1000, maxDelay: 60_000, jitter: true }; + +const normalizeRelink = (value) => { + if (value === false) return null; + if (value === undefined || value === null || value === true) return RELINK; + const relink = { ...RELINK, ...value }; + const { minDelay, maxDelay } = relink; + if (!(Number.isFinite(minDelay) && minDelay >= 0 && Number.isFinite(maxDelay) && maxDelay >= minDelay)) { + throw new TypeError('Mesh: relink must be false or { minDelay, maxDelay, jitter } with 0 <= minDelay <= maxDelay'); + } + return relink; +}; + +class Mesh extends Emitter { + #peer; + #room; + #data; + #signaler; + // Who the room holds, as the signaler told it: id -> { data, instance }. + #roster = new Map(); + #relink; + // id -> { timer, attempt, said }: an edge waiting to be dialled again. + #relinks = new Map(); + #members = new Map(); // id -> PeerLink + #away = new Set(); // ids whose signaling dropped while their link stayed up + #responders = new Map(); + #joined = null; + #left = false; + #unbind = []; + + constructor(peer, room, { data = null, relink } = {}) { + super(); + if (!hasRoster(peer.signaler)) throw new TypeError('Mesh: the signaler must carry a roster (join/leave)'); + this.#peer = peer; + this.#room = room; + this.#data = data; + this.#relink = normalizeRelink(relink); + this.#signaler = peer.signaler; + const on = (emitter, name, fn) => { + emitter.on(name, fn); + this.#unbind.push(() => emitter.off(name, fn)); + }; + on(this.#signaler, 'join', (event) => { + if (event.room === room) this.#ensure(event.id, event.data ?? null, event.instance ?? null); + }); + on(this.#signaler, 'leave', (event) => { + if (event.room !== room) return; + const link = this.#members.get(event.id); + // A leave about another incarnation of the member than the one + // linked — the old tab's goodbye, landing after the new tab was + // linked — is not about this link; acting on it would drop the fresh one. + if (link && isPeerId(event.instance) && link.instance !== null && event.instance !== link.instance) { + peer.log.debug({ event: 'mesh.leave.stale', room, peer: event.id, instance: event.instance }); + return; + } + // The same guard for a member not linked right now: a stale goodbye + // must not take the incarnation the roster holds off it. + const listed = this.#roster.get(event.id); + if ( + !link && + listed && + isPeerId(event.instance) && + listed.instance !== null && + event.instance !== listed.instance + ) { + return; + } + if (event.reason === 'disconnect' && link?.open) return void this.#away.add(event.id); + // Gone from the room: off the roster, and nothing left to dial again. + this.#forget(event.id); + // A replaced member's link is abandoned, not closed: a goodbye sent + // to its id now would reach the NEW incarnation — and land on the + // fresh link this mesh is about to make with it. + this.#dropId(event.id, true, event.reason === 'replaced'); + }); + on(peer, 'reset', (event) => { + const entry = Array.isArray(event?.rooms) ? event.rooms.find((item) => item.room === room) : null; + if (!entry) return; + for (const member of entry.members) this.#ensure(member.id, member.data ?? null, member.instance ?? null); + }); + on(peer, 'link', (link) => this.#adopt(link)); + this.#joined = this.#join(); + // A join nobody awaits (peer.join() fires and forgets) whose roster + // fetch fails used to be an unhandled rejection: it is the peer's + // error, and the mesh detaches — it never joined. + this.#joined.catch((error) => { + if (this.#left) return; + this.#peer.escalate(error, this); + this.detach(); + }); + } + + get room() { + return this.#room; + } + + /** Ids of the members this peer has an open link with (a copy). */ + get peers() { + const ids = new Set(); + for (const [id, link] of this.#members) if (link.open) ids.add(id); + return ids; + } + + /** Members whose signaling connection dropped while their link stayed up (a copy). */ + get away() { + return new Set(this.#away); + } + + /** Every member link, open or still connecting (a copy). */ + get links() { + return new Map(this.#members); + } + + link(id) { + return this.#members.get(id); + } + + /** @internal */ + has(id) { + return this.#members.has(id); + } + + /** The host-side room every member link's Client is kept in. */ + get hostRoom() { + return `mesh:${this.#room}`; + } + + /** Resolves once the roster was fetched and every member link is dialling. */ + ready() { + return this.#joined; + } + + /** One event to every open member; how many received it. */ + broadcast(name, data) { + return this.#host('broadcast').to(this.hostRoom).emit(name, data); + } + + /** A question to every open member: { answers, errors, expected, incomplete }. */ + ask(name, data, options) { + return this.#host('ask').to(this.hostRoom).ask(name, data, options); + } + + /** Answers asks from every member, current and future. */ + respond(name, handler) { + if (typeof handler !== 'function') throw new TypeError('Mesh.respond: handler must be a function'); + this.#responders.set(name, handler); + for (const link of this.#members.values()) link.respond(name, handler); + } + + unrespond(name) { + const had = this.#responders.delete(name); + for (const link of this.#members.values()) link.unrespond(name); + return had; + } + + /** Leaves the room; links no other mesh holds are closed. */ + async leave() { + if (this.#left) return; + this.detach(); + try { + await this.#signaler.leave(this.#room); + } catch (error) { + this.#peer.escalate(error, this); + } + } + + /** @internal Drops every member without touching the signaler. */ + detach() { + if (this.#left) return; + this.#left = true; + for (const unbind of this.#unbind) unbind(); + this.#unbind = []; + for (const id of [...this.#roster.keys()]) this.#forget(id); + for (const link of [...this.#members.values()]) this.#drop(link, false); + void this.emit('left').catch((error) => this.#peer.escalate(error, this)); + } + + #host(what) { + const host = this.#peer.host; + if (!host) throw new Error(`Mesh.${what}: this peer has no router, so no host to fan out from`); + return host; + } + + async #join() { + const members = await this.#signaler.join(this.#room, this.#data); + if (this.#left) return; + for (const member of members) this.#ensure(member.id, member.data ?? null, member.instance ?? null); + } + + // A member: link if not yet linked (from either side), and keep its host + // Client in the mesh room. The same incarnation coming back from `away` + // is already linked and only cleared; another incarnation makes the peer + // abandon the stale link (its close drops the member) and dial the fresh + // one, adopted through the peer's 'link' event. + #ensure(id, data, instance) { + if (this.#left || id === this.#peer.id) return; + this.#away.delete(id); + this.#roster.set(id, { data, instance }); + // connect() resolves on open and rejects on close; both are announced + // through the link's events below, so the promise itself is only kept + // from being an unhandled rejection. It is still worth a line: a mesh + // that never forms a particular edge is otherwise indistinguishable + // from one whose member simply has nothing to say. Debug, because a + // roster churning through unreachable members would repeat it. + this.#peer.connect(id, { room: this.#room, data, instance }).catch((error) => { + this.#peer.log.debug({ err: error, event: 'mesh.dial', room: this.#room, peer: id }); + }); + const link = this.#peer.link(id); + if (link) this.#adopt(link, data); + } + + // A link this peer made or accepted: a member of this mesh when it was + // made in this room, or when the roster names it. A link that replaced a + // member's earlier one takes its place; the old one leaves on its close. + #adopt(link, data = undefined) { + if (this.#left) return; + const known = this.#members.get(link.id); + if (known === link) return; + if (data === undefined && link.room !== this.#room) return; + if (known) this.#release(known); + this.#members.set(link.id, link); + link.join(this.hostRoom); + for (const [name, handler] of this.#responders) link.respond(name, handler); + const onOpen = () => { + // The edge is back: whatever was waiting to dial it again is over. + this.#settle(link.id); + void this.emit('join', { id: link.id, data: data ?? link.data }).catch((e) => this.#peer.escalate(e, this)); + }; + if (link.open) onOpen(); + else link.once('open', onOpen); + link.once('close', (closure) => { + link.off('open', onOpen); + // Dropped by this mesh (a leave, a detach): not its member any more. + if (this.#members.get(link.id) !== link) return; + // A member that was only `away` — its signaling gone, the link all + // that was left of it — is out of the room once the link is too. + if (this.#away.has(link.id)) this.#forget(link.id); + this.#drop(link, true); + // Ended by an application — `link.close()` on either side, a goodbye: + // that was a decision, and it used to be undone by a redial within + // half a second, the member back in `peers` with a new `join` (the side + // that heard the goodbye redialled too). A link that ended by itself — + // a failure, a redial budget run out, a refusal — is dialled again. + if (closure?.reason === 'goodbye') return; + // The link ended by itself and the member is still in the room: the + // edge is dialled again. + this.#again(link.id); + }); + void this.emit('link', link).catch((error) => this.#peer.escalate(error, this)); + } + + // Off the roster: out of the room, so nothing is dialled for it again. + #forget(id) { + this.#roster.delete(id); + this.#settle(id); + } + + #settle(id) { + const pending = this.#relinks.get(id); + if (pending === undefined) return; + clearTimeout(pending.timer); + this.#relinks.delete(id); + } + + // The edge to a member still on the roster, dialled again after a pause + // that grows to `maxDelay` and stays there. No count: a member that + // refuses, or cannot be reached, is asked again once a minute for as long + // as it is in the room — and the application is told, once per outage, + // when the pace has reached that floor ('unreachable'). + #again(id) { + if (this.#left || this.#relink === null || !this.#roster.has(id)) return; + const pending = this.#relinks.get(id) ?? { timer: null, attempt: 0, said: false }; + const relink = this.#relink; + const delay = backoffDelay({ ...relink, attempt: pending.attempt }); + if (!pending.said && relink.minDelay * 2 ** pending.attempt >= relink.maxDelay) { + pending.said = true; + this.#peer.log.warn({ event: 'mesh.unreachable', room: this.#room, peer: id, attempts: pending.attempt }); + void this.emit('unreachable', { id, attempts: pending.attempt }).catch((e) => this.#peer.escalate(e, this)); + } + pending.attempt++; + clearTimeout(pending.timer); + pending.timer = setTimeout(() => { + pending.timer = null; + const listed = this.#roster.get(id); + if (this.#left || listed === undefined) return void this.#relinks.delete(id); + this.#peer + .connect(id, { room: this.#room, data: listed.data, instance: listed.instance }) + // Never linked: connect() rejected before there was a link to hear + // a close from (a link that was made says so through its 'close'). + .catch((error) => { + this.#peer.log.debug({ err: error, event: 'mesh.dial', room: this.#room, peer: id }); + const waiting = this.#relinks.get(id); + if (waiting !== undefined && waiting.timer === null && !this.#members.has(id)) this.#again(id); + }); + const link = this.#peer.link(id); + if (link) this.#adopt(link, listed.data); + }, delay); + pending.timer.unref?.(); + this.#relinks.set(id, pending); + } + + #dropId(id, announce, abandon = false) { + const link = this.#members.get(id); + if (link) this.#drop(link, announce, abandon); + } + + #drop(link, announce, abandon = false) { + this.#members.delete(link.id); + this.#away.delete(link.id); + this.#release(link); + if (!this.#peer.held(link.id, this)) { + if (abandon) link.abandon(); + else link.close(); + } + if (announce) void this.emit('leave', { id: link.id }).catch((error) => this.#peer.escalate(error, this)); + } + + // Out of the mesh room and off the responders, whether or not it closes. + #release(link) { + link.leave(this.hostRoom); + for (const name of this.#responders.keys()) link.unrespond(name); + } +} + +module.exports = { Mesh }; diff --git a/src/webrtc/peer.js b/src/webrtc/peer.js new file mode 100644 index 0000000..23c5d4b --- /dev/null +++ b/src/webrtc/peer.js @@ -0,0 +1,623 @@ +'use strict'; + +// WrpcPeer: one wrpc peer — a router others can call, a signaler to find +// them through, an RTC adapter to reach them with. It keeps the links (one +// PeerLink per remote peer — peerLink.js), reads the signals, holds a peer +// whose accept() has not answered, tracks incarnations, and owns the +// assertion layer (stamping outbound descriptions, verifying inbound ones). +// +// Roles are decided by id order alone: the peer with the LOWER id is the +// RtcLink initiator (it offers, it restarts ICE, it redials), the other is +// the polite responder. connect() works from either side — a responder +// that wants a link sends the initiator a 'connect' knock over signaling, +// and the initiator dials. So there is never an offer glare by +// construction, and a responder recovering from a failed link asks the +// same way. +// +// What a PeerLink and a Mesh need of the peer they belong to is handed to +// them as a PORT — closures over this class's private state — rather than +// the peer itself: `signal`, `stamp`, `verifyDescription`, `released` and +// `held` used to be public methods marked @internal, absent from the types +// and callable all the same. + +const { Emitter } = require('../utils.js'); +const { createLoggerWriter } = require('../logging.js'); +const { createServerTelemetry } = require('../telemetry/server.js'); +const { isRtcAdapter, createW3cAdapter } = require('./port.js'); +const { RtcLink, DEFAULT_CHANNELS, normalizeChannels } = require('./link.js'); +const { normalizeWaterMarks, normalizeBackpressure } = require('./transport.js'); +const { PeerLink, normalizeRedial, REDIAL } = require('./peerLink.js'); + +// Signals held for a peer whose accept() has not answered yet; past it +// they are dropped, said once. +const MAX_PENDING_SIGNALS = 64; +// How long a peer that said goodbye ignores a knock or an offer from the +// incarnation it said it to: the other side's channels close before the +// goodbye — which goes through the signaling server — reaches it, and the +// redial that starts meanwhile would re-open what the application ended. +// A goodbye is one signaling hop; this is many. +const GOODBYE_HOLD = 5000; +const { PeerHost } = require('./host.js'); +const { isSignaler, isSignalMessage } = require('./signaler.js'); +const { isPeerId } = require('./ids.js'); +const { createAssertionVerifier, sdpFingerprint, isAssertion, AssertionError } = require('./assertions.js'); +const { normalizeCompression } = require('../compression/index.js'); + +class WrpcPeer extends Emitter { + #signaler; + #adapter; + #configuration; + #channels; + #host = null; + #router; + #clientOptions; + #hostOptions; + #framing; + #compression = null; + #connectTimeout; + #restartTimeout; + #redial; + #accept; + #verifier = null; + // The signaling server's clock minus ours, from our own assertions' iat: + // the common reference both peers check `exp` against. + #clock = 0; + #log; + #otel; + #links = new Map(); + // Peers this side said goodbye to, for GOODBYE_HOLD: id -> { instance, until }. + #goodbyes = new Map(); + // Signals for a peer whose accept() is still pending. + #pending = new Map(); + #meshes = new Map(); + #started = null; + #closed = false; + // What a PeerLink is given instead of this peer (peerLink.js), and a Mesh. + #linkPort; + #meshPort; + // Contained: a signal whose handling rejects (a link refusing a + // description) used to be an unhandled rejection. + #onSignal = (event) => void this.#receive(event).catch((error) => this.escalate(error)); + #onReset = (event) => void this.#reset(event); + #onReplaced = (event) => void this.#replaced(event); + + constructor(options = {}) { + super(); + const { + router = null, + signaler, + rtc = null, + configuration = {}, + iceServers, + channels = {}, + client = {}, + host = {}, + framing = {}, + compression = null, + connectTimeout, + restartTimeout, + redial = {}, + accept = null, + assertions = null, + logger = false, + telemetry = null, + } = options; + if (!isSignaler(signaler)) throw new TypeError('WrpcPeer: options.signaler must satisfy the Signaler contract'); + if (assertions !== null && (typeof assertions !== 'object' || Array.isArray(assertions))) { + throw new TypeError('WrpcPeer: options.assertions must be an object'); + } + const adapter = rtc ?? createW3cAdapter(); + if (!isRtcAdapter(adapter)) throw new TypeError('WrpcPeer: options.rtc must satisfy the RtcAdapter contract'); + if (accept !== null && typeof accept !== 'function') { + throw new TypeError('WrpcPeer: options.accept must be a function'); + } + if (typeof client !== 'object' || client === null) { + throw new TypeError('WrpcPeer: options.client must be an object'); + } + if (typeof host !== 'object' || host === null) throw new TypeError('WrpcPeer: options.host must be an object'); + if (assertions !== null) { + if (typeof signaler.assert !== 'function') { + throw new TypeError('WrpcPeer: options.assertions needs a signaler with assert() — the server must issue them'); + } + const keys = assertions.keys ?? (typeof signaler.keys === 'function' ? () => signaler.keys() : null); + if (keys === null) { + throw new TypeError('WrpcPeer: options.assertions.keys is required when the signaler has no keys()'); + } + this.#verifier = createAssertionVerifier({ + keys, + issuer: assertions.issuer ?? null, + ...(assertions.refreshInterval === undefined ? {} : { refreshInterval: assertions.refreshInterval }), + ...(assertions.maxAge === undefined ? {} : { maxAge: assertions.maxAge }), + }); + } + if (host.trust === 'assertion' && this.#verifier === null) { + throw new TypeError("WrpcPeer: host.trust 'assertion' needs options.assertions"); + } + this.#signaler = signaler; + this.#adapter = adapter; + this.#configuration = iceServers ? { ...configuration, iceServers } : configuration; + this.#channels = normalizeChannels({ ...DEFAULT_CHANNELS, ...channels }); + this.#router = router; + this.#log = createLoggerWriter(logger).child({ component: 'peer' }); + this.#clientOptions = client; + const { highWaterMark, lowWaterMark, maxBackpressure, ...hostRest } = host; + // Checked here, at construction — not at the first link, where a bad + // mark used to surface. + const marks = normalizeWaterMarks(highWaterMark, lowWaterMark, 'WrpcPeer: options.host'); + const water = { + highWaterMark: marks.high, + lowWaterMark: marks.low, + maxBackpressure: normalizeBackpressure(maxBackpressure, 'WrpcPeer: options.host'), + }; + // Per-message compression on every link, both directions: announced in + // each description this peer sends, applied on a link whose peer named + // the same codec — a peer without it is served plain. + this.#compression = normalizeCompression(compression, 'WrpcPeer: options'); + this.#hostOptions = { water, framing, compression: this.#compression }; + this.#framing = framing; + this.#connectTimeout = connectTimeout; + this.#restartTimeout = restartTimeout; + this.#redial = normalizeRedial(redial); + this.#accept = accept; + if (router) this.#host = new PeerHost({ logger, telemetry, ...hostRest, router }); + // One writer for the peer: the host's when there is one (so its spans, + // connection gauge and the link instruments share instruments), its + // own otherwise — a client-only peer still has links to count. + this.#otel = this.#host ? this.#host.otel : createServerTelemetry(telemetry); + const escalate = (error, source) => this.escalate(error, source); + this.#linkPort = { + assertions: this.#verifier !== null, + signal: (to, message, room) => this.#signal(to, message, room), + verify: (from, message, pinned, claims) => this.#verifyDescription(from, message, pinned, claims), + released: (link) => this.#released(link), + escalate, + }; + const peer = this; + this.#meshPort = { + get id() { + return peer.id; + }, + signaler, + log: this.#log, + host: this.#host, + connect: (id, options) => this.connect(id, options), + link: (id) => this.#links.get(id), + held: (id, except) => this.#held(id, except), + escalate, + on: (name, listener) => this.on(name, listener), + off: (name, listener) => this.off(name, listener), + }; + // Listening from the start: a peer that never called start() itself + // still answers a knock — start() runs on the first signal. + signaler.on('signal', this.#onSignal); + signaler.on('reset', this.#onReset); + signaler.on('replaced', this.#onReplaced); + } + + /** This peer's id: the signaler's, once start() resolved. */ + get id() { + return this.#signaler.id; + } + + get signaler() { + return this.#signaler; + } + + // The writer this peer was built with, childed with `component: 'peer'`. + // A Mesh built on top reports through it rather than carrying a logger + // option of its own — same seam as `Client.log` and `RpcServer.log`. + get log() { + return this.#log; + } + + get host() { + return this.#host; + } + + get router() { + return this.#host ? this.#host.router : this.#router; + } + + get channels() { + return this.#channels; + } + + /** True when this peer issues and verifies trust assertions. */ + get assertions() { + return this.#verifier !== null; + } + + /** Every link, keyed by remote id (a copy). */ + get links() { + return new Map(this.#links); + } + + /** The link to `id`, or undefined. */ + link(id) { + return this.#links.get(id); + } + + /** Identifies through the signaler and starts listening for peers. */ + start() { + if (this.#closed) return Promise.reject(new Error('WrpcPeer is closed')); + return (this.#started ??= this.#signaler.ready().catch((error) => { + this.#started = null; + throw error; + })); + } + + // What every description this peer sends announces: its codecs, and + // which of its halves read framed messages (`f`: 1 the host, 2 the + // client) — each end of a link sends a frame only where the other said so. + #caps() { + const client = this.#clientOptions; + const f = (this.#host?.revision === 2 ? 1 : 0) | (client.attachments !== false && !client.codec ? 2 : 0); + return this.#compression === null ? { f } : { enc: this.#compression.ids, f }; + } + + /** + * A link to `remoteId`, dialled from either side; idempotent while one + * exists — unless `options.instance` names another incarnation of the + * id than the one linked, which abandons the stale link and dials the new + * endpoint. Resolves with the PeerLink once both directions are up. + */ + async connect(remoteId, options = {}) { + if (!isPeerId(remoteId)) { + throw new TypeError('WrpcPeer.connect: remoteId must be a non-empty string of at most 256 characters'); + } + await this.start(); + if (remoteId === this.id) throw new Error('WrpcPeer.connect: cannot connect to self'); + const instance = isPeerId(options.instance) ? options.instance : null; + // Dialling it is this side changing its mind about a goodbye. + this.#goodbyes.delete(remoteId); + const existing = this.#links.get(remoteId); + if (existing && this.#current(existing, instance)) return existing.ready(); + const link = this.#create(remoteId, options.room ?? null, options.data ?? null, instance); + if (link !== existing) { + try { + link.start({ knock: true }); + } catch (error) { + // A dial that could not be made: no link to keep. + link.abandon(); + throw error; + } + // An inbound open for the same peer still in its accept(): this link + // is the one. What it queued — the peer's knock, its offer, the + // candidates — is this link's now, and the accept, when it answers, + // finds nothing left to open. A second PeerLink used to be made for + // it, over this one, which leaked. + const pending = this.#pending.get(remoteId); + if (pending) { + this.#pending.delete(remoteId); + for (const queued of pending) { + if (queued.type === 'connect') link.knocked(); + else if (queued.type === 'candidate' || queued.description?.type === 'offer') { + link.receive(queued).catch((error) => this.escalate(error, link)); + } + } + } + } + return link.ready(); + } + + /** Joins a signaling room and links with everyone in it: a Mesh. */ + join(room, options = {}) { + const { Mesh } = require('./mesh.js'); + if (typeof room !== 'string' || room.length === 0) { + throw new TypeError('WrpcPeer.join: room must be a non-empty string'); + } + const existing = this.#meshes.get(room); + if (existing) return existing; + const mesh = new Mesh(this.#meshPort, room, options); + this.#meshes.set(room, mesh); + mesh.once('left', () => this.#meshes.delete(room)); + return mesh; + } + + /** The Mesh for `room`, if joined. */ + mesh(room) { + return this.#meshes.get(room); + } + + /** Closes every link and mesh; the signaler is the owner's to close. */ + close() { + if (this.#closed) return; + this.#closed = true; + this.#signaler.off('signal', this.#onSignal); + this.#signaler.off('reset', this.#onReset); + this.#signaler.off('replaced', this.#onReplaced); + for (const mesh of [...this.#meshes.values()]) mesh.detach(); + this.#meshes.clear(); + for (const link of [...this.#links.values()]) link.close(); + this.#pending.clear(); + void this.emit('close').catch((error) => this.escalate(error)); + } + + // ---- what the ports are made of + + // A signal to a peer; a signaler that throws or rejects is this peer's + // error, never the caller's. + #signal(to, message, room) { + try { + const result = this.#signaler.send(to, message, room === null ? undefined : { room }); + if (result && typeof result.then === 'function') result.catch((error) => this.escalate(error)); + } catch (error) { + this.escalate(error); + } + } + + // An outbound description gets this peer's assertion for the certificate + // it declares — one call to the signaling server per dial. + async #stamp(message) { + if (message.type !== 'description') return message; + const fingerprint = sdpFingerprint(message.description?.sdp); + if (fingerprint === null) { + throw new Error('WrpcPeer: the local description declares no single certificate fingerprint'); + } + const result = await this.#signaler.assert({ fingerprint }); + if (!isAssertion(result?.assertion)) throw new TypeError('signaler.assert() answered without an assertion'); + if (typeof result.iat === 'number') this.#clock = result.iat * 1000 - Date.now(); + return { ...message, assertion: result.assertion }; + } + + // The claims behind an inbound description from `from`: the signature is + // checked when the description's certificate differs from the one pinned + // on the link (a new pc — dial or redial); an ICE restart on the same pc + // is a string compare. + async #verifyDescription(from, message, pinned = null, claims = null) { + const sdp = message.description?.sdp; + const fingerprint = sdpFingerprint(sdp); + // A description on a pc whose fingerprint is already pinned needs no + // second verification, and is not counted: it is not an assertion check. + if (fingerprint !== null && fingerprint === pinned) return { fingerprint, claims }; + // A failure here is a SECURITY signal — a peer presenting a token that + // does not bind to the DTLS fingerprint of the connection it sent — and + // it had no metric at all, so a campaign of them was invisible. + if (!isAssertion(message.assertion)) { + this.#otel.recordRtcAssertion('missing'); + throw new AssertionError('assertion: missing', 'missing'); + } + let verified; + try { + verified = await this.#verifier.verify(message.assertion, { from, sdp, now: Date.now() + this.#clock }); + } catch (error) { + // The verifier's refusal code is the outcome — signature, fingerprint, + // subject (a substitution), expired, kid, issuer, malformed (an + // operational fault). It used to read a `reason` no AssertionError + // ever had, so every refusal counted as 'invalid'. + this.#otel.recordRtcAssertion(error instanceof AssertionError ? error.code : 'invalid'); + throw error; + } + this.#otel.recordRtcAssertion('ok'); + return { fingerprint, claims: verified }; + } + + // A link closed: forget it — and, when this side said goodbye, remember + // whom to for GOODBYE_HOLD (see #receive). Swept as it grows: every entry + // is younger than the hold. + #released(link) { + if (this.#links.get(link.id) === link) this.#links.delete(link.id); + const closure = link.link.closure; + if (closure?.reason !== 'goodbye' || closure.remote) return; + const now = Date.now(); + for (const [id, said] of this.#goodbyes) if (said.until <= now) this.#goodbyes.delete(id); + this.#goodbyes.set(link.id, { instance: link.instance, until: now + GOODBYE_HOLD }); + } + + // Whether this side said goodbye to that incarnation of `id` a moment ago. + // A signaler that names no instances matches by id alone. + #saidGoodbye(id, instance) { + const said = this.#goodbyes.get(id); + if (said === undefined) return false; + if (said.until <= Date.now()) { + this.#goodbyes.delete(id); + return false; + } + return said.instance === null || instance === null || said.instance === instance; + } + + /** A link (or mesh) with nobody listening for its error: the peer's 'error', or a log line. */ + escalate(error, source = null) { + if (this.listenerCount('error') > 0) return void this.emit('error', error, source).catch(() => {}); + this.#log.error({ err: error, event: 'rtc.peer.unhandled' }); + } + + // Whether any mesh other than `except` holds `id`. + #held(id, except) { + for (const mesh of this.#meshes.values()) if (mesh !== except && mesh.has(id)) return true; + return false; + } + + // Whether `existing` is still the link to its peer: it is, unless + // `instance` says the id now lives at another endpoint — then the stale + // link is abandoned (no goodbye: it would land on the new one) and the + // answer is false. A link that never learned an instance adopts the + // first it sees. + #current(existing, instance) { + if (instance === null) return true; + if (existing.instance === null) { + existing.adopt(instance); + return true; + } + if (existing.instance === instance) return true; + this.#log.info({ event: 'rtc.peer.incarnation', peer: existing.id, previous: existing.instance, instance }); + existing.abandon(); + return false; + } + + #create(remoteId, room, data, instance = null, verified = null) { + // A link that is not closed is THE link: a second one over it would + // leak the first, and take its peer's signals. + const current = this.#links.get(remoteId); + if (current !== undefined && current.state !== 'closed') { + this.#log.error({ event: 'rtc.peer.duplicate', peer: remoteId, state: current.state }); + return current; + } + const localId = this.id; + const relay = (message) => this.#signal(remoteId, message, room); + // With assertions, a description waits for its token — one round trip + // to the server — and the candidates that follow it wait their turn, + // so none overtakes the description they belong to. + let chain = Promise.resolve(); + const stamped = (message) => { + const next = chain.then(() => this.#stamp(message)).then(relay); + chain = next.catch(() => {}); + return next; + }; + const link = new RtcLink({ + localId, + remoteId, + adapter: this.#adapter, + configuration: this.#configuration, + channels: this.#channels, + connectTimeout: this.#connectTimeout, + restartTimeout: this.#restartTimeout, + log: this.#log, + caps: this.#caps(), + signal: this.#verifier === null ? relay : stamped, + }); + const peerLink = new PeerLink(this.#linkPort, { + id: remoteId, + instance, + room, + data, + claims: verified ? verified.claims : null, + fingerprint: verified ? verified.fingerprint : null, + link, + host: this.#host, + hostOptions: this.#hostOptions, + client: this.#clientOptions, + framing: this.#framing, + redial: this.#redial, + connectTimeout: this.#connectTimeout, + log: this.#log.child({ peer: remoteId }), + otel: this.#otel, + }); + this.#links.set(remoteId, peerLink); + void this.emit('link', peerLink).catch((error) => this.escalate(error)); + return peerLink; + } + + async #receive({ from, instance, room, message }) { + if (this.#closed || !isPeerId(from) || !isSignalMessage(message)) return; + try { + await this.start(); + } catch (error) { + return void this.escalate(error); + } + if (this.#closed || this.id === null || from === this.id) return; + const incarnation = isPeerId(instance) ? instance : null; + const existing = this.#links.get(from); + const stale = existing !== undefined && !this.#current(existing, incarnation); + if (existing && !stale) { + if (message.type === 'connect') return void existing.knocked(); + return void (await existing.receive(message)); + } + // Unknown peer: a knock or an offer opens a link, once accept() agrees; + // a candidate or close for a link we do not have is noise. Except after + // a stale link went: its redial may already have reached the new + // incarnation and this is that side's answer, so the initiator (lower + // id) dials afresh on anything the newcomer says. + const pending = this.#pending.get(from); + if (pending) { + // Bounded: a peer whose accept() is thinking is not a peer who may + // fill memory with candidates meanwhile. Said once per queue. + if (pending.length < MAX_PENDING_SIGNALS) pending.push(message); + else if (!pending.overflowed) { + pending.overflowed = true; + this.#log.warn({ event: 'rtc.signal.overflow', peer: from, what: 'signals', max: MAX_PENDING_SIGNALS }); + } + return; + } + const opening = + message.type === 'connect' || + (message.type === 'description' && message.description?.type === 'offer') || + (stale && message.type !== 'close' && this.id < from); + if (!opening) return; + // The other side's redial, begun before our goodbye reached it — its + // channels closed first — would undo an application's close: a link it + // ended came back within half a second. The goodbye is on its way and + // ends that redial; the knock or offer is dropped. + if (this.#saidGoodbye(from, incarnation)) { + return void this.#log.debug({ event: 'rtc.signal.goodbye', peer: from, type: message.type }); + } + const queue = [message]; + this.#pending.set(from, queue); + // Verification is protocol, accept() is policy: an offer's assertion is + // checked first, and the hook sees the verified claims. A knock carries + // no description; its claims arrive with the answer, before the host + // half attaches. + let verified = null; + if (this.#verifier !== null && message.type === 'description') { + try { + verified = await this.#verifyDescription(from, message); + } catch (error) { + if (this.#pending.get(from) !== queue) return; + this.#pending.delete(from); + if (this.#closed) return; + this.#log.warn({ event: 'rtc.peer.refused', peer: from, room, reason: error.code ?? 'assertion', err: error }); + this.#signal(from, { type: 'close', reason: 'refused' }, room ?? null); + return; + } + } + let accepted = true; + try { + const about = { instance: incarnation, claims: verified ? verified.claims : null }; + accepted = this.#accept === null ? true : await this.#accept(from, room ?? null, about); + } catch (error) { + accepted = false; + this.escalate(error); + } + // connect() took this queue over while accept() was pending, or close() + // cleared it: nothing is left here to open. + if (this.#pending.get(from) !== queue) return; + this.#pending.delete(from); + if (this.#closed) return; + if (accepted !== true) { + this.#log.info({ event: 'rtc.peer.refused', peer: from, room, reason: 'accept' }); + if (message.type !== 'close') this.#signal(from, { type: 'close', reason: 'refused' }, room ?? null); + return; + } + const link = this.#create(from, room ?? null, null, incarnation, verified); + try { + link.start(); + } catch (error) { + link.abandon(); + return void this.escalate(error, link); + } + for (const queued of queue) { + if (queued.type === 'connect') link.knocked(); + // A stale-incarnation answer or candidate belongs to the link that + // went; the fresh dial above is what reaches the newcomer. + else if (!stale || queued.type !== 'description' || queued.description?.type === 'offer') { + await link.receive(queued); + } + } + } + + // The signaler re-identified. Under a stable identity the id is the same + // and the links, which never needed signaling to keep working, stay; a + // Mesh re-adopts them from the rosters the reset carries. When the id + // changed, every link was made under the old one and the remote side + // keys it by that: close them all, without a goodbye. + #reset(event) { + const changed = event?.id !== event?.previous; + this.#log.warn({ event: 'rtc.peer.reset', id: event?.id, previous: event?.previous, changed }); + if (changed) for (const link of [...this.#links.values()]) link.abandon(); + void this.emit('reset', event).catch((error) => this.escalate(error)); + } + + // A newer connection took this peer id: this incarnation is over. The + // remote peers drop their links to it as the roster says so; here the + // links are abandoned (a goodbye would come from a stranger) and the peer + // closes. The owner decides whether a page starts a new one. + #replaced(event) { + if (this.#closed) return; + this.#log.warn({ event: 'rtc.peer.replaced', id: event?.id }); + for (const link of [...this.#links.values()]) link.abandon(); + void this.emit('replaced', event).catch((error) => this.escalate(error)); + this.close(); + } +} + +module.exports = { WrpcPeer, PeerLink, normalizeRedial, REDIAL }; diff --git a/src/webrtc/peerLink.js b/src/webrtc/peerLink.js new file mode 100644 index 0000000..edb8c41 --- /dev/null +++ b/src/webrtc/peerLink.js @@ -0,0 +1,501 @@ +'use strict'; + +// PeerLink: one connected peer, both directions — the RtcLink underneath, +// the WrpcClient that calls the remote's router over it, and the host-side +// Client the remote is to this peer's own router. +// +// peer A peer B +// link.remote (WrpcClient) ──channel 0──▶ PeerHost.attach → Client +// PeerHost.attach → Client ◀──channel 1── link.remote (WrpcClient) +// +// Failure: the RtcLink reports 'failed' (ICE did not come back inside the +// restart window); the client half's transport went 'close' and its +// WrpcClient runs the ordinary backoff/reconnect cycle, whose open() waits +// for the link to be connected again; the host half's Client is destroyed +// and re-attached on the next 'open'. The PeerLink drives the link: the +// initiator redials with backoff, the responder knocks with backoff, both +// give up after `redial.retries` and close. +// +// A PeerLink never holds its WrpcPeer. What it needs of it is a PORT, built +// by the peer over its own private state and handed in at construction: +// +// assertions whether inbound descriptions are verified +// signal(to, message, room) a signal to a peer, failures contained +// verify(from, message, pinned, claims) -> { fingerprint, claims } +// released(link) this link closed: forget it +// escalate(error, source) an error nobody on the link listens for +// +// Those used to be public methods of WrpcPeer marked @internal — absent +// from the types, callable all the same, and able to desynchronize it. + +const { Emitter, backoffDelay } = require('../utils.js'); +const { WrpcClient } = require('../client/core.js'); +const { ClientRtcTransport, RtcPeerTransport } = require('./transport.js'); +const { deferred } = require('./ids.js'); + +const REDIAL = { retries: 5, minDelay: 500, maxDelay: 10_000, factor: 2, jitter: true }; +// The remote WrpcClient's reconnect: quick, since the link itself carries +// the real backoff, and unbounded, since the PeerLink decides when a link +// is over. +const CLIENT_RECONNECT = { minDelay: 100, maxDelay: 2_000, jitter: true, retries: Infinity }; + +const normalizeRedial = (redial) => { + if (redial === false) return { ...REDIAL, retries: 0 }; + const merged = { ...REDIAL, ...(redial && typeof redial === 'object' ? redial : {}) }; + if (!(merged.retries >= 0)) merged.retries = REDIAL.retries; + if (!(merged.minDelay > 0)) merged.minDelay = REDIAL.minDelay; + if (!(merged.maxDelay >= merged.minDelay)) merged.maxDelay = merged.minDelay; + return merged; +}; + +/** One peer, both directions. Constructed by WrpcPeer; never directly. */ +class PeerLink extends Emitter { + #port; + #id; + #instance; + #room; + #data; + #link; + #remote; + #host; + #hostOptions; + #client = null; + #rooms = new Set(); + #claims; + #fingerprint; + #inbound = Promise.resolve(); + #state = 'connecting'; + #everOpen = false; + #redial; + #redialAttempt = 0; + #redialTimer = null; + #connectTimeout; + #opened; + #log; + #otel; + #counted = false; + #unbind = []; + + constructor(port, options) { + super(); + const { id, instance, room, data, link, host, hostOptions, client, framing, redial, log, otel } = options; + const { compression } = hostOptions; + this.#connectTimeout = options.connectTimeout ?? 0; + this.#port = port; + this.#id = id; + this.#instance = instance; + this.#claims = options.claims ?? null; + this.#fingerprint = options.fingerprint ?? null; + this.#room = room; + this.#data = data; + this.#link = link; + this.#host = host; + this.#hostOptions = hostOptions; + this.#redial = redial; + this.#log = log; + this.#otel = otel; + this.#opened = deferred(); + const transport = new ClientRtcTransport(`webrtc:${id}`, { link, framing, compression, ...hostOptions.water }); + this.#remote = new WrpcClient(`webrtc:${id}`, transport, { + logger: false, + ...client, + reconnect: client.reconnect === false ? false : { ...CLIENT_RECONNECT, ...(client.reconnect ?? {}) }, + }); + this.#bindAll(); + } + + /** The remote peer's id. */ + get id() { + return this.#id; + } + + /** + * The remote peer's incarnation — the `instance` its signaler carries — + * once known, else null. A signal from the same id under another instance + * is another endpoint, and this link is stale. + */ + get instance() { + return this.#instance; + } + + /** + * The remote peer's verified assertion claims (`sub`, `exp`, `fp`, and + * whatever the signaling server added), or null: no assertions + * configured, or — on the dialling side — none seen yet. + */ + get claims() { + return this.#claims; + } + + /** The signaling room this link was made in, or null. */ + get room() { + return this.#room; + } + + /** The remote peer's roster data, when known. */ + get data() { + return this.#data; + } + + /** True when this side dialled (lower id). */ + get initiator() { + return this.#link.initiator; + } + + /** 'connecting' | 'open' | 'reconnecting' | 'closed' */ + get state() { + return this.#state; + } + + /** The RtcLink underneath. */ + get link() { + return this.#link; + } + + /** The WrpcClient that calls the remote peer's router. */ + get remote() { + return this.#remote; + } + + /** The remote's scaffolded api (after load()). */ + get api() { + return this.#remote.api; + } + + /** The host-side Client the remote peer is to this router; null while down. */ + get client() { + return this.#client; + } + + /** The host-side rooms this link is kept in across redials (a copy). */ + get rooms() { + return new Set(this.#rooms); + } + + /** True once both directions are up. */ + get open() { + return this.#state === 'open'; + } + + /** Resolves when both directions are up; rejects when the link closes first. */ + ready() { + return this.#opened.promise; + } + + // ---- the remote's api, delegated + + load(...units) { + return this.#remote.load(...units); + } + + call(method, args, options) { + return this.#remote.call(method, args, options); + } + + /** Answers the remote peer's asks; replaces an earlier responder of that name. */ + respond(name, handler) { + this.#remote.unrespond(name); + this.#remote.respond(name, handler); + } + + unrespond(name) { + return this.#remote.unrespond(name); + } + + // ---- the host side, addressed + + /** An event to the remote peer, through this router's Client for it. */ + send(name, data) { + this.#hostClient('send').sendEvent(name, data); + } + + /** Asks the remote peer; answered by its remote.respond(). */ + ask(name, data, options) { + return this.#hostClient('ask').ask(name, data, options); + } + + /** A binary stream to the remote peer. */ + createStream(name, size) { + return this.#hostClient('createStream').createStream(name, size); + } + + /** Keeps this link's host Client in `room`, now and after every redial. */ + join(room) { + this.#rooms.add(room); + if (this.#client) this.#client.join(room); + } + + leave(room) { + this.#rooms.delete(room); + if (this.#client) this.#client.leave(room); + } + + /** Goodbye: the peer is told, both directions end, no redial. */ + close() { + if (this.#state === 'closed') return; + this.#link.close(); + } + + // ---- lifecycle (WrpcPeer drives these) + + /** + * @internal Dials. The initiator offers; a responder that asked for the + * link itself (connect() from the higher id) knocks so the initiator + * dials — one answering an offer already arriving does not. + */ + start({ knock = false } = {}) { + this.#link.start(); + if (knock && !this.initiator) this.#knock(); + // The first open: the client half waits for the link; a failure before + // the first open is the redial cycle's to retry, and the client is only + // opened once the link is. + void this.#link.waitOpen().then( + () => this.#openClient(), + () => {}, + ); + } + + /** + * @internal A knock from the responder: it wants the link and has none. + * The initiator dials again when its own link failed — and when its link + * looks CONNECTED but once opened: the responder's half is gone and this + * side's pc never noticed (an asymmetric failure), so the link is failed + * here for the redial cycle to rebuild. A dial under way is left to + * finish; a responder never dials. + */ + knocked() { + if (!this.initiator) return; + const state = this.#link.state; + if (state === 'failed') return void this.#redialNow(); + if (state === 'connected' && this.#everOpen) this.#link.fail(new Error('the peer knocked on a link it lost')); + } + + /** + * @internal A signal from the remote peer. Serialized: a description is + * verified (asynchronously) before the link applies it, and the next + * signal must not overtake it. + */ + receive(message) { + const run = () => this.#receive(message); + this.#inbound = this.#inbound.then(run, run); + return this.#inbound; + } + + async #receive(message) { + if (this.#state === 'closed') return; + if (message?.type === 'description' && this.#port.assertions) { + try { + const verified = await this.#port.verify(this.#id, message, this.#fingerprint, this.#claims); + this.#fingerprint = verified.fingerprint; + this.#claims = verified.claims; + } catch (error) { + // Not who the signaling said, or not provably so: the link ends + // here with a goodbye, before the description touches the pc. + this.#log.warn({ event: 'rtc.peer.refused', peer: this.#id, reason: error.code ?? 'assertion', err: error }); + this.#link.close('refused'); + return; + } + } + return this.#link.receive(message); + } + + /** @internal The remote's instance, learned from its first signal or roster entry. */ + adopt(instance) { + if (this.#instance === null && typeof instance === 'string') this.#instance = instance; + } + + /** + * @internal Closes without a goodbye: after a signaling reset this side's + * id changed, or the remote came back as another incarnation, so a + * 'close' sent now would reach the peer from a stranger — or worse, land + * on the fresh link already being made. The peer learns through the + * roster (leave/join) or through ICE. + */ + abandon() { + this.#link.abandon(); + } + + #hostClient(what) { + if (!this.#client) throw new Error(`PeerLink.${what}: the link to '${this.#id}' is not open`); + return this.#client; + } + + #bindAll() { + const link = this.#link; + const remote = this.#remote; + const on = (emitter, name, fn) => { + emitter.on(name, fn); + this.#unbind.push(() => emitter.off(name, fn)); + }; + on(link, 'open', () => this.#attachHost()); + on(link, 'state', (state) => { + if (state === 'failed') this.#onFailed(); + else if (state === 'closed') this.#onClosed(); + }); + on(link, 'error', (error) => this.#error(error)); + on(link, 'restart', ({ outcome }) => this.#otel.recordRtcRestart(outcome)); + on(remote, 'open', () => this.#onUp()); + on(remote, 'reconnect-failed', () => this.#link.close('gave-up')); + // Bound for the client's whole life, not unbound on close: an open() + // still in flight when the link closes settles afterwards, and an + // unheard client error goes to the console. + remote.on('error', (error) => { + // While the link is down its reconnect attempts fail by design; only + // an error on a live link is anyone's business. + if (this.#state === 'open') this.#error(error); + else this.#log.debug({ err: error, event: 'rtc.peer.client', state: this.#state }); + }); + } + + #attachHost() { + if (!this.#host || this.#client) return; + const transport = new RtcPeerTransport(this.#link, { + peer: this.#id, + framing: this.#hostOptions.framing, + compression: this.#hostOptions.compression, + ...this.#hostOptions.water, + onError: (error) => this.#error(error), + }); + const client = this.#host.attach(transport, { + peer: this.#id, + room: this.#room, + data: this.#data, + claims: this.#claims, + }); + this.#client = client; + for (const room of this.#rooms) client.join(room); + transport.once('close', () => { + if (this.#client === client) this.#client = null; + }); + void this.emit('attach', client).catch((error) => this.#error(error)); + } + + async #openClient() { + if (this.#state === 'closed') return; + try { + await this.#remote.open(); + } catch (error) { + // The link went down between its 'open' and the client's: the redial + // cycle owns the retry, and only a failure on a LIVE link is news. + if (this.#state !== 'closed' && this.#link.state === 'connected') this.#error(error); + } + } + + #onUp() { + if (this.#state === 'closed') return; + this.#redialAttempt = 0; + // A redial or knock still scheduled is for a failure that is over. + clearTimeout(this.#redialTimer); + this.#redialTimer = null; + const first = !this.#everOpen; + this.#everOpen = true; + this.#setState('open'); + this.#count(1); + if (first) { + this.#opened.resolve(this); + void this.emit('open').catch((error) => this.#error(error)); + } else { + void this.emit('reconnect').catch((error) => this.#error(error)); + } + } + + #onFailed() { + if (this.#state === 'closed') return; + this.#setState('reconnecting'); + this.#count(-1); + if (this.#redialAttempt >= this.#redial.retries) { + this.#log.warn({ event: 'rtc.peer.gave-up', attempts: this.#redialAttempt }); + // The initiator's goodbye ends a link nobody else can rebuild. A + // responder gives up quietly: the initiator may still be redialling + // on a longer backoff, and a 'close' from here used to end the link + // it was about to rebuild. + return void (this.initiator ? this.#link.close('gave-up') : this.#link.abandon('gave-up')); + } + const delay = backoffDelay({ ...this.#redial, attempt: this.#redialAttempt }); + this.#redialAttempt++; + this.#log.info({ event: 'rtc.peer.redial', attempt: this.#redialAttempt, delay, initiator: this.initiator }); + clearTimeout(this.#redialTimer); + this.#redialTimer = setTimeout(() => { + this.#redialTimer = null; + if (this.#state === 'closed') return; + // Counted where the action happened, not where it was scheduled. + if (this.initiator) { + if (this.#redialNow()) this.#otel.recordRtcRedial(this.#role); + return; + } + if (this.#link.state !== 'failed') return; + this.#knock(); + this.#otel.recordRtcRedial(this.#role); + // A responder whose initiator never answers fails again on the + // connect timeout of nothing: re-armed once that window has passed, + // not at once — each knock used to count an attempt within one + // backoff step, the whole budget gone in milliseconds while the + // initiator's dial had not even timed out. + this.#redialTimer = setTimeout(() => { + this.#redialTimer = null; + if (this.#state !== 'closed' && this.#link.state === 'failed') this.#onFailed(); + }, this.#connectTimeout); + this.#redialTimer.unref?.(); + }, delay); + } + + #redialNow() { + if (!this.#link.redial()) return false; + void this.#link.waitOpen().then( + () => this.#openClient(), + () => {}, + ); + return true; + } + + #knock() { + this.#port.signal(this.#id, { type: 'connect' }, this.#room); + } + + #onClosed() { + if (this.#state === 'closed') return; + this.#setState('closed'); + this.#count(-1); + clearTimeout(this.#redialTimer); + this.#redialTimer = null; + for (const unbind of this.#unbind) unbind(); + this.#unbind = []; + // The client's reconnect cycle stops here; its transport's close() on a + // closed link is a no-op. + this.#remote.close(); + this.#opened.reject(new Error(`PeerLink to '${this.#id}' closed`)); + this.#port.released(this); + // Why the link ended — the goodbye's reason, or this side's own — is + // what tells a refusal from a give-up from a goodbye on a dashboard. + const closure = this.#link.closure; + this.#otel.recordRtcClose(closure.reason, closure.remote); + void this.emit('close', closure).catch((error) => this.#error(error)); + } + + get #role() { + return this.initiator ? 'initiator' : 'responder'; + } + + // The open-links gauge: +1 when both directions come up, -1 once when + // they go down, whatever the order of failure and close. + #count(delta) { + if (delta > 0 && this.#counted) return; + if (delta < 0 && !this.#counted) return; + this.#counted = delta > 0; + this.#otel.recordRtcLink(delta, this.#role); + } + + #setState(state) { + if (this.#state === state) return; + this.#state = state; + void this.emit('state', state).catch((error) => this.#error(error)); + } + + #error(error) { + this.#log.error({ err: error, event: 'rtc.peer.error', peer: this.#id }); + if (this.listenerCount('error') > 0) return void this.emit('error', error).catch(() => {}); + this.#port.escalate(error, this); + } +} + +module.exports = { PeerLink, normalizeRedial, REDIAL }; diff --git a/src/webrtc/port.js b/src/webrtc/port.js new file mode 100644 index 0000000..3573ba8 --- /dev/null +++ b/src/webrtc/port.js @@ -0,0 +1,107 @@ +'use strict'; + +// The WebRTC port: what the peer-to-peer transport is written against. +// +// It is wrpc's OWN structural contract, not a binding to any library — a +// W3C-shaped subset, so a browser satisfies it natively and Node satisfies +// it through whatever implementation the application injects +// (node-datachannel's polyfill is W3C-shaped as-is; werift, with its own +// event API, needs a thin wrapper — see docs/guide/webrtc.md). Nothing is +// required here, on purpose: the file is browser-safe by construction and +// the validators are the only runtime check, in the style of isEngine and +// isBackplane (duck typing per the zero-dependency injection rule). +// +// interface RtcAdapter { +// name?: string +// createPeerConnection(configuration: object): RtcPeerConnectionLike +// } +// +// interface RtcPeerConnectionLike { +// createDataChannel(label, { negotiated: true, id, ordered? }): RtcDataChannelLike +// createOffer(options?: { iceRestart?: boolean }): Promise<{ type, sdp }> +// createAnswer(): Promise<{ type, sdp }> +// setLocalDescription(description?): Promise // no argument = implicit, per perfect negotiation +// setRemoteDescription(description): Promise +// addIceCandidate(candidate | null): Promise +// restartIce?(): void +// close(): void +// readonly localDescription, signalingState, connectionState, iceConnectionState +// readonly sctp: { maxMessageSize: number } | null +// addEventListener / removeEventListener: +// 'negotiationneeded' | 'icecandidate' | 'connectionstatechange' | 'iceconnectionstatechange' +// } +// +// interface RtcDataChannelLike { +// readonly id, label, readyState: 'connecting' | 'open' | 'closing' | 'closed', bufferedAmount +// bufferedAmountLowThreshold: number +// binaryType: 'arraybuffer' | 'blob' +// send(data: string | ArrayBuffer | ArrayBufferView): void +// close(): void +// addEventListener / removeEventListener: 'open' | 'message' | 'close' | 'error' | 'bufferedamountlow' +// } +// +// send() MUST copy `data` before it returns, and copy exactly the view it +// was handed — its byteOffset and byteLength, not the ArrayBuffer under it. +// The caller reuses the buffer the moment send() returns: framing.js' +// FrameEncoder writes every fragment of a message into ONE scratch buffer +// and hands over a view of it. A browser's and node-datachannel's send do +// this; a wrapper that keeps the view, or sends `view.buffer` whole, turns +// every multi-fragment message into the last fragment repeated. The shared +// port contract (tests/webrtc/portContract.js) checks it. + +const PC_METHODS = [ + 'createDataChannel', + 'createOffer', + 'createAnswer', + 'setLocalDescription', + 'setRemoteDescription', + 'addIceCandidate', + 'close', + 'addEventListener', + 'removeEventListener', +]; + +const CHANNEL_METHODS = ['send', 'close', 'addEventListener', 'removeEventListener']; + +const hasMethods = (value, names) => { + if (typeof value !== 'object' || value === null) return false; + for (let i = 0; i < names.length; i++) { + if (typeof value[names[i]] !== 'function') return false; + } + return true; +}; + +const isRtcAdapter = (value) => + typeof value === 'object' && value !== null && typeof value.createPeerConnection === 'function'; + +// `sctp` may be null (before the connection is up, or on an implementation +// that never reports it) — the framing layer falls back to the interop floor. +const isRtcPeerConnection = (value) => hasMethods(value, PC_METHODS) && typeof value.signalingState === 'string'; + +const isRtcDataChannel = (value) => hasMethods(value, CHANNEL_METHODS) && typeof value.readyState === 'string'; + +/** + * The default adapter: any W3C-shaped RTCPeerConnection constructor. In a + * browser the global one; in Node whatever the application hands over + * (`createW3cAdapter(require('node-datachannel/polyfill'))`). + */ +const createW3cAdapter = ({ RTCPeerConnection } = globalThis) => { + if (typeof RTCPeerConnection !== 'function') { + throw new TypeError( + 'createW3cAdapter: an RTCPeerConnection constructor is required — ' + + 'a browser has one; in Node inject one, e.g. createW3cAdapter(require("node-datachannel/polyfill"))', + ); + } + return { + name: 'w3c', + createPeerConnection(configuration = {}) { + const pc = new RTCPeerConnection(configuration); + if (!isRtcPeerConnection(pc)) { + throw new TypeError('createW3cAdapter: the constructor did not produce an RTCPeerConnection-shaped object'); + } + return pc; + }, + }; +}; + +module.exports = { isRtcAdapter, isRtcPeerConnection, isRtcDataChannel, createW3cAdapter }; diff --git a/src/webrtc/signaler.js b/src/webrtc/signaler.js new file mode 100644 index 0000000..7f423bf --- /dev/null +++ b/src/webrtc/signaler.js @@ -0,0 +1,365 @@ +'use strict'; + +// The Signaler contract and the built-in implementation over a WrpcClient. +// +// interface Signaler { +// readonly id: string | null this peer's id once ready() +// readonly instance?: string | null this incarnation of the id (optional) +// ready(): Promise resolves the id (single-flight) +// send(to, message, { room?, address? }) relay one SignalMessage to a peer +// on('signal', ({ from, instance?, address?, room, message }) => void) an inbound one +// off(event, handler); close?() +// } +// interface RosterSignaler extends Signaler { +// join(room, data?): Promise> the other members +// leave(room): Promise +// on('join', ({ room, id, instance?, address?, data? }) => void) +// on('leave', ({ room, id, instance?, reason? }) => void) +// reason: 'left' | 'disconnect' | 'replaced' +// on('reset', ({ id, previous, rooms }) => void) re-identified: rebuild +// on('replaced', ({ id }) => void) a newer connection took this id +// } +// interface AssertingSignaler extends Signaler { trust assertions (hasAssertions) +// assert({ fingerprint }): Promise<{ assertion, iat?, exp? }> a token for one of MY certificates +// keys?(): Promise> the server's public keys +// } +// SignalMessage = { type: 'description', description } +// | { type: 'candidate', candidate } +// | { type: 'close' } +// | { type: 'connect' } a knock: "dial me" (WrpcPeer, from the non-initiator) +// +// WrpcPeer needs only a Signaler; Mesh needs a RosterSignaler. Anything +// with the shape qualifies (isSignaler / hasRoster are structural, like +// isEngine and isBackplane) — a hand-rolled one over socket.io, a hosted +// signaling service, a MessagePort between two tabs. wrpcSignaler() is the +// one wrpc ships: the client half of createSignalingUnit, over any +// WrpcClient transport (ws, sse, ...). +// +// Identity: the id is whatever the server's identity strategy answers to +// whoami — the connection's client id by default, or a stable +// application id — with the `identity` option as this side's proposal. +// `instance` is generated once per signaler and tells incarnations of the +// same id apart across reconnects; the server only carries it. Peer +// addresses (the routable client ids the roster and signals carry) are +// remembered here and attached to send(), so a relay never has to resolve +// a peer id on the hot path. + +const { Emitter, resolveGenerateId } = require('../utils.js'); +const { isPeerId: isId } = require('./ids.js'); + +const SIGNAL_MESSAGE_TYPES = Object.freeze(['description', 'candidate', 'close', 'connect']); + +const isSignalMessage = (message) => + typeof message === 'object' && message !== null && SIGNAL_MESSAGE_TYPES.includes(message.type); + +const isFunction = (value) => typeof value === 'function'; + +const isSignaler = (value) => + typeof value === 'object' && + value !== null && + 'id' in value && + isFunction(value.ready) && + isFunction(value.send) && + isFunction(value.on) && + isFunction(value.off); + +const hasRoster = (value) => isSignaler(value) && isFunction(value.join) && isFunction(value.leave); + +const hasAssertions = (value) => isSignaler(value) && isFunction(value.assert); + +const isWrpcClientLike = (client) => + typeof client === 'object' && + client !== null && + isFunction(client.call) && + isFunction(client.sendEvent) && + isFunction(client.use) && + isFunction(client.on) && + isFunction(client.off); + +const checkRoom = (room) => { + if (typeof room !== 'string' || room.length === 0) throw new TypeError('room must be a non-empty string'); + return room; +}; + +const optionalId = (value) => (isId(value) ? value : null); + +class WrpcSignaler extends Emitter { + #client; + #unit; + #identity; + #instance; + #id = null; + #ready = null; + // room -> join data, so a reconnect can re-join what this peer was in. + #rooms = new Map(); + // peer id -> the routable address the server last told us for it. + #addresses = new Map(); + #closed = false; + #replaced = false; + #listeners; + + constructor(client, options = {}) { + super(); + if (!isWrpcClientLike(client)) { + throw new TypeError('wrpcSignaler: client must be a WrpcClient (call, sendEvent, use, on, off)'); + } + const { unit = 'signaling', identity = null, generateId = null } = options; + if (typeof unit !== 'string' || unit.length === 0) throw new TypeError('wrpcSignaler: unit must be a unit name'); + if (identity !== null && !isId(identity) && !isFunction(identity)) { + throw new TypeError('wrpcSignaler: identity must be a non-empty string or a function'); + } + const { first: instance } = resolveGenerateId(generateId, 'wrpcSignaler'); + this.#client = client; + this.#unit = unit; + this.#identity = identity; + this.#instance = instance; + // A static unit with no methods: enough for client.api[unit] to exist as + // the Emitter inbound events are delivered on, with no wire traffic and + // no load() — usable before open() and untouched by reconnect's reload. + client.use({ [unit]: {} }); + const api = client.api[unit]; + this.#listeners = { + signal: (payload) => { + if (typeof payload !== 'object' || payload === null || !isSignalMessage(payload.message)) return; + if (!isId(payload.from)) return; + // Addressed to a peer id or an incarnation this is not: a stale + // address hint landed on this connection. Not ours. + if (isId(payload.to) && payload.to !== this.#id) return; + if (isId(payload.toInstance) && payload.toInstance !== this.#instance) return; + const address = optionalId(payload.address); + if (address) this.#addresses.set(payload.from, address); + return this.emit('signal', { + from: payload.from, + instance: optionalId(payload.instance), + address, + room: payload.room ?? null, + message: payload.message, + }); + }, + join: (payload) => { + if (typeof payload !== 'object' || payload === null) return; + if (isId(payload.id) && isId(payload.address)) this.#addresses.set(payload.id, payload.address); + return this.emit('join', payload); + }, + leave: (payload) => { + if (typeof payload !== 'object' || payload === null) return; + if (isId(payload.id)) { + // A leave naming another address than the one this id is known + // at — an older incarnation's goodbye, landing after the newer + // one joined — is not about the member known here. + const known = this.#addresses.get(payload.id); + if (known !== undefined && isId(payload.address) && payload.address !== known) return; + this.#addresses.delete(payload.id); + } + return this.emit('leave', payload); + }, + replaced: (payload) => { + if (typeof payload !== 'object' || payload === null) return; + return this.#onReplaced(payload); + }, + reconnect: () => void this.#reset(), + }; + api.on('signal', this.#listeners.signal); + api.on('join', this.#listeners.join); + api.on('leave', this.#listeners.leave); + api.on('replaced', this.#listeners.replaced); + client.on('reconnect', this.#listeners.reconnect); + } + + /** The peer id the server agreed to, or null before ready(). */ + get id() { + return this.#id; + } + + /** This incarnation of the id: generated once, sent with every whoami. */ + get instance() { + return this.#instance; + } + + /** The WrpcClient this signaler rides. */ + get client() { + return this.#client; + } + + /** The unit name on the server. */ + get unit() { + return this.#unit; + } + + /** The rooms joined through this signaler (a copy). */ + get rooms() { + return new Set(this.#rooms.keys()); + } + + /** True once a newer connection took this peer id; the signaler is over. */ + get replaced() { + return this.#replaced; + } + + /** The routable address last learned for a peer, or null. */ + addressOf(id) { + return this.#addresses.get(id) ?? null; + } + + ready() { + if (this.#closed) return Promise.reject(new Error('Signaler is closed')); + if (this.#replaced) return Promise.reject(new Error('Signaler was replaced')); + if (this.#ready === null) { + // Any failure — a refused call, an answer without an id — forgets the + // attempt, so the next ready() asks again instead of caching it. + this.#ready = this.#whoami().catch((error) => { + this.#ready = null; + throw error; + }); + } + return this.#ready; + } + + async #whoami() { + const identity = this.#identity; + const proposed = isFunction(identity) ? await identity() : identity; + if (proposed !== null && proposed !== undefined && !isId(proposed)) { + throw new TypeError('wrpcSignaler: identity must produce a non-empty string'); + } + const args = { instance: this.#instance }; + if (isId(proposed)) args.id = proposed; + const result = await this.#client.call(`${this.#unit}/whoami`, args); + if (this.#closed) throw new Error('Signaler is closed'); + if (this.#replaced) throw new Error('Signaler was replaced'); + if (typeof result?.id !== 'string' || result.id.length === 0) { + throw new TypeError(`${this.#unit}/whoami answered without an id`); + } + this.#id = result.id; + return result.id; + } + + send(to, message, options = {}) { + if (!isId(to)) throw new TypeError('send: to must be a peer id'); + if (!isSignalMessage(message)) { + throw new TypeError('send: message.type must be description, candidate, close or connect'); + } + if (this.#replaced) throw new Error('Signaler was replaced'); + const room = options.room === undefined ? null : checkRoom(options.room); + const address = optionalId(options.address) ?? this.#addresses.get(to) ?? null; + const payload = { to, room, message }; + if (address !== null) payload.address = address; + this.#client.sendEvent(`${this.#unit}/signal`, payload); + } + + async join(room, data = null) { + checkRoom(room); + await this.ready(); + const result = await this.#client.call(`${this.#unit}/join`, { room, data }); + // A close() that landed while the call was in flight must not + // resurrect the room it just forgot. + if (!this.#closed && !this.#replaced) this.#rooms.set(room, data); + const members = Array.isArray(result?.members) ? result.members : []; + for (let i = 0; i < members.length; i++) { + const member = members[i]; + if (isId(member?.id) && isId(member.address)) this.#addresses.set(member.id, member.address); + } + return members; + } + + async leave(room) { + checkRoom(room); + this.#rooms.delete(room); + await this.#client.call(`${this.#unit}/leave`, { room }); + } + + async members(room) { + checkRoom(room); + const result = await this.#client.call(`${this.#unit}/members`, { room }); + return Array.isArray(result) ? result : []; + } + + /** A trust assertion binding this peer's id to one of its certificates: `/assert`. */ + async assert({ fingerprint } = {}) { + if (typeof fingerprint !== 'string' || fingerprint.length === 0) { + throw new TypeError('assert: fingerprint must be a non-empty string'); + } + await this.ready(); + const result = await this.#client.call(`${this.#unit}/assert`, { fingerprint }); + if (typeof result?.assertion !== 'string' || result.assertion.length === 0) { + throw new TypeError(`${this.#unit}/assert answered without an assertion`); + } + return result; + } + + /** The server's public assertion keys (JWKs): `/keys`. */ + async keys() { + const result = await this.#client.call(`${this.#unit}/keys`); + if (!Array.isArray(result?.keys)) throw new TypeError(`${this.#unit}/keys answered without keys`); + return result.keys; + } + + // The signaling connection came back as a NEW server-side client: rooms + // gone, the id to be agreed again (the same one, under a stable identity + // strategy — so it is kept until the answer lands, and a knock arriving + // meanwhile still has a local id to dial with). Re-join every room this + // peer was in, then announce the reset with the fresh rosters so a Mesh + // can rebuild in one pass. + async #reset() { + if (this.#replaced) return; + const previous = this.#id; + this.#ready = null; + this.#addresses.clear(); + const rooms = []; + try { + await this.ready(); + for (const [room, data] of this.#rooms) { + const members = await this.join(room, data); + rooms.push({ room, members }); + } + } catch (error) { + if (!this.#closed && !this.#replaced && this.listenerCount('error') > 0) await this.emit('error', error); + return; + } + // close() may have run while the calls above were in flight. + if (this.#closed || this.#replaced) return; + await this.emit('reset', { id: this.#id, previous, rooms }); + } + + // A newer connection identified as this peer id: this incarnation is + // over. Nothing is re-identified on reconnect and nothing is sent; the + // owner decides what the page does next. + async #onReplaced(payload) { + if (this.#closed || this.#replaced) return; + const id = this.#id; + this.#replaced = true; + this.#id = null; + this.#ready = null; + this.#rooms.clear(); + this.#addresses.clear(); + await this.emit('replaced', { id: isId(payload.id) ? payload.id : id }); + } + + /** Detaches from the client; the client itself stays open. */ + close() { + if (this.#closed) return; + this.#closed = true; + const api = this.#client.api[this.#unit]; + api.off('signal', this.#listeners.signal); + api.off('join', this.#listeners.join); + api.off('leave', this.#listeners.leave); + api.off('replaced', this.#listeners.replaced); + this.#client.off('reconnect', this.#listeners.reconnect); + this.#rooms.clear(); + this.#addresses.clear(); + this.#ready = null; + this.#id = null; + } +} + +/** The client half of createSignalingUnit, over an open or opening WrpcClient. */ +const wrpcSignaler = (client, options) => new WrpcSignaler(client, options); + +module.exports = { + WrpcSignaler, + wrpcSignaler, + isSignaler, + hasRoster, + hasAssertions, + isSignalMessage, + SIGNAL_MESSAGE_TYPES, +}; diff --git a/src/webrtc/signaling.js b/src/webrtc/signaling.js new file mode 100644 index 0000000..1daff0c --- /dev/null +++ b/src/webrtc/signaling.js @@ -0,0 +1,634 @@ +'use strict'; + +// The built-in signaling unit: what a wrpc server adds to its router so its +// WS clients can find each other and exchange WebRTC descriptions and +// candidates through it. One unit, four methods and one inbound event: +// +// whoami({ id?, instance? }) -> { id } identify: the caller's peer id +// join({ room, data }) -> { id, room, members } join a roster room (data.peer/room/claims reserved) +// leave({ room }) -> { room, left } +// members({ room }) -> [{ id, instance, address, data }] the roster, caller excluded — members only +// on.signal { to, address?, room, message } relayed to `to` as `/signal` +// emits: signal { from, instance, address, to, toInstance, room, message } +// join { room, id, instance, address, data } +// leave { room, id, instance, address, reason: 'left' | 'disconnect' | 'replaced' } +// replaced { id } this connection lost its peer id to a newer one +// with `assertions` configured, two more: +// assert({ fingerprint }) -> { assertion, iat, exp } a signed token: this peer id, this certificate +// keys() -> { keys: [JWK] } the public keys (access 'public') +// +// Identity. A peer's id is what the `identity` strategy says: by default the +// signaling connection's client id (instance-prefixed, server-issued — +// nobody can claim another's), or whatever the application derives from +// the context (a user id from the session, say), with the id the client +// proposed in whoami as one input. The id is stable across signaling +// reconnects when the strategy is; what tells two incarnations of the same +// id apart (a new tab, a replaced one) is `instance`, a discriminator the +// client generates once per signaler and the server merely carries. +// +// Addressing. RpcServer.sendTo routes by the instance prefix of a CLIENT id +// (an app-level id would route nowhere), so every roster member, join and +// signal carries `address` — the routable client id — and a relayed signal +// is delivered to the `address` the sender learned from the roster, with a +// cold by-id resolution (the local registry, then the room's descriptors) +// only for a connect() by name. Rooms live under `prefix` in the room +// registry, so a signaling room never collides with an application room, +// and every relay is bounded by a membership: `signal` reaches `to` only +// while both peers are in the room. +// +// Everything here is a plain router unit over public core seams — Client +// rooms, Broadcast, RpcServer.sendTo — so it clusters for free: the roster +// is cluster.fetchClients (which answers locally, at once, without a +// backplane), and the per-connection state travels as client.data.rtc. + +const { procedure } = require('../rpc/router.js'); +const { isSignalMessage, SIGNAL_MESSAGE_TYPES } = require('./signaler.js'); +const { normalizeFingerprint } = require('./assertions.js'); +const { createAssertionIssuer } = require('./assertionIssuer.js'); +const { MAX_ID_LENGTH, isPeerId } = require('./ids.js'); + +const DEFAULT_NAME = 'signaling'; +const DEFAULT_PREFIX = 'rtc:'; +const MAX_ROOM_LENGTH = 256; +const DUPLICATE = ['replace', 'refuse']; + +const refusal = (message, code) => Object.assign(new Error(message), { code }); + +// Rooms are peer-named. Bounded, non-empty, and never a string that could +// address a prototype when used as an object key downstream. +const checkRoom = (room) => { + if (typeof room !== 'string' || room.length === 0 || room.length > MAX_ROOM_LENGTH) { + throw new TypeError(`room must be a non-empty string of at most ${MAX_ROOM_LENGTH} characters`); + } + return room; +}; + +// Ids and instances become Map keys, session tokens, log fields and URLs +// on every peer: bounded the same way (ids.js — one number for both halves). +const isId = isPeerId; + +const checkId = (value, what) => { + if (!isId(value)) throw new TypeError(`${what} must be a non-empty string of at most ${MAX_ID_LENGTH} characters`); + return value; +}; + +const optionalId = (value, what) => (value === undefined || value === null ? null : checkId(value, what)); + +// The per-connection signaling state lives on client.data.rtc — a +// null-prototype record — so it travels inside the cluster's client +// descriptors (client.data is what fetchClients replicates) and the roster +// can be assembled from descriptors alone, local or remote. +// { id, instance, since, revoked?, rooms: { [room]: joinData } } +const rtcOf = (client) => { + const rtc = client.data?.rtc; + return typeof rtc === 'object' && rtc !== null && typeof rtc.id === 'string' ? rtc : null; +}; + +const dataFor = (rtc, room) => { + const rooms = rtc?.rooms; + if (typeof rooms !== 'object' || rooms === null || !Object.hasOwn(rooms, room)) return null; + return rooms[room]; +}; + +const inputRoom = (args) => { + if (typeof args !== 'object' || args === null) throw new TypeError('expected { room }'); + return { room: checkRoom(args.room) }; +}; + +// What a peer says about itself at join lands beside `peer`, `room` and +// `claims` in every other peer's session data (PeerHost.attach writes those +// over it): the three names are not a peer's to say. +const RESERVED_JOIN_DATA = ['peer', 'room', 'claims']; + +const inputJoin = (args) => { + const { room } = inputRoom(args); + const data = args.data === undefined ? null : args.data; + if (data !== null && typeof data === 'object') { + const taken = RESERVED_JOIN_DATA.find((key) => Object.hasOwn(data, key)); + if (taken !== undefined) throw new TypeError(`data.${taken} is reserved`); + } + return { room, data }; +}; + +const inputWhoami = (args) => { + if (args === undefined || args === null) return { proposed: null, instance: null }; + if (typeof args !== 'object') throw new TypeError('expected { id?, instance? }'); + return { proposed: optionalId(args.id, 'id'), instance: optionalId(args.instance, 'instance') }; +}; + +const inputAssert = (args) => { + if (typeof args !== 'object' || args === null) throw new TypeError('expected { fingerprint }'); + const fingerprint = normalizeFingerprint(args.fingerprint); + if (fingerprint === null) throw new TypeError("fingerprint must look like 'sha-256 AB:CD:...'"); + return { fingerprint }; +}; + +// Ceilings on what one connection can make the relay hold or do, on by +// default (the unit is new): rooms joined at once, the bytes of a join's +// `data` (replicated to every instance), the bytes of a relayed signal, +// and the room lookups in flight for signals to peers this instance does +// not hold. `false` switches one off. +const DEFAULT_LIMITS = { maxRooms: 32, maxDataBytes: 4096, maxSignalBytes: 65536, maxResolves: 4 }; + +const normalizeLimits = (limits) => { + if (limits === null || limits === undefined) return { ...DEFAULT_LIMITS }; + if (typeof limits !== 'object' || Array.isArray(limits)) { + throw new TypeError('createSignalingUnit: limits must be an object'); + } + const out = { ...DEFAULT_LIMITS }; + for (const key of Object.keys(DEFAULT_LIMITS)) { + const value = limits[key]; + if (value === undefined) continue; + if (value !== false && !(Number.isInteger(value) && value > 0)) { + throw new TypeError(`createSignalingUnit: limits.${key} must be a positive integer or false`); + } + out[key] = value; + } + return out; +}; + +// An upper bound on JSON.stringify(value).length without serializing, for +// the shape a signal has — scalars, and one level of nested object (a +// description's type and sdp, a candidate's fields); -1 for anything +// deeper. The exact length is taken only when the bound is over the limit +// (the model of sanitizeMeta in rpc/dispatcher.js). +const scalarBound = (entry) => { + const type = typeof entry; + if (type === 'string') return entry.length * 6 + 2; + if (type === 'number' || type === 'boolean') return 24; + if (entry === null) return 4; + return -1; +}; +const signalUpperBound = (message) => { + let bound = 2; + for (const key in message) { + bound += key.length * 6 + 6; + const entry = message[key]; + let size = scalarBound(entry); + if (size < 0 && typeof entry === 'object' && !Array.isArray(entry)) { + size = 2; + for (const inner in entry) { + const innerSize = scalarBound(entry[inner]); + if (innerSize < 0) return -1; + size += inner.length * 6 + 6 + innerSize; + } + } + if (size < 0) return -1; + bound += size; + } + return bound; +}; +const tooLarge = (value, limit) => { + if (limit === false) return false; + const bound = signalUpperBound(value); + return (bound < 0 || bound > limit) && JSON.stringify(value).length > limit; +}; + +const inputSignal = (args) => { + if (typeof args !== 'object' || args === null) throw new TypeError('expected { to, room, message }'); + const { to, message } = args; + checkId(to, 'to'); + if (!isSignalMessage(message)) throw new TypeError(`message.type must be one of ${SIGNAL_MESSAGE_TYPES.join(', ')}`); + const room = args.room === undefined || args.room === null ? null : checkRoom(args.room); + return { to, address: optionalId(args.address, 'address'), room, message }; +}; + +/** + * The signaling unit as a router definition fragment: spread it into + * defineRouter's units, `{ ...createSignalingUnit() }`. Options: + * name unit name (default 'signaling'); the client helper must agree + * access 'session' (default) or 'public' — applied to every method + * identity async (context, { proposed }) -> string — the peer id of a + * connection; default: its client id, the proposal ignored + * duplicate 'replace' (default): a second connection identifying as an id + * this instance already holds takes it over, the first is told + * `replaced`; 'refuse': the second is answered 409. Node-local: + * a cluster-wide claim belongs in the identity strategy. + * authorize async (context, { action, room, data?, to?, message? }) — a + * hook run before join and before every relayed signal; return + * false to refuse with 403, or throw a coded error of your own + * relay 'room' (default): a signal reaches `to` only while sender and + * receiver share the room; 'any': any connected client by id + * prefix the room-registry namespace (default 'rtc:') + * assertions { key, kid?, ttl?, issuer?, claims? } — issue trust + * assertions (see assertions.js): `key` a private ES256 JWK or + * CryptoKey pair, `claims(context)` extra claims to sign in + * (roles, say). Adds `assert` and the public `keys` method. + * limits { maxRooms, maxDataBytes, maxSignalBytes, maxResolves } — + * per-connection ceilings (32, 4096, 65536, 4); `false` off + */ +const createSignalingUnit = (options = {}) => { + const { + name = DEFAULT_NAME, + access = 'session', + identity = null, + duplicate = 'replace', + authorize = null, + relay = 'room', + prefix = DEFAULT_PREFIX, + assertions = null, + } = options; + const limits = normalizeLimits(options.limits); + if (typeof name !== 'string' || name.length === 0 || name.includes('/') || name.includes('.')) { + throw new TypeError('createSignalingUnit: name must be a unit name without "/" or "."'); + } + if (access !== 'session' && access !== 'public') { + throw new TypeError("createSignalingUnit: access must be 'session' or 'public'"); + } + if (identity !== null && typeof identity !== 'function') { + throw new TypeError('createSignalingUnit: identity must be a function'); + } + if (!DUPLICATE.includes(duplicate)) { + throw new TypeError(`createSignalingUnit: duplicate must be one of ${DUPLICATE.join(', ')}`); + } + if (authorize !== null && typeof authorize !== 'function') { + throw new TypeError('createSignalingUnit: authorize must be a function'); + } + if (relay !== 'room' && relay !== 'any') throw new TypeError("createSignalingUnit: relay must be 'room' or 'any'"); + if (typeof prefix !== 'string') throw new TypeError('createSignalingUnit: prefix must be a string'); + if (assertions !== null && (typeof assertions !== 'object' || Array.isArray(assertions))) { + throw new TypeError('createSignalingUnit: assertions must be an object'); + } + if (assertions !== null && assertions.claims !== undefined && typeof assertions.claims !== 'function') { + throw new TypeError('createSignalingUnit: assertions.claims must be a function'); + } + const issuer = + assertions === null + ? null + : createAssertionIssuer({ + key: assertions.key, + kid: assertions.kid, + ttl: assertions.ttl, + issuer: assertions.issuer, + }); + const extraClaims = assertions?.claims ?? null; + + const roomOf = (room) => prefix + room; + const event = (kind) => `${name}/${kind}`; + + // The peers identified on THIS instance, by peer id. Cleared when the + // connection closes, unless the id was taken over meanwhile. + const registry = new Map(); + + const allow = async (context, info) => { + if (authorize === null) return; + const verdict = await authorize(context, info); + if (verdict === false) throw refusal(`${name}: ${info.action} refused`, 403); + }; + + // Re-checked after every await: a call from a connection that lost its + // id while the call was in flight must not act under it. + const owns = (client, rtc) => { + if (rtc.revoked === true || registry.get(rtc.id) !== client) throw refusal(`${name}: replaced`, 409); + }; + + const member = (rtc, address, room) => ({ id: rtc.id, instance: rtc.instance, address, data: dataFor(rtc, room) }); + + // Takes `id` away from `holder` for a newer connection. The same + // incarnation coming back (a reconnect whose old socket is not yet dead) + // leaves silently — its id is still present; a different one is + // announced as a `replaced` leave, so the other peers drop the links they + // hold to the old incarnation. + const replace = (holder, server, incoming) => { + const rtc = rtcOf(holder); + rtc.revoked = true; + const same = rtc.instance !== null && rtc.instance === incoming; + for (const full of holder.rooms) { + if (!full.startsWith(prefix)) continue; + holder.leave(full); + if (same) continue; + server.to(full).emit(event('leave'), { + room: full.slice(prefix.length), + id: rtc.id, + instance: rtc.instance, + address: holder.id, + reason: 'replaced', + }); + } + rtc.rooms = Object.create(null); + holder.sendEvent(event('replaced'), { id: rtc.id }); + }; + + // One identification per connection: whoami does it explicitly, join and + // signal lazily. Idempotent for the same id; a different proposal is 409. + const identify = async (context, proposed = null, instance = null) => { + const { client, server } = context; + const current = rtcOf(client); + if (current) { + if (current.revoked === true) throw refusal(`${name}: replaced`, 409); + if (proposed !== null && proposed !== current.id) throw refusal(`${name}: already identified`, 409); + return current; + } + const id = identity === null ? client.id : await identity(context, { proposed }); + if (!isId(id)) throw refusal(`${name}: identity must return a non-empty string`, 500); + // Two whoami calls in flight on one connection: the second to land + // adopts the first's answer. + const raced = rtcOf(client); + if (raced) { + if (raced.id !== id) throw refusal(`${name}: already identified`, 409); + return raced; + } + const holder = registry.get(id); + if (holder && holder !== client) { + if (duplicate === 'refuse') throw refusal(`${name}: '${id}' is already connected`, 409); + replace(holder, server, instance); + } + const rtc = { __proto__: null, id, instance, since: Date.now(), rooms: Object.create(null) }; + client.data.rtc = rtc; + registry.set(id, client); + client.once('close', () => { + if (registry.get(id) === client) registry.delete(id); + }); + return rtc; + }; + + // The roster of `room` minus the asking connection, from cluster + // descriptors: one code path whether or not a backplane exists. Two + // descriptors with one peer id (a duplicate on another node) collapse to + // the newest. + const roster = async (server, room, self) => { + const descriptors = await server.cluster.fetchClients({ room: roomOf(room) }); + const members = new Map(); + for (let i = 0; i < descriptors.length; i++) { + const descriptor = descriptors[i]; + if (descriptor.id === self.id) continue; + const rtc = rtcOf(descriptor); + if (rtc?.revoked === true) continue; + const id = rtc ? rtc.id : descriptor.id; + if (id === self.peer) continue; + const since = rtc ? rtc.since : 0; + const known = members.get(id); + if (known && known.since >= since) continue; + members.set(id, { + since, + member: { id, instance: rtc ? rtc.instance : null, address: descriptor.id, data: dataFor(rtc, room) }, + }); + } + const list = []; + for (const entry of members.values()) list.push(entry.member); + return list; + }; + + // Where a signal to peer `to` goes: the local holder of the id, else the + // `address` the sender learned from the roster (sendTo routes it by its + // instance prefix), else — room relay only — the room's descriptors. + // The room lookups in flight, one per room: every signal to a peer this + // instance does not hold used to be a cluster-wide fetch of its own — a + // burst of them to a peer that left was a burst of fetches on every node. + const lookups = new Map(); + const fetchRoom = (server, room) => { + const key = roomOf(room); + const shared = lookups.get(key); + if (shared) return shared; + if (limits.maxResolves !== false && lookups.size >= limits.maxResolves) { + throw refusal(`${name}: too many lookups in flight`, 503); + } + const pending = server.cluster.fetchClients({ room: key }).finally(() => lookups.delete(key)); + lookups.set(key, pending); + return pending; + }; + + const resolve = async (server, to, address, room) => { + const local = registry.get(to); + if (local) return { address: local.id, instance: rtcOf(local).instance }; + if (address !== null && !server.getClient(address)) return { address, instance: null }; + if (relay !== 'room') return null; + const descriptors = await fetchRoom(server, room); + let found = null; + for (let i = 0; i < descriptors.length; i++) { + const rtc = rtcOf(descriptors[i]); + if (!rtc || rtc.revoked === true || rtc.id !== to) continue; + if (!found || rtc.since > found.since) { + found = { since: rtc.since, address: descriptors[i].id, instance: rtc.instance }; + } + } + return found; + }; + + const unit = { + whoami: procedure({ + access, + input: inputWhoami, + signature: { args: { id: 'string', instance: 'string' }, returns: { id: 'string' } }, + handler: async (context, { proposed, instance }) => { + const rtc = await identify(context, proposed, instance); + return { id: rtc.id }; + }, + }), + + join: procedure({ + access, + input: inputJoin, + signature: { + args: { room: 'string', data: 'unknown' }, + returns: { id: 'string', room: 'string', members: 'object[]' }, + }, + handler: async (context, { room, data }) => { + const { client, server } = context; + if (limits.maxDataBytes !== false && data !== null && JSON.stringify(data).length > limits.maxDataBytes) { + throw refusal(`${name}: join data too large`, 413); + } + const rtc = await identify(context); + // A room already joined — or being joined — is not another room. The + // rooms being joined count: the `allow` hook is awaited between this + // check and the write below, and joins sent together all passed the + // check before any of them wrote (ten joins under maxRooms 2 were ten + // rooms). + const joining = (rtc.joining ??= new Set()); + if (limits.maxRooms !== false && rtc.rooms[room] === undefined && !joining.has(room)) { + let count = joining.size; + for (const key in rtc.rooms) if (key) count++; + if (count >= limits.maxRooms) throw refusal(`${name}: too many rooms`, 429); + } + joining.add(room); + try { + await allow(context, { action: 'join', room, data }); + } finally { + joining.delete(room); + } + owns(client, rtc); + rtc.rooms[room] = data; + // Join, announce, then read the roster: a peer joining at the same + // moment is in the roster or hears the announcement (or, across a + // backplane, both — the mesh treats a repeated join as one). + const fresh = client.join(roomOf(room)); + if (fresh) { + const announce = { room, ...member(rtc, client.id, room) }; + server.to(roomOf(room)).except(client).emit(event('join'), announce); + } + const members = await roster(server, room, { id: client.id, peer: rtc.id }); + return { id: rtc.id, room, members }; + }, + }), + + leave: procedure({ + access, + input: inputRoom, + signature: { args: { room: 'string' }, returns: { room: 'string', left: 'boolean' } }, + handler: async (context, { room }) => { + const { client, server } = context; + const rtc = rtcOf(client); + if (rtc) delete rtc.rooms[room]; + const left = client.leave(roomOf(room)); + if (left && rtc?.revoked !== true) { + server.to(roomOf(room)).emit(event('leave'), { + room, + id: rtc ? rtc.id : client.id, + instance: rtc ? rtc.instance : null, + address: client.id, + reason: 'left', + }); + } + return { room, left }; + }, + }), + + members: procedure({ + access, + input: inputRoom, + signature: { args: { room: 'string' }, returns: 'object[]' }, + handler: async (context, { room }) => { + const { client } = context; + // A roster is for the room's members — the bound every relayed + // signal already has, and what keeps a room from being enumerated + // (one cluster-wide fetch a call) by anyone who knows its name. + if (!client.in(roomOf(room))) throw refusal(`members: not a member of '${room}'`, 403); + const rtc = rtcOf(client); + return roster(context.server, room, { id: client.id, peer: rtc ? rtc.id : client.id }); + }, + }), + + on: { + signal: procedure({ + access, + input: inputSignal, + signature: { args: { to: 'string', address: 'string', room: 'string', message: 'object' } }, + handler: async (context, { to, address, room, message }) => { + const { client, server } = context; + if (tooLarge(message, limits.maxSignalBytes)) throw refusal(`${name}: signal too large`, 413); + const rtc = await identify(context); + if (relay === 'room') { + if (room === null) throw refusal('signal: room is required', 400); + if (!client.in(roomOf(room))) throw refusal(`signal: not a member of '${room}'`, 403); + } + await allow(context, { action: 'signal', room, to, message }); + owns(client, rtc); + const target = await resolve(server, to, address, room); + owns(client, rtc); + const bounded = relay === 'room' ? { room: roomOf(room) } : {}; + const payload = { + from: rtc.id, + instance: rtc.instance, + address: client.id, + to, + toInstance: target ? target.instance : null, + room, + message, + }; + const delivered = target !== null && server.sendTo(target.address, event('signal'), payload, bounded); + // A trickle candidate that crossed the peer's leave, mostly — a + // routine race, not a warning per candidate. + if (!delivered) client.log.debug({ event: 'signaling.undeliverable', to, room, type: message.type }); + }, + }), + }, + + ...(issuer === null + ? {} + : { + assert: procedure({ + access, + input: inputAssert, + signature: { + args: { fingerprint: 'string' }, + returns: { assertion: 'string', iat: 'number', exp: 'number' }, + }, + handler: async (context, { fingerprint }) => { + const { client } = context; + // In order, not Promise.all: identify() may be the connection's + // FIRST identification (a client that calls assert before + // whoami), and the claims hook reads the result from + // context.client.data.rtc. Once identified, identify() settles + // on a microtask, so running them together would save nothing + // and would run the hook for a connection about to be refused. + const rtc = await identify(context); + const custom = extraClaims === null ? null : await extraClaims(context); + owns(client, rtc); + if (custom !== null && (typeof custom !== 'object' || Array.isArray(custom))) { + throw refusal(`${name}: assertions.claims must return an object`, 500); + } + return issuer.sign({ ...custom, sub: rtc.id, fp: fingerprint }); + }, + }), + // Public on purpose: a peer fetches the keys before it has a + // session of its own, and they are public keys. + keys: procedure({ + access: 'public', + signature: { returns: { keys: 'object[]' } }, + handler: async () => ({ keys: await issuer.publicKeys() }), + }), + }), + + emits: { + signal: { + data: { + from: 'string', + instance: 'string', + address: 'string', + to: 'string', + toInstance: 'string', + room: 'string', + message: 'object', + }, + }, + join: { data: { room: 'string', id: 'string', instance: 'string', address: 'string', data: 'unknown' } }, + leave: { data: { room: 'string', id: 'string', instance: 'string', address: 'string', reason: 'string' } }, + replaced: { data: { id: 'string' } }, + }, + }; + + return { [name]: unit }; +}; + +/** + * The router-level connection hooks that make a dropped signaling + * connection LEAVE its rooms in front of the other members — unless its id + * was taken over by a newer connection meanwhile, in which case the peer is + * still there and nothing is announced. Compose into defineRouter(units, + * { hooks }): `hooks: createSignalingHooks()`, or append `.onDisconnect` to + * your own list. `name`/`prefix` must match the unit's. + */ +const createSignalingHooks = (options = {}) => { + const { name = DEFAULT_NAME, prefix = DEFAULT_PREFIX } = options; + if (typeof name !== 'string' || name.length === 0) throw new TypeError('createSignalingHooks: name must be a string'); + if (typeof prefix !== 'string') throw new TypeError('createSignalingHooks: prefix must be a string'); + const leave = `${name}/leave`; + return { + onDisconnect: (client, payload) => { + const rooms = payload?.rooms; + if (!rooms || typeof rooms[Symbol.iterator] !== 'function') return; + const rtc = rtcOf(client); + if (rtc?.revoked === true) return; + const id = rtc ? rtc.id : client.id; + const instance = rtc ? rtc.instance : null; + for (const full of rooms) { + if (typeof full !== 'string' || !full.startsWith(prefix)) continue; + client.server.to(full).emit(leave, { + room: full.slice(prefix.length), + id, + instance, + address: client.id, + reason: 'disconnect', + }); + } + }, + }; +}; + +module.exports = { + createSignalingUnit, + createSignalingHooks, + DEFAULT_SIGNALING_NAME: DEFAULT_NAME, + DEFAULT_SIGNALING_PREFIX: DEFAULT_PREFIX, + MAX_ID_LENGTH, +}; diff --git a/src/webrtc/transport.js b/src/webrtc/transport.js new file mode 100644 index 0000000..e425f1a --- /dev/null +++ b/src/webrtc/transport.js @@ -0,0 +1,872 @@ +'use strict'; + +// The two halves of a wrpc peer on one RtcLink: ClientRtcTransport is the +// client-side transport (what my WrpcClient writes on — `link.clientChannel`) +// and RtcPeerTransport the server-side one (what my PeerHost reads — +// `link.hostChannel`). Both speak the data-channel framing (framing.js) and +// both defer the link's lifecycle to the link: a data channel is one +// direction of a link that a PeerLink owns, so close() on either half +// closes the LINK (goodbye to the peer, no redial), never just its channel +// — closing one channel would look like a failure to the other side and +// trigger a redial of a link that was meant to end. +// +// Only terminate() is local: the client core calls it on a heartbeat or +// connect timeout, and the answer to "this direction looks dead" is the +// client's reconnect cycle — open() again, which waits for the link to be +// (re)connected — not the end of the link. +// +// Both halves also run without a link, over a data channel the application +// hands over — the level under RtcLink, the way the event transport takes +// a worker's port: the application owns the peer connection, its signaling +// and its recovery, and wrpc only speaks on the channel it was given. There +// the transport IS the channel's owner as far as wrpc goes: close() and +// terminate() close it (nobody else would restart its ICE), and the client +// half's `channel` may be a factory — how such an application plugs its own +// reconnect into the core's cycle: every re-open asks for the next channel. + +// The client CORE, not the client barrel: the barrel also registers the +// ws/http/event transports and the Service Worker proxy, none of which a +// peer needs — and every byte here lands in the webrtc browser bundle. +const { ClientTransport, WrpcClient } = require('../client/core.js'); +const { ServerTransport } = require('../rpc/serverTransport.js'); +const { isRtcDataChannel } = require('./port.js'); +const { + FrameEncoder, + FrameDecoder, + SharedFrames, + FramingError, + KIND_TEXT, + KIND_BINARY, + FLAG_COMPRESSED, + MIN_MESSAGE_SIZE, + DEFAULT_MAX_REASSEMBLY, + decodeText, + checkMessageSize, +} = require('./framing.js'); +const { normalizeCompression, negotiate, Sequencer, INFLIGHT_LIMIT } = require('../compression/index.js'); + +const TEXT_ENCODER = new TextEncoder(); + +// Outbound flow control, in bytes queued on the channel: write() answers +// false above the high-water mark, and 'drain' fires once the channel is +// back under the low-water mark (bufferedAmountLowThreshold). +const DEFAULT_HIGH_WATER_MARK = 1024 * 1024; +const DEFAULT_LOW_WATER_MARK = 256 * 1024; + +const positiveInteger = (value) => Number.isInteger(value) && value > 0; + +// The cap behind the high-water mark: a message that would put the channel +// past it — its own buffer, the codec's pending bytes and the message +// together — is refused and the CHANNEL is closed, locally: over a link +// that is a redial, over a raw channel the end of it. A browser closes the +// channel itself somewhere past 16 MiB of bufferedAmount, with an exception +// out of send(); this is the same outcome, announced. 0 switches it off. +const DEFAULT_MAX_BACKPRESSURE = 64 * 1024 * 1024; + +const normalizeBackpressure = (value, label) => { + if (value === undefined) return DEFAULT_MAX_BACKPRESSURE; + if (!Number.isInteger(value) || value < 0) { + throw new TypeError(`${label}: maxBackpressure must be a non-negative integer of bytes (0 = off)`); + } + return value; +}; + +// The two marks, checked: positive integers, the low one no higher than the +// high one, and a low default that follows a high mark set below it — a +// low mark above the high one used to mean a 'drain' that never came. +const normalizeWaterMarks = (highWaterMark, lowWaterMark, label) => { + if (highWaterMark !== undefined && !positiveInteger(highWaterMark)) { + throw new TypeError(`${label}: highWaterMark must be a positive integer of bytes`); + } + if (lowWaterMark !== undefined && !positiveInteger(lowWaterMark)) { + throw new TypeError(`${label}: lowWaterMark must be a positive integer of bytes`); + } + const high = highWaterMark ?? DEFAULT_HIGH_WATER_MARK; + if (lowWaterMark === undefined) return { high, low: Math.min(DEFAULT_LOW_WATER_MARK, high) }; + if (lowWaterMark > high) throw new TypeError(`${label}: lowWaterMark must not exceed highWaterMark`); + return { high, low: lowWaterMark }; +}; + +const toBytes = (data) => { + if (data instanceof Uint8Array) return data; + if (data instanceof ArrayBuffer) return new Uint8Array(data); + if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + throw new TypeError('write() takes a string or bytes'); +}; + +const CLOSED_CHANNEL = 'The data channel is closed; pass a factory as `channel` to reconnect'; + +const closeChannelQuietly = (channel) => { + try { + channel.close(); + } catch { + // Already closed. + } +}; + +// A raw channel may still be connecting when it is handed over (created +// before the peer connection came up); an already closed one is refused, +// not waited on — nothing would ever open it again. +const waitChannelOpen = (channel) => + new Promise((resolve, reject) => { + if (channel.readyState === 'open') return void resolve(); + if (channel.readyState !== 'connecting') return void reject(new Error(CLOSED_CHANNEL)); + const settle = (fn, value) => { + channel.removeEventListener('open', onOpen); + channel.removeEventListener('close', onClose); + channel.removeEventListener('error', onError); + fn(value); + }; + const onOpen = () => settle(resolve); + const onClose = () => settle(reject, new Error(CLOSED_CHANNEL)); + const onError = (event) => settle(reject, event?.error ?? new Error('data channel error')); + channel.addEventListener('open', onOpen); + channel.addEventListener('close', onClose); + channel.addEventListener('error', onError); + }); + +// The per-message compression in effect on a channel, `{ encode, decode }`: +// over a link, what the two lists agree on once the peer's description +// announced its own (src/compression negotiate); over a raw channel the +// HEAD of the local list both ways — there is no handshake on a raw channel +// to negotiate through, so both applications name the same first codec, or +// neither turns it on. +const activeCompression = (compression, link) => { + if (compression === null) return null; + if (link !== null) return negotiate(compression, link.peerCaps?.enc); + const head = compression.codecs[0]; + return { encode: head, decode: head }; +}; + +// The revision over a link: a frame goes only to a peer whose descriptions +// said the half on the other end reads them — `caps.f` bit 1 its host, bit +// 2 its client (protocol.md#webrtc). Two peers whose `attachments` disagree +// used to send each other frames the other refused unread. A raw channel has +// no handshake: there the two applications agree, as on compression. +const peerReads = (link, bit) => link === null || ((link.peerCaps?.f ?? 0) & bit) !== 0; + +// One place for what both halves do with a channel: encode outbound frames +// into it and decode inbound ones from it. `sink` is bound once so the hot +// path allocates nothing per call. With compression in effect a message +// past the threshold is compressed BEFORE fragmentation — here, the one +// place it exists whole — and the COMPRESSED flag rides every fragment; the +// two Sequencers keep each direction in order around a codec that answers +// asynchronously, and cost nothing while nothing is in flight. +class ChannelCodec { + #channel; + #encoder; + #decoder; + #sink; + #compression; + #maxInflate; + #onMessage; + #onError; + #outbound; + #inbound; + // Bytes accepted by send() and not yet framed: counted with the + // channel's bufferedAmount so backpressure sees them. + #pending = 0; + // The marks, and whether a send() answered false since the last drain: + // the codec owns the 'drain' because it owns half of what is buffered. + // The channel's bufferedamountlow alone could not say it — bytes waiting + // in an asynchronous codec never cross the channel's threshold, so a + // false answered for them used to be a false with no drain ever after. + #highWater; + #lowWater; + #onDrain; + #blocked = false; + // The cap, the fault it reports through, and whether it fired: after it + // the channel is closing and send() answers false without touching it. + #maxBackpressure; + #onFault; + #faulted = false; + // A send() that failed during the current send(): its answer is false. + #lost = false; + + constructor( + channel, + maxMessageSize, + framing, + { compression = null, onMessage, onError, highWater, lowWater, onDrain, maxBackpressure = 0, onFault = null }, + ) { + this.#channel = channel; + this.#encoder = new FrameEncoder(maxMessageSize); + this.#decoder = new FrameDecoder(framing); + this.#decoder.compressed = compression !== null; + // The one place channel.send() runs: what it throws — closed under us, + // a message the implementation refuses — is caught here, never into + // the core, and never lets the encoder go on after a fragment it lost. + this.#sink = (frame) => { + if (this.#faulted) return; + try { + channel.send(frame); + } catch (error) { + this.#sendFailed(error); + } + }; + this.#compression = compression; + this.#maxInflate = framing?.maxReassembly ?? DEFAULT_MAX_REASSEMBLY; + this.#onMessage = onMessage; + this.#onError = onError; + this.#highWater = highWater; + this.#lowWater = lowWater; + this.#onDrain = onDrain; + this.#maxBackpressure = maxBackpressure; + this.#onFault = onFault; + this.#outbound = new Sequencer(onError); + this.#inbound = new Sequencer(onError); + } + + /** The codec ids in effect — `{ encode, decode }` — or null. */ + get compression() { + const active = this.#compression; + return active === null ? null : { encode: active.encode.id, decode: active.decode.id }; + } + + /** + * A string is a packet, bytes are a chunk; `options.compress === false` + * sends this one plain. Answers true under the high-water mark — the + * bytes still being compressed counted — and false past it, after which + * exactly one 'drain' follows. + */ + send(data, options = null) { + if (this.#faulted) return false; + const text = typeof data === 'string'; + const bytes = text ? null : toBytes(data); + const size = text ? data.length : bytes.length; + if (this.#overCap()) return false; + const compression = this.#compression; + const plain = compression === null || (options !== null && options.compress === false); + if (text) { + if (plain || size < compression.encode.threshold) this.#enqueueText(data); + else this.#compress(KIND_TEXT, TEXT_ENCODER.encode(data), null); + } else if (plain || size < compression.encode.threshold) this.#enqueue(KIND_BINARY, bytes); + else this.#compress(KIND_BINARY, bytes, null); + return this.#answer(); + } + + /** + * A fan-out's shared message (`{ text, frames, compress }` — rpc/rooms.js): + * what every link of the emit has in common is done ONCE and kept in the + * message's `frames` slot — the UTF-8 of the text and, under compression, + * the deflated body per codec — and only the fragmenting is per link. It + * used to be all per link: an emit to 32 peers was 32 encodes of the same + * string and 32 deflates of the same bytes. bench/rtc-fanout.js, per + * recipient at 32 links: 10.3 → 1.7 µs for 16 KB plain, 37.7 → 2.2 + * compressed; 0.51 → 0.14 and 9.1 → 0.44 at 512 B. The shared path wins + * from two recipients on, and costs 0.2 µs on an emit to exactly one. + * + * A slot another engine filled (a WebSocket's PreparedFrames, in a room + * mixing the two) is left alone, and the message goes as an ordinary send. + */ + sendShared(message) { + let shared = message.frames; + if (shared === null) shared = message.frames = new SharedFrames(message.text); + else if (!(shared instanceof SharedFrames)) return this.send(message.text, message); + if (this.#faulted) return false; + const { kind, bytes } = shared; + if (this.#overCap()) return false; + const compression = this.#compression; + if (compression === null || message.compress === false || bytes.length < compression.encode.threshold) { + this.#enqueue(kind, bytes); + } else this.#compress(kind, bytes, (shared.bodies ??= new Map())); + return this.#answer(); + } + + // Past maxBackpressure: the channel is closed, and said — before the + // message is queued, so one message larger than the cap still goes on an + // empty buffer. + // What is ALREADY queued is checked, as WebTransport and the WebSocket + // engine count it: the message's own size used to be added, so a single + // message past the cap closed a channel that held nothing. + #overCap() { + const max = this.#maxBackpressure; + const queued = this.#channel.bufferedAmount + this.#pending; + if (max === 0 || queued <= max) return false; + this.#faulted = true; + const error = new Error(`Backpressure limit exceeded (${queued} bytes queued over ${max}), closing the channel`); + error.code = 'backpressure'; + if (this.#onFault !== null) this.#onFault(error); + this.#channel.close(); + return true; + } + + // What a send answers: false when the channel lost the message, or past + // the high-water mark — and then exactly one 'drain' follows. + #answer() { + if (this.#lost) { + this.#lost = false; + return false; + } + if (this.#channel.bufferedAmount + this.#pending <= this.#highWater) return true; + this.#blocked = true; + return false; + } + + // channel.send() threw. Mid-message the peer holds a message with no end + // and nothing sent after it would parse: the channel is closed — locally, + // so over a link that is a redial. Before the first fragment the wire is + // intact and only this message is lost, which the fault says. A channel + // that is no longer open says so through its own close event instead. + #sendFailed(error) { + this.#lost = true; + const partial = this.#encoder.partial; + const closed = this.#channel.readyState !== 'open'; + if (partial || closed) this.#faulted = true; + if (closed && !partial) return; + const wrapped = new Error(`data channel send failed${partial ? ' mid-message' : ''}: ${error?.message ?? error}`); + wrapped.code = partial ? 'desync' : 'send'; + wrapped.cause = error; + if (this.#onFault !== null) this.#onFault(wrapped); + if (partial) this.#channel.close(); + } + + /** + * Something buffered was taken — the channel's bufferedamountlow, or a + * codec settling: the one 'drain' after a false, once under the low mark. + */ + drained() { + if (!this.#blocked || this.#channel.bufferedAmount + this.#pending > this.#lowWater) return; + this.#blocked = false; + this.#onDrain(); + } + + #enqueueText(text) { + if (this.#outbound.pending === 0) return void this.#encoder.encodeText(text, this.#sink); + this.#pending += text.length; + this.#outbound.push(text, (ready) => { + this.#pending -= text.length; + this.#encoder.encodeText(ready, this.#sink); + this.drained(); + }); + } + + #enqueue(kind, bytes) { + if (this.#outbound.pending === 0) return void this.#encoder.encode(kind, bytes, this.#sink); + this.#pending += bytes.length; + this.#outbound.push(bytes, (ready) => { + this.#pending -= bytes.length; + this.#encoder.encode(kind, ready, this.#sink); + this.drained(); + }); + } + + // Compressed under the flag when the codec shrank it, plain when it did + // not or failed — a message is never lost to compression. `bodies` is a + // shared message's cache (codec id -> body): the codec runs for the first + // link of an emit, and every later one takes what it answered — a value, + // or the same promise, which any number of Sequencers may wait on. + #compress(kind, bytes, bodies) { + const size = bytes.length; + this.#pending += size; + const plain = () => { + this.#pending -= size; + this.#encoder.encode(kind, bytes, this.#sink); + this.drained(); + }; + const { codec, id } = this.#compression.encode; + let encoded = bodies === null ? undefined : bodies.get(id); + if (encoded === undefined) { + try { + encoded = codec.encode(bytes); + } catch { + encoded = null; + } + if (bodies !== null) bodies.set(id, encoded); + } + // The codec threw: plain, in order behind whatever is in flight. + if (encoded === null) return void this.#outbound.push(bytes, plain); + this.#outbound.push( + encoded, + (out) => { + if (out.length >= size) return void plain(); + this.#pending -= size; + this.#encoder.encode(kind | FLAG_COMPRESSED, out, this.#sink); + this.drained(); + }, + plain, + ); + } + + /** + * Feeds one channel message; a completed message reaches `onMessage(kind, + * data)` in wire order — at once on the plain path, after its inflate + * (and behind whatever is still inflating) otherwise. Throws FramingError + * on a malformed frame, exactly as the decoder does; an inflate that + * fails or blows the cap reaches `onError` as one instead. + */ + receive(data) { + const message = this.#decoder.push(data); + if (message === null) return; + const { kind } = message; + if (!message.compressed) { + if (this.#inbound.pending === 0) return void this.#onMessage(kind, message.data); + return void this.#inbound.push(message.data, (bytes) => this.#onMessage(kind, bytes)); + } + const codec = this.#compression.decode.codec; + const payload = message.data; + const decode = () => codec.decode(payload, this.#maxInflate); + // Started now while few are in flight, in its slot past the limit: a + // burst of compressed messages is not a burst of parallel inflates. + let inflated = decode; + if (this.#inbound.pending < INFLIGHT_LIMIT) { + try { + inflated = decode(); + } catch (error) { + return void this.#onError(inflateError(error)); + } + } + this.#inbound.push( + inflated, + (bytes) => this.#onMessage(kind, kind === KIND_TEXT ? decodeText(bytes) : bytes), + (error) => this.#onError(inflateError(error)), + ); + } +} + +// An inflate failure is the peer's protocol violation, reported in the +// decoder's own shape so both transports treat it as they treat a bad frame. +const inflateError = (error) => { + const wrapped = new FramingError(`inflate failed: ${error?.message ?? error}`, 'inflate'); + wrapped.cause = error; + return wrapped; +}; + +class ClientRtcTransport extends ClientTransport { + // A data channel can die silently exactly like a socket (ICE stalls, the + // peer's tab is suspended): the client's app-level ping/pong is the + // detector, and the core owns every timer of it. + heartbeat = true; + persistent = true; + + #link = null; + // Raw-channel mode: the channel or factory handed over, and the channel + // currently spoken on (set before its open is awaited, so a terminate() + // during that wait closes it). + #source = null; + #channel = null; + #maxMessageSize; + #framing; + #highWater; + #lowWater; + #maxBackpressure; + #compression = null; + #codec = null; + #detach = null; + #opening = null; + // Bumped by terminate(): an open() that was waiting on the link when the + // core gave up must not come back to life on top of a later attempt. + #attempt = 0; + #onLinkState = (state) => { + if (state === 'failed' || state === 'closed') this.#down(); + }; + + constructor( + url, + { + link = null, + channel = null, + maxMessageSize = MIN_MESSAGE_SIZE, + framing = {}, + highWaterMark, + lowWaterMark, + maxBackpressure, + compression = null, + } = {}, + ) { + super(url); + if (link && channel) throw new TypeError('ClientRtcTransport: link and channel are mutually exclusive'); + checkMessageSize(maxMessageSize, 'ClientRtcTransport: options'); + this.#maxBackpressure = normalizeBackpressure(maxBackpressure, 'ClientRtcTransport: options'); + this.#link = link; + this.#source = channel; + this.#maxMessageSize = maxMessageSize; + this.#framing = framing; + const marks = normalizeWaterMarks(highWaterMark, lowWaterMark, 'ClientRtcTransport: options'); + this.#highWater = marks.high; + this.#lowWater = marks.low; + this.#compression = normalizeCompression(compression, 'ClientRtcTransport: options'); + if (link) this.#bindLink(link); + } + + get link() { + return this.#link; + } + + /** The compression codec id in effect on the channel — both ends named it — or null. */ + get compression() { + return this.#codec === null ? null : this.#codec.compression; + } + + /** The data channel spoken on — a link's client channel or the raw one; null before open(). */ + get channel() { + return this.#channel; + } + + /** + * Waits for the link to be connected and takes over its client channel — + * or, without a link, for the channel handed over (asking the factory + * for it first). Re-entrant: the core re-opens the same transport on + * every reconnect, and after a redial the link hands out a new channel + * (the factory, a new one). The link or channel may also arrive here, as + * `options.link` / `options.channel`, when the transport was picked by + * name through connect() — the same way the event transport receives + * `options.worker`. + */ + async open(options = {}) { + const link = this.#link ?? options.link ?? null; + const source = link ? null : (this.#source ?? options.channel ?? null); + if (!link && !source) { + throw new Error('WebRTC transport needs a link or a channel: pass options.link or options.channel to connect()'); + } + if (link && this.#link !== link) { + this.#link = link; + this.#bindLink(link); + } + if (source) { + this.#source = source; + if (options.maxMessageSize !== undefined) { + checkMessageSize(options.maxMessageSize, 'webrtc transport: options'); + this.#maxMessageSize = options.maxMessageSize; + } + } + // connect()'s own `compression` — the option a Node ws client and a + // WebTransport client share — resolved per open like link/channel. + if (options.compression !== undefined) { + this.#compression = normalizeCompression(options.compression, 'webrtc transport: options'); + } + if (this.active) return; + if (this.#opening) return this.#opening; + this.#opening = link ? this.#openLink(link) : this.#openChannel(source); + try { + await this.#opening; + } finally { + this.#opening = null; + } + } + + async #openLink(link) { + const attempt = ++this.#attempt; + await link.waitOpen(); + if (attempt !== this.#attempt) throw new Error('Connection terminated'); + const channel = link.clientChannel; + if (!channel || channel.readyState !== 'open') throw new Error('The link has no open client channel'); + this.revision = this.attachments !== false && !this.codec && peerReads(link, 1) ? 2 : 1; + this.#attach(channel, link.maxMessageSize, activeCompression(this.#compression, link)); + } + + async #openChannel(source) { + const attempt = ++this.#attempt; + const channel = typeof source === 'function' ? await source() : source; + if (attempt !== this.#attempt) { + // terminate() won while the factory was working: what it made is + // closed, not left open for nobody. + if (isRtcDataChannel(channel)) closeChannelQuietly(channel); + throw new Error('Connection terminated'); + } + if (!isRtcDataChannel(channel)) { + throw new TypeError('WebRTC transport: channel must be a data channel, or a factory returning one'); + } + this.#channel = channel; + await waitChannelOpen(channel); + if (attempt !== this.#attempt) throw new Error('Connection terminated'); + // A browser's default is 'blob'; the framing reads bytes. + channel.binaryType = 'arraybuffer'; + this.#attach(channel, this.#maxMessageSize, activeCompression(this.#compression, null)); + } + + #attach(channel, maxMessageSize, compression) { + this.#channel = channel; + const codec = new ChannelCodec(channel, maxMessageSize, this.#framing, { + compression, + onMessage: (_kind, data) => void this.emit('message', data), + // Scoped to THIS codec: a stale channel's inflate settling after a + // redial must not hang up the channel that replaced it. + onError: (error) => { + if (this.#codec === codec) this.#violation(error); + }, + highWater: this.#highWater, + lowWater: this.#lowWater, + onDrain: () => void this.emit('drain').catch((error) => this.#escalate(error)), + maxBackpressure: this.#maxBackpressure, + onFault: (error) => this.#escalate(error), + }); + this.#codec = codec; + channel.bufferedAmountLowThreshold = this.#lowWater; + // Scoped to THIS channel: a stale channel's late events after a redial + // must not reach a transport that has moved on. + const onMessage = ({ data }) => this.#receive(data); + const onClose = () => this.#down(); + const onDrain = () => codec.drained(); + const onError = (event) => this.#escalate(event?.error ?? new Error('data channel error')); + channel.addEventListener('message', onMessage); + channel.addEventListener('close', onClose); + channel.addEventListener('bufferedamountlow', onDrain); + channel.addEventListener('error', onError); + this.#detach = () => { + channel.removeEventListener('message', onMessage); + channel.removeEventListener('close', onClose); + channel.removeEventListener('bufferedamountlow', onDrain); + channel.removeEventListener('error', onError); + }; + this.active = true; + // Announced before open() resolves — the core's 'open' handler runs + // synchronously here, which is the invariant every transport keeps. + this.emit('open'); + } + + /** + * A string is a packet, bytes are a stream chunk. Throws when not + * connected (the core turns that into a coded 503); answers false above + * the high-water mark, after which 'drain' follows. + */ + write(data) { + if (!this.active) throw new Error('Not connected'); + return this.#codec.send(data); + } + + /** + * Ends the LINK: goodbye to the peer, both directions, no redial. Over a + * raw channel, closes that channel — the host half on the other end sees + * its close. + */ + close() { + const link = this.#link; + const channel = this.#channel; + this.#down(); + if (link) link.close(); + else if (channel) channel.close(); + } + + /** + * Local only: this direction is considered dead; the link is the + * owner's. The core terminates on a heartbeat timeout — a path that is + * silently dead while the link still reads 'connected' — and the RTC + * remedy for that is an ICE restart: it either heals the path under the + * open channels or fails the link, whose owner then redials. A raw + * channel has no such owner: a dead one is closed, and the next open() + * asks the factory for its successor. + */ + terminate() { + this.#attempt++; + const link = this.#link; + const channel = this.#channel; + this.#down(); + if (link) { + if (link.state === 'connected') link.restart(); + } else if (channel) { + channel.close(); + } + } + + #bindLink(link) { + link.on('state', this.#onLinkState); + } + + #receive(data) { + try { + this.#codec.receive(data); + } catch (error) { + this.#violation(error); + } + } + + // A protocol error on the wire — the data-channel 1002: the peer's + // framing is broken (or what it compressed does not inflate), so is the + // link. Scoped to the codec that saw it: a late inflate on a channel + // already replaced must not close its successor. + #violation(error) { + if (this.#codec === null) return; + this.#escalate(error); + this.close(); + } + + #down() { + if (this.#detach) this.#detach(); + this.#detach = null; + this.#codec = null; + this.#channel = null; + if (!this.active) return; + this.active = false; + this.emit('close'); + } + + #escalate(error) { + // The core binds 'error' and routes it through its own escalation; a + // transport used bare (tests) may have no listener, and Emitter throws + // on an unheard 'error'. + if (this.listenerCount('error') === 0) return; + void this.emit('error', error).catch(() => {}); + } +} + +class RtcPeerTransport extends ServerTransport { + kind = 'webrtc'; + #link; + #channel; + #codec; + #up = true; + #detach = null; + #onError; + #onLinkState = (state) => { + if (state === 'failed' || state === 'closed') this.#down(); + }; + + /** + * Over a link (its host channel) or over a raw data channel. `peer` is + * the remote peer id — the transport's `source`, what every log line and + * the Client's identity carry; over a raw channel it defaults to the + * channel's label. Inbound traffic is announced as 'packet' (text) and + * 'chunk' (bytes) events; PeerHost.attach and RpcServer.attach bind them. + */ + constructor( + source, + { + peer, + maxMessageSize = MIN_MESSAGE_SIZE, + framing = {}, + highWaterMark, + lowWaterMark, + maxBackpressure, + onError = null, + compression = null, + } = {}, + ) { + const raw = isRtcDataChannel(source); + const channel = raw ? source : (source?.hostChannel ?? null); + if (!channel) throw new Error('RtcPeerTransport: no host channel — pass a data channel or a link that has one'); + // A channel that is closing or closed never opens again: refused here, + // not discovered at the first write. + if (raw && (channel.readyState === 'closing' || channel.readyState === 'closed')) { + throw new TypeError(`RtcPeerTransport: the data channel is ${channel.readyState}`); + } + super(raw ? (peer ?? (channel.label || 'data channel')) : peer); + // Over a link the size is the link's (negotiated); the option is a raw + // channel's, and checked as one. + if (raw) checkMessageSize(maxMessageSize, 'RtcPeerTransport: options'); + const link = raw ? null : source; + this.#link = link; + this.#channel = channel; + if (!peerReads(link, 2)) this.setRevision(1); + this.#onError = onError; + const marks = normalizeWaterMarks(highWaterMark, lowWaterMark, 'RtcPeerTransport: options'); + // A link's channels are already binary; a raw one carries the browser + // default ('blob') until told otherwise. + if (raw) channel.binaryType = 'arraybuffer'; + const normalized = normalizeCompression(compression, 'RtcPeerTransport: options'); + const codec = new ChannelCodec(channel, raw ? maxMessageSize : link.maxMessageSize, framing, { + compression: activeCompression(normalized, link), + onMessage: (kind, data) => void this.emit(kind === KIND_TEXT ? 'packet' : 'chunk', data), + onError: (error) => this.#violation(error), + highWater: marks.high, + lowWater: marks.low, + onDrain: () => void this.emit('drain'), + maxBackpressure: normalizeBackpressure(maxBackpressure, 'RtcPeerTransport: options'), + onFault: (error) => this.#error(error), + }); + this.#codec = codec; + // What Client.persistent checks: a channel stays open like a socket. + this.connection = this; + channel.bufferedAmountLowThreshold = marks.low; + const onMessage = ({ data }) => this.#receive(data); + const onClose = () => this.#down(); + const onDrain = () => codec.drained(); + const onChannelError = (event) => this.#error(event?.error ?? new Error('data channel error')); + channel.addEventListener('message', onMessage); + channel.addEventListener('close', onClose); + channel.addEventListener('bufferedamountlow', onDrain); + channel.addEventListener('error', onChannelError); + if (link) link.on('state', this.#onLinkState); + this.#detach = () => { + channel.removeEventListener('message', onMessage); + channel.removeEventListener('close', onClose); + channel.removeEventListener('bufferedamountlow', onDrain); + channel.removeEventListener('error', onChannelError); + if (link) link.off('state', this.#onLinkState); + }; + } + + /** null over a raw channel. */ + get link() { + return this.#link; + } + + get channel() { + return this.#channel; + } + + /** The compression codec id in effect on the channel — both ends named it — or null. */ + get compression() { + return this.#codec.compression; + } + + /** The backpressure boolean the dispatcher and Broadcast read; false once down. */ + write(data) { + if (!this.#up) return false; + return this.#codec.send(data); + } + + // A write with per-message options (`compress: false`) — what + // Client.sendRaw and a Broadcast use, as on a WebSocket. + writeWith(text, options) { + if (!this.#up) return false; + return this.#codec.send(text, options); + } + + // The fan-out seam (Client.sendShared): one message for every recipient + // of an emit, its UTF-8 and its compressed body made once for all of them. + writeShared(message) { + if (!this.#up) return false; + return this.#codec.sendShared(message); + } + + /** Ends the link (or closes the raw channel) — the peer's client sees its transport close too. */ + close() { + this.#down(); + if (this.#link) this.#link.close(); + else this.#channel.close(); + } + + #receive(data) { + try { + this.#codec.receive(data); + } catch (error) { + this.#violation(error); + } + } + + #violation(error) { + if (!this.#up) return; + this.#error(error); + this.close(); + } + + #down() { + if (this.#detach) this.#detach(); + this.#detach = null; + if (!this.#up) return; + this.#up = false; + void this.emit('close'); + } + + #error(error) { + if (this.#onError) this.#onError(error); + } +} + +// Registered under the name connect() looks up: `transport: 'webrtc'` +// with `link` in the options. The PeerLink constructs it directly. +WrpcClient.transport.webrtc = ClientRtcTransport; +// What the client barrel does once: online/offline listeners. Idempotent — +// addEventListener ignores a duplicate of the same listener. +WrpcClient.initialize(); + +module.exports = { + ClientRtcTransport, + RtcPeerTransport, + normalizeWaterMarks, + normalizeBackpressure, + DEFAULT_HIGH_WATER_MARK, + DEFAULT_LOW_WATER_MARK, + DEFAULT_MAX_BACKPRESSURE, +}; diff --git a/src/websocket/connection.js b/src/websocket/connection.js index 19a3a0d..7094e4b 100644 --- a/src/websocket/connection.js +++ b/src/websocket/connection.js @@ -1,14 +1,34 @@ 'use strict'; const { EventEmitter } = require('node:events'); +const { createLoggerWriter } = require('../logging.js'); + +const crypto = require('node:crypto'); const { OPCODES, CLOSE_CODES, RSV1 } = require('./constants.js'); -const { Frame, EMPTY_PING, EMPTY_PONG } = require('./frame.js'); +const { Frame, EMPTY_PING, EMPTY_PONG, encodeFrame, encodeFrameFrom } = require('./frame.js'); const { FrameParser, isValidUTF8 } = require('./frameParser.js'); const { SegmentQueue } = require('./segments.js'); +const { PreparedFrames } = require('./prepared.js'); +const { DeflateContext } = require('./deflateContext.js'); const permessageDeflate = require('./permessageDeflate.js'); +// The write-queue pressure signal when a socket does not say (a test +// double): net.Socket's own default. +const DEFAULT_HIGH_WATER_MARK = 16 * 1024; + const MAX_BUFFER = 1024 * 1024 * 100; +// Up to this payload size a data frame is encoded as ONE contiguous buffer +// (header + payload) and written once; above it the payload is written as +// its own chunk behind a separate header, because copying a large payload +// costs more than the second write it saves (bench/send-path.js). +const SINGLE_WRITE_MAX = 16 * 1024; +// Module-wide utf8 scratch for sendText: written and copied out within one +// synchronous call, so sharing it between connections is safe. 48 KiB +// covers a 16 K-char message at the 3-bytes-per-unit worst case, i.e. +// every message that can take the single-write path. +const SCRATCH_SIZE = 48 * 1024; +const SCRATCH = Buffer.allocUnsafeSlow(SCRATCH_SIZE); // The inflated-size cap for permessage-deflate, separate from MAX_BUFFER on // purpose: MAX_BUFFER bounds bytes that already crossed the wire, while a // compression bomb turns a few KB on the wire into whatever this allows — @@ -19,8 +39,16 @@ const CLOSE_TIMEOUT = 1000; const CLOSE_GRACE = 200; const MAX_HEADER_SIZE = 14; // 2 base + 8 extended length + 4 mask key +// The inflate queue's marks (messages): held at INBOX_HIGH — INBOX_ASYNC_HIGH +// on the threadpool path, where inflates run in parallel — and taken up +// again at INBOX_LOW. Bytes are bounded by maxPayload alongside. +const INBOX_HIGH = 32; +const INBOX_ASYNC_HIGH = 4; +const INBOX_LOW = 8; + class Connection extends EventEmitter { #socket; + #log; #isClient; #queue = new SegmentQueue(); #pendingHeader = null; @@ -38,8 +66,49 @@ class Connection extends EventEmitter { #closeTimer = null; #needsDrain = false; #paused = false; + // Receive-side backpressure of the INFLATE queue: while this many + // messages (or maxPayload of compressed bytes) are inflating off the + // loop, the socket is paused — apart from the public pause(), which the + // application owns — and the frame loop stops, until enough landed. A + // peer that sent thousands of compressed frames in one segment used to + // have every one of them inflating at once, each holding its output. + #inboxBytes = 0; + #inboxHeld = false; #closeCode = CLOSE_CODES.CONNECTION_CLOSED_ABNORMALLY; #closeReason = ''; + // Set when WE failed the connection (a protocol error, an oversized or + // undecodable message): RFC 6455 7.1.7 — nothing the peer sends after + // that is acted on except its Close, which lets us hang up sooner. A + // ping in particular is not answered any more (Autobahn 4.1.3–4.2.5). + #failed = false; + // Context takeover (a live zlib stream per direction) when negotiated. + #context = null; + // Ordering queues around an asynchronous deflate/inflate — context + // takeover, or `async` past its threshold. Outbound: every write issued + // while a compress is in flight waits behind it, so frames leave in send + // order; `#outboxBytes` counts them into bufferedAmount so backpressure + // stays honest while they wait. Inbound: a message decoded off the loop + // is delivered in arrival order, the ones behind it held until it lands. + #outbox = []; + #outboxBytes = 0; + #draining = false; + // The Close frame of a graceful close() that found writes still waiting + // behind a compress: it is the outbox's tail, and the pump sends it when + // it gets there (see sendClose). + #pendingClose = null; + #inbox = []; + #coalesce; + #corked = false; + // Write coalescing: the first write of an event-loop turn corks the socket + // and this uncorks it on the next tick, so every frame produced in that + // turn — the N callbacks of a batch, a burst of events — leaves in ONE + // writev instead of N syscalls. The idiom node:http uses. The `send()` + // boolean stays honest: a corked write still reports writableLength + // against the high-water mark, and 'drain' is unchanged. + #uncork = () => { + this.#corked = false; + if (!this.#socket.destroyed) this.#socket.uncork(); + }; constructor(socket, head, options = {}) { super(); @@ -59,7 +128,17 @@ class Connection extends EventEmitter { fragmentThreshold = 0, protocol = '', deflate = null, + // Off for a bare Connection so a write is on the socket the moment + // send() returns; WebsocketServer turns it on for the connections it + // creates (a server answering many peers is where bursts happen). + coalesce = false, + // The writer WebsocketServer hands down, already childed with this + // connection's peer. A bare Connection gets the disabled one, whose + // methods are frozen no-ops — so the guarded paths below cost nothing + // when nobody is listening. + logger = false, } = options; + this.#log = createLoggerWriter(logger); this.#isClient = isClient; this.#maxBuffer = maxBuffer; this.#maxPayload = maxPayload; @@ -67,13 +146,27 @@ class Connection extends EventEmitter { this.#maxBackpressure = maxBackpressure; this.#fragmentThreshold = fragmentThreshold; this.#deflate = deflate; + if (deflate && (deflate.serverTakeover || deflate.clientTakeover)) { + this.#context = new DeflateContext({ + windowBits: deflate.windowBits, + level: deflate.level, + memLevel: deflate.memLevel, + server: deflate.serverTakeover, + client: deflate.clientTakeover, + }); + } + this.#coalesce = coalesce && typeof socket.cork === 'function'; if (deflate) this.#allowedRsv = RSV1; this.protocol = protocol; this.#init(head); } get bufferedAmount() { - return this.#socket.writableLength ?? 0; + return (this.#socket.writableLength ?? 0) + this.#outboxBytes; + } + + #highWaterMark() { + return this.#socket.writableHighWaterMark ?? DEFAULT_HIGH_WATER_MARK; } get remoteAddress() { @@ -81,9 +174,24 @@ class Connection extends EventEmitter { } #init(head) { + // Bytes the peer sent right behind its handshake. Parsed here, a frame + // among them that failed emitted 'error' from inside this constructor — + // before anyone could listen, so it was thrown out of the server's + // 'upgrade' listener, which is the process. Handed back to the socket + // instead, they are read first through 'data', once the owner of this + // connection is listening. A socket without `unshift` (a test double) + // gets them on the next tick — queued before the 'data' listener below + // resumes the socket, so nothing the socket holds overtakes them. + if (head && head.length > 0) { + if (typeof this.#socket.unshift === 'function') this.#socket.unshift(head); + else process.nextTick(() => this.#receive(head)); + } this.#socket.on('data', (data) => this.#receive(data)); this.#socket.on('drain', () => { if (!this.#needsDrain) return; + // Bytes still waiting behind a compress keep the pressure on; the + // queue pump announces the drain once they have left. + if (this.#outboxBytes > 0 && this.bufferedAmount >= this.#highWaterMark()) return; this.#needsDrain = false; this.emit('drain'); }); @@ -96,9 +204,6 @@ class Connection extends EventEmitter { if (this.#closeTimer) clearTimeout(this.#closeTimer); this.emit('close', this.#closeCode, this.#closeReason); }); - - // received data before upgrade - if (head && head.length > 0) this.#receive(head); } // Receive-side flow control: stops/restarts socket reads so a slow @@ -106,7 +211,7 @@ class Connection extends EventEmitter { // paused, inbound pongs are not read either — liveness checks must not // treat a paused connection as dead (see the heartbeat's isPaused skip). get isPaused() { - return this.#paused; + return this.#paused || this.#inboxHeld; } pause() { @@ -118,7 +223,24 @@ class Connection extends EventEmitter { resume() { if (this.#socket.destroyed) return; this.#paused = false; - this.#socket.resume(); + // Held for the inflate queue: the socket stays paused until it drains. + if (!this.#inboxHeld) this.#socket.resume(); + } + + #holdInbox() { + if (this.#inboxHeld) return; + this.#inboxHeld = true; + if (!this.#socket.destroyed) this.#socket.pause(); + } + + // Enough inflates landed: the socket reads again (unless the application + // paused it), and the frames already buffered are taken up — no 'data' + // event will come for those. + #releaseInbox() { + this.#inboxHeld = false; + if (this.#socket.destroyed) return; + if (!this.#paused) this.#socket.resume(); + this.#processFrames(); } #receive(data) { @@ -128,12 +250,13 @@ class Connection extends EventEmitter { if (this.#queue.length > this.#maxBuffer) { const error = new Error('Buffer overflow, closing connection'); - this.emit('error', error); + this.#fault('ws.overflow', error, { queued: this.#queue.length, max: this.#maxBuffer }); if (this.#isClient) { + this.#failed = true; return void this.sendClose(CLOSE_CODES.MESSAGE_TOO_BIG, 'Message too big'); } const frame = Frame.errorClose('MESSAGE_TOO_BIG'); - return void this.#close(frame); + return void this.#fail(frame); } this.#processFrames(); @@ -146,6 +269,9 @@ class Connection extends EventEmitter { // so anything pipelined behind the peer's Close in the same segment is // not just pointless to handle, it would write past end(). if (this.#closeReceived) break; + // The inflate queue is full: what is left waits in the segment queue + // (under maxBuffer) until it drains — see #releaseInbox. + if (this.#inboxHeld) break; if (!this.#pendingHeader) { const headerBytes = this.#queue.peek(MAX_HEADER_SIZE); const result = FrameParser.parseHeader(headerBytes, { allowedRsv: this.#allowedRsv }); @@ -155,11 +281,11 @@ class Connection extends EventEmitter { if (!this.#isClient && !value.masked) { const closeFrame = Frame.protocolErrorClose('UNMASKED'); - return void this.#close(closeFrame); + return void this.#fail(closeFrame); } if (this.#isClient && value.masked) { const closeFrame = Frame.protocolErrorClose('MASKED', this.#isClient); - return void this.#close(closeFrame); + return void this.#fail(closeFrame); } // A frame that can never fit is rejected on its header, before the // payload is buffered. @@ -191,6 +317,7 @@ class Connection extends EventEmitter { const { opcode } = frame; if (opcode === OPCODES.PING) { + if (this.#failed) return; this.emit('ping', frame.payload); return void this.sendPong(frame.payload); } @@ -216,24 +343,25 @@ class Connection extends EventEmitter { #processFrameParserError(error) { const { code } = error; const [type, subtype] = code.split('-'); - this.emit('error', error); + this.#fault('ws.frame', error, { close: code }); const frame = type === 'PROTOCOL_ERROR' ? Frame.protocolErrorClose(subtype, this.#isClient) : Frame.errorClose(type, this.#isClient); - this.#close(frame); + this.#fail(frame); } #trackMessageSize(size) { const tooBig = size > this.#maxBuffer; if (tooBig) { const error = new Error('Message too big'); - this.emit('error', error); + this.#fault('ws.too-big', error, { size, max: this.#maxBuffer }); if (this.#isClient) { + this.#failed = true; this.sendClose(CLOSE_CODES.MESSAGE_TOO_BIG, 'Message too big'); } else { const frame = Frame.errorClose('MESSAGE_TOO_BIG'); - this.#close(frame); + this.#fail(frame); } } return !tooBig; @@ -246,15 +374,15 @@ class Connection extends EventEmitter { // Continuation frame without a started fragmented message if (opcode === OPCODES.CONTINUATION) { const error = new Error('Protocol error: Unexpected CONTINUATION without start'); - this.emit('error', error); + this.#fault('ws.protocol', error); const frame = Frame.protocolErrorClose('COMMON', this.#isClient); - return void this.#close(frame); + return void this.#fail(frame); } if (frame.fin) { // single frame if (compressed) return void this.#emitInflated(opcode, payload); const isBinary = opcode === OPCODES.BINARY; - this.emit('message', frame.payload, isBinary); + this.#emitMessage(frame.payload, isBinary); } else { if (!this.#trackMessageSize(payload.length)) return; this.#fragments = { @@ -279,48 +407,169 @@ class Connection extends EventEmitter { const isText = firstOpcode === OPCODES.TEXT; if (isText && !isValidUTF8(fullPayload)) { const error = new Error('Invalid UTF-8 in text frame'); - this.emit('error', error); + this.#fault('ws.invalid-utf8', error); const frame = Frame.errorClose('INVALID_PAYLOAD', this.#isClient); - return void this.#close(frame); + return void this.#fail(frame); } - this.emit('message', fullPayload, isBinary); + this.#emitMessage(fullPayload, isBinary); } else { const error = new Error('Protocol error: Unexpected data frame during fragments'); - this.emit('error', error); + this.#fault('ws.protocol', error); const frame = Frame.protocolErrorClose('COMMON', this.#isClient); - return void this.#close(frame); + return void this.#fail(frame); } } + // A message decoded synchronously while others are still inflating off + // the loop waits behind them: the inbox is what keeps arrival order. + #emitMessage(data, isBinary) { + if (this.#inbox.length > 0) { + this.#inbox.push({ done: true, error: null, data, isBinary, isText: false, size: 0 }); + return; + } + this.emit('message', data, isBinary); + } + #emitInflated(opcode, payload) { // The dedicated inflated-size cap. zlib's maxOutputLength stops the // inflation the moment output would exceed it, so a compression bomb // costs at most `maxPayload` of memory and CPU before the close. const limit = Math.min(this.#maxPayload, this.#maxBuffer); + const isBinary = opcode === OPCODES.BINARY; + const isText = opcode === OPCODES.TEXT; + const deflate = this.#deflate; + const viaContext = this.#context !== null && deflate.clientTakeover === true; + const async = deflate.async ?? null; + if (viaContext || (async !== null && payload.length >= async.threshold)) { + const size = payload.length; + const item = { done: false, error: null, data: null, isBinary, isText, size }; + this.#inbox.push(item); + this.#inboxBytes += size; + // Never called inside decompress(): zlib's callback API and the live + // stream both settle off the loop, which is what lets #deliver take + // the socket up again without a re-entrancy guard here. + const settle = (error, inflated) => { + item.done = true; + item.error = error ?? null; + item.data = inflated ?? null; + this.#deliver(); + }; + if (viaContext) this.#context.decompress(payload, limit, settle); + else permessageDeflate.decompressAsync(payload, limit, settle); + // The context is one stream, so its inflates are serial anyway and + // the bound is on what waits; the threadpool path runs them in + // parallel, and libuv's pool is four threads wide. + const high = viaContext ? INBOX_HIGH : INBOX_ASYNC_HIGH; + if (this.#inbox.length >= high || this.#inboxBytes >= this.#maxPayload) this.#holdInbox(); + return; + } let inflated = null; try { inflated = permessageDeflate.decompress(payload, limit); } catch (error) { - this.emit('error', error); - const type = error.code === 'ERR_BUFFER_TOO_LARGE' ? 'MESSAGE_TOO_BIG' : 'INVALID_PAYLOAD'; - return void this.#close(Frame.errorClose(type, this.#isClient)); + return void this.#failInflate(error); } - const isText = opcode === OPCODES.TEXT; if (isText && !isValidUTF8(inflated)) { const error = new Error('Invalid UTF-8 in text frame'); - this.emit('error', error); - return void this.#close(Frame.errorClose('INVALID_PAYLOAD', this.#isClient)); + this.#fault('ws.invalid-utf8', error); + return void this.#fail(Frame.errorClose('INVALID_PAYLOAD', this.#isClient)); + } + this.#emitMessage(inflated, isBinary); + } + + // Every way this connection dies of its own accord used to be an 'error' + // event and nothing else. A server rarely listens for 'error' on an + // individual socket, so the operator's view was a connection that simply + // vanished — the classic "it just disconnects sometimes" report, with + // nothing in the log to work from. + // + // `warn`, not `error`: a peer sending a bad frame or overrunning a limit + // is that peer's problem, and a server with many of them must not have its + // log dominated by them. The limits (maxPayload, maxBuffer, + // maxBackpressure) are the operator's to raise, which is exactly why they + // have to be told the limit was what closed the connection. + #fault(event, error, extra = null) { + this.#log.warn({ ...extra, err: error, event }); + this.emit('error', error); + } + + #failInflate(error) { + const type = error.code === 'ERR_BUFFER_TOO_LARGE' ? 'MESSAGE_TOO_BIG' : 'INVALID_PAYLOAD'; + this.#fault('ws.inflate', error, { close: type }); + this.#fail(Frame.errorClose(type, this.#isClient)); + } + + // Delivers inbox messages in order as far as they have landed. + #deliver() { + const inbox = this.#inbox; + while (inbox.length > 0 && inbox[0].done) { + const item = inbox.shift(); + this.#inboxBytes -= item.size; + if (this.#closing) continue; + if (item.error !== null) return void this.#failInflate(item.error); + if (item.isText && !isValidUTF8(item.data)) { + const error = new Error('Invalid UTF-8 in text frame'); + this.#fault('ws.invalid-utf8', error); + return void this.#fail(Frame.errorClose('INVALID_PAYLOAD', this.#isClient)); + } + this.emit('message', item.data, item.isBinary); + } + if (this.#inboxHeld && inbox.length <= INBOX_LOW && this.#inboxBytes <= this.#maxPayload / 2) { + this.#releaseInbox(); } - this.emit('message', inflated, opcode === OPCODES.BINARY); } - send(data) { - if (typeof data === 'string') return this.sendText(data); - if (Buffer.isBuffer(data)) return this.sendBinary(data); + // `options.compress === false` sends this one message uncompressed even + // when deflate was negotiated and the payload clears the threshold. + send(data, options = null) { + if (typeof data === 'string') return this.sendText(data, options); + if (Buffer.isBuffer(data)) return this.sendBinary(data, options); throw new TypeError('send() accepts only string or Buffer'); } + // A message prepared for fan-out: `{ text, frames, compress }` where + // `frames` is the engine-owned cache slot (see prepared.js). The slot is + // claimed when empty and reused when it already holds our own cache; a + // slot another engine claimed means a mixed room, and the text goes the + // ordinary way. Client connections and fragmenting ones do too — a client + // frame needs a fresh mask, and a fragmented one is not one buffer. + sendPrepared(message) { + if (this.#closing) return false; + if (this.#exceedsBackpressure()) return false; + if (this.#isClient || this.#fragmentThreshold) return this.send(message.text, message); + let frames = message.frames; + if (frames === null) { + frames = message.frames = new PreparedFrames(message.text); + } else if (!(frames instanceof PreparedFrames)) { + return this.send(message.text, message); + } + const deflate = this.#deflate; + if (deflate !== null && message.compress !== false && frames.length >= deflate.threshold) { + // Context takeover is per connection by construction: the shared + // frame cannot serve it, the shared utf8 payload still can. + if (this.#context !== null && deflate.serverTakeover === true) { + return this.#enqueueCompress(frames.opcode, frames.payload, null); + } + const async = deflate.async ?? null; + if (async !== null && frames.length >= async.threshold) { + return this.#enqueueCompress(frames.opcode, frames.payload, frames); + } + return this.#write(frames.deflated(deflate.windowBits, deflate.zlibOptions ?? null)); + } + return this.#write(frames.plain()); + } + #write(buffer) { + // Behind an in-flight compress every write waits its turn (the pump + // writes with #draining set, so its own frames pass through). + if (this.#outbox.length > 0 && !this.#draining) { + return this.#enqueue({ buffer, payload: null, shared: null, opcode: 0, started: false }); + } + if (this.#coalesce && !this.#corked) { + this.#corked = true; + this.#socket.cork(); + process.nextTick(this.#uncork); + } const ok = this.#socket.write(buffer); if (!ok) this.#needsDrain = true; return ok; @@ -339,19 +588,143 @@ class Connection extends EventEmitter { if (!this.#maxBackpressure) return false; if (this.bufferedAmount <= this.#maxBackpressure) return false; const error = new Error('Backpressure limit exceeded, terminating connection'); - this.emit('error', error); + this.#fault('ws.backpressure', error, { buffered: this.bufferedAmount, max: this.#maxBackpressure }); this.terminate(); return true; } - #sendData(opcode, payload) { + #shouldCompress(length, options) { + const deflate = this.#deflate; + return deflate !== null && length >= deflate.threshold && (options === null || options.compress !== false); + } + + #sendData(opcode, payload, options) { let rsv = 0; - if (this.#deflate && payload.length >= this.#deflate.threshold) { - payload = permessageDeflate.compress(payload, this.#deflate.windowBits); + if (this.#shouldCompress(payload.length, options)) { + const deflate = this.#deflate; + const async = deflate.async ?? null; + const takeover = this.#context !== null && deflate.serverTakeover === true; + if (takeover || (async !== null && payload.length >= async.threshold)) { + return this.#enqueueCompress(opcode, payload, null); + } + payload = permessageDeflate.compress(payload, deflate.windowBits, deflate.zlibOptions ?? null); rsv = RSV1; } + return this.#emitFrames(opcode, payload, rsv); + } + + // --- The outbound ordering queue --------------------------------------- + + #enqueue(item) { + this.#outbox.push(item); + this.#outboxBytes += item.buffer !== null ? item.buffer.length : item.payload.length; + const ok = this.bufferedAmount < this.#highWaterMark(); + if (!ok) this.#needsDrain = true; + return ok; + } + + // A message whose deflate runs off the loop (context takeover, or + // `async` past its threshold; `shared` is a fan-out's PreparedFrames + // whose one deflate every recipient waits on). Queued behind whatever + // is already waiting, then the pump starts it. + #enqueueCompress(opcode, payload, shared) { + const ok = this.#enqueue({ buffer: null, payload, shared, opcode, started: false }); + this.#pump(); + return ok; + } + + #pump() { + const outbox = this.#outbox; + while (outbox.length > 0) { + const head = outbox[0]; + if (head.buffer !== null) { + outbox.shift(); + this.#outboxBytes -= head.buffer.length; + // The Close a graceful close() queued behind what it had accepted: + // everything ahead of it is on the socket now. + if (head.buffer === this.#pendingClose) return void this.#flushClose(); + this.#draining = true; + this.#write(head.buffer); + this.#draining = false; + continue; + } + if (head.started) return; + head.started = true; + return void this.#startCompress(head); + } + // Everything queued has reached the socket: if the queue was what held + // the pressure, say so now; a socket above its own mark says it later. + if (this.#needsDrain && (this.#socket.writableLength ?? 0) < this.#highWaterMark()) { + this.#needsDrain = false; + this.emit('drain'); + } + } + + #startCompress(job) { + const finish = (error, compressed, frame) => { + // The connection went away meanwhile: the queue was dropped with it. + if (this.#outbox[0] !== job) return; + if (error) { + // The mirror of ws.inflate: a deflate that failed on the way out is + // a line, not only an 'error' nobody may be listening to. + this.#fault('ws.deflate', error); + return void this.terminate(); + } + this.#outbox.shift(); + this.#outboxBytes -= job.payload.length; + this.#draining = true; + if (frame !== null) this.#write(frame); + else this.#emitFrames(job.opcode, compressed, RSV1); + this.#draining = false; + this.#pump(); + }; + const deflate = this.#deflate; + const zlibOptions = deflate.zlibOptions ?? null; + if (job.shared !== null) { + job.shared.deflatedAsync(deflate.windowBits, zlibOptions, (error, frame) => finish(error, null, frame)); + } else if (this.#context !== null && deflate.serverTakeover === true) { + this.#context.compress(job.payload, (error, compressed) => finish(error, compressed, null)); + } else { + permessageDeflate.compressAsync( + job.payload, + deflate.windowBits, + (error, compressed) => finish(error, compressed, null), + zlibOptions, + ); + } + } + + #dropInbound() { + const held = this.#inboxHeld; + this.#inbox.length = 0; + this.#inboxBytes = 0; + this.#inboxHeld = false; + // The socket the hold paused reads again (unless the application paused + // it): the peer's Close, answering ours, is on it — left unread, the + // close waited out closeTimeout and ended as 1006. + if (held && !this.#paused && !this.#socket.destroyed) this.#socket.resume(); + } + + // Both directions, and the takeover context with them: every way out but + // a graceful close whose accepted writes are still on their way. + #dropQueues() { + this.#outbox.length = 0; + this.#outboxBytes = 0; + this.#pendingClose = null; + this.#dropInbound(); + if (this.#context !== null) this.#context.close(); + } + + // Frames an already-compressed (or plain) payload: one contiguous buffer + // up to SINGLE_WRITE_MAX, header + payload above it, fragments when a + // fragmentThreshold asks for them. + #emitFrames(opcode, payload, rsv) { const threshold = this.#fragmentThreshold; if (!threshold || payload.length <= threshold) { + if (payload.length <= SINGLE_WRITE_MAX) { + const mask = this.#isClient ? crypto.randomBytes(4) : null; + return this.#write(encodeFrame(opcode, rsv, payload, mask)); + } return this.#writeFrame(new Frame(true, opcode, false, payload, null, rsv)); } let ok = true; @@ -372,17 +745,34 @@ class Connection extends EventEmitter { // Data send methods return false when the socket did not take the bytes // without exceeding its high-water mark — wait for 'drain' before more. - sendText(message) { + sendText(message, options = null) { if (this.#closing) return false; if (this.#exceedsBackpressure()) return false; - return this.#sendData(OPCODES.TEXT, Buffer.from(message, 'utf8')); + // A message that fits the scratch buffer even at 3 bytes per UTF-16 + // unit is utf8-encoded ONCE into it; the frame then copies those bytes + // behind its header. Encoding straight into the frame would need + // Buffer.byteLength first — a second pass over the string that costs + // more than the memcpy it saves at typical packet sizes, and + // Buffer.from + a separate header costs an allocation and two writes + // (bench/send-path.js, "sendText 200 B"). + if (message.length * 3 <= SCRATCH_SIZE) { + const length = SCRATCH.write(message, 0, 'utf8'); + const threshold = this.#fragmentThreshold; + if (length <= SINGLE_WRITE_MAX && (!threshold || length <= threshold) && !this.#shouldCompress(length, options)) { + const mask = this.#isClient ? crypto.randomBytes(4) : null; + return this.#write(encodeFrameFrom(OPCODES.TEXT, 0, SCRATCH, length, mask)); + } + // Compressed, fragmented or large: a copy the scratch can outlive. + return this.#sendData(OPCODES.TEXT, Buffer.from(SCRATCH.subarray(0, length)), options); + } + return this.#sendData(OPCODES.TEXT, Buffer.from(message, 'utf8'), options); } - sendBinary(buffer) { + sendBinary(buffer, options = null) { if (this.#closing) return false; if (this.#exceedsBackpressure()) return false; if (!Buffer.isBuffer(buffer)) buffer = Buffer.from(buffer); - return this.#sendData(OPCODES.BINARY, buffer); + return this.#sendData(OPCODES.BINARY, buffer, options); } sendPing(payload) { @@ -401,6 +791,8 @@ class Connection extends EventEmitter { // compliance is not a suicide pact — past the cap the connection is // terminated like any other non-reading peer. sendPong(payload) { + // Behind a queued Close it would leave AFTER the Close frame. + if (this.#pendingClose !== null) return false; if (this.#exceedsBackpressure()) return false; if (payload) return this.#writeFrame(Frame.pong(payload)); return this.#fastPong(); @@ -416,11 +808,19 @@ class Connection extends EventEmitter { return this.#write(buf); } + // A close WE initiated because the peer broke the protocol: the close + // frame goes out and the connection is failed (see #failed). + #fail(frameBuffer) { + this.#failed = true; + this.#close(frameBuffer); + } + #close(frameBuffer) { if (this.#closing) return; this.#closing = true; this.#closeSent = true; this.#fragments = null; + this.#dropQueues(); this.#socket.write(frameBuffer); @@ -435,6 +835,16 @@ class Connection extends EventEmitter { }, this.#closeTimeout); } + // The queued Close of a graceful close(), at the moment it leaves: by the + // pump once the outbox ahead of it is written, or by the close timer when + // that took longer than `closeTimeout`. + #flushClose() { + const frame = this.#pendingClose; + this.#dropQueues(); + this.#closeSent = true; + this.#socket.write(frame); + } + // Answers a peer-initiated Close and hangs up. // // RFC 6455 5.5.1: the side ANSWERING a Close closes the TCP connection @@ -446,10 +856,14 @@ class Connection extends EventEmitter { // end() rather than destroy(): the echo has to flush before the FIN, and // the short grace timer only covers a peer that never answers the FIN. #answerClose(code = 1000, reason = '') { - if (this.#closing) return; + // Our own Close may still be queued behind accepted writes (closing, + // not yet sent): the peer closing first ends that — it reads nothing + // more — so the queue is dropped and the peer's Close answered. + if (this.#closing && this.#pendingClose === null) return; this.#closing = true; this.#closeSent = true; this.#fragments = null; + this.#dropQueues(); const frame = Frame.close(code, reason); if (this.#isClient) frame.maskPayload(); this.#socket.write(frame.toBuffer()); @@ -460,10 +874,38 @@ class Connection extends EventEmitter { }, CLOSE_GRACE); } + // A graceful close delivers what send() already accepted. With a compress + // in flight (context takeover, or `async` past its threshold) accepted + // writes wait in the outbox, and closing used to drop them — so whether + // `send(x); close()` delivered x depended on a performance option. The + // Close frame now takes its place at the outbox's TAIL; nothing new is + // accepted meanwhile, and `closeTimeout` stays the upper bound on the + // whole close: when it expires with the queue still waiting, the queue is + // dropped — and said (`ws.close.dropped`) — and the Close goes out. sendClose(code = 1000, reason = '') { const frame = Frame.close(code, reason); if (this.#isClient) frame.maskPayload(); - this.#close(frame.toBuffer()); + const buffer = frame.toBuffer(); + if (this.#closing || this.#outbox.length === 0) return void this.#close(buffer); + this.#closing = true; + this.#fragments = null; + this.#dropInbound(); + this.#pendingClose = buffer; + this.#enqueue({ buffer, payload: null, shared: null, opcode: 0, started: false }); + this.#closeTimer = setTimeout(() => { + if (this.#pendingClose !== null) { + // A line, not an 'error': nothing is wrong with the connection but + // the time its last writes took. + const frames = this.#outbox.length - 1; + const bytes = this.#outboxBytes - this.#pendingClose.length; + this.#log.warn({ event: 'ws.close.dropped', frames, bytes }); + this.#flushClose(); + } + this.#socket.end(); + setTimeout(() => { + this.#socket.destroy(); + }, 200); + }, this.#closeTimeout); } // WrpcSocket engine-contract alias for sendClose @@ -476,6 +918,7 @@ class Connection extends EventEmitter { clearTimeout(this.#closeTimer); this.#closeTimer = null; } + this.#dropQueues(); if (!this.#socket.destroyed) this.#socket.destroy(); } } diff --git a/src/websocket/deflateContext.js b/src/websocket/deflateContext.js new file mode 100644 index 0000000..f5f54f1 --- /dev/null +++ b/src/websocket/deflateContext.js @@ -0,0 +1,148 @@ +'use strict'; + +const zlib = require('node:zlib'); + +const { TRAILER } = require('./permessageDeflate.js'); + +// The per-connection half of permessage-deflate with context takeover +// (RFC 7692 7.1.1): one long-lived deflate stream, one long-lived inflate +// stream, each keeping its window across messages so a message may +// reference the bytes of the ones before it — the ratio the stateless +// one-shot path gives up. +// +// zlib exposes no synchronous API on a live stream (deflateRawSync builds +// a throwaway one), so both directions are the public write() + flush() +// pair, asynchronous, and the Connection's ordering queues keep frames in +// order around them. One operation at a time per stream, enforced HERE: a +// second operation started while the first is still flushing would collect +// the first one's output too, so each direction keeps its own FIFO and +// starts the next operation when the previous one settles. +// +// Memory per connection: the deflate window (1 << windowBits, up to 32 KiB) +// plus its hash tables (1 << (memLevel + 9), 128 KiB at the default 8) — +// what docs/reference/wire-format.md weighs against the ratio. +class DeflateContext { + #deflate = null; + #inflate = null; + #options; + #closed = false; + // Per-direction operation queues: [{ input, maxLength, cb }], head in flight. + #deflateOps = []; + #inflateOps = []; + + constructor({ windowBits = 15, level, memLevel, server = true, client = true } = {}) { + this.#options = { windowBits, level, memLevel, server, client }; + } + + get closed() { + return this.#closed; + } + + // The stream is created on first use so a connection that negotiated + // takeover but never sends a large message pays nothing. + #deflateStream() { + if (this.#deflate === null) { + const { windowBits, level, memLevel } = this.#options; + const options = { windowBits }; + if (level !== undefined) options.level = level; + if (memLevel !== undefined) options.memLevel = memLevel; + this.#deflate = zlib.createDeflateRaw(options); + } + return this.#deflate; + } + + #inflateStream() { + if (this.#inflate === null) this.#inflate = zlib.createInflateRaw({ windowBits: 15 }); + return this.#inflate; + } + + // Compresses one message on the live stream: write, then a sync flush so + // the output ends on a byte boundary with the empty stored block RFC 7692 + // 7.2.1 says to strip. cb(error, buffer). + compress(payload, cb) { + if (this.#closed) return void cb(new Error('deflate context closed')); + this.#schedule( + this.#deflateOps, + () => this.#deflateStream(), + payload, + Infinity, + (error, output) => { + if (error) return void cb(error); + cb(null, output.subarray(0, output.length - TRAILER.length)); + }, + ); + } + + // Inflates one message on the live stream, the peer's stripped trailer + // re-appended. `maxLength` is the inflated-size cap: the stream is + // watched as it produces output, and a message that would exceed the cap + // fails with ERR_BUFFER_TOO_LARGE — the code the sync path's + // maxOutputLength uses — after at most one chunk past it. + decompress(payload, maxLength, cb) { + if (this.#closed) return void cb(new Error('deflate context closed')); + this.#schedule(this.#inflateOps, () => this.#inflateStream(), Buffer.concat([payload, TRAILER]), maxLength, cb); + } + + #schedule(ops, stream, input, maxLength, cb) { + ops.push({ stream, input, maxLength, cb }); + if (ops.length === 1) this.#next(ops); + } + + #next(ops) { + if (ops.length === 0 || this.#closed) return; + const op = ops[0]; + run(op.stream(), op.input, op.maxLength, (error, output) => { + ops.shift(); + op.cb(error, output); + this.#next(ops); + }); + } + + close() { + if (this.#closed) return; + this.#closed = true; + // Whatever was still queued never lands: the connection is gone. + this.#deflateOps.length = 0; + this.#inflateOps.length = 0; + if (this.#deflate !== null) this.#deflate.close(); + if (this.#inflate !== null) this.#inflate.close(); + this.#deflate = null; + this.#inflate = null; + } +} + +// One write + Z_SYNC_FLUSH on a zlib stream, collecting the output. The +// stream's 'data' and 'error' listeners are attached for the duration of +// the operation only: a context serves one message at a time. +const run = (stream, input, maxLength, cb) => { + const chunks = []; + let total = 0; + let settled = false; + const finish = (error, output) => { + if (settled) return; + settled = true; + stream.off('data', onData); + stream.off('error', onError); + cb(error, output); + }; + const onData = (chunk) => { + total += chunk.length; + if (total > maxLength) { + const error = new RangeError('Inflated message exceeds the size cap'); + error.code = 'ERR_BUFFER_TOO_LARGE'; + // The stream holds the bomb's state; it is not reused after this. + stream.close(); + return void finish(error); + } + chunks.push(chunk); + }; + const onError = (error) => finish(error); + stream.on('data', onData); + stream.on('error', onError); + stream.write(input); + stream.flush(zlib.constants.Z_SYNC_FLUSH, () => { + finish(null, chunks.length === 1 ? chunks[0] : Buffer.concat(chunks)); + }); +}; + +module.exports = { DeflateContext }; diff --git a/src/websocket/frame.js b/src/websocket/frame.js index b1c03ca..e21f6fc 100644 --- a/src/websocket/frame.js +++ b/src/websocket/frame.js @@ -74,6 +74,55 @@ const applyMask = (payload, mask) => { for (; i < length; i++) payload[i] ^= mask[i & 3]; }; +// Frame header size for a payload of `length` bytes (RFC 6455 5.2). +const headerSize = (length, masked) => { + const base = length < LEN_16_BIT ? 2 : length <= MAX_16_BIT ? 4 : 10; + return masked ? base + 4 : base; +}; + +// Writes a final-frame header at the start of `target`, returns its size. +// `mask` is the 4-byte key for a client frame, null for a server frame. +const writeHeader = (target, opcode, rsv, length, mask) => { + const masked = mask !== null ? MASK_MASK : 0; + target[0] = FINAL_FRAME | rsv | opcode; + let offset; + if (length < LEN_16_BIT) { + target[1] = masked | length; + offset = 2; + } else if (length <= MAX_16_BIT) { + target[1] = masked | LEN_16_BIT; + target.writeUInt16BE(length, 2); + offset = 4; + } else { + target[1] = masked | LEN_64_BIT; + target.writeUInt32BE(Math.trunc(length / TWO_32), 2); + target.writeUInt32BE(length >>> 0, 6); + offset = 10; + } + if (mask !== null) { + mask.copy(target, offset); + offset += 4; + } + return offset; +}; + +// One contiguous buffer holding header + payload: a single socket write per +// frame instead of a cork/write/write/uncork sequence, and no separate +// header allocation (bench/send-path.js). allocUnsafe is safe here because +// every byte is written: the header by writeHeader, the rest by the copy. +const encodeFrame = (opcode, rsv, payload, mask = null) => encodeFrameFrom(opcode, rsv, payload, payload.length, mask); + +// Same, from the first `length` bytes of `source` — how sendText frames the +// scratch buffer without a subarray view per call. +const encodeFrameFrom = (opcode, rsv, source, length, mask) => { + const size = headerSize(length, mask !== null); + const frame = Buffer.allocUnsafe(size + length); + writeHeader(frame, opcode, rsv, length, mask); + source.copy(frame, size, 0, length); + if (mask !== null) applyMask(frame.subarray(size), mask); + return frame; +}; + class Frame { constructor(fin, opcode, masked, payload, mask, rsv = RSV) { this.fin = fin; @@ -220,4 +269,4 @@ class Frame { } } -module.exports = { Frame, EMPTY_PING, EMPTY_PONG, applyMask }; +module.exports = { Frame, EMPTY_PING, EMPTY_PONG, applyMask, headerSize, writeHeader, encodeFrame, encodeFrameFrom }; diff --git a/src/websocket/permessageDeflate.js b/src/websocket/permessageDeflate.js index 0b0ddc9..dd994d3 100644 --- a/src/websocket/permessageDeflate.js +++ b/src/websocket/permessageDeflate.js @@ -5,6 +5,12 @@ const zlib = require('node:zlib'); const EXTENSION_NAME = 'permessage-deflate'; const TRAILER = Buffer.from([0x00, 0x00, 0xff, 0xff]); const DEFAULT_THRESHOLD = 1024; +// The threadpool hand-off of zlib's async API costs a fixed amount per +// message: sequential throughput is a quarter of the sync path at 4 KB, a +// half at 32 KB and level at 256 KB (bench/deflate-context.js) — so below +// this a synchronous deflate is cheaper, and above it the hand-off is free +// and the event loop is the thing being bought. +const DEFAULT_ASYNC_THRESHOLD = 256 * 1024; // zlib cannot produce a true 8-bit raw-deflate window, so an offer // demanding server_max_window_bits=8 cannot be honored and is skipped. const MIN_WINDOW_BITS = 9; @@ -60,6 +66,29 @@ const parseWindowBits = (value) => { return bits; }; +// `contextTakeover`: false (default) pins both directions to no context +// takeover; 'server' keeps a deflate context for what WE send, 'client' +// keeps an inflate context for what the peer sends, true both. A peer's +// own `*_no_context_takeover` request is always honoured (RFC 7692 +// 7.1.1.1), so the ACCEPTED state can be narrower than the option. +const takeoverOf = (options) => { + const value = options.contextTakeover; + if (value === true) return { server: true, client: true }; + if (value === 'server') return { server: true, client: false }; + if (value === 'client') return { server: false, client: true }; + return { server: false, client: false }; +}; + +// `async: { threshold }` moves the deflate/inflate of messages at or over +// `threshold` bytes off the event loop (zlib's threadpool API); null keeps +// every message synchronous. +const asyncOf = (options) => { + const value = options.async; + if (!value || typeof value !== 'object') return null; + const threshold = value.threshold; + return { threshold: typeof threshold === 'number' && threshold > 0 ? threshold : DEFAULT_ASYNC_THRESHOLD }; +}; + const acceptOffer = (offer, options) => { if (!offer.valid) return null; let windowBits = MAX_WINDOW_BITS; @@ -74,21 +103,58 @@ const acceptOffer = (offer, options) => { if (parseWindowBits(value) === null) return null; } } - const response = [EXTENSION_NAME, 'server_no_context_takeover', 'client_no_context_takeover']; + const takeover = takeoverOf(options); + const serverTakeover = takeover.server && !offer.params.has('server_no_context_takeover'); + const clientTakeover = takeover.client && !offer.params.has('client_no_context_takeover'); + const response = [EXTENSION_NAME]; + if (!serverTakeover) response.push('server_no_context_takeover'); + if (!clientTakeover) response.push('client_no_context_takeover'); if (offer.params.has('server_max_window_bits')) { response.push(`server_max_window_bits=${windowBits}`); } + // The zlib knobs of every one-shot deflate this connection makes — + // unicast, fan-out and threadpool alike; null when neither is set, so + // the common path builds no extra object. They used to reach the live + // context only: a `level` on a no-takeover connection was ignored. Their + // cost is bench/send-path.js (levels 1/3/6 on 2 KB and 24 KB) beside + // bench/algorithms.js. + const zlibOptions = options.level === undefined && options.memLevel === undefined ? null : {}; + if (options.level !== undefined) zlibOptions.level = options.level; + if (options.memLevel !== undefined) zlibOptions.memLevel = options.memLevel; return { response: response.join('; '), threshold: options.threshold ?? DEFAULT_THRESHOLD, windowBits, + serverTakeover, + clientTakeover, + level: options.level, + memLevel: options.memLevel, + zlibOptions, + async: asyncOf(options), }; }; +// Refused at construction, not by zlib at the first message: level −1..9 +// (−1 is zlib's default, 6) and memLevel 1..9. +const assertDeflateOptions = (options) => { + if (options === true || options === undefined || options === null) return; + if (typeof options !== 'object') throw new TypeError('perMessageDeflate: expected true or an options object'); + const { level, memLevel } = options; + if (level !== undefined && !(Number.isInteger(level) && level >= -1 && level <= 9)) { + throw new TypeError('perMessageDeflate: level must be an integer from -1 to 9'); + } + if (memLevel !== undefined && !(Number.isInteger(memLevel) && memLevel >= 1 && memLevel <= 9)) { + throw new TypeError('perMessageDeflate: memLevel must be an integer from 1 to 9'); + } +}; + // Server-side negotiation: accepts the first honorable permessage-deflate -// offer. Both directions are pinned to no context takeover, so every -// message is a self-contained deflate stream — compression state never -// spans messages and one-shot zlib calls suffice. +// offer. By default both directions are pinned to no context takeover, so +// every message is a self-contained deflate stream — compression state +// never spans messages, one-shot zlib calls suffice, and the compressed +// bytes are a function of (payload, window) alone (what lets a fan-out +// share one frame). `contextTakeover` opts a direction into a live stream +// per connection (deflateContext.js). // Returns the accepted params, null to decline, or { malformed: true } // when the header violates the grammar — the handshake must then fail // with 400 (RFC 6455 4.2.1), not proceed without the extension. @@ -104,11 +170,13 @@ const negotiate = (header, options = {}) => { return null; }; -const compress = (payload, windowBits = MAX_WINDOW_BITS) => { - const compressed = zlib.deflateRawSync(payload, { - windowBits, - finishFlush: zlib.constants.Z_SYNC_FLUSH, - }); +const compress = (payload, windowBits = MAX_WINDOW_BITS, options = null) => { + const compressed = zlib.deflateRawSync( + payload, + options === null + ? { windowBits, finishFlush: zlib.constants.Z_SYNC_FLUSH } + : { ...options, windowBits, finishFlush: zlib.constants.Z_SYNC_FLUSH }, + ); // RFC 7692 7.2.1: strip the trailing empty block (00 00 ff ff) return compressed.subarray(0, compressed.length - TRAILER.length); }; @@ -122,11 +190,41 @@ const decompress = (payload, maxLength) => finishFlush: zlib.constants.Z_SYNC_FLUSH, }); +// The same two, off the event loop: zlib's callback API runs in libuv's +// threadpool. Same bytes as the sync pair for the same input. +const compressAsync = (payload, windowBits, cb, options = null) => { + zlib.deflateRaw( + payload, + options === null + ? { windowBits, finishFlush: zlib.constants.Z_SYNC_FLUSH } + : { ...options, windowBits, finishFlush: zlib.constants.Z_SYNC_FLUSH }, + (error, compressed) => { + if (error) return void cb(error); + cb(null, compressed.subarray(0, compressed.length - TRAILER.length)); + }, + ); +}; + +const decompressAsync = (payload, maxLength, cb) => { + zlib.inflateRaw( + Buffer.concat([payload, TRAILER]), + { windowBits: MAX_WINDOW_BITS, maxOutputLength: maxLength, finishFlush: zlib.constants.Z_SYNC_FLUSH }, + cb, + ); +}; + module.exports = { EXTENSION_NAME, DEFAULT_THRESHOLD, + DEFAULT_ASYNC_THRESHOLD, + MIN_WINDOW_BITS, + MAX_WINDOW_BITS, + TRAILER, parseExtensions, negotiate, + assertDeflateOptions, compress, decompress, + compressAsync, + decompressAsync, }; diff --git a/src/websocket/prepared.js b/src/websocket/prepared.js new file mode 100644 index 0000000..ad0239e --- /dev/null +++ b/src/websocket/prepared.js @@ -0,0 +1,105 @@ +'use strict'; + +const { OPCODES, RSV1 } = require('./constants.js'); +const { encodeFrame } = require('./frame.js'); +const { compress, compressAsync, MIN_WINDOW_BITS, MAX_WINDOW_BITS } = require('./permessageDeflate.js'); + +// The engine-owned half of a shared message (see Connection.sendPrepared): +// one text, encoded to the wire ONCE for however many connections write it. +// +// The plain frame is header + utf8 payload in one buffer. The deflated +// frames are keyed by the recipient's negotiated windowBits: with both +// no-context-takeover params pinned (permessageDeflate.js) the compressed +// bytes are a pure function of (payload, windowBits), so two connections +// that negotiated the same window get byte-identical frames — which is what +// turns a room fan-out's N deflates into at most one per distinct window. +// Without this a room of 50 paid 50 deflateRawSync calls per emit +// (bench/send-path.js, "room fan-out +deflate"). +// +// Server frames are never masked, so a prepared frame can be written to any +// number of sockets; a client Connection falls back to its own encoding. +const SLOTS = MAX_WINDOW_BITS - MIN_WINDOW_BITS + 1; + +class PreparedFrames { + #plain = null; + #deflated = null; + + // `text` is a string (a JSON packet, TEXT frames) or bytes (an + // attachments frame, BINARY frames) — one opcode per message, decided here. + constructor(text) { + if (typeof text === 'string') { + this.payload = Buffer.from(text, 'utf8'); + this.opcode = OPCODES.TEXT; + } else { + this.payload = Buffer.isBuffer(text) ? text : Buffer.from(text.buffer, text.byteOffset, text.byteLength); + this.opcode = OPCODES.BINARY; + } + } + + get length() { + return this.payload.length; + } + + plain() { + let frame = this.#plain; + if (frame === null) frame = this.#plain = encodeFrame(this.opcode, 0, this.payload); + return frame; + } + + #slots() { + let frames = this.#deflated; + if (frames === null) { + frames = this.#deflated = new Array(SLOTS); + for (let i = 0; i < SLOTS; i++) frames[i] = null; + } + return frames; + } + + // `options` are the recipient engine's zlib knobs (level, memLevel). The + // slot is keyed by windowBits alone: two engines on different levels in + // one process would give the slot the first recipient's bytes — correct + // for every recipient (any level inflates the same), just not their + // own level — and one PreparedFrames belongs to one broadcast, so the + // common case of one engine never sees it. + deflated(windowBits, options = null) { + const frames = this.#slots(); + const slot = windowBits - MIN_WINDOW_BITS; + let frame = frames[slot]; + // A slot may hold the waiters of an in-flight async deflate (below); + // a synchronous caller then computes the same bytes itself rather than + // block on the threadpool. + if (frame === null || !Buffer.isBuffer(frame)) { + frame = encodeFrame(this.opcode, RSV1, compress(this.payload, windowBits, options)); + if (frames[slot] === null) frames[slot] = frame; + } + return frame; + } + + // The off-loop variant (`perMessageDeflate.async`): the first recipient + // to need a window's frame starts ONE zlib.deflateRaw, and every later + // recipient of the same emit waits on that same result — still one + // deflate per window per fan-out, just not on the event loop. + deflatedAsync(windowBits, options, cb) { + const frames = this.#slots(); + const slot = windowBits - MIN_WINDOW_BITS; + const current = frames[slot]; + if (current !== null) { + if (Buffer.isBuffer(current)) return void cb(null, current); + return void current.push(cb); + } + const waiters = [cb]; + frames[slot] = waiters; + compressAsync( + this.payload, + windowBits, + (error, compressed) => { + const frame = error ? null : encodeFrame(this.opcode, RSV1, compressed); + frames[slot] = frame; + for (let i = 0; i < waiters.length; i++) waiters[i](error, frame); + }, + options, + ); + } +} + +module.exports = { PreparedFrames }; diff --git a/src/websocket/server.js b/src/websocket/server.js index 4e32855..9878a1f 100644 --- a/src/websocket/server.js +++ b/src/websocket/server.js @@ -1,6 +1,7 @@ 'use strict'; const crypto = require('node:crypto'); +const { createLoggerWriter } = require('../logging.js'); const { EventEmitter } = require('node:events'); const { Connection } = require('./connection.js'); @@ -23,8 +24,8 @@ const hasToken = (value, token) => !!value && value.toLowerCase().includes(token // A wrpc client OFFERS it; with no app-configured protocols the server // echoes it back, which is what stamps the wire with a version both sides // can rely on. A peer that offers nothing gets no subprotocol and speaks -// 1.0 — additive, nothing breaks. -const WRPC_PROTOCOL = 'wrpc.v1'; +// 1.0 — additive, nothing breaks. Re-exported below, as it always was. +const { WRPC_V1, WRPC_V2, CARRIER_PROTOCOL } = require('../wire.js'); const writeResponse = (socket, headerLines) => { socket.cork(); @@ -55,6 +56,7 @@ const getPathname = (url) => (url ? url.split('?')[0] : '/'); class WebsocketServer extends EventEmitter { #options; + #log; #connections = new Set(); #heartbeats = new Map(); // { awaiting: boolean } #pingTimer; @@ -63,13 +65,21 @@ class WebsocketServer extends EventEmitter { // `server` is optional: without it nothing is bound and upgrades are // driven manually through handleUpgrade(req, socket, head) — that is how // middleware adapters (express) hook their own 'upgrade' listener. - constructor({ server, ...opts } = {}) { + constructor({ server, logger = globalThis.console, ...opts } = {}) { super(); if (server !== undefined && (!server || typeof server.on !== 'function')) { throw new TypeError('WebsocketServer: options.server must be an http.Server'); } + permessageDeflate.assertDeflateOptions(opts.perMessageDeflate); + // Normalized once here, childed per connection below. The `Server` shell + // passes its own writer down, and re-wrapping a writer is free, so the + // common path allocates nothing extra. + this.#log = createLoggerWriter(logger).child({ component: 'ws' }); this.#options = { pingInterval: PING_INTERVAL, + // Server connections coalesce the writes of one event-loop turn into + // one flush (Connection's `coalesce`); `coalesce: false` opts out. + coalesce: true, ...opts, }; this.#startHeartbeat(); @@ -83,7 +93,10 @@ class WebsocketServer extends EventEmitter { // Drives one upgrade by hand. Same guarantees as the bound path: the raw // socket gets an error handler before parsing, and a throwing handshake - // answers 500 instead of leaving the socket dangling. + // answers 500 instead of leaving the socket dangling. The error is said in + // the log and emitted only to a listener: an 'error' with none throws, and + // the caller here is an http server's 'upgrade' listener — none of the + // shells puts a catch around it, so that throw ended the process. handleUpgrade(req, socket, head) { socket.on('error', () => { socket.destroy(); @@ -91,7 +104,8 @@ class WebsocketServer extends EventEmitter { try { this.#handleUpgrade(req, socket, head); } catch (error) { - this.emit('error', error); + this.#log.error({ err: error, event: 'ws.upgrade' }); + if (this.listenerCount('error') > 0) this.emit('error', error); abort(socket, 500, 'Internal Server Error'); } } @@ -151,10 +165,14 @@ class WebsocketServer extends EventEmitter { #negotiateProtocol(req, socket) { const header = req.headers['sec-websocket-protocol']; if (!header) return { protocol: '' }; + // Carrier tokens (wire.js) are DATA riding the offer, never a protocol to + // select: they leave the list before the application sees it, and one + // that comes back anyway is not echoed — `(offered) => offered.at(-1)` + // would otherwise reflect a Bearer credential into the response headers. const offered = header .split(',') .map((token) => token.trim()) - .filter(Boolean); + .filter((token) => token.length > 0 && !CARRIER_PROTOCOL.test(token)); const { protocols, handleProtocols } = this.#options; if (handleProtocols) { const selected = handleProtocols(offered, req); @@ -162,16 +180,18 @@ class WebsocketServer extends EventEmitter { abort(socket, 400, 'Subprotocol negotiation failed'); return null; } - return { protocol: selected || '' }; + return { protocol: typeof selected === 'string' && !CARRIER_PROTOCOL.test(selected) ? selected : '' }; } if (protocols) { const selected = offered.find((name) => protocols.includes(name)); return { protocol: selected ?? '' }; } - // No app configuration: answer the wrpc revision when it was offered. - // Required, not a nicety — a browser fails the whole connection when it - // offered subprotocols and the server selected none. - if (offered.includes(WRPC_PROTOCOL)) return { protocol: WRPC_PROTOCOL }; + // No app configuration: answer the newest wrpc revision that was offered + // — a 2.x client offers both, a 1.0 client `wrpc.v1` alone. Required, not + // a nicety — a browser fails the whole connection when it offered + // subprotocols and the server selected none. + if (offered.includes(WRPC_V2)) return { protocol: WRPC_V2 }; + if (offered.includes(WRPC_V1)) return { protocol: WRPC_V1 }; return { protocol: '' }; } @@ -228,6 +248,11 @@ class WebsocketServer extends EventEmitter { if (deflate && deflate.malformed) { return void abort(socket, 400, 'Invalid Sec-WebSocket-Extensions header'); } + // Per-connection selection: `filter(req)` decides whether THIS peer + // gets compression at all — a browser on a slow link yes, a service + // in the same datacenter no — by declining the offer, so the peer + // learns it from the handshake rather than from a missing RSV1. + if (deflate && typeof deflateOptions.filter === 'function' && !deflateOptions.filter(req)) deflate = null; } const extraHeaders = []; @@ -242,6 +267,7 @@ class WebsocketServer extends EventEmitter { isClient: false, protocol, deflate, + logger: this.#log.child({ peer: socket.remoteAddress ?? '' }), }); this.#setupHeartbeat(ws); this.emit('connection', ws, req); @@ -266,4 +292,4 @@ class WebsocketServer extends EventEmitter { } } -module.exports = { WebsocketServer, MAGIC, WRPC_PROTOCOL }; +module.exports = { WebsocketServer, MAGIC }; diff --git a/src/webtransport/channel.js b/src/webtransport/channel.js new file mode 100644 index 0000000..2f5ef3e --- /dev/null +++ b/src/webtransport/channel.js @@ -0,0 +1,621 @@ +'use strict'; + +// One WebTransport channel: a session's control stream, its datagrams and +// its side streams as ONE ordered, flow-controlled message pipe — the part +// of the protocol the two ends share. `WtSocket` (the server's engine-port +// socket) and `ClientWtTransport` (the client's transport) are adapters +// over it: each keeps what is its own — events and their shapes, pause and +// the idle timer and the log on the server; the open lifecycle, the wire +// codec and the session-encryption seam on the client — and neither frames, +// negotiates, compresses or counts a byte itself. They used to be mirror +// copies of all that, and a change of the channel (a new kind, the +// accounting of `bufferedAmount`, the graceful close, the datagram drop +// policy) was two edits that could disagree. +// +// Browser-safe — the client transport is in the main browser entry — so no +// node:events: the adapter is told through callbacks, set once, called per +// frame without an allocation. The six every adapter has are POSITIONAL +// (a minifier renames a parameter, never an option's key — measured in the +// main entry's budget, scripts/size.js): +// +// onMessage(data, isBinary) a packet (string) or a stream chunk (bytes) +// onDrain() the queue fell back under the low-water mark +// onViolation(error) the peer sent what cannot be read +// onOverflow(error) the queue passed `maxBackpressure` +// onError(error) a send-side step failed out of band +// onEnd() the peer ended the control stream +// +// and what only the server's socket has is in `options`: `gate()`, +// `onActivity()` (bytes arrived — the control stream, a side stream, a +// datagram), `onCodecError`, `onDatagramDrop`, and the mux's own options. +// +// Outbound flow control counts the bytes handed to the stream's writer and +// not yet taken by it (write() on a WHATWG writer resolves when the sink +// took the chunk): send() answers false above the high-water mark, and +// onDrain follows under the low-water mark. Inbound, `gate()` is what the +// reads wait on while the adapter is paused, and QUIC's own flow control +// carries the pressure to the peer. + +const { + StreamParser, + frame, + frameText, + frameCaps, + datagramText, + parseDatagram, + datagramWriter, + toBytes, + decodeText, + parseCaps, + KIND_TEXT, + KIND_BINARY, + KIND_CAPS, + KIND_TEXT_COMPRESSED, + KIND_BINARY_COMPRESSED, + DEFAULT_MAX_MESSAGE, +} = require('./framing.js'); +const { StreamMux } = require('./streams.js'); +const { negotiate, Sequencer, INFLIGHT_LIMIT } = require('../compression/index.js'); + +const TEXT_ENCODER = new TextEncoder(); + +const DEFAULT_HIGH_WATER_MARK = 1024 * 1024; +const DEFAULT_LOW_WATER_MARK = 256 * 1024; +// The cap behind the high-water mark: a peer that never drains is hung up +// on once this much is queued for it, as the WebSocket engine's +// maxBackpressure does. 0 switches it off. +const DEFAULT_MAX_BACKPRESSURE = 64 * 1024 * 1024; +// A graceful close ends the control stream first and the session after it: +// how long the stream may take to hand over what was already written (the +// WebSocket engine's CLOSE_TIMEOUT), and how long the END of the peer's +// control stream is given to be followed by its session close. +const DEFAULT_CLOSE_TIMEOUT = 1000; +const CLOSE_GRACE = 200; +// Bytes read off the control stream and not yet delivered — waiting behind +// an inflate in flight — before the reader stops and leaves the rest to +// QUIC's flow control. Bytes, not a count of frames: a reader that stopped +// at four inflates in flight left a peer's last burst unread in the stream, +// and the peer's session close that follows resets the stream and drops it. +const READ_AHEAD = 1 << 20; + +const normalizeBackpressure = (value, label) => { + if (value === undefined) return DEFAULT_MAX_BACKPRESSURE; + if (!Number.isInteger(value) || value < 0) { + throw new TypeError(`${label}: maxBackpressure must be a non-negative integer of bytes (0 = off)`); + } + return value; +}; + +// close() on a closed session is a no-op by spec; an implementation that +// throws instead must not turn a teardown into an error. Here rather than +// in port.js: the client transport is in the browser bundle, which the +// port's validators have no business in. +const closeQuietly = (session, info) => { + try { + session.close(info); + } catch { + // Already closed. + } +}; + +class WtChannel { + #session; + #writer; + #datagrams = null; + #datagramsInFlight = 0; + #datagramsDropped = 0; + #maxDatagrams; + #mux = null; + #parser; + #queued = 0; + #pressured = false; + #closed = false; + #highWater; + #lowWater; + #maxBackpressure; + #maxMessage; + #closeTimeout; + // Per-message compression (src/compression): the normalized option, and + // what is in effect once the peer named a codec of the list — null until + // then, and forever when the option is off. + #compression; + #active = null; + // Bytes of read messages the inbound order still holds (READ_AHEAD). + #held = 0; + // Whether this end reads framed messages — announced as `f` — and what + // to tell once the peer's capabilities said whether IT reads them. + #frames; + #onFrames; + // Order around a codec that may answer asynchronously, one per direction. + #outbound; + #inbound; + #gate; + #onMessage; + #onDrain; + #onViolation; + #onOverflow; + #onActivity; + #onCodecError; + #onDatagramDrop; + + /** + * `stream` is the control stream; reading starts at once, and the + * capabilities message is the first thing written. `mux: false` keeps + * every chunk on the control stream and announces no streams (a wire + * codec, a sealed session); an object is the StreamMux's own options + * (`onRefused`, `onFallback`, `maxHeldStreams`, `holdTimeout`). + * `datagrams: false` leaves the session's datagrams alone. + * `maxDatagramsInFlight` (0 = no bound) is how many datagrams may wait + * for the session before a further one is dropped. `frames` says this end + * reads framed messages (revision 2); `onFrames(peerReads)` hears the + * peer's answer — until it, an end sends none. + */ + constructor(session, stream, options, onMessage, onDrain, onViolation, onOverflow, onError, onEnd) { + const { + mux = true, + datagrams = true, + compression = null, + maxDatagramsInFlight = 0, + gate = null, + onActivity = null, + onCodecError = null, + onDatagramDrop = null, + frames = false, + onFrames = null, + } = options; + const { highWaterMark, lowWaterMark, maxBackpressure, maxMessage, closeTimeout } = options; + this.#session = session; + this.#highWater = highWaterMark ?? DEFAULT_HIGH_WATER_MARK; + this.#lowWater = lowWaterMark ?? DEFAULT_LOW_WATER_MARK; + this.#maxBackpressure = normalizeBackpressure(maxBackpressure, 'WebTransport: options'); + this.#maxMessage = maxMessage ?? DEFAULT_MAX_MESSAGE; + this.#closeTimeout = closeTimeout ?? DEFAULT_CLOSE_TIMEOUT; + this.#compression = compression; + this.#maxDatagrams = maxDatagramsInFlight; + this.#gate = gate; + this.#onMessage = onMessage; + this.#onDrain = onDrain; + this.#onViolation = onViolation; + this.#onOverflow = onOverflow; + this.#onActivity = onActivity; + this.#onCodecError = onCodecError; + this.#onDatagramDrop = onDatagramDrop; + this.#frames = frames; + this.#onFrames = onFrames; + this.#outbound = new Sequencer(onError); + this.#inbound = new Sequencer((error) => this.#violation(error)); + this.#writer = stream.writable.getWriter(); + // Binary streams on their own WebTransport streams, negotiated through + // the capabilities message each end sends first (streams.js). + if (mux) { + this.#mux = new StreamMux(session, { + emitPacket: (text) => onMessage(text, false), + emitChunk: (chunk) => onMessage(chunk, true), + onQueued: (size) => { + this.#queued += size; + }, + onSent: (size) => this.#sent(size), + // Through the outbound order, not straight to the writer: a stream + // packet must not overtake a message still being compressed. + writeControl: (chunk) => { + if (!this.exceedsBackpressure()) this.#enqueue(frame(KIND_BINARY, chunk)); + }, + // No codec where the mux is on, so a stream packet is its JSON. + sendControl: (packet) => { + if (!this.exceedsBackpressure()) this.#enqueue(frameText(JSON.stringify(packet))); + }, + // A pause stops the side streams too, and their bytes are liveness + // as much as the control stream's. + gate, + onActivity, + ...(mux === true ? null : mux), + }); + } + const parser = new StreamParser({ + maxMessage: this.#maxMessage, + onMessage: (kind, data) => { + if (kind !== KIND_CAPS) return void this.#receive(kind, data); + this.#mux?.peerCaps(data); + const caps = parseCaps(data); + // What the two lists share: this side compresses with the first + // codec of ITS list the peer announced, and reads the peer's. + this.#active = negotiate(this.#compression, caps?.enc); + parser.compressed = this.#active !== null; + // The revision, as a worker port settles it on its first ping: a + // frame goes only to a peer that said it reads one. Two ends whose + // `attachments` disagree used to meet a frame the other refused — + // an id-less error, and a call that timed out. + this.#onFrames?.(caps?.f === 1); + }, + }); + this.#parser = parser; + this.#writer.write(frameCaps(this.#caps())).catch(() => {}); + void this.#read(stream.readable, onEnd); + if (this.#mux !== null && typeof session.incomingUnidirectionalStreams?.getReader === 'function') { + void this.#readUni(session.incomingUnidirectionalStreams); + } + // Datagrams, where the session has them: a writer to send unreliable + // events on, a reader loop that hands the packets they carry to the + // same onMessage a stream packet takes. + const duplex = session.datagrams; + const writer = datagrams ? datagramWriter(duplex) : null; + if (writer && typeof duplex.readable?.getReader === 'function') { + this.#datagrams = writer; + void this.#readDatagrams(duplex.readable); + } + } + + get closed() { + return this.#closed; + } + + /** Bytes handed to the stream and not yet taken by it; 0 once shut. */ + get bufferedAmount() { + return this.#queued; + } + + /** The largest datagram the session carries; 0 when it carries none. */ + get maxDatagramSize() { + const datagrams = this.#session.datagrams; + if (!datagrams || !this.#datagrams || this.#closed) return 0; + const size = datagrams.maxDatagramSize; + return typeof size === 'number' && size > 0 ? size : 1200; + } + + /** The codec ids in effect — `{ encode, decode }` — or null while the two lists share none. */ + get compression() { + const active = this.#active; + return active === null ? null : { encode: active.encode.id, decode: active.decode.id }; + } + + /** Datagrams dropped because the session had not taken the ones before them. */ + get droppedDatagrams() { + return this.#datagramsDropped; + } + + // What we announce: the mux's streams, plus the codecs we hold, in our + // order, when the option is on, and `f` when this end reads frames. The peer compresses only once it has read + // this, with the first of ITS list found here. + #caps() { + const text = this.#mux === null ? '{}' : StreamMux.caps(this.#session); + if (this.#compression === null && !this.#frames) return text; + const caps = parseCaps(text) ?? {}; + if (this.#compression !== null) caps.enc = this.#compression.ids; + if (this.#frames) caps.f = 1; + return JSON.stringify(caps); + } + + // --- inbound -------------------------------------------------------- + + // A message past the capabilities: plain kinds are delivered at once + // while nothing is being inflated ahead of them; a compressed kind is + // inflated — possibly asynchronously — and everything behind it waits its + // turn, which is what keeps the wire's order. + #receive(kind, data) { + const active = this.#active; + if (kind <= KIND_BINARY) { + if (active === null || this.#inbound.pending === 0) return void this.#deliver(kind, data); + this.#held += data.length; + return void this.#inbound.push(data, (bytes) => { + this.#held -= data.length; + this.#deliver(kind, bytes); + }); + } + const plainKind = kind === KIND_TEXT_COMPRESSED ? KIND_TEXT : KIND_BINARY; + const codec = active.decode.codec; + const decode = () => codec.decode(data, this.#maxMessage); + // Started now while few are in flight, in its slot past the limit: a + // burst of compressed frames is not a burst of parallel inflates. + let inflated = decode; + if (this.#inbound.pending < INFLIGHT_LIMIT) { + try { + inflated = decode(); + } catch (error) { + return void this.#violation(error); + } + } + this.#held += data.length; + this.#inbound.push( + inflated, + (bytes) => { + this.#held -= data.length; + this.#deliver(plainKind, plainKind === KIND_TEXT ? decodeText(bytes) : bytes); + }, + (error) => this.#violation(error), + ); + } + + #deliver(kind, data) { + if (this.#closed) return; + if (kind === KIND_TEXT && this.#mux !== null && this.#mux.packet(data)) return; + this.#onMessage(data, kind === KIND_BINARY); + } + + // A message that cannot be read — a malformed frame, an inflate that + // failed or blew the cap, invalid UTF-8 underneath — is the peer's + // protocol violation, and the adapter's to answer. + #violation(error) { + if (!this.#closed) this.#onViolation(error); + } + + async #read(readable, onEnd) { + const reader = readable.getReader(); + try { + for (;;) { + const wait = this.#gate === null ? null : this.#gate(); + if (wait !== null) await wait; + // Enough read and not delivered: the next read waits for it — the + // bytes wait in the stream, under QUIC's flow control. + if (this.#held >= READ_AHEAD) await this.#inbound.idle; + const { value, done } = await reader.read(); + if (done || this.#closed) break; + if (this.#onActivity !== null) this.#onActivity(); + this.#parser.push(value); + } + } catch (error) { + // A read error is the session's to report through `closed`; a + // FramingError is the peer's protocol violation. + return void this.#violation(error); + } + // What the peer sent before its FIN is delivered before the end: an + // asynchronous codec — a browser's DecompressionStream — was still + // inflating it, and the end shut the channel under every one. + if (this.#inbound.pending > 0) await this.#inbound.idle; + if (!this.#closed) onEnd(); + } + + async #readUni(streams) { + const reader = streams.getReader(); + try { + for (;;) { + const { value, done } = await reader.read(); + if (done || this.#closed) break; + this.#mux.accept(value); + } + } catch { + // The session's end, reported through `closed`. + } + } + + async #readDatagrams(readable) { + const reader = readable.getReader(); + try { + for (;;) { + const { value, done } = await reader.read(); + if (done || this.#closed) break; + if (this.#onActivity !== null) this.#onActivity(); + const text = parseDatagram(value); + if (text !== null) this.#onMessage(text, false); + } + } catch { + // The session's end, reported through `closed`. + } + } + + // --- outbound ------------------------------------------------------- + + /** + * Told about an outbound stream packet before it is serialized: true + * when the packet must not go on the control stream — its stream's own + * FIN or RESET carries it. + */ + control(packet) { + return !this.#closed && this.#mux !== null && this.#mux.control(packet); + } + + /** + * A packet as ONE datagram — unreliable, unordered, at most + * maxDatagramSize bytes: true when it went out, false when the session + * has no datagrams, the packet does not fit, or the channel is shut — the + * caller's cue to send() it on the control stream instead. + */ + sendUnreliable(data) { + if (this.#closed || !this.#datagrams || typeof data !== 'string') return false; + const bytes = datagramText(data); + if (bytes.length > this.maxDatagramSize) return false; + // The session has not taken the ones before it: this one is DROPPED, + // not queued behind them — and answered true. False would have the + // caller send it reliably on the control stream, which is head-of-line + // blocking exactly when the link is congested, for a value the next + // datagram supersedes anyway. (Not `desiredSize`: on a synchronous sink + // it reads 0 after the first write of a tick, and the second datagram + // of a healthy session would be dropped.) + if (this.#maxDatagrams > 0 && this.#datagramsInFlight >= this.#maxDatagrams) { + if (this.#datagramsDropped++ === 0 && this.#onDatagramDrop !== null) this.#onDatagramDrop(); + return true; + } + this.#datagramsInFlight++; + const settled = () => void this.#datagramsInFlight--; + this.#datagrams.write(bytes).then(settled, settled); + return true; + } + + /** + * A string is a packet, bytes are a stream chunk. False above the + * high-water mark (then onDrain), and false, quietly, once shut. + * `options.compress === false` sends this one plain whatever was + * negotiated. + */ + send(data, options = null) { + if (this.#closed || this.exceedsBackpressure()) return false; + const active = this.#active; + const plain = active === null || (options !== null && options.compress === false); + if (typeof data === 'string') { + if (plain || data.length < active.encode.threshold) return this.#enqueue(frameText(data)); + return this.#compress(KIND_TEXT, TEXT_ENCODER.encode(data)); + } + const chunk = toBytes(data); + // A chunk of a stream that has its own WebTransport stream goes there. + if (this.#mux !== null && this.#mux.chunk(chunk)) return this.accepted(); + if (plain || chunk.length < active.encode.threshold) return this.#enqueue(frame(KIND_BINARY, chunk)); + return this.#compress(KIND_BINARY, chunk); + } + + // What is queued already, against the cap — before this frame is added, + // as the WebSocket engine counts it: one frame past the cap on an empty + // queue is sent, a queue the peer never drains is not. + exceedsBackpressure() { + const max = this.#maxBackpressure; + if (max === 0 || this.#queued <= max) return false; + const error = new Error(`Backpressure limit exceeded (${this.#queued} > ${max} bytes), terminating session`); + error.code = 'backpressure'; + this.#onOverflow(error); + return true; + } + + // The answer to a send: true under the high-water mark, false past it — + // and with a false, the promise of an onDrain. Every path answers through + // here: a false from the side-stream path, a compress in flight or a + // frame queued behind one used to set no mark, so a caller waiting for + // 'drain' after it waited forever. + accepted() { + if (this.#queued <= this.#highWater) return true; + this.#pressured = true; + return false; + } + + // A ready frame, in order: straight to the writer while nothing is being + // compressed ahead of it, behind the queue otherwise. `#queued` counts it + // from here on either way. + #enqueue(bytes) { + if (this.#outbound.pending === 0) return this.#writeFrame(bytes); + const size = bytes.length; + this.#queued += size; + this.#outbound.push(bytes, (ready) => { + this.#queued -= size; + this.#writeFrame(ready); + }); + return this.accepted(); + } + + // Compresses one message past the threshold. The codec may answer at + // once (zlib) or later (CompressionStream); either way the frame goes + // out under the compressed kind when it is smaller, plain when it is not + // or the codec failed — the message is never lost to compression. + #compress(kind, bytes) { + const size = bytes.length; + this.#queued += size; + const compressed = kind === KIND_TEXT ? KIND_TEXT_COMPRESSED : KIND_BINARY_COMPRESSED; + const plain = () => { + this.#queued -= size; + this.#writeFrame(frame(kind, bytes)); + }; + // A codec that failed — at once, or later — is said (the session's + // owner counts it and logs it once a codec); the frame leaves plain. + const failed = (error) => { + if (this.#onCodecError !== null) this.#onCodecError(this.#active.encode.id, error); + plain(); + }; + let encoded; + try { + encoded = this.#active.encode.codec.encode(bytes); + } catch (error) { + failed(error); + return this.accepted(); + } + this.#outbound.push( + encoded, + (out) => { + if (out.length >= size) return void plain(); + this.#queued -= size; + this.#writeFrame(frame(compressed, out)); + }, + failed, + ); + return this.accepted(); + } + + #writeFrame(bytes) { + if (this.#closed) return false; + const size = bytes.length; + this.#queued += size; + // A rejected write is the session failing, which `closed` reports. + this.#writer.write(bytes).then( + () => this.#sent(size), + () => {}, + ); + return this.accepted(); + } + + // A write the stream took. After the shut nothing is counted: the count + // was zeroed, and a late settlement used to take it negative. + #sent(size) { + if (this.#closed) return; + this.#queued -= size; + if (!this.#pressured || this.#queued > this.#lowWater) return; + this.#pressured = false; + this.#onDrain(); + } + + // --- the end -------------------------------------------------------- + + /** This end is over: nothing more is delivered, sent or counted. Says nothing to the peer. */ + /** + * What an end awaits between the session's graceful end and its own: + * resolves once the messages an asynchronous codec is still inflating + * are delivered, or after `closeTimeout` — null when none are. A server's + * graceful close used to shut the channel under every one of them. + */ + settled() { + if (this.#closed || this.#inbound.pending === 0) return null; + return new Promise((resolve) => { + const timer = setTimeout(resolve, this.#closeTimeout); + void this.#inbound.idle.then(() => { + clearTimeout(timer); + resolve(); + }); + }); + } + + shut() { + if (this.#closed) return; + this.#closed = true; + this.#mux?.close(); + this.#queued = 0; + this.#pressured = false; + } + + /** + * The graceful end, after shut(): what send() already handed to the + * control stream reaches the peer, then the session closes with `info`. + * Closing a SESSION resets its streams and drops whatever they still + * hold, so the stream is closed first — its close resolves once the + * queued bytes were taken and the FIN sent — and the session after it, + * `closeTimeout` at most. A message still being compressed + * asynchronously is not waited for. + */ + finish(info) { + const session = this.#session; + let timer = null; + const end = () => { + clearTimeout(timer); + closeQuietly(session, info); + }; + timer = setTimeout(end, this.#closeTimeout); + timer.unref?.(); + this.#writer.close().then(end, end); + } + + /** + * The peer ended the control stream — its graceful close: the session + * close that follows carries its code, and is given a moment to arrive + * before this end closes the session itself, with `info`. + */ + expectClose(info) { + const session = this.#session; + const timer = setTimeout(() => closeQuietly(session, info), CLOSE_GRACE); + timer.unref?.(); + const settled = () => clearTimeout(timer); + session.closed.then(settled, settled); + } +} + +module.exports = { + WtChannel, + closeQuietly, + normalizeBackpressure, + DEFAULT_HIGH_WATER_MARK, + DEFAULT_LOW_WATER_MARK, + DEFAULT_MAX_BACKPRESSURE, + DEFAULT_CLOSE_TIMEOUT, +}; diff --git a/src/webtransport/framing.js b/src/webtransport/framing.js new file mode 100644 index 0000000..37f419b --- /dev/null +++ b/src/webtransport/framing.js @@ -0,0 +1,331 @@ +'use strict'; + +// Framing for a WebTransport stream: how wrpc packets and stream chunks ride +// a QUIC stream, which is a byte stream with no message boundaries. +// +// A WebSocket frame and a data-channel message each carry ONE packet or +// chunk. A WebTransport stream carries bytes, so every message on it is +// prefixed with a five-byte header: +// +// bytes 0-3 LENGTH payload byte length, unsigned big-endian +// byte 4 KIND 0 = a wrpc packet (UTF-8 JSON, what a WebSocket text frame carries) +// 1 = a binary stream chunk (a chunkEncode frame) +// 2 = a capabilities message (UTF-8 JSON), each end's first message +// 3 = a packet, compressed (src/compression) — only once negotiated +// 4 = a chunk, compressed — likewise +// 5-255 reserved — a protocol error, as are 3 and 4 before negotiation +// +// No fragmentation and no FIN bit: a QUIC stream is ordered and reliable and +// a message of any size is one contiguous run of bytes. The receiver's cap +// (maxMessage, 16 MiB by default — the engine's maxPayload) bounds what it +// buffers for one message; a LENGTH past it is a protocol error, not a +// bigger allocation. The format is documented in docs/reference/protocol.md +// (WebTransport) and wire-format.md. +// +// Frames are FRESH buffers, never a reused scratch: a WHATWG writer takes +// the chunk by reference and may process it after write() returns (a +// queued write under backpressure), so a buffer the next frame overwrites +// would corrupt what is still in flight — the opposite of a data channel's +// synchronous send(). This file is on the hot path (once per packet, once +// per chunk) and browser-budgeted: manual loops, no generators, no spread. + +const HEADER_BYTES = 5; +const KIND_TEXT = 0; +const KIND_BINARY = 1; +const KIND_CAPS = 2; +// Compressed twins of KIND_TEXT and KIND_BINARY: the payload is what the +// negotiated codec produced, and the receiver inflates before it reads. +const KIND_TEXT_COMPRESSED = 3; +const KIND_BINARY_COMPRESSED = 4; +const DEFAULT_MAX_MESSAGE = 16 * 1024 * 1024; +// A packet up to this many UTF-16 code units is encoded straight into a +// frame sized for the worst case (three bytes per code unit) with +// encodeInto — one allocation, one pass, and up to 2/3 of it unused. Past +// it the text is encoded first and copied, so a large batch frame does not +// hold three times its size. The cap is a memory choice, not a speed one: +// bench/wt-framing.js has encodeInto ahead at every size (3.3M vs 2.1M +// packets/s at 64 B, 992K vs 827K at 1 KiB, 92K vs 71K at 16 KiB). +const INLINE_TEXT = 4096; +const EMPTY = new Uint8Array(0); + +const TEXT_ENCODER = new TextEncoder(); +// fatal: an invalid byte sequence in a packet is a protocol error, not a +// U+FFFD the JSON parser then trips over one layer up. +const TEXT_DECODER = new TextDecoder('utf-8', { fatal: true }); + +class FramingError extends Error { + constructor(message, code) { + super(message); + this.name = 'FramingError'; + this.code = code; + } +} + +const toBytes = (input) => { + if (input instanceof Uint8Array) return input; + if (input instanceof ArrayBuffer) return new Uint8Array(input); + if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength); + throw new TypeError('framing: expected an ArrayBuffer or an ArrayBufferView'); +}; + +/** A packet's bytes as text — the parser's own decode, for a packet inflated after parsing. */ +const decodeText = (bytes) => { + try { + return TEXT_DECODER.decode(bytes); + } catch { + throw new FramingError('invalid UTF-8 in a packet', 'utf8'); + } +}; + +/** The object a capabilities message carries, or null when it is not one. */ +const parseCaps = (text) => { + try { + const caps = JSON.parse(text); + return typeof caps === 'object' && caps !== null ? caps : null; + } catch { + return null; + } +}; + +const writeHeader = (out, length, kind) => { + out[0] = length >>> 24; + out[1] = (length >>> 16) & 255; + out[2] = (length >>> 8) & 255; + out[3] = length & 255; + out[4] = kind; +}; + +/** One message of `kind` over `bytes`: a fresh frame, header included. */ +const frame = (kind, bytes) => { + const out = new Uint8Array(HEADER_BYTES + bytes.length); + writeHeader(out, bytes.length, kind); + out.set(bytes, HEADER_BYTES); + return out; +}; + +/** A capabilities message: UTF-8 JSON under a KIND 2 header. */ +const frameCaps = (text) => frame(KIND_CAPS, TEXT_ENCODER.encode(text)); + +/** A packet: UTF-8 under a KIND 0 header. */ +const frameText = (text) => { + if (text.length <= INLINE_TEXT) { + const out = new Uint8Array(HEADER_BYTES + text.length * 3); + const { written } = TEXT_ENCODER.encodeInto(text, out.subarray(HEADER_BYTES)); + writeHeader(out, written, KIND_TEXT); + return out.subarray(0, HEADER_BYTES + written); + } + return frame(KIND_TEXT, TEXT_ENCODER.encode(text)); +}; + +// A datagram is one whole message with no length to announce: its header +// is the KIND byte alone. Only packets ride datagrams today (KIND_TEXT); +// the receiver ignores any other kind, since a datagram may be dropped +// anyway and a wrong one is not worth a hangup. +const DATAGRAM_HEADER_BYTES = 1; + +/** A packet as one datagram: `[KIND_TEXT][UTF-8]`, a fresh buffer. */ +const datagramText = (text) => { + const out = new Uint8Array(DATAGRAM_HEADER_BYTES + text.length * 3); + const { written } = TEXT_ENCODER.encodeInto(text, out.subarray(DATAGRAM_HEADER_BYTES)); + out[0] = KIND_TEXT; + return out.subarray(0, DATAGRAM_HEADER_BYTES + written); +}; + +// The datagram writer: the specification moved from `datagrams.writable` +// to `datagrams.createWritable()` (the former still works in browsers, and +// a host may log it as deprecated); take the newer one where it exists. +const datagramWriter = (datagrams) => { + if (!datagrams) return null; + if (typeof datagrams.createWritable === 'function') { + try { + return datagrams.createWritable().getWriter(); + } catch { + // Fall through to the legacy stream. + } + } + return typeof datagrams.writable?.getWriter === 'function' ? datagrams.writable.getWriter() : null; +}; + +/** + * The packet a datagram carries, or null when it carries anything else + * (an empty datagram, a reserved kind, invalid UTF-8): lossy by nature, so + * an unreadable one is dropped, not answered with a hangup. + */ +const parseDatagram = (input) => { + const bytes = toBytes(input); + if (bytes.length < DATAGRAM_HEADER_BYTES || bytes[0] !== KIND_TEXT) return null; + try { + return TEXT_DECODER.decode(bytes.subarray(DATAGRAM_HEADER_BYTES)); + } catch { + return null; + } +}; + +/** + * The receiving half: feed it whatever the stream's reader yields, in + * order, and it calls `onMessage(kind, data)` once per completed message — + * `data` a string for KIND_TEXT and a Uint8Array for KIND_BINARY. A message + * contained in one read is answered as a view over that read's buffer (no + * copy: every read is a fresh buffer, so nothing aliases); one spanning + * reads is joined once. Throws FramingError on a malformed header; the + * parser is reset so the caller can hang up cleanly. + */ +class StreamParser { + // Whether the compressed kinds (3, 4) are accepted: set by the transport + // once both ends named the same codec, a protocol error before. + compressed = false; + + #max; + #onMessage; + // Buffered reads, the index of the first live one and the read cursor + // within it — a FIFO byte queue in the shape of src/websocket/segments.js, + // over Uint8Array so it runs in a browser, and with a head index instead + // of shift(): a message assembled from many small reads would otherwise + // pay O(n) per read consumed, O(n²) per message. + #reads = []; + #head = 0; + #offset = 0; + #buffered = 0; + // What the next step needs: a header, or the payload the header announced. + #need = HEADER_BYTES; + #length = -1; + #kind = -1; + + constructor({ maxMessage = DEFAULT_MAX_MESSAGE, onMessage } = {}) { + if (!Number.isInteger(maxMessage) || maxMessage <= 0) { + throw new TypeError('framing: maxMessage must be a positive integer'); + } + if (typeof onMessage !== 'function') throw new TypeError('framing: onMessage must be a function'); + this.#max = maxMessage; + this.#onMessage = onMessage; + } + + /** Bytes buffered towards the next message; 0 between messages. */ + get pending() { + return this.#buffered; + } + + push(input) { + const bytes = toBytes(input); + if (bytes.length === 0) return; + this.#reads.push(bytes); + this.#buffered += bytes.length; + while (this.#buffered >= this.#need) { + if (this.#length < 0) { + const header = this.#take(HEADER_BYTES); + const length = ((header[0] << 24) >>> 0) + (header[1] << 16) + (header[2] << 8) + header[3]; + const kind = header[4]; + if (kind > KIND_CAPS && !(this.compressed && kind <= KIND_BINARY_COMPRESSED)) { + throw this.#fail('unknown message kind', 'kind'); + } + if (length > this.#max) throw this.#fail('message exceeds maxMessage', 'too-large'); + if (length === 0) { + this.#deliver(kind, EMPTY); + continue; + } + this.#length = length; + this.#kind = kind; + this.#need = length; + } else { + this.#deliver(this.#kind, this.#take(this.#length)); + } + } + } + + reset() { + this.#reads.length = 0; + this.#head = 0; + this.#offset = 0; + this.#buffered = 0; + this.#need = HEADER_BYTES; + this.#length = -1; + this.#kind = -1; + } + + #deliver(kind, payload) { + this.#need = HEADER_BYTES; + this.#length = -1; + this.#kind = -1; + // Bytes as they are for a chunk and for either compressed kind — a + // compressed packet is text only once the transport has inflated it. + if (kind === KIND_BINARY || kind >= KIND_TEXT_COMPRESSED) return void this.#onMessage(kind, payload); + let text; + try { + text = TEXT_DECODER.decode(payload); + } catch { + throw this.#fail('invalid UTF-8 in a packet', 'utf8'); + } + this.#onMessage(kind, text); + } + + // Consumes exactly `size` leading bytes: a view when they lie within the + // first read, one copy when they span reads. + #take(size) { + const reads = this.#reads; + let head = this.#head; + const first = reads[head]; + const available = first.length - this.#offset; + let out; + if (available > size) { + out = first.subarray(this.#offset, this.#offset + size); + this.#offset += size; + } else if (available === size) { + out = this.#offset === 0 ? first : first.subarray(this.#offset); + reads[head++] = null; + this.#offset = 0; + } else { + out = new Uint8Array(size); + out.set(first.subarray(this.#offset), 0); + let copied = available; + reads[head++] = null; + this.#offset = 0; + while (copied < size) { + const read = reads[head]; + const take = read.length < size - copied ? read.length : size - copied; + out.set(take === read.length ? read : read.subarray(0, take), copied); + copied += take; + if (take === read.length) reads[head++] = null; + else this.#offset = take; + } + } + // Compact once the consumed prefix outweighs what is left. + if (head === reads.length) { + reads.length = 0; + head = 0; + } else if (head > 32 && head * 2 > reads.length) { + reads.splice(0, head); + head = 0; + } + this.#head = head; + this.#buffered -= size; + return out; + } + + #fail(message, code) { + this.reset(); + return new FramingError(message, code); + } +} + +module.exports = { + HEADER_BYTES, + KIND_TEXT, + KIND_BINARY, + KIND_CAPS, + KIND_TEXT_COMPRESSED, + KIND_BINARY_COMPRESSED, + DEFAULT_MAX_MESSAGE, + INLINE_TEXT, + FramingError, + toBytes, + decodeText, + parseCaps, + frame, + frameText, + frameCaps, + DATAGRAM_HEADER_BYTES, + datagramText, + parseDatagram, + datagramWriter, + StreamParser, +}; diff --git a/src/webtransport/index.js b/src/webtransport/index.js new file mode 100644 index 0000000..523cc13 --- /dev/null +++ b/src/webtransport/index.js @@ -0,0 +1,416 @@ +'use strict'; + +// The Node barrel of @alexify/wrpc/wt: the server half of WebTransport. +// The client transport (`transport: 'wt'`) lives in the base entry; what a +// server needs is here — the session contract (port.js), the WrpcSocket shim +// over a session (socket.js), attachSession/acceptSessions to hand sessions +// to an RpcServer, and the adapters that read a host implementation's +// CONNECT request (fromFails for @fails-components/webtransport, fromQuico +// for quico). No implementation is required from here: Node has no +// WebTransport of its own, and the one an application runs is injected. + +const { WtSocket, normalizeBackpressure, DEFAULT_HIGH_WATER_MARK, DEFAULT_LOW_WATER_MARK } = require('./socket.js'); +const { createLoggerWriter } = require('../logging.js'); +const { isWtSession, isWtStream, isWtDatagrams } = require('./port.js'); +const { normalizeCompression } = require('../compression/index.js'); +const { closeQuietly } = require('./channel.js'); +const { fromQuico } = require('./quico.js'); +// Requiring it registers ServerTransport.transport.wt — what attachSocket +// picks for `kind: 'wt'`. +const { ServerWtTransport } = require('./transport.js'); +const framing = require('./framing.js'); + +// How long a session may sit without opening its control stream before it +// is refused: a client that connected and never spoke. +const DEFAULT_ACCEPT_TIMEOUT = 10_000; +// Sessions acceptSessions may hold in their handshake at once; the next one +// is refused 503 rather than queued behind them. +const DEFAULT_MAX_PENDING = 256; + +// The RpcServer behind whatever was handed over: a Server (its `rpc`), an +// RpcServer, or anything else with attachSocket — a PeerHost does not +// qualify, it has no socket path. +const rpcOf = (server) => { + if (typeof server?.attachSocket === 'function') return server; + if (typeof server?.rpc?.attachSocket === 'function') return server.rpc; + throw new TypeError('attachSession: a Server or RpcServer (something with attachSocket) is required'); +}; + +// ONE deadline for the whole accept path — verify, the session's own +// `ready`, the first stream — and the acceptor's stop() as a second way to +// end it: `interruption` resolves 'timeout' or 'stopped', and `race` +// answers `{ value }` or `{ why }`. A session whose `ready` never settled +// used to hold attachSession forever, outside acceptTimeout. +const interruptible = (timeout, signal) => { + let timer = null; + let onAbort = null; + const interruption = new Promise((resolve) => { + timer = setTimeout(() => resolve('timeout'), timeout); + timer.unref?.(); + if (signal) { + onAbort = () => resolve('stopped'); + if (signal.aborted) onAbort(); + else signal.addEventListener('abort', onAbort, { once: true }); + } + }); + const stopped = interruption.then((why) => ({ why })); + return { + race: (promise) => Promise.race([Promise.resolve(promise).then((value) => ({ value })), stopped]), + interruption, + clear: () => { + clearTimeout(timer); + if (onAbort) signal.removeEventListener('abort', onAbort); + }, + }; +}; + +// The first bidirectional stream the client opens is the control stream. +// Read with a reader released afterwards, so the incoming-streams stream +// stays usable for what comes next. +const firstStream = async (session, race) => { + const reader = session.incomingBidirectionalStreams.getReader(); + try { + const { value: result, why } = await race(reader.read()); + if (why) return { stream: null, why }; + return { stream: result.done ? null : result.value, why: result.done ? 'ended' : null }; + } finally { + reader.releaseLock(); + } +}; + +const refusals = { + timeout: { closeCode: 408, reason: 'No control stream' }, + stopped: { closeCode: 1001, reason: 'Server is closing' }, + ended: { closeCode: 408, reason: 'No control stream' }, +}; + +/** + * Hands one WebTransport session to a server as a client: waits for its + * control stream, wraps the session as a WrpcSocket and attaches it the way + * a WebSocket is attached — sessions, declared headers, flow control and the + * ServerWsTransport all apply. `headers`, `url` and `remoteAddress` are the + * CONNECT request as the host implementation saw it (an adapter reads them + * off the session: fromFails, fromQuico); `url` carries the `wrpc_h` / + * `wrpc_meta` query a browser client declares its bags in. + * + * Resolves with the Client, or null when the session was refused — by + * `verify` (closed 403) or for never opening a control stream within + * `acceptTimeout` (closed 408; the deadline covers `verify` and the + * session's own `ready` too), or because `signal` aborted meanwhile (closed + * 1001 — what acceptSessions' stop() does). `idleTimeout` (off by default) + * terminates a session that sends nothing for that long — see WtSocket. + */ +// Every per-session option that can be checked before a session exists. +// attachSession runs it before the handshake; acceptSessions once, at the +// call — it used to accept a bad option and then fail every session it +// attached, one by one (`Not connected` for each client, a TypeError in +// onError), with nothing at the call site. +const checkSessionOptions = (options, label) => { + const { acceptTimeout = DEFAULT_ACCEPT_TIMEOUT, maxBackpressure, closeTimeout, idleTimeout, compression } = options; + if (!Number.isInteger(acceptTimeout) || acceptTimeout <= 0) { + throw new TypeError(`${label}: acceptTimeout must be a positive integer (ms)`); + } + normalizeBackpressure(maxBackpressure, label); + for (const [name, value] of [ + ['closeTimeout', closeTimeout], + ['idleTimeout', idleTimeout], + ]) { + if (value !== undefined && (!Number.isInteger(value) || value < 0)) { + throw new TypeError(`${label}: ${name} must be a non-negative integer (ms)`); + } + } + for (const [name, value] of [ + ['maxHeldStreams', options.maxHeldStreams], + ['holdTimeout', options.holdTimeout], + ]) { + if (value !== undefined && (!Number.isInteger(value) || value <= 0)) { + throw new TypeError(`${label}: ${name} must be a positive integer`); + } + } + normalizeCompression(compression, label); +}; + +const attachSession = async (server, session, options = {}) => { + const rpc = rpcOf(server); + if (!isWtSession(session)) { + throw new TypeError( + 'attachSession: a WebTransport session (incomingBidirectionalStreams, close, closed) is required', + ); + } + const { + headers = {}, + url = '/', + remoteAddress = '', + verify = null, + acceptTimeout = DEFAULT_ACCEPT_TIMEOUT, + kind = 'wt', + highWaterMark, + lowWaterMark, + maxBackpressure, + maxMessage, + idleTimeout, + closeTimeout, + compression, + maxHeldStreams, + holdTimeout, + signal = null, + logger = null, + } = options; + // Checked before the handshake, not after it by the socket. + checkSessionOptions(options, 'attachSession'); + // One child per session, the peer bound: what a refusal here, and the + // socket's own lines after attach, write to. The server's writer unless + // the caller (acceptSessions, an application) hands one down. + const log = createLoggerWriter(logger ?? rpc.log).child( + typeof remoteAddress === 'string' && remoteAddress ? { component: 'wt', peer: remoteAddress } : { component: 'wt' }, + ); + const { race, clear } = interruptible(acceptTimeout, signal); + const refuse = (why) => { + log.warn({ event: 'wt.refused', status: refusals[why].closeCode, reason: why }); + closeQuietly(session, refusals[why]); + return null; + }; + let stream = null; + try { + if (verify) { + const { value, why } = await race(verify({ headers, url, remoteAddress, session })); + if (why) return refuse(why); + if (value === false) { + log.warn({ event: 'wt.refused', status: 403, reason: 'verify' }); + closeQuietly(session, { closeCode: 403, reason: 'Forbidden' }); + return null; + } + } + if (session.ready) { + const { why } = await race(session.ready); + if (why) return refuse(why); + } + const first = await firstStream(session, race); + if (first.why) return refuse(first.why); + stream = first.stream; + } finally { + clear(); + } + const socket = new WtSocket(session, stream, { + remoteAddress, + highWaterMark, + lowWaterMark, + maxBackpressure, + maxMessage, + idleTimeout, + closeTimeout, + compression, + maxHeldStreams, + holdTimeout, + log, + onCodecError: (codec, error) => rpc.compressionFailed?.('wt', 'encode', codec, error), + frames: rpc.revision === 2, + }); + // A stream the peer opened for an id it never named, past the cap or + // without announcing streams at all: cancelled unread, and a line here. + socket.on('stream-refused', ({ reason, id }) => { + log.warn({ event: 'wt.mux.refused', reason, id }); + }); + const client = rpc.attachSocket(socket, { headers, url, remoteAddress, kind }); + // Stopped while the handshake was finishing: the client is not kept. + if (signal?.aborted) { + client.close(); + return null; + } + return client; +}; + +// A ReadableStream or any (async) iterable of sessions, as one iterator +// that stop() can end from outside. +const iterate = (sessions) => { + if (typeof sessions?.getReader === 'function') { + const reader = sessions.getReader(); + return { + next: () => reader.read(), + stop: () => reader.cancel().catch(() => {}), + }; + } + const iterator = sessions?.[Symbol.asyncIterator]?.() ?? sessions?.[Symbol.iterator]?.(); + if (!iterator) throw new TypeError('acceptSessions: a ReadableStream or an iterable of sessions is required'); + return { + next: () => iterator.next(), + // Not awaited: an async generator suspended on an await it owns queues + // the return() until that await settles, which may be never — the + // source is the application's to end; stop() only stops attaching. + stop: () => { + Promise.resolve() + .then(() => iterator.return?.()) + .catch(() => {}); + return Promise.resolve(); + }, + }; +}; + +/** + * Attaches every session a host hands out — a `sessionStream(path)` from + * @fails-components/webtransport, or any async iterable. `meta(session)` + * reads the CONNECT request off each (fromFails by default); the rest of + * the options go to attachSession. Sessions are attached CONCURRENTLY — + * one that opens no control stream waits out its acceptTimeout on its own, + * not in front of the sessions behind it, so `onClient` may be called out + * of arrival order — and at most `maxPending` (256) of them at once: the + * next one is refused 503 rather than queued. Returns `{ stop, done }`: + * stop() ends the loop — cancelling a ReadableStream, asking an iterator to + * return — and closes what is still handshaking (1001); `done` settles once + * the source has yielded its last session (at once for a stream; an + * iterator blocked on its own await, when that settles) and every pending + * attach has ended. + */ +const acceptSessions = (server, sessions, options = {}) => { + const rpc = rpcOf(server); + const { + meta = fromFails, + onError = null, + onClient = null, + logger = null, + maxPending = DEFAULT_MAX_PENDING, + ...rest + } = options; + if (!Number.isInteger(maxPending) || maxPending <= 0) { + throw new TypeError('acceptSessions: maxPending must be a positive integer'); + } + checkSessionOptions(rest, 'acceptSessions'); + // `onError` defaulted to null, so a session that failed to attach — a + // verify that threw, a source that died — was dropped without a trace: + // the one failure mode of a WebTransport server that nothing above could + // observe. The default is now the server's own writer (borrowed through + // `rpc.log`, which exists for exactly this), and an explicit `onError` + // still wins, since a caller that handles the error wants to decide. + const log = createLoggerWriter(logger ?? rpc.log).child({ component: 'wt' }); + const tell = + onError ?? + ((error, session) => { + log.error({ err: error, event: session === null ? 'wt.source' : 'wt.attach' }); + }); + // The caller's `onError` may throw itself — `session.close()` on the null + // a failed source is reported with is the easy way. That is a line, not a + // rejection of `done` nobody awaited (it is documented as always + // resolving) and not the end of a session's own error path. + const report = (error, session) => { + try { + tell(error, session); + } catch (thrown) { + log.error({ err: thrown, event: 'wt.onError' }); + } + }; + const source = iterate(sessions); + const controller = new AbortController(); + const pending = new Set(); + let stopped = false; + let saturated = false; + const attachOne = async (session) => { + try { + const observed = await meta(session); + const client = await attachSession(rpc, session, { + logger: log, + ...rest, + ...observed, + signal: controller.signal, + }); + if (client && onClient) onClient(client, session); + } catch (error) { + closeQuietly(session, { closeCode: 500, reason: 'Internal error' }); + report(error, session); + } + }; + // `done` always resolves: a source that throws ends the loop through + // onError, never as a rejection nobody awaited. + const done = (async () => { + for (;;) { + let next; + try { + next = await source.next(); + } catch (error) { + if (!stopped) report(error, null); + break; + } + const { value: session, done: finished } = next; + if (finished || stopped) break; + if (pending.size >= maxPending) { + // Refused now, not queued behind sessions still in their handshake + // — and said once per episode, not once per refused session. + closeQuietly(session, { closeCode: 503, reason: 'Busy' }); + if (!saturated) { + saturated = true; + log.warn({ event: 'wt.accept.saturated', pending: pending.size, maxPending }); + } + continue; + } + saturated = false; + const task = attachOne(session); + pending.add(task); + void task.then(() => pending.delete(task)); + } + await Promise.all(pending); + })(); + return { + stop() { + stopped = true; + controller.abort(); + return source.stop(); + }, + done, + }; +}; + +/** + * The CONNECT request off a @fails-components/webtransport session: its + * `header` carries the HTTP/3 pseudo-headers and the real ones; `:path` + * keeps the query. Pseudo-headers are dropped from the bag the server sees + * (`origin` and friends stay), the way an upgrade request's are. + */ +const fromFails = (session) => { + const header = session?.header ?? {}; + const headers = {}; + for (const name in header) { + if (name.charCodeAt(0) === 58) continue; // ':' + const value = header[name]; + if (value !== undefined && value !== null) headers[name.toLowerCase()] = String(value); + } + const address = session?.peerAddress; + const remoteAddress = + typeof address === 'string' + ? address.replace(/:\d+$/, '') + : typeof address?.address === 'string' + ? address.address + : ''; + return { headers, url: typeof header[':path'] === 'string' ? header[':path'] : '/', remoteAddress }; +}; + +/** + * The request callback an @fails-components/webtransport server needs + * (`h3.setRequestCallback(failsRequestCallback)`): its `sessionStream(path)` + * matches the request path literally, query included, and a wrpc client + * declares its headers in the query — so the session is routed by pathname, + * while `header[':path']` keeps the query for fromFails() to read. + */ +const failsRequestCallback = async (args) => { + const target = args.header?.[':path'] ?? '/'; + const at = target.indexOf('?'); + // `status` is what accepts the request; the header is left as received, + // so the session's `header[':path']` keeps the query. + return { ...args, path: at === -1 ? target : target.slice(0, at), status: 200 }; +}; + +module.exports = { + attachSession, + acceptSessions, + fromFails, + failsRequestCallback, + fromQuico, + WtSocket, + ServerWtTransport, + isWtSession, + isWtStream, + isWtDatagrams, + DEFAULT_ACCEPT_TIMEOUT, + DEFAULT_MAX_PENDING, + DEFAULT_HIGH_WATER_MARK, + DEFAULT_LOW_WATER_MARK, + ...framing, +}; diff --git a/src/webtransport/port.js b/src/webtransport/port.js new file mode 100644 index 0000000..0121009 --- /dev/null +++ b/src/webtransport/port.js @@ -0,0 +1,70 @@ +'use strict'; + +// The WebTransport port: what the server half is written against. +// +// It is wrpc's OWN structural contract, not a binding to any library — the +// W3C WebTransport session shape, so a session from any implementation +// satisfies it as-is (@fails-components/webtransport's does; a browser's +// WebTransport object does too, which is what lets the fake in tests be one +// class for both ends) and one with its own API needs a thin wrapper +// (quico's node-stream shape — see quico.js). Nothing is required here, on +// purpose: the validators are the only runtime check, in the style of +// isEngine, isBackplane and isRtcAdapter (duck typing per the +// zero-dependency injection rule). Node never speaks HTTP/3 itself: the +// implementation is the application's to inject, never this package's to +// depend on. +// +// interface WtSession { +// readonly ready?: Promise // settled before a server session is handed over +// readonly closed: Promise<{ closeCode: number, reason: string }> +// readonly incomingBidirectionalStreams: ReadableStream +// readonly incomingUnidirectionalStreams?: ReadableStream> +// createBidirectionalStream(): Promise +// createUnidirectionalStream?(): Promise> +// readonly datagrams?: WtDatagrams // absent on an implementation without them +// close(info?: { closeCode?: number, reason?: string }): void +// } +// +// interface WtStream { +// readonly readable: ReadableStream +// readonly writable: WritableStream +// } +// +// interface WtDatagrams { +// readonly readable: ReadableStream +// createWritable?(): WritableStream // the newer factory, or +// readonly writable?: WritableStream // the legacy stream (read only without the factory) +// readonly maxDatagramSize?: number +// } + +const isObject = (value) => typeof value === 'object' && value !== null; +const isReadable = (value) => isObject(value) && typeof value.getReader === 'function'; +const isWritable = (value) => isObject(value) && typeof value.getWriter === 'function'; + +/** A `{ readable, writable }` pair of WHATWG streams. */ +const isWtStream = (value) => isObject(value) && isReadable(value.readable) && isWritable(value.writable); + +/** + * The datagram duplex: a readable and the newer `createWritable()` (or the + * legacy writable), `maxDatagramSize` optional. The factory is tested FIRST: + * `writable` is a getter a host may log as deprecated when it is read + * (@fails-components/webtransport does, and creates a writable for it), so + * it is never touched where `createWritable` exists — as in datagramWriter. + */ +const isWtDatagrams = (value) => + isObject(value) && + isReadable(value.readable) && + (typeof value.createWritable === 'function' || isWritable(value.writable)) && + (value.maxDatagramSize === undefined || typeof value.maxDatagramSize === 'number'); + +/** The session shape attachSession() takes — either end of a WebTransport. */ +const isWtSession = (value) => + isObject(value) && + typeof value.close === 'function' && + typeof value.createBidirectionalStream === 'function' && + isReadable(value.incomingBidirectionalStreams) && + isObject(value.closed) && + typeof value.closed.then === 'function' && + (value.datagrams === undefined || value.datagrams === null || isWtDatagrams(value.datagrams)); + +module.exports = { isWtSession, isWtStream, isWtDatagrams }; diff --git a/src/webtransport/quico.js b/src/webtransport/quico.js new file mode 100644 index 0000000..0dda114 --- /dev/null +++ b/src/webtransport/quico.js @@ -0,0 +1,135 @@ +'use strict'; + +// The quico adapter: quico (a pure-JS HTTP/3 stack) surfaces a WebTransport +// session as an http-shaped request — `req.headers[':protocol'] === +// 'webtransport'`, accepted with `res.writeHead(200)`, streams as node +// Duplexes on `req.on('stream')` and `res.createBidirectionalStream()`, +// datagrams as Buffers on `req.on('datagram')` and `res.sendDatagram()`. +// fromQuico() turns that into the W3C-shaped session the port names, node +// streams bridged to WHATWG ones with the built-in toWeb() adapters, and +// hands back the CONNECT request alongside. Nothing is required from quico: +// the request and response are whatever its server passed the handler. + +const { Readable, Writable } = require('node:stream'); + +const { isWtStream } = require('./port.js'); + +// A readable whose controller stays reachable — what a node event source +// is pushed into, and what a close ends. +const source = () => { + let controller = null; + let ended = false; + const readable = new ReadableStream({ + start(c) { + controller = c; + }, + }); + return { + readable, + push(value) { + if (!ended) controller.enqueue(value); + }, + end() { + if (ended) return; + ended = true; + try { + controller.close(); + } catch { + // Cancelled by its reader. + } + }, + }; +}; + +const toWtStream = (duplex) => { + if (isWtStream(duplex)) return duplex; + return { readable: Readable.toWeb(duplex), writable: Writable.toWeb(duplex) }; +}; + +/** + * `{ session, headers, url, remoteAddress }` for one accepted WebTransport + * request — spread the last three into attachSession's options. Accepts the + * request (`writeHead(200)`) unless `accept: false`, in which case the + * caller has already done so. + */ +const fromQuico = (req, res, { accept = true, maxDatagramSize = 1200 } = {}) => { + if (typeof req?.on !== 'function' || typeof res?.writeHead !== 'function') { + throw new TypeError('fromQuico: a quico request and response are required'); + } + if (req.headers?.[':protocol'] !== 'webtransport') { + throw new TypeError("fromQuico: not a WebTransport request (':protocol' is not 'webtransport')"); + } + if (accept) res.writeHead(200); + const bidi = source(); + const uni = source(); + const datagramsIn = source(); + let settleClosed = null; + const closed = new Promise((resolve) => { + settleClosed = resolve; + }); + let done = false; + const finish = (info) => { + if (done) return; + done = true; + bidi.end(); + uni.end(); + datagramsIn.end(); + settleClosed(info); + }; + req.on('stream', (duplex) => bidi.push(toWtStream(duplex))); + req.on('unidirectionalStream', (readable) => uni.push(Readable.toWeb(readable))); + req.on('datagram', (data) => datagramsIn.push(new Uint8Array(data.buffer, data.byteOffset, data.byteLength))); + // quico 0.4 reports none of these for a session that vanished — pair the + // adapter with attachSession's idleTimeout for liveness. + for (const event of ['close', 'end', 'error', 'aborted']) { + req.on(event, () => finish({ closeCode: 0, reason: '' })); + } + const session = { + ready: Promise.resolve(), + closed, + incomingBidirectionalStreams: bidi.readable, + incomingUnidirectionalStreams: uni.readable, + datagrams: { + readable: datagramsIn.readable, + writable: new WritableStream({ + write(chunk) { + if (chunk.byteLength > maxDatagramSize) return; + res.sendDatagram(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)); + }, + }), + maxDatagramSize, + }, + async createBidirectionalStream() { + const duplex = res.createBidirectionalStream(); + if (!duplex) throw new Error('quico: the session is not open'); + return toWtStream(duplex); + }, + async createUnidirectionalStream() { + const writable = res.createUnidirectionalStream(); + if (!writable) throw new Error('quico: the session is not open'); + return Writable.toWeb(writable); + }, + // quico has no session close of its own: ending the CONNECT response + // ends the session (the capsule protocol's FIN), which is what a peer + // observes as the close. + close(info = {}) { + finish({ closeCode: info.closeCode ?? 0, reason: info.reason ?? '' }); + try { + res.end(); + } catch { + // Already ended. + } + }, + }; + const headers = {}; + for (const name in req.headers) { + if (name.charCodeAt(0) === 58) continue; + const value = req.headers[name]; + if (value !== undefined && value !== null) headers[name.toLowerCase()] = String(value); + } + const url = typeof req.url === 'string' ? req.url : (req.headers[':path'] ?? '/'); + const remoteAddress = req.socket?.remoteAddress ?? req.remoteAddress ?? ''; + return { session, headers, url, remoteAddress }; +}; + +module.exports = { fromQuico }; diff --git a/src/webtransport/socket.js b/src/webtransport/socket.js new file mode 100644 index 0000000..5e65dfa --- /dev/null +++ b/src/webtransport/socket.js @@ -0,0 +1,352 @@ +'use strict'; + +// A WebTransport session as a WrpcSocket — the engine-port socket contract +// (engine.d.ts, docs/reference/engine.md) over one bidirectional stream, the +// control stream the client opened. That shape is what RpcServer.attachSocket +// takes, and going through it rather than RpcServer.attach is the point: the +// session restore, the declared-headers parse, the receive-side flow control +// and ServerWsTransport itself all come for free, and a WebTransport client +// lands in the same RpcServer, rooms and cluster as a WebSocket one. +// +// Framing is src/webtransport/framing.js on both directions. Outbound flow +// control counts the bytes handed to the stream's writer and not yet taken +// by it (write() on a WHATWG writer resolves when the sink took the chunk): +// send() answers false above the high-water mark and 'drain' fires under the +// low-water mark. Inbound, pause() stops pulling the reader, and QUIC's own +// stream flow control carries the pressure to the peer — the one place a +// WebTransport socket is simpler than a TCP one. +// +// Close codes: `close(code, reason)` maps to `session.close({ closeCode: +// code, reason })`, so the WebSocket close code IS the WebTransport close +// code (1001 on server shutdown, 1002 on a framing violation), and the +// client reads it back from `closed`. A poisoned handle stays quiet: after +// the session is gone every method is a no-op that answers false/0, never a +// throw — the engine contract's rule, same as UwsSocket. + +// A node EventEmitter, like Connection and UwsSocket: the engine port's +// 'message' carries two arguments (data, isBinary), which wrpc's own +// single-value Emitter cannot. +// +// The channel itself — framing, the capabilities exchange, compression, the +// stream mux, the datagrams, the byte accounting — is src/webtransport/ +// channel.js, shared with the client transport. What is here is what makes +// it a server socket: the events, pause(), the idle timer, the log, the +// close codes. +const { EventEmitter } = require('node:events'); +const { clip } = require('../rpc/errors.js'); +const { normalizeCompression } = require('../compression/index.js'); +const { + WtChannel, + closeQuietly, + normalizeBackpressure, + DEFAULT_HIGH_WATER_MARK, + DEFAULT_LOW_WATER_MARK, + DEFAULT_MAX_BACKPRESSURE, + DEFAULT_CLOSE_TIMEOUT, +} = require('./channel.js'); + +// Datagrams handed to the session and not yet taken by it. On the hosts +// wrpc is run against the sink is synchronous and this never passes one; +// the cap is for a host whose sink holds its promise — a W3C-shaped one +// under congestion — where an unbounded queue of positions nobody wants +// any more is the opposite of what a datagram is for. +const MAX_DATAGRAMS_IN_FLIGHT = 64; + +class WtSocket extends EventEmitter { + // What attachSocket reads into the client's meta; a WebTransport session + // negotiates no subprotocol, so `protocol` stays ''. + remoteAddress; + protocol = ''; + // Whether the peer's capabilities said it reads framed messages: null + // until they arrive, then announced as 'frames' too — the revision, which + // attachSocket raises to 2 only on a true (protocol.md#versioning). + peerFrames = null; + + #session; + #stream; + #channel; + #closed = false; + #paused = false; + // While paused: the promise every read — the control stream's and the + // mux's side streams' — waits on, and what resume() settles it with. + #gate = null; + #release = null; + #log; + #idle = 0; + #idleTimer = null; + + /** + * `stream` is the control stream — the first bidirectional stream the + * client opened, which attachSession() waits for. Reading starts at once. + * + * `idleTimeout` (ms, 0 = off) terminates the session when nothing arrives + * on the control stream for that long — the liveness the WebSocket engine + * gets from its own ping frames. A wrpc client pings on its heartbeat, so + * a live one is never idle; a host that reports no session end (quico + * 0.4 does not) needs this to shed a peer that vanished. + */ + constructor( + session, + stream, + { + remoteAddress = '', + highWaterMark, + lowWaterMark, + maxBackpressure, + maxMessage, + idleTimeout = 0, + closeTimeout = DEFAULT_CLOSE_TIMEOUT, + compression = null, + maxHeldStreams, + holdTimeout, + // The host's writer, already bound to the peer (attachSession): what + // a violation, an idle, a failed session and a close write to. Null + // for a socket used bare. + log = null, + // `(codec id, error)`: a codec that failed on the way out — the frame + // left plain. attachSession hands it to the server's reporter. + onCodecError = null, + // This end reads framed messages (the server's revision is 2): said + // to the peer in the capabilities. + frames = false, + } = {}, + ) { + super(); + this.#session = session; + this.#stream = stream; + this.#log = log; + this.remoteAddress = remoteAddress; + if (!Number.isInteger(closeTimeout) || closeTimeout < 0) { + throw new TypeError('WtSocket: options.closeTimeout must be a non-negative integer of milliseconds'); + } + this.#idle = idleTimeout; + this.#channel = new WtChannel( + session, + stream, + { + highWaterMark, + lowWaterMark, + maxBackpressure: normalizeBackpressure(maxBackpressure, 'WtSocket: options'), + maxMessage, + closeTimeout, + compression: normalizeCompression(compression, 'WtSocket: options'), + maxDatagramsInFlight: MAX_DATAGRAMS_IN_FLIGHT, + mux: { + // An inbound stream cancelled unread (streams.js): announced for + // the host to log — a peer opening streams it never names is a + // signal. + onRefused: (reason, id) => void this.emit('stream-refused', { reason, id }), + onFallback: () => void this.#log?.info({ event: 'wt.mux.fallback' }), + ...(maxHeldStreams === undefined ? null : { maxHeldStreams }), + ...(holdTimeout === undefined ? null : { holdTimeout }), + }, + // pause() stops the side streams too, and their bytes are liveness + // as much as the control stream's — an upload used to keep flowing + // around a pause, and a session busy with one used to idle out. + gate: () => this.#gate, + onActivity: () => this.#touch(), + onCodecError, + onDatagramDrop: () => void this.#log?.warn({ event: 'wt.datagram.dropped' }), + frames, + onFrames: (reads) => { + this.peerFrames = reads; + this.emit('frames', reads); + }, + }, + (data, isBinary) => this.emit('message', data, isBinary), + () => this.emit('drain'), + // A message the peer sent that cannot be read is its protocol + // violation: the 1002 a malformed frame gets, and its line. + (error) => { + this.#fault('wt.violation', error, { code: typeof error?.code === 'string' ? error.code : null }); + this.close(1002, 'Protocol error'); + }, + // Past maxBackpressure: a peer that never drains. A warn, as the + // WebSocket engine's `ws.backpressure` — a limit the operator set cut + // a client off, and it used to show only at debug. + (error) => { + this.#fault('wt.backpressure', error, { + buffered: this.#channel.bufferedAmount, + max: normalizeBackpressure(maxBackpressure, 'WtSocket: options'), + }); + this.terminate(); + }, + (error) => this.#error(error), + // The peer ended the control stream: the connection is over. Its + // session close follows with its code; 1000 when it does not. + () => this.#channel.expectClose({ closeCode: 1000, reason: '' }), + ); + // Armed once the channel exists: an option the channel refused used to + // throw out of this constructor with the timer already set, and its + // expiry then read the channel that was never built — an uncaught + // TypeError, `idleTimeout` ms later. + this.#touch(); + // The session's own end — the peer closed, the transport failed — is a + // close here; our own close() settles it too, by then a no-op. + session.closed.then( + (info) => { + const down = () => this.#down(info?.closeCode ?? 1006, info?.reason ?? ''); + const settled = this.#channel.settled(); + if (settled === null) down(); + else void settled.then(down); + }, + (error) => { + this.#fault('wt.session.error', error); + this.#down(1006, ''); + }, + ); + } + + get session() { + return this.#session; + } + + get stream() { + return this.#stream; + } + + /** Bytes handed to the stream and not yet taken by it; 0 once closed. */ + get bufferedAmount() { + return this.#channel.bufferedAmount; + } + + /** The largest datagram the session carries; 0 when it carries none. */ + get maxDatagramSize() { + return this.#channel.maxDatagramSize; + } + + get isPaused() { + return this.#paused; + } + + /** + * The codec ids in effect — `{ encode, decode }`, what this side sends + * with and what the peer does — or null while the two lists share none. + */ + get compression() { + return this.#channel.compression; + } + + /** Datagrams dropped because the session had not taken the ones before them. */ + get droppedDatagrams() { + return this.#channel.droppedDatagrams; + } + + // The mirror of Connection.#fault: one line, then the 'error' event a + // bound socket has a listener for. + #fault(event, error, extra = null) { + this.#log?.warn({ ...extra, err: error, event }); + this.#error(error); + } + + /** + * Told about an outbound stream packet before it is serialized (by + * ServerWtTransport): true when the packet must not go on the control + * stream — its stream's own FIN or RESET carries it. + */ + streamControl(packet) { + return this.#channel.control(packet); + } + + /** + * A packet as ONE datagram — unreliable, unordered, at most + * maxDatagramSize bytes: true when it went out, false when the session + * has no datagrams, the packet does not fit, or the socket is closed — + * the caller's cue to send() it on the control stream instead. A datagram + * the session has not kept up with is dropped, and answered true. + */ + sendUnreliable(data) { + return this.#channel.sendUnreliable(data); + } + + /** + * A string is a packet, bytes are a stream chunk. False above the + * high-water mark (then 'drain'), and false, quietly, once closed. + * `options.compress === false` (the per-message opt-out Client.sendRaw + * passes through writeWith) sends this one plain whatever was negotiated. + */ + send(data, options = null) { + return this.#channel.send(data, options); + } + + /** Stops pulling the control stream and the side streams; QUIC flow control does the rest. */ + pause() { + if (this.#paused || this.#closed) return; + this.#paused = true; + this.#gate = new Promise((resolve) => { + this.#release = resolve; + }); + } + + resume() { + if (!this.#paused) return; + this.#paused = false; + const release = this.#release; + this.#gate = null; + this.#release = null; + if (release) release(); + } + + /** + * Graceful: what send() already handed to the control stream reaches the + * peer, and the peer's `closed` carries the code and reason — the stream + * is closed first and the session after it, at most `closeTimeout` later + * (channel.js). This side is closed synchronously either way: 'close' + * fires here, nothing more is accepted. + */ + close(code = 1000, reason = '') { + if (this.#closed) return; + this.#down(code, reason); + this.#channel.finish({ closeCode: code, reason }); + } + + /** Hard: no reason travels; the peer sees 1006. */ + terminate() { + if (this.#closed) return; + this.#down(1006, ''); + closeQuietly(this.#session); + } + + // Re-arms the idle timer on every read; unref'd, so an idle socket never + // keeps a process alive on its own. + #touch() { + if (this.#idle <= 0) return; + clearTimeout(this.#idleTimer); + this.#idleTimer = setTimeout(() => { + this.#idleTimer = null; + this.#fault('wt.idle', new Error(`No data for ${this.#idle} ms`), { idle: this.#idle }); + this.terminate(); + }, this.#idle); + this.#idleTimer.unref?.(); + } + + #down(code, reason) { + if (this.#closed) return; + this.#closed = true; + // The peer's reason is its text: clipped, as a field, at debug — a + // routine end is not an alert, but a code an operator can grep for. + const dropped = this.#channel.droppedDatagrams; + this.#log?.debug({ event: 'wt.close', code, reason: clip(reason), ...(dropped > 0 ? { dropped } : null) }); + clearTimeout(this.#idleTimer); + this.#idleTimer = null; + this.#channel.shut(); + this.resume(); + this.emit('close', code, reason); + } + + #error(error) { + // attachSocket binds 'error'; a socket used bare (tests) may not have, + // and Emitter throws on an unheard 'error'. + if (this.listenerCount('error') === 0) return; + this.emit('error', error); + } +} + +module.exports = { + WtSocket, + normalizeBackpressure, + DEFAULT_HIGH_WATER_MARK, + DEFAULT_LOW_WATER_MARK, + DEFAULT_MAX_BACKPRESSURE, + DEFAULT_CLOSE_TIMEOUT, +}; diff --git a/src/webtransport/streams.js b/src/webtransport/streams.js new file mode 100644 index 0000000..1d29e63 --- /dev/null +++ b/src/webtransport/streams.js @@ -0,0 +1,539 @@ +'use strict'; + +// Binary streams on their own WebTransport streams — the one place the +// wire departs from "everything on the control stream", and the reason to: +// a 64 MiB upload's chunks on the control stream sit in front of every +// callback and event behind them, and QUIC's independent streams are +// exactly what removes that head-of-line wait. The mapping is symmetric — +// one StreamMux per end, the client transport's and the server socket's. +// +// Negotiated, never assumed: each end's first message on the control +// stream is a KIND 2 capabilities message (`{"streams":true}`); a peer that +// never sends one gets every chunk on the control stream, as revision 1 +// reads it. Then, per wrpc binary stream (a `stream` packet with an id): +// +// sender the OPEN packet stays on the control stream (it carries name +// and size, and its order against the call that names the id +// matters); the chunks go on ONE unidirectional WebTransport +// stream whose first bytes are the chunk header — [idLen][id] +// — once, then raw payload; FIN is the end, a RESET (abort) the +// termination, and the `end`/`terminate` packets are NOT sent. +// receiver a QUIC stream is ordered only against itself, so chunks may +// arrive before the open packet and FIN before the last read +// of another stream: chunks are held until the open packet has +// passed, and the `end`/`terminate` packet is synthesized only +// once the unidirectional stream has ended — after every chunk. +// +// Packet inspection is the cost: the receiver looks at the first bytes of +// every inbound packet for a `stream` packet (a prefix test, never a parse +// of anything else), and the sender is told the object before it is +// serialized. Under an injected wire codec neither is possible, and the +// capability is not offered. Browser-budgeted: manual loops, no spread on +// the hot path. + +const { chunkEncode } = require('../chunks.js'); + +const TEXT_ENCODER = new TextEncoder(); +const TEXT_DECODER = new TextDecoder(); +// What JSON.stringify makes of every wrpc stream packet's first key. +const STREAM_PREFIX = '{"type":"stream"'; +const CAPS_STREAMS = '{"streams":true}'; +// An inbound stream whose open packet has not passed is HELD: read no +// further than the read that carried its header (the rest waits in the +// peer's stream, under QUIC's own per-stream flow control), for at most +// `holdTimeout`, and at most `maxHeldStreams` of them at once — a peer +// used to be able to open streams for ids it never named and have every +// byte of them buffered here, before any authentication, past maxMessage. +const DEFAULT_MAX_HELD_STREAMS = 32; +const DEFAULT_HOLD_TIMEOUT = 10_000; +// How long createUnidirectionalStream() may take before the stream — and +// every one after it — goes on the control stream instead. A browser parks +// the promise until the peer grants stream credit, forever against a host +// that never does; the chunks held for it were the only bound. +const DEFAULT_OPEN_TIMEOUT = 5_000; + +// The chunk header a unidirectional stream opens with — chunkEncode's, +// without the payload. +const idHeader = (id) => { + const bytes = TEXT_ENCODER.encode(id); + if (bytes.length > 255) throw new Error(`ID length ${bytes.length} exceeds maximum of 255 characters`); + const out = new Uint8Array(1 + bytes.length); + out[0] = bytes.length; + out.set(bytes, 1); + return out; +}; + +// The id a chunkEncode frame names, and where its payload starts. +const readId = (frame) => { + const idLength = frame[0]; + return { id: TEXT_DECODER.decode(frame.subarray(1, 1 + idLength)), offset: 1 + idLength }; +}; + +class StreamMux { + #session; + #emitPacket; + #emitChunk; + #onQueued; + #onSent; + #writeControl; + #sendControl; + #onRefused; + #onFallback; + #gate; + #onActivity; + #maxHeld; + #holdTimeout; + #openTimeout; + #enabled = false; + // Whether the peer announced streams — what makes an inbound + // unidirectional stream one of ours to read. Kept apart from #enabled, + // which the fallback in #open switches off for OUR sending only. + #peerStreams = false; + #closed = false; + // Inbound streams held for their open packet, against #maxHeld. + #held = 0; + // Outbound: id -> { writer, chain, pending, ended } — pending holds the + // chunk frames written while the stream is still opening, ended the + // status that arrived meanwhile. + #out = new Map(); + // Inbound: id -> { opened, queue, ended, stream, release, released, + // timer, refused, cancel } (see #entry). + #in = new Map(); + + /** + * `emitPacket(text)` and `emitChunk(frame)` deliver inbound messages in + * the transport's own way; `onQueued(n)`/`onSent(n)` keep its outbound + * byte accounting honest for chunks that left through a side stream; + * `writeControl(frame)` and `sendControl(packet)` put a chunk frame or a + * stream packet on the control stream — where a stream's chunks go when + * its own WebTransport stream could not be opened (a host that grants no + * unidirectional streams; quico 0.4 does not). `onRefused(reason, id)` + * hears of an inbound stream cancelled unread: `'unannounced'` (the peer + * announced no streams), `'id'` (an empty one), `'duplicate'` (a second + * stream for an id), `'held'` (past maxHeldStreams), `'timeout'` (its + * open packet never came within holdTimeout). `openTimeout` bounds a + * createUnidirectionalStream() that never settles. `gate()` answers a + * promise while inbound streams must not be read (the transport is + * paused — the peer's chunks used to keep arriving around the pause), + * null otherwise; `onActivity()` hears of every read that carried bytes. + */ + constructor( + session, + { + emitPacket, + emitChunk, + onQueued, + onSent, + writeControl = null, + sendControl = null, + onRefused = null, + gate = null, + onActivity = null, + // Called once, when the first side stream could not be had and every + // chunk from then on rides the control stream — the host's to log. + onFallback = null, + maxHeldStreams = DEFAULT_MAX_HELD_STREAMS, + holdTimeout = DEFAULT_HOLD_TIMEOUT, + openTimeout = DEFAULT_OPEN_TIMEOUT, + }, + ) { + if (!Number.isInteger(maxHeldStreams) || maxHeldStreams <= 0) { + throw new TypeError('StreamMux: maxHeldStreams must be a positive integer'); + } + if (!Number.isInteger(holdTimeout) || holdTimeout <= 0) { + throw new TypeError('StreamMux: holdTimeout must be a positive integer (ms)'); + } + if (!Number.isInteger(openTimeout) || openTimeout <= 0) { + throw new TypeError('StreamMux: openTimeout must be a positive integer (ms)'); + } + this.#session = session; + this.#emitPacket = emitPacket; + this.#emitChunk = emitChunk; + this.#onQueued = onQueued; + this.#onSent = onSent; + this.#writeControl = writeControl; + this.#sendControl = sendControl; + this.#onRefused = onRefused; + this.#onFallback = onFallback; + this.#gate = gate; + this.#onActivity = onActivity; + this.#maxHeld = maxHeldStreams; + this.#holdTimeout = holdTimeout; + this.#openTimeout = openTimeout; + } + + /** What we announce: streams, when the session can open unidirectional ones. */ + static caps(session) { + return typeof session?.createUnidirectionalStream === 'function' ? CAPS_STREAMS : '{}'; + } + + /** Whether the peer takes chunks on unidirectional streams. */ + get enabled() { + return this.#enabled; + } + + /** The peer's capabilities message (KIND 2). */ + peerCaps(text) { + let caps = null; + try { + caps = JSON.parse(text); + } catch { + return; + } + this.#peerStreams = Boolean(caps?.streams); + this.#enabled = + this.#peerStreams && + typeof this.#session.createUnidirectionalStream === 'function' && + typeof this.#session.incomingUnidirectionalStreams?.getReader === 'function'; + } + + // --- outbound ------------------------------------------------------- + + /** + * Told about every outbound packet BEFORE it is serialized. Answers true + * when the packet must NOT go on the control stream — an `end` or + * `terminate` of a stream that went out on its own WebTransport stream, + * which its FIN or RESET carries instead. + */ + control(packet) { + if (!this.#enabled || packet.type !== 'stream' || typeof packet.id !== 'string') return false; + const { id, status } = packet; + if (status === undefined) { + if (!this.#out.has(id)) this.#open(id); + return false; + } + const entry = this.#out.get(id); + if (!entry) return false; + if (entry.writer === null) { + // Still opening: the status waits with the chunks. + entry.ended = status; + return true; + } + this.#out.delete(id); + this.#finishOut(entry, status); + return true; + } + + #finishOut(entry, status) { + if (status === 'end') { + entry.chain = entry.chain.then(() => entry.writer.close()).catch(() => {}); + } else { + entry.chain.then(() => entry.writer.abort()).catch(() => {}); + } + } + + #open(id) { + const entry = { writer: null, chain: Promise.resolve(), pending: [], ended: null }; + this.#out.set(id, entry); + let expired = false; + const timer = setTimeout(() => { + expired = true; + this.#fallback(id, entry); + }, this.#openTimeout); + timer.unref?.(); + this.#session.createUnidirectionalStream().then( + (writable) => { + clearTimeout(timer); + if (expired || this.#closed || this.#out.get(id) !== entry) { + // Granted too late, or to a stream that is gone: reset unused. + const abort = writable.abort(); + if (abort && typeof abort.catch === 'function') abort.catch(() => {}); + return; + } + entry.writer = writable.getWriter(); + entry.chain = entry.writer.write(idHeader(id)).catch(() => {}); + const pending = entry.pending; + entry.pending = null; + // Counted when held (chunk()): routed without counting again. + for (let i = 0; i < pending.length; i++) this.#route(entry, pending[i], true); + if (entry.ended) { + this.#out.delete(id); + this.#finishOut(entry, entry.ended); + } + }, + () => { + clearTimeout(timer); + if (!expired) this.#fallback(id, entry); + }, + ); + } + + // No stream to be had (the host grants none, or not within openTimeout): + // this stream, and every one after it, goes on the control stream — what + // was held for it is replayed there in order, its end packet last. + #fallback(id, entry) { + if (this.#closed || this.#out.get(id) !== entry) return; + if (this.#enabled && this.#onFallback !== null) this.#onFallback(); + this.#enabled = false; + this.#out.delete(id); + const pending = entry.pending; + entry.pending = null; + for (let i = 0; i < pending.length; i++) { + const frame = pending[i]; + // Uncounted here; the control stream counts it as its own. + this.#onQueued(1 + frame[0] - frame.length); + if (this.#writeControl) this.#writeControl(frame); + } + if (entry.ended && this.#sendControl) this.#sendControl({ type: 'stream', id, status: entry.ended }); + } + + #route(entry, frame, counted) { + // The payload starts past the id: one byte read, not a second decode + // of the id chunk() already looked the entry up by (bench/wt-streams.js). + const payload = frame.subarray(1 + frame[0]); + const size = payload.length; + if (!counted) this.#onQueued(size); + entry.chain = entry.chain + .then(() => entry.writer.write(payload)) + .then( + () => this.#onSent(size), + () => this.#onSent(size), + ); + } + + /** + * An outbound chunk frame (chunkEncode). Answers true when it was routed + * to the stream's own WebTransport stream, false when it belongs on the + * control stream (no side stream for this id). + */ + chunk(frame) { + if (this.#out.size === 0) return false; + const { id, offset } = readId(frame); + const entry = this.#out.get(id); + if (!entry) return false; + if (entry.writer === null) { + // Held for the stream to open, and counted against the transport's + // marks from now: what waits for the open is as buffered as what + // waits in a writer — it used to be invisible to bufferedAmount. + entry.pending.push(frame); + this.#onQueued(frame.length - offset); + } else this.#route(entry, frame, false); + return true; + } + + // --- inbound -------------------------------------------------------- + + /** + * Every inbound packet passes here first. Answers true when the mux + * delivered it (or will, in order) — an open packet that releases held + * chunks — and false when the caller should deliver it as usual. + */ + packet(text) { + if (!text.startsWith(STREAM_PREFIX)) return false; + let packet = null; + try { + packet = JSON.parse(text); + } catch { + return false; + } + if (typeof packet?.id !== 'string') return false; + const { id, status } = packet; + if (status !== undefined) { + // An end or terminate on the control stream: the peer sent this + // stream's chunks there too — nothing of ours is pending. + this.#in.delete(id); + return false; + } + const entry = this.#in.get(id); + if (!entry) { + this.#in.set(id, this.#entry(true)); + return false; + } + entry.opened = true; + this.#emitPacket(text); + const queue = entry.queue; + for (let i = 0; i < queue.length; i++) this.#emitChunk(chunkEncode(id, queue[i])); + queue.length = 0; + this.#unhold(entry); + if (entry.ended) this.#finish(id, entry.ended); + return true; + } + + // An inbound stream's record. `stream`: a unidirectional stream claimed + // the id; `release`/`released`: the hold on a stream read ahead of its + // open packet; `timer`: the hold's deadline; `refused`: cancelled unread; + // `cancel`: how (the reader's STOP_SENDING). + #entry(opened) { + return { + opened, + queue: [], + ended: null, + stream: false, + release: null, + released: null, + timer: null, + refused: false, + cancel: null, + }; + } + + /** + * An incoming unidirectional stream: reads it to the end — once its open + * packet has passed; only what the first read carried is held before. + */ + accept(readable) { + // A peer that announced no streams sends nothing on one: cancelled + // unread, never held. + if (!this.#peerStreams) { + try { + const cancelled = readable.cancel(); + if (cancelled && typeof cancelled.catch === 'function') cancelled.catch(() => {}); + } catch { + // Not cancellable: nothing is read from it either way. + } + this.#onRefused?.('unannounced', null); + return; + } + void this.#consume(readable); + } + + async #consume(readable) { + const reader = readable.getReader(); + let id = null; + let header = null; + let entry = null; + let refused = null; + try { + for (;;) { + // Held for its open packet: only what the read that carried the + // header brought is queued; the rest waits in the peer's stream, + // under QUIC's own per-stream flow control, never in this process. + if (entry !== null && !entry.opened) await entry.released; + if (entry !== null && entry.refused) return; + // Paused: no read until the transport resumes — the bytes wait in + // the peer's stream, under QUIC's flow control, like the control + // stream's do. + const wait = this.#gate === null ? null : this.#gate(); + if (wait !== null) await wait; + const { value, done } = await reader.read(); + if (done) break; + if (this.#closed) return; + if (this.#onActivity !== null) this.#onActivity(); + let bytes = value; + if (id === null) { + header = header === null ? bytes : concat(header, bytes); + const need = 1 + header[0]; + if (header.length < need) continue; + id = TEXT_DECODER.decode(header.subarray(1, need)); + bytes = header.subarray(need); + header = null; + const claimed = this.#claim(id); + if (typeof claimed === 'string') { + refused = claimed; + break; + } + entry = claimed; + entry.cancel = () => reader.cancel().catch(() => {}); + if (bytes.length === 0) continue; + } + this.#inbound(id, entry, bytes); + } + if (refused !== null) { + await reader.cancel().catch(() => {}); + this.#onRefused?.(refused, id); + return; + } + if (id !== null && !entry.refused) this.#ended(id, 'end'); + } catch { + // A RESET from the peer: the stream was terminated. (A hold that + // expired cancelled the reader itself and has said so already.) + if (id !== null && entry !== null && !entry.refused) this.#ended(id, 'terminate'); + } + } + + // The record for a unidirectional stream naming `id`, or why it is + // refused: an empty id, a second stream for an id (the first is the + // stream), more streams held for their open packet than the cap. + #claim(id) { + if (id.length === 0) return 'id'; + const existing = this.#in.get(id); + if (existing) { + if (existing.stream) return 'duplicate'; + // The open packet came first: nothing to hold. + existing.stream = true; + return existing; + } + if (this.#held >= this.#maxHeld) return 'held'; + const entry = this.#entry(false); + entry.stream = true; + entry.released = new Promise((resolve) => { + entry.release = resolve; + }); + this.#held++; + entry.timer = setTimeout(() => this.#expire(id, entry), this.#holdTimeout); + entry.timer.unref?.(); + this.#in.set(id, entry); + return entry; + } + + #unhold(entry) { + if (entry.timer !== null) { + clearTimeout(entry.timer); + entry.timer = null; + } + if (entry.release !== null) { + this.#held--; + const release = entry.release; + entry.release = null; + release(); + } + } + + // Its open packet never came: the stream is cancelled unread — what was + // held is dropped, and the peer sees STOP_SENDING. + #expire(id, entry) { + if (this.#in.get(id) !== entry || entry.opened) return; + this.#in.delete(id); + entry.refused = true; + entry.queue.length = 0; + this.#unhold(entry); + entry.cancel?.(); + this.#onRefused?.('timeout', id); + } + + #inbound(id, entry, bytes) { + if (entry.opened) return void this.#emitChunk(chunkEncode(id, bytes)); + entry.queue.push(bytes); + } + + #ended(id, status) { + const entry = this.#in.get(id); + if (!entry) return this.#finish(id, status); + if (entry.opened) return this.#finish(id, status); + entry.ended = status; + } + + #finish(id, status) { + this.#in.delete(id); + this.#emitPacket(JSON.stringify({ type: 'stream', id, status })); + } + + close() { + this.#closed = true; + for (const entry of this.#out.values()) { + entry.chain.then(() => entry.writer?.abort()).catch(() => {}); + entry.pending = null; + } + this.#out.clear(); + for (const entry of this.#in.values()) this.#unhold(entry); + this.#in.clear(); + } +} + +const concat = (a, b) => { + const out = new Uint8Array(a.length + b.length); + out.set(a, 0); + out.set(b, a.length); + return out; +}; + +module.exports = { + StreamMux, + STREAM_PREFIX, + CAPS_STREAMS, + DEFAULT_MAX_HELD_STREAMS, + DEFAULT_HOLD_TIMEOUT, + DEFAULT_OPEN_TIMEOUT, + idHeader, + readId, +}; diff --git a/src/webtransport/transport.js b/src/webtransport/transport.js new file mode 100644 index 0000000..99d1273 --- /dev/null +++ b/src/webtransport/transport.js @@ -0,0 +1,30 @@ +'use strict'; + +// The server-side transport of a WebTransport client: ServerWsTransport — +// the same outbound half a WebSocket gets, over a WtSocket — plus the one +// thing the socket needs that a WebSocket never did: to see a stream packet +// BEFORE it is serialized, so a binary stream's end or termination can +// travel as its own WebTransport stream's FIN or RESET instead of as a +// packet on the control stream (streams.js). Registered under 'wt' in the +// ServerTransport table, which is how RpcServer.attachSocket picks it for a +// socket attached with `kind: 'wt'`. + +const { ServerTransport } = require('../transport.js'); + +const ServerWsTransport = ServerTransport.transport.ws; + +class ServerWtTransport extends ServerWsTransport { + kind = 'wt'; + + send(obj, code = 200, text = null) { + const socket = this.connection; + // Under a codec the mux is off on both ends (the client announced no + // streams), and control() answers false without looking further. + if (obj.type === 'stream' && typeof socket.streamControl === 'function' && socket.streamControl(obj)) return true; + return super.send(obj, code, text); + } +} + +ServerTransport.transport.wt = ServerWtTransport; + +module.exports = { ServerWtTransport }; diff --git a/src/wire.js b/src/wire.js index 6af75e6..2ca1aa8 100644 --- a/src/wire.js +++ b/src/wire.js @@ -1,10 +1,11 @@ 'use strict'; -// The wire names of the connection-metadata carriers, in ONE place. Five -// modules speak them — the server parser (rpc/core), the client emitter -// (client/core), the ws connect-URL builder (client/transports), the CORS -// allowlist (transport) and the auth strategies (auth) — and a spelling -// change used to be a five-file grep with nothing to catch a miss. +// The wire names of the connection-metadata carriers, in ONE place. The +// server parsers (rpc/meta, rpc/handshake), the client emitter (client/core), +// the ws handshake builders (client/wsHandshake*), the two subprotocol +// negotiators (websocket/server, adapters/uws), the CORS allowlist +// (transport) and the auth strategies (auth) all speak them — and a spelling +// change used to be a multi-file grep with nothing to catch a miss. // Zero-require beyond sse/constants.js (itself import-free) and // browser-safe by construction: esbuild inlines these strings, so pulling // this file into a budgeted entry costs nothing. @@ -17,10 +18,70 @@ const { CHANNEL_HEADER } = require('./sse/constants.js'); const HEADERS_PARAM = 'wrpc_h'; const META_PARAM = 'wrpc_meta'; +// The protocol revisions a wrpc client OFFERS as WebSocket subprotocols — +// newest first — and the server selects from (protocol.md#versioning). +// Revision 1 is what 1.0 speaks. Revision 2 adds the framed messages below +// (a packet whose bytes travel as bytes), and a frame is sent only on a +// connection that negotiated it: a 1.0 peer offers or selects `wrpc.v1` +// and is spoken to as 1.0 was. +const WRPC_V1 = 'wrpc.v1'; +const WRPC_V2 = 'wrpc.v2'; + +// The same two bags, and the Bearer credential, as subprotocol OFFERS: +// Sec-WebSocket-Protocol is the one handshake header a page controls, so on +// a browser WebSocket the bags ride it instead of the URL — base64url JSON +// without padding, because an offer is an RFC 7230 token. Carrier tokens are +// read, never selected: the negotiators drop them from the offer before an +// application sees it, and refuse to echo one (a credential would come back +// in the response headers). +const HEADERS_PROTOCOL = 'wrpc.h.'; +const META_PROTOCOL = 'wrpc.m.'; +const BEARER_PROTOCOL = 'wrpc.bearer.'; +const CARRIER_PROTOCOL = /^wrpc\.(?:h|m|bearer)\./; + // The canonical single meta header (percent-encoded JSON, type-faithful, // one CORS entry) and the per-key x-amz-meta-style prefix spelling (string // values, hand-typeable, one CORS entry per key). const META_HEADER = 'x-wrpc-meta'; const META_PREFIX = 'x-wrpc-meta-'; -module.exports = { HEADERS_PARAM, META_PARAM, META_HEADER, META_PREFIX, CHANNEL_HEADER }; +// A binary WebSocket frame whose first byte is 0 is not a stream chunk (a +// chunk's first byte is its id length, at least 1) but a FRAMED MESSAGE, +// its second byte the kind: a packet or a chunk compressed with the codec +// negotiated on ping/pong (protocol.md#binary-chunks), or a packet whose +// byte values travel as binary attachments (src/attachments.js). Kind 2 +// stays reserved for a binary packet codec, should one ever be needed. +const FRAME_MARK = 0; +const FRAME_ATTACHMENTS = 1; +const FRAME_PACKET_COMPRESSED = 3; +const FRAME_CHUNK_COMPRESSED = 4; +// Session encryption (@alexify/wrpc/encryption): a Noise handshake message, +// then every message of the connection sealed — the framed kinds above, the +// text packets and the stream chunks all travel INSIDE a kind-6 frame, so +// nothing of the connection is left outside it. A client announces the mode +// with `wrpc_e=1` in the connect URL: the server may be the first to send +// (an onConnect hook, a broadcast), so it has to know before any frame. +const FRAME_HANDSHAKE = 5; +const FRAME_SEALED = 6; +const ENCRYPTION_PARAM = 'wrpc_e'; + +module.exports = { + HEADERS_PARAM, + META_PARAM, + WRPC_V1, + WRPC_V2, + HEADERS_PROTOCOL, + META_PROTOCOL, + BEARER_PROTOCOL, + CARRIER_PROTOCOL, + META_HEADER, + META_PREFIX, + CHANNEL_HEADER, + FRAME_MARK, + FRAME_ATTACHMENTS, + FRAME_PACKET_COMPRESSED, + FRAME_CHUNK_COMPRESSED, + FRAME_HANDSHAKE, + FRAME_SEALED, + ENCRYPTION_PARAM, +}; diff --git a/sse.d.ts b/sse.d.ts index 9b6f5cf..96a473a 100644 --- a/sse.d.ts +++ b/sse.d.ts @@ -1,18 +1,23 @@ -import type { Client, HttpCall, WrpcLogger } from './index.js'; +import type { Client, HttpCall, HttpEncoding, WrpcLogger } from './index.js'; /** * `@alexify/wrpc/sse` — Server-Sent Events as a wrpc transport. * * SSE is one-way, so a channel is two halves that find each other by id: * - * GET {basePath}/events opens a NEW channel - * GET {basePath}/events x-wrpc-channel: re-attaches to it - * POST {basePath} x-wrpc-channel: client -> server + * GET {basePath}/events opens a NEW channel + * GET {basePath}/events x-wrpc-channel: . re-attaches to it + * POST {basePath} x-wrpc-channel: . client -> server * - * The id is SERVER-minted and handed out once, in the `ready` frame; the - * channel is bound to the cookie identity of the GET that created it, and - * every re-attach and POST must present the same one (403 otherwise; 409 - * when the id is unknown). Both halves belong to ONE server-side `Client`, + * The id is SERVER-minted (by the application's `generateId` — a uuid, a + * cuid, a counter: its format is the application's business) and the secret + * server-drawn; both are handed out as ONE string in the `ready` frame's + * `channel` (`.` — opaque to the client, a 1.0 one included), + * and every re-attach and POST presents it back in one header, split by + * the server at the last dot. The secret is the credential: a request without the + * channel's is answered 409, exactly like an unknown id. On top, a channel + * is bound to the cookie identity of the GET that created it, and a request + * presenting the secret without that identity is 403. 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. @@ -58,6 +63,18 @@ export interface SseOptions { replay?: number; /** Comment-frame interval in ms; 0 disables. Default 15000. */ heartbeat?: number; + /** + * Mints the channel id. The id is server-minted and never taken from the + * request, so holding one proves the server said it — replace the default + * uuid only with something at least as unguessable. + * + * An `RpcServer` passes its own `generateId` down, so setting it there + * covers channel ids too; this is the override for a standalone + * `SseChannels`. Strict: a value that is not a function, or a function + * that does not answer a non-empty string of at most 255 characters, is a + * TypeError at construction. + */ + generateId?: () => string; /** The `retry:` value handed to the peer, in ms. Default 2000. */ retry?: number; /** Byte budget for the replay buffer; evicts oldest first. Default 1 MiB. */ @@ -75,6 +92,28 @@ export interface SseOptions { * (trust it only when the proxy is yours). */ clientAddress?: (call: { headers?: Record; remoteAddress?: string }) => string; + /** + * Encode the event stream for a GET whose `Accept-Encoding` admits it — + * one encoder per response (for gzip, one member), flushed after every + * event, so a repeated event shape compresses against the stream's own + * history and nothing waits for a next event. Off by default; decided + * per response, so a re-attach negotiates again. `filter(call)` decides + * per GET; `encodings` is the server's list of codings (default + * `['gzip']`, and gzip is the recommendation here: flushed per event, + * Brotli and zstd save nothing on small events and hold 570 / 930 KB per + * open response against gzip's 320 — `bench/algorithms.js`). The wrpc + * client needs nothing: `fetch` inflates. + */ + compression?: boolean | SseCompressionOptions; +} + +export interface SseCompressionOptions { + filter?: (call: { headers?: Record; remoteAddress?: string; method?: string; url?: string }) => boolean; + /** + * The codings, in the server's order of preference; every one must be + * able to stream (a custom coding needs `createStream`). Default `['gzip']`. + */ + encodings?: ReadonlyArray; } /** The server-side transport behind one event stream. Text-only. */ @@ -88,10 +127,17 @@ export declare class ServerSseTransport { export declare class SseChannel { readonly id: string; + /** + * The channel's credential: 18 random bytes as base64url, drawn by the + * server at creation and handed out in the `ready` frame. Every re-attach + * and POST presents it after the id (`.`); the id alone is + * never enough, whatever generator made it. + */ + readonly secret: string; /** * The identity the channel was created under: the session token read from * the opening GET's cookie, or '' for an anonymous peer. Every re-attach - * and POST must present it again — the id alone is never enough. + * and POST must present it again, on top of the secret. */ readonly key: string; readonly client: Client; @@ -124,20 +170,28 @@ export declare class SseChannels { ); readonly size: number; get(channelId: string): SseChannel | null; + /** True when `secret` is the channel's (constant-time). Checked before `authorized`. */ + holds(channel: SseChannel, secret: string): boolean; /** True when `headers` present the identity the channel was created under. */ authorized(channel: SseChannel, requestHeaders?: Record): boolean; /** * Opens or re-attaches the server -> client half. The call must provide * `stream`; a host that cannot keep a response open gets a 501. Ids are - * server-minted: an unknown `channelId` answers 409, a known one with the - * wrong cookie identity 403, and creation past the caps 503/429. + * server-minted: an unknown `channelId`, or a known one without its + * `secret`, answers 409; a known one with the wrong cookie identity 403; + * creation past the caps 503/429. * * `headers` are RESPONSE headers (CORS and the rest); the request headers a * new channel's client is built from come off `call` itself. */ open( call: HttpCall, - options?: { channelId?: string | null; lastEventId?: string | null; headers?: Record }, + options?: { + channelId?: string | null; + secret?: string; + lastEventId?: string | null; + headers?: Record; + }, ): SseChannel | undefined; close(): void; } diff --git a/tests/adapters/boots.js b/tests/adapters/boots.js index d5322f0..97f6f14 100644 --- a/tests/adapters/boots.js +++ b/tests/adapters/boots.js @@ -65,6 +65,9 @@ const rpcOptions = (options) => ({ sessions: options.sessions, cors: options.cors ?? null, basePath: options.basePath, + http: options.http, + encryption: options.encryption, + querystring: options.querystring, logger: false, }); diff --git a/tests/adapters/common.test.js b/tests/adapters/common.test.js index 9610273..3ca5ba1 100644 --- a/tests/adapters/common.test.js +++ b/tests/adapters/common.test.js @@ -30,6 +30,69 @@ test('receiveBody', async (t) => { await assert.rejects(receiveBody(streamOf('x'), 1.5), TypeError); await assert.rejects(receiveBody(streamOf('x'), Number.MAX_SAFE_INTEGER + 10), TypeError); }); + + // The outcomes the `for await` loop had, kept by the listeners that + // replaced it (bench/http-call.js). + const live = () => new Readable({ read() {} }); + + // Settled, the body leaves one listener: the error sink a late error + // lands in instead of becoming an uncaught exception. + const settledListeners = (stream) => { + for (const event of ['data', 'end', 'close']) assert.strictEqual(stream.listenerCount(event), 0, event); + assert.strictEqual(stream.listenerCount('error'), 1); + stream.emit('error', new Error('late')); + }; + + await t.test('past the limit the stream is destroyed and the body refused', async () => { + const stream = live(); + const pending = receiveBody(stream, 4); + stream.push(Buffer.from('12345')); + await assert.rejects(pending, /Body size limit exceeded/); + assert.strictEqual(stream.destroyed, true); + settledListeners(stream); + }); + + await t.test("the stream's own error is the rejection", async () => { + const stream = live(); + const pending = receiveBody(stream); + stream.push(Buffer.from('half')); + stream.destroy(new Error('aborted')); + await assert.rejects(pending, /aborted/); + }); + + await t.test('a stream closed before its end is a premature close', async () => { + const stream = live(); + const pending = receiveBody(stream); + stream.push(Buffer.from('half')); + stream.destroy(); + await assert.rejects(pending, { code: 'ERR_STREAM_PREMATURE_CLOSE' }); + }); + + await t.test('a stream already read to its end reads as no body; a dead one is refused', async () => { + const ended = streamOf('gone'); + ended.resume(); + await new Promise((resolve) => ended.once('end', resolve)); + assert.strictEqual(await receiveBody(ended), null); + const errored = live(); + errored.on('error', () => {}); + errored.destroy(new Error('reset')); + await assert.rejects(receiveBody(errored), /reset/); + const closed = live(); + closed.destroy(); + await assert.rejects(receiveBody(closed), { code: 'ERR_STREAM_PREMATURE_CLOSE' }); + }); + + await t.test('a stream paused upstream is read all the same', async () => { + const stream = streamOf('paused', ' body'); + stream.pause(); + assert.strictEqual((await receiveBody(stream)).toString(), 'paused body'); + }); + + await t.test('a body read to its end leaves only the error sink behind', async () => { + const stream = streamOf('a', 'b'); + assert.strictEqual((await receiveBody(stream)).toString(), 'ab'); + settledListeners(stream); + }); }); test('normalizeBody', async (t) => { diff --git a/tests/adapters/fastify.test.js b/tests/adapters/fastify.test.js index 1c38500..367ed19 100644 --- a/tests/adapters/fastify.test.js +++ b/tests/adapters/fastify.test.js @@ -179,8 +179,8 @@ test('options.engine short-circuits detection and preClose owns teardown', async assert.ok(instance.decorations.get('wrpc') instanceof RpcServer); assert.deepStrictEqual( instance.routes.map((route) => route.url), - ['/api', '/api/events', '/api/:unit/:method'], - 'packet route + SSE stream + REST route', + ['/api', '/api/events', '/api/encryption-key', '/api/:unit/:method'], + 'packet route + SSE stream + key discovery + REST route', ); assert.ok(instance.hooks.has('preClose')); diff --git a/tests/adapters/spec.js b/tests/adapters/spec.js index 6fbbb26..b70d44c 100644 --- a/tests/adapters/spec.js +++ b/tests/adapters/spec.js @@ -17,10 +17,13 @@ // close would wedge the whole run instead of failing it. const assert = require('node:assert'); +const http = require('node:http'); +const zlib = require('node:zlib'); const { randomUUID } = require('node:crypto'); const { Blob } = require('node:buffer'); const { WrpcClient, defineRouter, procedure, createEventStream, tracked } = require('../../index.js'); +const { waitFor } = require('../helpers/wait.js'); const JSON_HEADERS = { 'Content-Type': 'application/json' }; @@ -59,6 +62,8 @@ const router = defineRouter({ protocol: context.meta.protocol, hasHeaders: Object.keys(context.meta.headers).length > 0, appVersion: context.meta.headers['x-app-version'] ?? null, + offer: context.meta.headers['sec-websocket-protocol'] ?? null, + data: context.meta.data, }), }), whoami: procedure({ @@ -81,6 +86,26 @@ const router = defineRouter({ http: { method: 'POST', path: '/things/:thingId', status: 201 }, handler: async (_context, { params, query, body }) => ({ thingId: params.thingId, q: query, name: body?.name }), }), + // A handler that tries to write a header value with a line break in it: + // on node a refusal, on uws a split response — unless the seam refuses. + split: procedure({ + access: 'public', + http: { method: 'GET', path: '/split' }, + handler: async (context) => { + context.http.setHeader('X-Note', `ok\r\nX-Injected: yes\r\n\r\n