@statewalker/webrun-http-events 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/dist/index.d.mts +141 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +261 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +37 -0
- package/src/broker.test.ts +192 -0
- package/src/broker.ts +121 -0
- package/src/client.test.ts +216 -0
- package/src/client.ts +133 -0
- package/src/handler.test.ts +191 -0
- package/src/handler.ts +166 -0
- package/src/index.ts +7 -0
- package/src/sse.test.ts +56 -0
- package/src/sse.ts +36 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { newBroker } from "./broker.js";
|
|
3
|
+
import { createSseSource, newPubSub } from "./handler.js";
|
|
4
|
+
|
|
5
|
+
/** Read exactly `count` SSE frames off a response body, then release it. */
|
|
6
|
+
async function readFrames(res: Response, count: number): Promise<string[]> {
|
|
7
|
+
const reader = (res.body as ReadableStream<Uint8Array>).getReader();
|
|
8
|
+
const decoder = new TextDecoder();
|
|
9
|
+
const frames: string[] = [];
|
|
10
|
+
let buffer = "";
|
|
11
|
+
while (frames.length < count) {
|
|
12
|
+
const { value, done } = await reader.read();
|
|
13
|
+
if (done) break;
|
|
14
|
+
buffer += decoder.decode(value, { stream: true });
|
|
15
|
+
for (let i = buffer.indexOf("\n\n"); i !== -1; i = buffer.indexOf("\n\n")) {
|
|
16
|
+
frames.push(buffer.slice(0, i + 2));
|
|
17
|
+
buffer = buffer.slice(i + 2);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
await reader.cancel();
|
|
21
|
+
return frames;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("newPubSub handler", () => {
|
|
25
|
+
it("answers with an event-stream content type", async () => {
|
|
26
|
+
const ps = newPubSub();
|
|
27
|
+
const res = await ps.handler(new Request("http://h/_events/build"));
|
|
28
|
+
expect(res.status).toBe(200);
|
|
29
|
+
expect(res.headers.get("content-type")).toBe("text/event-stream");
|
|
30
|
+
expect(res.headers.get("cache-control")).toBe("no-cache");
|
|
31
|
+
await res.body?.cancel();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("streams events published after the subscription opens", async () => {
|
|
35
|
+
const ps = newPubSub();
|
|
36
|
+
const res = await ps.handler(new Request("http://h/_events/build"));
|
|
37
|
+
const frames = readFrames(res, 2);
|
|
38
|
+
ps.publish("build", { changed: ["/a.html"] }, "rebuilt");
|
|
39
|
+
ps.publish("build", { changed: ["/b.html"] }, "rebuilt");
|
|
40
|
+
expect(await frames).toEqual([
|
|
41
|
+
'id: 1\nevent: rebuilt\ndata: {"changed":["/a.html"]}\n\n',
|
|
42
|
+
'id: 2\nevent: rebuilt\ndata: {"changed":["/b.html"]}\n\n',
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("replays missed events when Last-Event-ID is supplied", async () => {
|
|
47
|
+
const ps = newPubSub();
|
|
48
|
+
ps.publish("build", "one");
|
|
49
|
+
ps.publish("build", "two");
|
|
50
|
+
const res = await ps.handler(
|
|
51
|
+
new Request("http://h/_events/build", { headers: { "last-event-id": "1" } }),
|
|
52
|
+
);
|
|
53
|
+
const frames = await readFrames(res, 1);
|
|
54
|
+
expect(frames[0]).toBe('id: 2\ndata: "two"\n\n');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// (4) a gap the buffer cannot cover must be signalled, not silently partial
|
|
58
|
+
it("signals a gap when Last-Event-ID predates the replay buffer", async () => {
|
|
59
|
+
const ps = newPubSub({ bufferSize: 2 });
|
|
60
|
+
for (const d of ["a", "b", "c", "d"]) ps.publish("build", d);
|
|
61
|
+
const res = await ps.handler(
|
|
62
|
+
new Request("http://h/_events/build", { headers: { "last-event-id": "1" } }),
|
|
63
|
+
);
|
|
64
|
+
const frames = await readFrames(res, 1);
|
|
65
|
+
expect(frames[0]).toContain("event: gap");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("releases the subscriber when the client cancels the body", async () => {
|
|
69
|
+
const ps = newPubSub();
|
|
70
|
+
const res = await ps.handler(new Request("http://h/_events/build"));
|
|
71
|
+
await readFrames(res, 0); // opens and cancels the reader
|
|
72
|
+
expect(ps.subscriberCount("build")).toBe(0);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("newPubSub handler — hostile input", () => {
|
|
77
|
+
// C-1: `publish("build", undefined, "reload")` — a payload-free signal — used to
|
|
78
|
+
// throw out of publish(), and then out of ps.handler() for every reconnecting
|
|
79
|
+
// client that hit the event in the replay ring.
|
|
80
|
+
it("survives a payload that cannot be serialized, live and on replay", async () => {
|
|
81
|
+
const ps = newPubSub();
|
|
82
|
+
const res = await ps.handler(new Request("http://h/_events/build"));
|
|
83
|
+
const frames = readFrames(res, 1);
|
|
84
|
+
|
|
85
|
+
expect(() => ps.publish("build", undefined, "reload")).not.toThrow();
|
|
86
|
+
expect(await frames).toEqual(["id: 1\nevent: reload\ndata: null\n\n"]);
|
|
87
|
+
|
|
88
|
+
const again = await ps.handler(
|
|
89
|
+
new Request("http://h/_events/build", { headers: { "last-event-id": "0" } }),
|
|
90
|
+
);
|
|
91
|
+
expect((await readFrames(again, 1))[0]).toBe("id: 1\nevent: reload\ndata: null\n\n");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// C-2, through the handler: a Last-Event-ID from a previous server epoch.
|
|
95
|
+
it("signals a gap when Last-Event-ID is above anything this server has issued", async () => {
|
|
96
|
+
const ps = newPubSub();
|
|
97
|
+
ps.publish("build", "a");
|
|
98
|
+
const res = await ps.handler(
|
|
99
|
+
new Request("http://h/_events/build", { headers: { "last-event-id": "42" } }),
|
|
100
|
+
);
|
|
101
|
+
expect((await readFrames(res, 1))[0]).toContain("event: gap");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// m-12: an unparseable id used to skip the replay block entirely — no replay and
|
|
105
|
+
// no gap, so a client whose id was mangled was told nothing at all.
|
|
106
|
+
it("signals a gap when Last-Event-ID cannot be parsed", async () => {
|
|
107
|
+
const ps = newPubSub();
|
|
108
|
+
ps.publish("build", "a");
|
|
109
|
+
const res = await ps.handler(
|
|
110
|
+
new Request("http://h/_events/build", { headers: { "last-event-id": "abc" } }),
|
|
111
|
+
);
|
|
112
|
+
expect((await readFrames(res, 1))[0]).toContain("event: gap");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("rejects a non-GET request", async () => {
|
|
116
|
+
const ps = newPubSub();
|
|
117
|
+
const res = await ps.handler(new Request("http://h/_events/build", { method: "POST" }));
|
|
118
|
+
expect(res.status).toBe(405);
|
|
119
|
+
expect(res.headers.get("allow")).toBe("GET");
|
|
120
|
+
expect(ps.topicCount()).toBe(0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("rejects a request with no topic", async () => {
|
|
124
|
+
const ps = newPubSub();
|
|
125
|
+
expect((await ps.handler(new Request("http://h/"))).status).toBe(404);
|
|
126
|
+
expect(ps.topicCount()).toBe(0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("serves only the allow-listed topics when one is configured", async () => {
|
|
130
|
+
const ps = newPubSub({ topics: ["build"] });
|
|
131
|
+
expect((await ps.handler(new Request("http://h/_events/evil"))).status).toBe(404);
|
|
132
|
+
expect(ps.topicCount()).toBe(0);
|
|
133
|
+
const ok = await ps.handler(new Request("http://h/_events/build"));
|
|
134
|
+
expect(ok.status).toBe(200);
|
|
135
|
+
await ok.body?.cancel();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// I-6: any GET /_events/<random> allocated a topic that was never reclaimed.
|
|
139
|
+
it("drops topic state once the last subscriber leaves and nothing is retained", async () => {
|
|
140
|
+
const ps = newPubSub();
|
|
141
|
+
const res = await ps.handler(new Request("http://h/_events/ghost"));
|
|
142
|
+
expect(ps.topicCount()).toBe(1);
|
|
143
|
+
await res.body?.cancel();
|
|
144
|
+
expect(ps.subscriberCount("ghost")).toBe(0);
|
|
145
|
+
expect(ps.topicCount()).toBe(0);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("newPubSub handler — a stream that breaks", () => {
|
|
150
|
+
// m-13: `unsubscribe` was assigned only after the replay loop, so send's catch
|
|
151
|
+
// called it on undefined and then subscribed anyway; the catch also never closed
|
|
152
|
+
// the controller, leaving a dead stream open to swallow every later event.
|
|
153
|
+
it("releases the subscriber and closes the stream when the first send fails", () => {
|
|
154
|
+
const broker = newBroker();
|
|
155
|
+
broker.publish("build", "a");
|
|
156
|
+
const source = createSseSource(broker, "build", { replayFrom: 0 });
|
|
157
|
+
|
|
158
|
+
let closed = false;
|
|
159
|
+
const controller = {
|
|
160
|
+
enqueue() {
|
|
161
|
+
throw new TypeError("the client went away");
|
|
162
|
+
},
|
|
163
|
+
close() {
|
|
164
|
+
closed = true;
|
|
165
|
+
},
|
|
166
|
+
error() {},
|
|
167
|
+
} as unknown as ReadableStreamDefaultController<Uint8Array>;
|
|
168
|
+
|
|
169
|
+
source.start?.(controller);
|
|
170
|
+
|
|
171
|
+
expect(broker.subscriberCount("build")).toBe(0);
|
|
172
|
+
expect(closed).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// m-14: opt-in keepalive. Comment frames are ignored by the SSE parser but keep a
|
|
176
|
+
// half-open connection observable to proxies.
|
|
177
|
+
it("emits comment frames when a heartbeat interval is configured", async () => {
|
|
178
|
+
vi.useFakeTimers();
|
|
179
|
+
try {
|
|
180
|
+
const ps = newPubSub({ heartbeatMs: 1000 });
|
|
181
|
+
const res = await ps.handler(new Request("http://h/_events/build"));
|
|
182
|
+
const reader = (res.body as ReadableStream<Uint8Array>).getReader();
|
|
183
|
+
vi.advanceTimersByTime(2500);
|
|
184
|
+
expect(new TextDecoder().decode((await reader.read()).value)).toBe(": ping\n\n");
|
|
185
|
+
await reader.cancel();
|
|
186
|
+
expect(ps.subscriberCount("build")).toBe(0);
|
|
187
|
+
} finally {
|
|
188
|
+
vi.useRealTimers();
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
});
|
package/src/handler.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { type Broker, type BrokerOptions, newBroker, type Unsubscribe } from "./broker.js";
|
|
2
|
+
import { formatSseEvent } from "./sse.js";
|
|
3
|
+
|
|
4
|
+
export type FetchHandler = (request: Request) => Promise<Response>;
|
|
5
|
+
|
|
6
|
+
export interface PubSubOptions extends BrokerOptions {
|
|
7
|
+
/**
|
|
8
|
+
* When given, only these topic names are served; anything else answers 404.
|
|
9
|
+
* Without it any request path allocates a topic, so an open endpoint should
|
|
10
|
+
* set this.
|
|
11
|
+
*/
|
|
12
|
+
topics?: readonly string[];
|
|
13
|
+
/**
|
|
14
|
+
* Interval in ms for `: ping` comment frames, which the SSE parser ignores but
|
|
15
|
+
* which keep a half-open connection visible to proxies. 0 (the default) means
|
|
16
|
+
* no heartbeat: a connection that dies silently is then only noticed when the
|
|
17
|
+
* client's own reconnect timer fires.
|
|
18
|
+
*/
|
|
19
|
+
heartbeatMs?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PubSub {
|
|
23
|
+
handler: FetchHandler;
|
|
24
|
+
publish(topic: string, data: unknown, event?: string): void;
|
|
25
|
+
subscriberCount(topic: string): number;
|
|
26
|
+
/** How many topics currently hold state; a topic with no subscribers and no retained events is dropped. */
|
|
27
|
+
topicCount(): number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The topic is the final non-empty path segment: /_events/build -> "build". */
|
|
31
|
+
function topicOf(request: Request): string {
|
|
32
|
+
const segments = new URL(request.url).pathname.split("/").filter(Boolean);
|
|
33
|
+
return segments.at(-1) ?? "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SseSourceOptions {
|
|
37
|
+
/** Replay events after this id; undefined for a fresh subscription. */
|
|
38
|
+
replayFrom?: number;
|
|
39
|
+
/** Send a `gap` before anything else — the client's Last-Event-ID was unusable. */
|
|
40
|
+
forceGap?: boolean;
|
|
41
|
+
/** See {@link PubSubOptions.heartbeatMs}. */
|
|
42
|
+
heartbeatMs?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The `ReadableStream` source behind one subscription.
|
|
47
|
+
*
|
|
48
|
+
* Exported so tests can drive it with a controller whose `enqueue` fails, which
|
|
49
|
+
* is the one path a real stream cannot be pushed down from outside.
|
|
50
|
+
*/
|
|
51
|
+
export function createSseSource(
|
|
52
|
+
broker: Broker,
|
|
53
|
+
topic: string,
|
|
54
|
+
{ replayFrom, forceGap = false, heartbeatMs = 0 }: SseSourceOptions = {},
|
|
55
|
+
): UnderlyingSource<Uint8Array> {
|
|
56
|
+
const encoder = new TextEncoder();
|
|
57
|
+
let unsubscribe: Unsubscribe | undefined;
|
|
58
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
59
|
+
let broken = false;
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
start(controller) {
|
|
63
|
+
/** Tear the subscription down and end the stream, once. */
|
|
64
|
+
const stop = () => {
|
|
65
|
+
if (broken) return;
|
|
66
|
+
broken = true;
|
|
67
|
+
if (heartbeat !== undefined) clearInterval(heartbeat);
|
|
68
|
+
unsubscribe?.();
|
|
69
|
+
unsubscribe = undefined;
|
|
70
|
+
try {
|
|
71
|
+
controller.close();
|
|
72
|
+
} catch {
|
|
73
|
+
// Already closed or errored; nothing left to do.
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const write = (frame: () => string) => {
|
|
78
|
+
if (broken) return;
|
|
79
|
+
try {
|
|
80
|
+
// Formatting happens inside the guard: it is given caller data, and a
|
|
81
|
+
// throw here used to escape this catch entirely.
|
|
82
|
+
controller.enqueue(encoder.encode(frame()));
|
|
83
|
+
} catch {
|
|
84
|
+
// The client went away, or the frame could not be produced.
|
|
85
|
+
stop();
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Subscribe first, so the teardown above can release it even if the very
|
|
90
|
+
// first replay frame fails.
|
|
91
|
+
unsubscribe = broker.subscribe(topic, (e) => write(() => formatSseEvent(e)));
|
|
92
|
+
|
|
93
|
+
if (forceGap || replayFrom !== undefined) {
|
|
94
|
+
const from = replayFrom ?? 0;
|
|
95
|
+
const { events, complete } =
|
|
96
|
+
replayFrom === undefined
|
|
97
|
+
? { events: [], complete: false }
|
|
98
|
+
: broker.replay(topic, replayFrom);
|
|
99
|
+
// Tell the client its history is incomplete rather than handing it a
|
|
100
|
+
// partial replay that looks whole; it can reload instead of patching.
|
|
101
|
+
if (!complete) write(() => formatSseEvent({ id: from, event: "gap", data: { from } }));
|
|
102
|
+
for (const e of events) write(() => formatSseEvent(e));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (heartbeatMs > 0) {
|
|
106
|
+
heartbeat = setInterval(() => write(() => ": ping\n\n"), heartbeatMs);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
cancel() {
|
|
111
|
+
if (heartbeat !== undefined) clearInterval(heartbeat);
|
|
112
|
+
broken = true;
|
|
113
|
+
unsubscribe?.();
|
|
114
|
+
unsubscribe = undefined;
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function newPubSub(options: PubSubOptions = {}): PubSub {
|
|
120
|
+
const { topics, heartbeatMs, ...brokerOptions } = options;
|
|
121
|
+
const broker = newBroker(brokerOptions);
|
|
122
|
+
const allowed = topics === undefined ? undefined : new Set(topics);
|
|
123
|
+
|
|
124
|
+
const handler: FetchHandler = async (request) => {
|
|
125
|
+
if (request.method !== "GET") {
|
|
126
|
+
return new Response("method not allowed", { status: 405, headers: { allow: "GET" } });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const topic = topicOf(request);
|
|
130
|
+
if (topic === "" || (allowed !== undefined && !allowed.has(topic))) {
|
|
131
|
+
return new Response("no such topic", { status: 404 });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const header = request.headers.get("last-event-id");
|
|
135
|
+
const parsed = header === null ? Number.NaN : Number(header);
|
|
136
|
+
const usable = header !== null && Number.isSafeInteger(parsed) && parsed >= 0;
|
|
137
|
+
// An id we cannot read is not a fresh client: it had history and lost it, so
|
|
138
|
+
// it gets a gap rather than silence.
|
|
139
|
+
const forceGap = header !== null && !usable;
|
|
140
|
+
|
|
141
|
+
const stream = new ReadableStream<Uint8Array>(
|
|
142
|
+
createSseSource(broker, topic, {
|
|
143
|
+
...(usable ? { replayFrom: parsed } : {}),
|
|
144
|
+
forceGap,
|
|
145
|
+
...(heartbeatMs === undefined ? {} : { heartbeatMs }),
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
return new Response(stream, {
|
|
150
|
+
status: 200,
|
|
151
|
+
headers: {
|
|
152
|
+
"content-type": "text/event-stream",
|
|
153
|
+
"cache-control": "no-cache",
|
|
154
|
+
connection: "keep-alive",
|
|
155
|
+
"x-accel-buffering": "no", // defeat proxy buffering that would stall delivery
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
handler,
|
|
162
|
+
publish: (topic, data, event) => void broker.publish(topic, data, event),
|
|
163
|
+
subscriberCount: (topic) => broker.subscriberCount(topic),
|
|
164
|
+
topicCount: () => broker.topicCount(),
|
|
165
|
+
};
|
|
166
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type { Broker, BrokerEvent, BrokerOptions, Subscriber, Unsubscribe } from "./broker.js";
|
|
2
|
+
export { newBroker } from "./broker.js";
|
|
3
|
+
export type { PubSubClient, PubSubClientOptions, SubscribeOptions } from "./client.js";
|
|
4
|
+
export { newPubSubClient } from "./client.js";
|
|
5
|
+
export type { FetchHandler, PubSub, PubSubOptions } from "./handler.js";
|
|
6
|
+
export { newPubSub } from "./handler.js";
|
|
7
|
+
export { formatSseEvent } from "./sse.js";
|
package/src/sse.test.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { formatSseEvent } from "./sse.js";
|
|
3
|
+
|
|
4
|
+
describe("formatSseEvent", () => {
|
|
5
|
+
it("frames id, event name and data", () => {
|
|
6
|
+
expect(formatSseEvent({ id: 7, event: "rebuilt", data: { a: 1 } })).toBe(
|
|
7
|
+
'id: 7\nevent: rebuilt\ndata: {"a":1}\n\n',
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("omits the event line when no name is given", () => {
|
|
12
|
+
expect(formatSseEvent({ id: 1, data: 5 })).toBe("id: 1\ndata: 5\n\n");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// (3) an unframed newline terminates the event early and truncates the payload
|
|
16
|
+
it("emits one data line per newline in the payload", () => {
|
|
17
|
+
const frame = formatSseEvent({ id: 2, data: "line one\nline two" });
|
|
18
|
+
expect(frame).toBe('id: 2\ndata: "line one\\nline two"\n\n');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("splits a multi-line serialized payload across data lines", () => {
|
|
22
|
+
const frame = formatSseEvent({ id: 3, data: { raw: "a\nb" }, event: "e" });
|
|
23
|
+
// JSON.stringify escapes the newline, so exactly one data line results
|
|
24
|
+
expect(frame.split("\n").filter((l) => l.startsWith("data:"))).toHaveLength(1);
|
|
25
|
+
expect(frame.endsWith("\n\n")).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("formatSseEvent — total on any input", () => {
|
|
30
|
+
// C-1: JSON.stringify returns undefined (not a string) for these, so `.split` threw
|
|
31
|
+
// and the throw escaped every guard in the handler and the broker.
|
|
32
|
+
it("frames a payload JSON cannot represent as null instead of throwing", () => {
|
|
33
|
+
const circular: Record<string, unknown> = {};
|
|
34
|
+
circular.self = circular;
|
|
35
|
+
expect(formatSseEvent({ id: 1, event: "reload", data: undefined })).toBe(
|
|
36
|
+
"id: 1\nevent: reload\ndata: null\n\n",
|
|
37
|
+
);
|
|
38
|
+
expect(formatSseEvent({ id: 2, data: () => {} })).toBe("id: 2\ndata: null\n\n");
|
|
39
|
+
expect(formatSseEvent({ id: 3, data: Symbol("s") })).toBe("id: 3\ndata: null\n\n");
|
|
40
|
+
expect(formatSseEvent({ id: 4, data: 1n })).toBe("id: 4\ndata: null\n\n");
|
|
41
|
+
expect(formatSseEvent({ id: 5, data: circular })).toBe("id: 5\ndata: null\n\n");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// m-10: a newline in the event name used to inject whole frames onto the wire.
|
|
45
|
+
it("cannot be made to inject a frame through the event name", () => {
|
|
46
|
+
const frame = formatSseEvent({ id: 1, event: "a\ndata: injected\r\nevent: x", data: 0 });
|
|
47
|
+
expect(frame.split("\n").filter((l) => l.startsWith("data:"))).toEqual(["data: 0"]);
|
|
48
|
+
expect(frame).toBe("id: 1\nevent: adata: injectedevent: x\ndata: 0\n\n");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// m-9: the doc comment used to promise special handling for a pre-serialized
|
|
52
|
+
// string; it never happened. A string is JSON-encoded like anything else.
|
|
53
|
+
it("JSON-encodes a string payload like any other value", () => {
|
|
54
|
+
expect(formatSseEvent({ id: 1, data: "hi" })).toBe('id: 1\ndata: "hi"\n\n');
|
|
55
|
+
});
|
|
56
|
+
});
|
package/src/sse.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { BrokerEvent } from "./broker.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* JSON for the wire. Total by construction: `JSON.stringify` returns `undefined`
|
|
5
|
+
* rather than a string for `undefined`, a function or a symbol, and throws on a
|
|
6
|
+
* BigInt or a circular object. Any of those is framed as `null` — the event still
|
|
7
|
+
* reaches the client with its id and name, which is what an empty signal is for.
|
|
8
|
+
*/
|
|
9
|
+
function serialize(data: unknown): string {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.stringify(data) ?? "null";
|
|
12
|
+
} catch {
|
|
13
|
+
return "null";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Format one WHATWG Server-Sent Event frame, terminated by the blank line that
|
|
19
|
+
* ends an event.
|
|
20
|
+
*
|
|
21
|
+
* `data` is always JSON-serialized, strings included (a string payload arrives
|
|
22
|
+
* back through `JSON.parse` as the same string, never half-decoded). JSON escapes
|
|
23
|
+
* newlines, so the body is a single line in practice; every line is prefixed
|
|
24
|
+
* anyway, because an unframed newline would end the event and truncate it.
|
|
25
|
+
*
|
|
26
|
+
* CR and LF are stripped from the event name: it is caller-supplied, and a
|
|
27
|
+
* newline there would inject whole frames onto the wire.
|
|
28
|
+
*/
|
|
29
|
+
export function formatSseEvent(e: BrokerEvent): string {
|
|
30
|
+
const lines = serialize(e.data)
|
|
31
|
+
.split("\n")
|
|
32
|
+
.map((l) => `data: ${l}\n`)
|
|
33
|
+
.join("");
|
|
34
|
+
const name = e.event === undefined ? "" : `event: ${e.event.replace(/[\r\n]/g, "")}\n`;
|
|
35
|
+
return `id: ${e.id}\n${name}${lines}\n`;
|
|
36
|
+
}
|