assistant-stream
Version:
Streaming utilities for AI assistants
299 lines (265 loc) • 10.3 kB
text/typescript
import { describe, expect, it } from "vitest";
import {
createSSEEventDecoder,
SSEEventDecoder,
SSEEventDecoderError,
} from "./SSEEventDecoder";
describe("SSEEventDecoder", () => {
it("keeps an internally terminated decoder closed for later chunks", () => {
const decoder = createSSEEventDecoder(
{ trailing: "dispatch" },
(event) => event.data === "[DONE]",
);
expect(
decoder.push('data: before\n\ndata: [DONE]\n\nevent: custom\ndata: {"a"'),
).toEqual([{ data: "before" }, { data: "[DONE]" }]);
expect(decoder.push(":1}\n\ndata: 2\n\n")).toEqual([]);
expect(decoder.flush()).toBeNull();
});
it("decodes LF terminated events", () => {
const decoder = new SSEEventDecoder();
expect(decoder.push("data: x\n\n")).toEqual([{ data: "x" }]);
});
it("ignores one initial BOM across chunk boundaries and empty pushes", () => {
const text = "\uFEFFdata: hello\n\n";
for (let split = 0; split <= text.length; split++) {
const decoder = new SSEEventDecoder();
const events = [
...decoder.push(""),
...decoder.push(text.slice(0, split)),
...decoder.push(""),
...decoder.push(text.slice(split)),
];
expect(events).toEqual([{ data: "hello" }]);
}
});
it("preserves a later BOM at the start of a data chunk", () => {
const decoder = new SSEEventDecoder();
expect(decoder.push("data: ")).toEqual([]);
expect(decoder.push("\uFEFFhello\n\n")).toEqual([{ data: "\uFEFFhello" }]);
});
it("does not strip a second BOM or leading whitespace from field names", () => {
for (const prefix of ["\uFEFF\uFEFF", " ", "\n\uFEFF"]) {
const decoder = new SSEEventDecoder();
expect(decoder.push(`${prefix}data: ignored\ndata: kept\n\n`)).toEqual([
{ data: "kept" },
]);
}
});
it("does not strip a BOM from later frames or after flush", () => {
const decoder = new SSEEventDecoder();
expect(decoder.push("\uFEFFdata: first\n\n")).toEqual([{ data: "first" }]);
expect(decoder.push("\uFEFFdata: ignored\ndata: second\n\n")).toEqual([
{ data: "second" },
]);
expect(decoder.flush()).toBeNull();
expect(decoder.push("\uFEFFdata: ignored\ndata: third\n\n")).toEqual([
{ data: "third" },
]);
});
it("decodes CRLF terminated events", () => {
const decoder = new SSEEventDecoder();
expect(decoder.push("data: x\r\n\r\n")).toEqual([{ data: "x" }]);
});
it("decodes CR terminated events", () => {
const decoder = new SSEEventDecoder();
expect(decoder.push("data: x\r\r")).toEqual([{ data: "x" }]);
});
it("handles CRLF split across pushes", () => {
const decoder = new SSEEventDecoder();
expect(decoder.push("data: x\r")).toEqual([]);
expect(decoder.push("\n\r\n")).toEqual([{ data: "x" }]);
});
it("handles CR terminated lines split across pushes", () => {
const decoder = new SSEEventDecoder();
expect(decoder.push("data: x\r")).toEqual([]);
expect(decoder.push("\r")).toEqual([{ data: "x" }]);
});
it("decodes multiple events in one push", () => {
const decoder = new SSEEventDecoder();
expect(decoder.push("data: first\n\ndata: second\n\n")).toEqual([
{ data: "first" },
{ data: "second" },
]);
});
it("joins multiple data lines with a newline", () => {
const decoder = new SSEEventDecoder();
expect(decoder.push("data: first\ndata: second\n\n")).toEqual([
{ data: "first\nsecond" },
]);
});
it("preserves data values without a space after the colon", () => {
const decoder = new SSEEventDecoder();
expect(decoder.push("data:x\n\n")).toEqual([{ data: "x" }]);
});
it("skips comment lines", () => {
const decoder = new SSEEventDecoder();
expect(decoder.push(": heartbeat\ndata: x\n\n")).toEqual([{ data: "x" }]);
});
it("captures event fields and clears them after dispatch", () => {
const decoder = new SSEEventDecoder();
expect(
decoder.push("event: update\ndata: first\n\ndata: second\n\n"),
).toEqual([{ event: "update", data: "first" }, { data: "second" }]);
});
it("persists IDs across frames", () => {
const decoder = new SSEEventDecoder();
expect(decoder.push("id: 1\ndata: first\n\ndata: second\n\n")).toEqual([
{ data: "first", id: "1" },
{ data: "second", id: "1" },
]);
});
it("parses numeric retries and ignores non-numeric retries", () => {
const decoder = new SSEEventDecoder();
expect(
decoder.push(
"retry: 1000\ndata: first\n\nretry: invalid\ndata: second\n\n",
),
).toEqual([
{ data: "first", retry: 1000 },
{ data: "second", retry: 1000 },
]);
});
it("ignores ids containing NUL and keeps ids containing spaces", () => {
const decoder = new SSEEventDecoder();
expect(
decoder.push(
"id: ok\ndata: a\n\nid: bad\u0000id\ndata: b\n\nid: spaced id\ndata: c\n\n",
),
).toEqual([
{ data: "a", id: "ok" },
{ data: "b", id: "ok" },
{ data: "c", id: "spaced id" },
]);
});
it("ignores non-digit and unsafe-integer retry values", () => {
const decoder = new SSEEventDecoder();
expect(
decoder.push(
"retry: 1000\ndata: a\n\nretry: -1\ndata: b\n\nretry: 1.5\ndata: c\n\nretry: 1e3\ndata: d\n\nretry:\ndata: e\n\n" +
`retry: ${"9".repeat(400)}\ndata: f\n\n`,
),
).toEqual([
{ data: "a", retry: 1000 },
{ data: "b", retry: 1000 },
{ data: "c", retry: 1000 },
{ data: "d", retry: 1000 },
{ data: "e", retry: 1000 },
{ data: "f", retry: 1000 },
]);
});
it("does not dispatch blank frames", () => {
const decoder = new SSEEventDecoder();
expect(decoder.push("event: update\n\ndata: x\n\n")).toEqual([
{ data: "x" },
]);
});
it("preserves a pending LF across empty pushes", () => {
const decoder = new SSEEventDecoder();
expect(decoder.push("data: x\r")).toEqual([]);
expect(decoder.push("")).toEqual([]);
expect(decoder.push("\n\r\n")).toEqual([{ data: "x" }]);
});
it("drops unterminated trailing data by default", () => {
const decoder = new SSEEventDecoder();
expect(decoder.push("data: x")).toEqual([]);
expect(decoder.flush()).toBeNull();
});
it("dispatches an unterminated final data line when configured", () => {
const decoder = new SSEEventDecoder({ trailing: "dispatch" });
expect(decoder.push("data: x")).toEqual([]);
expect(decoder.flush()).toEqual({ data: "x" });
});
it("makes flush idempotent", () => {
const decoder = new SSEEventDecoder({ trailing: "dispatch" });
decoder.push("data: x");
expect(decoder.flush()).toEqual({ data: "x" });
expect(decoder.flush()).toBeNull();
});
it("rejects an unterminated line that exceeds the configured limit", () => {
const decoder = new SSEEventDecoder({ maxLineLength: 8 });
expect(decoder.push("data: ")).toEqual([]);
expect(() => decoder.push("abc")).toThrow(
"SSE line exceeds maxLineLength (9 > 8)",
);
});
it("rejects a complete line that exceeds the configured limit", () => {
const decoder = new SSEEventDecoder({ maxLineLength: 8 });
expect(() => decoder.push("data: abc\n")).toThrow(
"SSE line exceeds maxLineLength (9 > 8)",
);
});
it("rejects an event whose data lines exceed the configured limit", () => {
const decoder = new SSEEventDecoder({ maxEventLength: 2 });
expect(decoder.push("data: a\n")).toEqual([]);
expect(() => decoder.push("data: b\n")).toThrow(
"SSE event exceeds maxEventLength (3 > 2)",
);
});
it("counts parsed data values toward the configured event limit", () => {
const decoder = new SSEEventDecoder({ maxEventLength: 3 });
expect(decoder.push("data: abc\n\n")).toEqual([{ data: "abc" }]);
});
it("rejects an unfinished data line that exceeds the event limit", () => {
const decoder = new SSEEventDecoder({ maxEventLength: 3 });
expect(decoder.push("data: ab")).toEqual([]);
expect(() => decoder.push("cd")).toThrow(
"SSE event exceeds maxEventLength (4 > 3)",
);
});
it("does not dispatch a partial frame after event overflow", () => {
const decoder = new SSEEventDecoder({
trailing: "dispatch",
maxEventLength: 2,
});
expect(decoder.push("data: a\n")).toEqual([]);
expect(() => decoder.push("data: b\n")).toThrow(
"SSE event exceeds maxEventLength (3 > 2)",
);
expect(decoder.flush()).toBeNull();
});
it("validates configured limits", () => {
expect(() => new SSEEventDecoder({ maxLineLength: 0 })).toThrowError(
SSEEventDecoderError,
);
expect(() => new SSEEventDecoder({ maxEventLength: Infinity })).toThrow(
"maxEventLength must be a positive safe integer",
);
});
it.each(["\n", "\r", "\r\n"])(
"decodes fragmented events with %j delimiters",
(newline) => {
const data = "x".repeat(4 * 1024);
const wire = `\uFEFFid: 7${newline}retry: 1000${newline}event: update${newline}data: ${data}${newline}data: end${newline}${newline}data: next${newline}${newline}`;
for (const chunkSize of [1, 1024, 4093]) {
const decoder = new SSEEventDecoder();
const events = [];
for (let i = 0; i < wire.length; i += chunkSize) {
events.push(...decoder.push(wire.slice(i, i + chunkSize)));
events.push(...decoder.push(""));
}
expect(events).toEqual([
{ id: "7", retry: 1000, event: "update", data: `${data}\nend` },
{ id: "7", retry: 1000, data: "next" },
]);
expect(decoder.flush()).toBeNull();
}
},
);
it.each(["drop", "dispatch"] as const)(
"clears fragmented trailing data with the %s policy",
(trailing) => {
const decoder = new SSEEventDecoder({ trailing });
const data = "x".repeat(8192);
decoder.push("data: ");
for (let i = 0; i < data.length; i += 128) {
expect(decoder.push(data.slice(i, i + 128))).toEqual([]);
}
expect(decoder.flush()).toEqual(
trailing === "dispatch" ? { data } : null,
);
expect(decoder.flush()).toBeNull();
expect(decoder.push("data: fresh\n\n")).toEqual([{ data: "fresh" }]);
},
);
});