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