@marianmeres/http-utils 2.8.0 → 2.9.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,15 @@ 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 kind: "abort" | "timeout" | "network" (throw => swallowed, real error preserved).
166
+ // Global defaults (overridable per call; resolution: per-call ?? global; instruments HttpApi too):
167
+ // fetchOrThrow.global.onRequest / fetchOrThrow.global.onError
163
168
  function fetchOrThrow(
164
169
  input: string | URL | Request,
165
170
  init?: RequestInit,
166
- what?: string,
171
+ whatOrOptions?: string | FetchOrThrowOptions,
167
172
  ): Promise<Response>;
168
173
  ```
169
174
 
@@ -209,6 +214,9 @@ class HTTP_STATUS {
209
214
  host) throw `HTTP_ERROR.NetworkError` (status 0) via `fetchOrThrow`, with the real
210
215
  reason in the message and the underlying error as `cause` — never an opaque "fetch
211
216
  failed". Deliberate `AbortError`/`TimeoutError` are not wrapped.
217
+ 11. **Request tracing**: `fetchOrThrow.global.onRequest` / `onError` are observer-only
218
+ hooks (no recovery/transform), overridable per call (`per-call ?? global`). They also
219
+ fire for `HttpApi` requests, since the client routes through `fetchOrThrow`.
212
220
 
213
221
  ## Request Body Serialization
214
222
 
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,54 @@ 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
+ }) => void;
750
+ }
751
+
752
+ // Observer hooks only — `what` is per-call by nature.
753
+ type FetchOrThrowGlobalOptions = Pick<FetchOrThrowOptions, "onRequest" | "onError">;
754
+ ```
755
+
756
+ `onRequest`/`onError` are **pure observers**: their return value is ignored and they
757
+ cannot recover or transform the request/error. Reach for them to trace requests — notably
758
+ the _hang_ case, where neither a response nor an error ever arrives. For recovery or
759
+ retries use the `HttpApi` interceptors or your own `catch`.
760
+
761
+ - `onError` fires for **every** terminal failure; `kind`
762
+ (`"abort" | "timeout" | "network"`) lets you filter — e.g. skip deliberate aborts. Only
763
+ a `"network"` failure is wrapped in a `NetworkError`; aborts/timeouts propagate
764
+ untouched (the `error` passed to the hook is always the one that actually throws).
765
+ - A throwing `onRequest` aborts before the request is sent (clear consumer bug, no
766
+ original error to lose). A throwing `onError` is **swallowed**, so a broken hook can
767
+ never mask the real error. This asymmetry is intentional.
768
+
769
+ Set defaults once on `fetchOrThrow.global`; per-call options win (resolution is
770
+ `per-call ?? global`, an override — not a chain). Because `HttpApi` routes through
771
+ `fetchOrThrow`, these globals instrument the client's requests too.
772
+
773
+ ```ts
774
+ // app-wide defaults (also fire for every HttpApi request)
775
+ fetchOrThrow.global.onRequest = ({ method, url }) => console.debug(`→ ${method} ${url}`);
776
+ fetchOrThrow.global.onError = ({ url, kind }) =>
777
+ kind !== "abort" && console.error(`✗ ${url}`);
778
+
779
+ // per-call object form (overrides the global onRequest for this call only)
780
+ await fetchOrThrow(url, init, { what: "Token issuer", onRequest: () => {} });
781
+ ```
782
+
734
783
  ### createHttpError
735
784
 
736
785
  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 }) =>
244
+ kind !== "abort" && console.error(`✗ ${url}`); // 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,48 @@ 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
+ }) => void;
155
+ }
156
+ /**
157
+ * Global defaults for {@link fetchOrThrow}, exposed as `fetchOrThrow.global` and
158
+ * overridable per call (resolution is `per-call ?? global`). Observer hooks only
159
+ * — `what` is per-call by nature, so it is intentionally excluded.
160
+ */
161
+ export type FetchOrThrowGlobalOptions = Pick<FetchOrThrowOptions, "onRequest" | "onError">;
120
162
  /**
121
163
  * Wraps the native `fetch` so a transport-level failure surfaces the target host
122
164
  * and the real reason instead of an opaque "fetch failed".
@@ -133,10 +175,18 @@ export declare function opts<T extends GetOptions | DataOptions>(options: T): T;
133
175
  * re-thrown untouched — they already carry clear semantics and must not be
134
176
  * masked as "host unreachable".
135
177
  *
178
+ * Optional observer hooks (`onRequest`/`onError`, see {@link FetchOrThrowOptions})
179
+ * can trace the request without wrapping the call site in `try/catch`; the most
180
+ * useful case is detecting a hang, where neither a response nor an error ever
181
+ * arrives. Defaults can be set once on `fetchOrThrow.global` and overridden per
182
+ * call (resolution is `per-call ?? global`). Because {@link HttpApi} routes every
183
+ * request through this function, the global hooks instrument it too.
184
+ *
136
185
  * @param input - The `fetch` resource (URL string, `URL`, or `Request`).
137
186
  * @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.
187
+ * @param what - Either a label describing the target (e.g. "Token issuer"), used
188
+ * to prefix the error message, or a {@link FetchOrThrowOptions} object carrying
189
+ * that label plus the `onRequest`/`onError` observer hooks.
140
190
  *
141
191
  * @returns The `Response`. This does NOT throw on non-2xx HTTP statuses — only
142
192
  * on transport-level failures, exactly like the native `fetch`.
@@ -148,6 +198,11 @@ export declare function opts<T extends GetOptions | DataOptions>(options: T): T;
148
198
  * ```ts
149
199
  * import { fetchOrThrow, HTTP_ERROR } from "@marianmeres/http-utils";
150
200
  *
201
+ * // Configure tracing once, app-wide (also instruments the HttpApi client):
202
+ * fetchOrThrow.global.onRequest = ({ method, url }) => console.debug(`→ ${method} ${url}`);
203
+ * fetchOrThrow.global.onError = ({ url, kind }) =>
204
+ * kind !== "abort" && console.error(`✗ ${url}`);
205
+ *
151
206
  * try {
152
207
  * const res = await fetchOrThrow("https://issuer.example.com/jwks", undefined, "Token issuer");
153
208
  * } catch (e) {
@@ -158,7 +213,10 @@ export declare function opts<T extends GetOptions | DataOptions>(options: T): T;
158
213
  * }
159
214
  * ```
160
215
  */
161
- export declare function fetchOrThrow(input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1], what?: string): Promise<Response>;
216
+ export declare function fetchOrThrow(input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1], what?: string | FetchOrThrowOptions): Promise<Response>;
217
+ export declare namespace fetchOrThrow {
218
+ var global: FetchOrThrowGlobalOptions;
219
+ }
162
220
  /**
163
221
  * HTTP API client with convenient defaults and error handling.
164
222
  */
package/dist/api.js CHANGED
@@ -218,6 +218,30 @@ 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` is only computed when a hook
232
+ * is present, and a throwing hook is swallowed so it can never replace the real
233
+ * error that is about to propagate.
234
+ */
235
+ function _notifyFetchError(hook, error, describe, input, what, kind) {
236
+ if (!hook)
237
+ return;
238
+ try {
239
+ hook({ error, url: describe(input), what, kind });
240
+ }
241
+ catch {
242
+ /* observer hooks must not alter control flow */
243
+ }
244
+ }
221
245
  /**
222
246
  * Wraps the native `fetch` so a transport-level failure surfaces the target host
223
247
  * and the real reason instead of an opaque "fetch failed".
@@ -234,10 +258,18 @@ function _describeFetchTarget(input) {
234
258
  * re-thrown untouched — they already carry clear semantics and must not be
235
259
  * masked as "host unreachable".
236
260
  *
261
+ * Optional observer hooks (`onRequest`/`onError`, see {@link FetchOrThrowOptions})
262
+ * can trace the request without wrapping the call site in `try/catch`; the most
263
+ * useful case is detecting a hang, where neither a response nor an error ever
264
+ * arrives. Defaults can be set once on `fetchOrThrow.global` and overridden per
265
+ * call (resolution is `per-call ?? global`). Because {@link HttpApi} routes every
266
+ * request through this function, the global hooks instrument it too.
267
+ *
237
268
  * @param input - The `fetch` resource (URL string, `URL`, or `Request`).
238
269
  * @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.
270
+ * @param what - Either a label describing the target (e.g. "Token issuer"), used
271
+ * to prefix the error message, or a {@link FetchOrThrowOptions} object carrying
272
+ * that label plus the `onRequest`/`onError` observer hooks.
241
273
  *
242
274
  * @returns The `Response`. This does NOT throw on non-2xx HTTP statuses — only
243
275
  * on transport-level failures, exactly like the native `fetch`.
@@ -249,6 +281,11 @@ function _describeFetchTarget(input) {
249
281
  * ```ts
250
282
  * import { fetchOrThrow, HTTP_ERROR } from "@marianmeres/http-utils";
251
283
  *
284
+ * // Configure tracing once, app-wide (also instruments the HttpApi client):
285
+ * fetchOrThrow.global.onRequest = ({ method, url }) => console.debug(`→ ${method} ${url}`);
286
+ * fetchOrThrow.global.onError = ({ url, kind }) =>
287
+ * kind !== "abort" && console.error(`✗ ${url}`);
288
+ *
252
289
  * try {
253
290
  * const res = await fetchOrThrow("https://issuer.example.com/jwks", undefined, "Token issuer");
254
291
  * } catch (e) {
@@ -260,26 +297,55 @@ function _describeFetchTarget(input) {
260
297
  * ```
261
298
  */
262
299
  export async function fetchOrThrow(input, init, what) {
300
+ const opts = typeof what === "string" ? { what } : (what ?? {});
301
+ const label = opts.what;
302
+ // Per-call wins over the global default (override, not chain).
303
+ const onRequest = opts.onRequest ?? _FOT_GLOBAL.onRequest;
304
+ const onError = opts.onError ?? _FOT_GLOBAL.onError;
305
+ if (onRequest) {
306
+ const method = init?.method ??
307
+ (input instanceof Request ? input.method : undefined);
308
+ onRequest({ url: _describeFetchTarget(input), method, what: label });
309
+ }
263
310
  try {
264
311
  return await fetch(input, init);
265
312
  }
266
313
  catch (err) {
267
314
  const name = err?.name;
315
+ const kind = name === "AbortError"
316
+ ? "abort"
317
+ : name === "TimeoutError"
318
+ ? "timeout"
319
+ : "network";
268
320
  // Preserve deliberate cancellations and timeouts as-is.
269
- if (name === "AbortError" || name === "TimeoutError")
321
+ if (kind !== "network") {
322
+ _notifyFetchError(onError, err, _describeFetchTarget, input, label, kind);
270
323
  throw err;
324
+ }
271
325
  // undici/Node bury the real reason on `err.cause`; prefer it so both
272
326
  // `.message` and `getErrorMessage(networkError)` surface ENOTFOUND/etc.
273
327
  // rather than re-surfacing the opaque outer "fetch failed".
274
328
  const underlying = err?.cause ?? err;
275
329
  const reason = getErrorMessage(underlying);
276
330
  const url = _describeFetchTarget(input);
277
- const message = what
278
- ? `${what} unreachable (${url}): ${reason}`
331
+ const message = label
332
+ ? `${label} unreachable (${url}): ${reason}`
279
333
  : `Network request to ${url} failed: ${reason}`;
280
- throw new NetworkError(message, { cause: underlying });
334
+ const networkError = new NetworkError(message, { cause: underlying });
335
+ _notifyFetchError(onError, networkError, () => url, input, label, kind);
336
+ throw networkError;
281
337
  }
282
338
  }
339
+ /**
340
+ * Global defaults for {@link fetchOrThrow}'s observer hooks, overridable per call.
341
+ * Mirrors `createHttpApi.defaultErrorMessageExtractor` and `createClog.global`.
342
+ *
343
+ * @example
344
+ * ```ts
345
+ * fetchOrThrow.global.onRequest = ({ method, url }) => console.debug(`→ ${method} ${url}`);
346
+ * ```
347
+ */
348
+ fetchOrThrow.global = _FOT_GLOBAL;
283
349
  const _fetch = async (params, respHeaders = null, errorMessageExtractor = null, requestInterceptor = null, responseInterceptor = null, _dumpParams = false) => {
284
350
  if (_dumpParams)
285
351
  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.9.0",
4
4
  "type": "module",
5
5
  "main": "dist/mod.js",
6
6
  "types": "dist/mod.d.ts",