Files
2026-05-27 23:29:53 +01:00

168 lines
6.1 KiB
TypeScript

import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import { createRingBuffer } from "../../src/buffer/ring-buffer.js";
import { createInProcessFanout } from "../../src/fanout/in-process.js";
import { parseSseStream } from "../../src/formatter/parse.js";
import { createSseRoute } from "../../src/middleware/sse-handler.js";
import { createSseService } from "../../src/services/service.js";
/**
* End-to-end integration coverage. A real Hono app, a real fetch
* round-trip (via `app.fetch` over a `Request`), and a real ReadableStream
* client reader that decodes the SSE wire format using `parseSseStream`.
*/
describe("api-plugin-sse — full-stream integration", () => {
it("end-to-end fetch subscriber receives three ordered events", {
timeout: 10_000,
}, async () => {
let n = 0;
const service = createSseService({
buffer: createRingBuffer(100),
fanout: createInProcessFanout(),
idGenerator: () => `evt-${++n}`,
maxConnectionsPerPrincipal: 16,
maxTotalConnections: 100,
});
const app = new Hono();
app.get(
"/sse/:channel",
createSseRoute(service, {
heartbeatIntervalMs: 60_000,
queueHighWaterMark: 64,
overflowPolicy: "drop-oldest",
})
);
const controller = new AbortController();
const res = await app.request(
new Request("http://test.local/sse/notifications", {
signal: controller.signal,
})
);
expect(res.status).toBe(200);
// Broadcast on a small delay so the subscriber has installed.
setTimeout(() => {
service.broadcast("notifications", { data: { n: 1 } });
service.broadcast("notifications", { data: { n: 2 } });
service.broadcast("notifications", { data: { n: 3 } });
}, 10);
const body = res.body;
if (body === null) throw new Error("no body");
const reader = body.getReader();
const collected = [] as { id: string; data: unknown }[];
for await (const event of parseSseStream(reader)) {
collected.push({
id: event.id,
data: JSON.parse(event.data as string),
});
if (collected.length >= 3) break;
}
controller.abort();
await reader.cancel().catch(() => undefined);
// service.subscribe consumes one id for the connection handle
// (evt-1), then broadcasts consume evt-2/3/4 for the events.
expect(collected.map((c) => c.id)).toEqual(["evt-2", "evt-3", "evt-4"]);
expect(collected.map((c) => (c.data as { n: number }).n)).toEqual([
1, 2, 3,
]);
await service.close();
});
it("reconnect with Last-Event-ID skips delivered events", {
timeout: 10_000,
}, async () => {
let n = 0;
const service = createSseService({
buffer: createRingBuffer(100),
fanout: createInProcessFanout(),
idGenerator: () => `evt-${++n}`,
maxConnectionsPerPrincipal: 16,
maxTotalConnections: 100,
});
const app = new Hono();
app.get(
"/sse/:channel",
createSseRoute(service, {
heartbeatIntervalMs: 60_000,
queueHighWaterMark: 64,
overflowPolicy: "drop-oldest",
})
);
// Pre-load three broadcasts into the ring buffer.
service.broadcast("c", { data: { n: 1 } });
service.broadcast("c", { data: { n: 2 } });
service.broadcast("c", { data: { n: 3 } });
const controller = new AbortController();
const res = await app.request(
new Request("http://test.local/sse/c", {
headers: { "Last-Event-ID": "evt-2" },
signal: controller.signal,
})
);
const body = res.body;
if (body === null) throw new Error("no body");
const reader = body.getReader();
const collected = [] as { id: string }[];
for await (const event of parseSseStream(reader)) {
collected.push({ id: event.id });
if (collected.length >= 1) break;
}
controller.abort();
await reader.cancel().catch(() => undefined);
expect(collected.map((c) => c.id)).toEqual(["evt-3"]);
await service.close();
});
it("heartbeat tick via injected interval triggers a comment frame", {
timeout: 10_000,
}, async () => {
const service = createSseService({
buffer: createRingBuffer(10),
fanout: createInProcessFanout(),
idGenerator: () => "x",
maxConnectionsPerPrincipal: 16,
maxTotalConnections: 100,
});
const app = new Hono();
let fire: (() => void) | undefined;
app.get(
"/sse/:channel",
createSseRoute(service, {
heartbeatIntervalMs: 30_000,
queueHighWaterMark: 64,
overflowPolicy: "drop-oldest",
setIntervalImpl: (fn) => {
fire = fn;
return { mock: true };
},
clearIntervalImpl: () => {
fire = undefined;
},
})
);
const controller = new AbortController();
const res = await app.request(
new Request("http://t/sse/c", { signal: controller.signal })
);
const body = res.body;
if (body === null) throw new Error("no body");
const reader = body.getReader();
// Allow stream install
await new Promise((r) => setTimeout(r, 10));
fire?.();
const { value } = await reader.read();
const decoder = new TextDecoder();
const text = decoder.decode(value ?? new Uint8Array());
expect(text).toContain(": heartbeat");
controller.abort();
await reader.cancel().catch(() => undefined);
await service.close();
});
});