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,192 @@
|
||||
# @suaveplan/websocket-gateway - Server-tier WebSocket gateway daemon — rooms, presence, and channel multiplexing over WebSocket connections. Idempotent start/stop lifecycle, injectable wsServerFactory, AbortSignal-aware, inline /health endpoints, OTel observability, callbacks for auth/authz/rate-limit/audit until those packages land .
|
||||
|
||||
## Summary
|
||||
|
||||
Server-tier WebSocket gateway daemon with rooms, presence tracking, and channel-multiplexed pub/sub. Pairs with [`@suaveplan/network-transport-websockets`](../network-transport-websockets) on the browser side.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [channel](./src/channel/channel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [channel](./src/channel/channel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [gateway](./src/gateway/gateway.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - heartbeat — Builds the heartbeat sweep that the dae…
|
||||
- [access-gate](./src/gateway/access-gate.md) — Central rate-limit / authorization / audit pipeline. The gateway calls `gate(...)` once before every mutating action (r…
|
||||
- [channel-manager](./src/gateway/channel-manager.md) — Owns the lifecycle of every `Channel` registered on a gateway instance. Clients never see a raw `Channel` — every chann…
|
||||
- [channel-ops](./src/gateway/channel-ops.md) — Free-function module containing every channel-level mutation the gateway exposes — `subscribeToChannel`, `unsubscribeFr…
|
||||
- [client-lifecycle](./src/gateway/client-lifecycle.md) — Free-function module containing `connectClient` and `disconnectClient` — every state mutation that happens when a clien…
|
||||
- [defaults](./src/gateway/defaults.md) — The four deferred-dependency hooks (`AuthVerifier`, `AuthorizationCheck`, `RateLimitGate`, `AuditLogger`) need sensible…
|
||||
- [gateway-deps](./src/gateway/gateway-deps.md) — Centralises the cross-module dependency-bundle construction the daemon needs once at construction time. Without this mo…
|
||||
- [gateway](./src/gateway/gateway.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - heartbeat — Builds the heartbeat sweep that the dae…
|
||||
- [heartbeat](./src/gateway/heartbeat.md) — Builds the heartbeat sweep that the daemon installs while running. The sweep periodically calls `PresenceTracker.detect…
|
||||
- [lifecycle](./src/gateway/lifecycle.md) — Centralises three pieces of lifecycle plumbing the daemon needs but doesn't want inline: WS-server boot, start-completi…
|
||||
- [message-factory](./src/gateway/message-factory.md) — Tiny factory that produces immutable `Message` envelopes for every outbound dispatch the gateway emits. The factory thr…
|
||||
- [presence-fire](./src/gateway/presence-fire.md) — Single side-effect helper that fans out one presence transition (`join` / `leave` / `update`) to every observation sink…
|
||||
- [presence-webhook](./src/gateway/presence-webhook.md) — Best-effort, signed delivery of a single presence event to a configured webhook receiver. The gateway calls this functi…
|
||||
- [room-ops](./src/gateway/room-ops.md) — Free-function module containing every room-level mutation the gateway exposes — create / delete / join / leave / broadc…
|
||||
- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [presence](./src/presence/presence.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [presence](./src/presence/presence.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [room](./src/room/room.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [room](./src/room/room.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [channel](./src/channel/channel.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [gateway](./src/gateway/gateway.md) — <!-- begin:auto-docs --> _(no submodules)_- heartbeat — Builds the heartbeat sweep that the dae…
|
||||
- [access-gate](./src/gateway/access-gate.md) — Central rate-limit / authorization / audit pipeline. The gateway calls `gate(...)` once before every mutating action (r…
|
||||
- [channel-manager](./src/gateway/channel-manager.md) — Owns the lifecycle of every `Channel` registered on a gateway instance. Clients never see a raw `Channel` — every chann…
|
||||
- [channel-ops](./src/gateway/channel-ops.md) — Free-function module containing every channel-level mutation the gateway exposes — `subscribeToChannel`, `unsubscribeFr…
|
||||
- [client-lifecycle](./src/gateway/client-lifecycle.md) — Free-function module containing `connectClient` and `disconnectClient` — every state mutation that happens when a clien…
|
||||
- [defaults](./src/gateway/defaults.md) — The four deferred-dependency hooks (`AuthVerifier`, `AuthorizationCheck`, `RateLimitGate`, `AuditLogger`) need sensible…
|
||||
- [gateway-deps](./src/gateway/gateway-deps.md) — Centralises the cross-module dependency-bundle construction the daemon needs once at construction time. Without this mo…
|
||||
- [gateway](./src/gateway/gateway.md) — <!-- begin:auto-docs --> _(no submodules)_- heartbeat — Builds the heartbeat sweep that the dae…
|
||||
- [heartbeat](./src/gateway/heartbeat.md) — Builds the heartbeat sweep that the daemon installs while running. The sweep periodically calls `PresenceTracker.detect…
|
||||
- [lifecycle](./src/gateway/lifecycle.md) — Centralises three pieces of lifecycle plumbing the daemon needs but doesn't want inline: WS-server boot, start-completi…
|
||||
- [message-factory](./src/gateway/message-factory.md) — Tiny factory that produces immutable `Message` envelopes for every outbound dispatch the gateway emits. The factory thr…
|
||||
- [presence-fire](./src/gateway/presence-fire.md) — Single side-effect helper that fans out one presence transition (`join` / `leave` / `update`) to every observation sink…
|
||||
- [presence-webhook](./src/gateway/presence-webhook.md) — Best-effort, signed delivery of a single presence event to a configured webhook receiver. The gateway calls this functi…
|
||||
- [room-ops](./src/gateway/room-ops.md) — Free-function module containing every room-level mutation the gateway exposes — create / delete / join / leave / broadc…
|
||||
- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [presence](./src/presence/presence.md) — <!-- begin:auto-docs --> _(no submodules)_- [presence](./src/presence/presence.md) — <!-- begin:auto-docs --> _(no submodules)_- [room](./src/room/room.md) — <!-- begin:auto-docs --> _(no submodules)_- [room](./src/room/room.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [channel](./src/channel/channel.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [gateway](./src/gateway/gateway.md) — <!-- begin:auto-docs --> _(no submodules)_- heartbeat — Builds the heartbeat sweep that the dae…
|
||||
- [access-gate](./src/gateway/access-gate.md) — Central rate-limit / authorization / audit pipeline. The gateway calls `gate(...)` once before every mutating action (r…
|
||||
- [channel-manager](./src/gateway/channel-manager.md) — Owns the lifecycle of every `Channel` registered on a gateway instance. Clients never see a raw `Channel` — every chann…
|
||||
- [channel-ops](./src/gateway/channel-ops.md) — Free-function module containing every channel-level mutation the gateway exposes — `subscribeToChannel`, `unsubscribeFr…
|
||||
- [client-lifecycle](./src/gateway/client-lifecycle.md) — Free-function module containing `connectClient` and `disconnectClient` — every state mutation that happens when a clien…
|
||||
- [defaults](./src/gateway/defaults.md) — The four deferred-dependency hooks (`AuthVerifier`, `AuthorizationCheck`, `RateLimitGate`, `AuditLogger`) need sensible…
|
||||
- [gateway-deps](./src/gateway/gateway-deps.md) — Centralises the cross-module dependency-bundle construction the daemon needs once at construction time. Without this mo…
|
||||
- [gateway](./src/gateway/gateway.md) — <!-- begin:auto-docs --> _(no submodules)_- heartbeat — Builds the heartbeat sweep that the dae…
|
||||
- [heartbeat](./src/gateway/heartbeat.md) — Builds the heartbeat sweep that the daemon installs while running. The sweep periodically calls `PresenceTracker.detect…
|
||||
- [lifecycle](./src/gateway/lifecycle.md) — Centralises three pieces of lifecycle plumbing the daemon needs but doesn't want inline: WS-server boot, start-completi…
|
||||
- [message-factory](./src/gateway/message-factory.md) — Tiny factory that produces immutable `Message` envelopes for every outbound dispatch the gateway emits. The factory thr…
|
||||
- [presence-fire](./src/gateway/presence-fire.md) — Single side-effect helper that fans out one presence transition (`join` / `leave` / `update`) to every observation sink…
|
||||
- [presence-webhook](./src/gateway/presence-webhook.md) — Best-effort, signed delivery of a single presence event to a configured webhook receiver. The gateway calls this functi…
|
||||
- [room-ops](./src/gateway/room-ops.md) — Free-function module containing every room-level mutation the gateway exposes — create / delete / join / leave / broadc…
|
||||
- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [presence](./src/presence/presence.md) — <!-- begin:auto-docs --> _(no submodules)_- [presence](./src/presence/presence.md) — <!-- begin:auto-docs --> _(no submodules)_- [room](./src/room/room.md) — <!-- begin:auto-docs --> _(no submodules)_- [room](./src/room/room.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [channel](./src/channel/channel.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [gateway](./src/gateway/gateway.md) — <!-- begin:auto-docs --> _(no submodules)_- heartbeat — Builds the heartbeat sweep that the dae…
|
||||
- [access-gate](./src/gateway/access-gate.md) — Central rate-limit / authorization / audit pipeline. The gateway calls `gate(...)` once before every mutating action (r…
|
||||
- [channel-manager](./src/gateway/channel-manager.md) — Owns the lifecycle of every `Channel` registered on a gateway instance. Clients never see a raw `Channel` — every chann…
|
||||
- [channel-ops](./src/gateway/channel-ops.md) — Free-function module containing every channel-level mutation the gateway exposes — `subscribeToChannel`, `unsubscribeFr…
|
||||
- [client-lifecycle](./src/gateway/client-lifecycle.md) — Free-function module containing `connectClient` and `disconnectClient` — every state mutation that happens when a clien…
|
||||
- [defaults](./src/gateway/defaults.md) — The four deferred-dependency hooks (`AuthVerifier`, `AuthorizationCheck`, `RateLimitGate`, `AuditLogger`) need sensible…
|
||||
- [gateway-deps](./src/gateway/gateway-deps.md) — Centralises the cross-module dependency-bundle construction the daemon needs once at construction time. Without this mo…
|
||||
- [gateway](./src/gateway/gateway.md) — <!-- begin:auto-docs --> _(no submodules)_- heartbeat — Builds the heartbeat sweep that the dae…
|
||||
- [heartbeat](./src/gateway/heartbeat.md) — Builds the heartbeat sweep that the daemon installs while running. The sweep periodically calls `PresenceTracker.detect…
|
||||
- [lifecycle](./src/gateway/lifecycle.md) — Centralises three pieces of lifecycle plumbing the daemon needs but doesn't want inline: WS-server boot, start-completi…
|
||||
- [message-factory](./src/gateway/message-factory.md) — Tiny factory that produces immutable `Message` envelopes for every outbound dispatch the gateway emits. The factory thr…
|
||||
- [presence-fire](./src/gateway/presence-fire.md) — Single side-effect helper that fans out one presence transition (`join` / `leave` / `update`) to every observation sink…
|
||||
- [presence-webhook](./src/gateway/presence-webhook.md) — Best-effort, signed delivery of a single presence event to a configured webhook receiver. The gateway calls this functi…
|
||||
- [room-ops](./src/gateway/room-ops.md) — Free-function module containing every room-level mutation the gateway exposes — create / delete / join / leave / broadc…
|
||||
- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [presence](./src/presence/presence.md) — <!-- begin:auto-docs --> _(no submodules)_- [presence](./src/presence/presence.md) — <!-- begin:auto-docs --> _(no submodules)_- [room](./src/room/room.md) — <!-- begin:auto-docs --> _(no submodules)_- [room](./src/room/room.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [channel](./src/channel/channel.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ - [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [gateway](./src/gateway/gateway.md) — <!-- begin:auto-docs --> _(no submodules)_ - [access-gate](./src/gateway/access-gate.md) — Central rate-limit / authorization / audit pipeline. The gateway calls `gate(...)` once before every mutating action (r…
|
||||
- [channel-manager](./src/gateway/channel-manager.md) — Owns the lifecycle of every `Channel` registered on a gateway instance. Clients never see a raw `Channel` — every chann…
|
||||
- [channel-ops](./src/gateway/channel-ops.md) — Free-function module containing every channel-level mutation the gateway exposes — `subscribeToChannel`, `unsubscribeFr…
|
||||
- [client-lifecycle](./src/gateway/client-lifecycle.md) — Free-function module containing `connectClient` and `disconnectClient` — every state mutation that happens when a clien…
|
||||
- [defaults](./src/gateway/defaults.md) — The four deferred-dependency hooks (`AuthVerifier`, `AuthorizationCheck`, `RateLimitGate`, `AuditLogger`) need sensible…
|
||||
- [gateway-deps](./src/gateway/gateway-deps.md) — Centralises the cross-module dependency-bundle construction the daemon needs once at construction time. Without this mo…
|
||||
- [gateway](./src/gateway/gateway.md) — <!-- begin:auto-docs --> _(no submodules)_ - [heartbeat](./src/gateway/heartbeat.md) — Builds the heartbeat sweep that the daemon installs while running. The sweep periodically calls `PresenceTracker.detect…
|
||||
- [lifecycle](./src/gateway/lifecycle.md) — Centralises three pieces of lifecycle plumbing the daemon needs but doesn't want inline: WS-server boot, start-completi…
|
||||
- [message-factory](./src/gateway/message-factory.md) — Tiny factory that produces immutable `Message` envelopes for every outbound dispatch the gateway emits. The factory thr…
|
||||
- [presence-fire](./src/gateway/presence-fire.md) — Single side-effect helper that fans out one presence transition (`join` / `leave` / `update`) to every observation sink…
|
||||
- [presence-webhook](./src/gateway/presence-webhook.md) — Best-effort, signed delivery of a single presence event to a configured webhook receiver. The gateway calls this functi…
|
||||
- [room-ops](./src/gateway/room-ops.md) — Free-function module containing every room-level mutation the gateway exposes — create / delete / join / leave / broadc…
|
||||
- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_ - [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [presence](./src/presence/presence.md) — <!-- begin:auto-docs --> _(no submodules)_ - [presence](./src/presence/presence.md) — <!-- begin:auto-docs --> _(no submodules)_- [room](./src/room/room.md) — <!-- begin:auto-docs --> _(no submodules)_ - [room](./src/room/room.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/websocket-gateway
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { WebSocketGateway } from "@suaveplan/websocket-gateway";
|
||||
import { makeRealWsServerFactory } from "@suaveplan/websocket-gateway/tests/setup/real-ws-adapter";
|
||||
|
||||
const gateway = new WebSocketGateway({
|
||||
port: 8080,
|
||||
host: "0.0.0.0",
|
||||
wsServerFactory: makeRealWsServerFactory(() => gateway.snapshot()),
|
||||
});
|
||||
|
||||
const { address, port } = await gateway.start();
|
||||
console.log(`gateway listening on ws://${address}:${port}`);
|
||||
|
||||
// Rooms
|
||||
gateway.createRoom({ id: "general", maxClients: 100 });
|
||||
await gateway.joinRoom("general", "alice");
|
||||
await gateway.broadcastToRoom("general", "alice", { text: "hi" });
|
||||
|
||||
// Channels
|
||||
await gateway.subscribeToChannel("events", "alice", "user.created");
|
||||
await gateway.publishToChannel("events", "alice", "user.created", { id: "u1" });
|
||||
|
||||
// Graceful shutdown
|
||||
await gateway.stop();
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Five core subsystems, each in its own feature module:
|
||||
|
||||
- `room` — capacity-controlled member sets with broadcast + history.
|
||||
- `presence` — online/away/busy/offline state with heartbeat sweep.
|
||||
- `channel` — pub/sub with `.`-delimited topic wildcards (`*`, `**`).
|
||||
- `gateway` — the orchestrator that owns the WS server, the gate pipeline, and idempotency.
|
||||
- `health` — inline `/health`, `/ready`, `/live` probes served from the same TCP port.
|
||||
|
||||
The daemon binds an injectable `WsServerLike` adapter: production wires the real `ws.WebSocketServer`, tests bind a deterministic in-memory `FakeWsServer` so the no-mocks policy holds.
|
||||
|
||||
## Deferred integrations (replace these callbacks when packages land )
|
||||
|
||||
The proposal explicitly defers full integration with four upstream packages until they ship. Until then, the gateway accepts callbacks:
|
||||
|
||||
| Callback | Default | Replace with |
|
||||
|---|---|---|
|
||||
| `authVerifier(handshake) => Promise<AuthContext>` | anonymous pass-through | `@suaveplan/jwt-tenant` JWT verifier |
|
||||
| `authorizationCheck(ctx, action) => Promise<boolean>` | always allow | `@suaveplan/permissions` RBAC/ABAC gate |
|
||||
| `rateLimitGate(ctx, action) => Promise<boolean>` | always allow | `@suaveplan/rate-limiter` bucket gate |
|
||||
| `auditLogger(event) => void` | no-op | `@suaveplan/audit-logging` sink |
|
||||
|
||||
Replacement is per-callback: when `@suaveplan/permissions` ships, swap `authorizationCheck` and leave the others alone. Public types (`AuthContext`, `GatewayAction`, `AuditEvent`) do not change.
|
||||
|
||||
```ts
|
||||
import { WebSocketGateway } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const gateway = new WebSocketGateway({
|
||||
port: 0,
|
||||
wsServerFactory: realFactory,
|
||||
authVerifier: async (handshake) => {
|
||||
// When @suaveplan/jwt-tenant lands, replace this body with:
|
||||
// return jwtTenant.verify(handshake.headers.authorization);
|
||||
return defaultAuthVerifier(handshake);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Direct integrations (already wired)
|
||||
|
||||
These packages have shipped and the gateway uses them directly:
|
||||
|
||||
- `@suaveplan/idempotency` — `IdempotencyGuard` wraps every mutating event (room create, broadcast, presence transition). Pass an `idempotencyKey` on `joinRoom` / `broadcastToRoom` to deduplicate at-least-once retries.
|
||||
- `@suaveplan/webhook-client` — `deliver()` ships every presence event (when `presenceWebhook` is configured) with HMAC-SHA-256 signing and retry budget. Failures are swallowed so presence is best-effort.
|
||||
- `@suaveplan/retry` — consumed transitively by `webhook-client` for exponential backoff + circuit breaking.
|
||||
- `@suaveplan/logger` — structured logging (peer dep; consumer wires its own logger).
|
||||
|
||||
## Observability
|
||||
|
||||
OTel spans: `ws_gateway.{start, stop, connect, message, broadcast}`. Counters: `ws_gateway.rooms_active` (UpDown), `ws_gateway.presence_updates_total{kind}`, `ws_gateway.actions_total{action, outcome}`. Histograms: `ws_gateway.message_dispatch_ms`, `ws_gateway.start_duration_ms`. All instruments live in `./otel/otel.ts`.
|
||||
|
||||
## Configuration
|
||||
|
||||
See `WebSocketGatewayConfig` in `src/types/types.ts` for the full surface. Highlights:
|
||||
|
||||
- `port` / `host` — listener bind. `port: 0` for ephemeral.
|
||||
- `maxRooms` / `maxClientsPerRoom` — capacity caps; overflow throws `ROOM_FULL`.
|
||||
- `heartbeatIntervalMs` / `clientTimeoutMs` — heartbeat sweep cadence + timeout.
|
||||
- `enablePresence` / `enableChannels` — feature toggles.
|
||||
- `presenceWebhook` — optional signed webhook delivery for presence events.
|
||||
- `bus` — optional `EventBusLike<GatewayEventMap>` for typed observability.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,60 @@
|
||||
# `Channel`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Pub/sub channel primitive with topic-based pattern matching. The gateway uses one `Channel` per registered logical channel id and routes `channel.publish` frames through it. Channels are independent of rooms — they let a single TCP connection carry multiple logical streams without interleaving (the daemon's "channel multiplexing" requirement).
|
||||
|
||||
## Features
|
||||
|
||||
- Per-client subscription set, keyed by exact topic OR pattern.
|
||||
- Two wildcard verbs when `allowPatterns: true`: `*` matches one segment, `**` matches zero or more segments (separator `.`).
|
||||
- `publish(topic, message, excludeSender?)` returns deterministic recipient ids so the daemon dispatches outside the channel.
|
||||
- Invalid topics (`""`, or wildcard tokens under `allowPatterns: false`) throw `WS_GATEWAY.CHANNEL_INVALID` so the daemon can surface the typed denial back to the offending client.
|
||||
- Empty / negative subscriber sets clean up automatically so the channel can be observed for membership-based cleanup.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { Channel } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const ch = new Channel({ name: "events", allowPatterns: true });
|
||||
ch.subscribe("alice", "user.*");
|
||||
ch.publish("user.created", message);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { Channel, WS_GATEWAY_CODES, WsGatewayError } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const ch = new Channel({ name: "events", allowPatterns: false });
|
||||
try {
|
||||
ch.subscribe("alice", "user.*");
|
||||
} catch (err) {
|
||||
if (err instanceof WsGatewayError && err.code === WS_GATEWAY_CODES.CHANNEL_INVALID) {
|
||||
// wildcard rejected under allowPatterns:false
|
||||
}
|
||||
}
|
||||
const patterns = new Channel({ name: "logs", allowPatterns: true });
|
||||
patterns.subscribe("bob", "logs.**"); // matches logs.app, logs.app.error, etc.
|
||||
patterns.unsubscribe("bob", "logs.**");
|
||||
patterns.unsubscribeAll("bob");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new Channel(config: ChannelConfig)` — throws `CHANNEL_INVALID` on empty `name`.
|
||||
- `name` / `allowPatterns` / `subscriberCount` getters.
|
||||
- `subscribe(clientId, topic): boolean` — false if already subscribed, throws on invalid topic.
|
||||
- `unsubscribe(clientId, topic): boolean` — false if not present.
|
||||
- `unsubscribeAll(clientId): boolean` — drops every subscription for one client.
|
||||
- `publish(topic, message, excludeSender?): string[]` — deterministic recipient set.
|
||||
- `getSubscriptions(clientId)` / `getSubscribers()` / `hasSubscriber(clientId)`.
|
||||
- `topicMatches(pattern, topic): boolean` — exposed for callers that want to check match without subscribing.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The wildcard matcher is a hand-rolled recursive descent over `.`-delimited segments. `**` recurses with the same pattern index across every possible tail of the topic, enabling zero-segment matches at the trailing position (`logs.**` matches `logs`). The flat per-client `Set<string>` storage keeps subscribe/unsubscribe O(topics-per-client) and lets the daemon's disconnect path call `unsubscribeAll` in O(1). Publish is O(subscribers × topics-per-subscriber) — the design assumes channels stay under a few thousand subscribers in practice; sharded channels are the future job of a `websocket-gateway-store-redis` adapter.
|
||||
@@ -0,0 +1,56 @@
|
||||
# `@suaveplan/websocket-gateway` errors
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Stable, machine-readable error registry for every failure path the WebSocket gateway exposes to callers. The daemon never throws raw `Error` objects across public boundaries — every failure surfaces as a `WsGatewayError` carrying one of the ten codes registered here. This lets consumers branch on a typed discriminator and keeps i18n message keys decoupled from English copy.
|
||||
|
||||
The registry is built through `defineCodes` from `@suaveplan/error` so the codes participate in the repo-wide stable code registry without colliding with neighbouring packages (every code is prefixed `WS_GATEWAY.*`).
|
||||
|
||||
## Features
|
||||
|
||||
- Ten frozen codes covering lifecycle, room/channel admission, and the four deferred-dependency hooks (auth, authz, rate limit, audit) until those packages land .
|
||||
- `WsGatewayError extends SuaveplanError<WsGatewayErrorCode>` preserves cause chain, i18n message key, structured context, and OTel span-event adapter for free.
|
||||
- Every code carries a documented semantic in the file header; renaming or repurposing a code is a breaking change.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { WS_GATEWAY_CODES, WsGatewayError } from "@suaveplan/websocket-gateway";
|
||||
|
||||
throw new WsGatewayError(
|
||||
WS_GATEWAY_CODES.ROOM_FULL,
|
||||
"ws_gateway.room.full",
|
||||
{ context: { roomId: "general", maxClients: 50 } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { WS_GATEWAY_CODES, WsGatewayError } from "@suaveplan/websocket-gateway";
|
||||
|
||||
try {
|
||||
await gateway.start();
|
||||
} catch (err) {
|
||||
if (err instanceof WsGatewayError && err.code === WS_GATEWAY_CODES.LISTEN_FAILED) {
|
||||
// Port-in-use — fall back to ephemeral
|
||||
} else if (err instanceof WsGatewayError && err.code === WS_GATEWAY_CODES.START_FAILED) {
|
||||
// Factory threw or pre-aborted
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `WS_GATEWAY_CODES` — frozen string-enum produced by `defineCodes("WS_GATEWAY", [...])`. Keys are uppercase code names, values are the prefixed identifier strings (e.g. `"WS_GATEWAY.ROOM_FULL"`).
|
||||
- `WsGatewayErrorCode` — union of every value in `WS_GATEWAY_CODES`.
|
||||
- `class WsGatewayError extends SuaveplanError<WsGatewayErrorCode>` — constructor `(code, messageKey, options?)` where `options` accepts `cause`, `context`, and the standard `SuaveplanErrorOptions` envelope.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is intentionally short and orthogonal — every code answers exactly one yes/no question at one boundary. Lifecycle codes (`INVALID_OPTIONS` / `START_FAILED` / `LISTEN_FAILED` / `DISPOSED` / `NOT_RUNNING`) and admission codes (`ROOM_NOT_FOUND` / `ROOM_FULL` / `CHANNEL_INVALID`) cover the daemon's own decisions. The three callback-gated codes (`AUTH_REJECTED` / `RATE_LIMITED` and the audit path) are the surfaces where unmigrated packages (`@suaveplan/jwt-tenant`, `@suaveplan/rate-limiter`, `@suaveplan/audit-logging`) will eventually plug in. When those land , the corresponding callbacks become real package gates and these codes stay stable.
|
||||
@@ -0,0 +1,51 @@
|
||||
# `gate` + `audit`
|
||||
|
||||
## Purpose
|
||||
|
||||
Central rate-limit / authorization / audit pipeline. The gateway calls `gate(...)` once before every mutating action (room create, broadcast, channel subscribe, channel publish, presence update, unicast). Splitting this out of the main daemon file keeps `gateway.ts` under the 300-line cap and makes the gate behaviour unit-testable without booting a server.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict ordering: rate-limit first, authorization second. DoS traffic never exercises the (potentially expensive) authorization callback.
|
||||
- Typed denials: `RATE_LIMITED` and `AUTH_REJECTED` codes surface the exact failure reason to the caller.
|
||||
- Every decision records an OTel `recordAction(action, outcome)` counter increment AND a structured audit-log entry through the injected `AuditLogger`.
|
||||
- `audit(...)` swallows any thrown logger error so observability bugs cannot crash the daemon.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { gate } from "@suaveplan/websocket-gateway";
|
||||
|
||||
await gate(
|
||||
{ rateLimitGate, authorizationCheck, auditLogger, clock: Date.now },
|
||||
ctx,
|
||||
"room.broadcast"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { audit, gate } from "@suaveplan/websocket-gateway";
|
||||
|
||||
try {
|
||||
await gate(deps, ctx, "channel.publish");
|
||||
// … perform the publish
|
||||
} catch (err) {
|
||||
if (err instanceof WsGatewayError && err.code === WS_GATEWAY_CODES.RATE_LIMITED) {
|
||||
// The audit logger has already received a "denied" record; the
|
||||
// caller may emit an additional client-side denial event.
|
||||
audit(deps.auditLogger, "channel.publish", ctx, "denied", "client-handled", Date.now());
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `gate(deps, ctx, action): Promise<void>` — runs rate-limit + authz. Throws `WsGatewayError` on denial.
|
||||
- `audit(logger, action, ctx, outcome, reason, at): void` — pushes one record through the audit logger; swallows errors.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function is intentionally a free function (not a class method) so the daemon can pass it any composition of callbacks — a future tier might want to gate-only without auditing, or audit-only without gating. Decoupling the four hooks from each other matches the proposal's "Hooks for unmigrated deps" stance: each callback is replaceable independently when the matching `@suaveplan/*` package lands.
|
||||
@@ -0,0 +1,52 @@
|
||||
# `ChannelManager`
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the lifecycle of every `Channel` registered on a gateway instance. Clients never see a raw `Channel` — every channel call enters the gateway, which delegates here and translates the result into wire frames the daemon dispatches. Keeping the manager separate from the daemon keeps both files under the 300-line cap and lets channel behaviour be unit-tested independently.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy channel creation via `getOrCreateChannel(name, allowPatterns?)` — channels are stable across the gateway's lifetime once created.
|
||||
- `publish` returns the constructed `Message` + the deterministic recipient set; the daemon performs the actual socket fan-out so the manager has no WebSocket dependencies.
|
||||
- `removeClient(clientId)` is the disconnect hook — sweeps every channel and drops the client's subscriptions in one pass.
|
||||
- `clear()` for the daemon's shutdown path.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ChannelManager } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const mgr = new ChannelManager();
|
||||
mgr.subscribe("events", "alice", "user.*");
|
||||
const result = mgr.publish("events", "bob", "user.created", { id: "u1" }, Date.now());
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ChannelManager } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const mgr = new ChannelManager();
|
||||
// Pattern channel
|
||||
mgr.subscribe("events", "alice", "user.**");
|
||||
// Exact-only channel
|
||||
const exact = mgr.getOrCreateChannel("metrics", false);
|
||||
mgr.publish("events", "bob", "user.signup", null, 1);
|
||||
// On disconnect
|
||||
mgr.removeClient("alice");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `getOrCreateChannel(name, allowPatterns = true): Channel`
|
||||
- `getChannel(name): Channel | undefined`
|
||||
- `subscribe(channelName, clientId, topic): { subscribed }`
|
||||
- `unsubscribe(channelName, clientId, topic): { unsubscribed }`
|
||||
- `publish(channelName, senderId, topic, payload, now): { message, recipients } | null`
|
||||
- `removeClient(clientId): void`
|
||||
- `clear(): void`
|
||||
- `size: number` getter
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The manager never constructs channels behind `publish` — only `subscribe` and `getOrCreateChannel` create them. This matches upstream behaviour: publishes to a channel nobody subscribed to are no-ops so observers do not pay for unregistered topics. `removeClient` iterates every channel because the per-client subscription map lives inside each channel — flattening that out into a global per-client index is the future job of a sharded backend; today the linear sweep is fine at the documented N×subscribers ceiling.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Gateway channel operations
|
||||
|
||||
## Purpose
|
||||
|
||||
Free-function module containing every channel-level mutation the gateway exposes — `subscribeToChannel`, `unsubscribeFromChannel`, `publishToChannel`. Extracted from `gateway.ts` so the daemon file stays under the 300-line cap; consumed via `import * as channelOps from "./channel-ops.js"` from the gateway.
|
||||
|
||||
## Features
|
||||
|
||||
- `subscribeToChannel` runs the rate-limit + authorization gate before the subscribe.
|
||||
- `unsubscribeFromChannel` is a pure passthrough (no gating on unsubscribe — the proposal treats teardown actions as always-allowed).
|
||||
- `publishToChannel` gates first, then routes through the channel manager, returning the deterministic recipient set.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import * as channelOps from "@suaveplan/websocket-gateway/channel-ops";
|
||||
|
||||
await channelOps.subscribeToChannel(deps, "events", "alice", "user.created");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import * as channelOps from "@suaveplan/websocket-gateway/channel-ops";
|
||||
|
||||
const result = await channelOps.publishToChannel(
|
||||
deps,
|
||||
"events",
|
||||
"alice",
|
||||
"user.created",
|
||||
{ id: "u1", at: Date.now() }
|
||||
);
|
||||
// result.recipients contains every subscriber that matched, excluding alice.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `subscribeToChannel(deps, channel, clientId, topic): Promise<boolean>`
|
||||
- `unsubscribeFromChannel(mgr, channel, clientId, topic): boolean`
|
||||
- `publishToChannel(deps, channel, sender, topic, payload): Promise<{message, recipients} | null>`
|
||||
- `ChannelOpsDeps` — structurally-typed dependency bundle.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`unsubscribe` does not consult the gate because rejecting an unsubscribe would leave the client subscribed when they explicitly asked to stop receiving — a worse failure mode than allowing the teardown. `publishToChannel` returns `null` when the channel hasn't been registered (no subscribers exist yet); the daemon translates that into a no-op rather than a typed error so empty channels are silently ignored.
|
||||
|
||||
The dep bundle is structural (`ChannelOpsDeps`), not nominal. Tests that exercise the ops module directly can construct minimal bundles wiring only the parts each test needs — the gate closures, the requireAuthCtx fallback, the clock — without instantiating the full daemon. The factoring follows the same pattern `room-ops.ts` uses and keeps the gateway file under the 300-line cap.
|
||||
|
||||
The `subscribe` path is the only place a channel gets created today; the `getOrCreateChannel` call hides inside `ChannelManager.subscribe`. This keeps the wire protocol implicit: a client subscribes, the channel exists. Publishes to never-subscribed channels stay no-ops by design — a future stronger contract (rejecting publishes to unknown channels) is a follow-up policy, not a current requirement.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Gateway client-lifecycle helpers
|
||||
|
||||
## Purpose
|
||||
|
||||
Free-function module containing `connectClient` and `disconnectClient` — every state mutation that happens when a client attaches or detaches from the daemon. Extracted from `gateway.ts` so the daemon file stays under the 300-line cap.
|
||||
|
||||
## Features
|
||||
|
||||
- `connectClient` registers the client in the client map, presence tracker (when enabled), and bumps the OTel `presence_updates_total{kind: "join"}` counter.
|
||||
- `disconnectClient` is the cleanup orchestrator: leaves every room, removes the client from every channel, untracks presence (with counter bump), drops the auth context, emits the typed bus event, and fires the `presence:leave` side effects.
|
||||
- Returns `false` from `disconnectClient` when the client was never connected so the daemon's error-path handler is idempotent.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { connectClient, disconnectClient } from "@suaveplan/websocket-gateway";
|
||||
|
||||
connectClient(deps, "alice");
|
||||
disconnectClient(deps, "alice", "normal");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { connectClient } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const client = connectClient(deps, "alice", {
|
||||
userAgent: "Chrome/123",
|
||||
region: "us-east-1",
|
||||
});
|
||||
// client.metadata is a defensive copy of the input
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `connectClient(deps, id, metadata?): ClientInfo` — registers a client with `lastHeartbeat = clock()`.
|
||||
- `disconnectClient(deps, clientId, reason = "normal"): boolean` — full cleanup; idempotent.
|
||||
- `ClientLifecycleDeps` — structurally-typed dependency bundle.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`connectClient` clones `metadata` so the daemon cannot leak mutations back to the caller. `disconnectClient` calls `room.leave` on every registered room rather than maintaining a per-client room index because the room set is bounded by `maxRooms` and the linear sweep keeps disconnect O(rooms) without paying the index-maintenance cost on every join/leave. The OTel counter increments fire next to the actual map mutations so the counter reflects true client lifecycle, not best-effort presence webhook fan-out.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Gateway default callbacks
|
||||
|
||||
## Purpose
|
||||
|
||||
The four deferred-dependency hooks (`AuthVerifier`, `AuthorizationCheck`, `RateLimitGate`, `AuditLogger`) need sensible no-op defaults so the daemon can boot without forcing every caller to wire `@suaveplan/jwt-tenant` etc. before those packages land. This module centralises the defaults and the `resolveConfig` step that applies them to a parsed config.
|
||||
|
||||
## Features
|
||||
|
||||
- `defaultAuthVerifier` — returns an anonymous `AuthContext` with empty scopes; the daemon overlays the auto-assigned client id.
|
||||
- `defaultAuthorizationCheck` — returns `true` for every action.
|
||||
- `defaultRateLimitGate` — returns `true` for every action.
|
||||
- `defaultAuditLogger` — no-op.
|
||||
- `resolveConfig(cfg)` — applies the defaults to a Zod-defaulted config snapshot.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { defaultAuthVerifier } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const ctx = await defaultAuthVerifier({
|
||||
headers: {},
|
||||
remoteAddress: undefined,
|
||||
url: "/",
|
||||
query: {},
|
||||
});
|
||||
// ctx.principal === undefined, ctx.scopes === []
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { resolveConfig } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const resolved = resolveConfig({
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
maxRooms: 100,
|
||||
maxClientsPerRoom: 50,
|
||||
heartbeatIntervalMs: 30_000,
|
||||
clientTimeoutMs: 90_000,
|
||||
enablePresence: true,
|
||||
enableChannels: true,
|
||||
wsServerFactory: realFactory,
|
||||
});
|
||||
// resolved.authVerifier is defaultAuthVerifier
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `defaultAuthVerifier: AuthVerifier`
|
||||
- `defaultAuthorizationCheck: AuthorizationCheck`
|
||||
- `defaultRateLimitGate: RateLimitGate`
|
||||
- `defaultAuditLogger: AuditLogger`
|
||||
- `resolveConfig(cfg): ResolvedGatewayConfig`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The defaults are exported individually so consumers can compose them (e.g. wrap the default `AuthorizationCheck` with a tenant-aware filter while still benefiting from the rest of the no-op chain). When `@suaveplan/jwt-tenant` / `@suaveplan/permissions` / `@suaveplan/rate-limiter` / `@suaveplan/audit-logging` ship , the documented replacement path is to swap the relevant `default*` callback for the package's gate at config-construction time. The public types do not change.
|
||||
|
||||
The `resolveConfig` step expects the Zod-defaulted snapshot from `WebSocketGatewayConfigSchema.parse(...)`. That schema applies the numeric defaults (`maxRooms: 100`, `maxClientsPerRoom: 50`, `heartbeatIntervalMs: 30_000`, `clientTimeoutMs: 90_000`, etc.); this function only fills in the function-typed callbacks the Zod schema declares as `z.unknown().optional()`. Splitting the responsibility this way means the schema stays serialisable and the callback defaults stay testable in isolation.
|
||||
|
||||
The synthesised `AuthContext` from `defaultAuthVerifier` carries an empty `clientId` — the daemon's `runAuthHandshake` helper folds in the socket id on the `ctx.clientId || socketId` branch, so anonymous gateways still get a stable id per connection.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Gateway internal dep factory
|
||||
|
||||
## Purpose
|
||||
|
||||
Centralises the cross-module dependency-bundle construction the daemon needs once at construction time. Without this module the gateway class re-builds `RoomOpsDeps`, `ChannelOpsDeps`, and `ClientLifecycleDeps` inline at every public method, blowing the file's line count past the 300-line cap. The factory builds every dep bundle once and the daemon caches the references.
|
||||
|
||||
## Features
|
||||
|
||||
- One pure factory function (`buildGatewayDepFactories`) returning every bundle the daemon needs.
|
||||
- Helper builders (`buildRunGate`, `buildFirePresence`, `buildEmit`, `buildRequireAuthCtx`) exposed individually for unit tests that want to wire a sub-bundle without a full daemon.
|
||||
- The synthetic auth-context fallback inside `buildRequireAuthCtx` is captured here so tests can drive ops paths without going through the full handshake.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { buildGatewayDepFactories } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const factories = buildGatewayDepFactories({
|
||||
state,
|
||||
resolved,
|
||||
clock,
|
||||
authContexts,
|
||||
idempotency,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildEmit,
|
||||
buildFirePresence,
|
||||
buildGatewayDepFactories,
|
||||
buildRequireAuthCtx,
|
||||
buildRunGate,
|
||||
} from "@suaveplan/websocket-gateway";
|
||||
|
||||
const emit = buildEmit(internals);
|
||||
const runGate = buildRunGate(internals);
|
||||
const fire = buildFirePresence(internals);
|
||||
const requireAuthCtx = buildRequireAuthCtx(internals);
|
||||
// Wire each builder individually when testing one op path in isolation.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `GatewayInternals` — state + resolved-config + idempotency-guard snapshot the factory needs.
|
||||
- `GatewayDepFactories` — the four dep bundles the daemon hands to the op modules.
|
||||
- `buildGatewayDepFactories(internals): GatewayDepFactories`
|
||||
- `buildRunGate(internals): (ctx, action) => Promise<void>`
|
||||
- `buildFirePresence(internals): (kind, clientId, roomId, status) => Promise<void>`
|
||||
- `buildEmit(internals): <K>(topic, payload) => void`
|
||||
- `buildRequireAuthCtx(internals): (clientId) => AuthContext`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory closes over `internals.state` and `internals.resolved` by reference, so when the daemon mutates either, the dep bundles stay current — there is no per-call re-bundle cost beyond what the constructor pays once. The synthetic auth-context fallback uses `internals.authContexts` rather than holding its own state because every consumer (including the public `getAuthContext` method) shares the same lookup table.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,55 @@
|
||||
# `createHeartbeat`
|
||||
|
||||
## Purpose
|
||||
|
||||
Builds the heartbeat sweep that the daemon installs while running. The sweep periodically calls `PresenceTracker.detectTimedOut()` and forwards every returned id through an `onTimedOut(id)` callback (the daemon disconnects the client). Splitting this out of `gateway.ts` keeps the daemon file under the line cap and lets tests assert sweep behaviour without booting a server.
|
||||
|
||||
## Features
|
||||
|
||||
- Idempotent `start()` / `stop()` — both methods can be called multiple times safely.
|
||||
- `enabled()` gate so the daemon can disable presence (`enablePresence: false`) without paying the sweep cost.
|
||||
- `timer.unref()` keeps the sweep from blocking process exit.
|
||||
- Pure factory — no class state escapes the closure.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createHeartbeat } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const hb = createHeartbeat(presenceTracker, 30_000, (id) => gateway.disconnect(id), () => true);
|
||||
hb.start();
|
||||
hb.stop();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createHeartbeat } from "@suaveplan/websocket-gateway";
|
||||
|
||||
let enabled = true;
|
||||
const hb = createHeartbeat(
|
||||
presenceTracker,
|
||||
1_000,
|
||||
(id) => {
|
||||
gateway.disconnectClient(id, "timeout");
|
||||
},
|
||||
() => enabled
|
||||
);
|
||||
hb.start();
|
||||
// Pause sweeps without stopping the timer (e.g. during maintenance)
|
||||
enabled = false;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createHeartbeat(presence, intervalMs, onTimedOut, enabled): HeartbeatHandle`
|
||||
- `HeartbeatHandle.start(): void`
|
||||
- `HeartbeatHandle.stop(): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The handle keeps the timer reference inside the closure so the daemon never needs to introspect or replace it. Using `unref()` (when available) means the heartbeat does not pin the Node process during graceful shutdown — the daemon still calls `stop()` explicitly in its `stop()` path. The `enabled()` predicate is checked inside the timer callback (not at start-time) so consumers can disable sweeps at runtime without restarting the heartbeat.
|
||||
|
||||
The sweep runs synchronously inside the `setInterval` callback. `detectTimedOut()` returns at most `presence.size` entries and the typical workload (a few thousand clients per gateway instance) finishes well within a single millisecond, so the sweep cannot starve other timers. The caller is responsible for handling the actual disconnect — the heartbeat does not own the client map, the room set, or any other state. That separation keeps the unit tests trivial: a `PresenceTracker` plus an array as the sink is enough to assert every branch.
|
||||
|
||||
The interval is bound at construction time and cannot be changed without recreating the handle. This is intentional — runtime interval changes would require re-scheduling logic, and the daemon's stable configuration model has no use case for that. Consumers wanting maintenance-window dynamic behaviour use the `enabled()` predicate to pause without restarting.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Gateway lifecycle helpers
|
||||
|
||||
## Purpose
|
||||
|
||||
Centralises three pieces of lifecycle plumbing the daemon needs but doesn't want inline: WS-server boot, start-completion accounting (OTel histogram + bus event), and the per-connection auth handshake. Splitting these out of `gateway.ts` keeps the daemon file under the 300-line cap and makes each phase independently unit-testable.
|
||||
|
||||
## Features
|
||||
|
||||
- `bootWsServer(resolved, callbacks, signal)` — calls the injected factory, invokes `start()`, and translates every failure into a typed `WsGatewayError(START_FAILED)` with the original cause attached. Always best-effort-stops the (possibly-partially-created) server before rethrowing.
|
||||
- `recordStartCompleted(resolved, address, port, startedAt, t0, emit)` — records the start-duration histogram and emits the typed `ws_gateway:started` event.
|
||||
- `runAuthHandshake(resolved, socketId, handshake, now)` — runs the injected `authVerifier`, normalises the resulting auth context (handles missing client-ids), audits both outcomes, raises `AUTH_REJECTED` on failure.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { bootWsServer } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const { server, address, port } = await bootWsServer(resolved, callbacks, signal);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { runAuthHandshake } from "@suaveplan/websocket-gateway";
|
||||
|
||||
try {
|
||||
const ctx = await runAuthHandshake(resolved, socketId, handshake, Date.now());
|
||||
// ctx.clientId guaranteed to be non-empty
|
||||
} catch (err) {
|
||||
// err is a WsGatewayError(AUTH_REJECTED); audit log has already received
|
||||
// the "denied" record.
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `bootWsServer(resolved, callbacks, signal): Promise<{server, address, port}>`
|
||||
- `recordStartCompleted(resolved, address, port, startedAt, t0, emit): void`
|
||||
- `runAuthHandshake(resolved, socketId, handshake, now): Promise<AuthContext>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`bootWsServer` always awaits `server?.stop()` before rethrowing because the WS factory may throw mid-`start()` after attaching internal state — letting that state leak would prevent a retry. `runAuthHandshake` defaults a missing `ctx.clientId` to the socket id so consumers that adopt async auth don't need to invent ids twice. Auditing fires on both branches because the audit log is the source of truth for "did this handshake even reach us" alerts.
|
||||
@@ -0,0 +1,44 @@
|
||||
# `createMessage`
|
||||
|
||||
## Purpose
|
||||
|
||||
Tiny factory that produces immutable `Message` envelopes for every outbound dispatch the gateway emits. The factory threads the gateway's injectable clock through every id so the wire-visible ids stay deterministic when tests fake time.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable id format: `msg_<unix-ms>_<monotonic-counter>` — counter is process-local but the daemon resets it via `_resetMessageCounter` in test setup if needed.
|
||||
- Caller provides the timestamp so the factory does not pull `Date.now()` directly — keeps the function pure.
|
||||
- Returns a frozen-shape `Message` matching every field of the public type.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createMessage } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const msg = createMessage("room", "alice", "general", { text: "hi" }, Date.now());
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createMessage, _resetMessageCounter } from "@suaveplan/websocket-gateway";
|
||||
|
||||
// In test setup
|
||||
_resetMessageCounter();
|
||||
const a = createMessage("room", "alice", "r1", null, 1);
|
||||
const b = createMessage("room", "alice", "r1", null, 1);
|
||||
// a.id === "msg_1_1", b.id === "msg_1_2"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createMessage(type, sender, target, payload, now): Message` — pure factory.
|
||||
- `_resetMessageCounter(): void` — test-only counter reset; prefixed with `_` to mark intent.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory does not own the clock — every caller passes `now` explicitly so the daemon's `Clock` injection is honoured up the stack. The factory is intentionally tiny because every dispatch path goes through it; keeping it allocation-only avoids inline GC churn. Setting `MessageType` to a string-literal union (not a Zod enum) keeps the factory zero-cost in production builds while leaving the public wire schema responsible for validation.
|
||||
|
||||
The counter is process-local rather than per-instance because the format `msg_<unix-ms>_<counter>` is already disambiguated by the timestamp at production frequencies (no two messages share the same ms unless the daemon is processing thousands of messages per ms — at which point the counter takes over and the id stays unique). For deterministic tests, `_resetMessageCounter` brings the counter back to zero so test fixtures match across vitest workers; production never calls it.
|
||||
|
||||
The `Message` envelope is the wire shape exchanged between the gateway and clients of `@suaveplan/network-transport-websockets`. Callers MUST treat the envelope as opaque — adding fields here is a breaking change that requires a coordinated bump in the companion client package and a schema-registry compatibility check.
|
||||
@@ -0,0 +1,55 @@
|
||||
# `firePresence`
|
||||
|
||||
## Purpose
|
||||
|
||||
Single side-effect helper that fans out one presence transition (`join` / `leave` / `update`) to every observation sink the gateway supports: the typed `EventBusLike` bus, the OTel `presence_updates_total{kind}` counter, and the optional signed presence webhook. Splitting this out keeps `gateway.ts` under the 300-line cap and lets unit tests exercise the side effects without booting a WebSocket server.
|
||||
|
||||
## Features
|
||||
|
||||
- Lossy by design — every sink is independent; one sink throwing never blocks the others.
|
||||
- Auto-routes `join` / `leave` / `update` to the matching `presence:*` webhook event kind.
|
||||
- Honours the optional `config.events` allowlist on the webhook config.
|
||||
- Emits the typed `ws_gateway:presence` bus event with the right `kind`.
|
||||
- Bumps OTel `presence_updates_total{kind: "update"}` for `update` transitions; the daemon bumps `join` / `leave` directly in `connectClient` / `disconnectClient`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { firePresence } from "@suaveplan/websocket-gateway";
|
||||
|
||||
await firePresence({ clock: Date.now }, "join", "alice", "ops", "online");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { firePresence } from "@suaveplan/websocket-gateway";
|
||||
|
||||
await firePresence(
|
||||
{
|
||||
bus: gatewayBus,
|
||||
presenceWebhook: {
|
||||
url: "https://hooks.example.com/presence",
|
||||
secret: "shared",
|
||||
events: ["presence:join", "presence:leave"],
|
||||
},
|
||||
clock: () => Date.now(),
|
||||
},
|
||||
"update",
|
||||
"alice",
|
||||
"ops",
|
||||
"away"
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `firePresence(deps, kind, clientId, roomId, status): Promise<void>`
|
||||
- `deps.bus` — optional `EventBusLike<GatewayEventMap>`.
|
||||
- `deps.presenceWebhook` — optional `PresenceWebhookConfig`.
|
||||
- `deps.clock` — injectable clock.
|
||||
- `kind: "join" | "leave" | "update"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Both bus emission and webhook delivery are best-effort: presence is reconciled by the next heartbeat / disconnect event, so a single lost transition is acceptable. The function does not own the OTel join/leave counter increments (the daemon emits those next to the actual client lifecycle hooks so the counter reflects the true map size, not the side-effect routing). For `update`, the join/leave hooks haven't fired, so this is the right place to bump the counter.
|
||||
@@ -0,0 +1,55 @@
|
||||
# `deliverPresenceWebhook`
|
||||
|
||||
## Purpose
|
||||
|
||||
Best-effort, signed delivery of a single presence event to a configured webhook receiver. The gateway calls this function on every presence transition (`join` / `leave` / `update`) when a `presenceWebhook` is configured on the daemon. Retry, exponential backoff, and circuit-breaking are owned by `@suaveplan/webhook-client.deliver`, which internally threads `@suaveplan/retry` — the gateway never has to reinvent that policy.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-event opt-in filter: `config.events` constrains which kinds fan out. Default (`undefined`) fans out every kind.
|
||||
- HMAC-SHA-256 signed payloads when `config.secret` is set, matching the proposal's "signed delivery payload" requirement.
|
||||
- Errors are swallowed and routed to an optional `onError(err)` hook so presence — which is inherently lossy — never crashes the daemon.
|
||||
- Deterministic delivery id: `presence-<clientId>-<at>` makes the receiver's idempotency story easy.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { deliverPresenceWebhook } from "@suaveplan/websocket-gateway";
|
||||
|
||||
await deliverPresenceWebhook(
|
||||
{ url: "https://example.com/webhooks/presence", secret: "shared-secret" },
|
||||
{ kind: "presence:join", clientId: "alice", roomId: "ops", status: "online", at: Date.now() }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { deliverPresenceWebhook } from "@suaveplan/websocket-gateway";
|
||||
import { logger } from "@suaveplan/logger";
|
||||
|
||||
await deliverPresenceWebhook(
|
||||
{
|
||||
url: "https://hooks.example.com/presence",
|
||||
secret: process.env.PRESENCE_HMAC,
|
||||
events: ["presence:join", "presence:leave"], // skip 'update' chatter
|
||||
},
|
||||
{
|
||||
kind: "presence:update",
|
||||
clientId: "alice",
|
||||
roomId: undefined,
|
||||
status: "away",
|
||||
at: Date.now(),
|
||||
},
|
||||
(err) => logger.warn("presence webhook delivery failed", { err })
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `deliverPresenceWebhook(config, event, onError?): Promise<void>`
|
||||
- `PresenceWebhookEvent` — `{ kind, clientId, roomId, status, at }` value object.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function never throws — `webhook-client.deliver` already classifies retryable vs terminal status codes and consumes the retry budget internally. Surfacing failures upstream would force the gateway to invent a recovery policy, but presence is best-effort by design (receivers reconcile via the next heartbeat / connection event). Delivery is signed with the same algorithm `webhook-client` defaults to (`sha256`); future stronger algorithms (e.g. ed25519) become a `config.algorithm` switch the moment `webhook-client` exposes one.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Gateway room operations
|
||||
|
||||
## Purpose
|
||||
|
||||
Free-function module containing every room-level mutation the gateway exposes — create / delete / join / leave / broadcast / unicast — extracted from `gateway.ts` so the daemon file stays under the 300-line cap. The functions consume a structurally-typed `RoomOpsDeps` and have no class dependency, which makes them straightforward to unit-test with hand-rolled fakes.
|
||||
|
||||
## Features
|
||||
|
||||
- `createRoom(deps, config)` — capacity check + idempotent registration; throws `ROOM_FULL` once the daemon hits `maxRooms`.
|
||||
- `deleteRoom(rooms, id)` — clears members, drops the entry, decrements the OTel rooms-active gauge.
|
||||
- `joinRoom(deps, roomId, clientId, idempotencyKey?)` — gates through rate-limit + authz, wraps the join in `IdempotencyGuard.execute`, fires the `presence:update`.
|
||||
- `leaveRoom(deps, roomId, clientId)` — symmetric leave + presence fire.
|
||||
- `broadcastToRoom(deps, roomId, senderId, payload, idempotencyKey?)` — gated + idempotent message dispatch returning `{message, recipients}`.
|
||||
- `unicast(deps, senderId, targetId, payload)` — gated unicast.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createRoom, joinRoom } from "@suaveplan/websocket-gateway";
|
||||
|
||||
createRoom(deps, { id: "general" });
|
||||
await joinRoom(deps, "general", "alice");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { broadcastToRoom } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const result = await broadcastToRoom(
|
||||
deps,
|
||||
"general",
|
||||
"alice",
|
||||
{ text: "hi" },
|
||||
"client-supplied-idempotency-key"
|
||||
);
|
||||
// result.recipients excludes alice; result.message is the dispatched envelope.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createRoom(deps, config): Room`
|
||||
- `deleteRoom(rooms, roomId): boolean`
|
||||
- `joinRoom(deps, roomId, clientId, idempotencyKey?): Promise<boolean>`
|
||||
- `leaveRoom(deps, roomId, clientId): Promise<boolean>`
|
||||
- `broadcastToRoom(deps, roomId, senderId, payload, idempotencyKey?): Promise<{message, recipients} | null>`
|
||||
- `unicast(deps, senderId, targetId, payload): Promise<Message | null>`
|
||||
- `RoomOpsDeps` — structurally-typed dependency bundle.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The dependency bundle uses `Pick<>` / `Omit<>` per function so tests can construct minimal fakes — `createRoom` and `deleteRoom` deliberately avoid pulling in the gate/idempotency/presence-fire dependencies that aren't relevant to their pure path. Idempotency keys default to a deterministic `<verb>:<roomId>:<clientId>` shape; per-broadcast they incorporate the daemon's clock so two `broadcastToRoom` calls in the same logical operation are NOT deduplicated unless the caller passes an explicit key (the upstream behaviour).
|
||||
@@ -0,0 +1,59 @@
|
||||
# `@suaveplan/websocket-gateway` health probes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Inline implementation of the three Kubernetes-style health verbs (`/health`, `/ready`, `/live`) that the daemon hosts on the same TCP port as the WebSocket upgrade endpoint. The proposal explicitly defers full integration with `@suaveplan/health-check` until that package ships — this module is the standalone replacement and the contract stays stable once `health-check` lands.
|
||||
|
||||
## Features
|
||||
|
||||
- `classifyHealthRequest(url)` — pure URL-path classifier; ignores querystrings and trailing slashes.
|
||||
- `buildHealthResponse(verb, snap)` — pure decision function returning the HTTP status + body for one verb.
|
||||
- `handleHealth(url, snap)` — single-call wrapper returning `{status, body}` or `null` so the daemon can short-circuit health requests before falling through to the WS upgrade path.
|
||||
- Status discriminator (`ok` / `degraded` / `starting` / `stopped`) carries enough state for k8s probes and human dashboards.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { handleHealth } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const result = handleHealth(req.url, {
|
||||
running: true,
|
||||
disposed: false,
|
||||
clientCount: 7,
|
||||
roomCount: 2,
|
||||
startedAt: 1700000000000,
|
||||
});
|
||||
if (result) res.writeHead(result.status).end(result.body);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildHealthResponse,
|
||||
classifyHealthRequest,
|
||||
type HealthSnapshot,
|
||||
} from "@suaveplan/websocket-gateway";
|
||||
|
||||
const snap: HealthSnapshot = gateway.snapshot();
|
||||
const verb = classifyHealthRequest("/ready?probe=k8s");
|
||||
if (verb !== null) {
|
||||
const decision = buildHealthResponse(verb, snap);
|
||||
metrics.ready_probe.add(1, { status: decision.body.status });
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `HEALTH_PATHS` — frozen mapping of verb → path string.
|
||||
- `classifyHealthRequest(url): HealthKind` — returns `"health" | "ready" | "live" | null`.
|
||||
- `buildHealthResponse(verb, snap): {status, body}` — returns the HTTP status + JSON-encodable body for a verb.
|
||||
- `handleHealth(url, snap): {status, body} | null` — combined classify + build returning the serialized response body.
|
||||
- `HealthSnapshot`, `HealthResponse`, `HealthKind`, `HealthPath` — supporting types.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`live` returns 200 unless `stop()` has cleared state (process is up but daemon-internal storage is torn down). `ready` returns 503 with `status: "starting"` until `start()` resolves so cluster ingress holds traffic until the daemon is actually accepting WebSocket upgrades. `health` is the catch-all aggregate verb consumers can wire to PagerDuty / Prometheus. The functions are pure to keep the daemon under the 300-line cap and to let unit tests cover every branch without booting a server.
|
||||
@@ -0,0 +1,63 @@
|
||||
# `@suaveplan/websocket-gateway` OTel surface
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Centralises every OpenTelemetry instrument the WebSocket gateway emits — five span wrappers (`connect`, `message`, `broadcast`, `start`, `stop`), three counters, one up-down gauge for active rooms, and two histograms for `start()` and per-message dispatch latency. Splitting the OTel module out from the gateway file keeps the daemon under the 300-line cap and lets tests assert observability separately from behaviour.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy meter/tracer acquisition via `@opentelemetry/api` — no provider coupling.
|
||||
- Span helpers `withStartSpan` / `withStopSpan` / `withConnectSpan` / `withMessageSpan` / `withBroadcastSpan` always end the span via `finally` so an exception in the wrapped body cannot leak an open span.
|
||||
- Counter `ws_gateway.actions_total{action,outcome}` records every authoritative decision (`allowed` / `denied` / `error`) so dashboards can chart auth/authz/rate-limit denial rates.
|
||||
- Counter `ws_gateway.presence_updates_total{kind}` matches the proposal's required surface.
|
||||
- Gauge `ws_gateway.rooms_active` follows the proposal's required surface and is monotone-by-delta (+1 on room create, -1 on delete).
|
||||
- Histograms `ws_gateway.message_dispatch_ms{action}` and `ws_gateway.start_duration_ms` for SLO tracking.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { withMessageSpan, recordDispatchDuration } from "@suaveplan/websocket-gateway";
|
||||
|
||||
await withMessageSpan(async () => {
|
||||
const t0 = Date.now();
|
||||
await dispatch(message);
|
||||
recordDispatchDuration("room.broadcast", Date.now() - t0);
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
adjustRoomsActive,
|
||||
recordAction,
|
||||
recordPresenceUpdate,
|
||||
withStartSpan,
|
||||
} from "@suaveplan/websocket-gateway";
|
||||
|
||||
await withStartSpan(async () => {
|
||||
await bind();
|
||||
adjustRoomsActive(0);
|
||||
});
|
||||
|
||||
// On every authoritative decision
|
||||
recordAction("room.broadcast", "denied");
|
||||
// On every presence transition
|
||||
recordPresenceUpdate("join");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `withStartSpan` / `withStopSpan` / `withConnectSpan` / `withMessageSpan` / `withBroadcastSpan` — wrap an async body in a named span.
|
||||
- `adjustRoomsActive(delta)` — bump the rooms-active gauge.
|
||||
- `recordPresenceUpdate(kind)` — increment the presence counter; `kind` is `"join" | "leave" | "update"`.
|
||||
- `recordAction(action, outcome)` — increment the actions counter; `outcome` is `"allowed" | "denied" | "error"`.
|
||||
- `recordDispatchDuration(action, ms)` — push one observation to the dispatch histogram.
|
||||
- `recordStartDuration(ms)` — push one observation to the start-duration histogram.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every counter is multi-dimensional with a low-cardinality attribute set so the surface stays Prometheus/OTLP-friendly. Spans use `tracer.startActiveSpan` to participate in W3C context propagation; consumers reading inbound traceparent headers from clients will automatically nest the gateway's spans under the request span. The histograms clamp at zero to defend against clock skew producing negative deltas. The gauge is implemented as `UpDownCounter` because the gateway tracks per-event deltas, not snapshot reads.
|
||||
@@ -0,0 +1,59 @@
|
||||
# `PresenceTracker`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Per-gateway-instance presence map tracking which clients are online, away, busy, or offline. The gateway uses one tracker shared across rooms — presence is a property of the connection, not the room — and exposes it to listeners via the typed `EventBusLike` events. The tracker takes an injectable `Clock` so unit + property tests can drive deterministic heartbeat timeouts without `vi.useFakeTimers()`.
|
||||
|
||||
## Features
|
||||
|
||||
- `track` / `untrack` / `getPresence` for the lifecycle hooks.
|
||||
- `heartbeat(clientId)` — the gateway's per-frame keepalive update; auto-promotes `offline` back to `online`.
|
||||
- `updateStatus(clientId, status)` for explicit caller-driven transitions (away / busy).
|
||||
- `updateState(clientId, state)` — merges arbitrary state into the per-client custom payload.
|
||||
- `detectTimedOut()` — sweep used by the gateway's heartbeat interval to mark clients offline once they exceed `clientTimeoutMs`.
|
||||
- `listActive()` / `listAll()` for read-side fan-out to listeners.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PresenceTracker } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const tracker = new PresenceTracker(90_000);
|
||||
tracker.track("alice");
|
||||
tracker.heartbeat("alice");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PresenceTracker } from "@suaveplan/websocket-gateway";
|
||||
|
||||
let now = 1_000;
|
||||
const tracker = new PresenceTracker(5_000, () => now);
|
||||
tracker.track("alice");
|
||||
now = 10_000;
|
||||
expect(tracker.detectTimedOut()).toEqual(["alice"]);
|
||||
tracker.heartbeat("alice"); // back to online
|
||||
tracker.updateState("alice", { typing: true });
|
||||
tracker.updateStatus("alice", "busy");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new PresenceTracker(timeoutMs: number, clock?: Clock)` — `clock` defaults to `Date.now`. Implementing `Clock` lets you fake time.
|
||||
- `track(clientId, initialState?)` — registers a new client with `status: "online"`.
|
||||
- `untrack(clientId): boolean` — removes a client; returns true if present.
|
||||
- `heartbeat(clientId): PresenceInfo | null` — bumps `lastSeen`, auto-promotes offline → online.
|
||||
- `updateStatus(clientId, status): PresenceInfo | null` — caller-driven status set.
|
||||
- `updateState(clientId, state): PresenceInfo | null` — merges custom state.
|
||||
- `getPresence(clientId): PresenceInfo | null` — returns a defensive copy.
|
||||
- `listActive()` / `listAll()` — defensive copies of non-offline / all entries.
|
||||
- `detectTimedOut(): string[]` — mark every client past `timeoutMs` as offline and return their ids.
|
||||
- `isTracked(clientId)` / `clear()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The tracker holds presence state in-process; clustering across multiple gateway instances is out of scope and is the future job of a `websocket-gateway-store-*` adapter. `detectTimedOut` is O(n) over the tracked set — n is bounded by the concurrent-client cap and the sweep runs at the configurable `heartbeatIntervalMs`, so the cost stays predictable. Mutation methods return defensive copies of `PresenceInfo` so the caller cannot accidentally mutate internal state by holding a reference; the internal records mutate in-place for performance.
|
||||
@@ -0,0 +1,65 @@
|
||||
# `Room`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Single-room state container for the gateway daemon. A `Room` tracks its member set, a bounded rolling message history, and configurable metadata. The daemon owns one `Room` instance per registered room id and routes inbound messages through it. Room-level capacity enforcement throws a typed `WsGatewayError` so the daemon never has to translate a sentinel boolean into a denial code.
|
||||
|
||||
## Features
|
||||
|
||||
- O(1) join / leave / membership-check backed by a `Map<string, ClientInfo>`.
|
||||
- `isFull` guard backed by `maxClients` configured at room creation.
|
||||
- `broadcast(message, excludeSender?)` records the message in a bounded ring buffer (100 entries max) and returns the deterministic recipient set so the daemon can dispatch outside the room class.
|
||||
- `getHistory(limit?)` exposes the last N entries for client catch-up on reconnect.
|
||||
- `updateMetadata(updates)` for room-level descriptors (titles, topics, ACL hints).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { Room } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const room = new Room({ id: "general", name: "General", maxClients: 50, metadata: {} });
|
||||
room.join({ id: "alice", connectedAt: Date.now(), lastHeartbeat: Date.now(), metadata: {} });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { Room, WS_GATEWAY_CODES, WsGatewayError } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const room = new Room({ id: "ops", name: "Ops Chat", maxClients: 2, metadata: { topic: "alerts" } });
|
||||
room.join(alice);
|
||||
room.join(bob);
|
||||
try {
|
||||
room.join(carol);
|
||||
} catch (err) {
|
||||
if (err instanceof WsGatewayError && err.code === WS_GATEWAY_CODES.ROOM_FULL) {
|
||||
// surface typed denial to carol
|
||||
}
|
||||
}
|
||||
const recipients = room.broadcast({
|
||||
id: "msg-1",
|
||||
type: "room",
|
||||
sender: "alice",
|
||||
target: "ops",
|
||||
payload: { text: "hi" },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new Room(config: RoomConfig)` — clones the config; `maxClients` is the hard cap.
|
||||
- `id` / `name` / `size` / `isFull` / `maxClients` / `metadata` getters.
|
||||
- `join(client): boolean` — returns `false` if already a member, throws `WS_GATEWAY.ROOM_FULL` on overflow.
|
||||
- `leave(clientId): boolean` — returns `true` if removed.
|
||||
- `hasClient(clientId)` / `getMembers()` / `getMemberInfos()`.
|
||||
- `broadcast(message, excludeSender = true): string[]` — records + returns recipient ids.
|
||||
- `getHistory(limit?)` — sliced from the rolling buffer.
|
||||
- `updateMetadata(updates)` / `clear()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bounded history buffer is a simple `Array` plus `shift()`-on-overflow — adequate for the 100-entry default. Capacity overflow throws because the daemon's wire protocol surfaces typed errors back to the offending client; returning a silent `false` here would force the daemon to invent an error at the call site every time. The room never broadcasts directly over a socket — it returns recipient ids and the daemon performs the actual dispatch so the room stays free of WebSocket dependencies and can be reused by unit tests.
|
||||
@@ -0,0 +1,69 @@
|
||||
# `@suaveplan/websocket-gateway` types
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Every public type the WebSocket gateway daemon exposes — configuration structs, message envelopes, the `WsServerLike` adapter contract, the four callback hooks (auth/authz/rate-limit/audit), and the typed event-bus map — lives in this module. Keeping the public type surface in one file means consumers can import from `@suaveplan/websocket-gateway` and reason about the daemon's contract without touching the implementation files. The types module ships zero runtime code; the matching Zod boundary schemas live in the sibling `types.schemas.ts`.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict `WebSocketGatewayConfig` / `ResolvedGatewayConfig` split — the config is what the caller hands in, the resolved form is what the daemon stores after defaulting.
|
||||
- `WsServerLike` / `WsServerFactory` adapter contract so production wires real `ws.WebSocketServer` and tests wire a deterministic in-memory fake.
|
||||
- Four callback hooks (`AuthVerifier`, `AuthorizationCheck`, `RateLimitGate`, `AuditLogger`) declared as bare `async` function signatures — every one becomes a real package wiring when the corresponding `@suaveplan/jwt-tenant` / `@suaveplan/permissions` / `@suaveplan/rate-limiter` / `@suaveplan/audit-logging` ships . Until then, default no-op pass-throughs let consumers stand up a gateway without dependency surface.
|
||||
- Typed `GatewayEventMap` for the optional `EventBusLike`-shaped bus so subscribers stay type-safe.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { WebSocketGatewayConfig, WsServerFactory } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const factory: WsServerFactory = () => mySingleton;
|
||||
const config: WebSocketGatewayConfig = { port: 8080, wsServerFactory: factory };
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
AuthVerifier,
|
||||
GatewayAction,
|
||||
WebSocketGatewayConfig,
|
||||
} from "@suaveplan/websocket-gateway";
|
||||
|
||||
const authVerifier: AuthVerifier = async (handshake) => {
|
||||
const token = handshake.headers.authorization?.replace(/^Bearer /, "");
|
||||
if (!token) throw new Error("missing token");
|
||||
return {
|
||||
clientId: cryptoRandomId(),
|
||||
principal: parseJwt(token).sub,
|
||||
tenantId: parseJwt(token).tid,
|
||||
scopes: parseJwt(token).scope.split(" "),
|
||||
metadata: {},
|
||||
};
|
||||
};
|
||||
const config: WebSocketGatewayConfig = {
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
wsServerFactory: realWsFactory,
|
||||
authVerifier,
|
||||
rateLimitGate: async (_ctx, action: GatewayAction) =>
|
||||
action !== "room.broadcast" || (await limiter.allow()),
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ClientInfo`, `PresenceInfo`, `RoomConfig`, `ChannelConfig`, `Message`, `MessageType`, `PresenceStatus` — domain value shapes ported from upstream.
|
||||
- `GatewayAction` — discriminator passed to `AuthorizationCheck` and `RateLimitGate`.
|
||||
- `Handshake`, `AuthContext`, `AuditEvent` — payloads for the four callback hooks.
|
||||
- `WsSocketLike`, `WsServerCallbacks`, `WsServerStartOptions`, `WsServerStartResult`, `WsServerLike`, `WsServerFactory` — adapter contract enabling the no-mocks test policy.
|
||||
- `WebSocketGatewayConfig` — accepted at construction. Every optional field is defaulted by the daemon.
|
||||
- `ResolvedGatewayConfig` — internal post-default snapshot returned by `gateway.config`.
|
||||
- `GatewayEventMap` + per-event payload interfaces — typed surface for the optional `EventBusLike`.
|
||||
- `GatewayStartResult` — return type of `WebSocketGateway.start()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The four callback hooks default to no-op pass-throughs so the daemon boots cleanly without dependency wiring. The pattern matches `network-trystero-server`'s broker-factory split: callable contracts stay type-only (Zod accepts them as `z.unknown()`), and the daemon enforces presence and shape at runtime where it matters. When the four upstream packages land, the defaults are replaced inline with real wirings and the public types here do not change — that is the entire point of the deferred-integration design noted in the proposal.
|
||||
Reference in New Issue
Block a user