Files
suaveplan/packages/api-stack/api/api-ws-bun/tests/integration/end-to-end.test.ts
2026-05-27 23:29:53 +01:00

142 lines
4.6 KiB
TypeScript

import type { Principal, PrincipalId } from "@suaveplan/api-types/principal";
import type { TenantId } from "@suaveplan/api-types/tenant";
import {
afterAll,
beforeAll,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
type BunWsEngine,
type BunWsSocketSink,
createBunWsEngine,
} from "../../src/engine/bun-ws-engine.js";
const tenant = "tenant-int" as unknown as TenantId;
const principal: Principal = {
id: "p-int" as unknown as PrincipalId,
scopes: [],
claims: {},
};
interface BunServeShim {
upgrade(req: Request, ctx?: { data?: Record<string, unknown> }): boolean;
publish?: unknown;
stop(graceful?: boolean): void;
hostname: string;
port: number;
}
// Bun is available when this test is run under Bun. Vitest also runs under
// Bun in this monorepo. Skip if Bun.serve is missing (e.g. pure Node).
const bunGlobal = (
globalThis as unknown as {
Bun?: {
serve(opts: unknown): BunServeShim;
};
}
).Bun;
const shouldRun = bunGlobal !== undefined;
describe.runIf(shouldRun)("end-to-end Bun.serve WS", () => {
let server: BunServeShim | null = null;
let engine: BunWsEngine<unknown>;
let port = 0;
beforeAll(async () => {
engine = createBunWsEngine({
reconnectTokenSecret: new Uint8Array(32).fill(7),
heartbeatMs: 5_000,
maxQueuedMessages: 32,
});
// Build a Bun.serve wiring that adapts every accepted ws into a
// BunWsSocketSink and calls engine.attach on upgrade.
server = bunGlobal!.serve({
port: 0,
fetch(
req: Request,
srv: {
upgrade(
req: Request,
ctx?: { data?: Record<string, unknown> }
): boolean;
}
): Response | undefined {
const url = new URL(req.url);
if (url.pathname === "/ws") {
const id = url.searchParams.get("id") ?? "anon";
const ok = srv.upgrade(req, { data: { id } });
return ok
? undefined
: new Response("upgrade failed", { status: 400 });
}
return new Response("ok");
},
websocket: {
open(ws: {
data: { id: string };
send(data: string): void;
close(code: number, reason?: string): void;
ping?(): void;
}): void {
const sink: BunWsSocketSink = {
send: (data: string) => ws.send(data),
close: (code: number, reason?: string) =>
ws.close(code, reason),
ping: () => ws.ping?.(),
};
const conn = engine.attach({
id: ws.data.id,
principal,
tenantId: tenant,
capabilities: new Set(),
tokenExpiresAt: Number.MAX_SAFE_INTEGER,
sink,
});
conn.subscribe("orders");
},
message(): void {
/* unused for this smoke */
},
close(): void {
/* unused for this smoke */
},
},
});
port = server.port;
});
afterAll(async () => {
server?.stop(true);
await engine.dispose();
});
it("handshake → subscribe → broadcast → receive over a real socket", {
timeout: 30_000,
}, async () => {
const url = `ws://${server!.hostname}:${port}/ws?id=client-1`;
const received: string[] = [];
const ws = new WebSocket(url);
await new Promise<void>((resolve, reject) => {
ws.onopen = () => resolve();
ws.onerror = (err) => reject(err);
});
ws.onmessage = (ev) => {
received.push(typeof ev.data === "string" ? ev.data : "");
};
// Wait one microtask so engine.attach + subscribe have run.
await new Promise((r) => setTimeout(r, 50));
engine.broadcast({ tenant, capabilities: new Set() }, "orders", {
tenant,
payload: { hello: "world" },
});
await new Promise((r) => setTimeout(r, 100));
expect(received.length).toBeGreaterThan(0);
const parsed = JSON.parse(received[0] as string);
expect(parsed.topic).toBe("orders");
ws.close();
});
});