# `WebSocketGateway` _(no submodules)_ - heartbeat — Builds the heartbeat sweep that the dae… - [heartbeat](./heartbeat.md) — Builds the heartbeat sweep that the daemon installs while running. The sweep periodically calls `PresenceTracker.detect… - [lifecycle](./lifecycle.md) — Centralises three pieces of lifecycle plumbing the daemon needs but doesn't want inline: WS-server boot, start-completi… - [message-factory](./message-factory.md) — Tiny factory that produces immutable `Message` envelopes for every outbound dispatch the gateway emits. The factory thr… - [presence-fire](./presence-fire.md) — Single side-effect helper that fans out one presence transition (`join` / `leave` / `update`) to every observation sink… - [presence-webhook](./presence-webhook.md) — Best-effort, signed delivery of a single presence event to a configured webhook receiver. The gateway calls this functi… - [room-ops](./room-ops.md) — Free-function module containing every room-level mutation the gateway exposes — create / delete / join / leave / broadc… - heartbeat — Builds the heartbeat sweep that the dae… - [heartbeat](./heartbeat.md) — Builds the heartbeat sweep that the daemon installs while running. The sweep periodically calls `PresenceTracker.detect… - [lifecycle](./lifecycle.md) — Centralises three pieces of lifecycle plumbing the daemon needs but doesn't want inline: WS-server boot, start-completi… - [message-factory](./message-factory.md) — Tiny factory that produces immutable `Message` envelopes for every outbound dispatch the gateway emits. The factory thr… - [presence-fire](./presence-fire.md) — Single side-effect helper that fans out one presence transition (`join` / `leave` / `update`) to every observation sink… - [presence-webhook](./presence-webhook.md) — Best-effort, signed delivery of a single presence event to a configured webhook receiver. The gateway calls this functi… - [room-ops](./room-ops.md) — Free-function module containing every room-level mutation the gateway exposes — create / delete / join / leave / broadc… - heartbeat — Builds the heartbeat sweep that the dae… - [heartbeat](./heartbeat.md) — Builds the heartbeat sweep that the daemon installs while running. The sweep periodically calls `PresenceTracker.detect… - [lifecycle](./lifecycle.md) — Centralises three pieces of lifecycle plumbing the daemon needs but doesn't want inline: WS-server boot, start-completi… - [message-factory](./message-factory.md) — Tiny factory that produces immutable `Message` envelopes for every outbound dispatch the gateway emits. The factory thr… - [presence-fire](./presence-fire.md) — Single side-effect helper that fans out one presence transition (`join` / `leave` / `update`) to every observation sink… - [presence-webhook](./presence-webhook.md) — Best-effort, signed delivery of a single presence event to a configured webhook receiver. The gateway calls this functi… - [room-ops](./room-ops.md) — Free-function module containing every room-level mutation the gateway exposes — create / delete / join / leave / broadc… - heartbeat — Builds the heartbeat sweep that the dae… - [heartbeat](./heartbeat.md) — Builds the heartbeat sweep that the daemon installs while running. The sweep periodically calls `PresenceTracker.detect… - [lifecycle](./lifecycle.md) — Centralises three pieces of lifecycle plumbing the daemon needs but doesn't want inline: WS-server boot, start-completi… - [message-factory](./message-factory.md) — Tiny factory that produces immutable `Message` envelopes for every outbound dispatch the gateway emits. The factory thr… - [presence-fire](./presence-fire.md) — Single side-effect helper that fans out one presence transition (`join` / `leave` / `update`) to every observation sink… - [presence-webhook](./presence-webhook.md) — Best-effort, signed delivery of a single presence event to a configured webhook receiver. The gateway calls this functi… - [room-ops](./room-ops.md) — Free-function module containing every room-level mutation the gateway exposes — create / delete / join / leave / broadc… - [heartbeat](./heartbeat.md) — Builds the heartbeat sweep that the daemon installs while running. The sweep periodically calls `PresenceTracker.detect… - [lifecycle](./lifecycle.md) — Centralises three pieces of lifecycle plumbing the daemon needs but doesn't want inline: WS-server boot, start-completi… - [message-factory](./message-factory.md) — Tiny factory that produces immutable `Message` envelopes for every outbound dispatch the gateway emits. The factory thr… - [presence-fire](./presence-fire.md) — Single side-effect helper that fans out one presence transition (`join` / `leave` / `update`) to every observation sink… - [presence-webhook](./presence-webhook.md) — Best-effort, signed delivery of a single presence event to a configured webhook receiver. The gateway calls this functi… - [room-ops](./room-ops.md) — Free-function module containing every room-level mutation the gateway exposes — create / delete / join / leave / broadc… ## Purpose Server-tier WebSocket gateway daemon. Hosts rooms (broadcast / direct / capacity-controlled), presence tracking, channel-multiplexed pub/sub, and a four-callback access pipeline (auth / authz / rate-limit / audit) on top of an injectable `WsServerLike` adapter. Production binds `ws.WebSocketServer` from `ws`; tests bind a deterministic in-memory `FakeWsServer` so the no-mocks rule holds. Pairs with `@suaveplan/network-transport-websockets` on the browser side. ## Features - Idempotent `start({signal?}) → {address, port}` / `stop()` lifecycle. `stop()` drains in-flight clients and is safe to call multiple times. `AbortSignal` is honoured at every checkpoint. - Five core subsystems: gateway (this class), room, presence, channel, health — each in its own feature module under `src/`. - Direct integration with three shipped packages: `@suaveplan/idempotency` (every mutating event runs through `IdempotencyGuard`), `@suaveplan/webhook-client` (signed presence webhook delivery), `@suaveplan/retry` (consumed transitively by webhook-client). - Four deferred-dep callbacks (`authVerifier`, `authorizationCheck`, `rateLimitGate`, `auditLogger`) that become real package gates when `@suaveplan/jwt-tenant` / `@suaveplan/permissions` / `@suaveplan/rate-limiter` / `@suaveplan/audit-logging` ship . Defaults are no-op pass-throughs documented in `defaults.md`. - OTel surface: spans `ws_gateway.{start,stop,connect,message,broadcast}`, counters `rooms_active` (UpDown), `presence_updates_total{kind}`, `actions_total{action,outcome}`, histograms `message_dispatch_ms`, `start_duration_ms`. - Bounded heartbeat sweep with injectable `Clock`. Times-out clients past `clientTimeoutMs` and disconnects them. ## Usage: Basic Example ```ts import { WebSocketGateway } from "@suaveplan/websocket-gateway"; import { WebSocketServer } from "ws"; const gateway = new WebSocketGateway({ port: 8080, wsServerFactory: () => new MyWsAdapter(WebSocketServer), }); const { address, port } = await gateway.start(); // … later await gateway.stop(); ``` ## Usage: Advanced Example ```ts import { WebSocketGateway } from "@suaveplan/websocket-gateway"; const gateway = new WebSocketGateway({ port: 0, host: "127.0.0.1", wsServerFactory: realWsFactory, presenceWebhook: { url: "https://hooks.example.com/presence", secret: process.env.PRESENCE_SECRET, events: ["presence:join", "presence:leave"], }, authVerifier: async (handshake) => { const token = handshake.headers.authorization?.replace(/^Bearer /, ""); const claims = await verifyJwt(token); return { clientId: claims.sub, principal: claims.sub, tenantId: claims.tid, scopes: claims.scope.split(" "), metadata: {}, }; }, rateLimitGate: async (ctx, action) => action === "room.broadcast" ? bucket.tryConsume(ctx.principal) : true, }); const { address, port } = await gateway.start(); gateway.createRoom({ id: "general", maxClients: 100 }); await gateway.joinRoom("general", "alice"); await gateway.broadcastToRoom("general", "alice", { text: "hi" }); ``` ## API Reference - `new WebSocketGateway(config, clock?)` — Zod-validates config; throws `WsGatewayError(INVALID_OPTIONS)` on failure. - `start(options?): Promise<{address, port}>` / `stop(): Promise` — lifecycle. - `connectClient(id, metadata?)` / `disconnectClient(id, reason?)` / `isClientConnected(id)` / `getClient(id)` / `getAuthContext(id)` — client surface. - `createRoom(config)` / `getRoom(id)` / `deleteRoom(id)` / `joinRoom(roomId, clientId, idempotencyKey?)` / `leaveRoom` / `broadcastToRoom(roomId, senderId, payload, idempotencyKey?)` / `unicast(senderId, targetId, payload)` — room surface. - `getOrCreateChannel(name, allowPatterns?)` / `subscribeToChannel(channel, clientId, topic)` / `unsubscribeFromChannel` / `publishToChannel(channel, senderId, topic, payload)` — channel surface. - `getPresenceTracker()` / `handleHeartbeat(clientId)` — presence surface. - `snapshot()` — `HealthSnapshot` for inline health probes. - `boundAddress` / `boundPort` getters available after `start()`. ## Implementation Notes The daemon's state lives in a single `GatewayState` struct so lifecycle helpers (in `lifecycle.ts`) can drive `start`, `stop`, and `runAuthHandshake` as free functions; the public class is then a thin facade. The same factoring lets the four op modules (`client-lifecycle.ts`, `room-ops.ts`, `channel-ops.ts`, `access-gate.ts`) stay free of class state — every test can drive them with hand-rolled fakes. Capacity enforcement throws typed errors rather than returning sentinel booleans: a caller that runs out of room slots or hits a missing room receives `ROOM_FULL` / `ROOM_NOT_FOUND` and can branch on `error.code`. Auth failure during the handshake closes the socket with WS code 4001 and surfaces an `AUTH_REJECTED` error. Rate-limit and authorization denials surface `RATE_LIMITED` / `AUTH_REJECTED` (the latter shared with handshake denials — every authorisation rejection lands in the same bucket so audit dashboards stay clean). Idempotency is wired on `joinRoom` and `broadcastToRoom` via `IdempotencyGuard` from `@suaveplan/idempotency`. Callers can pass an explicit `idempotencyKey`; without one, the daemon synthesizes a deterministic key including the clock so two same-second `broadcastToRoom` calls do NOT collapse unless the caller wants them to.