4.6 KiB
bun-ws-engine
(no submodules)
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 forrunWsEngineConformance. engine.attach({ ... })runs handshake validation, enforces the per-tenant cap, instantiates the backpressure / heartbeat / principal-expiry machinery, and wires the sink onto the resultingManagedConnection.engine.broadcast(caller, topic, opts)records tows.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 theconnection.principal_expiredevent.engine.dispose()closes every open connection and tears down the reconnect buffer.
Usage: Basic
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
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 toengine.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.