@marianmeres/http-utils 2.5.0 → 2.6.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
@@ -4,7 +4,7 @@
4
4
 
5
5
  ```yaml
6
6
  name: "@marianmeres/http-utils"
7
- version: "2.0.2"
7
+ version: "2.5.1"
8
8
  license: MIT
9
9
  runtime: deno, node
10
10
  type: library
@@ -46,19 +46,40 @@ function createHttpApi(
46
46
  | `put` | `put(path, options?: DataOptions): Promise<unknown>` | PUT request |
47
47
  | `patch` | `patch(path, options?: DataOptions): Promise<unknown>` | PATCH request |
48
48
  | `del` | `del(path, options?: DataOptions): Promise<unknown>` | DELETE request |
49
- | `url` | `url(path: string): string` | Build full URL |
49
+ | `url` | `url(path: string): string` | Build full URL (base trailing slash + path leading slash normalized) |
50
50
  | `base` | `get/set base: string \| null` | Base URL property |
51
+ | `onRequest` | `onRequest(i: RequestInterceptor \| null): this` | Register request interceptor |
52
+ | `onResponse` | `onResponse(i: ResponseInterceptor \| null): this` | Register response interceptor |
51
53
 
52
54
  ### Exported Types
53
55
 
54
56
  ```typescript
55
- type RequestData = Record<string, unknown> | FormData | string | null;
57
+ type RequestData =
58
+ | Record<string, unknown>
59
+ | unknown[]
60
+ | FormData
61
+ | Blob
62
+ | ArrayBuffer
63
+ | ArrayBufferView
64
+ | URLSearchParams
65
+ | ReadableStream
66
+ | string
67
+ | number
68
+ | boolean
69
+ | null;
70
+
71
+ type QueryValue =
72
+ | string | number | boolean
73
+ | (string | number | boolean)[]
74
+ | null | undefined;
56
75
 
57
76
  interface FetchParams {
58
77
  data?: RequestData;
59
78
  token?: string | null;
60
- headers?: Record<string, string> | null;
79
+ headers?: HeadersInit | null;
61
80
  signal?: AbortSignal;
81
+ timeout?: number | null; // ms
82
+ query?: Record<string, QueryValue> | null;
62
83
  credentials?: 'omit' | 'same-origin' | 'include' | null;
63
84
  raw?: boolean | null;
64
85
  assert?: boolean | null;
@@ -79,6 +100,16 @@ interface DataOptions {
79
100
 
80
101
  type ErrorMessageExtractor = (body: unknown, response: Response) => string;
81
102
  type ResponseHeaders = Record<string, string | number>;
103
+
104
+ type RequestInterceptor = (
105
+ init: RequestInit,
106
+ ctx: { method: string; url: string }
107
+ ) => RequestInit | void | Promise<RequestInit | void>;
108
+
109
+ type ResponseInterceptor = (
110
+ response: Response,
111
+ ctx: { method: string; url: string }
112
+ ) => Response | void | Promise<Response | void>;
82
113
  ```
83
114
 
84
115
  ### Error Classes (HTTP_ERROR namespace)
@@ -138,12 +169,26 @@ class HTTP_STATUS {
138
169
 
139
170
  ## Key Behaviors
140
171
 
141
- 1. **Auto JSON parsing**: Response bodies are automatically parsed as JSON if possible
172
+ 1. **Auto JSON parsing**: Response bodies are parsed as JSON if possible; empty bodies (204/205) return `null`
142
173
  2. **Bearer token**: `token` param auto-adds `Authorization: Bearer {token}` header
143
174
  3. **Error throwing**: By default, non-OK responses throw HttpError (disable with `assert: false`)
144
175
  4. **Response headers**: Pass `respHeaders: {}` to capture response headers (mutated in place)
145
- 5. **Raw response**: Use `raw: true` to get raw Response object instead of parsed body
146
- 6. **Error priority**: per-request extractor → per-instance → global → built-in fallback
176
+ 5. **Raw response**: Use `raw: true` to get raw Response object; the caller must consume the body
177
+ 6. **Error priority**: per-request extractor → per-instance → global → built-in fallback; a throwing extractor falls through to the next priority rather than crashing
178
+ 7. **URL normalization**: `#url(path)` strips trailing `/` from base and ensures leading `/` on path, so `base + path` never produces `//` or missing `/`
179
+ 8. **Timeout**: `params.timeout` (ms) aborts via `AbortSignal.timeout`; composed with `params.signal` via `AbortSignal.any`
180
+ 9. **Query**: `params.query` object appended as URL search params; `null`/`undefined` skipped, arrays → repeated keys
181
+
182
+ ## Request Body Serialization
183
+
184
+ | Runtime type | Sent as | Content-Type set |
185
+ |---|---|---|
186
+ | `null` / `undefined` | No body | — |
187
+ | `string` | raw | caller-controlled |
188
+ | `number` / `boolean` / plain object / array | `JSON.stringify` | `application/json` only if not already set |
189
+ | `FormData`, `URLSearchParams`, `Blob`, `ArrayBuffer`, typed arrays, `ReadableStream` | passed to fetch unchanged | fetch handles (e.g. multipart boundary, form-urlencoded) |
190
+
191
+ User-provided `Content-Type` header is always preserved.
147
192
 
148
193
  ## Development Commands
149
194
 
package/API.md CHANGED
@@ -7,6 +7,10 @@ Complete API reference for `@marianmeres/http-utils`.
7
7
  - [createHttpApi](#createhttpapi)
8
8
  - [opts](#opts)
9
9
  - [HttpApi Class](#httpapi-class)
10
+ - [Request Bodies](#request-bodies)
11
+ - [Query Parameters](#query-parameters)
12
+ - [Timeouts and Cancellation](#timeouts-and-cancellation)
13
+ - [Interceptors](#interceptors)
10
14
  - [Types](#types)
11
15
  - [HTTP Errors](#http-errors)
12
16
  - [HTTP Status Codes](#http-status-codes)
@@ -208,7 +212,7 @@ Performs a DELETE request. Same signature as `post<T>()`.
208
212
 
209
213
  #### `url(path)`
210
214
 
211
- Builds the full URL from a path.
215
+ Builds the full URL from a path. Base trailing slashes and missing path leading slashes are normalized so there is exactly one `/` between base and path.
212
216
 
213
217
  ```ts
214
218
  url(path: string): string
@@ -217,10 +221,22 @@ url(path: string): string
217
221
  **Example:**
218
222
  ```ts
219
223
  const api = createHttpApi("https://api.example.com");
220
- api.url("/users"); // "https://api.example.com/users"
221
- api.url("https://other.com/path"); // "https://other.com/path" (absolute URLs returned as-is)
224
+ api.url("/users"); // "https://api.example.com/users"
225
+ api.url("users"); // "https://api.example.com/users" (leading slash added)
226
+ api.url("https://other.com/path"); // "https://other.com/path" (absolute URLs returned as-is)
227
+
228
+ const api2 = createHttpApi("https://api.example.com/v1/");
229
+ api2.url("/users"); // "https://api.example.com/v1/users" (no double slash)
222
230
  ```
223
231
 
232
+ #### `onRequest(interceptor)`
233
+
234
+ Registers a request interceptor. See [Interceptors](#interceptors).
235
+
236
+ #### `onResponse(interceptor)`
237
+
238
+ Registers a response interceptor. See [Interceptors](#interceptors).
239
+
224
240
  ### Properties
225
241
 
226
242
  #### `base`
@@ -234,14 +250,160 @@ set base(v: string | null | undefined)
234
250
 
235
251
  ---
236
252
 
253
+ ## Request Bodies
254
+
255
+ The `data` parameter is serialized based on its runtime type:
256
+
257
+ | Runtime type | Behavior | Content-Type |
258
+ |---|---|---|
259
+ | `null` / `undefined` | No body sent | — |
260
+ | `string` | Sent as-is | Caller's (fetch may default to `text/plain;charset=UTF-8`) |
261
+ | `number` / `boolean` | `JSON.stringify`'d (e.g. `0` → `"0"`) | `application/json` if not set |
262
+ | Plain object / array | `JSON.stringify`'d | `application/json` if not set |
263
+ | `FormData` | Passed to fetch unchanged | `multipart/form-data; boundary=…` (fetch auto-sets) |
264
+ | `URLSearchParams` | Passed to fetch unchanged | `application/x-www-form-urlencoded;charset=UTF-8` (fetch auto-sets) |
265
+ | `Blob` | Passed to fetch unchanged | From the Blob's `.type` |
266
+ | `ArrayBuffer` / typed arrays | Passed to fetch unchanged | Caller's (none by default) |
267
+ | `ReadableStream` | Passed to fetch unchanged | Caller's |
268
+
269
+ If you explicitly set `Content-Type` in `headers`, it is always respected — even for object data. Objects are still JSON-stringified; set your own body (e.g. via a string) if you need a non-JSON serialization.
270
+
271
+ ```ts
272
+ // Plain object → JSON
273
+ await api.post("/users", { name: "John" });
274
+
275
+ // Respect user Content-Type
276
+ await api.post("/graph", { query: "{ me { id } }" }, {
277
+ headers: { "content-type": "application/graphql+json" },
278
+ });
279
+
280
+ // FormData (file upload)
281
+ const fd = new FormData();
282
+ fd.append("file", fileBlob);
283
+ await api.post("/upload", fd);
284
+
285
+ // URL-encoded form
286
+ await api.post("/login", new URLSearchParams({ user: "a", pass: "b" }));
287
+
288
+ // Raw string body
289
+ await api.post("/logs", "raw log line", {
290
+ headers: { "content-type": "text/plain" },
291
+ });
292
+ ```
293
+
294
+ ---
295
+
296
+ ## Query Parameters
297
+
298
+ Use `params.query` to append query-string parameters to the URL.
299
+
300
+ ```ts
301
+ await api.get("/search", {
302
+ query: {
303
+ q: "hello world",
304
+ page: 1,
305
+ active: true,
306
+ tag: ["a", "b"], // → ?tag=a&tag=b
307
+ ignored: null, // null and undefined are skipped
308
+ },
309
+ });
310
+ // GET /search?q=hello+world&page=1&active=true&tag=a&tag=b
311
+ ```
312
+
313
+ If the path already contains `?`, query params are appended with `&`.
314
+
315
+ ---
316
+
317
+ ## Timeouts and Cancellation
318
+
319
+ `params.timeout` (in milliseconds) aborts the request via `AbortSignal.timeout`. It composes with a user-provided `signal` — whichever fires first aborts the request.
320
+
321
+ ```ts
322
+ // Simple timeout
323
+ await api.get("/slow", { timeout: 5000 });
324
+
325
+ // Timeout + cancellable signal
326
+ const ctrl = new AbortController();
327
+ await api.get("/slow", {
328
+ timeout: 10_000,
329
+ signal: ctrl.signal,
330
+ });
331
+ // ctrl.abort() or timeout — whichever first — cancels the request.
332
+ ```
333
+
334
+ Uses `AbortSignal.any` when available (Node 20+, modern Deno). Falls back to manual composition otherwise.
335
+
336
+ ---
337
+
338
+ ## Interceptors
339
+
340
+ Register per-instance hooks that run around each request.
341
+
342
+ ### `onRequest(interceptor)`
343
+
344
+ Called after defaults are merged, with the final `RequestInit` and resolved URL. Return a new `RequestInit` to replace the original, or `void` / `undefined` to keep it.
345
+
346
+ ```ts
347
+ const api = createHttpApi("https://api.example.com").onRequest((init, ctx) => {
348
+ console.log(`[http] → ${ctx.method} ${ctx.url}`);
349
+ const h = new Headers(init.headers);
350
+ h.set("x-trace-id", crypto.randomUUID());
351
+ return { ...init, headers: h };
352
+ });
353
+ ```
354
+
355
+ ### `onResponse(interceptor)`
356
+
357
+ Called before the body is consumed. **Must not read the body.** Return a replacement `Response` (e.g. after a retry) or `void` to keep the original. If you return a replacement, the original's body is cancelled for you.
358
+
359
+ ```ts
360
+ api.onResponse(async (resp, ctx) => {
361
+ console.log(`[http] ← ${ctx.method} ${ctx.url} ${resp.status}`);
362
+ if (resp.status === 401) {
363
+ await refreshToken();
364
+ // return a new fetch() if you want to retry
365
+ }
366
+ });
367
+ ```
368
+
369
+ Only one interceptor of each kind is supported per instance. Passing `null` clears it.
370
+
371
+ ---
372
+
237
373
  ## Types
238
374
 
239
375
  ### RequestData
240
376
 
241
- Request body data type.
377
+ Request body data type. See [Request Bodies](#request-bodies) for serialization rules.
378
+
379
+ ```ts
380
+ type RequestData =
381
+ | Record<string, unknown>
382
+ | unknown[]
383
+ | FormData
384
+ | Blob
385
+ | ArrayBuffer
386
+ | ArrayBufferView
387
+ | URLSearchParams
388
+ | ReadableStream
389
+ | string
390
+ | number
391
+ | boolean
392
+ | null;
393
+ ```
394
+
395
+ ### QueryValue
396
+
397
+ A value for `FetchParams.query`. `null` / `undefined` entries are skipped; arrays emit repeated keys.
242
398
 
243
399
  ```ts
244
- type RequestData = Record<string, unknown> | FormData | string | null;
400
+ type QueryValue =
401
+ | string
402
+ | number
403
+ | boolean
404
+ | (string | number | boolean)[]
405
+ | null
406
+ | undefined;
245
407
  ```
246
408
 
247
409
  ### FetchParams
@@ -250,17 +412,21 @@ Parameters for fetch requests.
250
412
 
251
413
  ```ts
252
414
  interface FetchParams {
253
- /** Request body data (automatically JSON stringified unless FormData). */
415
+ /** Request body. See "Request Bodies" for serialization rules. */
254
416
  data?: RequestData;
255
417
  /** Bearer token (auto-adds `Authorization: Bearer {token}` header). */
256
418
  token?: string | null;
257
419
  /** Custom request headers. */
258
- headers?: Record<string, string> | null;
259
- /** AbortSignal for request cancellation. */
420
+ headers?: HeadersInit | null;
421
+ /** AbortSignal for request cancellation. Combined with `timeout` if both are set. */
260
422
  signal?: AbortSignal;
423
+ /** Abort the request after this many milliseconds. Combined with `signal`. */
424
+ timeout?: number | null;
425
+ /** Query parameters appended to the URL. */
426
+ query?: Record<string, QueryValue> | null;
261
427
  /** Credentials mode for the request. */
262
428
  credentials?: 'omit' | 'same-origin' | 'include' | null;
263
- /** If true, returns the raw Response object instead of parsed body. */
429
+ /** If true, returns the raw Response object instead of parsed body. Caller must consume the body. */
264
430
  raw?: boolean | null;
265
431
  /** If false, does not throw on HTTP errors (default: true). */
266
432
  assert?: boolean | null;
@@ -313,12 +479,30 @@ Special keys added after request:
313
479
 
314
480
  ### ErrorMessageExtractor
315
481
 
316
- Function to extract error messages from failed HTTP responses.
482
+ Function to extract error messages from failed HTTP responses. If the extractor throws, the call falls back to the next-priority extractor (per-instance → global → built-in) instead of crashing.
317
483
 
318
484
  ```ts
319
485
  type ErrorMessageExtractor = (body: unknown, response: Response) => string;
320
486
  ```
321
487
 
488
+ ### RequestInterceptor
489
+
490
+ ```ts
491
+ type RequestInterceptor = (
492
+ init: RequestInit,
493
+ context: { method: string; url: string }
494
+ ) => RequestInit | void | Promise<RequestInit | void>;
495
+ ```
496
+
497
+ ### ResponseInterceptor
498
+
499
+ ```ts
500
+ type ResponseInterceptor = (
501
+ response: Response,
502
+ context: { method: string; url: string }
503
+ ) => Response | void | Promise<Response | void>;
504
+ ```
505
+
322
506
  ---
323
507
 
324
508
  ## HTTP Errors
package/CLAUDE.md ADDED
@@ -0,0 +1,3 @@
1
+ # Project Instructions
2
+
3
+ See [AGENTS.md](./AGENTS.md) for complete project documentation and AI agent instructions.
package/README.md CHANGED
@@ -137,14 +137,35 @@ try {
137
137
 
138
138
  ### Key Features
139
139
 
140
- - **Auto JSON**: Response bodies are automatically parsed as JSON
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
142
+ - **Query params**: Pass `query: { page: 1, tag: ["a", "b"] }` for URL search params
143
+ - **Timeouts**: Pass `timeout: 5000` for automatic request cancellation
141
144
  - **Bearer tokens**: Use `token` param to auto-add `Authorization: Bearer` header
142
145
  - **Response headers**: Pass `respHeaders: {}` to capture response headers
143
- - **Raw response**: Use `raw: true` to get the raw Response object
146
+ - **Raw response**: Use `raw: true` to get the raw Response object (caller must consume the body)
144
147
  - **Non-throwing**: Use `assert: false` to prevent throwing on errors
145
- - **AbortController**: Pass `signal` for request cancellation
148
+ - **AbortController**: Pass `signal` for request cancellation (composes with `timeout`)
149
+ - **Interceptors**: `api.onRequest(...)` / `api.onResponse(...)` for tracing, auth refresh, etc.
146
150
  - **Typed responses**: Use generics for type-safe responses: `api.get<User>("/users/1")`
147
151
 
152
+ ### Query, Timeout, Interceptors
153
+
154
+ ```ts
155
+ // Query params
156
+ await api.get("/search", { query: { q: "hi", tag: ["a", "b"] } });
157
+
158
+ // Timeout (abort after 5s; composes with AbortSignal)
159
+ await api.get("/slow", { timeout: 5000 });
160
+
161
+ // Interceptors
162
+ api.onRequest((init, { method, url }) => {
163
+ console.log(method, url);
164
+ }).onResponse(async (resp) => {
165
+ if (resp.status === 401) await refreshToken();
166
+ });
167
+ ```
168
+
148
169
  ## Full API Reference
149
170
 
150
171
  For complete API documentation including all error classes, HTTP status codes, types, and utilities, see **[API.md](API.md)**.
package/dist/api.d.ts CHANGED
@@ -6,28 +6,60 @@
6
6
  */
7
7
  /**
8
8
  * Request body data type.
9
- * Supports JSON-serializable objects, FormData for file uploads, or raw strings.
9
+ *
10
+ * Plain objects and arrays are JSON-serialized (with `Content-Type: application/json`
11
+ * when not set). Strings are sent as-is (the caller controls `Content-Type`).
12
+ * Native `BodyInit` types (`FormData`, `Blob`, `ArrayBuffer`, typed arrays,
13
+ * `URLSearchParams`, `ReadableStream`) are passed through unchanged so that
14
+ * `fetch` can handle content-type negotiation (e.g. multipart boundary for
15
+ * FormData, `application/x-www-form-urlencoded` for URLSearchParams).
10
16
  */
11
- export type RequestData = Record<string, unknown> | FormData | string | null;
17
+ export type RequestData = Record<string, unknown> | unknown[] | FormData | Blob | ArrayBuffer | ArrayBufferView | URLSearchParams | ReadableStream | string | number | boolean | null;
18
+ /** A primitive that can be serialized into a query-string value. */
19
+ type QueryPrimitive = string | number | boolean;
20
+ /** A value for {@link FetchParams.query}. `null`/`undefined` entries are skipped. */
21
+ export type QueryValue = QueryPrimitive | QueryPrimitive[] | null | undefined;
12
22
  interface BaseParams {
13
23
  method: "GET" | "POST" | "PATCH" | "DELETE" | "PUT";
14
24
  path: string;
15
25
  }
26
+ /**
27
+ * Request interceptor. Called after defaults are merged, with the final
28
+ * `RequestInit` and the resolved URL. May return an updated `RequestInit`
29
+ * (or a promise of one). Returning `undefined` keeps the original `init`.
30
+ */
31
+ export type RequestInterceptor = (init: RequestInit, context: {
32
+ method: string;
33
+ url: string;
34
+ }) => RequestInit | void | Promise<RequestInit | void>;
35
+ /**
36
+ * Response interceptor. Called before the response body is consumed.
37
+ * May return a replacement `Response` (e.g. retry result); returning
38
+ * `undefined` keeps the original. Must not consume the response body.
39
+ */
40
+ export type ResponseInterceptor = (response: Response, context: {
41
+ method: string;
42
+ url: string;
43
+ }) => Response | void | Promise<Response | void>;
16
44
  /**
17
45
  * Parameters for fetch requests.
18
46
  */
19
47
  export interface FetchParams {
20
- /** Request body data (automatically JSON stringified unless FormData). */
48
+ /** Request body. Plain objects/arrays are JSON-serialized; strings and native BodyInit types are passed through. */
21
49
  data?: RequestData;
22
50
  /** Bearer token (auto-adds `Authorization: Bearer {token}` header). */
23
51
  token?: string | null;
24
52
  /** Custom request headers. */
25
- headers?: HeadersInit | Record<string, string> | null;
26
- /** AbortSignal for request cancellation. */
53
+ headers?: HeadersInit | null;
54
+ /** AbortSignal for request cancellation. Combined with `timeout` if both are set. */
27
55
  signal?: AbortSignal;
56
+ /** Abort the request after this many milliseconds. Combined with `signal`. */
57
+ timeout?: number | null;
58
+ /** Query parameters appended to the URL. Null/undefined values skipped; arrays become repeated keys. */
59
+ query?: Record<string, QueryValue> | null;
28
60
  /** Credentials mode for the request. */
29
61
  credentials?: "omit" | "same-origin" | "include" | null;
30
- /** If true, returns the raw Response object instead of parsed body. */
62
+ /** If true, returns the raw Response object instead of parsed body. Caller must consume the body. */
31
63
  raw?: boolean | null;
32
64
  /** If false, does not throw on HTTP errors (default: true). */
33
65
  assert?: boolean | null;
@@ -51,7 +83,7 @@ export type ResponseHeaders = Record<string, string | number>;
51
83
  * Options for HTTP GET requests using the new cleaner API.
52
84
  */
53
85
  export interface GetOptions {
54
- /** Fetch parameters (headers, token, signal, credentials, raw, assert). */
86
+ /** Fetch parameters (headers, token, signal, credentials, raw, assert, timeout, query). */
55
87
  params?: FetchParams;
56
88
  /** Object to receive response headers (will be mutated). */
57
89
  respHeaders?: ResponseHeaders | null;
@@ -64,7 +96,7 @@ export interface GetOptions {
64
96
  export interface DataOptions {
65
97
  /** Request body data. */
66
98
  data?: RequestData;
67
- /** Fetch parameters (headers, token, signal, credentials, raw, assert). */
99
+ /** Fetch parameters (headers, token, signal, credentials, raw, assert, timeout, query). */
68
100
  params?: FetchParams;
69
101
  /** Object to receive response headers (will be mutated). */
70
102
  respHeaders?: ResponseHeaders | null;
@@ -91,6 +123,16 @@ export declare function opts<T extends GetOptions | DataOptions>(options: T): T;
91
123
  export declare class HttpApi {
92
124
  #private;
93
125
  constructor(base?: string | null, defaults?: Partial<BaseFetchParams> | (() => Promise<Partial<BaseFetchParams>>), factoryErrorMessageExtractor?: ErrorMessageExtractor | null | undefined);
126
+ /**
127
+ * Register a request interceptor. Called after defaults are merged.
128
+ * Returning a new `RequestInit` replaces the original.
129
+ */
130
+ onRequest(interceptor: RequestInterceptor | null): this;
131
+ /**
132
+ * Register a response interceptor. Called before the body is consumed.
133
+ * Must not consume the body. Returning a new `Response` replaces the original.
134
+ */
135
+ onResponse(interceptor: ResponseInterceptor | null): this;
94
136
  /**
95
137
  * Performs a GET request (new options API - recommended).
96
138
  *
@@ -101,54 +143,23 @@ export declare class HttpApi {
101
143
  *
102
144
  * @example
103
145
  * ```ts
104
- * const data = await api.get('/users', {
146
+ * const data = await api.get('/users', opts({
105
147
  * params: { headers: { 'X-Custom': 'value' } },
106
148
  * respHeaders: {}
107
- * });
149
+ * }));
108
150
  * ```
109
151
  */
110
152
  get<T = unknown>(path: string, options: GetOptions): Promise<T>;
111
153
  /**
112
154
  * Performs a GET request (legacy API).
113
- *
114
- * @param path - The request path (will be appended to base URL if set).
115
- * @param params - Optional fetch parameters.
116
- * @param respHeaders - Optional object to be mutated with response headers.
117
- * @param errorMessageExtractor - Optional custom error message extractor.
118
- * @param _dumpParams - Internal parameter for testing.
119
- * @returns The response body (auto-parsed as JSON if possible), or Response if `raw: true`.
120
- * @throws {HttpError} When the response is not OK and `assert` is true (default).
121
155
  */
122
156
  get<T = unknown>(path: string, params?: FetchParams, respHeaders?: ResponseHeaders | null, errorMessageExtractor?: ErrorMessageExtractor | null, _dumpParams?: boolean): Promise<T>;
123
157
  /**
124
158
  * Performs a POST request (new options API - recommended).
125
- *
126
- * @param path - The request path (will be appended to base URL if set).
127
- * @param options - Request options object including data and params.
128
- * @returns The response body (auto-parsed as JSON if possible), or Response if `raw: true`.
129
- * @throws {HttpError} When the response is not OK and `assert` is true (default).
130
- *
131
- * @example
132
- * ```ts
133
- * await api.post('/users', {
134
- * data: { name: 'John' },
135
- * params: { headers: { 'X-Custom': 'value' } },
136
- * respHeaders: {}
137
- * });
138
- * ```
139
159
  */
140
160
  post<T = unknown>(path: string, options: DataOptions): Promise<T>;
141
161
  /**
142
162
  * Performs a POST request (legacy API).
143
- *
144
- * @param path - The request path (will be appended to base URL if set).
145
- * @param data - Request body data.
146
- * @param params - Optional fetch parameters.
147
- * @param respHeaders - Optional object to be mutated with response headers.
148
- * @param errorMessageExtractor - Optional custom error message extractor.
149
- * @param _dumpParams - Internal parameter for testing.
150
- * @returns The response body (auto-parsed as JSON if possible), or Response if `raw: true`.
151
- * @throws {HttpError} When the response is not OK and `assert` is true (default).
152
163
  */
153
164
  post<T = unknown>(path: string, data?: RequestData, params?: FetchParams, respHeaders?: ResponseHeaders | null, errorMessageExtractor?: ErrorMessageExtractor | null, _dumpParams?: boolean): Promise<T>;
154
165
  /** Performs a PUT request (new options API). @see post */
package/dist/api.js CHANGED
@@ -7,6 +7,7 @@
7
7
  import { createHttpError } from "./error.js";
8
8
  /**
9
9
  * Deep merges two objects. Later properties overwrite earlier properties.
10
+ * Arrays are overwritten, not concatenated (conventional behavior).
10
11
  */
11
12
  function deepMerge(target, source) {
12
13
  const output = { ...target };
@@ -32,6 +33,78 @@ function deepMerge(target, source) {
32
33
  function isObject(item) {
33
34
  return item !== null && typeof item === "object" && !Array.isArray(item);
34
35
  }
36
+ /**
37
+ * Returns true for body types that native `fetch` knows how to serialize
38
+ * (including setting Content-Type where appropriate). These are passed through
39
+ * unchanged rather than JSON-stringified.
40
+ */
41
+ function isNativeBodyInit(v) {
42
+ if (v instanceof FormData)
43
+ return true;
44
+ if (typeof Blob !== "undefined" && v instanceof Blob)
45
+ return true;
46
+ if (v instanceof ArrayBuffer)
47
+ return true;
48
+ if (ArrayBuffer.isView(v))
49
+ return true;
50
+ if (v instanceof URLSearchParams)
51
+ return true;
52
+ if (typeof ReadableStream !== "undefined" && v instanceof ReadableStream) {
53
+ return true;
54
+ }
55
+ return false;
56
+ }
57
+ /**
58
+ * Appends query parameters to a URL path. Null/undefined values are skipped.
59
+ * Array values are emitted as repeated keys (e.g. `?tag=a&tag=b`).
60
+ */
61
+ function appendQuery(path, query) {
62
+ const sp = new URLSearchParams();
63
+ for (const [k, v] of Object.entries(query)) {
64
+ if (v === null || v === undefined)
65
+ continue;
66
+ if (Array.isArray(v)) {
67
+ for (const item of v) {
68
+ if (item !== null && item !== undefined)
69
+ sp.append(k, String(item));
70
+ }
71
+ }
72
+ else {
73
+ sp.append(k, String(v));
74
+ }
75
+ }
76
+ const qs = sp.toString();
77
+ if (!qs)
78
+ return path;
79
+ return path + (path.includes("?") ? "&" : "?") + qs;
80
+ }
81
+ /**
82
+ * Combines a user-provided AbortSignal with an optional timeout-based signal.
83
+ * Returns `undefined` if neither is present.
84
+ */
85
+ function composeSignal(userSignal, timeoutMs) {
86
+ if (!timeoutMs || timeoutMs <= 0)
87
+ return userSignal;
88
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
89
+ if (!userSignal)
90
+ return timeoutSignal;
91
+ // AbortSignal.any is available in Node 20+ and modern Deno.
92
+ if (typeof AbortSignal.any === "function") {
93
+ return AbortSignal.any([userSignal, timeoutSignal]);
94
+ }
95
+ // Fallback: manual composition.
96
+ const ctrl = new AbortController();
97
+ const abort = (reason) => ctrl.abort(reason);
98
+ if (userSignal.aborted)
99
+ abort(userSignal.reason);
100
+ else
101
+ userSignal.addEventListener("abort", () => abort(userSignal.reason));
102
+ if (timeoutSignal.aborted)
103
+ abort(timeoutSignal.reason);
104
+ else
105
+ timeoutSignal.addEventListener("abort", () => abort(timeoutSignal.reason));
106
+ return ctrl.signal;
107
+ }
35
108
  /** Symbol marker for explicit options API detection. */
36
109
  const OPTIONS_MARKER = Symbol("options");
37
110
  /**
@@ -55,7 +128,6 @@ export function opts(options) {
55
128
  */
56
129
  function parseGetOptions(paramsOrOptions, legacyRespHeaders, legacyErrorExtractor) {
57
130
  if (paramsOrOptions && OPTIONS_MARKER in paramsOrOptions) {
58
- // New options API (explicit via opts() wrapper)
59
131
  const o = paramsOrOptions;
60
132
  return {
61
133
  params: o.params,
@@ -63,7 +135,6 @@ function parseGetOptions(paramsOrOptions, legacyRespHeaders, legacyErrorExtracto
63
135
  errorExtractor: o.errorExtractor ?? null,
64
136
  };
65
137
  }
66
- // Legacy positional API
67
138
  return {
68
139
  params: paramsOrOptions,
69
140
  respHeaders: legacyRespHeaders ?? null,
@@ -77,7 +148,6 @@ function parseDataOptions(dataOrOptions, legacyParams, legacyRespHeaders, legacy
77
148
  if (dataOrOptions &&
78
149
  typeof dataOrOptions === "object" &&
79
150
  OPTIONS_MARKER in dataOrOptions) {
80
- // New options API (explicit via opts() wrapper)
81
151
  const o = dataOrOptions;
82
152
  return {
83
153
  data: o.data ?? null,
@@ -86,7 +156,6 @@ function parseDataOptions(dataOrOptions, legacyParams, legacyRespHeaders, legacy
86
156
  errorExtractor: o.errorExtractor ?? null,
87
157
  };
88
158
  }
89
- // Legacy positional API
90
159
  return {
91
160
  data: dataOrOptions ?? null,
92
161
  params: legacyParams,
@@ -94,45 +163,79 @@ function parseDataOptions(dataOrOptions, legacyParams, legacyRespHeaders, legacy
94
163
  errorExtractor: legacyErrorExtractor ?? null,
95
164
  };
96
165
  }
97
- const _fetchRaw = async ({ method, path, data = null, token = null, headers = null, signal, credentials, }) => {
166
+ /**
167
+ * Builds the final RequestInit and serialized URL from FetchParams.
168
+ * Does not call fetch.
169
+ */
170
+ function buildRequest(params) {
171
+ const { method, path, data = null, token = null, headers = null, signal, timeout, query, credentials, } = params;
98
172
  const normalizedHeaders = {};
99
173
  if (headers) {
100
174
  new Headers(headers).forEach((value, key) => {
101
175
  normalizedHeaders[key] = value;
102
176
  });
103
177
  }
104
- const opts = {
105
- method,
106
- credentials: credentials ?? undefined,
107
- headers: normalizedHeaders,
108
- signal,
109
- };
110
- if (data) {
111
- const isObj = typeof data === "object";
112
- // FormData: multipart/form-data -- no explicit Content-Type
113
- if (data instanceof FormData) {
114
- opts.body = data;
178
+ const init = { method };
179
+ if (credentials)
180
+ init.credentials = credentials;
181
+ const composedSignal = composeSignal(signal, timeout ?? undefined);
182
+ if (composedSignal)
183
+ init.signal = composedSignal;
184
+ // Body handling — send in order of most specific to least.
185
+ if (data !== null && data !== undefined) {
186
+ if (isNativeBodyInit(data)) {
187
+ // fetch knows how to serialize these and sets Content-Type as needed.
188
+ init.body = data;
189
+ }
190
+ else if (typeof data === "string") {
191
+ // Raw strings are sent as-is; caller controls Content-Type.
192
+ init.body = data;
115
193
  }
116
- // Cover 99% of use cases (may not fit all scenarios)
117
194
  else {
118
- // If not explicitly stated, assume JSON
119
- if (isObj || !normalizedHeaders["content-type"]) {
195
+ // Plain objects, arrays, numbers, booleans JSON.
196
+ if (!normalizedHeaders["content-type"]) {
120
197
  normalizedHeaders["content-type"] = "application/json";
121
198
  }
122
- opts.body = JSON.stringify(data);
199
+ init.body = JSON.stringify(data);
123
200
  }
124
201
  }
125
202
  // Opinionated convention: auto-add Bearer token
126
203
  if (token) {
127
204
  normalizedHeaders["authorization"] = `Bearer ${token}`;
128
205
  }
129
- opts.headers = normalizedHeaders;
130
- return await fetch(path, opts);
131
- };
132
- const _fetch = async (params, respHeaders = null, errorMessageExtractor = null, _dumpParams = false) => {
206
+ init.headers = normalizedHeaders;
207
+ let url = path;
208
+ if (query)
209
+ url = appendQuery(url, query);
210
+ return { url, init };
211
+ }
212
+ const _fetch = async (params, respHeaders = null, errorMessageExtractor = null, requestInterceptor = null, responseInterceptor = null, _dumpParams = false) => {
133
213
  if (_dumpParams)
134
214
  return params;
135
- const r = await _fetchRaw(params);
215
+ let { url, init } = buildRequest(params);
216
+ if (requestInterceptor) {
217
+ const patched = await requestInterceptor(init, {
218
+ method: params.method,
219
+ url,
220
+ });
221
+ if (patched)
222
+ init = patched;
223
+ }
224
+ let r = await fetch(url, init);
225
+ if (responseInterceptor) {
226
+ const patched = await responseInterceptor(r, {
227
+ method: params.method,
228
+ url,
229
+ });
230
+ if (patched && patched !== r) {
231
+ // Cancel the original body so the underlying stream doesn't leak.
232
+ try {
233
+ await r.body?.cancel();
234
+ }
235
+ catch (_e) { /* ignore */ }
236
+ r = patched;
237
+ }
238
+ }
136
239
  if (params.raw)
137
240
  return r;
138
241
  // Convert Headers to plain object
@@ -143,34 +246,50 @@ const _fetch = async (params, respHeaders = null, errorMessageExtractor = null,
143
246
  // Add status/text under special keys
144
247
  { __http_status_code__: r.status, __http_status_text__: r.statusText });
145
248
  }
146
- let body = await r.text();
147
- // prettier-ignore
148
- try {
149
- body = JSON.parse(body);
249
+ const text = await r.text();
250
+ let body = text;
251
+ if (text === "") {
252
+ // Treat empty body (204/205 and friends) as null rather than "".
253
+ body = null;
254
+ }
255
+ else {
256
+ // prettier-ignore
257
+ try {
258
+ body = JSON.parse(text);
259
+ }
260
+ catch (_e) { /* ignore parse errors */ }
150
261
  }
151
- catch (_e) { /* ignore parse errors */ }
152
262
  params.assert ??= true; // default is true
153
263
  if (!r.ok && params.assert) {
154
- // now we need to extract error message from an unknown response... this is obviously
155
- // impossible unless we know what to expect, but we'll do some educated tries...
156
- const extractor = errorMessageExtractor ?? // provided arg
157
- createHttpApi.defaultErrorMessageExtractor ?? // static default
158
- // educated guess fallback
159
- function (_body, _response) {
160
- const b = _body;
161
- let msg = String(
162
- // try opinionated convention first
163
- b?.error?.message ||
164
- b?.message ||
165
- b?.error ||
166
- _response?.statusText ||
167
- "Unknown error");
168
- if (msg.length > 255)
169
- msg = `[Shortened]: ${msg.slice(0, 255)}`;
170
- return msg;
171
- };
172
- // adding `cause` describing more details
173
- throw createHttpError(r.status, extractor(body, r), body, {
264
+ // Now we need to extract an error message from an unknown response shape.
265
+ // We try, in order: the per-call extractor, the factory/global, and a
266
+ // built-in guess. If a user-provided extractor throws, we must not let
267
+ // it replace the real HTTP error — fall back to statusText.
268
+ const tryExtract = (fn) => {
269
+ if (!fn)
270
+ return null;
271
+ try {
272
+ return fn(body, r);
273
+ }
274
+ catch (_e) {
275
+ return null;
276
+ }
277
+ };
278
+ const builtIn = (_body, _response) => {
279
+ const b = _body;
280
+ let msg = String(b?.error?.message ||
281
+ b?.message ||
282
+ b?.error ||
283
+ _response?.statusText ||
284
+ "Unknown error");
285
+ if (msg.length > 255)
286
+ msg = `[Shortened]: ${msg.slice(0, 255)}`;
287
+ return msg;
288
+ };
289
+ const msg = tryExtract(errorMessageExtractor) ??
290
+ tryExtract(createHttpApi.defaultErrorMessageExtractor) ??
291
+ builtIn(body, r);
292
+ throw createHttpError(r.status, msg, body, {
174
293
  method: params.method,
175
294
  path: params.path,
176
295
  response: {
@@ -189,6 +308,8 @@ export class HttpApi {
189
308
  #base;
190
309
  #defaults;
191
310
  #factoryErrorMessageExtractor;
311
+ #requestInterceptor;
312
+ #responseInterceptor;
192
313
  constructor(base, defaults, factoryErrorMessageExtractor) {
193
314
  this.#base = base;
194
315
  this.#defaults = defaults;
@@ -211,54 +332,58 @@ export class HttpApi {
211
332
  return { ...(this.#defaults || {}) };
212
333
  }
213
334
  #buildPath(path, base) {
214
- base = `${base || ""}`;
215
- path = `${path || ""}`;
216
- return /^https?:/.test(path) ? path : base + path;
335
+ const p = `${path ?? ""}`;
336
+ const b = `${base ?? ""}`;
337
+ if (/^https?:/i.test(p))
338
+ return p;
339
+ if (!b)
340
+ return p;
341
+ const baseNoTrail = b.replace(/\/+$/, "");
342
+ const pathLead = p.startsWith("/") ? p : `/${p}`;
343
+ return baseNoTrail + pathLead;
344
+ }
345
+ /**
346
+ * Register a request interceptor. Called after defaults are merged.
347
+ * Returning a new `RequestInit` replaces the original.
348
+ */
349
+ onRequest(interceptor) {
350
+ this.#requestInterceptor = interceptor;
351
+ return this;
352
+ }
353
+ /**
354
+ * Register a response interceptor. Called before the body is consumed.
355
+ * Must not consume the body. Returning a new `Response` replaces the original.
356
+ */
357
+ onResponse(interceptor) {
358
+ this.#responseInterceptor = interceptor;
359
+ return this;
217
360
  }
218
361
  async get(path, paramsOrOptions, respHeaders, errorMessageExtractor, _dumpParams = false) {
219
362
  const { params, respHeaders: headers, errorExtractor, } = parseGetOptions(paramsOrOptions, respHeaders, errorMessageExtractor);
220
363
  path = this.#buildPath(path, this.#base);
221
- return _fetch(this.#merge(await this.#getDefs(), { ...params, method: "GET", path }), headers, errorExtractor ?? this.#factoryErrorMessageExtractor, _dumpParams);
364
+ return _fetch(this.#merge(await this.#getDefs(), { ...params, method: "GET", path }), headers, errorExtractor ?? this.#factoryErrorMessageExtractor, this.#requestInterceptor, this.#responseInterceptor, _dumpParams);
222
365
  }
223
366
  async post(path, dataOrOptions, params, respHeaders, errorMessageExtractor, _dumpParams = false) {
224
- const { data, params: fetchParams, respHeaders: headers, errorExtractor, } = parseDataOptions(dataOrOptions, params, respHeaders, errorMessageExtractor);
225
- path = this.#buildPath(path, this.#base);
226
- return _fetch(this.#merge(await this.#getDefs(), {
227
- ...(fetchParams || {}),
228
- data,
229
- method: "POST",
230
- path,
231
- }), headers, errorExtractor ?? this.#factoryErrorMessageExtractor, _dumpParams);
367
+ return await this.#body("POST", path, dataOrOptions, params, respHeaders, errorMessageExtractor, _dumpParams);
232
368
  }
233
369
  async put(path, dataOrOptions, params, respHeaders, errorMessageExtractor, _dumpParams = false) {
234
- const { data, params: fetchParams, respHeaders: headers, errorExtractor, } = parseDataOptions(dataOrOptions, params, respHeaders, errorMessageExtractor);
235
- path = this.#buildPath(path, this.#base);
236
- return _fetch(this.#merge(await this.#getDefs(), {
237
- ...(fetchParams || {}),
238
- data,
239
- method: "PUT",
240
- path,
241
- }), headers, errorExtractor ?? this.#factoryErrorMessageExtractor, _dumpParams);
370
+ return await this.#body("PUT", path, dataOrOptions, params, respHeaders, errorMessageExtractor, _dumpParams);
242
371
  }
243
372
  async patch(path, dataOrOptions, params, respHeaders, errorMessageExtractor, _dumpParams = false) {
244
- const { data, params: fetchParams, respHeaders: headers, errorExtractor, } = parseDataOptions(dataOrOptions, params, respHeaders, errorMessageExtractor);
245
- path = this.#buildPath(path, this.#base);
246
- return _fetch(this.#merge(await this.#getDefs(), {
247
- ...(fetchParams || {}),
248
- data,
249
- method: "PATCH",
250
- path,
251
- }), headers, errorExtractor ?? this.#factoryErrorMessageExtractor, _dumpParams);
373
+ return await this.#body("PATCH", path, dataOrOptions, params, respHeaders, errorMessageExtractor, _dumpParams);
252
374
  }
253
375
  async del(path, dataOrOptions, params, respHeaders, errorMessageExtractor, _dumpParams = false) {
376
+ return await this.#body("DELETE", path, dataOrOptions, params, respHeaders, errorMessageExtractor, _dumpParams);
377
+ }
378
+ async #body(method, path, dataOrOptions, params, respHeaders, errorMessageExtractor, _dumpParams) {
254
379
  const { data, params: fetchParams, respHeaders: headers, errorExtractor, } = parseDataOptions(dataOrOptions, params, respHeaders, errorMessageExtractor);
255
380
  path = this.#buildPath(path, this.#base);
256
381
  return _fetch(this.#merge(await this.#getDefs(), {
257
382
  ...(fetchParams || {}),
258
383
  data,
259
- method: "DELETE",
384
+ method,
260
385
  path,
261
- }), headers, errorExtractor ?? this.#factoryErrorMessageExtractor, _dumpParams);
386
+ }), headers, errorExtractor ?? this.#factoryErrorMessageExtractor, this.#requestInterceptor, this.#responseInterceptor, _dumpParams);
262
387
  }
263
388
  /**
264
389
  * Helper method to build the full URL from a path.
@@ -306,6 +431,9 @@ export function createHttpApi(base, defaults, factoryErrorMessageExtractor) {
306
431
  * Applied to all requests unless overridden at instance or request level.
307
432
  * Priority: per-request → per-instance → global → built-in fallback.
308
433
  *
434
+ * A throwing extractor will not crash the call — the next priority level is
435
+ * used as a fallback.
436
+ *
309
437
  * @example
310
438
  * ```ts
311
439
  * createHttpApi.defaultErrorMessageExtractor = (body, response) => {
package/dist/mod.d.ts CHANGED
@@ -21,6 +21,6 @@
21
21
  * }
22
22
  * ```
23
23
  */
24
- export { HttpApi, createHttpApi, opts, type DataOptions, type GetOptions, type FetchParams, type ErrorMessageExtractor, type ResponseHeaders, type RequestData, } from "./api.js";
24
+ export { HttpApi, createHttpApi, opts, type DataOptions, type GetOptions, type FetchParams, type ErrorMessageExtractor, type ResponseHeaders, type RequestData, type QueryValue, type RequestInterceptor, type ResponseInterceptor, } from "./api.js";
25
25
  export { HTTP_ERROR, createHttpError, getErrorMessage } 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.5.0",
3
+ "version": "2.6.0",
4
4
  "type": "module",
5
5
  "main": "dist/mod.js",
6
6
  "types": "dist/mod.d.ts",
@@ -10,6 +10,14 @@
10
10
  "import": "./dist/mod.js"
11
11
  }
12
12
  },
13
+ "files": [
14
+ "dist",
15
+ "LICENSE",
16
+ "README.md",
17
+ "API.md",
18
+ "AGENTS.md",
19
+ "CLAUDE.md"
20
+ ],
13
21
  "author": "Marian Meres",
14
22
  "license": "MIT",
15
23
  "dependencies": {},