@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 tetsuodev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # @tetsujs/sse
2
+
3
+ Server-sent events from an async generator, and the same machinery for any
4
+ other streamed format.
5
+
6
+ ```bash
7
+ bun add @tetsujs/sse
8
+ ```
9
+
10
+ ## Usage
11
+
12
+ ```ts
13
+ import { sse } from "@tetsujs/sse";
14
+
15
+ route({
16
+ method: "GET",
17
+ path: "/prices",
18
+ handler: (ctx) =>
19
+ sse(ctx, async function* (signal) {
20
+ for await (const price of prices.watch({ signal })) {
21
+ yield { data: price, id: price.at };
22
+ }
23
+ }),
24
+ });
25
+ ```
26
+
27
+ `sse()` sets the SSE headers, frames every event, sends a keep-alive
28
+ comment every 15 seconds so proxies do not close an idle connection, and
29
+ pulls events one at a time — a client that stops reading stops the
30
+ generator instead of filling memory.
31
+
32
+ An event:
33
+
34
+ ```ts
35
+ yield {
36
+ data: { price: 42 }, // a string is sent as is, anything else as JSON
37
+ event: "tick", // the name for addEventListener
38
+ id: "1712", // sent back as Last-Event-ID when the browser reconnects
39
+ retry: 3_000, // how long the browser waits before reconnecting
40
+ };
41
+ ```
42
+
43
+ ## Resuming
44
+
45
+ A browser that lost the connection reconnects with the last id it saw.
46
+ `lastEventId(ctx)` reads it, so the stream can continue from there:
47
+
48
+ ```ts
49
+ import { lastEventId, sse } from "@tetsujs/sse";
50
+
51
+ handler: (ctx) =>
52
+ sse(ctx, async function* () {
53
+ for await (const item of history.since(lastEventId(ctx))) {
54
+ yield { data: item, id: item.id };
55
+ }
56
+ }),
57
+ ```
58
+
59
+ ## Cleaning up
60
+
61
+ The generator receives an `AbortSignal` that fires when the stream is over
62
+ — the client left, the response was discarded, or the generator finished.
63
+
64
+ A generator that yields regularly needs nothing more: when the client
65
+ leaves, its loop ends and its `finally` runs. **A generator that can go
66
+ quiet must pass the signal on** to whatever it waits for — a queue with no
67
+ traffic, a poll of something unchanged. Otherwise it waits forever and is
68
+ never cleaned up:
69
+
70
+ ```ts
71
+ sse(ctx, async function* (signal) {
72
+ const queue = await broker.subscribe("prices", { signal });
73
+
74
+ try {
75
+ for await (const price of queue) {
76
+ yield { data: price, id: price.at };
77
+ }
78
+ } finally {
79
+ await queue.close();
80
+ }
81
+ });
82
+ ```
83
+
84
+ A generator that throws ends the stream where it stood, and the error is
85
+ logged as `[tetsu] stream generator failed:` — the response has already
86
+ left, so there is no `onError` to hand it to.
87
+
88
+ ## Knowing what a stream did
89
+
90
+ An access log sees a stream when it starts, so it records a long feed as a
91
+ fast `200`. `onEnd` reports how it actually ended:
92
+
93
+ ```ts
94
+ sse(ctx, feed, {
95
+ onEnd: (summary) => logger.info({ ...summary, requestId: ctx.requestId }, "stream closed"),
96
+ });
97
+ // { events: 412, bytes: 38104, durationMs: 2401882.6, reason: "cancelled" }
98
+ ```
99
+
100
+ `reason` is `"ended"` (the generator finished), `"cancelled"` (the client
101
+ left, or the response was discarded) or `"failed"` (the generator threw).
102
+
103
+ ## Other formats: `stream()`
104
+
105
+ `sse()` is `stream()` with SSE framing on top. For anything else — NDJSON,
106
+ CSV, a model's tokens — `stream()` takes the chunks as they are, with the
107
+ same backpressure, signal and summary:
108
+
109
+ ```ts
110
+ import { stream } from "@tetsujs/sse";
111
+
112
+ handler: (ctx) =>
113
+ stream(
114
+ ctx,
115
+ async function* (signal) {
116
+ for await (const row of rows.watch({ signal })) {
117
+ yield `${JSON.stringify(row)}\n`;
118
+ }
119
+ },
120
+ { contentType: "application/x-ndjson" },
121
+ ),
122
+ ```
123
+
124
+ `stream()` sends no keep-alives unless asked, because not every format has
125
+ a line a client will ignore:
126
+
127
+ ```ts
128
+ { keepAlive: { everyMs: 15_000, chunk: "\n" } }
129
+ ```
130
+
131
+ ## Options
132
+
133
+ | `sse()` | Default | |
134
+ | --- | --- | --- |
135
+ | `heartbeatMs` | `15000` | keep-alive interval; `0` turns it off |
136
+ | `status` | `200` | |
137
+ | `onEnd` | — | receives the summary when the stream ends |
138
+
139
+ | `stream()` | Default | |
140
+ | --- | --- | --- |
141
+ | `contentType` | none | the `content-type` header |
142
+ | `status` | `200` | |
143
+ | `headers` | — | more response headers |
144
+ | `keepAlive` | off | `{ everyMs, chunk }` |
145
+ | `onEnd` | — | receives the summary; it counts `chunks` instead of `events` |
@@ -0,0 +1,206 @@
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
+ import type { BaseCtx } from "@tetsujs/core";
31
+ import type { StreamReason, StreamSummary } from "./stream.ts";
32
+ export type { KeepAlive, StreamOptions, StreamReason, StreamSummary, } from "./stream.ts";
33
+ export { stream } from "./stream.ts";
34
+ /**
35
+ * One event, as it goes over the wire.
36
+ *
37
+ * `data` is deliberately untyped. A stream usually carries several kinds
38
+ * of event under different names, so one type parameter would be wrong for
39
+ * all but the simplest feed — and nothing on the server consumes the
40
+ * payload's type anyway: it leaves as JSON. A feed that is uniform can say
41
+ * so where it is written, by typing its own generator.
42
+ */
43
+ export interface ServerSentEvent {
44
+ /**
45
+ * The payload. A string is sent as it is; anything else is JSON, which
46
+ * is what a browser's `EventSource` expects to parse. A value JSON has
47
+ * no form for — `undefined`, a function, a symbol — is refused rather
48
+ * than sent as an empty string: a payload that went missing, a
49
+ * `map.get()` that found nothing, would reach the page as a valid event
50
+ * with nothing in it.
51
+ */
52
+ readonly data: unknown;
53
+ /** Event name, read by `addEventListener(name)` rather than `onmessage`. */
54
+ readonly event?: string;
55
+ /**
56
+ * Event id. The browser sends the last one back as `Last-Event-ID` when
57
+ * it reconnects, which is how a stream resumes where it stopped.
58
+ */
59
+ readonly id?: string | number;
60
+ /** How long the browser waits before reconnecting, in milliseconds. */
61
+ readonly retry?: number;
62
+ }
63
+ /** How the stream behaves. */
64
+ export interface SseOptions {
65
+ /**
66
+ * How often a comment line is sent to keep the connection alive, in
67
+ * milliseconds. Defaults to 15 seconds; `0` turns it off.
68
+ *
69
+ * On by default because the failure it prevents is silent and remote: a
70
+ * proxy between the server and the browser closes a connection that has
71
+ * been idle — nginx after 60 seconds by default — and the application
72
+ * sees a client that keeps reconnecting for no visible reason.
73
+ */
74
+ readonly heartbeatMs?: number;
75
+ /** Status of the response. `200` by default. */
76
+ readonly status?: number;
77
+ /**
78
+ * Called once when the stream is over, with what it did.
79
+ *
80
+ * The gap this closes: `afterResponse` runs when the response is handed
81
+ * to the runtime, which for a stream is the moment it *starts*. An
82
+ * access log therefore records a forty-minute feed as a `200` that took
83
+ * microseconds, and a torn connection as a success. Delivery to the
84
+ * client is not observable in the fetch model and stays that way — but
85
+ * the end of *generation* is, and that is what this reports.
86
+ *
87
+ * It carries no request id on purpose. This callback is written at the
88
+ * call site, where `ctx` is already in scope, so the caller adds
89
+ * whatever identifies the request better than this package could guess.
90
+ *
91
+ * @example
92
+ * ```ts
93
+ * sse(ctx, feed, {
94
+ * onEnd: (summary) =>
95
+ * logger.info({ ...summary, requestId: ctx.requestId }, "stream closed"),
96
+ * });
97
+ * ```
98
+ */
99
+ readonly onEnd?: (summary: SseSummary) => void;
100
+ }
101
+ /**
102
+ * How an SSE stream ended — {@link StreamReason} by another name, because
103
+ * a reader of this package should not have to go looking.
104
+ */
105
+ export type SseReason = StreamReason;
106
+ /**
107
+ * One finished stream, as server-sent events count it.
108
+ *
109
+ * The same record {@link StreamSummary} carries, with `chunks` named
110
+ * `events`: for this helper one chunk is one event, and the word an author
111
+ * reads at the call site should be the one they wrote.
112
+ */
113
+ export interface SseSummary extends Omit<StreamSummary, "chunks"> {
114
+ /** Events yielded and written, not counting heartbeats. */
115
+ readonly events: number;
116
+ }
117
+ /**
118
+ * Builds a server-sent events response from an async generator.
119
+ *
120
+ * The generator is handed an `AbortSignal` that fires when the stream is
121
+ * over, whichever way it ended — the client disconnected, the consumer
122
+ * cancelled, the generator itself failed. It is not `ctx.req.signal`
123
+ * directly: a source wants to know that this stream is finished, not which
124
+ * of the ways finished it.
125
+ *
126
+ * **Passing it on is what makes cleanup work**, and it is the caller's job
127
+ * rather than this package's. A generator between two `yield`s leaves on
128
+ * its own — the loop sees the abort at the next value, ends the `for
129
+ * await`, and the language calls the generator's `return()`, which runs
130
+ * its `finally`. A generator parked inside an `await` is resumed by
131
+ * nothing: `return()` on it is queued behind that `await` and applies only
132
+ * once it settles, so an `await` on a source that has gone quiet never
133
+ * unwinds, and the subscription inside it lives as long as the process.
134
+ * Neither `cancel()` on the stream nor `return()` on the generator changes
135
+ * that — both were measured, both fire, neither wakes it — which is why
136
+ * the signal goes to the source instead.
137
+ *
138
+ * So the rule, stated plainly: **a stream ends with the connection if its
139
+ * generator keeps yielding, or if it waits on the signal.** A generator
140
+ * that does neither leaks, and no amount of care out here can collect it.
141
+ *
142
+ * A generator that fails instead of ending is logged and the stream is
143
+ * closed where it stood, so what already went out stays valid and the
144
+ * client sees an ordinary end of stream. Letting the failure escape
145
+ * `start()` instead would reach no one the application can hear: the
146
+ * platform prints a raw stack and tears the connection down, and whether
147
+ * the bytes already queued are lost with it depends on whether a macrotask
148
+ * happened to run in between.
149
+ *
150
+ * @example A source that yields on its own — the loop ends it.
151
+ * ```ts
152
+ * sse(ctx, async function* () {
153
+ * const subscription = topic.subscribe();
154
+ *
155
+ * try {
156
+ * for await (const message of subscription) {
157
+ * yield { event: "message", data: message, id: message.id };
158
+ * }
159
+ * } finally {
160
+ * subscription.close();
161
+ * }
162
+ * });
163
+ * ```
164
+ *
165
+ * @example A source that can go quiet — it has to take the signal.
166
+ * ```ts
167
+ * sse(ctx, async function* (signal) {
168
+ * const queue = await broker.subscribe("prices", { signal });
169
+ *
170
+ * try {
171
+ * for await (const price of queue) {
172
+ * yield { data: price, id: price.at };
173
+ * }
174
+ * } finally {
175
+ * await queue.close();
176
+ * }
177
+ * });
178
+ * ```
179
+ */
180
+ export declare function sse(ctx: BaseCtx, source: (signal: AbortSignal) => AsyncGenerator<ServerSentEvent, void, undefined>, options?: SseOptions): Response;
181
+ /**
182
+ * Formats one event.
183
+ *
184
+ * Every line of the payload carries its own `data:` prefix — a raw line
185
+ * break inside one would otherwise end the field — and a blank line ends
186
+ * the event, which is what makes the client dispatch it.
187
+ */
188
+ export declare function frame(event: ServerSentEvent): string;
189
+ /**
190
+ * The id the client last received, when it is reconnecting.
191
+ *
192
+ * A browser sends it automatically after a dropped connection; a stream
193
+ * that yields ids can resume from it instead of starting over.
194
+ *
195
+ * @example
196
+ * ```ts
197
+ * handler: (ctx) =>
198
+ * sse(ctx, async function* () {
199
+ * for await (const item of history.since(lastEventId(ctx))) {
200
+ * yield { data: item, id: item.id };
201
+ * }
202
+ * });
203
+ * ```
204
+ */
205
+ export declare function lastEventId(ctx: BaseCtx): string | undefined;
206
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAC7C,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAG/D,YAAY,EACV,SAAS,EACT,aAAa,EACb,YAAY,EACZ,aAAa,GACd,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAErC;;;;;;;;GAQG;AACH,MAAM,WAAW,eAAe;IAC9B;;;;;;;OAOG;IACH,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IAEvB,4EAA4E;IAC5E,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAExB;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAE9B,uEAAuE;IACvE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,8BAA8B;AAC9B,MAAM,WAAW,UAAU;IACzB;;;;;;;;OAQG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAE9B,gDAAgD;IAChD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAEzB;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,UAAU,KAAK,IAAI,CAAC;CAChD;AAED;;;GAGG;AACH,MAAM,MAAM,SAAS,GAAG,YAAY,CAAC;AAErC;;;;;;GAMG;AACH,MAAM,WAAW,UAAW,SAAQ,IAAI,CAAC,aAAa,EAAE,QAAQ,CAAC;IAC/D,2DAA2D;IAC3D,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8DG;AACH,wBAAgB,GAAG,CACjB,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,CACN,MAAM,EAAE,WAAW,KAChB,cAAc,CAAC,eAAe,EAAE,IAAI,EAAE,SAAS,CAAC,EACrD,OAAO,GAAE,UAAe,GACvB,QAAQ,CAsCV;AAcD;;;;;;GAMG;AACH,wBAAgB,KAAK,CAAC,KAAK,EAAE,eAAe,GAAG,MAAM,CA6BpD;AA6BD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAE5D"}
@@ -0,0 +1,153 @@
1
+ // @bun
2
+ // packages/sse/src/stream.ts
3
+ function stream(ctx, source, options = {}) {
4
+ const encoder = new TextEncoder;
5
+ const ending = new AbortController;
6
+ const signal = AbortSignal.any([ctx.req.signal, ending.signal]);
7
+ const chunks = source(signal);
8
+ const startedAt = performance.now();
9
+ let beating;
10
+ let written = 0;
11
+ let bytes = 0;
12
+ let over = false;
13
+ const done = (reason) => {
14
+ if (beating !== undefined) {
15
+ clearInterval(beating);
16
+ beating = undefined;
17
+ }
18
+ ending.abort();
19
+ if (over) {
20
+ return;
21
+ }
22
+ over = true;
23
+ try {
24
+ options.onEnd?.({
25
+ chunks: written,
26
+ bytes,
27
+ durationMs: Math.round((performance.now() - startedAt) * 1000) / 1000,
28
+ reason
29
+ });
30
+ } catch (error) {
31
+ console.error("[tetsu] stream onEnd failed:", error);
32
+ }
33
+ };
34
+ const emit = (controller, chunk) => {
35
+ const encoded = encoder.encode(chunk);
36
+ bytes += encoded.byteLength;
37
+ controller.enqueue(encoded);
38
+ };
39
+ const body = new ReadableStream({
40
+ start(controller) {
41
+ const alive = options.keepAlive;
42
+ if (!alive || alive.everyMs <= 0) {
43
+ return;
44
+ }
45
+ beating = setInterval(() => {
46
+ if ((controller.desiredSize ?? 0) <= 0) {
47
+ return;
48
+ }
49
+ try {
50
+ emit(controller, alive.chunk);
51
+ } catch {
52
+ done("cancelled");
53
+ }
54
+ }, alive.everyMs);
55
+ },
56
+ async pull(controller) {
57
+ try {
58
+ const next = await chunks.next();
59
+ if (next.done || signal.aborted) {
60
+ done(signal.aborted ? "cancelled" : "ended");
61
+ close(controller);
62
+ return;
63
+ }
64
+ written += 1;
65
+ emit(controller, next.value);
66
+ } catch (error) {
67
+ console.error("[tetsu] stream generator failed:", error);
68
+ done("failed");
69
+ close(controller);
70
+ }
71
+ },
72
+ cancel() {
73
+ done("cancelled");
74
+ chunks.return();
75
+ }
76
+ });
77
+ return new Response(body, {
78
+ status: options.status ?? 200,
79
+ headers: {
80
+ ...options.contentType ? { "content-type": options.contentType } : undefined,
81
+ ...options.headers
82
+ }
83
+ });
84
+ }
85
+ function close(controller) {
86
+ try {
87
+ controller.close();
88
+ } catch {}
89
+ }
90
+
91
+ // packages/sse/src/index.ts
92
+ function sse(ctx, source, options = {}) {
93
+ const heartbeatMs = options.heartbeatMs ?? 15000;
94
+ const { onEnd } = options;
95
+ return stream(ctx, async function* (signal) {
96
+ for await (const event of source(signal)) {
97
+ yield frame(event);
98
+ }
99
+ }, {
100
+ contentType: "text/event-stream",
101
+ headers: { "cache-control": "no-cache" },
102
+ ...options.status === undefined ? {} : { status: options.status },
103
+ ...heartbeatMs > 0 ? { keepAlive: { everyMs: heartbeatMs, chunk: `: ping
104
+
105
+ ` } } : {},
106
+ ...onEnd ? {
107
+ onEnd: ({ chunks, ...rest }) => onEnd({ ...rest, events: chunks })
108
+ } : {}
109
+ });
110
+ }
111
+ var lineBreak = /\r\n|[\r\n]/;
112
+ var unrepresentable = /[\r\n\0]/;
113
+ function frame(event) {
114
+ const lines = [];
115
+ if (event.event !== undefined) {
116
+ lines.push(`event: ${single("event", event.event)}`);
117
+ }
118
+ if (event.id !== undefined) {
119
+ lines.push(`id: ${single("id", String(event.id))}`);
120
+ }
121
+ if (event.retry !== undefined) {
122
+ lines.push(`retry: ${event.retry}`);
123
+ }
124
+ const payload = typeof event.data === "string" ? event.data : JSON.stringify(event.data);
125
+ if (payload === undefined) {
126
+ throw new TypeError(`an SSE event's data has no JSON form: ${typeof event.data}`);
127
+ }
128
+ for (const line of payload.split(lineBreak)) {
129
+ lines.push(`data: ${line}`);
130
+ }
131
+ return `${lines.join(`
132
+ `)}
133
+
134
+ `;
135
+ }
136
+ function single(field, value) {
137
+ if (unrepresentable.test(value)) {
138
+ throw new TypeError(`an SSE ${field} cannot contain a line break or NUL: ${JSON.stringify(value)}`);
139
+ }
140
+ return value;
141
+ }
142
+ function lastEventId(ctx) {
143
+ return ctx.req.headers.get("last-event-id") ?? undefined;
144
+ }
145
+ export {
146
+ frame,
147
+ lastEventId,
148
+ sse,
149
+ stream
150
+ };
151
+
152
+ //# debugId=41A90C435B2C5A2764756E2164756E21
153
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,11 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/stream.ts", "../src/index.ts"],
4
+ "sourcesContent": [
5
+ "/**\n * A generator, piped to a response at the rate the client reads it.\n *\n * This is the machinery `sse()` is built on, exported because the wire\n * format is the only part of it that is about server-sent events. A\n * server-to-server feed wants newline-delimited JSON, an export wants CSV,\n * a proxy wants whatever it was handed — and none of them should have to\n * rediscover backpressure, the abort signal, ending the generator on\n * cancellation, or reporting what the stream did.\n *\n * ```ts\n * handler: (ctx) =>\n * stream(\n * ctx,\n * async function* (signal) {\n * for await (const row of rows.watch({ signal })) {\n * yield `${JSON.stringify(row)}\\n`;\n * }\n * },\n * { contentType: \"application/x-ndjson\" },\n * );\n * ```\n *\n * @module\n */\n\nimport type { BaseCtx } from \"@tetsujs/core\";\n\n/** How a stream ended. */\nexport type StreamReason =\n /** The generator ran out on its own. */\n | \"ended\"\n /**\n * Nobody is reading any more — the client disconnected, or the pipeline\n * discarded the response the stream was the body of.\n */\n | \"cancelled\"\n /** The generator threw. What had already gone out stayed valid. */\n | \"failed\";\n\n/** One finished stream. */\nexport interface StreamSummary {\n /**\n * Chunks the generator yielded and the stream wrote, not counting\n * keep-alives. For `sse()` that is one per event.\n */\n readonly chunks: number;\n\n /**\n * Bytes enqueued, keep-alives included — what the stream put on the wire\n * rather than what the application meant to say.\n */\n readonly bytes: number;\n\n /** How long the stream lived, in milliseconds, to the microsecond. */\n readonly durationMs: number;\n\n readonly reason: StreamReason;\n}\n\n/** Something written on a schedule, so an idle connection stays open. */\nexport interface KeepAlive {\n /** How often, in milliseconds. */\n readonly everyMs: number;\n\n /**\n * What to write. It has to be something the consumer's parser ignores —\n * a comment in a format that has them, a blank line in one that does\n * not, nothing at all in a format where neither is true.\n */\n readonly chunk: string;\n}\n\n/** How the stream behaves and what it answers with. */\nexport interface StreamOptions {\n /** The response's `content-type`. Omitted, none is set. */\n readonly contentType?: string;\n\n /** Status of the response. `200` by default. */\n readonly status?: number;\n\n /** Headers to send alongside — `cache-control`, and whatever else. */\n readonly headers?: Record<string, string>;\n\n /** A filler written while nothing else is. Off by default. */\n readonly keepAlive?: KeepAlive;\n\n /** Called once when the stream is over, with what it did. */\n readonly onEnd?: (summary: StreamSummary) => void;\n}\n\n/**\n * Builds a streaming response from an async generator.\n *\n * The generator is handed an `AbortSignal` that fires when the stream is\n * over, whichever way it ended — the client disconnected, the consumer\n * cancelled, the generator itself failed. It is not `ctx.req.signal`\n * directly: a source wants to know that this stream is finished, not which\n * of the ways finished it.\n *\n * **Passing it on is what makes cleanup work**, and it is the caller's job\n * rather than this module's. A generator between two `yield`s leaves on\n * its own — the loop sees the abort at the next chunk, and `return()` runs\n * its `finally`. A generator parked inside an `await` is resumed by\n * nothing: `return()` on it is queued behind that `await` and applies only\n * once it settles, so an `await` on a source that has gone quiet never\n * unwinds, and the subscription inside it lives as long as the process.\n * Neither cancelling the stream nor `return()` changes that — both were\n * measured, both fire, neither wakes it — which is why the signal goes to\n * the source instead.\n *\n * So the rule, stated plainly: **a stream ends with the connection if its\n * generator keeps yielding, or if it waits on the signal.** A generator\n * that does neither leaks, and no amount of care out here can collect it.\n *\n * A generator that fails instead of ending is logged and the stream is\n * closed where it stood, so what already went out stays valid and the\n * client sees an ordinary end of stream. Letting the failure escape\n * instead would reach no one the application can hear: the platform prints\n * a raw stack and tears the connection down, and whether the bytes already\n * queued are lost with it depends on whether a macrotask happened to run\n * in between.\n */\nexport function stream(\n ctx: BaseCtx,\n source: (signal: AbortSignal) => AsyncGenerator<string, void, undefined>,\n options: StreamOptions = {},\n): Response {\n const encoder = new TextEncoder();\n const ending = new AbortController();\n const signal = AbortSignal.any([ctx.req.signal, ending.signal]);\n\n const chunks = source(signal);\n\n const startedAt = performance.now();\n\n let beating: ReturnType<typeof setInterval> | undefined;\n let written = 0;\n let bytes = 0;\n let over = false;\n\n /**\n * Ends the stream once, whichever path got here first.\n *\n * Four of them do — the generator running out, the consumer going away,\n * the generator throwing, and a keep-alive finding the controller shut —\n * and the summary must be reported once, not once per path.\n */\n const done = (reason: StreamReason): void => {\n if (beating !== undefined) {\n clearInterval(beating);\n\n beating = undefined;\n }\n\n ending.abort();\n\n if (over) {\n return;\n }\n\n over = true;\n\n try {\n options.onEnd?.({\n chunks: written,\n bytes,\n durationMs: Math.round((performance.now() - startedAt) * 1000) / 1000,\n reason,\n });\n } catch (error) {\n /**\n * The response left long ago, so there is nothing to map this to and\n * nobody to answer — the same reason the generator's own failure is\n * printed rather than raised.\n */\n console.error(\"[tetsu] stream onEnd failed:\", error);\n }\n };\n\n /** Writes one chunk and counts what it put on the wire. */\n const emit = (\n controller: ReadableStreamDefaultController<Uint8Array>,\n chunk: string,\n ): void => {\n const encoded = encoder.encode(chunk);\n\n bytes += encoded.byteLength;\n\n controller.enqueue(encoded);\n };\n\n const body = new ReadableStream<Uint8Array>({\n /**\n * Starts the keep-alive, which is the only thing that writes on its\n * own schedule rather than on demand.\n *\n * It skips a beat the consumer has no room for, by the same rule the\n * chunks follow: a stream with a full queue is backed up, not idle,\n * and the filler exists only to keep an idle connection from being\n * closed by a proxy. Without the check a stalled stream would collect\n * one every interval for as long as it stalls, which is small and\n * unbounded — the shape of the defect this whole pull loop exists to\n * remove, in miniature.\n *\n * No test separates the two: the fillers are a few bytes each and they\n * queue behind the megabyte the transport is already holding, so\n * nothing observable through a socket ever reaches them. The check is\n * kept on the reasoning, not on a measurement, and this is the note\n * saying so.\n */\n start(controller) {\n const alive = options.keepAlive;\n\n if (!alive || alive.everyMs <= 0) {\n return;\n }\n\n beating = setInterval(() => {\n if ((controller.desiredSize ?? 0) <= 0) {\n return;\n }\n\n try {\n emit(controller, alive.chunk);\n } catch {\n done(\"cancelled\");\n }\n }, alive.everyMs);\n },\n\n /**\n * Produces one chunk, and only when the consumer has room for it.\n *\n * This is the whole of the backpressure: the platform calls `pull`\n * while the queue wants more and stops calling it when it does not, so\n * exactly one `next()` is ever in flight and the generator advances at\n * the rate the client reads. Driving the generator from a loop instead\n * asks it for everything at once, because `enqueue` never blocks and\n * never refuses: a client that stopped reading had a million chunks\n * built for it and held in memory.\n *\n * The cost of the shape is that leaving a loop no longer ends the\n * generator, because there is no loop; `cancel` calls `return()` in\n * its place.\n */\n async pull(controller) {\n try {\n const next = await chunks.next();\n\n if (next.done || signal.aborted) {\n /**\n * The signal is checked first on purpose: a generator that takes\n * it does the polite thing and returns, so `done` would be true\n * on a stream the client walked away from. What ended it is the\n * departure, and that is what the summary should say.\n *\n * No test separates this from always reporting `ended`, and that\n * is not a gap in the tests. Every way the signal becomes true\n * here runs through a `done` call that has already fixed the\n * reason — `cancel` on the consumer's side, the keep-alive\n * finding a shut controller — and all of them say `cancelled`\n * too. The branch decides a race whose other outcome agrees with\n * it, which is why it is kept and why nothing can observe it.\n */\n done(signal.aborted ? \"cancelled\" : \"ended\");\n close(controller);\n\n return;\n }\n\n written += 1;\n\n emit(controller, next.value);\n } catch (error) {\n console.error(\"[tetsu] stream generator failed:\", error);\n\n done(\"failed\");\n close(controller);\n }\n },\n\n /**\n * The stream's own end of life, told by whoever consumed it.\n *\n * Not a backstop: this is the only thing that ends a stream whose\n * response never reached the client. The pipeline releases a response\n * it discards — one a `beforeResponse` hook replaced, one an error\n * displaced, one a `HEAD` request answered without — by cancelling its\n * body, and the request's own signal says nothing in those cases,\n * because the request itself ended normally.\n *\n * A client that leaves aborts `ctx.req.signal` first, so that path\n * does not depend on this line; the request whose response was thrown\n * away depends on nothing else.\n */\n cancel() {\n done(\"cancelled\");\n\n void chunks.return();\n },\n });\n\n return new Response(body, {\n status: options.status ?? 200,\n headers: {\n ...(options.contentType\n ? { \"content-type\": options.contentType }\n : undefined),\n ...options.headers,\n },\n });\n}\n\n/**\n * Ends the stream, tolerating a client that already left.\n *\n * `close()` throws on a controller the platform closed when the connection\n * went away, and that is not a failure worth reporting: the stream ended\n * exactly as it was going to.\n */\nfunction close(controller: ReadableStreamDefaultController<Uint8Array>): void {\n try {\n controller.close();\n } catch {\n // The stream is already closed because the client left.\n }\n}\n",
6
+ "/**\n * Server-sent events.\n *\n * ```ts\n * route({\n * method: \"GET\",\n * path: \"/prices\",\n * handler: (ctx) =>\n * sse(ctx, async function* (signal) {\n * for await (const price of prices.watch({ signal })) {\n * yield { data: price, id: price.at };\n * }\n * }),\n * });\n * ```\n *\n * The framework needs none of this to stream — a handler returning a\n * `Response` with a stream already works. What this adds is the part that\n * is easy to get quietly wrong: the wire format (a blank line ends an\n * event, and every line of a multi-line payload carries its own `data:`\n * prefix), the headers a proxy needs to see, the heartbeat that keeps an\n * idle connection from being closed by one, an `AbortSignal` that says the\n * stream is over so a source that waits can stop waiting, and the\n * backpressure that makes a slow client cost a buffer rather than a heap:\n * events are produced on demand, one at a time, at the rate they are\n * read.\n *\n * @module\n */\n\nimport type { BaseCtx } from \"@tetsujs/core\";\nimport type { StreamReason, StreamSummary } from \"./stream.ts\";\nimport { stream } from \"./stream.ts\";\n\nexport type {\n KeepAlive,\n StreamOptions,\n StreamReason,\n StreamSummary,\n} from \"./stream.ts\";\nexport { stream } from \"./stream.ts\";\n\n/**\n * One event, as it goes over the wire.\n *\n * `data` is deliberately untyped. A stream usually carries several kinds\n * of event under different names, so one type parameter would be wrong for\n * all but the simplest feed — and nothing on the server consumes the\n * payload's type anyway: it leaves as JSON. A feed that is uniform can say\n * so where it is written, by typing its own generator.\n */\nexport interface ServerSentEvent {\n /**\n * The payload. A string is sent as it is; anything else is JSON, which\n * is what a browser's `EventSource` expects to parse. A value JSON has\n * no form for — `undefined`, a function, a symbol — is refused rather\n * than sent as an empty string: a payload that went missing, a\n * `map.get()` that found nothing, would reach the page as a valid event\n * with nothing in it.\n */\n readonly data: unknown;\n\n /** Event name, read by `addEventListener(name)` rather than `onmessage`. */\n readonly event?: string;\n\n /**\n * Event id. The browser sends the last one back as `Last-Event-ID` when\n * it reconnects, which is how a stream resumes where it stopped.\n */\n readonly id?: string | number;\n\n /** How long the browser waits before reconnecting, in milliseconds. */\n readonly retry?: number;\n}\n\n/** How the stream behaves. */\nexport interface SseOptions {\n /**\n * How often a comment line is sent to keep the connection alive, in\n * milliseconds. Defaults to 15 seconds; `0` turns it off.\n *\n * On by default because the failure it prevents is silent and remote: a\n * proxy between the server and the browser closes a connection that has\n * been idle — nginx after 60 seconds by default — and the application\n * sees a client that keeps reconnecting for no visible reason.\n */\n readonly heartbeatMs?: number;\n\n /** Status of the response. `200` by default. */\n readonly status?: number;\n\n /**\n * Called once when the stream is over, with what it did.\n *\n * The gap this closes: `afterResponse` runs when the response is handed\n * to the runtime, which for a stream is the moment it *starts*. An\n * access log therefore records a forty-minute feed as a `200` that took\n * microseconds, and a torn connection as a success. Delivery to the\n * client is not observable in the fetch model and stays that way — but\n * the end of *generation* is, and that is what this reports.\n *\n * It carries no request id on purpose. This callback is written at the\n * call site, where `ctx` is already in scope, so the caller adds\n * whatever identifies the request better than this package could guess.\n *\n * @example\n * ```ts\n * sse(ctx, feed, {\n * onEnd: (summary) =>\n * logger.info({ ...summary, requestId: ctx.requestId }, \"stream closed\"),\n * });\n * ```\n */\n readonly onEnd?: (summary: SseSummary) => void;\n}\n\n/**\n * How an SSE stream ended — {@link StreamReason} by another name, because\n * a reader of this package should not have to go looking.\n */\nexport type SseReason = StreamReason;\n\n/**\n * One finished stream, as server-sent events count it.\n *\n * The same record {@link StreamSummary} carries, with `chunks` named\n * `events`: for this helper one chunk is one event, and the word an author\n * reads at the call site should be the one they wrote.\n */\nexport interface SseSummary extends Omit<StreamSummary, \"chunks\"> {\n /** Events yielded and written, not counting heartbeats. */\n readonly events: number;\n}\n\n/**\n * Builds a server-sent events response from an async generator.\n *\n * The generator is handed an `AbortSignal` that fires when the stream is\n * over, whichever way it ended — the client disconnected, the consumer\n * cancelled, the generator itself failed. It is not `ctx.req.signal`\n * directly: a source wants to know that this stream is finished, not which\n * of the ways finished it.\n *\n * **Passing it on is what makes cleanup work**, and it is the caller's job\n * rather than this package's. A generator between two `yield`s leaves on\n * its own — the loop sees the abort at the next value, ends the `for\n * await`, and the language calls the generator's `return()`, which runs\n * its `finally`. A generator parked inside an `await` is resumed by\n * nothing: `return()` on it is queued behind that `await` and applies only\n * once it settles, so an `await` on a source that has gone quiet never\n * unwinds, and the subscription inside it lives as long as the process.\n * Neither `cancel()` on the stream nor `return()` on the generator changes\n * that — both were measured, both fire, neither wakes it — which is why\n * the signal goes to the source instead.\n *\n * So the rule, stated plainly: **a stream ends with the connection if its\n * generator keeps yielding, or if it waits on the signal.** A generator\n * that does neither leaks, and no amount of care out here can collect it.\n *\n * A generator that fails instead of ending is logged and the stream is\n * closed where it stood, so what already went out stays valid and the\n * client sees an ordinary end of stream. Letting the failure escape\n * `start()` instead would reach no one the application can hear: the\n * platform prints a raw stack and tears the connection down, and whether\n * the bytes already queued are lost with it depends on whether a macrotask\n * happened to run in between.\n *\n * @example A source that yields on its own — the loop ends it.\n * ```ts\n * sse(ctx, async function* () {\n * const subscription = topic.subscribe();\n *\n * try {\n * for await (const message of subscription) {\n * yield { event: \"message\", data: message, id: message.id };\n * }\n * } finally {\n * subscription.close();\n * }\n * });\n * ```\n *\n * @example A source that can go quiet — it has to take the signal.\n * ```ts\n * sse(ctx, async function* (signal) {\n * const queue = await broker.subscribe(\"prices\", { signal });\n *\n * try {\n * for await (const price of queue) {\n * yield { data: price, id: price.at };\n * }\n * } finally {\n * await queue.close();\n * }\n * });\n * ```\n */\nexport function sse(\n ctx: BaseCtx,\n source: (\n signal: AbortSignal,\n ) => AsyncGenerator<ServerSentEvent, void, undefined>,\n options: SseOptions = {},\n): Response {\n const heartbeatMs = options.heartbeatMs ?? 15_000;\n\n const { onEnd } = options;\n\n return stream(\n ctx,\n /**\n * The only part of this helper that is about server-sent events: the\n * events become frames, and everything else — backpressure, the\n * signal, ending the generator, the summary — is the stream's.\n *\n * A `for await` rather than a manual loop, because leaving it is what\n * passes `return()` on to the source when the stream is cancelled.\n */\n async function* (signal) {\n for await (const event of source(signal)) {\n yield frame(event);\n }\n },\n {\n contentType: \"text/event-stream\",\n headers: { \"cache-control\": \"no-cache\" },\n\n ...(options.status === undefined ? {} : { status: options.status }),\n\n ...(heartbeatMs > 0\n ? { keepAlive: { everyMs: heartbeatMs, chunk: \": ping\\n\\n\" } }\n : {}),\n\n ...(onEnd\n ? {\n onEnd: ({ chunks, ...rest }: StreamSummary) =>\n onEnd({ ...rest, events: chunks }),\n }\n : {}),\n },\n );\n}\n\n/**\n * What ends a line for a client reading this stream.\n *\n * The protocol terminates a line on CRLF, CR or LF — all three, which is\n * why splitting the payload on `\\n` alone is not enough: a lone `\\r` ends\n * the field just as surely, and the rest of the value is read as a new one.\n */\nconst lineBreak = /\\r\\n|[\\r\\n]/;\n\n/** What a single-line field cannot carry without ceasing to be one. */\nconst unrepresentable = /[\\r\\n\\0]/;\n\n/**\n * Formats one event.\n *\n * Every line of the payload carries its own `data:` prefix — a raw line\n * break inside one would otherwise end the field — and a blank line ends\n * the event, which is what makes the client dispatch it.\n */\nexport function frame(event: ServerSentEvent): string {\n const lines: string[] = [];\n\n if (event.event !== undefined) {\n lines.push(`event: ${single(\"event\", event.event)}`);\n }\n\n if (event.id !== undefined) {\n lines.push(`id: ${single(\"id\", String(event.id))}`);\n }\n\n if (event.retry !== undefined) {\n lines.push(`retry: ${event.retry}`);\n }\n\n const payload =\n typeof event.data === \"string\" ? event.data : JSON.stringify(event.data);\n\n if (payload === undefined) {\n throw new TypeError(\n `an SSE event's data has no JSON form: ${typeof event.data}`,\n );\n }\n\n for (const line of payload.split(lineBreak)) {\n lines.push(`data: ${line}`);\n }\n\n return `${lines.join(\"\\n\")}\\n\\n`;\n}\n\n/**\n * Checks a field the protocol gives no way to continue onto a second line.\n *\n * `data` can carry a line break because every one of its lines is prefixed\n * again; `event` and `id` cannot — the protocol has no syntax for it. So a\n * value holding one is not \"an event name with a newline in it\", it is a\n * second field the client will read and act on: a name the stream never\n * sent, or an id it will send back on reconnect. That is an injection, and\n * the values most likely to hold a line break are exactly the ones built\n * from outside input — a topic name, a row's key, a user's label.\n *\n * Throwing rather than stripping, for the reason the platform throws on a\n * header value with CRLF in it: a quietly rewritten id resumes the stream\n * somewhere else, and the application never learns it asked for something\n * the wire cannot carry. NUL joins them because a client drops an `id`\n * holding one, which breaks resumption just as silently.\n */\nfunction single(field: string, value: string): string {\n if (unrepresentable.test(value)) {\n throw new TypeError(\n `an SSE ${field} cannot contain a line break or NUL: ${JSON.stringify(value)}`,\n );\n }\n\n return value;\n}\n\n/**\n * The id the client last received, when it is reconnecting.\n *\n * A browser sends it automatically after a dropped connection; a stream\n * that yields ids can resume from it instead of starting over.\n *\n * @example\n * ```ts\n * handler: (ctx) =>\n * sse(ctx, async function* () {\n * for await (const item of history.since(lastEventId(ctx))) {\n * yield { data: item, id: item.id };\n * }\n * });\n * ```\n */\nexport function lastEventId(ctx: BaseCtx): string | undefined {\n return ctx.req.headers.get(\"last-event-id\") ?? undefined;\n}\n"
7
+ ],
8
+ "mappings": "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",
9
+ "debugId": "41A90C435B2C5A2764756E2164756E21",
10
+ "names": []
11
+ }