create-request 1.5.4 → 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 +82 -1
- package/dist/library/BaseRequest.d.ts +32 -1
- package/dist/library/RequestError.d.ts +54 -3
- package/dist/library/apiBuilder.d.ts +20 -1
- package/dist/library/index.cjs +120 -6
- package/dist/library/index.cjs.map +1 -1
- package/dist/library/index.d.ts +1 -1
- package/dist/library/index.esm.js +120 -6
- package/dist/library/index.esm.js.map +1 -1
- package/dist/library/index.esm.min.js +1 -1
- package/dist/library/index.esm.min.js.map +1 -1
- package/dist/library/index.min.cjs +1 -1
- package/dist/library/index.min.cjs.map +1 -1
- package/dist/library/types.d.ts +15 -0
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
* @
|
|
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
|
-
*
|
|
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.
|
package/dist/library/index.cjs
CHANGED
|
@@ -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
|
-
* @
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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);
|