@tetsujs/sse 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.
@@ -0,0 +1,111 @@
1
+ /**
2
+ * A generator, piped to a response at the rate the client reads it.
3
+ *
4
+ * This is the machinery `sse()` is built on, exported because the wire
5
+ * format is the only part of it that is about server-sent events. A
6
+ * server-to-server feed wants newline-delimited JSON, an export wants CSV,
7
+ * a proxy wants whatever it was handed — and none of them should have to
8
+ * rediscover backpressure, the abort signal, ending the generator on
9
+ * cancellation, or reporting what the stream did.
10
+ *
11
+ * ```ts
12
+ * handler: (ctx) =>
13
+ * stream(
14
+ * ctx,
15
+ * async function* (signal) {
16
+ * for await (const row of rows.watch({ signal })) {
17
+ * yield `${JSON.stringify(row)}\n`;
18
+ * }
19
+ * },
20
+ * { contentType: "application/x-ndjson" },
21
+ * );
22
+ * ```
23
+ *
24
+ * @module
25
+ */
26
+ import type { BaseCtx } from "@tetsujs/core";
27
+ /** How a stream ended. */
28
+ export type StreamReason =
29
+ /** The generator ran out on its own. */
30
+ "ended"
31
+ /**
32
+ * Nobody is reading any more — the client disconnected, or the pipeline
33
+ * discarded the response the stream was the body of.
34
+ */
35
+ | "cancelled"
36
+ /** The generator threw. What had already gone out stayed valid. */
37
+ | "failed";
38
+ /** One finished stream. */
39
+ export interface StreamSummary {
40
+ /**
41
+ * Chunks the generator yielded and the stream wrote, not counting
42
+ * keep-alives. For `sse()` that is one per event.
43
+ */
44
+ readonly chunks: number;
45
+ /**
46
+ * Bytes enqueued, keep-alives included — what the stream put on the wire
47
+ * rather than what the application meant to say.
48
+ */
49
+ readonly bytes: number;
50
+ /** How long the stream lived, in milliseconds, to the microsecond. */
51
+ readonly durationMs: number;
52
+ readonly reason: StreamReason;
53
+ }
54
+ /** Something written on a schedule, so an idle connection stays open. */
55
+ export interface KeepAlive {
56
+ /** How often, in milliseconds. */
57
+ readonly everyMs: number;
58
+ /**
59
+ * What to write. It has to be something the consumer's parser ignores —
60
+ * a comment in a format that has them, a blank line in one that does
61
+ * not, nothing at all in a format where neither is true.
62
+ */
63
+ readonly chunk: string;
64
+ }
65
+ /** How the stream behaves and what it answers with. */
66
+ export interface StreamOptions {
67
+ /** The response's `content-type`. Omitted, none is set. */
68
+ readonly contentType?: string;
69
+ /** Status of the response. `200` by default. */
70
+ readonly status?: number;
71
+ /** Headers to send alongside — `cache-control`, and whatever else. */
72
+ readonly headers?: Record<string, string>;
73
+ /** A filler written while nothing else is. Off by default. */
74
+ readonly keepAlive?: KeepAlive;
75
+ /** Called once when the stream is over, with what it did. */
76
+ readonly onEnd?: (summary: StreamSummary) => void;
77
+ }
78
+ /**
79
+ * Builds a streaming response from an async generator.
80
+ *
81
+ * The generator is handed an `AbortSignal` that fires when the stream is
82
+ * over, whichever way it ended — the client disconnected, the consumer
83
+ * cancelled, the generator itself failed. It is not `ctx.req.signal`
84
+ * directly: a source wants to know that this stream is finished, not which
85
+ * of the ways finished it.
86
+ *
87
+ * **Passing it on is what makes cleanup work**, and it is the caller's job
88
+ * rather than this module's. A generator between two `yield`s leaves on
89
+ * its own — the loop sees the abort at the next chunk, and `return()` runs
90
+ * its `finally`. A generator parked inside an `await` is resumed by
91
+ * nothing: `return()` on it is queued behind that `await` and applies only
92
+ * once it settles, so an `await` on a source that has gone quiet never
93
+ * unwinds, and the subscription inside it lives as long as the process.
94
+ * Neither cancelling the stream nor `return()` changes that — both were
95
+ * measured, both fire, neither wakes it — which is why the signal goes to
96
+ * the source instead.
97
+ *
98
+ * So the rule, stated plainly: **a stream ends with the connection if its
99
+ * generator keeps yielding, or if it waits on the signal.** A generator
100
+ * that does neither leaks, and no amount of care out here can collect it.
101
+ *
102
+ * A generator that fails instead of ending is logged and the stream is
103
+ * closed where it stood, so what already went out stays valid and the
104
+ * client sees an ordinary end of stream. Letting the failure escape
105
+ * instead would reach no one the application can hear: the platform prints
106
+ * a raw stack and tears the connection down, and whether the bytes already
107
+ * queued are lost with it depends on whether a macrotask happened to run
108
+ * in between.
109
+ */
110
+ export declare function stream(ctx: BaseCtx, source: (signal: AbortSignal) => AsyncGenerator<string, void, undefined>, options?: StreamOptions): Response;
111
+ //# sourceMappingURL=stream.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stream.d.ts","sourceRoot":"","sources":["../../src/stream.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAE7C,0BAA0B;AAC1B,MAAM,MAAM,YAAY;AACtB,wCAAwC;AACtC,OAAO;AACT;;;GAGG;GACD,WAAW;AACb,mEAAmE;GACjE,QAAQ,CAAC;AAEb,2BAA2B;AAC3B,MAAM,WAAW,aAAa;IAC5B;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAExB;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAEvB,sEAAsE;IACtE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAE5B,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;CAC/B;AAED,yEAAyE;AACzE,MAAM,WAAW,SAAS;IACxB,kCAAkC;IAClC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IAEzB;;;;OAIG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,uDAAuD;AACvD,MAAM,WAAW,aAAa;IAC5B,2DAA2D;IAC3D,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAE9B,gDAAgD;IAChD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAEzB,sEAAsE;IACtE,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAE1C,8DAA8D;IAC9D,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC;IAE/B,6DAA6D;IAC7D,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,aAAa,KAAK,IAAI,CAAC;CACnD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,MAAM,CACpB,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,cAAc,CAAC,MAAM,EAAE,IAAI,EAAE,SAAS,CAAC,EACxE,OAAO,GAAE,aAAkB,GAC1B,QAAQ,CAyLV"}
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@tetsujs/sse",
3
+ "version": "0.1.0",
4
+ "description": "Server-sent events and streamed responses for Tetsu, from an async generator",
5
+ "keywords": [
6
+ "bun",
7
+ "tetsu",
8
+ "sse",
9
+ "server-sent-events",
10
+ "streaming",
11
+ "ndjson"
12
+ ],
13
+ "author": "tetsuodev",
14
+ "license": "MIT",
15
+ "homepage": "https://github.com/tetsujs/tetsu/tree/main/packages/sse#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/tetsujs/tetsu/issues"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/tetsujs/tetsu.git",
22
+ "directory": "packages/sse"
23
+ },
24
+ "type": "module",
25
+ "engines": {
26
+ "bun": ">=1.4"
27
+ },
28
+ "scripts": {
29
+ "prepack": "bun run --cwd ../.. build"
30
+ },
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/src/index.d.ts",
34
+ "default": "./dist/src/index.js"
35
+ }
36
+ },
37
+ "peerDependencies": {
38
+ "@tetsujs/core": "^0.1.0"
39
+ },
40
+ "devDependencies": {
41
+ "@tetsujs/core": "0.1.0"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "files": [
47
+ "dist",
48
+ "!dist/**/*.tsbuildinfo",
49
+ "src",
50
+ "!**/*.test.ts",
51
+ "!**/*.test-d.ts"
52
+ ]
53
+ }
package/src/index.ts ADDED
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Server-sent events.
3
+ *
4
+ * ```ts
5
+ * route({
6
+ * method: "GET",
7
+ * path: "/prices",
8
+ * handler: (ctx) =>
9
+ * sse(ctx, async function* (signal) {
10
+ * for await (const price of prices.watch({ signal })) {
11
+ * yield { data: price, id: price.at };
12
+ * }
13
+ * }),
14
+ * });
15
+ * ```
16
+ *
17
+ * The framework needs none of this to stream — a handler returning a
18
+ * `Response` with a stream already works. What this adds is the part that
19
+ * is easy to get quietly wrong: the wire format (a blank line ends an
20
+ * event, and every line of a multi-line payload carries its own `data:`
21
+ * prefix), the headers a proxy needs to see, the heartbeat that keeps an
22
+ * idle connection from being closed by one, an `AbortSignal` that says the
23
+ * stream is over so a source that waits can stop waiting, and the
24
+ * backpressure that makes a slow client cost a buffer rather than a heap:
25
+ * events are produced on demand, one at a time, at the rate they are
26
+ * read.
27
+ *
28
+ * @module
29
+ */
30
+
31
+ import type { BaseCtx } from "@tetsujs/core";
32
+ import type { StreamReason, StreamSummary } from "./stream.ts";
33
+ import { stream } from "./stream.ts";
34
+
35
+ export type {
36
+ KeepAlive,
37
+ StreamOptions,
38
+ StreamReason,
39
+ StreamSummary,
40
+ } from "./stream.ts";
41
+ export { stream } from "./stream.ts";
42
+
43
+ /**
44
+ * One event, as it goes over the wire.
45
+ *
46
+ * `data` is deliberately untyped. A stream usually carries several kinds
47
+ * of event under different names, so one type parameter would be wrong for
48
+ * all but the simplest feed — and nothing on the server consumes the
49
+ * payload's type anyway: it leaves as JSON. A feed that is uniform can say
50
+ * so where it is written, by typing its own generator.
51
+ */
52
+ export interface ServerSentEvent {
53
+ /**
54
+ * The payload. A string is sent as it is; anything else is JSON, which
55
+ * is what a browser's `EventSource` expects to parse. A value JSON has
56
+ * no form for — `undefined`, a function, a symbol — is refused rather
57
+ * than sent as an empty string: a payload that went missing, a
58
+ * `map.get()` that found nothing, would reach the page as a valid event
59
+ * with nothing in it.
60
+ */
61
+ readonly data: unknown;
62
+
63
+ /** Event name, read by `addEventListener(name)` rather than `onmessage`. */
64
+ readonly event?: string;
65
+
66
+ /**
67
+ * Event id. The browser sends the last one back as `Last-Event-ID` when
68
+ * it reconnects, which is how a stream resumes where it stopped.
69
+ */
70
+ readonly id?: string | number;
71
+
72
+ /** How long the browser waits before reconnecting, in milliseconds. */
73
+ readonly retry?: number;
74
+ }
75
+
76
+ /** How the stream behaves. */
77
+ export interface SseOptions {
78
+ /**
79
+ * How often a comment line is sent to keep the connection alive, in
80
+ * milliseconds. Defaults to 15 seconds; `0` turns it off.
81
+ *
82
+ * On by default because the failure it prevents is silent and remote: a
83
+ * proxy between the server and the browser closes a connection that has
84
+ * been idle — nginx after 60 seconds by default — and the application
85
+ * sees a client that keeps reconnecting for no visible reason.
86
+ */
87
+ readonly heartbeatMs?: number;
88
+
89
+ /** Status of the response. `200` by default. */
90
+ readonly status?: number;
91
+
92
+ /**
93
+ * Called once when the stream is over, with what it did.
94
+ *
95
+ * The gap this closes: `afterResponse` runs when the response is handed
96
+ * to the runtime, which for a stream is the moment it *starts*. An
97
+ * access log therefore records a forty-minute feed as a `200` that took
98
+ * microseconds, and a torn connection as a success. Delivery to the
99
+ * client is not observable in the fetch model and stays that way — but
100
+ * the end of *generation* is, and that is what this reports.
101
+ *
102
+ * It carries no request id on purpose. This callback is written at the
103
+ * call site, where `ctx` is already in scope, so the caller adds
104
+ * whatever identifies the request better than this package could guess.
105
+ *
106
+ * @example
107
+ * ```ts
108
+ * sse(ctx, feed, {
109
+ * onEnd: (summary) =>
110
+ * logger.info({ ...summary, requestId: ctx.requestId }, "stream closed"),
111
+ * });
112
+ * ```
113
+ */
114
+ readonly onEnd?: (summary: SseSummary) => void;
115
+ }
116
+
117
+ /**
118
+ * How an SSE stream ended — {@link StreamReason} by another name, because
119
+ * a reader of this package should not have to go looking.
120
+ */
121
+ export type SseReason = StreamReason;
122
+
123
+ /**
124
+ * One finished stream, as server-sent events count it.
125
+ *
126
+ * The same record {@link StreamSummary} carries, with `chunks` named
127
+ * `events`: for this helper one chunk is one event, and the word an author
128
+ * reads at the call site should be the one they wrote.
129
+ */
130
+ export interface SseSummary extends Omit<StreamSummary, "chunks"> {
131
+ /** Events yielded and written, not counting heartbeats. */
132
+ readonly events: number;
133
+ }
134
+
135
+ /**
136
+ * Builds a server-sent events response from an async generator.
137
+ *
138
+ * The generator is handed an `AbortSignal` that fires when the stream is
139
+ * over, whichever way it ended — the client disconnected, the consumer
140
+ * cancelled, the generator itself failed. It is not `ctx.req.signal`
141
+ * directly: a source wants to know that this stream is finished, not which
142
+ * of the ways finished it.
143
+ *
144
+ * **Passing it on is what makes cleanup work**, and it is the caller's job
145
+ * rather than this package's. A generator between two `yield`s leaves on
146
+ * its own — the loop sees the abort at the next value, ends the `for
147
+ * await`, and the language calls the generator's `return()`, which runs
148
+ * its `finally`. A generator parked inside an `await` is resumed by
149
+ * nothing: `return()` on it is queued behind that `await` and applies only
150
+ * once it settles, so an `await` on a source that has gone quiet never
151
+ * unwinds, and the subscription inside it lives as long as the process.
152
+ * Neither `cancel()` on the stream nor `return()` on the generator changes
153
+ * that — both were measured, both fire, neither wakes it — which is why
154
+ * the signal goes to the source instead.
155
+ *
156
+ * So the rule, stated plainly: **a stream ends with the connection if its
157
+ * generator keeps yielding, or if it waits on the signal.** A generator
158
+ * that does neither leaks, and no amount of care out here can collect it.
159
+ *
160
+ * A generator that fails instead of ending is logged and the stream is
161
+ * closed where it stood, so what already went out stays valid and the
162
+ * client sees an ordinary end of stream. Letting the failure escape
163
+ * `start()` instead would reach no one the application can hear: the
164
+ * platform prints a raw stack and tears the connection down, and whether
165
+ * the bytes already queued are lost with it depends on whether a macrotask
166
+ * happened to run in between.
167
+ *
168
+ * @example A source that yields on its own — the loop ends it.
169
+ * ```ts
170
+ * sse(ctx, async function* () {
171
+ * const subscription = topic.subscribe();
172
+ *
173
+ * try {
174
+ * for await (const message of subscription) {
175
+ * yield { event: "message", data: message, id: message.id };
176
+ * }
177
+ * } finally {
178
+ * subscription.close();
179
+ * }
180
+ * });
181
+ * ```
182
+ *
183
+ * @example A source that can go quiet — it has to take the signal.
184
+ * ```ts
185
+ * sse(ctx, async function* (signal) {
186
+ * const queue = await broker.subscribe("prices", { signal });
187
+ *
188
+ * try {
189
+ * for await (const price of queue) {
190
+ * yield { data: price, id: price.at };
191
+ * }
192
+ * } finally {
193
+ * await queue.close();
194
+ * }
195
+ * });
196
+ * ```
197
+ */
198
+ export function sse(
199
+ ctx: BaseCtx,
200
+ source: (
201
+ signal: AbortSignal,
202
+ ) => AsyncGenerator<ServerSentEvent, void, undefined>,
203
+ options: SseOptions = {},
204
+ ): Response {
205
+ const heartbeatMs = options.heartbeatMs ?? 15_000;
206
+
207
+ const { onEnd } = options;
208
+
209
+ return stream(
210
+ ctx,
211
+ /**
212
+ * The only part of this helper that is about server-sent events: the
213
+ * events become frames, and everything else — backpressure, the
214
+ * signal, ending the generator, the summary — is the stream's.
215
+ *
216
+ * A `for await` rather than a manual loop, because leaving it is what
217
+ * passes `return()` on to the source when the stream is cancelled.
218
+ */
219
+ async function* (signal) {
220
+ for await (const event of source(signal)) {
221
+ yield frame(event);
222
+ }
223
+ },
224
+ {
225
+ contentType: "text/event-stream",
226
+ headers: { "cache-control": "no-cache" },
227
+
228
+ ...(options.status === undefined ? {} : { status: options.status }),
229
+
230
+ ...(heartbeatMs > 0
231
+ ? { keepAlive: { everyMs: heartbeatMs, chunk: ": ping\n\n" } }
232
+ : {}),
233
+
234
+ ...(onEnd
235
+ ? {
236
+ onEnd: ({ chunks, ...rest }: StreamSummary) =>
237
+ onEnd({ ...rest, events: chunks }),
238
+ }
239
+ : {}),
240
+ },
241
+ );
242
+ }
243
+
244
+ /**
245
+ * What ends a line for a client reading this stream.
246
+ *
247
+ * The protocol terminates a line on CRLF, CR or LF — all three, which is
248
+ * why splitting the payload on `\n` alone is not enough: a lone `\r` ends
249
+ * the field just as surely, and the rest of the value is read as a new one.
250
+ */
251
+ const lineBreak = /\r\n|[\r\n]/;
252
+
253
+ /** What a single-line field cannot carry without ceasing to be one. */
254
+ const unrepresentable = /[\r\n\0]/;
255
+
256
+ /**
257
+ * Formats one event.
258
+ *
259
+ * Every line of the payload carries its own `data:` prefix — a raw line
260
+ * break inside one would otherwise end the field — and a blank line ends
261
+ * the event, which is what makes the client dispatch it.
262
+ */
263
+ export function frame(event: ServerSentEvent): string {
264
+ const lines: string[] = [];
265
+
266
+ if (event.event !== undefined) {
267
+ lines.push(`event: ${single("event", event.event)}`);
268
+ }
269
+
270
+ if (event.id !== undefined) {
271
+ lines.push(`id: ${single("id", String(event.id))}`);
272
+ }
273
+
274
+ if (event.retry !== undefined) {
275
+ lines.push(`retry: ${event.retry}`);
276
+ }
277
+
278
+ const payload =
279
+ typeof event.data === "string" ? event.data : JSON.stringify(event.data);
280
+
281
+ if (payload === undefined) {
282
+ throw new TypeError(
283
+ `an SSE event's data has no JSON form: ${typeof event.data}`,
284
+ );
285
+ }
286
+
287
+ for (const line of payload.split(lineBreak)) {
288
+ lines.push(`data: ${line}`);
289
+ }
290
+
291
+ return `${lines.join("\n")}\n\n`;
292
+ }
293
+
294
+ /**
295
+ * Checks a field the protocol gives no way to continue onto a second line.
296
+ *
297
+ * `data` can carry a line break because every one of its lines is prefixed
298
+ * again; `event` and `id` cannot — the protocol has no syntax for it. So a
299
+ * value holding one is not "an event name with a newline in it", it is a
300
+ * second field the client will read and act on: a name the stream never
301
+ * sent, or an id it will send back on reconnect. That is an injection, and
302
+ * the values most likely to hold a line break are exactly the ones built
303
+ * from outside input — a topic name, a row's key, a user's label.
304
+ *
305
+ * Throwing rather than stripping, for the reason the platform throws on a
306
+ * header value with CRLF in it: a quietly rewritten id resumes the stream
307
+ * somewhere else, and the application never learns it asked for something
308
+ * the wire cannot carry. NUL joins them because a client drops an `id`
309
+ * holding one, which breaks resumption just as silently.
310
+ */
311
+ function single(field: string, value: string): string {
312
+ if (unrepresentable.test(value)) {
313
+ throw new TypeError(
314
+ `an SSE ${field} cannot contain a line break or NUL: ${JSON.stringify(value)}`,
315
+ );
316
+ }
317
+
318
+ return value;
319
+ }
320
+
321
+ /**
322
+ * The id the client last received, when it is reconnecting.
323
+ *
324
+ * A browser sends it automatically after a dropped connection; a stream
325
+ * that yields ids can resume from it instead of starting over.
326
+ *
327
+ * @example
328
+ * ```ts
329
+ * handler: (ctx) =>
330
+ * sse(ctx, async function* () {
331
+ * for await (const item of history.since(lastEventId(ctx))) {
332
+ * yield { data: item, id: item.id };
333
+ * }
334
+ * });
335
+ * ```
336
+ */
337
+ export function lastEventId(ctx: BaseCtx): string | undefined {
338
+ return ctx.req.headers.get("last-event-id") ?? undefined;
339
+ }