Repository navigation
wrpc v2: protocol revision 2, WebRTC, WebTransport, message brokers, compression & encryption - #5
Merged
Merged
Conversation
… 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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…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>
… 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>
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>
… 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>
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>
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>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Release branch for
@alexify/wrpc2.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.jsonis 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
devDependenciesentry 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 (thewrpc-v1alias) 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:
wrpc.v2, wrpc.v1.wrpc-versionresponse header plus the request'sAccept.von the first ping/pong.revisionis read-only onWrpcClient, the serverClientandRpcServer.attachments: false(or a packet codec) means "this end speaks revision 1".New subpaths
./webrtc(+ browser)WrpcPeer,PeerHost, mesh, built-in/pluggable signaling, raw-channelattachChannel, JWS ES256 trust assertions bound to the DTLS fingerprint./wt(experimental)fromFails,fromQuico). The clienttransport: 'wt'ships in the base entry./broker+./broker/{redis,nats,amqp,kafka}(experimental)brokerFeed), queue consumers (attachConsumers),createPublisher, RPC over a broker (attachBrokerRpc,transport: 'broker')./encryption(experimental, + browser)sealedStore, Noise sessions on WS/WT, HPKE per request on HTTP/SSE, E2EE helpers./deflatePayload and compression
Content-Encodingon HTTP/SSE.Transports, client and operations
eventtransport.fetch.headers/metaas real headers from Node and subprotocol tokens from a browser.createRedisSessionStore.http.headers/http.cache.generateId.Docs: wRPC — Web RPC
@alexify/wrpc, thewrpcCLI, wire names (wrpc.v2,x-wrpc-*, the HKDF labels) and class names are unchanged.package.json#description, og/JSON-LD metadata, the README,llms.txtand the og image are updated to match.docs/guide/client.md#transports.Benchmarks: WebTransport and WebRTC, end to end
bench/transports.js, four new rows inbench/rpc-comparison.js, andpnpm 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.WRPC_WT=fails/WRPC_RTC=node-datachannel. Without them they are skipped with the reason, sopnpm benchstill passes.docs/guide/performance.md#across-transports):WrpcWritablekept a'close'listener on its transport for the connection's lifetime. Separately, the serverClientkept every download inclient.streams, so once a connection had sentmaxStreamsdownloads, every later upload on it was answered429.rpc.d.tsnow narrowsstreams/getStream()toWrpcReadable.datagrams.writablebeforecreateWritable(), 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.
Uint8Array, not{ type: 'Buffer', data }. This is not a wire break for 1.0 peers, because it is negotiated.headers/metain the connect URL.carrier: 'query'restores the old behaviour.forwarded,via,x-forwarded-*,x-real-ipand others.Context.server/Client.serverare typedClientHost | null. This affects TypeScript only; narrow withinstanceof RpcServer.attachPortclients are persistent: one long-livedClientper port.cluster.secretenvelopes are sequenced, so a 2.0 node ignores a 1.x node. Usecluster: { secret, replay: 'accept' }during a rolling upgrade.generateIdis aTypeErrorat construction.Security
About 50 hardening entries, listed in CHANGELOG → Security. Among them:
context.http.setHeader.Accept-Encoding, packetid, streams per connection and signaling rooms.SECURITY.mdnow covers the cryptography and the new parsers.Tests, CI, docs
tests/interop/runs the published 1.0 against this tree, both ways.redis,nats(JetStream),rabbitmqandkafkaservice jobs.test:ciwith--test-force-exit.since 2.0badges and a Changes since 1.0 table.bench/plus twobench:browsersuites.pnpm sizebudgets cover the new browser-reachable entries; the main browser entry is 27.7 of 28.0 KB.WRPC_RTC=node-datachannel,WRPC_WT=fails|quico, the transport benchmarks, Autobahn,test:perf.