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