wrpc v2: - #5
Open
Alex-Dolid wants to merge 82 commits into
Open
wrpc v2: #5Alex-Dolid wants to merge 82 commits into
Alex-Dolid wants to merge 82 commits into
Conversation
…:http transport.js, rpc/client.js and adapters/common.js only ever wanted STATUS_CODES for a status line, yet the require dragged node:http into every bundle that touches the dispatcher. A frozen in-repo table (kept in sync with node's by tests/status.test.js) removes the blocker that kept the server-side dispatcher out of a browser bundle — groundwork for the peer-to-peer WebRTC transport, where a page runs the dispatcher. Deliberately not in src/runtime/: that pair ships in the main browser entry, whose byte budget has no headroom left. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
publicErrorMessage/publicErrorDetails/wireError were the dispatcher's only reason to require transport.js, which drags the HTTP and WebSocket transports (Buffer, cookies) into any bundle that includes the dispatcher. They are pure functions over the status table, so they now live under src/rpc/ and transport.js re-exports them — its public module.exports is unchanged. tests/rpc/browser-safe.test.js pins the payoff: the dispatcher and the per-connection Client bundle for a browser with no externals, the same self-contained trap scripts/size.js sets for published entries. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
WrpcWritable holds the WrpcClient as its transport, checks write() for false and parks on 'drain' — but WrpcClient.write dropped the transport's boolean and never re-emitted 'drain', so a flow-controlled transport could not slow a producer down. write() now returns the signal and the client re-announces 'drain'; a transport that reports nothing (browser WebSocket) still counts as accepted. client.d.ts declares the boolean and the three ClientTransport members the contract test already required (persistent, heartbeat, terminate). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Client.persistent is Boolean(transport.connection). ServerWsTransport sets it to the socket and ServerSseTransport to itself, but ServerEventTransport set only `port`, so every attachPort client was treated as request/response only: events, subscriptions and streams were refused with 400 and broadcasts skipped it. Set connection = this, as the SSE transport does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the parser attachPort checked Buffer.isBuffer before Uint8Array, and a Buffer IS a Uint8Array — a binary chunk posted as one went to handleMessage and answered 'Packet structure error'. Text is a packet, any byte view is a chunk, anything else a structured clone that is not on the wire. This also removes the last Buffer reference on that path. tests/rpc/port.test.js drives attachPort over a real worker_threads MessageChannel end to end (subscribe, inbound event, Buffer and Uint8Array uploads) — the seam the testing guide documents had no test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A transport picked by name gets the url exactly as written (mapScheme only rewrites ws/http schemes) and the whole connect() options bag in open() — which is how the event transport receives `worker` today and how a peer-to-peer transport will receive its link. Locked by a test before src/webrtc/ exists so the seam cannot drift under it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…sendTo
The cluster's addressed commands carried only join/leave/disconnect;
there was no way to hand ONE event to a client on another instance
short of a room broadcast every node filters. An additive 'event' op
(fields name/data next to the existing rooms — nodes that predate it
ignore it, the envelope version is unchanged) rides the target
instance's own channel exactly like the room ops.
cluster.send(clientId, name, data, { room }) is the raw command;
server.sendTo() adds the local short-cut and reports deliverability
(true = delivered here or handed to the backplane, false = known
undeliverable). `room` travels inside the selector, so a client that
left the room between send and delivery is not selected — a relay
bounded by a membership must not outlive it. Groundwork for the WebRTC
signaling unit, whose SDP/ICE relay is exactly this shape.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… contract src/webrtc/port.js is what the peer-to-peer transport will be written against: a W3C-shaped subset (RtcAdapter -> RtcPeerConnectionLike -> RtcDataChannelLike) checked structurally, in the isEngine/isBackplane style, with no require at all. A browser satisfies it natively; Node satisfies it through whatever the application injects — the core binds to NO Node WebRTC library. createW3cAdapter wraps any W3C-shaped constructor (globalThis.RTCPeerConnection, node-datachannel's polyfill). tests/webrtc/fakeRtc.js is an in-repo fake of the same subset with the state machines the link will lean on: perfect-negotiation rollback, trickle ICE, ICE restart, negotiated channel pairing, the per-message size limit (Chrome closes the channel), bufferedAmount/bufferedamountlow, blob as the default binaryType, and a peer's close() surfacing as 'disconnected'. Two fakes connect by exchanging descriptions whose sdp names the peer, so signaling is exercised for real, never short-cut. tests/webrtc/portContract.js is the shared suite, run on every pnpm test against the fake and, skip-guarded on WRPC_RTC=node-datachannel, against the real thing (node-datachannel is a devDependency used only there — same rule as ioredis). Two assertions were loosened for the real implementation: libdatachannel starts negotiating on createDataChannel (signalingState is already have-local-offer) and keeps signalingState 'stable' after close(), so the contract pins connectionState instead. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…reassembly A data channel caps one message at 16 KiB (interop) to what sctp.maxMessageSize negotiates, and wrpc's batch frames and 64 KiB stream chunks are routinely over it. src/webrtc/framing.js sends every message as binary under a one-byte header — bit 0 KIND (packet / chunk), bit 1 FIN, the rest reserved — and splits at the negotiated size: no message id, no sequence number, because the channel is ordered and reliable and fragments go back to back. negotiateMessageSize honours what the pair agreed (a size UNDER the 16 KiB floor is respected, the floor is only a fallback for no report) and caps at 256 KiB. FrameDecoder answers a single-fragment binary message as a zero-copy view, reassembles the rest with one allocation, and throws a coded FramingError (reserved bits, kind mismatch, invalid UTF-8, reassembly cap) after resetting itself. The encoder reuses one scratch buffer per fragment and encodes text straight into it with encodeInto — bench/rtc-framing.js measures a fresh Uint8Array per fragment at 3-7x less throughput and encode()-then-copy at 4x less; the contract (the frame is valid only inside the sink call, RTCDataChannel.send copies) is documented on the method, and the fake channel now copies at send() like a browser so tests would catch a consumer that retains one. docs/reference/protocol.md gains the WebRTC section (two negotiated channels, initiator = smaller id = impolite, the framing rules and its error cases) additively inside the frozen page, plus the framing-table row and the ping/pong note; wire-format.md documents the frame. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… perfect negotiation src/webrtc/link.js is one WebRTC link between two wrpc peers, written against the port (src/webrtc/port.js) so it runs unchanged in a browser and over an injected Node implementation. Roles are a function of the two ids: the peer whose id sorts first is the initiator — it offers, it is the impolite side of perfect negotiation, and it alone restarts ICE on failure, so the two sides never race two restarts. Both negotiated channels (ids configurable, must match on both peers) exist before the first offer; clientChannel is what my client writes, hostChannel what my host reads. Negotiation follows the W3C recipe (makingOffer / ignoreOffer, implicit rollback on the polite side, candidates buffered until a remote description, addIceCandidate errors ignored for an ignored offer), with two allowances the port contract taught about libdatachannel: the initiator offers explicitly rather than trusting negotiationneeded, and signalingState is never read after close(). An ICE restart keeps the channels; a restart that misses restartTimeout, a channel closing without a goodbye, a connect timeout or a pc reporting 'closed' fails the link, which the owner redials from scratch — and a responder that receives the initiator's redial offer while still 'failed' follows it onto a fresh pc on its own, so only one owner has to act. Every dial promise is settled with its own reason (timeout, failed, closed, re-dialled); background failures are 'error' events, or a log line when nobody listens. The fake gains the spec's negotiation-needed flag (raised by createDataChannel/restartIce, cleared by createOffer, re-checked when stable) so a finished negotiation fires no late event, and brings both transports up before opening any channel so an 'open' handler on either side already sees sctp. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three cuts, none of them a behaviour change, measured on the webrtc browser bundle (39.6 -> 34.0 KB min+gzip): - The ServerTransport base class moves to src/rpc/serverTransport.js; src/transport.js re-exports it, so every require path is unchanged. A transport living in a browser bundle no longer drags the HTTP and WebSocket transports, cookies and CORS in for one 35-line base class. - Client takes the disabled telemetry shape from telemetry/shared.js instead of calling createServerTelemetry(null) — identical result, minus the whole server facade in the bundle. - The session methods refuse with a coded 400 when the host has no session manager (a peer host, a standalone Client) instead of a TypeError from inside a handler, and finalizeSession() drops a session that never came from a store. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
src/webrtc/transport.js — ClientRtcTransport (what my WrpcClient writes on: link.clientChannel) and RtcPeerTransport (what my PeerHost reads: link.hostChannel), both speaking the data-channel framing. A channel is one direction of a link a PeerLink owns, so close() on EITHER half closes the link — goodbye to the peer, no redial — never just its channel, which the other side would read as a failure and redial. Only terminate() is local: the client core's answer to a dead-looking direction is its reconnect cycle, and open() re-attaches to whatever channel the (re)connected link hands out. write() answers the bufferedAmount high-water boolean, 'drain' rides bufferedamountlow, a framing error is the data-channel 1002. Registered as WrpcClient.transport.webrtc (connect() passes the link as options.link). src/webrtc/host.js — PeerHost, the server half of a peer: a router, the dispatcher and one Client per attached transport, composed from the same modules RpcServer uses (Client, handleMessage/handleBinary, RoomRegistry/Broadcast, router introspection) and deliberately not RpcServer itself — sessions, HTTP/REST/SSE and the cluster are dead weight in a page. trust: 'link' (default) stamps a frozen pseudo-session whose token is the peer id, so procedures with the default access 'session' answer; 'none' leaves it null. attach() binds a transport's 'packet'/'chunk' events, runs the router's connection hooks in order and destroys the Client on 'close'. Bundle gate: src/webrtc/browser.js measures 34.0 KB min+gzip, under the plan's 36 KB; the row in scripts/size.js lands with the packaging phase. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Phase 5 of the WebRTC plan. Two halves of one thing:
src/webrtc/signaler.js (browser-safe, requires only utils.js) carries the
structural Signaler / RosterSignaler contract — isSignaler, hasRoster,
isSignalMessage — and wrpcSignaler(client, { unit }), the client half over
any WrpcClient: use({ [unit]: {} }) scaffolds the unit Emitter with no wire
traffic, whoami/join/leave/members are plain calls, send() is the inbound
'<unit>/signal' event, and a client 'reconnect' re-identifies, re-joins
every room and emits 'reset' with the fresh rosters (fire-and-forget, so
the client's reconnect cycle is never held hostage; a close() during the
cycle swallows it).
src/webrtc/signaling.js (Node barrel only) is createSignalingUnit({ name,
access, authorize, relay, prefix }) — whoami, join, leave, members and the
on.signal relay — plus createSignalingHooks() whose onDisconnect announces
the leave of every rtc room the dropped connection was in. A peer's id is
its signaling client id; join data lives on client.data.rtc per room so the
roster comes from cluster.fetchClients alone, local or remote; the relay is
RpcServer.sendTo bounded by a shared room membership (relay 'room') or
open to any connected id (relay 'any'); authorize gates join and signal.
Tests drive it the way a browser would: real WS clients over bootServer
with wrpcSignaler, two RpcServers over a MemoryBackplane for the cross-
instance relay, and a fake WrpcClient for every branch of the client half.
Browser bundle: 34.9 KB min+gzip (gate 36).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Phase 6 of the WebRTC plan: the pieces that turn a router, a signaler and an RTC adapter into a peer other peers can call. src/webrtc/peer.js — WrpcPeer owns the PeerHost (when there is a router), the links, and the signaling listeners (bound at construction: a peer that never called start() still answers). Roles are by id order alone: the lower id is the RtcLink initiator (offers, restarts ICE, redials), the other the polite responder. connect() works from either side — a responder sends the initiator a 'connect' knock (the fourth SignalMessage type, added to the contract in signaler.js), so there is never an offer glare by construction, and a responder recovering from a failed link asks the same way. accept(from, room) gates incoming links; signals for a peer whose accept is pending are queued, a refusal answers with a goodbye. A signaling reset abandons every link WITHOUT a goodbye: the new id would reach the peer as a stranger, or land on the fresh link it is already making. PeerLink is one peer, both directions: `remote` (a WrpcClient over ClientRtcTransport; load/call/respond delegated, `api`), `client` (the host-side Client for the peer, re-attached on every link open; send/ask/ createStream addressed through it), host-side rooms kept across redials. On 'failed' the initiator redials and the responder knocks, with backoff and a retry budget, then closes; the remote WrpcClient's own reconnect cycle waits on the link and resumes subscriptions with lastEventId. src/webrtc/mesh.js — everyone in a signaling room linked to everyone: the roster and later joins go through peer.connect() (either side), each member link's host Client is kept in `mesh:<room>`, so broadcast() and ask() are one Broadcast fan-out; respond() covers members present and future; leave() closes the links no other mesh holds; a reset rebuilds from the rosters the signaler carries. Tests: an in-memory RosterSignaler hub (tests/webrtc/fakeSignalHub.js: mute/drop/reset hooks), both directions of a link, knocks and simultaneous connects, accept, 300 KiB uploads at a 16 KiB message size, ICE failure → redial → client reconnect with a resumed subscription, both give-up paths, close/reset, three-peer meshes with broadcast/ask/respond, shared links across rooms, reset rebuild — and end to end over a real server with createSignalingUnit + wrpcSignaler on WebSocket. Browser bundle: 38.3 KB min+gzip (peer 3.3, mesh 1.1 on top of 34.9); the budget row lands with the packaging phase. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Phase 7 of the WebRTC plan: packaging. webrtc.js / webrtc.browser.js are the root shims (the second subpath with a `browser` condition, after sse); package.json gains the exports entry, the browser-field mapping, the files, and the keywords webrtc / peer-to-peer / data-channel, mirrored in the docs config. The webrtc browser barrel also exports defineRouter, procedure, tracked and createEventLog: a browser peer defines its router with them, and the main browser entry leaves them out for its byte budget (they are already in the webrtc bundle, which dispatches over a Router). Types. A browser peer serves a router, so the router, subscription, session, room, Client and Context types had to be reachable from a node-free file: they moved verbatim from index.d.ts into rpc.d.ts (no rpc.js at runtime — the same pattern as client.d.ts), and index.d.ts re-exports it. The one deliberate change on the way: Context.server and Client.server are typed as the new `ClientHost` contract (router, rooms, getClient, to, except, broadcast) that an RpcServer and a PeerHost both satisfy, instead of RpcServer — narrow with instanceof for the rest. webrtc.browser.d.ts declares the whole peer surface (port, framing, link, transports, PeerHost, the Signaler contracts, WrpcSignaler, WrpcPeer, PeerLink<Api>, Mesh); webrtc.d.ts re-exports it and adds the Node-only createSignalingUnit / createSignalingHooks. client.d.ts registers the `webrtc` transport, the 'webrtc' name and the `link` connect option; ServerTransport gains kind / connection / binary / write / close. Guards: tests/webrtc.test-d.ts; the browser-types fixture now builds a peer under `types: []` and expects the signaling unit to be a compile error there; the barrel-vs-declarations pairs cover both webrtc entries and rpc.d.ts; scripts/size.js carries the two rows — the browser one at a 40 KB budget, measured 38.3 (the peer is a client AND a server: router, dispatcher, rooms, Broadcast, link, framing, peer, mesh, signaler on top of the client core). CHANGELOG opens an [Unreleased] section above 1.0.0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Phase 8 of the WebRTC plan: the integration scenarios the plan listed against what tests/webrtc already covered left two gaps, and closing the first found a bug. A data-channel path can die silently — a NAT that dropped its binding — with ICE none the wiser until consent expires. The app-level heartbeat notices first, and the core answers a timeout with transport.terminate(). ClientRtcTransport.terminate() now asks the link for an ICE restart when the link still reads 'connected': the RTC remedy for exactly that, which either heals the path under the open channels or fails the link, whose owner then redials. That exposed the bug: RtcLink.restart() re-armed its timer on every call, and the client re-opening on the still-'connected' link and timing out again every heartbeat interval re-armed it before it could ever fire — the restart never got to fail. restart() is now a no-op while one is under way. Tests: the fake gains blackhole() (bytes vanish on both ends, no state change), the peer suite drives heartbeat timeout → ICE restart → failed → redial → reconnect end to end, and the upload scenario now streams a full 1 MiB at a 16 KiB message size. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Phase 9 of the WebRTC plan. docs/guide/webrtc.md, in the sidebar after Server-Sent Events: using it in a browser and in Node (an injected W3C-shaped implementation; the core binds to none), how a link works (the sequence diagram, checked in both themes and at mobile width), roles by id order and the connect knock, the framing, symmetric peers and the two event directions, trust: 'link', the built-in signaling unit and the Signaler contract for your own, Mesh, failure and recovery across the three layers, options, what it cannot do, and the bundle size. browser.md gains the swap-table, size-table and transports rows; client.md notes why 'webrtc' is not a fallback candidate; README gains the size rows, the Transports feature, the Exports row and the docs link. examples/syncom-mesh/: a signaling server (any wrpc server with the unit spread in) and one page where every tab is a peer — links and their states, broadcast, ask everyone, a 1 MiB stream to each peer with backpressure, leave on tab close. Verified live in the app's browser with three tabs over real RTCPeerConnections; that run caught the example listening for broadcasts in its router's `on` map instead of on each link's unit emitter, which the guide now spells out. CLAUDE.md: the src/webrtc/ module paragraph (with src/status.js, src/rpc/errors.js and src/rpc/serverTransport.js), the webrtc subpath and rpc.d.ts in the package layout, the WebRTC test helpers, the webrtc entry in the byte-budget note, and the line references that had drifted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A peer answers calls, so it should emit what a server does. PeerHost now
takes the same `telemetry` injection RpcServer takes and builds the same
server-side writer: SERVER spans for the calls, subscriptions and inbound
events it answers (under wrpc.transport 'webrtc'), joined to the calling
peer's CLIENT spans through the packet's traceparent when a propagator is
injected on both, and wrpc.server.connections for its links. Until now it
handed every Client a disabled writer and had no option to do otherwise —
a trace from a peer ended at the link.
Three instruments are the peer layer's own, recorded by PeerLink:
wrpc.rtc.links (open links, by role: +1 when both directions come up, -1
once when they go down, whatever the order of failure and close),
wrpc.rtc.redials (redials and knocks after a failure, by role) and
wrpc.rtc.ice_restarts (by outcome — RtcLink now emits 'restart' with
'requested' / 'recovered' / 'failed'). WrpcPeer({ telemetry }) reaches the
host's writer when there is a router and makes its own otherwise, so a
client-only peer still counts its links.
Tested against the real OpenTelemetry SDK exporting into memory: the two
spans of one call share a trace with the client span as the parent, the
gauges over a failure the redial cycle recovers from, and a goodbye
taking a link out exactly once. Bundle: 40.2 KB min+gzip; the budget goes
40 -> 41 for the server writer (+1.9 KB, all of src/telemetry/server.js).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The level under RtcLink — the event transport's arrangement for WebRTC: the application owns the peer connection, its signaling and its recovery, and wrpc speaks on the RTCDataChannel it is handed. - ClientRtcTransport takes `channel` instead of `link`: a data channel, or a factory the reconnect cycle asks for the next one on every re-open (how an application plugs its own recovery in — subscriptions resume with lastEventId as on any transport). A static channel is refused once closed, with a pointer to the factory. A still-connecting channel is waited on; terminate() during that wait closes it rather than adopting it late. Over a raw channel close() and terminate() close the channel — nobody else would restart its ICE. - RtcPeerTransport takes a data channel as well as a link; `peer` then defaults to the channel's label, `link` is null. - `maxMessageSize` option on both halves (default the 16 KiB interop floor): a transport cannot see the peer connection's sctp, and each side fragments independently so the two need not agree. binaryType is set to 'arraybuffer' on a raw channel (the browser default is 'blob'). - isInboundTransport moves to src/rpc/serverTransport.js — the shape both PeerHost.attach and the coming RpcServer.attach check. - tests/webrtc/rawChannel.js: two fake pcs with a negotiated channel each, offer/answer done by hand — the raw level's own signaling in miniature. The 15 link-mode transport tests pass unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The attachPort of WebRTC: a data channel the application negotiated
itself, attached to an ordinary RpcServer — sessions, rooms, cluster and
all — reachable from a browser with connect(url, { transport: 'webrtc',
channel }).
- attach(transport, { meta }) is the structural seam under it: any
persistent transport announcing inbound text as 'packet' and bytes as
'chunk' events (isInboundTransport, the shape PeerHost.attach checks)
becomes a client, WebRTC or not.
- attachChannel(channel, { peer, headers, data, remoteAddress,
maxMessageSize, framing, highWaterMark, lowWaterMark }) builds an
RtcPeerTransport over the raw channel and attaches it; framing errors
are warned as 'channel.error'. Like a port, a channel carries no
request: the client starts with no session and the default access
answers 403 until one is established; what the application observed
goes in headers/data and lands in context.meta. src/rpc/core.js now
requires src/webrtc/transport.js for this — the one rpc→webrtc edge,
Node-only, no cycle.
- tests/rpc/channel.test.js: calls and events both ways over fake raw
channels, meta and the session-less 403, connection hooks and detach on
close, 1 MiB upload/download fragmented at 16 KiB, a factory-driven
reconnect resuming a subscription across channels, reconnect: false on
a static channel, a framing error's warning, attach() with a hand-made
transport. A skip-guarded case runs attachChannel over node-datachannel.
- Types: InboundTransport, AttachChannelOptions; docs: the "Your own
connection" section of the WebRTC guide (the three levels of the
stack), the server guide's port section, the transport tables.
webrtc.browser.js measures 40.7 KB against its 41 KB budget.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…cServer The server core knows no framing, so it should not require the WebRTC transport: `attachChannel(server, channel, options)` moves from RpcServer to src/webrtc/index.js (the Node barrel), builds the RtcPeerTransport over the raw channel and hands it to `server.attach` — the structural seam that stays on the server. src/rpc/ requires nothing under src/webrtc/ again. Framing errors are warned on the attached client's own log (already tagged with the peer). Types move to webrtc.d.ts (AttachChannelOptions, AttachingServer); docs, CHANGELOG and CLAUDE.md follow. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`connect(url, { worker })` now accepts a SharedWorker (reached through its
`port`), a dedicated Worker or a raw MessagePort in addition to a
ServiceWorker — the page posts to `worker.port ?? worker`, and every client
still gets its own MessageChannel.
`WrpcClientProxy` listens on both `self` `message` (Service Worker/dedicated
Worker) and the SharedWorker `connect` event, treating each page's connection
port as the same control bus `self` is in a Service Worker. It also takes a
`url` option — the server it connects to — instead of always deriving one
from `self.location`, and releases a page's port (and its pending answers)
when that port closes.
Also fixes ClientEventTransport.close() being callable only once: a second
close(), or terminate() after close(), threw a TypeError that masked the
real error from a failed open().
Docs, types, CHANGELOG and the browser bundle budget (15 -> 16 KB; SSE entry
16 -> 17 KB) updated accordingly.
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
The previous warning said SharedWorker was missing from Chrome for Android, which was true historically but is out of date — caniuse now shows it supported there and in Firefox for Android, with Samsung Internet and Opera Mobile as the actual holdouts. Feature-detection is still the right advice, just for the right reason. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ve a signaling reconnect
A peer's id is what the signaling unit's `identity` strategy answers —
`createSignalingUnit({ identity: (context, { proposed }) => id })` — with the
connection's client id as the default and the id the client proposed
(`wrpcSignaler(client, { identity })`) as one input. A stable id no longer
routes anywhere by itself (sendTo routes by the client-id prefix), so every
roster member, join and signal now carries the peer's routable `address`
and the signaler's `instance`; the signaler remembers addresses and attaches
them to send(), and the unit resolves a bare id through its registry (and,
for a room relay, the room's descriptors) only on the cold connect-by-name
path.
`instance` — one per wrpcSignaler, from the `generateId` option — tells two
incarnations of one id apart: a signal or roster entry under another
instance abandons the stale link (no goodbye: it would land on the
newcomer) and relinks; the initiator dials afresh on anything the newcomer
says, since its own redial may already have reached it. A reset that keeps
the id keeps the links; the signaler keeps its id through the re-identify
window. `duplicate: 'replace' | 'refuse'` decides a second connection
under a held id on one instance: the older hears `replaced`, silently
leaves its rooms (a 'replaced' leave when the incarnation differs), and
its later calls are 409 — ownership is re-checked after every await. The
disconnect hook announces nothing for a revoked connection; leaves carry
`reason: 'left' | 'disconnect' | 'replaced'`, and a Mesh keeps a member's
link through a 'disconnect' (`away`) instead of closing it.
Budget 41 -> 42 for the webrtc browser entry (measured 41.4).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An assertion is a JWS (compact serialization, ES256 — the one algorithm every WebCrypto ships, and its raw r||s signature is exactly what JWS wants) whose payload binds a peer id (`sub`) to the DTLS certificate fingerprint of the peer connection it was issued for (`fp`), with `iat`, `exp`, an optional `iss` and whatever claims the server adds. The verifier (src/webrtc/assertions.js, browser-safe and require-free) checks the header first — `typ: 'wrpc-rtc+jwt'`, `alg: 'ES256'`, `kid` looked up in a set of public JWKs, refetched once for an unknown kid when the keys come from a function — then the signature, then binds: `sub` is the signaling `from`, `fp` is the `a=fingerprint:` of the description the token arrived with, `exp` is in the future (a minute of skew), `iss` matches when expected. Every refusal is an AssertionError with a code. The issuer (src/webrtc/assertionIssuer.js, Node-only by placement) signs from a private JWK or a CryptoKey pair and publishes the public JWK; generateAssertionKeys() makes a pair. The fake peer connection now declares a per-pc fingerprint in its SDP, the way a real one does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…g unit
`createSignalingUnit({ assertions: { key, ttl, issuer, claims } })` issues a
token per peer — `assert({ fingerprint })`, refused for a replaced
connection, with the `claims` hook's additions signed in — and publishes
its keys on the public `keys()`; the client half gains `assert()`/`keys()`
and `hasAssertions`.
`WrpcPeer({ assertions })` stamps every description it sends with a token
for the certificate that description declares (one server round trip per
dial, behind a per-link chain so no candidate overtakes its description)
and verifies every description it receives before the RtcLink sees it —
before accept(), which now gets `{ instance, claims }` — pinning the
link's certificate so an ICE restart is a string compare and a redial's
new certificate is verified afresh. A refusal closes the link with a
goodbye and a `rtc.peer.refused` line naming the check that failed. The
server's clock is learned from the peer's own tokens. `PeerHost` gains
`trust: 'assertion'`: attach() requires the verified claims and puts them,
after the roster data, in `session.data.claims`.
Types for both halves, the guide page "WebRTC: identity and trust", the
token format in the protocol reference, and the mesh example issuing and
verifying assertions with a stable per-tab identity. Budget 42 -> 45 for
the webrtc browser entry (measured 44.0).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Seen live: when b learns that a's id changed hands ('replaced' leave), the
Mesh closed its old link to a with a goodbye — a 'close' signal addressed to
'a', which the relay delivered to the NEW incarnation and which landed on
the fresh link b was about to make with it (the newcomer then saw an answer
"in wrong state: stable" and its link hung until the connect timeout).
Like a reset under a new id and an incarnation mismatch, a drop caused by
a replacement abandons the link: nobody at the old endpoint listens, and
the new one must not hear a stranger's goodbye.
Also: the node-datachannel integration test checks that a real local
description declares the sha-256 fingerprint the assertions bind to, and
the mesh example page no longer references its removed name input.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Optimize `generateAssertionKeys()` by exporting private/public JWKs concurrently with `Promise.all`, keeping the same `key_ops` stripping behavior. Also document why signaling `assert` keeps `identify()` and `extraClaims()` sequential: `identify()` may initialize rtc identity needed by claims and should run before hooks on connections that may be rejected.
Introduce an experimental `wt` transport and `@alexify/wrpc/wt` server subpath, including WebTransport framing, session attachment helpers, stream/datagram support, and unreliable event delivery. The change also makes transport fallback apply on the initial connect, and updates types, docs, examples, benchmarks, and tests for the new HTTP/3 path.
Relocates the `@fails-components/webtransport-transport-http3-quiche` build permission from `package.json` (`pnpm.onlyBuiltDependencies`) into `pnpm-workspace.yaml` under `allowBuilds`, keeping build policy in the workspace-level config.
Nothing compresses a QUIC stream's payload — HTTP/3 compresses headers,
never bodies — so a WebTransport session carried exactly the bytes wrpc
handed it. This is the first half of the phase that gives the transports
with nothing under them their own compression; WebRTC follows.
- src/compression/: the seam every such transport shares. `isCompressor`
is the structural codec contract (`id`, `encode(bytes)`,
`decode(bytes, maxOutput)`, either may answer a promise), exported from
the main entry; `normalizeCompression` is strict on what it is given and
off in every spelling of off; `negotiate` turns it on only when the peer
named the same `id`; `Sequencer` keeps the wire in order around an
asynchronous codec with a synchronous fast path when nothing is in
flight (107M pushes/s plain, 1.7M through resolved promises). The
platform codec is a package.json#browser pair: node:zlib raw deflate,
sync, 1 KiB threshold; CompressionStream('deflate-raw'), async, 4 KiB —
~6x the cost per call and no dictionary, so a small message is not
worth it there. Same `id` on both, so a Node peer and a page negotiate.
- WebTransport: KIND 3 (packet) and 4 (chunk) are the compressed twins,
accepted by the parser only once negotiated; each end names its codec
in the capabilities message (`deflate: "deflate-raw"`). Both WtSocket
and ClientWtTransport compress past the threshold, send plain what the
codec did not shrink or failed on, inflate before delivery, honour
`compress: false` per message, route the mux's control writes through
the outbound order so a stream packet cannot overtake a message still
being compressed, and hang up (1002) on an inflate past maxMessage.
Chunks on their own streams and datagrams are never compressed.
- Options: `attachSession`/`acceptSessions({ compression })`, and on the
client connect()'s own `compression` (the name the ws Node client will
share) or the `wt` bag's.
Measured (bench/message-compression.js, node:zlib one message at a time):
a 108 B event 143K/sec for 1.1x — the reason for the threshold — a 1.4 KB
callback 84K/sec for 6.4x, 24 KB 12K/sec for 13.8x, 493 KB 552/sec for
14.8x; inflate 3-6x cheaper at every size. CompressionStream in this Node:
23K/sec on the small message, 9K/sec at 24 KB.
Budgets: main browser entry 20 -> 22 KB (measured 20.8 against 19.5), sse
21 -> 23 (21.7) — the seam every browser transport will share.
Tests: tests/compression/index.test.js (contract, normalization,
negotiation, both native halves round-tripping through each other and
through zlib, the cap, the Sequencer's ordering and error paths),
tests/wt/compression.test.js (socket and client against a hand-driven
peer: announce, negotiate, KIND 3/4 both ways, threshold, compress:false,
an async codec keeping order both ways, a failing codec, another codec
id, the bomb; and end to end through acceptSessions/attachSession with
the wire spied on).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… signaling
The other half of phase A2. SCTP over DTLS carries a data channel's bytes
as they are (TLS 1.3 dropped compression), so a link carried exactly what
wrpc handed it. Same seam as WebTransport (src/compression), same option
name, same rule: off by default, on only once both ends named the codec.
- framing: bit 2 of the header is the DEFLATE flag — a reserved bit, and
a protocol error, until the decoder is told (`decoder.deflate`), and a
continuation must repeat it like the KIND. A deflated message comes out
of the decoder as bytes with `deflated: true`; `decodeText` turns an
inflated packet into text with the decoder's own fatal UTF-8 rule.
bench/rtc-framing.js before/after at 256 KiB: encode 64 KiB 1.11M ->
1.09M ops/s, decode 64 KiB 16.75M -> 17.22M, 1 MiB 25.9K -> 27.7K — the
extra AND is noise.
- ChannelCodec compresses BEFORE fragmentation, the one place a message
exists whole, and every fragment carries the flag; two Sequencers keep
each direction in order around a codec that answers asynchronously, and
cost nothing while nothing is in flight; a codec that fails or does not
shrink the message sends it plain; an inflate that fails or blows
maxReassembly is a FramingError('inflate') and closes the channel like
any bad frame. `compress: false` per message, and `writeWith` on the
peer transport so Client.sendRaw and a Broadcast honour it.
- Negotiation: the channels have no handshake, so RtcLink announces
`caps` in every description it sends (a redial's fresh pc and an ICE
restart announce again) and reads the peer's `caps` before applying a
description — by the time the channels open, `link.peerCaps` is known
and the transports negotiate on attach. WrpcPeer takes `compression`
and sets `caps: { deflate: id }` on every link; a peer without it is
served plain, nothing hangs up. Over a raw channel there is no
description: `attachChannel`, `connect(url, { channel, compression })`
and the transports apply the option as given — both applications turn
it on or neither, and the plain side closes on the first flagged frame.
Budget: webrtc browser entry 48 -> 50 KB (measured 49.2 against 47.5).
Tests: tests/webrtc/compression.test.js — the flag on every fragment and
its refusal before negotiation, both transports over a raw pair with the
wire spied (packets and chunks both ways, compress:false, the plain side
hanging up, an async codec keeping order, the inflate cap, connect()
carrying the option), links learning caps from the description before
open (matching, absent, another codec), WrpcPeer end to end (both on,
one on), attachChannel. The old framing expectations gained the
`deflated` field.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e envelopes
Phase A3: the Node<->Node carriers. Everything opt-in, nothing negotiated
by default.
- The broker binding: `attachBrokerRpc(server, broker, { compression })`
and `connect('broker://…', { compression })`. A session names its codec
on hello (`wrpc-enc`), the server that agreed answers it on welcome, and
every frame past the threshold then travels compressed both ways —
packets, events, subscription values, stream chunks — marked `wrpc-enc`
on the frame; `compress: false` per message and `writeWith` as on a
WebSocket. A stateless request names what it accepts and travels plain
itself (HTTP's Accept-Encoding shape); only the answer is compressed. A
marked frame the receiver cannot inflate — no codec agreed, another
codec, past `maxMessage` (16 MiB) — ends the session like a sequence
gap. The two ends upgrade in any order: a lone one is served plain.
- The backplane envelopes: `rooms: { compression }` and
`cluster: { compression }`. The contract is strings, so a compressed
envelope rides as base64 under a `wrpc-enc:<id>:` marker — a message
that starts with `w` rather than `{`. Nothing to negotiate against on a
fan-out, so this one is a two-step rollout, and an instance without the
option drops such an envelope LOUDLY (`backplane.encoded`,
`cluster.encoded`) rather than silently. The cluster signs first and
compresses after, so HMAC verification is unchanged.
- src/compression/sync.js: the Node-only half of the seam —
`normalizeSyncCompression` probes the codec once and refuses a
promise-answering one at construction (a backplane handler and a frame
sequence have no ordering queue to hide a promise behind), plus
`encodeIfSmaller`/`decodeOrNull` and `createEnvelopeCodec`. The codec is
INJECTED into RoomsBackplane and Cluster by the core, so the
browser-bundled rooms.js carries none of zlib or base64 (webrtc entry
unchanged at 49.2 KB).
Measured (bench/broker.js, MemoryBroker): a session call answering a 9 KB
result 9,315/sec plain, 5,407/sec compressed — ~80 us a round trip for
~10x fewer bytes through the broker.
Tests: tests/broker/compression.test.js (off by default, hello/welcome
negotiation, both directions past the threshold, events/subscriptions/
chunks on the session, the stateless accept shape, one side on, the
undecodable frame ending the session, validation and the async refusal,
an injected codec named on the wire), tests/scaling/compression.test.js
(the envelope codec's marker/threshold/cap/foreign-codec paths, rooms
envelopes compressed between two instances, the plain instance dropping
loudly, cluster signed-then-compressed with a third node warning).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ing/pong
Phase A4: the one direction permessage-deflate cannot cover. Node's
built-in WebSocket offers the extension on the handshake but only ever
inflates — verified on a raw capture, RSV1 clear on every client frame —
so a Node client's uploads arrived as they were whatever the server
negotiated. Browsers compress both directions themselves and are untouched.
- The client (src/client/wsCompression.js, a package.json#browser pair
whose browser half is a stub, so the page bundle carries none of it):
`connect(url, { compression: true })` sends `{ type: 'ping', enc:
'deflate-raw' }` first on open, inspects pongs only until one answered,
and once the server named the same codec frames every packet or chunk
past the threshold as a binary frame `0x00 <kind> <deflate>` — kind 3 a
packet, 4 a chunk (src/wire.js). A stream chunk never starts with 0x00
(its first byte is an id length, at least 1), so the marker costs the
chunk path one comparison. Re-negotiated per open; off under a wire codec.
- The server: `new Server({ compression })` (RpcServer; `maxMessage`
caps the inflate at 16 MiB). The dispatcher's ping handler negotiates —
a Node ws client only — and stores the codec on the server Client
(`client.compression`); attachSocket inflates marked frames, dispatching
a packet and handing a chunk on to the chunk path with its flow control.
A frame before negotiation, of an unknown kind or past the cap is an
id-less 400 and a `frame.refused` warn, never a hang-up.
- bench/bench.js before/after, alone on the machine: WS small 26,885 ->
26,912 ops/s, 10 KB 13,946 -> 14,118, event round trip 26,252 -> 26,569
— the extra argument to handlePacket and the marker check are noise.
(A first after-run taken in parallel with the coverage job read -28% on
the HTTP row A4 never touched; that run was the machine, not the code.)
Tests: tests/rpc/ws-compression.test.js — off by default, both ends on
with the inbound frames spied through attachSocket (a 20 KB packet as a
~400 B marked frame that inflates to the call, small packets as text,
upload chunks compressed and reassembled), one side on, and the wire by
hand through ProtocolClient: a marked frame before negotiation is a 400,
another codec gets a plain pong, the platform codec is agreed, a
compressed call dispatches, an unknown kind and an over-cap body are
400s, plain text still works on the same connection; option validation
on both ends; the browser stub.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… over it
Phase B1. One-shot deflate sees every field name and every method target
for the first time, in every message — a 108 B event compresses to 99 B.
A preset dictionary is that history sitting in the window before the
first byte, and the router already knows every string a packet is made of.
- src/rpc/dictionary.js: `buildDictionary(router)` reads the router's
introspection — field names from `signature` shapes and JSON-Schema
parts, every `unit/method` target and `unit/event` name (inbound
handlers and declared emits), then the packet skeletons every message
starts with — ordered least to most frequent (zlib finds recent bytes
cheapest and looks at the last 32 KiB) and cut from the FRONT past the
cap. Deterministic: sorted within each group, so two instances of the
same router build the same bytes. Node-free; exported from the main
entry and the WebRTC browser barrel (webrtc budget 50 -> 51 KB,
measured 50.3), for the pure-JS codec that follows.
- src/compression/dictionary.js: `dictionaryCompressor(dictionary)` — raw
deflate through node:zlib with the dictionary, id
`deflate-raw+dict:<fnv-1a>` (`dictionaryId` in the shared seam, a
synchronous fingerprint that runs in a browser too), so every wire's
negotiation compares dictionaries and not just codecs: a rolling
deploy's odd instance names another id and that pair stays plain rather
than corrupt. Threshold 64 B against the plain codec's 1 KiB — small
messages are what it is for. Injected as `compression: { codec }` on any
Node<->Node carrier; the plain codec's output reads back through it.
- The plan's "pool of persistent streams" was dropped: zlib's one-shot API
with a dictionary costs the same as without it at these sizes (118K/sec
against 111K on the event), so there is nothing to pool.
- docs/guide/compression.md: one page mapping every knob, its negotiation
and its numbers, the dictionary, and the codec seam.
Measured (bench/dictionary.js, a 546 B dictionary from a small router):
108 B event 99 -> 55 B (1.1x -> 2.0x), 84 B call 77 -> 30 B (2.6x),
1.1 KB callback 201 -> 160 B (5.5x -> 6.9x), 27 KB 1892 -> 1824 B.
Tests: tests/compression/dictionary.test.js — determinism and the same
id across two routers, content and order, the cap keeping the frequent
tail, the codec's interop with zlib both ways and against the plain codec,
the ratio, the cap on inflate, validation, dictionaryId's sensitivity, and
the broker binding negotiating by id: the same router compresses even a
small callback, a different router stays plain.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… preset dictionary
Phase B2: the dictionary in a browser. A page has only CompressionStream,
which takes no dictionary and answers asynchronously; this is a DEFLATE
codec in plain JavaScript on its own subpath, so a page that does not
inject it never loads a byte of it (4.5 KB min+gzip when it does).
- src/deflate/inflate.js: RFC 1951 complete — stored, fixed and dynamic
blocks, whatever the other end chose. Table-driven: one Uint32Array per
tree sized 1 << (longest code), indexed by the bit-reversed code, the
fixed tables built once. A preset dictionary preloads the output
window, so a distance into it is an ordinary copy; the buffer doubles
up to `maxOutput` and a DeflateError('too-large') past it — the cap that
bounds a bomb. Every malformed input is a coded DeflateError (truncated,
huffman, stored, distance, block), never a wrong byte.
- src/deflate/deflate.js: LZ77 over hash chains against the dictionary,
ONE fixed-Huffman block, stored blocks when that is smaller — measured
before it was written (bench/deflate-js.js): on a 108 B event with the
dictionary fixed codes produce the same 51 B zlib's dynamic trees do,
at 100K/sec against zlib's 136K and CompressionStream's 24K, and the
inflate side runs at a million a second, faster than zlib's one-shot
API. Past a couple of kilobytes fixed codes fall 30-45% behind (2 KB:
263 B vs 204; 28 KB: 2645 vs 1824), which is the hybrid's hand-off.
- src/deflate/index.js: `createDeflateCodec({ dictionary, threshold,
native, nativeAbove, level })` — a Compressor whose id is
`dictionaryCompressor`'s for the same bytes (DICTIONARY_ID_PREFIX now
lives in the shared seam), so a browser peer on it and a Node peer on
node:zlib negotiate with each other; `deflate-raw` without a dictionary.
From `nativeAbove` (4 KiB) up a browser hands the message to
CompressionStream — dynamic Huffman, no dictionary, and the output still
inflates on the dictionary side since a stream that never reaches back
does not care; in Node the codec stays synchronous by default, so it
also serves the Node<->Node carriers.
- The subpath: deflate.js + deflate.d.ts, exports/files, a size row with
its own budget, tests/deflate.test-d.ts, the compression guide's own
section, README, CLAUDE.md.
Tests (tests/deflate/codec.test.js — the main body of this change, as
the plan said): an interop matrix both ways against node:zlib on a
thirteen-payload corpus at every level and strategy (stored, fixed,
dynamic, RLE, Huffman-only, a 512 B window), with and without the
dictionary; the sizes (fixed = dynamic on the small message, exactly the
stored form on noise); a 400-payload fuzz run through every pair; the
malformed-input table; CompressionStream/DecompressionStream both ways;
the codec's id agreement and both-way interop with the Node codecs, the
sync/async policy, validation.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Track C: a Uint8Array, ArrayBuffer, DataView or typed array anywhere in
a call's args, a result, an event's data or an error's details used to go
through JSON.stringify — a `{"0":1,"1":2,…}` object nine times the size
that arrived as a plain object, silently. Now a packet that holds bytes
is one binary frame, `0x00 0x01 u32 headerLen JSON [packet, index]
buffers…` (src/attachments.js, src/wire.js FRAME_ATTACHMENTS): the JSON
carries `null` at every byte leaf and a Jupyter-style buffer_paths index,
the buffers follow back to back. The 0x00 marker is what a stream chunk
never starts with; everything else stays revision-1 JSON.
- Detection is `hasBytes(packet)`, a walk of every outbound packet, and
the reason `attachments: false` exists (RpcServer/Server, WrpcClient,
PeerHost, attachChannel; set on both ends). A packet `codec` turns it
off by itself — a codec owns the text.
- Decoding copies every buffer out of the frame: the WebSocket engine
hands zero-copy views into its socket segments, and a value kept in
`args` past the next frame must own its bytes (tested: the next frame
does not overwrite the bytes a handler kept). The decoder refuses
`__proto__`, writes only where JSON.parse left an own `null`
placeholder, caps path depth at 32 and requires the byte lengths to add
up exactly — every malformed frame is a TypeError, never a wrong packet.
- Carriers: ws both ways (the client sets binaryType = 'arraybuffer' so
classification is synchronous, in order with text), a room broadcast as
ONE shared BINARY prepared frame (PreparedFrames carries its opcode;
SharedMessage.text is `string | Uint8Array` in the engine contract),
HTTP packet POSTs and batches up and down as application/octet-stream,
attachPort, and WebRTC/WebTransport through the transports' binary
paths. REST answers 501 for a bytes result without `codec.rest`.
- SSE refuses explicitly (user decision): the client throws a TypeError,
a channel POST with a frame is 415, a result with bytes is a 501, an
event with bytes is dropped with an `sse.bytes` warn. A backplane
broadcast with bytes is not published (`backplane.bytes` warn) — the
backplane contract is a string.
- bench/attachments.js: hasBytes 175 ns on a 233 B callback, 2 µs on
3 KB, 19 µs on 30 KB (against 0.5/6/60 µs of JSON.stringify); a 1 KB
attachment is a 1,139 B frame against 9,793 B of JSON and 1,448 B of
base64; encode 1.7 µs, decode 1 µs. bench/bench.js before/after, alone:
WS small 28,114 -> 27,882 ops/s, 10 KB 13,532 -> 13,526, HTTP 8,042 ->
8,269, event round trip 27,293 -> 27,221 — noise. bench/send-path.js
fan-out x50 577k -> 522k, x200 221k -> 216k, inside the bench's own
spread (three back-to-back runs read 522k/542k/160k); the hot-path
change there is `frames.opcode` in place of the OPCODES.TEXT constant.
- Budgets (scripts/size.js): browser 22 -> 23 (measured 22.4 against
21.2), sse 23 -> 24 (23.4 against 22.0), webrtc 51 -> 53 (51.8 against
50.3), each with the measured delta.
Tests: tests/rpc/attachments.test.js — hasBytes, encode/decode (leaves
of every kind, byteOffset honoured, the packet untouched, copies own their
bytes, a batch at the root), every malformed frame, ws both ways, the
kept-bytes guard, events to one client / a room / from the client, a
batching client, HTTP packet + batch + REST 501, the SSE refusals, the
opt-out and the codec, attachPort over a MessageChannel, option
validation. Docs: guide/streams.md "Bytes inside a call", protocol.md
kind-1 layout, wire-format.md, sse.md, codec.md, server/client notes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ool on WT and WebRTC
An audit of every zlib call under src/, prompted by the question whether
the synchronous convenience calls (deflateRawSync, inflateRawSync,
gzipSync) should give way to the callback API to keep the event loop
free. Measured first (bench/zlib-async.js): the threadpool hand-off costs
a fixed ~20 µs per call, so on the sizes a packet has it makes the message
SLOWER — 291 B: 8.6 µs sync against 28.4 async, 2.6 KB: 15.9 against 35.8,
27 KB: 100 against 120 — and the two are level only at ~256 KB, which is
exactly the DEFAULT_ASYNC_THRESHOLD permessage-deflate and the HTTP
encoder already use. Inflate never earns it: eight times faster than
deflate, it loses at every size up to the 16 MiB cap (1.1 MB: 578 µs sync,
1,136 async). So nothing is rewritten wholesale; the one carrier family
without the knob gets it, in the shape the others have.
- `compression: { async: true | { threshold } }` (256 KiB) on the
WebTransport and WebRTC carriers: the Node platform codec hands a
message that large to zlib.deflateRaw and answers a promise, which the
transports' Sequencers already keep in order (a CompressionStream's
shape). `dictionaryCompressor(dict, { async })` takes the same option.
Everything under the threshold, and every decode, stays synchronous.
`deflateEncoder` in native.js is the one hybrid, shared by both codecs.
- Refused where it cannot work: the broker binding, the backplane
envelopes and a Node ws client (`normalizeSyncCompression`) reject a
codec that declares `async` at construction — a frame sequence has no
ordering queue for a promise; `compression.async` alongside an injected
`codec` is a TypeError (the option belongs on the codec's factory).
- Unchanged, and why: permessage-deflate and the HTTP encoder already
have `async` with the same threshold; the SSE gzip is a zlib stream,
whose writes run off the loop already; the pure-JS codec is JS.
- `Compressor.async` in the types; the compression guide's new
"On the loop, or on the threadpool" section carries the table; WT and
WebRTC pages point at it. Budgets untouched (+0.2 KB, inside the
headroom).
Tests: normalizeCompression/normalizeAsync (defaults, strictness, the
injected-codec refusal), the native codec's sync-under/promise-past
split with zlib interop and a synchronous decode, the dictionary codec's
`async`, normalizeSyncCompression's two refusals, the broker binding
refusing `async: true`, a WtSocket with `async` sending big/medium/small
/big in order under the right kinds, and both RTC transports with the
platform codec's `async` keeping six messages in order each way.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… 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>
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.
This pull request introduces several major enhancements to the project, focusing on expanding integration test coverage for message broker adapters (Redis, NATS, RabbitMQ, Kafka) in CI, and updating documentation to reflect new features and architectural changes. Notably, it adds a new WebRTC transport, a binary attachments feature, and improves the protocol handshake and broker adapter documentation.
CI/CD Improvements:
.github/workflows/ci.ymlto run integration tests against real Redis, NATS, RabbitMQ, and Kafka servers, ensuring that each broker adapter is contract-checked against its respective backend.Documentation and Architecture Updates:
CLAUDE.mdto:src/webrtc/), detailing its structure, peer-to-peer negotiation, and trust assertions.src/attachments.js), including its encoding/decoding logic, integration points, and security checks.src/broker/{redis,nats,amqp,kafka}/), including the injection/duck-typing strategy and adapter-specific behaviors.src/rpc/dictionary.js), describing how preset dictionaries are built from router introspection and used for compression.These changes collectively improve test reliability, clarify the project's architecture, and document important new features for future development and onboarding.