@claudewerk/bun-reliable-ws
v0.2.0
Published
Reliable, self-healing WebSocket connection for Bun: staged handshake, pluggable auth + capability negotiation, keepalive, backoff reconnect, graceful drain, delivery acks, deterministic duplicate-connection tiebreak. Zero required runtime deps.
Maintainers
Readme
bun-reliable-ws
A standalone, dependency-free (except Bun) library that turns a raw WebSocket into a reliable, self-healing connection. It moves opaque frames and guarantees the connection's health -- what rides on the frames is someone else's problem. It knows nothing about message queues, envelopes, or any larger system.
Built on Bun's native WebSocket (dialer) and Bun.serve websockets
(listener). Zero required runtime dependencies. pino is an optional
peerDependency used only by the convenience logger adapter.
Features
- Reliable reconnect (dialer): exponential backoff with full jitter, forever. Emits every state transition.
- Keepalive: app-level ping/pong; a peer silent past a deadline is declared dead and torn down.
- Staged handshake:
connect -> capabilities -> auth -> ready. Nothing but the handshake flows beforeready. - Capability / version negotiation: highest common version + feature intersection; mismatch aborts cleanly with a reason.
- Pluggable auth (
AuthProvider): mutual challenge/response by default, bound to the negotiated result and the collision connId, so a mid-handshake downgrade or a tampered nonce is detected. - Pluggable codec (
Codec): JSON by default; a binary codec (CBOR/protobuf) rides on real WS binary frames with zero base64. - Binary wire: TEXT frames carry the JSON control envelope; BINARY frames carry data payloads and bulk chunks, each tagged with a 1-byte discriminator.
- Graceful drain: stop new sends, let outstanding delivery-acks settle within a window, then close. Outstanding frames are exposed for resend elsewhere.
- Delivery acks (optional per send): resolves on the peer's ack, rejects on timeout / connection loss. Report-only -- the library never dedupes.
- Deterministic duplicate-connection tiebreak: when two connections exist for the same peer, both ends independently keep the same survivor (never both, never neither). Prior art: XMPP stream collision (RFC 6120 §4.9.3.3), BGP connection collision (RFC 4271 §6.8).
- Injectable Logger + Clock: structured logs on every transition; a
FakeClockmakes all timing deterministic in tests.
Install
bun add @claudewerk/bun-reliable-ws
# optional, only if you want the pino logger adapter:
bun add pinoConnection state machine
backoff + jitter (dialer only, on drop/dead)
┌───────────────────────────────────────────────┐
│ │
▼ │
idle ─▶ connecting ─▶ capabilities ─▶ authenticating ─▶ ready ─▶ draining ─▶ closed
│ │ │ ▲ ▲
│ └─ no auth ────┼─────────────┘ │
│ │ │
└─────────────────┴── abort / dead / close ─────────┘ready ─▶ drainingisdrain();draining ─▶ closedcompletes once outstanding acks settle (or the drain window lapses).capabilities: each side sends one
hello(peer id, nonce, capabilities). Negotiation resolves the shared version + features. No common version or auth method → abort.authenticating: the negotiated
AuthProviderruns a turn-based exchange, bound to the negotiated fingerprint. Skipped when no auth method is negotiated.ready: reached only after both sides send and receive
ready. Frames, keepalive, acks, and drain all live here.Fatal aborts (version/auth/protocol mismatch) go straight to
closedwith no retry. Transport drops and dead peers trigger a backoff reconnect on the dialer; the listener side just closes (the peer redials).
Wire protocol
Two WS frame types are used, so a binary codec never pays a base64 tax:
TEXT frames carry the JSON control envelope (
hello,auth,ready,abort,ping/pong,ack,drain).BINARY frames carry data payloads and bulk chunks. Every binary frame begins with a 1-byte discriminator:
| Byte | Meaning | | ----------- | -------------------------------------------------------------- | |
0x00| reserved (invalid) | |0x01| data --[0x01][flags u8][seq varint if flags&1][payload bytes]| |0x02| bulk/raw chunk --[0x02][raw bytes]| |0x03| compact stream --[0x03][label varint (LEB128)][payload]| |0x04..0x0f| protocol-control (reserved;0x04= compact stream-ack) | |0x10..0xff| application --[tag][raw bytes],tagis app-defined |
send(frame) emits a 0x01 data frame: one flags byte, then the ack seq as
an LEB128 varint only when an ack was requested (flags & 1), then the codec's
bytes verbatim. A fire-and-forget frame therefore costs two header bytes.
sendChunk(bytes, {tag}) emits a
0x02 (or application 0x10..0xff) chunk, delivered to the peer as a chunk
event. sendStream(label, payload) emits a compact 0x03 stream frame
([0x03][label varint][payload]), delivered to the peer as a stream event and
demuxed by label (the substrate's ephemeral-stream data path). The payload
codec advertises an opaque payloadEncoding (e.g. "json", "cbor") which
fills the codec negotiation slot and is bound into auth, so an encoding
downgrade is detected.
Slot negotiation
Every negotiable mechanism is a slot carrying an ordered name-list, the
model SSH settled in RFC 4253 §7.1. The slots are auth, e2eSeal, e2eSign
and codec; the first name in the dialer's list that the listener also lists
wins.
- Every slot is stated. An omitted or empty list is refused -- at
construction for your own capabilities, as
capabilities-invalidfor a peer's. A peer that says nothing about a mechanism has not opted out of it. noneis a listed name.auth: ["none"]is how you run unauthenticated,auth: ["shared-secret", "none"]is how you say you would take either. There is no way to get there by forgetting an option, which is the whole point: opting out is as explicit as SSH'sCiphers none.- An opt-out is mutual.
nonewins only when both sides list it, so a peer cannot talk you out of a mechanism you require -- that isslot-mismatch. - A name you cannot run is fatal. Negotiating
auth: "shared-secret"with noauthfactory wired abortsslot-unsupportedrather than skipping. - Resolving a security slot to
nonelogs at warn, naming the slots, so the decision is visible in a log a year later.
codec is the one slot you do not type: it is filled from the codec option's
own payloadEncoding, because a list naming an encoding your codec cannot
decode would break every data frame.
e2eSeal and e2eSign are the end-to-end crypto slots. This library
negotiates the names and never interprets them -- what a relay may read, and
what a receiver can prove about an origin, belongs to the layer above.
Usage
Listener (server)
import { ReliableServer, sharedSecretAuth } from "@claudewerk/bun-reliable-ws";
const server = new ReliableServer({
peerId: "server-1",
// Every slot is an ordered name-list and every one is stated -- `["none"]`
// is how you switch a mechanism off, and it is the only way.
capabilities: {
versions: [1],
features: ["chat"],
auth: ["shared-secret"],
e2eSeal: ["none"],
e2eSign: ["none"],
},
auth: sharedSecretAuth(process.env.SECRET!),
port: 8080,
});
server.onConnection((conn) => {
conn.on("ready", (info) => console.log("peer ready", info.remotePeerId));
conn.on("frame", (frame) => console.log("got", frame));
});
server.listen();Dialer (client)
import { ReliableConnection, sharedSecretAuth } from "@claudewerk/bun-reliable-ws";
const conn = new ReliableConnection({
role: "dialer",
peerId: "client-1",
url: "ws://localhost:8080",
// Every slot is an ordered name-list and every one is stated -- `["none"]`
// is how you switch a mechanism off, and it is the only way.
capabilities: {
versions: [1],
features: ["chat"],
auth: ["shared-secret"],
e2eSeal: ["none"],
e2eSign: ["none"],
},
auth: sharedSecretAuth(process.env.SECRET!),
});
conn.on("state", (s) => console.log("state:", s));
conn.on("ready", () => console.log("ready"));
conn.on("frame", (frame) => console.log("got", frame));
await conn.send({ hello: "world" }); // fire-and-forget (binary data frame)
await conn.send({ important: true }, { ack: true }); // resolves on delivery-ack
conn.sendChunk(new Uint8Array([1, 2, 3]), { tag: 0x20 }); // bulk binary, arrives as a "chunk" event
conn.on("chunk", ({ tag, bytes }) => { /* ... */ });
await conn.drain(); // graceful: settle outstanding acks, then closeDuplicate-connection tiebreak
Share one CollisionRegistry across a node's dialer connection(s) and its
listener server. When two connections to the same peer become ready, the
registry runs the deterministic tiebreak and drains the loser. Both nodes
compute the identical survivor.
import { CollisionRegistry, ReliableServer, ReliableConnection } from "@claudewerk/bun-reliable-ws";
const registry = new CollisionRegistry();
const server = new ReliableServer({ peerId: "A", capabilities, registry, /* ... */ });
const dialer = new ReliableConnection({ role: "dialer", peerId: "A", url, capabilities, registry, /* ... */ });Options
| Option | Default | Meaning |
| -------------------- | ------------------ | --------------------------------------------------- |
| role | (required) | "dialer" or "listener" |
| peerId | (required) | Stable local id; used in the collision tiebreak |
| url | - | Dialer only: the ws:// endpoint |
| transport | - | Listener/custom: a preconstructed Transport |
| capabilities | (required) | { versions, features, requiredFeatures?, auth, e2eSeal, e2eSign, agent? } -- every slot is a non-empty ordered name-list; a required feature missing from the intersection aborts feature-mismatch; agent is an informational User-Agent-style string (printable ASCII, <= 256), bound into the transcript and surfaced on ready as localAgent / remoteAgent, never negotiated |
| auth | none | () => AuthProvider factory (fresh state per session). Required unless the auth slot resolves to none |
| codec | jsonCodec() | Frame encode/decode |
| logger | silentLogger | Structured logger |
| clock | realClock | Injectable timers (FakeClock in tests) |
| keepaliveMs | 30_000 | Ping interval |
| deadMs | 90_000 | Silence deadline before declaring the peer dead |
| handshakeTimeoutMs | 10_000 | Abort if ready isn't reached in time |
| ackTimeoutMs | 30_000 | Per-send delivery-ack timeout |
| backoff | 1s base / 60s cap| Reconnect backoff window (full jitter) |
| drainWindowMs | 30_000 | Max time drain() waits for outstanding acks |
| registry | none | Shared CollisionRegistry for the tiebreak |
ReliableServer adds: maxPendingHandshakes (256), maxPayloadLength
(16 MiB + 64 KiB; a larger WebSocket message closes the socket before it is
parsed), idleTimeoutSec (120; a socket with no traffic at all is closed by
the runtime).
Aborts: local verdicts vs remote claims
Nothing a peer sends can throw out of the inbound path: a malformed hello,
auth payload, control message or binary frame ends that session with an
abort (ended reason {kind:"abort", reason, origin:"local"}). A throw from
your own frame listener is logged and swallowed. The fatal reasons
(version-mismatch, slot-mismatch, slot-unsupported, feature-mismatch,
auth-failed, downgrade-detected, protocol-error, capabilities-invalid)
stop a dialer only when this side decided them. An abort frame received
from the peer is origin:"remote" and never fatal: the peer is unauthenticated
at that point, so the dialer logs it and reconnects under backoff. A negotiated
auth method with no auth provider configured is slot-unsupported, not a
silent skip; an auth frame outside the exchange is protocol-error.
state (State), ready (PeerInfo), frame (decoded frame), chunk
({tag, bytes} bulk binary), stream ({label, payload} compact 0x03 stream),
peerInfo (PeerInfo, at capability exchange),
reconnecting ({attempt, delayMs}), ended (EndReason).
Auth context
An AuthProvider receives a full AuthContext per step, enough to bind proofs
without reconstructing anything:
role,localPeerId,connId(canonical, both nonces)remotePeerId-- the id the peer CLAIMED in its hello. Unauthenticated: use it to pick which key record verifies the peer (a listener serving many peers), never as proof; a wrong claim picks a record whose proof failsnegotiated-- the full tuple:version,features, and the chosen name in every slot (auth,e2eSeal,e2eSign,codec); nevernull, because an unused mechanism resolves to the namenonelocalCapabilities/remoteCapabilities-- the raw hello frames as sent and as received (a MITM edit shows up inremoteCapabilities)transcriptHash-- a deterministic hash over BOTH raw hello frames, identical on both ends
The reference sharedSecretAuth binds negotiatedFingerprint + connId +
transcriptHash into every proof, so a downgrade, a tampered nonce, or an
edited capability set all fail auth.
Logging
Inject any Logger (debug/info/warn/error, each (msg, fields?)). Ships with
silentLogger (default), consoleLogger(), and pinoLogger() -- the last
lazily requires the optional pino peerDependency. Every state transition,
handshake stage, keepalive timeout, drain, and collision decision is logged with
structured fields (peer, connId, state).
Delivery-ack semantics (read this)
- An ack means the peer received the frame, not that it processed it. The ack itself can be lost after the peer handled the frame.
- On timeout / connection loss the send rejects, but the frame may still
have been delivered. Treat sends as at-least-once; if you resend
(e.g.
conn.outstandingafter a drain), the payload layer must dedupe on an application-level id. Sequence numbers here are per-connection and reset on reconnect -- they are not a global message id. - No ordering is guaranteed across a reconnect, nor between acked and fire-and-forget sends.
Testing
bun testTests run against loopback Bun.serve and an in-memory transport pipe with a
FakeClock, so they need no network and no wall-clock delays. Coverage:
reconnect-after-drop, keepalive timeout, handshake failure at each stage,
version/downgrade abort, drain-then-resend, and the collision tiebreak (both
ends pick the same survivor).
Deviations from the original SPEC
This is greenfield; the SPEC was a starting contract. Concrete changes:
peerIdis a required option. The collision tiebreak needs a stable peer id; the sketched surface omitted it.- Downgrade detection is real, not just an optional hook. The reference
sharedSecretAuthbinds the negotiated fingerprint and the canonical connId into every proof, so a mid-handshake capability downgrade or a tampered handshake nonce fails auth instead of being accepted. This also authenticates the collision-tiebreak input (seeDESIGN-FEEDBACK.md#1). - Mutual auth. The shared-secret provider authenticates both directions.
AuthStepmay carry a finalsendon its terminal step (needed to emit the last proof while declaring done).authis a factory (() => AuthProvider) so each session/reconnect gets fresh state.- Binary wire (updated). The library uses TEXT frames for the JSON control
envelope and BINARY frames (1-byte discriminator) for data payloads and bulk
chunks, so a binary codec rides with zero base64.
Codec.encodereturnsUint8Array | string;Codec.decodetakesUint8Array. AddedsendChunk()- a
chunkevent and abytesCodec().
- a
- AuthContext is complete. It now carries the raw local/remote hello
capability frames, a deterministic
transcriptHash, and the fullNegotiatedtuple including every slot's chosen name -- no reconstruction needed. - Slot negotiation replaced set intersection. Capabilities carry one
ordered name-list per slot (SSH RFC 4253 §7.1) instead of an
authMethodsset and a scalarpayloadEncoding. This removed two shapes of accidental opt-out: a host constructed without anauthoption used to advertiseauthMethods: []and run unauthenticated with no log line, and a side that advertised nopayloadEncodingused to inherit the peer's.noneis now a listed name that both sides must offer, and an omitted slot is refused.
Broader cross-package protocol concerns are recorded in DESIGN-FEEDBACK.md.
License
MIT
