@znight/http 0.0.0-stage → 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 +2333 -2
- package/dist/adapter-CGSceVEN.d.mts +876 -0
- package/dist/compiler.d.mts +156 -0
- package/dist/compiler.mjs +2180 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/express.d.mts +39 -0
- package/dist/express.mjs +135 -0
- package/dist/express.mjs.map +1 -0
- package/dist/fastify.d.mts +33 -0
- package/dist/fastify.mjs +191 -0
- package/dist/fastify.mjs.map +1 -0
- package/dist/hono.d.mts +49 -0
- package/dist/hono.mjs +319 -0
- package/dist/hono.mjs.map +1 -0
- package/dist/index.d.mts +1254 -0
- package/dist/index.mjs +5618 -0
- package/dist/index.mjs.map +1 -0
- package/dist/node-BUnkdSsa.mjs +183 -0
- package/dist/node-BUnkdSsa.mjs.map +1 -0
- package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
- package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
- package/dist/node.d.mts +30 -0
- package/dist/node.mjs +1 -0
- package/dist/path-JI87C4k8.d.mts +109 -0
- package/dist/path.d.mts +2 -0
- package/dist/path.mjs +245 -0
- package/dist/path.mjs.map +1 -0
- package/dist/size-D8rsQcV0.mjs +184 -0
- package/dist/size-D8rsQcV0.mjs.map +1 -0
- package/dist/utils-DCbu3a4w.mjs +27 -0
- package/dist/utils-DCbu3a4w.mjs.map +1 -0
- package/package.json +142 -4
- package/src/adapter.ts +240 -0
- package/src/adapters/express.ts +201 -0
- package/src/adapters/fastify.ts +280 -0
- package/src/adapters/hono.ts +488 -0
- package/src/adapters/node-io.ts +368 -0
- package/src/adapters/node.ts +135 -0
- package/src/adapters/shared.ts +508 -0
- package/src/body.ts +135 -0
- package/src/coerce.ts +195 -0
- package/src/compiler/analyze.ts +1513 -0
- package/src/compiler/ast.ts +90 -0
- package/src/compiler/coercion.ts +267 -0
- package/src/compiler/generate.ts +374 -0
- package/src/compiler/index.ts +76 -0
- package/src/compiler/model.ts +260 -0
- package/src/compiler/route-decorators.ts +405 -0
- package/src/compiler/validate.ts +288 -0
- package/src/context.ts +533 -0
- package/src/cookie-settings.ts +68 -0
- package/src/cookie-signer.ts +106 -0
- package/src/cookies.ts +726 -0
- package/src/cors.ts +349 -0
- package/src/decisions.ts +331 -0
- package/src/decorators.ts +486 -0
- package/src/event-channel.ts +197 -0
- package/src/exceptions.ts +257 -0
- package/src/files.ts +429 -0
- package/src/headers.ts +39 -0
- package/src/index.ts +185 -0
- package/src/inject-sse.ts +309 -0
- package/src/interfaces.ts +101 -0
- package/src/metadata.ts +162 -0
- package/src/mime.ts +157 -0
- package/src/multipart.ts +597 -0
- package/src/option-error.ts +31 -0
- package/src/path.ts +288 -0
- package/src/pipeline.ts +918 -0
- package/src/plugin-routes.ts +188 -0
- package/src/plugin.ts +1432 -0
- package/src/proxy.ts +236 -0
- package/src/query.ts +30 -0
- package/src/response.ts +240 -0
- package/src/router.ts +119 -0
- package/src/security-headers.ts +161 -0
- package/src/serialize.ts +264 -0
- package/src/size.ts +66 -0
- package/src/sse-parse.ts +194 -0
- package/src/sse.ts +650 -0
- package/src/standard-schema.ts +81 -0
- package/src/status.ts +78 -0
- package/src/stream.ts +200 -0
- package/src/tokens.ts +21 -0
- package/src/uploads.ts +176 -0
- package/src/utils.ts +31 -0
- package/src/validate.ts +65 -0
package/src/sse.ts
ADDED
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-Sent Events (RFC 0006 §4.6, §4.7): the event encoder, the `HttpResponse.sse()` body marker
|
|
3
|
+
* and the event stream (preamble, pull loop, heartbeats, raw byte passthrough, error handling,
|
|
4
|
+
* graceful close with a jittered `retry`, bounded source stop).
|
|
5
|
+
*/
|
|
6
|
+
import type { RequestAbortKind } from './context.ts'
|
|
7
|
+
|
|
8
|
+
import { type ContextRunner, captureContext } from './stream.ts'
|
|
9
|
+
|
|
10
|
+
/** One event of an event stream. */
|
|
11
|
+
export interface SseEvent<T = unknown> {
|
|
12
|
+
/** A string is sent as is; anything else goes through `JSON.stringify` (`toJSON` honoured). */
|
|
13
|
+
readonly data?: T
|
|
14
|
+
/** Event type (`addEventListener(type)`). The client defaults to `message`. No CR or LF. */
|
|
15
|
+
readonly event?: string
|
|
16
|
+
/** Last event id, echoed by the client as `Last-Event-ID`. No CR, LF or NUL. `''` resets it. */
|
|
17
|
+
readonly id?: string | number
|
|
18
|
+
/** Reconnection delay in ms (a safe integer ≥ 0). */
|
|
19
|
+
readonly retry?: number
|
|
20
|
+
/** Comment lines, ignored by `EventSource` (debugging, padding). */
|
|
21
|
+
readonly comment?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Options of `@Sse()` and `HttpResponse.sse()`. */
|
|
25
|
+
export interface SseOptions {
|
|
26
|
+
/**
|
|
27
|
+
* Milliseconds without an event after which a keep-alive comment (`:`) is sent (an integer
|
|
28
|
+
* from 1 000 to 2 147 483 647). `false` disables it. Default: the plugin option `sse.heartbeat`
|
|
29
|
+
* (8 000).
|
|
30
|
+
*/
|
|
31
|
+
readonly heartbeat?: number | false
|
|
32
|
+
/**
|
|
33
|
+
* Client reconnection delay (`retry:`, ms), sent first. Default: the plugin option `sse.retry`
|
|
34
|
+
* (none: browsers use about 3 s).
|
|
35
|
+
*/
|
|
36
|
+
readonly retry?: number
|
|
37
|
+
/**
|
|
38
|
+
* The server ends the stream gracefully after this many ms (load rebalancing, re-authentication):
|
|
39
|
+
* `ctx.signal` aborts (`'expired'`), then the client gets a jittered `retry` and reconnects. An
|
|
40
|
+
* integer from 1 000 to 2 147 483 647 (about 24.8 days, the longest timer). Default: none.
|
|
41
|
+
*/
|
|
42
|
+
readonly maxDuration?: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What `@Sse` handlers and `HttpResponse.sse()` accept: an async iterable (async generators,
|
|
47
|
+
* `EventChannel`, a Node.js `Readable` of raw bytes), an iterable (arrays, sync generators; not
|
|
48
|
+
* strings) or a `ReadableStream`, of `SseEvent` objects or `Uint8Array` chunks (already-framed SSE
|
|
49
|
+
* text, written as is: proxying an upstream event stream).
|
|
50
|
+
*/
|
|
51
|
+
export type SseSource =
|
|
52
|
+
| AsyncIterable<SseEvent | Uint8Array>
|
|
53
|
+
| Iterable<SseEvent>
|
|
54
|
+
| ReadableStream<SseEvent | Uint8Array>
|
|
55
|
+
|
|
56
|
+
/** The body of `HttpResponse.sse()`: an event source and its options (served by the plugin). */
|
|
57
|
+
export class SseBody {
|
|
58
|
+
readonly source: SseSource
|
|
59
|
+
readonly options: SseOptions
|
|
60
|
+
|
|
61
|
+
constructor(source: SseSource, options: SseOptions = {}) {
|
|
62
|
+
if (!isSseSource(source)) {
|
|
63
|
+
throw new TypeError(
|
|
64
|
+
'HttpResponse.sse() expects an AsyncIterable, an Iterable or a ReadableStream of SseEvent (not a string)',
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
checkSseOptions(options, 'HttpResponse.sse()')
|
|
68
|
+
this.source = source
|
|
69
|
+
this.options = options
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Whether `value` is an {@link SseBody}. */
|
|
74
|
+
export function isSseBody(value: unknown): value is SseBody {
|
|
75
|
+
return value instanceof SseBody
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Whether `value` can be served as an event stream (async iterable, non-string iterable, `ReadableStream`). */
|
|
79
|
+
export function isSseSource(value: unknown): value is SseSource {
|
|
80
|
+
if (value instanceof ReadableStream) return true
|
|
81
|
+
if (value === null || typeof value !== 'object') return false
|
|
82
|
+
return (
|
|
83
|
+
typeof (value as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === 'function' ||
|
|
84
|
+
typeof (value as { [Symbol.iterator]?: unknown })[Symbol.iterator] === 'function'
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Smallest heartbeat and `maxDuration` (ms). */
|
|
89
|
+
export const MIN_SSE_INTERVAL_MS = 1_000
|
|
90
|
+
/**
|
|
91
|
+
* Longest heartbeat, `maxDuration` and grace period (ms): the longest `setTimeout` delay (larger
|
|
92
|
+
* ones overflow to 1 ms on Node.js and Bun, which would end every stream at once).
|
|
93
|
+
*/
|
|
94
|
+
export const MAX_SSE_TIMER_MS = 2_147_483_647
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Checks SSE options (`HttpResponse.sse()`, hand-written metadata): `heartbeat` an integer from
|
|
98
|
+
* 1 000 to 2 147 483 647 or `false`, `retry` an integer ≥ 0, `maxDuration` an integer from 1 000 to
|
|
99
|
+
* 2 147 483 647, no other key.
|
|
100
|
+
*
|
|
101
|
+
* @throws TypeError naming `what`.
|
|
102
|
+
*/
|
|
103
|
+
export function checkSseOptions(options: unknown, what: string): void {
|
|
104
|
+
const problem = sseOptionsProblem(options)
|
|
105
|
+
if (problem !== undefined) throw new TypeError(`${what}: ${problem}`)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** What is wrong with SSE options, `undefined` when they are valid. */
|
|
109
|
+
export function sseOptionsProblem(options: unknown): string | undefined {
|
|
110
|
+
if (options === null || typeof options !== 'object' || Array.isArray(options)) {
|
|
111
|
+
return 'options must be an object { heartbeat?, retry?, maxDuration? }'
|
|
112
|
+
}
|
|
113
|
+
for (const [key, value] of Object.entries(options)) {
|
|
114
|
+
if (value === undefined) continue
|
|
115
|
+
switch (key) {
|
|
116
|
+
case 'heartbeat':
|
|
117
|
+
if (value !== false && !isInteger(value, MIN_SSE_INTERVAL_MS, MAX_SSE_TIMER_MS)) {
|
|
118
|
+
return `heartbeat must be an integer number of ms from ${MIN_SSE_INTERVAL_MS} to ${MAX_SSE_TIMER_MS}, or false (received ${String(value)})`
|
|
119
|
+
}
|
|
120
|
+
break
|
|
121
|
+
case 'retry':
|
|
122
|
+
if (!isInteger(value, 0)) return `retry must be an integer number of ms ≥ 0 (received ${String(value)})`
|
|
123
|
+
break
|
|
124
|
+
case 'maxDuration':
|
|
125
|
+
if (!isInteger(value, MIN_SSE_INTERVAL_MS, MAX_SSE_TIMER_MS)) {
|
|
126
|
+
return `maxDuration must be an integer number of ms from ${MIN_SSE_INTERVAL_MS} to ${MAX_SSE_TIMER_MS} (received ${String(value)})`
|
|
127
|
+
}
|
|
128
|
+
break
|
|
129
|
+
default:
|
|
130
|
+
return `unknown option "${key}" (options: { heartbeat?, retry?, maxDuration? })`
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return undefined
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isInteger(value: unknown, min: number, max = Number.MAX_SAFE_INTEGER): boolean {
|
|
137
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= min && value <= max
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// -------------------------------------------------------------------------------------------------
|
|
141
|
+
// Encoding
|
|
142
|
+
// -------------------------------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
const EVENT_KEYS: ReadonlySet<string> = new Set(['data', 'event', 'id', 'retry', 'comment'])
|
|
145
|
+
const LINE_BREAK = /\r\n|\r|\n/
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Encodes one event (WHATWG framing): one `: <line>` per comment line, `id:`, `event:`, `retry:`,
|
|
149
|
+
* then one `data: <line>` per line of the data (split on CRLF, CR and LF, so data can never inject
|
|
150
|
+
* fields or end the event early), and an empty line. An `event` without `data` gets an empty
|
|
151
|
+
* `data:` line (otherwise `EventSource` would not dispatch it); events with only `id`, `retry` or
|
|
152
|
+
* `comment` have no data line.
|
|
153
|
+
*
|
|
154
|
+
* ```ts
|
|
155
|
+
* encodeSseEvent({ id: 41, event: 'status', data: { status: 'paid' } })
|
|
156
|
+
* // 'id: 41\nevent: status\ndata: {"status":"paid"}\n\n'
|
|
157
|
+
* ```
|
|
158
|
+
*
|
|
159
|
+
* @throws TypeError for anything but a plain object with the keys of {@link SseEvent} (strings,
|
|
160
|
+
* numbers, `null`: yield `{ data: value }`), an empty event, an `event` / `id` with CR or LF (NUL
|
|
161
|
+
* for `id`), a `retry` that is not an integer ≥ 0, a `data` JSON cannot serialise.
|
|
162
|
+
*/
|
|
163
|
+
export function encodeSseEvent(event: SseEvent): string {
|
|
164
|
+
if (event === null || typeof event !== 'object' || !isPlainObject(event)) {
|
|
165
|
+
throw new TypeError(
|
|
166
|
+
`Invalid SSE event: expected an object { data?, event?, id?, retry?, comment? } (received ${describe(event)}): yield { data: value }`,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
let defined = 0
|
|
170
|
+
for (const key of Object.keys(event)) {
|
|
171
|
+
if (!EVENT_KEYS.has(key)) {
|
|
172
|
+
throw new TypeError(`Invalid SSE event: unknown key "${key}" (keys: data, event, id, retry, comment)`)
|
|
173
|
+
}
|
|
174
|
+
if ((event as Record<string, unknown>)[key] !== undefined) defined++
|
|
175
|
+
}
|
|
176
|
+
if (defined === 0) throw new TypeError('Invalid SSE event: an empty event (set data, event, id, retry or comment)')
|
|
177
|
+
const { data, event: type, id, retry, comment } = event
|
|
178
|
+
let text = ''
|
|
179
|
+
if (comment !== undefined) {
|
|
180
|
+
if (typeof comment !== 'string') throw new TypeError('Invalid SSE event: comment must be a string')
|
|
181
|
+
for (const line of comment.split(LINE_BREAK)) text += line === '' ? ':\n' : `: ${line}\n`
|
|
182
|
+
}
|
|
183
|
+
if (id !== undefined) {
|
|
184
|
+
const value = typeof id === 'number' && Number.isFinite(id) ? String(id) : id
|
|
185
|
+
if (typeof value !== 'string' || /[\r\n\0]/.test(value)) {
|
|
186
|
+
throw new TypeError('Invalid SSE event: id must be a string without CR, LF or NUL, or a finite number')
|
|
187
|
+
}
|
|
188
|
+
text += `id: ${value}\n`
|
|
189
|
+
}
|
|
190
|
+
if (type !== undefined) {
|
|
191
|
+
if (typeof type !== 'string' || /[\r\n]/.test(type)) {
|
|
192
|
+
throw new TypeError('Invalid SSE event: event must be a string without CR or LF')
|
|
193
|
+
}
|
|
194
|
+
text += `event: ${type}\n`
|
|
195
|
+
}
|
|
196
|
+
if (retry !== undefined) {
|
|
197
|
+
if (!isInteger(retry, 0)) throw new TypeError('Invalid SSE event: retry must be an integer number of ms ≥ 0')
|
|
198
|
+
text += `retry: ${retry}\n`
|
|
199
|
+
}
|
|
200
|
+
if (data !== undefined) {
|
|
201
|
+
for (const line of dataText(data).split(LINE_BREAK)) text += `data: ${line}\n`
|
|
202
|
+
} else if (type !== undefined) {
|
|
203
|
+
text += 'data: \n'
|
|
204
|
+
}
|
|
205
|
+
return `${text}\n`
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function dataText(data: unknown): string {
|
|
209
|
+
if (typeof data === 'string') return data
|
|
210
|
+
let json: string | undefined
|
|
211
|
+
try {
|
|
212
|
+
json = JSON.stringify(data)
|
|
213
|
+
} catch (error) {
|
|
214
|
+
throw new TypeError(`Invalid SSE event: data cannot be serialised as JSON (${errorText(error)})`, { cause: error })
|
|
215
|
+
}
|
|
216
|
+
if (json === undefined) throw new TypeError(`Invalid SSE event: data cannot be serialised as JSON (${typeof data})`)
|
|
217
|
+
return json
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function isPlainObject(value: object): boolean {
|
|
221
|
+
const prototype: unknown = Object.getPrototypeOf(value)
|
|
222
|
+
return prototype === Object.prototype || prototype === null
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function describe(value: unknown): string {
|
|
226
|
+
if (value === null) return 'null'
|
|
227
|
+
if (typeof value === 'string') return 'a string'
|
|
228
|
+
if (Array.isArray(value)) return 'an array'
|
|
229
|
+
if (value instanceof Uint8Array) return 'bytes'
|
|
230
|
+
return typeof value === 'object' ? `an instance of ${value.constructor?.name ?? 'a class'}` : typeof value
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function errorText(error: unknown): string {
|
|
234
|
+
return error instanceof Error ? error.message : String(error)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// -------------------------------------------------------------------------------------------------
|
|
238
|
+
// The event stream
|
|
239
|
+
// -------------------------------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
/** Default reconnection delay the jitter of the final `retry` starts from (ms). */
|
|
242
|
+
export const DEFAULT_RECONNECT_MS = 3_000
|
|
243
|
+
/** How long a stopped source may take before the request scope closes anyway (ms). */
|
|
244
|
+
export const SOURCE_STOP_TIMEOUT_MS = 5_000
|
|
245
|
+
|
|
246
|
+
const ENCODER = new TextEncoder()
|
|
247
|
+
const HEARTBEAT = ENCODER.encode(':\n\n')
|
|
248
|
+
|
|
249
|
+
/** Settings of one event stream (route options resolved over the plugin options). */
|
|
250
|
+
export interface SseStreamSettings {
|
|
251
|
+
/** Heartbeat interval in ms; `0` disables it. */
|
|
252
|
+
readonly heartbeat: number
|
|
253
|
+
/** `retry` sent first, if any. */
|
|
254
|
+
readonly retry: number | undefined
|
|
255
|
+
/** `maxDuration` in ms, if any. */
|
|
256
|
+
readonly maxDuration: number | undefined
|
|
257
|
+
/** Time the source gets to finish after a graceful close started (ms). */
|
|
258
|
+
readonly graceMs: number
|
|
259
|
+
/** Bound of the source stop (ms). Default: {@link SOURCE_STOP_TIMEOUT_MS}. */
|
|
260
|
+
readonly stopTimeoutMs?: number
|
|
261
|
+
/** Random generator of the jitter (`[0, 1)`). Default: `Math.random`. */
|
|
262
|
+
readonly random?: () => number
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** What the stream reports to the request (its context and its logger). */
|
|
266
|
+
export interface SseHost {
|
|
267
|
+
/** Aborts `ctx.signal` with `kind`. */
|
|
268
|
+
abort(kind: RequestAbortKind): void
|
|
269
|
+
/** Logs a failure (`The SSE stream failed`) or a warning, with the request id and path. */
|
|
270
|
+
error(message: string, error: unknown): void
|
|
271
|
+
warn(message: string): void
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** A source being read: one pull at a time. */
|
|
275
|
+
interface SourceReader {
|
|
276
|
+
next(): Promise<IteratorResult<unknown>>
|
|
277
|
+
stop(): Promise<unknown>
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
type Settled =
|
|
281
|
+
| { readonly kind: 'value'; readonly value: unknown }
|
|
282
|
+
| { readonly kind: 'done' }
|
|
283
|
+
| { readonly kind: 'error'; readonly error: unknown }
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* One event stream: the byte stream sent to the client and the controls of the pipeline
|
|
287
|
+
* ({@link close} on shutdown or `maxDuration`). The source is read only when the client pulls
|
|
288
|
+
* (backpressure); the first chunk (`retry: N` or the comment `:`) is queued at once so that every
|
|
289
|
+
* adapter flushes the headers.
|
|
290
|
+
*/
|
|
291
|
+
export class SseSession {
|
|
292
|
+
/** The bytes to send (pull-based, `highWaterMark: 0`). */
|
|
293
|
+
readonly stream: ReadableStream<Uint8Array>
|
|
294
|
+
readonly #source: SseSource
|
|
295
|
+
readonly #settings: SseStreamSettings
|
|
296
|
+
readonly #host: SseHost
|
|
297
|
+
readonly #run: ContextRunner
|
|
298
|
+
#reader: SourceReader | undefined
|
|
299
|
+
/** The source read in progress, kept across heartbeats until its item is consumed. */
|
|
300
|
+
#pending: Promise<Settled> | undefined
|
|
301
|
+
/** What {@link #pending} settled with, until {@link #pull} consumes it. */
|
|
302
|
+
#settled: Settled | undefined
|
|
303
|
+
/** Resolves the race waiting for {@link #pending} (one at a time). */
|
|
304
|
+
#onSettled: (() => void) | undefined
|
|
305
|
+
#controller: ReadableStreamDefaultController<Uint8Array> | undefined
|
|
306
|
+
/** `open` → `closing` (graceful close running) → `ended`. */
|
|
307
|
+
#phase: 'open' | 'closing' | 'ended' = 'open'
|
|
308
|
+
/** The raw bytes written so far end at an event boundary (a heartbeat may follow). */
|
|
309
|
+
#atBoundary = true
|
|
310
|
+
/** Raw byte scanner: the current line has content / the previous byte was a CR. */
|
|
311
|
+
#lineHasContent = false
|
|
312
|
+
#afterCr = false
|
|
313
|
+
/** Whether the line the last CR ended was empty (an event boundary, whether a LF follows or not). */
|
|
314
|
+
#crBoundary = false
|
|
315
|
+
#wake: (() => void) | undefined
|
|
316
|
+
#graceTimer: ReturnType<typeof setTimeout> | undefined
|
|
317
|
+
#maxTimer: ReturnType<typeof setTimeout> | undefined
|
|
318
|
+
#stopped: Promise<void> | undefined
|
|
319
|
+
#ended: () => void = () => undefined
|
|
320
|
+
/** Resolves once the stream ended (source done, failed, stopped or cancelled). */
|
|
321
|
+
readonly ended: Promise<void>
|
|
322
|
+
|
|
323
|
+
constructor(source: SseSource, settings: SseStreamSettings, host: SseHost) {
|
|
324
|
+
this.#source = source
|
|
325
|
+
this.#settings = settings
|
|
326
|
+
this.#host = host
|
|
327
|
+
// the source runs in the handler's async context (request scope, tracing), timers included
|
|
328
|
+
this.#run = captureContext()
|
|
329
|
+
this.ended = new Promise(resolve => {
|
|
330
|
+
this.#ended = resolve
|
|
331
|
+
})
|
|
332
|
+
this.stream = new ReadableStream<Uint8Array>(
|
|
333
|
+
{
|
|
334
|
+
start: controller => {
|
|
335
|
+
this.#controller = controller
|
|
336
|
+
const retry = settings.retry
|
|
337
|
+
controller.enqueue(retry === undefined ? HEARTBEAT : ENCODER.encode(`retry: ${retry}\n\n`))
|
|
338
|
+
if (settings.maxDuration !== undefined) {
|
|
339
|
+
this.#maxTimer = unref(setTimeout(() => this.close('expired'), timerDelay(settings.maxDuration)))
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
pull: controller => this.#pull(controller),
|
|
343
|
+
cancel: () => this.#cancel(),
|
|
344
|
+
},
|
|
345
|
+
{ highWaterMark: 0 },
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Whether the stream is closing or over. */
|
|
350
|
+
get closed(): boolean {
|
|
351
|
+
return this.#phase !== 'open'
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Starts the graceful close (shutdown, `maxDuration`): `ctx.signal` aborts with `kind`, the source
|
|
356
|
+
* may yield its final events for `graceMs`, then a jittered `retry` is sent, the source is stopped
|
|
357
|
+
* and the response ends. Idempotent.
|
|
358
|
+
*/
|
|
359
|
+
close(kind: 'shutdown' | 'expired'): void {
|
|
360
|
+
if (this.#phase !== 'open') return
|
|
361
|
+
this.#phase = 'closing'
|
|
362
|
+
clearTimeout(this.#maxTimer)
|
|
363
|
+
this.#host.abort(kind)
|
|
364
|
+
this.#graceTimer = unref(setTimeout(() => void this.#endGracefully(), timerDelay(this.#settings.graceMs)))
|
|
365
|
+
this.#wake?.()
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async #pull(controller: ReadableStreamDefaultController<Uint8Array>): Promise<void> {
|
|
369
|
+
for (;;) {
|
|
370
|
+
if (this.#phase === 'ended') return
|
|
371
|
+
this.#next()
|
|
372
|
+
const heartbeat =
|
|
373
|
+
this.#phase === 'open' && this.#settings.heartbeat > 0 && this.#atBoundary ? this.#settings.heartbeat : 0
|
|
374
|
+
const outcome = await this.#race(heartbeat)
|
|
375
|
+
// closed or cancelled while waiting
|
|
376
|
+
if (this.#isEnded()) return
|
|
377
|
+
if (outcome === 'heartbeat') {
|
|
378
|
+
controller.enqueue(HEARTBEAT)
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
if (outcome === 'wake') continue
|
|
382
|
+
this.#pending = undefined
|
|
383
|
+
this.#settled = undefined
|
|
384
|
+
if (outcome.kind === 'done') {
|
|
385
|
+
await this.#finish()
|
|
386
|
+
return
|
|
387
|
+
}
|
|
388
|
+
if (outcome.kind === 'error') {
|
|
389
|
+
if (!this.#isAbortError(outcome.error)) this.#host.error('The SSE stream failed', outcome.error)
|
|
390
|
+
await this.#finish()
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
const value = outcome.value
|
|
394
|
+
if (value instanceof Uint8Array) {
|
|
395
|
+
if (value.byteLength === 0) continue
|
|
396
|
+
this.#scan(value)
|
|
397
|
+
controller.enqueue(value)
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
let encoded: string
|
|
401
|
+
try {
|
|
402
|
+
if (!this.#endsAtBoundary()) {
|
|
403
|
+
// an event written after an unterminated raw event would merge into it
|
|
404
|
+
throw new TypeError(
|
|
405
|
+
'Invalid SSE event: an SseEvent cannot follow raw bytes that do not end at an event boundary (an empty line)',
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
encoded = encodeSseEvent(value as SseEvent)
|
|
409
|
+
} catch (error) {
|
|
410
|
+
this.#host.error('The SSE stream failed', error)
|
|
411
|
+
await this.#finish(true)
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
controller.enqueue(ENCODER.encode(encoded))
|
|
415
|
+
this.#atBoundary = true
|
|
416
|
+
this.#lineHasContent = false
|
|
417
|
+
this.#afterCr = false
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Starts the next read of the source, unless one is pending (it is kept across heartbeats). The
|
|
424
|
+
* read gets one reaction for its whole life, which records its outcome: a quiet source does not
|
|
425
|
+
* accumulate a reaction per heartbeat.
|
|
426
|
+
*/
|
|
427
|
+
#next(): void {
|
|
428
|
+
if (this.#pending !== undefined) return
|
|
429
|
+
const pending = this.#run(() => {
|
|
430
|
+
try {
|
|
431
|
+
return this.#readerOf().next()
|
|
432
|
+
} catch (error) {
|
|
433
|
+
return Promise.reject(error)
|
|
434
|
+
}
|
|
435
|
+
}).then(
|
|
436
|
+
(result): Settled => (result.done === true ? { kind: 'done' } : { kind: 'value', value: result.value }),
|
|
437
|
+
(error: unknown): Settled => ({ kind: 'error', error }),
|
|
438
|
+
)
|
|
439
|
+
this.#pending = pending
|
|
440
|
+
void pending.then(outcome => {
|
|
441
|
+
if (this.#pending !== pending) return
|
|
442
|
+
this.#settled = outcome
|
|
443
|
+
this.#onSettled?.()
|
|
444
|
+
})
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Waits for the pending item, a heartbeat delay (`0`: none) or a wake-up (close, cancel). */
|
|
448
|
+
#race(heartbeat: number): Promise<Settled | 'heartbeat' | 'wake'> {
|
|
449
|
+
// settled while nobody waited (after a heartbeat or a wake-up): kept until consumed
|
|
450
|
+
if (this.#settled !== undefined) return Promise.resolve(this.#settled)
|
|
451
|
+
return new Promise(resolve => {
|
|
452
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
453
|
+
let done = false
|
|
454
|
+
const settle = (outcome: Settled | 'heartbeat' | 'wake'): void => {
|
|
455
|
+
if (done) return
|
|
456
|
+
done = true
|
|
457
|
+
clearTimeout(timer)
|
|
458
|
+
if (this.#wake === wake) this.#wake = undefined
|
|
459
|
+
if (this.#onSettled === settled) this.#onSettled = undefined
|
|
460
|
+
resolve(outcome)
|
|
461
|
+
}
|
|
462
|
+
const wake = (): void => settle('wake')
|
|
463
|
+
const settled = (): void => settle(this.#settled as Settled)
|
|
464
|
+
if (heartbeat > 0) timer = unref(setTimeout(() => settle('heartbeat'), timerDelay(heartbeat)))
|
|
465
|
+
this.#wake = wake
|
|
466
|
+
this.#onSettled = settled
|
|
467
|
+
})
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Tracks whether the raw bytes written so far end at an event boundary (an empty line). */
|
|
471
|
+
#scan(bytes: Uint8Array): void {
|
|
472
|
+
for (const byte of bytes) {
|
|
473
|
+
if (byte === 0x0a && this.#afterCr) {
|
|
474
|
+
// the LF of a CRLF (possibly the first byte of a chunk): the CR decided the boundary
|
|
475
|
+
this.#afterCr = false
|
|
476
|
+
this.#atBoundary = this.#crBoundary
|
|
477
|
+
continue
|
|
478
|
+
}
|
|
479
|
+
this.#afterCr = byte === 0x0d
|
|
480
|
+
if (byte === 0x0a || byte === 0x0d) {
|
|
481
|
+
this.#atBoundary = !this.#lineHasContent
|
|
482
|
+
this.#lineHasContent = false
|
|
483
|
+
if (this.#afterCr) this.#crBoundary = this.#atBoundary
|
|
484
|
+
} else {
|
|
485
|
+
this.#lineHasContent = true
|
|
486
|
+
this.#atBoundary = false
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// a final CR may be the first half of a CRLF: no heartbeat until the next byte tells
|
|
490
|
+
if (this.#afterCr) this.#atBoundary = false
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Whether the bytes written so far end at an event boundary, for bytes written once the source
|
|
495
|
+
* is over (the final `retry`) or replaced (an encoded event): a final CR ending an empty line
|
|
496
|
+
* counts, since no raw byte will follow it.
|
|
497
|
+
*/
|
|
498
|
+
#endsAtBoundary(): boolean {
|
|
499
|
+
return this.#atBoundary || (this.#afterCr && this.#crBoundary)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Ends the stream after the source: done, failed, or an invalid item (`stop`: the source is
|
|
504
|
+
* stopped). During a graceful close the jittered `retry` is written first.
|
|
505
|
+
*/
|
|
506
|
+
async #finish(stop = false): Promise<void> {
|
|
507
|
+
if (this.#phase === 'ended') return
|
|
508
|
+
const graceful = this.#phase === 'closing'
|
|
509
|
+
this.#phase = 'ended'
|
|
510
|
+
this.#clearTimers()
|
|
511
|
+
const controller = this.#controller as ReadableStreamDefaultController<Uint8Array>
|
|
512
|
+
// never inside an unterminated raw event (the client discards it at the end of the stream)
|
|
513
|
+
if (graceful && this.#endsAtBoundary()) controller.enqueue(this.#finalRetry())
|
|
514
|
+
if (stop) await this.#stop()
|
|
515
|
+
try {
|
|
516
|
+
controller.close()
|
|
517
|
+
} catch {
|
|
518
|
+
// cancelled meanwhile
|
|
519
|
+
}
|
|
520
|
+
this.#ended()
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** The grace period is over: final `retry`, the source is stopped (bounded), the response ends. */
|
|
524
|
+
async #endGracefully(): Promise<void> {
|
|
525
|
+
if (this.#phase !== 'closing') return
|
|
526
|
+
this.#phase = 'ended'
|
|
527
|
+
this.#clearTimers()
|
|
528
|
+
const controller = this.#controller as ReadableStreamDefaultController<Uint8Array>
|
|
529
|
+
// never inside an unterminated raw event (the client discards it at the end of the stream)
|
|
530
|
+
if (this.#endsAtBoundary()) controller.enqueue(this.#finalRetry())
|
|
531
|
+
this.#wake?.()
|
|
532
|
+
await this.#stop()
|
|
533
|
+
try {
|
|
534
|
+
controller.close()
|
|
535
|
+
} catch {
|
|
536
|
+
// cancelled meanwhile
|
|
537
|
+
}
|
|
538
|
+
this.#ended()
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** The client disconnected (or the body was discarded: HEAD, bodyless status, invalid headers). */
|
|
542
|
+
async #cancel(): Promise<void> {
|
|
543
|
+
const wasEnded = this.#phase === 'ended'
|
|
544
|
+
this.#phase = 'ended'
|
|
545
|
+
this.#clearTimers()
|
|
546
|
+
this.#host.abort('client')
|
|
547
|
+
this.#wake?.()
|
|
548
|
+
if (!wasEnded || this.#stopped !== undefined) await this.#stop()
|
|
549
|
+
this.#ended()
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** `retry: R` with R drawn uniformly in [r, 2r] (spreads the reconnections of a rolling deployment). */
|
|
553
|
+
#finalRetry(): Uint8Array {
|
|
554
|
+
const base = this.#settings.retry ?? DEFAULT_RECONNECT_MS
|
|
555
|
+
const random = this.#settings.random ?? Math.random
|
|
556
|
+
return ENCODER.encode(`retry: ${Math.round(base + random() * base)}\n\n`)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Stops the source (`return()` / `cancel()`) and waits for it at most `stopTimeoutMs`: an async
|
|
561
|
+
* generator blocked in an `await` that ignores `ctx.signal` would hold the request scope forever.
|
|
562
|
+
*/
|
|
563
|
+
#stop(): Promise<void> {
|
|
564
|
+
this.#stopped ??= this.#stopBounded()
|
|
565
|
+
return this.#stopped
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async #stopBounded(): Promise<void> {
|
|
569
|
+
const timeoutMs = this.#settings.stopTimeoutMs ?? SOURCE_STOP_TIMEOUT_MS
|
|
570
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
571
|
+
const stopping = this.#run(() => {
|
|
572
|
+
try {
|
|
573
|
+
return this.#readerOf().stop()
|
|
574
|
+
} catch {
|
|
575
|
+
return Promise.resolve()
|
|
576
|
+
}
|
|
577
|
+
}).then(
|
|
578
|
+
() => 'stopped' as const,
|
|
579
|
+
() => 'stopped' as const,
|
|
580
|
+
)
|
|
581
|
+
const timeout = new Promise<'timeout'>(resolve => {
|
|
582
|
+
timer = unref(setTimeout(() => resolve('timeout'), timeoutMs))
|
|
583
|
+
})
|
|
584
|
+
const outcome = await Promise.race([stopping, timeout])
|
|
585
|
+
clearTimeout(timer)
|
|
586
|
+
if (outcome === 'timeout') {
|
|
587
|
+
this.#host.warn(
|
|
588
|
+
`The SSE source did not stop within ${timeoutMs} ms after its signal was aborted: make it honour ctx.signal`,
|
|
589
|
+
)
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
#isEnded(): boolean {
|
|
594
|
+
return this.#phase === 'ended'
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
#clearTimers(): void {
|
|
598
|
+
clearTimeout(this.#graceTimer)
|
|
599
|
+
clearTimeout(this.#maxTimer)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** An error caused by the abort of `ctx.signal` (a source honouring it): not a failure. */
|
|
603
|
+
#isAbortError(error: unknown): boolean {
|
|
604
|
+
return this.#phase !== 'open' && (error as { name?: unknown } | null)?.name === 'AbortError'
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** The reader of the source, created on first use (a never-read async generator never runs). */
|
|
608
|
+
#readerOf(): SourceReader {
|
|
609
|
+
this.#reader ??= readerOf(this.#source)
|
|
610
|
+
return this.#reader
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function readerOf(source: SseSource): SourceReader {
|
|
615
|
+
if (source instanceof ReadableStream) {
|
|
616
|
+
const reader = source.getReader() as unknown as {
|
|
617
|
+
read(): Promise<IteratorResult<unknown>>
|
|
618
|
+
cancel(reason?: unknown): Promise<void>
|
|
619
|
+
}
|
|
620
|
+
return {
|
|
621
|
+
next: () => reader.read(),
|
|
622
|
+
stop: () => reader.cancel(),
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
const asyncFactory = (source as { [Symbol.asyncIterator]?: () => AsyncIterator<unknown> })[Symbol.asyncIterator]
|
|
626
|
+
if (typeof asyncFactory === 'function') {
|
|
627
|
+
const iterator = asyncFactory.call(source)
|
|
628
|
+
return {
|
|
629
|
+
next: () => iterator.next(),
|
|
630
|
+
stop: async () => {
|
|
631
|
+
await iterator.return?.()
|
|
632
|
+
},
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
const iterator = (source as Iterable<unknown>)[Symbol.iterator]()
|
|
636
|
+
return {
|
|
637
|
+
next: () => Promise.resolve(iterator.next()),
|
|
638
|
+
stop: () => Promise.resolve(iterator.return?.()),
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** A delay `setTimeout` keeps (larger ones overflow to 1 ms): the options are validated, this is a backstop. */
|
|
643
|
+
function timerDelay(ms: number): number {
|
|
644
|
+
return Math.min(ms, MAX_SSE_TIMER_MS)
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function unref<T>(timer: T): T {
|
|
648
|
+
;(timer as { unref?: () => void }).unref?.()
|
|
649
|
+
return timer
|
|
650
|
+
}
|