132 lines
5.2 KiB
TypeScript
132 lines
5.2 KiB
TypeScript
/**
|
|
* THE EVENT BUS + NOTIFICATION CHANNEL (push-architecture slice 1,
|
|
* 2026-10-02): the bus is pure core law (typed, globalThis-shared, error-
|
|
* isolated); the SSE seam is one connection per open window — frames in,
|
|
* cancel out, bus untouched. These pins are the channel's contract: every
|
|
* future consumer (chat view, sidebar, dock) builds on exactly this.
|
|
*/
|
|
import assert from "node:assert/strict";
|
|
import { describe, it } from "node:test";
|
|
import { emit, subscribe, type ServerEvent } from "../src/core/events.ts";
|
|
import { eventsResponse } from "../src/server/events-stream.ts";
|
|
|
|
// ─── the bus ────────────────────────────────────────────────────────────────
|
|
|
|
describe("event bus", () => {
|
|
it("delivers typed events with their payload to every subscriber", () => {
|
|
const got: ServerEvent[] = [];
|
|
const off = subscribe((e) => got.push(e));
|
|
emit("turn-started", { chatId: "chat_a" });
|
|
emit("jobs-changed", { chatId: "chat_a" });
|
|
emit("activity-changed", {});
|
|
off();
|
|
assert.deepEqual(got, [
|
|
{ type: "turn-started", chatId: "chat_a" },
|
|
{ type: "jobs-changed", chatId: "chat_a" },
|
|
{ type: "activity-changed" },
|
|
]);
|
|
});
|
|
|
|
it("unsubscribe stops delivery, exactly once", () => {
|
|
let calls = 0;
|
|
const off = subscribe(() => calls++);
|
|
emit("chats-changed", {});
|
|
off();
|
|
emit("chats-changed", {});
|
|
assert.equal(calls, 1);
|
|
});
|
|
|
|
it("one listener throwing NEVER breaks the others (error isolation)", () => {
|
|
const got: string[] = [];
|
|
subscribe(() => {
|
|
throw new Error("listener bug");
|
|
});
|
|
const off = subscribe((e) => got.push(e.type));
|
|
emit("turn-settled", { chatId: "chat_b" });
|
|
off();
|
|
assert.deepEqual(got, ["turn-settled"]);
|
|
});
|
|
|
|
it("emit with zero subscribers is a silent no-op", () => {
|
|
assert.doesNotThrow(() => emit("chats-changed", {}));
|
|
});
|
|
|
|
it("the registry is globalThis-backed — the Next multi-entry law (turn-liveness discipline)", () => {
|
|
// Every module instance must share ONE bus or nothing propagates
|
|
// (the original incident: page render vs route each held a private
|
|
// singleton). The key IS the contract.
|
|
const g = globalThis as { __lhEventBus?: unknown };
|
|
assert.ok(g.__lhEventBus instanceof Set, "the bus lives on globalThis");
|
|
});
|
|
|
|
it("an unsubscribe DURING emit is safe (snapshot iteration)", () => {
|
|
const got: string[] = [];
|
|
let off2: () => void = () => {};
|
|
const off1 = subscribe(() => off2()); // listener 2 leaves mid-emit
|
|
off2 = subscribe((e) => got.push(e.type));
|
|
emit("chats-changed", {});
|
|
off1();
|
|
off2();
|
|
assert.deepEqual(got, ["chats-changed"], "listener 2 still got THIS event (snapshot), no crash");
|
|
});
|
|
});
|
|
|
|
// ─── the SSE seam ───────────────────────────────────────────────────────────
|
|
|
|
function decode(chunk: Uint8Array): string {
|
|
return new TextDecoder().decode(chunk);
|
|
}
|
|
|
|
describe("eventsResponse (the SSE seam)", () => {
|
|
it("holds an SSE response: event frames as they are emitted", async () => {
|
|
const res = eventsResponse();
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.headers.get("Content-Type"), "text/event-stream");
|
|
assert.equal(res.headers.get("Cache-Control"), "no-cache, no-transform");
|
|
assert.equal(res.headers.get("X-Accel-Buffering"), "no");
|
|
const reader = res.body!.getReader();
|
|
// The listener attached synchronously in start() — emit NOW lands
|
|
// as a queued chunk (no race, single-threaded).
|
|
emit("turn-started", { chatId: "chat_x" });
|
|
const { value, done } = await reader.read();
|
|
assert.equal(done, false);
|
|
assert.equal(
|
|
decode(value!),
|
|
'event: turn-started\ndata: {"chatId":"chat_x"}\n\n',
|
|
"one parseable SSE frame: type on the event line, payload on the data line",
|
|
);
|
|
await reader.cancel();
|
|
});
|
|
|
|
it("cancel() unsubscribes — later emits reach OTHER windows only", async () => {
|
|
const first = eventsResponse();
|
|
const second = eventsResponse();
|
|
const r1 = first.body!.getReader();
|
|
const r2 = second.body!.getReader();
|
|
await r1.cancel(); // window one goes away
|
|
emit("turn-settled", { chatId: "chat_y" });
|
|
// window two still receives…
|
|
const { value } = await r2.read();
|
|
assert.match(decode(value!), /event: turn-settled/);
|
|
// …and window one's stream is finished — no frame, just done.
|
|
const after = await r1.read();
|
|
assert.equal(after.done, true, "the cancelled connection carries nothing further");
|
|
await r2.cancel();
|
|
});
|
|
|
|
it("the connection ends WITHOUT a [DONE] sentinel — the client owns the lifetime", async () => {
|
|
// A turn's stream terminates itself; the notification channel never
|
|
// self-closes. Pin the difference: settled events do NOT close the
|
|
// stream — it stays live for the next event.
|
|
const res = eventsResponse();
|
|
const reader = res.body!.getReader();
|
|
emit("turn-settled", { chatId: "chat_z" });
|
|
await reader.read();
|
|
emit("chats-changed", {});
|
|
const { value, done } = await reader.read();
|
|
assert.equal(done, false, "the channel survives every event");
|
|
assert.match(decode(value!), /event: chats-changed/);
|
|
await reader.cancel();
|
|
});
|
|
});
|