first commit
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
# `gate` + `audit`
|
||||
|
||||
## Purpose
|
||||
|
||||
Central rate-limit / authorization / audit pipeline. The gateway calls `gate(...)` once before every mutating action (room create, broadcast, channel subscribe, channel publish, presence update, unicast). Splitting this out of the main daemon file keeps `gateway.ts` under the 300-line cap and makes the gate behaviour unit-testable without booting a server.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict ordering: rate-limit first, authorization second. DoS traffic never exercises the (potentially expensive) authorization callback.
|
||||
- Typed denials: `RATE_LIMITED` and `AUTH_REJECTED` codes surface the exact failure reason to the caller.
|
||||
- Every decision records an OTel `recordAction(action, outcome)` counter increment AND a structured audit-log entry through the injected `AuditLogger`.
|
||||
- `audit(...)` swallows any thrown logger error so observability bugs cannot crash the daemon.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { gate } from "@suaveplan/websocket-gateway";
|
||||
|
||||
await gate(
|
||||
{ rateLimitGate, authorizationCheck, auditLogger, clock: Date.now },
|
||||
ctx,
|
||||
"room.broadcast"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { audit, gate } from "@suaveplan/websocket-gateway";
|
||||
|
||||
try {
|
||||
await gate(deps, ctx, "channel.publish");
|
||||
// … perform the publish
|
||||
} catch (err) {
|
||||
if (err instanceof WsGatewayError && err.code === WS_GATEWAY_CODES.RATE_LIMITED) {
|
||||
// The audit logger has already received a "denied" record; the
|
||||
// caller may emit an additional client-side denial event.
|
||||
audit(deps.auditLogger, "channel.publish", ctx, "denied", "client-handled", Date.now());
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `gate(deps, ctx, action): Promise<void>` — runs rate-limit + authz. Throws `WsGatewayError` on denial.
|
||||
- `audit(logger, action, ctx, outcome, reason, at): void` — pushes one record through the audit logger; swallows errors.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function is intentionally a free function (not a class method) so the daemon can pass it any composition of callbacks — a future tier might want to gate-only without auditing, or audit-only without gating. Decoupling the four hooks from each other matches the proposal's "Hooks for unmigrated deps" stance: each callback is replaceable independently when the matching `@suaveplan/*` package lands.
|
||||
@@ -0,0 +1,52 @@
|
||||
# `ChannelManager`
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the lifecycle of every `Channel` registered on a gateway instance. Clients never see a raw `Channel` — every channel call enters the gateway, which delegates here and translates the result into wire frames the daemon dispatches. Keeping the manager separate from the daemon keeps both files under the 300-line cap and lets channel behaviour be unit-tested independently.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy channel creation via `getOrCreateChannel(name, allowPatterns?)` — channels are stable across the gateway's lifetime once created.
|
||||
- `publish` returns the constructed `Message` + the deterministic recipient set; the daemon performs the actual socket fan-out so the manager has no WebSocket dependencies.
|
||||
- `removeClient(clientId)` is the disconnect hook — sweeps every channel and drops the client's subscriptions in one pass.
|
||||
- `clear()` for the daemon's shutdown path.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ChannelManager } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const mgr = new ChannelManager();
|
||||
mgr.subscribe("events", "alice", "user.*");
|
||||
const result = mgr.publish("events", "bob", "user.created", { id: "u1" }, Date.now());
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ChannelManager } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const mgr = new ChannelManager();
|
||||
// Pattern channel
|
||||
mgr.subscribe("events", "alice", "user.**");
|
||||
// Exact-only channel
|
||||
const exact = mgr.getOrCreateChannel("metrics", false);
|
||||
mgr.publish("events", "bob", "user.signup", null, 1);
|
||||
// On disconnect
|
||||
mgr.removeClient("alice");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `getOrCreateChannel(name, allowPatterns = true): Channel`
|
||||
- `getChannel(name): Channel | undefined`
|
||||
- `subscribe(channelName, clientId, topic): { subscribed }`
|
||||
- `unsubscribe(channelName, clientId, topic): { unsubscribed }`
|
||||
- `publish(channelName, senderId, topic, payload, now): { message, recipients } | null`
|
||||
- `removeClient(clientId): void`
|
||||
- `clear(): void`
|
||||
- `size: number` getter
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The manager never constructs channels behind `publish` — only `subscribe` and `getOrCreateChannel` create them. This matches upstream behaviour: publishes to a channel nobody subscribed to are no-ops so observers do not pay for unregistered topics. `removeClient` iterates every channel because the per-client subscription map lives inside each channel — flattening that out into a global per-client index is the future job of a sharded backend; today the linear sweep is fine at the documented N×subscribers ceiling.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Gateway channel operations
|
||||
|
||||
## Purpose
|
||||
|
||||
Free-function module containing every channel-level mutation the gateway exposes — `subscribeToChannel`, `unsubscribeFromChannel`, `publishToChannel`. Extracted from `gateway.ts` so the daemon file stays under the 300-line cap; consumed via `import * as channelOps from "./channel-ops.js"` from the gateway.
|
||||
|
||||
## Features
|
||||
|
||||
- `subscribeToChannel` runs the rate-limit + authorization gate before the subscribe.
|
||||
- `unsubscribeFromChannel` is a pure passthrough (no gating on unsubscribe — the proposal treats teardown actions as always-allowed).
|
||||
- `publishToChannel` gates first, then routes through the channel manager, returning the deterministic recipient set.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import * as channelOps from "@suaveplan/websocket-gateway/channel-ops";
|
||||
|
||||
await channelOps.subscribeToChannel(deps, "events", "alice", "user.created");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import * as channelOps from "@suaveplan/websocket-gateway/channel-ops";
|
||||
|
||||
const result = await channelOps.publishToChannel(
|
||||
deps,
|
||||
"events",
|
||||
"alice",
|
||||
"user.created",
|
||||
{ id: "u1", at: Date.now() }
|
||||
);
|
||||
// result.recipients contains every subscriber that matched, excluding alice.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `subscribeToChannel(deps, channel, clientId, topic): Promise<boolean>`
|
||||
- `unsubscribeFromChannel(mgr, channel, clientId, topic): boolean`
|
||||
- `publishToChannel(deps, channel, sender, topic, payload): Promise<{message, recipients} | null>`
|
||||
- `ChannelOpsDeps` — structurally-typed dependency bundle.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`unsubscribe` does not consult the gate because rejecting an unsubscribe would leave the client subscribed when they explicitly asked to stop receiving — a worse failure mode than allowing the teardown. `publishToChannel` returns `null` when the channel hasn't been registered (no subscribers exist yet); the daemon translates that into a no-op rather than a typed error so empty channels are silently ignored.
|
||||
|
||||
The dep bundle is structural (`ChannelOpsDeps`), not nominal. Tests that exercise the ops module directly can construct minimal bundles wiring only the parts each test needs — the gate closures, the requireAuthCtx fallback, the clock — without instantiating the full daemon. The factoring follows the same pattern `room-ops.ts` uses and keeps the gateway file under the 300-line cap.
|
||||
|
||||
The `subscribe` path is the only place a channel gets created today; the `getOrCreateChannel` call hides inside `ChannelManager.subscribe`. This keeps the wire protocol implicit: a client subscribes, the channel exists. Publishes to never-subscribed channels stay no-ops by design — a future stronger contract (rejecting publishes to unknown channels) is a follow-up policy, not a current requirement.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Gateway client-lifecycle helpers
|
||||
|
||||
## Purpose
|
||||
|
||||
Free-function module containing `connectClient` and `disconnectClient` — every state mutation that happens when a client attaches or detaches from the daemon. Extracted from `gateway.ts` so the daemon file stays under the 300-line cap.
|
||||
|
||||
## Features
|
||||
|
||||
- `connectClient` registers the client in the client map, presence tracker (when enabled), and bumps the OTel `presence_updates_total{kind: "join"}` counter.
|
||||
- `disconnectClient` is the cleanup orchestrator: leaves every room, removes the client from every channel, untracks presence (with counter bump), drops the auth context, emits the typed bus event, and fires the `presence:leave` side effects.
|
||||
- Returns `false` from `disconnectClient` when the client was never connected so the daemon's error-path handler is idempotent.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { connectClient, disconnectClient } from "@suaveplan/websocket-gateway";
|
||||
|
||||
connectClient(deps, "alice");
|
||||
disconnectClient(deps, "alice", "normal");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { connectClient } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const client = connectClient(deps, "alice", {
|
||||
userAgent: "Chrome/123",
|
||||
region: "us-east-1",
|
||||
});
|
||||
// client.metadata is a defensive copy of the input
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `connectClient(deps, id, metadata?): ClientInfo` — registers a client with `lastHeartbeat = clock()`.
|
||||
- `disconnectClient(deps, clientId, reason = "normal"): boolean` — full cleanup; idempotent.
|
||||
- `ClientLifecycleDeps` — structurally-typed dependency bundle.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`connectClient` clones `metadata` so the daemon cannot leak mutations back to the caller. `disconnectClient` calls `room.leave` on every registered room rather than maintaining a per-client room index because the room set is bounded by `maxRooms` and the linear sweep keeps disconnect O(rooms) without paying the index-maintenance cost on every join/leave. The OTel counter increments fire next to the actual map mutations so the counter reflects true client lifecycle, not best-effort presence webhook fan-out.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Gateway default callbacks
|
||||
|
||||
## Purpose
|
||||
|
||||
The four deferred-dependency hooks (`AuthVerifier`, `AuthorizationCheck`, `RateLimitGate`, `AuditLogger`) need sensible no-op defaults so the daemon can boot without forcing every caller to wire `@suaveplan/jwt-tenant` etc. before those packages land. This module centralises the defaults and the `resolveConfig` step that applies them to a parsed config.
|
||||
|
||||
## Features
|
||||
|
||||
- `defaultAuthVerifier` — returns an anonymous `AuthContext` with empty scopes; the daemon overlays the auto-assigned client id.
|
||||
- `defaultAuthorizationCheck` — returns `true` for every action.
|
||||
- `defaultRateLimitGate` — returns `true` for every action.
|
||||
- `defaultAuditLogger` — no-op.
|
||||
- `resolveConfig(cfg)` — applies the defaults to a Zod-defaulted config snapshot.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { defaultAuthVerifier } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const ctx = await defaultAuthVerifier({
|
||||
headers: {},
|
||||
remoteAddress: undefined,
|
||||
url: "/",
|
||||
query: {},
|
||||
});
|
||||
// ctx.principal === undefined, ctx.scopes === []
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { resolveConfig } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const resolved = resolveConfig({
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
maxRooms: 100,
|
||||
maxClientsPerRoom: 50,
|
||||
heartbeatIntervalMs: 30_000,
|
||||
clientTimeoutMs: 90_000,
|
||||
enablePresence: true,
|
||||
enableChannels: true,
|
||||
wsServerFactory: realFactory,
|
||||
});
|
||||
// resolved.authVerifier is defaultAuthVerifier
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `defaultAuthVerifier: AuthVerifier`
|
||||
- `defaultAuthorizationCheck: AuthorizationCheck`
|
||||
- `defaultRateLimitGate: RateLimitGate`
|
||||
- `defaultAuditLogger: AuditLogger`
|
||||
- `resolveConfig(cfg): ResolvedGatewayConfig`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The defaults are exported individually so consumers can compose them (e.g. wrap the default `AuthorizationCheck` with a tenant-aware filter while still benefiting from the rest of the no-op chain). When `@suaveplan/jwt-tenant` / `@suaveplan/permissions` / `@suaveplan/rate-limiter` / `@suaveplan/audit-logging` ship , the documented replacement path is to swap the relevant `default*` callback for the package's gate at config-construction time. The public types do not change.
|
||||
|
||||
The `resolveConfig` step expects the Zod-defaulted snapshot from `WebSocketGatewayConfigSchema.parse(...)`. That schema applies the numeric defaults (`maxRooms: 100`, `maxClientsPerRoom: 50`, `heartbeatIntervalMs: 30_000`, `clientTimeoutMs: 90_000`, etc.); this function only fills in the function-typed callbacks the Zod schema declares as `z.unknown().optional()`. Splitting the responsibility this way means the schema stays serialisable and the callback defaults stay testable in isolation.
|
||||
|
||||
The synthesised `AuthContext` from `defaultAuthVerifier` carries an empty `clientId` — the daemon's `runAuthHandshake` helper folds in the socket id on the `ctx.clientId || socketId` branch, so anonymous gateways still get a stable id per connection.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Gateway internal dep factory
|
||||
|
||||
## Purpose
|
||||
|
||||
Centralises the cross-module dependency-bundle construction the daemon needs once at construction time. Without this module the gateway class re-builds `RoomOpsDeps`, `ChannelOpsDeps`, and `ClientLifecycleDeps` inline at every public method, blowing the file's line count past the 300-line cap. The factory builds every dep bundle once and the daemon caches the references.
|
||||
|
||||
## Features
|
||||
|
||||
- One pure factory function (`buildGatewayDepFactories`) returning every bundle the daemon needs.
|
||||
- Helper builders (`buildRunGate`, `buildFirePresence`, `buildEmit`, `buildRequireAuthCtx`) exposed individually for unit tests that want to wire a sub-bundle without a full daemon.
|
||||
- The synthetic auth-context fallback inside `buildRequireAuthCtx` is captured here so tests can drive ops paths without going through the full handshake.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { buildGatewayDepFactories } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const factories = buildGatewayDepFactories({
|
||||
state,
|
||||
resolved,
|
||||
clock,
|
||||
authContexts,
|
||||
idempotency,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildEmit,
|
||||
buildFirePresence,
|
||||
buildGatewayDepFactories,
|
||||
buildRequireAuthCtx,
|
||||
buildRunGate,
|
||||
} from "@suaveplan/websocket-gateway";
|
||||
|
||||
const emit = buildEmit(internals);
|
||||
const runGate = buildRunGate(internals);
|
||||
const fire = buildFirePresence(internals);
|
||||
const requireAuthCtx = buildRequireAuthCtx(internals);
|
||||
// Wire each builder individually when testing one op path in isolation.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `GatewayInternals` — state + resolved-config + idempotency-guard snapshot the factory needs.
|
||||
- `GatewayDepFactories` — the four dep bundles the daemon hands to the op modules.
|
||||
- `buildGatewayDepFactories(internals): GatewayDepFactories`
|
||||
- `buildRunGate(internals): (ctx, action) => Promise<void>`
|
||||
- `buildFirePresence(internals): (kind, clientId, roomId, status) => Promise<void>`
|
||||
- `buildEmit(internals): <K>(topic, payload) => void`
|
||||
- `buildRequireAuthCtx(internals): (clientId) => AuthContext`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory closes over `internals.state` and `internals.resolved` by reference, so when the daemon mutates either, the dep bundles stay current — there is no per-call re-bundle cost beyond what the constructor pays once. The synthetic auth-context fallback uses `internals.authContexts` rather than holding its own state because every consumer (including the public `getAuthContext` method) shares the same lookup table.
|
||||
124
docs/networking/network/websocket-gateway/src/gateway/gateway.md
Normal file
124
docs/networking/network/websocket-gateway/src/gateway/gateway.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# `WebSocketGateway`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - heartbeat — Builds the heartbeat sweep that the dae…
|
||||
- [heartbeat](./heartbeat.md) — Builds the heartbeat sweep that the daemon installs while running. The sweep periodically calls `PresenceTracker.detect…
|
||||
- [lifecycle](./lifecycle.md) — Centralises three pieces of lifecycle plumbing the daemon needs but doesn't want inline: WS-server boot, start-completi…
|
||||
- [message-factory](./message-factory.md) — Tiny factory that produces immutable `Message` envelopes for every outbound dispatch the gateway emits. The factory thr…
|
||||
- [presence-fire](./presence-fire.md) — Single side-effect helper that fans out one presence transition (`join` / `leave` / `update`) to every observation sink…
|
||||
- [presence-webhook](./presence-webhook.md) — Best-effort, signed delivery of a single presence event to a configured webhook receiver. The gateway calls this functi…
|
||||
- [room-ops](./room-ops.md) — Free-function module containing every room-level mutation the gateway exposes — create / delete / join / leave / broadc…
|
||||
- heartbeat — Builds the heartbeat sweep that the dae…
|
||||
|
||||
- [heartbeat](./heartbeat.md) — Builds the heartbeat sweep that the daemon installs while running. The sweep periodically calls `PresenceTracker.detect…
|
||||
- [lifecycle](./lifecycle.md) — Centralises three pieces of lifecycle plumbing the daemon needs but doesn't want inline: WS-server boot, start-completi…
|
||||
- [message-factory](./message-factory.md) — Tiny factory that produces immutable `Message` envelopes for every outbound dispatch the gateway emits. The factory thr…
|
||||
- [presence-fire](./presence-fire.md) — Single side-effect helper that fans out one presence transition (`join` / `leave` / `update`) to every observation sink…
|
||||
- [presence-webhook](./presence-webhook.md) — Best-effort, signed delivery of a single presence event to a configured webhook receiver. The gateway calls this functi…
|
||||
- [room-ops](./room-ops.md) — Free-function module containing every room-level mutation the gateway exposes — create / delete / join / leave / broadc…
|
||||
|
||||
- heartbeat — Builds the heartbeat sweep that the dae…
|
||||
|
||||
- [heartbeat](./heartbeat.md) — Builds the heartbeat sweep that the daemon installs while running. The sweep periodically calls `PresenceTracker.detect…
|
||||
- [lifecycle](./lifecycle.md) — Centralises three pieces of lifecycle plumbing the daemon needs but doesn't want inline: WS-server boot, start-completi…
|
||||
- [message-factory](./message-factory.md) — Tiny factory that produces immutable `Message` envelopes for every outbound dispatch the gateway emits. The factory thr…
|
||||
- [presence-fire](./presence-fire.md) — Single side-effect helper that fans out one presence transition (`join` / `leave` / `update`) to every observation sink…
|
||||
- [presence-webhook](./presence-webhook.md) — Best-effort, signed delivery of a single presence event to a configured webhook receiver. The gateway calls this functi…
|
||||
- [room-ops](./room-ops.md) — Free-function module containing every room-level mutation the gateway exposes — create / delete / join / leave / broadc…
|
||||
|
||||
- heartbeat — Builds the heartbeat sweep that the dae…
|
||||
|
||||
- [heartbeat](./heartbeat.md) — Builds the heartbeat sweep that the daemon installs while running. The sweep periodically calls `PresenceTracker.detect…
|
||||
- [lifecycle](./lifecycle.md) — Centralises three pieces of lifecycle plumbing the daemon needs but doesn't want inline: WS-server boot, start-completi…
|
||||
- [message-factory](./message-factory.md) — Tiny factory that produces immutable `Message` envelopes for every outbound dispatch the gateway emits. The factory thr…
|
||||
- [presence-fire](./presence-fire.md) — Single side-effect helper that fans out one presence transition (`join` / `leave` / `update`) to every observation sink…
|
||||
- [presence-webhook](./presence-webhook.md) — Best-effort, signed delivery of a single presence event to a configured webhook receiver. The gateway calls this functi…
|
||||
- [room-ops](./room-ops.md) — Free-function module containing every room-level mutation the gateway exposes — create / delete / join / leave / broadc…
|
||||
- [heartbeat](./heartbeat.md) — Builds the heartbeat sweep that the daemon installs while running. The sweep periodically calls `PresenceTracker.detect…
|
||||
- [lifecycle](./lifecycle.md) — Centralises three pieces of lifecycle plumbing the daemon needs but doesn't want inline: WS-server boot, start-completi…
|
||||
- [message-factory](./message-factory.md) — Tiny factory that produces immutable `Message` envelopes for every outbound dispatch the gateway emits. The factory thr…
|
||||
- [presence-fire](./presence-fire.md) — Single side-effect helper that fans out one presence transition (`join` / `leave` / `update`) to every observation sink…
|
||||
- [presence-webhook](./presence-webhook.md) — Best-effort, signed delivery of a single presence event to a configured webhook receiver. The gateway calls this functi…
|
||||
- [room-ops](./room-ops.md) — Free-function module containing every room-level mutation the gateway exposes — create / delete / join / leave / broadc…
|
||||
|
||||
## Purpose
|
||||
|
||||
Server-tier WebSocket gateway daemon. Hosts rooms (broadcast / direct / capacity-controlled), presence tracking, channel-multiplexed pub/sub, and a four-callback access pipeline (auth / authz / rate-limit / audit) on top of an injectable `WsServerLike` adapter. Production binds `ws.WebSocketServer` from `ws`; tests bind a deterministic in-memory `FakeWsServer` so the no-mocks rule holds. Pairs with `@suaveplan/network-transport-websockets` on the browser side.
|
||||
|
||||
## Features
|
||||
|
||||
- Idempotent `start({signal?}) → {address, port}` / `stop()` lifecycle. `stop()` drains in-flight clients and is safe to call multiple times. `AbortSignal` is honoured at every checkpoint.
|
||||
- Five core subsystems: gateway (this class), room, presence, channel, health — each in its own feature module under `src/`.
|
||||
- Direct integration with three shipped packages: `@suaveplan/idempotency` (every mutating event runs through `IdempotencyGuard`), `@suaveplan/webhook-client` (signed presence webhook delivery), `@suaveplan/retry` (consumed transitively by webhook-client).
|
||||
- Four deferred-dep callbacks (`authVerifier`, `authorizationCheck`, `rateLimitGate`, `auditLogger`) that become real package gates when `@suaveplan/jwt-tenant` / `@suaveplan/permissions` / `@suaveplan/rate-limiter` / `@suaveplan/audit-logging` ship . Defaults are no-op pass-throughs documented in `defaults.md`.
|
||||
- OTel surface: spans `ws_gateway.{start,stop,connect,message,broadcast}`, counters `rooms_active` (UpDown), `presence_updates_total{kind}`, `actions_total{action,outcome}`, histograms `message_dispatch_ms`, `start_duration_ms`.
|
||||
- Bounded heartbeat sweep with injectable `Clock`. Times-out clients past `clientTimeoutMs` and disconnects them.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { WebSocketGateway } from "@suaveplan/websocket-gateway";
|
||||
import { WebSocketServer } from "ws";
|
||||
|
||||
const gateway = new WebSocketGateway({
|
||||
port: 8080,
|
||||
wsServerFactory: () => new MyWsAdapter(WebSocketServer),
|
||||
});
|
||||
const { address, port } = await gateway.start();
|
||||
// … later
|
||||
await gateway.stop();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { WebSocketGateway } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const gateway = new WebSocketGateway({
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
wsServerFactory: realWsFactory,
|
||||
presenceWebhook: {
|
||||
url: "https://hooks.example.com/presence",
|
||||
secret: process.env.PRESENCE_SECRET,
|
||||
events: ["presence:join", "presence:leave"],
|
||||
},
|
||||
authVerifier: async (handshake) => {
|
||||
const token = handshake.headers.authorization?.replace(/^Bearer /, "");
|
||||
const claims = await verifyJwt(token);
|
||||
return {
|
||||
clientId: claims.sub,
|
||||
principal: claims.sub,
|
||||
tenantId: claims.tid,
|
||||
scopes: claims.scope.split(" "),
|
||||
metadata: {},
|
||||
};
|
||||
},
|
||||
rateLimitGate: async (ctx, action) =>
|
||||
action === "room.broadcast" ? bucket.tryConsume(ctx.principal) : true,
|
||||
});
|
||||
const { address, port } = await gateway.start();
|
||||
gateway.createRoom({ id: "general", maxClients: 100 });
|
||||
await gateway.joinRoom("general", "alice");
|
||||
await gateway.broadcastToRoom("general", "alice", { text: "hi" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new WebSocketGateway(config, clock?)` — Zod-validates config; throws `WsGatewayError(INVALID_OPTIONS)` on failure.
|
||||
- `start(options?): Promise<{address, port}>` / `stop(): Promise<void>` — lifecycle.
|
||||
- `connectClient(id, metadata?)` / `disconnectClient(id, reason?)` / `isClientConnected(id)` / `getClient(id)` / `getAuthContext(id)` — client surface.
|
||||
- `createRoom(config)` / `getRoom(id)` / `deleteRoom(id)` / `joinRoom(roomId, clientId, idempotencyKey?)` / `leaveRoom` / `broadcastToRoom(roomId, senderId, payload, idempotencyKey?)` / `unicast(senderId, targetId, payload)` — room surface.
|
||||
- `getOrCreateChannel(name, allowPatterns?)` / `subscribeToChannel(channel, clientId, topic)` / `unsubscribeFromChannel` / `publishToChannel(channel, senderId, topic, payload)` — channel surface.
|
||||
- `getPresenceTracker()` / `handleHeartbeat(clientId)` — presence surface.
|
||||
- `snapshot()` — `HealthSnapshot` for inline health probes.
|
||||
- `boundAddress` / `boundPort` getters available after `start()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The daemon's state lives in a single `GatewayState` struct so lifecycle helpers (in `lifecycle.ts`) can drive `start`, `stop`, and `runAuthHandshake` as free functions; the public class is then a thin facade. The same factoring lets the four op modules (`client-lifecycle.ts`, `room-ops.ts`, `channel-ops.ts`, `access-gate.ts`) stay free of class state — every test can drive them with hand-rolled fakes.
|
||||
|
||||
Capacity enforcement throws typed errors rather than returning sentinel booleans: a caller that runs out of room slots or hits a missing room receives `ROOM_FULL` / `ROOM_NOT_FOUND` and can branch on `error.code`. Auth failure during the handshake closes the socket with WS code 4001 and surfaces an `AUTH_REJECTED` error. Rate-limit and authorization denials surface `RATE_LIMITED` / `AUTH_REJECTED` (the latter shared with handshake denials — every authorisation rejection lands in the same bucket so audit dashboards stay clean).
|
||||
|
||||
Idempotency is wired on `joinRoom` and `broadcastToRoom` via `IdempotencyGuard` from `@suaveplan/idempotency`. Callers can pass an explicit `idempotencyKey`; without one, the daemon synthesizes a deterministic key including the clock so two same-second `broadcastToRoom` calls do NOT collapse unless the caller wants them to.
|
||||
@@ -0,0 +1,55 @@
|
||||
# `createHeartbeat`
|
||||
|
||||
## Purpose
|
||||
|
||||
Builds the heartbeat sweep that the daemon installs while running. The sweep periodically calls `PresenceTracker.detectTimedOut()` and forwards every returned id through an `onTimedOut(id)` callback (the daemon disconnects the client). Splitting this out of `gateway.ts` keeps the daemon file under the line cap and lets tests assert sweep behaviour without booting a server.
|
||||
|
||||
## Features
|
||||
|
||||
- Idempotent `start()` / `stop()` — both methods can be called multiple times safely.
|
||||
- `enabled()` gate so the daemon can disable presence (`enablePresence: false`) without paying the sweep cost.
|
||||
- `timer.unref()` keeps the sweep from blocking process exit.
|
||||
- Pure factory — no class state escapes the closure.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createHeartbeat } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const hb = createHeartbeat(presenceTracker, 30_000, (id) => gateway.disconnect(id), () => true);
|
||||
hb.start();
|
||||
hb.stop();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createHeartbeat } from "@suaveplan/websocket-gateway";
|
||||
|
||||
let enabled = true;
|
||||
const hb = createHeartbeat(
|
||||
presenceTracker,
|
||||
1_000,
|
||||
(id) => {
|
||||
gateway.disconnectClient(id, "timeout");
|
||||
},
|
||||
() => enabled
|
||||
);
|
||||
hb.start();
|
||||
// Pause sweeps without stopping the timer (e.g. during maintenance)
|
||||
enabled = false;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createHeartbeat(presence, intervalMs, onTimedOut, enabled): HeartbeatHandle`
|
||||
- `HeartbeatHandle.start(): void`
|
||||
- `HeartbeatHandle.stop(): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The handle keeps the timer reference inside the closure so the daemon never needs to introspect or replace it. Using `unref()` (when available) means the heartbeat does not pin the Node process during graceful shutdown — the daemon still calls `stop()` explicitly in its `stop()` path. The `enabled()` predicate is checked inside the timer callback (not at start-time) so consumers can disable sweeps at runtime without restarting the heartbeat.
|
||||
|
||||
The sweep runs synchronously inside the `setInterval` callback. `detectTimedOut()` returns at most `presence.size` entries and the typical workload (a few thousand clients per gateway instance) finishes well within a single millisecond, so the sweep cannot starve other timers. The caller is responsible for handling the actual disconnect — the heartbeat does not own the client map, the room set, or any other state. That separation keeps the unit tests trivial: a `PresenceTracker` plus an array as the sink is enough to assert every branch.
|
||||
|
||||
The interval is bound at construction time and cannot be changed without recreating the handle. This is intentional — runtime interval changes would require re-scheduling logic, and the daemon's stable configuration model has no use case for that. Consumers wanting maintenance-window dynamic behaviour use the `enabled()` predicate to pause without restarting.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Gateway lifecycle helpers
|
||||
|
||||
## Purpose
|
||||
|
||||
Centralises three pieces of lifecycle plumbing the daemon needs but doesn't want inline: WS-server boot, start-completion accounting (OTel histogram + bus event), and the per-connection auth handshake. Splitting these out of `gateway.ts` keeps the daemon file under the 300-line cap and makes each phase independently unit-testable.
|
||||
|
||||
## Features
|
||||
|
||||
- `bootWsServer(resolved, callbacks, signal)` — calls the injected factory, invokes `start()`, and translates every failure into a typed `WsGatewayError(START_FAILED)` with the original cause attached. Always best-effort-stops the (possibly-partially-created) server before rethrowing.
|
||||
- `recordStartCompleted(resolved, address, port, startedAt, t0, emit)` — records the start-duration histogram and emits the typed `ws_gateway:started` event.
|
||||
- `runAuthHandshake(resolved, socketId, handshake, now)` — runs the injected `authVerifier`, normalises the resulting auth context (handles missing client-ids), audits both outcomes, raises `AUTH_REJECTED` on failure.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { bootWsServer } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const { server, address, port } = await bootWsServer(resolved, callbacks, signal);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { runAuthHandshake } from "@suaveplan/websocket-gateway";
|
||||
|
||||
try {
|
||||
const ctx = await runAuthHandshake(resolved, socketId, handshake, Date.now());
|
||||
// ctx.clientId guaranteed to be non-empty
|
||||
} catch (err) {
|
||||
// err is a WsGatewayError(AUTH_REJECTED); audit log has already received
|
||||
// the "denied" record.
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `bootWsServer(resolved, callbacks, signal): Promise<{server, address, port}>`
|
||||
- `recordStartCompleted(resolved, address, port, startedAt, t0, emit): void`
|
||||
- `runAuthHandshake(resolved, socketId, handshake, now): Promise<AuthContext>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`bootWsServer` always awaits `server?.stop()` before rethrowing because the WS factory may throw mid-`start()` after attaching internal state — letting that state leak would prevent a retry. `runAuthHandshake` defaults a missing `ctx.clientId` to the socket id so consumers that adopt async auth don't need to invent ids twice. Auditing fires on both branches because the audit log is the source of truth for "did this handshake even reach us" alerts.
|
||||
@@ -0,0 +1,44 @@
|
||||
# `createMessage`
|
||||
|
||||
## Purpose
|
||||
|
||||
Tiny factory that produces immutable `Message` envelopes for every outbound dispatch the gateway emits. The factory threads the gateway's injectable clock through every id so the wire-visible ids stay deterministic when tests fake time.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable id format: `msg_<unix-ms>_<monotonic-counter>` — counter is process-local but the daemon resets it via `_resetMessageCounter` in test setup if needed.
|
||||
- Caller provides the timestamp so the factory does not pull `Date.now()` directly — keeps the function pure.
|
||||
- Returns a frozen-shape `Message` matching every field of the public type.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createMessage } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const msg = createMessage("room", "alice", "general", { text: "hi" }, Date.now());
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createMessage, _resetMessageCounter } from "@suaveplan/websocket-gateway";
|
||||
|
||||
// In test setup
|
||||
_resetMessageCounter();
|
||||
const a = createMessage("room", "alice", "r1", null, 1);
|
||||
const b = createMessage("room", "alice", "r1", null, 1);
|
||||
// a.id === "msg_1_1", b.id === "msg_1_2"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createMessage(type, sender, target, payload, now): Message` — pure factory.
|
||||
- `_resetMessageCounter(): void` — test-only counter reset; prefixed with `_` to mark intent.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory does not own the clock — every caller passes `now` explicitly so the daemon's `Clock` injection is honoured up the stack. The factory is intentionally tiny because every dispatch path goes through it; keeping it allocation-only avoids inline GC churn. Setting `MessageType` to a string-literal union (not a Zod enum) keeps the factory zero-cost in production builds while leaving the public wire schema responsible for validation.
|
||||
|
||||
The counter is process-local rather than per-instance because the format `msg_<unix-ms>_<counter>` is already disambiguated by the timestamp at production frequencies (no two messages share the same ms unless the daemon is processing thousands of messages per ms — at which point the counter takes over and the id stays unique). For deterministic tests, `_resetMessageCounter` brings the counter back to zero so test fixtures match across vitest workers; production never calls it.
|
||||
|
||||
The `Message` envelope is the wire shape exchanged between the gateway and clients of `@suaveplan/network-transport-websockets`. Callers MUST treat the envelope as opaque — adding fields here is a breaking change that requires a coordinated bump in the companion client package and a schema-registry compatibility check.
|
||||
@@ -0,0 +1,55 @@
|
||||
# `firePresence`
|
||||
|
||||
## Purpose
|
||||
|
||||
Single side-effect helper that fans out one presence transition (`join` / `leave` / `update`) to every observation sink the gateway supports: the typed `EventBusLike` bus, the OTel `presence_updates_total{kind}` counter, and the optional signed presence webhook. Splitting this out keeps `gateway.ts` under the 300-line cap and lets unit tests exercise the side effects without booting a WebSocket server.
|
||||
|
||||
## Features
|
||||
|
||||
- Lossy by design — every sink is independent; one sink throwing never blocks the others.
|
||||
- Auto-routes `join` / `leave` / `update` to the matching `presence:*` webhook event kind.
|
||||
- Honours the optional `config.events` allowlist on the webhook config.
|
||||
- Emits the typed `ws_gateway:presence` bus event with the right `kind`.
|
||||
- Bumps OTel `presence_updates_total{kind: "update"}` for `update` transitions; the daemon bumps `join` / `leave` directly in `connectClient` / `disconnectClient`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { firePresence } from "@suaveplan/websocket-gateway";
|
||||
|
||||
await firePresence({ clock: Date.now }, "join", "alice", "ops", "online");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { firePresence } from "@suaveplan/websocket-gateway";
|
||||
|
||||
await firePresence(
|
||||
{
|
||||
bus: gatewayBus,
|
||||
presenceWebhook: {
|
||||
url: "https://hooks.example.com/presence",
|
||||
secret: "shared",
|
||||
events: ["presence:join", "presence:leave"],
|
||||
},
|
||||
clock: () => Date.now(),
|
||||
},
|
||||
"update",
|
||||
"alice",
|
||||
"ops",
|
||||
"away"
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `firePresence(deps, kind, clientId, roomId, status): Promise<void>`
|
||||
- `deps.bus` — optional `EventBusLike<GatewayEventMap>`.
|
||||
- `deps.presenceWebhook` — optional `PresenceWebhookConfig`.
|
||||
- `deps.clock` — injectable clock.
|
||||
- `kind: "join" | "leave" | "update"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Both bus emission and webhook delivery are best-effort: presence is reconciled by the next heartbeat / disconnect event, so a single lost transition is acceptable. The function does not own the OTel join/leave counter increments (the daemon emits those next to the actual client lifecycle hooks so the counter reflects the true map size, not the side-effect routing). For `update`, the join/leave hooks haven't fired, so this is the right place to bump the counter.
|
||||
@@ -0,0 +1,55 @@
|
||||
# `deliverPresenceWebhook`
|
||||
|
||||
## Purpose
|
||||
|
||||
Best-effort, signed delivery of a single presence event to a configured webhook receiver. The gateway calls this function on every presence transition (`join` / `leave` / `update`) when a `presenceWebhook` is configured on the daemon. Retry, exponential backoff, and circuit-breaking are owned by `@suaveplan/webhook-client.deliver`, which internally threads `@suaveplan/retry` — the gateway never has to reinvent that policy.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-event opt-in filter: `config.events` constrains which kinds fan out. Default (`undefined`) fans out every kind.
|
||||
- HMAC-SHA-256 signed payloads when `config.secret` is set, matching the proposal's "signed delivery payload" requirement.
|
||||
- Errors are swallowed and routed to an optional `onError(err)` hook so presence — which is inherently lossy — never crashes the daemon.
|
||||
- Deterministic delivery id: `presence-<clientId>-<at>` makes the receiver's idempotency story easy.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { deliverPresenceWebhook } from "@suaveplan/websocket-gateway";
|
||||
|
||||
await deliverPresenceWebhook(
|
||||
{ url: "https://example.com/webhooks/presence", secret: "shared-secret" },
|
||||
{ kind: "presence:join", clientId: "alice", roomId: "ops", status: "online", at: Date.now() }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { deliverPresenceWebhook } from "@suaveplan/websocket-gateway";
|
||||
import { logger } from "@suaveplan/logger";
|
||||
|
||||
await deliverPresenceWebhook(
|
||||
{
|
||||
url: "https://hooks.example.com/presence",
|
||||
secret: process.env.PRESENCE_HMAC,
|
||||
events: ["presence:join", "presence:leave"], // skip 'update' chatter
|
||||
},
|
||||
{
|
||||
kind: "presence:update",
|
||||
clientId: "alice",
|
||||
roomId: undefined,
|
||||
status: "away",
|
||||
at: Date.now(),
|
||||
},
|
||||
(err) => logger.warn("presence webhook delivery failed", { err })
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `deliverPresenceWebhook(config, event, onError?): Promise<void>`
|
||||
- `PresenceWebhookEvent` — `{ kind, clientId, roomId, status, at }` value object.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function never throws — `webhook-client.deliver` already classifies retryable vs terminal status codes and consumes the retry budget internally. Surfacing failures upstream would force the gateway to invent a recovery policy, but presence is best-effort by design (receivers reconcile via the next heartbeat / connection event). Delivery is signed with the same algorithm `webhook-client` defaults to (`sha256`); future stronger algorithms (e.g. ed25519) become a `config.algorithm` switch the moment `webhook-client` exposes one.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Gateway room operations
|
||||
|
||||
## Purpose
|
||||
|
||||
Free-function module containing every room-level mutation the gateway exposes — create / delete / join / leave / broadcast / unicast — extracted from `gateway.ts` so the daemon file stays under the 300-line cap. The functions consume a structurally-typed `RoomOpsDeps` and have no class dependency, which makes them straightforward to unit-test with hand-rolled fakes.
|
||||
|
||||
## Features
|
||||
|
||||
- `createRoom(deps, config)` — capacity check + idempotent registration; throws `ROOM_FULL` once the daemon hits `maxRooms`.
|
||||
- `deleteRoom(rooms, id)` — clears members, drops the entry, decrements the OTel rooms-active gauge.
|
||||
- `joinRoom(deps, roomId, clientId, idempotencyKey?)` — gates through rate-limit + authz, wraps the join in `IdempotencyGuard.execute`, fires the `presence:update`.
|
||||
- `leaveRoom(deps, roomId, clientId)` — symmetric leave + presence fire.
|
||||
- `broadcastToRoom(deps, roomId, senderId, payload, idempotencyKey?)` — gated + idempotent message dispatch returning `{message, recipients}`.
|
||||
- `unicast(deps, senderId, targetId, payload)` — gated unicast.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createRoom, joinRoom } from "@suaveplan/websocket-gateway";
|
||||
|
||||
createRoom(deps, { id: "general" });
|
||||
await joinRoom(deps, "general", "alice");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { broadcastToRoom } from "@suaveplan/websocket-gateway";
|
||||
|
||||
const result = await broadcastToRoom(
|
||||
deps,
|
||||
"general",
|
||||
"alice",
|
||||
{ text: "hi" },
|
||||
"client-supplied-idempotency-key"
|
||||
);
|
||||
// result.recipients excludes alice; result.message is the dispatched envelope.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createRoom(deps, config): Room`
|
||||
- `deleteRoom(rooms, roomId): boolean`
|
||||
- `joinRoom(deps, roomId, clientId, idempotencyKey?): Promise<boolean>`
|
||||
- `leaveRoom(deps, roomId, clientId): Promise<boolean>`
|
||||
- `broadcastToRoom(deps, roomId, senderId, payload, idempotencyKey?): Promise<{message, recipients} | null>`
|
||||
- `unicast(deps, senderId, targetId, payload): Promise<Message | null>`
|
||||
- `RoomOpsDeps` — structurally-typed dependency bundle.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The dependency bundle uses `Pick<>` / `Omit<>` per function so tests can construct minimal fakes — `createRoom` and `deleteRoom` deliberately avoid pulling in the gate/idempotency/presence-fire dependencies that aren't relevant to their pure path. Idempotency keys default to a deterministic `<verb>:<roomId>:<clientId>` shape; per-broadcast they incorporate the daemon's clock so two `broadcastToRoom` calls in the same logical operation are NOT deduplicated unless the caller passes an explicit key (the upstream behaviour).
|
||||
Reference in New Issue
Block a user