arthron 0.1.0 → 1.0.1
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 +125 -48
- package/dist/index.cjs +752 -268
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +308 -83
- package/dist/index.d.mts +308 -83
- package/dist/index.mjs +752 -269
- package/dist/index.mjs.map +1 -1
- package/llms.txt +46 -30
- package/package.json +4 -2
package/dist/index.cjs
CHANGED
|
@@ -1,164 +1,228 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
//#region src/
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
2
|
+
//#region src/request/url.ts
|
|
3
|
+
const ABSOLUTE_URL = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
4
|
+
const HTTP_URL = /^https?:\/\//i;
|
|
5
|
+
function isAbsoluteUrl(path) {
|
|
6
|
+
return ABSOLUTE_URL.test(path);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Origin + pathname only — the query and userinfo tend to carry secrets.
|
|
10
|
+
* For anything printed: log lines, error messages, `ArthronError.toJSON`.
|
|
11
|
+
*/
|
|
12
|
+
function redactUrl(url) {
|
|
13
|
+
try {
|
|
14
|
+
const parsed = new URL(url);
|
|
15
|
+
return `${parsed.origin}${parsed.pathname}`;
|
|
16
|
+
} catch {
|
|
17
|
+
return url.split(/[?#]/)[0] ?? url;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* `undefined` when `url` has no origin of its own. A relative baseURL takes
|
|
22
|
+
* the page's origin in a browser and has none elsewhere.
|
|
23
|
+
*/
|
|
24
|
+
function originOf(url) {
|
|
25
|
+
const page = globalThis.location?.href;
|
|
26
|
+
try {
|
|
27
|
+
return new URL(url, page).origin;
|
|
28
|
+
} catch {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** `path` is absolute and points away from `baseURL`'s origin. */
|
|
33
|
+
function leavesBase(baseURL, path) {
|
|
34
|
+
if (baseURL === void 0 || !isAbsoluteUrl(path)) return false;
|
|
35
|
+
const base = originOf(baseURL);
|
|
36
|
+
return base === void 0 || originOf(path) !== base;
|
|
37
|
+
}
|
|
38
|
+
function toQueryString(params) {
|
|
39
|
+
if (!params) return "";
|
|
40
|
+
if (Object.prototype.toString.call(params) === "[object URLSearchParams]") return params.toString();
|
|
41
|
+
const search = new URLSearchParams();
|
|
42
|
+
for (const [key, value] of Object.entries(params)) for (const item of Array.isArray(value) ? value : [value]) {
|
|
43
|
+
if (item === null || item === void 0) continue;
|
|
44
|
+
if (typeof item === "object" || typeof item === "function") throw new TypeError(`arthron: params.${key} is not a string, number or boolean — pass one of those or an array of them (dates: date.toISOString())`);
|
|
45
|
+
search.append(key, String(item));
|
|
46
|
+
}
|
|
47
|
+
return search.toString();
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Fill `{name}` placeholders with encoded values, so each one stays inside
|
|
51
|
+
* its segment: "/", "?" and "#" are encoded, and the values WHATWG URL would
|
|
52
|
+
* turn into a different path — "", "." and ".." — are refused. A placeholder
|
|
53
|
+
* without a value, or a value without a placeholder, is a mistake too.
|
|
54
|
+
*/
|
|
55
|
+
function fillPath(template, values) {
|
|
56
|
+
const used = /* @__PURE__ */ new Set();
|
|
57
|
+
const filled = template.replace(/\{([^{}/]+)\}/g, (_, name) => {
|
|
58
|
+
if (!Object.hasOwn(values, name)) throw new TypeError(`arthron: path "${template}" has {${name}} but no value for it — pass path: { ${name}: … }`);
|
|
59
|
+
used.add(name);
|
|
60
|
+
const value = String(values[name]);
|
|
61
|
+
if (value === "" || value === "." || value === "..") throw new TypeError(`arthron: path value ${name}=${JSON.stringify(value)} would change the path, not fill a segment — pass a real id`);
|
|
62
|
+
return encodeURIComponent(value);
|
|
17
63
|
});
|
|
64
|
+
const unused = Object.keys(values).find((name) => !used.has(name));
|
|
65
|
+
if (unused !== void 0) throw new TypeError(`arthron: path "${template}" has no {${unused}} — add the placeholder or drop the value`);
|
|
66
|
+
return filled;
|
|
18
67
|
}
|
|
19
68
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
69
|
+
* Join `baseURL` and `path` by plain string concatenation so a path prefix in
|
|
70
|
+
* the base (`/v1`) survives a leading slash in the path. WHATWG `new URL()`
|
|
71
|
+
* would drop it. An absolute path replaces the base only when it stays on the
|
|
72
|
+
* base's origin, or when `allowAbsoluteUrls` says other origins are fine — a
|
|
73
|
+
* path built from user input or an API response must not carry the client's
|
|
74
|
+
* credentials somewhere else.
|
|
24
75
|
*/
|
|
25
|
-
function
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
headers
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
76
|
+
function resolveUrl(baseURL, path, params, allowAbsoluteUrls = false) {
|
|
77
|
+
let url;
|
|
78
|
+
if (isAbsoluteUrl(path)) {
|
|
79
|
+
if (!HTTP_URL.test(path)) {
|
|
80
|
+
const scheme = path.slice(0, path.indexOf(":") + 1);
|
|
81
|
+
throw new TypeError(`arthron: only http(s) URLs can be requested, got a "${scheme}" URL — use an http:// or https:// URL`);
|
|
82
|
+
}
|
|
83
|
+
if (!allowAbsoluteUrls && leavesBase(baseURL, path)) throw new TypeError(`arthron: ${originOf(path)} is outside baseURL ${(baseURL && originOf(baseURL)) ?? baseURL} — pass a path relative to baseURL, or set arthron({ allowAbsoluteUrls: true }) to allow other origins (config headers are not sent to them)`);
|
|
84
|
+
url = path;
|
|
85
|
+
} else {
|
|
86
|
+
if (baseURL === void 0) throw new TypeError(`arthron: relative path "${redactUrl(path)}" needs a baseURL — pass baseURL in arthron({ baseURL }) or use an absolute URL`);
|
|
87
|
+
url = `${baseURL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
|
|
88
|
+
}
|
|
89
|
+
const query = toQueryString(params);
|
|
90
|
+
if (!query) return url;
|
|
91
|
+
const hash = url.indexOf("#");
|
|
92
|
+
const [base, fragment] = hash === -1 ? [url, ""] : [url.slice(0, hash), url.slice(hash)];
|
|
93
|
+
return `${base}${base.includes("?") ? "&" : "?"}${query}${fragment}`;
|
|
42
94
|
}
|
|
43
95
|
//#endregion
|
|
44
|
-
//#region src/error.ts
|
|
96
|
+
//#region src/error/arthron-error.ts
|
|
97
|
+
/**
|
|
98
|
+
* Marks an ArthronError across copies of this package. The ESM and CJS
|
|
99
|
+
* builds can both end up loaded (a CJS dependency creates the client, ESM
|
|
100
|
+
* app code catches), each with its own class — `Symbol.for` is shared.
|
|
101
|
+
*/
|
|
102
|
+
const BRAND = Symbol.for("arthron.ArthronError");
|
|
45
103
|
/**
|
|
46
104
|
* The only error arthron itself produces. Two sources: a response with
|
|
47
105
|
* `ok === false`, or fetch rejecting outright (network failure, `status` 0).
|
|
48
106
|
* Everything else — cancellation, interceptor bugs — is rethrown untouched.
|
|
49
107
|
*/
|
|
50
|
-
var ArthronError = class extends Error {
|
|
108
|
+
var ArthronError = class ArthronError extends Error {
|
|
51
109
|
name = "ArthronError";
|
|
52
110
|
/** HTTP status of the failed response; `0` when fetch itself failed (network). */
|
|
53
111
|
status;
|
|
54
112
|
statusText;
|
|
55
|
-
/** Response headers; empty for network failures. */
|
|
56
|
-
headers;
|
|
57
113
|
/** Parsed body: JSON if it parses, else the text, else `undefined`. */
|
|
58
114
|
data;
|
|
59
|
-
/**
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
115
|
+
/**
|
|
116
|
+
* The server's own explanation, picked out of `data` (by default
|
|
117
|
+
* `message` → `error` → `error.message` → `detail`; see the `errorDetail`
|
|
118
|
+
* config). `undefined` when the body had none. Untrusted server text: show
|
|
119
|
+
* it to users as text, never as HTML. `message`, by contrast, is written by
|
|
120
|
+
* arthron — method, URL without query, status — and is meant for logs.
|
|
121
|
+
*/
|
|
122
|
+
detail;
|
|
63
123
|
/** The underlying error for network failures. */
|
|
64
124
|
cause;
|
|
65
125
|
constructor(init) {
|
|
66
126
|
super(init.message);
|
|
67
127
|
this.status = init.status;
|
|
68
128
|
this.statusText = init.statusText;
|
|
69
|
-
this.headers = init.headers;
|
|
70
129
|
this.data = init.data;
|
|
71
|
-
this.
|
|
72
|
-
this.response = init.response;
|
|
130
|
+
this.detail = init.detail;
|
|
73
131
|
this.cause = init.cause;
|
|
132
|
+
Object.defineProperties(this, {
|
|
133
|
+
headers: { value: init.headers },
|
|
134
|
+
request: { value: init.request },
|
|
135
|
+
response: { value: init.response },
|
|
136
|
+
[BRAND]: { value: true }
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/** `instanceof ArthronError` holds for an error from any copy of the package. */
|
|
140
|
+
static [Symbol.hasInstance](value) {
|
|
141
|
+
return this === ArthronError ? isArthronError(value) : Function.prototype[Symbol.hasInstance].call(this, value);
|
|
142
|
+
}
|
|
143
|
+
/** What serializers see: no headers, no body, no query or userinfo. */
|
|
144
|
+
toJSON() {
|
|
145
|
+
return {
|
|
146
|
+
name: this.name,
|
|
147
|
+
message: this.message,
|
|
148
|
+
detail: this.detail,
|
|
149
|
+
status: this.status,
|
|
150
|
+
statusText: this.statusText,
|
|
151
|
+
method: this.request.init.method ?? "GET",
|
|
152
|
+
url: redactUrl(this.request.url),
|
|
153
|
+
data: this.data
|
|
154
|
+
};
|
|
74
155
|
}
|
|
75
156
|
};
|
|
76
157
|
/** `true` for errors arthron produced: a `!ok` response or a network failure. Cancellations and plugin bugs are not ArthronErrors. */
|
|
77
158
|
function isArthronError(error) {
|
|
78
|
-
return error
|
|
79
|
-
}
|
|
80
|
-
/** Cancellation is not an HTTP failure. Matched by name so it works across realms. */
|
|
81
|
-
function isCancellation(error) {
|
|
82
|
-
const name = error?.name;
|
|
83
|
-
return name === "AbortError" || name === "TimeoutError";
|
|
84
|
-
}
|
|
85
|
-
const isRecord = (value) => typeof value === "object" && value !== null;
|
|
86
|
-
/** `message` → `error` (string) → `error.message` → `detail`. */
|
|
87
|
-
function defaultErrorMessage(data) {
|
|
88
|
-
if (!isRecord(data)) return void 0;
|
|
89
|
-
if (typeof data.message === "string") return data.message;
|
|
90
|
-
if (typeof data.error === "string") return data.error;
|
|
91
|
-
if (isRecord(data.error) && typeof data.error.message === "string") return data.error.message;
|
|
92
|
-
if (typeof data.detail === "string") return data.detail;
|
|
93
|
-
}
|
|
94
|
-
/** Body as JSON if it parses, else as text, else `undefined` when empty. */
|
|
95
|
-
async function readBody(response) {
|
|
96
|
-
const text = await response.text().catch(() => "");
|
|
97
|
-
if (text === "") return void 0;
|
|
98
|
-
try {
|
|
99
|
-
return JSON.parse(text);
|
|
100
|
-
} catch {
|
|
101
|
-
return text;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
async function responseError(response, request, resolve = defaultErrorMessage) {
|
|
105
|
-
const data = await readBody(response.clone());
|
|
106
|
-
return new ArthronError({
|
|
107
|
-
message: resolve(data, response) ?? `Request failed with status ${response.status}`,
|
|
108
|
-
status: response.status,
|
|
109
|
-
statusText: response.statusText,
|
|
110
|
-
headers: response.headers,
|
|
111
|
-
data,
|
|
112
|
-
request,
|
|
113
|
-
response
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
function networkError(cause, request) {
|
|
117
|
-
return new ArthronError({
|
|
118
|
-
message: cause instanceof Error ? cause.message : "Network error",
|
|
119
|
-
status: 0,
|
|
120
|
-
statusText: "",
|
|
121
|
-
headers: new Headers(),
|
|
122
|
-
data: void 0,
|
|
123
|
-
request,
|
|
124
|
-
cause
|
|
125
|
-
});
|
|
159
|
+
return typeof error === "object" && error !== null && error[BRAND] === true;
|
|
126
160
|
}
|
|
127
161
|
//#endregion
|
|
128
|
-
//#region src/headers.ts
|
|
162
|
+
//#region src/request/headers.ts
|
|
163
|
+
const isPlainRecord = (headers) => !Array.isArray(headers) && Object.prototype.toString.call(headers) !== "[object Headers]";
|
|
129
164
|
/**
|
|
130
|
-
*
|
|
131
|
-
*
|
|
165
|
+
* Layer header sets left to right into one plain record. Names are compared
|
|
166
|
+
* case-insensitively and come back lowercased; a later layer wins, so a
|
|
167
|
+
* caller's `content-type` replaces a default `Content-Type` instead of fetch
|
|
168
|
+
* sending both values joined. `Headers` validates every value it sets.
|
|
132
169
|
*/
|
|
170
|
+
function mergeHeaders(...layers) {
|
|
171
|
+
const merged = new Headers();
|
|
172
|
+
for (const layer of layers) {
|
|
173
|
+
if (!layer) continue;
|
|
174
|
+
if (isPlainRecord(layer)) for (const [name, value] of Object.entries(layer)) if (value == null) merged.delete(name);
|
|
175
|
+
else merged.set(name, value);
|
|
176
|
+
else for (const [name, value] of new Headers(layer)) merged.set(name, value);
|
|
177
|
+
}
|
|
178
|
+
return Object.fromEntries(merged);
|
|
179
|
+
}
|
|
180
|
+
/** Normalize any header shape into a plain record with lowercased names. */
|
|
133
181
|
function toHeaderRecord(headers) {
|
|
134
|
-
|
|
135
|
-
|
|
182
|
+
return mergeHeaders(headers);
|
|
183
|
+
}
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/pipeline/pipeline.ts
|
|
186
|
+
/** The mutable body kinds an interceptor can `append` to; anything else is shared as is. */
|
|
187
|
+
function cloneBody(body) {
|
|
188
|
+
if (typeof FormData !== "undefined" && body instanceof FormData) {
|
|
189
|
+
const copy = new FormData();
|
|
190
|
+
for (const [name, value] of body) copy.append(name, value);
|
|
191
|
+
return copy;
|
|
192
|
+
}
|
|
193
|
+
if (body instanceof URLSearchParams) return new URLSearchParams(body);
|
|
194
|
+
return body;
|
|
136
195
|
}
|
|
137
196
|
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
197
|
+
* Interceptors mutate the context in place. A middleware that calls `next`
|
|
198
|
+
* again (retry) must not see the previous attempt's changes, so every attempt
|
|
199
|
+
* starts from a fresh copy of the headers and of a FormData/URLSearchParams body.
|
|
141
200
|
*/
|
|
142
|
-
function
|
|
201
|
+
function cloneContext({ url, init, meta }) {
|
|
202
|
+
const copy = {
|
|
203
|
+
...init,
|
|
204
|
+
headers: toHeaderRecord(init.headers)
|
|
205
|
+
};
|
|
206
|
+
if (init.body) copy.body = cloneBody(init.body);
|
|
143
207
|
return {
|
|
144
|
-
|
|
145
|
-
|
|
208
|
+
url,
|
|
209
|
+
init: copy,
|
|
210
|
+
meta: { ...meta }
|
|
146
211
|
};
|
|
147
212
|
}
|
|
148
|
-
//#endregion
|
|
149
|
-
//#region src/pipeline.ts
|
|
150
213
|
/**
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
214
|
+
* Put headers back into a plain lowercase record. A plugin written in JS (or
|
|
215
|
+
* with a cast) may hand back a `Headers` object, and the next plugin's
|
|
216
|
+
* `{ ...ctx.init.headers }` would otherwise drop every header.
|
|
154
217
|
*/
|
|
155
|
-
function
|
|
218
|
+
function normalize(ctx, previous) {
|
|
156
219
|
return {
|
|
157
|
-
|
|
220
|
+
...ctx,
|
|
158
221
|
init: {
|
|
159
|
-
...init,
|
|
160
|
-
headers: toHeaderRecord(init.headers)
|
|
161
|
-
}
|
|
222
|
+
...ctx.init,
|
|
223
|
+
headers: toHeaderRecord(ctx.init.headers)
|
|
224
|
+
},
|
|
225
|
+
meta: ctx.meta ?? previous.meta
|
|
162
226
|
};
|
|
163
227
|
}
|
|
164
228
|
/**
|
|
@@ -184,7 +248,7 @@ async function runPipeline(plugins, ctx, send) {
|
|
|
184
248
|
const attempt = async (input) => {
|
|
185
249
|
let current = cloneContext(input);
|
|
186
250
|
try {
|
|
187
|
-
for (const interceptor of request) current = await interceptor(current);
|
|
251
|
+
for (const interceptor of request) current = normalize(await interceptor(current), current);
|
|
188
252
|
let res = await send(current);
|
|
189
253
|
for (const interceptor of response) res = await interceptor(res, current);
|
|
190
254
|
return res;
|
|
@@ -198,29 +262,314 @@ async function runPipeline(plugins, ctx, send) {
|
|
|
198
262
|
const dispatch = (index, input) => {
|
|
199
263
|
const middleware = middlewares[index];
|
|
200
264
|
if (!middleware) return attempt(input);
|
|
201
|
-
return middleware(input, (next) => dispatch(index + 1,
|
|
265
|
+
return middleware(input, (next) => dispatch(index + 1, {
|
|
266
|
+
...next,
|
|
267
|
+
meta: next.meta ?? input.meta
|
|
268
|
+
}));
|
|
202
269
|
};
|
|
203
270
|
return dispatch(0, ctx);
|
|
204
271
|
}
|
|
205
272
|
//#endregion
|
|
206
|
-
//#region src/
|
|
207
|
-
|
|
208
|
-
|
|
273
|
+
//#region src/request/body.ts
|
|
274
|
+
const RAW_TAGS = /* @__PURE__ */ new Set([
|
|
275
|
+
"FormData",
|
|
276
|
+
"Blob",
|
|
277
|
+
"File",
|
|
278
|
+
"ArrayBuffer",
|
|
279
|
+
"URLSearchParams",
|
|
280
|
+
"ReadableStream"
|
|
281
|
+
]);
|
|
282
|
+
/**
|
|
283
|
+
* `Symbol.toStringTag`, not `instanceof`: a body from another realm or a
|
|
284
|
+
* polyfill (formdata-node, fetch-blob) must not be JSON-encoded into `{}`.
|
|
285
|
+
*/
|
|
286
|
+
const tagOf = (value) => Object.prototype.toString.call(value).slice(8, -1);
|
|
287
|
+
function isReadableStream(value) {
|
|
288
|
+
return tagOf(value) === "ReadableStream";
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Whether `RequestCtor` can send a ReadableStream body. Node and Chromium
|
|
292
|
+
* read `duplex` and leave the stream alone; Firefox ignores `duplex` and
|
|
293
|
+
* would send the text "[object ReadableStream]"; older Safari refuses it.
|
|
294
|
+
* The check from
|
|
295
|
+
* https://developer.chrome.com/docs/capabilities/web-apis/fetch-streaming-requests
|
|
296
|
+
*/
|
|
297
|
+
function canStreamRequestBody(RequestCtor) {
|
|
298
|
+
let duplexRead = false;
|
|
299
|
+
try {
|
|
300
|
+
const request = new RequestCtor("https://stream.test", {
|
|
301
|
+
method: "POST",
|
|
302
|
+
body: new ReadableStream(),
|
|
303
|
+
get duplex() {
|
|
304
|
+
duplexRead = true;
|
|
305
|
+
return "half";
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
return duplexRead && !request.headers.has("content-type");
|
|
309
|
+
} catch {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
let runtimeStreams;
|
|
314
|
+
/** `canStreamRequestBody` for the global `Request`, checked once. */
|
|
315
|
+
function runtimeStreamsRequestBody() {
|
|
316
|
+
runtimeStreams ??= typeof Request !== "function" || canStreamRequestBody(Request);
|
|
317
|
+
return runtimeStreams;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* What JSON.stringify would turn into `{}`, `undefined` or internal state
|
|
321
|
+
* instead of the data — with the fix, so it reads as an error message.
|
|
322
|
+
*/
|
|
323
|
+
function mangledByJson(body) {
|
|
324
|
+
if (typeof body === "function" || typeof body === "symbol") return `a ${typeof body}, which JSON cannot carry — send its data instead`;
|
|
325
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) return;
|
|
326
|
+
if (Symbol.asyncIterator in body) return "an async iterable (a Node stream?) — pass a ReadableStream (Readable.toWeb(stream)) or collect it first";
|
|
327
|
+
if (Symbol.iterator in body) return "an iterable such as a Map, Set or generator — convert it to an array or plain object first";
|
|
328
|
+
}
|
|
329
|
+
/** Bodies fetch already knows how to send. Content-Type is left to the caller / runtime. */
|
|
330
|
+
function isRawBody(body) {
|
|
331
|
+
if (typeof body === "string") return true;
|
|
332
|
+
if (ArrayBuffer.isView(body)) return true;
|
|
333
|
+
return RAW_TAGS.has(tagOf(body));
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Turn a caller-supplied body into something fetch accepts. Raw bodies pass
|
|
337
|
+
* through untouched; anything else is JSON with `content-type` set — unless
|
|
338
|
+
* the caller already set one (headers arrive lowercased, so a plain lookup is
|
|
339
|
+
* enough).
|
|
340
|
+
*/
|
|
341
|
+
function serializeBody(body, headers) {
|
|
342
|
+
if (body === void 0) return {
|
|
343
|
+
body: void 0,
|
|
344
|
+
headers
|
|
345
|
+
};
|
|
346
|
+
if (tagOf(body) === "FormData") {
|
|
347
|
+
const { "content-type": _dropped, ...rest } = headers;
|
|
348
|
+
return {
|
|
349
|
+
body,
|
|
350
|
+
headers: rest
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
if (isRawBody(body)) return {
|
|
354
|
+
body,
|
|
355
|
+
headers
|
|
356
|
+
};
|
|
357
|
+
const mangled = mangledByJson(body);
|
|
358
|
+
if (mangled) throw new TypeError(`arthron: the body is ${mangled}`);
|
|
359
|
+
const type = headers["content-type"];
|
|
360
|
+
if (type !== void 0 && !/\bjson\b/i.test(type)) throw new TypeError(`arthron: a ${typeof body} body is sent as JSON, but content-type is "${type}" — pass a string, URLSearchParams or FormData for that content type, or drop the header`);
|
|
361
|
+
const withType = "content-type" in headers ? headers : {
|
|
362
|
+
...headers,
|
|
363
|
+
"content-type": "application/json"
|
|
364
|
+
};
|
|
365
|
+
return {
|
|
366
|
+
body: JSON.stringify(body),
|
|
367
|
+
headers: withType
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
//#endregion
|
|
371
|
+
//#region src/response/rebuild-response.ts
|
|
372
|
+
/**
|
|
373
|
+
* A new Response with `body` but everything else taken from `original`. The
|
|
374
|
+
* constructor resets `url`, `redirected` and `type`, so those are copied over
|
|
375
|
+
* as own properties — callers that resolve relative links or detect
|
|
376
|
+
* redirects keep working.
|
|
377
|
+
*/
|
|
378
|
+
function rebuildResponse(original, body) {
|
|
379
|
+
return withFetchedFields(new Response(body, {
|
|
380
|
+
status: original.status,
|
|
381
|
+
statusText: original.statusText,
|
|
382
|
+
headers: original.headers
|
|
383
|
+
}), original);
|
|
384
|
+
}
|
|
385
|
+
/** Copy the fields the constructor resets — onto `clone()`s as well. */
|
|
386
|
+
function withFetchedFields(target, from) {
|
|
387
|
+
const clone = target.clone.bind(target);
|
|
388
|
+
return Object.defineProperties(target, {
|
|
389
|
+
url: { value: from.url },
|
|
390
|
+
redirected: { value: from.redirected },
|
|
391
|
+
type: { value: from.type },
|
|
392
|
+
clone: { value: () => withFetchedFields(clone(), from) }
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
//#endregion
|
|
396
|
+
//#region src/error/create-error.ts
|
|
397
|
+
/**
|
|
398
|
+
* Cancellation is not an HTTP failure. Matched by name so it works across
|
|
399
|
+
* realms — or by the request's own `signal`, since `abort(reason)` rejects
|
|
400
|
+
* with whatever reason the caller chose.
|
|
401
|
+
*/
|
|
402
|
+
function isCancellation(error, signal) {
|
|
403
|
+
if (signal?.aborted) return true;
|
|
404
|
+
const name = error?.name;
|
|
405
|
+
return name === "AbortError" || name === "TimeoutError";
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* What a cancelled call rejects with: its signal's reason. WebKit's fetch
|
|
409
|
+
* rejects, and errors a half-read body, with a generic AbortError instead,
|
|
410
|
+
* dropping a TimeoutError or the caller's own reason.
|
|
411
|
+
*/
|
|
412
|
+
function cancellationError(error, signal) {
|
|
413
|
+
return signal?.aborted ? signal.reason : error;
|
|
414
|
+
}
|
|
415
|
+
const isRecord = (value) => typeof value === "object" && value !== null;
|
|
416
|
+
/** `message` → `error` (string) → `error.message` → `detail`. */
|
|
417
|
+
function defaultErrorDetail(data) {
|
|
418
|
+
if (!isRecord(data)) return void 0;
|
|
419
|
+
if (typeof data.message === "string") return data.message;
|
|
420
|
+
if (typeof data.error === "string") return data.error;
|
|
421
|
+
if (isRecord(data.error) && typeof data.error.message === "string") return data.error.message;
|
|
422
|
+
if (typeof data.detail === "string") return data.detail;
|
|
423
|
+
}
|
|
424
|
+
/** How much of an error body arthron reads. Error bodies are for diagnosis; a hostile or broken server must not be able to fill memory with one. */
|
|
425
|
+
const MAX_ERROR_BODY_BYTES = 65536;
|
|
426
|
+
/** Up to `limit` bytes of the body; the rest is cancelled unread. `undefined` when there is no body or reading it failed. */
|
|
427
|
+
async function readCapped(response, limit) {
|
|
428
|
+
if (!response.body) return void 0;
|
|
429
|
+
let reader;
|
|
430
|
+
const chunks = [];
|
|
431
|
+
let size = 0;
|
|
432
|
+
try {
|
|
433
|
+
reader = response.body.getReader();
|
|
434
|
+
while (size <= limit) {
|
|
435
|
+
const { value, done } = await reader.read();
|
|
436
|
+
if (done) break;
|
|
437
|
+
chunks.push(value);
|
|
438
|
+
size += value.byteLength;
|
|
439
|
+
}
|
|
440
|
+
} catch {
|
|
441
|
+
return;
|
|
442
|
+
} finally {
|
|
443
|
+
await reader?.cancel().catch(() => {});
|
|
444
|
+
reader?.releaseLock();
|
|
445
|
+
}
|
|
446
|
+
const bytes = new Uint8Array(size);
|
|
447
|
+
let offset = 0;
|
|
448
|
+
for (const chunk of chunks) {
|
|
449
|
+
bytes.set(chunk, offset);
|
|
450
|
+
offset += chunk.byteLength;
|
|
451
|
+
}
|
|
452
|
+
return size > limit ? {
|
|
453
|
+
bytes: bytes.subarray(0, limit),
|
|
454
|
+
truncated: true
|
|
455
|
+
} : {
|
|
456
|
+
bytes,
|
|
457
|
+
truncated: false
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
/** JSON if the whole body parses, else the text (cut at the limit), else `undefined` when empty. */
|
|
461
|
+
function parseBody(read) {
|
|
462
|
+
const text = new TextDecoder().decode(read.bytes);
|
|
463
|
+
if (text === "") return void 0;
|
|
464
|
+
if (read.truncated) return text;
|
|
465
|
+
try {
|
|
466
|
+
return JSON.parse(text);
|
|
467
|
+
} catch {
|
|
468
|
+
return text;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
/** The request as error messages name it: method and URL, no query or userinfo. */
|
|
472
|
+
function describeRequest(request) {
|
|
473
|
+
return `${request.init.method ?? "GET"} ${redactUrl(request.url)}`;
|
|
474
|
+
}
|
|
475
|
+
async function responseError(response, request, resolve = defaultErrorDetail) {
|
|
476
|
+
const read = await readCapped(response, MAX_ERROR_BODY_BYTES);
|
|
477
|
+
const data = read && parseBody(read);
|
|
478
|
+
if (read) response = rebuildResponse(response, read.bytes);
|
|
479
|
+
const status = response.statusText ? `${response.status} ${response.statusText}` : String(response.status);
|
|
480
|
+
return new ArthronError({
|
|
481
|
+
message: `${describeRequest(request)} failed with ${status}`,
|
|
482
|
+
detail: resolve(data, response) || void 0,
|
|
483
|
+
status: response.status,
|
|
484
|
+
statusText: response.statusText,
|
|
485
|
+
headers: response.headers,
|
|
486
|
+
data,
|
|
487
|
+
request,
|
|
488
|
+
response
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Browsers that stream a request body (Chromium, current WebKit) do it only
|
|
493
|
+
* over HTTP/2, and fail one sent to an HTTP/1.1 server with the same bare
|
|
494
|
+
* "Failed to fetch" / "Load failed" as a dead server, so name the likely cause.
|
|
495
|
+
*/
|
|
496
|
+
const STREAM_BODY_HINT = " — in a browser, a ReadableStream body streams only over HTTP/2; send a Blob or ArrayBuffer to an HTTP/1.1 server";
|
|
497
|
+
function networkError(cause, request) {
|
|
498
|
+
const reason = cause instanceof Error && cause.message ? cause.message : "network error";
|
|
499
|
+
const hint = isReadableStream(request.init.body) ? STREAM_BODY_HINT : "";
|
|
500
|
+
return new ArthronError({
|
|
501
|
+
message: `${describeRequest(request)} failed: ${reason}${hint}`,
|
|
502
|
+
status: 0,
|
|
503
|
+
statusText: "",
|
|
504
|
+
headers: new Headers(),
|
|
505
|
+
data: void 0,
|
|
506
|
+
request,
|
|
507
|
+
cause
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
//#endregion
|
|
511
|
+
//#region src/response/schema-validation.ts
|
|
512
|
+
const pathOf = (issue) => (issue.path ?? []).map((part) => String(typeof part === "object" && part !== null ? part.key : part)).join(".");
|
|
513
|
+
/**
|
|
514
|
+
* `.json(schema)` got a body that does not match. Not an ArthronError: the
|
|
515
|
+
* request succeeded — the data is what is wrong.
|
|
516
|
+
*/
|
|
517
|
+
var SchemaError = class extends Error {
|
|
518
|
+
name = "SchemaError";
|
|
519
|
+
/** Every problem the validator reported, with the path to it. */
|
|
520
|
+
issues;
|
|
521
|
+
constructor(issues) {
|
|
522
|
+
const [first] = issues;
|
|
523
|
+
const where = first ? pathOf(first) : "";
|
|
524
|
+
const more = issues.length > 1 ? ` (+${issues.length - 1} more)` : "";
|
|
525
|
+
super(`arthron: the response body does not match the schema — ${where ? `${where}: ` : ""}${first?.message ?? "invalid"}${more}. See error.issues`);
|
|
526
|
+
this.issues = issues;
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
/** Validate `data`; resolve with the schema's output or reject with a SchemaError. */
|
|
530
|
+
async function validate(schema, data) {
|
|
531
|
+
const result = await schema["~standard"].validate(data);
|
|
532
|
+
if (result.issues) throw new SchemaError(result.issues);
|
|
533
|
+
return result.value;
|
|
534
|
+
}
|
|
535
|
+
//#endregion
|
|
536
|
+
//#region src/response/stream-parser.ts
|
|
537
|
+
const DEFAULT_LIMIT = 8388608;
|
|
538
|
+
/**
|
|
539
|
+
* Yield the body line by line, reassembling lines split across chunks. Only
|
|
540
|
+
* the newly arrived text is scanned, so a long line costs linear time; a line
|
|
541
|
+
* past `maxLineLength` stops the stream instead of growing without bound.
|
|
542
|
+
*/
|
|
543
|
+
async function* lines(response, maxLineLength) {
|
|
209
544
|
if (!response.body) return;
|
|
210
545
|
const reader = response.body.getReader();
|
|
211
546
|
const decoder = new TextDecoder();
|
|
212
|
-
|
|
547
|
+
const breaks = /\r\n|\r|\n/g;
|
|
548
|
+
let pending = "";
|
|
549
|
+
let afterCR = false;
|
|
213
550
|
try {
|
|
214
551
|
while (true) {
|
|
215
552
|
const { value, done } = await reader.read();
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
553
|
+
let text = decoder.decode(value, { stream: !done });
|
|
554
|
+
if (afterCR && text.startsWith("\n")) {
|
|
555
|
+
text = text.slice(1);
|
|
556
|
+
afterCR = false;
|
|
557
|
+
}
|
|
558
|
+
if (text) afterCR = text.endsWith("\r");
|
|
559
|
+
let start = 0;
|
|
560
|
+
breaks.lastIndex = 0;
|
|
561
|
+
for (let m = breaks.exec(text); m; m = breaks.exec(text)) {
|
|
562
|
+
yield pending + text.slice(start, m.index);
|
|
563
|
+
pending = "";
|
|
564
|
+
start = breaks.lastIndex;
|
|
565
|
+
}
|
|
566
|
+
pending += text.slice(start);
|
|
567
|
+
if (pending.length > maxLineLength) throw new RangeError(`arthron: a stream line is longer than ${maxLineLength} characters — pass a larger maxLineLength to .sse() / .ndjson() if that is expected`);
|
|
220
568
|
if (done) break;
|
|
221
569
|
}
|
|
222
|
-
if (
|
|
570
|
+
if (pending) yield pending;
|
|
223
571
|
} finally {
|
|
572
|
+
await reader.cancel().catch(() => {});
|
|
224
573
|
reader.releaseLock();
|
|
225
574
|
}
|
|
226
575
|
}
|
|
@@ -228,28 +577,31 @@ async function* lines(response) {
|
|
|
228
577
|
* Parse a `text/event-stream` body into events. Follows the spec: a blank
|
|
229
578
|
* line dispatches, `data:` lines are joined with `\n`, one leading space
|
|
230
579
|
* after the colon is dropped, lines starting with `:` are comments, and a
|
|
231
|
-
* block without `data` is not dispatched.
|
|
580
|
+
* block without `data` — or one the stream ends inside — is not dispatched.
|
|
581
|
+
* Provider sentinels like
|
|
232
582
|
* `[DONE]` are passed through untouched — they are not part of the spec.
|
|
233
583
|
*/
|
|
234
|
-
async function* parseSse(response) {
|
|
584
|
+
async function* parseSse(response, { maxLineLength = DEFAULT_LIMIT, maxEventSize = DEFAULT_LIMIT } = {}) {
|
|
235
585
|
let data = [];
|
|
586
|
+
let size = 0;
|
|
236
587
|
let event;
|
|
237
588
|
let id;
|
|
589
|
+
let lastEventId = "";
|
|
238
590
|
let retry;
|
|
239
591
|
const flush = () => {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
if (
|
|
246
|
-
if (
|
|
247
|
-
if (retry !== void 0) out.retry = retry;
|
|
592
|
+
const out = data.length === 0 ? void 0 : {
|
|
593
|
+
data: data.join("\n"),
|
|
594
|
+
lastEventId
|
|
595
|
+
};
|
|
596
|
+
if (out && event !== void 0) out.event = event;
|
|
597
|
+
if (out && id !== void 0) out.id = id;
|
|
598
|
+
if (out && retry !== void 0) out.retry = retry;
|
|
248
599
|
data = [];
|
|
249
|
-
|
|
600
|
+
size = 0;
|
|
601
|
+
event = id = void 0;
|
|
250
602
|
return out;
|
|
251
603
|
};
|
|
252
|
-
for await (const line of lines(response)) {
|
|
604
|
+
for await (const line of lines(response, maxLineLength)) {
|
|
253
605
|
if (line === "") {
|
|
254
606
|
const out = flush();
|
|
255
607
|
if (out) yield out;
|
|
@@ -262,137 +614,216 @@ async function* parseSse(response) {
|
|
|
262
614
|
if (value.startsWith(" ")) value = value.slice(1);
|
|
263
615
|
switch (field) {
|
|
264
616
|
case "data":
|
|
617
|
+
size += value.length + 1;
|
|
618
|
+
if (size > maxEventSize + 1) throw new RangeError(`arthron: an SSE event's data is longer than ${maxEventSize} characters — pass a larger maxEventSize to .sse() if that is expected`);
|
|
265
619
|
data.push(value);
|
|
266
620
|
break;
|
|
267
621
|
case "event":
|
|
268
622
|
event = value;
|
|
269
623
|
break;
|
|
270
624
|
case "id":
|
|
271
|
-
id = value;
|
|
272
|
-
break;
|
|
273
|
-
case "retry": {
|
|
274
|
-
const ms = Number(value);
|
|
275
|
-
if (Number.isInteger(ms) && ms >= 0) retry = ms;
|
|
625
|
+
if (!value.includes("\0")) id = lastEventId = value;
|
|
276
626
|
break;
|
|
277
|
-
|
|
627
|
+
case "retry": if (/^\d+$/.test(value)) retry = Number(value);
|
|
278
628
|
}
|
|
279
629
|
}
|
|
280
|
-
const out = flush();
|
|
281
|
-
if (out) yield out;
|
|
282
630
|
}
|
|
283
631
|
/**
|
|
284
632
|
* Parse newline-delimited JSON. Blank lines are skipped; a malformed line
|
|
285
633
|
* rejects the iteration with the `SyntaxError` from `JSON.parse`.
|
|
286
634
|
*/
|
|
287
|
-
async function* parseNdjson(response) {
|
|
288
|
-
for await (const line of lines(response)) {
|
|
635
|
+
async function* parseNdjson(response, { maxLineLength = DEFAULT_LIMIT } = {}) {
|
|
636
|
+
for await (const line of lines(response, maxLineLength)) {
|
|
289
637
|
if (line.trim() === "") continue;
|
|
290
638
|
yield JSON.parse(line);
|
|
291
639
|
}
|
|
292
640
|
}
|
|
293
641
|
//#endregion
|
|
294
|
-
//#region src/promise.ts
|
|
642
|
+
//#region src/response/arthron-promise.ts
|
|
295
643
|
/** Defer the Response until iteration starts, so the helper itself never rejects. */
|
|
296
|
-
function iterate(promise, parse) {
|
|
644
|
+
function iterate(promise, parse, signal) {
|
|
645
|
+
promise.catch(() => {});
|
|
297
646
|
return { async *[Symbol.asyncIterator]() {
|
|
298
|
-
|
|
647
|
+
try {
|
|
648
|
+
yield* parse(await promise);
|
|
649
|
+
} catch (error) {
|
|
650
|
+
throw cancellationError(error, signal);
|
|
651
|
+
}
|
|
299
652
|
} };
|
|
300
653
|
}
|
|
301
654
|
/**
|
|
302
655
|
* Sugar over `Promise<Response>`: `.json()` is `(await p).json()`. Awaiting
|
|
303
656
|
* without a helper still yields the untouched `Response`.
|
|
304
657
|
*/
|
|
305
|
-
function toArthronPromise(promise) {
|
|
658
|
+
function toArthronPromise(promise, signal) {
|
|
659
|
+
const read = (body) => promise.then(body).catch((error) => {
|
|
660
|
+
throw cancellationError(error, signal);
|
|
661
|
+
});
|
|
306
662
|
return Object.assign(promise, {
|
|
307
|
-
json: () =>
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
663
|
+
json: ((schema) => read(async (res) => {
|
|
664
|
+
const data = await res.json();
|
|
665
|
+
return schema ? validate(schema, data) : data;
|
|
666
|
+
})),
|
|
667
|
+
text: () => read((res) => res.text()),
|
|
668
|
+
blob: () => read((res) => res.blob()),
|
|
669
|
+
arrayBuffer: () => read((res) => res.arrayBuffer()),
|
|
670
|
+
formData: () => read((res) => res.formData()),
|
|
671
|
+
sse: (options) => iterate(promise, (res) => parseSse(res, options), signal),
|
|
672
|
+
ndjson: (options) => iterate(promise, (res) => parseNdjson(res, options), signal)
|
|
314
673
|
});
|
|
315
674
|
}
|
|
316
675
|
//#endregion
|
|
317
|
-
//#region src/
|
|
318
|
-
const ABSOLUTE_URL = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
319
|
-
function isAbsoluteUrl(path) {
|
|
320
|
-
return ABSOLUTE_URL.test(path);
|
|
321
|
-
}
|
|
322
|
-
function toQueryString(params) {
|
|
323
|
-
if (!params) return "";
|
|
324
|
-
const search = new URLSearchParams();
|
|
325
|
-
for (const [key, value] of Object.entries(params)) {
|
|
326
|
-
if (value === null || value === void 0) continue;
|
|
327
|
-
search.append(key, String(value));
|
|
328
|
-
}
|
|
329
|
-
return search.toString();
|
|
330
|
-
}
|
|
676
|
+
//#region src/client/resolve-config.ts
|
|
331
677
|
/**
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
678
|
+
* Config keys that configure arthron itself; every other key is a fetch
|
|
679
|
+
* default. Typed so that a new config key fails to compile until it is
|
|
680
|
+
* listed here — otherwise it would be sent to fetch.
|
|
335
681
|
*/
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
682
|
+
const OWN_KEYS = {
|
|
683
|
+
baseURL: true,
|
|
684
|
+
allowAbsoluteUrls: true,
|
|
685
|
+
headers: true,
|
|
686
|
+
fetch: true,
|
|
687
|
+
use: true,
|
|
688
|
+
errorDetail: true,
|
|
689
|
+
throwOn: true
|
|
690
|
+
};
|
|
691
|
+
/** Fields that belong to one request, refused as client defaults. */
|
|
692
|
+
const PER_CALL_ONLY = [
|
|
693
|
+
"signal",
|
|
694
|
+
"body",
|
|
695
|
+
"method"
|
|
696
|
+
];
|
|
697
|
+
/**
|
|
698
|
+
* Add plugins to a list. A named plugin takes the place of the one already
|
|
699
|
+
* there with that name — same position, so a replaced `logging()` stays
|
|
700
|
+
* outermost and a second `retry()` does not multiply attempts. Unnamed
|
|
701
|
+
* plugins append.
|
|
702
|
+
*/
|
|
703
|
+
function addPlugins(base, added) {
|
|
704
|
+
const out = [...base];
|
|
705
|
+
for (const plugin of added) {
|
|
706
|
+
const at = plugin.name === void 0 ? -1 : out.findIndex((p) => p.name === plugin.name);
|
|
707
|
+
if (at === -1) out.push(plugin);
|
|
708
|
+
else out[at] = plugin;
|
|
342
709
|
}
|
|
343
|
-
|
|
344
|
-
if (!query) return url;
|
|
345
|
-
const hash = url.indexOf("#");
|
|
346
|
-
const [base, fragment] = hash === -1 ? [url, ""] : [url.slice(0, hash), url.slice(hash)];
|
|
347
|
-
return `${base}${base.includes("?") ? "&" : "?"}${query}${fragment}`;
|
|
710
|
+
return out;
|
|
348
711
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
712
|
+
/** The fetch defaults in a config: every set key that is not arthron's own. */
|
|
713
|
+
function fetchDefaults(config) {
|
|
714
|
+
return Object.fromEntries(Object.entries(config).filter(([key, value]) => value !== void 0 && !(key in OWN_KEYS)));
|
|
715
|
+
}
|
|
716
|
+
/** Normalize once so `extend` can merge headers and add plugins cheaply. */
|
|
353
717
|
function resolveConfig(config) {
|
|
718
|
+
for (const key of PER_CALL_ONLY) if (config[key] !== void 0) throw new TypeError(`arthron: ${key} cannot be a client default, it belongs to one request — pass it per call (api.get(path, { signal }), api.post(path, body), api.request(path, { method }))`);
|
|
354
719
|
const { headers, use, ...rest } = config;
|
|
355
720
|
return {
|
|
356
721
|
...rest,
|
|
357
722
|
headers: toHeaderRecord(headers),
|
|
358
|
-
use: use ?? []
|
|
723
|
+
use: addPlugins([], use ?? [])
|
|
359
724
|
};
|
|
360
725
|
}
|
|
726
|
+
//#endregion
|
|
727
|
+
//#region src/client/send.ts
|
|
728
|
+
/**
|
|
729
|
+
* The global fetch rejects a malformed URL or method with a TypeError that
|
|
730
|
+
* looks just like a network failure (status 0, retried). A bodiless `Request`
|
|
731
|
+
* runs the same checks first, so those become argument errors instead. Only
|
|
732
|
+
* for the global fetch: an injected one owns its URL rules (SvelteKit's
|
|
733
|
+
* server-side `fetch` accepts relative URLs, for one).
|
|
734
|
+
*/
|
|
735
|
+
function assertSendable(url, method) {
|
|
736
|
+
if (typeof Request !== "function") return;
|
|
737
|
+
try {
|
|
738
|
+
new Request(url, { method });
|
|
739
|
+
} catch (cause) {
|
|
740
|
+
const shown = redactUrl(url);
|
|
741
|
+
const reason = (cause instanceof Error ? cause.message : String(cause)).split(url).join(shown);
|
|
742
|
+
throw new TypeError(`arthron: cannot send ${method} ${shown} (${reason}) — use an absolute URL or a baseURL with a scheme (https://…), and a valid HTTP method`, { cause });
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* A ReadableStream body the browser cannot stream is rejected before it is
|
|
747
|
+
* sent: Firefox would send the text "[object ReadableStream]", older Safari
|
|
748
|
+
* fails with a bare network error. Only for the global fetch, like
|
|
749
|
+
* assertSendable.
|
|
750
|
+
*/
|
|
751
|
+
function assertStreamable(url, method, body) {
|
|
752
|
+
if (!isReadableStream(body) || runtimeStreamsRequestBody()) return;
|
|
753
|
+
throw new TypeError(`arthron: cannot send ${method} ${redactUrl(url)} with a ReadableStream body — this browser cannot stream a request body and would send it garbled or not at all; pass a Blob or ArrayBuffer instead`);
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Non-ok responses the caller asked for are not failures: an opaque one
|
|
757
|
+
* (`mode: "no-cors"`) and the 3xx of `redirect: "manual"`.
|
|
758
|
+
*/
|
|
759
|
+
function failedByDefault(res, init) {
|
|
760
|
+
if (res.type === "opaque" || res.type === "opaqueredirect") return false;
|
|
761
|
+
return !(init.redirect === "manual" && res.status >= 300 && res.status < 400);
|
|
762
|
+
}
|
|
763
|
+
/** The one place a failure becomes an ArthronError. Cancellation passes through. */
|
|
764
|
+
async function send(resolved, ctx, throwOn) {
|
|
765
|
+
const fetchImpl = resolved.fetch ?? globalThis.fetch;
|
|
766
|
+
if (resolved.fetch === void 0) {
|
|
767
|
+
const method = ctx.init.method ?? "GET";
|
|
768
|
+
assertSendable(ctx.url, method);
|
|
769
|
+
assertStreamable(ctx.url, method, ctx.init.body);
|
|
770
|
+
}
|
|
771
|
+
const { signal } = ctx.init;
|
|
772
|
+
let res;
|
|
773
|
+
try {
|
|
774
|
+
res = await fetchImpl(ctx.url, ctx.init);
|
|
775
|
+
} catch (thrown) {
|
|
776
|
+
if (isCancellation(thrown, signal)) throw cancellationError(thrown, signal);
|
|
777
|
+
throw networkError(thrown, ctx);
|
|
778
|
+
}
|
|
779
|
+
if (res.ok) return res;
|
|
780
|
+
if (!(throwOn ? throwOn(res) : failedByDefault(res, ctx.init))) return res;
|
|
781
|
+
const error = await responseError(res, ctx, resolved.errorDetail);
|
|
782
|
+
if (signal?.aborted) throw signal.reason;
|
|
783
|
+
throw error;
|
|
784
|
+
}
|
|
785
|
+
//#endregion
|
|
786
|
+
//#region src/client/client.ts
|
|
787
|
+
const BODYLESS_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD"]);
|
|
788
|
+
/** Sent uppercased whatever their case; fetch itself only normalizes six. */
|
|
789
|
+
const STANDARD_METHODS = /* @__PURE__ */ new Set([
|
|
790
|
+
"GET",
|
|
791
|
+
"HEAD",
|
|
792
|
+
"POST",
|
|
793
|
+
"PUT",
|
|
794
|
+
"PATCH",
|
|
795
|
+
"DELETE",
|
|
796
|
+
"OPTIONS",
|
|
797
|
+
"QUERY"
|
|
798
|
+
]);
|
|
361
799
|
/**
|
|
362
800
|
* Build a client from config. The result is immutable — `use` and `extend`
|
|
363
801
|
* return new clients and never touch the one they were called on.
|
|
364
802
|
*/
|
|
365
803
|
function arthron(config = {}) {
|
|
366
804
|
const resolved = resolveConfig(config);
|
|
367
|
-
|
|
368
|
-
const
|
|
369
|
-
const fetchImpl = resolved.fetch ?? globalThis.fetch;
|
|
370
|
-
let res;
|
|
371
|
-
try {
|
|
372
|
-
res = await fetchImpl(ctx.url, ctx.init);
|
|
373
|
-
} catch (thrown) {
|
|
374
|
-
if (isCancellation(thrown)) throw thrown;
|
|
375
|
-
throw networkError(thrown, ctx);
|
|
376
|
-
}
|
|
377
|
-
if (!res.ok) throw await responseError(res, ctx, resolved.errorMessage);
|
|
378
|
-
return res;
|
|
379
|
-
};
|
|
380
|
-
const dispatch = (path, method, rawBody, { params, headers: callHeaders, ...init }) => {
|
|
805
|
+
const initDefaults = fetchDefaults(resolved);
|
|
806
|
+
const dispatch = (path, method, rawBody, { params, headers: callHeaders, meta, throwOn = resolved.throwOn, path: pathValues, ...init }) => {
|
|
381
807
|
try {
|
|
382
|
-
|
|
383
|
-
const
|
|
384
|
-
|
|
808
|
+
const upper = method.toUpperCase();
|
|
809
|
+
const verb = STANDARD_METHODS.has(upper) ? upper : method;
|
|
810
|
+
if (rawBody !== void 0 && BODYLESS_METHODS.has(upper)) throw new TypeError(`arthron: ${upper} requests cannot carry a body — send it as params (query) or switch to POST`);
|
|
811
|
+
if (pathValues) path = fillPath(path, pathValues);
|
|
812
|
+
const url = resolveUrl(resolved.baseURL, path, params, resolved.allowAbsoluteUrls);
|
|
813
|
+
const { body, headers } = serializeBody(rawBody, mergeHeaders(leavesBase(resolved.baseURL, path) ? void 0 : resolved.headers, callHeaders));
|
|
385
814
|
const requestInit = {
|
|
386
|
-
...
|
|
815
|
+
...initDefaults,
|
|
387
816
|
...init,
|
|
388
|
-
method,
|
|
817
|
+
method: verb,
|
|
389
818
|
headers
|
|
390
819
|
};
|
|
391
820
|
if (body !== void 0) requestInit.body = body;
|
|
821
|
+
if (isReadableStream(body)) Object.assign(requestInit, { duplex: "half" });
|
|
392
822
|
return toArthronPromise(runPipeline(resolved.use, {
|
|
393
823
|
url,
|
|
394
|
-
init: requestInit
|
|
395
|
-
|
|
824
|
+
init: requestInit,
|
|
825
|
+
meta: { ...meta }
|
|
826
|
+
}, (ctx) => send(resolved, ctx, throwOn)), init.signal);
|
|
396
827
|
} catch (thrown) {
|
|
397
828
|
return toArthronPromise(Promise.reject(thrown));
|
|
398
829
|
}
|
|
@@ -402,7 +833,7 @@ function arthron(config = {}) {
|
|
|
402
833
|
...resolved,
|
|
403
834
|
...next,
|
|
404
835
|
headers: mergeHeaders(resolved.headers, next.headers),
|
|
405
|
-
use:
|
|
836
|
+
use: addPlugins(resolved.use, next.use ?? [])
|
|
406
837
|
});
|
|
407
838
|
};
|
|
408
839
|
return {
|
|
@@ -412,17 +843,22 @@ function arthron(config = {}) {
|
|
|
412
843
|
post: (path, body, init = {}) => dispatch(path, "POST", body, init),
|
|
413
844
|
put: (path, body, init = {}) => dispatch(path, "PUT", body, init),
|
|
414
845
|
patch: (path, body, init = {}) => dispatch(path, "PATCH", body, init),
|
|
846
|
+
query: (path, body, init = {}) => dispatch(path, "QUERY", body, init),
|
|
415
847
|
request: (path, init) => {
|
|
416
848
|
const { method, body, ...rest } = init;
|
|
417
849
|
if (!method) return toArthronPromise(Promise.reject(/* @__PURE__ */ new TypeError("arthron: request() needs init.method — e.g. request(path, { method: \"OPTIONS\" }); use get/post/... for the common verbs")));
|
|
418
850
|
return dispatch(path, method, body, rest);
|
|
419
851
|
},
|
|
420
852
|
use: (...plugins) => derive({ use: plugins }),
|
|
421
|
-
extend: (next) => derive(next)
|
|
853
|
+
extend: (next) => derive(next),
|
|
854
|
+
without: (...names) => arthron({
|
|
855
|
+
...resolved,
|
|
856
|
+
use: resolved.use.filter((p) => p.name === void 0 || !names.includes(p.name))
|
|
857
|
+
})
|
|
422
858
|
};
|
|
423
859
|
}
|
|
424
860
|
//#endregion
|
|
425
|
-
//#region src/define.ts
|
|
861
|
+
//#region src/pipeline/define-plugin.ts
|
|
426
862
|
/** Identity helper: gives a middleware written in its own file the right parameter types. */
|
|
427
863
|
const defineMiddleware = (middleware) => middleware;
|
|
428
864
|
/** Identity helpers for each interceptor kind — `defineInterceptor.request(fn)` etc. */
|
|
@@ -434,16 +870,7 @@ const defineInterceptor = {
|
|
|
434
870
|
/** Identity helper: type-checks a plugin object literal without an annotation. */
|
|
435
871
|
const definePlugin = (plugin) => plugin;
|
|
436
872
|
//#endregion
|
|
437
|
-
//#region src/plugins/logging.ts
|
|
438
|
-
/** Origin + pathname only — the query and userinfo tend to carry secrets. */
|
|
439
|
-
function displayPath(url) {
|
|
440
|
-
try {
|
|
441
|
-
const parsed = new URL(url);
|
|
442
|
-
return `${parsed.origin}${parsed.pathname}`;
|
|
443
|
-
} catch {
|
|
444
|
-
return url.split("?")[0] ?? url;
|
|
445
|
-
}
|
|
446
|
-
}
|
|
873
|
+
//#region src/plugins/logging/logging.ts
|
|
447
874
|
function consoleSink(logger) {
|
|
448
875
|
return (e) => {
|
|
449
876
|
if (e.phase === "start") logger.log(`[arthron] → ${e.method} ${e.path}`);
|
|
@@ -465,7 +892,7 @@ function logging(options = {}) {
|
|
|
465
892
|
middleware: async (ctx, next) => {
|
|
466
893
|
if (!enabled) return next(ctx);
|
|
467
894
|
const method = ctx.init.method ?? "GET";
|
|
468
|
-
const path =
|
|
895
|
+
const path = redactUrl(ctx.url);
|
|
469
896
|
const started = now();
|
|
470
897
|
const ms = () => Math.round(now() - started);
|
|
471
898
|
emit({
|
|
@@ -473,18 +900,11 @@ function logging(options = {}) {
|
|
|
473
900
|
method,
|
|
474
901
|
path
|
|
475
902
|
});
|
|
903
|
+
let res;
|
|
476
904
|
try {
|
|
477
|
-
|
|
478
|
-
emit({
|
|
479
|
-
phase: "success",
|
|
480
|
-
method,
|
|
481
|
-
path,
|
|
482
|
-
status: res.status,
|
|
483
|
-
ms: ms()
|
|
484
|
-
});
|
|
485
|
-
return res;
|
|
905
|
+
res = await next(ctx);
|
|
486
906
|
} catch (error) {
|
|
487
|
-
if (!isCancellation(error)) {
|
|
907
|
+
if (!isCancellation(error, ctx.init.signal)) {
|
|
488
908
|
const status = isArthronError(error) ? error.status : void 0;
|
|
489
909
|
emit({
|
|
490
910
|
phase: "error",
|
|
@@ -497,11 +917,19 @@ function logging(options = {}) {
|
|
|
497
917
|
}
|
|
498
918
|
throw error;
|
|
499
919
|
}
|
|
920
|
+
emit({
|
|
921
|
+
phase: "success",
|
|
922
|
+
method,
|
|
923
|
+
path,
|
|
924
|
+
status: res.status,
|
|
925
|
+
ms: ms()
|
|
926
|
+
});
|
|
927
|
+
return res;
|
|
500
928
|
}
|
|
501
929
|
};
|
|
502
930
|
}
|
|
503
931
|
//#endregion
|
|
504
|
-
//#region src/plugins/retry.ts
|
|
932
|
+
//#region src/plugins/retry/retry.ts
|
|
505
933
|
const DEFAULT_RETRY_ON = [
|
|
506
934
|
408,
|
|
507
935
|
429,
|
|
@@ -514,10 +942,13 @@ const DEFAULT_METHODS = [
|
|
|
514
942
|
"GET",
|
|
515
943
|
"HEAD",
|
|
516
944
|
"OPTIONS",
|
|
945
|
+
"QUERY",
|
|
517
946
|
"PUT",
|
|
518
947
|
"DELETE"
|
|
519
948
|
];
|
|
520
949
|
const DEFAULT_MAX_RETRY_AFTER = 6e4;
|
|
950
|
+
/** setTimeout fires at once past this, so every wait is capped to it. */
|
|
951
|
+
const MAX_TIMER_MS$1 = 2 ** 31 - 1;
|
|
521
952
|
function defaultSleep(ms, signal) {
|
|
522
953
|
return new Promise((resolve, reject) => {
|
|
523
954
|
if (signal?.aborted) return reject(signal.reason);
|
|
@@ -542,43 +973,70 @@ function retryAfterMs(error) {
|
|
|
542
973
|
if (Number.isNaN(at)) return void 0;
|
|
543
974
|
return Math.max(0, at - Date.now());
|
|
544
975
|
}
|
|
545
|
-
|
|
546
|
-
|
|
976
|
+
/** The rule without `shouldRetry`: a network error or a `retryOn` status, on a method in `methods`. */
|
|
977
|
+
function byDefault(thrown, method, settings) {
|
|
978
|
+
if (!settings.allowed.has(method) || !isArthronError(thrown)) return false;
|
|
979
|
+
return thrown.status === 0 || settings.retryOn.includes(thrown.status);
|
|
980
|
+
}
|
|
981
|
+
/** Defaults filled in and `retries` checked — `attempt >= NaN` is never true, so an unset env var would retry forever. */
|
|
982
|
+
function settle(options, where) {
|
|
983
|
+
const { retries = 2, retryOn = DEFAULT_RETRY_ON, methods = DEFAULT_METHODS, delay = (retry) => Math.random() * 300 * 2 ** retry, maxRetryAfter = DEFAULT_MAX_RETRY_AFTER, shouldRetry, onRetry } = options;
|
|
984
|
+
if (!Number.isInteger(retries) || retries < 0) throw new TypeError(`arthron: ${where} retries: ${retries} needs a whole number ≥ 0 — e.g. retries: 2`);
|
|
985
|
+
return {
|
|
986
|
+
retries,
|
|
987
|
+
retryOn,
|
|
988
|
+
allowed: new Set(methods.map((m) => m.toUpperCase())),
|
|
989
|
+
delay,
|
|
990
|
+
maxRetryAfter,
|
|
991
|
+
shouldRetry,
|
|
992
|
+
onRetry
|
|
993
|
+
};
|
|
547
994
|
}
|
|
548
995
|
/**
|
|
549
996
|
* Retry on network errors and the configured statuses by calling `next`
|
|
550
997
|
* again — the whole attempt (request interceptors included) is re-run.
|
|
551
998
|
* Only `ArthronError` is considered; cancellation and plugin bugs pass through.
|
|
999
|
+
* A call can turn it off or override its options with `meta.retry`.
|
|
552
1000
|
*/
|
|
553
1001
|
function retry(options = {}) {
|
|
554
|
-
const {
|
|
555
|
-
const
|
|
1002
|
+
const { sleep = defaultSleep, ...rest } = options;
|
|
1003
|
+
const base = settle(rest, "retry()");
|
|
556
1004
|
return {
|
|
557
1005
|
name: "retry",
|
|
558
1006
|
middleware: async (ctx, next) => {
|
|
1007
|
+
const override = ctx.meta?.retry;
|
|
1008
|
+
if (override === false) return next(ctx);
|
|
1009
|
+
const settings = override ? settle({
|
|
1010
|
+
...rest,
|
|
1011
|
+
...override
|
|
1012
|
+
}, "meta.retry") : base;
|
|
559
1013
|
const method = (ctx.init.method ?? "GET").toUpperCase();
|
|
560
|
-
const
|
|
1014
|
+
const resendable = !isReadableStream(ctx.init.body);
|
|
561
1015
|
for (let attempt = 0;; attempt += 1) try {
|
|
562
1016
|
return await next(ctx);
|
|
563
1017
|
} catch (thrown) {
|
|
564
|
-
if (!
|
|
565
|
-
if (!isArthronError(thrown)) throw thrown;
|
|
566
|
-
if (thrown.status !== 0 && !retryOn.includes(thrown.status)) throw thrown;
|
|
1018
|
+
if (!resendable || attempt >= settings.retries) throw thrown;
|
|
567
1019
|
if (ctx.init.signal?.aborted) throw thrown;
|
|
568
|
-
const
|
|
569
|
-
if (
|
|
570
|
-
|
|
1020
|
+
const verdict = settings.shouldRetry?.(thrown, attempt, ctx);
|
|
1021
|
+
if (verdict === false) throw thrown;
|
|
1022
|
+
if (verdict === void 0 && !byDefault(thrown, method, settings)) throw thrown;
|
|
1023
|
+
const retryAfter = isArthronError(thrown) ? retryAfterMs(thrown) : void 0;
|
|
1024
|
+
if (retryAfter !== void 0 && retryAfter > settings.maxRetryAfter) throw thrown;
|
|
1025
|
+
const wait = Math.min(retryAfter ?? settings.delay(attempt, thrown), MAX_TIMER_MS$1);
|
|
1026
|
+
settings.onRetry?.(thrown, attempt, wait);
|
|
1027
|
+
await sleep(wait, ctx.init.signal);
|
|
571
1028
|
}
|
|
572
1029
|
}
|
|
573
1030
|
};
|
|
574
1031
|
}
|
|
575
1032
|
//#endregion
|
|
576
|
-
//#region src/plugins/timeout.ts
|
|
1033
|
+
//#region src/plugins/timeout/timeout.ts
|
|
1034
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
577
1035
|
/**
|
|
578
1036
|
* Wrap `body` so the deadline keeps running until it is fully read, and so an
|
|
579
1037
|
* abort while reading surfaces as a stream error instead of a silent stall.
|
|
580
1038
|
*/
|
|
581
|
-
function guardBody(body, signal, finish) {
|
|
1039
|
+
function guardBody(body, signal, finish, onChunk) {
|
|
582
1040
|
const reader = body.getReader();
|
|
583
1041
|
return new ReadableStream({
|
|
584
1042
|
start(controller) {
|
|
@@ -594,6 +1052,7 @@ function guardBody(body, signal, finish) {
|
|
|
594
1052
|
controller.close();
|
|
595
1053
|
return;
|
|
596
1054
|
}
|
|
1055
|
+
onChunk();
|
|
597
1056
|
controller.enqueue(value);
|
|
598
1057
|
},
|
|
599
1058
|
cancel(reason) {
|
|
@@ -602,37 +1061,64 @@ function guardBody(body, signal, finish) {
|
|
|
602
1061
|
}
|
|
603
1062
|
});
|
|
604
1063
|
}
|
|
1064
|
+
/** setTimeout fires at once for NaN, negatives and anything past 2^31-1. */
|
|
1065
|
+
function checkDeadline(ms, where) {
|
|
1066
|
+
if (ms === Number.POSITIVE_INFINITY || ms >= 0 && ms <= MAX_TIMER_MS) return;
|
|
1067
|
+
throw new TypeError(`arthron: ${where} needs 0–${MAX_TIMER_MS} ms — pass Infinity for no deadline`);
|
|
1068
|
+
}
|
|
1069
|
+
/** A timer that aborts `controller` with a TimeoutError and never keeps Node alive on its own. */
|
|
1070
|
+
function deadlineTimer(controller, ms, message) {
|
|
1071
|
+
const timer = setTimeout(() => controller.abort(new DOMException(message, "TimeoutError")), ms);
|
|
1072
|
+
timer.unref?.();
|
|
1073
|
+
return timer;
|
|
1074
|
+
}
|
|
605
1075
|
/**
|
|
606
1076
|
* Abort the request when it takes longer than `ms` — counted from the start
|
|
607
1077
|
* of the attempt until the body has been fully read, so a server that sends
|
|
608
|
-
* headers and then stalls still trips it.
|
|
1078
|
+
* headers and then stalls still trips it. With `idle`, also abort once no
|
|
1079
|
+
* data has arrived for that long. Rejects with a `TimeoutError` (a
|
|
609
1080
|
* `DOMException`, like `AbortSignal.timeout()` would give); it is not an
|
|
610
1081
|
* `ArthronError`, so error interceptors and `retry` leave it alone.
|
|
611
1082
|
*
|
|
612
|
-
*
|
|
613
|
-
*
|
|
1083
|
+
* A call can change the total deadline with `meta.timeout`.
|
|
1084
|
+
*
|
|
1085
|
+
* Deadlines are timers on an AbortController — not `AbortSignal.timeout`,
|
|
1086
|
+
* since the idle one restarts — joined to the caller's signal with
|
|
1087
|
+
* `AbortSignal.any`.
|
|
614
1088
|
*/
|
|
615
|
-
function timeout(ms) {
|
|
1089
|
+
function timeout(ms, options = {}) {
|
|
1090
|
+
checkDeadline(ms, `timeout(${ms})`);
|
|
1091
|
+
const { idle } = options;
|
|
1092
|
+
if (idle !== void 0) checkDeadline(idle, `timeout(…, { idle: ${idle} })`);
|
|
616
1093
|
return {
|
|
617
1094
|
name: "timeout",
|
|
618
1095
|
middleware: async (ctx, next) => {
|
|
1096
|
+
const deadline = ctx.meta?.timeout ?? ms;
|
|
1097
|
+
if (deadline !== ms) checkDeadline(deadline, `meta.timeout: ${deadline}`);
|
|
1098
|
+
const idleMs = idle === Number.POSITIVE_INFINITY ? void 0 : idle;
|
|
1099
|
+
if (deadline === Number.POSITIVE_INFINITY && idleMs === void 0) return next(ctx);
|
|
619
1100
|
const controller = new AbortController();
|
|
620
1101
|
const outer = ctx.init.signal;
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
const
|
|
1102
|
+
const signal = outer ? AbortSignal.any([outer, controller.signal]) : controller.signal;
|
|
1103
|
+
const total = deadline === Number.POSITIVE_INFINITY ? void 0 : deadlineTimer(controller, deadline, `arthron: request timed out after ${deadline}ms`);
|
|
1104
|
+
let quiet;
|
|
1105
|
+
const stillAlive = () => {
|
|
1106
|
+
if (idleMs === void 0) return;
|
|
1107
|
+
clearTimeout(quiet);
|
|
1108
|
+
quiet = deadlineTimer(controller, idleMs, `arthron: no data for ${idleMs}ms`);
|
|
1109
|
+
};
|
|
625
1110
|
const finish = () => {
|
|
626
|
-
clearTimeout(
|
|
627
|
-
|
|
1111
|
+
clearTimeout(total);
|
|
1112
|
+
clearTimeout(quiet);
|
|
628
1113
|
};
|
|
1114
|
+
stillAlive();
|
|
629
1115
|
let res;
|
|
630
1116
|
try {
|
|
631
1117
|
res = await next({
|
|
632
1118
|
...ctx,
|
|
633
1119
|
init: {
|
|
634
1120
|
...ctx.init,
|
|
635
|
-
signal
|
|
1121
|
+
signal
|
|
636
1122
|
}
|
|
637
1123
|
});
|
|
638
1124
|
} catch (thrown) {
|
|
@@ -643,16 +1129,14 @@ function timeout(ms) {
|
|
|
643
1129
|
finish();
|
|
644
1130
|
return res;
|
|
645
1131
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
statusText: res.statusText,
|
|
649
|
-
headers: res.headers
|
|
650
|
-
});
|
|
1132
|
+
stillAlive();
|
|
1133
|
+
return rebuildResponse(res, guardBody(res.body, signal, finish, stillAlive));
|
|
651
1134
|
}
|
|
652
1135
|
};
|
|
653
1136
|
}
|
|
654
1137
|
//#endregion
|
|
655
1138
|
exports.ArthronError = ArthronError;
|
|
1139
|
+
exports.SchemaError = SchemaError;
|
|
656
1140
|
exports.arthron = arthron;
|
|
657
1141
|
exports.defineInterceptor = defineInterceptor;
|
|
658
1142
|
exports.defineMiddleware = defineMiddleware;
|