@marianmeres/http-utils 2.7.1 → 2.8.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/README.md CHANGED
@@ -4,14 +4,18 @@
4
4
  [![JSR version](https://jsr.io/badges/@marianmeres/http-utils)](https://jsr.io/@marianmeres/http-utils)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
6
 
7
- Opinionated, lightweight HTTP client wrapper for `fetch` with type-safe errors and convenient defaults.
7
+ Opinionated, lightweight HTTP client wrapper for `fetch` with type-safe errors and
8
+ convenient defaults.
8
9
 
9
10
  ## Features
10
11
 
11
12
  - ðŸŽŊ **Type-safe HTTP errors** - Well-known status codes map to specific error classes
12
13
  - 🔧 **Convenient defaults** - Auto JSON parsing, Bearer tokens, base URLs
13
14
  - ðŸŠķ **Lightweight** - Zero dependencies, thin wrapper over native `fetch`
14
- - ðŸŽĻ **Flexible error handling** - Three-tier error message extraction (local → factory → global)
15
+ - ðŸŽĻ **Flexible error handling** - Three-tier error message extraction (local → factory →
16
+ global)
17
+ - 🛰ïļ **No swallowed transport errors** - DNS/connection failures surface the host and real
18
+ reason instead of an opaque "fetch failed"
15
19
  - ðŸ“Ķ **Deno & Node.js** - Works in both runtimes
16
20
  - ðŸĶū **Generic return types** - Optional type parameters for typed responses
17
21
 
@@ -26,51 +30,60 @@ npm install @marianmeres/http-utils
26
30
  ```
27
31
 
28
32
  ```ts
29
- import { createHttpApi, opts, HTTP_ERROR } from "@marianmeres/http-utils";
33
+ import { createHttpApi, HTTP_ERROR, opts } from "@marianmeres/http-utils";
30
34
  ```
31
35
 
32
36
  ## Quick Start
33
37
 
34
38
  ```ts
35
- import { createHttpApi, opts, HTTP_ERROR, NotFound } from "@marianmeres/http-utils";
39
+ import { createHttpApi, HTTP_ERROR, NotFound, opts } from "@marianmeres/http-utils";
36
40
 
37
41
  // Create an API client with base URL
38
42
  const api = createHttpApi("https://api.example.com", {
39
- headers: { "Authorization": "Bearer your-token" }
43
+ headers: { "Authorization": "Bearer your-token" },
40
44
  });
41
45
 
42
46
  // GET request (options API with opts() wrapper)
43
- const users = await api.get("/users", opts({
44
- params: { headers: { "X-Custom": "value" } }
45
- }));
47
+ const users = await api.get(
48
+ "/users",
49
+ opts({
50
+ params: { headers: { "X-Custom": "value" } },
51
+ }),
52
+ );
46
53
 
47
54
  // POST request (options API with opts() wrapper)
48
- const newUser = await api.post("/users", opts({
49
- data: { name: "John Doe" },
50
- params: { headers: { "X-Custom": "value" } }
51
- }));
55
+ const newUser = await api.post(
56
+ "/users",
57
+ opts({
58
+ data: { name: "John Doe" },
59
+ params: { headers: { "X-Custom": "value" } },
60
+ }),
61
+ );
52
62
 
53
63
  // Legacy API (default behavior without opts())
54
64
  const legacyUsers = await api.get("/users", { headers: { "X-Custom": "value" } });
55
65
  const legacyUser = await api.post("/users", { name: "John Doe" });
56
66
 
57
67
  // With type parameters for typed responses
58
- interface User { id: number; name: string; }
68
+ interface User {
69
+ id: number;
70
+ name: string;
71
+ }
59
72
  const user = await api.get<User>("/users/1");
60
73
  const created = await api.post<User>("/users", opts({ data: { name: "Jane" } }));
61
74
 
62
75
  // Error handling
63
76
  try {
64
- await api.get("/not-found");
77
+ await api.get("/not-found");
65
78
  } catch (error) {
66
- if (error instanceof NotFound) {
67
- console.log("Resource not found");
68
- }
69
- // or use the namespace
70
- if (error instanceof HTTP_ERROR.NotFound) {
71
- console.log(error.status); // 404
72
- console.log(error.body); // Response body
73
- }
79
+ if (error instanceof NotFound) {
80
+ console.log("Resource not found");
81
+ }
82
+ // or use the namespace
83
+ if (error instanceof HTTP_ERROR.NotFound) {
84
+ console.log(error.status); // 404
85
+ console.log(error.body); // Response body
86
+ }
74
87
  }
75
88
  ```
76
89
 
@@ -82,7 +95,7 @@ Creates an HTTP API client.
82
95
 
83
96
  ```ts
84
97
  const api = createHttpApi("https://api.example.com", {
85
- headers: { "Authorization": "Bearer token" }
98
+ headers: { "Authorization": "Bearer token" },
86
99
  });
87
100
  ```
88
101
 
@@ -90,16 +103,22 @@ const api = createHttpApi("https://api.example.com", {
90
103
 
91
104
  ```ts
92
105
  // GET (options API with opts() wrapper)
93
- const data = await api.get("/users", opts({
94
- params: { headers: { "X-Custom": "value" } },
95
- respHeaders: {}
96
- }));
106
+ const data = await api.get(
107
+ "/users",
108
+ opts({
109
+ params: { headers: { "X-Custom": "value" } },
110
+ respHeaders: {},
111
+ }),
112
+ );
97
113
 
98
114
  // POST/PUT/PATCH/DELETE (options API with opts() wrapper)
99
- await api.post("/users", opts({
100
- data: { name: "John" },
101
- params: { token: "bearer-token" }
102
- }));
115
+ await api.post(
116
+ "/users",
117
+ opts({
118
+ data: { name: "John" },
119
+ params: { token: "bearer-token" },
120
+ }),
121
+ );
103
122
 
104
123
  // Legacy API (default behavior without opts())
105
124
  const data = await api.get("/users", { headers: { "X-Custom": "value" } });
@@ -108,14 +127,15 @@ await api.post("/users", { name: "John" });
108
127
 
109
128
  ### The `opts()` Helper
110
129
 
111
- The `opts()` function explicitly marks an options object for the options-based API. Without it, arguments are treated as legacy positional parameters.
130
+ The `opts()` function explicitly marks an options object for the options-based API.
131
+ Without it, arguments are treated as legacy positional parameters.
112
132
 
113
133
  ```ts
114
134
  // Without opts() - legacy behavior: object is sent as request body
115
- await api.post("/users", { data: { name: "John" } }); // Sends: { data: { name: "John" } }
135
+ await api.post("/users", { data: { name: "John" } }); // Sends: { data: { name: "John" } }
116
136
 
117
137
  // With opts() - options API: data is extracted and sent as body
118
- await api.post("/users", opts({ data: { name: "John" } })); // Sends: { name: "John" }
138
+ await api.post("/users", opts({ data: { name: "John" } })); // Sends: { name: "John" }
119
139
  ```
120
140
 
121
141
  This makes the API unambiguous and prevents accidental misinterpretation of request data.
@@ -126,27 +146,37 @@ This makes the API unambiguous and prevents accidental misinterpretation of requ
126
146
  import { HTTP_ERROR, NotFound } from "@marianmeres/http-utils";
127
147
 
128
148
  try {
129
- await api.get("/resource");
149
+ await api.get("/resource");
130
150
  } catch (error) {
131
- if (error instanceof NotFound) {
132
- console.log("Not found:", error.body);
133
- }
134
- // All errors have: status, statusText, body, cause
151
+ if (error instanceof NotFound) {
152
+ console.log("Not found:", error.body);
153
+ }
154
+ // Transport-level failures (DNS, refused connection, unreachable host) throw
155
+ // a NetworkError (status 0) instead of an opaque "fetch failed":
156
+ if (error instanceof HTTP_ERROR.NetworkError) {
157
+ console.log(error.message); // e.g. "GET unreachable (https://...): ECONNREFUSED"
158
+ console.log(error.cause); // underlying transport error
159
+ }
160
+ // All errors have: status, statusText, body, cause
135
161
  }
136
162
  ```
137
163
 
138
164
  ### Key Features
139
165
 
140
- - **Auto JSON**: Response bodies are automatically parsed as JSON; empty bodies (204/205) return `null`
141
- - **Smart body handling**: Plain objects → JSON; `FormData` / `URLSearchParams` / `Blob` / typed arrays / `ReadableStream` pass through; strings sent as-is
166
+ - **Auto JSON**: Response bodies are automatically parsed as JSON; empty bodies (204/205)
167
+ return `null`
168
+ - **Smart body handling**: Plain objects → JSON; `FormData` / `URLSearchParams` / `Blob` /
169
+ typed arrays / `ReadableStream` pass through; strings sent as-is
142
170
  - **Query params**: Pass `query: { page: 1, tag: ["a", "b"] }` for URL search params
143
171
  - **Timeouts**: Pass `timeout: 5000` for automatic request cancellation
144
172
  - **Bearer tokens**: Use `token` param to auto-add `Authorization: Bearer` header
145
173
  - **Response headers**: Pass `respHeaders: {}` to capture response headers
146
- - **Raw response**: Use `raw: true` to get the raw Response object (caller must consume the body)
174
+ - **Raw response**: Use `raw: true` to get the raw Response object (caller must consume
175
+ the body)
147
176
  - **Non-throwing**: Use `assert: false` to prevent throwing on errors
148
177
  - **AbortController**: Pass `signal` for request cancellation (composes with `timeout`)
149
- - **Interceptors**: `api.onRequest(...)` / `api.onResponse(...)` for tracing, auth refresh, etc.
178
+ - **Interceptors**: `api.onRequest(...)` / `api.onResponse(...)` for tracing, auth
179
+ refresh, etc.
150
180
  - **Typed responses**: Use generics for type-safe responses: `api.get<User>("/users/1")`
151
181
 
152
182
  ### Query, Timeout, Interceptors
@@ -160,18 +190,44 @@ await api.get("/slow", { timeout: 5000 });
160
190
 
161
191
  // Interceptors
162
192
  api.onRequest((init, { method, url }) => {
163
- console.log(method, url);
193
+ console.log(method, url);
164
194
  }).onResponse(async (resp) => {
165
- if (resp.status === 401) await refreshToken();
195
+ if (resp.status === 401) await refreshToken();
166
196
  });
167
197
  ```
168
198
 
169
199
  ## Full API Reference
170
200
 
171
- For complete API documentation including all error classes, HTTP status codes, types, and utilities, see **[API.md](API.md)**.
201
+ For complete API documentation including all error classes, HTTP status codes, types, and
202
+ utilities, see **[API.md](API.md)**.
172
203
 
173
204
  ## Utilities
174
205
 
206
+ ### `fetchOrThrow(input, init?, what?)`
207
+
208
+ Wraps the native `fetch` so a transport-level failure surfaces the target host and the
209
+ real reason instead of an opaque `TypeError: fetch failed`. On failure it throws a
210
+ `NetworkError` (in the `HTTP_ERROR` namespace) whose message includes the URL and reason,
211
+ and whose `cause` is the underlying transport error. Deliberate
212
+ `AbortError`/`TimeoutError` are re-thrown untouched. The `HttpApi` client uses this
213
+ internally — reach for it directly when wrapping your own `fetch` calls.
214
+
215
+ ```ts
216
+ import { fetchOrThrow, HTTP_ERROR } from "@marianmeres/http-utils";
217
+
218
+ try {
219
+ const res = await fetchOrThrow(
220
+ "https://issuer.example.com/jwks",
221
+ undefined,
222
+ "Token issuer",
223
+ );
224
+ } catch (e) {
225
+ if (e instanceof HTTP_ERROR.NetworkError) {
226
+ console.log(e.message); // "Token issuer unreachable (https://issuer.example.com/jwks): ENOTFOUND"
227
+ }
228
+ }
229
+ ```
230
+
175
231
  ### `getErrorMessage(error)`
176
232
 
177
233
  Extracts human-readable messages from any error format:
@@ -180,9 +236,9 @@ Extracts human-readable messages from any error format:
180
236
  import { getErrorMessage } from "@marianmeres/http-utils";
181
237
 
182
238
  try {
183
- await api.get("/fail");
239
+ await api.get("/fail");
184
240
  } catch (error) {
185
- console.log(getErrorMessage(error)); // "Not Found"
241
+ console.log(getErrorMessage(error)); // "Not Found"
186
242
  }
187
243
  ```
188
244
 
@@ -195,4 +251,4 @@ import { createHttpError } from "@marianmeres/http-utils";
195
251
 
196
252
  const error = createHttpError(404, "User not found", { userId: 123 });
197
253
  throw error; // instanceof NotFound
198
- ```
254
+ ```
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
+ * Wraps the native `fetch` so a transport-level failure surfaces the target host
122
+ * and the real reason instead of an opaque "fetch failed".
123
+ *
124
+ * Node/undici collapses DNS failures, refused connections and connect timeouts
125
+ * into a `TypeError: fetch failed` whose actual code (`ENOTFOUND`,
126
+ * `ECONNREFUSED`, `UND_ERR_CONNECT_TIMEOUT`, ...) lives on `err.cause` and is
127
+ * absent from the message and stack. On such a failure this throws a
128
+ * `NetworkError` (see {@link HTTP_ERROR}) whose message includes the URL and the
129
+ * resolved reason, and whose `cause` is the underlying transport error (so both
130
+ * `error.message` and `getErrorMessage(error)` report the real reason).
131
+ *
132
+ * Deliberate cancellations (`AbortError`) and timeouts (`TimeoutError`) are
133
+ * re-thrown untouched — they already carry clear semantics and must not be
134
+ * masked as "host unreachable".
135
+ *
136
+ * @param input - The `fetch` resource (URL string, `URL`, or `Request`).
137
+ * @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.
140
+ *
141
+ * @returns The `Response`. This does NOT throw on non-2xx HTTP statuses — only
142
+ * on transport-level failures, exactly like the native `fetch`.
143
+ *
144
+ * @throws A `NetworkError` on a transport-level failure (DNS, refused
145
+ * connection, unreachable host, ...).
146
+ *
147
+ * @example
148
+ * ```ts
149
+ * import { fetchOrThrow, HTTP_ERROR } from "@marianmeres/http-utils";
150
+ *
151
+ * try {
152
+ * const res = await fetchOrThrow("https://issuer.example.com/jwks", undefined, "Token issuer");
153
+ * } catch (e) {
154
+ * if (e instanceof HTTP_ERROR.NetworkError) {
155
+ * // e.message → "Token issuer unreachable (https://issuer.example.com/jwks): ENOTFOUND"
156
+ * // e.cause → underlying transport error
157
+ * }
158
+ * }
159
+ * ```
160
+ */
161
+ export declare function fetchOrThrow(input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1], what?: string): Promise<Response>;
120
162
  /**
121
163
  * HTTP API client with convenient defaults and error handling.
122
164
  */
package/dist/api.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * HTTP API client factory and related types.
5
5
  * Provides a convenient wrapper over the native `fetch` API with sensible defaults.
6
6
  */
7
- import { createHttpError } from "./error.js";
7
+ import { createHttpError, getErrorMessage, NetworkError } from "./error.js";
8
8
  /**
9
9
  * Deep merges two objects. Later properties overwrite earlier properties.
10
10
  * Arrays are overwritten, not concatenated (conventional behavior).
@@ -101,8 +101,9 @@ function composeSignal(userSignal, timeoutMs) {
101
101
  userSignal.addEventListener("abort", () => abort(userSignal.reason));
102
102
  if (timeoutSignal.aborted)
103
103
  abort(timeoutSignal.reason);
104
- else
104
+ else {
105
105
  timeoutSignal.addEventListener("abort", () => abort(timeoutSignal.reason));
106
+ }
106
107
  return ctrl.signal;
107
108
  }
108
109
  /** Symbol marker for explicit options API detection. */
@@ -209,6 +210,76 @@ function buildRequest(params) {
209
210
  url = appendQuery(url, query);
210
211
  return { url, init };
211
212
  }
213
+ /** Best-effort human-readable description of a `fetch` target for error messages. */
214
+ function _describeFetchTarget(input) {
215
+ if (typeof input === "string")
216
+ return input;
217
+ if (input instanceof URL)
218
+ return input.href;
219
+ return input?.url ?? String(input);
220
+ }
221
+ /**
222
+ * Wraps the native `fetch` so a transport-level failure surfaces the target host
223
+ * and the real reason instead of an opaque "fetch failed".
224
+ *
225
+ * Node/undici collapses DNS failures, refused connections and connect timeouts
226
+ * into a `TypeError: fetch failed` whose actual code (`ENOTFOUND`,
227
+ * `ECONNREFUSED`, `UND_ERR_CONNECT_TIMEOUT`, ...) lives on `err.cause` and is
228
+ * absent from the message and stack. On such a failure this throws a
229
+ * `NetworkError` (see {@link HTTP_ERROR}) whose message includes the URL and the
230
+ * resolved reason, and whose `cause` is the underlying transport error (so both
231
+ * `error.message` and `getErrorMessage(error)` report the real reason).
232
+ *
233
+ * Deliberate cancellations (`AbortError`) and timeouts (`TimeoutError`) are
234
+ * re-thrown untouched — they already carry clear semantics and must not be
235
+ * masked as "host unreachable".
236
+ *
237
+ * @param input - The `fetch` resource (URL string, `URL`, or `Request`).
238
+ * @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.
241
+ *
242
+ * @returns The `Response`. This does NOT throw on non-2xx HTTP statuses — only
243
+ * on transport-level failures, exactly like the native `fetch`.
244
+ *
245
+ * @throws A `NetworkError` on a transport-level failure (DNS, refused
246
+ * connection, unreachable host, ...).
247
+ *
248
+ * @example
249
+ * ```ts
250
+ * import { fetchOrThrow, HTTP_ERROR } from "@marianmeres/http-utils";
251
+ *
252
+ * try {
253
+ * const res = await fetchOrThrow("https://issuer.example.com/jwks", undefined, "Token issuer");
254
+ * } catch (e) {
255
+ * if (e instanceof HTTP_ERROR.NetworkError) {
256
+ * // e.message → "Token issuer unreachable (https://issuer.example.com/jwks): ENOTFOUND"
257
+ * // e.cause → underlying transport error
258
+ * }
259
+ * }
260
+ * ```
261
+ */
262
+ export async function fetchOrThrow(input, init, what) {
263
+ try {
264
+ return await fetch(input, init);
265
+ }
266
+ catch (err) {
267
+ const name = err?.name;
268
+ // Preserve deliberate cancellations and timeouts as-is.
269
+ if (name === "AbortError" || name === "TimeoutError")
270
+ throw err;
271
+ // undici/Node bury the real reason on `err.cause`; prefer it so both
272
+ // `.message` and `getErrorMessage(networkError)` surface ENOTFOUND/etc.
273
+ // rather than re-surfacing the opaque outer "fetch failed".
274
+ const underlying = err?.cause ?? err;
275
+ const reason = getErrorMessage(underlying);
276
+ const url = _describeFetchTarget(input);
277
+ const message = what
278
+ ? `${what} unreachable (${url}): ${reason}`
279
+ : `Network request to ${url} failed: ${reason}`;
280
+ throw new NetworkError(message, { cause: underlying });
281
+ }
282
+ }
212
283
  const _fetch = async (params, respHeaders = null, errorMessageExtractor = null, requestInterceptor = null, responseInterceptor = null, _dumpParams = false) => {
213
284
  if (_dumpParams)
214
285
  return params;
@@ -221,7 +292,7 @@ const _fetch = async (params, respHeaders = null, errorMessageExtractor = null,
221
292
  if (patched)
222
293
  init = patched;
223
294
  }
224
- let r = await fetch(url, init);
295
+ let r = await fetchOrThrow(url, init, params.method);
225
296
  if (responseInterceptor) {
226
297
  const patched = await responseInterceptor(r, {
227
298
  method: params.method,
@@ -232,7 +303,9 @@ const _fetch = async (params, respHeaders = null, errorMessageExtractor = null,
232
303
  try {
233
304
  await r.body?.cancel();
234
305
  }
235
- catch (_e) { /* ignore */ }
306
+ catch (_e) {
307
+ /* ignore */
308
+ }
236
309
  r = patched;
237
310
  }
238
311
  }
package/dist/error.d.ts CHANGED
@@ -124,7 +124,33 @@ declare class ServiceUnavailable extends HttpError {
124
124
  status: number;
125
125
  statusText: string;
126
126
  }
127
- export { HttpError, BadRequest, Unauthorized, Forbidden, NotFound, MethodNotAllowed, RequestTimeout, Conflict, Gone, LengthRequired, ImATeapot, UnprocessableContent, TooManyRequests, InternalServerError, NotImplemented, BadGateway, ServiceUnavailable, };
127
+ /**
128
+ * Transport-level failure: DNS resolution failure, refused connection, connect
129
+ * timeout, unreachable host, etc. No HTTP response was received, so `status` is
130
+ * `0` (the de-facto web convention for network-level failures, mirroring
131
+ * `XMLHttpRequest.status`). Thrown by `fetchOrThrow` (and, by extension, by the
132
+ * `HttpApi` client) with the underlying transport error attached as `cause`, so
133
+ * the real reason (`ENOTFOUND`, `ECONNREFUSED`, ...) is never swallowed behind
134
+ * an opaque "fetch failed".
135
+ *
136
+ * @example
137
+ * ```ts
138
+ * try {
139
+ * await api.get("/resource");
140
+ * } catch (error) {
141
+ * if (error instanceof HTTP_ERROR.NetworkError) {
142
+ * console.log(error.message); // e.g. "GET unreachable (https://...): ECONNREFUSED"
143
+ * console.log(error.cause); // the underlying transport error
144
+ * }
145
+ * }
146
+ * ```
147
+ */
148
+ declare class NetworkError extends HttpError {
149
+ name: string;
150
+ status: number;
151
+ statusText: string;
152
+ }
153
+ export { BadGateway, BadRequest, Conflict, Forbidden, Gone, HttpError, ImATeapot, InternalServerError, LengthRequired, MethodNotAllowed, NetworkError, NotFound, NotImplemented, RequestTimeout, ServiceUnavailable, TooManyRequests, Unauthorized, UnprocessableContent, };
128
154
  /**
129
155
  * Namespace containing all HTTP error classes for convenient access.
130
156
  *
@@ -162,6 +188,7 @@ export declare const HTTP_ERROR: {
162
188
  NotImplemented: typeof NotImplemented;
163
189
  BadGateway: typeof BadGateway;
164
190
  ServiceUnavailable: typeof ServiceUnavailable;
191
+ NetworkError: typeof NetworkError;
165
192
  };
166
193
  /**
167
194
  * Creates an HTTP error from a status code and optional details.