arthron 0.0.1 → 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.d.cts CHANGED
@@ -17,17 +17,37 @@ type ArthronErrorInit = {
17
17
  */
18
18
  export declare class ArthronError extends Error {
19
19
  readonly name = "ArthronError";
20
+ /** HTTP status of the failed response; `0` when fetch itself failed (network). */
20
21
  readonly status: number;
21
22
  readonly statusText: string;
23
+ /** Response headers; empty for network failures. */
22
24
  readonly headers: Headers;
25
+ /** Parsed body: JSON if it parses, else the text, else `undefined`. */
23
26
  readonly data: unknown;
27
+ /** The context that was sent — url and init after request interceptors. */
24
28
  readonly request: RequestContext;
29
+ /** The original Response, still readable (the body was parsed from a clone). Absent for network failures. */
25
30
  readonly response: Response | undefined;
31
+ /** The underlying error for network failures. */
26
32
  readonly cause: unknown;
27
33
  constructor(init: ArthronErrorInit);
28
34
  }
35
+ /** `true` for errors arthron produced: a `!ok` response or a network failure. Cancellations and plugin bugs are not ArthronErrors. */
29
36
  export declare function isArthronError(error: unknown): error is ArthronError;
30
37
  //#endregion
38
+ //#region src/stream.d.ts
39
+ /** One server-sent event, as described by the WHATWG EventSource spec. */
40
+ type SseEvent = {
41
+ /** The `event:` field, when the server sent one. */
42
+ event?: string;
43
+ /** All `data:` lines of the block, joined with `\n`. */
44
+ data: string;
45
+ /** The `id:` field, when present. */
46
+ id?: string;
47
+ /** The `retry:` field in ms, when present and numeric. */
48
+ retry?: number;
49
+ };
50
+ //#endregion
31
51
  //#region src/url.d.ts
32
52
  type QueryParams = Record<string, string | number | boolean | null | undefined>;
33
53
  //#endregion
@@ -83,6 +103,10 @@ type ArthronPromise = Promise<Response> & {
83
103
  blob(): Promise<Blob>;
84
104
  arrayBuffer(): Promise<ArrayBuffer>;
85
105
  formData(): Promise<FormData>;
106
+ /** Iterate a `text/event-stream` body as events. */
107
+ sse(): AsyncIterable<SseEvent>;
108
+ /** Iterate a newline-delimited JSON body, one parsed value per line. */
109
+ ndjson<T = unknown>(): AsyncIterable<T>;
86
110
  };
87
111
  type Arthron = {
88
112
  get(path: string, init?: RequestOptions): ArthronPromise;
@@ -106,26 +130,49 @@ type Arthron = {
106
130
  export declare function arthron(config?: ArthronConfig): Arthron;
107
131
  //#endregion
108
132
  //#region src/define.d.ts
133
+ /** Identity helper: gives a middleware written in its own file the right parameter types. */
109
134
  export declare const defineMiddleware: (middleware: Middleware) => Middleware;
135
+ /** Identity helpers for each interceptor kind — `defineInterceptor.request(fn)` etc. */
110
136
  export declare const defineInterceptor: {
111
137
  request: (interceptor: RequestInterceptor) => RequestInterceptor;
112
138
  response: (interceptor: ResponseInterceptor) => ResponseInterceptor;
113
139
  error: (interceptor: ErrorInterceptor) => ErrorInterceptor;
114
140
  };
141
+ /** Identity helper: type-checks a plugin object literal without an annotation. */
115
142
  export declare const definePlugin: (plugin: Plugin) => Plugin;
116
143
  //#endregion
117
144
  //#region src/plugins/logging.d.ts
145
+ type LogEventBase = {
146
+ method: string;
147
+ path: string;
148
+ };
149
+ type LogEvent = ({
150
+ phase: "start";
151
+ } & LogEventBase) | ({
152
+ phase: "success";
153
+ status: number;
154
+ ms: number;
155
+ } & LogEventBase) | ({
156
+ phase: "error";
157
+ /** HTTP status for an ArthronError (0 = network); undefined for anything else. */
158
+ status: number | undefined;
159
+ ms: number;
160
+ error: unknown;
161
+ } & LogEventBase);
118
162
  type LoggingOptions = {
119
163
  /** Default `true`. The library never reads `NODE_ENV` — pass `isDev` yourself. */
120
164
  enabled?: boolean;
121
165
  logger?: Pick<Console, "log" | "error">;
166
+ /** Receive structured events instead of the default console lines. */
167
+ onEvent?: (event: LogEvent) => void;
122
168
  /** Clock in ms. Injectable for tests. */
123
169
  now?: () => number;
124
170
  };
125
171
  /**
126
- * One line when a request starts, one when it settles, with status and
172
+ * One event when a request starts, one when it settles, with status and
127
173
  * elapsed time. Wraps the whole call, so retries are included in the time.
128
- * The query string is dropped — it tends to carry tokens.
174
+ * Default output is a console line per event; pass `onEvent` to get the
175
+ * structured event instead. Cancellations are not reported.
129
176
  */
130
177
  export declare function logging(options?: LoggingOptions): Plugin;
131
178
  //#endregion
@@ -155,5 +202,18 @@ type RetryOptions = {
155
202
  */
156
203
  export declare function retry(options?: RetryOptions): Plugin;
157
204
  //#endregion
158
- export type { Arthron, ArthronConfig, ArthronPromise, ErrorInterceptor, ErrorMessageResolver, LoggingOptions, Middleware, Plugin, QueryParams, RequestContext, RequestInterceptor, RequestOptions, ResponseInterceptor, RetryOptions };
205
+ //#region src/plugins/timeout.d.ts
206
+ /**
207
+ * Abort the request when it takes longer than `ms` — counted from the start
208
+ * of the attempt until the body has been fully read, so a server that sends
209
+ * headers and then stalls still trips it. Rejects with a `TimeoutError` (a
210
+ * `DOMException`, like `AbortSignal.timeout()` would give); it is not an
211
+ * `ArthronError`, so error interceptors and `retry` leave it alone.
212
+ *
213
+ * Built on `AbortController` + `setTimeout` rather than `AbortSignal.any` /
214
+ * `AbortSignal.timeout` so it runs on Node 18.
215
+ */
216
+ export declare function timeout(ms: number): Plugin;
217
+ //#endregion
218
+ export type { Arthron, ArthronConfig, ArthronPromise, ErrorInterceptor, ErrorMessageResolver, LogEvent, LoggingOptions, Middleware, Plugin, QueryParams, RequestContext, RequestInterceptor, RequestOptions, ResponseInterceptor, RetryOptions, SseEvent };
159
219
  //# sourceMappingURL=index.d.cts.map
package/dist/index.d.mts CHANGED
@@ -17,17 +17,37 @@ type ArthronErrorInit = {
17
17
  */
18
18
  export declare class ArthronError extends Error {
19
19
  readonly name = "ArthronError";
20
+ /** HTTP status of the failed response; `0` when fetch itself failed (network). */
20
21
  readonly status: number;
21
22
  readonly statusText: string;
23
+ /** Response headers; empty for network failures. */
22
24
  readonly headers: Headers;
25
+ /** Parsed body: JSON if it parses, else the text, else `undefined`. */
23
26
  readonly data: unknown;
27
+ /** The context that was sent — url and init after request interceptors. */
24
28
  readonly request: RequestContext;
29
+ /** The original Response, still readable (the body was parsed from a clone). Absent for network failures. */
25
30
  readonly response: Response | undefined;
31
+ /** The underlying error for network failures. */
26
32
  readonly cause: unknown;
27
33
  constructor(init: ArthronErrorInit);
28
34
  }
35
+ /** `true` for errors arthron produced: a `!ok` response or a network failure. Cancellations and plugin bugs are not ArthronErrors. */
29
36
  export declare function isArthronError(error: unknown): error is ArthronError;
30
37
  //#endregion
38
+ //#region src/stream.d.ts
39
+ /** One server-sent event, as described by the WHATWG EventSource spec. */
40
+ type SseEvent = {
41
+ /** The `event:` field, when the server sent one. */
42
+ event?: string;
43
+ /** All `data:` lines of the block, joined with `\n`. */
44
+ data: string;
45
+ /** The `id:` field, when present. */
46
+ id?: string;
47
+ /** The `retry:` field in ms, when present and numeric. */
48
+ retry?: number;
49
+ };
50
+ //#endregion
31
51
  //#region src/url.d.ts
32
52
  type QueryParams = Record<string, string | number | boolean | null | undefined>;
33
53
  //#endregion
@@ -83,6 +103,10 @@ type ArthronPromise = Promise<Response> & {
83
103
  blob(): Promise<Blob>;
84
104
  arrayBuffer(): Promise<ArrayBuffer>;
85
105
  formData(): Promise<FormData>;
106
+ /** Iterate a `text/event-stream` body as events. */
107
+ sse(): AsyncIterable<SseEvent>;
108
+ /** Iterate a newline-delimited JSON body, one parsed value per line. */
109
+ ndjson<T = unknown>(): AsyncIterable<T>;
86
110
  };
87
111
  type Arthron = {
88
112
  get(path: string, init?: RequestOptions): ArthronPromise;
@@ -106,26 +130,49 @@ type Arthron = {
106
130
  export declare function arthron(config?: ArthronConfig): Arthron;
107
131
  //#endregion
108
132
  //#region src/define.d.ts
133
+ /** Identity helper: gives a middleware written in its own file the right parameter types. */
109
134
  export declare const defineMiddleware: (middleware: Middleware) => Middleware;
135
+ /** Identity helpers for each interceptor kind — `defineInterceptor.request(fn)` etc. */
110
136
  export declare const defineInterceptor: {
111
137
  request: (interceptor: RequestInterceptor) => RequestInterceptor;
112
138
  response: (interceptor: ResponseInterceptor) => ResponseInterceptor;
113
139
  error: (interceptor: ErrorInterceptor) => ErrorInterceptor;
114
140
  };
141
+ /** Identity helper: type-checks a plugin object literal without an annotation. */
115
142
  export declare const definePlugin: (plugin: Plugin) => Plugin;
116
143
  //#endregion
117
144
  //#region src/plugins/logging.d.ts
145
+ type LogEventBase = {
146
+ method: string;
147
+ path: string;
148
+ };
149
+ type LogEvent = ({
150
+ phase: "start";
151
+ } & LogEventBase) | ({
152
+ phase: "success";
153
+ status: number;
154
+ ms: number;
155
+ } & LogEventBase) | ({
156
+ phase: "error";
157
+ /** HTTP status for an ArthronError (0 = network); undefined for anything else. */
158
+ status: number | undefined;
159
+ ms: number;
160
+ error: unknown;
161
+ } & LogEventBase);
118
162
  type LoggingOptions = {
119
163
  /** Default `true`. The library never reads `NODE_ENV` — pass `isDev` yourself. */
120
164
  enabled?: boolean;
121
165
  logger?: Pick<Console, "log" | "error">;
166
+ /** Receive structured events instead of the default console lines. */
167
+ onEvent?: (event: LogEvent) => void;
122
168
  /** Clock in ms. Injectable for tests. */
123
169
  now?: () => number;
124
170
  };
125
171
  /**
126
- * One line when a request starts, one when it settles, with status and
172
+ * One event when a request starts, one when it settles, with status and
127
173
  * elapsed time. Wraps the whole call, so retries are included in the time.
128
- * The query string is dropped — it tends to carry tokens.
174
+ * Default output is a console line per event; pass `onEvent` to get the
175
+ * structured event instead. Cancellations are not reported.
129
176
  */
130
177
  export declare function logging(options?: LoggingOptions): Plugin;
131
178
  //#endregion
@@ -155,5 +202,18 @@ type RetryOptions = {
155
202
  */
156
203
  export declare function retry(options?: RetryOptions): Plugin;
157
204
  //#endregion
158
- export type { Arthron, ArthronConfig, ArthronPromise, ErrorInterceptor, ErrorMessageResolver, LoggingOptions, Middleware, Plugin, QueryParams, RequestContext, RequestInterceptor, RequestOptions, ResponseInterceptor, RetryOptions };
205
+ //#region src/plugins/timeout.d.ts
206
+ /**
207
+ * Abort the request when it takes longer than `ms` — counted from the start
208
+ * of the attempt until the body has been fully read, so a server that sends
209
+ * headers and then stalls still trips it. Rejects with a `TimeoutError` (a
210
+ * `DOMException`, like `AbortSignal.timeout()` would give); it is not an
211
+ * `ArthronError`, so error interceptors and `retry` leave it alone.
212
+ *
213
+ * Built on `AbortController` + `setTimeout` rather than `AbortSignal.any` /
214
+ * `AbortSignal.timeout` so it runs on Node 18.
215
+ */
216
+ export declare function timeout(ms: number): Plugin;
217
+ //#endregion
218
+ export type { Arthron, ArthronConfig, ArthronPromise, ErrorInterceptor, ErrorMessageResolver, LogEvent, LoggingOptions, Middleware, Plugin, QueryParams, RequestContext, RequestInterceptor, RequestOptions, ResponseInterceptor, RetryOptions, SseEvent };
159
219
  //# sourceMappingURL=index.d.mts.map
package/dist/index.mjs CHANGED
@@ -48,12 +48,18 @@ function serializeBody(body, headers) {
48
48
  */
49
49
  var ArthronError = class extends Error {
50
50
  name = "ArthronError";
51
+ /** HTTP status of the failed response; `0` when fetch itself failed (network). */
51
52
  status;
52
53
  statusText;
54
+ /** Response headers; empty for network failures. */
53
55
  headers;
56
+ /** Parsed body: JSON if it parses, else the text, else `undefined`. */
54
57
  data;
58
+ /** The context that was sent — url and init after request interceptors. */
55
59
  request;
60
+ /** The original Response, still readable (the body was parsed from a clone). Absent for network failures. */
56
61
  response;
62
+ /** The underlying error for network failures. */
57
63
  cause;
58
64
  constructor(init) {
59
65
  super(init.message);
@@ -66,6 +72,7 @@ var ArthronError = class extends Error {
66
72
  this.cause = init.cause;
67
73
  }
68
74
  };
75
+ /** `true` for errors arthron produced: a `!ok` response or a network failure. Cancellations and plugin bugs are not ArthronErrors. */
69
76
  function isArthronError(error) {
70
77
  return error instanceof ArthronError;
71
78
  }
@@ -94,7 +101,7 @@ async function readBody(response) {
94
101
  }
95
102
  }
96
103
  async function responseError(response, request, resolve = defaultErrorMessage) {
97
- const data = await readBody(response);
104
+ const data = await readBody(response.clone());
98
105
  return new ArthronError({
99
106
  message: resolve(data, response) ?? `Request failed with status ${response.status}`,
100
107
  status: response.status,
@@ -195,7 +202,101 @@ async function runPipeline(plugins, ctx, send) {
195
202
  return dispatch(0, ctx);
196
203
  }
197
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
198
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
+ }
199
300
  /**
200
301
  * Sugar over `Promise<Response>`: `.json()` is `(await p).json()`. Awaiting
201
302
  * without a helper still yields the untouched `Response`.
@@ -206,7 +307,9 @@ function toArthronPromise(promise) {
206
307
  text: () => promise.then((res) => res.text()),
207
308
  blob: () => promise.then((res) => res.blob()),
208
309
  arrayBuffer: () => promise.then((res) => res.arrayBuffer()),
209
- formData: () => promise.then((res) => res.formData())
310
+ formData: () => promise.then((res) => res.formData()),
311
+ sse: () => iterate(promise, parseSse),
312
+ ndjson: () => iterate(promise, parseNdjson)
210
313
  });
211
314
  }
212
315
  //#endregion
@@ -233,15 +336,18 @@ function resolveUrl(baseURL, path, params) {
233
336
  let url;
234
337
  if (isAbsoluteUrl(path)) url = path;
235
338
  else {
236
- if (baseURL === void 0) throw new TypeError(`arthron: relative path "${path}" requires a baseURL`);
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`);
237
340
  url = `${baseURL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
238
341
  }
239
342
  const query = toQueryString(params);
240
343
  if (!query) return url;
241
- return `${url}${url.includes("?") ? "&" : "?"}${query}`;
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}`;
242
347
  }
243
348
  //#endregion
244
349
  //#region src/client.ts
350
+ const BODYLESS_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD"]);
245
351
  /** Normalize once so `extend` can merge headers and append plugins cheaply. */
246
352
  function resolveConfig(config) {
247
353
  const { headers, use, ...rest } = config;
@@ -257,9 +363,9 @@ function resolveConfig(config) {
257
363
  */
258
364
  function arthron(config = {}) {
259
365
  const resolved = resolveConfig(config);
260
- const fetchImpl = resolved.fetch ?? globalThis.fetch;
261
366
  /** The one place a failure becomes an ArthronError. Cancellation passes through. */
262
367
  const send = async (ctx) => {
368
+ const fetchImpl = resolved.fetch ?? globalThis.fetch;
263
369
  let res;
264
370
  try {
265
371
  res = await fetchImpl(ctx.url, ctx.init);
@@ -272,6 +378,7 @@ function arthron(config = {}) {
272
378
  };
273
379
  const dispatch = (path, method, rawBody, { params, headers: callHeaders, ...init }) => {
274
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`);
275
382
  const url = resolveUrl(resolved.baseURL, path, params);
276
383
  const { body, headers } = serializeBody(rawBody, mergeHeaders(resolved.headers, callHeaders));
277
384
  const requestInit = {
@@ -306,7 +413,7 @@ function arthron(config = {}) {
306
413
  patch: (path, body, init = {}) => dispatch(path, "PATCH", body, init),
307
414
  request: (path, init) => {
308
415
  const { method, body, ...rest } = init;
309
- if (!method) return toArthronPromise(Promise.reject(/* @__PURE__ */ new TypeError("arthron: request() requires init.method")));
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")));
310
417
  return dispatch(path, method, body, rest);
311
418
  },
312
419
  use: (...plugins) => derive({ use: plugins }),
@@ -315,41 +422,79 @@ function arthron(config = {}) {
315
422
  }
316
423
  //#endregion
317
424
  //#region src/define.ts
425
+ /** Identity helper: gives a middleware written in its own file the right parameter types. */
318
426
  const defineMiddleware = (middleware) => middleware;
427
+ /** Identity helpers for each interceptor kind — `defineInterceptor.request(fn)` etc. */
319
428
  const defineInterceptor = {
320
429
  request: (interceptor) => interceptor,
321
430
  response: (interceptor) => interceptor,
322
431
  error: (interceptor) => interceptor
323
432
  };
433
+ /** Identity helper: type-checks a plugin object literal without an annotation. */
324
434
  const definePlugin = (plugin) => plugin;
325
435
  //#endregion
326
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
+ }
327
453
  /**
328
- * One line when a request starts, one when it settles, with status and
454
+ * One event when a request starts, one when it settles, with status and
329
455
  * elapsed time. Wraps the whole call, so retries are included in the time.
330
- * The query string is dropped — it tends to carry tokens.
456
+ * Default output is a console line per event; pass `onEvent` to get the
457
+ * structured event instead. Cancellations are not reported.
331
458
  */
332
459
  function logging(options = {}) {
333
- const { enabled = true, logger = console, now = () => performance.now() } = options;
460
+ const { enabled = true, logger = console, onEvent, now = () => performance.now() } = options;
461
+ const emit = onEvent ?? consoleSink(logger);
334
462
  return {
335
463
  name: "logging",
336
464
  middleware: async (ctx, next) => {
337
465
  if (!enabled) return next(ctx);
338
466
  const method = ctx.init.method ?? "GET";
339
- const path = ctx.url.split("?")[0];
467
+ const path = displayPath(ctx.url);
340
468
  const started = now();
341
- const elapsed = () => `${Math.round(now() - started)}ms`;
342
- logger.log(`[arthron] → ${method} ${path}`);
469
+ const ms = () => Math.round(now() - started);
470
+ emit({
471
+ phase: "start",
472
+ method,
473
+ path
474
+ });
343
475
  try {
344
476
  const res = await next(ctx);
345
- logger.log(`[arthron] ← ${method} ${path} ${res.status} ${elapsed()}`);
477
+ emit({
478
+ phase: "success",
479
+ method,
480
+ path,
481
+ status: res.status,
482
+ ms: ms()
483
+ });
346
484
  return res;
347
- } catch (thrown) {
348
- if (!isCancellation(thrown)) {
349
- const status = isArthronError(thrown) ? thrown.status : "-";
350
- logger.error(`[arthron] ✕ ${method} ${path} ${status} ${elapsed()}`);
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
+ });
351
496
  }
352
- throw thrown;
497
+ throw error;
353
498
  }
354
499
  }
355
500
  };
@@ -427,6 +572,85 @@ function retry(options = {}) {
427
572
  };
428
573
  }
429
574
  //#endregion
430
- export { ArthronError, arthron, defineInterceptor, defineMiddleware, definePlugin, isArthronError, logging, retry };
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 };
431
655
 
432
656
  //# sourceMappingURL=index.mjs.map