@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/LICENSE +21 -0
- package/README.md +145 -0
- package/dist/src/index.d.ts +206 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +153 -0
- package/dist/src/index.js.map +11 -0
- package/dist/src/stream.d.ts +111 -0
- package/dist/src/stream.d.ts.map +1 -0
- package/package.json +53 -0
- package/src/index.ts +339 -0
- package/src/stream.ts +328 -0
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
|
+
}
|