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