create-request 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.1.0 — 2026-10-03
4
+
5
+ ### Added
6
+
7
+ - `withHeaders()` also accepts a `Headers` object or `[name, value]` pairs, the other forms `fetch` accepts, on requests and api instances. Before, a `Headers` object was a type error and sent no header at all, and pairs sent a header named `0`.
8
+ - `RequestError#toJSON()` and the `RequestErrorJSON` type: `JSON.stringify(error)` now gives `{ name, code, message, method, url, status }`. Before, it had no message, an empty `response` object, the whole body (up to 1 MB) and the full URL; now the URL loses its query string, which may hold API keys or tokens.
9
+
3
10
  ## 2.0.0 — 2026-10-01
4
11
 
5
12
  A ground-up rewrite with the same fluent API, a fifth fewer bytes on the wire, a 70 % smaller package and none of the known bugs. See [MIGRATION.md](MIGRATION.md) for the v1 → v2 map.
package/README.md CHANGED
@@ -26,7 +26,7 @@ under 5 KB min+gzip, and runs in browsers and Node.js 22+.
26
26
  - [Installation](#installation)
27
27
  - [60-second start](#60-second-start)
28
28
  - [The mental model](#the-mental-model)
29
- - [Requests](#requests) — [creating](#creating), [configuring](#configuring),
29
+ - [Requests](#requests): [creating](#creating), [configuring](#configuring),
30
30
  [executing](#executing), [reusing](#reusing)
31
31
  - [Errors](#errors)
32
32
  - [Api instances](#api-instances)
@@ -42,6 +42,8 @@ under 5 KB min+gzip, and runs in browsers and Node.js 22+.
42
42
  - [Design principles](#design-principles)
43
43
  - [Size](#size)
44
44
  - [Migrating from v1](#migrating-from-v1)
45
+ - [Contributing](#contributing)
46
+ - [License](#license)
45
47
 
46
48
  ## Installation
47
49
 
@@ -52,7 +54,7 @@ npm install create-request
52
54
  | Runtime | Support |
53
55
  | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
54
56
  | Node.js | 22 or newer |
55
- | Browsers | Anything with `fetch`, `AbortSignal` and ES2022 — Chrome/Edge 93+, Firefox 91+, Safari 15+ (2021 and later) |
57
+ | Browsers | Anything with `fetch`, `AbortSignal` and ES2022: Chrome/Edge 93+, Firefox 91+, Safari 15+ (2021 and later) |
56
58
  | Bun, Deno, edge runtimes | Only standard `fetch` / `AbortSignal` / `URL` APIs are used, so they are expected to work |
57
59
  | Module formats | ESM (`dist/index.js`) and CommonJS (`dist/index.cjs`). From CommonJS the entry point is the `default` export: `const { default: create, createApi } = require("create-request")` |
58
60
  | TypeScript | 5.0 or newer; the declarations resolve with the DOM lib **or** with `@types/node` alone |
@@ -74,14 +76,14 @@ const created = await create
74
76
  .withRetries(2)
75
77
  .getJson<User>();
76
78
 
77
- // 3. Shared defaults live on an api instance — create one, export it, use it everywhere
79
+ // 3. Shared defaults live on an api instance: create one, export it, use it everywhere
78
80
  const api = createApi().withBaseURL("https://api.example.com").withBearerToken(token);
79
81
 
80
82
  try {
81
83
  await api.delete(`/users/${id}`).getResponse();
82
84
  } catch (error) {
83
- if (isRequestError(error) && error.status === 404) return; // one error type, with a code and status
84
- throw error;
85
+ // one error type, with a code and a status; a 404 here means it was already deleted
86
+ if (!(isRequestError(error) && error.status === 404)) throw error;
85
87
  }
86
88
  ```
87
89
 
@@ -90,9 +92,9 @@ try {
90
92
  1. `create.get(url)` / `create.post(url)` / … (or `api.get(path)`) return a **request**.
91
93
  2. `with*()` methods configure it and return the same request, so calls chain. Nothing is sent
92
94
  yet.
93
- 3. A `get*()` method sends it and gives you the body in the format you ask for — or the
95
+ 3. A `get*()` method sends it and gives you the body in the format you ask for, or the
94
96
  `ResponseWrapper` with `getResponse()`, or `{ data, error }` with `getResult()`.
95
- 4. Every failure — HTTP status, network, timeout, abort, parsing, validation, interceptor —
97
+ 4. Every failure (HTTP status, network, timeout, abort, parsing, validation, interceptor)
96
98
  rejects with a **`RequestError`** whose `code` says which.
97
99
  5. An **api instance** holds defaults (base URL, auth, timeout, retries, interceptors, …) for the
98
100
  requests it creates. It is immutable: `api.withHeader(…)` returns a new instance.
@@ -109,7 +111,7 @@ api.get<User>("/me"); // declare the JSON type once; getJson() / getData() / get
109
111
  ```
110
112
 
111
113
  Named factories exist as well: `createGet`, `createPost`, `createPut`, `createPatch`,
112
- `createDelete`, `createHead`, `createOptions` — the same functions as `create.get`, … — and
114
+ `createDelete`, `createHead`, `createOptions` (the same functions as `create.get`, …), and
113
115
  `createApi()` is also available as `create.api()`.
114
116
 
115
117
  ### Configuring
@@ -117,12 +119,13 @@ Named factories exist as well: `createGet`, `createPost`, `createPut`, `createPa
117
119
  ```typescript
118
120
  create
119
121
  .post("https://api.example.com/items")
120
- // headers & auth — names are case-insensitive, null removes a header
122
+ // headers & auth: an object, a Headers object or [name, value] pairs; names are
123
+ // case-insensitive, and null (in an object) removes a header
121
124
  .withHeaders({ Accept: "application/json", "X-Trace": id })
122
125
  .withHeader("X-Feature", "beta")
123
126
  .withContentType("application/json") // rarely needed: JSON and text bodies set it themselves
124
127
  .withBearerToken(token) // or withBasicAuth(user, pass) / withAuthorization("Custom …")
125
- // query string — arrays repeat the key, Dates become ISO strings, null removes the key,
128
+ // query string: arrays repeat the key, Dates become ISO strings, null removes the key,
126
129
  // and a key set twice keeps the last value (a request can override an api default)
127
130
  .withQueryParams({ page: 2, tags: ["a", "b"], since: new Date() })
128
131
  .withQueryParam("q", "search term")
@@ -145,12 +148,13 @@ create
145
148
  .withIntegrity("sha256-…");
146
149
  ```
147
150
 
148
- Sending a `FormData`? Do not set a `Content-Type` — `fetch` adds the multipart boundary itself
151
+ Sending a `FormData`? Do not set a `Content-Type`: `fetch` adds the multipart boundary itself
149
152
  (the library removes one if an api default put it there). The remaining methods have sections of
150
- their own: `withCookie(s)` and `withCsrf` ([Cookies and CSRF](#cookies-and-csrf)),
151
- `withRequestInterceptor` / `withResponseInterceptor` / `withErrorInterceptor`
152
- ([Interceptors](#interceptors)), `withGraphQL` ([GraphQL](#graphql)), `withFetch`
153
- ([Testing and custom fetch](#testing-and-custom-fetch)).
153
+ their own: `withCookie(s)`, `withCsrf` and `withCsrfToken`
154
+ ([Cookies and CSRF](#cookies-and-csrf)), `onRetry` ([Retries](#retries)), `withAbortController`
155
+ ([Timeouts and cancellation](#timeouts-and-cancellation)), `withRequestInterceptor` /
156
+ `withResponseInterceptor` / `withErrorInterceptor` ([Interceptors](#interceptors)), `withGraphQL`
157
+ ([GraphQL](#graphql)) and `withFetch` ([Testing and custom fetch](#testing-and-custom-fetch)).
154
158
 
155
159
  ### Executing
156
160
 
@@ -161,20 +165,20 @@ their own: `withCookie(s)` and `withCsrf` ([Cookies and CSRF](#cookies-and-csrf)
161
165
  | `getBlob()` | The body as a `Blob` typed with the response `Content-Type` |
162
166
  | `getArrayBuffer()` | The body as an `ArrayBuffer` |
163
167
  | `getFormData()` | The body parsed as `multipart/form-data` or `application/x-www-form-urlencoded` |
164
- | `getBody()` | The raw `ReadableStream` (not buffered — for streaming), or `null` when there is no body (`HEAD`, `204`) |
168
+ | `getBody()` | The raw `ReadableStream` (not buffered, for streaming), or `null` when there is no body (`HEAD`, `204`) |
165
169
  | `getData(selector)` | `getJson()` followed by a selector, e.g. `getData(page => page.items)` |
166
170
  | `getResult<T>()` | `{ data, error }` instead of throwing |
167
171
  | `getResponse()` | A `ResponseWrapper`: `status`, `statusText`, `ok`, `headers`, `url`, `method`, the underlying `raw` `Response`, and every body reader above (`getJson` … `getData`) |
168
172
 
169
173
  The body is buffered once, so on a `ResponseWrapper` you can call several readers, in any order,
170
- even concurrently. `getBody()` is the exception: it hands you the live stream, so nothing else
171
- can read the body afterwards.
174
+ even concurrently. `getBody()` is the exception: it hands you the live stream, so it must be the
175
+ only reader of that response.
172
176
 
173
177
  ```typescript
174
178
  const response = await api.get<User>("/me").getResponse();
175
179
  console.log(response.status, response.headers.get("etag"));
176
180
  const user = await response.getJson(); // User
177
- const raw = await response.getText(); // still works — same buffer
181
+ const raw = await response.getText(); // still works: same buffer
178
182
  ```
179
183
 
180
184
  ### Reusing
@@ -195,26 +199,31 @@ const [page1, page2] = await Promise.all([
195
199
  Every rejection is a `RequestError`. Check it with `isRequestError(error)` (or `instanceof`) and
196
200
  switch on `code`:
197
201
 
198
- | `code` | When | Also set |
199
- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
200
- | `"HTTP"` | The server answered with a non-2xx status (a 3xx under `withRedirect("manual")` and an opaque response under `withMode("no-cors")` resolve instead) | `status`, `response`, `body`, `data` |
201
- | `"NETWORK"` | `fetch` itself failed: DNS, connection refused, CORS, offline | `cause` (the error `fetch` threw) |
202
- | `"TIMEOUT"` | `withTimeout()` fired, or a signal from `AbortSignal.timeout()` aborted | `cause`; plus `status`, `response` when it fired while the body was being read |
203
- | `"ABORTED"` | A signal passed with `withSignal()` / `withAbortController()` aborted | `cause` (the abort reason); plus `status`, `response` when it fired while the body was being read |
204
- | `"PARSE"` | The body could not be read or parsed, or a `getData` selector threw | `status`, `response`, `cause`, `body` (for invalid JSON) |
205
- | `"VALIDATION"` | The response failed the schema, or the request could not be built (empty or unparsable absolute URL, invalid header value, non-serialisable body) | `issues` and `body` for schema failures; `cause` otherwise |
206
- | `"INTERCEPTOR"` | A request/response interceptor or a callback (retry, CSRF token) threw | `cause`; plus `status`, `response` (and `body`) when a response existed |
207
- | `"GRAPHQL"` | The GraphQL response had `errors` and `throwOnError` was on | `status`, `response`, `body`, `data` |
208
-
209
- `url` and `method` are always set. `data` is `body` parsed as JSON — the shape most APIs use for
210
- error details — and never throws. `body` holds at most 1 MB: a response that announces more is
211
- left unread on `error.response`; a longer chunked or compressed one is cut off and `body` is
212
- `undefined`. `isTimeout` / `isAborted` are shorthands for the two codes. In Node.js a relative
213
- URL is a `"NETWORK"` failure, because `fetch` there has no page to resolve it against.
214
-
215
- Three mistakes are reported earlier, synchronously, by the `with*` call itself rather than by the
216
- execution: an invalid timeout (`withTimeout(-1)`), an invalid retry count (`withRetries(-1)`) and
217
- a body that cannot be JSON-serialised. They are `RequestError`s with code `"VALIDATION"` too.
202
+ | `code` | When | Also set |
203
+ | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
204
+ | `"HTTP"` | The server answered with a non-2xx status (a 3xx under `withRedirect("manual")` and an opaque response under `withMode("no-cors")` resolve instead) | `status`, `response`, `body`, `data` |
205
+ | `"NETWORK"` | `fetch` itself failed: DNS, connection refused, CORS, offline | `cause` (the error `fetch` threw) |
206
+ | `"TIMEOUT"` | `withTimeout()` fired, or a signal from `AbortSignal.timeout()` aborted | `cause`; plus `status`, `response` when it fired while the body was being read |
207
+ | `"ABORTED"` | A signal passed with `withSignal()` / `withAbortController()` aborted | `cause` (the abort reason); plus `status`, `response` when it fired while the body was being read |
208
+ | `"PARSE"` | The body could not be read or parsed, or a `getData` selector threw | `status`, `response`, `cause`, `body` (for invalid JSON) |
209
+ | `"VALIDATION"` | The response failed the schema, or the request could not be built (empty or unparsable absolute URL, invalid header name or value, non-serialisable body) | `issues`, `body`, `status`, `response` for schema failures; `cause` for a rejected header or body |
210
+ | `"INTERCEPTOR"` | An interceptor or a callback (retry, CSRF token) threw; a `RequestError` thrown by an error interceptor is kept as-is | `cause`; plus `status`, `response` (and `body`) when a response existed |
211
+ | `"GRAPHQL"` | The GraphQL response had `errors` and `throwOnError` was on | `status`, `response`, `body`, `data` |
212
+
213
+ `url` and `method` are always set. `data` is `body` parsed as JSON (the shape most APIs use for
214
+ error details) and never throws. On `"HTTP"` errors, `body` holds at most 1 MB: a response that
215
+ announces more is left unread on `error.response`; a longer chunked or compressed one is cut off
216
+ and `body` is `undefined`. `isTimeout` / `isAborted` are shorthands for the two codes. In Node.js
217
+ a relative URL is a `"NETWORK"` failure, because `fetch` there has no page to resolve it against.
218
+
219
+ `JSON.stringify(error)`, which loggers and `res.json()` use, gives `name`, `code`, `message`,
220
+ `method`, `url` and `status`. The URL loses its query string, which may hold API keys or tokens,
221
+ and the response, body and cause are left out; read those from the error itself.
222
+
223
+ A few bad arguments are caught before the request runs: `withTimeout(-1)`, `withRetries(-1)` and
224
+ a `withBody()` value that cannot be serialised to JSON (a circular object, a `BigInt`) throw a
225
+ `RequestError` with code `"VALIDATION"` from the `with*` call itself. Because they are thrown
226
+ immediately, `getResult()` and `.catch()` never see them.
218
227
 
219
228
  ```typescript
220
229
  try {
@@ -269,24 +278,25 @@ const post = await api.post<Post>("/posts").withBody({ title: "Hello" }).getJson
269
278
  const admin = api.withHeader("X-Role", "admin"); // a NEW instance; `api` is unchanged
270
279
  ```
271
280
 
272
- Requests inherit the defaults and can override any of them: `api.get("/slow").withTimeout(30_000)`,
281
+ Requests inherit the defaults and can override them: `api.get("/slow").withTimeout(30_000)`,
273
282
  `api.get("/public").withHeaders({ Authorization: null })`, `api.get("/live").withTimeout(0)` (no
274
- timeout at all).
283
+ timeout at all). Interceptors and cookies are the exception: a request adds its own to the api's
284
+ instead of replacing them.
275
285
 
276
286
  Paths are **joined** to the base URL, not resolved: `/v1` + `/users` → `/v1/users`; `users` and
277
287
  `./users` work the same; `api.get()` without a path requests the base URL; absolute URLs
278
- (`https://…`, `//…`) are used as-is — and carry the api's headers with them, so never build a
288
+ (`https://…`, `//…`) are used as-is. They carry the api's headers with them, so never build a
279
289
  path from untrusted input.
280
290
 
281
291
  ## Retries
282
292
 
283
293
  ```typescript
284
- api.get("/status").withRetries(3); // default policy
294
+ api.get("/status").withRetries(3); // 3 retries (up to 4 requests), default policy
285
295
  api.get("/status").withRetries({
286
296
  attempts: 3,
287
297
  delay: ({ attempt }) => attempt * 500, // ms, or a number; default: exponential backoff
288
298
  statuses: [503], // default: 408, 425, 429, 500, 502, 503, 504
289
- methods: ["GET", "HEAD", "PUT", "DELETE"], // default: every method — including POST and PATCH
299
+ methods: ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"], // default: all, POST and PATCH included
290
300
  maxDelay: 10_000, // caps the backoff (default 30 s); a longer Retry-After gives up instead
291
301
  shouldRetry: ({ error }) => error.code === "NETWORK", // full override of the decision
292
302
  onRetry: ({ attempt, error, delay }) =>
@@ -294,17 +304,22 @@ api.get("/status").withRetries({
294
304
  });
295
305
  ```
296
306
 
297
- Defaults that keep you out of trouble: network errors, timeouts and the statuses above are retried
298
- with exponential backoff (300 ms, 600 ms, 1.2 s, … plus up to 100 ms of jitter, capped at
299
- `maxDelay`); a `Retry-After` header is honoured unless you set `delay`, and one longer than
300
- `maxDelay` cancels the retry so you can react yourself; validation errors are not retried by the
301
- default policy; aborted requests and requests with a stream body are never retried, whatever the
302
- policy — that includes a timeout coming from your own `AbortSignal.timeout()` signal, which stays
303
- aborted (only `withTimeout()` timeouts are retried); the timeout applies to each attempt; error
304
- interceptors run once, after the last attempt.
305
- Every method is retried by default — pass `methods: ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"]`
306
- if a repeated `POST` could duplicate work. `onRetry(callback)` also exists as a method; it does
307
- not enable retries by itself.
307
+ How retries behave:
308
+
309
+ - By default, network errors, timeouts and the statuses above are retried with exponential
310
+ backoff (300 ms, 600 ms, 1.2 s, … plus up to 100 ms of jitter, capped at `maxDelay`);
311
+ validation errors and other failures are not.
312
+ - Every method is retried by default, `POST` and `PATCH` included. Pass
313
+ `methods: ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"]` if a repeated request could duplicate
314
+ work.
315
+ - A `Retry-After` header is honoured unless you set `delay`; one longer than `maxDelay` cancels
316
+ the retry so you can react yourself.
317
+ - Aborted requests and requests with a stream body are never retried, whatever the policy. That
318
+ includes a timeout from your own `AbortSignal.timeout()` signal, which stays aborted (only
319
+ `withTimeout()` timeouts are retried).
320
+ - The timeout applies to each attempt; error interceptors run once, after the last attempt.
321
+
322
+ `onRetry(callback)` also exists as a method; it does not enable retries by itself.
308
323
 
309
324
  ## Timeouts and cancellation
310
325
 
@@ -316,22 +331,22 @@ const controller = new AbortController();
316
331
  const download = api.get("/big").withAbortController(controller).getBlob();
317
332
  controller.abort(); // `download` rejects with code "ABORTED"
318
333
 
319
- // Data-fetching libraries hand you a signal — pass it straight through
334
+ // Data-fetching libraries hand you a signal: pass it straight through
320
335
  useQuery({
321
336
  queryKey: ["user", id],
322
337
  queryFn: ({ signal }) => api.get<User>(`/users/${id}`).withSignal(signal).getJson(),
323
338
  });
324
339
  ```
325
340
 
326
- The timeout covers the whole exchange — waiting for the response _and_ reading its body — and
341
+ The timeout covers the whole exchange (waiting for the response _and_ reading its body) and
327
342
  starts after request interceptors ran. Taking the stream with `getBody()` ends it. A signal
328
343
  created with `AbortSignal.timeout()` is reported as `"TIMEOUT"` too (but, unlike `withTimeout()`,
329
- is not retried — the signal stays aborted); any other abort is `"ABORTED"`. A request whose
330
- signal is already aborted fails before anything is sent.
344
+ is not retried, because the signal stays aborted); any other abort is `"ABORTED"`. A request
345
+ whose signal is already aborted fails before anything is sent.
331
346
 
332
347
  ## Interceptors
333
348
 
334
- Interceptors run in registration order — api-level ones first, then request-level ones.
349
+ Interceptors run in registration order: api-level ones first, then request-level ones.
335
350
  Returning nothing keeps your in-place changes.
336
351
 
337
352
  ```typescript
@@ -351,24 +366,25 @@ const authed = api
351
366
  if (error.status !== 401 || replayed.has(request)) return;
352
367
  accessToken = await refreshToken(); // the request interceptor above picks it up
353
368
  const retry = request.clone(); // same method, body, headers and query
354
- replayed.add(retry); // the clone runs this interceptor too — never loop on a persistent 401
369
+ replayed.add(retry); // the clone runs this interceptor too; never loop on a persistent 401
355
370
  return retry.getResponse();
356
371
  });
357
372
  ```
358
373
 
359
374
  The `config` a request interceptor receives is the `RequestInit`-shaped object about to be sent:
360
375
  `url`, `method`, lower-case `headers`, the serialised `body` (JSON bodies are already strings) and
361
- the combined `signal` — before the CSRF header (`withCsrf()`) and the timeout are added. A
362
- `Response` returned by a request interceptor skips the network — and the remaining request
363
- interceptors, URL/header validation and the CSRF header — but its status is checked and response
364
- interceptors run like for a fetched one; `withTimeout()` does not apply to it. A request or
365
- response interceptor that throws fails the request with code `"INTERCEPTOR"`; an error
366
- interceptor that throws replaces the error (a thrown `RequestError` is kept as-is).
376
+ the combined `signal`. The CSRF header (`withCsrf()`) and the timeout are added after request
377
+ interceptors ran. A `Response` returned by a request interceptor skips the network (and the
378
+ remaining request interceptors, URL/header validation and the CSRF header), but its status is
379
+ checked and response interceptors run like for a fetched one; `withTimeout()` does not apply to
380
+ it. A request or response interceptor that throws fails the request with code `"INTERCEPTOR"`;
381
+ an error interceptor that throws replaces the error with an `"INTERCEPTOR"` one (a thrown
382
+ `RequestError` is kept as-is).
367
383
 
368
384
  ## Schema validation
369
385
 
370
- Pass any [Standard Schema](https://standardschema.dev) — zod 3.24+, valibot 1+, arktype 2+,
371
- effect (via `Schema.standardSchemaV1`) and more — to `getJson`, `getData` or `getResult`. The
386
+ Pass any [Standard Schema](https://standardschema.dev) (zod 3.24+, valibot 1+, arktype 2+,
387
+ effect via `Schema.standardSchemaV1`, and more) to `getJson`, `getData` or `getResult`. The
372
388
  body is validated at runtime and the result is typed from the schema; this library adds no
373
389
  dependency for it.
374
390
 
@@ -396,7 +412,8 @@ const result = await api
396
412
  ```
397
413
 
398
414
  `withGraphQL` sends `{ query, variables }` as JSON. With `throwOnError`, a response whose `errors`
399
- array is non-empty rejects with `code: "GRAPHQL"` (GraphQL servers answer `200` for those).
415
+ array is non-empty is reported as a `"GRAPHQL"` error by `getJson()`, `getData()` and `getResult()`
416
+ (GraphQL servers answer `200` for those); `getResponse()` and the other readers do not check it.
400
417
 
401
418
  ## Streaming and downloads
402
419
 
@@ -413,20 +430,20 @@ for (;;) {
413
430
  if (done) break;
414
431
  loaded += value.length;
415
432
  if (total) onProgress(loaded / total);
416
- process(decoder.decode(value, { stream: true }));
433
+ handleChunk(decoder.decode(value, { stream: true }));
417
434
  }
418
435
  ```
419
436
 
420
- Request bodies can be streams too (`withBody(readableStream)`), sent with `duplex: "half"` —
421
- Node.js and Chromium support that, Firefox and Safari do not. Stream bodies are never retried.
437
+ Request bodies can be streams too (`withBody(readableStream)`), sent with `duplex: "half"`.
438
+ Node.js and Chromium support that; Firefox and Safari do not. Stream bodies are never retried.
422
439
  Upload _progress_ is not something `fetch` exposes in browsers, so there is no API for it.
423
440
 
424
441
  ## Testing and custom fetch
425
442
 
426
- `withFetch()` replaces the global `fetch` for a request or an api: inject a stub in tests, an
427
- undici `Agent` for proxies and keep-alive tuning in Node.js, or a framework's patched fetch. The
428
- function receives the final URL and `RequestInit`; it must honour `init.signal` for timeouts and
429
- cancellation to keep working.
443
+ `withFetch()` makes a request or an api call your function instead of the global `fetch`: a stub
444
+ in tests, undici with a dispatcher in Node.js (an `Agent` for keep-alive tuning and mTLS, a
445
+ `ProxyAgent` for proxies), or a framework's patched fetch. The function receives the final URL
446
+ and `RequestInit`; it must honour `init.signal` for timeouts and cancellation to keep working.
430
447
 
431
448
  ```typescript
432
449
  // Tests: no global mocks
@@ -434,7 +451,7 @@ const stubbed = api.withFetch(
434
451
  async () => new Response('{"id":1}', { headers: { "content-type": "application/json" } })
435
452
  );
436
453
 
437
- // Node.js: an undici Agent (proxy, mTLS, keep-alive)
454
+ // Node.js: undici with a dispatcher (an Agent here; a ProxyAgent for proxies)
438
455
  const viaAgent = api.withFetch(
439
456
  (url, init) =>
440
457
  undiciFetch(url, {
@@ -451,21 +468,21 @@ const cached = api.withFetch((url, init) =>
451
468
 
452
469
  ## Cookies and CSRF
453
470
 
454
- - `withCookie(name, value)` / `withCookies({ … })` add a `Cookie` header — **server-side only**.
471
+ - `withCookie(name, value)` / `withCookies({ … })` add a `Cookie` header (**server-side only**).
455
472
  Browsers ignore a `Cookie` header on `fetch` and send their own cookies; use
456
473
  `withCredentials("include")` there. Names and values are sent verbatim, so never pass untrusted
457
474
  input (a `;` in a value would smuggle in another cookie).
458
475
  - `withCsrf()` copies the `XSRF-TOKEN` cookie (URL-decoded) into an `X-XSRF-TOKEN` header unless
459
476
  the request already has one (the Angular, Laravel and Spring convention), **only for
460
- same-origin URLs** — judged on the final request URL, after
461
- interceptors and before any redirect (`fetch` forwards custom headers across redirects, so use
477
+ same-origin URLs**. The origin is judged on the final request URL, after interceptors and
478
+ before any redirect (`fetch` forwards custom headers across redirects, so use
462
479
  `withRedirect("error")` for endpoints that may redirect elsewhere). Outside a browser there is
463
480
  no page origin and every URL counts as same-origin. Configure other setups with
464
481
  `withCsrf({ cookie: "csrftoken", header: "X-CSRFToken" })` (Django),
465
482
  `withCsrf({ token: () => readMetaTag() })` (Rails; the token is then sent as `X-CSRF-Token`) or
466
483
  `withCsrf({ crossOrigin: true })`.
467
- - `withCsrfToken(token)` sends a token you already hold, wherever you send the request — it is a
468
- plain header; prefer `withCsrf({ token })` to keep the same-origin check.
484
+ - `withCsrfToken(token)` sends a token you already hold as a plain header, wherever you send the
485
+ request; prefer `withCsrf({ token })` to keep the same-origin check.
469
486
  - Nothing is sent unless you ask for it: there is no automatic `X-Requested-With` header. Add
470
487
  `withHeader("X-Requested-With", "XMLHttpRequest")` if a framework still checks it.
471
488
 
@@ -477,9 +494,9 @@ browser behaviour, not something the library adds on its own.
477
494
  - `api.get<User>("/me")` (or `create.get<User>(url)`) declares the response type once; every
478
495
  reader uses it: `getJson()`, `getData(user => user.name)`, `getResult()`, `getResponse()`.
479
496
  - Per-call overrides still work: `getJson<Other>()`. An endpoint that can answer `204` is typed as
480
- `getJson<User | null>()` — an empty body yields `null` at runtime.
497
+ `getJson<User | null>()`, since an empty body yields `null` at runtime.
481
498
  - `withBody` / `withGraphQL` are a compile error on `GET`, `HEAD` and `OPTIONS` requests (and on
482
- a `BaseRequest`, whose method is unknown — narrow it with `as BodyRequest`).
499
+ a `BaseRequest`, whose method is unknown; narrow it with `as BodyRequest`).
483
500
  - Method-typed aliases are exported for annotations: `GetRequest<T>`, `PostRequest<T>`, …,
484
501
  `BaseRequest<T>` (any method), `BodyRequest<T>`; the class itself is `HttpRequest<Method, T>`.
485
502
  - `ApiBuilder` (the api instance type) is derived from `HttpRequest`, so the two can never drift,
@@ -502,8 +519,8 @@ browser behaviour, not something the library adds on its own.
502
519
  - **Correct by default.** Only retriable failures are retried (with backoff and `Retry-After`),
503
520
  aborted requests are never retried, CSRF tokens only go to same-origin URLs, and a body can be
504
521
  read in any format, in any order.
505
- - **Types you can trust.** `getJson<User>()` is a `Promise<User>`, `withBody` does not exist on
506
- a `GET`, api instances share the request's configuration methods (derived, not copied), and
522
+ - **Types you can trust.** `getJson<User>()` is a `Promise<User>`, `withBody` on a `GET` does not
523
+ compile, api instances share the request's configuration methods (derived, not copied), and
507
524
  `error.code` narrows.
508
525
  - **Nothing global.** Defaults live on immutable api instances that you create and export
509
526
  yourself.
@@ -512,11 +529,11 @@ browser behaviour, not something the library adds on its own.
512
529
 
513
530
  Measured with `size-limit` on the published build of this version (`npm run size`):
514
531
 
515
- | Import | min + gzip | min + brotli |
516
- | ----------------------------------- | ---------: | -----------: |
517
- | everything (`import create from …`) | 4.86 KB | 4.40 KB |
518
- | `import { createGet }` only | 4.25 KB | |
519
- | `import { RequestError }` only | 0.18 KB | |
532
+ | Import | min + gzip | min + brotli |
533
+ | ------------------------------ | ---------: | -----------: |
534
+ | everything (`import * as …`) | 4.92 KB | 4.46 KB |
535
+ | `import { createGet }` only | 4.32 KB | |
536
+ | `import { RequestError }` only | 0.25 KB | |
520
537
 
521
538
  The package is one module with no side effects, so bundlers drop whatever you do not import. The
522
539
  JavaScript ships without JSDoc comments; the documentation lives in the declaration files, where
@@ -530,9 +547,9 @@ for the complete v1 → v2 map and the list of behaviour changes.
530
547
 
531
548
  ## Contributing
532
549
 
533
- `npm run check` runs lint, format, type-check, the type tests (including every code block of
534
- this README), the test suite at 100% coverage, the build, package linting and the size gate.
535
- See [CONTRIBUTING.md](CONTRIBUTING.md).
550
+ `npm run check` runs lint, format, type-check, the type tests (including every TypeScript code
551
+ block of this README), the test suite at 100% coverage, the build, package linting and the size
552
+ gate. See [CONTRIBUTING.md](CONTRIBUTING.md).
536
553
 
537
554
  ## License
538
555
 
package/dist/index.cjs CHANGED
@@ -12,6 +12,16 @@ var RequestError = class extends Error {
12
12
  } catch {}
13
13
  return this._data;
14
14
  }
15
+ toJSON() {
16
+ return {
17
+ name: this.name,
18
+ code: this.code,
19
+ message: this.message,
20
+ method: this.method,
21
+ url: this.url.split(/[?#]/)[0],
22
+ status: this.status
23
+ };
24
+ }
15
25
  get isTimeout() {
16
26
  return this.code === "TIMEOUT";
17
27
  }
@@ -284,7 +294,7 @@ var HttpRequest = class HttpRequest {
284
294
  return this;
285
295
  }
286
296
  withHeaders(headers) {
287
- for (const [name, value] of Object.entries(headers)) {
297
+ for (const [name, value] of Symbol.iterator in headers ? headers : Object.entries(headers)) {
288
298
  const key = name.toLowerCase();
289
299
  if (value == null) delete this._o.headers[key];
290
300
  else this._o.headers[key] = String(value);
package/dist/index.d.cts CHANGED
@@ -82,6 +82,16 @@ interface RequestErrorOptions {
82
82
  /** The underlying error (what `fetch`, an interceptor or `JSON.parse` threw). */
83
83
  cause?: unknown;
84
84
  }
85
+ /** What `JSON.stringify(error)` gives for a {@link RequestError} — see {@link RequestError.toJSON}. */
86
+ interface RequestErrorJSON {
87
+ name: "RequestError";
88
+ code: RequestErrorCode;
89
+ message: string;
90
+ method: Method;
91
+ /** The requested URL without its query string and fragment. */
92
+ url: string;
93
+ status?: number | undefined;
94
+ }
85
95
  /**
86
96
  * The single error type thrown by this library. Every rejection from `getResponse()`, `getJson()`, …
87
97
  * is a `RequestError`, so one `instanceof` (or {@link isRequestError}) check is enough, and `code`
@@ -132,6 +142,18 @@ export declare class RequestError<TData = unknown> extends Error {
132
142
  * ```
133
143
  */
134
144
  get data(): TData | undefined;
145
+ /**
146
+ * What `JSON.stringify(error)` gives, for logs and API responses: name, code, message, method, URL and status. The URL
147
+ * loses its query string, which may hold API keys or tokens; the response, body, issues and cause are left out — read
148
+ * them from the error itself.
149
+ *
150
+ * @example
151
+ * ```typescript
152
+ * console.error(JSON.stringify(error));
153
+ * // {"name":"RequestError","code":"HTTP","message":"HTTP 404 Not Found","method":"GET","url":"https://api.example.com/users/42","status":404}
154
+ * ```
155
+ */
156
+ toJSON(): RequestErrorJSON;
135
157
  /** Shorthand for `code === "TIMEOUT"`. */
136
158
  get isTimeout(): boolean;
137
159
  /** Shorthand for `code === "ABORTED"`. */
@@ -487,16 +509,18 @@ export declare class HttpRequest<M extends Method = Method, T = unknown> {
487
509
  private _fail;
488
510
  private _set;
489
511
  /**
490
- * Sets several headers at once. Names are case-insensitive (stored lower-case); a `null` or `undefined`
491
- * value removes the header, which is how a request drops a default set on its api.
512
+ * Sets several headers at once, from an object, a `Headers` object or `[name, value]` pairs — the forms `fetch`
513
+ * accepts. Names are case-insensitive (stored lower-case) and a later value replaces an earlier one. In an object,
514
+ * a `null` or `undefined` value removes the header, which is how a request drops a default set on its api.
492
515
  *
493
516
  * @example
494
517
  * ```typescript
495
518
  * request.withHeaders({ Accept: "application/json", "X-Request-Id": id });
519
+ * request.withHeaders(new Headers({ "X-Request-Id": id }));
496
520
  * api.get("/public").withHeaders({ Authorization: null }); // send this one unauthenticated
497
521
  * ```
498
522
  */
499
- withHeaders<H extends { [K in keyof H]: string | number | null | undefined; }>(headers: H): this;
523
+ withHeaders<H extends { [K in keyof H]: string | number | null | undefined; }>(headers: H | Headers | readonly (readonly [string, string])[]): this;
500
524
  /** Sets one header — see {@link withHeaders}. */
501
525
  withHeader(name: string, value: string | number | null | undefined): this;
502
526
  /**
@@ -751,7 +775,7 @@ type ApiChainables = { [K in keyof HttpRequest as K extends ApiKeys ? K : never]
751
775
  * ```
752
776
  */
753
777
  interface ApiBuilder extends ApiChainables {
754
- withHeaders<H extends { [K in keyof H]: string | number | null | undefined; }>(headers: H): ApiBuilder;
778
+ withHeaders<H extends { [K in keyof H]: string | number | null | undefined; }>(headers: H | Headers | readonly (readonly [string, string])[]): ApiBuilder;
755
779
  withCookies<C extends { [K in keyof C]: string; }>(cookies: C): ApiBuilder;
756
780
  withQueryParams<P extends { [K in keyof P]: QueryValue; }>(params: P | URLSearchParams): ApiBuilder;
757
781
  /**
@@ -848,4 +872,4 @@ declare const create: {
848
872
  readonly api: typeof createApi;
849
873
  };
850
874
  //#endregion
851
- export { type ApiBuilder, type Body, type BodyMethod, type CookiesRecord, type CsrfOptions, type ErrorInterceptor, type FetchFunction, type GraphQLOptions, type HeadersRecord, type Method, type QueryParams, type QueryValue, type RequestConfig, type RequestErrorCode, type RequestErrorOptions, type RequestInterceptor, type RequestResult, type ResponseInterceptor, type RetryConfig, type RetryContext, type StandardSchemaV1, create as default };
875
+ export { type ApiBuilder, type Body, type BodyMethod, type CookiesRecord, type CsrfOptions, type ErrorInterceptor, type FetchFunction, type GraphQLOptions, type HeadersRecord, type Method, type QueryParams, type QueryValue, type RequestConfig, type RequestErrorCode, type RequestErrorJSON, type RequestErrorOptions, type RequestInterceptor, type RequestResult, type ResponseInterceptor, type RetryConfig, type RetryContext, type StandardSchemaV1, create as default };
package/dist/index.d.ts CHANGED
@@ -82,6 +82,16 @@ interface RequestErrorOptions {
82
82
  /** The underlying error (what `fetch`, an interceptor or `JSON.parse` threw). */
83
83
  cause?: unknown;
84
84
  }
85
+ /** What `JSON.stringify(error)` gives for a {@link RequestError} — see {@link RequestError.toJSON}. */
86
+ interface RequestErrorJSON {
87
+ name: "RequestError";
88
+ code: RequestErrorCode;
89
+ message: string;
90
+ method: Method;
91
+ /** The requested URL without its query string and fragment. */
92
+ url: string;
93
+ status?: number | undefined;
94
+ }
85
95
  /**
86
96
  * The single error type thrown by this library. Every rejection from `getResponse()`, `getJson()`, …
87
97
  * is a `RequestError`, so one `instanceof` (or {@link isRequestError}) check is enough, and `code`
@@ -132,6 +142,18 @@ export declare class RequestError<TData = unknown> extends Error {
132
142
  * ```
133
143
  */
134
144
  get data(): TData | undefined;
145
+ /**
146
+ * What `JSON.stringify(error)` gives, for logs and API responses: name, code, message, method, URL and status. The URL
147
+ * loses its query string, which may hold API keys or tokens; the response, body, issues and cause are left out — read
148
+ * them from the error itself.
149
+ *
150
+ * @example
151
+ * ```typescript
152
+ * console.error(JSON.stringify(error));
153
+ * // {"name":"RequestError","code":"HTTP","message":"HTTP 404 Not Found","method":"GET","url":"https://api.example.com/users/42","status":404}
154
+ * ```
155
+ */
156
+ toJSON(): RequestErrorJSON;
135
157
  /** Shorthand for `code === "TIMEOUT"`. */
136
158
  get isTimeout(): boolean;
137
159
  /** Shorthand for `code === "ABORTED"`. */
@@ -487,16 +509,18 @@ export declare class HttpRequest<M extends Method = Method, T = unknown> {
487
509
  private _fail;
488
510
  private _set;
489
511
  /**
490
- * Sets several headers at once. Names are case-insensitive (stored lower-case); a `null` or `undefined`
491
- * value removes the header, which is how a request drops a default set on its api.
512
+ * Sets several headers at once, from an object, a `Headers` object or `[name, value]` pairs — the forms `fetch`
513
+ * accepts. Names are case-insensitive (stored lower-case) and a later value replaces an earlier one. In an object,
514
+ * a `null` or `undefined` value removes the header, which is how a request drops a default set on its api.
492
515
  *
493
516
  * @example
494
517
  * ```typescript
495
518
  * request.withHeaders({ Accept: "application/json", "X-Request-Id": id });
519
+ * request.withHeaders(new Headers({ "X-Request-Id": id }));
496
520
  * api.get("/public").withHeaders({ Authorization: null }); // send this one unauthenticated
497
521
  * ```
498
522
  */
499
- withHeaders<H extends { [K in keyof H]: string | number | null | undefined; }>(headers: H): this;
523
+ withHeaders<H extends { [K in keyof H]: string | number | null | undefined; }>(headers: H | Headers | readonly (readonly [string, string])[]): this;
500
524
  /** Sets one header — see {@link withHeaders}. */
501
525
  withHeader(name: string, value: string | number | null | undefined): this;
502
526
  /**
@@ -751,7 +775,7 @@ type ApiChainables = { [K in keyof HttpRequest as K extends ApiKeys ? K : never]
751
775
  * ```
752
776
  */
753
777
  interface ApiBuilder extends ApiChainables {
754
- withHeaders<H extends { [K in keyof H]: string | number | null | undefined; }>(headers: H): ApiBuilder;
778
+ withHeaders<H extends { [K in keyof H]: string | number | null | undefined; }>(headers: H | Headers | readonly (readonly [string, string])[]): ApiBuilder;
755
779
  withCookies<C extends { [K in keyof C]: string; }>(cookies: C): ApiBuilder;
756
780
  withQueryParams<P extends { [K in keyof P]: QueryValue; }>(params: P | URLSearchParams): ApiBuilder;
757
781
  /**
@@ -848,4 +872,4 @@ declare const create: {
848
872
  readonly api: typeof createApi;
849
873
  };
850
874
  //#endregion
851
- export { type ApiBuilder, type Body, type BodyMethod, type CookiesRecord, type CsrfOptions, type ErrorInterceptor, type FetchFunction, type GraphQLOptions, type HeadersRecord, type Method, type QueryParams, type QueryValue, type RequestConfig, type RequestErrorCode, type RequestErrorOptions, type RequestInterceptor, type RequestResult, type ResponseInterceptor, type RetryConfig, type RetryContext, type StandardSchemaV1, create as default };
875
+ export { type ApiBuilder, type Body, type BodyMethod, type CookiesRecord, type CsrfOptions, type ErrorInterceptor, type FetchFunction, type GraphQLOptions, type HeadersRecord, type Method, type QueryParams, type QueryValue, type RequestConfig, type RequestErrorCode, type RequestErrorJSON, type RequestErrorOptions, type RequestInterceptor, type RequestResult, type ResponseInterceptor, type RetryConfig, type RetryContext, type StandardSchemaV1, create as default };
package/dist/index.js CHANGED
@@ -11,6 +11,16 @@ var RequestError = class extends Error {
11
11
  } catch {}
12
12
  return this._data;
13
13
  }
14
+ toJSON() {
15
+ return {
16
+ name: this.name,
17
+ code: this.code,
18
+ message: this.message,
19
+ method: this.method,
20
+ url: this.url.split(/[?#]/)[0],
21
+ status: this.status
22
+ };
23
+ }
14
24
  get isTimeout() {
15
25
  return this.code === "TIMEOUT";
16
26
  }
@@ -283,7 +293,7 @@ var HttpRequest = class HttpRequest {
283
293
  return this;
284
294
  }
285
295
  withHeaders(headers) {
286
- for (const [name, value] of Object.entries(headers)) {
296
+ for (const [name, value] of Symbol.iterator in headers ? headers : Object.entries(headers)) {
287
297
  const key = name.toLowerCase();
288
298
  if (value == null) delete this._o.headers[key];
289
299
  else this._o.headers[key] = String(value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-request",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "A tiny, chainable, fully typed wrapper around fetch: retries, timeouts, cancellation, interceptors, api instances, schema validation and one error type",
5
5
  "type": "module",
6
6
  "sideEffects": false,