create-request 2.2.0 → 2.2.1
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 +15 -0
- package/README.md +26 -5
- package/dist/index.cjs +20 -24
- package/dist/index.d.cts +41 -24
- package/dist/index.d.ts +41 -24
- package/dist/index.js +20 -24
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.2.1 — 2026-10-10
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- `getJson()`, `getText()`, `getBlob()`, `getArrayBuffer()`, `getFormData()`, `getData()` and `getResult()` now read the body as part of each attempt, so a failure while reading it goes through the retry policy and the error interceptors like a failure before the response. Before, the body was read after both had finished: a body that stalled past `withTimeout()` was not retried, and error interceptors never saw a timeout or abort during the read, a connection dropped mid-body, invalid JSON, a schema mismatch or GraphQL errors. The default policy retries only the timeout; a custom `shouldRetry` now also receives the other errors (with `status` set). A `ResponseWrapper` returned by an error interceptor is read by the same reader, and if that read fails, the next interceptor receives the error. `getResponse()` and `getBody()` are unchanged: they resolve before the body is read.
|
|
8
|
+
- A `getData()` selector that returns a rejected promise is reported as a `"PARSE"` error, like a selector that throws; it used to reject with the raw error.
|
|
9
|
+
- A Node.js stream (`fs.createReadStream()`, a `Readable`) or another async iterable passed to `withBody()` is sent as a stream body (`duplex: "half"`, never retried), the way undici's `fetch` expects. Before, it was JSON-encoded: `withBody(fs.createReadStream(file))` sent the stream object's fields, local file path included, as `application/json`, and an async generator sent `{}`.
|
|
10
|
+
- `getBody()` now has the type `fetch` gives `response.body` (`Response["body"]`), so piping it through a `TextDecoderStream`, the usual way to read server-sent events or LLM output, compiles with TypeScript 5.9 and later. Its old type, `ReadableStream<Uint8Array>`, allows `SharedArrayBuffer`-backed chunks, which `TextDecoderStream` in the TypeScript 5.9 DOM lib rejects. In a Node-only project (`@types/node` without the DOM lib) the type is `@types/node`'s, as for `fetch`.
|
|
11
|
+
|
|
12
|
+
### Internal
|
|
13
|
+
|
|
14
|
+
- The full import is 16 B smaller (min+gzip) than in 2.2.0 despite both fixes: class fields are declared instead of being emitted as parameter properties, and the schema check is shorter.
|
|
15
|
+
- CI fails on packaging problems found by attw or publint (`npm run pack:check`); the build only warned about them.
|
|
16
|
+
- The release workflow runs the full check and packs the tarball in a job without credentials. A separate job in the `npm` environment publishes that tarball with trusted publishing and provenance; it checks out nothing and runs no scripts, so no dependency runs while an npm token can be minted. A third job creates the GitHub release. Actions are pinned to commit SHAs and kept current by Dependabot.
|
|
17
|
+
|
|
3
18
|
## 2.2.0 — 2026-10-09
|
|
4
19
|
|
|
5
20
|
### Added
|
package/README.md
CHANGED
|
@@ -141,7 +141,7 @@ create
|
|
|
141
141
|
.withQueryParams({ page: 2, tags: ["a", "b"], since: new Date() })
|
|
142
142
|
.withQueryParam("q", "search term")
|
|
143
143
|
// body (POST, PUT, PATCH, DELETE, QUERY only): objects → JSON, strings → text/plain,
|
|
144
|
-
// FormData / Blob / URLSearchParams / ArrayBuffer / typed arrays /
|
|
144
|
+
// FormData / Blob / URLSearchParams / ArrayBuffer / typed arrays / streams → sent as-is
|
|
145
145
|
.withBody({ name: "Ada" })
|
|
146
146
|
// resilience
|
|
147
147
|
.withTimeout(5000) // per attempt; covers the response and the body read
|
|
@@ -328,7 +328,10 @@ How retries behave:
|
|
|
328
328
|
- Aborted requests and requests with a stream body are never retried, whatever the policy. That
|
|
329
329
|
includes a timeout from your own `AbortSignal.timeout()` signal, which stays aborted (only
|
|
330
330
|
`withTimeout()` timeouts are retried).
|
|
331
|
-
- The timeout applies to each attempt
|
|
331
|
+
- The timeout applies to each attempt. With `getJson()`, `getText()`, `getData()` and the other
|
|
332
|
+
readers an attempt includes reading the body, so a body that stalls past the timeout is
|
|
333
|
+
retried too; `getResponse()` and `getBody()` hand you the body unread.
|
|
334
|
+
- Error interceptors run once, after the last attempt.
|
|
332
335
|
|
|
333
336
|
`onRetry(callback)` also exists as a method; it does not enable retries by itself.
|
|
334
337
|
|
|
@@ -392,6 +395,11 @@ it. A request or response interceptor that throws fails the request with code `"
|
|
|
392
395
|
an error interceptor that throws replaces the error with an `"INTERCEPTOR"` one (a thrown
|
|
393
396
|
`RequestError` is kept as-is).
|
|
394
397
|
|
|
398
|
+
Error interceptors also see failures while `getJson()`, `getText()` and the other readers read
|
|
399
|
+
the body: invalid JSON (`"PARSE"`), a schema mismatch (`"VALIDATION"`), GraphQL errors
|
|
400
|
+
(`"GRAPHQL"`) and a timeout or abort during the read. A `ResponseWrapper` they return is read the
|
|
401
|
+
same way, so `getJson()` parses the recovered response.
|
|
402
|
+
|
|
395
403
|
## Schema validation
|
|
396
404
|
|
|
397
405
|
Pass any [Standard Schema](https://standardschema.dev) (zod 3.24+, valibot 1+, arktype 2+,
|
|
@@ -445,8 +453,21 @@ for (;;) {
|
|
|
445
453
|
}
|
|
446
454
|
```
|
|
447
455
|
|
|
456
|
+
For text streams (server-sent events, NDJSON, LLM output), pipe the body through a
|
|
457
|
+
`TextDecoderStream`:
|
|
458
|
+
|
|
459
|
+
```typescript
|
|
460
|
+
const stream = await api.post("/chat").withBody({ prompt: "Hello" }).getBody();
|
|
461
|
+
const reader = stream!.pipeThrough(new TextDecoderStream()).getReader();
|
|
462
|
+
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
|
|
463
|
+
render(chunk.value);
|
|
464
|
+
}
|
|
465
|
+
```
|
|
466
|
+
|
|
448
467
|
Request bodies can be streams too (`withBody(readableStream)`), sent with `duplex: "half"`.
|
|
449
|
-
Node.js and Chromium support that; Firefox and Safari do not.
|
|
468
|
+
Node.js and Chromium support that; Firefox and Safari do not. In Node.js a Node stream or any
|
|
469
|
+
async iterable works the same way, so `withBody(fs.createReadStream(path))` uploads a file (set
|
|
470
|
+
its `Content-Type` yourself). A stream body can be sent only once and is never retried.
|
|
450
471
|
Upload _progress_ is not something `fetch` exposes in browsers, so there is no API for it.
|
|
451
472
|
|
|
452
473
|
## Testing and custom fetch
|
|
@@ -542,8 +563,8 @@ Measured with `size-limit` on the published build of this version (`npm run size
|
|
|
542
563
|
|
|
543
564
|
| Import | min + gzip | min + brotli |
|
|
544
565
|
| ------------------------------ | ---------: | -----------: |
|
|
545
|
-
| everything (`import * as …`) | 4.
|
|
546
|
-
| `import { createGet }` only | 4.
|
|
566
|
+
| everything (`import * as …`) | 4.93 KB | 4.47 KB |
|
|
567
|
+
| `import { createGet }` only | 4.30 KB | |
|
|
547
568
|
| `import { RequestError }` only | 0.25 KB | |
|
|
548
569
|
|
|
549
570
|
The package is one module with no side effects, so bundlers drop whatever you do not import. The
|
package/dist/index.cjs
CHANGED
|
@@ -34,7 +34,7 @@ const messageOf = (error) => error instanceof Error ? error.message : String(err
|
|
|
34
34
|
|
|
35
35
|
//#endregion
|
|
36
36
|
//#region src/schema.ts
|
|
37
|
-
const isSchema = (value) =>
|
|
37
|
+
const isSchema = (value) => "~standard" in Object(value);
|
|
38
38
|
|
|
39
39
|
//#endregion
|
|
40
40
|
//#region src/utils.ts
|
|
@@ -124,9 +124,6 @@ const retryAfter = (response) => {
|
|
|
124
124
|
//#endregion
|
|
125
125
|
//#region src/response.ts
|
|
126
126
|
var ResponseWrapper = class {
|
|
127
|
-
raw;
|
|
128
|
-
url;
|
|
129
|
-
method;
|
|
130
127
|
constructor(raw, url = "", method = "GET") {
|
|
131
128
|
this.raw = raw;
|
|
132
129
|
this.url = url;
|
|
@@ -240,7 +237,7 @@ var ResponseWrapper = class {
|
|
|
240
237
|
const data = await this.getJson(schema);
|
|
241
238
|
if (!select) return data;
|
|
242
239
|
try {
|
|
243
|
-
return select(data);
|
|
240
|
+
return await select(data);
|
|
244
241
|
} catch (e) {
|
|
245
242
|
throw this._err(`Selector failed: ${messageOf(e)}`, "PARSE", { cause: e });
|
|
246
243
|
}
|
|
@@ -259,7 +256,6 @@ const RETRY_STATUSES = [
|
|
|
259
256
|
504
|
|
260
257
|
];
|
|
261
258
|
var HttpRequest = class HttpRequest {
|
|
262
|
-
method;
|
|
263
259
|
_o = {
|
|
264
260
|
init: {},
|
|
265
261
|
headers: {},
|
|
@@ -269,7 +265,6 @@ var HttpRequest = class HttpRequest {
|
|
|
269
265
|
res: [],
|
|
270
266
|
err: []
|
|
271
267
|
};
|
|
272
|
-
_url;
|
|
273
268
|
constructor(method, url) {
|
|
274
269
|
this.method = method;
|
|
275
270
|
this._url = url;
|
|
@@ -417,8 +412,8 @@ var HttpRequest = class HttpRequest {
|
|
|
417
412
|
withBody(body) {
|
|
418
413
|
const o = this._o;
|
|
419
414
|
const tag = Object.prototype.toString.call(body).slice(8, -1);
|
|
420
|
-
|
|
421
|
-
o.stream
|
|
415
|
+
o.stream = Symbol.asyncIterator in Object(body) || tag === "ReadableStream";
|
|
416
|
+
const raw = o.stream || ArrayBuffer.isView(body) || /^(String|Blob|File|FormData|URLSearchParams|ArrayBuffer)$/.test(tag);
|
|
422
417
|
if (raw) o.body = body;
|
|
423
418
|
else try {
|
|
424
419
|
o.body = JSON.stringify(body);
|
|
@@ -451,12 +446,15 @@ var HttpRequest = class HttpRequest {
|
|
|
451
446
|
};
|
|
452
447
|
return copy;
|
|
453
448
|
}
|
|
454
|
-
|
|
449
|
+
getResponse() {
|
|
450
|
+
return this._run((response) => response);
|
|
451
|
+
}
|
|
452
|
+
async _run(read) {
|
|
455
453
|
const o = this._o;
|
|
456
454
|
const retry = o.retry;
|
|
457
455
|
let error;
|
|
458
456
|
for (let attempt = 0;; attempt++) try {
|
|
459
|
-
return await this._attempt();
|
|
457
|
+
return await read(await this._attempt());
|
|
460
458
|
} catch (e) {
|
|
461
459
|
error = e instanceof RequestError ? e : this._fail(`Unexpected error: ${messageOf(e)}`, "NETWORK", { cause: e });
|
|
462
460
|
const context = {
|
|
@@ -486,7 +484,7 @@ var HttpRequest = class HttpRequest {
|
|
|
486
484
|
}
|
|
487
485
|
for (const interceptor of o.err) try {
|
|
488
486
|
const result = await interceptor(error, this);
|
|
489
|
-
if (result instanceof ResponseWrapper) return result;
|
|
487
|
+
if (result instanceof ResponseWrapper) return await read(result);
|
|
490
488
|
if (result) error = result;
|
|
491
489
|
} catch (e) {
|
|
492
490
|
error = e instanceof RequestError ? e : this._fail(`Error interceptor failed: ${messageOf(e)}`, "INTERCEPTOR", {
|
|
@@ -618,25 +616,25 @@ var HttpRequest = class HttpRequest {
|
|
|
618
616
|
if (token && !(header in config.headers)) config.headers[header] = token;
|
|
619
617
|
}
|
|
620
618
|
getJson(schema) {
|
|
621
|
-
return this.
|
|
619
|
+
return this._run((response) => response.getJson(schema));
|
|
622
620
|
}
|
|
623
621
|
getText() {
|
|
624
|
-
return this.
|
|
622
|
+
return this._run((response) => response.getText());
|
|
625
623
|
}
|
|
626
624
|
getBlob() {
|
|
627
|
-
return this.
|
|
625
|
+
return this._run((response) => response.getBlob());
|
|
628
626
|
}
|
|
629
627
|
getArrayBuffer() {
|
|
630
|
-
return this.
|
|
628
|
+
return this._run((response) => response.getArrayBuffer());
|
|
631
629
|
}
|
|
632
630
|
getFormData() {
|
|
633
|
-
return this.
|
|
631
|
+
return this._run((response) => response.getFormData());
|
|
634
632
|
}
|
|
635
633
|
getBody() {
|
|
636
|
-
return this.
|
|
634
|
+
return this._run((response) => response.getBody());
|
|
637
635
|
}
|
|
638
636
|
getData(schemaOrSelector, selector) {
|
|
639
|
-
return this.
|
|
637
|
+
return this._run((response) => response.getData(schemaOrSelector, selector));
|
|
640
638
|
}
|
|
641
639
|
async getResult(schema) {
|
|
642
640
|
try {
|
|
@@ -657,11 +655,9 @@ var HttpRequest = class HttpRequest {
|
|
|
657
655
|
//#region src/api.ts
|
|
658
656
|
const ABSOLUTE = /^([a-z][a-z0-9+.-]*:)?\/\//i;
|
|
659
657
|
var Api = class Api {
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
this._base = _base;
|
|
664
|
-
this._defaults = _defaults;
|
|
658
|
+
constructor(base = "", defaults = []) {
|
|
659
|
+
this._base = base;
|
|
660
|
+
this._defaults = defaults;
|
|
665
661
|
}
|
|
666
662
|
_with(fn) {
|
|
667
663
|
fn(new HttpRequest("GET", ""));
|
package/dist/index.d.cts
CHANGED
|
@@ -189,7 +189,8 @@ type PriorityHint = "auto" | "high" | "low";
|
|
|
189
189
|
*
|
|
190
190
|
* - `string` → sent as-is (`Content-Type: text/plain` unless you set one)
|
|
191
191
|
* - `Blob` / `File`, `FormData`, `URLSearchParams` → sent as-is, `fetch` sets the matching `Content-Type`
|
|
192
|
-
* - `ArrayBuffer`, typed arrays, `ReadableStream`
|
|
192
|
+
* - `ArrayBuffer`, typed arrays, `ReadableStream` and, in Node.js, Node streams and other async iterables → sent as-is,
|
|
193
|
+
* no `Content-Type` unless you set one
|
|
193
194
|
* - any other JSON-serialisable object or array → `JSON.stringify`-ed (`Content-Type: application/json` unless you set one)
|
|
194
195
|
*
|
|
195
196
|
* @example
|
|
@@ -240,6 +241,8 @@ interface RetryContext {
|
|
|
240
241
|
* 408, 425, 429, 500, 502, 503 or 504, with exponential backoff (300 ms, 600 ms, 1.2 s, … plus
|
|
241
242
|
* up to 100 ms of jitter, capped at `maxDelay`). A `Retry-After` header is honoured when present.
|
|
242
243
|
* Aborted requests and requests with a `ReadableStream` body are never retried, whatever the policy.
|
|
244
|
+
* With `getJson()`, `getText()` and the other readers an attempt includes reading the body, so a timeout
|
|
245
|
+
* during the read is retried too (`getResponse()` and `getBody()` hand the body over unread).
|
|
243
246
|
*
|
|
244
247
|
* @example
|
|
245
248
|
* ```typescript
|
|
@@ -271,7 +274,10 @@ interface RetryConfig {
|
|
|
271
274
|
* cancels the retry so you can react to it yourself. Default: `30000`.
|
|
272
275
|
*/
|
|
273
276
|
maxDelay?: number | undefined;
|
|
274
|
-
/**
|
|
277
|
+
/**
|
|
278
|
+
* Full override of the retry decision (`statuses` and `methods` are ignored). Aborts and stream bodies still never retry.
|
|
279
|
+
* It also receives the errors raised while a reader reads the body (`PARSE`, `VALIDATION`, `GRAPHQL`, with `status` set).
|
|
280
|
+
*/
|
|
275
281
|
shouldRetry?: ((context: RetryContext) => boolean | Promise<boolean>) | undefined;
|
|
276
282
|
/** Called before every retry, after the delay has been computed. Awaited if it returns a promise. */
|
|
277
283
|
onRetry?: ((context: RetryContext & {
|
|
@@ -330,6 +336,10 @@ type ResponseInterceptor = (response: ResponseWrapper, request: HttpRequest) =>
|
|
|
330
336
|
* Return nothing to keep the error, another {@link RequestError} to replace it, or a
|
|
331
337
|
* {@link ResponseWrapper} to recover — typically by replaying `request.clone()`. Throwing replaces the error as well.
|
|
332
338
|
*
|
|
339
|
+
* With `getJson()`, `getText()` and the other readers, a failure while reading the body (`PARSE`, `VALIDATION`,
|
|
340
|
+
* `GRAPHQL`, or a timeout or abort during the read) also reaches error interceptors, and a recovered
|
|
341
|
+
* `ResponseWrapper` is read the same way; if that read fails, the next interceptor receives the new error.
|
|
342
|
+
*
|
|
333
343
|
* @example
|
|
334
344
|
* ```typescript
|
|
335
345
|
* const replayed = new WeakSet<HttpRequest>();
|
|
@@ -391,23 +401,17 @@ type RequestResult<T> = {
|
|
|
391
401
|
* ```
|
|
392
402
|
*/
|
|
393
403
|
export declare class ResponseWrapper<T = unknown> {
|
|
404
|
+
private _bin?;
|
|
405
|
+
private _text?;
|
|
406
|
+
private _json?;
|
|
394
407
|
/** The underlying `Response`. Read its body through this wrapper's methods, or take it over with `getBody()`. */
|
|
395
408
|
readonly raw: Response;
|
|
396
409
|
/** The URL that was requested, including the query string (after interceptors). */
|
|
397
410
|
readonly url: string;
|
|
398
411
|
/** The HTTP method that was used. */
|
|
399
412
|
readonly method: Method;
|
|
400
|
-
private _bin?;
|
|
401
|
-
private _text?;
|
|
402
|
-
private _json?;
|
|
403
413
|
/** Wraps a `Response` — useful to hand a synthetic response to an error interceptor. */
|
|
404
|
-
constructor(
|
|
405
|
-
/** The underlying `Response`. Read its body through this wrapper's methods, or take it over with `getBody()`. */
|
|
406
|
-
raw: Response,
|
|
407
|
-
/** The URL that was requested, including the query string (after interceptors). */
|
|
408
|
-
url?: string,
|
|
409
|
-
/** The HTTP method that was used. */
|
|
410
|
-
method?: Method);
|
|
414
|
+
constructor(raw: Response, url?: string, method?: Method);
|
|
411
415
|
/** HTTP status code (`200`, `404`, …). */
|
|
412
416
|
get status(): number;
|
|
413
417
|
/** HTTP status text (`"OK"`, `"Not Found"`, …); empty over HTTP/2. */
|
|
@@ -433,6 +437,8 @@ export declare class ResponseWrapper<T = unknown> {
|
|
|
433
437
|
* The raw body stream, for streaming consumption (downloads with progress, SSE/NDJSON, LLM output…).
|
|
434
438
|
* Unlike the other readers it is not buffered: the body can be read once, and only if no other reader ran.
|
|
435
439
|
* Taking the stream also ends the request's `withTimeout()` deadline — from here on the stream is yours.
|
|
440
|
+
* It has the type `fetch` gives `response.body` in your environment (DOM lib or `@types/node`), so it pipes
|
|
441
|
+
* through `TextDecoderStream` and other web streams like that one does.
|
|
436
442
|
*
|
|
437
443
|
* @example
|
|
438
444
|
* ```typescript
|
|
@@ -440,7 +446,7 @@ export declare class ResponseWrapper<T = unknown> {
|
|
|
440
446
|
* for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) process(chunk.value);
|
|
441
447
|
* ```
|
|
442
448
|
*/
|
|
443
|
-
getBody():
|
|
449
|
+
getBody(): Response["body"];
|
|
444
450
|
/**
|
|
445
451
|
* The body parsed as JSON. An empty body (`204`, `Content-Length: 0`, whitespace) yields `null` —
|
|
446
452
|
* declare it in the type (`getJson<User | null>()`) for endpoints that may return nothing.
|
|
@@ -460,7 +466,8 @@ export declare class ResponseWrapper<T = unknown> {
|
|
|
460
466
|
/**
|
|
461
467
|
* `getJson()` followed by a selector, so you can pick the part you need in one call. The selector
|
|
462
468
|
* receives `T` (declare it on the request: `api.get<Page>()`), or pass both type arguments explicitly.
|
|
463
|
-
*
|
|
469
|
+
* A selector that throws (or returns a promise that rejects) is reported as a `RequestError` with code `"PARSE"`.
|
|
470
|
+
* A schema can be validated first.
|
|
464
471
|
*
|
|
465
472
|
* @example
|
|
466
473
|
* ```typescript
|
|
@@ -504,9 +511,7 @@ export declare class HttpRequest<M extends Method = Method, T = unknown> {
|
|
|
504
511
|
readonly method: M;
|
|
505
512
|
private readonly _url;
|
|
506
513
|
/** Prefer `create.get(url)`, `create.post(url)`, … or an api instance to construct requests. */
|
|
507
|
-
constructor(
|
|
508
|
-
/** The HTTP method. */
|
|
509
|
-
method: M, url: string);
|
|
514
|
+
constructor(method: M, url: string);
|
|
510
515
|
/** The URL this request will be sent to, including the query string. */
|
|
511
516
|
get url(): string;
|
|
512
517
|
private _fail;
|
|
@@ -651,14 +656,18 @@ export declare class HttpRequest<M extends Method = Method, T = unknown> {
|
|
|
651
656
|
withRequestInterceptor(interceptor: RequestInterceptor): this;
|
|
652
657
|
/** Adds a {@link ResponseInterceptor}, run after every successful response in registration order (it also receives the request). */
|
|
653
658
|
withResponseInterceptor(interceptor: ResponseInterceptor): this;
|
|
654
|
-
/**
|
|
659
|
+
/**
|
|
660
|
+
* Adds an {@link ErrorInterceptor}, run once after the request has failed for good (after retries, and including a failure while
|
|
661
|
+
* `getJson()`, `getText()`, … read the body); it also receives the request, so it can replay it.
|
|
662
|
+
*/
|
|
655
663
|
withErrorInterceptor(interceptor: ErrorInterceptor): this;
|
|
656
664
|
/**
|
|
657
665
|
* Sets the request body (POST, PUT, PATCH, DELETE and QUERY only — a compile error elsewhere). Objects and
|
|
658
|
-
* arrays are JSON-encoded; strings, `Blob`, `FormData`, `URLSearchParams`, `ArrayBuffer`, typed arrays
|
|
659
|
-
*
|
|
660
|
-
*
|
|
661
|
-
* Stream bodies are sent with `duplex: "half"`
|
|
666
|
+
* arrays are JSON-encoded; strings, `Blob`, `FormData`, `URLSearchParams`, `ArrayBuffer`, typed arrays,
|
|
667
|
+
* `ReadableStream` and, in Node.js, Node streams (`fs.createReadStream()`) and other async iterables are sent
|
|
668
|
+
* as-is. `Content-Type` is set to `application/json` / `text/plain` unless already present, and removed for
|
|
669
|
+
* `FormData` (fetch must add the multipart boundary itself). Stream bodies are sent with `duplex: "half"`
|
|
670
|
+
* (Chromium and Node.js only), can be sent only once and are never retried.
|
|
662
671
|
*
|
|
663
672
|
* @example
|
|
664
673
|
* ```typescript
|
|
@@ -682,7 +691,7 @@ export declare class HttpRequest<M extends Method = Method, T = unknown> {
|
|
|
682
691
|
withGraphQL(this: HttpRequest<BodyMethod, T>, query: string, variables?: object, options?: GraphQLOptions): this;
|
|
683
692
|
/**
|
|
684
693
|
* An independent copy of this request, so a configured request can serve as a template.
|
|
685
|
-
* Interceptors, signals and body objects are shared by reference (a
|
|
694
|
+
* Interceptors, signals and body objects are shared by reference (a stream body can only be sent once).
|
|
686
695
|
*
|
|
687
696
|
* @example
|
|
688
697
|
* ```typescript
|
|
@@ -696,8 +705,16 @@ export declare class HttpRequest<M extends Method = Method, T = unknown> {
|
|
|
696
705
|
* Sends the request (with retries, if configured) and resolves with the {@link ResponseWrapper} of a
|
|
697
706
|
* successful (2xx or opaque) response. Any failure — non-2xx status, network error, timeout, abort,
|
|
698
707
|
* interceptor error — rejects with a {@link RequestError}; use `getResult()` for a non-throwing variant.
|
|
708
|
+
* It resolves before the body is read, so reading it is up to you and happens after the retries and error
|
|
709
|
+
* interceptors; `getJson()`, `getText()` and the other readers read it as part of each attempt instead.
|
|
699
710
|
*/
|
|
700
711
|
getResponse(): Promise<ResponseWrapper<T>>;
|
|
712
|
+
/**
|
|
713
|
+
* Runs the attempts, the retry policy and the error interceptors around `read`, which receives each response — so a
|
|
714
|
+
* failure while reading the body (a timeout during the read, invalid JSON, a schema or GraphQL error) is retried and
|
|
715
|
+
* intercepted like a failure before it. A response recovered by an error interceptor goes through `read` as well.
|
|
716
|
+
*/
|
|
717
|
+
private _run;
|
|
701
718
|
private _retriable;
|
|
702
719
|
private _attempt;
|
|
703
720
|
/** Attaches the CSRF token (see {@link withCsrf}) to `config.headers` when the final URL qualifies. */
|
|
@@ -723,7 +740,7 @@ export declare class HttpRequest<M extends Method = Method, T = unknown> {
|
|
|
723
740
|
/** Sends the request and parses the body as `FormData`. */
|
|
724
741
|
getFormData(): Promise<FormData>;
|
|
725
742
|
/** Sends the request and returns the raw body stream — see {@link ResponseWrapper.getBody}. */
|
|
726
|
-
getBody(): Promise<
|
|
743
|
+
getBody(): Promise<Response["body"]>;
|
|
727
744
|
/**
|
|
728
745
|
* Sends the request, parses JSON and applies a selector — see {@link ResponseWrapper.getData}.
|
|
729
746
|
*
|
package/dist/index.d.ts
CHANGED
|
@@ -189,7 +189,8 @@ type PriorityHint = "auto" | "high" | "low";
|
|
|
189
189
|
*
|
|
190
190
|
* - `string` → sent as-is (`Content-Type: text/plain` unless you set one)
|
|
191
191
|
* - `Blob` / `File`, `FormData`, `URLSearchParams` → sent as-is, `fetch` sets the matching `Content-Type`
|
|
192
|
-
* - `ArrayBuffer`, typed arrays, `ReadableStream`
|
|
192
|
+
* - `ArrayBuffer`, typed arrays, `ReadableStream` and, in Node.js, Node streams and other async iterables → sent as-is,
|
|
193
|
+
* no `Content-Type` unless you set one
|
|
193
194
|
* - any other JSON-serialisable object or array → `JSON.stringify`-ed (`Content-Type: application/json` unless you set one)
|
|
194
195
|
*
|
|
195
196
|
* @example
|
|
@@ -240,6 +241,8 @@ interface RetryContext {
|
|
|
240
241
|
* 408, 425, 429, 500, 502, 503 or 504, with exponential backoff (300 ms, 600 ms, 1.2 s, … plus
|
|
241
242
|
* up to 100 ms of jitter, capped at `maxDelay`). A `Retry-After` header is honoured when present.
|
|
242
243
|
* Aborted requests and requests with a `ReadableStream` body are never retried, whatever the policy.
|
|
244
|
+
* With `getJson()`, `getText()` and the other readers an attempt includes reading the body, so a timeout
|
|
245
|
+
* during the read is retried too (`getResponse()` and `getBody()` hand the body over unread).
|
|
243
246
|
*
|
|
244
247
|
* @example
|
|
245
248
|
* ```typescript
|
|
@@ -271,7 +274,10 @@ interface RetryConfig {
|
|
|
271
274
|
* cancels the retry so you can react to it yourself. Default: `30000`.
|
|
272
275
|
*/
|
|
273
276
|
maxDelay?: number | undefined;
|
|
274
|
-
/**
|
|
277
|
+
/**
|
|
278
|
+
* Full override of the retry decision (`statuses` and `methods` are ignored). Aborts and stream bodies still never retry.
|
|
279
|
+
* It also receives the errors raised while a reader reads the body (`PARSE`, `VALIDATION`, `GRAPHQL`, with `status` set).
|
|
280
|
+
*/
|
|
275
281
|
shouldRetry?: ((context: RetryContext) => boolean | Promise<boolean>) | undefined;
|
|
276
282
|
/** Called before every retry, after the delay has been computed. Awaited if it returns a promise. */
|
|
277
283
|
onRetry?: ((context: RetryContext & {
|
|
@@ -330,6 +336,10 @@ type ResponseInterceptor = (response: ResponseWrapper, request: HttpRequest) =>
|
|
|
330
336
|
* Return nothing to keep the error, another {@link RequestError} to replace it, or a
|
|
331
337
|
* {@link ResponseWrapper} to recover — typically by replaying `request.clone()`. Throwing replaces the error as well.
|
|
332
338
|
*
|
|
339
|
+
* With `getJson()`, `getText()` and the other readers, a failure while reading the body (`PARSE`, `VALIDATION`,
|
|
340
|
+
* `GRAPHQL`, or a timeout or abort during the read) also reaches error interceptors, and a recovered
|
|
341
|
+
* `ResponseWrapper` is read the same way; if that read fails, the next interceptor receives the new error.
|
|
342
|
+
*
|
|
333
343
|
* @example
|
|
334
344
|
* ```typescript
|
|
335
345
|
* const replayed = new WeakSet<HttpRequest>();
|
|
@@ -391,23 +401,17 @@ type RequestResult<T> = {
|
|
|
391
401
|
* ```
|
|
392
402
|
*/
|
|
393
403
|
export declare class ResponseWrapper<T = unknown> {
|
|
404
|
+
private _bin?;
|
|
405
|
+
private _text?;
|
|
406
|
+
private _json?;
|
|
394
407
|
/** The underlying `Response`. Read its body through this wrapper's methods, or take it over with `getBody()`. */
|
|
395
408
|
readonly raw: Response;
|
|
396
409
|
/** The URL that was requested, including the query string (after interceptors). */
|
|
397
410
|
readonly url: string;
|
|
398
411
|
/** The HTTP method that was used. */
|
|
399
412
|
readonly method: Method;
|
|
400
|
-
private _bin?;
|
|
401
|
-
private _text?;
|
|
402
|
-
private _json?;
|
|
403
413
|
/** Wraps a `Response` — useful to hand a synthetic response to an error interceptor. */
|
|
404
|
-
constructor(
|
|
405
|
-
/** The underlying `Response`. Read its body through this wrapper's methods, or take it over with `getBody()`. */
|
|
406
|
-
raw: Response,
|
|
407
|
-
/** The URL that was requested, including the query string (after interceptors). */
|
|
408
|
-
url?: string,
|
|
409
|
-
/** The HTTP method that was used. */
|
|
410
|
-
method?: Method);
|
|
414
|
+
constructor(raw: Response, url?: string, method?: Method);
|
|
411
415
|
/** HTTP status code (`200`, `404`, …). */
|
|
412
416
|
get status(): number;
|
|
413
417
|
/** HTTP status text (`"OK"`, `"Not Found"`, …); empty over HTTP/2. */
|
|
@@ -433,6 +437,8 @@ export declare class ResponseWrapper<T = unknown> {
|
|
|
433
437
|
* The raw body stream, for streaming consumption (downloads with progress, SSE/NDJSON, LLM output…).
|
|
434
438
|
* Unlike the other readers it is not buffered: the body can be read once, and only if no other reader ran.
|
|
435
439
|
* Taking the stream also ends the request's `withTimeout()` deadline — from here on the stream is yours.
|
|
440
|
+
* It has the type `fetch` gives `response.body` in your environment (DOM lib or `@types/node`), so it pipes
|
|
441
|
+
* through `TextDecoderStream` and other web streams like that one does.
|
|
436
442
|
*
|
|
437
443
|
* @example
|
|
438
444
|
* ```typescript
|
|
@@ -440,7 +446,7 @@ export declare class ResponseWrapper<T = unknown> {
|
|
|
440
446
|
* for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) process(chunk.value);
|
|
441
447
|
* ```
|
|
442
448
|
*/
|
|
443
|
-
getBody():
|
|
449
|
+
getBody(): Response["body"];
|
|
444
450
|
/**
|
|
445
451
|
* The body parsed as JSON. An empty body (`204`, `Content-Length: 0`, whitespace) yields `null` —
|
|
446
452
|
* declare it in the type (`getJson<User | null>()`) for endpoints that may return nothing.
|
|
@@ -460,7 +466,8 @@ export declare class ResponseWrapper<T = unknown> {
|
|
|
460
466
|
/**
|
|
461
467
|
* `getJson()` followed by a selector, so you can pick the part you need in one call. The selector
|
|
462
468
|
* receives `T` (declare it on the request: `api.get<Page>()`), or pass both type arguments explicitly.
|
|
463
|
-
*
|
|
469
|
+
* A selector that throws (or returns a promise that rejects) is reported as a `RequestError` with code `"PARSE"`.
|
|
470
|
+
* A schema can be validated first.
|
|
464
471
|
*
|
|
465
472
|
* @example
|
|
466
473
|
* ```typescript
|
|
@@ -504,9 +511,7 @@ export declare class HttpRequest<M extends Method = Method, T = unknown> {
|
|
|
504
511
|
readonly method: M;
|
|
505
512
|
private readonly _url;
|
|
506
513
|
/** Prefer `create.get(url)`, `create.post(url)`, … or an api instance to construct requests. */
|
|
507
|
-
constructor(
|
|
508
|
-
/** The HTTP method. */
|
|
509
|
-
method: M, url: string);
|
|
514
|
+
constructor(method: M, url: string);
|
|
510
515
|
/** The URL this request will be sent to, including the query string. */
|
|
511
516
|
get url(): string;
|
|
512
517
|
private _fail;
|
|
@@ -651,14 +656,18 @@ export declare class HttpRequest<M extends Method = Method, T = unknown> {
|
|
|
651
656
|
withRequestInterceptor(interceptor: RequestInterceptor): this;
|
|
652
657
|
/** Adds a {@link ResponseInterceptor}, run after every successful response in registration order (it also receives the request). */
|
|
653
658
|
withResponseInterceptor(interceptor: ResponseInterceptor): this;
|
|
654
|
-
/**
|
|
659
|
+
/**
|
|
660
|
+
* Adds an {@link ErrorInterceptor}, run once after the request has failed for good (after retries, and including a failure while
|
|
661
|
+
* `getJson()`, `getText()`, … read the body); it also receives the request, so it can replay it.
|
|
662
|
+
*/
|
|
655
663
|
withErrorInterceptor(interceptor: ErrorInterceptor): this;
|
|
656
664
|
/**
|
|
657
665
|
* Sets the request body (POST, PUT, PATCH, DELETE and QUERY only — a compile error elsewhere). Objects and
|
|
658
|
-
* arrays are JSON-encoded; strings, `Blob`, `FormData`, `URLSearchParams`, `ArrayBuffer`, typed arrays
|
|
659
|
-
*
|
|
660
|
-
*
|
|
661
|
-
* Stream bodies are sent with `duplex: "half"`
|
|
666
|
+
* arrays are JSON-encoded; strings, `Blob`, `FormData`, `URLSearchParams`, `ArrayBuffer`, typed arrays,
|
|
667
|
+
* `ReadableStream` and, in Node.js, Node streams (`fs.createReadStream()`) and other async iterables are sent
|
|
668
|
+
* as-is. `Content-Type` is set to `application/json` / `text/plain` unless already present, and removed for
|
|
669
|
+
* `FormData` (fetch must add the multipart boundary itself). Stream bodies are sent with `duplex: "half"`
|
|
670
|
+
* (Chromium and Node.js only), can be sent only once and are never retried.
|
|
662
671
|
*
|
|
663
672
|
* @example
|
|
664
673
|
* ```typescript
|
|
@@ -682,7 +691,7 @@ export declare class HttpRequest<M extends Method = Method, T = unknown> {
|
|
|
682
691
|
withGraphQL(this: HttpRequest<BodyMethod, T>, query: string, variables?: object, options?: GraphQLOptions): this;
|
|
683
692
|
/**
|
|
684
693
|
* An independent copy of this request, so a configured request can serve as a template.
|
|
685
|
-
* Interceptors, signals and body objects are shared by reference (a
|
|
694
|
+
* Interceptors, signals and body objects are shared by reference (a stream body can only be sent once).
|
|
686
695
|
*
|
|
687
696
|
* @example
|
|
688
697
|
* ```typescript
|
|
@@ -696,8 +705,16 @@ export declare class HttpRequest<M extends Method = Method, T = unknown> {
|
|
|
696
705
|
* Sends the request (with retries, if configured) and resolves with the {@link ResponseWrapper} of a
|
|
697
706
|
* successful (2xx or opaque) response. Any failure — non-2xx status, network error, timeout, abort,
|
|
698
707
|
* interceptor error — rejects with a {@link RequestError}; use `getResult()` for a non-throwing variant.
|
|
708
|
+
* It resolves before the body is read, so reading it is up to you and happens after the retries and error
|
|
709
|
+
* interceptors; `getJson()`, `getText()` and the other readers read it as part of each attempt instead.
|
|
699
710
|
*/
|
|
700
711
|
getResponse(): Promise<ResponseWrapper<T>>;
|
|
712
|
+
/**
|
|
713
|
+
* Runs the attempts, the retry policy and the error interceptors around `read`, which receives each response — so a
|
|
714
|
+
* failure while reading the body (a timeout during the read, invalid JSON, a schema or GraphQL error) is retried and
|
|
715
|
+
* intercepted like a failure before it. A response recovered by an error interceptor goes through `read` as well.
|
|
716
|
+
*/
|
|
717
|
+
private _run;
|
|
701
718
|
private _retriable;
|
|
702
719
|
private _attempt;
|
|
703
720
|
/** Attaches the CSRF token (see {@link withCsrf}) to `config.headers` when the final URL qualifies. */
|
|
@@ -723,7 +740,7 @@ export declare class HttpRequest<M extends Method = Method, T = unknown> {
|
|
|
723
740
|
/** Sends the request and parses the body as `FormData`. */
|
|
724
741
|
getFormData(): Promise<FormData>;
|
|
725
742
|
/** Sends the request and returns the raw body stream — see {@link ResponseWrapper.getBody}. */
|
|
726
|
-
getBody(): Promise<
|
|
743
|
+
getBody(): Promise<Response["body"]>;
|
|
727
744
|
/**
|
|
728
745
|
* Sends the request, parses JSON and applies a selector — see {@link ResponseWrapper.getData}.
|
|
729
746
|
*
|
package/dist/index.js
CHANGED
|
@@ -33,7 +33,7 @@ const messageOf = (error) => error instanceof Error ? error.message : String(err
|
|
|
33
33
|
|
|
34
34
|
//#endregion
|
|
35
35
|
//#region src/schema.ts
|
|
36
|
-
const isSchema = (value) =>
|
|
36
|
+
const isSchema = (value) => "~standard" in Object(value);
|
|
37
37
|
|
|
38
38
|
//#endregion
|
|
39
39
|
//#region src/utils.ts
|
|
@@ -123,9 +123,6 @@ const retryAfter = (response) => {
|
|
|
123
123
|
//#endregion
|
|
124
124
|
//#region src/response.ts
|
|
125
125
|
var ResponseWrapper = class {
|
|
126
|
-
raw;
|
|
127
|
-
url;
|
|
128
|
-
method;
|
|
129
126
|
constructor(raw, url = "", method = "GET") {
|
|
130
127
|
this.raw = raw;
|
|
131
128
|
this.url = url;
|
|
@@ -239,7 +236,7 @@ var ResponseWrapper = class {
|
|
|
239
236
|
const data = await this.getJson(schema);
|
|
240
237
|
if (!select) return data;
|
|
241
238
|
try {
|
|
242
|
-
return select(data);
|
|
239
|
+
return await select(data);
|
|
243
240
|
} catch (e) {
|
|
244
241
|
throw this._err(`Selector failed: ${messageOf(e)}`, "PARSE", { cause: e });
|
|
245
242
|
}
|
|
@@ -258,7 +255,6 @@ const RETRY_STATUSES = [
|
|
|
258
255
|
504
|
|
259
256
|
];
|
|
260
257
|
var HttpRequest = class HttpRequest {
|
|
261
|
-
method;
|
|
262
258
|
_o = {
|
|
263
259
|
init: {},
|
|
264
260
|
headers: {},
|
|
@@ -268,7 +264,6 @@ var HttpRequest = class HttpRequest {
|
|
|
268
264
|
res: [],
|
|
269
265
|
err: []
|
|
270
266
|
};
|
|
271
|
-
_url;
|
|
272
267
|
constructor(method, url) {
|
|
273
268
|
this.method = method;
|
|
274
269
|
this._url = url;
|
|
@@ -416,8 +411,8 @@ var HttpRequest = class HttpRequest {
|
|
|
416
411
|
withBody(body) {
|
|
417
412
|
const o = this._o;
|
|
418
413
|
const tag = Object.prototype.toString.call(body).slice(8, -1);
|
|
419
|
-
|
|
420
|
-
o.stream
|
|
414
|
+
o.stream = Symbol.asyncIterator in Object(body) || tag === "ReadableStream";
|
|
415
|
+
const raw = o.stream || ArrayBuffer.isView(body) || /^(String|Blob|File|FormData|URLSearchParams|ArrayBuffer)$/.test(tag);
|
|
421
416
|
if (raw) o.body = body;
|
|
422
417
|
else try {
|
|
423
418
|
o.body = JSON.stringify(body);
|
|
@@ -450,12 +445,15 @@ var HttpRequest = class HttpRequest {
|
|
|
450
445
|
};
|
|
451
446
|
return copy;
|
|
452
447
|
}
|
|
453
|
-
|
|
448
|
+
getResponse() {
|
|
449
|
+
return this._run((response) => response);
|
|
450
|
+
}
|
|
451
|
+
async _run(read) {
|
|
454
452
|
const o = this._o;
|
|
455
453
|
const retry = o.retry;
|
|
456
454
|
let error;
|
|
457
455
|
for (let attempt = 0;; attempt++) try {
|
|
458
|
-
return await this._attempt();
|
|
456
|
+
return await read(await this._attempt());
|
|
459
457
|
} catch (e) {
|
|
460
458
|
error = e instanceof RequestError ? e : this._fail(`Unexpected error: ${messageOf(e)}`, "NETWORK", { cause: e });
|
|
461
459
|
const context = {
|
|
@@ -485,7 +483,7 @@ var HttpRequest = class HttpRequest {
|
|
|
485
483
|
}
|
|
486
484
|
for (const interceptor of o.err) try {
|
|
487
485
|
const result = await interceptor(error, this);
|
|
488
|
-
if (result instanceof ResponseWrapper) return result;
|
|
486
|
+
if (result instanceof ResponseWrapper) return await read(result);
|
|
489
487
|
if (result) error = result;
|
|
490
488
|
} catch (e) {
|
|
491
489
|
error = e instanceof RequestError ? e : this._fail(`Error interceptor failed: ${messageOf(e)}`, "INTERCEPTOR", {
|
|
@@ -617,25 +615,25 @@ var HttpRequest = class HttpRequest {
|
|
|
617
615
|
if (token && !(header in config.headers)) config.headers[header] = token;
|
|
618
616
|
}
|
|
619
617
|
getJson(schema) {
|
|
620
|
-
return this.
|
|
618
|
+
return this._run((response) => response.getJson(schema));
|
|
621
619
|
}
|
|
622
620
|
getText() {
|
|
623
|
-
return this.
|
|
621
|
+
return this._run((response) => response.getText());
|
|
624
622
|
}
|
|
625
623
|
getBlob() {
|
|
626
|
-
return this.
|
|
624
|
+
return this._run((response) => response.getBlob());
|
|
627
625
|
}
|
|
628
626
|
getArrayBuffer() {
|
|
629
|
-
return this.
|
|
627
|
+
return this._run((response) => response.getArrayBuffer());
|
|
630
628
|
}
|
|
631
629
|
getFormData() {
|
|
632
|
-
return this.
|
|
630
|
+
return this._run((response) => response.getFormData());
|
|
633
631
|
}
|
|
634
632
|
getBody() {
|
|
635
|
-
return this.
|
|
633
|
+
return this._run((response) => response.getBody());
|
|
636
634
|
}
|
|
637
635
|
getData(schemaOrSelector, selector) {
|
|
638
|
-
return this.
|
|
636
|
+
return this._run((response) => response.getData(schemaOrSelector, selector));
|
|
639
637
|
}
|
|
640
638
|
async getResult(schema) {
|
|
641
639
|
try {
|
|
@@ -656,11 +654,9 @@ var HttpRequest = class HttpRequest {
|
|
|
656
654
|
//#region src/api.ts
|
|
657
655
|
const ABSOLUTE = /^([a-z][a-z0-9+.-]*:)?\/\//i;
|
|
658
656
|
var Api = class Api {
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
this._base = _base;
|
|
663
|
-
this._defaults = _defaults;
|
|
657
|
+
constructor(base = "", defaults = []) {
|
|
658
|
+
this._base = base;
|
|
659
|
+
this._defaults = defaults;
|
|
664
660
|
}
|
|
665
661
|
_with(fn) {
|
|
666
662
|
fn(new HttpRequest("GET", ""));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-request",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.1",
|
|
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,
|