@marianmeres/http-utils 2.8.0 → 2.10.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/AGENTS.md CHANGED
@@ -160,10 +160,16 @@ function createHttpError(
160
160
  function getErrorMessage(e: unknown, stripErrorPrefix?: boolean): string;
161
161
  // Wraps fetch; on transport failure throws NetworkError("<what> unreachable (<url>): <reason>", { cause }).
162
162
  // AbortError/TimeoutError pass through untouched. Used internally by HttpApi.
163
+ // 3rd arg is a label string OR FetchOrThrowOptions { what?, onRequest?, onError? } (pure observers; a string normalizes to { what }).
164
+ // onRequest fires before dispatch (throw => request not sent). onError fires on every failure
165
+ // with { error, url, what?, kind: "abort"|"timeout"|"network", reason } (reason via getErrorMessage;
166
+ // throw => swallowed, real error preserved).
167
+ // Global defaults (overridable per call; resolution: per-call ?? global; instruments HttpApi too):
168
+ // fetchOrThrow.global.onRequest / fetchOrThrow.global.onError
163
169
  function fetchOrThrow(
164
170
  input: string | URL | Request,
165
171
  init?: RequestInit,
166
- what?: string,
172
+ whatOrOptions?: string | FetchOrThrowOptions,
167
173
  ): Promise<Response>;
168
174
  ```
169
175
 
@@ -209,6 +215,9 @@ class HTTP_STATUS {
209
215
  host) throw `HTTP_ERROR.NetworkError` (status 0) via `fetchOrThrow`, with the real
210
216
  reason in the message and the underlying error as `cause` — never an opaque "fetch
211
217
  failed". Deliberate `AbortError`/`TimeoutError` are not wrapped.
218
+ 11. **Request tracing**: `fetchOrThrow.global.onRequest` / `onError` are observer-only
219
+ hooks (no recovery/transform), overridable per call (`per-call ?? global`). They also
220
+ fire for `HttpApi` requests, since the client routes through `fetchOrThrow`.
212
221
 
213
222
  ## Request Body Serialization
214
223
 
package/API.md CHANGED
@@ -704,13 +704,14 @@ too — you only need `fetchOrThrow` directly when wrapping your own `fetch` cal
704
704
  function fetchOrThrow(
705
705
  input: string | URL | Request,
706
706
  init?: RequestInit,
707
- what?: string,
707
+ whatOrOptions?: string | FetchOrThrowOptions,
708
708
  ): Promise<Response>;
709
709
  ```
710
710
 
711
711
  Like the native `fetch`, this does **not** throw on non-2xx HTTP statuses — only on
712
- transport-level failures. The optional `what` is a label describing the target (e.g.
713
- `"Token issuer"`) used to prefix the error message.
712
+ transport-level failures. The 3rd argument is either a label string (e.g.
713
+ `"Token issuer"`) used to prefix the error message, or a `FetchOrThrowOptions` object
714
+ carrying that label plus observer hooks (a bare string is normalized to `{ what }`).
714
715
 
715
716
  **Example:**
716
717
 
@@ -731,6 +732,56 @@ try {
731
732
  }
732
733
  ```
733
734
 
735
+ #### Observer hooks & global defaults
736
+
737
+ ```ts
738
+ interface FetchOrThrowOptions {
739
+ /** Label for the target, used to prefix the error message. */
740
+ what?: string;
741
+ /** Fires synchronously before the request is dispatched. If it throws, the request is NOT sent. */
742
+ onRequest?: (info: { url: string; method?: string; what?: string }) => void;
743
+ /** Fires before a failure is (re-)thrown. A throw here is swallowed. */
744
+ onError?: (info: {
745
+ error: unknown;
746
+ url: string;
747
+ what?: string;
748
+ kind: "abort" | "timeout" | "network";
749
+ /** Human-readable reason (via `getErrorMessage`); always a string. */
750
+ reason: string;
751
+ }) => void;
752
+ }
753
+
754
+ // Observer hooks only — `what` is per-call by nature.
755
+ type FetchOrThrowGlobalOptions = Pick<FetchOrThrowOptions, "onRequest" | "onError">;
756
+ ```
757
+
758
+ `onRequest`/`onError` are **pure observers**: their return value is ignored and they
759
+ cannot recover or transform the request/error. Reach for them to trace requests — notably
760
+ the _hang_ case, where neither a response nor an error ever arrives. For recovery or
761
+ retries use the `HttpApi` interceptors or your own `catch`.
762
+
763
+ - `onError` fires for **every** terminal failure; `kind`
764
+ (`"abort" | "timeout" | "network"`) lets you filter — e.g. skip deliberate aborts. Only
765
+ a `"network"` failure is wrapped in a `NetworkError`; aborts/timeouts propagate
766
+ untouched (the `error` passed to the hook is always the one that actually throws).
767
+ - A throwing `onRequest` aborts before the request is sent (clear consumer bug, no
768
+ original error to lose). A throwing `onError` is **swallowed**, so a broken hook can
769
+ never mask the real error. This asymmetry is intentional.
770
+
771
+ Set defaults once on `fetchOrThrow.global`; per-call options win (resolution is
772
+ `per-call ?? global`, an override — not a chain). Because `HttpApi` routes through
773
+ `fetchOrThrow`, these globals instrument the client's requests too.
774
+
775
+ ```ts
776
+ // app-wide defaults (also fire for every HttpApi request)
777
+ fetchOrThrow.global.onRequest = ({ method, url }) => console.debug(`→ ${method} ${url}`);
778
+ fetchOrThrow.global.onError = ({ url, kind, reason }) =>
779
+ kind !== "abort" && console.error(`✗ ${url}: ${reason}`);
780
+
781
+ // per-call object form (overrides the global onRequest for this call only)
782
+ await fetchOrThrow(url, init, { what: "Token issuer", onRequest: () => {} });
783
+ ```
784
+
734
785
  ### createHttpError
735
786
 
736
787
  Creates an HTTP error from a status code and optional details.
package/README.md CHANGED
@@ -203,7 +203,7 @@ utilities, see **[API.md](API.md)**.
203
203
 
204
204
  ## Utilities
205
205
 
206
- ### `fetchOrThrow(input, init?, what?)`
206
+ ### `fetchOrThrow(input, init?, whatOrOptions?)`
207
207
 
208
208
  Wraps the native `fetch` so a transport-level failure surfaces the target host and the
209
209
  real reason instead of an opaque `TypeError: fetch failed`. On failure it throws a
@@ -212,6 +212,9 @@ and whose `cause` is the underlying transport error. Deliberate
212
212
  `AbortError`/`TimeoutError` are re-thrown untouched. The `HttpApi` client uses this
213
213
  internally — reach for it directly when wrapping your own `fetch` calls.
214
214
 
215
+ The 3rd argument is either a label string or a `FetchOrThrowOptions` object carrying that
216
+ label plus optional `onRequest`/`onError` observer hooks.
217
+
215
218
  ```ts
216
219
  import { fetchOrThrow, HTTP_ERROR } from "@marianmeres/http-utils";
217
220
 
@@ -228,6 +231,24 @@ try {
228
231
  }
229
232
  ```
230
233
 
234
+ **Tracing requests.** The `onRequest`/`onError` hooks are pure observers (they can't
235
+ recover or transform anything) — handy for logging, and the only way to catch a _hang_
236
+ where neither a response nor an error ever arrives. Set defaults once on
237
+ `fetchOrThrow.global` (overridable per call; resolution is `per-call ?? global`). Because
238
+ `HttpApi` routes through `fetchOrThrow`, the global hooks instrument it too.
239
+
240
+ ```ts
241
+ // app-wide defaults (also fire for every HttpApi request)
242
+ fetchOrThrow.global.onRequest = ({ method, url }) => console.debug(`→ ${method} ${url}`);
243
+ fetchOrThrow.global.onError = ({ url, kind, reason }) =>
244
+ kind !== "abort" && console.error(`✗ ${url}: ${reason}`); // kind: "abort" | "timeout" | "network"
245
+
246
+ // per-call override (here: silence the global tracer for one call)
247
+ await fetchOrThrow(url, init, { what: "Token issuer", onRequest: () => {} });
248
+ ```
249
+
250
+ See [API.md](./API.md#fetchorthrow) for the full options reference.
251
+
231
252
  ### `getErrorMessage(error)`
232
253
 
233
254
  Extracts human-readable messages from any error format:
package/dist/api.d.ts CHANGED
@@ -117,6 +117,56 @@ export interface DataOptions {
117
117
  * ```
118
118
  */
119
119
  export declare function opts<T extends GetOptions | DataOptions>(options: T): T;
120
+ /**
121
+ * Options for {@link fetchOrThrow}. Pass as the 3rd argument in place of a bare
122
+ * `what` string (a string is normalized to `{ what }`).
123
+ *
124
+ * `onRequest`/`onError` are **pure observers** — their return value is ignored,
125
+ * they cannot recover or transform the request/error. For recovery or retries
126
+ * use the {@link HttpApi} interceptors or your own `catch`.
127
+ */
128
+ export interface FetchOrThrowOptions {
129
+ /** Human-readable label for the target (e.g. "Token issuer"), used in error messages. */
130
+ what?: string;
131
+ /**
132
+ * Called synchronously just before the request is dispatched. No-op by
133
+ * default. Useful for request tracing — including the hang case where
134
+ * neither a response nor an error ever arrives. If this throws, the request
135
+ * is NOT sent (a throwing tracer is a clear consumer bug, and there is no
136
+ * original error to preserve — unlike {@link FetchOrThrowOptions.onError}).
137
+ */
138
+ onRequest?: (info: {
139
+ url: string;
140
+ method?: string;
141
+ what?: string;
142
+ }) => void;
143
+ /**
144
+ * Called just before a transport-level failure is (re-)thrown. Pure observer:
145
+ * its return value is ignored and the original error always propagates. A
146
+ * throw here is swallowed so a broken hook can never mask the real error.
147
+ * `kind` classifies the failure so callers can, e.g., skip deliberate aborts.
148
+ */
149
+ onError?: (info: {
150
+ error: unknown;
151
+ url: string;
152
+ what?: string;
153
+ kind: "abort" | "timeout" | "network";
154
+ /**
155
+ * Human-readable failure reason, extracted via `getErrorMessage`. For a
156
+ * `network` failure this is the resolved transport reason embedded in the
157
+ * `NetworkError` message (e.g. `"ENOTFOUND"`); for `abort`/`timeout` it is
158
+ * the message of the original error. Always a string — handy for structured
159
+ * logging without re-parsing `error` yourself.
160
+ */
161
+ reason: string;
162
+ }) => void;
163
+ }
164
+ /**
165
+ * Global defaults for {@link fetchOrThrow}, exposed as `fetchOrThrow.global` and
166
+ * overridable per call (resolution is `per-call ?? global`). Observer hooks only
167
+ * — `what` is per-call by nature, so it is intentionally excluded.
168
+ */
169
+ export type FetchOrThrowGlobalOptions = Pick<FetchOrThrowOptions, "onRequest" | "onError">;
120
170
  /**
121
171
  * Wraps the native `fetch` so a transport-level failure surfaces the target host
122
172
  * and the real reason instead of an opaque "fetch failed".
@@ -133,10 +183,18 @@ export declare function opts<T extends GetOptions | DataOptions>(options: T): T;
133
183
  * re-thrown untouched — they already carry clear semantics and must not be
134
184
  * masked as "host unreachable".
135
185
  *
186
+ * Optional observer hooks (`onRequest`/`onError`, see {@link FetchOrThrowOptions})
187
+ * can trace the request without wrapping the call site in `try/catch`; the most
188
+ * useful case is detecting a hang, where neither a response nor an error ever
189
+ * arrives. Defaults can be set once on `fetchOrThrow.global` and overridden per
190
+ * call (resolution is `per-call ?? global`). Because {@link HttpApi} routes every
191
+ * request through this function, the global hooks instrument it too.
192
+ *
136
193
  * @param input - The `fetch` resource (URL string, `URL`, or `Request`).
137
194
  * @param init - The `fetch` `RequestInit` options.
138
- * @param what - Optional label describing the target (e.g. "Token issuer"),
139
- * used to prefix the error message.
195
+ * @param what - Either a label describing the target (e.g. "Token issuer"), used
196
+ * to prefix the error message, or a {@link FetchOrThrowOptions} object carrying
197
+ * that label plus the `onRequest`/`onError` observer hooks.
140
198
  *
141
199
  * @returns The `Response`. This does NOT throw on non-2xx HTTP statuses — only
142
200
  * on transport-level failures, exactly like the native `fetch`.
@@ -148,6 +206,11 @@ export declare function opts<T extends GetOptions | DataOptions>(options: T): T;
148
206
  * ```ts
149
207
  * import { fetchOrThrow, HTTP_ERROR } from "@marianmeres/http-utils";
150
208
  *
209
+ * // Configure tracing once, app-wide (also instruments the HttpApi client):
210
+ * fetchOrThrow.global.onRequest = ({ method, url }) => console.debug(`→ ${method} ${url}`);
211
+ * fetchOrThrow.global.onError = ({ url, kind, reason }) =>
212
+ * kind !== "abort" && console.error(`✗ ${url}: ${reason}`);
213
+ *
151
214
  * try {
152
215
  * const res = await fetchOrThrow("https://issuer.example.com/jwks", undefined, "Token issuer");
153
216
  * } catch (e) {
@@ -158,7 +221,10 @@ export declare function opts<T extends GetOptions | DataOptions>(options: T): T;
158
221
  * }
159
222
  * ```
160
223
  */
161
- export declare function fetchOrThrow(input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1], what?: string): Promise<Response>;
224
+ export declare function fetchOrThrow(input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1], what?: string | FetchOrThrowOptions): Promise<Response>;
225
+ export declare namespace fetchOrThrow {
226
+ var global: FetchOrThrowGlobalOptions;
227
+ }
162
228
  /**
163
229
  * HTTP API client with convenient defaults and error handling.
164
230
  */
package/dist/api.js CHANGED
@@ -218,6 +218,37 @@ function _describeFetchTarget(input) {
218
218
  return input.href;
219
219
  return input?.url ?? String(input);
220
220
  }
221
+ // `Symbol.for` + `globalThis` so multiple bundled copies of this package still
222
+ // share one config object (same approach as `@marianmeres/clog`'s global state).
223
+ const _FOT_GLOBAL_KEY = Symbol.for("@marianmeres/http-utils/fetchOrThrow");
224
+ const _FOT_GLOBAL =
225
+ // deno-lint-ignore no-explicit-any
226
+ (globalThis[_FOT_GLOBAL_KEY] ??= {
227
+ onRequest: undefined,
228
+ onError: undefined,
229
+ });
230
+ /**
231
+ * Invoke an `onError` observer defensively: `url` and `reason` are only computed
232
+ * when a hook is present (the network path passes its already-resolved `reason`
233
+ * to avoid a second extraction), and a throwing hook is swallowed so it can never
234
+ * replace the real error that is about to propagate.
235
+ */
236
+ function _notifyFetchError(hook, error, describe, input, what, kind, reason) {
237
+ if (!hook)
238
+ return;
239
+ try {
240
+ hook({
241
+ error,
242
+ url: describe(input),
243
+ what,
244
+ kind,
245
+ reason: reason ?? getErrorMessage(error),
246
+ });
247
+ }
248
+ catch {
249
+ /* observer hooks must not alter control flow */
250
+ }
251
+ }
221
252
  /**
222
253
  * Wraps the native `fetch` so a transport-level failure surfaces the target host
223
254
  * and the real reason instead of an opaque "fetch failed".
@@ -234,10 +265,18 @@ function _describeFetchTarget(input) {
234
265
  * re-thrown untouched — they already carry clear semantics and must not be
235
266
  * masked as "host unreachable".
236
267
  *
268
+ * Optional observer hooks (`onRequest`/`onError`, see {@link FetchOrThrowOptions})
269
+ * can trace the request without wrapping the call site in `try/catch`; the most
270
+ * useful case is detecting a hang, where neither a response nor an error ever
271
+ * arrives. Defaults can be set once on `fetchOrThrow.global` and overridden per
272
+ * call (resolution is `per-call ?? global`). Because {@link HttpApi} routes every
273
+ * request through this function, the global hooks instrument it too.
274
+ *
237
275
  * @param input - The `fetch` resource (URL string, `URL`, or `Request`).
238
276
  * @param init - The `fetch` `RequestInit` options.
239
- * @param what - Optional label describing the target (e.g. "Token issuer"),
240
- * used to prefix the error message.
277
+ * @param what - Either a label describing the target (e.g. "Token issuer"), used
278
+ * to prefix the error message, or a {@link FetchOrThrowOptions} object carrying
279
+ * that label plus the `onRequest`/`onError` observer hooks.
241
280
  *
242
281
  * @returns The `Response`. This does NOT throw on non-2xx HTTP statuses — only
243
282
  * on transport-level failures, exactly like the native `fetch`.
@@ -249,6 +288,11 @@ function _describeFetchTarget(input) {
249
288
  * ```ts
250
289
  * import { fetchOrThrow, HTTP_ERROR } from "@marianmeres/http-utils";
251
290
  *
291
+ * // Configure tracing once, app-wide (also instruments the HttpApi client):
292
+ * fetchOrThrow.global.onRequest = ({ method, url }) => console.debug(`→ ${method} ${url}`);
293
+ * fetchOrThrow.global.onError = ({ url, kind, reason }) =>
294
+ * kind !== "abort" && console.error(`✗ ${url}: ${reason}`);
295
+ *
252
296
  * try {
253
297
  * const res = await fetchOrThrow("https://issuer.example.com/jwks", undefined, "Token issuer");
254
298
  * } catch (e) {
@@ -260,26 +304,55 @@ function _describeFetchTarget(input) {
260
304
  * ```
261
305
  */
262
306
  export async function fetchOrThrow(input, init, what) {
307
+ const opts = typeof what === "string" ? { what } : (what ?? {});
308
+ const label = opts.what;
309
+ // Per-call wins over the global default (override, not chain).
310
+ const onRequest = opts.onRequest ?? _FOT_GLOBAL.onRequest;
311
+ const onError = opts.onError ?? _FOT_GLOBAL.onError;
312
+ if (onRequest) {
313
+ const method = init?.method ??
314
+ (input instanceof Request ? input.method : undefined);
315
+ onRequest({ url: _describeFetchTarget(input), method, what: label });
316
+ }
263
317
  try {
264
318
  return await fetch(input, init);
265
319
  }
266
320
  catch (err) {
267
321
  const name = err?.name;
322
+ const kind = name === "AbortError"
323
+ ? "abort"
324
+ : name === "TimeoutError"
325
+ ? "timeout"
326
+ : "network";
268
327
  // Preserve deliberate cancellations and timeouts as-is.
269
- if (name === "AbortError" || name === "TimeoutError")
328
+ if (kind !== "network") {
329
+ _notifyFetchError(onError, err, _describeFetchTarget, input, label, kind);
270
330
  throw err;
331
+ }
271
332
  // undici/Node bury the real reason on `err.cause`; prefer it so both
272
333
  // `.message` and `getErrorMessage(networkError)` surface ENOTFOUND/etc.
273
334
  // rather than re-surfacing the opaque outer "fetch failed".
274
335
  const underlying = err?.cause ?? err;
275
336
  const reason = getErrorMessage(underlying);
276
337
  const url = _describeFetchTarget(input);
277
- const message = what
278
- ? `${what} unreachable (${url}): ${reason}`
338
+ const message = label
339
+ ? `${label} unreachable (${url}): ${reason}`
279
340
  : `Network request to ${url} failed: ${reason}`;
280
- throw new NetworkError(message, { cause: underlying });
341
+ const networkError = new NetworkError(message, { cause: underlying });
342
+ _notifyFetchError(onError, networkError, () => url, input, label, kind, reason);
343
+ throw networkError;
281
344
  }
282
345
  }
346
+ /**
347
+ * Global defaults for {@link fetchOrThrow}'s observer hooks, overridable per call.
348
+ * Mirrors `createHttpApi.defaultErrorMessageExtractor` and `createClog.global`.
349
+ *
350
+ * @example
351
+ * ```ts
352
+ * fetchOrThrow.global.onRequest = ({ method, url }) => console.debug(`→ ${method} ${url}`);
353
+ * ```
354
+ */
355
+ fetchOrThrow.global = _FOT_GLOBAL;
283
356
  const _fetch = async (params, respHeaders = null, errorMessageExtractor = null, requestInterceptor = null, responseInterceptor = null, _dumpParams = false) => {
284
357
  if (_dumpParams)
285
358
  return params;
package/dist/mod.d.ts CHANGED
@@ -21,6 +21,6 @@
21
21
  * }
22
22
  * ```
23
23
  */
24
- export { createHttpApi, type DataOptions, type ErrorMessageExtractor, fetchOrThrow, type FetchParams, type GetOptions, HttpApi, opts, type QueryValue, type RequestData, type RequestInterceptor, type ResponseHeaders, type ResponseInterceptor, } from "./api.js";
24
+ export { createHttpApi, type DataOptions, type ErrorMessageExtractor, fetchOrThrow, type FetchOrThrowGlobalOptions, type FetchOrThrowOptions, type FetchParams, type GetOptions, HttpApi, opts, type QueryValue, type RequestData, type RequestInterceptor, type ResponseHeaders, type ResponseInterceptor, } from "./api.js";
25
25
  export { createHttpError, getErrorMessage, HTTP_ERROR } from "./error.js";
26
26
  export { HTTP_STATUS } from "./status.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/http-utils",
3
- "version": "2.8.0",
3
+ "version": "2.10.0",
4
4
  "type": "module",
5
5
  "main": "dist/mod.js",
6
6
  "types": "dist/mod.d.ts",