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 +13 -0
- package/README.md +126 -98
- package/dist/index.cjs +16 -2
- package/dist/index.d.cts +55 -11
- package/dist/index.d.ts +55 -11
- package/dist/index.js +16 -3
- package/package.json +1 -1
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)
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
151
|
-
|
|
152
|
-
([
|
|
153
|
-
|
|
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
|
|
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
|
|
171
|
-
|
|
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
|
|
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
|
|
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)
|
|
201
|
-
| `"NETWORK"` | `fetch` itself failed: DNS, connection refused, CORS, offline
|
|
202
|
-
| `"TIMEOUT"` | `withTimeout()` fired, or a signal from `AbortSignal.timeout()` aborted
|
|
203
|
-
| `"ABORTED"` | A signal passed with `withSignal()` / `withAbortController()` aborted
|
|
204
|
-
| `"PARSE"` | The body could not be read or parsed, or a `getData` selector threw
|
|
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)
|
|
206
|
-
| `"INTERCEPTOR"` |
|
|
207
|
-
| `"GRAPHQL"` | The GraphQL response had `errors` and `throwOnError` was on
|
|
208
|
-
|
|
209
|
-
`url` and `method` are always set. `data` is `body` parsed as JSON
|
|
210
|
-
error details
|
|
211
|
-
left unread on `error.response`; a longer chunked or compressed one is cut off
|
|
212
|
-
`undefined`. `isTimeout` / `isAborted` are shorthands for the two codes. In Node.js
|
|
213
|
-
URL is a `"NETWORK"` failure, because `fetch` there has no page to resolve it against.
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
362
|
-
`Response` returned by a request interceptor skips the network
|
|
363
|
-
interceptors, URL/header validation and the CSRF header
|
|
364
|
-
interceptors run like for a fetched one; `withTimeout()` does not apply to
|
|
365
|
-
response interceptor that throws fails the request with code `"INTERCEPTOR"`;
|
|
366
|
-
interceptor that throws replaces the error
|
|
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)
|
|
371
|
-
effect
|
|
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
|
|
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
|
-
|
|
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
|
|
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()`
|
|
427
|
-
undici `Agent` for
|
|
428
|
-
|
|
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:
|
|
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
|
|
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
|
|
461
|
-
|
|
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
|
|
468
|
-
|
|
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>()
|
|
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
|
|
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
|
|
506
|
-
|
|
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
|
|
516
|
-
|
|
|
517
|
-
| everything (`import
|
|
518
|
-
| `import { createGet }` only
|
|
519
|
-
| `import { RequestError }` only
|
|
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
|
|
534
|
-
this README), the test suite at 100% coverage, the build, package linting and the size
|
|
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
|
-
/**
|
|
145
|
-
|
|
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
|
|
491
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
145
|
-
|
|
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
|
|
491
|
-
*
|
|
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
|
|
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
|
|
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,
|