@nifrajs/client 3.5.0 → 4.0.2
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 +11 -2
- package/dist/client.d.ts +17 -4
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +288 -67
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/routes.d.ts +66 -4
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js.map +1 -1
- package/dist/sendable-path.d.ts +2 -0
- package/dist/sendable-path.d.ts.map +1 -0
- package/dist/sendable-path.js +14 -0
- package/dist/sendable-path.js.map +1 -0
- package/dist/treaty.d.ts +35 -5
- package/dist/treaty.d.ts.map +1 -1
- package/dist/validate-responses.d.ts +4 -2
- package/dist/validate-responses.d.ts.map +1 -1
- package/dist/validate-responses.js +2 -2
- package/dist/validate-responses.js.map +1 -1
- package/dist/ws.d.ts +0 -1
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +32 -6
- package/dist/ws.js.map +1 -1
- package/package.json +5 -2
- package/src/client.ts +352 -83
- package/src/index.ts +15 -1
- package/src/routes.ts +78 -4
- package/src/sendable-path.ts +14 -0
- package/src/treaty.ts +77 -15
- package/src/validate-responses.ts +11 -4
- package/src/ws.ts +35 -6
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 {
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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`.
|
|
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
|
|
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
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
460
|
-
|
|
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,
|
|
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
|
-
|
|
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")
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
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
|
-
|
|
545
|
-
if (
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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.")
|