Initial 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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user