Initial Commit
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user