npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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 before ready.
  • 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 FakeClock makes all timing deterministic in tests.

Install

bun add @claudewerk/bun-reliable-ws
# optional, only if you want the pino logger adapter:
bun add pino

Connection state machine

                 backoff + jitter (dialer only, on drop/dead)
              ┌───────────────────────────────────────────────┐
              │                                                │
              ▼                                                │
  idle ─▶ connecting ─▶ capabilities ─▶ authenticating ─▶ ready ─▶ draining ─▶ closed
                             │  │              │             ▲                    ▲
                             │  └─ no auth ────┼─────────────┘                    │
                             │                 │                                  │
                             └─────────────────┴── abort / dead / close ─────────┘
  • ready ─▶ draining is drain(); draining ─▶ closed completes 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 AuthProvider runs 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 closed with 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], tag is 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-invalid for a peer's. A peer that says nothing about a mechanism has not opted out of it.
  • none is 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's Ciphers none.
  • An opt-out is mutual. none wins only when both sides list it, so a peer cannot talk you out of a mechanism you require -- that is slot-mismatch.
  • A name you cannot run is fatal. Negotiating auth: "shared-secret" with no auth factory wired aborts slot-unsupported rather than skipping.
  • Resolving a security slot to none logs 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 close

Duplicate-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 fails
  • negotiated -- the full tuple: version, features, and the chosen name in every slot (auth, e2eSeal, e2eSign, codec); never null, because an unused mechanism resolves to the name none
  • localCapabilities / remoteCapabilities -- the raw hello frames as sent and as received (a MITM edit shows up in remoteCapabilities)
  • 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.outstanding after 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 test

Tests 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:

  • peerId is 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 sharedSecretAuth binds 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 (see DESIGN-FEEDBACK.md #1).
  • Mutual auth. The shared-secret provider authenticates both directions.
  • AuthStep may carry a final send on its terminal step (needed to emit the last proof while declaring done).
  • auth is 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.encode returns Uint8Array | string; Codec.decode takes Uint8Array. Added sendChunk()
    • a chunk event and a bytesCodec().
  • AuthContext is complete. It now carries the raw local/remote hello capability frames, a deterministic transcriptHash, and the full Negotiated tuple 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 authMethods set and a scalar payloadEncoding. This removed two shapes of accidental opt-out: a host constructed without an auth option used to advertise authMethods: [] and run unauthenticated with no log line, and a side that advertised no payloadEncoding used to inherit the peer's. none is 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