Initial Commit
This commit is contained in:
192
packages/networking/network/websocket-gateway/README.md
Normal file
192
packages/networking/network/websocket-gateway/README.md
Normal file
@@ -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
|
||||
80
packages/networking/network/websocket-gateway/package.json
Normal file
80
packages/networking/network/websocket-gateway/package.json
Normal file
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"name": "@suaveplan/websocket-gateway",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "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 on genesis.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"],
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
||||
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
|
||||
},
|
||||
"./gateway": {
|
||||
"import": { "types": "./dist/gateway/gateway.d.ts", "default": "./dist/gateway/gateway.js" }
|
||||
},
|
||||
"./room": {
|
||||
"import": { "types": "./dist/room/room.d.ts", "default": "./dist/room/room.js" }
|
||||
},
|
||||
"./presence": {
|
||||
"import": { "types": "./dist/presence/presence.d.ts", "default": "./dist/presence/presence.js" }
|
||||
},
|
||||
"./channel": {
|
||||
"import": { "types": "./dist/channel/channel.d.ts", "default": "./dist/channel/channel.js" }
|
||||
},
|
||||
"./health": {
|
||||
"import": { "types": "./dist/health/health.d.ts", "default": "./dist/health/health.js" }
|
||||
},
|
||||
"./otel": {
|
||||
"import": { "types": "./dist/otel/otel.d.ts", "default": "./dist/otel/otel.js" }
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "bunx biome check src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/idempotency": "workspace:^",
|
||||
"@suaveplan/logger": "workspace:^",
|
||||
"@suaveplan/network-transport-websockets": "workspace:^",
|
||||
"@suaveplan/retry": "workspace:^",
|
||||
"@suaveplan/timing": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"@suaveplan/webhook-client": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@suaveplan/testing-integration": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -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,136 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { WS_GATEWAY_CODES, WsGatewayError } from "../errors/errors.js";
|
||||
import type { Message } from "../types/types.js";
|
||||
import { Channel } from "./channel.js";
|
||||
|
||||
const msg = (sender: string): Message => ({
|
||||
id: "m1",
|
||||
type: "channel",
|
||||
sender,
|
||||
target: "t",
|
||||
payload: null,
|
||||
timestamp: 0,
|
||||
});
|
||||
|
||||
describe("Channel", () => {
|
||||
it("rejects empty name", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new Channel({ name: "", allowPatterns: true });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(WsGatewayError);
|
||||
expect((err as WsGatewayError).code).toBe(
|
||||
WS_GATEWAY_CODES.CHANNEL_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("basic subscribe / unsubscribe / unsubscribeAll", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = new Channel({ name: "evt", allowPatterns: false });
|
||||
expect(c.name).toBe("evt");
|
||||
expect(c.allowPatterns).toBe(false);
|
||||
expect(c.subscribe("a", "t1")).toBe(true);
|
||||
expect(c.subscribe("a", "t1")).toBe(false); // duplicate
|
||||
expect(c.subscriberCount).toBe(1);
|
||||
expect(c.hasSubscriber("a")).toBe(true);
|
||||
expect(c.getSubscriptions("a")).toEqual(["t1"]);
|
||||
expect(c.getSubscriptions("missing")).toEqual([]);
|
||||
expect(c.getSubscribers()).toEqual(["a"]);
|
||||
expect(c.unsubscribe("a", "missing")).toBe(false);
|
||||
expect(c.unsubscribe("missing", "t1")).toBe(false);
|
||||
expect(c.unsubscribe("a", "t1")).toBe(true);
|
||||
expect(c.subscriberCount).toBe(0);
|
||||
});
|
||||
|
||||
it("unsubscribeAll drops all topics for client", { timeout: 5000 }, () => {
|
||||
const c = new Channel({ name: "evt", allowPatterns: false });
|
||||
c.subscribe("a", "t1");
|
||||
c.subscribe("a", "t2");
|
||||
expect(c.unsubscribeAll("a")).toBe(true);
|
||||
expect(c.unsubscribeAll("a")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty topic", { timeout: 5000 }, () => {
|
||||
const c = new Channel({ name: "evt", allowPatterns: true });
|
||||
try {
|
||||
c.subscribe("a", "");
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as WsGatewayError).code).toBe(
|
||||
WS_GATEWAY_CODES.CHANNEL_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects wildcard topic under allowPatterns:false", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = new Channel({ name: "evt", allowPatterns: false });
|
||||
try {
|
||||
c.subscribe("a", "user.*");
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as WsGatewayError).code).toBe(
|
||||
WS_GATEWAY_CODES.CHANNEL_INVALID
|
||||
);
|
||||
}
|
||||
try {
|
||||
c.subscribe("a", "user.**");
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as WsGatewayError).code).toBe(
|
||||
WS_GATEWAY_CODES.CHANNEL_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("topicMatches: exact match always works regardless of pattern flag", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c1 = new Channel({ name: "evt", allowPatterns: false });
|
||||
expect(c1.topicMatches("a.b", "a.b")).toBe(true);
|
||||
expect(c1.topicMatches("a.*", "a.b")).toBe(false);
|
||||
});
|
||||
|
||||
it("topicMatches: * matches one segment", { timeout: 5000 }, () => {
|
||||
const c = new Channel({ name: "evt", allowPatterns: true });
|
||||
expect(c.topicMatches("a.*", "a.b")).toBe(true);
|
||||
expect(c.topicMatches("a.*", "a")).toBe(false);
|
||||
expect(c.topicMatches("a.*", "a.b.c")).toBe(false);
|
||||
expect(c.topicMatches("a.*", "x.b")).toBe(false);
|
||||
});
|
||||
|
||||
it("topicMatches: ** matches zero or more segments", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = new Channel({ name: "evt", allowPatterns: true });
|
||||
expect(c.topicMatches("a.**", "a")).toBe(true);
|
||||
expect(c.topicMatches("a.**", "a.b")).toBe(true);
|
||||
expect(c.topicMatches("a.**", "a.b.c")).toBe(true);
|
||||
expect(c.topicMatches("a.**", "x.b")).toBe(false);
|
||||
expect(c.topicMatches("**", "x.y")).toBe(true);
|
||||
});
|
||||
|
||||
it("publish: matches subscribers, excludes sender", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = new Channel({ name: "evt", allowPatterns: true });
|
||||
c.subscribe("a", "user.created");
|
||||
c.subscribe("b", "user.*");
|
||||
c.subscribe("c", "other.*");
|
||||
const recip = c.publish("user.created", msg("a"), "a").sort();
|
||||
expect(recip).toEqual(["b"]);
|
||||
const all = c.publish("user.created", msg("z")).sort();
|
||||
expect(all).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("publish: returns empty when no patterns match", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = new Channel({ name: "evt", allowPatterns: true });
|
||||
c.subscribe("a", "user.*");
|
||||
expect(c.publish("nope.created", msg("z"))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { WS_GATEWAY_CODES, WsGatewayError } from "../errors/errors.js";
|
||||
import type { ChannelConfig, Message } from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Manages pub/sub channels with topic-based pattern matching.
|
||||
*
|
||||
* Channels support both exact topic subscriptions and wildcard pattern
|
||||
* matching using `*` (single segment) and `**` (multi-segment) wildcards
|
||||
* separated by `.` delimiters. Subscriptions are per-client; the daemon
|
||||
* dispatches the publish recipients itself.
|
||||
*/
|
||||
export class Channel {
|
||||
readonly #channelName: string;
|
||||
readonly #allowPatterns: boolean;
|
||||
/** Map of clientId -> Set of topic patterns. */
|
||||
readonly #subscriptions: Map<string, Set<string>> = new Map();
|
||||
|
||||
public constructor(config: ChannelConfig) {
|
||||
if (!config.name || config.name.length === 0) {
|
||||
throw new WsGatewayError(
|
||||
WS_GATEWAY_CODES.CHANNEL_INVALID,
|
||||
"ws_gateway.channel.name-empty"
|
||||
);
|
||||
}
|
||||
this.#channelName = config.name;
|
||||
this.#allowPatterns = config.allowPatterns;
|
||||
}
|
||||
|
||||
public get name(): string {
|
||||
return this.#channelName;
|
||||
}
|
||||
|
||||
public get allowPatterns(): boolean {
|
||||
return this.#allowPatterns;
|
||||
}
|
||||
|
||||
public get subscriberCount(): number {
|
||||
return this.#subscriptions.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe a client to a topic within this channel. Throws
|
||||
* `WS_GATEWAY.CHANNEL_INVALID` for malformed topics (empty, or
|
||||
* wildcard when `allowPatterns: false`).
|
||||
*/
|
||||
public subscribe(clientId: string, topic: string): boolean {
|
||||
if (!this.#isValidTopic(topic)) {
|
||||
throw new WsGatewayError(
|
||||
WS_GATEWAY_CODES.CHANNEL_INVALID,
|
||||
"ws_gateway.channel.topic-invalid",
|
||||
{ context: { channel: this.#channelName, topic } }
|
||||
);
|
||||
}
|
||||
let topics = this.#subscriptions.get(clientId);
|
||||
if (!topics) {
|
||||
topics = new Set();
|
||||
this.#subscriptions.set(clientId, topics);
|
||||
}
|
||||
if (topics.has(topic)) return false;
|
||||
topics.add(topic);
|
||||
return true;
|
||||
}
|
||||
|
||||
public unsubscribe(clientId: string, topic: string): boolean {
|
||||
const topics = this.#subscriptions.get(clientId);
|
||||
if (!topics) return false;
|
||||
const removed = topics.delete(topic);
|
||||
if (topics.size === 0) this.#subscriptions.delete(clientId);
|
||||
return removed;
|
||||
}
|
||||
|
||||
public unsubscribeAll(clientId: string): boolean {
|
||||
return this.#subscriptions.delete(clientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every client whose topic patterns match `topic`. Optionally
|
||||
* exclude one sender client.
|
||||
*/
|
||||
public publish(
|
||||
topic: string,
|
||||
_message: Message,
|
||||
excludeSender?: string
|
||||
): string[] {
|
||||
const recipients: string[] = [];
|
||||
for (const [clientId, topics] of this.#subscriptions) {
|
||||
if (excludeSender && clientId === excludeSender) continue;
|
||||
for (const pattern of topics) {
|
||||
if (this.topicMatches(pattern, topic)) {
|
||||
recipients.push(clientId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return recipients;
|
||||
}
|
||||
|
||||
public getSubscriptions(clientId: string): string[] {
|
||||
const topics = this.#subscriptions.get(clientId);
|
||||
return topics ? Array.from(topics) : [];
|
||||
}
|
||||
|
||||
public getSubscribers(): string[] {
|
||||
return Array.from(this.#subscriptions.keys());
|
||||
}
|
||||
|
||||
public hasSubscriber(clientId: string): boolean {
|
||||
return this.#subscriptions.has(clientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether `pattern` matches `topic`. Supports `*`
|
||||
* (single segment) and `**` (multi-segment) wildcards.
|
||||
*/
|
||||
public topicMatches(pattern: string, topic: string): boolean {
|
||||
if (pattern === topic) return true;
|
||||
if (!this.#allowPatterns) return false;
|
||||
const patternParts = pattern.split(".");
|
||||
const topicParts = topic.split(".");
|
||||
return this.#matchParts(patternParts, 0, topicParts, 0);
|
||||
}
|
||||
|
||||
#matchParts(
|
||||
pattern: string[],
|
||||
pi: number,
|
||||
topic: string[],
|
||||
ti: number
|
||||
): boolean {
|
||||
if (pi === pattern.length && ti === topic.length) return true;
|
||||
if (pi === pattern.length) return false;
|
||||
const segment = pattern[pi];
|
||||
if (segment === "**") {
|
||||
for (let i = ti; i <= topic.length; i++) {
|
||||
if (this.#matchParts(pattern, pi + 1, topic, i)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (ti === topic.length) return false;
|
||||
if (segment === "*" || segment === topic[ti]) {
|
||||
return this.#matchParts(pattern, pi + 1, topic, ti + 1);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
#isValidTopic(topic: string): boolean {
|
||||
if (!topic || topic.length === 0) return false;
|
||||
if (
|
||||
!this.#allowPatterns &&
|
||||
(topic.includes("*") || topic.includes("**"))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -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,46 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { WS_GATEWAY_CODES, WsGatewayError } from "./errors.js";
|
||||
|
||||
describe("ws-gateway errors", () => {
|
||||
it("WS_GATEWAY_CODES exposes every registered code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(Object.keys(WS_GATEWAY_CODES)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"INVALID_OPTIONS",
|
||||
"START_FAILED",
|
||||
"LISTEN_FAILED",
|
||||
"DISPOSED",
|
||||
"NOT_RUNNING",
|
||||
"ROOM_NOT_FOUND",
|
||||
"ROOM_FULL",
|
||||
"CHANNEL_INVALID",
|
||||
"AUTH_REJECTED",
|
||||
"RATE_LIMITED",
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("WsGatewayError carries code, name, and message key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new WsGatewayError(
|
||||
WS_GATEWAY_CODES.ROOM_FULL,
|
||||
"ws_gateway.room.full",
|
||||
{ context: { roomId: "x" } }
|
||||
);
|
||||
expect(err.name).toBe("WsGatewayError");
|
||||
expect(err.code).toBe(WS_GATEWAY_CODES.ROOM_FULL);
|
||||
expect(err.context).toEqual({ roomId: "x" });
|
||||
});
|
||||
|
||||
it("constructor without options still produces a valid error", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new WsGatewayError(
|
||||
WS_GATEWAY_CODES.NOT_RUNNING,
|
||||
"ws_gateway.not-running"
|
||||
);
|
||||
expect(err.code).toBe(WS_GATEWAY_CODES.NOT_RUNNING);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Error codes emitted by `@suaveplan/websocket-gateway`.
|
||||
*
|
||||
* - `INVALID_OPTIONS` — caller-supplied config failed Zod validation.
|
||||
* - `START_FAILED` — start() was invoked twice, signal pre-aborted, or
|
||||
* the injected wsServerFactory threw.
|
||||
* - `LISTEN_FAILED` — port bind failed.
|
||||
* - `DISPOSED` — operation attempted after stop() cleared state.
|
||||
* - `NOT_RUNNING` — operation attempted before start() resolved.
|
||||
* - `ROOM_NOT_FOUND` — operation referenced a room id with no current
|
||||
* registration.
|
||||
* - `ROOM_FULL` — join() rejected because the room is at capacity.
|
||||
* - `CHANNEL_INVALID` — channel name or topic violates allowPatterns
|
||||
* policy / format.
|
||||
* - `AUTH_REJECTED` — authVerifier callback rejected the handshake.
|
||||
* - `RATE_LIMITED` — rateLimitGate callback rejected the action.
|
||||
*/
|
||||
export const WS_GATEWAY_CODES = defineCodes("WS_GATEWAY", [
|
||||
"INVALID_OPTIONS",
|
||||
"START_FAILED",
|
||||
"LISTEN_FAILED",
|
||||
"DISPOSED",
|
||||
"NOT_RUNNING",
|
||||
"ROOM_NOT_FOUND",
|
||||
"ROOM_FULL",
|
||||
"CHANNEL_INVALID",
|
||||
"AUTH_REJECTED",
|
||||
"RATE_LIMITED",
|
||||
] as const);
|
||||
|
||||
export type WsGatewayErrorCode =
|
||||
(typeof WS_GATEWAY_CODES)[keyof typeof WS_GATEWAY_CODES];
|
||||
|
||||
/**
|
||||
* Structured error class for the WebSocket gateway daemon. Every
|
||||
* public boundary throws this on failure; consumers branch on
|
||||
* `error.code`.
|
||||
*/
|
||||
export class WsGatewayError extends SuaveplanError<WsGatewayErrorCode> {
|
||||
public constructor(
|
||||
code: WsGatewayErrorCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "WsGatewayError";
|
||||
}
|
||||
}
|
||||
@@ -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,88 @@
|
||||
import { WS_GATEWAY_CODES, WsGatewayError } from "../errors/errors.js";
|
||||
import { recordAction } from "../otel/otel.js";
|
||||
import type {
|
||||
AuditLogger,
|
||||
AuthContext,
|
||||
AuthorizationCheck,
|
||||
GatewayAction,
|
||||
RateLimitGate,
|
||||
} from "../types/types.js";
|
||||
|
||||
/** Audit-event helper — swallows any thrown logger error. */
|
||||
export function audit(
|
||||
auditLogger: AuditLogger,
|
||||
action: GatewayAction,
|
||||
ctx: AuthContext,
|
||||
outcome: "allowed" | "denied",
|
||||
reason: string | undefined,
|
||||
at: number
|
||||
): void {
|
||||
try {
|
||||
auditLogger({
|
||||
action,
|
||||
clientId: ctx.clientId,
|
||||
principal: ctx.principal,
|
||||
outcome,
|
||||
reason,
|
||||
at,
|
||||
});
|
||||
} catch {
|
||||
/* audit logger must never crash the daemon */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the rate-limit + authorization pair for one action. Throws a
|
||||
* typed `WsGatewayError` on denial, records the outcome to OTel and
|
||||
* the audit logger, and returns silently on `allowed`.
|
||||
*
|
||||
* Rate-limit runs first so denial-of-service traffic cannot exercise
|
||||
* the authorization callback's CPU budget.
|
||||
*/
|
||||
export async function gate(
|
||||
deps: {
|
||||
readonly rateLimitGate: RateLimitGate;
|
||||
readonly authorizationCheck: AuthorizationCheck;
|
||||
readonly auditLogger: AuditLogger;
|
||||
readonly clock: () => number;
|
||||
},
|
||||
ctx: AuthContext,
|
||||
action: GatewayAction
|
||||
): Promise<void> {
|
||||
const allowedRate = await deps.rateLimitGate(ctx, action);
|
||||
if (!allowedRate) {
|
||||
recordAction(action, "denied");
|
||||
audit(
|
||||
deps.auditLogger,
|
||||
action,
|
||||
ctx,
|
||||
"denied",
|
||||
"rate-limited",
|
||||
deps.clock()
|
||||
);
|
||||
throw new WsGatewayError(
|
||||
WS_GATEWAY_CODES.RATE_LIMITED,
|
||||
"ws_gateway.rate-limited",
|
||||
{ context: { action } }
|
||||
);
|
||||
}
|
||||
const allowedAuthz = await deps.authorizationCheck(ctx, action);
|
||||
if (!allowedAuthz) {
|
||||
recordAction(action, "denied");
|
||||
audit(
|
||||
deps.auditLogger,
|
||||
action,
|
||||
ctx,
|
||||
"denied",
|
||||
"unauthorized",
|
||||
deps.clock()
|
||||
);
|
||||
throw new WsGatewayError(
|
||||
WS_GATEWAY_CODES.AUTH_REJECTED,
|
||||
"ws_gateway.unauthorized",
|
||||
{ context: { action } }
|
||||
);
|
||||
}
|
||||
recordAction(action, "allowed");
|
||||
audit(deps.auditLogger, action, ctx, "allowed", undefined, deps.clock());
|
||||
}
|
||||
@@ -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,84 @@
|
||||
import { Channel } from "../channel/channel.js";
|
||||
import type { Message } from "../types/types.js";
|
||||
import { createMessage } from "./message-factory.js";
|
||||
|
||||
/**
|
||||
* Manages channel subscriptions and pub/sub messaging on the gateway.
|
||||
*
|
||||
* The manager owns the channel lifetime — clients never see a `Channel`
|
||||
* directly; they call into the gateway, which delegates here.
|
||||
*/
|
||||
export class ChannelManager {
|
||||
readonly #channels: Map<string, Channel> = new Map();
|
||||
|
||||
public get size(): number {
|
||||
return this.#channels.size;
|
||||
}
|
||||
|
||||
public getOrCreateChannel(name: string, allowPatterns = true): Channel {
|
||||
let channel = this.#channels.get(name);
|
||||
if (!channel) {
|
||||
channel = new Channel({ name, allowPatterns });
|
||||
this.#channels.set(name, channel);
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
public getChannel(name: string): Channel | undefined {
|
||||
return this.#channels.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe a client to a topic. Returns `{recipients, message}`
|
||||
* where `recipients` is empty for subscribe (subscribers don't get
|
||||
* a fan-out frame).
|
||||
*/
|
||||
public subscribe(
|
||||
channelName: string,
|
||||
clientId: string,
|
||||
topic: string
|
||||
): { readonly subscribed: boolean } {
|
||||
const channel = this.getOrCreateChannel(channelName);
|
||||
return { subscribed: channel.subscribe(clientId, topic) };
|
||||
}
|
||||
|
||||
public unsubscribe(
|
||||
channelName: string,
|
||||
clientId: string,
|
||||
topic: string
|
||||
): { readonly unsubscribed: boolean } {
|
||||
const channel = this.#channels.get(channelName);
|
||||
if (!channel) return { unsubscribed: false };
|
||||
return { unsubscribed: channel.unsubscribe(clientId, topic) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a message to a channel topic. Returns `null` when the
|
||||
* channel is unknown, otherwise the constructed message + matching
|
||||
* recipient ids.
|
||||
*/
|
||||
public publish(
|
||||
channelName: string,
|
||||
senderId: string,
|
||||
topic: string,
|
||||
payload: unknown,
|
||||
now: number
|
||||
): { readonly message: Message; readonly recipients: string[] } | null {
|
||||
const channel = this.#channels.get(channelName);
|
||||
if (!channel) return null;
|
||||
const message = createMessage("channel", senderId, topic, payload, now);
|
||||
const recipients = channel.publish(topic, message, senderId);
|
||||
return { message, recipients };
|
||||
}
|
||||
|
||||
/** Remove a client from every channel. */
|
||||
public removeClient(clientId: string): void {
|
||||
for (const channel of this.#channels.values()) {
|
||||
channel.unsubscribeAll(clientId);
|
||||
}
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.#channels.clear();
|
||||
}
|
||||
}
|
||||
@@ -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,58 @@
|
||||
import type {
|
||||
AuthContext,
|
||||
ClientInfo,
|
||||
GatewayAction,
|
||||
Message,
|
||||
} from "../types/types.js";
|
||||
import type { ChannelManager } from "./channel-manager.js";
|
||||
|
||||
export interface ChannelOpsDeps {
|
||||
readonly clients: Map<string, ClientInfo>;
|
||||
readonly channelMgr: ChannelManager;
|
||||
readonly clock: () => number;
|
||||
readonly runGate: (
|
||||
ctx: AuthContext,
|
||||
action: GatewayAction
|
||||
) => Promise<void>;
|
||||
readonly requireAuthCtx: (clientId: string) => AuthContext;
|
||||
}
|
||||
|
||||
export async function subscribeToChannel(
|
||||
deps: ChannelOpsDeps,
|
||||
channelName: string,
|
||||
clientId: string,
|
||||
topic: string
|
||||
): Promise<boolean> {
|
||||
if (!deps.clients.has(clientId)) return false;
|
||||
await deps.runGate(deps.requireAuthCtx(clientId), "channel.subscribe");
|
||||
return deps.channelMgr.subscribe(channelName, clientId, topic).subscribed;
|
||||
}
|
||||
|
||||
export function unsubscribeFromChannel(
|
||||
channelMgr: ChannelManager,
|
||||
channelName: string,
|
||||
clientId: string,
|
||||
topic: string
|
||||
): boolean {
|
||||
return channelMgr.unsubscribe(channelName, clientId, topic).unsubscribed;
|
||||
}
|
||||
|
||||
export async function publishToChannel(
|
||||
deps: ChannelOpsDeps,
|
||||
channelName: string,
|
||||
senderId: string,
|
||||
topic: string,
|
||||
payload: unknown
|
||||
): Promise<{
|
||||
readonly message: Message;
|
||||
readonly recipients: string[];
|
||||
} | null> {
|
||||
await deps.runGate(deps.requireAuthCtx(senderId), "channel.publish");
|
||||
return deps.channelMgr.publish(
|
||||
channelName,
|
||||
senderId,
|
||||
topic,
|
||||
payload,
|
||||
deps.clock()
|
||||
);
|
||||
}
|
||||
@@ -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,74 @@
|
||||
import { recordPresenceUpdate } from "../otel/otel.js";
|
||||
import type { PresenceTracker } from "../presence/presence.js";
|
||||
import type { Room } from "../room/room.js";
|
||||
import type {
|
||||
AuthContext,
|
||||
ClientInfo,
|
||||
GatewayEventMap,
|
||||
} from "../types/types.js";
|
||||
import type { ChannelManager } from "./channel-manager.js";
|
||||
|
||||
export interface ClientLifecycleDeps {
|
||||
readonly clients: Map<string, ClientInfo>;
|
||||
readonly authContexts: Map<string, AuthContext>;
|
||||
readonly rooms: Map<string, Room>;
|
||||
readonly channelMgr: ChannelManager;
|
||||
readonly presence: PresenceTracker;
|
||||
readonly enablePresence: boolean;
|
||||
readonly clock: () => number;
|
||||
readonly emit: <K extends keyof GatewayEventMap>(
|
||||
topic: K,
|
||||
payload: GatewayEventMap[K]
|
||||
) => void;
|
||||
readonly firePresence: (
|
||||
kind: "join" | "leave" | "update",
|
||||
clientId: string,
|
||||
roomId: string | undefined,
|
||||
status: string
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Register a new client. */
|
||||
export function connectClient(
|
||||
deps: ClientLifecycleDeps,
|
||||
id: string,
|
||||
metadata?: Record<string, unknown>
|
||||
): ClientInfo {
|
||||
const now = deps.clock();
|
||||
const client: ClientInfo = {
|
||||
id,
|
||||
connectedAt: now,
|
||||
lastHeartbeat: now,
|
||||
metadata: metadata ? { ...metadata } : {},
|
||||
};
|
||||
deps.clients.set(id, client);
|
||||
if (deps.enablePresence) {
|
||||
deps.presence.track(id, metadata);
|
||||
recordPresenceUpdate("join");
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Disconnect a client and clean up every related per-client state slot. */
|
||||
export function disconnectClient(
|
||||
deps: ClientLifecycleDeps,
|
||||
clientId: string,
|
||||
reason = "normal"
|
||||
): boolean {
|
||||
if (!deps.clients.has(clientId)) return false;
|
||||
for (const room of deps.rooms.values()) room.leave(clientId);
|
||||
deps.channelMgr.removeClient(clientId);
|
||||
if (deps.enablePresence) {
|
||||
deps.presence.untrack(clientId);
|
||||
recordPresenceUpdate("leave");
|
||||
}
|
||||
deps.clients.delete(clientId);
|
||||
deps.authContexts.delete(clientId);
|
||||
deps.emit("ws_gateway:client-disconnected", {
|
||||
clientId,
|
||||
reason,
|
||||
at: deps.clock(),
|
||||
});
|
||||
void deps.firePresence("leave", clientId, undefined, "offline");
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { makeFakeWsServerFactory } from "../../tests/setup/fake-ws-gateway.js";
|
||||
import { WebSocketGateway } from "./gateway.js";
|
||||
import { buildEmit } from "./gateway-deps.js";
|
||||
import { runAuthHandshake } from "./lifecycle.js";
|
||||
import { firePresence } from "./presence-fire.js";
|
||||
|
||||
describe("further branch coverage", () => {
|
||||
it("buildEmit fires bus.emit and swallows thrown bus errors", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const events: string[] = [];
|
||||
const emit1 = buildEmit({
|
||||
state: {} as never,
|
||||
resolved: { bus: { emit: (t: string) => events.push(t) } } as never,
|
||||
clock: () => 0,
|
||||
authContexts: new Map(),
|
||||
idempotency: {} as never,
|
||||
});
|
||||
emit1("ws_gateway:stopped", { at: 0 });
|
||||
expect(events).toEqual(["ws_gateway:stopped"]);
|
||||
const emit2 = buildEmit({
|
||||
state: {} as never,
|
||||
resolved: {
|
||||
bus: {
|
||||
emit: () => {
|
||||
throw new Error("bus down");
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
clock: () => 0,
|
||||
authContexts: new Map(),
|
||||
idempotency: {} as never,
|
||||
});
|
||||
emit2("ws_gateway:stopped", { at: 0 });
|
||||
});
|
||||
|
||||
it("runAuthHandshake non-Error throw branch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
try {
|
||||
await runAuthHandshake(
|
||||
{
|
||||
authVerifier: async () => {
|
||||
const x: unknown = "not-an-error";
|
||||
throw x;
|
||||
},
|
||||
auditLogger: () => {},
|
||||
} as never,
|
||||
"sock",
|
||||
{ headers: {}, remoteAddress: undefined, url: "/", query: {} },
|
||||
0
|
||||
);
|
||||
} catch {
|
||||
/* expected */
|
||||
}
|
||||
});
|
||||
|
||||
it("Channel ** matcher returns false when remaining segments don't match", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { Channel } = await import("../channel/channel.js");
|
||||
const c = new Channel({ name: "evt", allowPatterns: true });
|
||||
expect(c.topicMatches("a.**.z", "a.b.c")).toBe(false);
|
||||
});
|
||||
|
||||
it("joinRoom returning false (already a member) does not fire presence", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { factory } = makeFakeWsServerFactory();
|
||||
const gw = new WebSocketGateway({ port: 0, wsServerFactory: factory });
|
||||
gw.connectClient("a");
|
||||
gw.createRoom({ id: "r1" });
|
||||
expect(await gw.joinRoom("r1", "a")).toBe(true);
|
||||
expect(await gw.joinRoom("r1", "a", "k2")).toBe(false);
|
||||
});
|
||||
|
||||
it("leaveRoom returning false (not a member) does not fire presence", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { factory } = makeFakeWsServerFactory();
|
||||
const gw = new WebSocketGateway({ port: 0, wsServerFactory: factory });
|
||||
gw.createRoom({ id: "r1" });
|
||||
expect(await gw.leaveRoom("r1", "ghost")).toBe(false);
|
||||
});
|
||||
|
||||
it("Channel single-segment exhaust: pattern shorter than topic returns false", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { Channel } = await import("../channel/channel.js");
|
||||
const c = new Channel({ name: "evt", allowPatterns: true });
|
||||
expect(c.topicMatches("a", "a.b")).toBe(false);
|
||||
});
|
||||
|
||||
it("heartbeat sweep fires the timeout disconnect callback", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let now = 0;
|
||||
const { factory } = makeFakeWsServerFactory();
|
||||
const gw = new WebSocketGateway(
|
||||
{
|
||||
port: 0,
|
||||
wsServerFactory: factory,
|
||||
heartbeatIntervalMs: 5,
|
||||
clientTimeoutMs: 10,
|
||||
},
|
||||
() => now
|
||||
);
|
||||
await gw.start();
|
||||
gw.connectClient("a");
|
||||
now = 1_000;
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(gw.isClientConnected("a")).toBe(false);
|
||||
await gw.stop();
|
||||
});
|
||||
|
||||
it("firePresence delivers a 'update' webhook (ternary tail)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let captured: string | undefined;
|
||||
const originalFetch = globalThis.fetch;
|
||||
(globalThis as { fetch: typeof fetch }).fetch = (async (
|
||||
_url: string | URL | Request,
|
||||
init?: RequestInit
|
||||
) => {
|
||||
captured = String(init?.body ?? "");
|
||||
return new Response(null, { status: 200 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await firePresence(
|
||||
{
|
||||
presenceWebhook: { url: "https://hooks.example.com" },
|
||||
clock: () => 0,
|
||||
},
|
||||
"update",
|
||||
"alice",
|
||||
"r1",
|
||||
"online"
|
||||
);
|
||||
expect(captured).toContain("presence:update");
|
||||
} finally {
|
||||
(globalThis as { fetch: typeof fetch }).fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("firePresence delivers a 'leave' webhook (ternary middle)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let captured: string | undefined;
|
||||
const originalFetch = globalThis.fetch;
|
||||
(globalThis as { fetch: typeof fetch }).fetch = (async (
|
||||
_url: string | URL | Request,
|
||||
init?: RequestInit
|
||||
) => {
|
||||
captured = String(init?.body ?? "");
|
||||
return new Response(null, { status: 200 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await firePresence(
|
||||
{
|
||||
presenceWebhook: { url: "https://hooks.example.com" },
|
||||
clock: () => 0,
|
||||
},
|
||||
"leave",
|
||||
"alice",
|
||||
undefined,
|
||||
"offline"
|
||||
);
|
||||
expect(captured).toContain("presence:leave");
|
||||
} finally {
|
||||
(globalThis as { fetch: typeof fetch }).fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("gateway onError on an unknown id is a no-op", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { factory, instances } = makeFakeWsServerFactory();
|
||||
const gw = new WebSocketGateway({ port: 0, wsServerFactory: factory });
|
||||
await gw.start();
|
||||
const server = instances[0]!;
|
||||
server.errorOn("never-connected", new Error("phantom"));
|
||||
await gw.stop();
|
||||
});
|
||||
|
||||
it("gateway onMessage callback is exercised by FakeWsServer.message", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { factory, instances } = makeFakeWsServerFactory();
|
||||
const gw = new WebSocketGateway({ port: 0, wsServerFactory: factory });
|
||||
await gw.start();
|
||||
const server = instances[0]!;
|
||||
server.connect("a");
|
||||
server.message("a", "frame");
|
||||
server.disconnect("a", 1000, "");
|
||||
await gw.stop();
|
||||
});
|
||||
|
||||
it("presence-fire emits update through bus without webhook", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const events: { topic: string; payload: unknown }[] = [];
|
||||
await firePresence(
|
||||
{
|
||||
bus: {
|
||||
emit: (t: string, p: unknown) =>
|
||||
events.push({ topic: t, payload: p }),
|
||||
} as never,
|
||||
clock: () => 0,
|
||||
},
|
||||
"update",
|
||||
"a",
|
||||
"r1",
|
||||
"online"
|
||||
);
|
||||
expect(events).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
FakeWsServer,
|
||||
makeFakeWsServerFactory,
|
||||
} from "../../tests/setup/fake-ws-gateway.js";
|
||||
import { PresenceTracker } from "../presence/presence.js";
|
||||
import { ChannelManager } from "./channel-manager.js";
|
||||
import { connectClient, disconnectClient } from "./client-lifecycle.js";
|
||||
import { WebSocketGateway } from "./gateway.js";
|
||||
import {
|
||||
buildEmit,
|
||||
buildFirePresence,
|
||||
buildRequireAuthCtx,
|
||||
} from "./gateway-deps.js";
|
||||
import { createHeartbeat } from "./heartbeat.js";
|
||||
import { runAuthHandshake } from "./lifecycle.js";
|
||||
import { firePresence } from "./presence-fire.js";
|
||||
import {
|
||||
deliverPresenceWebhook,
|
||||
type PresenceWebhookEvent,
|
||||
} from "./presence-webhook.js";
|
||||
|
||||
describe("ChannelManager direct surface", () => {
|
||||
it("size + getOrCreate idempotence + getChannel + publish on unknown + size", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const mgr = new ChannelManager();
|
||||
expect(mgr.size).toBe(0);
|
||||
const c1 = mgr.getOrCreateChannel("evt");
|
||||
const c2 = mgr.getOrCreateChannel("evt");
|
||||
expect(c1).toBe(c2);
|
||||
expect(mgr.getChannel("evt")).toBe(c1);
|
||||
expect(mgr.getChannel("nope")).toBeUndefined();
|
||||
expect(mgr.size).toBe(1);
|
||||
expect(mgr.publish("missing", "alice", "topic", null, 1)).toBeNull();
|
||||
mgr.removeClient("ghost");
|
||||
mgr.clear();
|
||||
expect(mgr.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("heartbeat sweep", () => {
|
||||
it("sweeps timed-out clients", { timeout: 5000 }, async () => {
|
||||
let now = 0;
|
||||
const presence = new PresenceTracker(10, () => now);
|
||||
presence.track("a");
|
||||
const out: string[] = [];
|
||||
const hb = createHeartbeat(
|
||||
presence,
|
||||
5,
|
||||
(id) => out.push(id),
|
||||
() => true
|
||||
);
|
||||
hb.start();
|
||||
hb.start(); // idempotent
|
||||
now = 1_000;
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
hb.stop();
|
||||
hb.stop(); // idempotent
|
||||
expect(out).toContain("a");
|
||||
});
|
||||
|
||||
it("skips sweep when disabled", { timeout: 5000 }, async () => {
|
||||
let now = 0;
|
||||
const presence = new PresenceTracker(10, () => now);
|
||||
presence.track("a");
|
||||
const out: string[] = [];
|
||||
const hb = createHeartbeat(
|
||||
presence,
|
||||
5,
|
||||
(id) => out.push(id),
|
||||
() => false
|
||||
);
|
||||
hb.start();
|
||||
now = 1_000;
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
hb.stop();
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("firePresence + presence-webhook branch coverage", () => {
|
||||
it("firePresence with no bus and no webhook is a no-op", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await firePresence(
|
||||
{ clock: () => 0 },
|
||||
"join",
|
||||
"a",
|
||||
undefined,
|
||||
"online"
|
||||
);
|
||||
});
|
||||
|
||||
it("firePresence with bus that throws is swallowed", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const bus = {
|
||||
emit: () => {
|
||||
throw new Error("bus down");
|
||||
},
|
||||
};
|
||||
await firePresence(
|
||||
{ bus: bus as never, clock: () => 0 },
|
||||
"leave",
|
||||
"a",
|
||||
undefined,
|
||||
"offline"
|
||||
);
|
||||
});
|
||||
|
||||
it("deliverPresenceWebhook filters by events allowlist", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let called = false;
|
||||
const originalFetch = globalThis.fetch;
|
||||
(globalThis as { fetch: typeof fetch }).fetch = (async () => {
|
||||
called = true;
|
||||
return new Response(null, { status: 200 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const ev: PresenceWebhookEvent = {
|
||||
kind: "presence:update",
|
||||
clientId: "a",
|
||||
roomId: undefined,
|
||||
status: "away",
|
||||
at: 0,
|
||||
};
|
||||
await deliverPresenceWebhook(
|
||||
{
|
||||
url: "https://hooks.example.com",
|
||||
events: ["presence:join"],
|
||||
},
|
||||
ev
|
||||
);
|
||||
expect(called).toBe(false);
|
||||
} finally {
|
||||
(globalThis as { fetch: typeof fetch }).fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("deliverPresenceWebhook routes errors through onError", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const errors: unknown[] = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
(globalThis as { fetch: typeof fetch }).fetch = (async () => {
|
||||
throw new Error("net");
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await deliverPresenceWebhook(
|
||||
{ url: "https://hooks.example.com" },
|
||||
{
|
||||
kind: "presence:join",
|
||||
clientId: "a",
|
||||
roomId: undefined,
|
||||
status: "online",
|
||||
at: 0,
|
||||
},
|
||||
(e) => errors.push(e)
|
||||
);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
(globalThis as { fetch: typeof fetch }).fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("client-lifecycle branch coverage", () => {
|
||||
it("connectClient with enablePresence:false skips presence tracker", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const presence = new PresenceTracker(100);
|
||||
const deps = {
|
||||
clients: new Map(),
|
||||
authContexts: new Map(),
|
||||
rooms: new Map(),
|
||||
channelMgr: new ChannelManager(),
|
||||
presence,
|
||||
enablePresence: false,
|
||||
clock: () => 0,
|
||||
emit: () => {},
|
||||
firePresence: async () => {},
|
||||
};
|
||||
connectClient(deps, "a");
|
||||
expect(presence.size).toBe(0);
|
||||
expect(disconnectClient(deps, "a")).toBe(true);
|
||||
expect(disconnectClient(deps, "a")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gateway-deps + buildRequireAuthCtx fallback", () => {
|
||||
it("synthetic auth ctx is created on miss", { timeout: 5000 }, () => {
|
||||
const requireAuthCtx = buildRequireAuthCtx({
|
||||
state: {
|
||||
clients: new Map(),
|
||||
rooms: new Map(),
|
||||
channelMgr: new ChannelManager(),
|
||||
presence: new PresenceTracker(100),
|
||||
wsServer: undefined,
|
||||
started: false,
|
||||
disposed: false,
|
||||
startedAt: undefined,
|
||||
boundAddress: undefined,
|
||||
boundPort: undefined,
|
||||
},
|
||||
resolved: {} as never,
|
||||
clock: () => 0,
|
||||
authContexts: new Map(),
|
||||
idempotency: {} as never,
|
||||
});
|
||||
const ctx = requireAuthCtx("ghost");
|
||||
expect(ctx.clientId).toBe("ghost");
|
||||
expect(ctx.principal).toBeUndefined();
|
||||
// Second call retrieves the synthetic
|
||||
expect(requireAuthCtx("ghost")).toBe(ctx);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runAuthHandshake fallback ctx.clientId", () => {
|
||||
it("synthesises clientId when verifier returns empty", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = await runAuthHandshake(
|
||||
{
|
||||
authVerifier: async () =>
|
||||
({
|
||||
clientId: "",
|
||||
principal: undefined,
|
||||
tenantId: undefined,
|
||||
scopes: [],
|
||||
metadata: {},
|
||||
}) as never,
|
||||
auditLogger: () => {},
|
||||
} as never,
|
||||
"sock-x",
|
||||
{ headers: {}, remoteAddress: undefined, url: "/", query: {} },
|
||||
0
|
||||
);
|
||||
expect(ctx.clientId).toBe("sock-x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildEmit no-op without bus + FakeWsServer extras", () => {
|
||||
it("emit is a no-op when bus is undefined", { timeout: 5000 }, () => {
|
||||
const emit = buildEmit({
|
||||
state: {} as never,
|
||||
resolved: { bus: undefined } as never,
|
||||
clock: () => 0,
|
||||
authContexts: new Map(),
|
||||
idempotency: {} as never,
|
||||
});
|
||||
// Should not throw, no observable side effect.
|
||||
emit("ws_gateway:stopped", { at: 0 });
|
||||
});
|
||||
|
||||
it("FakeWsServer socket.close, message, getSocket", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const server = new FakeWsServer();
|
||||
server.callbacks = {
|
||||
onConnection: () => {},
|
||||
onMessage: (id, payload) => {
|
||||
expect(id).toBe("a");
|
||||
expect(payload).toBe("p");
|
||||
},
|
||||
onClose: () => {},
|
||||
onError: () => {},
|
||||
};
|
||||
const s = server.connect("a");
|
||||
s.send("hi");
|
||||
s.close();
|
||||
s.close(); // idempotent
|
||||
server.message("a", "p");
|
||||
expect(server.getSocket("a")).toBeUndefined();
|
||||
expect(server.socketCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFirePresence branch coverage", () => {
|
||||
it("fire with no webhook short-circuits cleanly", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fire = buildFirePresence({
|
||||
state: {} as never,
|
||||
resolved: { bus: undefined, presenceWebhook: undefined } as never,
|
||||
clock: () => 0,
|
||||
authContexts: new Map(),
|
||||
idempotency: {} as never,
|
||||
});
|
||||
await fire("update", "a", undefined, "online");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocketGateway disabled-presence path", () => {
|
||||
it("connectClient does not track presence when disabled", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { factory } = makeFakeWsServerFactory();
|
||||
const gw = new WebSocketGateway({
|
||||
port: 0,
|
||||
wsServerFactory: factory,
|
||||
enablePresence: false,
|
||||
});
|
||||
gw.connectClient("a");
|
||||
expect(gw.getPresenceTracker().size).toBe(0);
|
||||
gw.disconnectClient("a");
|
||||
});
|
||||
|
||||
it("config getter returns the resolved config", { timeout: 5000 }, () => {
|
||||
const { factory } = makeFakeWsServerFactory();
|
||||
const gw = new WebSocketGateway({ port: 0, wsServerFactory: factory });
|
||||
expect(gw.config.maxRooms).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -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,103 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Handshake, WsServerFactory } from "../types/types.js";
|
||||
import {
|
||||
defaultAuditLogger,
|
||||
defaultAuthorizationCheck,
|
||||
defaultAuthVerifier,
|
||||
defaultRateLimitGate,
|
||||
resolveConfig,
|
||||
} from "./defaults.js";
|
||||
|
||||
const noopFactory: WsServerFactory = () => ({
|
||||
start: async () => ({ address: "127.0.0.1", port: 0 }),
|
||||
stop: async () => {},
|
||||
closeAll: async () => {},
|
||||
});
|
||||
|
||||
const handshake: Handshake = {
|
||||
headers: {},
|
||||
remoteAddress: undefined,
|
||||
url: "/",
|
||||
query: {},
|
||||
};
|
||||
|
||||
describe("gateway defaults", () => {
|
||||
it("defaultAuthVerifier returns anonymous ctx", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = await defaultAuthVerifier(handshake);
|
||||
expect(ctx.principal).toBeUndefined();
|
||||
expect(ctx.scopes).toEqual([]);
|
||||
expect(ctx.tenantId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("defaultAuthorizationCheck always allows", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = await defaultAuthVerifier(handshake);
|
||||
expect(await defaultAuthorizationCheck(ctx, "room.broadcast")).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("defaultRateLimitGate always allows", { timeout: 5000 }, async () => {
|
||||
const ctx = await defaultAuthVerifier(handshake);
|
||||
expect(await defaultRateLimitGate(ctx, "unicast")).toBe(true);
|
||||
});
|
||||
|
||||
it("defaultAuditLogger is callable and returns void", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
defaultAuditLogger({
|
||||
action: "room.join",
|
||||
clientId: "a",
|
||||
principal: undefined,
|
||||
outcome: "allowed",
|
||||
reason: undefined,
|
||||
at: 0,
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolveConfig applies defaults", { timeout: 5000 }, () => {
|
||||
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: noopFactory,
|
||||
});
|
||||
expect(resolved.authVerifier).toBe(defaultAuthVerifier);
|
||||
expect(resolved.authorizationCheck).toBe(defaultAuthorizationCheck);
|
||||
expect(resolved.rateLimitGate).toBe(defaultRateLimitGate);
|
||||
expect(resolved.auditLogger).toBe(defaultAuditLogger);
|
||||
});
|
||||
|
||||
it("resolveConfig keeps caller-provided hooks", { timeout: 5000 }, () => {
|
||||
const customVerifier = async () => ({
|
||||
clientId: "x",
|
||||
principal: "alice",
|
||||
tenantId: undefined,
|
||||
scopes: [],
|
||||
metadata: {},
|
||||
});
|
||||
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: noopFactory,
|
||||
authVerifier: customVerifier,
|
||||
});
|
||||
expect(resolved.authVerifier).toBe(customVerifier);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import type {
|
||||
AuditLogger,
|
||||
AuthContext,
|
||||
AuthorizationCheck,
|
||||
AuthVerifier,
|
||||
Handshake,
|
||||
RateLimitGate,
|
||||
ResolvedGatewayConfig,
|
||||
WebSocketGatewayConfig,
|
||||
} from "../types/types.js";
|
||||
|
||||
/** Default auth verifier — anonymous pass-through. Returns the inbound handshake's
|
||||
* remote-address-derived client id (caller can override). */
|
||||
export const defaultAuthVerifier: AuthVerifier = async (
|
||||
_handshake: Handshake
|
||||
): Promise<AuthContext> => ({
|
||||
clientId: "",
|
||||
principal: undefined,
|
||||
tenantId: undefined,
|
||||
scopes: [],
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
/** Default authorization check — allow everything. */
|
||||
export const defaultAuthorizationCheck: AuthorizationCheck = async () => true;
|
||||
|
||||
/** Default rate-limit gate — allow everything. */
|
||||
export const defaultRateLimitGate: RateLimitGate = async () => true;
|
||||
|
||||
/** Default audit logger — no-op. */
|
||||
export const defaultAuditLogger: AuditLogger = () => {
|
||||
/* no-op */
|
||||
};
|
||||
|
||||
/** Apply defaults to a `WebSocketGatewayConfig`. */
|
||||
export function resolveConfig(
|
||||
cfg: WebSocketGatewayConfig & {
|
||||
readonly host: string;
|
||||
readonly maxRooms: number;
|
||||
readonly maxClientsPerRoom: number;
|
||||
readonly heartbeatIntervalMs: number;
|
||||
readonly clientTimeoutMs: number;
|
||||
readonly enablePresence: boolean;
|
||||
readonly enableChannels: boolean;
|
||||
}
|
||||
): ResolvedGatewayConfig {
|
||||
return {
|
||||
port: cfg.port,
|
||||
host: cfg.host,
|
||||
maxRooms: cfg.maxRooms,
|
||||
maxClientsPerRoom: cfg.maxClientsPerRoom,
|
||||
heartbeatIntervalMs: cfg.heartbeatIntervalMs,
|
||||
clientTimeoutMs: cfg.clientTimeoutMs,
|
||||
enablePresence: cfg.enablePresence,
|
||||
enableChannels: cfg.enableChannels,
|
||||
wsServerFactory: cfg.wsServerFactory,
|
||||
authVerifier: cfg.authVerifier ?? defaultAuthVerifier,
|
||||
authorizationCheck: cfg.authorizationCheck ?? defaultAuthorizationCheck,
|
||||
rateLimitGate: cfg.rateLimitGate ?? defaultRateLimitGate,
|
||||
auditLogger: cfg.auditLogger ?? defaultAuditLogger,
|
||||
presenceWebhook: cfg.presenceWebhook,
|
||||
bus: cfg.bus,
|
||||
};
|
||||
}
|
||||
@@ -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,199 @@
|
||||
import type { IdempotencyGuard } from "@suaveplan/idempotency";
|
||||
import type { Clock, EventBusLike } from "@suaveplan/types";
|
||||
import type {
|
||||
AuthContext,
|
||||
GatewayAction,
|
||||
GatewayEventMap,
|
||||
Message,
|
||||
ResolvedGatewayConfig,
|
||||
} from "../types/types.js";
|
||||
import { gate } from "./access-gate.js";
|
||||
import type { ChannelManager } from "./channel-manager.js";
|
||||
import type { ChannelOpsDeps } from "./channel-ops.js";
|
||||
import type { ClientLifecycleDeps } from "./client-lifecycle.js";
|
||||
import type { GatewayState } from "./lifecycle.js";
|
||||
import { firePresence } from "./presence-fire.js";
|
||||
import type { RoomOpsDeps } from "./room-ops.js";
|
||||
|
||||
/**
|
||||
* Encapsulates the cross-module dependency-bundle construction the
|
||||
* gateway performs once per public method. Lives in its own file so
|
||||
* `gateway.ts` stays under the 300-line cap. The four factory
|
||||
* functions are pure builders — they read from the daemon's state +
|
||||
* resolved config without owning either.
|
||||
*/
|
||||
export interface GatewayInternals {
|
||||
readonly state: GatewayState;
|
||||
readonly resolved: ResolvedGatewayConfig;
|
||||
readonly clock: Clock;
|
||||
readonly authContexts: Map<string, AuthContext>;
|
||||
readonly idempotency: IdempotencyGuard;
|
||||
}
|
||||
|
||||
/** Build the gate runner bound to the daemon's callback set. */
|
||||
export function buildRunGate(
|
||||
internals: GatewayInternals
|
||||
): (ctx: AuthContext, action: GatewayAction) => Promise<void> {
|
||||
return (ctx, action) =>
|
||||
gate(
|
||||
{
|
||||
rateLimitGate: internals.resolved.rateLimitGate,
|
||||
authorizationCheck: internals.resolved.authorizationCheck,
|
||||
auditLogger: internals.resolved.auditLogger,
|
||||
clock: internals.clock,
|
||||
},
|
||||
ctx,
|
||||
action
|
||||
);
|
||||
}
|
||||
|
||||
/** Build the presence-side-effect helper bound to the daemon's sinks. */
|
||||
export function buildFirePresence(
|
||||
internals: GatewayInternals
|
||||
): (
|
||||
kind: "join" | "leave" | "update",
|
||||
clientId: string,
|
||||
roomId: string | undefined,
|
||||
status: string
|
||||
) => Promise<void> {
|
||||
return (kind, clientId, roomId, status) =>
|
||||
firePresence(
|
||||
{
|
||||
bus: internals.resolved.bus,
|
||||
presenceWebhook: internals.resolved.presenceWebhook,
|
||||
clock: internals.clock,
|
||||
},
|
||||
kind,
|
||||
clientId,
|
||||
roomId,
|
||||
status
|
||||
);
|
||||
}
|
||||
|
||||
/** Build the typed bus emitter, swallowing bus failures. */
|
||||
export function buildEmit(
|
||||
internals: GatewayInternals
|
||||
): <K extends keyof GatewayEventMap>(
|
||||
topic: K,
|
||||
payload: GatewayEventMap[K]
|
||||
) => void {
|
||||
return <K extends keyof GatewayEventMap>(
|
||||
topic: K,
|
||||
payload: GatewayEventMap[K]
|
||||
) => {
|
||||
const bus = internals.resolved.bus as
|
||||
| EventBusLike<GatewayEventMap>
|
||||
| undefined;
|
||||
if (!bus) return;
|
||||
try {
|
||||
bus.emit(topic, payload);
|
||||
} catch {
|
||||
/* bus failures must not crash the daemon */
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the auth-context lookup, creating a synthetic ctx on miss. */
|
||||
export function buildRequireAuthCtx(
|
||||
internals: GatewayInternals
|
||||
): (clientId: string) => AuthContext {
|
||||
return (clientId) => {
|
||||
const ctx = internals.authContexts.get(clientId);
|
||||
if (ctx) return ctx;
|
||||
const synthetic: AuthContext = {
|
||||
clientId,
|
||||
principal: undefined,
|
||||
tenantId: undefined,
|
||||
scopes: [],
|
||||
metadata: {},
|
||||
};
|
||||
internals.authContexts.set(clientId, synthetic);
|
||||
return synthetic;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GatewayDepFactories {
|
||||
readonly cl: ClientLifecycleDeps;
|
||||
readonly ops: RoomOpsDeps;
|
||||
readonly ch: ChannelOpsDeps;
|
||||
readonly runGate: (
|
||||
ctx: AuthContext,
|
||||
action: GatewayAction
|
||||
) => Promise<void>;
|
||||
readonly firePresence: (
|
||||
kind: "join" | "leave" | "update",
|
||||
clientId: string,
|
||||
roomId: string | undefined,
|
||||
status: string
|
||||
) => Promise<void>;
|
||||
readonly emit: <K extends keyof GatewayEventMap>(
|
||||
topic: K,
|
||||
payload: GatewayEventMap[K]
|
||||
) => void;
|
||||
readonly requireAuthCtx: (clientId: string) => AuthContext;
|
||||
}
|
||||
|
||||
function buildOpsDeps(
|
||||
internals: GatewayInternals,
|
||||
runGate: (c: AuthContext, a: GatewayAction) => Promise<void>,
|
||||
requireAuthCtx: (id: string) => AuthContext,
|
||||
firePresence: GatewayDepFactories["firePresence"],
|
||||
emit: GatewayDepFactories["emit"],
|
||||
channelMgr: ChannelManager
|
||||
): { ops: RoomOpsDeps; ch: ChannelOpsDeps } {
|
||||
return {
|
||||
ops: {
|
||||
rooms: internals.state.rooms,
|
||||
clients: internals.state.clients,
|
||||
maxRooms: internals.resolved.maxRooms,
|
||||
maxClientsPerRoom: internals.resolved.maxClientsPerRoom,
|
||||
idempotency: internals.idempotency,
|
||||
clock: internals.clock,
|
||||
runGate,
|
||||
requireAuthCtx,
|
||||
firePresence,
|
||||
emitMessage: (message: Message) =>
|
||||
emit("ws_gateway:message", {
|
||||
message,
|
||||
at: internals.clock(),
|
||||
}),
|
||||
},
|
||||
ch: {
|
||||
clients: internals.state.clients,
|
||||
channelMgr,
|
||||
clock: internals.clock,
|
||||
runGate,
|
||||
requireAuthCtx,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Build every dep-bundle the daemon hands to op modules. */
|
||||
export function buildGatewayDepFactories(
|
||||
internals: GatewayInternals
|
||||
): GatewayDepFactories {
|
||||
const runGate = buildRunGate(internals);
|
||||
const firePresence = buildFirePresence(internals);
|
||||
const emit = buildEmit(internals);
|
||||
const requireAuthCtx = buildRequireAuthCtx(internals);
|
||||
const cl: ClientLifecycleDeps = {
|
||||
clients: internals.state.clients,
|
||||
authContexts: internals.authContexts,
|
||||
rooms: internals.state.rooms,
|
||||
channelMgr: internals.state.channelMgr,
|
||||
presence: internals.state.presence,
|
||||
enablePresence: internals.resolved.enablePresence,
|
||||
clock: internals.clock,
|
||||
emit,
|
||||
firePresence,
|
||||
};
|
||||
const { ops, ch } = buildOpsDeps(
|
||||
internals,
|
||||
runGate,
|
||||
requireAuthCtx,
|
||||
firePresence,
|
||||
emit,
|
||||
internals.state.channelMgr
|
||||
);
|
||||
return { cl, ops, ch, runGate, firePresence, emit, requireAuthCtx };
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
type FakeWsServer,
|
||||
makeFakeWsServerFactory,
|
||||
} from "../../tests/setup/fake-ws-gateway.js";
|
||||
import { WS_GATEWAY_CODES, WsGatewayError } from "../errors/errors.js";
|
||||
import type {
|
||||
AuthorizationCheck,
|
||||
RateLimitGate,
|
||||
WebSocketGatewayConfig,
|
||||
WsServerFactory,
|
||||
} from "../types/types.js";
|
||||
import { WebSocketGateway } from "./gateway.js";
|
||||
import { _resetMessageCounter } from "./message-factory.js";
|
||||
|
||||
function buildGateway(overrides: Partial<WebSocketGatewayConfig> = {}): {
|
||||
readonly gateway: WebSocketGateway;
|
||||
readonly server: FakeWsServer;
|
||||
} {
|
||||
_resetMessageCounter();
|
||||
const { factory, instances } = makeFakeWsServerFactory();
|
||||
const gateway = new WebSocketGateway({
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
wsServerFactory: factory,
|
||||
...overrides,
|
||||
});
|
||||
return {
|
||||
gateway,
|
||||
get server() {
|
||||
const s = instances[0];
|
||||
if (!s) throw new Error("server not constructed");
|
||||
return s;
|
||||
},
|
||||
} as { gateway: WebSocketGateway; server: FakeWsServer };
|
||||
}
|
||||
|
||||
describe("rooms via gateway", () => {
|
||||
it("createRoom + joinRoom + broadcastToRoom + leaveRoom round-trip", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { gateway } = buildGateway();
|
||||
gateway.connectClient("alice");
|
||||
gateway.connectClient("bob");
|
||||
const room = gateway.createRoom({ id: "r1", maxClients: 10 });
|
||||
expect(room.id).toBe("r1");
|
||||
expect(await gateway.joinRoom("r1", "alice")).toBe(true);
|
||||
expect(await gateway.joinRoom("r1", "bob")).toBe(true);
|
||||
const result = await gateway.broadcastToRoom("r1", "alice", {
|
||||
msg: "hi",
|
||||
});
|
||||
expect(result?.recipients).toEqual(["bob"]);
|
||||
expect(result?.message.payload).toEqual({ msg: "hi" });
|
||||
expect(await gateway.leaveRoom("r1", "alice")).toBe(true);
|
||||
expect(gateway.getRoom("r1")?.size).toBe(1);
|
||||
});
|
||||
|
||||
it("createRoom returns existing on duplicate id", { timeout: 5000 }, () => {
|
||||
const { gateway } = buildGateway();
|
||||
const r1 = gateway.createRoom({ id: "r1" });
|
||||
const r2 = gateway.createRoom({ id: "r1" });
|
||||
expect(r1).toBe(r2);
|
||||
});
|
||||
|
||||
it("createRoom throws ROOM_FULL at maxRooms", { timeout: 5000 }, () => {
|
||||
const { gateway } = buildGateway({ maxRooms: 1 });
|
||||
gateway.createRoom({ id: "r1" });
|
||||
try {
|
||||
gateway.createRoom({ id: "r2" });
|
||||
throw new Error("expected ROOM_FULL");
|
||||
} catch (err) {
|
||||
expect((err as WsGatewayError).code).toBe(
|
||||
WS_GATEWAY_CODES.ROOM_FULL
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("joinRoom on unknown room throws ROOM_NOT_FOUND", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { gateway } = buildGateway();
|
||||
gateway.connectClient("alice");
|
||||
try {
|
||||
await gateway.joinRoom("missing", "alice");
|
||||
throw new Error("expected ROOM_NOT_FOUND");
|
||||
} catch (err) {
|
||||
expect((err as WsGatewayError).code).toBe(
|
||||
WS_GATEWAY_CODES.ROOM_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("joinRoom returns false when client unknown", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { gateway } = buildGateway();
|
||||
gateway.createRoom({ id: "r1" });
|
||||
expect(await gateway.joinRoom("r1", "ghost")).toBe(false);
|
||||
});
|
||||
|
||||
it("leaveRoom on unknown room returns false", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { gateway } = buildGateway();
|
||||
expect(await gateway.leaveRoom("missing", "ghost")).toBe(false);
|
||||
});
|
||||
|
||||
it("broadcastToRoom returns null on unknown room", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { gateway } = buildGateway();
|
||||
expect(
|
||||
await gateway.broadcastToRoom("missing", "alice", null)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("deleteRoom removes the room", { timeout: 5000 }, () => {
|
||||
const { gateway } = buildGateway();
|
||||
gateway.createRoom({ id: "r1" });
|
||||
expect(gateway.deleteRoom("r1")).toBe(true);
|
||||
expect(gateway.deleteRoom("r1")).toBe(false);
|
||||
});
|
||||
|
||||
it("unicast returns message for connected target, null otherwise", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { gateway } = buildGateway();
|
||||
gateway.connectClient("alice");
|
||||
gateway.connectClient("bob");
|
||||
const m = await gateway.unicast("alice", "bob", { x: 1 });
|
||||
expect(m?.type).toBe("unicast");
|
||||
expect(await gateway.unicast("alice", "ghost", null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("channels via gateway", () => {
|
||||
it("subscribe + publish + unsubscribe round-trip", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { gateway } = buildGateway();
|
||||
gateway.connectClient("alice");
|
||||
gateway.connectClient("bob");
|
||||
await gateway.subscribeToChannel("events", "alice", "user.created");
|
||||
await gateway.subscribeToChannel("events", "bob", "user.*");
|
||||
const result = await gateway.publishToChannel(
|
||||
"events",
|
||||
"alice",
|
||||
"user.created",
|
||||
{ id: 1 }
|
||||
);
|
||||
expect(result?.recipients).toEqual(["bob"]);
|
||||
expect(
|
||||
gateway.unsubscribeFromChannel("events", "alice", "user.created")
|
||||
).toBe(true);
|
||||
expect(gateway.unsubscribeFromChannel("missing", "x", "y")).toBe(false);
|
||||
});
|
||||
|
||||
it("subscribeToChannel false for unknown client", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { gateway } = buildGateway();
|
||||
expect(await gateway.subscribeToChannel("events", "ghost", "x")).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("getOrCreateChannel creates only once", { timeout: 5000 }, () => {
|
||||
const { gateway } = buildGateway();
|
||||
const c1 = gateway.getOrCreateChannel("evt", false);
|
||||
const c2 = gateway.getOrCreateChannel("evt", false);
|
||||
expect(c1).toBe(c2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("access gates", () => {
|
||||
it("rate-limit denial throws RATE_LIMITED", { timeout: 5000 }, async () => {
|
||||
const rateLimitGate: RateLimitGate = async (_ctx, action) =>
|
||||
action !== "room.broadcast";
|
||||
const { gateway } = buildGateway({ rateLimitGate });
|
||||
gateway.connectClient("a");
|
||||
gateway.createRoom({ id: "r1" });
|
||||
await gateway.joinRoom("r1", "a");
|
||||
try {
|
||||
await gateway.broadcastToRoom("r1", "a", { x: 1 });
|
||||
throw new Error("expected RATE_LIMITED");
|
||||
} catch (err) {
|
||||
expect((err as WsGatewayError).code).toBe(
|
||||
WS_GATEWAY_CODES.RATE_LIMITED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("authorization denial throws AUTH_REJECTED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const authorizationCheck: AuthorizationCheck = async (_ctx, action) =>
|
||||
action !== "channel.publish";
|
||||
const { gateway } = buildGateway({ authorizationCheck });
|
||||
gateway.connectClient("a");
|
||||
gateway.getOrCreateChannel("c", true);
|
||||
try {
|
||||
await gateway.publishToChannel("c", "a", "topic", null);
|
||||
throw new Error("expected AUTH_REJECTED");
|
||||
} catch (err) {
|
||||
expect((err as WsGatewayError).code).toBe(
|
||||
WS_GATEWAY_CODES.AUTH_REJECTED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("audit logger receives every decision and is crash-safe", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const events: { action: string; outcome: string }[] = [];
|
||||
const { gateway } = buildGateway({
|
||||
auditLogger: (e) => {
|
||||
events.push({ action: e.action, outcome: e.outcome });
|
||||
if (e.action === "unicast") throw new Error("audit crash");
|
||||
},
|
||||
});
|
||||
gateway.connectClient("a");
|
||||
gateway.connectClient("b");
|
||||
await gateway.unicast("a", "b", null); // logger throws — must not propagate
|
||||
expect(events.some((e) => e.outcome === "allowed")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("idempotency", () => {
|
||||
it("joinRoom is idempotent on the same key", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { gateway } = buildGateway();
|
||||
gateway.connectClient("a");
|
||||
gateway.createRoom({ id: "r1" });
|
||||
const k = "k-1";
|
||||
expect(await gateway.joinRoom("r1", "a", k)).toBe(true);
|
||||
expect(await gateway.joinRoom("r1", "a", k)).toBe(true);
|
||||
expect(gateway.getRoom("r1")?.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("presence webhook", () => {
|
||||
it("delivers a signed payload on join", { timeout: 5000 }, async () => {
|
||||
const calls: { url: string; body: string; headers: Headers }[] = [];
|
||||
const fakeFetch: typeof fetch = async (
|
||||
input: string | URL | Request,
|
||||
init?: RequestInit
|
||||
) => {
|
||||
calls.push({
|
||||
url: String(input),
|
||||
body: String(init?.body ?? ""),
|
||||
headers: new Headers(init?.headers ?? {}),
|
||||
});
|
||||
return new Response(null, { status: 200 });
|
||||
};
|
||||
const originalFetch = globalThis.fetch;
|
||||
(globalThis as { fetch: typeof fetch }).fetch = fakeFetch;
|
||||
try {
|
||||
const built = buildGateway({
|
||||
presenceWebhook: {
|
||||
url: "https://hooks.example.com/presence",
|
||||
secret: "shared",
|
||||
events: ["presence:join"],
|
||||
},
|
||||
});
|
||||
const { gateway } = built;
|
||||
await gateway.start();
|
||||
const { server } = built;
|
||||
server.connect("alice-sock", { query: {} });
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
expect(calls[0]?.headers.get("x-signature")).toBeDefined();
|
||||
await gateway.stop();
|
||||
} finally {
|
||||
(globalThis as { fetch: typeof fetch }).fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("graceful shutdown drains clients", () => {
|
||||
it("stop() disconnects all clients", { timeout: 5000 }, async () => {
|
||||
const { gateway } = buildGateway();
|
||||
await gateway.start();
|
||||
gateway.connectClient("a");
|
||||
gateway.connectClient("b");
|
||||
expect(gateway.clientCount).toBe(2);
|
||||
await gateway.stop();
|
||||
expect(gateway.clientCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrCreateChannel returns a real Channel", () => {
|
||||
it("ChannelManager defaults to allowPatterns=true", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { gateway } = buildGateway();
|
||||
const ch = gateway.getOrCreateChannel("c");
|
||||
expect(ch.allowPatterns).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPresenceTracker exposes a tracker", () => {
|
||||
it("getPresenceTracker is stable", { timeout: 5000 }, () => {
|
||||
const { gateway } = buildGateway();
|
||||
expect(gateway.getPresenceTracker()).toBe(gateway.getPresenceTracker());
|
||||
});
|
||||
});
|
||||
|
||||
describe("aborted start signal", () => {
|
||||
it("post-abort during start (signal aborts before fn) → START_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
const { gateway } = buildGateway();
|
||||
await expect(
|
||||
gateway.start({ signal: ac.signal })
|
||||
).rejects.toBeInstanceOf(WsGatewayError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wsServerFactory not function", () => {
|
||||
it("non-function factory rejected", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new WebSocketGateway({
|
||||
port: 0,
|
||||
wsServerFactory: "nope" as unknown as WsServerFactory,
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as WsGatewayError).code).toBe(
|
||||
WS_GATEWAY_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,291 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
FakeWsServer,
|
||||
makeFakeWsServerFactory,
|
||||
} from "../../tests/setup/fake-ws-gateway.js";
|
||||
import { WS_GATEWAY_CODES, WsGatewayError } from "../errors/errors.js";
|
||||
import type {
|
||||
AuthVerifier,
|
||||
Handshake,
|
||||
WebSocketGatewayConfig,
|
||||
WsServerFactory,
|
||||
} from "../types/types.js";
|
||||
import { WebSocketGateway } from "./gateway.js";
|
||||
import { _resetMessageCounter } from "./message-factory.js";
|
||||
|
||||
function buildGateway(overrides: Partial<WebSocketGatewayConfig> = {}): {
|
||||
readonly gateway: WebSocketGateway;
|
||||
readonly server: FakeWsServer;
|
||||
} {
|
||||
_resetMessageCounter();
|
||||
const { factory, instances } = makeFakeWsServerFactory();
|
||||
const gateway = new WebSocketGateway({
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
wsServerFactory: factory,
|
||||
heartbeatIntervalMs: 10_000,
|
||||
clientTimeoutMs: 30_000,
|
||||
...overrides,
|
||||
});
|
||||
return {
|
||||
gateway,
|
||||
get server() {
|
||||
const s = instances[0];
|
||||
if (!s) throw new Error("server not constructed yet");
|
||||
return s;
|
||||
},
|
||||
} as { gateway: WebSocketGateway; server: FakeWsServer };
|
||||
}
|
||||
|
||||
describe("WebSocketGateway construction", () => {
|
||||
it("rejects invalid options", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new WebSocketGateway({
|
||||
port: -1,
|
||||
wsServerFactory: (() =>
|
||||
undefined) as unknown as WsServerFactory,
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as WsGatewayError).code).toBe(
|
||||
WS_GATEWAY_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-function wsServerFactory", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new WebSocketGateway({
|
||||
port: 0,
|
||||
wsServerFactory: undefined as unknown as WsServerFactory,
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as WsGatewayError).code).toBe(
|
||||
WS_GATEWAY_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("snapshot reports running:false before start()", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { gateway } = buildGateway();
|
||||
expect(gateway.snapshot().running).toBe(false);
|
||||
expect(gateway.clientCount).toBe(0);
|
||||
expect(gateway.roomCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocketGateway lifecycle", () => {
|
||||
it("start() resolves with bound address/port and snapshot flips running", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { gateway } = buildGateway();
|
||||
const result = await gateway.start();
|
||||
expect(result.address).toBe("127.0.0.1");
|
||||
expect(gateway.snapshot().running).toBe(true);
|
||||
expect(gateway.boundAddress).toBe("127.0.0.1");
|
||||
expect(typeof gateway.boundPort).toBe("number");
|
||||
await gateway.stop();
|
||||
expect(gateway.snapshot().disposed).toBe(true);
|
||||
});
|
||||
|
||||
it("start() rejects twice", { timeout: 5000 }, async () => {
|
||||
const { gateway } = buildGateway();
|
||||
await gateway.start();
|
||||
await expect(gateway.start()).rejects.toBeInstanceOf(WsGatewayError);
|
||||
await gateway.stop();
|
||||
});
|
||||
|
||||
it("start() rejects after stop()", { timeout: 5000 }, async () => {
|
||||
const { gateway } = buildGateway();
|
||||
await gateway.start();
|
||||
await gateway.stop();
|
||||
try {
|
||||
await gateway.start();
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as WsGatewayError).code).toBe(
|
||||
WS_GATEWAY_CODES.DISPOSED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("start() rejects on pre-aborted signal", { timeout: 5000 }, async () => {
|
||||
const { gateway } = buildGateway();
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
try {
|
||||
await gateway.start({ signal: ac.signal });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as WsGatewayError).code).toBe(
|
||||
WS_GATEWAY_CODES.START_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("start() wraps factory errors into START_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const factory: WsServerFactory = () => {
|
||||
const s = new FakeWsServer();
|
||||
s.startError = new Error("boom");
|
||||
return s;
|
||||
};
|
||||
const gateway = new WebSocketGateway({
|
||||
port: 0,
|
||||
wsServerFactory: factory,
|
||||
});
|
||||
try {
|
||||
await gateway.start();
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as WsGatewayError).code).toBe(
|
||||
WS_GATEWAY_CODES.START_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("stop() is idempotent", { timeout: 5000 }, async () => {
|
||||
const { gateway } = buildGateway();
|
||||
await gateway.start();
|
||||
await gateway.stop();
|
||||
await gateway.stop();
|
||||
});
|
||||
|
||||
it("snapshot returns clientCount / roomCount", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { gateway } = buildGateway();
|
||||
await gateway.start();
|
||||
gateway.createRoom({ id: "r1" });
|
||||
gateway.connectClient("a");
|
||||
const snap = gateway.snapshot();
|
||||
expect(snap.roomCount).toBe(1);
|
||||
expect(snap.clientCount).toBe(1);
|
||||
await gateway.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocketGateway connection handling", () => {
|
||||
it("incoming connection runs authVerifier, registers client, fires presence", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const verifier: AuthVerifier = async (h: Handshake) => ({
|
||||
clientId: h.query.uid ?? "anon",
|
||||
principal: "alice",
|
||||
tenantId: "t1",
|
||||
scopes: ["read", "write"],
|
||||
metadata: { ua: "vitest" },
|
||||
});
|
||||
const built = buildGateway({ authVerifier: verifier });
|
||||
const { gateway } = built;
|
||||
await gateway.start();
|
||||
const { server } = built;
|
||||
server.connect("sock-1", { query: { uid: "alice-id" } });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(gateway.isClientConnected("sock-1")).toBe(true);
|
||||
const ctx = gateway.getAuthContext("sock-1");
|
||||
expect(ctx?.principal).toBe("alice");
|
||||
expect(ctx?.scopes).toEqual(["read", "write"]);
|
||||
await gateway.stop();
|
||||
});
|
||||
|
||||
it("authVerifier rejection: client is not registered", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const verifier: AuthVerifier = async () => {
|
||||
throw new Error("invalid token");
|
||||
};
|
||||
const built = buildGateway({ authVerifier: verifier });
|
||||
const { gateway } = built;
|
||||
await gateway.start();
|
||||
const { server } = built;
|
||||
try {
|
||||
server.connect("sock-1");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
} catch {
|
||||
/* the daemon swallows the async error from onConnection */
|
||||
}
|
||||
expect(gateway.isClientConnected("sock-1")).toBe(false);
|
||||
await gateway.stop();
|
||||
});
|
||||
|
||||
it("default authVerifier accepts every handshake", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const built = buildGateway();
|
||||
const { gateway } = built;
|
||||
await gateway.start();
|
||||
const { server } = built;
|
||||
server.connect("sock-1");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(gateway.isClientConnected("sock-1")).toBe(true);
|
||||
await gateway.stop();
|
||||
});
|
||||
|
||||
it("socket close path triggers disconnectClient", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const built = buildGateway();
|
||||
const { gateway } = built;
|
||||
await gateway.start();
|
||||
const { server } = built;
|
||||
server.connect("sock-1");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
server.disconnect("sock-1", 1000, "bye");
|
||||
expect(gateway.isClientConnected("sock-1")).toBe(false);
|
||||
await gateway.stop();
|
||||
});
|
||||
|
||||
it("socket error path triggers disconnectClient", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const built = buildGateway();
|
||||
const { gateway } = built;
|
||||
await gateway.start();
|
||||
const { server } = built;
|
||||
server.connect("sock-1");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
server.errorOn("sock-1", new Error("net"));
|
||||
expect(gateway.isClientConnected("sock-1")).toBe(false);
|
||||
await gateway.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocketGateway client helpers", () => {
|
||||
it("connectClient / disconnectClient / getClient / isClientConnected", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { gateway } = buildGateway();
|
||||
gateway.connectClient("a", { tag: "x" });
|
||||
expect(gateway.getClient("a")?.metadata).toEqual({ tag: "x" });
|
||||
expect(gateway.isClientConnected("a")).toBe(true);
|
||||
expect(gateway.disconnectClient("a")).toBe(true);
|
||||
expect(gateway.disconnectClient("a")).toBe(false);
|
||||
});
|
||||
|
||||
it("handleHeartbeat updates lastHeartbeat", { timeout: 5000 }, () => {
|
||||
let now = 100;
|
||||
const { factory } = makeFakeWsServerFactory();
|
||||
const gateway = new WebSocketGateway(
|
||||
{ port: 0, wsServerFactory: factory },
|
||||
() => now
|
||||
);
|
||||
gateway.connectClient("a");
|
||||
now = 200;
|
||||
expect(gateway.handleHeartbeat("a")).toBe(true);
|
||||
expect(gateway.getClient("a")?.lastHeartbeat).toBe(200);
|
||||
expect(gateway.handleHeartbeat("missing")).toBe(false);
|
||||
});
|
||||
|
||||
it("handleHeartbeat is no-op for presence-disabled gateway", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { gateway } = buildGateway({ enablePresence: false });
|
||||
gateway.connectClient("a");
|
||||
expect(gateway.handleHeartbeat("a")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
// Server-tier package — depends on `ws` (Node-only). Browser consumers cannot use this package.
|
||||
import {
|
||||
IdempotencyGuard,
|
||||
MemoryIdempotencyStore,
|
||||
} from "@suaveplan/idempotency";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import { WS_GATEWAY_CODES, WsGatewayError } from "../errors/errors.js";
|
||||
import type { HealthSnapshot } from "../health/health.js";
|
||||
import { PresenceTracker } from "../presence/presence.js";
|
||||
import type { Room } from "../room/room.js";
|
||||
import type {
|
||||
AuthContext,
|
||||
ClientInfo,
|
||||
GatewayStartResult,
|
||||
Handshake,
|
||||
Message,
|
||||
ResolvedGatewayConfig,
|
||||
RoomConfig,
|
||||
WebSocketGatewayConfig,
|
||||
} from "../types/types.js";
|
||||
import { WebSocketGatewayConfigSchema } from "../types/types.schemas.js";
|
||||
import { ChannelManager } from "./channel-manager.js";
|
||||
import * as ch from "./channel-ops.js";
|
||||
import * as cl from "./client-lifecycle.js";
|
||||
import { resolveConfig } from "./defaults.js";
|
||||
import {
|
||||
buildGatewayDepFactories,
|
||||
type GatewayDepFactories,
|
||||
} from "./gateway-deps.js";
|
||||
import { createHeartbeat, type HeartbeatHandle } from "./heartbeat.js";
|
||||
import {
|
||||
type GatewayState,
|
||||
runAuthHandshake,
|
||||
runStart,
|
||||
runStop,
|
||||
} from "./lifecycle.js";
|
||||
import * as ops from "./room-ops.js";
|
||||
|
||||
/**
|
||||
* Server-tier WebSocket gateway daemon. Hosts rooms, presence, and
|
||||
* channel-multiplexed pub/sub over an injectable `WsServerLike`.
|
||||
* Lifecycle: `start({signal?}) → {address, port}` then `stop()`. Both
|
||||
* honour `AbortSignal`; `stop()` is idempotent.
|
||||
*/
|
||||
export class WebSocketGateway {
|
||||
readonly #resolved: ResolvedGatewayConfig;
|
||||
readonly #clock: Clock;
|
||||
readonly #authContexts: Map<string, AuthContext> = new Map();
|
||||
readonly #idempotency: IdempotencyGuard;
|
||||
readonly #heartbeat: HeartbeatHandle;
|
||||
readonly #state: GatewayState;
|
||||
readonly #deps: GatewayDepFactories;
|
||||
|
||||
public constructor(config: WebSocketGatewayConfig, clock?: Clock) {
|
||||
const parsed = WebSocketGatewayConfigSchema.safeParse(config);
|
||||
if (!parsed.success) {
|
||||
throw new WsGatewayError(
|
||||
WS_GATEWAY_CODES.INVALID_OPTIONS,
|
||||
"ws_gateway.invalid-config",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
this.#clock = clock ?? (() => Date.now());
|
||||
this.#resolved = resolveConfig({
|
||||
...parsed.data,
|
||||
wsServerFactory: config.wsServerFactory,
|
||||
authVerifier: config.authVerifier,
|
||||
authorizationCheck: config.authorizationCheck,
|
||||
rateLimitGate: config.rateLimitGate,
|
||||
auditLogger: config.auditLogger,
|
||||
presenceWebhook: config.presenceWebhook,
|
||||
bus: config.bus,
|
||||
});
|
||||
const presence = new PresenceTracker(
|
||||
this.#resolved.clientTimeoutMs,
|
||||
this.#clock
|
||||
);
|
||||
this.#state = {
|
||||
clients: new Map<string, ClientInfo>(),
|
||||
rooms: new Map<string, Room>(),
|
||||
channelMgr: new ChannelManager(),
|
||||
presence,
|
||||
wsServer: undefined,
|
||||
started: false,
|
||||
disposed: false,
|
||||
startedAt: undefined,
|
||||
boundAddress: undefined,
|
||||
boundPort: undefined,
|
||||
};
|
||||
this.#idempotency = new IdempotencyGuard({
|
||||
store: new MemoryIdempotencyStore(),
|
||||
clock: this.#clock,
|
||||
ttlMs: 60_000,
|
||||
});
|
||||
this.#heartbeat = createHeartbeat(
|
||||
presence,
|
||||
this.#resolved.heartbeatIntervalMs,
|
||||
(id) => this.disconnectClient(id, "timeout"),
|
||||
() => this.#resolved.enablePresence
|
||||
);
|
||||
this.#deps = buildGatewayDepFactories({
|
||||
state: this.#state,
|
||||
resolved: this.#resolved,
|
||||
clock: this.#clock,
|
||||
authContexts: this.#authContexts,
|
||||
idempotency: this.#idempotency,
|
||||
});
|
||||
}
|
||||
|
||||
public get config(): ResolvedGatewayConfig {
|
||||
return this.#resolved;
|
||||
}
|
||||
public get clientCount(): number {
|
||||
return this.#state.clients.size;
|
||||
}
|
||||
public get roomCount(): number {
|
||||
return this.#state.rooms.size;
|
||||
}
|
||||
public get boundAddress(): string | undefined {
|
||||
return this.#state.boundAddress;
|
||||
}
|
||||
public get boundPort(): number | undefined {
|
||||
return this.#state.boundPort;
|
||||
}
|
||||
public snapshot(): HealthSnapshot {
|
||||
return {
|
||||
running: this.#state.started,
|
||||
disposed: this.#state.disposed,
|
||||
clientCount: this.clientCount,
|
||||
roomCount: this.roomCount,
|
||||
startedAt: this.#state.startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
public start(options?: {
|
||||
readonly signal?: AbortSignal;
|
||||
}): Promise<GatewayStartResult> {
|
||||
return runStart(
|
||||
this.#state,
|
||||
this.#resolved,
|
||||
options?.signal,
|
||||
this.#wsCallbacks(),
|
||||
this.#clock,
|
||||
() => this.#heartbeat.start(),
|
||||
(a, p, at) =>
|
||||
this.#deps.emit("ws_gateway:started", {
|
||||
address: a,
|
||||
port: p,
|
||||
at,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#wsCallbacks() {
|
||||
return {
|
||||
onConnection: (s: { id: string }, h: Handshake) => {
|
||||
this.#handleConnection(s.id, h).catch(() => {});
|
||||
},
|
||||
onMessage: (): void => {},
|
||||
onClose: (id: string, _c: number, reason: string): void => {
|
||||
this.disconnectClient(id, reason || "close");
|
||||
},
|
||||
onError: (id: string): void => {
|
||||
if (this.#state.clients.has(id))
|
||||
this.disconnectClient(id, "error");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public stop(): Promise<void> {
|
||||
return runStop(
|
||||
this.#state,
|
||||
() => this.#heartbeat.stop(),
|
||||
(id, reason) => this.disconnectClient(id, reason),
|
||||
this.#clock,
|
||||
(at) => this.#deps.emit("ws_gateway:stopped", { at })
|
||||
);
|
||||
}
|
||||
|
||||
async #handleConnection(
|
||||
socketId: string,
|
||||
handshake: Handshake
|
||||
): Promise<void> {
|
||||
const ctx = await runAuthHandshake(
|
||||
this.#resolved,
|
||||
socketId,
|
||||
handshake,
|
||||
this.#clock()
|
||||
);
|
||||
this.#authContexts.set(socketId, ctx);
|
||||
const client = this.connectClient(socketId);
|
||||
this.#deps.emit("ws_gateway:client-connected", {
|
||||
clientId: client.id,
|
||||
principal: ctx.principal,
|
||||
at: this.#clock(),
|
||||
});
|
||||
await this.#deps.firePresence("join", socketId, undefined, "online");
|
||||
}
|
||||
|
||||
public connectClient(
|
||||
id: string,
|
||||
metadata?: Record<string, unknown>
|
||||
): ClientInfo {
|
||||
return cl.connectClient(this.#deps.cl, id, metadata);
|
||||
}
|
||||
public disconnectClient(clientId: string, reason = "normal"): boolean {
|
||||
return cl.disconnectClient(this.#deps.cl, clientId, reason);
|
||||
}
|
||||
public isClientConnected(id: string): boolean {
|
||||
return this.#state.clients.has(id);
|
||||
}
|
||||
public getClient(id: string): ClientInfo | undefined {
|
||||
return this.#state.clients.get(id);
|
||||
}
|
||||
public getAuthContext(id: string): AuthContext | undefined {
|
||||
return this.#authContexts.get(id);
|
||||
}
|
||||
|
||||
public createRoom(
|
||||
config: Partial<RoomConfig> & { readonly id: string }
|
||||
): Room {
|
||||
return ops.createRoom(
|
||||
{
|
||||
rooms: this.#state.rooms,
|
||||
maxRooms: this.#resolved.maxRooms,
|
||||
maxClientsPerRoom: this.#resolved.maxClientsPerRoom,
|
||||
},
|
||||
config
|
||||
);
|
||||
}
|
||||
public getRoom(roomId: string): Room | undefined {
|
||||
return this.#state.rooms.get(roomId);
|
||||
}
|
||||
public deleteRoom(roomId: string): boolean {
|
||||
return ops.deleteRoom(this.#state.rooms, roomId);
|
||||
}
|
||||
public joinRoom(
|
||||
roomId: string,
|
||||
clientId: string,
|
||||
idempotencyKey?: string
|
||||
): Promise<boolean> {
|
||||
return ops.joinRoom(this.#deps.ops, roomId, clientId, idempotencyKey);
|
||||
}
|
||||
public leaveRoom(roomId: string, clientId: string): Promise<boolean> {
|
||||
return ops.leaveRoom(
|
||||
{ rooms: this.#state.rooms, firePresence: this.#deps.firePresence },
|
||||
roomId,
|
||||
clientId
|
||||
);
|
||||
}
|
||||
public broadcastToRoom(
|
||||
roomId: string,
|
||||
senderId: string,
|
||||
payload: unknown,
|
||||
idempotencyKey?: string
|
||||
): Promise<{
|
||||
readonly message: Message;
|
||||
readonly recipients: string[];
|
||||
} | null> {
|
||||
return ops.broadcastToRoom(
|
||||
this.#deps.ops,
|
||||
roomId,
|
||||
senderId,
|
||||
payload,
|
||||
idempotencyKey
|
||||
);
|
||||
}
|
||||
public unicast(
|
||||
senderId: string,
|
||||
targetId: string,
|
||||
payload: unknown
|
||||
): Promise<Message | null> {
|
||||
return ops.unicast(this.#deps.ops, senderId, targetId, payload);
|
||||
}
|
||||
|
||||
public getOrCreateChannel(name: string, allowPatterns = true) {
|
||||
return this.#state.channelMgr.getOrCreateChannel(name, allowPatterns);
|
||||
}
|
||||
public subscribeToChannel(
|
||||
channelName: string,
|
||||
clientId: string,
|
||||
topic: string
|
||||
): Promise<boolean> {
|
||||
return ch.subscribeToChannel(
|
||||
this.#deps.ch,
|
||||
channelName,
|
||||
clientId,
|
||||
topic
|
||||
);
|
||||
}
|
||||
public unsubscribeFromChannel(
|
||||
channelName: string,
|
||||
clientId: string,
|
||||
topic: string
|
||||
): boolean {
|
||||
return ch.unsubscribeFromChannel(
|
||||
this.#state.channelMgr,
|
||||
channelName,
|
||||
clientId,
|
||||
topic
|
||||
);
|
||||
}
|
||||
public publishToChannel(
|
||||
channelName: string,
|
||||
senderId: string,
|
||||
topic: string,
|
||||
payload: unknown
|
||||
): Promise<{
|
||||
readonly message: Message;
|
||||
readonly recipients: string[];
|
||||
} | null> {
|
||||
return ch.publishToChannel(
|
||||
this.#deps.ch,
|
||||
channelName,
|
||||
senderId,
|
||||
topic,
|
||||
payload
|
||||
);
|
||||
}
|
||||
|
||||
public getPresenceTracker(): PresenceTracker {
|
||||
return this.#state.presence;
|
||||
}
|
||||
public handleHeartbeat(clientId: string): boolean {
|
||||
const client = this.#state.clients.get(clientId);
|
||||
if (!client) return false;
|
||||
client.lastHeartbeat = this.#clock();
|
||||
if (this.#resolved.enablePresence)
|
||||
this.#state.presence.heartbeat(clientId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -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 @@
|
||||
import type { PresenceTracker } from "../presence/presence.js";
|
||||
|
||||
/** Heartbeat sweep handle owned by the daemon. */
|
||||
export interface HeartbeatHandle {
|
||||
/** Start (idempotent) — sweep every `intervalMs`. */
|
||||
start(): void;
|
||||
/** Stop (idempotent). */
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a heartbeat sweep that calls `presence.detectTimedOut()` every
|
||||
* `intervalMs` and forwards every returned client-id through
|
||||
* `onTimedOut(id)`. The returned handle is idempotent — calling
|
||||
* `start()` twice is a no-op, and `stop()` is safe to invoke before
|
||||
* any `start()`.
|
||||
*/
|
||||
export function createHeartbeat(
|
||||
presence: PresenceTracker,
|
||||
intervalMs: number,
|
||||
onTimedOut: (clientId: string) => void,
|
||||
enabled: () => boolean
|
||||
): HeartbeatHandle {
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
return {
|
||||
start(): void {
|
||||
if (timer) return;
|
||||
timer = setInterval(() => {
|
||||
if (!enabled()) return;
|
||||
for (const clientId of presence.detectTimedOut()) {
|
||||
onTimedOut(clientId);
|
||||
}
|
||||
}, intervalMs);
|
||||
timer.unref?.();
|
||||
},
|
||||
stop(): void {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,215 @@
|
||||
import { WS_GATEWAY_CODES, WsGatewayError } from "../errors/errors.js";
|
||||
import {
|
||||
adjustRoomsActive,
|
||||
recordAction,
|
||||
recordStartDuration,
|
||||
withStartSpan,
|
||||
withStopSpan,
|
||||
} from "../otel/otel.js";
|
||||
import type { PresenceTracker } from "../presence/presence.js";
|
||||
import type { Room } from "../room/room.js";
|
||||
import type {
|
||||
AuthContext,
|
||||
ClientInfo,
|
||||
GatewayStartResult,
|
||||
Handshake,
|
||||
ResolvedGatewayConfig,
|
||||
WsServerCallbacks,
|
||||
WsServerLike,
|
||||
} from "../types/types.js";
|
||||
import { audit } from "./access-gate.js";
|
||||
import type { ChannelManager } from "./channel-manager.js";
|
||||
|
||||
/**
|
||||
* Boot the WS server with the daemon's callback wiring. Translates
|
||||
* every failure into a typed `START_FAILED` error.
|
||||
*/
|
||||
export async function bootWsServer(
|
||||
resolved: ResolvedGatewayConfig,
|
||||
callbacks: WsServerCallbacks,
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<{
|
||||
readonly server: WsServerLike;
|
||||
readonly address: string;
|
||||
readonly port: number;
|
||||
}> {
|
||||
let server: WsServerLike | undefined;
|
||||
try {
|
||||
server = await resolved.wsServerFactory({
|
||||
host: resolved.host,
|
||||
port: resolved.port,
|
||||
});
|
||||
const result = await server.start({
|
||||
host: resolved.host,
|
||||
port: resolved.port,
|
||||
signal,
|
||||
callbacks,
|
||||
});
|
||||
return { server, address: result.address, port: result.port };
|
||||
} catch (cause) {
|
||||
await server?.stop();
|
||||
throw new WsGatewayError(
|
||||
WS_GATEWAY_CODES.START_FAILED,
|
||||
"ws_gateway.factory-start-failed",
|
||||
{ cause: cause as Error }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Pre-flight checks for `start()`. */
|
||||
export function preflightStart(
|
||||
disposed: boolean,
|
||||
started: boolean,
|
||||
signal: AbortSignal | undefined
|
||||
): void {
|
||||
if (disposed)
|
||||
throw new WsGatewayError(
|
||||
WS_GATEWAY_CODES.DISPOSED,
|
||||
"ws_gateway.start-after-stop"
|
||||
);
|
||||
if (started)
|
||||
throw new WsGatewayError(
|
||||
WS_GATEWAY_CODES.START_FAILED,
|
||||
"ws_gateway.already-started"
|
||||
);
|
||||
if (signal?.aborted === true)
|
||||
throw new WsGatewayError(
|
||||
WS_GATEWAY_CODES.START_FAILED,
|
||||
"ws_gateway.aborted"
|
||||
);
|
||||
}
|
||||
|
||||
/** Record `start()` completion and observe its duration. */
|
||||
export function recordStartCompleted(
|
||||
address: string,
|
||||
port: number,
|
||||
startedAt: number,
|
||||
t0: number,
|
||||
emit: (a: string, p: number, at: number) => void
|
||||
): void {
|
||||
recordStartDuration(startedAt - t0);
|
||||
emit(address, port, startedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run authVerifier, derive the effective auth context, audit, raise
|
||||
* `AUTH_REJECTED` on failure.
|
||||
*/
|
||||
export async function runAuthHandshake(
|
||||
resolved: ResolvedGatewayConfig,
|
||||
socketId: string,
|
||||
handshake: Handshake,
|
||||
now: number
|
||||
): Promise<AuthContext> {
|
||||
try {
|
||||
const ctx = await resolved.authVerifier(handshake);
|
||||
const effective: AuthContext = {
|
||||
clientId: ctx.clientId || socketId,
|
||||
principal: ctx.principal,
|
||||
tenantId: ctx.tenantId,
|
||||
scopes: ctx.scopes,
|
||||
metadata: ctx.metadata,
|
||||
};
|
||||
recordAction("connect", "allowed");
|
||||
audit(
|
||||
resolved.auditLogger,
|
||||
"connect",
|
||||
effective,
|
||||
"allowed",
|
||||
undefined,
|
||||
now
|
||||
);
|
||||
return effective;
|
||||
} catch (err) {
|
||||
recordAction("connect", "denied");
|
||||
audit(
|
||||
resolved.auditLogger,
|
||||
"connect",
|
||||
{
|
||||
clientId: socketId,
|
||||
principal: undefined,
|
||||
tenantId: undefined,
|
||||
scopes: [],
|
||||
metadata: {},
|
||||
},
|
||||
"denied",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
now
|
||||
);
|
||||
throw new WsGatewayError(
|
||||
WS_GATEWAY_CODES.AUTH_REJECTED,
|
||||
"ws_gateway.auth-rejected",
|
||||
{ cause: err as Error }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** State surface the daemon hands to lifecycle helpers. */
|
||||
export interface GatewayState {
|
||||
readonly clients: Map<string, ClientInfo>;
|
||||
readonly rooms: Map<string, Room>;
|
||||
readonly channelMgr: ChannelManager;
|
||||
readonly presence: PresenceTracker;
|
||||
wsServer: WsServerLike | undefined;
|
||||
started: boolean;
|
||||
disposed: boolean;
|
||||
startedAt: number | undefined;
|
||||
boundAddress: string | undefined;
|
||||
boundPort: number | undefined;
|
||||
}
|
||||
|
||||
/** Wrap `withStartSpan` with the start workflow. */
|
||||
export async function runStart(
|
||||
state: GatewayState,
|
||||
resolved: ResolvedGatewayConfig,
|
||||
signal: AbortSignal | undefined,
|
||||
callbacks: WsServerCallbacks,
|
||||
clock: () => number,
|
||||
heartbeatStart: () => void,
|
||||
emitStarted: (a: string, p: number, at: number) => void
|
||||
): Promise<GatewayStartResult> {
|
||||
preflightStart(state.disposed, state.started, signal);
|
||||
const t0 = clock();
|
||||
return withStartSpan(async () => {
|
||||
const { server, address, port } = await bootWsServer(
|
||||
resolved,
|
||||
callbacks,
|
||||
signal
|
||||
);
|
||||
state.wsServer = server;
|
||||
state.boundAddress = address;
|
||||
state.boundPort = port;
|
||||
state.started = true;
|
||||
state.startedAt = clock();
|
||||
recordStartCompleted(address, port, state.startedAt, t0, emitStarted);
|
||||
heartbeatStart();
|
||||
return { address, port };
|
||||
});
|
||||
}
|
||||
|
||||
/** Wrap `withStopSpan` with the stop workflow. */
|
||||
export async function runStop(
|
||||
state: GatewayState,
|
||||
heartbeatStop: () => void,
|
||||
disconnectClient: (id: string, reason: string) => void,
|
||||
clock: () => number,
|
||||
emitStopped: (at: number) => void
|
||||
): Promise<void> {
|
||||
if (state.disposed) return;
|
||||
await withStopSpan(async () => {
|
||||
state.disposed = true;
|
||||
state.started = false;
|
||||
heartbeatStop();
|
||||
await state.wsServer?.closeAll(1001, "shutdown");
|
||||
for (const id of Array.from(state.clients.keys()))
|
||||
disconnectClient(id, "shutdown");
|
||||
await state.wsServer?.stop();
|
||||
state.wsServer = undefined;
|
||||
const remaining = state.rooms.size;
|
||||
state.rooms.clear();
|
||||
adjustRoomsActive(-remaining);
|
||||
state.channelMgr.clear();
|
||||
state.presence.clear();
|
||||
emitStopped(clock());
|
||||
});
|
||||
}
|
||||
@@ -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,36 @@
|
||||
import type { Message, MessageType } from "../types/types.js";
|
||||
|
||||
let messageCounter = 0;
|
||||
|
||||
/** Generate a unique message id. */
|
||||
function generateMessageId(now: number): string {
|
||||
messageCounter += 1;
|
||||
return `msg_${now}_${messageCounter}`;
|
||||
}
|
||||
|
||||
/** Reset the in-process message counter (test-only affordance). */
|
||||
export function _resetMessageCounter(): void {
|
||||
messageCounter = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link Message} value with an auto-generated id and a
|
||||
* caller-provided timestamp (the gateway threads its injectable clock
|
||||
* here so message ids stay deterministic in tests).
|
||||
*/
|
||||
export function createMessage(
|
||||
type: MessageType,
|
||||
sender: string,
|
||||
target: string,
|
||||
payload: unknown,
|
||||
now: number
|
||||
): Message {
|
||||
return {
|
||||
id: generateMessageId(now),
|
||||
type,
|
||||
sender,
|
||||
target,
|
||||
payload,
|
||||
timestamp: now,
|
||||
};
|
||||
}
|
||||
@@ -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,48 @@
|
||||
import type { EventBusLike } from "@suaveplan/types";
|
||||
import { recordPresenceUpdate } from "../otel/otel.js";
|
||||
import type { GatewayEventMap, PresenceWebhookConfig } from "../types/types.js";
|
||||
import { deliverPresenceWebhook } from "./presence-webhook.js";
|
||||
|
||||
/**
|
||||
* Single presence-event side-effect helper. Emits the typed bus event,
|
||||
* bumps the OTel counter, and (if configured) delivers a signed
|
||||
* webhook. Failures from any of the three sinks are swallowed.
|
||||
*/
|
||||
export async function firePresence(
|
||||
deps: {
|
||||
readonly bus?: EventBusLike<GatewayEventMap> | undefined;
|
||||
readonly presenceWebhook?: PresenceWebhookConfig | undefined;
|
||||
readonly clock: () => number;
|
||||
},
|
||||
kind: "join" | "leave" | "update",
|
||||
clientId: string,
|
||||
roomId: string | undefined,
|
||||
status: string
|
||||
): Promise<void> {
|
||||
if (kind === "update") recordPresenceUpdate("update");
|
||||
if (deps.bus) {
|
||||
try {
|
||||
deps.bus.emit("ws_gateway:presence", {
|
||||
kind,
|
||||
clientId,
|
||||
roomId,
|
||||
at: deps.clock(),
|
||||
});
|
||||
} catch {
|
||||
/* bus failures must not crash the daemon */
|
||||
}
|
||||
}
|
||||
if (!deps.presenceWebhook) return;
|
||||
await deliverPresenceWebhook(deps.presenceWebhook, {
|
||||
kind:
|
||||
kind === "join"
|
||||
? "presence:join"
|
||||
: kind === "leave"
|
||||
? "presence:leave"
|
||||
: "presence:update",
|
||||
clientId,
|
||||
roomId,
|
||||
status,
|
||||
at: deps.clock(),
|
||||
});
|
||||
}
|
||||
@@ -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,46 @@
|
||||
import { deliver } from "@suaveplan/webhook-client";
|
||||
import type { PresenceWebhookConfig } from "../types/types.js";
|
||||
|
||||
/** Event type pushed to the optional presence webhook. */
|
||||
export interface PresenceWebhookEvent {
|
||||
readonly kind: "presence:join" | "presence:leave" | "presence:update";
|
||||
readonly clientId: string;
|
||||
readonly roomId: string | undefined;
|
||||
readonly status: string | undefined;
|
||||
readonly at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort, signed delivery of a single presence event. Retries and
|
||||
* circuit-breaking are owned by `@suaveplan/webhook-client.deliver`,
|
||||
* which threads `@suaveplan/retry` internally. Delivery failures are
|
||||
* swallowed (logged via the optional logger callback) — presence is
|
||||
* inherently lossy and must never crash the daemon.
|
||||
*/
|
||||
export async function deliverPresenceWebhook(
|
||||
config: PresenceWebhookConfig,
|
||||
event: PresenceWebhookEvent,
|
||||
onError?: (err: unknown) => void
|
||||
): Promise<void> {
|
||||
if (config.events && !config.events.includes(event.kind)) return;
|
||||
try {
|
||||
const cfg: { url: string; algorithm: "sha256"; secret?: string } = {
|
||||
url: config.url,
|
||||
algorithm: "sha256",
|
||||
};
|
||||
if (config.secret !== undefined) cfg.secret = config.secret;
|
||||
await deliver(cfg, {
|
||||
event: event.kind,
|
||||
data: {
|
||||
clientId: event.clientId,
|
||||
roomId: event.roomId,
|
||||
status: event.status,
|
||||
at: event.at,
|
||||
},
|
||||
timestamp: new Date(event.at).toISOString(),
|
||||
id: `presence-${event.clientId}-${event.at}`,
|
||||
});
|
||||
} catch (err) {
|
||||
onError?.(err);
|
||||
}
|
||||
}
|
||||
@@ -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,146 @@
|
||||
import type { IdempotencyGuard } from "@suaveplan/idempotency";
|
||||
import { WS_GATEWAY_CODES, WsGatewayError } from "../errors/errors.js";
|
||||
import { adjustRoomsActive } from "../otel/otel.js";
|
||||
import { Room } from "../room/room.js";
|
||||
import type {
|
||||
AuthContext,
|
||||
ClientInfo,
|
||||
GatewayAction,
|
||||
Message,
|
||||
RoomConfig,
|
||||
} from "../types/types.js";
|
||||
import { createMessage } from "./message-factory.js";
|
||||
|
||||
export interface RoomOpsDeps {
|
||||
readonly rooms: Map<string, Room>;
|
||||
readonly clients: Map<string, ClientInfo>;
|
||||
readonly maxRooms: number;
|
||||
readonly maxClientsPerRoom: number;
|
||||
readonly idempotency: IdempotencyGuard;
|
||||
readonly clock: () => number;
|
||||
readonly runGate: (
|
||||
ctx: AuthContext,
|
||||
action: GatewayAction
|
||||
) => Promise<void>;
|
||||
readonly requireAuthCtx: (clientId: string) => AuthContext;
|
||||
readonly firePresence: (
|
||||
kind: "join" | "leave" | "update",
|
||||
clientId: string,
|
||||
roomId: string | undefined,
|
||||
status: string
|
||||
) => Promise<void>;
|
||||
readonly emitMessage: (message: Message) => void;
|
||||
}
|
||||
|
||||
/** Create or fetch a room; enforces max-rooms cap. */
|
||||
export function createRoom(
|
||||
deps: Pick<RoomOpsDeps, "rooms" | "maxRooms" | "maxClientsPerRoom">,
|
||||
config: Partial<RoomConfig> & { readonly id: string }
|
||||
): Room {
|
||||
const existing = deps.rooms.get(config.id);
|
||||
if (existing) return existing;
|
||||
if (deps.rooms.size >= deps.maxRooms) {
|
||||
throw new WsGatewayError(
|
||||
WS_GATEWAY_CODES.ROOM_FULL,
|
||||
"ws_gateway.rooms.cap-exceeded",
|
||||
{ context: { attempted: config.id, max: deps.maxRooms } }
|
||||
);
|
||||
}
|
||||
const room = new Room({
|
||||
id: config.id,
|
||||
name: config.name ?? config.id,
|
||||
maxClients: config.maxClients ?? deps.maxClientsPerRoom,
|
||||
metadata: config.metadata ?? {},
|
||||
});
|
||||
deps.rooms.set(config.id, room);
|
||||
adjustRoomsActive(1);
|
||||
return room;
|
||||
}
|
||||
|
||||
export function deleteRoom(rooms: Map<string, Room>, roomId: string): boolean {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
room.clear();
|
||||
rooms.delete(roomId);
|
||||
adjustRoomsActive(-1);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function joinRoom(
|
||||
deps: RoomOpsDeps,
|
||||
roomId: string,
|
||||
clientId: string,
|
||||
idempotencyKey?: string
|
||||
): Promise<boolean> {
|
||||
const room = deps.rooms.get(roomId);
|
||||
if (!room) {
|
||||
throw new WsGatewayError(
|
||||
WS_GATEWAY_CODES.ROOM_NOT_FOUND,
|
||||
"ws_gateway.room.not-found",
|
||||
{ context: { roomId } }
|
||||
);
|
||||
}
|
||||
const client = deps.clients.get(clientId);
|
||||
if (!client) return false;
|
||||
await deps.runGate(deps.requireAuthCtx(clientId), "room.join");
|
||||
const key = idempotencyKey ?? `join:${roomId}:${clientId}`;
|
||||
const joined = await deps.idempotency.execute(key, async () =>
|
||||
room.join(client)
|
||||
);
|
||||
if (joined) await deps.firePresence("update", clientId, roomId, "online");
|
||||
return joined;
|
||||
}
|
||||
|
||||
export async function leaveRoom(
|
||||
deps: Pick<RoomOpsDeps, "rooms" | "firePresence">,
|
||||
roomId: string,
|
||||
clientId: string
|
||||
): Promise<boolean> {
|
||||
const room = deps.rooms.get(roomId);
|
||||
if (!room) return false;
|
||||
const left = room.leave(clientId);
|
||||
if (left) await deps.firePresence("update", clientId, roomId, "online");
|
||||
return left;
|
||||
}
|
||||
|
||||
export async function broadcastToRoom(
|
||||
deps: RoomOpsDeps,
|
||||
roomId: string,
|
||||
senderId: string,
|
||||
payload: unknown,
|
||||
idempotencyKey?: string
|
||||
): Promise<{
|
||||
readonly message: Message;
|
||||
readonly recipients: string[];
|
||||
} | null> {
|
||||
const room = deps.rooms.get(roomId);
|
||||
if (!room) return null;
|
||||
await deps.runGate(deps.requireAuthCtx(senderId), "room.broadcast");
|
||||
const now = deps.clock();
|
||||
const key = idempotencyKey ?? `broadcast:${roomId}:${senderId}:${now}`;
|
||||
const message = await deps.idempotency.execute(key, async () =>
|
||||
createMessage("room", senderId, roomId, payload, now)
|
||||
);
|
||||
const recipients = room.broadcast(message);
|
||||
deps.emitMessage(message);
|
||||
return { message, recipients };
|
||||
}
|
||||
|
||||
export async function unicast(
|
||||
deps: Omit<RoomOpsDeps, "rooms" | "maxRooms" | "maxClientsPerRoom">,
|
||||
senderId: string,
|
||||
targetId: string,
|
||||
payload: unknown
|
||||
): Promise<Message | null> {
|
||||
if (!deps.clients.has(targetId)) return null;
|
||||
await deps.runGate(deps.requireAuthCtx(senderId), "unicast");
|
||||
const message = createMessage(
|
||||
"unicast",
|
||||
senderId,
|
||||
targetId,
|
||||
payload,
|
||||
deps.clock()
|
||||
);
|
||||
deps.emitMessage(message);
|
||||
return message;
|
||||
}
|
||||
@@ -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,84 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
buildHealthResponse,
|
||||
classifyHealthRequest,
|
||||
HEALTH_PATHS,
|
||||
type HealthSnapshot,
|
||||
handleHealth,
|
||||
} from "./health.js";
|
||||
|
||||
const running: HealthSnapshot = {
|
||||
running: true,
|
||||
disposed: false,
|
||||
clientCount: 0,
|
||||
roomCount: 0,
|
||||
startedAt: 1,
|
||||
};
|
||||
const starting: HealthSnapshot = { ...running, running: false };
|
||||
const stopped: HealthSnapshot = { ...running, running: false, disposed: true };
|
||||
|
||||
describe("classifyHealthRequest", () => {
|
||||
it("classifies known verbs", { timeout: 5000 }, () => {
|
||||
expect(classifyHealthRequest("/health")).toBe("health");
|
||||
expect(classifyHealthRequest("/ready")).toBe("ready");
|
||||
expect(classifyHealthRequest("/live")).toBe("live");
|
||||
});
|
||||
it("strips querystring and trailing slash", { timeout: 5000 }, () => {
|
||||
expect(classifyHealthRequest("/health?probe=1")).toBe("health");
|
||||
expect(classifyHealthRequest("/ready/")).toBe("ready");
|
||||
});
|
||||
it("returns null for unknown / missing url", { timeout: 5000 }, () => {
|
||||
expect(classifyHealthRequest(undefined)).toBeNull();
|
||||
expect(classifyHealthRequest("")).toBeNull();
|
||||
expect(classifyHealthRequest("/other")).toBeNull();
|
||||
});
|
||||
it("exposes the HEALTH_PATHS map", { timeout: 5000 }, () => {
|
||||
expect(HEALTH_PATHS.health).toBe("/health");
|
||||
expect(HEALTH_PATHS.ready).toBe("/ready");
|
||||
expect(HEALTH_PATHS.live).toBe("/live");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildHealthResponse", () => {
|
||||
it("live returns 200 ok while running", { timeout: 5000 }, () => {
|
||||
const r = buildHealthResponse("live", running);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.status).toBe("ok");
|
||||
});
|
||||
it("live returns 503 stopped after dispose", { timeout: 5000 }, () => {
|
||||
const r = buildHealthResponse("live", stopped);
|
||||
expect(r.status).toBe(503);
|
||||
expect(r.body.status).toBe("stopped");
|
||||
});
|
||||
it("ready returns 503 starting before start()", { timeout: 5000 }, () => {
|
||||
const r = buildHealthResponse("ready", starting);
|
||||
expect(r.status).toBe(503);
|
||||
expect(r.body.status).toBe("starting");
|
||||
});
|
||||
it("ready returns 200 ok once running", { timeout: 5000 }, () => {
|
||||
const r = buildHealthResponse("ready", running);
|
||||
expect(r.status).toBe(200);
|
||||
});
|
||||
it("health returns 503 stopped after dispose", { timeout: 5000 }, () => {
|
||||
const r = buildHealthResponse("health", stopped);
|
||||
expect(r.status).toBe(503);
|
||||
expect(r.body.status).toBe("stopped");
|
||||
});
|
||||
it("health returns 200 ok while running", { timeout: 5000 }, () => {
|
||||
const r = buildHealthResponse("health", running);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.status).toBe("ok");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleHealth", () => {
|
||||
it("returns serialized body for known verbs", { timeout: 5000 }, () => {
|
||||
const r = handleHealth("/health", running);
|
||||
expect(r).not.toBeNull();
|
||||
const parsed = JSON.parse(r!.body);
|
||||
expect(parsed.status).toBe("ok");
|
||||
});
|
||||
it("returns null on unknown route", { timeout: 5000 }, () => {
|
||||
expect(handleHealth("/other", running)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Snapshot of the gateway's runtime state used to decide each health
|
||||
* verb's response. Captured at request-time so each response reflects
|
||||
* current truth.
|
||||
*/
|
||||
export interface HealthSnapshot {
|
||||
readonly running: boolean;
|
||||
readonly disposed: boolean;
|
||||
readonly clientCount: number;
|
||||
readonly roomCount: number;
|
||||
readonly startedAt: number | undefined;
|
||||
}
|
||||
|
||||
/** Health-verb route table. */
|
||||
export const HEALTH_PATHS = {
|
||||
health: "/health",
|
||||
ready: "/ready",
|
||||
live: "/live",
|
||||
} as const;
|
||||
|
||||
export type HealthPath = (typeof HEALTH_PATHS)[keyof typeof HEALTH_PATHS];
|
||||
export type HealthKind = "health" | "ready" | "live" | null;
|
||||
|
||||
/** Body returned by each health verb. */
|
||||
export interface HealthResponse {
|
||||
readonly status: "ok" | "degraded" | "starting" | "stopped";
|
||||
readonly running: boolean;
|
||||
readonly disposed: boolean;
|
||||
readonly clientCount: number;
|
||||
readonly roomCount: number;
|
||||
readonly startedAt: number | undefined;
|
||||
readonly verb: "health" | "ready" | "live";
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify an inbound HTTP request URL by path. Returns the matching
|
||||
* verb or `null` if the URL is not a known health route. Querystrings
|
||||
* and trailing slashes are ignored.
|
||||
*/
|
||||
export function classifyHealthRequest(url: string | undefined): HealthKind {
|
||||
if (!url) return null;
|
||||
const qIdx = url.indexOf("?");
|
||||
const noQuery = qIdx === -1 ? url : url.slice(0, qIdx);
|
||||
const path = noQuery.endsWith("/") ? noQuery.slice(0, -1) : noQuery;
|
||||
switch (path) {
|
||||
case HEALTH_PATHS.health:
|
||||
return "health";
|
||||
case HEALTH_PATHS.ready:
|
||||
return "ready";
|
||||
case HEALTH_PATHS.live:
|
||||
return "live";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the body + HTTP status for one health verb.
|
||||
*
|
||||
* - `live` returns 200 unless disposed.
|
||||
* - `ready` returns 200 only when the daemon has finished start().
|
||||
* - `health` returns 200 when running, 503 if not running / disposed.
|
||||
*/
|
||||
export function buildHealthResponse(
|
||||
verb: "health" | "ready" | "live",
|
||||
snap: HealthSnapshot
|
||||
): { readonly status: number; readonly body: HealthResponse } {
|
||||
const base = {
|
||||
running: snap.running,
|
||||
disposed: snap.disposed,
|
||||
clientCount: snap.clientCount,
|
||||
roomCount: snap.roomCount,
|
||||
startedAt: snap.startedAt,
|
||||
verb,
|
||||
} as const;
|
||||
if (verb === "live") {
|
||||
if (snap.disposed) {
|
||||
return { status: 503, body: { ...base, status: "stopped" } };
|
||||
}
|
||||
return { status: 200, body: { ...base, status: "ok" } };
|
||||
}
|
||||
if (!snap.running || snap.disposed) {
|
||||
return {
|
||||
status: 503,
|
||||
body: {
|
||||
...base,
|
||||
status: snap.disposed ? "stopped" : "starting",
|
||||
},
|
||||
};
|
||||
}
|
||||
return { status: 200, body: { ...base, status: "ok" } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch one HTTP request URL to the health verb pipeline. Returns
|
||||
* `[status, body]` if the URL matched a verb, or `null` otherwise so
|
||||
* the caller can forward to the next handler.
|
||||
*/
|
||||
export function handleHealth(
|
||||
url: string | undefined,
|
||||
snap: HealthSnapshot
|
||||
): { readonly status: number; readonly body: string } | null {
|
||||
const verb = classifyHealthRequest(url);
|
||||
if (verb === null) return null;
|
||||
const { status, body } = buildHealthResponse(verb, snap);
|
||||
return { status, body: JSON.stringify(body) };
|
||||
}
|
||||
144
packages/networking/network/websocket-gateway/src/index.ts
Normal file
144
packages/networking/network/websocket-gateway/src/index.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
// Server-tier package — depends on `ws` (Node-only). Browser consumers cannot use this package.
|
||||
|
||||
// Channel
|
||||
export { Channel } from "./channel/channel.js";
|
||||
// Errors
|
||||
export {
|
||||
WS_GATEWAY_CODES,
|
||||
WsGatewayError,
|
||||
type WsGatewayErrorCode,
|
||||
} from "./errors/errors.js";
|
||||
// Gateway
|
||||
export { audit, gate } from "./gateway/access-gate.js";
|
||||
export { ChannelManager } from "./gateway/channel-manager.js";
|
||||
export {
|
||||
type ChannelOpsDeps,
|
||||
publishToChannel,
|
||||
subscribeToChannel,
|
||||
unsubscribeFromChannel,
|
||||
} from "./gateway/channel-ops.js";
|
||||
export {
|
||||
type ClientLifecycleDeps,
|
||||
connectClient,
|
||||
disconnectClient,
|
||||
} from "./gateway/client-lifecycle.js";
|
||||
export {
|
||||
defaultAuditLogger,
|
||||
defaultAuthorizationCheck,
|
||||
defaultAuthVerifier,
|
||||
defaultRateLimitGate,
|
||||
resolveConfig,
|
||||
} from "./gateway/defaults.js";
|
||||
export { WebSocketGateway } from "./gateway/gateway.js";
|
||||
export {
|
||||
buildEmit,
|
||||
buildFirePresence,
|
||||
buildGatewayDepFactories,
|
||||
buildRequireAuthCtx,
|
||||
buildRunGate,
|
||||
type GatewayDepFactories,
|
||||
type GatewayInternals,
|
||||
} from "./gateway/gateway-deps.js";
|
||||
export {
|
||||
createHeartbeat,
|
||||
type HeartbeatHandle,
|
||||
} from "./gateway/heartbeat.js";
|
||||
export {
|
||||
bootWsServer,
|
||||
type GatewayState,
|
||||
preflightStart,
|
||||
recordStartCompleted,
|
||||
runAuthHandshake,
|
||||
runStart,
|
||||
runStop,
|
||||
} from "./gateway/lifecycle.js";
|
||||
export {
|
||||
_resetMessageCounter,
|
||||
createMessage,
|
||||
} from "./gateway/message-factory.js";
|
||||
export { firePresence } from "./gateway/presence-fire.js";
|
||||
export {
|
||||
deliverPresenceWebhook,
|
||||
type PresenceWebhookEvent,
|
||||
} from "./gateway/presence-webhook.js";
|
||||
export {
|
||||
broadcastToRoom,
|
||||
createRoom,
|
||||
deleteRoom,
|
||||
joinRoom,
|
||||
leaveRoom,
|
||||
type RoomOpsDeps,
|
||||
unicast,
|
||||
} from "./gateway/room-ops.js";
|
||||
// Health
|
||||
export {
|
||||
buildHealthResponse,
|
||||
classifyHealthRequest,
|
||||
HEALTH_PATHS,
|
||||
type HealthKind,
|
||||
type HealthPath,
|
||||
type HealthResponse,
|
||||
type HealthSnapshot,
|
||||
handleHealth,
|
||||
} from "./health/health.js";
|
||||
// OTel
|
||||
export {
|
||||
type ActionOutcome,
|
||||
adjustRoomsActive,
|
||||
type PresenceUpdateKind,
|
||||
recordAction,
|
||||
recordDispatchDuration,
|
||||
recordPresenceUpdate,
|
||||
recordStartDuration,
|
||||
withBroadcastSpan,
|
||||
withConnectSpan,
|
||||
withMessageSpan,
|
||||
withStartSpan,
|
||||
withStopSpan,
|
||||
} from "./otel/otel.js";
|
||||
// Presence
|
||||
export { PresenceTracker } from "./presence/presence.js";
|
||||
// Room
|
||||
export { Room } from "./room/room.js";
|
||||
// Types
|
||||
export type {
|
||||
AuditEvent,
|
||||
AuditLogger,
|
||||
AuthContext,
|
||||
AuthorizationCheck,
|
||||
AuthVerifier,
|
||||
ChannelConfig,
|
||||
ClientInfo,
|
||||
GatewayAction,
|
||||
GatewayClientConnectedEvent,
|
||||
GatewayClientDisconnectedEvent,
|
||||
GatewayEventMap,
|
||||
GatewayMessageEvent,
|
||||
GatewayPresenceEvent,
|
||||
GatewayStartedEvent,
|
||||
GatewayStartResult,
|
||||
GatewayStoppedEvent,
|
||||
Handshake,
|
||||
Message,
|
||||
MessageType,
|
||||
PresenceInfo,
|
||||
PresenceStatus,
|
||||
PresenceWebhookConfig,
|
||||
RateLimitGate,
|
||||
ResolvedGatewayConfig,
|
||||
RoomConfig,
|
||||
WebSocketGatewayConfig,
|
||||
WsServerCallbacks,
|
||||
WsServerFactory,
|
||||
WsServerLike,
|
||||
WsServerStartOptions,
|
||||
WsServerStartResult,
|
||||
WsSocketLike,
|
||||
} from "./types/types.js";
|
||||
// Schemas
|
||||
export {
|
||||
type InboundFrame,
|
||||
InboundFrameSchema,
|
||||
PresenceWebhookConfigSchema,
|
||||
WebSocketGatewayConfigSchema,
|
||||
} from "./types/types.schemas.js";
|
||||
@@ -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,58 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
adjustRoomsActive,
|
||||
recordAction,
|
||||
recordDispatchDuration,
|
||||
recordPresenceUpdate,
|
||||
recordStartDuration,
|
||||
withBroadcastSpan,
|
||||
withConnectSpan,
|
||||
withMessageSpan,
|
||||
withStartSpan,
|
||||
withStopSpan,
|
||||
} from "./otel.js";
|
||||
|
||||
describe("otel instruments", () => {
|
||||
it("withStartSpan resolves the wrapped fn", { timeout: 5000 }, async () => {
|
||||
const out = await withStartSpan(async () => 42);
|
||||
expect(out).toBe(42);
|
||||
});
|
||||
|
||||
it("withStartSpan ends the span even on throw", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
withStartSpan(async () => {
|
||||
throw new Error("boom");
|
||||
})
|
||||
).rejects.toThrow("boom");
|
||||
});
|
||||
|
||||
it("withStopSpan / withConnectSpan / withMessageSpan / withBroadcastSpan run their bodies", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
expect(await withStopSpan(async () => "stop")).toBe("stop");
|
||||
expect(await withConnectSpan(async () => "c")).toBe("c");
|
||||
expect(await withMessageSpan(async () => "m")).toBe("m");
|
||||
expect(await withBroadcastSpan(async () => "b")).toBe("b");
|
||||
});
|
||||
|
||||
it("counter / gauge / histogram helpers are no-throw", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
adjustRoomsActive(0); // early-return branch
|
||||
adjustRoomsActive(1);
|
||||
adjustRoomsActive(-1);
|
||||
recordPresenceUpdate("join");
|
||||
recordPresenceUpdate("leave");
|
||||
recordPresenceUpdate("update");
|
||||
recordAction("room.broadcast", "allowed");
|
||||
recordAction("room.broadcast", "denied");
|
||||
recordAction("room.broadcast", "error");
|
||||
recordDispatchDuration("room.broadcast", 12);
|
||||
recordDispatchDuration("room.broadcast", -5); // clamp branch
|
||||
recordStartDuration(42);
|
||||
recordStartDuration(-1); // clamp branch
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
143
packages/networking/network/websocket-gateway/src/otel/otel.ts
Normal file
143
packages/networking/network/websocket-gateway/src/otel/otel.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { metrics, type Span, type Tracer, trace } from "@opentelemetry/api";
|
||||
|
||||
const TRACER_NAME = "@suaveplan/websocket-gateway";
|
||||
const METER_NAME = "@suaveplan/websocket-gateway";
|
||||
|
||||
const tracer: Tracer = trace.getTracer(TRACER_NAME);
|
||||
const meter = metrics.getMeter(METER_NAME);
|
||||
|
||||
/**
|
||||
* Up-down counter tracking the live number of rooms registered on
|
||||
* the gateway.
|
||||
*/
|
||||
const roomsActiveGauge = meter.createUpDownCounter("ws_gateway.rooms_active", {
|
||||
description: "Currently registered rooms on the gateway.",
|
||||
});
|
||||
|
||||
/** Counter of presence-state updates, labelled by kind. */
|
||||
const presenceUpdatesCounter = meter.createCounter(
|
||||
"ws_gateway.presence_updates_total",
|
||||
{
|
||||
description: "Presence-state updates, by kind (join/leave/update).",
|
||||
}
|
||||
);
|
||||
|
||||
/** Counter of attempted gateway actions, labelled by action + outcome. */
|
||||
const actionsCounter = meter.createCounter("ws_gateway.actions_total", {
|
||||
description: "Gateway action attempts by action + outcome.",
|
||||
});
|
||||
|
||||
/** Histogram of message-dispatch wall-clock duration in milliseconds. */
|
||||
const dispatchHistogram = meter.createHistogram(
|
||||
"ws_gateway.message_dispatch_ms",
|
||||
{
|
||||
description: "Wall-clock duration of message dispatch in ms.",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
|
||||
/** Histogram of start() wall-clock duration in ms. */
|
||||
const startDurationHistogram = meter.createHistogram(
|
||||
"ws_gateway.start_duration_ms",
|
||||
{
|
||||
description: "Wall-clock duration of WebSocketGateway.start() in ms.",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
|
||||
/** Wrap a `connect` operation in an OTel span. */
|
||||
export async function withConnectSpan<T>(
|
||||
fn: (span: Span) => Promise<T>
|
||||
): Promise<T> {
|
||||
return tracer.startActiveSpan("ws_gateway.connect", async (span) => {
|
||||
try {
|
||||
return await fn(span);
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Wrap a `message` dispatch in an OTel span. */
|
||||
export async function withMessageSpan<T>(
|
||||
fn: (span: Span) => Promise<T>
|
||||
): Promise<T> {
|
||||
return tracer.startActiveSpan("ws_gateway.message", async (span) => {
|
||||
try {
|
||||
return await fn(span);
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Wrap a `broadcast` dispatch in an OTel span. */
|
||||
export async function withBroadcastSpan<T>(
|
||||
fn: (span: Span) => Promise<T>
|
||||
): Promise<T> {
|
||||
return tracer.startActiveSpan("ws_gateway.broadcast", async (span) => {
|
||||
try {
|
||||
return await fn(span);
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Wrap a `start` operation in an OTel span. */
|
||||
export async function withStartSpan<T>(
|
||||
fn: (span: Span) => Promise<T>
|
||||
): Promise<T> {
|
||||
return tracer.startActiveSpan("ws_gateway.start", async (span) => {
|
||||
try {
|
||||
return await fn(span);
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Wrap a `stop` operation in an OTel span. */
|
||||
export async function withStopSpan<T>(
|
||||
fn: (span: Span) => Promise<T>
|
||||
): Promise<T> {
|
||||
return tracer.startActiveSpan("ws_gateway.stop", async (span) => {
|
||||
try {
|
||||
return await fn(span);
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Adjust the rooms-active gauge by `delta` (+1 / -1). */
|
||||
export function adjustRoomsActive(delta: number): void {
|
||||
if (delta === 0) return;
|
||||
roomsActiveGauge.add(delta);
|
||||
}
|
||||
|
||||
/** Possible labels for {@link recordPresenceUpdate}. */
|
||||
export type PresenceUpdateKind = "join" | "leave" | "update";
|
||||
|
||||
/** Increment the presence-updates counter. */
|
||||
export function recordPresenceUpdate(kind: PresenceUpdateKind): void {
|
||||
presenceUpdatesCounter.add(1, { kind });
|
||||
}
|
||||
|
||||
/** Outcome label for {@link recordAction}. */
|
||||
export type ActionOutcome = "allowed" | "denied" | "error";
|
||||
|
||||
/** Increment the actions counter. */
|
||||
export function recordAction(action: string, outcome: ActionOutcome): void {
|
||||
actionsCounter.add(1, { action, outcome });
|
||||
}
|
||||
|
||||
/** Record one observation of message-dispatch duration. */
|
||||
export function recordDispatchDuration(action: string, ms: number): void {
|
||||
dispatchHistogram.record(Math.max(0, ms), { action });
|
||||
}
|
||||
|
||||
/** Record one observation of `start()` wall-clock duration. */
|
||||
export function recordStartDuration(ms: number): void {
|
||||
startDurationHistogram.record(Math.max(0, ms));
|
||||
}
|
||||
@@ -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,108 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PresenceTracker } from "./presence.js";
|
||||
|
||||
describe("PresenceTracker", () => {
|
||||
it("track / untrack / isTracked / size", { timeout: 5000 }, () => {
|
||||
const t = new PresenceTracker(1_000);
|
||||
expect(t.size).toBe(0);
|
||||
const info = t.track("a", { typing: false });
|
||||
expect(info.status).toBe("online");
|
||||
expect(info.customState).toEqual({ typing: false });
|
||||
expect(t.isTracked("a")).toBe(true);
|
||||
expect(t.size).toBe(1);
|
||||
expect(t.untrack("a")).toBe(true);
|
||||
expect(t.untrack("a")).toBe(false);
|
||||
});
|
||||
|
||||
it("track without initial state defaults to empty record", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new PresenceTracker(1_000);
|
||||
const info = t.track("a");
|
||||
expect(info.customState).toEqual({});
|
||||
});
|
||||
|
||||
it("heartbeat updates lastSeen and revives offline", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let now = 0;
|
||||
const t = new PresenceTracker(100, () => now);
|
||||
t.track("a");
|
||||
now = 200;
|
||||
expect(t.detectTimedOut()).toEqual(["a"]);
|
||||
expect(t.getPresence("a")?.status).toBe("offline");
|
||||
now = 300;
|
||||
const hb = t.heartbeat("a");
|
||||
expect(hb?.status).toBe("online");
|
||||
expect(hb?.lastSeen).toBe(300);
|
||||
});
|
||||
|
||||
it("heartbeat on unknown client returns null", { timeout: 5000 }, () => {
|
||||
const t = new PresenceTracker(1_000);
|
||||
expect(t.heartbeat("missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("updateStatus / updateState on tracked client", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let now = 0;
|
||||
const t = new PresenceTracker(1_000, () => now);
|
||||
t.track("a", { typing: false });
|
||||
now = 5;
|
||||
const s = t.updateStatus("a", "busy");
|
||||
expect(s?.status).toBe("busy");
|
||||
expect(s?.lastSeen).toBe(5);
|
||||
now = 7;
|
||||
const u = t.updateState("a", { typing: true, room: "r1" });
|
||||
expect(u?.customState).toEqual({ typing: true, room: "r1" });
|
||||
});
|
||||
|
||||
it("updateStatus / updateState on unknown client return null", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new PresenceTracker(1_000);
|
||||
expect(t.updateStatus("missing", "away")).toBeNull();
|
||||
expect(t.updateState("missing", { x: 1 })).toBeNull();
|
||||
});
|
||||
|
||||
it("getPresence returns null for unknown", { timeout: 5000 }, () => {
|
||||
const t = new PresenceTracker(1_000);
|
||||
expect(t.getPresence("missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("listActive omits offline; listAll includes everything", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let now = 0;
|
||||
const t = new PresenceTracker(100, () => now);
|
||||
t.track("a");
|
||||
t.track("b");
|
||||
t.updateStatus("a", "busy");
|
||||
now = 1000;
|
||||
t.detectTimedOut(); // marks both as offline
|
||||
const stillBusyOrOffline = t.listAll();
|
||||
expect(stillBusyOrOffline).toHaveLength(2);
|
||||
expect(t.listActive()).toHaveLength(0);
|
||||
t.heartbeat("a");
|
||||
expect(t.listActive()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("detectTimedOut is idempotent on already-offline", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let now = 0;
|
||||
const t = new PresenceTracker(50, () => now);
|
||||
t.track("a");
|
||||
now = 1000;
|
||||
expect(t.detectTimedOut()).toEqual(["a"]);
|
||||
expect(t.detectTimedOut()).toEqual([]);
|
||||
});
|
||||
|
||||
it("clear empties the tracker", { timeout: 5000 }, () => {
|
||||
const t = new PresenceTracker(1_000);
|
||||
t.track("a");
|
||||
t.track("b");
|
||||
t.clear();
|
||||
expect(t.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import type { PresenceInfo, PresenceStatus } from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Tracks online/offline presence state for connected clients.
|
||||
*
|
||||
* Each client has a status (online, away, busy, offline), custom state
|
||||
* data, and a last-seen timestamp. Supports heartbeat updates and can
|
||||
* detect clients that have exceeded the heartbeat timeout. The clock
|
||||
* is injectable so tests can drive deterministic timeouts.
|
||||
*/
|
||||
export class PresenceTracker {
|
||||
readonly #presenceMap: Map<string, PresenceInfo> = new Map();
|
||||
readonly #timeoutMs: number;
|
||||
readonly #clock: Clock;
|
||||
|
||||
public constructor(timeoutMs: number, clock?: Clock) {
|
||||
this.#timeoutMs = timeoutMs;
|
||||
this.#clock = clock ?? (() => Date.now());
|
||||
}
|
||||
|
||||
public get size(): number {
|
||||
return this.#presenceMap.size;
|
||||
}
|
||||
|
||||
public track(
|
||||
clientId: string,
|
||||
initialState?: Record<string, unknown>
|
||||
): PresenceInfo {
|
||||
const info: PresenceInfo = {
|
||||
clientId,
|
||||
status: "online",
|
||||
customState: initialState ? { ...initialState } : {},
|
||||
lastSeen: this.#clock(),
|
||||
};
|
||||
this.#presenceMap.set(clientId, info);
|
||||
return { ...info };
|
||||
}
|
||||
|
||||
public untrack(clientId: string): boolean {
|
||||
return this.#presenceMap.delete(clientId);
|
||||
}
|
||||
|
||||
public heartbeat(clientId: string): PresenceInfo | null {
|
||||
const info = this.#presenceMap.get(clientId);
|
||||
if (!info) return null;
|
||||
info.lastSeen = this.#clock();
|
||||
if (info.status === "offline") info.status = "online";
|
||||
return { ...info };
|
||||
}
|
||||
|
||||
public updateStatus(
|
||||
clientId: string,
|
||||
status: PresenceStatus
|
||||
): PresenceInfo | null {
|
||||
const info = this.#presenceMap.get(clientId);
|
||||
if (!info) return null;
|
||||
info.status = status;
|
||||
info.lastSeen = this.#clock();
|
||||
return { ...info };
|
||||
}
|
||||
|
||||
public updateState(
|
||||
clientId: string,
|
||||
state: Record<string, unknown>
|
||||
): PresenceInfo | null {
|
||||
const info = this.#presenceMap.get(clientId);
|
||||
if (!info) return null;
|
||||
Object.assign(info.customState, state);
|
||||
info.lastSeen = this.#clock();
|
||||
return { ...info };
|
||||
}
|
||||
|
||||
public getPresence(clientId: string): PresenceInfo | null {
|
||||
const info = this.#presenceMap.get(clientId);
|
||||
return info ? { ...info } : null;
|
||||
}
|
||||
|
||||
public listActive(): PresenceInfo[] {
|
||||
const result: PresenceInfo[] = [];
|
||||
for (const info of this.#presenceMap.values()) {
|
||||
if (info.status !== "offline") result.push({ ...info });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public listAll(): PresenceInfo[] {
|
||||
return Array.from(this.#presenceMap.values()).map((info) => ({
|
||||
...info,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find clients that have exceeded the heartbeat timeout. Returns
|
||||
* their ids and marks them as offline.
|
||||
*/
|
||||
public detectTimedOut(): string[] {
|
||||
const now = this.#clock();
|
||||
const timedOut: string[] = [];
|
||||
for (const [clientId, info] of this.#presenceMap) {
|
||||
if (
|
||||
info.status !== "offline" &&
|
||||
now - info.lastSeen > this.#timeoutMs
|
||||
) {
|
||||
info.status = "offline";
|
||||
timedOut.push(clientId);
|
||||
}
|
||||
}
|
||||
return timedOut;
|
||||
}
|
||||
|
||||
public isTracked(clientId: string): boolean {
|
||||
return this.#presenceMap.has(clientId);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.#presenceMap.clear();
|
||||
}
|
||||
}
|
||||
@@ -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,152 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { WS_GATEWAY_CODES, WsGatewayError } from "../errors/errors.js";
|
||||
import type { ClientInfo, Message } from "../types/types.js";
|
||||
import { Room } from "./room.js";
|
||||
|
||||
function makeClient(id: string): ClientInfo {
|
||||
return {
|
||||
id,
|
||||
connectedAt: 0,
|
||||
lastHeartbeat: 0,
|
||||
metadata: {},
|
||||
};
|
||||
}
|
||||
|
||||
function makeMessage(sender: string, id = "m1"): Message {
|
||||
return {
|
||||
id,
|
||||
type: "room",
|
||||
sender,
|
||||
target: "r1",
|
||||
payload: { x: 1 },
|
||||
timestamp: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Room", () => {
|
||||
it("join / leave / hasClient / size", { timeout: 5000 }, () => {
|
||||
const r = new Room({
|
||||
id: "r1",
|
||||
name: "R1",
|
||||
maxClients: 5,
|
||||
metadata: {},
|
||||
});
|
||||
expect(r.id).toBe("r1");
|
||||
expect(r.name).toBe("R1");
|
||||
expect(r.size).toBe(0);
|
||||
expect(r.maxClients).toBe(5);
|
||||
expect(r.join(makeClient("a"))).toBe(true);
|
||||
expect(r.size).toBe(1);
|
||||
// duplicate join → false
|
||||
expect(r.join(makeClient("a"))).toBe(false);
|
||||
expect(r.hasClient("a")).toBe(true);
|
||||
expect(r.leave("a")).toBe(true);
|
||||
expect(r.leave("a")).toBe(false);
|
||||
});
|
||||
|
||||
it("isFull throws ROOM_FULL on overflow", { timeout: 5000 }, () => {
|
||||
const r = new Room({
|
||||
id: "r1",
|
||||
name: "R1",
|
||||
maxClients: 1,
|
||||
metadata: {},
|
||||
});
|
||||
r.join(makeClient("a"));
|
||||
expect(r.isFull).toBe(true);
|
||||
try {
|
||||
r.join(makeClient("b"));
|
||||
throw new Error("expected ROOM_FULL");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(WsGatewayError);
|
||||
expect((err as WsGatewayError).code).toBe(
|
||||
WS_GATEWAY_CODES.ROOM_FULL
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("getMembers / getMemberInfos return current state", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new Room({
|
||||
id: "r1",
|
||||
name: "R1",
|
||||
maxClients: 5,
|
||||
metadata: {},
|
||||
});
|
||||
r.join(makeClient("a"));
|
||||
r.join(makeClient("b"));
|
||||
expect(r.getMembers().sort()).toEqual(["a", "b"]);
|
||||
expect(r.getMemberInfos()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("broadcast excludes sender by default", { timeout: 5000 }, () => {
|
||||
const r = new Room({
|
||||
id: "r1",
|
||||
name: "R1",
|
||||
maxClients: 5,
|
||||
metadata: {},
|
||||
});
|
||||
r.join(makeClient("a"));
|
||||
r.join(makeClient("b"));
|
||||
r.join(makeClient("c"));
|
||||
const recip = r.broadcast(makeMessage("a")).sort();
|
||||
expect(recip).toEqual(["b", "c"]);
|
||||
});
|
||||
|
||||
it("broadcast can include sender", { timeout: 5000 }, () => {
|
||||
const r = new Room({
|
||||
id: "r1",
|
||||
name: "R1",
|
||||
maxClients: 5,
|
||||
metadata: {},
|
||||
});
|
||||
r.join(makeClient("a"));
|
||||
r.join(makeClient("b"));
|
||||
const recip = r.broadcast(makeMessage("a"), false).sort();
|
||||
expect(recip).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("history is bounded at 100 entries", { timeout: 5000 }, () => {
|
||||
const r = new Room({
|
||||
id: "r1",
|
||||
name: "R1",
|
||||
maxClients: 5,
|
||||
metadata: {},
|
||||
});
|
||||
r.join(makeClient("a"));
|
||||
for (let i = 0; i < 105; i++) r.broadcast(makeMessage("a", `m${i}`));
|
||||
const hist = r.getHistory();
|
||||
expect(hist).toHaveLength(100);
|
||||
expect(hist[0]?.id).toBe("m5");
|
||||
// getHistory(limit) returns the tail
|
||||
expect(r.getHistory(3)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("updateMetadata + metadata returns a defensive copy", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new Room({
|
||||
id: "r1",
|
||||
name: "R1",
|
||||
maxClients: 5,
|
||||
metadata: { a: 1 },
|
||||
});
|
||||
r.updateMetadata({ b: 2 });
|
||||
expect(r.metadata).toEqual({ a: 1, b: 2 });
|
||||
// returned record is a copy
|
||||
(r.metadata as Record<string, unknown>).c = 3;
|
||||
expect(r.metadata).toEqual({ a: 1, b: 2 });
|
||||
});
|
||||
|
||||
it("clear empties the room", { timeout: 5000 }, () => {
|
||||
const r = new Room({
|
||||
id: "r1",
|
||||
name: "R1",
|
||||
maxClients: 5,
|
||||
metadata: {},
|
||||
});
|
||||
r.join(makeClient("a"));
|
||||
r.clear();
|
||||
expect(r.size).toBe(0);
|
||||
});
|
||||
});
|
||||
122
packages/networking/network/websocket-gateway/src/room/room.ts
Normal file
122
packages/networking/network/websocket-gateway/src/room/room.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { WS_GATEWAY_CODES, WsGatewayError } from "../errors/errors.js";
|
||||
import type { ClientInfo, Message, RoomConfig } from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Manages a single room with members, broadcasting, and metadata.
|
||||
*
|
||||
* Rooms are containers grouping connected clients. Clients can join and
|
||||
* leave; messages can be broadcast to all members or sent to specific
|
||||
* members. Capacity is enforced by `maxClients` — overflow throws a
|
||||
* `ROOM_FULL` error so the daemon can surface a typed denial.
|
||||
*/
|
||||
export class Room {
|
||||
readonly #clients: Map<string, ClientInfo> = new Map();
|
||||
readonly #config: RoomConfig;
|
||||
readonly #messageHistory: Message[] = [];
|
||||
readonly #maxHistorySize = 100;
|
||||
|
||||
public constructor(config: RoomConfig) {
|
||||
this.#config = { ...config };
|
||||
}
|
||||
|
||||
public get id(): string {
|
||||
return this.#config.id;
|
||||
}
|
||||
|
||||
public get name(): string {
|
||||
return this.#config.name;
|
||||
}
|
||||
|
||||
public get size(): number {
|
||||
return this.#clients.size;
|
||||
}
|
||||
|
||||
public get isFull(): boolean {
|
||||
return this.#clients.size >= this.#config.maxClients;
|
||||
}
|
||||
|
||||
public get maxClients(): number {
|
||||
return this.#config.maxClients;
|
||||
}
|
||||
|
||||
public get metadata(): Readonly<Record<string, unknown>> {
|
||||
return { ...this.#config.metadata };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a client to the room. Throws `WS_GATEWAY.ROOM_FULL` when the
|
||||
* room is at capacity; returns `false` when the client is already
|
||||
* a member.
|
||||
*/
|
||||
public join(client: ClientInfo): boolean {
|
||||
if (this.#clients.has(client.id)) return false;
|
||||
if (this.isFull) {
|
||||
throw new WsGatewayError(
|
||||
WS_GATEWAY_CODES.ROOM_FULL,
|
||||
"ws_gateway.room.full",
|
||||
{
|
||||
context: {
|
||||
roomId: this.#config.id,
|
||||
maxClients: this.#config.maxClients,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
this.#clients.set(client.id, client);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Remove a client from the room. Returns true if the client was present. */
|
||||
public leave(clientId: string): boolean {
|
||||
return this.#clients.delete(clientId);
|
||||
}
|
||||
|
||||
public hasClient(clientId: string): boolean {
|
||||
return this.#clients.has(clientId);
|
||||
}
|
||||
|
||||
public getMembers(): string[] {
|
||||
return Array.from(this.#clients.keys());
|
||||
}
|
||||
|
||||
public getMemberInfos(): ClientInfo[] {
|
||||
return Array.from(this.#clients.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a broadcast message and return the recipient client IDs
|
||||
* (excluding the sender unless `excludeSender === false`).
|
||||
*/
|
||||
public broadcast(message: Message, excludeSender = true): string[] {
|
||||
this.#addToHistory(message);
|
||||
const recipients: string[] = [];
|
||||
for (const clientId of this.#clients.keys()) {
|
||||
if (excludeSender && clientId === message.sender) continue;
|
||||
recipients.push(clientId);
|
||||
}
|
||||
return recipients;
|
||||
}
|
||||
|
||||
/** Recent message history, capped at `maxHistorySize`. */
|
||||
public getHistory(limit?: number): Message[] {
|
||||
const count = limit ?? this.#messageHistory.length;
|
||||
return this.#messageHistory.slice(-count);
|
||||
}
|
||||
|
||||
/** Merge updates into room metadata. */
|
||||
public updateMetadata(updates: Record<string, unknown>): void {
|
||||
Object.assign(this.#config.metadata, updates);
|
||||
}
|
||||
|
||||
/** Clear all clients from the room. */
|
||||
public clear(): void {
|
||||
this.#clients.clear();
|
||||
}
|
||||
|
||||
#addToHistory(message: Message): void {
|
||||
this.#messageHistory.push(message);
|
||||
if (this.#messageHistory.length > this.#maxHistorySize) {
|
||||
this.#messageHistory.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,71 @@
|
||||
import z from "zod";
|
||||
|
||||
/** Zod boundary schemas for every public option struct. */
|
||||
|
||||
export const PresenceWebhookConfigSchema = z
|
||||
.object({
|
||||
url: z.string().url(),
|
||||
secret: z.string().min(1).optional(),
|
||||
events: z
|
||||
.array(
|
||||
z.enum(["presence:join", "presence:leave", "presence:update"])
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const WebSocketGatewayConfigSchema = z
|
||||
.object({
|
||||
port: z.number().int().min(0).max(65535),
|
||||
host: z.string().min(1).default("0.0.0.0"),
|
||||
maxRooms: z.number().int().positive().default(100),
|
||||
maxClientsPerRoom: z.number().int().positive().default(50),
|
||||
heartbeatIntervalMs: z.number().int().positive().default(30_000),
|
||||
clientTimeoutMs: z.number().int().positive().default(90_000),
|
||||
enablePresence: z.boolean().default(true),
|
||||
enableChannels: z.boolean().default(true),
|
||||
wsServerFactory: z.unknown(),
|
||||
authVerifier: z.unknown().optional(),
|
||||
authorizationCheck: z.unknown().optional(),
|
||||
rateLimitGate: z.unknown().optional(),
|
||||
auditLogger: z.unknown().optional(),
|
||||
presenceWebhook: PresenceWebhookConfigSchema.optional(),
|
||||
bus: z.unknown().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((cfg, ctx) => {
|
||||
if (typeof cfg.wsServerFactory !== "function") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["wsServerFactory"],
|
||||
message: "wsServerFactory must be a function",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/** Wire-frame schema for inbound client messages. */
|
||||
export const InboundFrameSchema = z
|
||||
.object({
|
||||
type: z.enum([
|
||||
"room.join",
|
||||
"room.leave",
|
||||
"room.broadcast",
|
||||
"channel.subscribe",
|
||||
"channel.unsubscribe",
|
||||
"channel.publish",
|
||||
"presence.update",
|
||||
"unicast",
|
||||
"heartbeat",
|
||||
]),
|
||||
idempotencyKey: z.string().min(1).max(256).optional(),
|
||||
roomId: z.string().min(1).optional(),
|
||||
channel: z.string().min(1).optional(),
|
||||
topic: z.string().min(1).optional(),
|
||||
target: z.string().min(1).optional(),
|
||||
status: z.enum(["online", "away", "busy", "offline"]).optional(),
|
||||
state: z.record(z.string(), z.unknown()).optional(),
|
||||
payload: z.unknown().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type InboundFrame = z.infer<typeof InboundFrameSchema>;
|
||||
297
packages/networking/network/websocket-gateway/src/types/types.ts
Normal file
297
packages/networking/network/websocket-gateway/src/types/types.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import type { EventBusLike } from "@suaveplan/types";
|
||||
|
||||
/** Possible presence statuses for tracked clients. */
|
||||
export type PresenceStatus = "online" | "away" | "busy" | "offline";
|
||||
|
||||
/** A connected gateway client. */
|
||||
export interface ClientInfo {
|
||||
readonly id: string;
|
||||
readonly connectedAt: number;
|
||||
lastHeartbeat: number;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Presence record for one tracked client. */
|
||||
export interface PresenceInfo {
|
||||
readonly clientId: string;
|
||||
status: PresenceStatus;
|
||||
customState: Record<string, unknown>;
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
/** Persistent room configuration. */
|
||||
export interface RoomConfig {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly maxClients: number;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Channel configuration for pub/sub. */
|
||||
export interface ChannelConfig {
|
||||
readonly name: string;
|
||||
readonly allowPatterns: boolean;
|
||||
}
|
||||
|
||||
/** Supported message types. */
|
||||
export type MessageType =
|
||||
| "broadcast"
|
||||
| "unicast"
|
||||
| "room"
|
||||
| "channel"
|
||||
| "system";
|
||||
|
||||
/** A message sent through the gateway. */
|
||||
export interface Message {
|
||||
readonly id: string;
|
||||
readonly type: MessageType;
|
||||
readonly sender: string;
|
||||
readonly target: string;
|
||||
readonly payload: unknown;
|
||||
readonly timestamp: number;
|
||||
}
|
||||
|
||||
/** Action verb passed to authorization + rate-limit callbacks. */
|
||||
export type GatewayAction =
|
||||
| "connect"
|
||||
| "room.create"
|
||||
| "room.join"
|
||||
| "room.broadcast"
|
||||
| "channel.subscribe"
|
||||
| "channel.publish"
|
||||
| "presence.update"
|
||||
| "unicast";
|
||||
|
||||
/** Handshake context handed to authVerifier. */
|
||||
export interface Handshake {
|
||||
readonly headers: Readonly<Record<string, string | undefined>>;
|
||||
readonly remoteAddress: string | undefined;
|
||||
readonly url: string | undefined;
|
||||
readonly query: Readonly<Record<string, string | undefined>>;
|
||||
}
|
||||
|
||||
/** Auth context returned by authVerifier. Passed to subsequent gates. */
|
||||
export interface AuthContext {
|
||||
readonly clientId: string;
|
||||
readonly principal: string | undefined;
|
||||
readonly tenantId: string | undefined;
|
||||
readonly scopes: readonly string[];
|
||||
readonly metadata: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** Audit event payload handed to auditLogger. */
|
||||
export interface AuditEvent {
|
||||
readonly action: GatewayAction;
|
||||
readonly clientId: string;
|
||||
readonly principal: string | undefined;
|
||||
readonly outcome: "allowed" | "denied";
|
||||
readonly reason: string | undefined;
|
||||
readonly at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth verifier callback. Called once per inbound WebSocket handshake.
|
||||
* Default: returns a fresh AuthContext with the auto-assigned clientId
|
||||
* and no principal / scopes (i.e. anonymous-pass-through).
|
||||
*
|
||||
* Replace with `@suaveplan/jwt-tenant` once that package ships on genesis.
|
||||
*/
|
||||
export type AuthVerifier = (handshake: Handshake) => Promise<AuthContext>;
|
||||
|
||||
/**
|
||||
* Authorization check callback. Called on every mutating action.
|
||||
* Default: returns true (allow). Replace with `@suaveplan/permissions`
|
||||
* once that package ships on genesis.
|
||||
*/
|
||||
export type AuthorizationCheck = (
|
||||
ctx: AuthContext,
|
||||
action: GatewayAction
|
||||
) => Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Rate-limit gate callback. Called on every mutating action.
|
||||
* Default: returns true (allow). Replace with `@suaveplan/rate-limiter`
|
||||
* once that package ships on genesis.
|
||||
*/
|
||||
export type RateLimitGate = (
|
||||
ctx: AuthContext,
|
||||
action: GatewayAction
|
||||
) => Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Audit logger callback. Called on every authoritative decision.
|
||||
* Default: no-op. Replace with `@suaveplan/audit-logging` once that
|
||||
* package ships on genesis.
|
||||
*/
|
||||
export type AuditLogger = (event: AuditEvent) => void;
|
||||
|
||||
/** Webhook delivery target for presence events. */
|
||||
export interface PresenceWebhookConfig {
|
||||
readonly url: string;
|
||||
readonly secret?: string | undefined;
|
||||
readonly events?: readonly (
|
||||
| "presence:join"
|
||||
| "presence:leave"
|
||||
| "presence:update"
|
||||
)[];
|
||||
}
|
||||
|
||||
/** Typed events emitted on the optional EventBusLike. */
|
||||
export interface GatewayStartedEvent {
|
||||
readonly address: string;
|
||||
readonly port: number;
|
||||
readonly at: number;
|
||||
}
|
||||
export interface GatewayStoppedEvent {
|
||||
readonly at: number;
|
||||
}
|
||||
export interface GatewayClientConnectedEvent {
|
||||
readonly clientId: string;
|
||||
readonly principal: string | undefined;
|
||||
readonly at: number;
|
||||
}
|
||||
export interface GatewayClientDisconnectedEvent {
|
||||
readonly clientId: string;
|
||||
readonly reason: string;
|
||||
readonly at: number;
|
||||
}
|
||||
export interface GatewayMessageEvent {
|
||||
readonly message: Message;
|
||||
readonly at: number;
|
||||
}
|
||||
export interface GatewayPresenceEvent {
|
||||
readonly kind: "join" | "leave" | "update";
|
||||
readonly clientId: string;
|
||||
readonly roomId: string | undefined;
|
||||
readonly at: number;
|
||||
}
|
||||
|
||||
export type GatewayEventMap = {
|
||||
"ws_gateway:started": GatewayStartedEvent;
|
||||
"ws_gateway:stopped": GatewayStoppedEvent;
|
||||
"ws_gateway:client-connected": GatewayClientConnectedEvent;
|
||||
"ws_gateway:client-disconnected": GatewayClientDisconnectedEvent;
|
||||
"ws_gateway:message": GatewayMessageEvent;
|
||||
"ws_gateway:presence": GatewayPresenceEvent;
|
||||
};
|
||||
|
||||
/**
|
||||
* Structural view of a hosted WebSocket connection. Production wires
|
||||
* the real `ws.WebSocket` instance; tests bind a deterministic fake.
|
||||
*/
|
||||
export interface WsSocketLike {
|
||||
/** Stable id assigned by the daemon at attach time. */
|
||||
readonly id: string;
|
||||
/** Send a frame down the socket. */
|
||||
send(payload: string): void;
|
||||
/** Close the socket. */
|
||||
close(code?: number, reason?: string): void;
|
||||
/** Optional remote-address hint. */
|
||||
readonly remoteAddress?: string | undefined;
|
||||
}
|
||||
|
||||
/** Inbound-event callbacks the daemon wires into the WS server. */
|
||||
export interface WsServerCallbacks {
|
||||
onConnection(socket: WsSocketLike, handshake: Handshake): void;
|
||||
onMessage(socketId: string, payload: string): void;
|
||||
onClose(socketId: string, code: number, reason: string): void;
|
||||
onError(socketId: string, err: Error): void;
|
||||
}
|
||||
|
||||
/** Args handed to WsServerLike.start. */
|
||||
export interface WsServerStartOptions {
|
||||
readonly host: string;
|
||||
readonly port: number;
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
readonly callbacks: WsServerCallbacks;
|
||||
}
|
||||
|
||||
/** Result of WsServerLike.start — the bound address. */
|
||||
export interface WsServerStartResult {
|
||||
readonly address: string;
|
||||
readonly port: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural view of a WebSocket server. The daemon owns the lifecycle.
|
||||
* `start()` binds and resolves once accepting clients. `stop()` drains
|
||||
* in-flight clients and resolves once the listener has closed.
|
||||
* `handleHttpRequest` is invoked for non-upgrade HTTP requests so the
|
||||
* daemon can serve inline /health endpoints from the same port.
|
||||
*/
|
||||
export interface WsServerLike {
|
||||
start(options: WsServerStartOptions): Promise<WsServerStartResult>;
|
||||
stop(): Promise<void>;
|
||||
/** Best-effort drain — close every attached socket. */
|
||||
closeAll(code?: number, reason?: string): Promise<void>;
|
||||
/** Optional HTTP-request shim for /health probes. */
|
||||
handleHttpRequest?(
|
||||
url: string | undefined,
|
||||
respond: (status: number, body: string) => void
|
||||
): boolean;
|
||||
}
|
||||
|
||||
/** Factory wiring the WS server. Production: real `ws`; tests: fake. */
|
||||
export type WsServerFactory = (deps: {
|
||||
readonly host: string;
|
||||
readonly port: number;
|
||||
}) => WsServerLike | Promise<WsServerLike>;
|
||||
|
||||
/** Strict configuration for `WebSocketGateway`. */
|
||||
export interface WebSocketGatewayConfig {
|
||||
/** TCP port. 0 for ephemeral. */
|
||||
readonly port: number;
|
||||
/** Bind address. Default "0.0.0.0". */
|
||||
readonly host?: string | undefined;
|
||||
/** Maximum number of rooms allowed. Default 100. */
|
||||
readonly maxRooms?: number | undefined;
|
||||
/** Maximum clients per room. Default 50. */
|
||||
readonly maxClientsPerRoom?: number | undefined;
|
||||
/** Heartbeat interval ms. Default 30000. */
|
||||
readonly heartbeatIntervalMs?: number | undefined;
|
||||
/** Client timeout ms — no heartbeat = disconnect. Default 90000. */
|
||||
readonly clientTimeoutMs?: number | undefined;
|
||||
/** Toggle presence tracking. Default true. */
|
||||
readonly enablePresence?: boolean | undefined;
|
||||
/** Toggle channel pub/sub. Default true. */
|
||||
readonly enableChannels?: boolean | undefined;
|
||||
/** Required factory wiring the WS server. */
|
||||
readonly wsServerFactory: WsServerFactory;
|
||||
/** Hook for auth — replace with @suaveplan/jwt-tenant when shipped. */
|
||||
readonly authVerifier?: AuthVerifier | undefined;
|
||||
/** Hook for authz — replace with @suaveplan/permissions when shipped. */
|
||||
readonly authorizationCheck?: AuthorizationCheck | undefined;
|
||||
/** Hook for rate-limiting — replace with @suaveplan/rate-limiter when shipped. */
|
||||
readonly rateLimitGate?: RateLimitGate | undefined;
|
||||
/** Hook for audit — replace with @suaveplan/audit-logging when shipped. */
|
||||
readonly auditLogger?: AuditLogger | undefined;
|
||||
/** Optional webhook delivery for presence events. */
|
||||
readonly presenceWebhook?: PresenceWebhookConfig | undefined;
|
||||
/** Optional event bus for typed lifecycle emissions. */
|
||||
readonly bus?: EventBusLike<GatewayEventMap> | undefined;
|
||||
}
|
||||
|
||||
/** Fully-resolved configuration with every default applied. */
|
||||
export interface ResolvedGatewayConfig {
|
||||
readonly port: number;
|
||||
readonly host: string;
|
||||
readonly maxRooms: number;
|
||||
readonly maxClientsPerRoom: number;
|
||||
readonly heartbeatIntervalMs: number;
|
||||
readonly clientTimeoutMs: number;
|
||||
readonly enablePresence: boolean;
|
||||
readonly enableChannels: boolean;
|
||||
readonly wsServerFactory: WsServerFactory;
|
||||
readonly authVerifier: AuthVerifier;
|
||||
readonly authorizationCheck: AuthorizationCheck;
|
||||
readonly rateLimitGate: RateLimitGate;
|
||||
readonly auditLogger: AuditLogger;
|
||||
readonly presenceWebhook: PresenceWebhookConfig | undefined;
|
||||
readonly bus: EventBusLike<GatewayEventMap> | undefined;
|
||||
}
|
||||
|
||||
/** Result of WebSocketGateway.start(). */
|
||||
export interface GatewayStartResult {
|
||||
readonly address: string;
|
||||
readonly port: number;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("websocket-gateway dep-graph", () => {
|
||||
it("must not have circular dependencies", { timeout: 30000 }, async () => {
|
||||
const mod = await import("../src/index.js");
|
||||
expect(mod).toBeDefined();
|
||||
expect(mod.WebSocketGateway).toBeDefined();
|
||||
expect(mod.WS_GATEWAY_CODES).toBeDefined();
|
||||
expect(mod.WsGatewayError).toBeDefined();
|
||||
expect(mod.Room).toBeDefined();
|
||||
expect(mod.PresenceTracker).toBeDefined();
|
||||
expect(mod.Channel).toBeDefined();
|
||||
expect(mod.ChannelManager).toBeDefined();
|
||||
expect(mod.createMessage).toBeDefined();
|
||||
expect(mod.handleHealth).toBeDefined();
|
||||
expect(mod.classifyHealthRequest).toBeDefined();
|
||||
expect(mod.buildHealthResponse).toBeDefined();
|
||||
expect(mod.HEALTH_PATHS).toBeDefined();
|
||||
expect(mod.WebSocketGatewayConfigSchema).toBeDefined();
|
||||
expect(mod.PresenceWebhookConfigSchema).toBeDefined();
|
||||
expect(mod.InboundFrameSchema).toBeDefined();
|
||||
expect(mod.defaultAuthVerifier).toBeDefined();
|
||||
expect(mod.defaultAuthorizationCheck).toBeDefined();
|
||||
expect(mod.defaultRateLimitGate).toBeDefined();
|
||||
expect(mod.defaultAuditLogger).toBeDefined();
|
||||
expect(mod.resolveConfig).toBeDefined();
|
||||
expect(mod.createHeartbeat).toBeDefined();
|
||||
expect(mod.gate).toBeDefined();
|
||||
expect(mod.audit).toBeDefined();
|
||||
expect(mod.firePresence).toBeDefined();
|
||||
expect(mod.deliverPresenceWebhook).toBeDefined();
|
||||
expect(mod.bootWsServer).toBeDefined();
|
||||
expect(mod.runAuthHandshake).toBeDefined();
|
||||
expect(mod.runStart).toBeDefined();
|
||||
expect(mod.runStop).toBeDefined();
|
||||
expect(mod.preflightStart).toBeDefined();
|
||||
expect(mod.recordStartCompleted).toBeDefined();
|
||||
expect(mod.buildGatewayDepFactories).toBeDefined();
|
||||
expect(mod.connectClient).toBeDefined();
|
||||
expect(mod.disconnectClient).toBeDefined();
|
||||
expect(mod.createRoom).toBeDefined();
|
||||
expect(mod.deleteRoom).toBeDefined();
|
||||
expect(mod.joinRoom).toBeDefined();
|
||||
expect(mod.leaveRoom).toBeDefined();
|
||||
expect(mod.broadcastToRoom).toBeDefined();
|
||||
expect(mod.unicast).toBeDefined();
|
||||
expect(mod.subscribeToChannel).toBeDefined();
|
||||
expect(mod.unsubscribeFromChannel).toBeDefined();
|
||||
expect(mod.publishToChannel).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { WebSocket as WsClient } from "ws";
|
||||
import { WebSocketGateway } from "../src/gateway/gateway.js";
|
||||
import { makeRealWsServerFactory } from "./setup/real-ws-adapter.js";
|
||||
|
||||
function baseUrl(address: string, port: number): string {
|
||||
const host =
|
||||
address === "::" || address === "0.0.0.0" ? "127.0.0.1" : address;
|
||||
return `http://${host}:${port}`;
|
||||
}
|
||||
|
||||
function wsUrl(address: string, port: number): string {
|
||||
const host =
|
||||
address === "::" || address === "0.0.0.0" ? "127.0.0.1" : address;
|
||||
return `ws://${host}:${port}`;
|
||||
}
|
||||
|
||||
let gateway: WebSocketGateway;
|
||||
let address = "";
|
||||
let port = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
let snap: () => ReturnType<WebSocketGateway["snapshot"]> = () => ({
|
||||
running: false,
|
||||
disposed: false,
|
||||
clientCount: 0,
|
||||
roomCount: 0,
|
||||
startedAt: undefined,
|
||||
});
|
||||
const factory = makeRealWsServerFactory(() => snap());
|
||||
gateway = new WebSocketGateway({
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
wsServerFactory: factory,
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
snap = () => gateway.snapshot();
|
||||
const result = await gateway.start();
|
||||
address = result.address;
|
||||
port = result.port;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await gateway?.stop();
|
||||
});
|
||||
|
||||
describe("integration: real ws server boot + health probes", () => {
|
||||
it("/health returns 200 ok while running", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
const res = await fetch(`${baseUrl(address, port)}/health`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { status: string; running: boolean };
|
||||
expect(body.status).toBe("ok");
|
||||
expect(body.running).toBe(true);
|
||||
});
|
||||
|
||||
it("/ready returns 200 once started", { timeout: 10_000 }, async () => {
|
||||
const res = await fetch(`${baseUrl(address, port)}/ready`);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("/live returns 200", { timeout: 10_000 }, async () => {
|
||||
const res = await fetch(`${baseUrl(address, port)}/live`);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration: 3 real ws clients connect, join a room, broadcast", () => {
|
||||
it("all 3 clients receive broadcasts to a shared room", {
|
||||
timeout: 15_000,
|
||||
}, async () => {
|
||||
// Open 3 real WebSocket connections to the gateway.
|
||||
const c1 = new WsClient(wsUrl(address, port));
|
||||
const c2 = new WsClient(wsUrl(address, port));
|
||||
const c3 = new WsClient(wsUrl(address, port));
|
||||
const all = [c1, c2, c3];
|
||||
await Promise.all(
|
||||
all.map(
|
||||
(c) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
c.once("open", () => resolve());
|
||||
c.once("error", reject);
|
||||
})
|
||||
)
|
||||
);
|
||||
// Wait a tick for the daemon to register all three sockets.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const ids = Array.from(
|
||||
{ length: 3 },
|
||||
(_, i) => `wsc-${i + 1}` as const
|
||||
);
|
||||
expect(gateway.clientCount).toBe(3);
|
||||
// Create the room and join all three clients to it.
|
||||
gateway.createRoom({ id: "shared", maxClients: 10 });
|
||||
for (const id of ids) {
|
||||
const joined = await gateway.joinRoom("shared", id);
|
||||
expect(joined).toBe(true);
|
||||
}
|
||||
expect(gateway.getRoom("shared")?.size).toBe(3);
|
||||
// Broadcast from id-1; the room returns ids of (id-2, id-3).
|
||||
const result = await gateway.broadcastToRoom("shared", ids[0]!, {
|
||||
text: "hello",
|
||||
});
|
||||
expect(result?.recipients.sort()).toEqual([ids[1]!, ids[2]!]);
|
||||
expect(result?.message.payload).toEqual({ text: "hello" });
|
||||
for (const c of all) c.close();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(gateway.clientCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import type {
|
||||
Handshake,
|
||||
WsServerCallbacks,
|
||||
WsServerFactory,
|
||||
WsServerLike,
|
||||
WsServerStartOptions,
|
||||
WsServerStartResult,
|
||||
WsSocketLike,
|
||||
} from "../../src/types/types.js";
|
||||
|
||||
/**
|
||||
* In-memory deterministic `WsServerLike` implementation. The
|
||||
* production wiring binds the real `ws.WebSocketServer`, but unit
|
||||
* tests + the `tests/integration.test.ts` 3-client scenario drive
|
||||
* lifecycle events through this real-implementation fake. Per the
|
||||
* CLAUDE.md "no mocks" rule, the daemon never sees `vi.mock` — only
|
||||
* an alternative concrete implementation.
|
||||
*/
|
||||
export class FakeWsServer implements WsServerLike {
|
||||
public started = false;
|
||||
public stopped = false;
|
||||
public lastStartOptions: WsServerStartOptions | undefined;
|
||||
public callbacks: WsServerCallbacks | undefined;
|
||||
public boundAddress = "127.0.0.1";
|
||||
public boundPort = 0;
|
||||
public startError: Error | undefined;
|
||||
private readonly sockets = new Map<string, FakeWsSocket>();
|
||||
|
||||
public async start(
|
||||
options: WsServerStartOptions
|
||||
): Promise<WsServerStartResult> {
|
||||
if (this.startError) throw this.startError;
|
||||
this.lastStartOptions = options;
|
||||
this.callbacks = options.callbacks;
|
||||
this.started = true;
|
||||
return { address: this.boundAddress, port: this.boundPort };
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
this.stopped = true;
|
||||
for (const id of [...this.sockets.keys()])
|
||||
this.disconnect(id, 1001, "stop");
|
||||
}
|
||||
|
||||
public async closeAll(code = 1001, reason = "shutdown"): Promise<void> {
|
||||
for (const id of [...this.sockets.keys()])
|
||||
this.disconnect(id, code, reason);
|
||||
}
|
||||
|
||||
/** Test affordance: simulate a new client connecting. */
|
||||
public connect(id: string, handshake?: Partial<Handshake>): FakeWsSocket {
|
||||
const socket = new FakeWsSocket(id, () =>
|
||||
this.disconnect(id, 1000, "client-close")
|
||||
);
|
||||
this.sockets.set(id, socket);
|
||||
const h: Handshake = {
|
||||
headers: handshake?.headers ?? {},
|
||||
remoteAddress: handshake?.remoteAddress,
|
||||
url: handshake?.url ?? "/",
|
||||
query: handshake?.query ?? {},
|
||||
};
|
||||
this.callbacks?.onConnection(socket, h);
|
||||
return socket;
|
||||
}
|
||||
|
||||
public disconnect(id: string, code = 1000, reason = "close"): void {
|
||||
const socket = this.sockets.get(id);
|
||||
if (!socket) return;
|
||||
this.sockets.delete(id);
|
||||
socket.closed = true;
|
||||
this.callbacks?.onClose(id, code, reason);
|
||||
}
|
||||
|
||||
public message(id: string, payload: string): void {
|
||||
this.callbacks?.onMessage(id, payload);
|
||||
}
|
||||
|
||||
public errorOn(id: string, err: Error): void {
|
||||
this.callbacks?.onError(id, err);
|
||||
}
|
||||
|
||||
public getSocket(id: string): FakeWsSocket | undefined {
|
||||
return this.sockets.get(id);
|
||||
}
|
||||
|
||||
public socketCount(): number {
|
||||
return this.sockets.size;
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeWsSocket implements WsSocketLike {
|
||||
public readonly id: string;
|
||||
public readonly sent: string[] = [];
|
||||
public closed = false;
|
||||
public readonly remoteAddress = "127.0.0.1";
|
||||
private readonly onClose: () => void;
|
||||
|
||||
public constructor(id: string, onClose: () => void) {
|
||||
this.id = id;
|
||||
this.onClose = onClose;
|
||||
}
|
||||
|
||||
public send(payload: string): void {
|
||||
if (!this.closed) this.sent.push(payload);
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
/** Factory producing a fresh `FakeWsServer` per construction. */
|
||||
export function makeFakeWsServerFactory(): {
|
||||
readonly instances: FakeWsServer[];
|
||||
readonly factory: WsServerFactory;
|
||||
} {
|
||||
const instances: FakeWsServer[] = [];
|
||||
return {
|
||||
instances,
|
||||
factory: () => {
|
||||
const inst = new FakeWsServer();
|
||||
instances.push(inst);
|
||||
return inst;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { createServer, type Server as HttpServer } from "node:http";
|
||||
import { type WebSocket, WebSocketServer } from "ws";
|
||||
import { handleHealth } from "../../src/health/health.js";
|
||||
import type {
|
||||
Handshake,
|
||||
WsServerFactory,
|
||||
WsServerLike,
|
||||
WsServerStartOptions,
|
||||
WsServerStartResult,
|
||||
WsSocketLike,
|
||||
} from "../../src/types/types.js";
|
||||
|
||||
/**
|
||||
* Production-grade `WsServerLike` implementation backed by the real
|
||||
* `ws` package and a Node `http.Server`. The HTTP server also routes
|
||||
* `/health` / `/ready` / `/live` probes through the daemon's snapshot
|
||||
* so the integration test can hit those endpoints from the same port.
|
||||
*/
|
||||
export class RealWsServer implements WsServerLike {
|
||||
#http: HttpServer | undefined;
|
||||
#wss: WebSocketServer | undefined;
|
||||
readonly #sockets = new Map<
|
||||
string,
|
||||
{ socket: WebSocket; like: WsSocketLike }
|
||||
>();
|
||||
readonly #snapshot: () => {
|
||||
running: boolean;
|
||||
disposed: boolean;
|
||||
clientCount: number;
|
||||
roomCount: number;
|
||||
startedAt: number | undefined;
|
||||
};
|
||||
#nextId = 0;
|
||||
|
||||
public constructor(
|
||||
snapshot: () => {
|
||||
running: boolean;
|
||||
disposed: boolean;
|
||||
clientCount: number;
|
||||
roomCount: number;
|
||||
startedAt: number | undefined;
|
||||
}
|
||||
) {
|
||||
this.#snapshot = snapshot;
|
||||
}
|
||||
|
||||
public async start(
|
||||
opts: WsServerStartOptions
|
||||
): Promise<WsServerStartResult> {
|
||||
const http = createServer((req, res) => {
|
||||
const handled = handleHealth(req.url, this.#snapshot());
|
||||
if (handled) {
|
||||
res.writeHead(handled.status, {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
res.end(handled.body);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { "content-type": "text/plain" });
|
||||
res.end("not found");
|
||||
});
|
||||
const wss = new WebSocketServer({ server: http });
|
||||
wss.on("connection", (socket, request) => {
|
||||
const id = `wsc-${++this.#nextId}`;
|
||||
const url = request.url ?? "/";
|
||||
const q = new URL(url, "http://x").searchParams;
|
||||
const handshake: Handshake = {
|
||||
headers: Object.fromEntries(
|
||||
Object.entries(request.headers).map(([k, v]) => [
|
||||
k,
|
||||
Array.isArray(v) ? v.join(",") : v,
|
||||
])
|
||||
),
|
||||
remoteAddress: request.socket.remoteAddress,
|
||||
url,
|
||||
query: Object.fromEntries(q.entries()),
|
||||
};
|
||||
const like: WsSocketLike = {
|
||||
id,
|
||||
remoteAddress: request.socket.remoteAddress,
|
||||
send: (payload) => {
|
||||
if (socket.readyState === socket.OPEN) socket.send(payload);
|
||||
},
|
||||
close: (code, reason) => socket.close(code, reason),
|
||||
};
|
||||
this.#sockets.set(id, { socket, like });
|
||||
opts.callbacks.onConnection(like, handshake);
|
||||
socket.on("message", (data) =>
|
||||
opts.callbacks.onMessage(id, data.toString())
|
||||
);
|
||||
socket.on("close", (code, reason) => {
|
||||
this.#sockets.delete(id);
|
||||
opts.callbacks.onClose(id, code, reason.toString());
|
||||
});
|
||||
socket.on("error", (err) => opts.callbacks.onError(id, err));
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onErr = (err: Error): void => reject(err);
|
||||
http.once("error", onErr);
|
||||
http.listen(opts.port, opts.host, () => {
|
||||
http.removeListener("error", onErr);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
this.#http = http;
|
||||
this.#wss = wss;
|
||||
const addr = http.address() as { address: string; port: number };
|
||||
return { address: addr.address, port: addr.port };
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.#wss?.close(() => resolve());
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
const http = this.#http;
|
||||
if (!http) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
http.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
public async closeAll(code = 1001, reason = "shutdown"): Promise<void> {
|
||||
for (const { socket } of this.#sockets.values())
|
||||
socket.close(code, reason);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a real-ws factory bound to a snapshot accessor. */
|
||||
export function makeRealWsServerFactory(
|
||||
snapshot: () => {
|
||||
running: boolean;
|
||||
disposed: boolean;
|
||||
clientCount: number;
|
||||
roomCount: number;
|
||||
startedAt: number | undefined;
|
||||
}
|
||||
): WsServerFactory {
|
||||
return () => new RealWsServer(snapshot);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"references": [{ "path": "./tsconfig.lib.json" }]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
45
packages/networking/network/websocket-gateway/vite.config.ts
Normal file
45
packages/networking/network/websocket-gateway/vite.config.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/websocket-gateway",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/idempotency",
|
||||
"@suaveplan/logger",
|
||||
"@suaveplan/network-transport-websockets",
|
||||
"@suaveplan/retry",
|
||||
"@suaveplan/timing",
|
||||
"@suaveplan/types",
|
||||
"@suaveplan/webhook-client",
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
"ws",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: [
|
||||
"src/__benchmarks__/**",
|
||||
"benchmarks/**",
|
||||
"e2e/**",
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 10_000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user