Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,115 @@
# @suaveplan/api-ws-bun - Bun.serve-backed WebSocket engine for @suaveplan/api-ws. Implements the abstract WsEngine contract using Bun's native WebSocket primitives: handshake pipeline freezing the principal, per-tenant ConnectionRegistry, SubscriptionBus integration, backpressure eviction (default 256 msg queue), heartbeat (30s ping / 2 missed-pong drop), per-tenant caps (1000), HMAC-signed reconnect tokens (5-min TTL) with auto/memory/broker event-buffer strategies, and the OTel surface from design §11.7. Passes runWsEngineConformance.
Bun.serve-backed WebSocket engine for the `@suaveplan/api-ws` Stage-1 abstract contract. The engine composes the per-connection handshake pipeline, registry, subscription bus, backpressure guard, heartbeat controller, principal-expiry watcher, reconnect-token issuer, reconnect-event buffer, and OTel surface into one `createBunWsEngine(opts)` factory call. Passes `runWsEngineConformance` and ships a real `Bun.serve` integration smoke under `tests/integration/`.
<!-- begin:auto-docs -->
## Documentation tree
- [backpressure](./src/backpressure/backpressure.md) — Enforces the per-connection send-queue cap described in design §11.4. Each connection owns one `BackpressureGuard` inst…
- [backpressure](./src/backpressure/backpressure.md) — Enforces the per-connection send-queue cap described in design §11.4. Each connection owns one `BackpressureGuard` inst…
- [engine](./src/engine/bun-ws-engine.md) — `createBunWsEngine` is the package's headline factory. It composes every other module in the package — registry, bus, i…
- [bun-ws-engine](./src/engine/bun-ws-engine.md) — `createBunWsEngine` is the package's headline factory. It composes every other module in the package — registry, bus, i…
- [error-codes](./src/error-codes/error-codes.md) — Provides the stable error-code registry and the typed `WsBunError` subclass for `@suaveplan/api-ws-bun`. Every producti…
- [error-codes](./src/error-codes/error-codes.md) — Provides the stable error-code registry and the typed `WsBunError` subclass for `@suaveplan/api-ws-bun`. Every producti…
- [handshake](./src/handshake/handshake.md) — Freezes the per-request pipeline result onto a per-connection `HandshakeOutcome`. The host runs the standard pipeline s…
- [handshake](./src/handshake/handshake.md) — Freezes the per-request pipeline result onto a per-connection `HandshakeOutcome`. The host runs the standard pipeline s…
- [subprotocol](./src/handshake/subprotocol.md) — Implements RFC 6455 §4.1 subprotocol negotiation as a tiny pure function. The server is supplied the client's requested…
- [heartbeat](./src/heartbeat/heartbeat.md) — Implements the per-connection ping/pong state machine described in design §11.4. The engine instantiates one `Heartbeat…
- [heartbeat](./src/heartbeat/heartbeat.md) — Implements the per-connection ping/pong state machine described in design §11.4. The engine instantiates one `Heartbeat…
- [instruments](./src/instruments/instruments.md) — Builds the OTel instrument registry the engine emits to. The names, units, and attribute keys all match design doc §11.…
- [instruments](./src/instruments/instruments.md) — Builds the OTel instrument registry the engine emits to. The names, units, and attribute keys all match design doc §11.…
- [limits](./src/limits/tenant-cap.md) — Provides the per-tenant connection-count enforcement function that the handshake pipeline calls before admitting a new…
- [tenant-cap](./src/limits/tenant-cap.md) — Provides the per-tenant connection-count enforcement function that the handshake pipeline calls before admitting a new…
- [options](./src/options/options.md) — Defines the Zod-validated options surface accepted by `createBunWsEngine`. Every field has a documented default sourced…
- [options](./src/options/options.md) — Defines the Zod-validated options surface accepted by `createBunWsEngine`. Every field has a documented default sourced…
- [principal](./src/principal/principal-expiry.md) — Tracks the underlying token expiry for a frozen principal so the engine can drop the connection at the right moment. Th…
- [principal-expiry](./src/principal/principal-expiry.md) — Tracks the underlying token expiry for a frozen principal so the engine can drop the connection at the right moment. Th…
- [event-buffer](./src/reconnect/event-buffer.md) — Provides the per-`(tenant, topic)` reconnect-event buffer used to replay events to a reconnecting client whose `lastEve…
- [reconnect-token](./src/reconnect/reconnect-token.md) — Wraps the api-ws contract's `signReconnectToken` / `verifyReconnectToken` HMAC token helpers so the engine has a statef…
- [registry](./src/registry/connection-registry.md) — Wraps the api-ws contract's `InMemoryConnectionRegistry` to give the Bun engine a typed registry surface that the confo…
- [connection-registry](./src/registry/connection-registry.md) — Wraps the api-ws contract's `InMemoryConnectionRegistry` to give the Bun engine a typed registry surface that the confo…
- [subscription](./src/subscription/subscription-bus.md) — Builds the per-engine subscription bus instance and binds it to the engine's connection registry. The bus implementatio…
- [subscription-bus](./src/subscription/subscription-bus.md) — Builds the per-engine subscription bus instance and binds it to the engine's connection registry. The bus implementatio…
- [testing](./src/testing/conformance-binding.md) — Binds the abstract `runWsEngineConformance` harness from `@suaveplan/api-ws/testing` to a default `createBunWsEngine`-b…
- [conformance-binding](./src/testing/conformance-binding.md) — Binds the abstract `runWsEngineConformance` harness from `@suaveplan/api-ws/testing` to a default `createBunWsEngine`-b…
<!-- end:auto-docs -->
## Defaults
| Option | Default | Source |
|---|---|---|
| `maxQueuedMessages` | `256` | §11.4 |
| `heartbeatMs` | `30_000` (min 5_000) | §11.4 |
| `missedPongLimit` | `2` | §11.4 |
| `maxConnectionsPerTenant` | `1_000` | §11.4 |
| `reconnectTokenTtlMs` | `300_000` | §11.5 |
| `reconnectEventBufferStrategy` | `"auto"` | §11.5 + §14 |
| `reconnectEventBufferLimit` | `256` | engine |
| `subprotocols` | `[]` | engine |
| `pluginId` | `"api-ws-bun"` | engine |
## Modules
- `engine` — `createBunWsEngine`, `ManagedConnection`, `BunWsSocketSink`.
- `options` — `BunWsEngineOptions`, `bunWsEngineOptionsSchema`, `resolveBunWsEngineOptions`.
- `handshake` — `runHandshake`, `resolveSubprotocol`.
- `registry` — `BunConnectionRegistry`.
- `subscription` — `createBunSubscriptionBus`.
- `backpressure` — `BackpressureGuard`.
- `heartbeat` — `HeartbeatController`.
- `limits` — `enforceTenantCap`.
- `principal` — `PrincipalExpiryWatcher`.
- `reconnect` — `ReconnectTokenIssuer`, `MemoryReconnectEventBuffer`, `BrokerReconnectEventBuffer`, `resolveReconnectEventBuffer`.
- `instruments` — `createBunWsInstruments` (every §11.7 instrument).
- `error-codes` — `WS_BUN_CODES`, `WsBunError`, ten `raise*` helpers.
- `testing` (subpath `@suaveplan/api-ws-bun/testing`) — `runBunWsConformance`, `bunEngineFactory`, `adaptEngineToFactory`.
## Example
```ts
import { createBunWsEngine } from "@suaveplan/api-ws-bun";
import { createInMemoryWsBroker } from "@suaveplan/api-ws-broker-in-memory";
const broker = createInMemoryWsBroker();
const engine = createBunWsEngine({
reconnectTokenSecret: new Uint8Array(32).fill(7),
broker,
});
Bun.serve({
port: 8787,
fetch(req, srv) {
if (new URL(req.url).pathname === "/ws") {
return srv.upgrade(req, { data: { id: crypto.randomUUID() } })
? undefined
: new Response("upgrade failed", { status: 400 });
}
return new Response("ok");
},
websocket: {
open(ws) {
engine.attach({
id: ws.data.id,
principal,
tenantId,
capabilities: new Set(),
tokenExpiresAt: Date.now() + 3_600_000,
sink: {
send: (s) => ws.send(s),
close: (c, r) => ws.close(c, r),
ping: () => ws.ping?.(),
},
});
},
message() {},
close() {},
},
});
```
## Tests
```bash
bunx turbo lint typecheck test:coverage --filter=@suaveplan/api-ws-bun
```

View File

@@ -0,0 +1,68 @@
{
"name": "@suaveplan/api-ws-bun",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Bun.serve-backed WebSocket engine for @suaveplan/api-ws. Implements the abstract WsEngine contract using Bun's native WebSocket primitives: handshake pipeline freezing the principal, per-tenant ConnectionRegistry, SubscriptionBus integration, backpressure eviction (default 256 msg queue), heartbeat (30s ping / 2 missed-pong drop), per-tenant caps (1000), HMAC-signed reconnect tokens (5-min TTL) with auto/memory/broker event-buffer strategies, and the OTel surface from design §11.7. Passes runWsEngineConformance.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./testing": {
"import": {
"types": "./dist/testing/index.d.ts",
"default": "./dist/testing/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/api-types": "workspace:^",
"@suaveplan/api-ws": "workspace:^",
"@suaveplan/api-ws-broker-in-memory": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/opentelemetry": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

@@ -0,0 +1,60 @@
# `backpressure`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Enforces the per-connection send-queue cap described in design §11.4. Each connection owns one `BackpressureGuard` instance; every outbound message increments the guard's depth, and the guard fires the eviction callback (and throws `WS_BUN.BACKPRESSURE_EVICTED`) the first time the depth exceeds `maxQueuedMessages`.
The guard reuses `evaluateBackpressure` from `@suaveplan/api-ws` so the eviction threshold stays identical across every engine that implements the contract — only the storage / wire layer differs.
## Features
- Constant-time `enqueue()` and `release(n)` operations.
- Idempotent eviction — re-`enqueue` after eviction throws the same `WS_BUN.BACKPRESSURE_EVICTED` code rather than firing the callback twice.
- Exposes `queued` depth and `isEvicted` for instruments and assertions.
## Usage: Basic
```ts
import { BackpressureGuard } from "@suaveplan/api-ws-bun";
const guard = new BackpressureGuard("c1", 4, (id) => console.log(`evict ${id}`));
for (let i = 0; i < 4; i++) guard.enqueue();
console.log(guard.queued); // 4
guard.release(2);
console.log(guard.queued); // 2
```
## Usage: Advanced
```ts
import { BackpressureGuard, WsBunError } from "@suaveplan/api-ws-bun";
const guard = new BackpressureGuard("c2", 2, () => undefined);
try {
for (let i = 0; i < 5; i++) guard.enqueue();
} catch (err) {
if (err instanceof WsBunError) {
// err.wsBunCode === "WS_BUN.BACKPRESSURE_EVICTED"
// The engine maps this onto a close frame with the same reason.
}
}
console.log(guard.isEvicted); // true
```
## API Reference
- `new BackpressureGuard(connectionId, maxQueuedMessages, onEvict)` — constructor.
- `guard.enqueue(): "accept"` — accounts for one outbound message; throws on overflow.
- `guard.release(n?: number): void` — frees up `n` slots after the socket flushed; defaults to `1`.
- `guard.queued: number` — current queue depth.
- `guard.isEvicted: boolean` — true once eviction has fired.
## Implementation Notes
The guard intentionally throws inside `enqueue()` rather than returning `"evict"` to the caller. This lets the engine treat backpressure overflow as a thrown error that propagates through the OTel span automatically, keeping every overflow path symmetrical with the other `WS_BUN.*` codes.
`release` clamps the depth at zero so a buggy caller cannot make the guard "lend" capacity it never accounted for. The clamp is silent — overshoot is treated as ignorable bookkeeping noise.

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WS_BUN_CODES, WsBunError } from "../error-codes/error-codes.js";
import { BackpressureGuard } from "./backpressure.js";
describe("BackpressureGuard", () => {
it("accepts up to the cap", { timeout: 5000 }, () => {
const guard = new BackpressureGuard("c1", 4, () => undefined);
for (let i = 0; i < 4; i += 1) guard.enqueue();
expect(guard.queued).toBe(4);
expect(guard.isEvicted).toBe(false);
});
it("evicts on overflow", { timeout: 5000 }, () => {
let evicted: string | null = null;
const guard = new BackpressureGuard("c2", 2, (id) => {
evicted = id;
});
guard.enqueue();
guard.enqueue();
try {
guard.enqueue();
throw new Error("expected throw");
} catch (err) {
expect(err instanceof WsBunError).toBe(true);
expect((err as WsBunError).wsBunCode).toBe(
WS_BUN_CODES.BACKPRESSURE_EVICTED
);
}
expect(evicted).toBe("c2");
expect(guard.isEvicted).toBe(true);
});
it("subsequent enqueue after eviction re-throws", { timeout: 5000 }, () => {
let count = 0;
const guard = new BackpressureGuard("c3", 1, () => {
count += 1;
});
guard.enqueue();
try {
guard.enqueue();
} catch {
// first eviction
}
try {
guard.enqueue();
} catch (err) {
expect((err as WsBunError).wsBunCode).toBe(
WS_BUN_CODES.BACKPRESSURE_EVICTED
);
}
// Evict callback fired only once.
expect(count).toBe(1);
});
it("release decrements queued depth", { timeout: 5000 }, () => {
const guard = new BackpressureGuard("c4", 10, () => undefined);
guard.enqueue();
guard.enqueue();
guard.release(1);
expect(guard.queued).toBe(1);
guard.release(5); // overshoot
expect(guard.queued).toBe(0);
});
it("release defaults to 1", { timeout: 5000 }, () => {
const guard = new BackpressureGuard("c5", 10, () => undefined);
guard.enqueue();
guard.release();
expect(guard.queued).toBe(0);
});
});

View File

@@ -0,0 +1,57 @@
import { evaluateBackpressure } from "@suaveplan/api-ws";
import { raiseBackpressureEvicted } from "../error-codes/error-codes.js";
/**
* Per-connection send queue guard. The Bun engine bumps `count` on every
* outbound enqueue (before handing the frame to `ws.send`) and decrements
* on flush completion.
*
* The guard does not own the wire-level transmit — it owns the bookkeeping
* needed to decide "evict or keep" against the configured policy.
*/
export class BackpressureGuard {
private depth = 0;
private evicted = false;
public constructor(
public readonly connectionId: string,
private readonly maxQueuedMessages: number,
private readonly onEvict: (id: string) => void
) {}
/**
* Account for an outbound message. Returns `"accept"` when within the
* cap; on overflow, throws `WS_BUN.BACKPRESSURE_EVICTED` and invokes
* `onEvict` so the engine can close the underlying socket.
*/
public enqueue(): "accept" {
if (this.evicted) {
raiseBackpressureEvicted(this.connectionId);
}
this.depth += 1;
const action = evaluateBackpressure(this.depth, {
maxQueuedMessages: this.maxQueuedMessages,
});
if (action === "evict") {
this.evicted = true;
this.onEvict(this.connectionId);
raiseBackpressureEvicted(this.connectionId);
}
return "accept";
}
/** Called by the engine when the socket has flushed `n` frames. */
public release(n = 1): void {
this.depth = Math.max(0, this.depth - n);
}
/** Current queue depth — exposed for instruments + tests. */
public get queued(): number {
return this.depth;
}
/** Whether the guard has already triggered eviction. */
public get isEvicted(): boolean {
return this.evicted;
}
}

View File

@@ -0,0 +1 @@
export { BackpressureGuard } from "./backpressure.js";

View File

@@ -0,0 +1,89 @@
# `bun-ws-engine`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createBunWsEngine` is the package's headline factory. It composes every other module in the package — registry, bus, instruments, backpressure guard, heartbeat controller, reconnect token issuer, reconnect buffer, principal-expiry watcher — into a single `BunWsEngine` value that the host wires into `Bun.serve`'s `websocket` option. The factory is also the entry the conformance harness drives via `makeTestConnection`, so the unit-level conformance run exercises the same code paths a production handshake takes.
The engine owns connection lifetime: it freezes the principal at handshake, holds per-connection backpressure and heartbeat state, fires `principal_expired` listeners when the underlying token lapses, and emits the OTel surface from design §11.7 against every state transition.
## Features
- One-line composition of the full §11 connection lifecycle.
- Drop-in `(bus, registry, broker)` triple for `runWsEngineConformance`.
- `engine.attach({ ... })` runs handshake validation, enforces the per-tenant cap, instantiates the backpressure / heartbeat / principal-expiry machinery, and wires the sink onto the resulting `ManagedConnection`.
- `engine.broadcast(caller, topic, opts)` records to `ws.broadcasts_total`, fans out via the bus, and persists the broadcast into the reconnect-event buffer in one call.
- `engine.onPrincipalExpired(listener)` registers a host-level listener for the `connection.principal_expired` event.
- `engine.dispose()` closes every open connection and tears down the reconnect buffer.
## Usage: Basic
```ts
import { createBunWsEngine } from "@suaveplan/api-ws-bun";
const engine = createBunWsEngine({
reconnectTokenSecret: new Uint8Array(32).fill(7),
});
const conn = engine.attach({
id: "c1",
principal,
tenantId,
capabilities: new Set(),
tokenExpiresAt: Date.now() + 60_000,
sink: { send: () => undefined, close: () => undefined },
});
conn.subscribe("orders");
engine.broadcast(
{ tenant: tenantId, capabilities: new Set() },
"orders",
{ tenant: tenantId, payload: { ok: true } }
);
```
## Usage: Advanced
```ts
import { createInMemoryWsBroker } from "@suaveplan/api-ws-broker-in-memory";
import { createBunWsEngine, WS_BUN_CODES } from "@suaveplan/api-ws-bun";
const broker = createInMemoryWsBroker();
const engine = createBunWsEngine({
reconnectTokenSecret: new Uint8Array(32).fill(11),
broker,
heartbeatMs: 10_000,
missedPongLimit: 3,
maxConnectionsPerTenant: 5_000,
reconnectEventBufferStrategy: "auto",
pluginId: "demo-plugin",
});
engine.onPrincipalExpired((evt) => log.warn(evt));
try {
engine.attach({ id: "c-overflow", principal, tenantId, capabilities: new Set(), tokenExpiresAt: Date.now() + 60_000, sink });
} catch (err) {
if ((err as { code: string }).code === WS_BUN_CODES.TENANT_CAP_EXCEEDED) {
// surface 429
}
}
await engine.dispose();
```
## API Reference
- `CreateBunWsEngineInput` — `BunWsEngineOptions & { broker?, clock? }`.
- `BunWsEngine<Payload>` — `{ bus, registry, broker, instruments, options, reconnectTokens, reconnectBuffer, attach, recordInbound, broadcast, onPrincipalExpired, dispose }`.
- `AttachConnectionInput` — args to `engine.attach`.
- `ManagedConnection extends WsConnection` — exposes the per-connection guards for assertions.
- `BunWsSocketSink` — `{ send, close, ping? }`.
- `createBunWsEngine(input): BunWsEngine` — factory.
- `makeTestConnection(engine, { id, tenant })` — conformance harness helper.
## Implementation Notes
The engine deliberately keeps the sink abstract (`BunWsSocketSink`) rather than depending on Bun's `ServerWebSocket` type. In production the host adapts a `ServerWebSocket` into a sink with three methods; in tests the sink is a closure capturing sent frames. This split is what lets the unit test suite drive the engine without spinning up Bun.serve, while the integration test (`tests/integration/end-to-end.test.ts`) drives the full Bun listener path against the same sink contract.
The `lastEventId` and `state` fields on `ManagedConnection` use getters so the engine can mutate the underlying closures without breaking `WsConnection`'s readonly contract. The contract is a structural type, so this is fine.
The principal-expiry callback flips `state` to `"closing"` but does not eagerly close the socket; the engine waits for the next handshake or send to drain the connection. This matches the spec wording — "host emits `connection.principal_expired`; plugin's `onClose` MUST then be invokable" — which puts the actual close decision on the plugin, not the engine.

View File

@@ -0,0 +1,7 @@
import { runWsEngineConformance } from "@suaveplan/api-ws/testing";
import { bunEngineFactory } from "../testing/conformance-binding.js";
runWsEngineConformance({
label: "bun",
factory: bunEngineFactory,
});

View File

@@ -0,0 +1,559 @@
import type { Principal, PrincipalId } from "@suaveplan/api-types/principal";
import type { TenantId } from "@suaveplan/api-types/tenant";
import { createInMemoryWsBroker } from "@suaveplan/api-ws-broker-in-memory";
import { FakeClock } from "@suaveplan/testing";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WS_BUN_CODES, type WsBunError } from "../error-codes/error-codes.js";
import {
type BunWsSocketSink,
createBunWsEngine,
makeTestConnection,
} from "./bun-ws-engine.js";
const tenantA = "tenant-a" as unknown as TenantId;
const tenantB = "tenant-b" as unknown as TenantId;
const principal: Principal = {
id: "p1" as unknown as PrincipalId,
scopes: [],
claims: {},
};
interface Captured {
sent: string[];
closed: { code: number; reason?: string }[];
pings: number;
}
function captureSink(): { sink: BunWsSocketSink; capture: Captured } {
const capture: Captured = { sent: [], closed: [], pings: 0 };
const sink: BunWsSocketSink = {
send: (data: string) => {
capture.sent.push(data);
},
close: (code: number, reason?: string) => {
capture.closed.push({ code, reason });
},
ping: () => {
capture.pings += 1;
},
};
return { sink, capture };
}
function secret(): Uint8Array {
return new Uint8Array(32).fill(11);
}
describe("createBunWsEngine — handshake + send + close", () => {
it("attach mints a managed connection", { timeout: 5000 }, () => {
const engine = createBunWsEngine({ reconnectTokenSecret: secret() });
const { sink } = captureSink();
const conn = engine.attach({
id: "c1",
principal,
tenantId: tenantA,
capabilities: new Set(),
tokenExpiresAt: Number.MAX_SAFE_INTEGER,
sink,
});
expect(conn.id).toBe("c1");
expect(conn.principal).toBe(principal);
expect(conn.tenantId).toBe(tenantA);
expect(engine.registry.countByTenant(tenantA)).toBe(1);
});
it("send routes through the sink + emits messages_out", {
timeout: 5000,
}, () => {
const engine = createBunWsEngine({ reconnectTokenSecret: secret() });
const { sink, capture } = captureSink();
const conn = engine.attach({
id: "c1",
principal,
tenantId: tenantA,
capabilities: new Set(),
tokenExpiresAt: Number.MAX_SAFE_INTEGER,
sink,
});
conn.send({ id: "e1", type: "broadcast", topic: "orders", payload: 1 });
expect(capture.sent.length).toBe(1);
expect(JSON.parse(capture.sent[0] as string)).toMatchObject({
id: "e1",
type: "broadcast",
});
});
it("subscribe + broadcast delivers via the bus", {
timeout: 5000,
}, () => {
const engine = createBunWsEngine({ reconnectTokenSecret: secret() });
const { sink, capture } = captureSink();
const conn = engine.attach({
id: "c1",
principal,
tenantId: tenantA,
capabilities: new Set(),
tokenExpiresAt: Number.MAX_SAFE_INTEGER,
sink,
});
conn.subscribe("orders");
const delivered = engine.broadcast(
{ tenant: tenantA, capabilities: new Set() },
"orders",
{ tenant: tenantA, payload: 99 }
);
expect(delivered).toBe(1);
expect(capture.sent.length).toBe(1);
});
it("broadcast persists into the reconnect buffer", {
timeout: 5000,
}, () => {
const engine = createBunWsEngine({ reconnectTokenSecret: secret() });
engine.broadcast(
{ tenant: tenantA, capabilities: new Set() },
"orders",
{ tenant: tenantA, payload: 1, id: "evt-1" }
);
const replay = engine.reconnectBuffer.since(tenantA, "orders", null);
expect(replay.length).toBe(1);
expect(replay[0]?.id).toBe("evt-1");
});
it("close surfaces WS_BUN.CLOSE_TIMEOUT when sink.close throws", {
timeout: 5000,
}, () => {
const engine = createBunWsEngine({ reconnectTokenSecret: secret() });
const sink: BunWsSocketSink = {
send: () => undefined,
close: () => {
throw new Error("socket already torn down");
},
};
const conn = engine.attach({
id: "c-x",
principal,
tenantId: tenantA,
capabilities: new Set(),
tokenExpiresAt: Number.MAX_SAFE_INTEGER,
sink,
});
try {
conn.close(1011, "boom");
throw new Error("expected throw");
} catch (err) {
expect((err as WsBunError).wsBunCode).toBe(
WS_BUN_CODES.CLOSE_TIMEOUT
);
}
});
it("close removes from registry", { timeout: 5000 }, () => {
const engine = createBunWsEngine({ reconnectTokenSecret: secret() });
const { sink } = captureSink();
const conn = engine.attach({
id: "c1",
principal,
tenantId: tenantA,
capabilities: new Set(),
tokenExpiresAt: Number.MAX_SAFE_INTEGER,
sink,
});
conn.close(1000, "bye");
expect(engine.registry.countByTenant(tenantA)).toBe(0);
// close after close is a no-op.
conn.close(1000);
});
it("state exposes current connection lifecycle", { timeout: 5000 }, () => {
const engine = createBunWsEngine({ reconnectTokenSecret: secret() });
const { sink } = captureSink();
const conn = engine.attach({
id: "c1",
principal,
tenantId: tenantA,
capabilities: new Set(),
tokenExpiresAt: Number.MAX_SAFE_INTEGER,
sink,
});
expect(conn.state).toBe("open");
conn.close(1000);
expect(conn.state).toBe("closed");
});
it("unsubscribe stops broadcast delivery", { timeout: 5000 }, () => {
const engine = createBunWsEngine({ reconnectTokenSecret: secret() });
const { sink, capture } = captureSink();
const conn = engine.attach({
id: "c1",
principal,
tenantId: tenantA,
capabilities: new Set(),
tokenExpiresAt: Number.MAX_SAFE_INTEGER,
sink,
});
conn.subscribe("orders");
engine.broadcast(
{ tenant: tenantA, capabilities: new Set() },
"orders",
{ tenant: tenantA, payload: 1 }
);
expect(capture.sent.length).toBe(1);
conn.unsubscribe("orders");
engine.broadcast(
{ tenant: tenantA, capabilities: new Set() },
"orders",
{ tenant: tenantA, payload: 2 }
);
expect(capture.sent.length).toBe(1);
});
it("subscribe forwards filter when supplied", { timeout: 5000 }, () => {
const engine = createBunWsEngine({ reconnectTokenSecret: secret() });
const { sink, capture } = captureSink();
const conn = engine.attach({
id: "c1",
principal,
tenantId: tenantA,
capabilities: new Set(),
tokenExpiresAt: Number.MAX_SAFE_INTEGER,
sink,
});
conn.subscribe("orders", { filter: (p) => p === 1 });
engine.broadcast(
{ tenant: tenantA, capabilities: new Set() },
"orders",
{ tenant: tenantA, payload: 1 }
);
engine.broadcast(
{ tenant: tenantA, capabilities: new Set() },
"orders",
{ tenant: tenantA, payload: 2 }
);
expect(capture.sent.length).toBe(1);
});
it("ack updates lastEventId", { timeout: 5000 }, () => {
const engine = createBunWsEngine({ reconnectTokenSecret: secret() });
const { sink } = captureSink();
const conn = engine.attach({
id: "c1",
principal,
tenantId: tenantA,
capabilities: new Set(),
tokenExpiresAt: Number.MAX_SAFE_INTEGER,
sink,
});
expect(conn.lastEventId).toBeNull();
conn.ack("evt-7");
expect(conn.lastEventId).toBe("evt-7");
});
it("send after close is a no-op", { timeout: 5000 }, () => {
const engine = createBunWsEngine({ reconnectTokenSecret: secret() });
const { sink, capture } = captureSink();
const conn = engine.attach({
id: "c1",
principal,
tenantId: tenantA,
capabilities: new Set(),
tokenExpiresAt: Number.MAX_SAFE_INTEGER,
sink,
});
conn.close(1000);
conn.send({ id: "x", type: "n", payload: 1 });
expect(capture.sent.length).toBe(0);
});
it("subscribe after close is a no-op", { timeout: 5000 }, () => {
const engine = createBunWsEngine({ reconnectTokenSecret: secret() });
const { sink } = captureSink();
const conn = engine.attach({
id: "c1",
principal,
tenantId: tenantA,
capabilities: new Set(),
tokenExpiresAt: Number.MAX_SAFE_INTEGER,
sink,
});
conn.close(1000);
conn.subscribe("orders");
expect(engine.registry.lookupByTopic("orders", tenantA).length).toBe(0);
});
});
describe("createBunWsEngine — backpressure", () => {
it("queue overflow evicts the connection with BACKPRESSURE_EVICTED", {
timeout: 5000,
}, () => {
const engine = createBunWsEngine({
reconnectTokenSecret: secret(),
maxQueuedMessages: 1,
});
const { sink, capture } = captureSink();
// Stub the sink to never "flush" — every enqueue stays counted.
const original = sink.send;
let inFlight = 0;
sink.send = (data: string) => {
inFlight += 1;
original(data);
};
const conn = engine.attach({
id: "c1",
principal,
tenantId: tenantA,
capabilities: new Set(),
tokenExpiresAt: Number.MAX_SAFE_INTEGER,
sink,
});
// Mute release so enqueues accumulate.
conn.backpressure.release = () => undefined;
conn.send({ id: "e1", type: "n", payload: 1 });
try {
conn.send({ id: "e2", type: "n", payload: 2 });
throw new Error("expected throw");
} catch (err) {
expect((err as WsBunError).wsBunCode).toBe(
WS_BUN_CODES.BACKPRESSURE_EVICTED
);
}
expect(capture.closed.some((c) => c.code === 1013)).toBe(true);
expect(inFlight).toBeGreaterThan(0);
});
});
describe("createBunWsEngine — heartbeat", () => {
it("2 missed pongs drops the connection with code ABNORMAL", {
timeout: 5000,
}, () => {
const clock = new FakeClock({ startTime: 1_000 });
const engine = createBunWsEngine({
reconnectTokenSecret: secret(),
heartbeatMs: 5_000,
missedPongLimit: 2,
clock: { now: () => clock.now() },
});
const { sink, capture } = captureSink();
const conn = engine.attach({
id: "c-hb",
principal,
tenantId: tenantA,
capabilities: new Set(),
tokenExpiresAt: Number.MAX_SAFE_INTEGER,
sink,
});
clock.advance(5000);
conn.heartbeat.tick(); // miss 1
clock.advance(5000);
conn.heartbeat.tick(); // miss 2
clock.advance(5000);
try {
conn.heartbeat.tick(); // misses >= limit → abort
throw new Error("expected throw");
} catch (err) {
expect((err as WsBunError).wsBunCode).toBe(
WS_BUN_CODES.HEARTBEAT_FAILED
);
}
expect(capture.closed.some((c) => c.code === 1006)).toBe(true);
});
});
describe("createBunWsEngine — tenant cap", () => {
it("1001st connection from same tenant rejected with TENANT_CAP_EXCEEDED", {
timeout: 5000,
}, () => {
const engine = createBunWsEngine({
reconnectTokenSecret: secret(),
maxConnectionsPerTenant: 3,
});
for (let i = 0; i < 3; i += 1) {
const { sink } = captureSink();
engine.attach({
id: `c${i}`,
principal,
tenantId: tenantA,
capabilities: new Set(),
tokenExpiresAt: Number.MAX_SAFE_INTEGER,
sink,
});
}
const { sink } = captureSink();
try {
engine.attach({
id: "c-over",
principal,
tenantId: tenantA,
capabilities: new Set(),
tokenExpiresAt: Number.MAX_SAFE_INTEGER,
sink,
});
throw new Error("expected throw");
} catch (err) {
expect((err as WsBunError).wsBunCode).toBe(
WS_BUN_CODES.TENANT_CAP_EXCEEDED
);
}
});
});
describe("createBunWsEngine — principal expiry", () => {
it("fires onPrincipalExpired listener when expiresAt passes", {
timeout: 5000,
}, () => {
const clock = new FakeClock({ startTime: 0 });
const engine = createBunWsEngine({
reconnectTokenSecret: secret(),
clock: { now: () => clock.now() },
});
const events: Array<{ connectionId: string; principalId: string }> = [];
engine.onPrincipalExpired((e) => events.push(e));
const { sink } = captureSink();
const conn = engine.attach({
id: "c-expire",
principal,
tenantId: tenantA,
capabilities: new Set(),
tokenExpiresAt: 100,
sink,
});
clock.advance(100);
try {
conn.principalExpiry.check();
} catch {
/* expected */
}
expect(events.length).toBe(1);
expect(events[0]?.principalId).toBe("p1");
});
it("onPrincipalExpired returns an unsubscribe fn", {
timeout: 5000,
}, () => {
const engine = createBunWsEngine({ reconnectTokenSecret: secret() });
let calls = 0;
const off = engine.onPrincipalExpired(() => {
calls += 1;
});
off();
// Trigger via raw bus path — we won't fire it here, just ensure it doesn't blow up.
expect(typeof off).toBe("function");
expect(calls).toBe(0);
});
});
describe("createBunWsEngine — reconnect tokens", () => {
it("issued token resumes a session", { timeout: 5000 }, async () => {
const engine = createBunWsEngine({ reconnectTokenSecret: secret() });
const token = await engine.reconnectTokens.sign({
tenant: "tenant-a",
principalId: "p1",
lastEventId: "evt-5",
});
const payload = await engine.reconnectTokens.verify(token);
expect(payload.lastEventId).toBe("evt-5");
});
it("attach supports resumedFrom to restore lastEventId", {
timeout: 5000,
}, () => {
const engine = createBunWsEngine({ reconnectTokenSecret: secret() });
const { sink } = captureSink();
const conn = engine.attach({
id: "c-resume",
principal,
tenantId: tenantA,
capabilities: new Set(),
tokenExpiresAt: Number.MAX_SAFE_INTEGER,
sink,
resumedFrom: "evt-7",
});
expect(conn.lastEventId).toBe("evt-7");
});
});
describe("createBunWsEngine — connectionsSnapshot", () => {
it("returns 0 with no attached connections", { timeout: 5000 }, () => {
const engine = createBunWsEngine({ reconnectTokenSecret: secret() });
expect(engine.connectionsSnapshot()).toBe(0);
});
it("counts attached connections + decrements on close", {
timeout: 5000,
}, () => {
const engine = createBunWsEngine({ reconnectTokenSecret: secret() });
const { sink: s1 } = captureSink();
const { sink: s2 } = captureSink();
engine.attach({
id: "cs1",
principal,
tenantId: tenantA,
capabilities: new Set(),
tokenExpiresAt: Number.MAX_SAFE_INTEGER,
sink: s1,
});
const c2 = engine.attach({
id: "cs2",
principal,
tenantId: tenantA,
capabilities: new Set(),
tokenExpiresAt: Number.MAX_SAFE_INTEGER,
sink: s2,
});
expect(engine.connectionsSnapshot()).toBe(2);
c2.close(1000);
expect(engine.connectionsSnapshot()).toBe(1);
});
});
describe("createBunWsEngine — dispose", () => {
it("closes every active connection", { timeout: 5000 }, async () => {
const engine = createBunWsEngine({ reconnectTokenSecret: secret() });
const captures: Captured[] = [];
for (let i = 0; i < 3; i += 1) {
const { sink, capture } = captureSink();
captures.push(capture);
engine.attach({
id: `c${i}`,
principal,
tenantId: tenantA,
capabilities: new Set(),
tokenExpiresAt: Number.MAX_SAFE_INTEGER,
sink,
});
}
await engine.dispose();
for (const cap of captures) {
expect(cap.closed.length).toBeGreaterThan(0);
}
expect(engine.registry.countByTenant(tenantA)).toBe(0);
});
});
describe("createBunWsEngine — recordInbound + broker wiring", () => {
it("recordInbound increments without throwing", { timeout: 5000 }, () => {
const engine = createBunWsEngine({ reconnectTokenSecret: secret() });
const conn = makeTestConnection(engine, { id: "c1", tenant: tenantA });
engine.recordInbound(conn);
engine.recordInbound(conn, "/custom");
});
it("makeTestConnection wires a fresh connection through attach", {
timeout: 5000,
}, () => {
const engine = createBunWsEngine({ reconnectTokenSecret: secret() });
const conn = makeTestConnection(engine, { id: "c1", tenant: tenantB });
expect(conn.tenantId).toBe(tenantB);
expect(engine.registry.countByTenant(tenantB)).toBe(1);
});
it("respects supplied broker reference", { timeout: 5000 }, () => {
const broker = createInMemoryWsBroker();
const engine = createBunWsEngine({
reconnectTokenSecret: secret(),
broker,
});
expect(engine.broker).toBe(broker);
});
});

View File

@@ -0,0 +1,345 @@
import type { CapabilityId } from "@suaveplan/api-types/capabilities";
import type { Principal, PrincipalId } from "@suaveplan/api-types/principal";
import type { TenantId } from "@suaveplan/api-types/tenant";
import type {
SubscribeOptions,
SubscriptionBus,
WsBroker,
WsConnection,
WsConnectionState,
WsEnvelope,
} from "@suaveplan/api-ws";
import { BackpressureGuard } from "../backpressure/backpressure.js";
import { raiseCloseTimeout } from "../error-codes/error-codes.js";
import { runHandshake } from "../handshake/handshake.js";
import {
type HeartbeatClock,
HeartbeatController,
} from "../heartbeat/heartbeat.js";
import {
type BunWsInstruments,
createBunWsInstruments,
} from "../instruments/instruments.js";
import { enforceTenantCap } from "../limits/tenant-cap.js";
import {
type BunWsEngineOptions,
type ResolvedBunWsEngineOptions,
resolveBunWsEngineOptions,
} from "../options/options.js";
import {
type PrincipalExpiryListener,
PrincipalExpiryWatcher,
} from "../principal/principal-expiry.js";
import {
type BufferedEvent,
type ReconnectEventBuffer,
resolveReconnectEventBuffer,
} from "../reconnect/event-buffer.js";
import { ReconnectTokenIssuer } from "../reconnect/reconnect-token.js";
import { BunConnectionRegistry } from "../registry/connection-registry.js";
import { createBunSubscriptionBus } from "../subscription/subscription-bus.js";
/** Sink used by tests + real Bun WS to actually transmit frames. */
export interface BunWsSocketSink {
send(data: string): void;
close(code: number, reason?: string): void;
ping?(): void;
}
/** Args to `engine.attach` — driven by the handshake outcome. */
export interface AttachConnectionInput {
readonly id: string;
readonly principal: Principal;
readonly tenantId: TenantId;
readonly capabilities: ReadonlySet<CapabilityId>;
readonly tokenExpiresAt: number;
readonly sink: BunWsSocketSink;
/** Optional resumed-from-reconnect-token last event id. */
readonly resumedFrom?: string | null;
}
/** Reference to the engine-owned per-connection bag, exposed for tests. */
export interface ManagedConnection extends WsConnection {
readonly backpressure: BackpressureGuard;
readonly heartbeat: HeartbeatController;
readonly principalExpiry: PrincipalExpiryWatcher;
}
/** Full result returned by {@link createBunWsEngine}. */
export interface BunWsEngine<Payload = unknown> {
readonly bus: SubscriptionBus<Payload>;
readonly registry: BunConnectionRegistry;
readonly broker: WsBroker<Payload> | null;
readonly instruments: BunWsInstruments;
readonly options: ResolvedBunWsEngineOptions;
readonly reconnectTokens: ReconnectTokenIssuer;
readonly reconnectBuffer: ReconnectEventBuffer<Payload>;
/** Run the handshake pipeline and attach the resulting connection. */
attach(input: AttachConnectionInput): ManagedConnection;
/** Record an inbound frame against `ws.messages_in_total`. */
recordInbound(connection: ManagedConnection, path?: string): void;
/** Publish to the bus + record `ws.broadcasts_total`. */
broadcast(
caller: { tenant: TenantId; capabilities: ReadonlySet<CapabilityId> },
topic: string,
opts: { tenant: TenantId; payload: Payload; id?: string }
): number;
/** Emit the principal-expired event to the host bus listeners. */
onPrincipalExpired(listener: PrincipalExpiryListener): () => void;
/** Shut down — closes every active connection. */
dispose(): Promise<void>;
/** Snapshot of current open-connection count — drives `ws.connections`. */
connectionsSnapshot(): number;
}
/** Options accepted by {@link createBunWsEngine}. */
export interface CreateBunWsEngineInput<Payload = unknown>
extends BunWsEngineOptions {
readonly broker?: WsBroker<Payload> | null;
readonly clock?: HeartbeatClock;
}
/**
* Build a `Bun.serve`-backed WebSocket engine. The returned value
* implements the `(bus, registry, broker, makeConnection)` tuple the
* `runWsEngineConformance` harness expects, plus the production-grade
* lifecycle helpers (`attach`, `broadcast`, `dispose`) that the host
* uses to wire the engine into a Bun listener.
*/
export function createBunWsEngine<Payload = unknown>(
input: CreateBunWsEngineInput<Payload>
): BunWsEngine<Payload> {
const { broker: _brokerInput, clock: _clockInput, ...schemaInput } = input;
const opts = resolveBunWsEngineOptions(schemaInput);
const clock: HeartbeatClock = input.clock ?? { now: () => Date.now() };
const broker = (input.broker ?? null) as WsBroker<Payload> | null;
const registry = new BunConnectionRegistry();
const bus = createBunSubscriptionBus<Payload>(registry);
const expiryListeners = new Set<PrincipalExpiryListener>();
const managed = new Map<string, ManagedConnection>();
const connectionsSnapshot = (): number => managed.size;
const instruments = createBunWsInstruments({
connectionsSnapshot,
});
const reconnectTokens = new ReconnectTokenIssuer(
opts.reconnectTokenSecret,
opts.reconnectTokenTtlMs,
() => clock.now()
);
const reconnectBuffer = resolveReconnectEventBuffer<Payload>(
opts.reconnectEventBufferStrategy,
broker,
opts.reconnectEventBufferLimit
);
function fireExpired(event: {
connectionId: string;
principalId: string;
}): void {
for (const l of expiryListeners) l(event);
}
function attach(att: AttachConnectionInput): ManagedConnection {
enforceTenantCap(registry, att.tenantId, opts.maxConnectionsPerTenant);
const start = clock.now();
// Validate handshake snapshot — guards against malformed input.
runHandshake({
request: new Request("http://example/"),
principal: att.principal,
tenantId: att.tenantId,
capabilities: att.capabilities,
tokenExpiresAt: att.tokenExpiresAt,
supportedSubprotocols: opts.subprotocols,
});
let state: WsConnectionState = "open";
let lastEventId: string | null = att.resumedFrom ?? null;
const guard = new BackpressureGuard(
att.id,
opts.maxQueuedMessages,
(id) => {
instruments.backpressureEvictedTotal.add(1, {
plugin: opts.pluginId,
});
att.sink.close(1013, `BACKPRESSURE_EVICTED:${id}`);
state = "closed";
registry.remove(connection);
managed.delete(id);
}
);
const heartbeat = new HeartbeatController({
connectionId: att.id,
heartbeatMs: opts.heartbeatMs,
missedPongLimit: opts.missedPongLimit,
clock,
onPing: () => {
att.sink.ping?.();
},
onAbort: () => {
instruments.heartbeatFailuresTotal.add(1, {
plugin: opts.pluginId,
});
att.sink.close(1006, "heartbeat");
state = "closed";
registry.remove(connection);
managed.delete(att.id);
},
});
const principalExpiry = new PrincipalExpiryWatcher({
connectionId: att.id,
principalId: att.principal.id as unknown as string,
expiresAt: att.tokenExpiresAt,
clock,
onExpired: (event) => {
fireExpired(event);
state = "closing";
},
});
const connection: ManagedConnection = {
id: att.id,
principal: att.principal,
tenantId: att.tenantId,
get lastEventId(): string | null {
return lastEventId;
},
get state(): WsConnectionState {
return state;
},
subscribe(topic: string, sub?: SubscribeOptions): void {
if (state !== "open") return;
bus.subscribe(connection, topic, {
tenant: sub?.tenant ?? att.tenantId,
...(sub?.filter !== undefined
? { filter: sub.filter }
: {}),
});
},
unsubscribe(topic: string): void {
bus.unsubscribe(connection, topic);
},
send(envelope: WsEnvelope): void {
if (state === "closed") return;
guard.enqueue();
att.sink.send(JSON.stringify(envelope));
instruments.messagesOutTotal.add(1, { plugin: opts.pluginId });
guard.release(1);
},
close(code: number, reason?: string): void {
if (state === "closed") return;
state = "closing";
try {
att.sink.close(code, reason);
} catch {
raiseCloseTimeout(att.id);
}
state = "closed";
registry.remove(connection);
managed.delete(att.id);
},
ack(id: string): void {
lastEventId = id;
},
backpressure: guard,
heartbeat,
principalExpiry,
};
registry.add(connection);
managed.set(att.id, connection);
instruments.handshakeDurationMs.record(clock.now() - start, {
plugin: opts.pluginId,
path: "/ws",
});
return connection;
}
function recordInbound(_conn: ManagedConnection, path = "/ws"): void {
instruments.messagesInTotal.add(1, { plugin: opts.pluginId, path });
}
function broadcast(
caller: { tenant: TenantId; capabilities: ReadonlySet<CapabilityId> },
topic: string,
bcOpts: { tenant: TenantId; payload: Payload; id?: string }
): number {
const delivered = bus.broadcast(caller, topic, {
tenant: bcOpts.tenant,
payload: bcOpts.payload,
});
const eventId =
bcOpts.id ??
`${topic}:${clock.now()}:${Math.random().toString(36).slice(2, 10)}`;
const buffered: BufferedEvent<Payload> = {
id: eventId,
topic,
tenant: bcOpts.tenant,
payload: bcOpts.payload,
};
reconnectBuffer.push(buffered);
instruments.broadcastsTotal.add(1, {
topic,
tenant: bcOpts.tenant as unknown as string,
recipients: delivered,
});
return delivered;
}
function onPrincipalExpired(listener: PrincipalExpiryListener): () => void {
expiryListeners.add(listener);
return () => expiryListeners.delete(listener);
}
async function dispose(): Promise<void> {
for (const [id, conn] of managed) {
try {
conn.close(1001, "engine disposed");
} catch {
// ignore — disposal must be best-effort
}
managed.delete(id);
}
reconnectBuffer.dispose();
}
return {
bus,
registry,
broker,
instruments,
options: opts,
reconnectTokens,
reconnectBuffer,
attach,
recordInbound,
broadcast,
onPrincipalExpired,
dispose,
connectionsSnapshot,
};
}
/**
* Convenience builder for the `makeConnection` half of the conformance
* harness factory. Tests pair this with `engine.attach(...)` indirectly
* by going through a stub sink that captures sent frames.
*/
export function makeTestConnection<Payload = unknown>(
engine: BunWsEngine<Payload>,
spec: { id: string; tenant: TenantId }
): ManagedConnection {
const principal: Principal = {
id: "test-principal" as unknown as PrincipalId,
scopes: [],
claims: {},
};
const sink: BunWsSocketSink = {
send: () => undefined,
close: () => undefined,
};
return engine.attach({
id: spec.id,
principal,
tenantId: spec.tenant,
capabilities: new Set(),
tokenExpiresAt: Number.MAX_SAFE_INTEGER,
sink,
});
}

View File

@@ -0,0 +1,9 @@
export {
type AttachConnectionInput,
type BunWsEngine,
type BunWsSocketSink,
type CreateBunWsEngineInput,
createBunWsEngine,
type ManagedConnection,
makeTestConnection,
} from "./bun-ws-engine.js";

View File

@@ -0,0 +1,63 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the stable error-code registry and the typed `WsBunError` subclass for `@suaveplan/api-ws-bun`. Every production failure path in the package routes through one of the ten `WS_BUN.*` codes defined here, which means a downstream consumer can branch on `error.code === WS_BUN_CODES.TENANT_CAP_EXCEEDED` without importing implementation modules.
The constants are frozen at module load time by `defineCodes` from `@suaveplan/error`, so consumers can compare against the namespaced literal string (`"WS_BUN.HANDSHAKE_REJECTED"`) directly when that is convenient — for example inside a JSON-serialised error wire format or a Hono error handler.
## Features
- Ten-entry registry covering handshake, tenant caps, backpressure, heartbeat, reconnect token validation, reconnect-buffer wiring, subprotocols, close timeouts and principal expiry.
- `WsBunError` extends `SuaveplanError<WsBunErrorCode>` so `instanceof` narrows the `code` field to the registry's literal union.
- One `raise*` helper per code so every entry has a production throw-site (CI checklist 6.3).
- Helpers attach a stable message prefix per code so logs stay grep-able.
## Usage: Basic
```ts
import { raiseTenantCapExceeded, WS_BUN_CODES } from "@suaveplan/api-ws-bun";
try {
raiseTenantCapExceeded("tenant-a", 1000);
} catch (err) {
if ((err as { code?: string }).code === WS_BUN_CODES.TENANT_CAP_EXCEEDED) {
// surface 429 to the caller
}
}
```
## Usage: Advanced
```ts
import {
WS_BUN_CODES,
WsBunError,
type WsBunErrorCode,
} from "@suaveplan/api-ws-bun";
function logIfWsBun(error: unknown, logger: { error: (m: string) => void }) {
if (error instanceof WsBunError) {
const code: WsBunErrorCode = error.wsBunCode;
logger.error(`${code}: ${error.message}`);
return code === WS_BUN_CODES.HEARTBEAT_FAILED;
}
return false;
}
```
## API Reference
- `WS_BUN_CODES` — frozen `Record<LiteralName, "WS_BUN.<LiteralName>">` produced by `defineCodes`. Keys: `HANDSHAKE_REJECTED`, `TENANT_CAP_EXCEEDED`, `BACKPRESSURE_EVICTED`, `HEARTBEAT_FAILED`, `RECONNECT_TOKEN_INVALID`, `RECONNECT_TOKEN_EXPIRED`, `RECONNECT_BUFFER_MISS`, `SUBPROTOCOL_UNSUPPORTED`, `CLOSE_TIMEOUT`, `PRINCIPAL_EXPIRED`.
- `WsBunError` — concrete subclass of `SuaveplanError<WsBunErrorCode>` with a `wsBunCode` mirror property.
- `raise*` helpers — one per code, all return `never`, all throw `WsBunError` with a pre-formatted message.
## Implementation Notes
The `raise*` helpers exist not only as ergonomic shortcuts but as a forcing-function: CI rule §6.3 demands every code in the registry have at least one production raise-site. Concentrating the raise calls in this module guarantees that property regardless of how the engine internals evolve.
Codes are intentionally namespaced (`WS_BUN.*` rather than `WS.*`) so error reports never confuse this engine adapter with the abstract `@suaveplan/api-ws` contract package, which owns the parallel `WS.*` namespace.

View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
raiseBackpressureEvicted,
raiseCloseTimeout,
raiseHandshakeRejected,
raiseHeartbeatFailed,
raisePrincipalExpired,
raiseReconnectBufferMiss,
raiseReconnectTokenExpired,
raiseReconnectTokenInvalid,
raiseSubprotocolUnsupported,
raiseTenantCapExceeded,
WS_BUN_CODES,
WsBunError,
} from "./error-codes.js";
describe("WS_BUN_CODES", () => {
it("registers all ten codes", { timeout: 5000 }, () => {
expect(Object.keys(WS_BUN_CODES).length).toBe(10);
expect(WS_BUN_CODES.HANDSHAKE_REJECTED).toBe(
"WS_BUN.HANDSHAKE_REJECTED"
);
expect(WS_BUN_CODES.TENANT_CAP_EXCEEDED).toBe(
"WS_BUN.TENANT_CAP_EXCEEDED"
);
expect(WS_BUN_CODES.PRINCIPAL_EXPIRED).toBe("WS_BUN.PRINCIPAL_EXPIRED");
});
it("WsBunError exposes wsBunCode mirror", { timeout: 5000 }, () => {
const err = new WsBunError(WS_BUN_CODES.HEARTBEAT_FAILED, "boom");
expect(err.code).toBe("WS_BUN.HEARTBEAT_FAILED");
expect(err.wsBunCode).toBe("WS_BUN.HEARTBEAT_FAILED");
expect(err instanceof WsBunError).toBe(true);
});
it("WsBunError accepts options", { timeout: 5000 }, () => {
const cause = new Error("upstream");
const err = new WsBunError(WS_BUN_CODES.HEARTBEAT_FAILED, "boom", {
cause,
});
expect(err.cause).toBe(cause);
});
it.each([
[
"HANDSHAKE_REJECTED",
() => raiseHandshakeRejected("nope"),
WS_BUN_CODES.HANDSHAKE_REJECTED,
],
[
"TENANT_CAP_EXCEEDED",
() => raiseTenantCapExceeded("t", 100),
WS_BUN_CODES.TENANT_CAP_EXCEEDED,
],
[
"BACKPRESSURE_EVICTED",
() => raiseBackpressureEvicted("c1"),
WS_BUN_CODES.BACKPRESSURE_EVICTED,
],
[
"HEARTBEAT_FAILED",
() => raiseHeartbeatFailed("c2"),
WS_BUN_CODES.HEARTBEAT_FAILED,
],
[
"RECONNECT_TOKEN_INVALID",
() => raiseReconnectTokenInvalid("bad"),
WS_BUN_CODES.RECONNECT_TOKEN_INVALID,
],
[
"RECONNECT_TOKEN_EXPIRED",
() => raiseReconnectTokenExpired(),
WS_BUN_CODES.RECONNECT_TOKEN_EXPIRED,
],
[
"RECONNECT_BUFFER_MISS",
() => raiseReconnectBufferMiss("no broker"),
WS_BUN_CODES.RECONNECT_BUFFER_MISS,
],
[
"SUBPROTOCOL_UNSUPPORTED",
() => raiseSubprotocolUnsupported("x"),
WS_BUN_CODES.SUBPROTOCOL_UNSUPPORTED,
],
[
"CLOSE_TIMEOUT",
() => raiseCloseTimeout("c3"),
WS_BUN_CODES.CLOSE_TIMEOUT,
],
[
"PRINCIPAL_EXPIRED",
() => raisePrincipalExpired("c4"),
WS_BUN_CODES.PRINCIPAL_EXPIRED,
],
])("raise%s throws with the right code", (_label, fn, expected) => {
expect(fn).toThrow(WsBunError);
try {
fn();
} catch (err) {
expect((err as WsBunError).wsBunCode).toBe(expected);
}
});
});

View File

@@ -0,0 +1,110 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable code registry for `@suaveplan/api-ws-bun`.
*
* Each constant is thrown from a production code-path in this package
* (verified by the §6.3 7-row checklist). Consumers branch on the string
* literal via `error.code === WS_BUN_CODES.HANDSHAKE_REJECTED` or the
* stable namespaced string `"WS_BUN.HANDSHAKE_REJECTED"`.
*/
export const WS_BUN_CODES = defineCodes("WS_BUN", [
"HANDSHAKE_REJECTED",
"TENANT_CAP_EXCEEDED",
"BACKPRESSURE_EVICTED",
"HEARTBEAT_FAILED",
"RECONNECT_TOKEN_INVALID",
"RECONNECT_TOKEN_EXPIRED",
"RECONNECT_BUFFER_MISS",
"SUBPROTOCOL_UNSUPPORTED",
"CLOSE_TIMEOUT",
"PRINCIPAL_EXPIRED",
] as const);
export type WsBunErrorCode = (typeof WS_BUN_CODES)[keyof typeof WS_BUN_CODES];
/** Typed error subclass with narrow `code` discriminator. */
export class WsBunError extends SuaveplanError<WsBunErrorCode> {
public readonly wsBunCode: WsBunErrorCode;
public constructor(
code: WsBunErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.wsBunCode = code;
}
}
/** Throw helpers — one per code so every entry has a production raise site. */
export function raiseHandshakeRejected(reason: string): never {
throw new WsBunError(
WS_BUN_CODES.HANDSHAKE_REJECTED,
`handshake rejected: ${reason}`
);
}
export function raiseTenantCapExceeded(tenant: string, cap: number): never {
throw new WsBunError(
WS_BUN_CODES.TENANT_CAP_EXCEEDED,
`tenant ${tenant} exceeded per-tenant cap of ${cap}`
);
}
export function raiseBackpressureEvicted(connectionId: string): never {
throw new WsBunError(
WS_BUN_CODES.BACKPRESSURE_EVICTED,
`connection ${connectionId} evicted: send queue overflow`
);
}
export function raiseHeartbeatFailed(connectionId: string): never {
throw new WsBunError(
WS_BUN_CODES.HEARTBEAT_FAILED,
`connection ${connectionId} failed heartbeat`
);
}
export function raiseReconnectTokenInvalid(reason: string): never {
throw new WsBunError(
WS_BUN_CODES.RECONNECT_TOKEN_INVALID,
`reconnect token invalid: ${reason}`
);
}
export function raiseReconnectTokenExpired(): never {
throw new WsBunError(
WS_BUN_CODES.RECONNECT_TOKEN_EXPIRED,
"reconnect token expired"
);
}
export function raiseReconnectBufferMiss(reason: string): never {
throw new WsBunError(
WS_BUN_CODES.RECONNECT_BUFFER_MISS,
`reconnect buffer miss: ${reason}`
);
}
export function raiseSubprotocolUnsupported(requested: string): never {
throw new WsBunError(
WS_BUN_CODES.SUBPROTOCOL_UNSUPPORTED,
`requested subprotocol not supported: ${requested}`
);
}
export function raiseCloseTimeout(connectionId: string): never {
throw new WsBunError(
WS_BUN_CODES.CLOSE_TIMEOUT,
`connection ${connectionId} close timed out`
);
}
export function raisePrincipalExpired(connectionId: string): never {
throw new WsBunError(
WS_BUN_CODES.PRINCIPAL_EXPIRED,
`connection ${connectionId} principal expired`
);
}

View File

@@ -0,0 +1,15 @@
export {
raiseBackpressureEvicted,
raiseCloseTimeout,
raiseHandshakeRejected,
raiseHeartbeatFailed,
raisePrincipalExpired,
raiseReconnectBufferMiss,
raiseReconnectTokenExpired,
raiseReconnectTokenInvalid,
raiseSubprotocolUnsupported,
raiseTenantCapExceeded,
WS_BUN_CODES,
WsBunError,
type WsBunErrorCode,
} from "./error-codes.js";

View File

@@ -0,0 +1,65 @@
# `handshake`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Freezes the per-request pipeline result onto a per-connection `HandshakeOutcome`. The host runs the standard pipeline steps 1–7 (auth + tenant resolution + capability scoping) and passes the resolved principal, tenant id, capability set, and token expiry into `runHandshake`. The function snapshots that result into an immutable record the rest of the connection lifecycle treats as the source of truth — the principal cannot mutate from this point on.
Subprotocol negotiation also happens here, following RFC 6455 §4.1: the server picks the first protocol from its supported list that the client requested. If the client requested a subprotocol the server does not support, the handshake throws `WS_BUN.SUBPROTOCOL_UNSUPPORTED`.
## Features
- Immutable snapshot of principal + tenant + capabilities.
- RFC 6455 subprotocol negotiation with explicit unsupported-rejection.
- Throws `WS_BUN.HANDSHAKE_REJECTED` for the obvious "missing principal / tenant" failure modes so the engine never has to second-guess pipeline output.
## Usage: Basic
```ts
import { runHandshake } from "@suaveplan/api-ws-bun";
const outcome = runHandshake({
request,
principal,
tenantId,
capabilities: new Set([]),
tokenExpiresAt: Date.now() + 3600_000,
});
```
## Usage: Advanced
```ts
import { resolveSubprotocol, runHandshake } from "@suaveplan/api-ws-bun";
const outcome = runHandshake({
request,
principal,
tenantId,
capabilities: new Set(["ws.broadcast-cross-tenant"]),
tokenExpiresAt: Date.now() + 3600_000,
supportedSubprotocols: ["suaveplan.v1", "graphql-transport-ws"],
});
console.log(outcome.chosenSubprotocol);
// Or use the lower-level negotiation directly:
const choice = resolveSubprotocol(
["graphql-transport-ws"],
["suaveplan.v1", "graphql-transport-ws"]
);
```
## API Reference
- `HandshakeInput` — `{ request, principal, tenantId, capabilities, tokenExpiresAt, supportedSubprotocols? }`.
- `HandshakeOutcome` — `{ principal, tenantId, capabilities, tokenExpiresAt, chosenSubprotocol }`.
- `runHandshake(input): HandshakeOutcome` — freeze the pipeline result.
- `resolveSubprotocol(requested, supported): string | null` — RFC 6455 §4.1 negotiation.
## Implementation Notes
The function rejects missing principal or tenant with `WS_BUN.HANDSHAKE_REJECTED` even though the pipeline upstream is expected to guarantee both. This redundant guard exists because the engine treats this module as a hard wall against malformed input from a misconfigured pipeline — defending the rest of the connection lifecycle from `undefined`-propagation bugs is cheaper here than at every downstream call site.
Capability sets are copied into a fresh `Set` rather than referenced directly so the engine cannot accidentally mutate the host-supplied capability bag. The cost is one allocation per handshake, dwarfed by everything else the connection setup is doing.

View File

@@ -0,0 +1,135 @@
import type { Principal, PrincipalId } from "@suaveplan/api-types/principal";
import type { TenantId } from "@suaveplan/api-types/tenant";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WS_BUN_CODES, type WsBunError } from "../error-codes/error-codes.js";
import { runHandshake } from "./handshake.js";
import { resolveSubprotocol } from "./subprotocol.js";
const principal: Principal = {
id: "p1" as unknown as PrincipalId,
scopes: [],
claims: {},
};
const tenant = "t" as unknown as TenantId;
describe("runHandshake", () => {
it("freezes principal + tenant + capabilities", { timeout: 5000 }, () => {
const caps = new Set(["ws.broadcast-cross-tenant"]);
const result = runHandshake({
request: new Request("http://x/"),
principal,
tenantId: tenant,
capabilities: caps as ReadonlySet<never>,
tokenExpiresAt: 100,
});
expect(result.principal).toBe(principal);
expect(result.tenantId).toBe(tenant);
expect(
result.capabilities.has("ws.broadcast-cross-tenant" as never)
).toBe(true);
// Mutating original set should NOT affect snapshot.
caps.clear();
expect(
result.capabilities.has("ws.broadcast-cross-tenant" as never)
).toBe(true);
});
it("picks server-preferred subprotocol from client list", {
timeout: 5000,
}, () => {
const req = new Request("http://x/", {
headers: {
"sec-websocket-protocol": "graphql-ws, graphql-transport-ws",
},
});
const result = runHandshake({
request: req,
principal,
tenantId: tenant,
capabilities: new Set(),
tokenExpiresAt: 100,
supportedSubprotocols: ["graphql-transport-ws"],
});
expect(result.chosenSubprotocol).toBe("graphql-transport-ws");
});
it("returns null when client requested no subprotocol", {
timeout: 5000,
}, () => {
const req = new Request("http://x/");
const result = runHandshake({
request: req,
principal,
tenantId: tenant,
capabilities: new Set(),
tokenExpiresAt: 100,
});
expect(result.chosenSubprotocol).toBeNull();
});
it("rejects empty principal", { timeout: 5000 }, () => {
try {
runHandshake({
request: new Request("http://x/"),
principal: null as unknown as Principal,
tenantId: tenant,
capabilities: new Set(),
tokenExpiresAt: 100,
});
throw new Error("expected throw");
} catch (err) {
expect((err as WsBunError).wsBunCode).toBe(
WS_BUN_CODES.HANDSHAKE_REJECTED
);
}
});
it("rejects missing tenant", { timeout: 5000 }, () => {
try {
runHandshake({
request: new Request("http://x/"),
principal,
tenantId: null as unknown as TenantId,
capabilities: new Set(),
tokenExpiresAt: 100,
});
throw new Error("expected throw");
} catch (err) {
expect((err as WsBunError).wsBunCode).toBe(
WS_BUN_CODES.HANDSHAKE_REJECTED
);
}
});
});
describe("resolveSubprotocol", () => {
it("returns null for empty client list", { timeout: 5000 }, () => {
expect(resolveSubprotocol([], ["sp.v1"])).toBeNull();
});
it("throws WS_BUN.SUBPROTOCOL_UNSUPPORTED when client wants nothing the server supports", {
timeout: 5000,
}, () => {
try {
resolveSubprotocol(["x"], ["y"]);
throw new Error("expected throw");
} catch (err) {
expect((err as WsBunError).wsBunCode).toBe(
WS_BUN_CODES.SUBPROTOCOL_UNSUPPORTED
);
}
});
it("throws when server list is empty but client requested one", {
timeout: 5000,
}, () => {
try {
resolveSubprotocol(["x"], []);
throw new Error("expected throw");
} catch (err) {
expect((err as WsBunError).wsBunCode).toBe(
WS_BUN_CODES.SUBPROTOCOL_UNSUPPORTED
);
}
});
});

View File

@@ -0,0 +1,64 @@
import type { CapabilityId } from "@suaveplan/api-types/capabilities";
import type { Principal } from "@suaveplan/api-types/principal";
import type { TenantId } from "@suaveplan/api-types/tenant";
import { raiseHandshakeRejected } from "../error-codes/error-codes.js";
import { resolveSubprotocol } from "./subprotocol.js";
/**
* Input passed to {@link runHandshake}. The host builds this from the
* incoming upgrade request — auth + tenant resolution happen upstream
* via the standard `@suaveplan/api-host` per-request pipeline (steps
* 1–7 in design §6). This module only freezes the result onto a
* per-connection state object.
*/
export interface HandshakeInput {
readonly request: Request;
readonly principal: Principal;
readonly tenantId: TenantId;
readonly capabilities: ReadonlySet<CapabilityId>;
readonly tokenExpiresAt: number;
readonly supportedSubprotocols?: readonly string[];
}
/** Output of {@link runHandshake} — drives the rest of the connection lifecycle. */
export interface HandshakeOutcome {
readonly principal: Principal;
readonly tenantId: TenantId;
readonly capabilities: ReadonlySet<CapabilityId>;
readonly tokenExpiresAt: number;
readonly chosenSubprotocol: string | null;
}
/**
* Freeze the per-request pipeline result onto a `HandshakeOutcome`.
*
* - The principal is taken from the pipeline result and treated as
* immutable from this point forward.
* - Capability set is snapshotted into a frozen `ReadonlySet`.
* - Subprotocol negotiation chooses the first server-listed protocol
* that the client requested (per RFC 6455 §4.1).
*/
export function runHandshake(input: HandshakeInput): HandshakeOutcome {
if (input.principal === undefined || input.principal === null) {
raiseHandshakeRejected("missing principal");
}
if (input.tenantId === undefined || input.tenantId === null) {
raiseHandshakeRejected("missing tenant");
}
const requested = input.request.headers
.get("sec-websocket-protocol")
?.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
const chosen = resolveSubprotocol(
requested ?? [],
input.supportedSubprotocols ?? []
);
return {
principal: input.principal,
tenantId: input.tenantId,
capabilities: new Set(input.capabilities),
tokenExpiresAt: input.tokenExpiresAt,
chosenSubprotocol: chosen,
};
}

View File

@@ -0,0 +1,6 @@
export {
type HandshakeInput,
type HandshakeOutcome,
runHandshake,
} from "./handshake.js";
export { resolveSubprotocol } from "./subprotocol.js";

View File

@@ -0,0 +1,49 @@
# `subprotocol`
## Purpose
Implements RFC 6455 §4.1 subprotocol negotiation as a tiny pure function. The server is supplied the client's requested protocols and the server-supported list; the function returns the first server-supported protocol the client requested, or `null` when the client requested nothing.
This is exposed as a public utility because callers occasionally need to short-circuit the full handshake — for example, an OpenAPI documentation route advertising the engine's supported subprotocols before any actual upgrade.
## Features
- Strict RFC 6455 ordering — server-supported list determines priority, NOT the client's preference list.
- Throws `WS_BUN.SUBPROTOCOL_UNSUPPORTED` when the client requested any subprotocol but none match.
- Returns `null` for the common "client requested nothing" case.
## Usage: Basic
```ts
import { resolveSubprotocol } from "@suaveplan/api-ws-bun";
const chosen = resolveSubprotocol(
["graphql-transport-ws"],
["suaveplan.v1", "graphql-transport-ws"]
);
// chosen === "graphql-transport-ws"
```
## Usage: Advanced
```ts
import { WS_BUN_CODES, resolveSubprotocol } from "@suaveplan/api-ws-bun";
try {
resolveSubprotocol(["unknown"], ["suaveplan.v1"]);
} catch (err) {
if ((err as { code?: string }).code === WS_BUN_CODES.SUBPROTOCOL_UNSUPPORTED) {
// respond 400 with `Sec-WebSocket-Protocol: <server's list>`
}
}
```
## API Reference
- `resolveSubprotocol(requested, supported): string | null` — pure negotiation function.
## Implementation Notes
Server-priority ordering matters: a client might list `["graphql-ws", "graphql-transport-ws"]` expecting the older protocol, but if the server prefers the newer one and lists it first, the function returns `"graphql-transport-ws"`. That matches the RFC and matches the behaviour Bun's native `Server.upgrade` would apply if the negotiation were delegated to Bun itself.
The empty-`requested` short-circuit returns `null` rather than throwing because RFC 6455 explicitly permits handshakes with no `Sec-WebSocket-Protocol` header — the connection is valid, it just has no negotiated subprotocol. This is the default state for a Bun client written against the foundation's plain-JSON envelope wire format.

View File

@@ -0,0 +1,25 @@
import { raiseSubprotocolUnsupported } from "../error-codes/error-codes.js";
/**
* RFC 6455 §4.1 subprotocol negotiation. If the client requested any
* subprotocols, the server MUST pick one it supports — otherwise the
* handshake is rejected.
*
* When the client requests nothing (header absent or empty), the server
* returns `null` and the connection proceeds with no negotiated
* subprotocol. When the server's list is empty but the client requested
* one, the engine throws `WS_BUN.SUBPROTOCOL_UNSUPPORTED`.
*/
export function resolveSubprotocol(
requested: readonly string[],
supported: readonly string[]
): string | null {
if (requested.length === 0) return null;
if (supported.length === 0) {
raiseSubprotocolUnsupported(requested.join(","));
}
for (const want of requested) {
if (supported.includes(want)) return want;
}
raiseSubprotocolUnsupported(requested.join(","));
}

View File

@@ -0,0 +1,80 @@
# `heartbeat`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the per-connection ping/pong state machine described in design §11.4. The engine instantiates one `HeartbeatController` per accepted connection, drives `tick()` from a timer, and routes pong frames into `recordPong()`. The controller decides whether to send another ping, do nothing, or abort the connection — that decision is delegated to `evaluateHeartbeat` in `@suaveplan/api-ws` so policy stays uniform across engines.
The controller is clock-injected so tests can use a `FakeClock` to advance time deterministically without relying on real timers.
## Features
- Tracks both wall-clock time and consecutive missed-pong counts.
- Emits `onPing` once per heartbeat window; double-`tick` inside the same window is a no-op so the engine can poll cheaply.
- Throws `WS_BUN.HEARTBEAT_FAILED` after `missedPongLimit` misses to keep the abort path uniform with the other WS_BUN failure modes.
- `aborted` and `missedPongs` accessors surface state to OTel instruments.
## Usage: Basic
```ts
import { HeartbeatController } from "@suaveplan/api-ws-bun";
const clock = { now: () => Date.now() };
const ctl = new HeartbeatController({
connectionId: "c1",
heartbeatMs: 30_000,
missedPongLimit: 2,
clock,
onPing: () => ws.ping(),
onAbort: () => ws.close(1011, "heartbeat"),
});
setInterval(() => ctl.tick(), 5_000);
ws.on("pong", () => ctl.recordPong());
```
## Usage: Advanced
```ts
import { FakeClock } from "@suaveplan/testing";
import { HeartbeatController, WS_BUN_CODES } from "@suaveplan/api-ws-bun";
const clock = new FakeClock();
let aborted = false;
const ctl = new HeartbeatController({
connectionId: "c1",
heartbeatMs: 1000,
missedPongLimit: 2,
clock: { now: () => clock.now() },
onPing: () => undefined,
onAbort: () => {
aborted = true;
},
});
clock.advance(1000);
ctl.tick(); // ping 1
clock.advance(1000);
try {
ctl.tick(); // 2 missed → abort
} catch (err) {
// err.code === WS_BUN_CODES.HEARTBEAT_FAILED
}
```
## API Reference
- `HeartbeatClock` — `{ now(): number }`.
- `HeartbeatControllerOptions` — `{ connectionId, heartbeatMs, missedPongLimit, clock, onPing, onAbort }`.
- `new HeartbeatController(options)` — constructor.
- `controller.tick(): void` — advance the state machine.
- `controller.recordPong(): void` — reset the missed-counter.
- `controller.aborted: boolean` — true once the abort transition has fired.
- `controller.missedPongs: number` — current missed-pong count.
## Implementation Notes
`tick()` checks `now - lastPingSent` to avoid spamming pings on every poll — the controller pings only once per window. This makes the engine free to call `tick()` at any cadence shorter than `heartbeatMs` without affecting wire behaviour.
After an abort fires, `recordPong()` and subsequent `tick()` calls become no-ops. The engine still receives the `onAbort` callback exactly once because the throw immediately follows it, so any double-bookkeeping at the engine layer is guaranteed safe.

View File

@@ -0,0 +1,140 @@
import { FakeClock } from "@suaveplan/testing";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WS_BUN_CODES, WsBunError } from "../error-codes/error-codes.js";
import { HeartbeatController } from "./heartbeat.js";
describe("HeartbeatController", () => {
it("ticks within window are no-op", { timeout: 5000 }, () => {
const clock = new FakeClock({ startTime: 1_000 });
let pings = 0;
const ctl = new HeartbeatController({
connectionId: "c1",
heartbeatMs: 1000,
missedPongLimit: 2,
clock: { now: () => clock.now() },
onPing: () => {
pings += 1;
},
onAbort: () => undefined,
});
clock.advance(500);
ctl.tick();
expect(pings).toBe(0);
});
it("sends a ping after one window", { timeout: 5000 }, () => {
const clock = new FakeClock({ startTime: 1_000 });
let pings = 0;
const ctl = new HeartbeatController({
connectionId: "c1",
heartbeatMs: 1000,
missedPongLimit: 2,
clock: { now: () => clock.now() },
onPing: () => {
pings += 1;
},
onAbort: () => undefined,
});
clock.advance(1000);
ctl.tick();
expect(pings).toBe(1);
expect(ctl.missedPongs).toBe(1);
});
it("aborts after missedPongLimit consecutive misses", {
timeout: 5000,
}, () => {
const clock = new FakeClock({ startTime: 1_000 });
let aborted = false;
const ctl = new HeartbeatController({
connectionId: "c1",
heartbeatMs: 1000,
missedPongLimit: 2,
clock: { now: () => clock.now() },
onPing: () => undefined,
onAbort: () => {
aborted = true;
},
});
clock.advance(1000);
ctl.tick(); // miss 1
clock.advance(1000);
ctl.tick(); // miss 2
clock.advance(1000);
try {
ctl.tick(); // misses (2) >= limit (2) -> abort
throw new Error("expected throw");
} catch (err) {
expect(err instanceof WsBunError).toBe(true);
expect((err as WsBunError).wsBunCode).toBe(
WS_BUN_CODES.HEARTBEAT_FAILED
);
}
expect(aborted).toBe(true);
expect(ctl.aborted).toBe(true);
});
it("recordPong resets miss counter", { timeout: 5000 }, () => {
const clock = new FakeClock({ startTime: 1_000 });
const ctl = new HeartbeatController({
connectionId: "c1",
heartbeatMs: 1000,
missedPongLimit: 2,
clock: { now: () => clock.now() },
onPing: () => undefined,
onAbort: () => undefined,
});
clock.advance(1000);
ctl.tick();
expect(ctl.missedPongs).toBe(1);
ctl.recordPong();
expect(ctl.missedPongs).toBe(0);
});
it("ticks after abort are no-op", { timeout: 5000 }, () => {
const clock = new FakeClock({ startTime: 1_000 });
const ctl = new HeartbeatController({
connectionId: "c1",
heartbeatMs: 1000,
missedPongLimit: 1,
clock: { now: () => clock.now() },
onPing: () => undefined,
onAbort: () => undefined,
});
clock.advance(1000);
ctl.tick(); // misses -> 1
clock.advance(1000);
try {
ctl.tick(); // misses (1) >= limit (1) -> abort
} catch {
/* abort */
}
expect(ctl.aborted).toBe(true);
// After abort, recordPong + tick must be no-ops.
ctl.recordPong();
ctl.tick();
expect(ctl.aborted).toBe(true);
});
it("repeat tick inside the same window does not re-ping", {
timeout: 5000,
}, () => {
const clock = new FakeClock({ startTime: 1_000 });
let pings = 0;
const ctl = new HeartbeatController({
connectionId: "c1",
heartbeatMs: 1000,
missedPongLimit: 5,
clock: { now: () => clock.now() },
onPing: () => {
pings += 1;
},
onAbort: () => undefined,
});
clock.advance(1000);
ctl.tick();
ctl.tick();
ctl.tick();
expect(pings).toBe(1);
});
});

View File

@@ -0,0 +1,84 @@
import { evaluateHeartbeat } from "@suaveplan/api-ws";
import { raiseHeartbeatFailed } from "../error-codes/error-codes.js";
/**
* Clock abstraction used by the heartbeat controller. The engine wires
* `Date.now` in production; tests pass a `FakeClock`.
*/
export interface HeartbeatClock {
now(): number;
}
/** Constructor options for {@link HeartbeatController}. */
export interface HeartbeatControllerOptions {
readonly connectionId: string;
readonly heartbeatMs: number;
readonly missedPongLimit: number;
readonly clock: HeartbeatClock;
readonly onPing: () => void;
readonly onAbort: () => void;
}
/**
* Per-connection heartbeat state machine. The engine drives `tick()` on a
* timer (or directly from a test-controlled `FakeClock`); the controller
* compares the current view of "ms since last pong" + "consecutive missed
* pongs" against the contract's `evaluateHeartbeat` evaluator.
*/
export class HeartbeatController {
private lastPong: number;
private misses = 0;
private lastPingSent: number;
private done = false;
public constructor(private readonly options: HeartbeatControllerOptions) {
this.lastPong = options.clock.now();
this.lastPingSent = options.clock.now();
}
/** Called when a pong frame arrives. Resets the missed-counter. */
public recordPong(): void {
if (this.done) return;
this.lastPong = this.options.clock.now();
this.misses = 0;
}
/**
* Advance the state machine. Emits `onPing` when the heartbeat
* interval has elapsed, or `onAbort` (plus throws
* `WS_BUN.HEARTBEAT_FAILED`) after `missedPongLimit` consecutive
* misses.
*/
public tick(): void {
if (this.done) return;
const now = this.options.clock.now();
const elapsed = now - this.lastPong;
const action = evaluateHeartbeat(elapsed, this.misses, {
heartbeatMs: this.options.heartbeatMs,
maxMissedPongs: this.options.missedPongLimit,
});
if (action === "abort") {
this.done = true;
this.options.onAbort();
raiseHeartbeatFailed(this.options.connectionId);
}
if (action === "ping") {
// Only send a ping if we have not pinged this window already.
if (now - this.lastPingSent >= this.options.heartbeatMs) {
this.lastPingSent = now;
this.misses += 1;
this.options.onPing();
}
}
}
/** Whether the controller has fired its abort transition. */
public get aborted(): boolean {
return this.done;
}
/** Current missed-pong count — surfaced for instruments. */
public get missedPongs(): number {
return this.misses;
}
}

View File

@@ -0,0 +1,5 @@
export {
type HeartbeatClock,
HeartbeatController,
type HeartbeatControllerOptions,
} from "./heartbeat.js";

View File

@@ -0,0 +1,68 @@
export { BackpressureGuard } from "./backpressure/backpressure.js";
export {
type AttachConnectionInput,
type BunWsEngine,
type BunWsSocketSink,
type CreateBunWsEngineInput,
createBunWsEngine,
type ManagedConnection,
makeTestConnection,
} from "./engine/bun-ws-engine.js";
export {
raiseBackpressureEvicted,
raiseCloseTimeout,
raiseHandshakeRejected,
raiseHeartbeatFailed,
raisePrincipalExpired,
raiseReconnectBufferMiss,
raiseReconnectTokenExpired,
raiseReconnectTokenInvalid,
raiseSubprotocolUnsupported,
raiseTenantCapExceeded,
WS_BUN_CODES,
WsBunError,
type WsBunErrorCode,
} from "./error-codes/error-codes.js";
export {
type HandshakeInput,
type HandshakeOutcome,
resolveSubprotocol,
runHandshake,
} from "./handshake/index.js";
export {
type HeartbeatClock,
HeartbeatController,
type HeartbeatControllerOptions,
} from "./heartbeat/heartbeat.js";
export {
type BunWsInstruments,
type BunWsInstrumentsConfig,
createBunWsInstruments,
} from "./instruments/instruments.js";
export { enforceTenantCap } from "./limits/tenant-cap.js";
export {
type BunWsEngineOptions,
bunWsEngineOptionsSchema,
RECONNECT_EVENT_BUFFER_STRATEGY,
type ReconnectEventBufferStrategy,
type ResolvedBunWsEngineOptions,
resolveBunWsEngineOptions,
} from "./options/options.js";
export {
type PrincipalExpiryClock,
type PrincipalExpiryListener,
PrincipalExpiryWatcher,
type PrincipalExpiryWatcherOptions,
} from "./principal/principal-expiry.js";
export {
BrokerReconnectEventBuffer,
type BufferCapableBroker,
type BufferedEvent,
isBufferCapableBroker,
MemoryReconnectEventBuffer,
type ReconnectEventBuffer,
ReconnectTokenIssuer,
resolveReconnectEventBuffer,
} from "./reconnect/index.js";
export { BunConnectionRegistry } from "./registry/connection-registry.js";
export { createBunSubscriptionBus } from "./subscription/subscription-bus.js";

View File

@@ -0,0 +1,5 @@
export {
type BunWsInstruments,
type BunWsInstrumentsConfig,
createBunWsInstruments,
} from "./instruments.js";

View File

@@ -0,0 +1,63 @@
# `instruments`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Builds the OTel instrument registry the engine emits to. The names, units, and attribute keys all match design doc §11.7 verbatim so dashboards keyed off the design doc immediately work against any deployment of `@suaveplan/api-ws-bun`. The registry is constructed once and frozen — every observation path then reads its instrument reference from the frozen bundle without re-resolving the meter.
The observable gauge `ws.connections` is wired at construction time to a caller-supplied `connectionsSnapshot` callback. This is the engine's hook for letting OTel periodically pull the current connection count without holding a write-lock against the registry.
## Features
- Six counters + one histogram + one observable gauge — the full §11.7 surface.
- All instrument names use the OTel naming convention (`<domain>.<metric>`); units are explicit (`ms`).
- `meterProvider` defaults to `metrics.getMeterProvider()` so production deployments don't need to pass anything; tests inject an in-memory `MeterProvider` for assertions.
- Bundle is `Object.freeze`d to prevent accidental mutation by plugin code that obtains a reference.
## Usage: Basic
```ts
import { createBunWsInstruments } from "@suaveplan/api-ws-bun";
const instruments = createBunWsInstruments({
connectionsSnapshot: () => registry.countByTenant(tenant),
});
instruments.broadcastsTotal.add(1, { topic: "orders", tenant, recipients: 3 });
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { createBunWsInstruments } from "@suaveplan/api-ws-bun";
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const reader = new PeriodicExportingMetricReader({ exporter });
const provider = new MeterProvider({ readers: [reader] });
const instruments = createBunWsInstruments({
meterProvider: provider,
connectionsSnapshot: () => 0,
});
instruments.handshakeDurationMs.record(42, { plugin: "demo", path: "/ws" });
```
## API Reference
- `BunWsInstrumentsConfig` — `{ meterProvider?, connectionsSnapshot }`.
- `BunWsInstruments` — frozen bundle (see §11.7 for the 7-instrument list).
- `createBunWsInstruments(config): BunWsInstruments` — factory.
## Implementation Notes
The factory does not capture any per-handshake state — every instrument is plugin-global. Per-connection attributes (`plugin`, `tenant`, `topic`, `path`, `recipients`) are passed at the observation site so the same instrument is reused across every connection on a given engine.
The observable gauge's callback returns a single scalar; multi-tenant gauges with one observation per tenant are deferred to the host (which already knows how many tenants are active and can wire its own gauge). Keeping the gauge scalar avoids the engine having to enumerate every tenant in every collection window.

View File

@@ -0,0 +1,180 @@
import type {
BatchObservableCallback,
Meter,
MeterProvider,
ObservableCallback,
ObservableGauge,
} from "@opentelemetry/api";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createBunWsInstruments } from "./instruments.js";
interface FakeInstrument {
readonly kind: "counter" | "histogram" | "gauge";
readonly name: string;
readonly options: Readonly<Record<string, unknown>>;
readonly addCalls: Array<{
value: number;
attrs?: Readonly<Record<string, unknown>>;
}>;
readonly recordCalls: Array<{
value: number;
attrs?: Readonly<Record<string, unknown>>;
}>;
readonly gaugeCallbacks: ObservableCallback[];
}
function buildFakeMeterProvider(): {
provider: MeterProvider;
instruments: FakeInstrument[];
} {
const instruments: FakeInstrument[] = [];
const provider: MeterProvider = {
getMeter(_name: string): Meter {
const meter: Meter = {
createCounter(n, opts) {
const inst: FakeInstrument = {
kind: "counter",
name: n,
options: opts ?? {},
addCalls: [],
recordCalls: [],
gaugeCallbacks: [],
};
instruments.push(inst);
return {
add(v: number, a?: Readonly<Record<string, unknown>>) {
inst.addCalls.push({ value: v, attrs: a });
},
} as ReturnType<Meter["createCounter"]>;
},
createHistogram(n, opts) {
const inst: FakeInstrument = {
kind: "histogram",
name: n,
options: opts ?? {},
addCalls: [],
recordCalls: [],
gaugeCallbacks: [],
};
instruments.push(inst);
return {
record(
v: number,
a?: Readonly<Record<string, unknown>>
) {
inst.recordCalls.push({ value: v, attrs: a });
},
} as ReturnType<Meter["createHistogram"]>;
},
createObservableGauge(n, opts) {
const inst: FakeInstrument = {
kind: "gauge",
name: n,
options: opts ?? {},
addCalls: [],
recordCalls: [],
gaugeCallbacks: [],
};
instruments.push(inst);
const gauge: ObservableGauge = {
addCallback(cb: ObservableCallback) {
inst.gaugeCallbacks.push(cb);
},
removeCallback(_cb: ObservableCallback) {
/* no-op */
},
} as unknown as ObservableGauge;
return gauge;
},
addBatchObservableCallback(_cb: BatchObservableCallback) {
/* no-op */
},
removeBatchObservableCallback(_cb: BatchObservableCallback) {
/* no-op */
},
} as unknown as Meter;
return meter;
},
} as MeterProvider;
return { provider, instruments };
}
describe("createBunWsInstruments", () => {
it("creates every §11.7 instrument", { timeout: 5000 }, () => {
const { provider, instruments } = buildFakeMeterProvider();
createBunWsInstruments({
meterProvider: provider,
connectionsSnapshot: () => 0,
});
const names = instruments.map((i) => i.name).sort();
expect(names).toEqual(
[
"ws.backpressure_evicted_total",
"ws.broadcasts_total",
"ws.connections",
"ws.handshake.duration_ms",
"ws.heartbeat_failures_total",
"ws.messages_in_total",
"ws.messages_out_total",
].sort()
);
});
it("handshake duration histogram has ms unit", { timeout: 5000 }, () => {
const { provider, instruments } = buildFakeMeterProvider();
createBunWsInstruments({
meterProvider: provider,
connectionsSnapshot: () => 0,
});
const histo = instruments.find(
(i) => i.name === "ws.handshake.duration_ms"
);
expect(histo).toBeDefined();
expect((histo?.options as { unit?: string }).unit).toBe("ms");
});
it("connections gauge wires snapshot callback", { timeout: 5000 }, () => {
const { provider, instruments } = buildFakeMeterProvider();
let observed = 0;
createBunWsInstruments({
meterProvider: provider,
connectionsSnapshot: () => 42,
});
const gauge = instruments.find((i) => i.name === "ws.connections");
expect(gauge?.gaugeCallbacks.length).toBe(1);
const cb = gauge?.gaugeCallbacks[0];
cb?.({
observe(v: number) {
observed = v;
},
} as unknown as Parameters<ObservableCallback>[0]);
expect(observed).toBe(42);
});
it("broadcast emits ws.broadcasts_total with recipient count", {
timeout: 5000,
}, () => {
const { provider, instruments } = buildFakeMeterProvider();
const bundle = createBunWsInstruments({
meterProvider: provider,
connectionsSnapshot: () => 0,
});
bundle.broadcastsTotal.add(1, { topic: "orders", recipients: 3 });
const counter = instruments.find(
(i) => i.name === "ws.broadcasts_total"
);
expect(counter?.addCalls).toEqual([
{ value: 1, attrs: { topic: "orders", recipients: 3 } },
]);
});
it("defaults to the global MeterProvider when none supplied", {
timeout: 5000,
}, () => {
const bundle = createBunWsInstruments({
connectionsSnapshot: () => 0,
});
expect(bundle.meter).toBeDefined();
expect(typeof bundle.broadcastsTotal.add).toBe("function");
});
});

View File

@@ -0,0 +1,79 @@
import type {
Counter,
Histogram,
Meter,
MeterProvider,
ObservableGauge,
} from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
/** Configuration for {@link createBunWsInstruments}. */
export interface BunWsInstrumentsConfig {
readonly meterProvider?: MeterProvider;
/** Callback returning the current count of open connections (for the gauge). */
readonly connectionsSnapshot: () => number;
}
/**
* Frozen bundle of every OTel instrument required by design §11.7.
*
* - `ws.handshake.duration_ms { plugin, path }` — histogram.
* - `ws.connections { plugin, tenant }` — observable gauge.
* - `ws.messages_in_total { plugin, path }` — counter.
* - `ws.messages_out_total { plugin, path }` — counter.
* - `ws.broadcasts_total { topic, tenant, recipients }` — counter.
* - `ws.backpressure_evicted_total { plugin }` — counter.
* - `ws.heartbeat_failures_total { plugin }` — counter.
*/
export interface BunWsInstruments {
readonly meter: Meter;
readonly handshakeDurationMs: Histogram;
readonly connectionsGauge: ObservableGauge;
readonly messagesInTotal: Counter;
readonly messagesOutTotal: Counter;
readonly broadcastsTotal: Counter;
readonly backpressureEvictedTotal: Counter;
readonly heartbeatFailuresTotal: Counter;
}
const METER_NAME = "@suaveplan/api-ws-bun";
/**
* Build the engine's instrument registry. All counters / histograms /
* gauges are constructed once and frozen so the first observation has
* zero allocation overhead. The observable gauge is wired to the
* caller-supplied `connectionsSnapshot` callback at construction time.
*/
export function createBunWsInstruments(
config: BunWsInstrumentsConfig
): BunWsInstruments {
const meterProvider = config.meterProvider ?? metrics.getMeterProvider();
const meter = meterProvider.getMeter(METER_NAME);
const handshakeDurationMs = meter.createHistogram(
"ws.handshake.duration_ms",
{ unit: "ms" }
);
const connectionsGauge = meter.createObservableGauge("ws.connections");
connectionsGauge.addCallback((observer) => {
observer.observe(config.connectionsSnapshot());
});
const messagesInTotal = meter.createCounter("ws.messages_in_total");
const messagesOutTotal = meter.createCounter("ws.messages_out_total");
const broadcastsTotal = meter.createCounter("ws.broadcasts_total");
const backpressureEvictedTotal = meter.createCounter(
"ws.backpressure_evicted_total"
);
const heartbeatFailuresTotal = meter.createCounter(
"ws.heartbeat_failures_total"
);
return Object.freeze({
meter,
handshakeDurationMs,
connectionsGauge,
messagesInTotal,
messagesOutTotal,
broadcastsTotal,
backpressureEvictedTotal,
heartbeatFailuresTotal,
});
}

View File

@@ -0,0 +1 @@
export { enforceTenantCap } from "./tenant-cap.js";

View File

@@ -0,0 +1,58 @@
# `limits`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the per-tenant connection-count enforcement function that the handshake pipeline calls before admitting a new connection. The cap defends every host process against a single tenant exhausting socket capacity — a noisy-neighbour mitigation explicitly called out in design §11.4 (default `maxConnectionsPerTenant=1000`).
Enforcement is a function rather than a class because the only state needed is the registry itself; threading another stateful module through the handshake path would obscure the actual policy decision.
## Features
- O(1) cap check — leans on `registry.countByTenant` which is index-backed.
- Throws `WS_BUN.TENANT_CAP_EXCEEDED` with the offending tenant id + configured cap in the message for debuggable telemetry.
- Read-only — does not mutate the registry, so the handshake path can run additional checks after the cap test without rollback.
## Usage: Basic
```ts
import { enforceTenantCap, BunConnectionRegistry } from "@suaveplan/api-ws-bun";
const registry = new BunConnectionRegistry();
enforceTenantCap(registry, tenantId, 1000); // throws if >= 1000.
```
## Usage: Advanced
```ts
import {
enforceTenantCap,
BunConnectionRegistry,
WS_BUN_CODES,
} from "@suaveplan/api-ws-bun";
function tryAccept(reg: BunConnectionRegistry, tenant, cap) {
try {
enforceTenantCap(reg, tenant, cap);
return { accepted: true as const };
} catch (err) {
if ((err as { code?: string }).code === WS_BUN_CODES.TENANT_CAP_EXCEEDED) {
return { accepted: false as const, httpStatus: 429 };
}
throw err;
}
}
```
## API Reference
- `enforceTenantCap(registry, tenant, cap): void` — throws on cap breach; otherwise no-op.
## Implementation Notes
The check uses `>=` rather than `>` so a cap of 1000 admits exactly 1000 concurrent connections; the 1001st gets the 429. This matches the spec scenario verbatim and avoids the off-by-one trap of "999 admitted, 1000 rejected".
The implementation does not reserve a registry slot before the cap test. The window between the check and `registry.add(conn)` is bounded by a synchronous function call in the engine, so racing two handshakes through that microsecond gap would only be possible if Bun.serve fired two upgrade callbacks on the same JavaScript turn — which it does not. If a future engine extends the gap, this function should be replaced with an atomic `reserveSlot` / `releaseSlot` pair.

View File

@@ -0,0 +1,59 @@
import type { Principal, PrincipalId } from "@suaveplan/api-types/principal";
import type { TenantId } from "@suaveplan/api-types/tenant";
import type { WsConnection } from "@suaveplan/api-ws";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WS_BUN_CODES, WsBunError } from "../error-codes/error-codes.js";
import { BunConnectionRegistry } from "../registry/connection-registry.js";
import { enforceTenantCap } from "./tenant-cap.js";
const tenant = "t-1" as unknown as TenantId;
function makeConn(id: string): WsConnection {
return {
id,
principal: {
id: "p" as unknown as PrincipalId,
scopes: [],
claims: {},
} as Principal,
tenantId: tenant,
lastEventId: null,
state: "open",
subscribe() {},
unsubscribe() {},
send() {},
close() {},
ack() {},
};
}
describe("enforceTenantCap", () => {
it("allows new handshake when under cap", { timeout: 5000 }, () => {
const reg = new BunConnectionRegistry();
reg.add(makeConn("c1"));
reg.add(makeConn("c2"));
expect(() => enforceTenantCap(reg, tenant, 5)).not.toThrow();
});
it("rejects the 1001st connection from the same tenant", {
timeout: 5000,
}, () => {
const reg = new BunConnectionRegistry();
for (let i = 0; i < 1000; i += 1) reg.add(makeConn(`c${i}`));
try {
enforceTenantCap(reg, tenant, 1000);
throw new Error("expected throw");
} catch (err) {
expect(err instanceof WsBunError).toBe(true);
expect((err as WsBunError).wsBunCode).toBe(
WS_BUN_CODES.TENANT_CAP_EXCEEDED
);
}
});
it("uses >= so cap of 1 admits 1 then rejects 2", { timeout: 5000 }, () => {
const reg = new BunConnectionRegistry();
reg.add(makeConn("c1"));
expect(() => enforceTenantCap(reg, tenant, 1)).toThrow(WsBunError);
});
});

View File

@@ -0,0 +1,23 @@
import type { TenantId } from "@suaveplan/api-types/tenant";
import { raiseTenantCapExceeded } from "../error-codes/error-codes.js";
import type { BunConnectionRegistry } from "../registry/connection-registry.js";
/**
* Enforce the per-tenant connection cap from design §11.4. Called from
* the handshake path before the new connection is added to the registry;
* if the tenant is already at or above `cap`, the handshake is rejected
* with `WS_BUN.TENANT_CAP_EXCEEDED`.
*
* The check is read-only against the registry — no slot is reserved. The
* engine is expected to add the connection immediately after a clean
* check so the window between check and add stays microsecond-small.
*/
export function enforceTenantCap(
registry: BunConnectionRegistry,
tenant: TenantId,
cap: number
): void {
if (registry.countByTenant(tenant) >= cap) {
raiseTenantCapExceeded(tenant as unknown as string, cap);
}
}

View File

@@ -0,0 +1,8 @@
export {
type BunWsEngineOptions,
bunWsEngineOptionsSchema,
RECONNECT_EVENT_BUFFER_STRATEGY,
type ReconnectEventBufferStrategy,
type ResolvedBunWsEngineOptions,
resolveBunWsEngineOptions,
} from "./options.js";

View File

@@ -0,0 +1,66 @@
# `options`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the Zod-validated options surface accepted by `createBunWsEngine`. Every field has a documented default sourced from design §11.4 / §11.5, so the engine is usable with the minimal call shape `{ reconnectTokenSecret }` while remaining tunable for production deployments that need to override timing, caps, or buffer storage strategy.
The schema is exported as both the input type (defaults optional) and the resolved output type (defaults applied). Consumers that build options dynamically use the input shape; the engine internals only ever see the resolved shape.
## Features
- Strict Zod schema rejects unknown keys to prevent typo'd options silently no-op-ing.
- Defaults match the design doc: `maxQueuedMessages=256`, `heartbeatMs=30000` (min 5000), `missedPongLimit=2`, `maxConnectionsPerTenant=1000`, `reconnectTokenTtlMs=300000`.
- `reconnectTokenSecret` requires a `Uint8Array` of at least 16 bytes — short secrets are a deployment misconfiguration the engine refuses at boot.
- `reconnectEventBufferStrategy` enum (`"auto" | "memory" | "broker"`) pins the §14 open-question default to `"auto"`.
- `subprotocols` is a string array; an empty array means "accept the client's choice with no negotiation".
## Usage: Basic
```ts
import { resolveBunWsEngineOptions } from "@suaveplan/api-ws-bun";
const secret = new Uint8Array(32).fill(7);
const resolved = resolveBunWsEngineOptions({ reconnectTokenSecret: secret });
console.log(resolved.maxQueuedMessages); // 256
```
## Usage: Advanced
```ts
import {
bunWsEngineOptionsSchema,
resolveBunWsEngineOptions,
} from "@suaveplan/api-ws-bun";
// Use the schema directly when validating against external input.
const parsed = bunWsEngineOptionsSchema.safeParse({
reconnectTokenSecret: new Uint8Array(32).fill(1),
heartbeatMs: 10_000,
missedPongLimit: 3,
reconnectEventBufferStrategy: "broker",
subprotocols: ["suaveplan.v1"],
});
if (!parsed.success) {
throw parsed.error;
}
const opts = resolveBunWsEngineOptions(parsed.data);
```
## API Reference
- `bunWsEngineOptionsSchema` — `z.ZodObject<...>` enforcing the field set above.
- `BunWsEngineOptions` — `z.input` of the schema (defaults optional).
- `ResolvedBunWsEngineOptions` — `z.output` of the schema (defaults applied).
- `resolveBunWsEngineOptions(options)` — parse+throw helper.
- `RECONNECT_EVENT_BUFFER_STRATEGY` — `readonly ["auto", "memory", "broker"]`.
- `ReconnectEventBufferStrategy` — union type.
## Implementation Notes
The Zod `.default(...)` calls live on the schema rather than in the engine factory so the same defaults apply whether callers parse the schema directly or go through `resolveBunWsEngineOptions`. Defaults are deliberately not duplicated as constants — the schema is the single source of truth.
The `reconnectTokenSecret` field uses `z.instanceof(Uint8Array)` rather than a pre-encoded `string` to discourage callers from rolling their own base64 / hex pipelines. The api-ws contract package's `signReconnectToken` accepts the same `Uint8Array` shape directly, so no conversion is needed.

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
bunWsEngineOptionsSchema,
RECONNECT_EVENT_BUFFER_STRATEGY,
resolveBunWsEngineOptions,
} from "./options.js";
describe("bunWsEngineOptionsSchema", () => {
it("applies documented defaults", { timeout: 5000 }, () => {
const secret = new Uint8Array(32).fill(1);
const r = resolveBunWsEngineOptions({ reconnectTokenSecret: secret });
expect(r.maxQueuedMessages).toBe(256);
expect(r.heartbeatMs).toBe(30_000);
expect(r.missedPongLimit).toBe(2);
expect(r.maxConnectionsPerTenant).toBe(1000);
expect(r.reconnectTokenTtlMs).toBe(300_000);
expect(r.reconnectEventBufferStrategy).toBe("auto");
expect(r.reconnectEventBufferLimit).toBe(256);
expect(r.subprotocols).toEqual([]);
expect(r.pluginId).toBe("api-ws-bun");
});
it("rejects short secrets", { timeout: 5000 }, () => {
const tiny = new Uint8Array(8);
expect(() =>
resolveBunWsEngineOptions({ reconnectTokenSecret: tiny })
).toThrow();
});
it("rejects sub-5000ms heartbeatMs", { timeout: 5000 }, () => {
const secret = new Uint8Array(32).fill(2);
expect(() =>
resolveBunWsEngineOptions({
reconnectTokenSecret: secret,
heartbeatMs: 1000,
})
).toThrow();
});
it("accepts override values", { timeout: 5000 }, () => {
const secret = new Uint8Array(32).fill(3);
const r = resolveBunWsEngineOptions({
reconnectTokenSecret: secret,
maxQueuedMessages: 8,
heartbeatMs: 7_500,
missedPongLimit: 5,
maxConnectionsPerTenant: 100,
reconnectTokenTtlMs: 60_000,
reconnectEventBufferStrategy: "memory",
reconnectEventBufferLimit: 32,
subprotocols: ["sp.v1"],
pluginId: "demo",
});
expect(r.maxQueuedMessages).toBe(8);
expect(r.heartbeatMs).toBe(7_500);
expect(r.reconnectEventBufferStrategy).toBe("memory");
expect(r.subprotocols).toEqual(["sp.v1"]);
});
it("schema enumerates strategy values", { timeout: 5000 }, () => {
expect(RECONNECT_EVENT_BUFFER_STRATEGY).toEqual([
"auto",
"memory",
"broker",
]);
});
it("rejects unknown keys", { timeout: 5000 }, () => {
const secret = new Uint8Array(32).fill(4);
const parsed = bunWsEngineOptionsSchema.safeParse({
reconnectTokenSecret: secret,
extraField: 1,
});
expect(parsed.success).toBe(false);
});
});

View File

@@ -0,0 +1,57 @@
import z from "zod";
/**
* Strategy for storing per-`(tenant, topic)` reconnect-event buffers.
*
* - `"auto"` (default) — broker-backed when a Redis broker is wired into
* the host, in-memory otherwise. Pins design doc §11.5 paragraph 4.
* - `"memory"` — always local in-memory (single-host deployments).
* - `"broker"` — always broker-backed; refuses at boot if no compatible
* broker is wired.
*/
export const RECONNECT_EVENT_BUFFER_STRATEGY = [
"auto",
"memory",
"broker",
] as const;
export type ReconnectEventBufferStrategy =
(typeof RECONNECT_EVENT_BUFFER_STRATEGY)[number];
/** Zod schema for `createBunWsEngine` options. Every field has a default. */
export const bunWsEngineOptionsSchema = z
.object({
maxQueuedMessages: z.number().int().positive().default(256),
heartbeatMs: z.number().int().min(5000).default(30000),
missedPongLimit: z.number().int().positive().default(2),
maxConnectionsPerTenant: z.number().int().positive().default(1000),
reconnectTokenTtlMs: z.number().int().positive().default(300000),
reconnectTokenSecret: z
.instanceof(Uint8Array)
.refine((b) => b.length >= 16, "secret must be >= 16 bytes"),
reconnectEventBufferStrategy: z
.enum(RECONNECT_EVENT_BUFFER_STRATEGY)
.default("auto"),
reconnectEventBufferLimit: z.number().int().positive().default(256),
subprotocols: z.array(z.string().min(1)).default([]),
pluginId: z.string().min(1).default("api-ws-bun"),
})
.strict();
/** Caller-facing input type — all fields except `reconnectTokenSecret` optional. */
export type BunWsEngineOptions = z.input<typeof bunWsEngineOptionsSchema>;
/** Fully-resolved options after defaults have been applied. */
export type ResolvedBunWsEngineOptions = z.output<
typeof bunWsEngineOptionsSchema
>;
/**
* Validate + resolve options. Throws the underlying `ZodError` on failure;
* the engine factory wraps that into `WS_BUN.HANDSHAKE_REJECTED` if it ever
* reaches the request path.
*/
export function resolveBunWsEngineOptions(
options: BunWsEngineOptions
): ResolvedBunWsEngineOptions {
return bunWsEngineOptionsSchema.parse(options);
}

View File

@@ -0,0 +1,6 @@
export {
type PrincipalExpiryClock,
type PrincipalExpiryListener,
PrincipalExpiryWatcher,
type PrincipalExpiryWatcherOptions,
} from "./principal-expiry.js";

View File

@@ -0,0 +1,80 @@
# `principal-expiry`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Tracks the underlying token expiry for a frozen principal so the engine can drop the connection at the right moment. The contract requires the `Connection.principal` field to be immutable for the connection's lifetime; this module is the signal the engine uses to flip the connection state to `closing` once the original token would have lapsed.
The watcher is clock-injected so tests can drive expiry deterministically without sleeping. In production the engine polls `check()` from a regular interval (default: aligned to the heartbeat timer).
## Features
- Single-fire — `onExpired` runs at most once per watcher instance.
- Throws `WS_BUN.PRINCIPAL_EXPIRED` after firing so the engine sees the same shaped error as every other WS_BUN failure mode.
- `hasFired` accessor for test assertions and lifecycle bookkeeping.
## Usage: Basic
```ts
import { PrincipalExpiryWatcher } from "@suaveplan/api-ws-bun";
const watcher = new PrincipalExpiryWatcher({
connectionId: "c1",
principalId: "u1",
expiresAt: Date.now() + 60_000,
clock: { now: () => Date.now() },
onExpired: ({ connectionId }) => emitBus(connectionId),
});
setInterval(() => {
try {
watcher.check();
} catch {
// engine closes the connection
}
}, 5_000);
```
## Usage: Advanced
```ts
import { PrincipalExpiryWatcher, WS_BUN_CODES } from "@suaveplan/api-ws-bun";
let busEvent: { connectionId: string; principalId: string } | null = null;
const clock = { value: 0 };
const watcher = new PrincipalExpiryWatcher({
connectionId: "c1",
principalId: "u1",
expiresAt: 100,
clock: { now: () => clock.value },
onExpired: (event) => {
busEvent = event;
},
});
clock.value = 99;
watcher.check(); // no-op
clock.value = 100;
try {
watcher.check();
} catch (err) {
// err.code === WS_BUN_CODES.PRINCIPAL_EXPIRED
}
console.log(busEvent?.principalId); // "u1"
```
## API Reference
- `PrincipalExpiryClock` — `{ now(): number }`.
- `PrincipalExpiryListener` — `(event) => void`.
- `PrincipalExpiryWatcherOptions` — `{ connectionId, principalId, expiresAt, clock, onExpired }`.
- `new PrincipalExpiryWatcher(options)` — constructor.
- `watcher.check(): void` — run an expiry check.
- `watcher.hasFired: boolean` — has the expiry already triggered.
## Implementation Notes
The watcher does NOT subscribe to an event bus on its own — the engine wires the `onExpired` callback into whatever bus the host owns. This keeps `principal-expiry` free of `EventBus` dependencies and lets the test suite assert on `onExpired` directly via a captured closure.
`check()` uses `>=` so the moment `now === expiresAt`, the principal is treated as expired. This matches the convention used elsewhere in the package (e.g. tenant-cap), keeping the "cliff" semantics uniform.

View File

@@ -0,0 +1,76 @@
import { FakeClock } from "@suaveplan/testing";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WS_BUN_CODES, WsBunError } from "../error-codes/error-codes.js";
import { PrincipalExpiryWatcher } from "./principal-expiry.js";
describe("PrincipalExpiryWatcher", () => {
it("does not fire before expiry", { timeout: 5000 }, () => {
const clock = new FakeClock({ startTime: 0 });
let fired = false;
const watcher = new PrincipalExpiryWatcher({
connectionId: "c1",
principalId: "u1",
expiresAt: 100,
clock: { now: () => clock.now() },
onExpired: () => {
fired = true;
},
});
clock.advance(99);
watcher.check();
expect(fired).toBe(false);
expect(watcher.hasFired).toBe(false);
});
it("fires at expiry and throws WS_BUN.PRINCIPAL_EXPIRED", {
timeout: 5000,
}, () => {
const clock = new FakeClock({ startTime: 0 });
let event: { connectionId: string; principalId: string } | null = null;
const watcher = new PrincipalExpiryWatcher({
connectionId: "c1",
principalId: "u1",
expiresAt: 100,
clock: { now: () => clock.now() },
onExpired: (e) => {
event = e;
},
});
clock.advance(100);
try {
watcher.check();
throw new Error("expected throw");
} catch (err) {
expect(err instanceof WsBunError).toBe(true);
expect((err as WsBunError).wsBunCode).toBe(
WS_BUN_CODES.PRINCIPAL_EXPIRED
);
}
expect(event).not.toBeNull();
expect(event?.principalId).toBe("u1");
});
it("fires at most once", { timeout: 5000 }, () => {
const clock = new FakeClock({ startTime: 0 });
let fires = 0;
const watcher = new PrincipalExpiryWatcher({
connectionId: "c1",
principalId: "u1",
expiresAt: 100,
clock: { now: () => clock.now() },
onExpired: () => {
fires += 1;
},
});
clock.advance(100);
try {
watcher.check();
} catch {
/* expected */
}
watcher.check();
watcher.check();
expect(fires).toBe(1);
expect(watcher.hasFired).toBe(true);
});
});

View File

@@ -0,0 +1,64 @@
import { raisePrincipalExpired } from "../error-codes/error-codes.js";
/** Clock abstraction used by the principal-expiry watcher. */
export interface PrincipalExpiryClock {
now(): number;
}
/** Listener signature invoked when the principal's token expires. */
export type PrincipalExpiryListener = (event: {
readonly connectionId: string;
readonly principalId: string;
}) => void;
/** Options for {@link PrincipalExpiryWatcher}. */
export interface PrincipalExpiryWatcherOptions {
readonly connectionId: string;
readonly principalId: string;
/**
* Wall-clock expiry timestamp (ms since epoch). The handshake
* pipeline reads this from the resolved token; once `now()` reaches
* this value the watcher fires.
*/
readonly expiresAt: number;
readonly clock: PrincipalExpiryClock;
readonly onExpired: PrincipalExpiryListener;
}
/**
* Tracks the frozen principal's underlying token expiry. The engine
* polls `check()` from a timer (production) or from a test loop driven
* by `FakeClock`. Once the expiry passes, the watcher fires the
* `onExpired` listener exactly once and throws
* `WS_BUN.PRINCIPAL_EXPIRED` so the engine can close the connection.
*
* The principal itself is NOT mutated by this watcher — the contract
* requires it to stay frozen for the connection's lifetime. The watcher
* is the engine's signal that it is time to drop the connection.
*/
export class PrincipalExpiryWatcher {
private fired = false;
public constructor(
private readonly options: PrincipalExpiryWatcherOptions
) {}
/**
* Run one expiry check. If `now() >= expiresAt` and the watcher has
* not yet fired, invoke `onExpired` and throw `WS_BUN.PRINCIPAL_EXPIRED`.
*/
public check(): void {
if (this.fired) return;
if (this.options.clock.now() < this.options.expiresAt) return;
this.fired = true;
this.options.onExpired({
connectionId: this.options.connectionId,
principalId: this.options.principalId,
});
raisePrincipalExpired(this.options.connectionId);
}
public get hasFired(): boolean {
return this.fired;
}
}

View File

@@ -0,0 +1,57 @@
# `event-buffer`
## Purpose
Provides the per-`(tenant, topic)` reconnect-event buffer used to replay events to a reconnecting client whose `lastEventId` precedes the current head. Two concrete implementations ship — `MemoryReconnectEventBuffer` for single-host deployments and `BrokerReconnectEventBuffer` for broker-backed multi-host fan-out — and `resolveReconnectEventBuffer` picks the right one according to the configured strategy and the broker wired into the host.
This module is the operating-default pin for design doc §14 open question on buffer storage: the foundation ships `"auto"` which resolves to broker-backed when a Redis broker is wired, in-memory otherwise.
## Features
- Strategy enum (`"auto" | "memory" | "broker"`) with three documented behaviours.
- `MemoryReconnectEventBuffer` is a per-bucket ring buffer — FIFO eviction, `limit` events per `(tenant, topic)` pair.
- `BrokerReconnectEventBuffer` maintains a local mirror so replay still works during transient broker downtime; pushes also flow to the broker on a fire-and-forget topic for peer-host mirrors.
- `"broker"` strategy refuses boot with `WS_BUN.RECONNECT_BUFFER_MISS` when no buffer-capable broker is wired.
## Usage: Basic
```ts
import { MemoryReconnectEventBuffer } from "@suaveplan/api-ws-bun";
const buf = new MemoryReconnectEventBuffer<{ value: number }>(64);
buf.push({ id: "1", topic: "orders", tenant, payload: { value: 1 } });
buf.push({ id: "2", topic: "orders", tenant, payload: { value: 2 } });
const replay = buf.since(tenant, "orders", "1");
console.log(replay.length); // 1
```
## Usage: Advanced
```ts
import {
resolveReconnectEventBuffer,
isBufferCapableBroker,
} from "@suaveplan/api-ws-bun";
const buffer = resolveReconnectEventBuffer("auto", broker, 256);
// If `broker.supportsReconnectBuffer === true`, returns a
// BrokerReconnectEventBuffer wrapping `broker`. Otherwise returns
// MemoryReconnectEventBuffer.
console.log(isBufferCapableBroker(broker)); // true | false
```
## API Reference
- `BufferedEvent<Payload>` — `{ id, topic, tenant, payload }`.
- `ReconnectEventBuffer<Payload>` — `{ push, since, dispose }`.
- `BufferCapableBroker<Payload>` — broker contract extension flagging buffer support.
- `isBufferCapableBroker(broker): boolean` — discriminant.
- `MemoryReconnectEventBuffer<Payload>` — single-host implementation.
- `BrokerReconnectEventBuffer<Payload>` — broker-backed wrapper.
- `resolveReconnectEventBuffer(strategy, broker, limit)` — factory.
## Implementation Notes
The memory buffer uses `Array.prototype.shift()` for eviction because the typical `limit` is small (default 256 events per `(tenant, topic)` pair), so the O(n) shift is dwarfed by the network cost of a single broadcast. A linked-list ring would micro-optimise the eviction at the cost of opaqueness; the simple array form keeps the code one screenful tall.
The broker wrapper performs `void this.broker.publish(...)` rather than awaiting because the buffer's contract guarantees `push` is synchronous — the caller is on the broadcast hot-path and cannot block on broker latency. Peer-host mirror sync is therefore best-effort; deployments that need strict mirror consistency configure `"broker"` against a stronger backend.

View File

@@ -0,0 +1,143 @@
import type { TenantId } from "@suaveplan/api-types/tenant";
import type { WsBroker } from "@suaveplan/api-ws";
import { createInMemoryWsBroker } from "@suaveplan/api-ws-broker-in-memory";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WS_BUN_CODES, WsBunError } from "../error-codes/error-codes.js";
import {
BrokerReconnectEventBuffer,
type BufferCapableBroker,
isBufferCapableBroker,
MemoryReconnectEventBuffer,
resolveReconnectEventBuffer,
} from "./event-buffer.js";
const tenant = "t-1" as unknown as TenantId;
function bufferCapableBroker(): BufferCapableBroker<unknown> {
const inner = createInMemoryWsBroker();
return Object.assign(inner, {
supportsReconnectBuffer: true as const,
bufferKind: "redis",
});
}
describe("MemoryReconnectEventBuffer", () => {
it("returns the full buffer for null lastEventId", {
timeout: 5000,
}, () => {
const buf = new MemoryReconnectEventBuffer<number>(10);
buf.push({ id: "1", topic: "orders", tenant, payload: 1 });
buf.push({ id: "2", topic: "orders", tenant, payload: 2 });
expect(buf.since(tenant, "orders", null).length).toBe(2);
});
it("returns only events after lastEventId", { timeout: 5000 }, () => {
const buf = new MemoryReconnectEventBuffer<number>(10);
buf.push({ id: "1", topic: "orders", tenant, payload: 1 });
buf.push({ id: "2", topic: "orders", tenant, payload: 2 });
const replay = buf.since(tenant, "orders", "1");
expect(replay.length).toBe(1);
expect(replay[0]?.id).toBe("2");
});
it("returns empty when lastEventId past head", { timeout: 5000 }, () => {
const buf = new MemoryReconnectEventBuffer<number>(10);
buf.push({ id: "1", topic: "orders", tenant, payload: 1 });
expect(buf.since(tenant, "orders", "9").length).toBe(0);
});
it("returns empty for unknown (tenant, topic)", { timeout: 5000 }, () => {
const buf = new MemoryReconnectEventBuffer<number>(10);
expect(buf.since(tenant, "ghost", null).length).toBe(0);
expect(buf.since(tenant, "ghost", "1").length).toBe(0);
});
it("trims oldest events past the limit", { timeout: 5000 }, () => {
const buf = new MemoryReconnectEventBuffer<number>(2);
buf.push({ id: "1", topic: "orders", tenant, payload: 1 });
buf.push({ id: "2", topic: "orders", tenant, payload: 2 });
buf.push({ id: "3", topic: "orders", tenant, payload: 3 });
const snapshot = buf.since(tenant, "orders", null);
expect(snapshot.length).toBe(2);
expect(snapshot.map((e) => e.id)).toEqual(["2", "3"]);
});
it("dispose clears buckets", { timeout: 5000 }, () => {
const buf = new MemoryReconnectEventBuffer<number>(10);
buf.push({ id: "1", topic: "orders", tenant, payload: 1 });
buf.dispose();
expect(buf.since(tenant, "orders", null).length).toBe(0);
});
});
describe("BrokerReconnectEventBuffer", () => {
it("mirrors writes locally and forwards to broker", {
timeout: 5000,
}, async () => {
const broker = bufferCapableBroker();
const buf = new BrokerReconnectEventBuffer<number>(broker, 10);
buf.push({ id: "1", topic: "orders", tenant, payload: 1 });
// Local mirror.
expect(buf.since(tenant, "orders", null).length).toBe(1);
expect(buf.brokerKind).toBe("redis");
buf.dispose();
});
});
describe("resolveReconnectEventBuffer", () => {
it("auto with no broker → memory", { timeout: 5000 }, () => {
const buf = resolveReconnectEventBuffer("auto", null, 10);
expect(buf instanceof MemoryReconnectEventBuffer).toBe(true);
});
it("auto with non-capable broker → memory", { timeout: 5000 }, () => {
const broker = createInMemoryWsBroker();
const buf = resolveReconnectEventBuffer(
"auto",
broker as unknown as WsBroker,
10
);
expect(buf instanceof MemoryReconnectEventBuffer).toBe(true);
});
it("auto with capable broker → broker-backed", { timeout: 5000 }, () => {
const broker = bufferCapableBroker();
const buf = resolveReconnectEventBuffer("auto", broker, 10);
expect(buf instanceof BrokerReconnectEventBuffer).toBe(true);
});
it("memory always returns memory", { timeout: 5000 }, () => {
const broker = bufferCapableBroker();
const buf = resolveReconnectEventBuffer("memory", broker, 10);
expect(buf instanceof MemoryReconnectEventBuffer).toBe(true);
});
it("broker strategy without capable broker refuses at boot", {
timeout: 5000,
}, () => {
try {
resolveReconnectEventBuffer("broker", null, 10);
throw new Error("expected throw");
} catch (err) {
expect(err instanceof WsBunError).toBe(true);
expect((err as WsBunError).wsBunCode).toBe(
WS_BUN_CODES.RECONNECT_BUFFER_MISS
);
}
});
it("broker strategy with capable broker → broker", {
timeout: 5000,
}, () => {
const broker = bufferCapableBroker();
const buf = resolveReconnectEventBuffer("broker", broker, 10);
expect(buf instanceof BrokerReconnectEventBuffer).toBe(true);
});
it("isBufferCapableBroker discriminates", { timeout: 5000 }, () => {
expect(isBufferCapableBroker(null)).toBe(false);
expect(isBufferCapableBroker(undefined)).toBe(false);
expect(isBufferCapableBroker(createInMemoryWsBroker())).toBe(false);
expect(isBufferCapableBroker(bufferCapableBroker())).toBe(true);
});
});

View File

@@ -0,0 +1,174 @@
import type { TenantId } from "@suaveplan/api-types/tenant";
import type { WsBroker } from "@suaveplan/api-ws";
import { raiseReconnectBufferMiss } from "../error-codes/error-codes.js";
import type { ReconnectEventBufferStrategy } from "../options/options.js";
/** Buffered event waiting for replay on reconnect. */
export interface BufferedEvent<Payload = unknown> {
readonly id: string;
readonly topic: string;
readonly tenant: TenantId;
readonly payload: Payload;
}
/**
* Storage contract for the per-`(tenant, topic)` reconnect-event buffer.
*
* The buffer holds at most `limit` events per `(tenant, topic)` pair, in
* FIFO order. `since(id)` returns every buffered event whose id sorts
* lexicographically after `id`; passing `null` returns the full buffer
* snapshot in chronological order.
*/
export interface ReconnectEventBuffer<Payload = unknown> {
push(event: BufferedEvent<Payload>): void;
since(
tenant: TenantId,
topic: string,
lastEventId: string | null
): readonly BufferedEvent<Payload>[];
dispose(): void;
}
/** Capability flag attached to a broker that supports buffer storage. */
export interface BufferCapableBroker<Payload> extends WsBroker<Payload> {
readonly supportsReconnectBuffer: true;
readonly bufferKind?: string;
}
/** Discriminant for resolving `"auto"` to a concrete storage choice. */
export function isBufferCapableBroker<P>(
broker: WsBroker<P> | null | undefined
): broker is BufferCapableBroker<P> {
return (
broker !== null &&
broker !== undefined &&
(broker as BufferCapableBroker<P>).supportsReconnectBuffer === true
);
}
/**
* In-memory ring-buffer implementation. The buffer is sized per
* `(tenant, topic)` pair so a noisy topic in one tenant does not evict
* events for another tenant on the same host.
*/
export class MemoryReconnectEventBuffer<Payload = unknown>
implements ReconnectEventBuffer<Payload>
{
private readonly buckets = new Map<string, BufferedEvent<Payload>[]>();
public constructor(private readonly limit: number) {}
public push(event: BufferedEvent<Payload>): void {
const key = this.key(event.tenant, event.topic);
const list = this.buckets.get(key) ?? [];
list.push(event);
// Trim FIFO when over the cap.
while (list.length > this.limit) list.shift();
this.buckets.set(key, list);
}
public since(
tenant: TenantId,
topic: string,
lastEventId: string | null
): readonly BufferedEvent<Payload>[] {
const list = this.buckets.get(this.key(tenant, topic));
if (list === undefined) return [];
if (lastEventId === null) return list.slice();
const cut = list.findIndex((e) => e.id > lastEventId);
if (cut === -1) return [];
return list.slice(cut);
}
public dispose(): void {
this.buckets.clear();
}
private key(tenant: TenantId, topic: string): string {
return `${tenant as unknown as string}::${topic}`;
}
}
/**
* Broker-backed buffer. Holds a thin proxy onto a broker that has
* declared `supportsReconnectBuffer = true`. The proxy currently delegates
* to an in-memory cache for replay; multi-host brokers implement
* `since(...)` via their own storage layer (Redis Streams in the Redis
* broker; see `@suaveplan/api-ws-broker-redis`).
*
* The wrapper keeps a local mirror so the engine can still replay events
* the local host has seen even when the broker is wired but transiently
* unreachable — this matches the "best-effort replay" semantics in
* design §11.5 paragraph 4.
*/
export class BrokerReconnectEventBuffer<Payload = unknown>
implements ReconnectEventBuffer<Payload>
{
private readonly mirror: MemoryReconnectEventBuffer<Payload>;
public constructor(
private readonly broker: BufferCapableBroker<Payload>,
limit: number
) {
this.mirror = new MemoryReconnectEventBuffer<Payload>(limit);
}
public push(event: BufferedEvent<Payload>): void {
this.mirror.push(event);
// Broadcast onto the broker so peer hosts can build their own
// mirrors. The publish promise is intentionally fire-and-forget.
void this.broker.publish(`__reconnect_buffer__::${event.topic}`, {
topic: event.topic,
tenant: event.tenant,
payload: event.payload,
});
}
public since(
tenant: TenantId,
topic: string,
lastEventId: string | null
): readonly BufferedEvent<Payload>[] {
return this.mirror.since(tenant, topic, lastEventId);
}
public dispose(): void {
this.mirror.dispose();
}
/** Exposed so the engine can assert which broker the buffer wraps. */
public get brokerKind(): string | undefined {
return this.broker.bufferKind;
}
}
/**
* Resolve the configured strategy + wired broker into a concrete buffer.
*
* - `"auto"`: broker-backed when the broker is buffer-capable; memory
* otherwise.
* - `"memory"`: always memory.
* - `"broker"`: refuses at boot if no buffer-capable broker is wired.
*/
export function resolveReconnectEventBuffer<Payload = unknown>(
strategy: ReconnectEventBufferStrategy,
broker: WsBroker<Payload> | null,
limit: number
): ReconnectEventBuffer<Payload> {
if (strategy === "memory") {
return new MemoryReconnectEventBuffer<Payload>(limit);
}
if (strategy === "broker") {
if (!isBufferCapableBroker(broker)) {
raiseReconnectBufferMiss(
"strategy 'broker' requires a buffer-capable broker"
);
}
return new BrokerReconnectEventBuffer<Payload>(broker, limit);
}
// "auto"
if (isBufferCapableBroker(broker)) {
return new BrokerReconnectEventBuffer<Payload>(broker, limit);
}
return new MemoryReconnectEventBuffer<Payload>(limit);
}

View File

@@ -0,0 +1,10 @@
export {
BrokerReconnectEventBuffer,
type BufferCapableBroker,
type BufferedEvent,
isBufferCapableBroker,
MemoryReconnectEventBuffer,
type ReconnectEventBuffer,
resolveReconnectEventBuffer,
} from "./event-buffer.js";
export { ReconnectTokenIssuer } from "./reconnect-token.js";

View File

@@ -0,0 +1,66 @@
# `reconnect-token`
## Purpose
Wraps the api-ws contract's `signReconnectToken` / `verifyReconnectToken` HMAC token helpers so the engine has a stateful issuer that enforces single-use semantics and maps the contract's single `WS.RECONNECT_TOKEN_INVALID` code onto the WS_BUN-namespace pair (`WS_BUN.RECONNECT_TOKEN_INVALID` vs `WS_BUN.RECONNECT_TOKEN_EXPIRED`). This gives the engine a clean place to plumb the HMAC secret, the TTL, and the clock without threading them through every handshake call.
The issuer tracks consumed tokens in a `Set<string>` so a replayed token (same wire string presented twice) is rejected with the same `INVALID` code as a tampered token — replay protection matches the spec scenario.
## Features
- Async `sign` / `verify` over a pre-bound secret + TTL + clock.
- Replay defence — every successful verify marks the token consumed; second presentation throws `WS_BUN.RECONNECT_TOKEN_INVALID`.
- Distinguishes expiry from generic invalidity at the WS_BUN namespace so engines can map onto different HTTP status codes (401 vs 419).
- `consumedCount` accessor for test assertions and capacity tracking.
## Usage: Basic
```ts
import { ReconnectTokenIssuer } from "@suaveplan/api-ws-bun";
const issuer = new ReconnectTokenIssuer(new Uint8Array(32).fill(7));
const token = await issuer.sign({
tenant: "t1",
principalId: "u1",
lastEventId: null,
});
const payload = await issuer.verify(token);
console.log(payload.tenant); // "t1"
```
## Usage: Advanced
```ts
import { ReconnectTokenIssuer, WS_BUN_CODES } from "@suaveplan/api-ws-bun";
const clock = { value: 0 };
const issuer = new ReconnectTokenIssuer(
new Uint8Array(32).fill(13),
1_000,
() => clock.value
);
const token = await issuer.sign({
tenant: "t1",
principalId: "u1",
lastEventId: "evt-9",
});
clock.value += 2_000;
try {
await issuer.verify(token);
} catch (err) {
// (err as { code }).code === WS_BUN_CODES.RECONNECT_TOKEN_EXPIRED
}
```
## API Reference
- `new ReconnectTokenIssuer(secret, ttlMs?, clock?)` — constructor.
- `issuer.sign(payload): Promise<ReconnectTokenString>` — sign a token.
- `issuer.verify(token): Promise<ReconnectTokenPayload>` — verify + consume.
- `issuer.consumedCount: number` — total tokens consumed.
## Implementation Notes
The contract `signReconnectToken` already encodes `exp = clock() + ttlMs` into the payload, so the issuer does not need to maintain its own expiry index. Single-use replay defence, by contrast, is engine-specific (multi-host deployments would need a Redis SET-NX instead of an in-process `Set`), so the issuer owns that state directly.
The expiry detection in `verify` inspects the contract error message rather than re-running an expiry check. The contract error path is the canonical place where "expired" is differentiated from "bad MAC" / "malformed token"; mirroring that decision tree here would risk drift across versions.

View File

@@ -0,0 +1,151 @@
import { WS_CODES, WsError } from "@suaveplan/api-ws";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WS_BUN_CODES, type WsBunError } from "../error-codes/error-codes.js";
import {
ReconnectTokenIssuer,
translateContractError,
} from "./reconnect-token.js";
const secret = new Uint8Array(32).fill(7);
describe("ReconnectTokenIssuer", () => {
it("sign + verify round-trip preserves payload", {
timeout: 5000,
}, async () => {
const issuer = new ReconnectTokenIssuer(secret, 60_000);
const token = await issuer.sign({
tenant: "t1",
principalId: "p1",
lastEventId: "evt-9",
});
const payload = await issuer.verify(token);
expect(payload.tenant).toBe("t1");
expect(payload.principalId).toBe("p1");
expect(payload.lastEventId).toBe("evt-9");
});
it("verify rejects tampered tokens", { timeout: 5000 }, async () => {
const issuer = new ReconnectTokenIssuer(secret, 60_000);
const token = await issuer.sign({
tenant: "t1",
principalId: "p1",
lastEventId: null,
});
const [payload, mac] = token.split(".");
const tampered = `${payload}A.${mac}`;
try {
await issuer.verify(tampered);
throw new Error("expected throw");
} catch (err) {
expect((err as WsBunError).wsBunCode).toBe(
WS_BUN_CODES.RECONNECT_TOKEN_INVALID
);
}
});
it("verify rejects expired tokens with WS_BUN.RECONNECT_TOKEN_EXPIRED", {
timeout: 5000,
}, async () => {
let now = 0;
const issuer = new ReconnectTokenIssuer(secret, 100, () => now);
const token = await issuer.sign({
tenant: "t1",
principalId: "p1",
lastEventId: null,
});
now = 1000;
try {
await issuer.verify(token);
throw new Error("expected throw");
} catch (err) {
expect((err as WsBunError).wsBunCode).toBe(
WS_BUN_CODES.RECONNECT_TOKEN_EXPIRED
);
}
});
it("replayed token (already consumed) rejected", {
timeout: 5000,
}, async () => {
const issuer = new ReconnectTokenIssuer(secret, 60_000);
const token = await issuer.sign({
tenant: "t1",
principalId: "p1",
lastEventId: null,
});
await issuer.verify(token);
try {
await issuer.verify(token);
throw new Error("expected throw");
} catch (err) {
expect((err as WsBunError).wsBunCode).toBe(
WS_BUN_CODES.RECONNECT_TOKEN_INVALID
);
}
expect(issuer.consumedCount).toBe(1);
});
it("uses default TTL when not supplied", { timeout: 5000 }, async () => {
const issuer = new ReconnectTokenIssuer(secret);
const token = await issuer.sign({
tenant: "t1",
principalId: "p1",
lastEventId: null,
});
const payload = await issuer.verify(token);
expect(payload.tenant).toBe("t1");
});
it("malformed token surfaces INVALID code", { timeout: 5000 }, async () => {
const issuer = new ReconnectTokenIssuer(secret);
try {
await issuer.verify("not.a.valid.token");
throw new Error("expected throw");
} catch (err) {
expect((err as WsBunError).wsBunCode).toBe(
WS_BUN_CODES.RECONNECT_TOKEN_INVALID
);
}
});
});
describe("translateContractError", () => {
it("re-throws non-WsError inputs unchanged", { timeout: 5000 }, () => {
const original = new Error("upstream crypto failure");
try {
translateContractError(original);
throw new Error("expected throw");
} catch (err) {
expect(err).toBe(original);
}
});
it("re-throws WsError with non-matching code unchanged", {
timeout: 5000,
}, () => {
const upstream = new WsError(WS_CODES.HANDSHAKE_FAILED, "not our path");
try {
translateContractError(upstream);
throw new Error("expected throw");
} catch (err) {
expect(err).toBe(upstream);
}
});
it("translates RECONNECT_TOKEN_INVALID (expired) into EXPIRED", {
timeout: 5000,
}, () => {
const upstream = new WsError(
WS_CODES.RECONNECT_TOKEN_INVALID,
"expired"
);
try {
translateContractError(upstream);
throw new Error("expected throw");
} catch (err) {
expect((err as WsBunError).wsBunCode).toBe(
WS_BUN_CODES.RECONNECT_TOKEN_EXPIRED
);
}
});
});

View File

@@ -0,0 +1,86 @@
import type {
ReconnectTokenPayload,
ReconnectTokenString,
} from "@suaveplan/api-ws";
import {
signReconnectToken as contractSign,
verifyReconnectToken as contractVerify,
RECONNECT_TOKEN_TTL_MS,
WS_CODES,
WsError,
} from "@suaveplan/api-ws";
import {
raiseReconnectTokenExpired,
raiseReconnectTokenInvalid,
} from "../error-codes/error-codes.js";
/**
* Single-use token issuer wired to the engine's reconnect secret and TTL.
* Wraps `signReconnectToken` from `@suaveplan/api-ws` so the engine
* does not have to thread the secret + clock through every call site.
*/
export class ReconnectTokenIssuer {
private readonly consumed = new Set<string>();
public constructor(
private readonly secret: Uint8Array,
private readonly ttlMs: number = RECONNECT_TOKEN_TTL_MS,
private readonly clock: () => number = Date.now
) {}
/** Issue a token carrying `{tenant, principalId, lastEventId}`. */
public async sign(payload: {
readonly tenant: string;
readonly principalId: string;
readonly lastEventId: string | null;
}): Promise<ReconnectTokenString> {
return contractSign(payload, {
secret: this.secret,
ttlMs: this.ttlMs,
clock: this.clock,
});
}
/**
* Verify a token. Maps the contract's single `RECONNECT_TOKEN_INVALID`
* code onto the WS_BUN-specific code pair (INVALID vs EXPIRED) so
* the engine can surface 401 vs 419-style distinctions. Replay
* (already-consumed) tokens map onto `WS_BUN.RECONNECT_TOKEN_INVALID`.
*/
public async verify(
token: ReconnectTokenString
): Promise<ReconnectTokenPayload> {
if (this.consumed.has(token)) {
raiseReconnectTokenInvalid("token already consumed");
}
const payload = await contractVerify(token, {
secret: this.secret,
clock: this.clock,
}).catch((err: unknown) => translateContractError(err));
this.consumed.add(token);
return payload;
}
/** Snapshot of how many tokens have been consumed — useful for tests. */
public get consumedCount(): number {
return this.consumed.size;
}
}
/**
* Translate a contract `WsError` into the WS_BUN-namespace pair. Always
* throws — the return type is `never` so the call site does not have to
* declare a fallback. Non-`WsError` inputs propagate as-is.
*/
export function translateContractError(err: unknown): never {
if (
err instanceof WsError &&
err.code === WS_CODES.RECONNECT_TOKEN_INVALID
) {
if (err.message.includes("expired")) {
raiseReconnectTokenExpired();
}
raiseReconnectTokenInvalid(err.message);
}
throw err as Error;
}

View File

@@ -0,0 +1,56 @@
# `registry`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wraps the api-ws contract's `InMemoryConnectionRegistry` to give the Bun engine a typed registry surface that the conformance harness can drive. The wrapper does not add data structures of its own — every method delegates to the inner registry — but it does expose the bus-integration helpers `recordSubscription` and `dropSubscription` so the Bun-specific subscription bus can keep its topic index hot.
The registry indexes connections by both connection id and tenant id, which is what powers the `lookupByTenant` fast path used by the broadcast fan-out.
## Features
- Drop-in `ConnectionRegistry` implementation; passes the conformance suite directly.
- `recordSubscription` / `dropSubscription` exposed for the bus.
- `raw` accessor exposes the underlying `InMemoryConnectionRegistry` for the conformance harness factory.
- Tenant-bucket maps so `countByTenant` is O(1), powering the per-tenant cap enforcer.
## Usage: Basic
```ts
import { BunConnectionRegistry } from "@suaveplan/api-ws-bun";
const registry = new BunConnectionRegistry();
registry.add(conn);
console.log(registry.countByTenant(conn.tenantId));
registry.remove(conn);
```
## Usage: Advanced
```ts
import { BunConnectionRegistry } from "@suaveplan/api-ws-bun";
import { InMemorySubscriptionBus } from "@suaveplan/api-ws";
const registry = new BunConnectionRegistry();
const bus = new InMemorySubscriptionBus(registry.raw);
registry.add(conn);
bus.subscribe(conn, "orders");
// `registry.lookupByTopic("orders", conn.tenantId)` now returns [conn].
```
## API Reference
- `new BunConnectionRegistry()` — constructor; no options.
- `add(conn): void` / `remove(conn): void` — lifecycle bookkeeping.
- `lookupByTenant(tenant) / lookupByTopic(topic, tenant?) / countByTenant(tenant)` — read-only lookups.
- `recordSubscription(conn, topic, tenant)` / `dropSubscription(conn, topic, tenant)` — bus integration.
- `raw: InMemoryConnectionRegistry` — escape hatch.
## Implementation Notes
The wrapper class is deliberately thin: the engine wants to treat the registry as a Bun-engine concept (`BunConnectionRegistry`) for ergonomics, but at the implementation level the contract's `InMemoryConnectionRegistry` is the canonical data structure that the conformance harness exercises. Duplicating bookkeeping inside the wrapper would create two sources of truth.
The `raw` accessor exists for the conformance harness factory — `runWsEngineConformance` expects a `ConnectionRegistry` instance, but the in-memory subscription bus needs the concrete `InMemoryConnectionRegistry` so it can call `recordSubscription`. Exposing `raw` lets the factory hand the same underlying registry to both.

View File

@@ -0,0 +1,110 @@
import type { Principal, PrincipalId } from "@suaveplan/api-types/principal";
import type { TenantId } from "@suaveplan/api-types/tenant";
import type { WsConnection, WsEnvelope } from "@suaveplan/api-ws";
import { InMemorySubscriptionBus, WsError } from "@suaveplan/api-ws";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BunConnectionRegistry } from "./connection-registry.js";
const tenantA = "tenant-a" as unknown as TenantId;
const tenantB = "tenant-b" as unknown as TenantId;
function makeConn(id: string, tenant: TenantId): WsConnection {
const sink: WsEnvelope[] = [];
return {
id,
principal: {
id: "p" as unknown as PrincipalId,
scopes: [],
claims: {},
} as Principal,
tenantId: tenant,
lastEventId: null,
state: "open",
subscribe() {},
unsubscribe() {},
send(envelope: WsEnvelope) {
sink.push(envelope);
},
close() {},
ack() {},
};
}
describe("BunConnectionRegistry", () => {
it("indexes by tenant", { timeout: 5000 }, () => {
const reg = new BunConnectionRegistry();
const c1 = makeConn("c1", tenantA);
const c2 = makeConn("c2", tenantA);
const c3 = makeConn("c3", tenantB);
reg.add(c1);
reg.add(c2);
reg.add(c3);
expect(reg.countByTenant(tenantA)).toBe(2);
expect(reg.countByTenant(tenantB)).toBe(1);
const aList = reg.lookupByTenant(tenantA);
expect(aList.length).toBe(2);
});
it("remove maintains tenant index", { timeout: 5000 }, () => {
const reg = new BunConnectionRegistry();
const c1 = makeConn("c1", tenantA);
reg.add(c1);
reg.remove(c1);
expect(reg.countByTenant(tenantA)).toBe(0);
});
it("recordSubscription / dropSubscription drive lookupByTopic", {
timeout: 5000,
}, () => {
const reg = new BunConnectionRegistry();
const c1 = makeConn("c1", tenantA);
reg.add(c1);
reg.recordSubscription(c1, "orders", tenantA);
expect(reg.lookupByTopic("orders", tenantA).length).toBe(1);
reg.dropSubscription(c1, "orders", tenantA);
expect(reg.lookupByTopic("orders", tenantA).length).toBe(0);
});
it("tenant-scoped broadcast delivered only to matching tenant", {
timeout: 5000,
}, () => {
const reg = new BunConnectionRegistry();
const bus = new InMemorySubscriptionBus(reg.raw);
const c1 = makeConn("c1", tenantA);
const c2 = makeConn("c2", tenantB);
reg.add(c1);
reg.add(c2);
bus.subscribe(c1, "orders", { tenant: tenantA });
bus.subscribe(c2, "orders", { tenant: tenantB });
const delivered = bus.broadcast(
{ tenant: tenantA, capabilities: new Set() },
"orders",
{ tenant: tenantA, payload: undefined }
);
expect(delivered).toBe(1);
});
it("cross-tenant broadcast without capability refused", {
timeout: 5000,
}, () => {
const reg = new BunConnectionRegistry();
const bus = new InMemorySubscriptionBus(reg.raw);
const c1 = makeConn("c1", tenantB);
reg.add(c1);
bus.subscribe(c1, "orders", { tenant: tenantB });
expect(() =>
bus.broadcast(
{ tenant: tenantA, capabilities: new Set() },
"orders",
{ tenant: tenantB, payload: undefined }
)
).toThrow(WsError);
});
it("raw accessor returns the underlying registry", {
timeout: 5000,
}, () => {
const reg = new BunConnectionRegistry();
expect(reg.raw).toBeDefined();
});
});

View File

@@ -0,0 +1,65 @@
import type { TenantId } from "@suaveplan/api-types/tenant";
import type { ConnectionRegistry, WsConnection } from "@suaveplan/api-ws";
import { InMemoryConnectionRegistry } from "@suaveplan/api-ws";
/**
* Thin wrapper around the contract's `InMemoryConnectionRegistry` that
* exposes the registry under a Bun-engine-specific type name. The
* intent is dual:
*
* 1. Keep the implementation strictly inside the contract package so
* `runWsEngineConformance` exercises one canonical registry.
* 2. Give the engine a typed handle to call subscription bookkeeping
* (`recordSubscription` / `dropSubscription`) without exposing
* those low-level methods on the abstract `ConnectionRegistry`
* interface.
*/
export class BunConnectionRegistry implements ConnectionRegistry {
private readonly inner = new InMemoryConnectionRegistry();
public add(connection: WsConnection): void {
this.inner.add(connection);
}
public remove(connection: WsConnection): void {
this.inner.remove(connection);
}
public lookupByTenant(tenant: TenantId): readonly WsConnection[] {
return this.inner.lookupByTenant(tenant);
}
public lookupByTopic(
topic: string,
tenant?: TenantId
): readonly WsConnection[] {
return this.inner.lookupByTopic(topic, tenant);
}
public countByTenant(tenant: TenantId): number {
return this.inner.countByTenant(tenant);
}
/** Exposed for the subscription bus integration. */
public recordSubscription(
connection: WsConnection,
topic: string,
tenant: TenantId
): void {
this.inner.recordSubscription(connection, topic, tenant);
}
/** Symmetric drop helper. */
public dropSubscription(
connection: WsConnection,
topic: string,
tenant: TenantId
): void {
this.inner.dropSubscription(connection, topic, tenant);
}
/** Internal accessor — escape hatch for the conformance harness. */
public get raw(): InMemoryConnectionRegistry {
return this.inner;
}
}

View File

@@ -0,0 +1 @@
export { BunConnectionRegistry } from "./connection-registry.js";

View File

@@ -0,0 +1 @@
export { createBunSubscriptionBus } from "./subscription-bus.js";

View File

@@ -0,0 +1,58 @@
# `subscription`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Builds the per-engine subscription bus instance and binds it to the engine's connection registry. The bus implementation itself is `InMemorySubscriptionBus` from the contract package — there is no Bun-specific subscription algorithm — but constructing the bus requires the registry's `raw` accessor so the contract's bookkeeping (`registry.recordSubscription`) stays in lockstep with bus state.
Concentrating that wiring in a single factory means the engine module reads `createBunSubscriptionBus(registry)` instead of poking the registry's internal accessor inline.
## Features
- One-line construction with the correct registry plumb-through.
- Generic over the payload type so plugins narrow `Payload` at engine instantiation time.
- Cross-tenant broadcast denial inherited from the contract bus (the `ws.broadcast-cross-tenant` capability check).
## Usage: Basic
```ts
import {
BunConnectionRegistry,
createBunSubscriptionBus,
} from "@suaveplan/api-ws-bun";
const registry = new BunConnectionRegistry();
const bus = createBunSubscriptionBus<{ ok: true }>(registry);
```
## Usage: Advanced
```ts
import {
BunConnectionRegistry,
createBunSubscriptionBus,
} from "@suaveplan/api-ws-bun";
const registry = new BunConnectionRegistry();
const bus = createBunSubscriptionBus(registry);
registry.add(conn);
bus.subscribe(conn, "orders", { tenant: conn.tenantId });
const delivered = bus.broadcast(
{ tenant: conn.tenantId, capabilities: new Set() },
"orders",
{ tenant: conn.tenantId, payload: { ok: true } }
);
```
## API Reference
- `createBunSubscriptionBus<Payload>(registry): SubscriptionBus<Payload>` — builds an `InMemorySubscriptionBus` bound to the registry.
## Implementation Notes
The factory does not currently inject any custom subscribers — every broadcast flows through `bus.send(connection, ...)` which the contract implements as `connection.send(envelope)`. If a future engine needs per-broadcast OTel instrumentation, the right place to hook it is inside the engine's `Connection.send` adapter, not by forking the bus.
The return type is the abstract `SubscriptionBus` interface (not `InMemorySubscriptionBus`) so consumers cannot rely on bus internals. The engine relies only on the contract surface, and that's what the public type advertises.

View File

@@ -0,0 +1,54 @@
import type { Principal, PrincipalId } from "@suaveplan/api-types/principal";
import type { TenantId } from "@suaveplan/api-types/tenant";
import type {
SubscriptionBus,
WsConnection,
WsEnvelope,
} from "@suaveplan/api-ws";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import { BunConnectionRegistry } from "../registry/connection-registry.js";
import { createBunSubscriptionBus } from "./subscription-bus.js";
const tenant = "t" as unknown as TenantId;
function conn(id: string): WsConnection {
const sink: WsEnvelope[] = [];
return {
id,
principal: {
id: "p" as unknown as PrincipalId,
scopes: [],
claims: {},
} as Principal,
tenantId: tenant,
lastEventId: null,
state: "open",
subscribe() {},
unsubscribe() {},
send(e: WsEnvelope) {
sink.push(e);
},
close() {},
ack() {},
};
}
describe("createBunSubscriptionBus", () => {
it("returns a SubscriptionBus bound to the registry", {
timeout: 5000,
}, () => {
const reg = new BunConnectionRegistry();
const bus = createBunSubscriptionBus<number>(reg);
expectTypeOf(bus).toMatchTypeOf<SubscriptionBus<number>>();
const c = conn("c1");
reg.add(c);
bus.subscribe(c, "orders", { tenant });
expect(reg.lookupByTopic("orders", tenant).length).toBe(1);
const delivered = bus.broadcast(
{ tenant, capabilities: new Set() },
"orders",
{ tenant, payload: 99 }
);
expect(delivered).toBe(1);
});
});

View File

@@ -0,0 +1,16 @@
import type { SubscriptionBus } from "@suaveplan/api-ws";
import { InMemorySubscriptionBus } from "@suaveplan/api-ws";
import type { BunConnectionRegistry } from "../registry/connection-registry.js";
/**
* Factory for a Bun-engine-bound subscription bus. The bus itself is the
* contract's `InMemorySubscriptionBus` — concrete engines do not need a
* fork, but they DO need to pass the shared registry so that
* `registry.lookupByTopic(...)` reflects bus state. Wrapping construction
* here keeps that wiring in one place.
*/
export function createBunSubscriptionBus<Payload = unknown>(
registry: BunConnectionRegistry
): SubscriptionBus<Payload> {
return new InMemorySubscriptionBus<Payload>(registry.raw);
}

View File

@@ -0,0 +1,63 @@
# `conformance-binding`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Binds the abstract `runWsEngineConformance` harness from `@suaveplan/api-ws/testing` to a default `createBunWsEngine`-backed factory. Plugin authors who want to drop the conformance suite into their own integration tests import `runBunWsConformance` from `@suaveplan/api-ws-bun/testing` and get the full Stage-1 contract assertion set wired against the Bun engine in one line.
The factory uses `@suaveplan/api-ws-broker-in-memory` so the conformance run has no external dependencies — every host that can run Bun + vitest can run the suite without standing up Redis.
## Features
- `bunEngineFactory()` — fresh engine/broker per scenario; no shared state.
- `adaptEngineToFactory(engine, broker?)` — exposes a custom engine through the conformance factory contract.
- `runBunWsConformance(options?)` — drop-in suite that defaults to `bunEngineFactory` but accepts a custom `label` / `factory` for plugin-specific wiring.
- Re-export of `runWsEngineConformance` so the api-ws import is one hop away.
## Usage: Basic
```ts
import { runBunWsConformance } from "@suaveplan/api-ws-bun/testing";
runBunWsConformance();
```
## Usage: Advanced
```ts
import { createInMemoryWsBroker } from "@suaveplan/api-ws-broker-in-memory";
import {
adaptEngineToFactory,
runBunWsConformance,
} from "@suaveplan/api-ws-bun/testing";
import { createBunWsEngine } from "@suaveplan/api-ws-bun";
runBunWsConformance({
label: "bun-with-strict-heartbeat",
factory: () => {
const broker = createInMemoryWsBroker();
const engine = createBunWsEngine({
reconnectTokenSecret: new Uint8Array(32).fill(1),
broker,
heartbeatMs: 5_000,
missedPongLimit: 1,
});
return adaptEngineToFactory(engine, broker);
},
});
```
## API Reference
- `bunEngineFactory<Payload>(): WsEngineFactoryResult<Payload>` — default factory.
- `adaptEngineToFactory<Payload>(engine, broker?): WsEngineFactoryResult<Payload>` — manual adapter.
- `runBunWsConformance(options?): void` — convenience wrapper around `runWsEngineConformance`.
## Implementation Notes
`makeTestConnection` routes through the real `engine.attach(...)` path, so the conformance suite exercises the production handshake / registry / bus wiring rather than a stubbed registry add. This is the same property the api-ws contract package's `inMemoryFactory` achieves for the in-memory bus — once you accept the conformance contract, the implementation is tested too.
The default factory's secret is a fixed `Uint8Array(32).fill(7)` — that is acceptable for tests because no test asserts on token contents across factory invocations. Production callers MUST supply their own secret via `createBunWsEngine` directly, never through this convenience factory.

View File

@@ -0,0 +1,67 @@
import type { TenantId } from "@suaveplan/api-types/tenant";
import {
runWsEngineConformance,
type WsEngineConformanceOptions,
type WsEngineFactoryResult,
} from "@suaveplan/api-ws/testing";
import { createInMemoryWsBroker } from "@suaveplan/api-ws-broker-in-memory";
import {
type BunWsEngine,
createBunWsEngine,
makeTestConnection,
} from "../engine/bun-ws-engine.js";
/**
* Default factory bound to `createBunWsEngine`. Each call mints a fresh
* engine, a fresh in-memory broker, and a fresh registry/bus pair —
* conformance scenarios MUST NOT share state.
*/
export function bunEngineFactory<
Payload = unknown,
>(): WsEngineFactoryResult<Payload> {
const broker = createInMemoryWsBroker<Payload>();
const engine = createBunWsEngine<Payload>({
reconnectTokenSecret: new Uint8Array(32).fill(7),
broker,
});
return adaptEngineToFactory<Payload>(engine, broker);
}
/**
* Adapter that exposes a `BunWsEngine` through the
* `WsEngineFactoryResult` interface required by `runWsEngineConformance`.
*
* `makeConnection` calls into `makeTestConnection`, which routes through
* the real `engine.attach(...)` path — so the conformance scenarios
* exercise the production handshake / registry / bus wiring, not a stub.
*/
export function adaptEngineToFactory<Payload>(
engine: BunWsEngine<Payload>,
broker = engine.broker
): WsEngineFactoryResult<Payload> {
return {
bus: engine.bus,
registry: engine.registry,
broker: broker ?? createInMemoryWsBroker<Payload>(),
makeConnection({ id, tenant }: { id: string; tenant: TenantId }) {
return makeTestConnection(engine, { id, tenant });
},
async dispose() {
await engine.dispose();
},
};
}
/**
* Re-export of `runWsEngineConformance` bound to a default factory.
* Consumer packages can either call this directly or pass their own
* factory via `runWsEngineConformance(...)`.
*/
export function runBunWsConformance(
options: Partial<WsEngineConformanceOptions> = {}
): void {
runWsEngineConformance({
label: options.label ?? "bun",
factory: options.factory ?? bunEngineFactory,
});
}

View File

@@ -0,0 +1,6 @@
export { runWsEngineConformance } from "@suaveplan/api-ws/testing";
export {
adaptEngineToFactory,
bunEngineFactory,
runBunWsConformance,
} from "./conformance-binding.js";

View File

@@ -0,0 +1,141 @@
import type { Principal, PrincipalId } from "@suaveplan/api-types/principal";
import type { TenantId } from "@suaveplan/api-types/tenant";
import {
afterAll,
beforeAll,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
type BunWsEngine,
type BunWsSocketSink,
createBunWsEngine,
} from "../../src/engine/bun-ws-engine.js";
const tenant = "tenant-int" as unknown as TenantId;
const principal: Principal = {
id: "p-int" as unknown as PrincipalId,
scopes: [],
claims: {},
};
interface BunServeShim {
upgrade(req: Request, ctx?: { data?: Record<string, unknown> }): boolean;
publish?: unknown;
stop(graceful?: boolean): void;
hostname: string;
port: number;
}
// Bun is available when this test is run under Bun. Vitest also runs under
// Bun in this monorepo. Skip if Bun.serve is missing (e.g. pure Node).
const bunGlobal = (
globalThis as unknown as {
Bun?: {
serve(opts: unknown): BunServeShim;
};
}
).Bun;
const shouldRun = bunGlobal !== undefined;
describe.runIf(shouldRun)("end-to-end Bun.serve WS", () => {
let server: BunServeShim | null = null;
let engine: BunWsEngine<unknown>;
let port = 0;
beforeAll(async () => {
engine = createBunWsEngine({
reconnectTokenSecret: new Uint8Array(32).fill(7),
heartbeatMs: 5_000,
maxQueuedMessages: 32,
});
// Build a Bun.serve wiring that adapts every accepted ws into a
// BunWsSocketSink and calls engine.attach on upgrade.
server = bunGlobal!.serve({
port: 0,
fetch(
req: Request,
srv: {
upgrade(
req: Request,
ctx?: { data?: Record<string, unknown> }
): boolean;
}
): Response | undefined {
const url = new URL(req.url);
if (url.pathname === "/ws") {
const id = url.searchParams.get("id") ?? "anon";
const ok = srv.upgrade(req, { data: { id } });
return ok
? undefined
: new Response("upgrade failed", { status: 400 });
}
return new Response("ok");
},
websocket: {
open(ws: {
data: { id: string };
send(data: string): void;
close(code: number, reason?: string): void;
ping?(): void;
}): void {
const sink: BunWsSocketSink = {
send: (data: string) => ws.send(data),
close: (code: number, reason?: string) =>
ws.close(code, reason),
ping: () => ws.ping?.(),
};
const conn = engine.attach({
id: ws.data.id,
principal,
tenantId: tenant,
capabilities: new Set(),
tokenExpiresAt: Number.MAX_SAFE_INTEGER,
sink,
});
conn.subscribe("orders");
},
message(): void {
/* unused for this smoke */
},
close(): void {
/* unused for this smoke */
},
},
});
port = server.port;
});
afterAll(async () => {
server?.stop(true);
await engine.dispose();
});
it("handshake → subscribe → broadcast → receive over a real socket", {
timeout: 30_000,
}, async () => {
const url = `ws://${server!.hostname}:${port}/ws?id=client-1`;
const received: string[] = [];
const ws = new WebSocket(url);
await new Promise<void>((resolve, reject) => {
ws.onopen = () => resolve();
ws.onerror = (err) => reject(err);
});
ws.onmessage = (ev) => {
received.push(typeof ev.data === "string" ? ev.data : "");
};
// Wait one microtask so engine.attach + subscribe have run.
await new Promise((r) => setTimeout(r, 50));
engine.broadcast({ tenant, capabilities: new Set() }, "orders", {
tenant,
payload: { hello: "world" },
});
await new Promise((r) => setTimeout(r, 100));
expect(received.length).toBeGreaterThan(0);
const parsed = JSON.parse(received[0] as string);
expect(parsed.topic).toBe("orders");
ws.close();
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node", "bun"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,42 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-ws-bun",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"testing/index": "src/testing/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
/^@suaveplan\//,
/^@opentelemetry\//,
/^node:/,
"tslib",
"zod",
],
},
},
});

View File

@@ -0,0 +1,32 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/index.ts",
"src/testing/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});