@nifrajs/client 3.5.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/README.md CHANGED
@@ -20,10 +20,19 @@ const { ok, status, data, error } = await api.users({ id: "42" }).get()
20
20
  - **Zero codegen.** Types flow from `typeof app` (coupled) - or from a contract via
21
21
  `client(contract, url)` (decoupled), with no dependency on the server's source.
22
22
  - **Proxy chaining.** Path segments are properties, `:params` are calls, verbs are
23
- methods: `api.users({ id }).posts({ postId }).get()`. The root is `api.index`.
23
+ methods: `api.users({ id }).posts({ postId }).get()`. The root is `api.index`. A segment that
24
+ is part literal, part param (`/files/:name.json`) is a call with the segment as it is sent:
25
+ `api.files("report.json").get()`.
24
26
  - **Result, never exceptions.** Every call resolves to `{ ok, status, data, error }`,
25
27
  so the failure path is in the types. Bodies are positional (`api.users.post({ name })`);
26
- pass `{ query, headers, signal }` as call options.
28
+ pass `{ query, headers, signal }` as call options. A call that gets no HTTP answer has
29
+ `status: 0` and the code `network_error`, `timeout`, `response_too_large` or `invalid_path`.
30
+ - **A param value is never a path step.** `.` and `..` are resolved by URL parsing in every
31
+ encoding, so `api.users({ id: ".." }).delete()` sends nothing and returns `invalid_path`
32
+ instead of reaching another route. `.subscribe()` reports it through `onError`; `.ws()` throws.
33
+ - **Files.** A body that holds a `File` or `Blob` is sent as `multipart/form-data`, typed from the
34
+ route's `t.form`: `api.avatars.post({ avatar: file, caption: "me" })`. `inProcessClient` and
35
+ `testClient` send the same form with its `Content-Length`, as a network peer would.
27
36
  - **Environment-agnostic.** No Bun/Node APIs - runs in the browser, workers, or any
28
37
  runtime with `fetch`.
29
38
 
package/dist/client.d.ts CHANGED
@@ -21,7 +21,11 @@ export interface ClientRetryOptions {
21
21
  readonly backoff?: (attempt: number) => number;
22
22
  }
23
23
  export interface ClientOptions {
24
- /** Headers sent on every request (a per-call `headers` option is merged on top). */
24
+ /**
25
+ * Headers sent on every HTTP request (a per-call `headers` option is merged on top). A WebSocket
26
+ * handshake carries none of them, on any runtime: a browser cannot set them, and a socket that
27
+ * authenticated under Bun but not in the browser would be worse than one that never did.
28
+ */
25
29
  readonly headers?: Record<string, string>;
26
30
  /** Override the `fetch` implementation (tests, an in-process bridge, a custom agent, etc.). */
27
31
  readonly fetch?: FetchFn;
@@ -51,7 +55,8 @@ export interface ClientOptions {
51
55
  *
52
56
  * Bounds the two paths that build something in memory - a JSON body becoming an object graph, a text
53
57
  * body becoming a string - and it bounds them while streaming, cancelling the read the moment the
54
- * total passes. A 2 GB string costs as much as a 2 GB object, so both answer to one number.
58
+ * total passes. A 2 GB string costs as much as a 2 GB object, so both answer to one number. Each
59
+ * event a `.subscribe()` stream assembles answers to it too.
55
60
  *
56
61
  * A BINARY body is deliberately not bounded by this. That is a download, and a size limit on a
57
62
  * download is a bug rather than a defence - the caller asked for the file. Bound one at the call site
@@ -84,6 +89,10 @@ export interface InProcessClientOptions extends Omit<ClientOptions, "fetch"> {
84
89
  */
85
90
  readonly validateResponses?: boolean;
86
91
  }
92
+ export interface TestClientOptions extends InProcessClientOptions {
93
+ /** The address every call comes from, as a socket peer's would (default `"127.0.0.1"`). */
94
+ readonly clientIp?: string;
95
+ }
87
96
  /**
88
97
  * Create an end-to-end-typed client for a nifra server. Two modes:
89
98
  *
@@ -108,7 +117,9 @@ export declare function inProcessClient<App extends {
108
117
  * The in-process test client - the Fastify-`inject` / supertest equivalent for nifra. Drives the
109
118
  * app's own `fetch` directly: no server, no port, no network, the full real lifecycle (validation,
110
119
  * middleware, contracts, auth), and end-to-end types from `App`. Calls never throw - branch on
111
- * `res.ok`. An alias of {@link inProcessClient} with a test-focused name; identical behavior.
120
+ * `res.ok`. It is {@link inProcessClient} plus a client address: every call comes from `127.0.0.1`
121
+ * (or `options.clientIp`), so middleware keyed on the caller, like `rateLimit()`, runs as it would
122
+ * behind a listener.
112
123
  *
113
124
  * ```ts
114
125
  * import { testClient } from "@nifrajs/client"
@@ -119,6 +130,8 @@ export declare function inProcessClient<App extends {
119
130
  * expect(res.ok && res.data.id).toBe("42")
120
131
  * ```
121
132
  */
122
- export declare const testClient: typeof inProcessClient;
133
+ export declare function testClient<App extends {
134
+ fetch(request: Request): Response | Promise<Response>;
135
+ }>(app: App, options?: TestClientOptions): InProcessClient<App>;
123
136
  export {};
124
137
  //# sourceMappingURL=client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
package/dist/client.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { NIFRA_BINARY_HEADER } from "@nifrajs/core/binary";
2
- import { NIFRA_BACKEND_MOUNT, } from "@nifrajs/core/mount";
2
+ import { NIFRA_BACKEND_BIND_PLATFORM, NIFRA_BACKEND_EVIDENCE, NIFRA_BACKEND_MOUNT, NIFRA_BACKEND_WS_MOUNT, NIFRA_BACKEND_WS_RUNTIME, } from "@nifrajs/core/mount";
3
3
  import { assertTransportTextBounded, createTransportCodecRegistry, decodeTransportResponse, decodeTransportText, defaultTransportCodecs, plainJsonCodec, readBoundedBytes, } from "@nifrajs/core/transport-codec";
4
4
  import { RESERVED_VERB_KEYS } from "./reserved.js";
5
- import { ResponseContractViolation, withResponseValidation } from "./validate-responses.js";
5
+ import { hasDotSegment } from "./sendable-path.js";
6
+ import { ResponseContractViolation, withResponseValidation, } from "./validate-responses.js";
6
7
  import { NO_SOCKET, openWebSocket } from "./ws.js";
8
+ const CORE_WS_RUNTIME = Symbol.for("@nifrajs/core/get-ws-runtime");
7
9
  /**
8
10
  * The RESERVED proxy keys, resolved before path segments (see `resolveSegment` and the `then`
9
11
  * guard in `createProxy`). The closed list and the reasons it can never grow live in
@@ -17,7 +19,7 @@ import { NO_SOCKET, openWebSocket } from "./ws.js";
17
19
  */
18
20
  const HTTP_VERBS = new Set(RESERVED_VERB_KEYS);
19
21
  const BODY_VERBS = new Set(["post", "put", "patch"]);
20
- /** Marks a fetcher whose responses are same-process objects (see {@link inProcessClient}). */
22
+ /** Marks client options whose transport returns same-process responses (see {@link inProcessClient}). */
21
23
  const LOCAL_FETCH = Symbol("nifra.local-fetch");
22
24
  const DEFAULT_RETRY_STATUS_SET = new Set([502, 503, 504]);
23
25
  const DEFAULT_IDEMPOTENT_SET = new Set([
@@ -30,9 +32,23 @@ const DEFAULT_IDEMPOTENT_SET = new Set([
30
32
  function defaultBackoff(attempt) {
31
33
  return Math.min(300 * 2 ** (attempt - 1), 3000) + Math.random() * 100;
32
34
  }
33
- function delay(ms) {
35
+ /** Resolves after `ms`, or as soon as `signal` aborts, and leaves no listener on the signal either way:
36
+ * a long-lived signal waited on once per reconnect otherwise collects one listener per wait. */
37
+ function delay(ms, signal, unref = false) {
34
38
  return new Promise((resolve) => {
35
- setTimeout(resolve, ms);
39
+ if (signal?.aborted === true) {
40
+ resolve();
41
+ return;
42
+ }
43
+ const done = () => {
44
+ clearTimeout(timer);
45
+ signal?.removeEventListener("abort", done);
46
+ resolve();
47
+ };
48
+ const timer = setTimeout(done, ms);
49
+ if (unref)
50
+ timer.unref?.();
51
+ signal?.addEventListener("abort", done, { once: true });
36
52
  });
37
53
  }
38
54
  /** Combine a per-call signal with a timeout into one signal; also returns the timeout signal so the
@@ -69,10 +85,20 @@ export function client(arg1, arg2, arg3) {
69
85
  * The returned proxy also implements the explicit symbol-keyed {@link BackendMount} interface, so
70
86
  * `createWebApp({ api: inProcessClient(backend) })` can auto-mount the backend while forwarding the
71
87
  * outer runtime's `env` and `waitUntil`.
88
+ *
89
+ * **Loader calls inherit the page request's platform identity.** While `createWebApp` renders a page,
90
+ * each loader, action and boundary gets a `ctx.api` bound to that request's platform: the backend sees
91
+ * the visitor's `c.clientIp` (as the outer app derived it under its `clientIp` trust declaration),
92
+ * `c.env` and `c.waitUntil`. Read the caller through `c.clientIp` - identity travels in the platform,
93
+ * so rebuilding a `Request` never loses it. Only platform fields travel: the page request's `cookie`,
94
+ * `authorization` and other headers are NOT copied, so a loader call is anonymous unless the loader
95
+ * passes headers itself (`ctx.api.me.get({ headers: { cookie } })`). Used outside a page render (a
96
+ * script, a job), calls carry no platform, so they come from no address; {@link testClient} is the
97
+ * variant whose calls come from a local peer.
72
98
  */
73
99
  const utf8 = new TextEncoder();
74
100
  /** Byte length of a body whose size is knowable without reading a stream; `undefined` otherwise
75
- * (a `ReadableStream` or `FormData` body stays lengthless - multipart framing is the runtime's). */
101
+ * (a `ReadableStream` body stays lengthless; a `FormData` body is framed by {@link framedFormRequest}). */
76
102
  function knownBodyLength(body) {
77
103
  if (typeof body === "string")
78
104
  return utf8.encode(body).byteLength;
@@ -102,27 +128,98 @@ function synthesizedRequest(url, init) {
102
128
  request.headers.set("content-length", String(length));
103
129
  return request;
104
130
  }
131
+ /**
132
+ * A form body as the bytes a socket would carry, with the length a network peer would declare.
133
+ *
134
+ * Handing the `FormData` itself to `Request` leaves the runtime to encode it while the app reads,
135
+ * which has two costs in-process. The request arrives lengthless, so a fail-closed length gate
136
+ * refuses it. And an app that stops reading early - a body over its limit - cancels a stream the
137
+ * runtime is still writing to, which Node 26 reports as an unhandled rejection. Encoding first
138
+ * gives the app a finished body it can refuse like any other.
139
+ */
140
+ async function framedFormRequest(url, init, form) {
141
+ const encoded = new Response(form);
142
+ // Read before the body: Bun derives this header from the body and cannot once it is consumed.
143
+ const contentType = encoded.headers.get("content-type");
144
+ const bytes = await encoded.arrayBuffer();
145
+ const headers = new Headers(init.headers);
146
+ headers.set("content-type", contentType);
147
+ headers.set("content-length", String(bytes.byteLength));
148
+ return new Request(url, { ...init, headers, body: bytes });
149
+ }
105
150
  export function inProcessClient(app, options) {
151
+ return createInProcessClient(app, options, undefined);
152
+ }
153
+ function createInProcessClient(app, options,
154
+ // The platform an unbound call carries. Only `testClient` sets one: a production call made on behalf
155
+ // of a remote user must not look local to rate limits or IP rules.
156
+ unboundPlatform) {
106
157
  // The in-process bridge: the client speaks `fetch(url, init)` (the `FetchFn` shape) while the app's
107
158
  // own `fetch` takes a `Request`. It is the proxy's per-call transport; the symbol-keyed mount below
108
- // is the platform-aware auto-mount path.
109
- const direct = (url, init) => Promise.resolve(app.fetch(synthesizedRequest(url, init)));
159
+ // is the platform-aware auto-mount path. The optional third argument is the calling request's
160
+ // platform, supplied only by a view from the bind seam below; the unbound proxy never passes one.
161
+ const direct = (url, init, boundPlatform) => {
162
+ const platform = boundPlatform ?? unboundPlatform;
163
+ const body = init?.body;
164
+ if (body instanceof FormData) {
165
+ return framedFormRequest(url, init, body).then((request) => app.fetch(request, platform));
166
+ }
167
+ return Promise.resolve(app.fetch(synthesizedRequest(url, init), platform));
168
+ };
110
169
  const bridge = options?.validateResponses === true ? withResponseValidation(app, direct) : direct;
111
- bridge[LOCAL_FETCH] = true;
112
170
  const mount = (request, platform) => Promise.resolve(app.fetch(request, platform));
171
+ const evidenceProvider = async () => {
172
+ const { snapshotProjectEvidence } = await import("@nifrajs/core/evidence");
173
+ return snapshotProjectEvidence(app);
174
+ };
175
+ const resolveWebSocketUpgrade = app.resolveWebSocketUpgrade;
176
+ const getWebSocketRuntime = app[CORE_WS_RUNTIME];
177
+ const runtimeProvider = typeof getWebSocketRuntime === "function"
178
+ ? getWebSocketRuntime.bind(app)
179
+ : undefined;
180
+ if (typeof resolveWebSocketUpgrade === "function") {
181
+ ;
182
+ mount[NIFRA_BACKEND_WS_MOUNT] = (request, platform) => resolveWebSocketUpgrade.call(app, request, platform);
183
+ }
184
+ if (runtimeProvider !== undefined) {
185
+ ;
186
+ mount[NIFRA_BACKEND_WS_RUNTIME] = runtimeProvider;
187
+ }
188
+ ;
189
+ mount[NIFRA_BACKEND_EVIDENCE] = evidenceProvider;
113
190
  // NO_SOCKET marks the options so a typed `.ws()` call fails with a real explanation - an
114
191
  // in-process app has no socket to upgrade - instead of dialing ws://nifra.internal into the void.
115
- const proxy = client("http://nifra.internal", {
116
- ...options,
117
- fetch: bridge,
118
- [NO_SOCKET]: true,
119
- });
192
+ // LOCAL_FETCH marks the transport as same-process: its response bodies are memory the app already
193
+ // holds, so `parseBody` may use the native `Response.text()` read (measured ~23x cheaper than the
194
+ // streaming byte-cap reader) - the cap itself is still enforced on the result. Network clients are
195
+ // never marked; they keep the bounded-while-streaming read.
196
+ const baseOptions = { ...options, [NO_SOCKET]: true, [LOCAL_FETCH]: true };
197
+ const proxy = client("http://nifra.internal", { ...baseOptions, fetch: bridge });
198
+ // Request scoping: a view whose every call carries one request's platform (clientIp/env/waitUntil),
199
+ // built by the page executor once per render. The view owns its own route tree because the tree's
200
+ // nodes close over their transport; the validator's router and every other per-client structure are
201
+ // shared. A view lives for one render, so its nodes skip the memo Maps a long-lived client keeps.
202
+ // Nothing here runs unless a render asks, so the unbound proxy's per-call path is unchanged.
203
+ const bindPlatform = (platform) => {
204
+ const bound = (url, init) => bridge(url, init, platform);
205
+ return createProxy("http://nifra.internal", "", { ...baseOptions, fetch: bound }, false);
206
+ };
120
207
  // An outer Proxy intercepts only the explicit mount symbol, delegating every typed route segment
121
208
  // unchanged (`api.users({ id }).get()`).
122
209
  return new Proxy(proxy, {
123
210
  get(targetProxy, key, receiver) {
124
211
  if (key === NIFRA_BACKEND_MOUNT)
125
212
  return mount;
213
+ if (key === NIFRA_BACKEND_EVIDENCE)
214
+ return evidenceProvider;
215
+ if (key === NIFRA_BACKEND_BIND_PLATFORM)
216
+ return bindPlatform;
217
+ if (key === NIFRA_BACKEND_WS_MOUNT) {
218
+ return mount[NIFRA_BACKEND_WS_MOUNT];
219
+ }
220
+ if (key === NIFRA_BACKEND_WS_RUNTIME) {
221
+ return mount[NIFRA_BACKEND_WS_RUNTIME];
222
+ }
126
223
  return Reflect.get(targetProxy, key, receiver);
127
224
  },
128
225
  });
@@ -131,7 +228,9 @@ export function inProcessClient(app, options) {
131
228
  * The in-process test client - the Fastify-`inject` / supertest equivalent for nifra. Drives the
132
229
  * app's own `fetch` directly: no server, no port, no network, the full real lifecycle (validation,
133
230
  * middleware, contracts, auth), and end-to-end types from `App`. Calls never throw - branch on
134
- * `res.ok`. An alias of {@link inProcessClient} with a test-focused name; identical behavior.
231
+ * `res.ok`. It is {@link inProcessClient} plus a client address: every call comes from `127.0.0.1`
232
+ * (or `options.clientIp`), so middleware keyed on the caller, like `rateLimit()`, runs as it would
233
+ * behind a listener.
135
234
  *
136
235
  * ```ts
137
236
  * import { testClient } from "@nifrajs/client"
@@ -142,7 +241,10 @@ export function inProcessClient(app, options) {
142
241
  * expect(res.ok && res.data.id).toBe("42")
143
242
  * ```
144
243
  */
145
- export const testClient = inProcessClient;
244
+ export function testClient(app, options) {
245
+ const { clientIp = "127.0.0.1", ...rest } = options ?? {};
246
+ return createInProcessClient(app, rest, { clientIp });
247
+ }
146
248
  function createProxy(base, path, options, cacheable = true) {
147
249
  const target = () => { };
148
250
  // Every get-trap result is a pure function of (base, path, key, options), so a STATIC node
@@ -194,7 +296,6 @@ function resolveSegment(base, path, key, options, cacheable) {
194
296
  // Typed WebSocket handle for `app.ws()` routes - `ws` is a reserved proxy key like `subscribe`.
195
297
  if (key === "ws") {
196
298
  return (wsOptions) => openWebSocket(base, path, {
197
- headers: options.headers,
198
299
  ...wsOptions,
199
300
  ...(options.transport === undefined ? {} : { transport: options.transport }),
200
301
  }, NO_SOCKET in options);
@@ -202,7 +303,51 @@ function resolveSegment(base, path, key, options, cacheable) {
202
303
  // `index` addresses the root path "/" and adds no segment.
203
304
  return createProxy(base, key === "index" ? path : `${path}/${key}`, options, cacheable);
204
305
  }
306
+ /**
307
+ * The `multipart/form-data` encoding of a body that carries a file: JSON cannot hold one, so a
308
+ * record with a `Blob`/`File` value (or a list of them) is sent as a form instead - one part per
309
+ * value, a list as one part per item under the same name, `null`/`undefined` left out. A
310
+ * `FormData` passed as the body is sent as is. Anything else returns `undefined` and stays JSON.
311
+ * A form part is text or a file, so a nested object or list cannot be sent and throws.
312
+ */
313
+ function formBody(body) {
314
+ if (body instanceof FormData)
315
+ return body;
316
+ if (body === null || typeof body !== "object" || Array.isArray(body) || body instanceof Blob) {
317
+ return undefined;
318
+ }
319
+ const entries = Object.entries(body);
320
+ const isFile = (value) => value instanceof Blob;
321
+ if (!entries.some(([, value]) => isFile(value) || (Array.isArray(value) && value.some(isFile)))) {
322
+ return undefined;
323
+ }
324
+ const form = new FormData();
325
+ for (const [name, value] of entries) {
326
+ for (const item of Array.isArray(value) ? value : [value]) {
327
+ if (item === undefined || item === null)
328
+ continue;
329
+ if (item instanceof Blob) {
330
+ form.append(name, item);
331
+ }
332
+ else if (typeof item === "string" ||
333
+ typeof item === "number" ||
334
+ typeof item === "boolean" ||
335
+ typeof item === "bigint") {
336
+ form.append(name, String(item));
337
+ }
338
+ else {
339
+ throw new TypeError(`Cannot send "${name}" in a form body: a form field is text or a file, not ${typeof item}`);
340
+ }
341
+ }
342
+ }
343
+ return form;
344
+ }
205
345
  async function execute(base, path, verb, args, options) {
346
+ // Refused before anything runs - no hook, no fetch, no retry - because nothing can be sent: the
347
+ // request would reach a different path than this call names. Same shape as a network failure.
348
+ if (hasDotSegment(path)) {
349
+ return { ok: false, status: 0, data: null, error: { error: "invalid_path" } };
350
+ }
206
351
  const isBodyVerb = BODY_VERBS.has(verb);
207
352
  const body = isBodyVerb ? args[0] : undefined;
208
353
  const callOptions = (isBodyVerb ? args[1] : args[0]);
@@ -219,11 +364,23 @@ async function execute(base, path, verb, args, options) {
219
364
  }
220
365
  const init = { method, headers };
221
366
  if (body !== undefined) {
222
- const codec = options.transport?.codec ?? plainJsonCodec;
223
- init.body = codec.encode(body);
224
- headers["content-type"] = codec.mediaType;
225
- if (options.transport !== undefined)
226
- headers.accept ??= codec.mediaType;
367
+ const form = formBody(body);
368
+ if (form !== undefined) {
369
+ init.body = form;
370
+ // The platform writes this header itself, boundary included; one set by hand would name a
371
+ // boundary the body does not use and the server would refuse the request.
372
+ for (const name of Object.keys(headers)) {
373
+ if (name.toLowerCase() === "content-type")
374
+ delete headers[name];
375
+ }
376
+ }
377
+ else {
378
+ const codec = options.transport?.codec ?? plainJsonCodec;
379
+ init.body = codec.encode(body);
380
+ headers["content-type"] = codec.mediaType;
381
+ if (options.transport !== undefined)
382
+ headers.accept ??= codec.mediaType;
383
+ }
227
384
  }
228
385
  const { signal, timeout } = buildSignal(callOptions?.signal, options.timeoutMs);
229
386
  if (signal !== undefined)
@@ -251,17 +408,23 @@ async function execute(base, path, verb, args, options) {
251
408
  // fail the test instead of degrading into a `Result` the test would happily branch on.
252
409
  if (error instanceof ResponseContractViolation)
253
410
  throw error;
254
- if (methodRetryable && attempt < maxRetries) {
411
+ // An aborted call (the caller's signal, or `timeoutMs` spent) fails every further attempt at once.
412
+ if (methodRetryable && attempt < maxRetries && signal?.aborted !== true) {
255
413
  attempt += 1;
256
- await delay(backoff(attempt));
414
+ await delay(backoff(attempt), signal);
257
415
  continue;
258
416
  }
259
417
  const code = timeout?.aborted === true ? "timeout" : "network_error";
260
418
  return { ok: false, status: 0, data: null, error: { error: code } };
261
419
  }
262
- if (methodRetryable && attempt < maxRetries && retryStatuses.has(response.status)) {
420
+ if (methodRetryable &&
421
+ attempt < maxRetries &&
422
+ signal?.aborted !== true &&
423
+ retryStatuses.has(response.status)) {
424
+ // This answer is discarded; cancelling its body frees the connection now rather than at GC.
425
+ void response.body?.cancel().catch(() => { });
263
426
  attempt += 1;
264
- await delay(backoff(attempt));
427
+ await delay(backoff(attempt), signal);
265
428
  continue;
266
429
  }
267
430
  break;
@@ -278,9 +441,16 @@ async function execute(base, path, verb, args, options) {
278
441
  data = await parseBody(response, options);
279
442
  }
280
443
  catch (cause) {
281
- if (!isPayloadTooLarge(cause))
444
+ if (isPayloadTooLarge(cause)) {
445
+ return { ok: false, status: 0, data: null, error: { error: "response_too_large" } };
446
+ }
447
+ // A codec refusing the payload stays a throw. Anything else is the read itself failing - the
448
+ // deadline or the caller's signal firing mid-body, or the connection dropping - which the fetch
449
+ // above already reports as a Result.
450
+ if (cause instanceof Error && cause.name === "TransportCodecError")
282
451
  throw cause;
283
- return { ok: false, status: 0, data: null, error: { error: "response_too_large" } };
452
+ const code = timeout?.aborted === true ? "timeout" : "network_error";
453
+ return { ok: false, status: 0, data: null, error: { error: code } };
284
454
  }
285
455
  if (response.ok) {
286
456
  return { ok: true, status: response.status, data, error: null };
@@ -289,15 +459,23 @@ async function execute(base, path, verb, args, options) {
289
459
  // `error` is the server's normalized `{ error, issues }` summary.
290
460
  return { ok: false, status: response.status, data, error: toApiError(data) };
291
461
  }
462
+ // Past 2^31-1 ms a timer fires at once, so a larger `retry:` would turn into an immediate reconnect.
463
+ const MAX_RETRY_MS = 2_147_483_647;
292
464
  /**
293
465
  * Incrementally parse a `text/event-stream` body, invoking `onFrame` per dispatched event.
294
- * Implements the SSE wire format: `data:` accumulates multi-line, `id:`/`retry:` update stream
295
- * state, `:` lines are comments, a blank line dispatches.
466
+ * Implements the SSE wire format: CRLF, CR, and LF all end a line, `data:` accumulates multi-line,
467
+ * `id:`/`retry:` update stream state, `:` lines are comments, a blank line dispatches. An event
468
+ * still being assembled may hold at most `maxEventLength` characters.
296
469
  */
297
- async function readSseStream(body, onFrame) {
470
+ async function readSseStream(body, onFrame, maxEventLength) {
298
471
  const reader = body.getReader();
299
472
  const decoder = new TextDecoder();
300
- let buffer = "";
473
+ const lineEnd = /\r\n?|\n/g;
474
+ // Only newly decoded text is scanned; the unfinished line is carried here, so a line that arrives
475
+ // over many small reads costs its length once rather than once per read.
476
+ let partial = "";
477
+ let afterCr = false;
478
+ let held = 0;
301
479
  let dataLines = [];
302
480
  let frame = {};
303
481
  const dispatch = () => {
@@ -307,6 +485,7 @@ async function readSseStream(body, onFrame) {
307
485
  onFrame(frame);
308
486
  }
309
487
  dataLines = [];
488
+ held = 0;
310
489
  frame = {};
311
490
  };
312
491
  const handleLine = (line) => {
@@ -321,47 +500,74 @@ async function readSseStream(body, onFrame) {
321
500
  let value = colon === -1 ? "" : line.slice(colon + 1);
322
501
  if (value.startsWith(" "))
323
502
  value = value.slice(1);
324
- if (field === "data")
503
+ if (field === "data") {
325
504
  dataLines.push(value);
326
- else if (field === "id")
327
- frame.id = value;
505
+ held += value.length + 1;
506
+ }
507
+ else if (field === "id") {
508
+ // The grammar ignores an id with a NUL; it could never be sent back as `Last-Event-ID`.
509
+ if (!value.includes("\0"))
510
+ frame.id = value;
511
+ }
328
512
  else if (field === "retry") {
329
- const parsed = Number(value);
330
- if (Number.isFinite(parsed))
331
- frame.retry = parsed;
513
+ if (/^\d+$/.test(value))
514
+ frame.retry = Math.min(Number(value), MAX_RETRY_MS);
332
515
  }
333
516
  // `event:` names pass through untyped for now - the contract types the data payload.
334
517
  };
518
+ const feed = (text) => {
519
+ if (text === "")
520
+ return;
521
+ // An LF opening this read completes a CRLF whose CR closed the previous one.
522
+ let start = afterCr && text.charCodeAt(0) === 10 ? 1 : 0;
523
+ lineEnd.lastIndex = start;
524
+ for (let end = lineEnd.exec(text); end !== null; end = lineEnd.exec(text)) {
525
+ handleLine(partial + text.slice(start, end.index));
526
+ partial = "";
527
+ start = lineEnd.lastIndex;
528
+ }
529
+ afterCr = text.charCodeAt(text.length - 1) === 13;
530
+ partial += text.slice(start);
531
+ if (partial.length + held > maxEventLength)
532
+ throw new Error("sse_event_too_large");
533
+ };
335
534
  try {
336
535
  for (;;) {
337
536
  const { done, value } = await reader.read();
338
537
  if (done)
339
538
  break;
340
- buffer += decoder.decode(value, { stream: true });
341
- for (;;) {
342
- const newline = buffer.indexOf("\n");
343
- if (newline === -1)
344
- break;
345
- const line = buffer.slice(0, newline).replace(/\r$/, "");
346
- buffer = buffer.slice(newline + 1);
347
- handleLine(line);
348
- }
539
+ feed(decoder.decode(value, { stream: true }));
349
540
  }
350
- buffer += decoder.decode();
351
- if (buffer !== "")
352
- handleLine(buffer.replace(/\r$/, ""));
541
+ feed(decoder.decode());
542
+ if (partial !== "")
543
+ handleLine(partial);
353
544
  dispatch(); // an unterminated final frame still dispatches
354
545
  }
546
+ catch (error) {
547
+ // Stopping early must not leave the response open behind a released lock.
548
+ void reader.cancel(error).catch(() => { });
549
+ throw error;
550
+ }
355
551
  finally {
356
552
  reader.releaseLock();
357
553
  }
358
554
  }
555
+ /** A `Last-Event-ID` value as EventSource sends it: a header carries bytes, so an id beyond ASCII goes
556
+ * as its UTF-8 bytes - a character past U+00FF would otherwise fail every reconnect before it left. */
557
+ function lastEventIdHeader(id) {
558
+ if (/^[\x20-\x7e]*$/.test(id))
559
+ return id;
560
+ let bytes = "";
561
+ for (const byte of new TextEncoder().encode(id))
562
+ bytes += String.fromCharCode(byte);
563
+ return bytes;
564
+ }
359
565
  /**
360
566
  * The `.subscribe()` runtime for `app.sse()` routes. fetch-based (never `EventSource`), so it
361
567
  * streams over the configured fetcher - network, an in-process bridge, or a test mock - with
362
568
  * EventSource semantics where they matter: auto-reconnect with backoff + jitter (honoring the
363
- * server's `retry:` hint), `Last-Event-ID` resumption, JSON-parsed typed events. Never throws:
364
- * failures reach `onError`; a terminal end reaches `onClose`.
569
+ * server's `retry:` hint after a stream it served), `Last-Event-ID` resumption, JSON-parsed typed
570
+ * events. Never throws: failures reach `onError`; a terminal end reaches `onClose`.
365
571
  */
366
572
  function subscribeSse(base, path, onEvent, callOptions, options) {
367
573
  const controller = new AbortController();
@@ -390,15 +596,19 @@ function subscribeSse(base, path, onEvent, callOptions, options) {
390
596
  if (query !== "")
391
597
  url += `?${query}`;
392
598
  const doFetch = options.fetch ?? fetch;
393
- const delay = (ms) => new Promise((resolve) => {
394
- const timer = setTimeout(resolve, ms);
395
- timer.unref?.();
396
- controller.signal.addEventListener("abort", () => {
397
- clearTimeout(timer);
398
- resolve();
399
- }, { once: true });
400
- });
599
+ // One event decodes into one value, so it answers to the same bound a decoded response body does.
600
+ const maxEventLength = options.transport?.maxBytes ?? options.maxDecodedBytes ?? 16 * 1024 * 1024;
401
601
  void (async () => {
602
+ if (hasDotSegment(path)) {
603
+ // Nothing is sent (see `hasDotSegment`). Reported after `subscribe()` has returned, as every
604
+ // other failure is, so a handler can use the subscription it was given.
605
+ await Promise.resolve();
606
+ if (closed)
607
+ return;
608
+ callOptions?.onError?.(new Error("invalid_path"));
609
+ close();
610
+ return;
611
+ }
402
612
  let attempt = 0;
403
613
  while (!closed) {
404
614
  try {
@@ -406,14 +616,17 @@ function subscribeSse(base, path, onEvent, callOptions, options) {
406
616
  ...options.headers,
407
617
  ...callOptions?.headers,
408
618
  accept: "text/event-stream",
409
- ...(lastEventId !== undefined ? { "last-event-id": lastEventId } : {}),
619
+ ...(lastEventId ? { "last-event-id": lastEventIdHeader(lastEventId) } : {}),
410
620
  };
411
621
  const response = await doFetch(url, { headers, signal: controller.signal });
412
622
  if (!response.ok || response.body === null) {
623
+ void response.body?.cancel().catch(() => { });
413
624
  throw new Error(`sse_http_${response.status}`);
414
625
  }
415
626
  attempt = 0; // a successful connect resets the backoff
416
627
  await readSseStream(response.body, (frame) => {
628
+ if (closed)
629
+ return;
417
630
  if (frame.id !== undefined)
418
631
  lastEventId = frame.id;
419
632
  if (frame.retry !== undefined)
@@ -426,7 +639,7 @@ function subscribeSse(base, path, onEvent, callOptions, options) {
426
639
  callOptions?.onError?.(error);
427
640
  }
428
641
  }
429
- });
642
+ }, maxEventLength);
430
643
  // Clean server-side end: a finite stream (`reconnect: false`) completes here.
431
644
  if (!reconnectEnabled) {
432
645
  close();
@@ -445,9 +658,17 @@ function subscribeSse(base, path, onEvent, callOptions, options) {
445
658
  if (closed)
446
659
  return;
447
660
  const backoff = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
661
+ const jittered = backoff / 2 + Math.random() * (backoff / 2);
662
+ // The server's `retry:` times the reconnect after a stream it served (`attempt` is 0 only then),
663
+ // and is only a floor while reconnects keep failing: a hint of 0 must not become a request loop
664
+ // against a server that has started answering 503.
665
+ const wait = serverRetryMs === undefined
666
+ ? jittered
667
+ : attempt === 0
668
+ ? serverRetryMs
669
+ : Math.max(serverRetryMs, jittered);
448
670
  attempt = Math.min(attempt + 1, 10);
449
- const wait = serverRetryMs ?? backoff / 2 + Math.random() * (backoff / 2);
450
- await delay(wait);
671
+ await delay(wait, controller.signal, true);
451
672
  }
452
673
  })();
453
674
  return { close };
@@ -541,11 +762,11 @@ async function parseBody(response, options) {
541
762
  // the transport path, so an app that set it there keeps the number it chose.
542
763
  const decodedBytes = options.transport?.maxBytes ?? options.maxDecodedBytes;
543
764
  const bound = decodedBytes === undefined ? {} : { maxBytes: decodedBytes };
544
- // In-process responses (the marked fetcher) hold their body as same-process memory the app
765
+ // In-process responses (the marked transport) hold their body as same-process memory the app
545
766
  // already allocated, so the streaming byte-cap reader protects nothing there - the native read
546
767
  // is ~23x cheaper and the SAME cap is enforced on the result (identical error). A network
547
- // fetcher is never marked and keeps the bounded-while-streaming read.
548
- const local = options.fetch?.[LOCAL_FETCH] === true;
768
+ // client is never marked and keeps the bounded-while-streaming read.
769
+ const local = options[LOCAL_FETCH] === true;
549
770
  if (contentType.startsWith("application/json") ||
550
771
  contentType.startsWith("application/vnd.nifra.")) {
551
772
  if (local) {