@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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2333 -2
  3. package/dist/adapter-CGSceVEN.d.mts +876 -0
  4. package/dist/compiler.d.mts +156 -0
  5. package/dist/compiler.mjs +2180 -0
  6. package/dist/compiler.mjs.map +1 -0
  7. package/dist/express.d.mts +39 -0
  8. package/dist/express.mjs +135 -0
  9. package/dist/express.mjs.map +1 -0
  10. package/dist/fastify.d.mts +33 -0
  11. package/dist/fastify.mjs +191 -0
  12. package/dist/fastify.mjs.map +1 -0
  13. package/dist/hono.d.mts +49 -0
  14. package/dist/hono.mjs +319 -0
  15. package/dist/hono.mjs.map +1 -0
  16. package/dist/index.d.mts +1254 -0
  17. package/dist/index.mjs +5618 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/node-BUnkdSsa.mjs +183 -0
  20. package/dist/node-BUnkdSsa.mjs.map +1 -0
  21. package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
  22. package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
  23. package/dist/node.d.mts +30 -0
  24. package/dist/node.mjs +1 -0
  25. package/dist/path-JI87C4k8.d.mts +109 -0
  26. package/dist/path.d.mts +2 -0
  27. package/dist/path.mjs +245 -0
  28. package/dist/path.mjs.map +1 -0
  29. package/dist/size-D8rsQcV0.mjs +184 -0
  30. package/dist/size-D8rsQcV0.mjs.map +1 -0
  31. package/dist/utils-DCbu3a4w.mjs +27 -0
  32. package/dist/utils-DCbu3a4w.mjs.map +1 -0
  33. package/package.json +142 -4
  34. package/src/adapter.ts +240 -0
  35. package/src/adapters/express.ts +201 -0
  36. package/src/adapters/fastify.ts +280 -0
  37. package/src/adapters/hono.ts +488 -0
  38. package/src/adapters/node-io.ts +368 -0
  39. package/src/adapters/node.ts +135 -0
  40. package/src/adapters/shared.ts +508 -0
  41. package/src/body.ts +135 -0
  42. package/src/coerce.ts +195 -0
  43. package/src/compiler/analyze.ts +1513 -0
  44. package/src/compiler/ast.ts +90 -0
  45. package/src/compiler/coercion.ts +267 -0
  46. package/src/compiler/generate.ts +374 -0
  47. package/src/compiler/index.ts +76 -0
  48. package/src/compiler/model.ts +260 -0
  49. package/src/compiler/route-decorators.ts +405 -0
  50. package/src/compiler/validate.ts +288 -0
  51. package/src/context.ts +533 -0
  52. package/src/cookie-settings.ts +68 -0
  53. package/src/cookie-signer.ts +106 -0
  54. package/src/cookies.ts +726 -0
  55. package/src/cors.ts +349 -0
  56. package/src/decisions.ts +331 -0
  57. package/src/decorators.ts +486 -0
  58. package/src/event-channel.ts +197 -0
  59. package/src/exceptions.ts +257 -0
  60. package/src/files.ts +429 -0
  61. package/src/headers.ts +39 -0
  62. package/src/index.ts +185 -0
  63. package/src/inject-sse.ts +309 -0
  64. package/src/interfaces.ts +101 -0
  65. package/src/metadata.ts +162 -0
  66. package/src/mime.ts +157 -0
  67. package/src/multipart.ts +597 -0
  68. package/src/option-error.ts +31 -0
  69. package/src/path.ts +288 -0
  70. package/src/pipeline.ts +918 -0
  71. package/src/plugin-routes.ts +188 -0
  72. package/src/plugin.ts +1432 -0
  73. package/src/proxy.ts +236 -0
  74. package/src/query.ts +30 -0
  75. package/src/response.ts +240 -0
  76. package/src/router.ts +119 -0
  77. package/src/security-headers.ts +161 -0
  78. package/src/serialize.ts +264 -0
  79. package/src/size.ts +66 -0
  80. package/src/sse-parse.ts +194 -0
  81. package/src/sse.ts +650 -0
  82. package/src/standard-schema.ts +81 -0
  83. package/src/status.ts +78 -0
  84. package/src/stream.ts +200 -0
  85. package/src/tokens.ts +21 -0
  86. package/src/uploads.ts +176 -0
  87. package/src/utils.ts +31 -0
  88. 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
+ }