first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,124 @@
# `WebSocketGateway`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - 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<void>` — 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.