create-request 2.0.1 → 2.2.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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.2.0 — 2026-10-09
4
+
5
+ ### Added
6
+
7
+ - `create.query()`, `createQuery()`, `api.query()` and the `QueryRequest` type for the HTTP `QUERY` method ([RFC 10008](https://www.rfc-editor.org/rfc/rfc10008)): a safe, idempotent read whose query is sent in the body, for searches too large or too structured for a URL. `"QUERY"` joins the `Method` and `BodyMethod` unions, so `withBody()` and `withGraphQL()` accept it and `withRetries({ methods })` can list it; a `switch` that handles every `Method` needs a `"QUERY"` case.
8
+
9
+ ## 2.1.0 — 2026-10-03
10
+
11
+ ### Added
12
+
13
+ - `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`.
14
+ - `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.
15
+
3
16
  ## 2.0.0 — 2026-10-01
4
17
 
5
18
  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.
@@ -102,31 +104,43 @@ try {
102
104
  ### Creating
103
105
 
104
106
  ```typescript
105
- create.get(url); // also head, options, post, put, patch, delete (alias: del)
107
+ create.get(url); // also head, options, post, put, patch, delete (alias: del), query
106
108
  api.get("/users"); // joined to the api's base URL
107
109
  api.get(); // no path → the base URL itself
108
110
  api.get<User>("/me"); // declare the JSON type once; getJson() / getData() / getResult() use it
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
113
- `createApi()` is also available as `create.api()`.
114
+ `createDelete`, `createHead`, `createOptions`, `createQuery` (the same functions as
115
+ `create.get`, …), and `createApi()` is also available as `create.api()`.
116
+
117
+ `QUERY` works like `GET`, but the query goes in the body:
118
+
119
+ ```typescript
120
+ const results = await api
121
+ .query<Page<Post>>("/posts/search")
122
+ .withBody({ text: "fetch", tags: ["http"], sort: "recent" })
123
+ .getJson();
124
+ ```
125
+
126
+ The server must support `QUERY`. In browsers, a cross-origin `QUERY` request is preflighted.
114
127
 
115
128
  ### Configuring
116
129
 
117
130
  ```typescript
118
131
  create
119
132
  .post("https://api.example.com/items")
120
- // headers & auth — names are case-insensitive, null removes a header
133
+ // headers & auth: an object, a Headers object or [name, value] pairs; names are
134
+ // case-insensitive, and null (in an object) removes a header
121
135
  .withHeaders({ Accept: "application/json", "X-Trace": id })
122
136
  .withHeader("X-Feature", "beta")
123
137
  .withContentType("application/json") // rarely needed: JSON and text bodies set it themselves
124
138
  .withBearerToken(token) // or withBasicAuth(user, pass) / withAuthorization("Custom …")
125
- // query string — arrays repeat the key, Dates become ISO strings, null removes the key,
139
+ // query string: arrays repeat the key, Dates become ISO strings, null removes the key,
126
140
  // and a key set twice keeps the last value (a request can override an api default)
127
141
  .withQueryParams({ page: 2, tags: ["a", "b"], since: new Date() })
128
142
  .withQueryParam("q", "search term")
129
- // body (POST, PUT, PATCH, DELETE only): objects → JSON, strings → text/plain,
143
+ // body (POST, PUT, PATCH, DELETE, QUERY only): objects → JSON, strings → text/plain,
130
144
  // FormData / Blob / URLSearchParams / ArrayBuffer / typed arrays / ReadableStream → sent as-is
131
145
  .withBody({ name: "Ada" })
132
146
  // resilience
@@ -145,12 +159,13 @@ create
145
159
  .withIntegrity("sha256-…");
146
160
  ```
147
161
 
148
- Sending a `FormData`? Do not set a `Content-Type` — `fetch` adds the multipart boundary itself
162
+ Sending a `FormData`? Do not set a `Content-Type`: `fetch` adds the multipart boundary itself
149
163
  (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)).
164
+ their own: `withCookie(s)`, `withCsrf` and `withCsrfToken`
165
+ ([Cookies and CSRF](#cookies-and-csrf)), `onRetry` ([Retries](#retries)), `withAbortController`
166
+ ([Timeouts and cancellation](#timeouts-and-cancellation)), `withRequestInterceptor` /
167
+ `withResponseInterceptor` / `withErrorInterceptor` ([Interceptors](#interceptors)), `withGraphQL`
168
+ ([GraphQL](#graphql)) and `withFetch` ([Testing and custom fetch](#testing-and-custom-fetch)).
154
169
 
155
170
  ### Executing
156
171
 
@@ -161,20 +176,20 @@ their own: `withCookie(s)` and `withCsrf` ([Cookies and CSRF](#cookies-and-csrf)
161
176
  | `getBlob()` | The body as a `Blob` typed with the response `Content-Type` |
162
177
  | `getArrayBuffer()` | The body as an `ArrayBuffer` |
163
178
  | `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`) |
179
+ | `getBody()` | The raw `ReadableStream` (not buffered, for streaming), or `null` when there is no body (`HEAD`, `204`) |
165
180
  | `getData(selector)` | `getJson()` followed by a selector, e.g. `getData(page => page.items)` |
166
181
  | `getResult<T>()` | `{ data, error }` instead of throwing |
167
182
  | `getResponse()` | A `ResponseWrapper`: `status`, `statusText`, `ok`, `headers`, `url`, `method`, the underlying `raw` `Response`, and every body reader above (`getJson` … `getData`) |
168
183
 
169
184
  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.
185
+ even concurrently. `getBody()` is the exception: it hands you the live stream, so it must be the
186
+ only reader of that response.
172
187
 
173
188
  ```typescript
174
189
  const response = await api.get<User>("/me").getResponse();
175
190
  console.log(response.status, response.headers.get("etag"));
176
191
  const user = await response.getJson(); // User
177
- const raw = await response.getText(); // still works — same buffer
192
+ const raw = await response.getText(); // still works: same buffer
178
193
  ```
179
194
 
180
195
  ### Reusing
@@ -195,26 +210,31 @@ const [page1, page2] = await Promise.all([
195
210
  Every rejection is a `RequestError`. Check it with `isRequestError(error)` (or `instanceof`) and
196
211
  switch on `code`:
197
212
 
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.
213
+ | `code` | When | Also set |
214
+ | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
215
+ | `"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` |
216
+ | `"NETWORK"` | `fetch` itself failed: DNS, connection refused, CORS, offline | `cause` (the error `fetch` threw) |
217
+ | `"TIMEOUT"` | `withTimeout()` fired, or a signal from `AbortSignal.timeout()` aborted | `cause`; plus `status`, `response` when it fired while the body was being read |
218
+ | `"ABORTED"` | A signal passed with `withSignal()` / `withAbortController()` aborted | `cause` (the abort reason); plus `status`, `response` when it fired while the body was being read |
219
+ | `"PARSE"` | The body could not be read or parsed, or a `getData` selector threw | `status`, `response`, `cause`, `body` (for invalid JSON) |
220
+ | `"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 |
221
+ | `"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 |
222
+ | `"GRAPHQL"` | The GraphQL response had `errors` and `throwOnError` was on | `status`, `response`, `body`, `data` |
223
+
224
+ `url` and `method` are always set. `data` is `body` parsed as JSON (the shape most APIs use for
225
+ error details) and never throws. On `"HTTP"` errors, `body` holds at most 1 MB: a response that
226
+ announces more is left unread on `error.response`; a longer chunked or compressed one is cut off
227
+ and `body` is `undefined`. `isTimeout` / `isAborted` are shorthands for the two codes. In Node.js
228
+ a relative URL is a `"NETWORK"` failure, because `fetch` there has no page to resolve it against.
229
+
230
+ `JSON.stringify(error)`, which loggers and `res.json()` use, gives `name`, `code`, `message`,
231
+ `method`, `url` and `status`. The URL loses its query string, which may hold API keys or tokens,
232
+ and the response, body and cause are left out; read those from the error itself.
233
+
234
+ A few bad arguments are caught before the request runs: `withTimeout(-1)`, `withRetries(-1)` and
235
+ a `withBody()` value that cannot be serialised to JSON (a circular object, a `BigInt`) throw a
236
+ `RequestError` with code `"VALIDATION"` from the `with*` call itself. Because they are thrown
237
+ immediately, `getResult()` and `.catch()` never see them.
218
238
 
219
239
  ```typescript
220
240
  try {
@@ -269,24 +289,25 @@ const post = await api.post<Post>("/posts").withBody({ title: "Hello" }).getJson
269
289
  const admin = api.withHeader("X-Role", "admin"); // a NEW instance; `api` is unchanged
270
290
  ```
271
291
 
272
- Requests inherit the defaults and can override any of them: `api.get("/slow").withTimeout(30_000)`,
292
+ Requests inherit the defaults and can override them: `api.get("/slow").withTimeout(30_000)`,
273
293
  `api.get("/public").withHeaders({ Authorization: null })`, `api.get("/live").withTimeout(0)` (no
274
- timeout at all).
294
+ timeout at all). Interceptors and cookies are the exception: a request adds its own to the api's
295
+ instead of replacing them.
275
296
 
276
297
  Paths are **joined** to the base URL, not resolved: `/v1` + `/users` → `/v1/users`; `users` and
277
298
  `./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
299
+ (`https://…`, `//…`) are used as-is. They carry the api's headers with them, so never build a
279
300
  path from untrusted input.
280
301
 
281
302
  ## Retries
282
303
 
283
304
  ```typescript
284
- api.get("/status").withRetries(3); // default policy
305
+ api.get("/status").withRetries(3); // 3 retries (up to 4 requests), default policy
285
306
  api.get("/status").withRetries({
286
307
  attempts: 3,
287
308
  delay: ({ attempt }) => attempt * 500, // ms, or a number; default: exponential backoff
288
309
  statuses: [503], // default: 408, 425, 429, 500, 502, 503, 504
289
- methods: ["GET", "HEAD", "PUT", "DELETE"], // default: every method — including POST and PATCH
310
+ methods: ["GET", "HEAD", "OPTIONS", "QUERY", "PUT", "DELETE"], // default: all, POST and PATCH included
290
311
  maxDelay: 10_000, // caps the backoff (default 30 s); a longer Retry-After gives up instead
291
312
  shouldRetry: ({ error }) => error.code === "NETWORK", // full override of the decision
292
313
  onRetry: ({ attempt, error, delay }) =>
@@ -294,17 +315,22 @@ api.get("/status").withRetries({
294
315
  });
295
316
  ```
296
317
 
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.
318
+ How retries behave:
319
+
320
+ - By default, network errors, timeouts and the statuses above are retried with exponential
321
+ backoff (300 ms, 600 ms, 1.2 s, … plus up to 100 ms of jitter, capped at `maxDelay`);
322
+ validation errors and other failures are not.
323
+ - Every method is retried by default, `POST` and `PATCH` included. Pass
324
+ `methods: ["GET", "HEAD", "OPTIONS", "QUERY", "PUT", "DELETE"]` if a repeated request could
325
+ duplicate work.
326
+ - A `Retry-After` header is honoured unless you set `delay`; one longer than `maxDelay` cancels
327
+ the retry so you can react yourself.
328
+ - Aborted requests and requests with a stream body are never retried, whatever the policy. That
329
+ includes a timeout from your own `AbortSignal.timeout()` signal, which stays aborted (only
330
+ `withTimeout()` timeouts are retried).
331
+ - The timeout applies to each attempt; error interceptors run once, after the last attempt.
332
+
333
+ `onRetry(callback)` also exists as a method; it does not enable retries by itself.
308
334
 
309
335
  ## Timeouts and cancellation
310
336
 
@@ -316,22 +342,22 @@ const controller = new AbortController();
316
342
  const download = api.get("/big").withAbortController(controller).getBlob();
317
343
  controller.abort(); // `download` rejects with code "ABORTED"
318
344
 
319
- // Data-fetching libraries hand you a signal — pass it straight through
345
+ // Data-fetching libraries hand you a signal: pass it straight through
320
346
  useQuery({
321
347
  queryKey: ["user", id],
322
348
  queryFn: ({ signal }) => api.get<User>(`/users/${id}`).withSignal(signal).getJson(),
323
349
  });
324
350
  ```
325
351
 
326
- The timeout covers the whole exchange — waiting for the response _and_ reading its body — and
352
+ The timeout covers the whole exchange (waiting for the response _and_ reading its body) and
327
353
  starts after request interceptors ran. Taking the stream with `getBody()` ends it. A signal
328
354
  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.
355
+ is not retried, because the signal stays aborted); any other abort is `"ABORTED"`. A request
356
+ whose signal is already aborted fails before anything is sent.
331
357
 
332
358
  ## Interceptors
333
359
 
334
- Interceptors run in registration order — api-level ones first, then request-level ones.
360
+ Interceptors run in registration order: api-level ones first, then request-level ones.
335
361
  Returning nothing keeps your in-place changes.
336
362
 
337
363
  ```typescript
@@ -351,24 +377,25 @@ const authed = api
351
377
  if (error.status !== 401 || replayed.has(request)) return;
352
378
  accessToken = await refreshToken(); // the request interceptor above picks it up
353
379
  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
380
+ replayed.add(retry); // the clone runs this interceptor too; never loop on a persistent 401
355
381
  return retry.getResponse();
356
382
  });
357
383
  ```
358
384
 
359
385
  The `config` a request interceptor receives is the `RequestInit`-shaped object about to be sent:
360
386
  `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).
387
+ the combined `signal`. The CSRF header (`withCsrf()`) and the timeout are added after request
388
+ interceptors ran. A `Response` returned by a request interceptor skips the network (and the
389
+ remaining request interceptors, URL/header validation and the CSRF header), but its status is
390
+ checked and response interceptors run like for a fetched one; `withTimeout()` does not apply to
391
+ it. A request or response interceptor that throws fails the request with code `"INTERCEPTOR"`;
392
+ an error interceptor that throws replaces the error with an `"INTERCEPTOR"` one (a thrown
393
+ `RequestError` is kept as-is).
367
394
 
368
395
  ## Schema validation
369
396
 
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
397
+ Pass any [Standard Schema](https://standardschema.dev) (zod 3.24+, valibot 1+, arktype 2+,
398
+ effect via `Schema.standardSchemaV1`, and more) to `getJson`, `getData` or `getResult`. The
372
399
  body is validated at runtime and the result is typed from the schema; this library adds no
373
400
  dependency for it.
374
401
 
@@ -396,7 +423,8 @@ const result = await api
396
423
  ```
397
424
 
398
425
  `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).
426
+ array is non-empty is reported as a `"GRAPHQL"` error by `getJson()`, `getData()` and `getResult()`
427
+ (GraphQL servers answer `200` for those); `getResponse()` and the other readers do not check it.
400
428
 
401
429
  ## Streaming and downloads
402
430
 
@@ -413,20 +441,20 @@ for (;;) {
413
441
  if (done) break;
414
442
  loaded += value.length;
415
443
  if (total) onProgress(loaded / total);
416
- process(decoder.decode(value, { stream: true }));
444
+ handleChunk(decoder.decode(value, { stream: true }));
417
445
  }
418
446
  ```
419
447
 
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.
448
+ Request bodies can be streams too (`withBody(readableStream)`), sent with `duplex: "half"`.
449
+ Node.js and Chromium support that; Firefox and Safari do not. Stream bodies are never retried.
422
450
  Upload _progress_ is not something `fetch` exposes in browsers, so there is no API for it.
423
451
 
424
452
  ## Testing and custom fetch
425
453
 
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.
454
+ `withFetch()` makes a request or an api call your function instead of the global `fetch`: a stub
455
+ in tests, undici with a dispatcher in Node.js (an `Agent` for keep-alive tuning and mTLS, a
456
+ `ProxyAgent` for proxies), or a framework's patched fetch. The function receives the final URL
457
+ and `RequestInit`; it must honour `init.signal` for timeouts and cancellation to keep working.
430
458
 
431
459
  ```typescript
432
460
  // Tests: no global mocks
@@ -434,7 +462,7 @@ const stubbed = api.withFetch(
434
462
  async () => new Response('{"id":1}', { headers: { "content-type": "application/json" } })
435
463
  );
436
464
 
437
- // Node.js: an undici Agent (proxy, mTLS, keep-alive)
465
+ // Node.js: undici with a dispatcher (an Agent here; a ProxyAgent for proxies)
438
466
  const viaAgent = api.withFetch(
439
467
  (url, init) =>
440
468
  undiciFetch(url, {
@@ -451,21 +479,21 @@ const cached = api.withFetch((url, init) =>
451
479
 
452
480
  ## Cookies and CSRF
453
481
 
454
- - `withCookie(name, value)` / `withCookies({ … })` add a `Cookie` header — **server-side only**.
482
+ - `withCookie(name, value)` / `withCookies({ … })` add a `Cookie` header (**server-side only**).
455
483
  Browsers ignore a `Cookie` header on `fetch` and send their own cookies; use
456
484
  `withCredentials("include")` there. Names and values are sent verbatim, so never pass untrusted
457
485
  input (a `;` in a value would smuggle in another cookie).
458
486
  - `withCsrf()` copies the `XSRF-TOKEN` cookie (URL-decoded) into an `X-XSRF-TOKEN` header unless
459
487
  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
488
+ same-origin URLs**. The origin is judged on the final request URL, after interceptors and
489
+ before any redirect (`fetch` forwards custom headers across redirects, so use
462
490
  `withRedirect("error")` for endpoints that may redirect elsewhere). Outside a browser there is
463
491
  no page origin and every URL counts as same-origin. Configure other setups with
464
492
  `withCsrf({ cookie: "csrftoken", header: "X-CSRFToken" })` (Django),
465
493
  `withCsrf({ token: () => readMetaTag() })` (Rails; the token is then sent as `X-CSRF-Token`) or
466
494
  `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.
495
+ - `withCsrfToken(token)` sends a token you already hold as a plain header, wherever you send the
496
+ request; prefer `withCsrf({ token })` to keep the same-origin check.
469
497
  - Nothing is sent unless you ask for it: there is no automatic `X-Requested-With` header. Add
470
498
  `withHeader("X-Requested-With", "XMLHttpRequest")` if a framework still checks it.
471
499
 
@@ -477,9 +505,9 @@ browser behaviour, not something the library adds on its own.
477
505
  - `api.get<User>("/me")` (or `create.get<User>(url)`) declares the response type once; every
478
506
  reader uses it: `getJson()`, `getData(user => user.name)`, `getResult()`, `getResponse()`.
479
507
  - 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.
508
+ `getJson<User | null>()`, since an empty body yields `null` at runtime.
481
509
  - `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`).
510
+ a `BaseRequest`, whose method is unknown; narrow it with `as BodyRequest`).
483
511
  - Method-typed aliases are exported for annotations: `GetRequest<T>`, `PostRequest<T>`, …,
484
512
  `BaseRequest<T>` (any method), `BodyRequest<T>`; the class itself is `HttpRequest<Method, T>`.
485
513
  - `ApiBuilder` (the api instance type) is derived from `HttpRequest`, so the two can never drift,
@@ -502,8 +530,8 @@ browser behaviour, not something the library adds on its own.
502
530
  - **Correct by default.** Only retriable failures are retried (with backoff and `Retry-After`),
503
531
  aborted requests are never retried, CSRF tokens only go to same-origin URLs, and a body can be
504
532
  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
533
+ - **Types you can trust.** `getJson<User>()` is a `Promise<User>`, `withBody` on a `GET` does not
534
+ compile, api instances share the request's configuration methods (derived, not copied), and
507
535
  `error.code` narrows.
508
536
  - **Nothing global.** Defaults live on immutable api instances that you create and export
509
537
  yourself.
@@ -512,11 +540,11 @@ browser behaviour, not something the library adds on its own.
512
540
 
513
541
  Measured with `size-limit` on the published build of this version (`npm run size`):
514
542
 
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 | |
543
+ | Import | min + gzip | min + brotli |
544
+ | ------------------------------ | ---------: | -----------: |
545
+ | everything (`import * as …`) | 4.95 KB | 4.48 KB |
546
+ | `import { createGet }` only | 4.32 KB | |
547
+ | `import { RequestError }` only | 0.25 KB | |
520
548
 
521
549
  The package is one module with no side effects, so bundlers drop whatever you do not import. The
522
550
  JavaScript ships without JSDoc comments; the documentation lives in the declaration files, where
@@ -530,9 +558,9 @@ for the complete v1 → v2 map and the list of behaviour changes.
530
558
 
531
559
  ## Contributing
532
560
 
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).
561
+ `npm run check` runs lint, format, type-check, the type tests (including every TypeScript code
562
+ block of this README), the test suite at 100% coverage, the build, package linting and the size
563
+ gate. See [CONTRIBUTING.md](CONTRIBUTING.md).
536
564
 
537
565
  ## License
538
566
 
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);
@@ -675,7 +685,8 @@ const METHODS = [
675
685
  "POST",
676
686
  "PUT",
677
687
  "PATCH",
678
- "DELETE"
688
+ "DELETE",
689
+ "QUERY"
679
690
  ];
680
691
  let installed = false;
681
692
  function createApi() {
@@ -702,6 +713,7 @@ const createPost = (url) => new HttpRequest("POST", url);
702
713
  const createPut = (url) => new HttpRequest("PUT", url);
703
714
  const createPatch = (url) => new HttpRequest("PATCH", url);
704
715
  const createDelete = (url) => new HttpRequest("DELETE", url);
716
+ const createQuery = (url) => new HttpRequest("QUERY", url);
705
717
  const create = {
706
718
  get: createGet,
707
719
  head: createHead,
@@ -711,6 +723,7 @@ const create = {
711
723
  patch: createPatch,
712
724
  delete: createDelete,
713
725
  del: createDelete,
726
+ query: createQuery,
714
727
  api: createApi
715
728
  };
716
729
 
@@ -726,5 +739,6 @@ exports.createOptions = createOptions;
726
739
  exports.createPatch = createPatch;
727
740
  exports.createPost = createPost;
728
741
  exports.createPut = createPut;
742
+ exports.createQuery = createQuery;
729
743
  exports.default = create;
730
744
  exports.isRequestError = isRequestError;
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"`. */
@@ -141,10 +163,13 @@ export declare class RequestError<TData = unknown> extends Error {
141
163
  export declare const isRequestError: (error: unknown) => error is RequestError;
142
164
  //#endregion
143
165
  //#region src/types.d.ts
144
- /** The HTTP methods a request can be created with. */
145
- type Method = "GET" | "HEAD" | "OPTIONS" | "DELETE" | "POST" | "PUT" | "PATCH";
166
+ /**
167
+ * The HTTP methods a request can be created with. `QUERY` ([RFC 10008](https://www.rfc-editor.org/rfc/rfc10008)) is a
168
+ * safe, idempotent read like `GET` whose query is sent in the body.
169
+ */
170
+ type Method = "GET" | "HEAD" | "OPTIONS" | "DELETE" | "POST" | "PUT" | "PATCH" | "QUERY";
146
171
  /** The HTTP methods that may carry a request body (`withBody` / `withGraphQL` are only available on these). */
147
- type BodyMethod = "POST" | "PUT" | "PATCH" | "DELETE";
172
+ type BodyMethod = "POST" | "PUT" | "PATCH" | "DELETE" | "QUERY";
148
173
  /** What `fetch` accepts as a body. */
149
174
  type FetchBody = NonNullable<RequestInit["body"]>;
150
175
  /** `"include"`, `"omit"` or `"same-origin"`. */
@@ -239,7 +264,7 @@ interface RetryConfig {
239
264
  delay?: number | ((context: RetryContext) => number) | undefined;
240
265
  /** Statuses that are retried. Default: `[408, 425, 429, 500, 502, 503, 504]`. Network errors and timeouts are retried by default. */
241
266
  statuses?: readonly number[] | undefined;
242
- /** Methods that are retried. Default: all. Use `["GET", "HEAD", "OPTIONS", "PUT", "DELETE"]` to retry idempotent requests only. */
267
+ /** Methods that are retried. Default: all. Use `["GET", "HEAD", "OPTIONS", "QUERY", "PUT", "DELETE"]` to retry idempotent requests only. */
243
268
  methods?: readonly Method[] | undefined;
244
269
  /**
245
270
  * Upper bound, in milliseconds, for the default backoff. A `Retry-After` header longer than this
@@ -487,16 +512,18 @@ export declare class HttpRequest<M extends Method = Method, T = unknown> {
487
512
  private _fail;
488
513
  private _set;
489
514
  /**
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.
515
+ * Sets several headers at once, from an object, a `Headers` object or `[name, value]` pairs — the forms `fetch`
516
+ * accepts. Names are case-insensitive (stored lower-case) and a later value replaces an earlier one. In an object,
517
+ * a `null` or `undefined` value removes the header, which is how a request drops a default set on its api.
492
518
  *
493
519
  * @example
494
520
  * ```typescript
495
521
  * request.withHeaders({ Accept: "application/json", "X-Request-Id": id });
522
+ * request.withHeaders(new Headers({ "X-Request-Id": id }));
496
523
  * api.get("/public").withHeaders({ Authorization: null }); // send this one unauthenticated
497
524
  * ```
498
525
  */
499
- withHeaders<H extends { [K in keyof H]: string | number | null | undefined; }>(headers: H): this;
526
+ withHeaders<H extends { [K in keyof H]: string | number | null | undefined; }>(headers: H | Headers | readonly (readonly [string, string])[]): this;
500
527
  /** Sets one header — see {@link withHeaders}. */
501
528
  withHeader(name: string, value: string | number | null | undefined): this;
502
529
  /**
@@ -627,7 +654,7 @@ export declare class HttpRequest<M extends Method = Method, T = unknown> {
627
654
  /** Adds an {@link ErrorInterceptor}, run once after the request has failed for good (after retries); it also receives the request, so it can replay it. */
628
655
  withErrorInterceptor(interceptor: ErrorInterceptor): this;
629
656
  /**
630
- * Sets the request body (POST, PUT, PATCH and DELETE only — a compile error elsewhere). Objects and
657
+ * Sets the request body (POST, PUT, PATCH, DELETE and QUERY only — a compile error elsewhere). Objects and
631
658
  * arrays are JSON-encoded; strings, `Blob`, `FormData`, `URLSearchParams`, `ArrayBuffer`, typed arrays
632
659
  * and `ReadableStream` are sent as-is. `Content-Type` is set to `application/json` / `text/plain`
633
660
  * unless already present, and removed for `FormData` (fetch must add the multipart boundary itself).
@@ -751,7 +778,7 @@ type ApiChainables = { [K in keyof HttpRequest as K extends ApiKeys ? K : never]
751
778
  * ```
752
779
  */
753
780
  interface ApiBuilder extends ApiChainables {
754
- withHeaders<H extends { [K in keyof H]: string | number | null | undefined; }>(headers: H): ApiBuilder;
781
+ withHeaders<H extends { [K in keyof H]: string | number | null | undefined; }>(headers: H | Headers | readonly (readonly [string, string])[]): ApiBuilder;
755
782
  withCookies<C extends { [K in keyof C]: string; }>(cookies: C): ApiBuilder;
756
783
  withQueryParams<P extends { [K in keyof P]: QueryValue; }>(params: P | URLSearchParams): ApiBuilder;
757
784
  /**
@@ -775,6 +802,8 @@ interface ApiBuilder extends ApiChainables {
775
802
  delete<T = unknown>(path?: string): HttpRequest<"DELETE", T>;
776
803
  /** Alias of `delete`. */
777
804
  del<T = unknown>(path?: string): HttpRequest<"DELETE", T>;
805
+ /** Creates a QUERY request (RFC 10008): a safe, idempotent read whose query is sent with `withBody()`. */
806
+ query<T = unknown>(path?: string): HttpRequest<"QUERY", T>;
778
807
  }
779
808
  /**
780
809
  * Creates an empty {@link ApiBuilder}. Configure it once, export it, and create every request through it.
@@ -797,9 +826,11 @@ export type PutRequest<T = unknown> = HttpRequest<"PUT", T>;
797
826
  export type PatchRequest<T = unknown> = HttpRequest<"PATCH", T>;
798
827
  /** A DELETE request. */
799
828
  export type DeleteRequest<T = unknown> = HttpRequest<"DELETE", T>;
829
+ /** A QUERY request (RFC 10008). */
830
+ export type QueryRequest<T = unknown> = HttpRequest<"QUERY", T>;
800
831
  /** Any request — the v1 name for {@link HttpRequest}. */
801
832
  export type BaseRequest<T = unknown> = HttpRequest<Method, T>;
802
- /** Any request that may carry a body — the v1 name for `HttpRequest<"POST" | "PUT" | "PATCH" | "DELETE">`. */
833
+ /** Any request that may carry a body — the v1 name for `HttpRequest<"POST" | "PUT" | "PATCH" | "DELETE" | "QUERY">`. */
803
834
  export type BodyRequest<T = unknown> = HttpRequest<BodyMethod, T>;
804
835
  /** Creates a GET request. `T` declares the JSON type the response is expected to have. */
805
836
  export declare const createGet: <T = unknown>(url: string) => GetRequest<T>;
@@ -815,6 +846,17 @@ export declare const createPut: <T = unknown>(url: string) => PutRequest<T>;
815
846
  export declare const createPatch: <T = unknown>(url: string) => PatchRequest<T>;
816
847
  /** Creates a DELETE request. */
817
848
  export declare const createDelete: <T = unknown>(url: string) => DeleteRequest<T>;
849
+ /**
850
+ * Creates a QUERY request ([RFC 10008](https://www.rfc-editor.org/rfc/rfc10008)): a safe, idempotent read like GET
851
+ * whose query travels in the body, so it can be as large and as structured as needed. The server must support the
852
+ * method and needs a `Content-Type`, which is set for you except for binary and stream bodies (use `withContentType()`).
853
+ *
854
+ * @example
855
+ * ```typescript
856
+ * const open = await create.query<Issue[]>("https://api.example.com/issues").withBody({ state: "open", labels: ["bug"] }).getJson();
857
+ * ```
858
+ */
859
+ export declare const createQuery: <T = unknown>(url: string) => QueryRequest<T>;
818
860
  /**
819
861
  * The entry point: one factory per HTTP method plus `api()` for configured instances.
820
862
  *
@@ -844,8 +886,10 @@ declare const create: {
844
886
  readonly delete: <T = unknown>(url: string) => DeleteRequest<T>;
845
887
  /** Alias of `delete`. */
846
888
  readonly del: <T = unknown>(url: string) => DeleteRequest<T>;
889
+ /** Creates a QUERY request (RFC 10008): a safe, idempotent read whose query is sent with `withBody()` — see {@link createQuery}. */
890
+ readonly query: <T = unknown>(url: string) => QueryRequest<T>;
847
891
  /** Creates an api instance — see {@link createApi}. */
848
892
  readonly api: typeof createApi;
849
893
  };
850
894
  //#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 };
895
+ 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"`. */
@@ -141,10 +163,13 @@ export declare class RequestError<TData = unknown> extends Error {
141
163
  export declare const isRequestError: (error: unknown) => error is RequestError;
142
164
  //#endregion
143
165
  //#region src/types.d.ts
144
- /** The HTTP methods a request can be created with. */
145
- type Method = "GET" | "HEAD" | "OPTIONS" | "DELETE" | "POST" | "PUT" | "PATCH";
166
+ /**
167
+ * The HTTP methods a request can be created with. `QUERY` ([RFC 10008](https://www.rfc-editor.org/rfc/rfc10008)) is a
168
+ * safe, idempotent read like `GET` whose query is sent in the body.
169
+ */
170
+ type Method = "GET" | "HEAD" | "OPTIONS" | "DELETE" | "POST" | "PUT" | "PATCH" | "QUERY";
146
171
  /** The HTTP methods that may carry a request body (`withBody` / `withGraphQL` are only available on these). */
147
- type BodyMethod = "POST" | "PUT" | "PATCH" | "DELETE";
172
+ type BodyMethod = "POST" | "PUT" | "PATCH" | "DELETE" | "QUERY";
148
173
  /** What `fetch` accepts as a body. */
149
174
  type FetchBody = NonNullable<RequestInit["body"]>;
150
175
  /** `"include"`, `"omit"` or `"same-origin"`. */
@@ -239,7 +264,7 @@ interface RetryConfig {
239
264
  delay?: number | ((context: RetryContext) => number) | undefined;
240
265
  /** Statuses that are retried. Default: `[408, 425, 429, 500, 502, 503, 504]`. Network errors and timeouts are retried by default. */
241
266
  statuses?: readonly number[] | undefined;
242
- /** Methods that are retried. Default: all. Use `["GET", "HEAD", "OPTIONS", "PUT", "DELETE"]` to retry idempotent requests only. */
267
+ /** Methods that are retried. Default: all. Use `["GET", "HEAD", "OPTIONS", "QUERY", "PUT", "DELETE"]` to retry idempotent requests only. */
243
268
  methods?: readonly Method[] | undefined;
244
269
  /**
245
270
  * Upper bound, in milliseconds, for the default backoff. A `Retry-After` header longer than this
@@ -487,16 +512,18 @@ export declare class HttpRequest<M extends Method = Method, T = unknown> {
487
512
  private _fail;
488
513
  private _set;
489
514
  /**
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.
515
+ * Sets several headers at once, from an object, a `Headers` object or `[name, value]` pairs — the forms `fetch`
516
+ * accepts. Names are case-insensitive (stored lower-case) and a later value replaces an earlier one. In an object,
517
+ * a `null` or `undefined` value removes the header, which is how a request drops a default set on its api.
492
518
  *
493
519
  * @example
494
520
  * ```typescript
495
521
  * request.withHeaders({ Accept: "application/json", "X-Request-Id": id });
522
+ * request.withHeaders(new Headers({ "X-Request-Id": id }));
496
523
  * api.get("/public").withHeaders({ Authorization: null }); // send this one unauthenticated
497
524
  * ```
498
525
  */
499
- withHeaders<H extends { [K in keyof H]: string | number | null | undefined; }>(headers: H): this;
526
+ withHeaders<H extends { [K in keyof H]: string | number | null | undefined; }>(headers: H | Headers | readonly (readonly [string, string])[]): this;
500
527
  /** Sets one header — see {@link withHeaders}. */
501
528
  withHeader(name: string, value: string | number | null | undefined): this;
502
529
  /**
@@ -627,7 +654,7 @@ export declare class HttpRequest<M extends Method = Method, T = unknown> {
627
654
  /** Adds an {@link ErrorInterceptor}, run once after the request has failed for good (after retries); it also receives the request, so it can replay it. */
628
655
  withErrorInterceptor(interceptor: ErrorInterceptor): this;
629
656
  /**
630
- * Sets the request body (POST, PUT, PATCH and DELETE only — a compile error elsewhere). Objects and
657
+ * Sets the request body (POST, PUT, PATCH, DELETE and QUERY only — a compile error elsewhere). Objects and
631
658
  * arrays are JSON-encoded; strings, `Blob`, `FormData`, `URLSearchParams`, `ArrayBuffer`, typed arrays
632
659
  * and `ReadableStream` are sent as-is. `Content-Type` is set to `application/json` / `text/plain`
633
660
  * unless already present, and removed for `FormData` (fetch must add the multipart boundary itself).
@@ -751,7 +778,7 @@ type ApiChainables = { [K in keyof HttpRequest as K extends ApiKeys ? K : never]
751
778
  * ```
752
779
  */
753
780
  interface ApiBuilder extends ApiChainables {
754
- withHeaders<H extends { [K in keyof H]: string | number | null | undefined; }>(headers: H): ApiBuilder;
781
+ withHeaders<H extends { [K in keyof H]: string | number | null | undefined; }>(headers: H | Headers | readonly (readonly [string, string])[]): ApiBuilder;
755
782
  withCookies<C extends { [K in keyof C]: string; }>(cookies: C): ApiBuilder;
756
783
  withQueryParams<P extends { [K in keyof P]: QueryValue; }>(params: P | URLSearchParams): ApiBuilder;
757
784
  /**
@@ -775,6 +802,8 @@ interface ApiBuilder extends ApiChainables {
775
802
  delete<T = unknown>(path?: string): HttpRequest<"DELETE", T>;
776
803
  /** Alias of `delete`. */
777
804
  del<T = unknown>(path?: string): HttpRequest<"DELETE", T>;
805
+ /** Creates a QUERY request (RFC 10008): a safe, idempotent read whose query is sent with `withBody()`. */
806
+ query<T = unknown>(path?: string): HttpRequest<"QUERY", T>;
778
807
  }
779
808
  /**
780
809
  * Creates an empty {@link ApiBuilder}. Configure it once, export it, and create every request through it.
@@ -797,9 +826,11 @@ export type PutRequest<T = unknown> = HttpRequest<"PUT", T>;
797
826
  export type PatchRequest<T = unknown> = HttpRequest<"PATCH", T>;
798
827
  /** A DELETE request. */
799
828
  export type DeleteRequest<T = unknown> = HttpRequest<"DELETE", T>;
829
+ /** A QUERY request (RFC 10008). */
830
+ export type QueryRequest<T = unknown> = HttpRequest<"QUERY", T>;
800
831
  /** Any request — the v1 name for {@link HttpRequest}. */
801
832
  export type BaseRequest<T = unknown> = HttpRequest<Method, T>;
802
- /** Any request that may carry a body — the v1 name for `HttpRequest<"POST" | "PUT" | "PATCH" | "DELETE">`. */
833
+ /** Any request that may carry a body — the v1 name for `HttpRequest<"POST" | "PUT" | "PATCH" | "DELETE" | "QUERY">`. */
803
834
  export type BodyRequest<T = unknown> = HttpRequest<BodyMethod, T>;
804
835
  /** Creates a GET request. `T` declares the JSON type the response is expected to have. */
805
836
  export declare const createGet: <T = unknown>(url: string) => GetRequest<T>;
@@ -815,6 +846,17 @@ export declare const createPut: <T = unknown>(url: string) => PutRequest<T>;
815
846
  export declare const createPatch: <T = unknown>(url: string) => PatchRequest<T>;
816
847
  /** Creates a DELETE request. */
817
848
  export declare const createDelete: <T = unknown>(url: string) => DeleteRequest<T>;
849
+ /**
850
+ * Creates a QUERY request ([RFC 10008](https://www.rfc-editor.org/rfc/rfc10008)): a safe, idempotent read like GET
851
+ * whose query travels in the body, so it can be as large and as structured as needed. The server must support the
852
+ * method and needs a `Content-Type`, which is set for you except for binary and stream bodies (use `withContentType()`).
853
+ *
854
+ * @example
855
+ * ```typescript
856
+ * const open = await create.query<Issue[]>("https://api.example.com/issues").withBody({ state: "open", labels: ["bug"] }).getJson();
857
+ * ```
858
+ */
859
+ export declare const createQuery: <T = unknown>(url: string) => QueryRequest<T>;
818
860
  /**
819
861
  * The entry point: one factory per HTTP method plus `api()` for configured instances.
820
862
  *
@@ -844,8 +886,10 @@ declare const create: {
844
886
  readonly delete: <T = unknown>(url: string) => DeleteRequest<T>;
845
887
  /** Alias of `delete`. */
846
888
  readonly del: <T = unknown>(url: string) => DeleteRequest<T>;
889
+ /** Creates a QUERY request (RFC 10008): a safe, idempotent read whose query is sent with `withBody()` — see {@link createQuery}. */
890
+ readonly query: <T = unknown>(url: string) => QueryRequest<T>;
847
891
  /** Creates an api instance — see {@link createApi}. */
848
892
  readonly api: typeof createApi;
849
893
  };
850
894
  //#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 };
895
+ 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);
@@ -674,7 +684,8 @@ const METHODS = [
674
684
  "POST",
675
685
  "PUT",
676
686
  "PATCH",
677
- "DELETE"
687
+ "DELETE",
688
+ "QUERY"
678
689
  ];
679
690
  let installed = false;
680
691
  function createApi() {
@@ -701,6 +712,7 @@ const createPost = (url) => new HttpRequest("POST", url);
701
712
  const createPut = (url) => new HttpRequest("PUT", url);
702
713
  const createPatch = (url) => new HttpRequest("PATCH", url);
703
714
  const createDelete = (url) => new HttpRequest("DELETE", url);
715
+ const createQuery = (url) => new HttpRequest("QUERY", url);
704
716
  const create = {
705
717
  get: createGet,
706
718
  head: createHead,
@@ -710,8 +722,9 @@ const create = {
710
722
  patch: createPatch,
711
723
  delete: createDelete,
712
724
  del: createDelete,
725
+ query: createQuery,
713
726
  api: createApi
714
727
  };
715
728
 
716
729
  //#endregion
717
- export { HttpRequest, RequestError, ResponseWrapper, createApi, createDelete, createGet, createHead, createOptions, createPatch, createPost, createPut, create as default, isRequestError };
730
+ export { HttpRequest, RequestError, ResponseWrapper, createApi, createDelete, createGet, createHead, createOptions, createPatch, createPost, createPut, createQuery, create as default, isRequestError };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-request",
3
- "version": "2.0.1",
3
+ "version": "2.2.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,