arthron 0.0.0-stage → 0.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/dist/index.cjs ADDED
@@ -0,0 +1,665 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/body.ts
3
+ const RAW_CTORS = [
4
+ "FormData",
5
+ "Blob",
6
+ "ArrayBuffer",
7
+ "URLSearchParams",
8
+ "ReadableStream"
9
+ ];
10
+ /** Bodies fetch already knows how to send. Content-Type is left to the caller / runtime. */
11
+ function isRawBody(body) {
12
+ if (typeof body === "string") return true;
13
+ if (ArrayBuffer.isView(body)) return true;
14
+ return RAW_CTORS.some((name) => {
15
+ const ctor = globalThis[name];
16
+ return typeof ctor === "function" && body instanceof ctor;
17
+ });
18
+ }
19
+ /**
20
+ * Turn a caller-supplied body into something fetch accepts. Raw bodies pass
21
+ * through untouched; anything else is JSON with `content-type` set — unless
22
+ * the caller already set one (headers arrive lowercased, so a plain lookup is
23
+ * enough).
24
+ */
25
+ function serializeBody(body, headers) {
26
+ if (body === void 0) return {
27
+ body: void 0,
28
+ headers
29
+ };
30
+ if (isRawBody(body)) return {
31
+ body,
32
+ headers
33
+ };
34
+ const withType = "content-type" in headers ? headers : {
35
+ ...headers,
36
+ "content-type": "application/json"
37
+ };
38
+ return {
39
+ body: JSON.stringify(body),
40
+ headers: withType
41
+ };
42
+ }
43
+ //#endregion
44
+ //#region src/error.ts
45
+ /**
46
+ * The only error arthron itself produces. Two sources: a response with
47
+ * `ok === false`, or fetch rejecting outright (network failure, `status` 0).
48
+ * Everything else — cancellation, interceptor bugs — is rethrown untouched.
49
+ */
50
+ var ArthronError = class extends Error {
51
+ name = "ArthronError";
52
+ /** HTTP status of the failed response; `0` when fetch itself failed (network). */
53
+ status;
54
+ statusText;
55
+ /** Response headers; empty for network failures. */
56
+ headers;
57
+ /** Parsed body: JSON if it parses, else the text, else `undefined`. */
58
+ data;
59
+ /** The context that was sent — url and init after request interceptors. */
60
+ request;
61
+ /** The original Response, still readable (the body was parsed from a clone). Absent for network failures. */
62
+ response;
63
+ /** The underlying error for network failures. */
64
+ cause;
65
+ constructor(init) {
66
+ super(init.message);
67
+ this.status = init.status;
68
+ this.statusText = init.statusText;
69
+ this.headers = init.headers;
70
+ this.data = init.data;
71
+ this.request = init.request;
72
+ this.response = init.response;
73
+ this.cause = init.cause;
74
+ }
75
+ };
76
+ /** `true` for errors arthron produced: a `!ok` response or a network failure. Cancellations and plugin bugs are not ArthronErrors. */
77
+ function isArthronError(error) {
78
+ return error instanceof ArthronError;
79
+ }
80
+ /** Cancellation is not an HTTP failure. Matched by name so it works across realms. */
81
+ function isCancellation(error) {
82
+ const name = error?.name;
83
+ return name === "AbortError" || name === "TimeoutError";
84
+ }
85
+ const isRecord = (value) => typeof value === "object" && value !== null;
86
+ /** `message` → `error` (string) → `error.message` → `detail`. */
87
+ function defaultErrorMessage(data) {
88
+ if (!isRecord(data)) return void 0;
89
+ if (typeof data.message === "string") return data.message;
90
+ if (typeof data.error === "string") return data.error;
91
+ if (isRecord(data.error) && typeof data.error.message === "string") return data.error.message;
92
+ if (typeof data.detail === "string") return data.detail;
93
+ }
94
+ /** Body as JSON if it parses, else as text, else `undefined` when empty. */
95
+ async function readBody(response) {
96
+ const text = await response.text().catch(() => "");
97
+ if (text === "") return void 0;
98
+ try {
99
+ return JSON.parse(text);
100
+ } catch {
101
+ return text;
102
+ }
103
+ }
104
+ async function responseError(response, request, resolve = defaultErrorMessage) {
105
+ const data = await readBody(response.clone());
106
+ return new ArthronError({
107
+ message: resolve(data, response) ?? `Request failed with status ${response.status}`,
108
+ status: response.status,
109
+ statusText: response.statusText,
110
+ headers: response.headers,
111
+ data,
112
+ request,
113
+ response
114
+ });
115
+ }
116
+ function networkError(cause, request) {
117
+ return new ArthronError({
118
+ message: cause instanceof Error ? cause.message : "Network error",
119
+ status: 0,
120
+ statusText: "",
121
+ headers: new Headers(),
122
+ data: void 0,
123
+ request,
124
+ cause
125
+ });
126
+ }
127
+ //#endregion
128
+ //#region src/headers.ts
129
+ /**
130
+ * Normalize any `HeadersInit` (plain object, `Headers`, tuple array) into a
131
+ * plain record. Keys come back lowercased — `Headers` does that for us.
132
+ */
133
+ function toHeaderRecord(headers) {
134
+ if (!headers) return {};
135
+ return Object.fromEntries(new Headers(headers).entries());
136
+ }
137
+ /**
138
+ * Layer `override` on top of `base`. Names are compared case-insensitively and
139
+ * the override wins, so a caller's `content-type` replaces a default
140
+ * `Content-Type` instead of fetch sending both values joined.
141
+ */
142
+ function mergeHeaders(base, override) {
143
+ return {
144
+ ...toHeaderRecord(base),
145
+ ...toHeaderRecord(override)
146
+ };
147
+ }
148
+ //#endregion
149
+ //#region src/pipeline.ts
150
+ /**
151
+ * Interceptors mutate the context in place. A middleware that calls `next`
152
+ * again (retry) must not see the previous attempt's changes, so every attempt
153
+ * starts from a fresh copy.
154
+ */
155
+ function cloneContext({ url, init }) {
156
+ return {
157
+ url,
158
+ init: {
159
+ ...init,
160
+ headers: toHeaderRecord(init.headers)
161
+ }
162
+ };
163
+ }
164
+ /**
165
+ * Owns the order of one request.
166
+ *
167
+ * Middleware wraps the whole call, outermost first in registration order.
168
+ * Each attempt below it runs request interceptors → `send` → response
169
+ * interceptors. Only an `ArthronError` reaches the error interceptors; whatever
170
+ * they return is thrown. Anything else that is thrown — cancellation, a bug
171
+ * in a plugin — propagates untouched.
172
+ */
173
+ async function runPipeline(plugins, ctx, send) {
174
+ const middlewares = [];
175
+ const request = [];
176
+ const response = [];
177
+ const error = [];
178
+ for (const plugin of plugins) {
179
+ if (plugin.middleware) middlewares.push(plugin.middleware);
180
+ if (plugin.request) request.push(plugin.request);
181
+ if (plugin.response) response.push(plugin.response);
182
+ if (plugin.error) error.push(plugin.error);
183
+ }
184
+ const attempt = async (input) => {
185
+ let current = cloneContext(input);
186
+ try {
187
+ for (const interceptor of request) current = await interceptor(current);
188
+ let res = await send(current);
189
+ for (const interceptor of response) res = await interceptor(res, current);
190
+ return res;
191
+ } catch (thrown) {
192
+ if (!isArthronError(thrown)) throw thrown;
193
+ let err = thrown;
194
+ for (const interceptor of error) err = await interceptor(err, current);
195
+ throw err;
196
+ }
197
+ };
198
+ const dispatch = (index, input) => {
199
+ const middleware = middlewares[index];
200
+ if (!middleware) return attempt(input);
201
+ return middleware(input, (next) => dispatch(index + 1, next));
202
+ };
203
+ return dispatch(0, ctx);
204
+ }
205
+ //#endregion
206
+ //#region src/stream.ts
207
+ /** Yield the body line by line, reassembling lines split across chunks. */
208
+ async function* lines(response) {
209
+ if (!response.body) return;
210
+ const reader = response.body.getReader();
211
+ const decoder = new TextDecoder();
212
+ let buffer = "";
213
+ try {
214
+ while (true) {
215
+ const { value, done } = await reader.read();
216
+ buffer += decoder.decode(value, { stream: !done });
217
+ const parts = buffer.split(/\r\n|\r|\n/);
218
+ buffer = parts.pop() ?? "";
219
+ for (const line of parts) yield line;
220
+ if (done) break;
221
+ }
222
+ if (buffer) yield buffer;
223
+ } finally {
224
+ reader.releaseLock();
225
+ }
226
+ }
227
+ /**
228
+ * Parse a `text/event-stream` body into events. Follows the spec: a blank
229
+ * line dispatches, `data:` lines are joined with `\n`, one leading space
230
+ * after the colon is dropped, lines starting with `:` are comments, and a
231
+ * block without `data` is not dispatched. Provider sentinels like
232
+ * `[DONE]` are passed through untouched — they are not part of the spec.
233
+ */
234
+ async function* parseSse(response) {
235
+ let data = [];
236
+ let event;
237
+ let id;
238
+ let retry;
239
+ const flush = () => {
240
+ if (data.length === 0) {
241
+ event = id = retry = void 0;
242
+ return;
243
+ }
244
+ const out = { data: data.join("\n") };
245
+ if (event !== void 0) out.event = event;
246
+ if (id !== void 0) out.id = id;
247
+ if (retry !== void 0) out.retry = retry;
248
+ data = [];
249
+ event = id = retry = void 0;
250
+ return out;
251
+ };
252
+ for await (const line of lines(response)) {
253
+ if (line === "") {
254
+ const out = flush();
255
+ if (out) yield out;
256
+ continue;
257
+ }
258
+ if (line.startsWith(":")) continue;
259
+ const colon = line.indexOf(":");
260
+ const field = colon === -1 ? line : line.slice(0, colon);
261
+ let value = colon === -1 ? "" : line.slice(colon + 1);
262
+ if (value.startsWith(" ")) value = value.slice(1);
263
+ switch (field) {
264
+ case "data":
265
+ data.push(value);
266
+ break;
267
+ case "event":
268
+ event = value;
269
+ break;
270
+ case "id":
271
+ id = value;
272
+ break;
273
+ case "retry": {
274
+ const ms = Number(value);
275
+ if (Number.isInteger(ms) && ms >= 0) retry = ms;
276
+ break;
277
+ }
278
+ }
279
+ }
280
+ const out = flush();
281
+ if (out) yield out;
282
+ }
283
+ /**
284
+ * Parse newline-delimited JSON. Blank lines are skipped; a malformed line
285
+ * rejects the iteration with the `SyntaxError` from `JSON.parse`.
286
+ */
287
+ async function* parseNdjson(response) {
288
+ for await (const line of lines(response)) {
289
+ if (line.trim() === "") continue;
290
+ yield JSON.parse(line);
291
+ }
292
+ }
293
+ //#endregion
294
+ //#region src/promise.ts
295
+ /** Defer the Response until iteration starts, so the helper itself never rejects. */
296
+ function iterate(promise, parse) {
297
+ return { async *[Symbol.asyncIterator]() {
298
+ yield* parse(await promise);
299
+ } };
300
+ }
301
+ /**
302
+ * Sugar over `Promise<Response>`: `.json()` is `(await p).json()`. Awaiting
303
+ * without a helper still yields the untouched `Response`.
304
+ */
305
+ function toArthronPromise(promise) {
306
+ return Object.assign(promise, {
307
+ json: () => promise.then((res) => res.json()),
308
+ text: () => promise.then((res) => res.text()),
309
+ blob: () => promise.then((res) => res.blob()),
310
+ arrayBuffer: () => promise.then((res) => res.arrayBuffer()),
311
+ formData: () => promise.then((res) => res.formData()),
312
+ sse: () => iterate(promise, parseSse),
313
+ ndjson: () => iterate(promise, parseNdjson)
314
+ });
315
+ }
316
+ //#endregion
317
+ //#region src/url.ts
318
+ const ABSOLUTE_URL = /^[a-z][a-z0-9+.-]*:\/\//i;
319
+ function isAbsoluteUrl(path) {
320
+ return ABSOLUTE_URL.test(path);
321
+ }
322
+ function toQueryString(params) {
323
+ if (!params) return "";
324
+ const search = new URLSearchParams();
325
+ for (const [key, value] of Object.entries(params)) {
326
+ if (value === null || value === void 0) continue;
327
+ search.append(key, String(value));
328
+ }
329
+ return search.toString();
330
+ }
331
+ /**
332
+ * Join `baseURL` and `path` by plain string concatenation so a path prefix in
333
+ * the base (`/v1`) survives a leading slash in the path. WHATWG `new URL()`
334
+ * would drop it. Absolute paths skip the base entirely.
335
+ */
336
+ function resolveUrl(baseURL, path, params) {
337
+ let url;
338
+ if (isAbsoluteUrl(path)) url = path;
339
+ else {
340
+ if (baseURL === void 0) throw new TypeError(`arthron: relative path "${path}" needs a baseURL — pass baseURL in arthron({ baseURL }) or use an absolute URL`);
341
+ url = `${baseURL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
342
+ }
343
+ const query = toQueryString(params);
344
+ if (!query) return url;
345
+ const hash = url.indexOf("#");
346
+ const [base, fragment] = hash === -1 ? [url, ""] : [url.slice(0, hash), url.slice(hash)];
347
+ return `${base}${base.includes("?") ? "&" : "?"}${query}${fragment}`;
348
+ }
349
+ //#endregion
350
+ //#region src/client.ts
351
+ const BODYLESS_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD"]);
352
+ /** Normalize once so `extend` can merge headers and append plugins cheaply. */
353
+ function resolveConfig(config) {
354
+ const { headers, use, ...rest } = config;
355
+ return {
356
+ ...rest,
357
+ headers: toHeaderRecord(headers),
358
+ use: use ?? []
359
+ };
360
+ }
361
+ /**
362
+ * Build a client from config. The result is immutable — `use` and `extend`
363
+ * return new clients and never touch the one they were called on.
364
+ */
365
+ function arthron(config = {}) {
366
+ const resolved = resolveConfig(config);
367
+ /** The one place a failure becomes an ArthronError. Cancellation passes through. */
368
+ const send = async (ctx) => {
369
+ const fetchImpl = resolved.fetch ?? globalThis.fetch;
370
+ let res;
371
+ try {
372
+ res = await fetchImpl(ctx.url, ctx.init);
373
+ } catch (thrown) {
374
+ if (isCancellation(thrown)) throw thrown;
375
+ throw networkError(thrown, ctx);
376
+ }
377
+ if (!res.ok) throw await responseError(res, ctx, resolved.errorMessage);
378
+ return res;
379
+ };
380
+ const dispatch = (path, method, rawBody, { params, headers: callHeaders, ...init }) => {
381
+ try {
382
+ if (rawBody !== void 0 && BODYLESS_METHODS.has(method.toUpperCase())) throw new TypeError(`arthron: ${method.toUpperCase()} requests cannot carry a body — send it as params (query) or switch to POST`);
383
+ const url = resolveUrl(resolved.baseURL, path, params);
384
+ const { body, headers } = serializeBody(rawBody, mergeHeaders(resolved.headers, callHeaders));
385
+ const requestInit = {
386
+ ...resolved.credentials !== void 0 && { credentials: resolved.credentials },
387
+ ...init,
388
+ method,
389
+ headers
390
+ };
391
+ if (body !== void 0) requestInit.body = body;
392
+ return toArthronPromise(runPipeline(resolved.use, {
393
+ url,
394
+ init: requestInit
395
+ }, send));
396
+ } catch (thrown) {
397
+ return toArthronPromise(Promise.reject(thrown));
398
+ }
399
+ };
400
+ const derive = (next) => {
401
+ return arthron({
402
+ ...resolved,
403
+ ...next,
404
+ headers: mergeHeaders(resolved.headers, next.headers),
405
+ use: [...resolved.use, ...next.use ?? []]
406
+ });
407
+ };
408
+ return {
409
+ get: (path, init = {}) => dispatch(path, "GET", void 0, init),
410
+ delete: (path, init = {}) => dispatch(path, "DELETE", void 0, init),
411
+ head: (path, init = {}) => dispatch(path, "HEAD", void 0, init),
412
+ post: (path, body, init = {}) => dispatch(path, "POST", body, init),
413
+ put: (path, body, init = {}) => dispatch(path, "PUT", body, init),
414
+ patch: (path, body, init = {}) => dispatch(path, "PATCH", body, init),
415
+ request: (path, init) => {
416
+ const { method, body, ...rest } = init;
417
+ if (!method) return toArthronPromise(Promise.reject(/* @__PURE__ */ new TypeError("arthron: request() needs init.method — e.g. request(path, { method: \"OPTIONS\" }); use get/post/... for the common verbs")));
418
+ return dispatch(path, method, body, rest);
419
+ },
420
+ use: (...plugins) => derive({ use: plugins }),
421
+ extend: (next) => derive(next)
422
+ };
423
+ }
424
+ //#endregion
425
+ //#region src/define.ts
426
+ /** Identity helper: gives a middleware written in its own file the right parameter types. */
427
+ const defineMiddleware = (middleware) => middleware;
428
+ /** Identity helpers for each interceptor kind — `defineInterceptor.request(fn)` etc. */
429
+ const defineInterceptor = {
430
+ request: (interceptor) => interceptor,
431
+ response: (interceptor) => interceptor,
432
+ error: (interceptor) => interceptor
433
+ };
434
+ /** Identity helper: type-checks a plugin object literal without an annotation. */
435
+ const definePlugin = (plugin) => plugin;
436
+ //#endregion
437
+ //#region src/plugins/logging.ts
438
+ /** Origin + pathname only — the query and userinfo tend to carry secrets. */
439
+ function displayPath(url) {
440
+ try {
441
+ const parsed = new URL(url);
442
+ return `${parsed.origin}${parsed.pathname}`;
443
+ } catch {
444
+ return url.split("?")[0] ?? url;
445
+ }
446
+ }
447
+ function consoleSink(logger) {
448
+ return (e) => {
449
+ if (e.phase === "start") logger.log(`[arthron] → ${e.method} ${e.path}`);
450
+ else if (e.phase === "success") logger.log(`[arthron] ← ${e.method} ${e.path} ${e.status} ${e.ms}ms`);
451
+ else logger.error(`[arthron] ✕ ${e.method} ${e.path} ${e.status ?? "-"} ${e.ms}ms`);
452
+ };
453
+ }
454
+ /**
455
+ * One event when a request starts, one when it settles, with status and
456
+ * elapsed time. Wraps the whole call, so retries are included in the time.
457
+ * Default output is a console line per event; pass `onEvent` to get the
458
+ * structured event instead. Cancellations are not reported.
459
+ */
460
+ function logging(options = {}) {
461
+ const { enabled = true, logger = console, onEvent, now = () => performance.now() } = options;
462
+ const emit = onEvent ?? consoleSink(logger);
463
+ return {
464
+ name: "logging",
465
+ middleware: async (ctx, next) => {
466
+ if (!enabled) return next(ctx);
467
+ const method = ctx.init.method ?? "GET";
468
+ const path = displayPath(ctx.url);
469
+ const started = now();
470
+ const ms = () => Math.round(now() - started);
471
+ emit({
472
+ phase: "start",
473
+ method,
474
+ path
475
+ });
476
+ try {
477
+ const res = await next(ctx);
478
+ emit({
479
+ phase: "success",
480
+ method,
481
+ path,
482
+ status: res.status,
483
+ ms: ms()
484
+ });
485
+ return res;
486
+ } catch (error) {
487
+ if (!isCancellation(error)) {
488
+ const status = isArthronError(error) ? error.status : void 0;
489
+ emit({
490
+ phase: "error",
491
+ method,
492
+ path,
493
+ status,
494
+ ms: ms(),
495
+ error
496
+ });
497
+ }
498
+ throw error;
499
+ }
500
+ }
501
+ };
502
+ }
503
+ //#endregion
504
+ //#region src/plugins/retry.ts
505
+ const DEFAULT_RETRY_ON = [
506
+ 408,
507
+ 429,
508
+ 500,
509
+ 502,
510
+ 503,
511
+ 504
512
+ ];
513
+ const DEFAULT_METHODS = [
514
+ "GET",
515
+ "HEAD",
516
+ "OPTIONS",
517
+ "PUT",
518
+ "DELETE"
519
+ ];
520
+ const DEFAULT_MAX_RETRY_AFTER = 6e4;
521
+ function defaultSleep(ms, signal) {
522
+ return new Promise((resolve, reject) => {
523
+ if (signal?.aborted) return reject(signal.reason);
524
+ const onAbort = () => {
525
+ clearTimeout(timer);
526
+ reject(signal?.reason);
527
+ };
528
+ const timer = setTimeout(() => {
529
+ signal?.removeEventListener("abort", onAbort);
530
+ resolve();
531
+ }, ms);
532
+ signal?.addEventListener("abort", onAbort, { once: true });
533
+ });
534
+ }
535
+ /** `Retry-After` as ms — seconds or an HTTP-date. `undefined` when absent or unparsable. */
536
+ function retryAfterMs(error) {
537
+ const value = error.headers.get("retry-after")?.trim();
538
+ if (!value) return void 0;
539
+ const seconds = Number(value);
540
+ if (!Number.isNaN(seconds)) return Math.max(0, seconds * 1e3);
541
+ const at = Date.parse(value);
542
+ if (Number.isNaN(at)) return void 0;
543
+ return Math.max(0, at - Date.now());
544
+ }
545
+ function isStreamBody(ctx) {
546
+ return typeof ReadableStream !== "undefined" && ctx.init.body instanceof ReadableStream;
547
+ }
548
+ /**
549
+ * Retry on network errors and the configured statuses by calling `next`
550
+ * again — the whole attempt (request interceptors included) is re-run.
551
+ * Only `ArthronError` is considered; cancellation and plugin bugs pass through.
552
+ */
553
+ function retry(options = {}) {
554
+ const { retries = 2, retryOn = DEFAULT_RETRY_ON, methods = DEFAULT_METHODS, delay = (retry) => 300 * 2 ** retry, sleep = defaultSleep, maxRetryAfter = DEFAULT_MAX_RETRY_AFTER } = options;
555
+ const allowed = new Set(methods.map((m) => m.toUpperCase()));
556
+ return {
557
+ name: "retry",
558
+ middleware: async (ctx, next) => {
559
+ const method = (ctx.init.method ?? "GET").toUpperCase();
560
+ const eligible = allowed.has(method) && !isStreamBody(ctx);
561
+ for (let attempt = 0;; attempt += 1) try {
562
+ return await next(ctx);
563
+ } catch (thrown) {
564
+ if (!eligible || attempt >= retries) throw thrown;
565
+ if (!isArthronError(thrown)) throw thrown;
566
+ if (thrown.status !== 0 && !retryOn.includes(thrown.status)) throw thrown;
567
+ if (ctx.init.signal?.aborted) throw thrown;
568
+ const retryAfter = retryAfterMs(thrown);
569
+ if (retryAfter !== void 0 && retryAfter > maxRetryAfter) throw thrown;
570
+ await sleep(retryAfter ?? delay(attempt, thrown), ctx.init.signal);
571
+ }
572
+ }
573
+ };
574
+ }
575
+ //#endregion
576
+ //#region src/plugins/timeout.ts
577
+ /**
578
+ * Wrap `body` so the deadline keeps running until it is fully read, and so an
579
+ * abort while reading surfaces as a stream error instead of a silent stall.
580
+ */
581
+ function guardBody(body, signal, finish) {
582
+ const reader = body.getReader();
583
+ return new ReadableStream({
584
+ start(controller) {
585
+ signal.addEventListener("abort", () => {
586
+ controller.error(signal.reason);
587
+ reader.cancel(signal.reason).catch(() => {});
588
+ }, { once: true });
589
+ },
590
+ async pull(controller) {
591
+ const { value, done } = await reader.read();
592
+ if (done) {
593
+ finish();
594
+ controller.close();
595
+ return;
596
+ }
597
+ controller.enqueue(value);
598
+ },
599
+ cancel(reason) {
600
+ finish();
601
+ return reader.cancel(reason);
602
+ }
603
+ });
604
+ }
605
+ /**
606
+ * Abort the request when it takes longer than `ms` — counted from the start
607
+ * of the attempt until the body has been fully read, so a server that sends
608
+ * headers and then stalls still trips it. Rejects with a `TimeoutError` (a
609
+ * `DOMException`, like `AbortSignal.timeout()` would give); it is not an
610
+ * `ArthronError`, so error interceptors and `retry` leave it alone.
611
+ *
612
+ * Built on `AbortController` + `setTimeout` rather than `AbortSignal.any` /
613
+ * `AbortSignal.timeout` so it runs on Node 18.
614
+ */
615
+ function timeout(ms) {
616
+ return {
617
+ name: "timeout",
618
+ middleware: async (ctx, next) => {
619
+ const controller = new AbortController();
620
+ const outer = ctx.init.signal;
621
+ const forward = () => controller.abort(outer?.reason);
622
+ if (outer?.aborted) forward();
623
+ else outer?.addEventListener("abort", forward, { once: true });
624
+ const timer = setTimeout(() => controller.abort(new DOMException(`arthron: request timed out after ${ms}ms`, "TimeoutError")), ms);
625
+ const finish = () => {
626
+ clearTimeout(timer);
627
+ outer?.removeEventListener("abort", forward);
628
+ };
629
+ let res;
630
+ try {
631
+ res = await next({
632
+ ...ctx,
633
+ init: {
634
+ ...ctx.init,
635
+ signal: controller.signal
636
+ }
637
+ });
638
+ } catch (thrown) {
639
+ finish();
640
+ throw thrown;
641
+ }
642
+ if (!res.body) {
643
+ finish();
644
+ return res;
645
+ }
646
+ return new Response(guardBody(res.body, controller.signal, finish), {
647
+ status: res.status,
648
+ statusText: res.statusText,
649
+ headers: res.headers
650
+ });
651
+ }
652
+ };
653
+ }
654
+ //#endregion
655
+ exports.ArthronError = ArthronError;
656
+ exports.arthron = arthron;
657
+ exports.defineInterceptor = defineInterceptor;
658
+ exports.defineMiddleware = defineMiddleware;
659
+ exports.definePlugin = definePlugin;
660
+ exports.isArthronError = isArthronError;
661
+ exports.logging = logging;
662
+ exports.retry = retry;
663
+ exports.timeout = timeout;
664
+
665
+ //# sourceMappingURL=index.cjs.map