arthron 1.1.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/plugins.cjs CHANGED
@@ -1,6 +1,847 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_plugins = require("./plugins-CVrzb8x6.cjs");
3
- exports.bearerAuth = require_plugins.bearerAuth;
4
- exports.logging = require_plugins.logging;
5
- exports.retry = require_plugins.retry;
6
- exports.timeout = require_plugins.timeout;
2
+ const require_create_error = require("./shared.cjs");
3
+ //#region src/plugins/bearer-auth/bearer-auth.ts
4
+ /** 1.x option, its replacement, and how to pass it. */
5
+ const REPLACED$1 = [
6
+ [
7
+ "token",
8
+ "accessTokenFn",
9
+ "accessTokenFn"
10
+ ],
11
+ [
12
+ "refresh",
13
+ "refreshFn",
14
+ "refreshFn"
15
+ ],
16
+ [
17
+ "origin",
18
+ "tokenOrigins",
19
+ "tokenOrigins: [url]"
20
+ ]
21
+ ];
22
+ const EXAMPLE = "tokenOrigins: [\"https://api.example.com\"]";
23
+ /** `tokenOrigins` as origins, checked when the plugin is created. */
24
+ function tokenOriginSet(urls) {
25
+ if (!Array.isArray(urls)) throw new TypeError(`arthron: bearerAuth({ tokenOrigins }) takes a list of URLs, got ${typeof urls === "string" ? "a string" : String(urls)} — e.g. ${EXAMPLE}`);
26
+ return require_create_error.httpOrigins(urls, {
27
+ empty: `arthron: bearerAuth({ tokenOrigins }) needs at least one URL — e.g. ${EXAMPLE}`,
28
+ invalid: (url) => `arthron: bearerAuth({ tokenOrigins }) needs http(s) URLs, got ${url} — e.g. ${EXAMPLE}`
29
+ });
30
+ }
31
+ /**
32
+ * Bearer-token auth. A request interceptor sets `authorization: Bearer
33
+ * <token>` on every attempt to `tokenOrigins`; with `refreshFn`, a middleware turns a
34
+ * 401 into one refresh and one more try. Concurrent 401s share the refresh
35
+ * (with refresh-token rotation, parallel refreshes revoke each other), and a
36
+ * call whose token was replaced meanwhile just retries. A call opts out with
37
+ * `meta: { bearerAuth: false }`.
38
+ */
39
+ function bearerAuth(options) {
40
+ for (const [old, now, fix] of REPLACED$1) if (options[old] !== void 0) throw new TypeError(`arthron: bearerAuth's ${old} option was replaced by ${now} in 2.0 — pass bearerAuth({ ${fix} })`);
41
+ const { accessTokenFn, refreshFn } = options;
42
+ const allowed = tokenOriginSet(options.tokenOrigins);
43
+ const ours = (url) => allowed.has(require_create_error.originOf(url) ?? "");
44
+ const bearer = async () => {
45
+ const value = await accessTokenFn();
46
+ return value ? `Bearer ${value}` : void 0;
47
+ };
48
+ let refreshing;
49
+ let inRefresh = false;
50
+ const startRefresh = (refresh) => {
51
+ inRefresh = true;
52
+ try {
53
+ return refresh().finally(() => {
54
+ refreshing = void 0;
55
+ });
56
+ } finally {
57
+ inRefresh = false;
58
+ }
59
+ };
60
+ return {
61
+ pluginName: "bearerAuth",
62
+ requestInterceptor: async (ctx) => {
63
+ if (ctx.meta?.bearerAuth === false || !ours(ctx.url)) return ctx;
64
+ const value = await bearer();
65
+ if (!value) return ctx;
66
+ return {
67
+ ...ctx,
68
+ init: {
69
+ ...ctx.init,
70
+ headers: {
71
+ ...ctx.init.headers,
72
+ authorization: value
73
+ }
74
+ }
75
+ };
76
+ },
77
+ middleware: async (ctx, next) => {
78
+ if (!refreshFn || ctx.meta?.bearerAuth === false) return next(ctx);
79
+ if (inRefresh) return next({
80
+ ...ctx,
81
+ meta: {
82
+ ...ctx.meta,
83
+ bearerAuth: false
84
+ }
85
+ });
86
+ if (require_create_error.isReadableStream(ctx.init.body)) return next(ctx);
87
+ try {
88
+ return await next(ctx);
89
+ } catch (error) {
90
+ if (!require_create_error.isArthronError(error) || error.status !== 401) throw error;
91
+ if (!ours(error.requestContext.url)) throw error;
92
+ if (error.requestContext.init.headers.authorization === await bearer()) {
93
+ refreshing ??= startRefresh(refreshFn);
94
+ await refreshing;
95
+ }
96
+ return next(ctx);
97
+ }
98
+ }
99
+ };
100
+ }
101
+ //#endregion
102
+ //#region src/plugins/logging/logging.ts
103
+ function consoleSink(logger) {
104
+ return (e) => {
105
+ if (e.phase === "start") logger.log(`[arthron] → ${e.method} ${e.url}`);
106
+ else if (e.phase === "success") logger.log(`[arthron] ← ${e.method} ${e.url} ${e.status} ${e.duration}ms`);
107
+ else logger.error(`[arthron] ✕ ${e.method} ${e.url} ${e.status ?? "-"} ${e.duration}ms`);
108
+ };
109
+ }
110
+ /**
111
+ * One event when a request starts, one when it settles, with status and
112
+ * elapsed time. Wraps the whole call, so retries are included in the time.
113
+ * Default output is a console line per event; pass `onEvent` to get the
114
+ * structured event instead. Cancellations are not reported.
115
+ */
116
+ function logging(options = {}) {
117
+ if (options.now !== void 0) throw new TypeError("arthron: logging's now option left the public options in 2.0 — mock performance.now in tests (vi.spyOn(performance, \"now\"))");
118
+ return loggingWithClock(options, () => performance.now());
119
+ }
120
+ /** `logging()` with the clock injected — for this package's tests; `arthron/plugins` does not export it. */
121
+ function loggingWithClock(options, now) {
122
+ const { enabled = true, logger = console, onEvent } = options;
123
+ const emit = onEvent ?? consoleSink(logger);
124
+ return {
125
+ pluginName: "logging",
126
+ middleware: async (ctx, next) => {
127
+ if (!enabled) return next(ctx);
128
+ const method = ctx.init.method ?? "GET";
129
+ const url = require_create_error.redactUrl(ctx.url);
130
+ const started = now();
131
+ const duration = () => Math.round(now() - started);
132
+ emit({
133
+ phase: "start",
134
+ method,
135
+ url
136
+ });
137
+ let res;
138
+ try {
139
+ res = await next(ctx);
140
+ } catch (error) {
141
+ if (!require_create_error.isCancellation(error, ctx.init.signal)) {
142
+ const status = require_create_error.isArthronError(error) ? error.status : void 0;
143
+ emit({
144
+ phase: "error",
145
+ method,
146
+ url,
147
+ status,
148
+ duration: duration(),
149
+ error
150
+ });
151
+ }
152
+ throw error;
153
+ }
154
+ emit({
155
+ phase: "success",
156
+ method,
157
+ url,
158
+ status: res.status,
159
+ duration: duration()
160
+ });
161
+ return res;
162
+ }
163
+ };
164
+ }
165
+ //#endregion
166
+ //#region src/plugins/retry/retry.ts
167
+ const DEFAULT_STATUSES = [
168
+ 408,
169
+ 429,
170
+ 500,
171
+ 502,
172
+ 503,
173
+ 504
174
+ ];
175
+ const DEFAULT_METHODS = [
176
+ "GET",
177
+ "HEAD",
178
+ "OPTIONS",
179
+ "QUERY",
180
+ "PUT",
181
+ "DELETE"
182
+ ];
183
+ const DEFAULT_MAX_RETRY_AFTER = 6e4;
184
+ /** setTimeout fires at once past this, so every wait is capped to it. */
185
+ const MAX_TIMER_MS$1 = 2 ** 31 - 1;
186
+ function defaultSleep(ms, signal) {
187
+ return new Promise((resolve, reject) => {
188
+ if (signal?.aborted) return reject(signal.reason);
189
+ const onAbort = () => {
190
+ clearTimeout(timer);
191
+ reject(signal?.reason);
192
+ };
193
+ const timer = setTimeout(() => {
194
+ signal?.removeEventListener("abort", onAbort);
195
+ resolve();
196
+ }, ms);
197
+ signal?.addEventListener("abort", onAbort, { once: true });
198
+ });
199
+ }
200
+ /** `Retry-After` as ms — seconds or an HTTP-date. `undefined` when absent or unparsable. */
201
+ function retryAfterMs(error) {
202
+ const value = error.headers.get("retry-after")?.trim();
203
+ if (!value) return void 0;
204
+ const seconds = Number(value);
205
+ if (!Number.isNaN(seconds)) return Math.max(0, seconds * 1e3);
206
+ const at = Date.parse(value);
207
+ if (Number.isNaN(at)) return void 0;
208
+ return Math.max(0, at - Date.now());
209
+ }
210
+ /**
211
+ * `delayFn`'s answer, checked: setTimeout fires at once for NaN and
212
+ * negatives, so a 1.x `delay` moved over with its arguments swapped would
213
+ * retry in a tight loop.
214
+ */
215
+ function checkedDelay(ms) {
216
+ if (typeof ms === "number" && Number.isFinite(ms) && ms >= 0) return ms;
217
+ const shown = typeof ms === "string" ? JSON.stringify(ms) : String(ms);
218
+ throw new TypeError(`arthron: retry's delayFn returned ${shown} — return the wait in ms, a finite number ≥ 0`);
219
+ }
220
+ /** The rule without `shouldRetry`: a network error or a status in `statuses`, on a method in `methods`. */
221
+ function byDefault(thrown, method, settings) {
222
+ if (!settings.allowedMethods.has(method) || !require_create_error.isArthronError(thrown)) return false;
223
+ return thrown.status === 0 || settings.statuses.includes(thrown.status);
224
+ }
225
+ /** 1.x options and what replaced them: unknown keys would be ignored. */
226
+ const REPLACED = {
227
+ delay: "delayFn",
228
+ retryOn: "statuses"
229
+ };
230
+ /** Defaults filled in and `retries` checked — `attempt >= NaN` is never true, so an unset env var would retry forever. */
231
+ function resolveRetryOptions(options, where) {
232
+ const given = options;
233
+ for (const [old, now] of Object.entries(REPLACED)) if (given[old] !== void 0) {
234
+ const fix = where === "meta.retry" ? `meta: { retry: { ${now} } }` : `retry({ ${now} })`;
235
+ throw new TypeError(`arthron: ${where} ${old} was replaced by ${now} in 2.0 — pass ${fix}`);
236
+ }
237
+ if (given.sleep !== void 0) throw new TypeError(`arthron: ${where} sleep left the public options in 2.0 — use fake timers in tests (vi.useFakeTimers())`);
238
+ const { retries = 2, statuses = DEFAULT_STATUSES, methods = DEFAULT_METHODS, delayFn = (attempt) => Math.random() * 300 * 2 ** attempt, maxRetryAfter = DEFAULT_MAX_RETRY_AFTER, shouldRetry, onRetry } = options;
239
+ if (!Number.isInteger(retries) || retries < 0) throw new TypeError(`arthron: ${where} retries: ${retries} needs a whole number ≥ 0 — e.g. retries: 2`);
240
+ return {
241
+ retries,
242
+ statuses,
243
+ allowedMethods: new Set(methods.map((m) => m.toUpperCase())),
244
+ delayFn,
245
+ maxRetryAfter,
246
+ shouldRetry,
247
+ onRetry
248
+ };
249
+ }
250
+ /**
251
+ * Retry on network errors and the configured statuses by calling `next`
252
+ * again — the whole attempt (request interceptors included) is re-run.
253
+ * Only `ArthronError` is considered; cancellation and plugin bugs pass through.
254
+ * A call can turn it off or override its options with `meta.retry`.
255
+ */
256
+ function retry(options = {}) {
257
+ return retryWithSleep(options, defaultSleep);
258
+ }
259
+ /** `retry()` with the wait injected — for this package's tests; `arthron/plugins` does not export it. */
260
+ function retryWithSleep(options, sleep) {
261
+ const base = resolveRetryOptions(options, "retry()");
262
+ return {
263
+ pluginName: "retry",
264
+ middleware: async (ctx, next) => {
265
+ const override = ctx.meta?.retry;
266
+ if (override === false) return next(ctx);
267
+ const settings = override ? resolveRetryOptions({
268
+ ...options,
269
+ ...override
270
+ }, "meta.retry") : base;
271
+ const method = (ctx.init.method ?? "GET").toUpperCase();
272
+ const resendable = !require_create_error.isReadableStream(ctx.init.body);
273
+ for (let attempt = 0;; attempt += 1) try {
274
+ return await next(ctx);
275
+ } catch (thrown) {
276
+ if (!resendable || attempt >= settings.retries) throw thrown;
277
+ if (ctx.init.signal?.aborted) throw thrown;
278
+ const verdict = settings.shouldRetry?.(thrown, attempt, ctx);
279
+ if (verdict === false) throw thrown;
280
+ if (verdict === void 0 && !byDefault(thrown, method, settings)) throw thrown;
281
+ const retryAfter = require_create_error.isArthronError(thrown) ? retryAfterMs(thrown) : void 0;
282
+ if (retryAfter !== void 0 && retryAfter > settings.maxRetryAfter) throw thrown;
283
+ const delay = Math.min(retryAfter ?? checkedDelay(settings.delayFn(attempt, thrown)), MAX_TIMER_MS$1);
284
+ settings.onRetry?.(thrown, attempt, delay);
285
+ await sleep(delay, ctx.init.signal);
286
+ }
287
+ }
288
+ };
289
+ }
290
+ //#endregion
291
+ //#region src/plugins/timeout/timeout.ts
292
+ const MAX_TIMER_MS = 2 ** 31 - 1;
293
+ /**
294
+ * Wrap `body` so the deadline keeps running until it is fully read, and so an
295
+ * abort while reading surfaces as a stream error instead of a silent stall.
296
+ */
297
+ function guardBody(body, signal, finish, onChunk) {
298
+ const reader = body.getReader();
299
+ let onAbort = () => {};
300
+ const release = () => {
301
+ signal.removeEventListener("abort", onAbort);
302
+ finish();
303
+ };
304
+ return new ReadableStream({
305
+ start(controller) {
306
+ onAbort = () => {
307
+ controller.error(signal.reason);
308
+ reader.cancel(signal.reason).catch(() => {});
309
+ };
310
+ signal.addEventListener("abort", onAbort, { once: true });
311
+ },
312
+ async pull(controller) {
313
+ let chunk;
314
+ try {
315
+ chunk = await reader.read();
316
+ } catch (error) {
317
+ release();
318
+ throw error;
319
+ }
320
+ if (chunk.done) {
321
+ release();
322
+ controller.close();
323
+ return;
324
+ }
325
+ onChunk();
326
+ controller.enqueue(chunk.value);
327
+ },
328
+ cancel(reason) {
329
+ release();
330
+ return reader.cancel(reason);
331
+ }
332
+ });
333
+ }
334
+ /** setTimeout fires at once for NaN, negatives and anything past 2^31-1. */
335
+ function checkDeadline(ms, where) {
336
+ if (ms === Number.POSITIVE_INFINITY || ms >= 0 && ms <= MAX_TIMER_MS) return;
337
+ throw new TypeError(`arthron: ${where} needs 0–${MAX_TIMER_MS} ms — pass Infinity for no deadline`);
338
+ }
339
+ /** A timer that aborts `controller` with a TimeoutError and never keeps Node alive on its own. */
340
+ function deadlineTimer(controller, ms, message) {
341
+ const timer = setTimeout(() => controller.abort(new DOMException(message, "TimeoutError")), ms);
342
+ timer.unref?.();
343
+ return timer;
344
+ }
345
+ /** `timeout(ms)` or `timeout({ total, idle })`, checked. */
346
+ function resolveTimeoutOptions(first) {
347
+ if (typeof first === "number") {
348
+ checkDeadline(first, `timeout(${first})`);
349
+ return {
350
+ ms: first,
351
+ idle: void 0
352
+ };
353
+ }
354
+ const { total, idle } = first;
355
+ 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 })");
356
+ if (total !== void 0) checkDeadline(total, `timeout({ total: ${total} })`);
357
+ if (idle !== void 0) checkDeadline(idle, `timeout({ idle: ${idle} })`);
358
+ return {
359
+ ms: total ?? Number.POSITIVE_INFINITY,
360
+ idle
361
+ };
362
+ }
363
+ /** One call's deadlines: the plugin's, with `meta.timeout` applied and checked. */
364
+ function forCall(base, override) {
365
+ if (override === void 0) return base;
366
+ if (override === false) return {
367
+ ms: Number.POSITIVE_INFINITY,
368
+ idle: void 0
369
+ };
370
+ if (typeof override === "number") {
371
+ checkDeadline(override, `meta.timeout: ${override}`);
372
+ return {
373
+ ...base,
374
+ ms: override
375
+ };
376
+ }
377
+ const { total, idle } = override;
378
+ if (total !== void 0) checkDeadline(total, `meta.timeout: { total: ${total} }`);
379
+ if (idle !== void 0) checkDeadline(idle, `meta.timeout: { idle: ${idle} }`);
380
+ return {
381
+ ms: total ?? base.ms,
382
+ idle: idle ?? base.idle
383
+ };
384
+ }
385
+ function timeout(first, removed) {
386
+ if (typeof removed === "object" && removed !== null) throw new TypeError("arthron: timeout(ms, { idle }) was removed in 2.0 — pass one object, timeout({ total: ms, idle })");
387
+ const base = resolveTimeoutOptions(first);
388
+ return {
389
+ pluginName: "timeout",
390
+ middleware: async (ctx, next) => {
391
+ const { ms: deadline, idle } = forCall(base, ctx.meta?.timeout);
392
+ const idleMs = idle === Number.POSITIVE_INFINITY ? void 0 : idle;
393
+ if (deadline === Number.POSITIVE_INFINITY && idleMs === void 0) return next(ctx);
394
+ const controller = new AbortController();
395
+ const outer = ctx.init.signal;
396
+ const signal = outer ? AbortSignal.any([outer, controller.signal]) : controller.signal;
397
+ const total = deadline === Number.POSITIVE_INFINITY ? void 0 : deadlineTimer(controller, deadline, `arthron: request timed out after ${deadline}ms`);
398
+ let quiet;
399
+ const stillAlive = () => {
400
+ if (idleMs === void 0) return;
401
+ clearTimeout(quiet);
402
+ quiet = deadlineTimer(controller, idleMs, `arthron: no data for ${idleMs}ms`);
403
+ };
404
+ const finish = () => {
405
+ clearTimeout(total);
406
+ clearTimeout(quiet);
407
+ };
408
+ stillAlive();
409
+ let res;
410
+ try {
411
+ res = await next({
412
+ ...ctx,
413
+ init: {
414
+ ...ctx.init,
415
+ signal
416
+ }
417
+ });
418
+ } catch (thrown) {
419
+ finish();
420
+ throw thrown;
421
+ }
422
+ if (!res.body) {
423
+ finish();
424
+ return res;
425
+ }
426
+ stillAlive();
427
+ return require_create_error.rebuildResponse(res, guardBody(res.body, signal, finish, stillAlive));
428
+ }
429
+ };
430
+ }
431
+ //#endregion
432
+ //#region src/plugins/upload-progress/form-data.ts
433
+ const encoder = new TextEncoder();
434
+ /** Names and filenames inside the quoted Content-Disposition parameters. */
435
+ const escapeName = (name) => name.replace(/\n/g, "%0A").replace(/\r/g, "%0D").replace(/"/g, "%22");
436
+ /** String values go out with CRLF line breaks, as the runtime's encoder sends them. */
437
+ const normalizeBreaks = (value) => value.replace(/\r\n|\r|\n/g, "\r\n");
438
+ function boundary() {
439
+ const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
440
+ return `----arthron${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
441
+ }
442
+ /**
443
+ * A FormData body as multipart/form-data, with its length known before
444
+ * anything is read: the parts' headers and string values are encoded up
445
+ * front, files are only counted by size and streamed while the body is
446
+ * sent — a large file is not held in memory to learn the length.
447
+ */
448
+ function encodeFormData(form) {
449
+ const b = boundary();
450
+ const parts = [];
451
+ for (const [name, value] of form) {
452
+ let head = `--${b}\r\nContent-Disposition: form-data; name="${escapeName(name)}"`;
453
+ if (typeof value === "string") {
454
+ parts.push(encoder.encode(`${head}\r\n\r\n`));
455
+ parts.push(encoder.encode(normalizeBreaks(value)));
456
+ } else {
457
+ head += `; filename="${escapeName(value.name)}"\r\nContent-Type: ${value.type || "application/octet-stream"}`;
458
+ parts.push(encoder.encode(`${head}\r\n\r\n`));
459
+ parts.push(value);
460
+ }
461
+ parts.push(encoder.encode("\r\n"));
462
+ }
463
+ parts.push(encoder.encode(`--${b}--\r\n`));
464
+ const total = parts.reduce((sum, part) => sum + (part instanceof Uint8Array ? part.byteLength : part.size), 0);
465
+ let index = 0;
466
+ let reader;
467
+ return {
468
+ stream: new ReadableStream({
469
+ async pull(controller) {
470
+ for (;;) {
471
+ if (reader) {
472
+ const { value, done } = await reader.read();
473
+ if (!done) return controller.enqueue(value);
474
+ reader = void 0;
475
+ }
476
+ const part = parts[index++];
477
+ if (part === void 0) return controller.close();
478
+ if (part instanceof Uint8Array) return controller.enqueue(part);
479
+ reader = part.stream().getReader();
480
+ }
481
+ },
482
+ cancel: (reason) => reader?.cancel(reason)
483
+ }, { highWaterMark: 0 }),
484
+ total,
485
+ type: `multipart/form-data; boundary=${b}`
486
+ };
487
+ }
488
+ //#endregion
489
+ //#region src/plugins/upload-progress/progress-event.ts
490
+ /** An empty body is complete at once, not `0 / 0`. */
491
+ function progressEvent(loaded, total) {
492
+ return {
493
+ loaded,
494
+ total,
495
+ fraction: total === void 0 ? void 0 : total === 0 ? 1 : loaded / total
496
+ };
497
+ }
498
+ //#endregion
499
+ //#region src/plugins/upload-progress/request-parts.ts
500
+ /**
501
+ * What sending an upload needs from fetch's arguments, in any shape a plugin
502
+ * wrapping it may pass: arthron sends a URL string and a lowercase header
503
+ * record, a wrapper may pass a Request, a URL or Headers.
504
+ */
505
+ const urlOf = (input) => input instanceof Request ? input.url : String(input);
506
+ /** A Request's headers, then init's on top, as one lowercase record. */
507
+ function headersOf(input, init) {
508
+ const merged = new Headers(input instanceof Request ? input.headers : void 0);
509
+ for (const [name, value] of new Headers(init.headers)) merged.set(name, value);
510
+ return Object.fromEntries(merged);
511
+ }
512
+ //#endregion
513
+ //#region src/plugins/upload-progress/counting-fetch.ts
514
+ /**
515
+ * The most one progress step covers. Chunks are cut to it whatever their
516
+ * source: Node hands an in-memory Blob over as a single chunk, which would
517
+ * jump from 0 to 100%.
518
+ */
519
+ const SLICE = 65536;
520
+ const fromBytes = (bytes, type) => ({
521
+ stream: new ReadableStream({ start(controller) {
522
+ controller.enqueue(bytes);
523
+ controller.close();
524
+ } }),
525
+ total: bytes.byteLength,
526
+ type: type ?? void 0
527
+ });
528
+ /** The body as bytes to count, or `undefined` for none (or one fetch should get as is). */
529
+ async function sourceOf(body, headers) {
530
+ if (body === void 0 || body === null) return void 0;
531
+ if (typeof body === "string") return fromBytes(new TextEncoder().encode(body), "text/plain;charset=UTF-8");
532
+ if (ArrayBuffer.isView(body)) return fromBytes(new Uint8Array(body.buffer, body.byteOffset, body.byteLength));
533
+ switch (require_create_error.tagOf(body)) {
534
+ case "Blob":
535
+ case "File": {
536
+ const blob = body;
537
+ return {
538
+ stream: blob.stream(),
539
+ total: blob.size,
540
+ type: blob.type || void 0
541
+ };
542
+ }
543
+ case "ArrayBuffer": return fromBytes(new Uint8Array(body));
544
+ case "URLSearchParams": return fromBytes(new TextEncoder().encode(String(body)), "application/x-www-form-urlencoded;charset=UTF-8");
545
+ case "FormData": {
546
+ const { stream, total, type } = encodeFormData(body);
547
+ return {
548
+ stream,
549
+ total,
550
+ type
551
+ };
552
+ }
553
+ case "ReadableStream": {
554
+ const length = Number(headers["content-length"]);
555
+ return {
556
+ stream: body,
557
+ total: Number.isSafeInteger(length) && length >= 0 ? length : void 0,
558
+ type: void 0
559
+ };
560
+ }
561
+ default: return;
562
+ }
563
+ }
564
+ /**
565
+ * `base` with the request body counted as it is read. The body is swapped
566
+ * here, inside the call, so the context the plugins see keeps the caller's
567
+ * own. A callback that throws fails the call with its own error, not the
568
+ * "fetch failed" the body error would become.
569
+ */
570
+ function countingFetch(base, onProgress) {
571
+ return (async (input, init = {}) => {
572
+ const headers = headersOf(input, init);
573
+ const source = await sourceOf(init.body, headers);
574
+ if (!source) return base(input, init);
575
+ const { total, type } = source;
576
+ if (total !== void 0 && !("content-length" in headers)) headers["content-length"] = String(total);
577
+ if (type && !("content-type" in headers)) headers["content-type"] = type;
578
+ const signal = init.signal;
579
+ const reader = source.stream.getReader();
580
+ let loaded = 0;
581
+ let rest;
582
+ let reported = false;
583
+ let answered = false;
584
+ let failure;
585
+ const report = () => {
586
+ if (answered) return;
587
+ reported = true;
588
+ try {
589
+ onProgress(progressEvent(loaded, total));
590
+ } catch (error) {
591
+ failure = { error };
592
+ throw error;
593
+ }
594
+ };
595
+ const body = new ReadableStream({
596
+ async pull(controller) {
597
+ const stopped = async () => {
598
+ if (!signal?.aborted) return false;
599
+ await reader.cancel(signal.reason).catch(() => {});
600
+ controller.error(signal.reason);
601
+ return true;
602
+ };
603
+ if (await stopped()) return;
604
+ let chunk = rest;
605
+ if (!chunk?.byteLength) {
606
+ const { value, done } = await reader.read();
607
+ if (done) {
608
+ if (!reported) report();
609
+ return controller.close();
610
+ }
611
+ chunk = value;
612
+ if (await stopped()) return;
613
+ }
614
+ rest = chunk.subarray(SLICE);
615
+ const piece = chunk.subarray(0, SLICE);
616
+ loaded += piece.byteLength;
617
+ report();
618
+ controller.enqueue(piece);
619
+ },
620
+ cancel: (reason) => reader.cancel(reason)
621
+ }, { highWaterMark: 0 });
622
+ try {
623
+ const res = await base(input, {
624
+ ...init,
625
+ body,
626
+ headers,
627
+ duplex: "half"
628
+ });
629
+ answered = true;
630
+ return res;
631
+ } catch (error) {
632
+ throw failure ? failure.error : error;
633
+ }
634
+ });
635
+ }
636
+ //#endregion
637
+ //#region src/plugins/upload-progress/xhr-fetch.ts
638
+ const NULL_BODY_STATUSES = /* @__PURE__ */ new Set([
639
+ 101,
640
+ 204,
641
+ 205,
642
+ 304
643
+ ]);
644
+ /** `getAllResponseHeaders()` as `Headers`; lines without a name are skipped. */
645
+ function parseResponseHeaders(raw) {
646
+ const headers = new Headers();
647
+ for (const line of raw.split(/\r?\n/)) {
648
+ const colon = line.indexOf(":");
649
+ if (colon <= 0) continue;
650
+ const name = line.slice(0, colon).trim();
651
+ if (name) headers.append(name, line.slice(colon + 1).trim());
652
+ }
653
+ return headers;
654
+ }
655
+ /**
656
+ * What fetch would report for this response. XHR gives only the final URL;
657
+ * the request URL is normalized (case, default port, no fragment) before
658
+ * comparing, so writing it differently is not a redirect.
659
+ */
660
+ function responseFields(requestUrl, responseURL, pageHref) {
661
+ const requested = new URL(requestUrl, pageHref);
662
+ requested.hash = "";
663
+ const final = new URL(responseURL || requested.href, pageHref);
664
+ const sameOrigin = pageHref !== void 0 && final.origin === new URL(pageHref).origin;
665
+ return {
666
+ url: final.href,
667
+ redirected: final.href !== requested.href,
668
+ type: sameOrigin ? "basic" : "cors"
669
+ };
670
+ }
671
+ /**
672
+ * A fetch over XMLHttpRequest, for the one thing fetch cannot do in a
673
+ * browser: report upload progress. It honours what an arthron attempt
674
+ * carries — method, headers, body, `credentials: "include"`, `signal` — and
675
+ * fails the way fetch does, so `send` makes the same ArthronErrors: a
676
+ * network failure rejects with a TypeError, an abort with the signal's
677
+ * reason. The response arrives whole; XHR does not stream it.
678
+ */
679
+ function xhrFetch(onProgress) {
680
+ return ((input, init = {}) => new Promise((resolve, reject) => {
681
+ const url = urlOf(input);
682
+ const { signal } = init;
683
+ if (signal?.aborted) return reject(signal.reason);
684
+ const xhr = new XMLHttpRequest();
685
+ let settled = false;
686
+ const settle = (done) => {
687
+ if (settled) return;
688
+ settled = true;
689
+ signal?.removeEventListener("abort", onAbort);
690
+ done();
691
+ };
692
+ const fail = (error) => {
693
+ settle(() => reject(error));
694
+ xhr.abort();
695
+ };
696
+ const onAbort = () => fail(signal?.reason);
697
+ signal?.addEventListener("abort", onAbort, { once: true });
698
+ let reported;
699
+ const report = (loaded, total) => {
700
+ if (settled || reported?.loaded === loaded && reported.total === total) return;
701
+ reported = progressEvent(loaded, total);
702
+ try {
703
+ onProgress(reported);
704
+ } catch (error) {
705
+ fail(error);
706
+ }
707
+ };
708
+ const fromEvent = (e) => report(e.loaded, e.lengthComputable ? e.total : void 0);
709
+ xhr.upload.onprogress = fromEvent;
710
+ xhr.upload.onload = fromEvent;
711
+ xhr.onload = () => {
712
+ if (!reported) report(0, 0);
713
+ if (xhr.status === 0) return settle(() => reject(/* @__PURE__ */ new TypeError("Network request failed")));
714
+ const headers = parseResponseHeaders(xhr.getAllResponseHeaders());
715
+ const body = NULL_BODY_STATUSES.has(xhr.status) ? null : xhr.response;
716
+ const res = new Response(body, {
717
+ status: xhr.status,
718
+ statusText: xhr.statusText,
719
+ headers
720
+ });
721
+ const page = globalThis.location?.href;
722
+ settle(() => resolve(require_create_error.withFields(res, responseFields(url, xhr.responseURL, page))));
723
+ };
724
+ xhr.onerror = xhr.ontimeout = () => settle(() => reject(/* @__PURE__ */ new TypeError("Network request failed")));
725
+ xhr.open(init.method ?? "GET", url);
726
+ xhr.responseType = "arraybuffer";
727
+ if (init.credentials === "include") xhr.withCredentials = true;
728
+ for (const [name, value] of Object.entries(headersOf(input, init))) xhr.setRequestHeader(name, value);
729
+ xhr.send(init.body ?? null);
730
+ }));
731
+ }
732
+ //#endregion
733
+ //#region src/plugins/upload-progress/upload-progress.ts
734
+ /**
735
+ * How a progress call can be sent here: a counted body on the runtime's
736
+ * fetch, or XMLHttpRequest. Node first — jsdom on Node has an
737
+ * XMLHttpRequest too, but Node's own fetch sends the requests there. Bun,
738
+ * Deno, Electron and Cloudflare Workers also expose `process.versions.node`;
739
+ * only Node was shown to keep content-length on a counted body, and an
740
+ * Electron renderer has XHR.
741
+ */
742
+ function uploadModeFor(runtime) {
743
+ const { versions, hasXhr, userAgent, deno } = runtime;
744
+ if (Boolean(versions?.node) && !versions?.bun && !versions?.electron && !deno && userAgent !== "Cloudflare-Workers") return "countedFetch";
745
+ return hasXhr ? "xhr" : void 0;
746
+ }
747
+ function currentRuntime() {
748
+ const g = globalThis;
749
+ const runtime = {
750
+ hasXhr: typeof XMLHttpRequest === "function",
751
+ deno: "Deno" in globalThis
752
+ };
753
+ if (g.process?.versions) runtime.versions = g.process.versions;
754
+ if (g.navigator?.userAgent) runtime.userAgent = g.navigator.userAgent;
755
+ return runtime;
756
+ }
757
+ /**
758
+ * What an XHR cannot honour and whose loss would change the call: it always
759
+ * follows redirects, has no no-cors mode, always sends cookies to its own
760
+ * origin, sends the page's referrer whatever the call asked for, and checks
761
+ * no integrity. `undefined` when the call can go over XHR.
762
+ */
763
+ function xhrRefusal(init, url, pageHref) {
764
+ if (init.redirect === "manual" || init.redirect === "error") return `redirect: "${init.redirect}" — XMLHttpRequest always follows redirects`;
765
+ if (init.mode === "no-cors") return "mode: \"no-cors\" — XMLHttpRequest has no no-cors mode";
766
+ if (init.credentials === "omit" && pageHref) {
767
+ const page = new URL(pageHref).origin;
768
+ if (new URL(url, pageHref).origin === page) return "credentials: \"omit\" on this origin — XMLHttpRequest always sends cookies to its own origin";
769
+ }
770
+ if (init.referrerPolicy) return `referrerPolicy: "${init.referrerPolicy}" — XMLHttpRequest sends the page's referrer under the page's policy`;
771
+ if (init.referrer !== void 0 && init.referrer !== "about:client") return `referrer: ${JSON.stringify(init.referrer)} — XMLHttpRequest sends the page's referrer`;
772
+ if (init.integrity) return `integrity: "${init.integrity}" — XMLHttpRequest cannot check it`;
773
+ }
774
+ /**
775
+ * The attempt with its own signal, joined to the call's. A callback that
776
+ * throws aborts the attempt with its error: send passes an abort reason on
777
+ * untouched, so it stays a plugin exception — not a network ArthronError
778
+ * that error interceptors would see and retry would repeat.
779
+ */
780
+ function withOwnSignal(ctx, onProgress) {
781
+ const own = new AbortController();
782
+ const outer = ctx.init.signal;
783
+ const signal = outer ? AbortSignal.any([outer, own.signal]) : own.signal;
784
+ const report = (event) => {
785
+ if (own.signal.aborted) return;
786
+ try {
787
+ onProgress(event);
788
+ } catch (error) {
789
+ own.abort(error);
790
+ }
791
+ };
792
+ return {
793
+ ctx: {
794
+ ...ctx,
795
+ init: {
796
+ ...ctx.init,
797
+ signal
798
+ }
799
+ },
800
+ report
801
+ };
802
+ }
803
+ /**
804
+ * Does nothing by itself — only a call that passes `meta.uploadProgress`
805
+ * reports upload progress. On such a call it changes how that one attempt
806
+ * is sent: over XMLHttpRequest in a browser, with a counted body in Node.
807
+ * Every other call, on this client too, goes out exactly as before.
808
+ *
809
+ * Progress needs the runtime's own fetch: with a client `fetchFn`, another
810
+ * plugin's fetchFn, or a runtime that is neither (Deno, Bun, Cloudflare
811
+ * Workers, a service worker), the call is sent as usual without progress.
812
+ * Put it last in `plugins`: the call's signal carries a throwing callback's
813
+ * error, and an interceptor after it that replaces the signal would cut it.
814
+ */
815
+ function uploadProgress() {
816
+ return {
817
+ pluginName: "uploadProgress",
818
+ requestInterceptor: (ctx) => {
819
+ if (ctx.meta?.onUploadProgress !== void 0) throw new TypeError("arthron: meta.onUploadProgress was replaced by meta.uploadProgress in 2.0 — pass meta: { uploadProgress: { onProgress } }");
820
+ const options = ctx.meta?.uploadProgress;
821
+ if (typeof options === "function") throw new TypeError("arthron: meta.uploadProgress takes options — pass meta: { uploadProgress: { onProgress } }");
822
+ const onProgress = options?.onProgress;
823
+ const { body } = ctx.init;
824
+ if (!onProgress || body === void 0 || body === null) return ctx;
825
+ if (ctx.fetchFn !== globalThis.fetch) return ctx;
826
+ const uploadMode = uploadModeFor(currentRuntime());
827
+ if (!uploadMode) return ctx;
828
+ if (uploadMode === "xhr") {
829
+ const page = globalThis.location?.href;
830
+ const refusal = xhrRefusal(ctx.init, ctx.url, page);
831
+ if (refusal) throw new TypeError(`arthron: meta.uploadProgress cannot be combined with ${refusal}; drop one of them`);
832
+ if (require_create_error.isReadableStream(body)) return ctx;
833
+ }
834
+ const { ctx: withSignal, report } = withOwnSignal(ctx, onProgress);
835
+ return {
836
+ ...withSignal,
837
+ fetchFn: uploadMode === "xhr" ? xhrFetch(report) : countingFetch(ctx.fetchFn, report)
838
+ };
839
+ }
840
+ };
841
+ }
842
+ //#endregion
843
+ exports.bearerAuth = bearerAuth;
844
+ exports.logging = logging;
845
+ exports.retry = retry;
846
+ exports.timeout = timeout;
847
+ exports.uploadProgress = uploadProgress;