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