Skip to content

wrpc v2: - #5

Open
Alex-Dolid wants to merge 82 commits into
mainfrom
feature/v2
Open

Alex-Dolid wants to merge 82 commits into
mainfrom
feature/v2

Conversation

@Alex-Dolid

Copy link
Copy Markdown
Contributor

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:

  • Added new CI jobs in .github/workflows/ci.yml to 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:

  • Updated the architecture overview in CLAUDE.md to:
    • Document the new WebRTC transport (src/webrtc/), detailing its structure, peer-to-peer negotiation, and trust assertions.
    • Describe the new binary attachments feature (src/attachments.js), including its encoding/decoding logic, integration points, and security checks.
    • Expand on the protocol handshake, especially for WebSocket headers and browser/Node differences, and clarify the use of carrier tokens and handshake strategies.
    • Add detailed descriptions of the message broker adapters (src/broker/{redis,nats,amqp,kafka}/), including the injection/duck-typing strategy and adapter-specific behaviors.
    • Include new sections for the dictionary codec (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.

Alex-Dolid and others added 30 commits September 13, 2026 22:12
…: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.
Alex-Dolid and others added 19 commits September 19, 2026 11:40
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>
@vercel

vercel Bot commented Sep 20, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated
wrpc Ready Ready Preview Sep 20, 2026 8:07pm UTC

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Phase 6. The sse client transport carries `options.encryption` through the
per-request binding: the stream request and every channel POST are sealed
requests, so the channel id and Last-Event-ID travel inside — and the stream
comes back sealed frame by frame.

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

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

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

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Phase 8a. An event whose data holds a Uint8Array used to reach only the
members on the emitting instance: the backplane's envelopes are JSON, so the
cross-instance half was refused (`backplane.bytes`). The envelope now rides
as the binary attachments frame itself — base64 under a `wrpc-bin:` marker,
or inside the sealed envelope (flag bit 1) under `rooms.encryption`, with
compression still composing — and members on every instance receive bytes.
It is what a relayed end-to-end payload needs: opaque bytes, room-wide,
cluster-wide.

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

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

Phase 8b. For a payload the SERVER should not read — a chat message it only
relays. `createSealer({ recipientPublicKey }).seal(data)` answers bytes
(enc ‖ ciphertext, HPKE with a fresh context per message) that wrpc carries
as they are: in a call, an event, a room broadcast and, since the previous
commit, across the backplane. Tested through a relaying server on two
instances.

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

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

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

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

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

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

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

This branch was successfully deployed

1 active deployment
Preview da222512 Deployed Sep 20, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant