@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/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
|
+
}
|