@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 +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/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
|
-
/**
|
|
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`.
|
|
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
|
|
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
|
package/dist/client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAA;AACxE,OAAO,
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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`.
|
|
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
|
|
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
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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 &&
|
|
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 (
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
327
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
351
|
-
if (
|
|
352
|
-
handleLine(
|
|
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
|
|
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
|
-
|
|
394
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
548
|
-
const local = options
|
|
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) {
|