@nifrajs/client 3.3.0 → 4.0.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/src/client.ts CHANGED
@@ -1,10 +1,19 @@
1
1
  import { NIFRA_BINARY_HEADER } from "@nifrajs/core/binary"
2
2
  import type { ContractShape, RegistryFor } from "@nifrajs/core/contract"
3
3
  import {
4
+ type BackendEvidenceProvider,
4
5
  type BackendMount,
5
6
  type BackendMountHandler,
7
+ type BackendPlatformBinder,
8
+ type BackendWebSocketMountHandler,
9
+ type BackendWebSocketRuntimeProvider,
10
+ NIFRA_BACKEND_BIND_PLATFORM,
11
+ NIFRA_BACKEND_EVIDENCE,
6
12
  NIFRA_BACKEND_MOUNT,
13
+ NIFRA_BACKEND_WS_MOUNT,
14
+ NIFRA_BACKEND_WS_RUNTIME,
7
15
  } from "@nifrajs/core/mount"
16
+ import type { Platform } from "@nifrajs/core/server"
8
17
  import {
9
18
  assertTransportTextBounded,
10
19
  createTransportCodecRegistry,
@@ -18,10 +27,17 @@ import {
18
27
  } from "@nifrajs/core/transport-codec"
19
28
  import { RESERVED_VERB_KEYS } from "./reserved.ts"
20
29
  import type { ApiError, Result } from "./result.ts"
30
+ import { hasDotSegment } from "./sendable-path.ts"
21
31
  import type { Subscription, Treaty, TreatyFromRegistry } from "./treaty.ts"
22
- import { ResponseContractViolation, withResponseValidation } from "./validate-responses.ts"
32
+ import {
33
+ type PlatformFetchFn,
34
+ ResponseContractViolation,
35
+ withResponseValidation,
36
+ } from "./validate-responses.ts"
23
37
  import { NO_SOCKET, openWebSocket } from "./ws.ts"
24
38
 
39
+ const CORE_WS_RUNTIME = Symbol.for("@nifrajs/core/get-ws-runtime")
40
+
25
41
  /**
26
42
  * The RESERVED proxy keys, resolved before path segments (see `resolveSegment` and the `then`
27
43
  * guard in `createProxy`). The closed list and the reasons it can never grow live in
@@ -36,7 +52,7 @@ import { NO_SOCKET, openWebSocket } from "./ws.ts"
36
52
  const HTTP_VERBS: ReadonlySet<string> = new Set(RESERVED_VERB_KEYS)
37
53
  const BODY_VERBS: ReadonlySet<string> = new Set(["post", "put", "patch"])
38
54
 
39
- /** Marks a fetcher whose responses are same-process objects (see {@link inProcessClient}). */
55
+ /** Marks client options whose transport returns same-process responses (see {@link inProcessClient}). */
40
56
  const LOCAL_FETCH = Symbol("nifra.local-fetch")
41
57
 
42
58
  /**
@@ -61,7 +77,11 @@ export interface ClientRetryOptions {
61
77
  }
62
78
 
63
79
  export interface ClientOptions {
64
- /** Headers sent on every request (a per-call `headers` option is merged on top). */
80
+ /**
81
+ * Headers sent on every HTTP request (a per-call `headers` option is merged on top). A WebSocket
82
+ * handshake carries none of them, on any runtime: a browser cannot set them, and a socket that
83
+ * authenticated under Bun but not in the browser would be worse than one that never did.
84
+ */
65
85
  readonly headers?: Record<string, string>
66
86
  /** Override the `fetch` implementation (tests, an in-process bridge, a custom agent, etc.). */
67
87
  readonly fetch?: FetchFn
@@ -91,7 +111,8 @@ export interface ClientOptions {
91
111
  *
92
112
  * Bounds the two paths that build something in memory - a JSON body becoming an object graph, a text
93
113
  * body becoming a string - and it bounds them while streaming, cancelling the read the moment the
94
- * total passes. A 2 GB string costs as much as a 2 GB object, so both answer to one number.
114
+ * total passes. A 2 GB string costs as much as a 2 GB object, so both answer to one number. Each
115
+ * event a `.subscribe()` stream assembles answers to it too.
95
116
  *
96
117
  * A BINARY body is deliberately not bounded by this. That is a download, and a size limit on a
97
118
  * download is a bug rather than a defence - the caller asked for the file. Bound one at the call site
@@ -126,9 +147,22 @@ function defaultBackoff(attempt: number): number {
126
147
  return Math.min(300 * 2 ** (attempt - 1), 3000) + Math.random() * 100
127
148
  }
128
149
 
129
- function delay(ms: number): Promise<void> {
150
+ /** Resolves after `ms`, or as soon as `signal` aborts, and leaves no listener on the signal either way:
151
+ * a long-lived signal waited on once per reconnect otherwise collects one listener per wait. */
152
+ function delay(ms: number, signal?: AbortSignal, unref = false): Promise<void> {
130
153
  return new Promise((resolve) => {
131
- setTimeout(resolve, ms)
154
+ if (signal?.aborted === true) {
155
+ resolve()
156
+ return
157
+ }
158
+ const done = (): void => {
159
+ clearTimeout(timer)
160
+ signal?.removeEventListener("abort", done)
161
+ resolve()
162
+ }
163
+ const timer = setTimeout(done, ms)
164
+ if (unref) (timer as { unref?: () => void }).unref?.()
165
+ signal?.addEventListener("abort", done, { once: true })
132
166
  })
133
167
  }
134
168
 
@@ -168,6 +202,11 @@ export interface InProcessClientOptions extends Omit<ClientOptions, "fetch"> {
168
202
  readonly validateResponses?: boolean
169
203
  }
170
204
 
205
+ export interface TestClientOptions extends InProcessClientOptions {
206
+ /** The address every call comes from, as a socket peer's would (default `"127.0.0.1"`). */
207
+ readonly clientIp?: string
208
+ }
209
+
171
210
  interface CallOptions {
172
211
  readonly query?: Record<string, unknown>
173
212
  readonly headers?: Record<string, string>
@@ -218,11 +257,21 @@ export function client(
218
257
  * The returned proxy also implements the explicit symbol-keyed {@link BackendMount} interface, so
219
258
  * `createWebApp({ api: inProcessClient(backend) })` can auto-mount the backend while forwarding the
220
259
  * outer runtime's `env` and `waitUntil`.
260
+ *
261
+ * **Loader calls inherit the page request's platform identity.** While `createWebApp` renders a page,
262
+ * each loader, action and boundary gets a `ctx.api` bound to that request's platform: the backend sees
263
+ * the visitor's `c.clientIp` (as the outer app derived it under its `clientIp` trust declaration),
264
+ * `c.env` and `c.waitUntil`. Read the caller through `c.clientIp` - identity travels in the platform,
265
+ * so rebuilding a `Request` never loses it. Only platform fields travel: the page request's `cookie`,
266
+ * `authorization` and other headers are NOT copied, so a loader call is anonymous unless the loader
267
+ * passes headers itself (`ctx.api.me.get({ headers: { cookie } })`). Used outside a page render (a
268
+ * script, a job), calls carry no platform, so they come from no address; {@link testClient} is the
269
+ * variant whose calls come from a local peer.
221
270
  */
222
271
  const utf8 = new TextEncoder()
223
272
 
224
273
  /** Byte length of a body whose size is knowable without reading a stream; `undefined` otherwise
225
- * (a `ReadableStream` or `FormData` body stays lengthless - multipart framing is the runtime's). */
274
+ * (a `ReadableStream` body stays lengthless; a `FormData` body is framed by {@link framedFormRequest}). */
226
275
  function knownBodyLength(body: NonNullable<RequestInit["body"]>): number | undefined {
227
276
  if (typeof body === "string") return utf8.encode(body).byteLength
228
277
  if (body instanceof URLSearchParams) return utf8.encode(body.toString()).byteLength
@@ -247,33 +296,130 @@ function synthesizedRequest(url: string, init?: RequestInit): Request {
247
296
  return request
248
297
  }
249
298
 
299
+ /**
300
+ * A form body as the bytes a socket would carry, with the length a network peer would declare.
301
+ *
302
+ * Handing the `FormData` itself to `Request` leaves the runtime to encode it while the app reads,
303
+ * which has two costs in-process. The request arrives lengthless, so a fail-closed length gate
304
+ * refuses it. And an app that stops reading early - a body over its limit - cancels a stream the
305
+ * runtime is still writing to, which Node 26 reports as an unhandled rejection. Encoding first
306
+ * gives the app a finished body it can refuse like any other.
307
+ */
308
+ async function framedFormRequest(url: string, init: RequestInit, form: FormData): Promise<Request> {
309
+ const encoded = new Response(form as ConstructorParameters<typeof Response>[0])
310
+ // Read before the body: Bun derives this header from the body and cannot once it is consumed.
311
+ const contentType = encoded.headers.get("content-type") as string
312
+ const bytes = await encoded.arrayBuffer()
313
+ const headers = new Headers(init.headers)
314
+ headers.set("content-type", contentType)
315
+ headers.set("content-length", String(bytes.byteLength))
316
+ return new Request(url, { ...init, headers, body: bytes })
317
+ }
318
+
250
319
  export function inProcessClient<
251
320
  App extends { fetch(request: Request): Response | Promise<Response> },
252
321
  >(app: App, options?: InProcessClientOptions): InProcessClient<App> {
322
+ return createInProcessClient(app, options, undefined)
323
+ }
324
+
325
+ function createInProcessClient<
326
+ App extends { fetch(request: Request): Response | Promise<Response> },
327
+ >(
328
+ app: App,
329
+ options: InProcessClientOptions | undefined,
330
+ // The platform an unbound call carries. Only `testClient` sets one: a production call made on behalf
331
+ // of a remote user must not look local to rate limits or IP rules.
332
+ unboundPlatform: Platform | undefined,
333
+ ): InProcessClient<App> {
253
334
  // The in-process bridge: the client speaks `fetch(url, init)` (the `FetchFn` shape) while the app's
254
335
  // own `fetch` takes a `Request`. It is the proxy's per-call transport; the symbol-keyed mount below
255
- // is the platform-aware auto-mount path.
256
- const direct: FetchFn = (url, init) => Promise.resolve(app.fetch(synthesizedRequest(url, init)))
336
+ // is the platform-aware auto-mount path. The optional third argument is the calling request's
337
+ // platform, supplied only by a view from the bind seam below; the unbound proxy never passes one.
338
+ const direct: PlatformFetchFn = (url, init, boundPlatform) => {
339
+ const platform = boundPlatform ?? unboundPlatform
340
+ const body = init?.body
341
+ if (body instanceof FormData) {
342
+ return framedFormRequest(url, init as RequestInit, body).then((request) =>
343
+ (app.fetch as BackendMountHandler)(request, platform),
344
+ )
345
+ }
346
+ return Promise.resolve(
347
+ (app.fetch as BackendMountHandler)(synthesizedRequest(url, init), platform),
348
+ )
349
+ }
257
350
  const bridge = options?.validateResponses === true ? withResponseValidation(app, direct) : direct
258
- // Mark the bridge as same-process: its response bodies are memory the app already holds, so
259
- // `parseBody` may use the native `Response.text()` read (measured ~23x cheaper than the streaming
260
- // byte-cap reader) - the cap itself is still enforced on the result. Network fetchers are never
261
- // marked; they keep the bounded-while-streaming read.
262
- ;(bridge as { [LOCAL_FETCH]?: true })[LOCAL_FETCH] = true
263
351
  const mount: BackendMountHandler = (request, platform) =>
264
352
  Promise.resolve((app.fetch as BackendMountHandler)(request, platform))
353
+ const evidenceProvider: BackendEvidenceProvider = async () => {
354
+ const { snapshotProjectEvidence } = await import("@nifrajs/core/evidence")
355
+ return snapshotProjectEvidence(app)
356
+ }
357
+ const resolveWebSocketUpgrade = (
358
+ app as {
359
+ resolveWebSocketUpgrade?: BackendWebSocketMountHandler
360
+ }
361
+ ).resolveWebSocketUpgrade
362
+ const getWebSocketRuntime = (app as Record<symbol, unknown>)[CORE_WS_RUNTIME]
363
+ const runtimeProvider =
364
+ typeof getWebSocketRuntime === "function"
365
+ ? (getWebSocketRuntime as () => unknown).bind(app)
366
+ : undefined
367
+ if (typeof resolveWebSocketUpgrade === "function") {
368
+ ;(
369
+ mount as BackendMountHandler & {
370
+ [NIFRA_BACKEND_WS_MOUNT]?: BackendWebSocketMountHandler
371
+ }
372
+ )[NIFRA_BACKEND_WS_MOUNT] = (request, platform) =>
373
+ resolveWebSocketUpgrade.call(app, request, platform)
374
+ }
375
+ if (runtimeProvider !== undefined) {
376
+ ;(
377
+ mount as BackendMountHandler & {
378
+ [NIFRA_BACKEND_WS_RUNTIME]?: BackendWebSocketRuntimeProvider
379
+ }
380
+ )[NIFRA_BACKEND_WS_RUNTIME] = runtimeProvider
381
+ }
382
+ ;(mount as BackendMountHandler & { [NIFRA_BACKEND_EVIDENCE]?: BackendEvidenceProvider })[
383
+ NIFRA_BACKEND_EVIDENCE
384
+ ] = evidenceProvider
265
385
  // NO_SOCKET marks the options so a typed `.ws()` call fails with a real explanation - an
266
386
  // in-process app has no socket to upgrade - instead of dialing ws://nifra.internal into the void.
267
- const proxy = client<App>("http://nifra.internal", {
268
- ...options,
269
- fetch: bridge,
270
- [NO_SOCKET]: true,
271
- } as ClientOptions)
387
+ // LOCAL_FETCH marks the transport as same-process: its response bodies are memory the app already
388
+ // holds, so `parseBody` may use the native `Response.text()` read (measured ~23x cheaper than the
389
+ // streaming byte-cap reader) - the cap itself is still enforced on the result. Network clients are
390
+ // never marked; they keep the bounded-while-streaming read.
391
+ const baseOptions = { ...options, [NO_SOCKET]: true, [LOCAL_FETCH]: true } as ClientOptions
392
+ const proxy = client<App>("http://nifra.internal", { ...baseOptions, fetch: bridge })
393
+ // Request scoping: a view whose every call carries one request's platform (clientIp/env/waitUntil),
394
+ // built by the page executor once per render. The view owns its own route tree because the tree's
395
+ // nodes close over their transport; the validator's router and every other per-client structure are
396
+ // shared. A view lives for one render, so its nodes skip the memo Maps a long-lived client keeps.
397
+ // Nothing here runs unless a render asks, so the unbound proxy's per-call path is unchanged.
398
+ const bindPlatform: BackendPlatformBinder = (platform) => {
399
+ const bound: FetchFn = (url, init) => bridge(url, init, platform)
400
+ return createProxy("http://nifra.internal", "", { ...baseOptions, fetch: bound }, false)
401
+ }
272
402
  // An outer Proxy intercepts only the explicit mount symbol, delegating every typed route segment
273
403
  // unchanged (`api.users({ id }).get()`).
274
404
  return new Proxy(proxy as object, {
275
405
  get(targetProxy, key, receiver) {
276
406
  if (key === NIFRA_BACKEND_MOUNT) return mount
407
+ if (key === NIFRA_BACKEND_EVIDENCE) return evidenceProvider
408
+ if (key === NIFRA_BACKEND_BIND_PLATFORM) return bindPlatform
409
+ if (key === NIFRA_BACKEND_WS_MOUNT) {
410
+ return (
411
+ mount as BackendMountHandler & {
412
+ [NIFRA_BACKEND_WS_MOUNT]?: BackendWebSocketMountHandler
413
+ }
414
+ )[NIFRA_BACKEND_WS_MOUNT]
415
+ }
416
+ if (key === NIFRA_BACKEND_WS_RUNTIME) {
417
+ return (
418
+ mount as BackendMountHandler & {
419
+ [NIFRA_BACKEND_WS_RUNTIME]?: BackendWebSocketRuntimeProvider
420
+ }
421
+ )[NIFRA_BACKEND_WS_RUNTIME]
422
+ }
277
423
  return Reflect.get(targetProxy, key, receiver)
278
424
  },
279
425
  }) as InProcessClient<App>
@@ -283,7 +429,9 @@ export function inProcessClient<
283
429
  * The in-process test client - the Fastify-`inject` / supertest equivalent for nifra. Drives the
284
430
  * app's own `fetch` directly: no server, no port, no network, the full real lifecycle (validation,
285
431
  * middleware, contracts, auth), and end-to-end types from `App`. Calls never throw - branch on
286
- * `res.ok`. An alias of {@link inProcessClient} with a test-focused name; identical behavior.
432
+ * `res.ok`. It is {@link inProcessClient} plus a client address: every call comes from `127.0.0.1`
433
+ * (or `options.clientIp`), so middleware keyed on the caller, like `rateLimit()`, runs as it would
434
+ * behind a listener.
287
435
  *
288
436
  * ```ts
289
437
  * import { testClient } from "@nifrajs/client"
@@ -294,7 +442,13 @@ export function inProcessClient<
294
442
  * expect(res.ok && res.data.id).toBe("42")
295
443
  * ```
296
444
  */
297
- export const testClient = inProcessClient
445
+ export function testClient<App extends { fetch(request: Request): Response | Promise<Response> }>(
446
+ app: App,
447
+ options?: TestClientOptions,
448
+ ): InProcessClient<App> {
449
+ const { clientIp = "127.0.0.1", ...rest } = options ?? {}
450
+ return createInProcessClient(app, rest, { clientIp })
451
+ }
298
452
 
299
453
  function createProxy(
300
454
  base: string,
@@ -363,7 +517,6 @@ function resolveSegment(
363
517
  base,
364
518
  path,
365
519
  {
366
- headers: options.headers,
367
520
  ...wsOptions,
368
521
  ...(options.transport === undefined ? {} : { transport: options.transport }),
369
522
  },
@@ -374,6 +527,46 @@ function resolveSegment(
374
527
  return createProxy(base, key === "index" ? path : `${path}/${key}`, options, cacheable)
375
528
  }
376
529
 
530
+ /**
531
+ * The `multipart/form-data` encoding of a body that carries a file: JSON cannot hold one, so a
532
+ * record with a `Blob`/`File` value (or a list of them) is sent as a form instead - one part per
533
+ * value, a list as one part per item under the same name, `null`/`undefined` left out. A
534
+ * `FormData` passed as the body is sent as is. Anything else returns `undefined` and stays JSON.
535
+ * A form part is text or a file, so a nested object or list cannot be sent and throws.
536
+ */
537
+ function formBody(body: unknown): FormData | undefined {
538
+ if (body instanceof FormData) return body
539
+ if (body === null || typeof body !== "object" || Array.isArray(body) || body instanceof Blob) {
540
+ return undefined
541
+ }
542
+ const entries = Object.entries(body)
543
+ const isFile = (value: unknown): boolean => value instanceof Blob
544
+ if (!entries.some(([, value]) => isFile(value) || (Array.isArray(value) && value.some(isFile)))) {
545
+ return undefined
546
+ }
547
+ const form = new FormData()
548
+ for (const [name, value] of entries) {
549
+ for (const item of Array.isArray(value) ? value : [value]) {
550
+ if (item === undefined || item === null) continue
551
+ if (item instanceof Blob) {
552
+ form.append(name, item)
553
+ } else if (
554
+ typeof item === "string" ||
555
+ typeof item === "number" ||
556
+ typeof item === "boolean" ||
557
+ typeof item === "bigint"
558
+ ) {
559
+ form.append(name, String(item))
560
+ } else {
561
+ throw new TypeError(
562
+ `Cannot send "${name}" in a form body: a form field is text or a file, not ${typeof item}`,
563
+ )
564
+ }
565
+ }
566
+ }
567
+ return form
568
+ }
569
+
377
570
  async function execute(
378
571
  base: string,
379
572
  path: string,
@@ -381,6 +574,11 @@ async function execute(
381
574
  args: unknown[],
382
575
  options: ClientOptions,
383
576
  ): Promise<Result<unknown>> {
577
+ // Refused before anything runs - no hook, no fetch, no retry - because nothing can be sent: the
578
+ // request would reach a different path than this call names. Same shape as a network failure.
579
+ if (hasDotSegment(path)) {
580
+ return { ok: false, status: 0, data: null, error: { error: "invalid_path" } }
581
+ }
384
582
  const isBodyVerb = BODY_VERBS.has(verb)
385
583
  const body = isBodyVerb ? args[0] : undefined
386
584
  const callOptions = (isBodyVerb ? args[1] : args[0]) as CallOptions | undefined
@@ -397,10 +595,20 @@ async function execute(
397
595
  }
398
596
  const init: RequestInit = { method, headers }
399
597
  if (body !== undefined) {
400
- const codec = options.transport?.codec ?? plainJsonCodec
401
- init.body = codec.encode(body)
402
- headers["content-type"] = codec.mediaType
403
- if (options.transport !== undefined) headers.accept ??= codec.mediaType
598
+ const form = formBody(body)
599
+ if (form !== undefined) {
600
+ init.body = form
601
+ // The platform writes this header itself, boundary included; one set by hand would name a
602
+ // boundary the body does not use and the server would refuse the request.
603
+ for (const name of Object.keys(headers)) {
604
+ if (name.toLowerCase() === "content-type") delete headers[name]
605
+ }
606
+ } else {
607
+ const codec = options.transport?.codec ?? plainJsonCodec
608
+ init.body = codec.encode(body)
609
+ headers["content-type"] = codec.mediaType
610
+ if (options.transport !== undefined) headers.accept ??= codec.mediaType
611
+ }
404
612
  }
405
613
  const { signal, timeout } = buildSignal(callOptions?.signal, options.timeoutMs)
406
614
  if (signal !== undefined) init.signal = signal
@@ -429,17 +637,25 @@ async function execute(
429
637
  // A contract violation is a test assertion (validateResponses), not a call outcome - let it
430
638
  // fail the test instead of degrading into a `Result` the test would happily branch on.
431
639
  if (error instanceof ResponseContractViolation) throw error
432
- if (methodRetryable && attempt < maxRetries) {
640
+ // An aborted call (the caller's signal, or `timeoutMs` spent) fails every further attempt at once.
641
+ if (methodRetryable && attempt < maxRetries && signal?.aborted !== true) {
433
642
  attempt += 1
434
- await delay(backoff(attempt))
643
+ await delay(backoff(attempt), signal)
435
644
  continue
436
645
  }
437
646
  const code = timeout?.aborted === true ? "timeout" : "network_error"
438
647
  return { ok: false, status: 0, data: null, error: { error: code } }
439
648
  }
440
- if (methodRetryable && attempt < maxRetries && retryStatuses.has(response.status)) {
649
+ if (
650
+ methodRetryable &&
651
+ attempt < maxRetries &&
652
+ signal?.aborted !== true &&
653
+ retryStatuses.has(response.status)
654
+ ) {
655
+ // This answer is discarded; cancelling its body frees the connection now rather than at GC.
656
+ void response.body?.cancel().catch(() => {})
441
657
  attempt += 1
442
- await delay(backoff(attempt))
658
+ await delay(backoff(attempt), signal)
443
659
  continue
444
660
  }
445
661
  break
@@ -456,8 +672,15 @@ async function execute(
456
672
  try {
457
673
  data = await parseBody(response, options)
458
674
  } catch (cause) {
459
- if (!isPayloadTooLarge(cause)) throw cause
460
- return { ok: false, status: 0, data: null, error: { error: "response_too_large" } }
675
+ if (isPayloadTooLarge(cause)) {
676
+ return { ok: false, status: 0, data: null, error: { error: "response_too_large" } }
677
+ }
678
+ // A codec refusing the payload stays a throw. Anything else is the read itself failing - the
679
+ // deadline or the caller's signal firing mid-body, or the connection dropping - which the fetch
680
+ // above already reports as a Result.
681
+ if (cause instanceof Error && cause.name === "TransportCodecError") throw cause
682
+ const code = timeout?.aborted === true ? "timeout" : "network_error"
683
+ return { ok: false, status: 0, data: null, error: { error: code } }
461
684
  }
462
685
  if (response.ok) {
463
686
  return { ok: true, status: response.status, data, error: null }
@@ -485,18 +708,28 @@ interface SseFrame {
485
708
  retry?: number
486
709
  }
487
710
 
711
+ // Past 2^31-1 ms a timer fires at once, so a larger `retry:` would turn into an immediate reconnect.
712
+ const MAX_RETRY_MS = 2_147_483_647
713
+
488
714
  /**
489
715
  * Incrementally parse a `text/event-stream` body, invoking `onFrame` per dispatched event.
490
- * Implements the SSE wire format: `data:` accumulates multi-line, `id:`/`retry:` update stream
491
- * state, `:` lines are comments, a blank line dispatches.
716
+ * Implements the SSE wire format: CRLF, CR, and LF all end a line, `data:` accumulates multi-line,
717
+ * `id:`/`retry:` update stream state, `:` lines are comments, a blank line dispatches. An event
718
+ * still being assembled may hold at most `maxEventLength` characters.
492
719
  */
493
720
  async function readSseStream(
494
721
  body: ReadableStream<Uint8Array>,
495
722
  onFrame: (frame: SseFrame) => void,
723
+ maxEventLength: number,
496
724
  ): Promise<void> {
497
725
  const reader = body.getReader()
498
726
  const decoder = new TextDecoder()
499
- let buffer = ""
727
+ const lineEnd = /\r\n?|\n/g
728
+ // Only newly decoded text is scanned; the unfinished line is carried here, so a line that arrives
729
+ // over many small reads costs its length once rather than once per read.
730
+ let partial = ""
731
+ let afterCr = false
732
+ let held = 0
500
733
  let dataLines: string[] = []
501
734
  let frame: SseFrame = {}
502
735
 
@@ -506,6 +739,7 @@ async function readSseStream(
506
739
  onFrame(frame)
507
740
  }
508
741
  dataLines = []
742
+ held = 0
509
743
  frame = {}
510
744
  }
511
745
 
@@ -519,42 +753,66 @@ async function readSseStream(
519
753
  const field = colon === -1 ? line : line.slice(0, colon)
520
754
  let value = colon === -1 ? "" : line.slice(colon + 1)
521
755
  if (value.startsWith(" ")) value = value.slice(1)
522
- if (field === "data") dataLines.push(value)
523
- else if (field === "id") frame.id = value
524
- else if (field === "retry") {
525
- const parsed = Number(value)
526
- if (Number.isFinite(parsed)) frame.retry = parsed
756
+ if (field === "data") {
757
+ dataLines.push(value)
758
+ held += value.length + 1
759
+ } else if (field === "id") {
760
+ // The grammar ignores an id with a NUL; it could never be sent back as `Last-Event-ID`.
761
+ if (!value.includes("\0")) frame.id = value
762
+ } else if (field === "retry") {
763
+ if (/^\d+$/.test(value)) frame.retry = Math.min(Number(value), MAX_RETRY_MS)
527
764
  }
528
765
  // `event:` names pass through untyped for now - the contract types the data payload.
529
766
  }
530
767
 
768
+ const feed = (text: string): void => {
769
+ if (text === "") return
770
+ // An LF opening this read completes a CRLF whose CR closed the previous one.
771
+ let start = afterCr && text.charCodeAt(0) === 10 ? 1 : 0
772
+ lineEnd.lastIndex = start
773
+ for (let end = lineEnd.exec(text); end !== null; end = lineEnd.exec(text)) {
774
+ handleLine(partial + text.slice(start, end.index))
775
+ partial = ""
776
+ start = lineEnd.lastIndex
777
+ }
778
+ afterCr = text.charCodeAt(text.length - 1) === 13
779
+ partial += text.slice(start)
780
+ if (partial.length + held > maxEventLength) throw new Error("sse_event_too_large")
781
+ }
782
+
531
783
  try {
532
784
  for (;;) {
533
785
  const { done, value } = await reader.read()
534
786
  if (done) break
535
- buffer += decoder.decode(value, { stream: true })
536
- for (;;) {
537
- const newline = buffer.indexOf("\n")
538
- if (newline === -1) break
539
- const line = buffer.slice(0, newline).replace(/\r$/, "")
540
- buffer = buffer.slice(newline + 1)
541
- handleLine(line)
542
- }
787
+ feed(decoder.decode(value, { stream: true }))
543
788
  }
544
- buffer += decoder.decode()
545
- if (buffer !== "") handleLine(buffer.replace(/\r$/, ""))
789
+ feed(decoder.decode())
790
+ if (partial !== "") handleLine(partial)
546
791
  dispatch() // an unterminated final frame still dispatches
792
+ } catch (error) {
793
+ // Stopping early must not leave the response open behind a released lock.
794
+ void reader.cancel(error).catch(() => {})
795
+ throw error
547
796
  } finally {
548
797
  reader.releaseLock()
549
798
  }
550
799
  }
551
800
 
801
+ /** A `Last-Event-ID` value as EventSource sends it: a header carries bytes, so an id beyond ASCII goes
802
+ * as its UTF-8 bytes - a character past U+00FF would otherwise fail every reconnect before it left. */
803
+ function lastEventIdHeader(id: string): string {
804
+ if (/^[\x20-\x7e]*$/.test(id)) return id
805
+ let bytes = ""
806
+ for (const byte of new TextEncoder().encode(id)) bytes += String.fromCharCode(byte)
807
+ return bytes
808
+ }
809
+
552
810
  /**
553
811
  * The `.subscribe()` runtime for `app.sse()` routes. fetch-based (never `EventSource`), so it
554
812
  * streams over the configured fetcher - network, an in-process bridge, or a test mock - with
555
813
  * EventSource semantics where they matter: auto-reconnect with backoff + jitter (honoring the
556
- * server's `retry:` hint), `Last-Event-ID` resumption, JSON-parsed typed events. Never throws:
557
- * failures reach `onError`; a terminal end reaches `onClose`.
814
+ * server's `retry:` hint after a stream it served), `Last-Event-ID` resumption, JSON-parsed typed
815
+ * events. Never throws: failures reach `onError`; a terminal end reaches `onClose`.
558
816
  */
559
817
  function subscribeSse(
560
818
  base: string,
@@ -591,22 +849,19 @@ function subscribeSse(
591
849
  const query = callOptions?.query ? buildQuery(callOptions.query) : ""
592
850
  if (query !== "") url += `?${query}`
593
851
  const doFetch = options.fetch ?? fetch
594
-
595
- const delay = (ms: number): Promise<void> =>
596
- new Promise((resolve) => {
597
- const timer = setTimeout(resolve, ms)
598
- ;(timer as { unref?: () => void }).unref?.()
599
- controller.signal.addEventListener(
600
- "abort",
601
- () => {
602
- clearTimeout(timer)
603
- resolve()
604
- },
605
- { once: true },
606
- )
607
- })
852
+ // One event decodes into one value, so it answers to the same bound a decoded response body does.
853
+ const maxEventLength = options.transport?.maxBytes ?? options.maxDecodedBytes ?? 16 * 1024 * 1024
608
854
 
609
855
  void (async () => {
856
+ if (hasDotSegment(path)) {
857
+ // Nothing is sent (see `hasDotSegment`). Reported after `subscribe()` has returned, as every
858
+ // other failure is, so a handler can use the subscription it was given.
859
+ await Promise.resolve()
860
+ if (closed) return
861
+ callOptions?.onError?.(new Error("invalid_path"))
862
+ close()
863
+ return
864
+ }
610
865
  let attempt = 0
611
866
  while (!closed) {
612
867
  try {
@@ -614,24 +869,30 @@ function subscribeSse(
614
869
  ...options.headers,
615
870
  ...callOptions?.headers,
616
871
  accept: "text/event-stream",
617
- ...(lastEventId !== undefined ? { "last-event-id": lastEventId } : {}),
872
+ ...(lastEventId ? { "last-event-id": lastEventIdHeader(lastEventId) } : {}),
618
873
  }
619
874
  const response = await doFetch(url, { headers, signal: controller.signal })
620
875
  if (!response.ok || response.body === null) {
876
+ void response.body?.cancel().catch(() => {})
621
877
  throw new Error(`sse_http_${response.status}`)
622
878
  }
623
879
  attempt = 0 // a successful connect resets the backoff
624
- await readSseStream(response.body, (frame) => {
625
- if (frame.id !== undefined) lastEventId = frame.id
626
- if (frame.retry !== undefined) serverRetryMs = frame.retry
627
- if (frame.data !== undefined) {
628
- try {
629
- onEvent(JSON.parse(frame.data))
630
- } catch (error) {
631
- callOptions?.onError?.(error)
880
+ await readSseStream(
881
+ response.body,
882
+ (frame) => {
883
+ if (closed) return
884
+ if (frame.id !== undefined) lastEventId = frame.id
885
+ if (frame.retry !== undefined) serverRetryMs = frame.retry
886
+ if (frame.data !== undefined) {
887
+ try {
888
+ onEvent(JSON.parse(frame.data))
889
+ } catch (error) {
890
+ callOptions?.onError?.(error)
891
+ }
632
892
  }
633
- }
634
- })
893
+ },
894
+ maxEventLength,
895
+ )
635
896
  // Clean server-side end: a finite stream (`reconnect: false`) completes here.
636
897
  if (!reconnectEnabled) {
637
898
  close()
@@ -647,9 +908,18 @@ function subscribeSse(
647
908
  }
648
909
  if (closed) return
649
910
  const backoff = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt)
911
+ const jittered = backoff / 2 + Math.random() * (backoff / 2)
912
+ // The server's `retry:` times the reconnect after a stream it served (`attempt` is 0 only then),
913
+ // and is only a floor while reconnects keep failing: a hint of 0 must not become a request loop
914
+ // against a server that has started answering 503.
915
+ const wait =
916
+ serverRetryMs === undefined
917
+ ? jittered
918
+ : attempt === 0
919
+ ? serverRetryMs
920
+ : Math.max(serverRetryMs, jittered)
650
921
  attempt = Math.min(attempt + 1, 10)
651
- const wait = serverRetryMs ?? backoff / 2 + Math.random() * (backoff / 2)
652
- await delay(wait)
922
+ await delay(wait, controller.signal, true)
653
923
  }
654
924
  })()
655
925
 
@@ -748,12 +1018,11 @@ async function parseBody(response: Response, options: ClientOptions): Promise<un
748
1018
  // the transport path, so an app that set it there keeps the number it chose.
749
1019
  const decodedBytes = options.transport?.maxBytes ?? options.maxDecodedBytes
750
1020
  const bound = decodedBytes === undefined ? {} : { maxBytes: decodedBytes }
751
- // In-process responses (the marked fetcher) hold their body as same-process memory the app
1021
+ // In-process responses (the marked transport) hold their body as same-process memory the app
752
1022
  // already allocated, so the streaming byte-cap reader protects nothing there - the native read
753
1023
  // is ~23x cheaper and the SAME cap is enforced on the result (identical error). A network
754
- // fetcher is never marked and keeps the bounded-while-streaming read.
755
- const local =
756
- (options.fetch as { readonly [LOCAL_FETCH]?: true } | undefined)?.[LOCAL_FETCH] === true
1024
+ // client is never marked and keeps the bounded-while-streaming read.
1025
+ const local = (options as { readonly [LOCAL_FETCH]?: true })[LOCAL_FETCH] === true
757
1026
  if (
758
1027
  contentType.startsWith("application/json") ||
759
1028
  contentType.startsWith("application/vnd.nifra.")