create-request 1.5.3 → 1.6.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/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![npm version](https://img.shields.io/npm/v/create-request.svg)](https://www.npmjs.com/package/create-request)
7
7
  [![Bundle Size](https://img.shields.io/bundlephobia/minzip/create-request)](https://bundlephobia.com/package/create-request)
8
8
  [![TypeScript](https://img.shields.io/badge/TypeScript-4.7%2B-blue)](https://www.typescriptlang.org/)
9
- [![Known Vulnerabilities](https://snyk.io/test/github/DanielAmenou/create-request/badge.svg)](https://snyk.io/test/github/DanielAmenou/create-request)
9
+ [![Snyk security report](https://img.shields.io/badge/Snyk-security%20report-4C1A51?logo=snyk)](https://security.snyk.io/package/npm/create-request)
10
10
 
11
11
  `create-request` is a modern TypeScript library that transforms how you make API calls. Built as an elegant wrapper around the native Fetch API, it provides a chainable, fluent interface that dramatically reduces boilerplate while adding powerful features like automatic retries, timeout handling, and comprehensive error management.
12
12
 
@@ -25,6 +25,7 @@
25
25
  - [Automatic Retries with Delay](#automatic-retries-with-delay)
26
26
  - [Interceptors](#interceptors)
27
27
  - [Request Cancellation](#request-cancellation)
28
+ - [Custom Fetch Injection](#custom-fetch-injection)
28
29
  - [Data Selection](#data-selection)
29
30
  - [TypeScript Support](#typescript-support)
30
31
  - [CSRF Protection](#csrf-protection)
@@ -51,6 +52,7 @@
51
52
  - 🏗️ **API Builder** - Create configured API instances with reusable default settings
52
53
  - 🛑 **Request Cancellation** - Abort requests on demand with AbortController integration
53
54
  - 🔌 **Interceptors** - Global and per-request interceptors for requests, responses, and errors
55
+ - 🧩 **Custom Fetch** - Inject any fetch-compatible function for testing, undici agents, or Next.js caching
54
56
  - 🔷 **GraphQL Support** - Built-in GraphQL query and mutation helpers
55
57
 
56
58
  ## Why create-request?
@@ -635,6 +637,7 @@ try {
635
637
  console.log(error.method); // HTTP method
636
638
  console.log(error.isTimeout); // Whether it was a timeout
637
639
  console.log(error.isAborted); // Whether it was aborted/cancelled
640
+ console.log(error.body); // Raw response body as text (if available)
638
641
 
639
642
  // Access the original response if available
640
643
  if (error.response) {
@@ -644,6 +647,32 @@ try {
644
647
  }
645
648
  ```
646
649
 
650
+ #### Error Response Body
651
+
652
+ When a request fails with an HTTP error (e.g., 400, 404, 500), the response body is automatically captured and available directly on the error - no need to read it from `error.response` manually:
653
+
654
+ ```typescript
655
+ try {
656
+ await create.post("https://api.example.com/users").withBody(newUser).getJson();
657
+ } catch (error) {
658
+ // Raw body as text (undefined for network errors, timeouts, and aborts)
659
+ console.log(error.body); // '{"message":"Email already taken","code":"DUPLICATE_EMAIL"}'
660
+
661
+ // Body parsed as JSON - never throws, returns undefined if the body isn't valid JSON
662
+ const details = error.getJson<{ message: string; code: string }>();
663
+ if (details) {
664
+ showToast(details.message);
665
+ }
666
+ }
667
+ ```
668
+
669
+ Notes on the captured body:
670
+
671
+ - `error.body` contains the raw body text whenever a response was received (HTTP errors, JSON parsing errors, GraphQL errors with `throwOnError`). For errors without a response (network failures, timeouts, aborts) it's `undefined`.
672
+ - `error.getJson()` lazily parses `error.body` as JSON and caches the result. It never throws - it returns `undefined` when there's no body or the body isn't valid JSON.
673
+ - The body is captured from a clone of the response, so `error.response` remains fully readable for backward compatibility.
674
+ - The captured body is also available in retry callbacks (`onRetry`, `delay`) and error interceptors.
675
+
647
676
  ## URL Handling
648
677
 
649
678
  The library handles both absolute and relative URLs, and automatically merges query parameters:
@@ -923,7 +952,11 @@ const request4 = create.get("https://api.example.com/data").withRetries({
923
952
  if (error.status === 429) {
924
953
  // Check Retry-After header if available
925
954
  const retryAfter = error.response?.headers.get("Retry-After");
926
- return retryAfter ? parseInt(retryAfter) * 1000 : 5000;
955
+ if (retryAfter) return parseInt(retryAfter) * 1000;
956
+
957
+ // Or read the delay from the error response body
958
+ const details = error.getJson<{ retryAfterMs?: number }>();
959
+ return details?.retryAfterMs ?? 5000;
927
960
  }
928
961
  return 1000; // Default delay
929
962
  },
@@ -1090,6 +1123,54 @@ try {
1090
1123
  }
1091
1124
  ```
1092
1125
 
1126
+ ### Custom Fetch Injection
1127
+
1128
+ By default, requests run through the global `fetch`. With `withFetch` you can inject any fetch-compatible function — per request or for a whole API instance. This unlocks testing without global mocks, custom undici agents/dispatchers in Node.js, and framework-patched fetch features like Next.js caching.
1129
+
1130
+ ```typescript
1131
+ import create, { createApi } from "create-request";
1132
+ import type { FetchFunction } from "create-request";
1133
+
1134
+ // Testing: inject a stub instead of monkey-patching globalThis.fetch
1135
+ const stubFetch: FetchFunction = async () =>
1136
+ new Response(JSON.stringify({ id: 1 }), {
1137
+ status: 200,
1138
+ headers: { "content-type": "application/json" },
1139
+ });
1140
+
1141
+ const user = await create.get("/api/users/1").withFetch(stubFetch).getJson();
1142
+ ```
1143
+
1144
+ ```typescript
1145
+ // Node.js: route requests through a custom undici Agent (proxies, keep-alive tuning, mTLS, ...)
1146
+ import { fetch as undiciFetch, Agent } from "undici";
1147
+
1148
+ const agent = new Agent({ keepAliveTimeout: 30_000, connections: 10 });
1149
+
1150
+ const api = createApi()
1151
+ .withBaseURL("https://api.example.com")
1152
+ .withFetch((url, init) => undiciFetch(url, { ...init, dispatcher: agent }));
1153
+
1154
+ const users = await api.get("/users").getJson();
1155
+ ```
1156
+
1157
+ ```typescript
1158
+ // Next.js: pass caching hints through to the framework's patched fetch
1159
+ const revalidatingFetch: FetchFunction = (url, init) =>
1160
+ fetch(url, { ...init, next: { revalidate: 60 } });
1161
+
1162
+ const posts = await create
1163
+ .get("https://api.example.com/posts")
1164
+ .withFetch(revalidatingFetch)
1165
+ .getJson();
1166
+ ```
1167
+
1168
+ Notes:
1169
+
1170
+ - The custom function receives the final URL and `RequestInit` after query params, headers, and request interceptors have been applied, and it is called once per attempt when retries are configured.
1171
+ - It should honor `init.signal`, otherwise `withTimeout` and `withAbortController` cannot cancel the underlying work.
1172
+ - A per-request `withFetch` overrides one set on an API builder.
1173
+
1093
1174
  ### Data Selection
1094
1175
 
1095
1176
  The `getData` method provides a powerful way to extract and transform specific data from API responses:
@@ -1,5 +1,5 @@
1
1
  import { type HttpMethod, RedirectMode, RequestMode, RequestPriority, ReferrerPolicy, CredentialsPolicy } from "./enums.js";
2
- import type { RetryConfig, RetryCallback, CookiesRecord, CookieOptions, RequestOptions, GraphQLOptions, ErrorInterceptor, RequestInterceptor, ResponseInterceptor } from "./types.js";
2
+ import type { RetryConfig, RetryCallback, CookiesRecord, CookieOptions, FetchFunction, RequestOptions, GraphQLOptions, ErrorInterceptor, RequestInterceptor, ResponseInterceptor } from "./types.js";
3
3
  import { ResponseWrapper } from "./ResponseWrapper.js";
4
4
  /**
5
5
  * Base class with common functionality for all request types
@@ -10,6 +10,7 @@ export declare abstract class BaseRequest {
10
10
  protected url: string;
11
11
  protected requestOptions: RequestOptions;
12
12
  protected abortController?: AbortController;
13
+ protected customFetch?: FetchFunction;
13
14
  protected queryParams: URLSearchParams;
14
15
  protected autoApplyCsrfProtection: boolean;
15
16
  private requestInterceptors;
@@ -165,6 +166,36 @@ export declare abstract class BaseRequest {
165
166
  * controller.abort();
166
167
  */
167
168
  withAbortController(controller: AbortController): this;
169
+ /**
170
+ * Sets a custom fetch implementation used to execute this request.
171
+ * By default, requests use the global `fetch`. Injecting a custom function unlocks
172
+ * testing without global mocks, custom undici dispatchers/agents in Node.js,
173
+ * and framework-specific fetch extensions (e.g. Next.js caching options).
174
+ *
175
+ * The provided function receives the final URL and `RequestInit` (after interceptors)
176
+ * and must return a `Promise<Response>`. It should honor `init.signal` so that
177
+ * `withTimeout` and `withAbortController` keep working.
178
+ *
179
+ * @param fetchFn - A fetch-compatible function
180
+ * @returns The request instance for chaining
181
+ * @throws {RequestError} If fetchFn is not a function
182
+ *
183
+ * @example
184
+ * // Testing: inject a stub instead of mocking the global fetch
185
+ * const stubFetch: FetchFunction = async () => new Response('{"ok":true}');
186
+ * const data = await create.get('/api/users').withFetch(stubFetch).getJson();
187
+ *
188
+ * @example
189
+ * // Node.js: route through a custom undici agent (proxy, keep-alive tuning, ...)
190
+ * import { fetch as undiciFetch, Agent } from 'undici';
191
+ * const agent = new Agent({ keepAliveTimeout: 30_000 });
192
+ * request.withFetch((url, init) => undiciFetch(url, { ...init, dispatcher: agent }));
193
+ *
194
+ * @example
195
+ * // Next.js: pass caching hints through to the framework's patched fetch
196
+ * request.withFetch((url, init) => fetch(url, { ...init, next: { revalidate: 60 } }));
197
+ */
198
+ withFetch(fetchFn: FetchFunction): this;
168
199
  /**
169
200
  * Sets the referrer URL for the request. The referrer is the URL of the page that initiated the request.
170
201
  * This can be used to override the default referrer that the browser would normally send.
@@ -11,6 +11,8 @@
11
11
  * console.log(`URL: ${error.url}`);
12
12
  * console.log(`Method: ${error.method}`);
13
13
  * console.log(`Status: ${error.status}`);
14
+ * console.log(`Body: ${error.body}`); // Raw response body (if available)
15
+ * console.log(error.getJson()); // Body parsed as JSON (or undefined)
14
16
  * console.log(`Is timeout: ${error.isTimeout}`);
15
17
  * console.log(`Is aborted: ${error.isAborted}`);
16
18
  * }
@@ -21,6 +23,12 @@ export declare class RequestError extends Error {
21
23
  readonly status?: number;
22
24
  /** The Response object if the request received a response before failing */
23
25
  readonly response?: Response;
26
+ /**
27
+ * The raw response body as text, if a response was received and its body could be read.
28
+ * `undefined` for errors without a response (network errors, timeouts, aborts)
29
+ * or when the body could not be read.
30
+ */
31
+ readonly body?: string;
24
32
  /** The URL that was requested */
25
33
  readonly url: string;
26
34
  /** The HTTP method that was used (e.g., 'GET', 'POST') */
@@ -29,6 +37,8 @@ export declare class RequestError extends Error {
29
37
  readonly isTimeout: boolean;
30
38
  /** Whether the request was aborted (cancelled) */
31
39
  readonly isAborted: boolean;
40
+ /** Cached result of parsing `body` as JSON (lazily populated by getJson) */
41
+ private parsedBody?;
32
42
  /**
33
43
  * Creates a new RequestError instance.
34
44
  *
@@ -38,6 +48,7 @@ export declare class RequestError extends Error {
38
48
  * @param options - Additional error context
39
49
  * @param options.status - HTTP status code if available
40
50
  * @param options.response - The Response object if available
51
+ * @param options.body - The raw response body as text, if available
41
52
  * @param options.isTimeout - Whether this was a timeout error
42
53
  * @param options.isAborted - Whether the request was aborted
43
54
  * @param options.cause - The underlying error that caused this error
@@ -45,10 +56,48 @@ export declare class RequestError extends Error {
45
56
  constructor(message: string, url: string, method: string, options?: {
46
57
  status?: number;
47
58
  response?: Response;
59
+ body?: string;
48
60
  isTimeout?: boolean;
49
61
  isAborted?: boolean;
50
62
  cause?: Error;
51
63
  });
64
+ /**
65
+ * Parses the captured response body (`body`) as JSON.
66
+ * The result is cached, so repeated calls don't re-parse.
67
+ * This method never throws - it returns `undefined` when there is no body
68
+ * or the body is not valid JSON, making it safe to use in error handlers.
69
+ *
70
+ * @returns The parsed JSON body, or `undefined` if no body was captured or it isn't valid JSON
71
+ *
72
+ * @example
73
+ * ```typescript
74
+ * try {
75
+ * await create.post('/api/users').withBody(user).getJson();
76
+ * } catch (error) {
77
+ * if (error instanceof RequestError) {
78
+ * const details = error.getJson<{ message: string; code: string }>();
79
+ * console.log(details?.message ?? error.body ?? error.message);
80
+ * }
81
+ * }
82
+ * ```
83
+ */
84
+ getJson<T = unknown>(): T | undefined;
85
+ /**
86
+ * Safely reads the body of a Response as text without consuming it.
87
+ * The response is cloned before reading, so the original body remains readable.
88
+ * Never throws - returns `undefined` if the body is unavailable or cannot be read
89
+ * (e.g., already consumed, locked stream, or read failure).
90
+ *
91
+ * @param response - The Response to read the body from
92
+ * @returns The body as text, or `undefined` if it could not be read
93
+ *
94
+ * @example
95
+ * ```typescript
96
+ * const body = await RequestError.captureBody(response);
97
+ * throw RequestError.fromResponse(response, url, 'GET', body);
98
+ * ```
99
+ */
100
+ static captureBody(response: Response): Promise<string | undefined>;
52
101
  /**
53
102
  * Creates a RequestError for a timeout failure.
54
103
  *
@@ -70,17 +119,19 @@ export declare class RequestError extends Error {
70
119
  * @param response - The Response object from the failed request
71
120
  * @param url - The URL that was requested
72
121
  * @param method - The HTTP method that was used
73
- * @returns A RequestError with the status code and response object
122
+ * @param body - The response body as text, if already read (see {@link RequestError.captureBody})
123
+ * @returns A RequestError with the status code, response object, and body (if provided)
74
124
  *
75
125
  * @example
76
126
  * ```typescript
77
127
  * const response = await fetch('/api/users');
78
128
  * if (!response.ok) {
79
- * throw RequestError.fromResponse(response, '/api/users', 'GET');
129
+ * const body = await RequestError.captureBody(response);
130
+ * throw RequestError.fromResponse(response, '/api/users', 'GET', body);
80
131
  * }
81
132
  * ```
82
133
  */
83
- static fromResponse(response: Response, url: string, method: string): RequestError;
134
+ static fromResponse(response: Response, url: string, method: string, body?: string): RequestError;
84
135
  /**
85
136
  * Creates a RequestError from a network-level error.
86
137
  * Automatically detects and categorizes common network errors (timeouts, DNS errors, connection errors).
@@ -1,5 +1,5 @@
1
1
  import { GetRequest, PostRequest, PutRequest, DeleteRequest, PatchRequest, HeadRequest, OptionsRequest } from "./requestMethods.js";
2
- import type { RetryConfig, RetryCallback, CookiesRecord, CookieOptions, RequestInterceptor, ResponseInterceptor, ErrorInterceptor } from "./types.js";
2
+ import type { RetryConfig, RetryCallback, CookiesRecord, CookieOptions, FetchFunction, RequestInterceptor, ResponseInterceptor, ErrorInterceptor } from "./types.js";
3
3
  import type { CredentialsPolicy, RedirectMode, RequestPriority, ReferrerPolicy, RequestMode } from "./enums.js";
4
4
  /**
5
5
  * API Builder for creating configured API instances with reusable default settings.
@@ -263,6 +263,25 @@ type ApiBuilder = {
263
263
  * ```
264
264
  */
265
265
  withCache(cache: RequestCache): ApiBuilder;
266
+ /**
267
+ * Sets a custom fetch implementation for all requests created through this API instance.
268
+ * Useful for injecting test stubs, undici agents/dispatchers, or framework-patched
269
+ * fetch functions (e.g. Next.js caching options). Individual requests can still
270
+ * override it with their own `withFetch` call.
271
+ *
272
+ * @param fetchFn - A fetch-compatible function
273
+ * @returns The API builder instance for chaining
274
+ *
275
+ * @example
276
+ * ```typescript
277
+ * import { fetch as undiciFetch, Agent } from 'undici';
278
+ * const agent = new Agent({ connections: 10 });
279
+ * const api = createApi()
280
+ * .withBaseURL('https://api.example.com')
281
+ * .withFetch((url, init) => undiciFetch(url, { ...init, dispatcher: agent }));
282
+ * ```
283
+ */
284
+ withFetch(fetchFn: FetchFunction): ApiBuilder;
266
285
  /**
267
286
  * Set the request mode.
268
287
  * Controls CORS behavior and what types of responses are allowed.
@@ -108,6 +108,8 @@ exports.CacheMode = void 0;
108
108
  * console.log(`URL: ${error.url}`);
109
109
  * console.log(`Method: ${error.method}`);
110
110
  * console.log(`Status: ${error.status}`);
111
+ * console.log(`Body: ${error.body}`); // Raw response body (if available)
112
+ * console.log(error.getJson()); // Body parsed as JSON (or undefined)
111
113
  * console.log(`Is timeout: ${error.isTimeout}`);
112
114
  * console.log(`Is aborted: ${error.isAborted}`);
113
115
  * }
@@ -118,6 +120,12 @@ class RequestError extends Error {
118
120
  status;
119
121
  /** The Response object if the request received a response before failing */
120
122
  response;
123
+ /**
124
+ * The raw response body as text, if a response was received and its body could be read.
125
+ * `undefined` for errors without a response (network errors, timeouts, aborts)
126
+ * or when the body could not be read.
127
+ */
128
+ body;
121
129
  /** The URL that was requested */
122
130
  url;
123
131
  /** The HTTP method that was used (e.g., 'GET', 'POST') */
@@ -126,6 +134,8 @@ class RequestError extends Error {
126
134
  isTimeout;
127
135
  /** Whether the request was aborted (cancelled) */
128
136
  isAborted;
137
+ /** Cached result of parsing `body` as JSON (lazily populated by getJson) */
138
+ parsedBody;
129
139
  /**
130
140
  * Creates a new RequestError instance.
131
141
  *
@@ -135,6 +145,7 @@ class RequestError extends Error {
135
145
  * @param options - Additional error context
136
146
  * @param options.status - HTTP status code if available
137
147
  * @param options.response - The Response object if available
148
+ * @param options.body - The raw response body as text, if available
138
149
  * @param options.isTimeout - Whether this was a timeout error
139
150
  * @param options.isAborted - Whether the request was aborted
140
151
  * @param options.cause - The underlying error that caused this error
@@ -146,6 +157,7 @@ class RequestError extends Error {
146
157
  this.method = method;
147
158
  this.status = options.status;
148
159
  this.response = options.response;
160
+ this.body = options.body;
149
161
  this.isTimeout = !!options.isTimeout;
150
162
  this.isAborted = !!options.isAborted;
151
163
  // For better stack traces in modern environments
@@ -155,6 +167,62 @@ class RequestError extends Error {
155
167
  // Maintains proper prototype chain for instanceof checks
156
168
  Object.setPrototypeOf(this, RequestError.prototype);
157
169
  }
170
+ /**
171
+ * Parses the captured response body (`body`) as JSON.
172
+ * The result is cached, so repeated calls don't re-parse.
173
+ * This method never throws - it returns `undefined` when there is no body
174
+ * or the body is not valid JSON, making it safe to use in error handlers.
175
+ *
176
+ * @returns The parsed JSON body, or `undefined` if no body was captured or it isn't valid JSON
177
+ *
178
+ * @example
179
+ * ```typescript
180
+ * try {
181
+ * await create.post('/api/users').withBody(user).getJson();
182
+ * } catch (error) {
183
+ * if (error instanceof RequestError) {
184
+ * const details = error.getJson<{ message: string; code: string }>();
185
+ * console.log(details?.message ?? error.body ?? error.message);
186
+ * }
187
+ * }
188
+ * ```
189
+ */
190
+ getJson() {
191
+ if (this.parsedBody === undefined && this.body) {
192
+ try {
193
+ this.parsedBody = JSON.parse(this.body);
194
+ }
195
+ catch {
196
+ // Body is not valid JSON - leave parsedBody undefined
197
+ }
198
+ }
199
+ return this.parsedBody;
200
+ }
201
+ /**
202
+ * Safely reads the body of a Response as text without consuming it.
203
+ * The response is cloned before reading, so the original body remains readable.
204
+ * Never throws - returns `undefined` if the body is unavailable or cannot be read
205
+ * (e.g., already consumed, locked stream, or read failure).
206
+ *
207
+ * @param response - The Response to read the body from
208
+ * @returns The body as text, or `undefined` if it could not be read
209
+ *
210
+ * @example
211
+ * ```typescript
212
+ * const body = await RequestError.captureBody(response);
213
+ * throw RequestError.fromResponse(response, url, 'GET', body);
214
+ * ```
215
+ */
216
+ static async captureBody(response) {
217
+ try {
218
+ if (!response.bodyUsed)
219
+ return await response.clone().text();
220
+ }
221
+ catch {
222
+ // Body could not be read (e.g., locked stream or read failure)
223
+ }
224
+ return undefined;
225
+ }
158
226
  /**
159
227
  * Creates a RequestError for a timeout failure.
160
228
  *
@@ -180,20 +248,23 @@ class RequestError extends Error {
180
248
  * @param response - The Response object from the failed request
181
249
  * @param url - The URL that was requested
182
250
  * @param method - The HTTP method that was used
183
- * @returns A RequestError with the status code and response object
251
+ * @param body - The response body as text, if already read (see {@link RequestError.captureBody})
252
+ * @returns A RequestError with the status code, response object, and body (if provided)
184
253
  *
185
254
  * @example
186
255
  * ```typescript
187
256
  * const response = await fetch('/api/users');
188
257
  * if (!response.ok) {
189
- * throw RequestError.fromResponse(response, '/api/users', 'GET');
258
+ * const body = await RequestError.captureBody(response);
259
+ * throw RequestError.fromResponse(response, '/api/users', 'GET', body);
190
260
  * }
191
261
  * ```
192
262
  */
193
- static fromResponse(response, url, method) {
263
+ static fromResponse(response, url, method, body) {
194
264
  return new RequestError(`HTTP ${response.status}`, url, method, {
195
265
  status: response.status,
196
266
  response,
267
+ body,
197
268
  });
198
269
  }
199
270
  /**
@@ -412,6 +483,7 @@ class ResponseWrapper {
412
483
  throw new RequestError(`GQL: ${errorMessage}`, this.url || "", this.method || "", {
413
484
  status: this.response.status,
414
485
  response: this.response,
486
+ body: this.cachedText,
415
487
  });
416
488
  }
417
489
  /**
@@ -472,6 +544,7 @@ class ResponseWrapper {
472
544
  throw new RequestError(`Bad JSON: ${error instanceof Error ? error.message : String(error)}`, this.url || "", this.method || "", {
473
545
  status: this.response.status,
474
546
  response: this.response,
547
+ body: this.cachedText,
475
548
  });
476
549
  }
477
550
  }
@@ -637,6 +710,7 @@ class ResponseWrapper {
637
710
  throw new RequestError(`Selector: ${error instanceof Error ? error.message : String(error)}`, this.url || "", this.method || "", {
638
711
  status: this.response.status,
639
712
  response: this.response,
713
+ body: this.cachedText,
640
714
  });
641
715
  }
642
716
  // If we get here and it's not a RequestError, wrap it
@@ -1043,6 +1117,7 @@ class BaseRequest {
1043
1117
  headers: {},
1044
1118
  };
1045
1119
  abortController;
1120
+ customFetch;
1046
1121
  queryParams = new URLSearchParams();
1047
1122
  autoApplyCsrfProtection = true;
1048
1123
  // Per-request interceptors
@@ -1288,6 +1363,41 @@ class BaseRequest {
1288
1363
  this.abortController = controller;
1289
1364
  return this;
1290
1365
  }
1366
+ /**
1367
+ * Sets a custom fetch implementation used to execute this request.
1368
+ * By default, requests use the global `fetch`. Injecting a custom function unlocks
1369
+ * testing without global mocks, custom undici dispatchers/agents in Node.js,
1370
+ * and framework-specific fetch extensions (e.g. Next.js caching options).
1371
+ *
1372
+ * The provided function receives the final URL and `RequestInit` (after interceptors)
1373
+ * and must return a `Promise<Response>`. It should honor `init.signal` so that
1374
+ * `withTimeout` and `withAbortController` keep working.
1375
+ *
1376
+ * @param fetchFn - A fetch-compatible function
1377
+ * @returns The request instance for chaining
1378
+ * @throws {RequestError} If fetchFn is not a function
1379
+ *
1380
+ * @example
1381
+ * // Testing: inject a stub instead of mocking the global fetch
1382
+ * const stubFetch: FetchFunction = async () => new Response('{"ok":true}');
1383
+ * const data = await create.get('/api/users').withFetch(stubFetch).getJson();
1384
+ *
1385
+ * @example
1386
+ * // Node.js: route through a custom undici agent (proxy, keep-alive tuning, ...)
1387
+ * import { fetch as undiciFetch, Agent } from 'undici';
1388
+ * const agent = new Agent({ keepAliveTimeout: 30_000 });
1389
+ * request.withFetch((url, init) => undiciFetch(url, { ...init, dispatcher: agent }));
1390
+ *
1391
+ * @example
1392
+ * // Next.js: pass caching hints through to the framework's patched fetch
1393
+ * request.withFetch((url, init) => fetch(url, { ...init, next: { revalidate: 60 } }));
1394
+ */
1395
+ withFetch(fetchFn) {
1396
+ if (typeof fetchFn !== "function")
1397
+ throw new RequestError("Bad fetch", this.url, this.method);
1398
+ this.customFetch = fetchFn;
1399
+ return this;
1400
+ }
1291
1401
  /**
1292
1402
  * Sets the referrer URL for the request. The referrer is the URL of the page that initiated the request.
1293
1403
  * This can be used to override the default referrer that the browser would normally send.
@@ -2225,6 +2335,7 @@ class BaseRequest {
2225
2335
  currentError = new RequestError(`ErrI${i + 1}: ${em}`, currentError.url, currentError.method, {
2226
2336
  status: currentError.status,
2227
2337
  response: currentError.response,
2338
+ body: currentError.body,
2228
2339
  });
2229
2340
  }
2230
2341
  else {
@@ -2369,10 +2480,11 @@ class BaseRequest {
2369
2480
  if (abortSignal.signal) {
2370
2481
  fetchOptions.signal = abortSignal.signal;
2371
2482
  }
2372
- // Execute fetch
2483
+ // Execute fetch (custom implementation if provided, global fetch otherwise)
2484
+ const fetchFn = this.customFetch ?? globalThis.fetch;
2373
2485
  let response;
2374
2486
  try {
2375
- response = await fetch(url, fetchOptions);
2487
+ response = await fetchFn(url, fetchOptions);
2376
2488
  }
2377
2489
  catch (error) {
2378
2490
  const errorObj = error instanceof Error ? error : new Error(String(error));
@@ -2405,7 +2517,9 @@ class BaseRequest {
2405
2517
  throw RequestError.networkError(url, method, new Error("Failed with status 0 (network error or CORS blocked)"));
2406
2518
  }
2407
2519
  if (!response.ok) {
2408
- throw RequestError.fromResponse(response, url, method);
2520
+ // Capture the response body so it's available on the error object
2521
+ // (reads from a clone, so error.response remains readable)
2522
+ throw RequestError.fromResponse(response, url, method, await RequestError.captureBody(response));
2409
2523
  }
2410
2524
  const graphQLOptions = this.getGraphQLOptions();
2411
2525
  const wrappedResponse = new ResponseWrapper(response, url, method, graphQLOptions);