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