Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user