142 lines
4.6 KiB
TypeScript
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();
|
|
});
|
|
});
|