Skip to content

wrpc v2: protocol revision 2, WebRTC, WebTransport, message brokers, compression & encryption - #5

Merged
Alex-Dolid merged 313 commits into
mainfrom
feature/v2
Oct 4, 2026
Merged

Alex-Dolid merged 313 commits into
mainfrom
feature/v2

Conversation

@Alex-Dolid

@Alex-Dolid Alex-Dolid commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Release branch for @alexify/wrpc 2.0.0. From this release on, the project is presented as wRPC (Web RPC): one router served over WebSocket, HTTP/REST, SSE, WebTransport, WebRTC, worker ports and message brokers. package.json is bumped and the CHANGELOG has ## [2.0.0] - 2026-10-03. The branch is 309 commits across 444 files (~+92.0k / −2.5k lines), mostly new subsystems behind new subpaths, plus their tests, benchmarks and docs.

There are still zero runtime dependencies. Every new integration (WebRTC stacks, HTTP/3 stacks, broker clients) is injected and duck-typed. Every new devDependencies entry is used only by tests or benches.

The wire protocol moves to revision 2 (wrpc.v2), negotiated per connection. A 2.x peer speaks both revisions, so 1.0 clients and servers keep working against 2.x and can be upgraded in any order, with no flag. tests/interop/ runs the published 1.0 (the wrpc-v1 alias) against this tree in both directions.

What's new

Protocol revision 2. Framed messages let bytes travel as bytes. The revision is negotiated per carrier:

  • WebSocket: the subprotocol offer wrpc.v2, wrpc.v1.
  • HTTP: the wrpc-version response header plus the request's Accept.
  • Worker port: v on the first ping/pong.
  • SSE stays on revision 1.

revision is read-only on WrpcClient, the server Client and RpcServer. attachments: false (or a packet codec) means "this end speaks revision 1".

New subpaths

Subpath What
./webrtc (+ browser) Peer-to-peer wRPC over data channels: WrpcPeer, PeerHost, mesh, built-in/pluggable signaling, raw-channel attachChannel, JWS ES256 trust assertions bound to the DTLS fingerprint
./wt (experimental) WebTransport (HTTP/3) server half, DI-only (fromFails, fromQuico). The client transport: 'wt' ships in the base entry
./broker + ./broker/{redis,nats,amqp,kafka} (experimental) Durable feeds (brokerFeed), queue consumers (attachConsumers), createPublisher, RPC over a broker (attachBrokerRpc, transport: 'broker')
./encryption (experimental, + browser) Sealed backplane/cluster/broker envelopes, sealedStore, Noise sessions on WS/WT, HPKE per request on HTTP/SSE, E2EE helpers
./deflate Pure-JS DEFLATE with a preset dictionary, for browsers and synchronous use

Payload and compression

  • Binary attachments: bytes in args, results, events and error details travel as bytes.
  • Per-message compression on every wire, with a negotiated codec preference list (deflate-raw, brotli, zstd).
  • A preset dictionary built from the router.
  • Content-Encoding on HTTP/SSE.
  • permessage-deflate gains context takeover and async compression.

Transports, client and operations

  • Transport fallback on the first connect.
  • SharedWorker support behind the event transport.
  • Injectable fetch.
  • ws handshake headers/meta as real headers from Node and subprotocol tokens from a browser.
  • Fan-out frames encoded once.
  • Backplane gap detection.
  • createRedisSessionStore.
  • REST http.headers / http.cache.
  • Pluggable generateId.
  • Structured logging on the silent paths.
  • OTel metrics and spans, including messaging spans.

Docs: wRPC — Web RPC

  • The name. In prose the project is written wRPC, short for Web RPC. The package @alexify/wrpc, the wrpc CLI, wire names (wrpc.v2, x-wrpc-*, the HKDF labels) and class names are unchanged. package.json#description, og/JSON-LD metadata, the README, llms.txt and the og image are updated to match.
  • Home page. 16 tiles (4×4) and a "One router, every transport" matrix. A consistency guard keeps that matrix equal to docs/guide/client.md#transports.
  • Why page. New sections: one router over every transport, secure in layers, upgrade without a flag day. A sourced comparison with tRPC, Socket.IO, gRPC/Connect, wRPC by the Bytecode Alliance and webrpc. The Bytecode Alliance project shares the name but is unrelated; a "Which wRPC?" note says so.
  • Every transport, everywhere. Getting started, the protocol/wire-format/engine references and ~20 guides now list all seven transports; most listed three or four.

Benchmarks: WebTransport and WebRTC, end to end

  • What runs. bench/transports.js, four new rows in bench/rpc-comparison.js, and pnpm bench:browser transports (Chrome). They use real stacks: @fails-components/webtransport (libquiche), node-datachannel (libdatachannel) and Chrome's own WebTransport and WebRTC. Each is measured next to a raw echo over the same stack.
  • Gating. The rows run with WRPC_WT=fails / WRPC_RTC=node-datachannel. Without them they are skipped with the reason, so pnpm bench still passes.
  • Findings (loopback, one machine; written up in docs/guide/performance.md#across-transports):
    • The stack sets the ceiling, not wRPC: calls run at 87–102% of a raw echo over the same stack.
    • From Chrome, a small call made while an upload saturates the connection answers in 9 ms over WebTransport vs 46 ms over a WebSocket.
  • Two bugs the benchmarks surfaced, fixed here:
    • A finished WrpcWritable kept a 'close' listener on its transport for the connection's lifetime. Separately, the server Client kept every download in client.streams, so once a connection had sent maxStreams downloads, every later upload on it was answered 429. rpc.d.ts now narrows streams/getStream() to WrpcReadable.
    • The WT session check read the deprecated datagrams.writable before createWritable(), which printed a warning on every session over @fails-components.

Breaking changes

The full list and a 7-step migration guide are in CHANGELOG → Changed (breaking) / Migrating from 1.0.

  • Binary attachments are on by default between two 2.x ends. Bytes arrive as Uint8Array, not { type: 'Buffer', data }. This is not a wire break for 1.0 peers, because it is negotiated.
  • The ws client no longer puts headers/meta in the connect URL. carrier: 'query' restores the old behaviour.
  • The declared-header deny list grew: forwarded, via, x-forwarded-*, x-real-ip and others.
  • Context.server / Client.server are typed ClientHost | null. This affects TypeScript only; narrow with instanceof RpcServer.
  • attachPort clients are persistent: one long-lived Client per port.
  • cluster.secret envelopes are sequenced, so a 2.0 node ignores a 1.x node. Use cluster: { secret, replay: 'accept' } during a rolling upgrade.
  • An invalid generateId is a TypeError at construction.

Security

About 50 hardening entries, listed in CHANGELOG → Security. Among them:

  • Cluster HMAC replay and transplant protection.
  • Response-header splitting through context.http.setHeader.
  • An event-loop hang on packets with back-references.
  • WebRTC roster impersonation and fingerprint binding.
  • WebTransport memory/stream exhaustion and head-of-line blocking.
  • Bounded inflate.
  • Caps on Accept-Encoding, packet id, streams per connection and signaling rooms.
  • Secrets kept out of logs.

SECURITY.md now covers the cryptography and the new parsers.

Tests, CI, docs

  • Tests: 91 new test files and 9 new tsd files. Shared contract suites (engine, client transports, WT channel, RTC port, broker backplane/log/queue/direct) run over in-repo fakes and, where available, real servers.
  • Interop: tests/interop/ runs the published 1.0 against this tree, both ways.
  • CI:
    • New redis, nats (JetStream), rabbitmq and kafka service jobs.
    • test:ci with --test-force-exit.
    • Per-file coverage floors for the new directories.
    • Node 22/24 matrix.
  • Docs: 15 new pages (8 broker pages, compression, encryption, WebRTC + trust, WebTransport, multiple backends, rate limiting). The protocol reference is at revision 2, with since 2.0 badges and a Changes since 1.0 table.
  • Benches: 18 new scripts under bench/ plus two bench:browser suites. pnpm size budgets cover the new browser-reachable entries; the main browser entry is 27.7 of 28.0 KB.
  • Manual, not in CI: WRPC_RTC=node-datachannel, WRPC_WT=fails|quico, the transport benchmarks, Autobahn, test:perf.

Alex-Dolid and others added 11 commits September 19, 2026 16:56
… and size

The measurement behind the defaults of the algorithm choice that follows:
deflate's knee is level 3, zstd level 1 and Brotli quality 4 are the
per-message picks, zlib's Brotli default (quality 11) is milliseconds, and
a flushed Brotli/zstd stream buys nothing over gzip for SSE while holding
2-3x the memory per open response.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nd their factories

`codec` takes the name of a platform codec beside an injected one; the ids
are CompressionStream's format names, so node:zlib and a browser negotiate
the same one. zstd is detected (node:zlib has it since 22.15 / 23.8), a
format a browser lacks leaves compression off. deflateCompressor,
brotliCompressor and zstdCompressor carry a level, threshold and async.

The defaults are measured (bench/algorithms.js): Brotli quality 4, zstd
level 1, and the platform deflate moves from zlib level 6 to 3 — the knee.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`codec` takes a list; each end announces the ids it can decode and a sender
compresses with the first codec of its OWN list the peer announced. A list
is the fallback (no zstd on the other end: deflate, not plain), the two
directions choose independently, and no frame names its codec. On the
WebTransport caps, the WebRTC description caps, a Node ws client's ping/pong
and the broker's wrpc-enc header; the backplane, which negotiates nothing,
encodes with the head and decodes any codec on the list — a change of codec
is a rollout without a lost envelope.

Wire names that said "deflate" while carrying any codec become neutral
(caps `enc`, KIND_*_COMPRESSED, FRAME_*_COMPRESSED, the COMPRESSED bit);
byte values are unchanged and none of it was released.

@alexify/wrpc/deflate takes its dictionary id from a leaf: 4.5 -> 3.8 KB.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t-Encoding

`http.compression.encodings` and `sse.compression.encodings` are the
server's list of codings in ITS order of preference; the first one the
request's Accept-Encoding admits is used (q=0 refuses, * covers the
unnamed). Default ['gzip'], so nothing changes until it is set. Built-ins
carry measured levels — Brotli quality 4, zstd level 1, never zlib's
quality 11 — zstd is detected rather than assumed, and a custom coding
`{ encoding, encode, createStream? }` may answer a promise; one that fails
answers the plain body. SSE takes the same list, flushed per event, and
refuses a coding that cannot stream; gzip stays the recommendation there
(bench/algorithms.js). `level`/`memLevel` move into the gzip entry.

The Accept-Encoding scan stays one pass: 2x the split-and-map spelling
(bench/http-compression.js).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… follows

The compression guide gets the measured comparison (deflate 3/6, Brotli
4/11, zstd 1 over 361 B - 255 KB, bench/algorithms.js) and the conclusions:
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, permessage-deflate fixed
by RFC 7692, and an injected LZ4 as the worked example of the seam. Rates
quoted elsewhere are re-measured at the new default level; CLAUDE.md and
the README describe the list negotiation and the encodings list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ffer

Under `protocols: []` the client offered `wrpc.bearer.<token>` alone. The
server never echoes a carrier token, and a client fails a handshake whose
every offer went unanswered (Chrome closes 1006, undici errors), so a
connection presenting a stored token could not open at all. With nothing
offered the credential now stays in the query carrier, as the escape hatch
already implied.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…col offers

A browser's WebSocket constructor cannot set a request header and fetch
refuses to perform the upgrade by hand — the one handshake header a page
controls is Sec-WebSocket-Protocol. The server now reads the declared bags
from it: `wrpc.h.<base64url>` and `wrpc.m.<base64url>`, the generalization
of `wrpc.bearer.<token>`.

- src/rpc/handshake.js (Node-only: rpc/meta.js is bundled into the WebRTC
  browser entry and may not touch Buffer): one budget for both offers, one
  sanitizer for the offer and the `wrpc_h` query, a carrier chosen and never
  merged, and `readHandshake(req)` — exported, and the same read
  attachSocket does, so a verifyClient gate stops parsing the query by hand.
- Both negotiators drop carrier tokens from the offer an application selects
  from and refuse to echo one; the tokens leave context.meta.headers too.
- The deny list grows by the names a page could forge next to a victim's
  cookie: x-forwarded-*, x-real-ip, forwarded, via and the CDN spellings.
- The wire names move into src/wire.js (WRPC_PROTOCOL was spelled thrice).

The query stays: `carrier: 'query'` and WebTransport have nothing else.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…n the query

The declared `headers`/`meta` options rode the ws connect URL as `wrpc_h` /
`wrpc_meta` — from Node too, on the belief that the built-in WebSocket
cannot set headers. It can: undici takes `{ protocols, headers }`. A browser
cannot, by any API — `ws` refuses to load there, socket.io drops
`extraHeaders` on its browser ws transport, fetch strips `Upgrade` — so the
page is left with the one handshake header it controls.

- src/client/wsHandshake.js (Node): real request headers, `x-wrpc-meta`
  included, so `metaFormat: 'prefixed'` finally shapes the ws wire; no Bearer
  lift, no query. A constructor that throws on the init bag falls back.
- src/client/wsHandshake.browser.js: `wrpc.h.<b64u>` / `wrpc.m.<b64u>`
  subprotocol tokens under ONE 2048-byte budget (the server's measure; clear
  of uWebSockets.js' 4096-byte header limit), the Bearer lift outside it.
- `carrier: 'auto' | 'protocol' | 'query'`; an empty `protocols` offer means
  the query, since a token needs a protocol the server can answer.

Verified in Chrome 152 against the node and the uws engine: the upgrade URL
is `/api`, the bags and a UTF-8 meta value arrive, forged cookie and
x-forwarded-for are dropped, a verifyClient gate reads them via readHandshake.

BREAKING CHANGE: a new client against a server older than the previous
commit loses its ws labels unless `carrier: 'query'` is set; a Node client's
declared cookie/origin now arrive as the real headers they are.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ehind `./encryption`

Phase 0 of application-level encryption: a new subpath with a `browser`
condition, opt-in and never a substitute for TLS. Nothing in the core reads
it yet; the envelopes, the session handshake and HPKE are built from this.

- `aead()`: 'aes-256-gcm' on both platforms and 'chacha20-poly1305' on Node
  behind one structural `Cipher` contract. Synchronous over node:crypto on
  Node (2.4 µs a 1 KB seal against 14 µs through crypto.subtle on the same
  machine, bench/encryption.js), promise-answering over crypto.subtle in a
  browser with a non-extractable CryptoKey. One `OpenError` for every cause.
- `x25519()`: a platform pair too — subtle's X25519 prints an
  ExperimentalWarning on the early Node 22 releases `engines` admits. A
  low-order public key is refused the same way on both halves.
- `createKdf()`: SHA-256, HMAC and HKDF with Extract and Expand apart.
- `normalizeKeys()`: one key, a ring with rotation, or an injected provider;
  a kid is a closed alphabet held in a Map and selects the key.
- No Math.random fallback: a missing CSPRNG or WebCrypto throws where the
  factory is called.
- `Sequencer` moves to the src/sequencer.js leaf; compression/index.js
  re-exports it.

Vectors: the GCM specification (case 16), RFC 8439 §2.8.2, RFC 5869 (1, 3),
RFC 7748 §6.1. Bundle: 3.1 KB min+gzip, its own budget row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…backplane envelopes

Phase 1. TLS to Redis protects the hop, not what Redis holds: every room
event, presence delta, sendTo payload and fetchClients reply crossed the
backplane as readable JSON. With the option on, the backplane carries
`wrpc-sealed:<kid>:<base64>` under a shared keyring. Off by default.

- src/encryption/envelope.js: u8 version ‖ u8 suite ‖ salt16 ‖ u64 counter ‖
  ct ‖ tag. Never a random nonce — each process derives its own key from the
  keyring key and a salt drawn at boot (hkdfSync) and counts under it,
  reseeding at 2^32: GCM's random-nonce bound is hours away on a busy
  fan-out. AAD binds layer, kid and CHANNEL. A per-sender sliding replay
  window; an entry is remembered only after a tag verified, so made-up salts
  evict nobody. Both built-in suites always open, so a cipher change is
  config, not a rollout. A sealer skips its own echo before any crypto.
- src/rpc/envelope.js: compression and sealing composed in ONE frame
  (compress, then seal, one base64) behind the seam RoomsBackplane and
  Cluster already hold; identical to createEnvelopeCodec when off. The seam
  takes the channel as a second argument.
- The rollout is three deploys, because pub/sub delivers at most once:
  { seal: false, acceptPlaintext: true } → { acceptPlaintext: true } → neither.
  Rotation through keys: { current, ring }.
- Log events: *.open (reason for the log only), *.unsealed, *.sealed — an
  instance with a compression codec and no keys names a sealed envelope too
  instead of dropping it silently.
- Cluster HMAC untouched: sign → seal, open → verify. A sealing cluster
  refuses a forged plaintext command even without `secret`.

~3.5 µs per 1 KB envelope each way (bench/encryption.js). Docs: scaling
guide, protocol reference.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…g readable

Phase 2. The Redis session store kept each session's state as JSON under the
bearer token itself: a keyspace listing was a list of live credentials, a
dump every user's state. `sealedStore(store, { keys })` wraps any structural
SessionStore:

- row key = base64url(HMAC-SHA256(index key, token)) — the token no longer
  rests anywhere; value = { v: 1, kid, s } sealed with the backplane
  envelope's frame (a key per writing process, a counter nonce) under layer
  'store', AAD = the row key, so a row copied into another session's slot
  does not open.
- The sealer gains `echo: true`: a store reads back what it wrote, where a
  backplane skips its own echo.
- Rotation signs nobody out: a miss under the current kid walks the older
  ones and migrates the row; the index key rotates with the rest.
  `acceptPlaintext` is the same move for adopting it over an existing store.
- A row that does not open is a missing session and one warning
  (session.open / session.unsealed / session.migrate); the token is never
  logged. `touch` is forwarded when the wrapped store has one.

Docs: the sessions guide. Verified through SessionManager across two
"instances" over the fake Redis.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
wrpc Ready Ready Preview Oct 4, 2026 10:14am UTC

Alex-Dolid and others added 8 commits September 20, 2026 17:25
…cket, then every frame sealed

Phase 3. For the TLS terminator you do not trust and for ws:// where no
certificate can be had; opt-in, never in place of TLS.

- src/encryption/noise.js: Noise rev 34 under canonical names —
  Noise_{NN,NK,XX,NNpsk0}_25519_{AESGCM,ChaChaPoly}_SHA256 — verified
  against the cacophony vectors (public domain, tests/encryption/vectors)
  on both the node:crypto and the crypto.subtle primitives. CipherState
  takes its counter synchronously, so an asynchronous cipher still seals in
  call order; deterministic REKEY every 2^20 messages.
- session.js: `00 05` handshake frames (the first names the protocol and
  the kid; the header is bound into the prologue with the wire revision and
  the transport kind), `00 06` sealed frames carrying text, chunks and the
  other framed kinds alike — compress, then seal, by construction.
- server.js: `SealedSocket` wraps the ENGINE socket and re-announces
  decrypted messages as an engine would, so attachSocket, the dispatcher,
  compression, attachments and chunks are untouched; one wrapper serves the
  built-in engine and uWebSockets.js. No sendPrepared: a broadcast to sealed
  clients is N seals (bench/encryption.js). A protocol the server does not
  list is refused, never negotiated down.
- The mode is announced by `wrpc_e=1` in the connect URL — the server may
  be the first to send. attachSocket stays synchronous; the session restore
  awaits the handshake and sets `client.encryption`.
- client.js: `createEncryption()` is injected as `options.encryption`; the
  base entry gains a seam only (+0.4 KB). A client that has it never speaks
  plaintext: transports are checked up front, the fallback list included.
  The server key is verified before XX's third message is sent.
- `encryption.required`: sockets close 1008, HTTP answers 426, attach()
  must be told `encrypted`. `rpc.encryptionKey()` publishes the bundle a
  client pins; static keys derive from one secret per kid, one pair per
  protocol.
- statics.js, types, protocol reference, bench rows (3.6 µs to seal 1 KB,
  0.6 ms a handshake). tests/websocket/protocolClient.js keeps its query.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Phase 4. The `wt` client transport carries `options.encryption`; the server
needed nothing new — attachSession hands its WtSocket to attachSocket, which
wraps it as it wraps a WebSocket, and a SealedSocket offering neither
`streamControl` nor `sendUnreliable` is what turns the server's mux and
datagrams off.

- The handshake runs over the control stream after the capabilities
  exchange, with `wt` bound into the prologue: a handshake from one kind of
  connection does not finish on the other (tested).
- Under it everything rides the control stream sealed: StreamMux is off (as
  under a codec — no `streams` capability, chunks on the control stream), no
  datagram writer or reader (an `unreliable` event goes reliably), and the
  carrier's per-message compression is forced off — ciphertext does not
  compress. Each would otherwise be a way around the channel.
- `#attach` returns the handshake promise, so open() resolves only once the
  session is sealed; `encryption` is cleared on close.

Main browser entry: 24.2 KB measured (budget 25).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Phase 5. A request is not a connection, so the http transport seals each one
on its own: HPKE (RFC 9180) to the pinned server key, the answer under a key
both ends export from that same context — the Oblivious HTTP construction
(RFC 9458 §4) without its relay.

- hpke.js: base and psk modes over DHKEM(X25519, HKDF-SHA256), AES-256-GCM
  or ChaCha20Poly1305, verified against the RFC 9180 vectors on both
  primitive halves. `dhKem`/`isKem`: the KEM is a structural seam, which is
  where ML-KEM or a hybrid is injected.
- http.js / httpServer.js: the REAL request is inside (method, URL, headers,
  the sender's clock, body); an observer sees POST <endpoint> and a 200. On
  the server it is one block in handleHttpCall — the call is unwrapped
  before routing and carries on as an ordinary one marked `encrypted`, so
  packets, batches and mapped REST routes ride it unchanged. On the client
  it is a wrapped fetch that answers a real Response; a response that is not
  sealed is an error whatever its status.
- Freshness, which HPKE does not give: `t` within `maxSkew` and an `enc`
  accepted once (in process by default, `replay: { seen }` injectable).
  Refusals are bare statuses — 400, 409, 426 — with the reason in the log.
- Set-Cookie stays on the outer response, so cookie sessions keep working.
- Discovery at GET <basePath>/encryption-key (`fetchServerKey`), trust on
  first use; `discovery: false` removes it.
- Hosts: the fastify adapter registers the sealed content type (else 415)
  and the discovery route; verified over all five boots.
- hkdf is now a platform pair: node:crypto HMACs on Node. A sealed request
  is 219 µs for both ends against 565 over crypto.subtle, a Noise handshake
  291 µs against 745 (bench/encryption.js).

Deferred: a P-256 DHKEM (its static key cannot be derived from a seed in
WebCrypto); the contract takes one by injection.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Phase 6. The sse client transport carries `options.encryption` through the
per-request 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 sealed frame by frame.

- httpServer.js: `unwrap` gives the inner call a `stream()` when the request
  asked for an event stream. Its writer seals every chunk of the real stream
  — events, the `ready` frame that hands out the channel id, the heartbeat —
  as `data: <base64>` under a counter nonce, with the key exported from the
  request that opened THIS stream. Sealed at the writer, so src/sse/server.js
  is untouched: the replay ring, retention and resume() work as they did, and
  a re-attach replays under a new key, from a counter of zero.
- http.js: `openSealedStream` turns the outer events back into the real
  stream's bytes for an SseParser that never knew; a dropped, reordered or
  altered frame errors the stream, and the client re-attaches as after any
  drop. The type stays `text/event-stream` (`; wrpc-sealed=1` — a parameter,
  the one response header a cross-origin page can always read).
- No HTTP content coding inside a sealed answer or stream: the outer
  Accept-Encoding no longer reaches the inner request.

This replaces the plan's "nonce = event id" design: a key per stream needs no
channel-key persistence on either end. `encryption.required` now holds on
every client↔server transport: ws, wt, http, sse.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…er binding

Phase 7. A broker keeps what it carries for its whole retention: until now
that was every RPC packet, every published event, and the bearer token a
client's headers carried — `authorization` rode as a plaintext broker header
on every hello and every stateless request.

- src/broker/sealing.js: the keyring envelopes of the rooms backplane, with
  the HEADERS sealed together with the body. The outer message keeps
  `wrpc-sealed: <kid>` and what a carrier routes by.
- Broker RPC (attachBrokerRpc + the `broker` client transport): every frame
  bound to address ‖ kind ‖ correlation id ‖ seq — the readable `wrpc-kind`
  and `wrpc-seq` cannot be rewritten, a frame does not open in another
  conversation, a per-sender window drops a replay. Compress, then seal. A
  sealed binding is what `encryption.required` accepts; an unsealed hello is
  answered `bye: encryption required`.
- Events (createPublisher, brokerFeed, attachConsumers): bound to the topic,
  carried as base64 text because a log is only promised to keep a string;
  no replay window — a log is re-read, a delivery redelivered. The partition
  key stays the broker's to read.
- A message that does not open is dropped (RPC), skipped (feed) or
  dead-lettered with 400 (consumer) — logged with its reason, never
  answered. Same three-deploy rollout as `rooms.encryption`.
- The client's `encryption` option takes the keyring form for a transport
  that declares `static encrypts = 'keys'`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Phase 8a. 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 (`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`, with
compression still composing — and members on every instance receive bytes.
It is what a relayed end-to-end payload needs: opaque bytes, room-wide,
cluster-wide.

- src/rpc/envelope.js: `createEnvelope` always answers an object now
  (`withBytes`): identity for text with no option on, `encodeBytes` for a
  binary envelope, and `decode` may answer the envelope object, which
  rooms.js already tolerated. An instance with no codec still answers null
  for a `wrpc-enc:` message — the loud `*.encoded` warning stays.
- rooms.js: Broadcast.emit passes `binary: true` instead of refusing;
  RoomsBackplane.publish feature-detects `encodeBytes`, so a registry wired
  by hand without the envelope still names the loss.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…createOpener

Phase 8b. 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
commit, across the backplane. Tested through a relaying server on two
instances.

- With `senderKey` / `senderPublicKey` it is HPKE auth mode: the recipient
  learns which identity sealed the message, and one from anybody else — or
  from nobody in particular — does not open. `info` says what the messages
  are for, and one sealed for one purpose does not open for another.
- hpke.js: the auth and auth_psk modes (AuthEncap/AuthDecap); the RFC 9180
  fixture now covers all four modes, on both primitive halves.
- An identity is a seed: keys derive from it, because a generated subtle key
  is non-extractable and could not be kept.
- Said plainly in the types: not a messaging protocol — no forward secrecy
  for the recipient, no group key, no replay memory (the sender-key helper of
  the plan was cut; Double Ratchet or MLS ride the same bytes) — and in a
  browser, no defence against the origin that ships the script.

`./encryption` browser entry: 10.8 KB (budget 12).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…uilt from, what it costs

Phase 9. One home for the whole story, docs/guide/encryption.md (Operations):

- "Do you need it?" — a table of where TLS ends before the data does, the
  knob for each row, and the row that says you do not need any of it.
- What it is built from (platform crypto under standard names, checked
  against the published vectors) and what is left to injection: other
  ciphers, post-quantum KEMs, KMS keys; Double Ratchet/MLS ride the bytes.
- Keys and rotation; the backplane, the brokers, sessions at rest; session
  encryption per transport with its patterns, `required`, channel binding
  through the handshake hash, key discovery as trust on first use.
- What it costs, from bench/encryption.js — including the N seals of a
  broadcast — and the compress-then-encrypt warning.
- End-to-end helpers with what they are NOT, and what nothing here
  protects: metadata, a compromised end, replay across processes, a browser
  from its own origin.

Linked from the security page (which no longer says "it does not encrypt"
without saying where the opt-in is), production, codec (a codec is not the
way to encrypt), compression, scaling and sessions; README feature and
subpath tables. Checked in the browser at desktop and mobile width.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Alex-Dolid and others added 8 commits September 26, 2026 19:29
… for members; stale leaves ignored

PeerHost.attach spread what a peer said about itself at join OVER `peer`
and `room`, so `data: { peer: 'alice' }` ran every handler on the other
peers as alice and could plant `claims` where no assertion was verified.
The facts are now written over the roster data, the signaling unit refuses
a join whose data names one of the three (400), and `members` answers a
member of the room only (403) — a roster could be enumerated, and a
cluster-wide fetch triggered, by anyone who knew the room's name.

A leave naming another incarnation of a member (the old tab's goodbye,
landing after the new tab was linked) dropped the fresh link in Mesh and
its address in wrpcSignaler; both now check what the leave names. Peer
ids are bounded to 256 characters on the client half too, through the
shared src/webrtc/ids.js.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ragment count is capped

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 byte cap was met only after 16 million
views. A fragment that is not the last must now carry a byte (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. Documented in protocol.md#webrtc-framing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
negotiateMessageSize honoured any a=max-message-size above one byte, so a
peer advertising 2 made the other side send (and itself buffer a view
for) a fragment per byte of every packet. Under MIN_NEGOTIABLE_SIZE (1 KiB)
the size is now a FramingError ('message-size') that RtcLink#onOpen turns
into a link failure before 'connected'; the advertised size is floored to
a whole byte. The interop fallback for 0 / 1 / Infinity / nothing stays.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the first

DTLS binds to the media-level fingerprint (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 handshake was with the relay. sdpFingerprint now
answers a fingerprint only when every a=fingerprint line, 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, and stamp()
refuses an ambiguous local description. The unreleased `algorithm`
parameter is gone. FakeSignalHub gained tamper(from, to, fn) — a relay
that lies — for the test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…N's TypeError, not a hang

The walk that finds bytes in a packet went through every enumerable field
of every object, toJSON() or not — a domain object projecting a Buffer
away in toJSON() had it sent anyway, as an attachment. An object with a
toJSON() is opaque to the walk now (its projection travels, never called
by the walk), and the frame skips inherited keys like JSON does.

hasBytes kept no memory of what it 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 hands over to one with an ancestor
stack the moment it reaches the cap; a cycle answers false and the packet
goes to JSON.stringify's own "circular structure" TypeError, which
encodeAttachments throws too. Its path bookkeeping is one mutable path
copied at each leaf: 414 → 137 µs on rows(400) + 1 KB, 1.57 → 1.11 µs on
a tiny event (bench/attachments.js, new rows).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…what it cannot forward

A call with bytes from a page reached WrpcClientProxy as a frame it
refused as "not JSON" — an unhandled rejection in the worker, a call that
waited out its timeout on the page — and an answer with bytes from the
server was broadcast to every tab instead of the one that asked. The proxy
routes a frame by the packet inside it now, forwards the page's frames
upstream as they are, and answers a call it could not forward with a coded
error. failPackets reads a frame too, so a call with bytes that failed on
the http/sse leg no longer waits out its callTimeout.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…n all-zero key

createEnvelopeSealer wiped the derived subkey right after cipher.key(subkey)
— right for the built-in ciphers, which copy it into a KeyObject, and wrong
for one that keeps the reference, which is the shape the guide shows: every
envelope on rooms/cluster/brokers/sealedStore was sealed under 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); the sealer wipes
only what a cipher it built was handed, decided by how the cipher arrived
and never by its id. The admission probe is a round trip under a random
key, so a cipher that cannot open its own seal fails at boot. The guide
stops suggesting an injected cipher for a Noise session, whose server half
takes the two built-in names.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…id not open; secure() gains cancel()

With a cipher over crypto.subtle the sealed frames 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. 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 refused WebTransport handshake waited out handshakeTimeout: the server
hung up, the session closed, and nothing told the handshake. secure()
answers cancel(error) now — the ws transport calls it on close/terminate,
the WebTransport client in its teardown — and a pending ready rejects the
moment the connection does. The handshake timer is unref'ed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Alex-Dolid and others added 4 commits October 3, 2026 15:22
A re-open waits a fully jittered delay — from 0 to 200 ms — so polling
`healthy === false` after a cancel could miss a re-open that took a
millisecond: two tests failed that way under load. Channels are refused
while the test asserts the down state, then allowed, and the recovery is
asserted as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ation block

The guard asked every `### Changed (breaking)` of [Unreleased] for a
`#### Migrating from` block, while allowing the section to be empty — so
the fresh, empty section a release opens could never pass. The checklist
asks it of a non-empty section, and so does the guard now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…channels close before the goodbye

The goodbye goes through the signaling server, the channel resets peer to
peer: the other side's link failed first, its redial knocked, and the side
that closed dialled back on the knock — the member was in `peers` again
with a new `join`, the application's close undone (reproduced with the
goodbye 200 ms late). The side that said goodbye now ignores a knock or an
offer from that incarnation for 5 s (`rtc.signal.goodbye`, debug); the
goodbye ends the other side's redial meanwhile, and `peer.connect(id)`
from the closing side lifts the hold. The RES-51 test counted that one
harmless knock as a dial and failed under load: it now holds the closing
side to no dial at all, and the other side to none once it heard the
goodbye.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`[Unreleased]` becomes `[2.0.0] - 2026-10-03` under a fresh, empty
`[Unreleased]`; package.json and the docs nav read 2.0.0; the bundle-size
tables of the README and the browser guide are the `pnpm size` of this
commit. Not tagged, not published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Alex-Dolid and others added 2 commits October 3, 2026 15:55
… types

query.d.ts imported the Node barrel (index.d.ts) for seven names client.d.ts
declares, so a browser-only TypeScript project without @types/node got ~40
errors (`node:http`, `Buffer`) for importing it — in 1.0 too. It imports
client.d.ts now. A guard type-checks every browser-reachable entry — the
platform 'browser' rows of scripts/size.js — with `types: []` and the DOM
lib; it fails on the old import. Recorded under 2.0.0, which is not tagged
or published yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts an unref'd timer; two flaky waits fixed

Node 22 (CI): 29 tests cancelled with "Promise resolution is still pending
but the event loop has already resolved" in tests/wt/client.test.js,
tests/wt/socket.test.js and tests/broker/rpc.test.js — since before 2.0's
review, on 22.10 and 22.23 alike. The close and idle timers are unref'd on
purpose, and over the fakes nothing else holds the loop, so Node 22's
runner cancelled the rest of each file; Node 24's waits, which is why a
local run never showed it. `keepAlive()` in tests/helpers/wait.js holds the
loop for such a file and lets it go in `after`.

Both versions (CI): the adapter spec's rooms test slept 25 ms before a
positive assertion and the second member's frame did not always arrive on
a loaded runner — it waits for it now (the drained `typed` result is kept
by the poll that read it). tail.test.js required four fully jittered
backoffs to sum past 300 ms, which fails about one run in 300 (CI: 260);
50 ms fails about one in 400 000 and still tells a backoff from none.

Node 22.23 and Node 24 (test:ci, floors, types): 3015 tests, 0 failed,
0 cancelled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Alex-Dolid Alex-Dolid changed the title wrpc v2: wrpc v2: protocol revision 2, WebRTC, WebTransport, message brokers, compression & encryption Oct 3, 2026
Alex-Dolid and others added 11 commits October 3, 2026 17:32
2.0 serves one router over WebSocket, HTTP, SSE, WebTransport, WebRTC, a
worker port and a broker, and every place the project describes itself
still said "WebSocket RPC". The name in prose is wRPC, short for Web RPC;
the package, the CLI and every wire name keep their spelling.

- package.json description, og title/description, JSON-LD alternateName,
  the site's titleTemplate and the "Why wRPC?" sidebar entry
- the home hero: "Fast, zero-dependency Web RPC" and every transport in
  the tagline
- README header and opening paragraph
- llms.txt: the name, a not-to-be-confused-with note (Bytecode Alliance's
  wRPC, webrpc), every transport, and the pages it was missing (brokers,
  WebRTC, WebTransport, codec, compression, encryption, rate limiting,
  multiple backends, REST, auth, metadata, stability); "Frozen at 1.0"
  was stale since revision 2
- the og image, regenerated by scripts/brand.js
- CLAUDE.md's first sentence, with the spelling rule

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The home page had ten tiles, all about what 1.0 did, laid out 4/4/2. It
now has sixteen in four rows of four, each row a story: the reach
(WebTransport, WebRTC, brokers, hosts), the RPC core (router,
subscriptions, bytes as bytes — binary attachments joined the streams
tile — REST), scale and security (rooms/cluster, sessions, encryption,
compression), and the platform (zero dependencies, types, Node and the
browser, upgrade in any order).

Under the tiles, "One router, every transport": what each carrier is for
and what it carries. It is the short copy of the client guide's matrix,
which grew the rows it lacked (webrtc, broker stateless/session, event's
selector) and three columns (bytes in a packet, compression, encryption),
and a guard keeps the two tables' shared columns equal both ways. The
`transport` option row named three of the six transports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every number the docs had for WebTransport and WebRTC was a microbenchmark
of a code path (framing, fan-out, compression). This measures the whole
thing on real stacks, next to the WebSocket on the same machine:

- bench/support/real-stacks.js — the injections the integration tests
  make (@fails-components/webtransport: libquiche for the HTTP/3 host and
  the Node client; node-datachannel: libdatachannel for a loopback pair),
  gated the same way (WRPC_WT=fails, WRPC_RTC=node-datachannel). Without
  the variable, or with an expired certificate, a row says why and the
  run still exits 0 — run-all.js counts any other exit as broken.
- bench/rpc-comparison.js grows four rows: wrpc over each stack, and a
  raw echo over the same stack with no RPC layer, so wrpc's share and the
  stack's are told apart (as the raw ws/uws rows do for the WebSocket).
- bench/transports.js (+ support/transport-worker.js, transport-api.js):
  open + load + first call (and, for WebRTC, the pair's own ICE/DTLS
  alone), a 16 MiB stream round trip next to the same bytes over the bare
  stack, and a small call's latency idle and while uploads keep the
  connection busy. One process per transport.
- pnpm bench:browser transports (bench/browser/transports.js): the same
  from Chrome — its WebSocket and WebTransport to a Node Server, and a
  data channel between two RTCPeerConnections in the page; the page is
  served from http://127.0.0.1 because WebTransport needs a secure
  context. Its pair buffers ICE candidates until a remote description is
  set: dropping them hung the run now and then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new section with the numbers bench/transports.js, the new rows of
bench/rpc-comparison.js and `pnpm bench:browser transports` produce, and
how to read them:

- on every transport the stack sets the ceiling, not wrpc: calls at
  87–102% of a raw echo over the same libquiche session or data channel,
  streams at 84–90% of the bare stack;
- in Node both native stacks are far behind the WebSocket on loopback —
  use them for what only they do;
- 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;
- what loopback cannot show (packet loss, network round trips) is said,
  not guessed.

The rows sit in a table of their own with a WebSocket row from the same
run, rather than in "Against other stacks", whose rows come from an
earlier run and are only comparable with each other. The short version,
the WebTransport and WebRTC guides ("What it costs") and llms.txt point
at it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rences

The first page a reader meets said "WebSocket-based" and "one connection
carries every feature", its diagram drew ws · http · sse · event and a
Redis-or-memory backplane, and its "where to go next" reached no
transport but SSE. The protocol reference's first paragraph named three
carriers of seven.

- getting-started: the intro, a diagram with the HTTP/3 host, the data
  channel and the broker binding next to the engine, the paths the
  Server answers on (SSE included), and next steps for transports,
  WebTransport, WebRTC, brokers, compression, encryption and production
- reference/protocol: every carrier in the intro and in introspection's
  "reachable over every transport"
- reference/wire-format: the data-channel and WebTransport frames it
  already documents, named up front
- reference/engine and guide/server: attach(transport) is how WebTransport,
  WebRTC and the broker binding reach an RpcServer without an engine

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The guides were written while WebSocket, HTTP, SSE and a worker port were
the whole list, and the places that enumerate transports kept that list:

- client: which transports run the heartbeat (wt, webrtc, a broker
  session too; the worker proxy keeps it with the server)
- browser: a `wt` row in "Transports in a browser"
- cluster: a client descriptor's `transport` is one of the kinds that
  hold a connection — `http` never was, wt/webrtc/broker were missing
- codec: its scope covers WebTransport, data channels and the broker
- rooms: `{ compress: false }` works on every wire with per-message
  compression, not only permessage-deflate
- hooks, rate-limiting: onRequest covers a worker port, the broker
  binding and queue consumers; the framework-limiter table has their rows
- sse: bytes and streams want "a WebSocket, WebTransport or a data
  channel", and "the only functional difference" followed two of them
- telemetry: one trace also covers a WebRTC link and a broker message
- scaling: a broker's `backplane` capability is an adapter; sticky
  routing rows for WebRTC and the broker binding
- metadata: carriers for wt (the query), the broker (message headers)
  and webrtc (what the attaching side declares)
- production: WebTransport is UDP behind the proxy, compression on every
  wire, encryption and WebTransport in the deploy checklist
- errors: a WebTransport session ends with the same close codes
- rest, router, consumers, sessions: "over WebSocket" where any
  transport was meant

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The product is written wRPC in prose — short for Web RPC — across the
guides and the references (189 places in 41 pages). What is not prose
keeps its spelling: the package `@alexify/wrpc`, the `wrpc types` CLI,
every wire name (`wrpc.v2`, `wrpc-version`, `x-wrpc-*`, `wrpc_h`), the
HKDF and Noise labels ("wrpc rooms v1", "wrpc cluster v1"), class names,
URLs, and everything in code. Headings keep their anchors — VitePress
lowercases a slug.

Done by a prose-only pass (code fences, inline code, link targets and
URLs skipped) and read through by hand; the three places where inline
code spans a line break — a CLI command and two HKDF labels — were put
back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…neighbours

The why page argued for a WebSocket RPC library: "what you get on one
connection", a comparison with tRPC and Socket.IO that said tRPC has "a
few" runtime dependencies (it has none), and "if you need a broker's
guarantees, use a broker" in a release that ships brokers.

- New sections: one router, every transport (with a diagram); what you
  get on whichever transport (transports, brokers, bytes, compression,
  encryption, protocol rows); a call that does not wait behind an upload
  in "fast where it counts" (the numbers of performance#across-transports);
  secure in layers; upgrade without a flag day.
- Against the alternatives, in two tables: realtime frameworks (tRPC v11,
  Socket.IO v4) and schema-first RPC (gRPC/Connect, the Bytecode
  Alliance's wRPC, webrpc), every cell checked against the project's own
  docs, source or npm entry in October 2026, with the sources listed and
  "—" meaning "documents nothing of the kind". A "Which wRPC?" note says
  the two wRPCs (and webrpc) are unrelated projects.
- When not to use: where a polyglot stack should go instead, delivery
  guarantees now that brokers exist, audio/video, the experimental
  subpaths.
- README: the same comparison in one compact six-column table with the
  "Which wRPC?" line, "When NOT" in step with the why page (its REST
  bullet was the stale 1.0 one), and the documentation list with the
  pages it lacked (brokers, multiple backends, WebRTC trust, rate
  limiting, compression, encryption, stability).
- custom.css: comparison columns get a floor and the table scrolls inside
  itself, instead of a word per line in the column that matters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two entries under 2.0.0's Changed: the docs present the project as wRPC
(Web RPC) — name in prose, home tiles and transport table, the why page's
six-project comparison, every transport list — and WebTransport and
WebRTC are benchmarked end to end on real stacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…able where createWritable exists

isWtDatagrams — the session check attachSession runs — evaluated
`isWritable(value.writable) || typeof value.createWritable === 'function'`,
so it read the legacy getter first, and @fails-components/webtransport logs
"datagrams.writable is deprecated" on that read (and creates a writable for
it). The factory is tested first now, as datagramWriter already did; a host
with only `writable` validates and sends as before.

A test traps the getter (records, then throws) on a session that has
createWritable and drives isWtDatagrams, isWtSession and a WtSocket datagram
through it; it fails on the old order. A second pins the legacy-only path
and datagramWriter's fallback when the factory throws, which had no test.
fails.integration.test.js printed the warning 3 times before, 0 after.
Recorded under 2.0.0, which is not tagged or published yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r downloads no longer count against maxStreams

Every WrpcWritable armed transport.once('close') in its constructor (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. On a WrpcClient the Emitter
warned from the 11th — `node bench/support/transport-worker.js ws` (60
sequential 16 MiB uploads) printed 52 MaxListenersExceededWarning lines,
62 listeners at the end; 0 after. The listener now comes off once the
stream is finished: ended or terminated, and no 'drain' still owed to a
producer — which the close listener stays to release if the transport
dies first. An open stream notices the disconnect as before; a finished
one stays `closed: false` (client.d.ts says so).

The server-side Client.createStream() also kept every writable in
`client.streams` and never removed it. That map is what the 2.0
`maxStreams` cap counts, so past `maxStreams` downloads every later upload
on the connection was answered 429. Writables are no longer registered:
the map holds what the peer announced (getStream, chunk routing, the cap),
and rpc.d.ts narrows `streams`/`getStream()` to WrpcReadable. Nothing is
lost on disconnect — destroy() runs from the transport's 'close', where
terminating a writable only sent a packet into a dead socket.

WrpcReadable waits on its own events only and had no such listener.

tests/streams.lifecycle.test.js: 12 streams on one transport (past the
default cap) — end/terminate detach, an open stream still sees the close,
both drain-still-owed paths, a transport without off(), the server Client,
and a real client<->server run checking listener counts, streams.size and
the 429 lockout; 5 of 8 fail without the fix, each source change needed on
its own. browser entry +36 B min+gzip (27.7 of 28.0 KB). Recorded under
2.0.0, which is not tagged or published yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e backoff

CI's Node 22 job failed "authenticate: a failing hook walks the backoff":
the delays came out [10, 20, 10, 20, 40] instead of [10, 20, 40].

A hook that throws synchronously runs inside the transport's 'open' emit,
where the ws transport still holds #opening — established() clears it
after the emit — so the terminate() the failure forces takes the
"connect still in flight" branch: it starts a close handshake and reports
'close' only when the server answers. Meanwhile the 'open' handler had
armed the stability timer (reconnect.stableAfter, defaulting to minDelay
= 10 ms); on a slow runner it fired first and zeroed the attempt count the
failure path had just restored, so the next reconnect started over at
minDelay. With a close that is always late the backoff never grows and
`retries` never exhaust.

A connection that fails its credential or its restore never proved
itself: both failure paths now stop the stability window (#unproven)
before terminate(), so the count survives however late the close lands.

The new test drives a transport whose forced close always arrives 40 ms
late, so the race does not depend on the runner: without the fix it reads
[10, 10, 10, 10]. Full test:ci passes on Node 22.23.1 and 24; the browser
entry stays 27.7 of 28.0 KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Alex-Dolid and others added 3 commits October 3, 2026 21:47
Two neighbours the comparison did not have:

- bench/rpc-comparison.js: gRPC through @grpc/grpc-js — a unary echo over
  HTTP/2, the service loaded from bench/support/echo.proto at runtime by
  @grpc/proto-loader, so no codegen step. Protobuf on the wire; the docs
  say what that does to its 10 KB column.
- bench/http-comparison.js (+ support/http-servers.js, http-server-worker.js):
  wRPC's two HTTP paths — a declared REST route and packet mode — next to
  fastify, express, tRPC's standalone adapter and bare node:http, each
  answering the same JSON echo, loaded by autocannon with
  fastify/benchmarks' settings (100 connections, 10 pipelined), one server
  process per stack, a warm-up that is not counted, and a run that fails
  on any non-2xx or error. WRPC_BENCH_DURATION sets the seconds per stack.

autocannon, @grpc/grpc-js and @grpc/proto-loader are devDependencies used
only here; wrpc stays zero-dependency. protobufjs (via proto-loader) is
denied its install script in pnpm-workspace.yaml: it only checks its own
CLI's optional dependencies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ours

Every comparison on the performance page now opens with a bar chart, the
way fastify.dev/benchmarks shows its numbers: one bar per stack, wRPC in
the brand green and the rest in gray (the colours checked for contrast and
colour-blind separation on both themes), best first, the value at the bar,
a metric switch, a hover/focus tooltip against the default wRPC row. The
home page shows the RPC-framework chart under "Fast where it counts".

- BenchChart.vue: no chart library; registered before the theme's SSR
  guard, so the charts are in the pre-rendered HTML; the label moves above
  its bar on a phone.
- benchmarks.json holds the charted numbers; a guard refuses a charted
  value its page does not print, and a <BenchChart> naming a dataset or a
  picked row that does not exist.
- performance: "Against other stacks" re-run as one run with gRPC in it
  (the WebTransport/WebRTC call rows come from the same run now), its
  reading rewritten to that run — socket.io is level on single small calls,
  batching (not the engine) closes the pipelined gap; a new "Over HTTP"
  section: wRPC's HTTP paths answer 0.58× fastify and 0.77× express on a
  JSON echo, and why; the short version, fan-out and the three transport
  tables get charts; the ratios derived from the old run are recomputed
  on the transport pages and the why page.
- Four sentence-final "wrpc." the prose pass skipped (it protected
  `wrpc.v2`) now read wRPC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bench/http-comparison.js had wRPC's two HTTP paths at ~35,000 requests a
second, 0.58x fastify and 0.77x express. A CPU profile under that load put
a fifth of the server 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. They now answer ~61,000: 0.9x
fastify, 1.2x express (62,236 / 60,927 against 69,187 / 50,567).

Ranked by what bench/http-call.js measures (new: handleHttpCall driven
with a stub where the socket would be — the core went 85K -> ~320K/s):

- dispatcher: a call is released the moment its answer is written, and
  Client#destroy builds the disconnect reason only when something is
  still in flight: 85K -> 235K on a declared REST route.
- buildMeta: meta.headers is a spread whose prototype is dropped after —
  a `__proto__: null` literal is a dictionary-mode object, alive for the
  whole request: 256K -> 305K. Still null-prototyped, frozen, a copy.
- packet mode parses its body once (handleParsed), not twice: 214K -> 245K.
- the per-client close listener is `on` + a flag, not `once`: ~315 -> ~75 ns.
- receiveBody listens on the request instead of `for await`: 744K ->
  1.09M bodies/s, 5.8% -> 0.9% of the server's CPU. An oversized body goes
  through node's own stream destroyer, which lets go of a server request's
  socket first, so the 400 still arrives; a paused stream is resumed; the
  error listener stays as a sink, as the loop's did.
- a body reaches JSON.parse as text (~100 ns), a path with no '%' skips
  the per-segment decode (383 -> 34 ns), an absent query string skips
  URLSearchParams (58 -> 6 ns).

bench/http-headers.js (new) prices the default headers at about a tenth of
node:http's own ceiling — node validates and writes each one; building
them is ~20 ns, so precomputing the block buys nothing. They stay on.

Visible to a handler: on HTTP, ctx.signal is no longer aborted once the
call has answered (as on a WebSocket); a request that goes away first
still aborts it. Fixed on the way: a cancel arriving while onResponse
hooks run no longer answers the call again with a 499, and an empty
packet POST is a packet.malformed warning instead of an error-level
http.failed (still 500). tests/rpc/http-path.test.js covers all four.

Docs: the Over HTTP table and benchmarks.json re-run together, its reading
rewritten, the home page claim corrected; CHANGELOG under 2.0.0.

pnpm test (3038, 0 fail), lint, format, size, coverage + floors and
docs:build pass; the touched suites pass on Node 22.23.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Alex-Dolid
Alex-Dolid merged commit a71a955 into main Oct 4, 2026
10 checks passed
@Alex-Dolid
Alex-Dolid deleted the feature/v2 branch October 4, 2026 10:50

This branch was successfully deployed

1 active deployment
Preview — 23feef18 Deployed Oct 4, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant