arthron 1.2.0 → 2.1.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.
@@ -1,1166 +0,0 @@
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
- * `text` with `url` redacted wherever it appears, as given or as the URL
21
- * parser normalizes it — for a runtime's own message that repeats the URL.
22
- */
23
- function redactUrlIn(text, url) {
24
- const shown = redactUrl(url);
25
- let href;
26
- try {
27
- href = new URL(url).href;
28
- } catch {}
29
- let out = text.split(url).join(shown);
30
- if (href && href !== url) out = out.split(href).join(shown);
31
- return out;
32
- }
33
- /**
34
- * `undefined` when `url` has no origin of its own. A relative baseURL takes
35
- * the page's origin in a browser and has none elsewhere.
36
- */
37
- function originOf$1(url) {
38
- const page = globalThis.location?.href;
39
- try {
40
- return new URL(url, page).origin;
41
- } catch {
42
- return;
43
- }
44
- }
45
- /** `path` is absolute and points away from `baseURL`'s origin. */
46
- function leavesBase(baseURL, path) {
47
- if (baseURL === void 0 || !isAbsoluteUrl(path)) return false;
48
- const base = originOf$1(baseURL);
49
- return base === void 0 || originOf$1(path) !== base;
50
- }
51
- function toQueryString(params) {
52
- if (!params) return "";
53
- if (Object.prototype.toString.call(params) === "[object URLSearchParams]") return params.toString();
54
- const search = new URLSearchParams();
55
- for (const [key, value] of Object.entries(params)) for (const item of Array.isArray(value) ? value : [value]) {
56
- if (item === null || item === void 0) continue;
57
- 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())`);
58
- search.append(key, String(item));
59
- }
60
- return search.toString();
61
- }
62
- /**
63
- * Fill `{name}` placeholders with encoded values, so each one stays inside
64
- * its segment: "/", "?" and "#" are encoded, and the values WHATWG URL would
65
- * turn into a different path — "", "." and ".." — are refused. A placeholder
66
- * without a value, or a value without a placeholder, is a mistake too.
67
- */
68
- function fillPath(template, values) {
69
- const used = /* @__PURE__ */ new Set();
70
- const filled = template.replace(/\{([^{}/]+)\}/g, (_, name) => {
71
- if (!Object.hasOwn(values, name)) throw new TypeError(`arthron: path "${template}" has {${name}} but no value for it — pass path: { ${name}: … }`);
72
- used.add(name);
73
- const value = String(values[name]);
74
- 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`);
75
- return encodeURIComponent(value);
76
- });
77
- const unused = Object.keys(values).find((name) => !used.has(name));
78
- if (unused !== void 0) throw new TypeError(`arthron: path "${template}" has no {${unused}} — add the placeholder or drop the value`);
79
- return filled;
80
- }
81
- /**
82
- * Join `baseURL` and `path` by plain string concatenation so a path prefix in
83
- * the base (`/v1`) survives a leading slash in the path. WHATWG `new URL()`
84
- * would drop it. An absolute path replaces the base only when it stays on the
85
- * base's origin, or when `allowAbsoluteUrls` says other origins are fine — a
86
- * path built from user input or an API response must not carry the client's
87
- * credentials somewhere else.
88
- */
89
- function resolveUrl(baseURL, path, params, allowAbsoluteUrls = false) {
90
- let url;
91
- if (isAbsoluteUrl(path)) {
92
- if (!HTTP_URL.test(path)) {
93
- const scheme = path.slice(0, path.indexOf(":") + 1);
94
- throw new TypeError(`arthron: only http(s) URLs can be requested, got a "${scheme}" URL — use an http:// or https:// URL`);
95
- }
96
- if (!allowAbsoluteUrls && leavesBase(baseURL, path)) throw new TypeError(`arthron: ${originOf$1(path)} is outside baseURL ${(baseURL && originOf$1(baseURL)) ?? baseURL} — pass a path relative to baseURL, or set arthron({ allowAbsoluteUrls: true }) to allow other origins (config headers are not sent to them)`);
97
- url = path;
98
- } else {
99
- 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`);
100
- url = `${baseURL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
101
- }
102
- const query = toQueryString(params);
103
- if (!query) return url;
104
- const hash = url.indexOf("#");
105
- const [base, fragment] = hash === -1 ? [url, ""] : [url.slice(0, hash), url.slice(hash)];
106
- return `${base}${base.includes("?") ? "&" : "?"}${query}${fragment}`;
107
- }
108
- //#endregion
109
- //#region src/error/arthron-error.ts
110
- /**
111
- * Marks an ArthronError across copies of this package. The ESM and CJS
112
- * builds can both end up loaded (a CJS dependency creates the client, ESM
113
- * app code catches), each with its own class — `Symbol.for` is shared.
114
- */
115
- const BRAND = Symbol.for("arthron.ArthronError");
116
- /**
117
- * The only error arthron itself produces. Two sources: a response with
118
- * `ok === false`, or fetch rejecting outright (network failure, `status` 0).
119
- * Everything else — cancellation, interceptor bugs — is rethrown untouched.
120
- */
121
- var ArthronError = class ArthronError extends Error {
122
- name = "ArthronError";
123
- /** HTTP status of the failed response; `0` when fetch itself failed (network). */
124
- status;
125
- statusText;
126
- /** Parsed body: JSON if it parses, else the text, else `undefined`. */
127
- data;
128
- /**
129
- * The server's own explanation, picked out of `data` (by default
130
- * `message` → `error` → `error.message` → `detail`; see the `errorDetail`
131
- * config). `undefined` when the body had none. Untrusted server text: show
132
- * it to users as text, never as HTML. `message`, by contrast, is written by
133
- * arthron — method, URL without query, status — and is meant for logs.
134
- */
135
- detail;
136
- /** The underlying error for network failures. */
137
- cause;
138
- constructor(init) {
139
- super(init.message);
140
- this.status = init.status;
141
- this.statusText = init.statusText;
142
- this.data = init.data;
143
- this.detail = init.detail;
144
- this.cause = init.cause;
145
- Object.defineProperties(this, {
146
- headers: { value: init.headers },
147
- request: { value: init.request },
148
- response: { value: init.response },
149
- [BRAND]: { value: true }
150
- });
151
- }
152
- /** `instanceof ArthronError` holds for an error from any copy of the package. */
153
- static [Symbol.hasInstance](value) {
154
- return this === ArthronError ? isArthronError(value) : Function.prototype[Symbol.hasInstance].call(this, value);
155
- }
156
- /** What serializers see: no headers, no body, no query or userinfo. */
157
- toJSON() {
158
- return {
159
- name: this.name,
160
- message: this.message,
161
- detail: this.detail,
162
- status: this.status,
163
- statusText: this.statusText,
164
- method: this.request.init.method ?? "GET",
165
- url: redactUrl(this.request.url),
166
- data: this.data
167
- };
168
- }
169
- };
170
- /** `true` for errors arthron produced: a `!ok` response or a network failure. Cancellations and plugin bugs are not ArthronErrors. */
171
- function isArthronError(error) {
172
- return typeof error === "object" && error !== null && error[BRAND] === true;
173
- }
174
- //#endregion
175
- //#region src/request/body.ts
176
- const RAW_TAGS = /* @__PURE__ */ new Set([
177
- "FormData",
178
- "Blob",
179
- "File",
180
- "ArrayBuffer",
181
- "URLSearchParams",
182
- "ReadableStream"
183
- ]);
184
- /**
185
- * `Symbol.toStringTag`, not `instanceof`: a body from another realm or a
186
- * polyfill (formdata-node, fetch-blob) must not be JSON-encoded into `{}`.
187
- */
188
- const tagOf = (value) => Object.prototype.toString.call(value).slice(8, -1);
189
- function isReadableStream(value) {
190
- return tagOf(value) === "ReadableStream";
191
- }
192
- /**
193
- * Whether `RequestCtor` can send a ReadableStream body. Node and Chromium
194
- * read `duplex` and leave the stream alone; Firefox ignores `duplex` and
195
- * would send the text "[object ReadableStream]"; older Safari refuses it.
196
- * The check from
197
- * https://developer.chrome.com/docs/capabilities/web-apis/fetch-streaming-requests
198
- */
199
- function canStreamRequestBody(RequestCtor) {
200
- let duplexRead = false;
201
- try {
202
- const request = new RequestCtor("https://stream.test", {
203
- method: "POST",
204
- body: new ReadableStream(),
205
- get duplex() {
206
- duplexRead = true;
207
- return "half";
208
- }
209
- });
210
- return duplexRead && !request.headers.has("content-type");
211
- } catch {
212
- return false;
213
- }
214
- }
215
- let runtimeStreams;
216
- /** `canStreamRequestBody` for the global `Request`, checked once. */
217
- function runtimeStreamsRequestBody() {
218
- runtimeStreams ??= typeof Request !== "function" || canStreamRequestBody(Request);
219
- return runtimeStreams;
220
- }
221
- /**
222
- * What JSON.stringify would turn into `{}`, `undefined` or internal state
223
- * instead of the data — with the fix, so it reads as an error message.
224
- */
225
- function mangledByJson(body) {
226
- if (typeof body === "function" || typeof body === "symbol") return `a ${typeof body}, which JSON cannot carry — send its data instead`;
227
- if (typeof body !== "object" || body === null || Array.isArray(body)) return;
228
- if (Symbol.asyncIterator in body) return "an async iterable (a Node stream?) — pass a ReadableStream (Readable.toWeb(stream)) or collect it first";
229
- if (Symbol.iterator in body) return "an iterable such as a Map, Set or generator — convert it to an array or plain object first";
230
- }
231
- /** Bodies fetch already knows how to send. Content-Type is left to the caller / runtime. */
232
- function isRawBody(body) {
233
- if (typeof body === "string") return true;
234
- if (ArrayBuffer.isView(body)) return true;
235
- return RAW_TAGS.has(tagOf(body));
236
- }
237
- /**
238
- * Turn a caller-supplied body into something fetch accepts. Raw bodies pass
239
- * through untouched; anything else is JSON with `content-type` set — unless
240
- * the caller already set one (headers arrive lowercased, so a plain lookup is
241
- * enough).
242
- */
243
- function serializeBody(body, headers) {
244
- if (body === void 0) return {
245
- body: void 0,
246
- headers
247
- };
248
- if (tagOf(body) === "FormData") {
249
- const { "content-type": _dropped, ...rest } = headers;
250
- return {
251
- body,
252
- headers: rest
253
- };
254
- }
255
- if (isRawBody(body)) return {
256
- body,
257
- headers
258
- };
259
- const mangled = mangledByJson(body);
260
- if (mangled) throw new TypeError(`arthron: the body is ${mangled}`);
261
- const type = headers["content-type"];
262
- 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`);
263
- const withType = "content-type" in headers ? headers : {
264
- ...headers,
265
- "content-type": "application/json"
266
- };
267
- return {
268
- body: JSON.stringify(body),
269
- headers: withType
270
- };
271
- }
272
- //#endregion
273
- //#region src/plugins/bearer-auth/bearer-auth.ts
274
- /** The origin of an absolute http(s) URL; `undefined` for anything else. */
275
- function originOf(url) {
276
- try {
277
- const { protocol, origin } = new URL(url);
278
- return protocol === "http:" || protocol === "https:" ? origin : void 0;
279
- } catch {
280
- return;
281
- }
282
- }
283
- function allowedOrigins(origin) {
284
- const urls = typeof origin === "string" ? [origin] : origin;
285
- if (urls.length === 0) throw new TypeError("arthron: bearerAuth({ origin }) needs at least one URL — e.g. origin: \"https://api.example.com\"");
286
- return new Set(urls.map((url) => {
287
- const found = originOf(url);
288
- if (found) return found;
289
- throw new TypeError(`arthron: bearerAuth({ origin }) needs an http(s) URL, got ${JSON.stringify(url)} — e.g. origin: "https://api.example.com"`);
290
- }));
291
- }
292
- /**
293
- * Bearer-token auth. A request interceptor sets `authorization: Bearer
294
- * <token>` on every attempt to `origin`; with `refresh`, a middleware turns a
295
- * 401 into one refresh and one more try. Concurrent 401s share the refresh
296
- * (with refresh-token rotation, parallel refreshes revoke each other), and a
297
- * call whose token was replaced meanwhile just retries. A call opts out with
298
- * `meta: { bearerAuth: false }`.
299
- */
300
- function bearerAuth(options) {
301
- const { token, refresh } = options;
302
- const allowed = allowedOrigins(options.origin);
303
- const ours = (url) => allowed.has(originOf(url) ?? "");
304
- const bearer = async () => {
305
- const value = await token();
306
- return value ? `Bearer ${value}` : void 0;
307
- };
308
- let refreshing;
309
- return {
310
- name: "bearerAuth",
311
- request: async (ctx) => {
312
- if (ctx.meta?.bearerAuth === false || !ours(ctx.url)) return ctx;
313
- const value = await bearer();
314
- if (!value) return ctx;
315
- return {
316
- ...ctx,
317
- init: {
318
- ...ctx.init,
319
- headers: {
320
- ...ctx.init.headers,
321
- authorization: value
322
- }
323
- }
324
- };
325
- },
326
- middleware: async (ctx, next) => {
327
- if (!refresh || ctx.meta?.bearerAuth === false) return next(ctx);
328
- if (isReadableStream(ctx.init.body)) return next(ctx);
329
- try {
330
- return await next(ctx);
331
- } catch (error) {
332
- if (!isArthronError(error) || error.status !== 401) throw error;
333
- if (!ours(error.request.url)) throw error;
334
- if (error.request.init.headers.authorization === await bearer()) {
335
- refreshing ??= refresh().finally(() => {
336
- refreshing = void 0;
337
- });
338
- await refreshing;
339
- }
340
- return next(ctx);
341
- }
342
- }
343
- };
344
- }
345
- //#endregion
346
- //#region src/response/rebuild-response.ts
347
- /**
348
- * A new Response with `body` but everything else taken from `original`. The
349
- * constructor resets `url`, `redirected` and `type`, so those are copied over
350
- * as own properties — callers that resolve relative links or detect
351
- * redirects keep working.
352
- */
353
- function rebuildResponse(original, body) {
354
- const rebuilt = new Response(body, {
355
- status: original.status,
356
- statusText: original.statusText,
357
- headers: original.headers
358
- });
359
- const { url, redirected, type } = original;
360
- return withFields(rebuilt, {
361
- url,
362
- redirected,
363
- type
364
- });
365
- }
366
- /** Set those fields on `target` — and on its `clone()`s as well. */
367
- function withFields(target, fields) {
368
- const clone = target.clone.bind(target);
369
- return Object.defineProperties(target, {
370
- url: { value: fields.url },
371
- redirected: { value: fields.redirected },
372
- type: { value: fields.type },
373
- clone: { value: () => withFields(clone(), fields) }
374
- });
375
- }
376
- //#endregion
377
- //#region src/error/create-error.ts
378
- /**
379
- * Cancellation is not an HTTP failure. Matched by name so it works across
380
- * realms — or by the request's own `signal`, since `abort(reason)` rejects
381
- * with whatever reason the caller chose.
382
- */
383
- function isCancellation(error, signal) {
384
- if (signal?.aborted) return true;
385
- const name = error?.name;
386
- return name === "AbortError" || name === "TimeoutError";
387
- }
388
- /**
389
- * What a cancelled call rejects with: its signal's reason. WebKit's fetch
390
- * rejects, and errors a half-read body, with a generic AbortError instead,
391
- * dropping a TimeoutError or the caller's own reason.
392
- */
393
- function cancellationError(error, signal) {
394
- return signal?.aborted ? signal.reason : error;
395
- }
396
- const isRecord = (value) => typeof value === "object" && value !== null;
397
- /** `message` → `error` (string) → `error.message` → `detail`. */
398
- function defaultErrorDetail(data) {
399
- if (!isRecord(data)) return void 0;
400
- if (typeof data.message === "string") return data.message;
401
- if (typeof data.error === "string") return data.error;
402
- if (isRecord(data.error) && typeof data.error.message === "string") return data.error.message;
403
- if (typeof data.detail === "string") return data.detail;
404
- }
405
- /** 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. */
406
- const MAX_ERROR_BODY_BYTES = 65536;
407
- /** Up to `limit` bytes of the body; the rest is cancelled unread. `undefined` when there is no body or reading it failed. */
408
- async function readCapped(response, limit) {
409
- if (!response.body) return void 0;
410
- let reader;
411
- const chunks = [];
412
- let size = 0;
413
- try {
414
- reader = response.body.getReader();
415
- while (size <= limit) {
416
- const { value, done } = await reader.read();
417
- if (done) break;
418
- chunks.push(value);
419
- size += value.byteLength;
420
- }
421
- } catch {
422
- return;
423
- } finally {
424
- await reader?.cancel().catch(() => {});
425
- reader?.releaseLock();
426
- }
427
- const bytes = new Uint8Array(size);
428
- let offset = 0;
429
- for (const chunk of chunks) {
430
- bytes.set(chunk, offset);
431
- offset += chunk.byteLength;
432
- }
433
- return size > limit ? {
434
- bytes: bytes.subarray(0, limit),
435
- truncated: true
436
- } : {
437
- bytes,
438
- truncated: false
439
- };
440
- }
441
- /** JSON if the whole body parses, else the text (cut at the limit), else `undefined` when empty. */
442
- function parseBody(read) {
443
- const text = new TextDecoder().decode(read.bytes);
444
- if (text === "") return void 0;
445
- if (read.truncated) return text;
446
- try {
447
- return JSON.parse(text);
448
- } catch {
449
- return text;
450
- }
451
- }
452
- /** The request as error messages name it: method and URL, no query or userinfo. */
453
- function describeRequest(request) {
454
- return `${request.init.method ?? "GET"} ${redactUrl(request.url)}`;
455
- }
456
- async function responseError(response, request, resolve = defaultErrorDetail) {
457
- const read = await readCapped(response, MAX_ERROR_BODY_BYTES);
458
- const data = read && parseBody(read);
459
- if (read) response = rebuildResponse(response, read.bytes);
460
- const status = response.statusText ? `${response.status} ${response.statusText}` : String(response.status);
461
- return new ArthronError({
462
- message: `${describeRequest(request)} failed with ${status}`,
463
- detail: resolve(data, response) || void 0,
464
- status: response.status,
465
- statusText: response.statusText,
466
- headers: response.headers,
467
- data,
468
- request,
469
- response
470
- });
471
- }
472
- /**
473
- * Browsers that stream a request body (Chromium, current WebKit) do it only
474
- * over HTTP/2, and fail one sent to an HTTP/1.1 server with the same bare
475
- * "Failed to fetch" / "Load failed" as a dead server, so name the likely cause.
476
- */
477
- 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";
478
- function networkError(cause, request) {
479
- const reason = cause instanceof Error && cause.message ? redactUrlIn(cause.message, request.url) : "network error";
480
- const hint = isReadableStream(request.init.body) ? STREAM_BODY_HINT : "";
481
- return new ArthronError({
482
- message: `${describeRequest(request)} failed: ${reason}${hint}`,
483
- status: 0,
484
- statusText: "",
485
- headers: new Headers(),
486
- data: void 0,
487
- request,
488
- cause
489
- });
490
- }
491
- //#endregion
492
- //#region src/plugins/logging/logging.ts
493
- function consoleSink(logger) {
494
- return (e) => {
495
- if (e.phase === "start") logger.log(`[arthron] → ${e.method} ${e.path}`);
496
- else if (e.phase === "success") logger.log(`[arthron] ← ${e.method} ${e.path} ${e.status} ${e.ms}ms`);
497
- else logger.error(`[arthron] ✕ ${e.method} ${e.path} ${e.status ?? "-"} ${e.ms}ms`);
498
- };
499
- }
500
- /**
501
- * One event when a request starts, one when it settles, with status and
502
- * elapsed time. Wraps the whole call, so retries are included in the time.
503
- * Default output is a console line per event; pass `onEvent` to get the
504
- * structured event instead. Cancellations are not reported.
505
- */
506
- function logging(options = {}) {
507
- const { enabled = true, logger = console, onEvent, now = () => performance.now() } = options;
508
- const emit = onEvent ?? consoleSink(logger);
509
- return {
510
- name: "logging",
511
- middleware: async (ctx, next) => {
512
- if (!enabled) return next(ctx);
513
- const method = ctx.init.method ?? "GET";
514
- const path = redactUrl(ctx.url);
515
- const started = now();
516
- const ms = () => Math.round(now() - started);
517
- emit({
518
- phase: "start",
519
- method,
520
- path
521
- });
522
- let res;
523
- try {
524
- res = await next(ctx);
525
- } catch (error) {
526
- if (!isCancellation(error, ctx.init.signal)) {
527
- const status = isArthronError(error) ? error.status : void 0;
528
- emit({
529
- phase: "error",
530
- method,
531
- path,
532
- status,
533
- ms: ms(),
534
- error
535
- });
536
- }
537
- throw error;
538
- }
539
- emit({
540
- phase: "success",
541
- method,
542
- path,
543
- status: res.status,
544
- ms: ms()
545
- });
546
- return res;
547
- }
548
- };
549
- }
550
- //#endregion
551
- //#region src/plugins/retry/retry.ts
552
- const DEFAULT_RETRY_ON = [
553
- 408,
554
- 429,
555
- 500,
556
- 502,
557
- 503,
558
- 504
559
- ];
560
- const DEFAULT_METHODS = [
561
- "GET",
562
- "HEAD",
563
- "OPTIONS",
564
- "QUERY",
565
- "PUT",
566
- "DELETE"
567
- ];
568
- const DEFAULT_MAX_RETRY_AFTER = 6e4;
569
- /** setTimeout fires at once past this, so every wait is capped to it. */
570
- const MAX_TIMER_MS$1 = 2 ** 31 - 1;
571
- function defaultSleep(ms, signal) {
572
- return new Promise((resolve, reject) => {
573
- if (signal?.aborted) return reject(signal.reason);
574
- const onAbort = () => {
575
- clearTimeout(timer);
576
- reject(signal?.reason);
577
- };
578
- const timer = setTimeout(() => {
579
- signal?.removeEventListener("abort", onAbort);
580
- resolve();
581
- }, ms);
582
- signal?.addEventListener("abort", onAbort, { once: true });
583
- });
584
- }
585
- /** `Retry-After` as ms — seconds or an HTTP-date. `undefined` when absent or unparsable. */
586
- function retryAfterMs(error) {
587
- const value = error.headers.get("retry-after")?.trim();
588
- if (!value) return void 0;
589
- const seconds = Number(value);
590
- if (!Number.isNaN(seconds)) return Math.max(0, seconds * 1e3);
591
- const at = Date.parse(value);
592
- if (Number.isNaN(at)) return void 0;
593
- return Math.max(0, at - Date.now());
594
- }
595
- /** The rule without `shouldRetry`: a network error or a `retryOn` status, on a method in `methods`. */
596
- function byDefault(thrown, method, settings) {
597
- if (!settings.allowed.has(method) || !isArthronError(thrown)) return false;
598
- return thrown.status === 0 || settings.retryOn.includes(thrown.status);
599
- }
600
- /** Defaults filled in and `retries` checked — `attempt >= NaN` is never true, so an unset env var would retry forever. */
601
- function settle$1(options, where) {
602
- 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;
603
- if (!Number.isInteger(retries) || retries < 0) throw new TypeError(`arthron: ${where} retries: ${retries} needs a whole number ≥ 0 — e.g. retries: 2`);
604
- return {
605
- retries,
606
- retryOn,
607
- allowed: new Set(methods.map((m) => m.toUpperCase())),
608
- delay,
609
- maxRetryAfter,
610
- shouldRetry,
611
- onRetry
612
- };
613
- }
614
- /**
615
- * Retry on network errors and the configured statuses by calling `next`
616
- * again — the whole attempt (request interceptors included) is re-run.
617
- * Only `ArthronError` is considered; cancellation and plugin bugs pass through.
618
- * A call can turn it off or override its options with `meta.retry`.
619
- */
620
- function retry(options = {}) {
621
- const { sleep = defaultSleep, ...rest } = options;
622
- const base = settle$1(rest, "retry()");
623
- return {
624
- name: "retry",
625
- middleware: async (ctx, next) => {
626
- const override = ctx.meta?.retry;
627
- if (override === false) return next(ctx);
628
- const settings = override ? settle$1({
629
- ...rest,
630
- ...override
631
- }, "meta.retry") : base;
632
- const method = (ctx.init.method ?? "GET").toUpperCase();
633
- const resendable = !isReadableStream(ctx.init.body);
634
- for (let attempt = 0;; attempt += 1) try {
635
- return await next(ctx);
636
- } catch (thrown) {
637
- if (!resendable || attempt >= settings.retries) throw thrown;
638
- if (ctx.init.signal?.aborted) throw thrown;
639
- const verdict = settings.shouldRetry?.(thrown, attempt, ctx);
640
- if (verdict === false) throw thrown;
641
- if (verdict === void 0 && !byDefault(thrown, method, settings)) throw thrown;
642
- const retryAfter = isArthronError(thrown) ? retryAfterMs(thrown) : void 0;
643
- if (retryAfter !== void 0 && retryAfter > settings.maxRetryAfter) throw thrown;
644
- const wait = Math.min(retryAfter ?? settings.delay(attempt, thrown), MAX_TIMER_MS$1);
645
- settings.onRetry?.(thrown, attempt, wait);
646
- await sleep(wait, ctx.init.signal);
647
- }
648
- }
649
- };
650
- }
651
- //#endregion
652
- //#region src/plugins/timeout/timeout.ts
653
- const MAX_TIMER_MS = 2 ** 31 - 1;
654
- /**
655
- * Wrap `body` so the deadline keeps running until it is fully read, and so an
656
- * abort while reading surfaces as a stream error instead of a silent stall.
657
- */
658
- function guardBody(body, signal, finish, onChunk) {
659
- const reader = body.getReader();
660
- return new ReadableStream({
661
- start(controller) {
662
- signal.addEventListener("abort", () => {
663
- controller.error(signal.reason);
664
- reader.cancel(signal.reason).catch(() => {});
665
- }, { once: true });
666
- },
667
- async pull(controller) {
668
- const { value, done } = await reader.read();
669
- if (done) {
670
- finish();
671
- controller.close();
672
- return;
673
- }
674
- onChunk();
675
- controller.enqueue(value);
676
- },
677
- cancel(reason) {
678
- finish();
679
- return reader.cancel(reason);
680
- }
681
- });
682
- }
683
- /** setTimeout fires at once for NaN, negatives and anything past 2^31-1. */
684
- function checkDeadline(ms, where) {
685
- if (ms === Number.POSITIVE_INFINITY || ms >= 0 && ms <= MAX_TIMER_MS) return;
686
- throw new TypeError(`arthron: ${where} needs 0–${MAX_TIMER_MS} ms — pass Infinity for no deadline`);
687
- }
688
- /** A timer that aborts `controller` with a TimeoutError and never keeps Node alive on its own. */
689
- function deadlineTimer(controller, ms, message) {
690
- const timer = setTimeout(() => controller.abort(new DOMException(message, "TimeoutError")), ms);
691
- timer.unref?.();
692
- return timer;
693
- }
694
- /** `timeout(ms)`, `timeout({ total, idle })` or the deprecated `timeout(ms, { idle })`, checked. */
695
- function settle(first, second) {
696
- if (typeof first === "number") {
697
- checkDeadline(first, `timeout(${first})`);
698
- const { idle } = second;
699
- if (idle !== void 0) checkDeadline(idle, `timeout(…, { idle: ${idle} })`);
700
- return {
701
- ms: first,
702
- idle
703
- };
704
- }
705
- const { total, idle } = first;
706
- if (total === void 0 && idle === void 0) throw new TypeError("arthron: timeout({}) needs total or idle — e.g. timeout({ total: 10_000 }) or timeout({ idle: 30_000 })");
707
- if (total !== void 0) checkDeadline(total, `timeout({ total: ${total} })`);
708
- if (idle !== void 0) checkDeadline(idle, `timeout({ idle: ${idle} })`);
709
- return {
710
- ms: total ?? Number.POSITIVE_INFINITY,
711
- idle
712
- };
713
- }
714
- function timeout(first, second = {}) {
715
- const { ms, idle } = settle(first, second);
716
- return {
717
- name: "timeout",
718
- middleware: async (ctx, next) => {
719
- const deadline = ctx.meta?.timeout ?? ms;
720
- if (deadline !== ms) checkDeadline(deadline, `meta.timeout: ${deadline}`);
721
- const idleMs = idle === Number.POSITIVE_INFINITY ? void 0 : idle;
722
- if (deadline === Number.POSITIVE_INFINITY && idleMs === void 0) return next(ctx);
723
- const controller = new AbortController();
724
- const outer = ctx.init.signal;
725
- const signal = outer ? AbortSignal.any([outer, controller.signal]) : controller.signal;
726
- const total = deadline === Number.POSITIVE_INFINITY ? void 0 : deadlineTimer(controller, deadline, `arthron: request timed out after ${deadline}ms`);
727
- let quiet;
728
- const stillAlive = () => {
729
- if (idleMs === void 0) return;
730
- clearTimeout(quiet);
731
- quiet = deadlineTimer(controller, idleMs, `arthron: no data for ${idleMs}ms`);
732
- };
733
- const finish = () => {
734
- clearTimeout(total);
735
- clearTimeout(quiet);
736
- };
737
- stillAlive();
738
- let res;
739
- try {
740
- res = await next({
741
- ...ctx,
742
- init: {
743
- ...ctx.init,
744
- signal
745
- }
746
- });
747
- } catch (thrown) {
748
- finish();
749
- throw thrown;
750
- }
751
- if (!res.body) {
752
- finish();
753
- return res;
754
- }
755
- stillAlive();
756
- return rebuildResponse(res, guardBody(res.body, signal, finish, stillAlive));
757
- }
758
- };
759
- }
760
- //#endregion
761
- //#region src/plugins/upload-progress/form-data.ts
762
- const encoder = new TextEncoder();
763
- /** Names and filenames inside the quoted Content-Disposition parameters. */
764
- const escapeName = (name) => name.replace(/\n/g, "%0A").replace(/\r/g, "%0D").replace(/"/g, "%22");
765
- /** String values go out with CRLF line breaks, as the runtime's encoder sends them. */
766
- const normalizeBreaks = (value) => value.replace(/\r\n|\r|\n/g, "\r\n");
767
- function boundary() {
768
- const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
769
- return `----arthron${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
770
- }
771
- /**
772
- * A FormData body as multipart/form-data, with its length known before
773
- * anything is read: the parts' headers and string values are encoded up
774
- * front, files are only counted by size and streamed while the body is
775
- * sent — a large file is not held in memory to learn the length.
776
- */
777
- function encodeFormData(form) {
778
- const b = boundary();
779
- const parts = [];
780
- for (const [name, value] of form) {
781
- let head = `--${b}\r\nContent-Disposition: form-data; name="${escapeName(name)}"`;
782
- if (typeof value === "string") {
783
- parts.push(encoder.encode(`${head}\r\n\r\n`));
784
- parts.push(encoder.encode(normalizeBreaks(value)));
785
- } else {
786
- head += `; filename="${escapeName(value.name)}"\r\nContent-Type: ${value.type || "application/octet-stream"}`;
787
- parts.push(encoder.encode(`${head}\r\n\r\n`));
788
- parts.push(value);
789
- }
790
- parts.push(encoder.encode("\r\n"));
791
- }
792
- parts.push(encoder.encode(`--${b}--\r\n`));
793
- const total = parts.reduce((sum, part) => sum + (part instanceof Uint8Array ? part.byteLength : part.size), 0);
794
- let index = 0;
795
- let reader;
796
- return {
797
- stream: new ReadableStream({
798
- async pull(controller) {
799
- for (;;) {
800
- if (reader) {
801
- const { value, done } = await reader.read();
802
- if (!done) return controller.enqueue(value);
803
- reader = void 0;
804
- }
805
- const part = parts[index++];
806
- if (part === void 0) return controller.close();
807
- if (part instanceof Uint8Array) return controller.enqueue(part);
808
- reader = part.stream().getReader();
809
- }
810
- },
811
- cancel: (reason) => reader?.cancel(reason)
812
- }, { highWaterMark: 0 }),
813
- total,
814
- type: `multipart/form-data; boundary=${b}`
815
- };
816
- }
817
- //#endregion
818
- //#region src/plugins/upload-progress/progress-event.ts
819
- /** An empty body is complete at once, not `0 / 0`. */
820
- function progressEvent(loaded, total) {
821
- return {
822
- loaded,
823
- total,
824
- progress: total === void 0 ? void 0 : total === 0 ? 1 : loaded / total
825
- };
826
- }
827
- //#endregion
828
- //#region src/plugins/upload-progress/request-parts.ts
829
- /**
830
- * What a transport needs from fetch's arguments, in any shape a plugin
831
- * wrapping it may pass: arthron sends a URL string and a lowercase header
832
- * record, a wrapper may pass a Request, a URL or Headers.
833
- */
834
- const urlOf = (input) => input instanceof Request ? input.url : String(input);
835
- /** A Request's headers, then init's on top, as one lowercase record. */
836
- function headersOf(input, init) {
837
- const merged = new Headers(input instanceof Request ? input.headers : void 0);
838
- for (const [name, value] of new Headers(init.headers)) merged.set(name, value);
839
- return Object.fromEntries(merged);
840
- }
841
- //#endregion
842
- //#region src/plugins/upload-progress/counting-fetch.ts
843
- /**
844
- * The most one progress step covers. Chunks are cut to it whatever their
845
- * source: Node hands an in-memory Blob over as a single chunk, which would
846
- * jump from 0 to 100%.
847
- */
848
- const SLICE = 65536;
849
- const fromBytes = (bytes, type) => ({
850
- stream: new ReadableStream({ start(controller) {
851
- controller.enqueue(bytes);
852
- controller.close();
853
- } }),
854
- total: bytes.byteLength,
855
- type: type ?? void 0
856
- });
857
- /** The body as bytes to count, or `undefined` for none (or one fetch should get as is). */
858
- async function sourceOf(body, headers) {
859
- if (body === void 0 || body === null) return void 0;
860
- if (typeof body === "string") return fromBytes(new TextEncoder().encode(body), "text/plain;charset=UTF-8");
861
- if (ArrayBuffer.isView(body)) return fromBytes(new Uint8Array(body.buffer, body.byteOffset, body.byteLength));
862
- switch (tagOf(body)) {
863
- case "Blob":
864
- case "File": {
865
- const blob = body;
866
- return {
867
- stream: blob.stream(),
868
- total: blob.size,
869
- type: blob.type || void 0
870
- };
871
- }
872
- case "ArrayBuffer": return fromBytes(new Uint8Array(body));
873
- case "URLSearchParams": return fromBytes(new TextEncoder().encode(String(body)), "application/x-www-form-urlencoded;charset=UTF-8");
874
- case "FormData": {
875
- const { stream, total, type } = encodeFormData(body);
876
- return {
877
- stream,
878
- total,
879
- type
880
- };
881
- }
882
- case "ReadableStream": {
883
- const length = Number(headers["content-length"]);
884
- return {
885
- stream: body,
886
- total: Number.isSafeInteger(length) && length >= 0 ? length : void 0,
887
- type: void 0
888
- };
889
- }
890
- default: return;
891
- }
892
- }
893
- /**
894
- * `base` with the request body counted as it is read. The body is swapped
895
- * here, inside the call, so the context the plugins see keeps the caller's
896
- * own. A callback that throws fails the call with its own error, not the
897
- * "fetch failed" the body error would become.
898
- */
899
- function countingFetch(base, onProgress) {
900
- return (async (input, init = {}) => {
901
- const headers = headersOf(input, init);
902
- const source = await sourceOf(init.body, headers);
903
- if (!source) return base(input, init);
904
- const { total, type } = source;
905
- if (total !== void 0 && !("content-length" in headers)) headers["content-length"] = String(total);
906
- if (type && !("content-type" in headers)) headers["content-type"] = type;
907
- const signal = init.signal;
908
- const reader = source.stream.getReader();
909
- let loaded = 0;
910
- let rest;
911
- let reported = false;
912
- let answered = false;
913
- let failure;
914
- const report = () => {
915
- if (answered) return;
916
- reported = true;
917
- try {
918
- onProgress(progressEvent(loaded, total));
919
- } catch (error) {
920
- failure = { error };
921
- throw error;
922
- }
923
- };
924
- const body = new ReadableStream({
925
- async pull(controller) {
926
- const stopped = async () => {
927
- if (!signal?.aborted) return false;
928
- await reader.cancel(signal.reason).catch(() => {});
929
- controller.error(signal.reason);
930
- return true;
931
- };
932
- if (await stopped()) return;
933
- let chunk = rest;
934
- if (!chunk?.byteLength) {
935
- const { value, done } = await reader.read();
936
- if (done) {
937
- if (!reported) report();
938
- return controller.close();
939
- }
940
- chunk = value;
941
- if (await stopped()) return;
942
- }
943
- rest = chunk.subarray(SLICE);
944
- const piece = chunk.subarray(0, SLICE);
945
- loaded += piece.byteLength;
946
- report();
947
- controller.enqueue(piece);
948
- },
949
- cancel: (reason) => reader.cancel(reason)
950
- }, { highWaterMark: 0 });
951
- try {
952
- const res = await base(input, {
953
- ...init,
954
- body,
955
- headers,
956
- duplex: "half"
957
- });
958
- answered = true;
959
- return res;
960
- } catch (error) {
961
- throw failure ? failure.error : error;
962
- }
963
- });
964
- }
965
- //#endregion
966
- //#region src/plugins/upload-progress/xhr-fetch.ts
967
- const NULL_BODY_STATUSES = /* @__PURE__ */ new Set([
968
- 101,
969
- 204,
970
- 205,
971
- 304
972
- ]);
973
- /** `getAllResponseHeaders()` as `Headers`; lines without a name are skipped. */
974
- function parseResponseHeaders(raw) {
975
- const headers = new Headers();
976
- for (const line of raw.split(/\r?\n/)) {
977
- const colon = line.indexOf(":");
978
- if (colon <= 0) continue;
979
- const name = line.slice(0, colon).trim();
980
- if (name) headers.append(name, line.slice(colon + 1).trim());
981
- }
982
- return headers;
983
- }
984
- /**
985
- * What fetch would report for this response. XHR gives only the final URL;
986
- * the request URL is normalized (case, default port, no fragment) before
987
- * comparing, so writing it differently is not a redirect.
988
- */
989
- function responseFields(requestUrl, responseURL, pageHref) {
990
- const requested = new URL(requestUrl, pageHref);
991
- requested.hash = "";
992
- const final = new URL(responseURL || requested.href, pageHref);
993
- const sameOrigin = pageHref !== void 0 && final.origin === new URL(pageHref).origin;
994
- return {
995
- url: final.href,
996
- redirected: final.href !== requested.href,
997
- type: sameOrigin ? "basic" : "cors"
998
- };
999
- }
1000
- /**
1001
- * A fetch over XMLHttpRequest, for the one thing fetch cannot do in a
1002
- * browser: report upload progress. It honours what an arthron attempt
1003
- * carries — method, headers, body, `credentials: "include"`, `signal` — and
1004
- * fails the way fetch does, so `send` makes the same ArthronErrors: a
1005
- * network failure rejects with a TypeError, an abort with the signal's
1006
- * reason. The response arrives whole; XHR does not stream it.
1007
- */
1008
- function xhrFetch(onProgress) {
1009
- return ((input, init = {}) => new Promise((resolve, reject) => {
1010
- const url = urlOf(input);
1011
- const { signal } = init;
1012
- if (signal?.aborted) return reject(signal.reason);
1013
- const xhr = new XMLHttpRequest();
1014
- let settled = false;
1015
- const settle = (done) => {
1016
- if (settled) return;
1017
- settled = true;
1018
- signal?.removeEventListener("abort", onAbort);
1019
- done();
1020
- };
1021
- const fail = (error) => {
1022
- settle(() => reject(error));
1023
- xhr.abort();
1024
- };
1025
- const onAbort = () => fail(signal?.reason);
1026
- signal?.addEventListener("abort", onAbort, { once: true });
1027
- let reported;
1028
- const report = (loaded, total) => {
1029
- if (settled || reported?.loaded === loaded && reported.total === total) return;
1030
- reported = progressEvent(loaded, total);
1031
- try {
1032
- onProgress(reported);
1033
- } catch (error) {
1034
- fail(error);
1035
- }
1036
- };
1037
- const fromEvent = (e) => report(e.loaded, e.lengthComputable ? e.total : void 0);
1038
- xhr.upload.onprogress = fromEvent;
1039
- xhr.upload.onload = fromEvent;
1040
- xhr.onload = () => {
1041
- if (!reported) report(0, 0);
1042
- if (xhr.status === 0) return settle(() => reject(/* @__PURE__ */ new TypeError("Network request failed")));
1043
- const headers = parseResponseHeaders(xhr.getAllResponseHeaders());
1044
- const body = NULL_BODY_STATUSES.has(xhr.status) ? null : xhr.response;
1045
- const res = new Response(body, {
1046
- status: xhr.status,
1047
- statusText: xhr.statusText,
1048
- headers
1049
- });
1050
- const page = globalThis.location?.href;
1051
- settle(() => resolve(withFields(res, responseFields(url, xhr.responseURL, page))));
1052
- };
1053
- xhr.onerror = xhr.ontimeout = () => settle(() => reject(/* @__PURE__ */ new TypeError("Network request failed")));
1054
- xhr.open(init.method ?? "GET", url);
1055
- xhr.responseType = "arraybuffer";
1056
- if (init.credentials === "include") xhr.withCredentials = true;
1057
- for (const [name, value] of Object.entries(headersOf(input, init))) xhr.setRequestHeader(name, value);
1058
- xhr.send(init.body ?? null);
1059
- }));
1060
- }
1061
- //#endregion
1062
- //#region src/plugins/upload-progress/upload-progress.ts
1063
- /**
1064
- * How a progress call can be sent here. Node first — jsdom on Node has an
1065
- * XMLHttpRequest too, but Node's fetch is the real transport there. Bun,
1066
- * Deno, Electron and Cloudflare Workers also expose `process.versions.node`;
1067
- * only Node was shown to keep content-length on a counted body, and an
1068
- * Electron renderer has XHR.
1069
- */
1070
- function transportFor(runtime) {
1071
- const { versions, hasXhr, userAgent, deno } = runtime;
1072
- if (Boolean(versions?.node) && !versions?.bun && !versions?.electron && !deno && userAgent !== "Cloudflare-Workers") return "count";
1073
- return hasXhr ? "xhr" : void 0;
1074
- }
1075
- function currentRuntime() {
1076
- const g = globalThis;
1077
- const runtime = {
1078
- hasXhr: typeof XMLHttpRequest === "function",
1079
- deno: "Deno" in globalThis
1080
- };
1081
- if (g.process?.versions) runtime.versions = g.process.versions;
1082
- if (g.navigator?.userAgent) runtime.userAgent = g.navigator.userAgent;
1083
- return runtime;
1084
- }
1085
- /**
1086
- * What an XHR cannot honour and whose loss would change the call: it always
1087
- * follows redirects, has no no-cors mode, and always sends cookies to its own
1088
- * origin. `undefined` when the call can go over XHR.
1089
- */
1090
- function xhrRefusal(init, url, pageHref) {
1091
- if (init.redirect === "manual" || init.redirect === "error") return `redirect: "${init.redirect}" — XMLHttpRequest always follows redirects`;
1092
- if (init.mode === "no-cors") return "mode: \"no-cors\" — XMLHttpRequest has no no-cors mode";
1093
- if (init.credentials === "omit" && pageHref) {
1094
- const page = new URL(pageHref).origin;
1095
- if (new URL(url, pageHref).origin === page) return "credentials: \"omit\" on this origin — XMLHttpRequest always sends cookies to its own origin";
1096
- }
1097
- }
1098
- /**
1099
- * The attempt with its own signal, joined to the call's. A callback that
1100
- * throws aborts the attempt with its error: send passes an abort reason on
1101
- * untouched, so it stays a plugin exception — not a network ArthronError
1102
- * that error interceptors would see and retry would repeat.
1103
- */
1104
- function withOwnSignal(ctx, onProgress) {
1105
- const own = new AbortController();
1106
- const outer = ctx.init.signal;
1107
- const signal = outer ? AbortSignal.any([outer, own.signal]) : own.signal;
1108
- const report = (event) => {
1109
- if (own.signal.aborted) return;
1110
- try {
1111
- onProgress(event);
1112
- } catch (error) {
1113
- own.abort(error);
1114
- }
1115
- };
1116
- return {
1117
- ctx: {
1118
- ...ctx,
1119
- init: {
1120
- ...ctx.init,
1121
- signal
1122
- }
1123
- },
1124
- report
1125
- };
1126
- }
1127
- /**
1128
- * Does nothing by itself — only a call that passes `meta.onUploadProgress`
1129
- * reports upload progress. On such a call it changes how that one attempt
1130
- * is sent: over XMLHttpRequest in a browser, with a counted body in Node.
1131
- * Every other call, on this client too, goes out exactly as before.
1132
- *
1133
- * Progress needs the runtime's own fetch: with an injected `fetch`, another
1134
- * plugin's transport, or a runtime that is neither (Deno, Bun, Cloudflare
1135
- * Workers, a service worker), the call is sent as usual without progress.
1136
- * Put it last in `use`: the call's signal carries a throwing callback's
1137
- * error, and an interceptor after it that replaces the signal would cut it.
1138
- */
1139
- function uploadProgress() {
1140
- return {
1141
- name: "uploadProgress",
1142
- request: (ctx) => {
1143
- const onProgress = ctx.meta?.onUploadProgress;
1144
- const { body } = ctx.init;
1145
- if (!onProgress || body === void 0 || body === null) return ctx;
1146
- if (ctx.fetch !== globalThis.fetch) return ctx;
1147
- const transport = transportFor(currentRuntime());
1148
- if (!transport) return ctx;
1149
- if (transport === "xhr") {
1150
- const page = globalThis.location?.href;
1151
- const refusal = xhrRefusal(ctx.init, ctx.url, page);
1152
- if (refusal) throw new TypeError(`arthron: onUploadProgress cannot be combined with ${refusal}; drop one of them`);
1153
- if (isReadableStream(body)) return ctx;
1154
- }
1155
- const { ctx: next, report } = withOwnSignal(ctx, onProgress);
1156
- return {
1157
- ...next,
1158
- fetch: transport === "xhr" ? xhrFetch(report) : countingFetch(ctx.fetch, report)
1159
- };
1160
- }
1161
- };
1162
- }
1163
- //#endregion
1164
- export { redactUrl as _, cancellationError as a, responseError as c, runtimeStreamsRequestBody as d, serializeBody as f, leavesBase as g, fillPath as h, logging as i, bearerAuth as l, isArthronError as m, timeout as n, isCancellation as o, ArthronError as p, retry as r, networkError as s, uploadProgress as t, isReadableStream as u, redactUrlIn as v, resolveUrl as y };
1165
-
1166
- //# sourceMappingURL=plugins-CHCMxtAE.mjs.map