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 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 / ReadableStream → sent as-is
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; error interceptors run once, after the last 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. Stream bodies are never retried.
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.95 KB | 4.48 KB |
546
- | `import { createGet }` only | 4.32 KB | |
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) => !!value && (typeof value === "object" || typeof value === "function") && "~standard" in 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
- const raw = ArrayBuffer.isView(body) || /^(String|Blob|File|FormData|URLSearchParams|ArrayBuffer|ReadableStream)$/.test(tag);
421
- o.stream = tag === "ReadableStream";
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
- async getResponse() {
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.getResponse().then((response) => response.getJson(schema));
619
+ return this._run((response) => response.getJson(schema));
622
620
  }
623
621
  getText() {
624
- return this.getResponse().then((response) => response.getText());
622
+ return this._run((response) => response.getText());
625
623
  }
626
624
  getBlob() {
627
- return this.getResponse().then((response) => response.getBlob());
625
+ return this._run((response) => response.getBlob());
628
626
  }
629
627
  getArrayBuffer() {
630
- return this.getResponse().then((response) => response.getArrayBuffer());
628
+ return this._run((response) => response.getArrayBuffer());
631
629
  }
632
630
  getFormData() {
633
- return this.getResponse().then((response) => response.getFormData());
631
+ return this._run((response) => response.getFormData());
634
632
  }
635
633
  getBody() {
636
- return this.getResponse().then((response) => response.getBody());
634
+ return this._run((response) => response.getBody());
637
635
  }
638
636
  getData(schemaOrSelector, selector) {
639
- return this.getResponse().then((response) => response.getData(schemaOrSelector, selector));
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
- _base;
661
- _defaults;
662
- constructor(_base = "", _defaults = []) {
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` → sent as-is, no `Content-Type` unless you set one
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
- /** Full override of the retry decision (`statuses` and `methods` are ignored). Aborts and stream bodies still never retry. */
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(): ReadableStream<Uint8Array> | null;
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
- * Errors thrown by the selector are reported as a `RequestError` with code `"PARSE"`. A schema can be validated first.
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
- /** 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. */
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
- * and `ReadableStream` are sent as-is. `Content-Type` is set to `application/json` / `text/plain`
660
- * unless already present, and removed for `FormData` (fetch must add the multipart boundary itself).
661
- * Stream bodies are sent with `duplex: "half"` (Chromium and Node.js only) and are never retried.
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 `ReadableStream` body can only be sent once).
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<ReadableStream<Uint8Array> | null>;
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` → sent as-is, no `Content-Type` unless you set one
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
- /** Full override of the retry decision (`statuses` and `methods` are ignored). Aborts and stream bodies still never retry. */
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(): ReadableStream<Uint8Array> | null;
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
- * Errors thrown by the selector are reported as a `RequestError` with code `"PARSE"`. A schema can be validated first.
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
- /** 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. */
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
- * and `ReadableStream` are sent as-is. `Content-Type` is set to `application/json` / `text/plain`
660
- * unless already present, and removed for `FormData` (fetch must add the multipart boundary itself).
661
- * Stream bodies are sent with `duplex: "half"` (Chromium and Node.js only) and are never retried.
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 `ReadableStream` body can only be sent once).
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<ReadableStream<Uint8Array> | null>;
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) => !!value && (typeof value === "object" || typeof value === "function") && "~standard" in 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
- const raw = ArrayBuffer.isView(body) || /^(String|Blob|File|FormData|URLSearchParams|ArrayBuffer|ReadableStream)$/.test(tag);
420
- o.stream = tag === "ReadableStream";
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
- async getResponse() {
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.getResponse().then((response) => response.getJson(schema));
618
+ return this._run((response) => response.getJson(schema));
621
619
  }
622
620
  getText() {
623
- return this.getResponse().then((response) => response.getText());
621
+ return this._run((response) => response.getText());
624
622
  }
625
623
  getBlob() {
626
- return this.getResponse().then((response) => response.getBlob());
624
+ return this._run((response) => response.getBlob());
627
625
  }
628
626
  getArrayBuffer() {
629
- return this.getResponse().then((response) => response.getArrayBuffer());
627
+ return this._run((response) => response.getArrayBuffer());
630
628
  }
631
629
  getFormData() {
632
- return this.getResponse().then((response) => response.getFormData());
630
+ return this._run((response) => response.getFormData());
633
631
  }
634
632
  getBody() {
635
- return this.getResponse().then((response) => response.getBody());
633
+ return this._run((response) => response.getBody());
636
634
  }
637
635
  getData(schemaOrSelector, selector) {
638
- return this.getResponse().then((response) => response.getData(schemaOrSelector, selector));
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
- _base;
660
- _defaults;
661
- constructor(_base = "", _defaults = []) {
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.0",
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,