Initial Commit
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("websocket-gateway dep-graph", () => {
|
||||
it("must not have circular dependencies", { timeout: 30000 }, async () => {
|
||||
const mod = await import("../src/index.js");
|
||||
expect(mod).toBeDefined();
|
||||
expect(mod.WebSocketGateway).toBeDefined();
|
||||
expect(mod.WS_GATEWAY_CODES).toBeDefined();
|
||||
expect(mod.WsGatewayError).toBeDefined();
|
||||
expect(mod.Room).toBeDefined();
|
||||
expect(mod.PresenceTracker).toBeDefined();
|
||||
expect(mod.Channel).toBeDefined();
|
||||
expect(mod.ChannelManager).toBeDefined();
|
||||
expect(mod.createMessage).toBeDefined();
|
||||
expect(mod.handleHealth).toBeDefined();
|
||||
expect(mod.classifyHealthRequest).toBeDefined();
|
||||
expect(mod.buildHealthResponse).toBeDefined();
|
||||
expect(mod.HEALTH_PATHS).toBeDefined();
|
||||
expect(mod.WebSocketGatewayConfigSchema).toBeDefined();
|
||||
expect(mod.PresenceWebhookConfigSchema).toBeDefined();
|
||||
expect(mod.InboundFrameSchema).toBeDefined();
|
||||
expect(mod.defaultAuthVerifier).toBeDefined();
|
||||
expect(mod.defaultAuthorizationCheck).toBeDefined();
|
||||
expect(mod.defaultRateLimitGate).toBeDefined();
|
||||
expect(mod.defaultAuditLogger).toBeDefined();
|
||||
expect(mod.resolveConfig).toBeDefined();
|
||||
expect(mod.createHeartbeat).toBeDefined();
|
||||
expect(mod.gate).toBeDefined();
|
||||
expect(mod.audit).toBeDefined();
|
||||
expect(mod.firePresence).toBeDefined();
|
||||
expect(mod.deliverPresenceWebhook).toBeDefined();
|
||||
expect(mod.bootWsServer).toBeDefined();
|
||||
expect(mod.runAuthHandshake).toBeDefined();
|
||||
expect(mod.runStart).toBeDefined();
|
||||
expect(mod.runStop).toBeDefined();
|
||||
expect(mod.preflightStart).toBeDefined();
|
||||
expect(mod.recordStartCompleted).toBeDefined();
|
||||
expect(mod.buildGatewayDepFactories).toBeDefined();
|
||||
expect(mod.connectClient).toBeDefined();
|
||||
expect(mod.disconnectClient).toBeDefined();
|
||||
expect(mod.createRoom).toBeDefined();
|
||||
expect(mod.deleteRoom).toBeDefined();
|
||||
expect(mod.joinRoom).toBeDefined();
|
||||
expect(mod.leaveRoom).toBeDefined();
|
||||
expect(mod.broadcastToRoom).toBeDefined();
|
||||
expect(mod.unicast).toBeDefined();
|
||||
expect(mod.subscribeToChannel).toBeDefined();
|
||||
expect(mod.unsubscribeFromChannel).toBeDefined();
|
||||
expect(mod.publishToChannel).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { WebSocket as WsClient } from "ws";
|
||||
import { WebSocketGateway } from "../src/gateway/gateway.js";
|
||||
import { makeRealWsServerFactory } from "./setup/real-ws-adapter.js";
|
||||
|
||||
function baseUrl(address: string, port: number): string {
|
||||
const host =
|
||||
address === "::" || address === "0.0.0.0" ? "127.0.0.1" : address;
|
||||
return `http://${host}:${port}`;
|
||||
}
|
||||
|
||||
function wsUrl(address: string, port: number): string {
|
||||
const host =
|
||||
address === "::" || address === "0.0.0.0" ? "127.0.0.1" : address;
|
||||
return `ws://${host}:${port}`;
|
||||
}
|
||||
|
||||
let gateway: WebSocketGateway;
|
||||
let address = "";
|
||||
let port = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
let snap: () => ReturnType<WebSocketGateway["snapshot"]> = () => ({
|
||||
running: false,
|
||||
disposed: false,
|
||||
clientCount: 0,
|
||||
roomCount: 0,
|
||||
startedAt: undefined,
|
||||
});
|
||||
const factory = makeRealWsServerFactory(() => snap());
|
||||
gateway = new WebSocketGateway({
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
wsServerFactory: factory,
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
snap = () => gateway.snapshot();
|
||||
const result = await gateway.start();
|
||||
address = result.address;
|
||||
port = result.port;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await gateway?.stop();
|
||||
});
|
||||
|
||||
describe("integration: real ws server boot + health probes", () => {
|
||||
it("/health returns 200 ok while running", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
const res = await fetch(`${baseUrl(address, port)}/health`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { status: string; running: boolean };
|
||||
expect(body.status).toBe("ok");
|
||||
expect(body.running).toBe(true);
|
||||
});
|
||||
|
||||
it("/ready returns 200 once started", { timeout: 10_000 }, async () => {
|
||||
const res = await fetch(`${baseUrl(address, port)}/ready`);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("/live returns 200", { timeout: 10_000 }, async () => {
|
||||
const res = await fetch(`${baseUrl(address, port)}/live`);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration: 3 real ws clients connect, join a room, broadcast", () => {
|
||||
it("all 3 clients receive broadcasts to a shared room", {
|
||||
timeout: 15_000,
|
||||
}, async () => {
|
||||
// Open 3 real WebSocket connections to the gateway.
|
||||
const c1 = new WsClient(wsUrl(address, port));
|
||||
const c2 = new WsClient(wsUrl(address, port));
|
||||
const c3 = new WsClient(wsUrl(address, port));
|
||||
const all = [c1, c2, c3];
|
||||
await Promise.all(
|
||||
all.map(
|
||||
(c) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
c.once("open", () => resolve());
|
||||
c.once("error", reject);
|
||||
})
|
||||
)
|
||||
);
|
||||
// Wait a tick for the daemon to register all three sockets.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const ids = Array.from(
|
||||
{ length: 3 },
|
||||
(_, i) => `wsc-${i + 1}` as const
|
||||
);
|
||||
expect(gateway.clientCount).toBe(3);
|
||||
// Create the room and join all three clients to it.
|
||||
gateway.createRoom({ id: "shared", maxClients: 10 });
|
||||
for (const id of ids) {
|
||||
const joined = await gateway.joinRoom("shared", id);
|
||||
expect(joined).toBe(true);
|
||||
}
|
||||
expect(gateway.getRoom("shared")?.size).toBe(3);
|
||||
// Broadcast from id-1; the room returns ids of (id-2, id-3).
|
||||
const result = await gateway.broadcastToRoom("shared", ids[0]!, {
|
||||
text: "hello",
|
||||
});
|
||||
expect(result?.recipients.sort()).toEqual([ids[1]!, ids[2]!]);
|
||||
expect(result?.message.payload).toEqual({ text: "hello" });
|
||||
for (const c of all) c.close();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(gateway.clientCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import type {
|
||||
Handshake,
|
||||
WsServerCallbacks,
|
||||
WsServerFactory,
|
||||
WsServerLike,
|
||||
WsServerStartOptions,
|
||||
WsServerStartResult,
|
||||
WsSocketLike,
|
||||
} from "../../src/types/types.js";
|
||||
|
||||
/**
|
||||
* In-memory deterministic `WsServerLike` implementation. The
|
||||
* production wiring binds the real `ws.WebSocketServer`, but unit
|
||||
* tests + the `tests/integration.test.ts` 3-client scenario drive
|
||||
* lifecycle events through this real-implementation fake. Per the
|
||||
* CLAUDE.md "no mocks" rule, the daemon never sees `vi.mock` — only
|
||||
* an alternative concrete implementation.
|
||||
*/
|
||||
export class FakeWsServer implements WsServerLike {
|
||||
public started = false;
|
||||
public stopped = false;
|
||||
public lastStartOptions: WsServerStartOptions | undefined;
|
||||
public callbacks: WsServerCallbacks | undefined;
|
||||
public boundAddress = "127.0.0.1";
|
||||
public boundPort = 0;
|
||||
public startError: Error | undefined;
|
||||
private readonly sockets = new Map<string, FakeWsSocket>();
|
||||
|
||||
public async start(
|
||||
options: WsServerStartOptions
|
||||
): Promise<WsServerStartResult> {
|
||||
if (this.startError) throw this.startError;
|
||||
this.lastStartOptions = options;
|
||||
this.callbacks = options.callbacks;
|
||||
this.started = true;
|
||||
return { address: this.boundAddress, port: this.boundPort };
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
this.stopped = true;
|
||||
for (const id of [...this.sockets.keys()])
|
||||
this.disconnect(id, 1001, "stop");
|
||||
}
|
||||
|
||||
public async closeAll(code = 1001, reason = "shutdown"): Promise<void> {
|
||||
for (const id of [...this.sockets.keys()])
|
||||
this.disconnect(id, code, reason);
|
||||
}
|
||||
|
||||
/** Test affordance: simulate a new client connecting. */
|
||||
public connect(id: string, handshake?: Partial<Handshake>): FakeWsSocket {
|
||||
const socket = new FakeWsSocket(id, () =>
|
||||
this.disconnect(id, 1000, "client-close")
|
||||
);
|
||||
this.sockets.set(id, socket);
|
||||
const h: Handshake = {
|
||||
headers: handshake?.headers ?? {},
|
||||
remoteAddress: handshake?.remoteAddress,
|
||||
url: handshake?.url ?? "/",
|
||||
query: handshake?.query ?? {},
|
||||
};
|
||||
this.callbacks?.onConnection(socket, h);
|
||||
return socket;
|
||||
}
|
||||
|
||||
public disconnect(id: string, code = 1000, reason = "close"): void {
|
||||
const socket = this.sockets.get(id);
|
||||
if (!socket) return;
|
||||
this.sockets.delete(id);
|
||||
socket.closed = true;
|
||||
this.callbacks?.onClose(id, code, reason);
|
||||
}
|
||||
|
||||
public message(id: string, payload: string): void {
|
||||
this.callbacks?.onMessage(id, payload);
|
||||
}
|
||||
|
||||
public errorOn(id: string, err: Error): void {
|
||||
this.callbacks?.onError(id, err);
|
||||
}
|
||||
|
||||
public getSocket(id: string): FakeWsSocket | undefined {
|
||||
return this.sockets.get(id);
|
||||
}
|
||||
|
||||
public socketCount(): number {
|
||||
return this.sockets.size;
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeWsSocket implements WsSocketLike {
|
||||
public readonly id: string;
|
||||
public readonly sent: string[] = [];
|
||||
public closed = false;
|
||||
public readonly remoteAddress = "127.0.0.1";
|
||||
private readonly onClose: () => void;
|
||||
|
||||
public constructor(id: string, onClose: () => void) {
|
||||
this.id = id;
|
||||
this.onClose = onClose;
|
||||
}
|
||||
|
||||
public send(payload: string): void {
|
||||
if (!this.closed) this.sent.push(payload);
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
/** Factory producing a fresh `FakeWsServer` per construction. */
|
||||
export function makeFakeWsServerFactory(): {
|
||||
readonly instances: FakeWsServer[];
|
||||
readonly factory: WsServerFactory;
|
||||
} {
|
||||
const instances: FakeWsServer[] = [];
|
||||
return {
|
||||
instances,
|
||||
factory: () => {
|
||||
const inst = new FakeWsServer();
|
||||
instances.push(inst);
|
||||
return inst;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { createServer, type Server as HttpServer } from "node:http";
|
||||
import { type WebSocket, WebSocketServer } from "ws";
|
||||
import { handleHealth } from "../../src/health/health.js";
|
||||
import type {
|
||||
Handshake,
|
||||
WsServerFactory,
|
||||
WsServerLike,
|
||||
WsServerStartOptions,
|
||||
WsServerStartResult,
|
||||
WsSocketLike,
|
||||
} from "../../src/types/types.js";
|
||||
|
||||
/**
|
||||
* Production-grade `WsServerLike` implementation backed by the real
|
||||
* `ws` package and a Node `http.Server`. The HTTP server also routes
|
||||
* `/health` / `/ready` / `/live` probes through the daemon's snapshot
|
||||
* so the integration test can hit those endpoints from the same port.
|
||||
*/
|
||||
export class RealWsServer implements WsServerLike {
|
||||
#http: HttpServer | undefined;
|
||||
#wss: WebSocketServer | undefined;
|
||||
readonly #sockets = new Map<
|
||||
string,
|
||||
{ socket: WebSocket; like: WsSocketLike }
|
||||
>();
|
||||
readonly #snapshot: () => {
|
||||
running: boolean;
|
||||
disposed: boolean;
|
||||
clientCount: number;
|
||||
roomCount: number;
|
||||
startedAt: number | undefined;
|
||||
};
|
||||
#nextId = 0;
|
||||
|
||||
public constructor(
|
||||
snapshot: () => {
|
||||
running: boolean;
|
||||
disposed: boolean;
|
||||
clientCount: number;
|
||||
roomCount: number;
|
||||
startedAt: number | undefined;
|
||||
}
|
||||
) {
|
||||
this.#snapshot = snapshot;
|
||||
}
|
||||
|
||||
public async start(
|
||||
opts: WsServerStartOptions
|
||||
): Promise<WsServerStartResult> {
|
||||
const http = createServer((req, res) => {
|
||||
const handled = handleHealth(req.url, this.#snapshot());
|
||||
if (handled) {
|
||||
res.writeHead(handled.status, {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
res.end(handled.body);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { "content-type": "text/plain" });
|
||||
res.end("not found");
|
||||
});
|
||||
const wss = new WebSocketServer({ server: http });
|
||||
wss.on("connection", (socket, request) => {
|
||||
const id = `wsc-${++this.#nextId}`;
|
||||
const url = request.url ?? "/";
|
||||
const q = new URL(url, "http://x").searchParams;
|
||||
const handshake: Handshake = {
|
||||
headers: Object.fromEntries(
|
||||
Object.entries(request.headers).map(([k, v]) => [
|
||||
k,
|
||||
Array.isArray(v) ? v.join(",") : v,
|
||||
])
|
||||
),
|
||||
remoteAddress: request.socket.remoteAddress,
|
||||
url,
|
||||
query: Object.fromEntries(q.entries()),
|
||||
};
|
||||
const like: WsSocketLike = {
|
||||
id,
|
||||
remoteAddress: request.socket.remoteAddress,
|
||||
send: (payload) => {
|
||||
if (socket.readyState === socket.OPEN) socket.send(payload);
|
||||
},
|
||||
close: (code, reason) => socket.close(code, reason),
|
||||
};
|
||||
this.#sockets.set(id, { socket, like });
|
||||
opts.callbacks.onConnection(like, handshake);
|
||||
socket.on("message", (data) =>
|
||||
opts.callbacks.onMessage(id, data.toString())
|
||||
);
|
||||
socket.on("close", (code, reason) => {
|
||||
this.#sockets.delete(id);
|
||||
opts.callbacks.onClose(id, code, reason.toString());
|
||||
});
|
||||
socket.on("error", (err) => opts.callbacks.onError(id, err));
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onErr = (err: Error): void => reject(err);
|
||||
http.once("error", onErr);
|
||||
http.listen(opts.port, opts.host, () => {
|
||||
http.removeListener("error", onErr);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
this.#http = http;
|
||||
this.#wss = wss;
|
||||
const addr = http.address() as { address: string; port: number };
|
||||
return { address: addr.address, port: addr.port };
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.#wss?.close(() => resolve());
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
const http = this.#http;
|
||||
if (!http) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
http.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
public async closeAll(code = 1001, reason = "shutdown"): Promise<void> {
|
||||
for (const { socket } of this.#sockets.values())
|
||||
socket.close(code, reason);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a real-ws factory bound to a snapshot accessor. */
|
||||
export function makeRealWsServerFactory(
|
||||
snapshot: () => {
|
||||
running: boolean;
|
||||
disposed: boolean;
|
||||
clientCount: number;
|
||||
roomCount: number;
|
||||
startedAt: number | undefined;
|
||||
}
|
||||
): WsServerFactory {
|
||||
return () => new RealWsServer(snapshot);
|
||||
}
|
||||
Reference in New Issue
Block a user