@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.
package/src/stream.ts ADDED
@@ -0,0 +1,328 @@
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
+
27
+ import type { BaseCtx } from "@tetsujs/core";
28
+
29
+ /** How a stream ended. */
30
+ export type StreamReason =
31
+ /** The generator ran out on its own. */
32
+ | "ended"
33
+ /**
34
+ * Nobody is reading any more — the client disconnected, or the pipeline
35
+ * discarded the response the stream was the body of.
36
+ */
37
+ | "cancelled"
38
+ /** The generator threw. What had already gone out stayed valid. */
39
+ | "failed";
40
+
41
+ /** One finished stream. */
42
+ export interface StreamSummary {
43
+ /**
44
+ * Chunks the generator yielded and the stream wrote, not counting
45
+ * keep-alives. For `sse()` that is one per event.
46
+ */
47
+ readonly chunks: number;
48
+
49
+ /**
50
+ * Bytes enqueued, keep-alives included — what the stream put on the wire
51
+ * rather than what the application meant to say.
52
+ */
53
+ readonly bytes: number;
54
+
55
+ /** How long the stream lived, in milliseconds, to the microsecond. */
56
+ readonly durationMs: number;
57
+
58
+ readonly reason: StreamReason;
59
+ }
60
+
61
+ /** Something written on a schedule, so an idle connection stays open. */
62
+ export interface KeepAlive {
63
+ /** How often, in milliseconds. */
64
+ readonly everyMs: number;
65
+
66
+ /**
67
+ * What to write. It has to be something the consumer's parser ignores —
68
+ * a comment in a format that has them, a blank line in one that does
69
+ * not, nothing at all in a format where neither is true.
70
+ */
71
+ readonly chunk: string;
72
+ }
73
+
74
+ /** How the stream behaves and what it answers with. */
75
+ export interface StreamOptions {
76
+ /** The response's `content-type`. Omitted, none is set. */
77
+ readonly contentType?: string;
78
+
79
+ /** Status of the response. `200` by default. */
80
+ readonly status?: number;
81
+
82
+ /** Headers to send alongside — `cache-control`, and whatever else. */
83
+ readonly headers?: Record<string, string>;
84
+
85
+ /** A filler written while nothing else is. Off by default. */
86
+ readonly keepAlive?: KeepAlive;
87
+
88
+ /** Called once when the stream is over, with what it did. */
89
+ readonly onEnd?: (summary: StreamSummary) => void;
90
+ }
91
+
92
+ /**
93
+ * Builds a streaming response from an async generator.
94
+ *
95
+ * The generator is handed an `AbortSignal` that fires when the stream is
96
+ * over, whichever way it ended — the client disconnected, the consumer
97
+ * cancelled, the generator itself failed. It is not `ctx.req.signal`
98
+ * directly: a source wants to know that this stream is finished, not which
99
+ * of the ways finished it.
100
+ *
101
+ * **Passing it on is what makes cleanup work**, and it is the caller's job
102
+ * rather than this module's. A generator between two `yield`s leaves on
103
+ * its own — the loop sees the abort at the next chunk, and `return()` runs
104
+ * its `finally`. A generator parked inside an `await` is resumed by
105
+ * nothing: `return()` on it is queued behind that `await` and applies only
106
+ * once it settles, so an `await` on a source that has gone quiet never
107
+ * unwinds, and the subscription inside it lives as long as the process.
108
+ * Neither cancelling the stream nor `return()` changes that — both were
109
+ * measured, both fire, neither wakes it — which is why the signal goes to
110
+ * the source instead.
111
+ *
112
+ * So the rule, stated plainly: **a stream ends with the connection if its
113
+ * generator keeps yielding, or if it waits on the signal.** A generator
114
+ * that does neither leaks, and no amount of care out here can collect it.
115
+ *
116
+ * A generator that fails instead of ending is logged and the stream is
117
+ * closed where it stood, so what already went out stays valid and the
118
+ * client sees an ordinary end of stream. Letting the failure escape
119
+ * instead would reach no one the application can hear: the platform prints
120
+ * a raw stack and tears the connection down, and whether the bytes already
121
+ * queued are lost with it depends on whether a macrotask happened to run
122
+ * in between.
123
+ */
124
+ export function stream(
125
+ ctx: BaseCtx,
126
+ source: (signal: AbortSignal) => AsyncGenerator<string, void, undefined>,
127
+ options: StreamOptions = {},
128
+ ): Response {
129
+ const encoder = new TextEncoder();
130
+ const ending = new AbortController();
131
+ const signal = AbortSignal.any([ctx.req.signal, ending.signal]);
132
+
133
+ const chunks = source(signal);
134
+
135
+ const startedAt = performance.now();
136
+
137
+ let beating: ReturnType<typeof setInterval> | undefined;
138
+ let written = 0;
139
+ let bytes = 0;
140
+ let over = false;
141
+
142
+ /**
143
+ * Ends the stream once, whichever path got here first.
144
+ *
145
+ * Four of them do — the generator running out, the consumer going away,
146
+ * the generator throwing, and a keep-alive finding the controller shut —
147
+ * and the summary must be reported once, not once per path.
148
+ */
149
+ const done = (reason: StreamReason): void => {
150
+ if (beating !== undefined) {
151
+ clearInterval(beating);
152
+
153
+ beating = undefined;
154
+ }
155
+
156
+ ending.abort();
157
+
158
+ if (over) {
159
+ return;
160
+ }
161
+
162
+ over = true;
163
+
164
+ try {
165
+ options.onEnd?.({
166
+ chunks: written,
167
+ bytes,
168
+ durationMs: Math.round((performance.now() - startedAt) * 1000) / 1000,
169
+ reason,
170
+ });
171
+ } catch (error) {
172
+ /**
173
+ * The response left long ago, so there is nothing to map this to and
174
+ * nobody to answer — the same reason the generator's own failure is
175
+ * printed rather than raised.
176
+ */
177
+ console.error("[tetsu] stream onEnd failed:", error);
178
+ }
179
+ };
180
+
181
+ /** Writes one chunk and counts what it put on the wire. */
182
+ const emit = (
183
+ controller: ReadableStreamDefaultController<Uint8Array>,
184
+ chunk: string,
185
+ ): void => {
186
+ const encoded = encoder.encode(chunk);
187
+
188
+ bytes += encoded.byteLength;
189
+
190
+ controller.enqueue(encoded);
191
+ };
192
+
193
+ const body = new ReadableStream<Uint8Array>({
194
+ /**
195
+ * Starts the keep-alive, which is the only thing that writes on its
196
+ * own schedule rather than on demand.
197
+ *
198
+ * It skips a beat the consumer has no room for, by the same rule the
199
+ * chunks follow: a stream with a full queue is backed up, not idle,
200
+ * and the filler exists only to keep an idle connection from being
201
+ * closed by a proxy. Without the check a stalled stream would collect
202
+ * one every interval for as long as it stalls, which is small and
203
+ * unbounded — the shape of the defect this whole pull loop exists to
204
+ * remove, in miniature.
205
+ *
206
+ * No test separates the two: the fillers are a few bytes each and they
207
+ * queue behind the megabyte the transport is already holding, so
208
+ * nothing observable through a socket ever reaches them. The check is
209
+ * kept on the reasoning, not on a measurement, and this is the note
210
+ * saying so.
211
+ */
212
+ start(controller) {
213
+ const alive = options.keepAlive;
214
+
215
+ if (!alive || alive.everyMs <= 0) {
216
+ return;
217
+ }
218
+
219
+ beating = setInterval(() => {
220
+ if ((controller.desiredSize ?? 0) <= 0) {
221
+ return;
222
+ }
223
+
224
+ try {
225
+ emit(controller, alive.chunk);
226
+ } catch {
227
+ done("cancelled");
228
+ }
229
+ }, alive.everyMs);
230
+ },
231
+
232
+ /**
233
+ * Produces one chunk, and only when the consumer has room for it.
234
+ *
235
+ * This is the whole of the backpressure: the platform calls `pull`
236
+ * while the queue wants more and stops calling it when it does not, so
237
+ * exactly one `next()` is ever in flight and the generator advances at
238
+ * the rate the client reads. Driving the generator from a loop instead
239
+ * asks it for everything at once, because `enqueue` never blocks and
240
+ * never refuses: a client that stopped reading had a million chunks
241
+ * built for it and held in memory.
242
+ *
243
+ * The cost of the shape is that leaving a loop no longer ends the
244
+ * generator, because there is no loop; `cancel` calls `return()` in
245
+ * its place.
246
+ */
247
+ async pull(controller) {
248
+ try {
249
+ const next = await chunks.next();
250
+
251
+ if (next.done || signal.aborted) {
252
+ /**
253
+ * The signal is checked first on purpose: a generator that takes
254
+ * it does the polite thing and returns, so `done` would be true
255
+ * on a stream the client walked away from. What ended it is the
256
+ * departure, and that is what the summary should say.
257
+ *
258
+ * No test separates this from always reporting `ended`, and that
259
+ * is not a gap in the tests. Every way the signal becomes true
260
+ * here runs through a `done` call that has already fixed the
261
+ * reason — `cancel` on the consumer's side, the keep-alive
262
+ * finding a shut controller — and all of them say `cancelled`
263
+ * too. The branch decides a race whose other outcome agrees with
264
+ * it, which is why it is kept and why nothing can observe it.
265
+ */
266
+ done(signal.aborted ? "cancelled" : "ended");
267
+ close(controller);
268
+
269
+ return;
270
+ }
271
+
272
+ written += 1;
273
+
274
+ emit(controller, next.value);
275
+ } catch (error) {
276
+ console.error("[tetsu] stream generator failed:", error);
277
+
278
+ done("failed");
279
+ close(controller);
280
+ }
281
+ },
282
+
283
+ /**
284
+ * The stream's own end of life, told by whoever consumed it.
285
+ *
286
+ * Not a backstop: this is the only thing that ends a stream whose
287
+ * response never reached the client. The pipeline releases a response
288
+ * it discards — one a `beforeResponse` hook replaced, one an error
289
+ * displaced, one a `HEAD` request answered without — by cancelling its
290
+ * body, and the request's own signal says nothing in those cases,
291
+ * because the request itself ended normally.
292
+ *
293
+ * A client that leaves aborts `ctx.req.signal` first, so that path
294
+ * does not depend on this line; the request whose response was thrown
295
+ * away depends on nothing else.
296
+ */
297
+ cancel() {
298
+ done("cancelled");
299
+
300
+ void chunks.return();
301
+ },
302
+ });
303
+
304
+ return new Response(body, {
305
+ status: options.status ?? 200,
306
+ headers: {
307
+ ...(options.contentType
308
+ ? { "content-type": options.contentType }
309
+ : undefined),
310
+ ...options.headers,
311
+ },
312
+ });
313
+ }
314
+
315
+ /**
316
+ * Ends the stream, tolerating a client that already left.
317
+ *
318
+ * `close()` throws on a controller the platform closed when the connection
319
+ * went away, and that is not a failure worth reporting: the stream ended
320
+ * exactly as it was going to.
321
+ */
322
+ function close(controller: ReadableStreamDefaultController<Uint8Array>): void {
323
+ try {
324
+ controller.close();
325
+ } catch {
326
+ // The stream is already closed because the client left.
327
+ }
328
+ }