create-request 0.0.1-beta.4 → 0.0.1-beta.6
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/dist/library/BaseRequest.d.ts +14 -2
- package/dist/library/BodyRequest.d.ts +0 -1
- package/dist/library/ResponseWrapper.d.ts +8 -41
- package/dist/library/index.d.ts +2 -2
- package/dist/library/index.esm.js +249 -111
- package/dist/library/index.js +249 -111
- package/dist/library/types.d.ts +1 -1
- package/dist/library/utils/Config.d.ts +1 -1
- package/dist/library/utils/CsrfUtils.d.ts +2 -2
- package/package.json +6 -6
|
@@ -57,10 +57,10 @@ export declare abstract class BaseRequest {
|
|
|
57
57
|
/**
|
|
58
58
|
* Adds a single query parameter
|
|
59
59
|
* @param key - The parameter name
|
|
60
|
-
* @param value - The parameter value
|
|
60
|
+
* @param value - The parameter value, can be a single value or array of values
|
|
61
61
|
* @returns The instance for chaining
|
|
62
62
|
*/
|
|
63
|
-
withQueryParam(key: string, value: string | number | boolean | null | undefined): this;
|
|
63
|
+
withQueryParam(key: string, value: string | string[] | number | boolean | null | undefined): this;
|
|
64
64
|
/**
|
|
65
65
|
* Sets the mode of the request
|
|
66
66
|
* @param mode - The mode for the request (cors, no-cors, same-origin, navigate)
|
|
@@ -86,12 +86,24 @@ export declare abstract class BaseRequest {
|
|
|
86
86
|
* @returns The instance for chaining
|
|
87
87
|
*/
|
|
88
88
|
withBasicAuth(username: string, password: string): this;
|
|
89
|
+
/**
|
|
90
|
+
* Cross-environment base64 encoding
|
|
91
|
+
* Works in both browser and Node.js environments
|
|
92
|
+
*/
|
|
93
|
+
private encodeBase64;
|
|
89
94
|
/**
|
|
90
95
|
* Sets the bearer token for authentication
|
|
91
96
|
* @param token - The bearer token
|
|
92
97
|
* @returns The instance for chaining
|
|
93
98
|
*/
|
|
94
99
|
withBearerToken(token: string): this;
|
|
100
|
+
/**
|
|
101
|
+
* Helper function to check for header presence in a case-insensitive way
|
|
102
|
+
* @param headers Headers object
|
|
103
|
+
* @param headerName Header name to check
|
|
104
|
+
* @returns Boolean indicating if the header exists (case-insensitive)
|
|
105
|
+
*/
|
|
106
|
+
private hasHeader;
|
|
95
107
|
/**
|
|
96
108
|
* Sets cookies for the request
|
|
97
109
|
* @param cookies Object containing cookie name-value pairs or cookie options
|
|
@@ -2,55 +2,22 @@
|
|
|
2
2
|
* Wrapper for HTTP responses with methods to transform the response data
|
|
3
3
|
*/
|
|
4
4
|
export declare class ResponseWrapper {
|
|
5
|
-
readonly
|
|
6
|
-
private
|
|
5
|
+
private readonly response;
|
|
6
|
+
private textCache;
|
|
7
|
+
private jsonCache;
|
|
8
|
+
private blobCache;
|
|
9
|
+
private arrayBufferCache;
|
|
10
|
+
private bodyUsed;
|
|
7
11
|
constructor(response: Response);
|
|
8
|
-
/**
|
|
9
|
-
* Get the raw Response object
|
|
10
|
-
*/
|
|
11
|
-
get raw(): Response;
|
|
12
|
-
/**
|
|
13
|
-
* Get the HTTP status code
|
|
14
|
-
*/
|
|
15
12
|
get status(): number;
|
|
16
|
-
/**
|
|
17
|
-
* Get the HTTP status text
|
|
18
|
-
*/
|
|
19
13
|
get statusText(): string;
|
|
20
|
-
/**
|
|
21
|
-
* Get response headers
|
|
22
|
-
*/
|
|
23
14
|
get headers(): Headers;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
*/
|
|
15
|
+
get ok(): boolean;
|
|
16
|
+
get raw(): Response;
|
|
27
17
|
getJson<T = unknown>(): Promise<T>;
|
|
28
|
-
/**
|
|
29
|
-
* Get response as text
|
|
30
|
-
*/
|
|
31
18
|
getText(): Promise<string>;
|
|
32
|
-
/**
|
|
33
|
-
* Get response as Blob
|
|
34
|
-
*/
|
|
35
19
|
getBlob(): Promise<Blob>;
|
|
36
|
-
/**
|
|
37
|
-
* Get response as ArrayBuffer
|
|
38
|
-
*/
|
|
39
20
|
getArrayBuffer(): Promise<ArrayBuffer>;
|
|
40
|
-
/**
|
|
41
|
-
* Get response as ReadableStream for true streaming consumption.
|
|
42
|
-
* This allows you to process data incrementally as it arrives.
|
|
43
|
-
*
|
|
44
|
-
* @example
|
|
45
|
-
* const stream = await request.sendTo('https://example.com/large-file').getBody();
|
|
46
|
-
* const reader = stream.getReader();
|
|
47
|
-
*
|
|
48
|
-
* while (true) {
|
|
49
|
-
* const { done, value } = await reader.read();
|
|
50
|
-
* if (done) break;
|
|
51
|
-
* // Process chunk of data in 'value'
|
|
52
|
-
* }
|
|
53
|
-
*/
|
|
54
21
|
getBody(): ReadableStream<Uint8Array> | null;
|
|
55
22
|
}
|
|
56
23
|
/**
|
package/dist/library/index.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { get, put, head, post, patch, del, options } from "./requestFactories";
|
|
2
2
|
import { Config } from "./utils/Config";
|
|
3
|
-
export { HttpMethod, RequestPriority, CredentialsPolicy, RequestMode, RedirectMode, CacheMode, SameSitePolicy
|
|
3
|
+
export { HttpMethod, RequestPriority, CredentialsPolicy, RequestMode, RedirectMode, CacheMode, SameSitePolicy } from "./enums";
|
|
4
4
|
export type { RetryCallback, RequestOptions, CookieOptions, CookiesRecord } from "./types";
|
|
5
5
|
export { RequestError } from "./RequestError";
|
|
6
6
|
export { ResponseWrapper } from "./ResponseWrapper";
|
|
7
7
|
export { CookieUtils } from "./utils/CookieUtils";
|
|
8
|
-
export { GetRequest, PostRequest, PutRequest, DeleteRequest, PatchRequest, HeadRequest, OptionsRequest
|
|
8
|
+
export { GetRequest, PostRequest, PutRequest, DeleteRequest, PatchRequest, HeadRequest, OptionsRequest } from "./requestMethods";
|
|
9
9
|
declare const create: {
|
|
10
10
|
readonly get: typeof get;
|
|
11
11
|
readonly post: typeof post;
|
|
@@ -121,72 +121,114 @@ class RequestError extends Error {
|
|
|
121
121
|
*/
|
|
122
122
|
class ResponseWrapper {
|
|
123
123
|
constructor(response) {
|
|
124
|
+
this.bodyUsed = false;
|
|
124
125
|
this.response = response;
|
|
125
|
-
this.ok = response.status >= 200 && response.status < 300; // Standard Fetch API behavior
|
|
126
126
|
}
|
|
127
|
-
|
|
128
|
-
* Get the raw Response object
|
|
129
|
-
*/
|
|
130
|
-
get raw() {
|
|
131
|
-
return this.response;
|
|
132
|
-
}
|
|
133
|
-
/**
|
|
134
|
-
* Get the HTTP status code
|
|
135
|
-
*/
|
|
127
|
+
// Wrapper properties
|
|
136
128
|
get status() {
|
|
137
129
|
return this.response.status;
|
|
138
130
|
}
|
|
139
|
-
/**
|
|
140
|
-
* Get the HTTP status text
|
|
141
|
-
*/
|
|
142
131
|
get statusText() {
|
|
143
132
|
return this.response.statusText;
|
|
144
133
|
}
|
|
145
|
-
/**
|
|
146
|
-
* Get response headers
|
|
147
|
-
*/
|
|
148
134
|
get headers() {
|
|
149
135
|
return this.response.headers;
|
|
150
136
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
137
|
+
get ok() {
|
|
138
|
+
return this.response.ok;
|
|
139
|
+
}
|
|
140
|
+
get raw() {
|
|
141
|
+
return this.response;
|
|
142
|
+
}
|
|
143
|
+
// Response body methods with caching
|
|
154
144
|
async getJson() {
|
|
155
|
-
|
|
145
|
+
if (this.jsonCache !== undefined) {
|
|
146
|
+
return this.jsonCache;
|
|
147
|
+
}
|
|
148
|
+
if (this.bodyUsed) {
|
|
149
|
+
// If we already have text, try to parse it as JSON
|
|
150
|
+
if (this.textCache !== undefined) {
|
|
151
|
+
try {
|
|
152
|
+
// Fix unsafe assignment by explicitly casting to unknown first
|
|
153
|
+
this.jsonCache = JSON.parse(this.textCache);
|
|
154
|
+
return this.jsonCache;
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
throw new Error("Cannot parse cached text as JSON");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
throw new Error("Response body has already been consumed in a different format");
|
|
161
|
+
}
|
|
162
|
+
this.bodyUsed = true;
|
|
163
|
+
this.jsonCache = (await this.response.json());
|
|
164
|
+
return this.jsonCache;
|
|
156
165
|
}
|
|
157
|
-
/**
|
|
158
|
-
* Get response as text
|
|
159
|
-
*/
|
|
160
166
|
async getText() {
|
|
161
|
-
|
|
167
|
+
if (this.textCache !== undefined) {
|
|
168
|
+
return this.textCache;
|
|
169
|
+
}
|
|
170
|
+
if (this.bodyUsed) {
|
|
171
|
+
// If we already have JSON, convert it to text
|
|
172
|
+
if (this.jsonCache !== undefined) {
|
|
173
|
+
this.textCache = JSON.stringify(this.jsonCache);
|
|
174
|
+
return this.textCache;
|
|
175
|
+
}
|
|
176
|
+
// If we have blob, convert to text
|
|
177
|
+
if (this.blobCache !== undefined) {
|
|
178
|
+
this.textCache = await this.blobCache.text();
|
|
179
|
+
return this.textCache;
|
|
180
|
+
}
|
|
181
|
+
throw new Error("Response body has already been consumed in a format that cannot be converted to text");
|
|
182
|
+
}
|
|
183
|
+
this.bodyUsed = true;
|
|
184
|
+
this.textCache = await this.response.text();
|
|
185
|
+
return this.textCache;
|
|
162
186
|
}
|
|
163
|
-
/**
|
|
164
|
-
* Get response as Blob
|
|
165
|
-
*/
|
|
166
187
|
async getBlob() {
|
|
167
|
-
|
|
188
|
+
if (this.blobCache !== undefined) {
|
|
189
|
+
return this.blobCache;
|
|
190
|
+
}
|
|
191
|
+
if (this.bodyUsed) {
|
|
192
|
+
// If we have text, convert to Blob
|
|
193
|
+
if (this.textCache !== undefined) {
|
|
194
|
+
this.blobCache = new Blob([this.textCache], { type: "text/plain" });
|
|
195
|
+
return this.blobCache;
|
|
196
|
+
}
|
|
197
|
+
throw new Error("Response body has already been consumed in a format that cannot be converted to Blob");
|
|
198
|
+
}
|
|
199
|
+
this.bodyUsed = true;
|
|
200
|
+
this.blobCache = await this.response.blob();
|
|
201
|
+
return this.blobCache;
|
|
168
202
|
}
|
|
169
|
-
/**
|
|
170
|
-
* Get response as ArrayBuffer
|
|
171
|
-
*/
|
|
172
203
|
async getArrayBuffer() {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
204
|
+
if (this.arrayBufferCache !== undefined) {
|
|
205
|
+
return this.arrayBufferCache;
|
|
206
|
+
}
|
|
207
|
+
if (this.bodyUsed) {
|
|
208
|
+
// If we have text, convert to ArrayBuffer
|
|
209
|
+
if (this.textCache !== undefined) {
|
|
210
|
+
const encoder = new TextEncoder();
|
|
211
|
+
// Fix ArrayBufferLike by explicitly casting to ArrayBuffer
|
|
212
|
+
const uint8Array = encoder.encode(this.textCache);
|
|
213
|
+
this.arrayBufferCache = uint8Array.buffer;
|
|
214
|
+
return this.arrayBufferCache;
|
|
215
|
+
}
|
|
216
|
+
// If we have blob, convert to ArrayBuffer
|
|
217
|
+
if (this.blobCache !== undefined) {
|
|
218
|
+
this.arrayBufferCache = await this.blobCache.arrayBuffer();
|
|
219
|
+
return this.arrayBufferCache;
|
|
220
|
+
}
|
|
221
|
+
throw new Error("Response body has already been consumed in a format that cannot be converted to ArrayBuffer");
|
|
222
|
+
}
|
|
223
|
+
this.bodyUsed = true;
|
|
224
|
+
this.arrayBufferCache = await this.response.arrayBuffer();
|
|
225
|
+
return this.arrayBufferCache;
|
|
226
|
+
}
|
|
189
227
|
getBody() {
|
|
228
|
+
if (this.bodyUsed) {
|
|
229
|
+
throw new Error("Response body has already been consumed");
|
|
230
|
+
}
|
|
231
|
+
this.bodyUsed = true;
|
|
190
232
|
return this.response.body;
|
|
191
233
|
}
|
|
192
234
|
}
|
|
@@ -292,15 +334,15 @@ class Config {
|
|
|
292
334
|
return this.enableAntiCsrf;
|
|
293
335
|
}
|
|
294
336
|
/**
|
|
295
|
-
* Reset configuration to defaults
|
|
337
|
+
* Reset all configuration options to their defaults
|
|
296
338
|
*/
|
|
297
339
|
reset() {
|
|
298
340
|
this.csrfToken = null;
|
|
299
341
|
this.csrfHeaderName = "X-CSRF-Token";
|
|
342
|
+
this.enableAntiCsrf = true;
|
|
300
343
|
this.xsrfCookieName = "XSRF-TOKEN";
|
|
301
344
|
this.xsrfHeaderName = "X-XSRF-TOKEN";
|
|
302
345
|
this.enableAutoXsrf = true;
|
|
303
|
-
this.enableAntiCsrf = true;
|
|
304
346
|
return this;
|
|
305
347
|
}
|
|
306
348
|
}
|
|
@@ -365,12 +407,34 @@ class CsrfUtils {
|
|
|
365
407
|
}
|
|
366
408
|
/**
|
|
367
409
|
* Validates if the provided string is a potential CSRF token
|
|
368
|
-
* Checks if the token meets
|
|
410
|
+
* Checks if the token meets security requirements
|
|
369
411
|
* @param token The token to validate
|
|
370
|
-
* @returns Whether the token is
|
|
412
|
+
* @returns Whether the token is valid
|
|
371
413
|
*/
|
|
372
414
|
static isValidToken(token) {
|
|
373
|
-
|
|
415
|
+
// Basic checks for null/undefined and type
|
|
416
|
+
if (typeof token !== "string") {
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
if (token.length < 8)
|
|
420
|
+
return false;
|
|
421
|
+
// If token is longer than 10 chars, perform additional security checks
|
|
422
|
+
if (token.length > 10) {
|
|
423
|
+
// Check for valid character set (alphanumeric & common token symbols)
|
|
424
|
+
const validTokenRegex = /^[A-Za-z0-9\-_=+/.]+$/;
|
|
425
|
+
if (!validTokenRegex.test(token)) {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
// For longer tokens, check for sufficient entropy (at least 2 character types)
|
|
429
|
+
const hasUpperCase = /[A-Z]/.test(token);
|
|
430
|
+
const hasLowerCase = /[a-z]/.test(token);
|
|
431
|
+
const hasNumbers = /[0-9]/.test(token);
|
|
432
|
+
const hasSpecials = /[-_=+/.]/.test(token);
|
|
433
|
+
const characterTypesCount = [hasUpperCase, hasLowerCase, hasNumbers, hasSpecials].filter(Boolean).length;
|
|
434
|
+
return characterTypesCount >= 2;
|
|
435
|
+
}
|
|
436
|
+
// For shorter tokens (8-10 chars), just return true if we reached here
|
|
437
|
+
return true;
|
|
374
438
|
}
|
|
375
439
|
}
|
|
376
440
|
|
|
@@ -391,10 +455,14 @@ class BaseRequest {
|
|
|
391
455
|
return this;
|
|
392
456
|
}
|
|
393
457
|
withTimeout(timeout) {
|
|
458
|
+
if (!Number.isFinite(timeout) || timeout <= 0)
|
|
459
|
+
throw new Error("Timeout must be a positive number");
|
|
394
460
|
this.requestOptions.timeout = timeout;
|
|
395
461
|
return this;
|
|
396
462
|
}
|
|
397
463
|
withRetries(retries) {
|
|
464
|
+
if (!Number.isInteger(retries) || retries < 0)
|
|
465
|
+
throw new Error("Retry count must be a non-negative integer");
|
|
398
466
|
this.requestOptions.retries = retries;
|
|
399
467
|
return this;
|
|
400
468
|
}
|
|
@@ -479,13 +547,16 @@ class BaseRequest {
|
|
|
479
547
|
/**
|
|
480
548
|
* Adds a single query parameter
|
|
481
549
|
* @param key - The parameter name
|
|
482
|
-
* @param value - The parameter value
|
|
550
|
+
* @param value - The parameter value, can be a single value or array of values
|
|
483
551
|
* @returns The instance for chaining
|
|
484
552
|
*/
|
|
485
553
|
withQueryParam(key, value) {
|
|
486
|
-
if (value
|
|
554
|
+
if (value === null || value === undefined)
|
|
555
|
+
return this;
|
|
556
|
+
if (Array.isArray(value))
|
|
557
|
+
value.forEach(v => this.queryParams.append(key, String(v)));
|
|
558
|
+
else
|
|
487
559
|
this.queryParams.append(key, String(value));
|
|
488
|
-
}
|
|
489
560
|
return this;
|
|
490
561
|
}
|
|
491
562
|
/**
|
|
@@ -524,9 +595,23 @@ class BaseRequest {
|
|
|
524
595
|
* @returns The instance for chaining
|
|
525
596
|
*/
|
|
526
597
|
withBasicAuth(username, password) {
|
|
527
|
-
const credentials =
|
|
598
|
+
const credentials = this.encodeBase64(`${username}:${password}`);
|
|
528
599
|
return this.withAuthorization(`Basic ${credentials}`);
|
|
529
600
|
}
|
|
601
|
+
/**
|
|
602
|
+
* Cross-environment base64 encoding
|
|
603
|
+
* Works in both browser and Node.js environments
|
|
604
|
+
*/
|
|
605
|
+
encodeBase64(str) {
|
|
606
|
+
// Browser environment
|
|
607
|
+
if (typeof btoa === "function")
|
|
608
|
+
return btoa(str);
|
|
609
|
+
// Node.js environment
|
|
610
|
+
if (typeof Buffer !== "undefined")
|
|
611
|
+
return Buffer.from(str).toString("base64");
|
|
612
|
+
// Fallback (should never happen in modern environments)
|
|
613
|
+
throw new Error("Base64 encoding is not supported in this environment");
|
|
614
|
+
}
|
|
530
615
|
/**
|
|
531
616
|
* Sets the bearer token for authentication
|
|
532
617
|
* @param token - The bearer token
|
|
@@ -535,6 +620,15 @@ class BaseRequest {
|
|
|
535
620
|
withBearerToken(token) {
|
|
536
621
|
return this.withAuthorization(`Bearer ${token}`);
|
|
537
622
|
}
|
|
623
|
+
/**
|
|
624
|
+
* Helper function to check for header presence in a case-insensitive way
|
|
625
|
+
* @param headers Headers object
|
|
626
|
+
* @param headerName Header name to check
|
|
627
|
+
* @returns Boolean indicating if the header exists (case-insensitive)
|
|
628
|
+
*/
|
|
629
|
+
hasHeader(headers, headerName) {
|
|
630
|
+
return Object.keys(headers).some(key => key.toLowerCase() === headerName.toLowerCase());
|
|
631
|
+
}
|
|
538
632
|
/**
|
|
539
633
|
* Sets cookies for the request
|
|
540
634
|
* @param cookies Object containing cookie name-value pairs or cookie options
|
|
@@ -547,19 +641,34 @@ class BaseRequest {
|
|
|
547
641
|
}
|
|
548
642
|
// Get current headers or initialize empty object
|
|
549
643
|
const currentHeaders = this.requestOptions.headers || {};
|
|
550
|
-
// Get
|
|
551
|
-
|
|
552
|
-
const
|
|
553
|
-
|
|
554
|
-
|
|
644
|
+
// Get all existing cookie headers with different cases
|
|
645
|
+
const cookieValues = [];
|
|
646
|
+
const cookieHeaderKeys = [];
|
|
647
|
+
Object.keys(currentHeaders).forEach(key => {
|
|
648
|
+
if (key.toLowerCase() === "cookie") {
|
|
649
|
+
// Don't add empty cookie values
|
|
650
|
+
if (currentHeaders[key])
|
|
651
|
+
cookieValues.push(currentHeaders[key]);
|
|
652
|
+
cookieHeaderKeys.push(key);
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
// Format the new cookies
|
|
555
656
|
const cookieString = CookieUtils.formatRequestCookies(cookies);
|
|
556
|
-
//
|
|
557
|
-
const
|
|
558
|
-
//
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
|
|
657
|
+
// Choose which header name to use - preserve existing case if possible
|
|
658
|
+
const headerName = cookieHeaderKeys.length > 0 ? cookieHeaderKeys[0] : "Cookie";
|
|
659
|
+
// Combine all existing cookie values with the new ones
|
|
660
|
+
const combinedCookieValue = [...cookieValues, cookieString].filter(Boolean).join("; ");
|
|
661
|
+
// Create a new headers object without any cookie headers
|
|
662
|
+
const newHeaders = { ...currentHeaders };
|
|
663
|
+
cookieHeaderKeys.forEach(key => {
|
|
664
|
+
delete newHeaders[key];
|
|
562
665
|
});
|
|
666
|
+
// Set the new combined cookie header
|
|
667
|
+
this.requestOptions.headers = {
|
|
668
|
+
...newHeaders,
|
|
669
|
+
[headerName]: combinedCookieValue,
|
|
670
|
+
};
|
|
671
|
+
return this;
|
|
563
672
|
}
|
|
564
673
|
/**
|
|
565
674
|
* Set a single cookie
|
|
@@ -618,12 +727,13 @@ class BaseRequest {
|
|
|
618
727
|
// Apply global CSRF token if set
|
|
619
728
|
const globalToken = config.getCsrfToken();
|
|
620
729
|
if (globalToken) {
|
|
621
|
-
// Check if local token exists
|
|
730
|
+
// Check if local token exists using case-insensitive comparison
|
|
622
731
|
const headers = this.requestOptions.headers;
|
|
623
|
-
const
|
|
732
|
+
const csrfHeaderName = config.getCsrfHeaderName();
|
|
733
|
+
const hasLocalToken = this.hasHeader(headers, "X-CSRF-Token") || this.hasHeader(headers, csrfHeaderName);
|
|
624
734
|
if (!hasLocalToken) {
|
|
625
735
|
this.withHeaders({
|
|
626
|
-
[
|
|
736
|
+
[csrfHeaderName]: globalToken,
|
|
627
737
|
});
|
|
628
738
|
}
|
|
629
739
|
}
|
|
@@ -632,10 +742,11 @@ class BaseRequest {
|
|
|
632
742
|
const xsrfToken = CsrfUtils.getTokenFromCookie(config.getXsrfCookieName());
|
|
633
743
|
if (xsrfToken && CsrfUtils.isValidToken(xsrfToken)) {
|
|
634
744
|
const headers = this.requestOptions.headers;
|
|
635
|
-
const
|
|
745
|
+
const xsrfHeaderName = config.getXsrfHeaderName();
|
|
746
|
+
const hasLocalToken = this.hasHeader(headers, "X-XSRF-TOKEN") || this.hasHeader(headers, xsrfHeaderName);
|
|
636
747
|
if (!hasLocalToken) {
|
|
637
748
|
this.withHeaders({
|
|
638
|
-
[
|
|
749
|
+
[xsrfHeaderName]: xsrfToken,
|
|
639
750
|
});
|
|
640
751
|
}
|
|
641
752
|
}
|
|
@@ -647,9 +758,7 @@ class BaseRequest {
|
|
|
647
758
|
method: this.method,
|
|
648
759
|
};
|
|
649
760
|
// Create the base promise
|
|
650
|
-
const basePromise = !this.requestOptions.retries
|
|
651
|
-
? this.executeRequest(url, fetchOptions)
|
|
652
|
-
: this.executeWithRetries(url, fetchOptions);
|
|
761
|
+
const basePromise = !this.requestOptions.retries ? this.executeRequest(url, fetchOptions) : this.executeWithRetries(url, fetchOptions);
|
|
653
762
|
const responsePromise = basePromise;
|
|
654
763
|
// Attach convenience methods that begin processing immediately
|
|
655
764
|
responsePromise.getJson = async () => {
|
|
@@ -678,12 +787,21 @@ class BaseRequest {
|
|
|
678
787
|
if (!this.queryParams.toString()) {
|
|
679
788
|
return url;
|
|
680
789
|
}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
790
|
+
try {
|
|
791
|
+
// Try to use the URL constructor (works for absolute URLs)
|
|
792
|
+
const urlObj = new URL(url);
|
|
793
|
+
// Merge our query params with any that might be in the URL already
|
|
794
|
+
this.queryParams.forEach((value, key) => {
|
|
795
|
+
urlObj.searchParams.append(key, value);
|
|
796
|
+
});
|
|
797
|
+
return urlObj.toString();
|
|
798
|
+
}
|
|
799
|
+
catch (error) {
|
|
800
|
+
// Handle relative URLs
|
|
801
|
+
const hasExistingParams = url.includes("?");
|
|
802
|
+
const separator = hasExistingParams ? "&" : "?";
|
|
803
|
+
return `${url}${separator}${this.queryParams.toString()}`;
|
|
804
|
+
}
|
|
687
805
|
}
|
|
688
806
|
/**
|
|
689
807
|
* Executes a request with configured retry logic
|
|
@@ -694,38 +812,65 @@ class BaseRequest {
|
|
|
694
812
|
async executeWithRetries(url, fetchOptions) {
|
|
695
813
|
let attempt = 0;
|
|
696
814
|
const maxRetries = this.requestOptions.retries || 0;
|
|
697
|
-
|
|
698
|
-
while (true) {
|
|
815
|
+
do {
|
|
699
816
|
try {
|
|
700
817
|
return await this.executeRequest(url, fetchOptions);
|
|
701
818
|
}
|
|
702
819
|
catch (error) {
|
|
703
|
-
const requestError = error instanceof RequestError
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
if (attempt >= maxRetries || requestError.timeoutError) {
|
|
820
|
+
const requestError = error instanceof RequestError ? error : RequestError.networkError(url, fetchOptions.method, error);
|
|
821
|
+
// Don't retry on timeout errors or if we've reached the max retries
|
|
822
|
+
if (attempt >= maxRetries || requestError.timeoutError)
|
|
707
823
|
throw requestError;
|
|
708
|
-
}
|
|
709
824
|
attempt++;
|
|
710
825
|
if (this.requestOptions.onRetry) {
|
|
711
826
|
await this.requestOptions.onRetry({ attempt, error: requestError });
|
|
712
827
|
}
|
|
713
828
|
}
|
|
714
|
-
}
|
|
829
|
+
} while (attempt <= maxRetries);
|
|
830
|
+
// If we somehow get here, we throw an error to avoid undefined behavior
|
|
831
|
+
throw new RequestError(`Max retries reached (${maxRetries}) but request still failed`, url, fetchOptions.method);
|
|
715
832
|
}
|
|
716
833
|
async executeRequest(url, fetchOptions) {
|
|
717
834
|
// Create a request controller for timeout if needed, or use the provided one
|
|
718
835
|
const controller = this.abortController || new AbortController();
|
|
719
836
|
const { signal } = controller;
|
|
720
837
|
// Use the controller signal for the fetch request, but don't overwrite an existing signal
|
|
721
|
-
// that might have been set in requestOptions if it exists
|
|
722
838
|
fetchOptions.signal = fetchOptions.signal || signal;
|
|
723
839
|
let timeoutId;
|
|
724
|
-
//
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
840
|
+
// Flag to track if abort was caused by our timeout
|
|
841
|
+
let abortedByTimeout = false;
|
|
842
|
+
// Handle timeout regardless of whether an external controller is provided
|
|
843
|
+
if (this.requestOptions.timeout) {
|
|
844
|
+
if (this.abortController) {
|
|
845
|
+
// If we have an external controller, create a separate timeout controller
|
|
846
|
+
// that won't interfere with the external one
|
|
847
|
+
// Use AbortSignal.timeout if available (modern browsers)
|
|
848
|
+
if (typeof AbortSignal.timeout === "function") {
|
|
849
|
+
const timeoutSignal = AbortSignal.timeout(this.requestOptions.timeout);
|
|
850
|
+
// Add event listener to propagate the abort to the main controller
|
|
851
|
+
timeoutSignal.addEventListener("abort", () => {
|
|
852
|
+
if (!controller.signal.aborted) {
|
|
853
|
+
abortedByTimeout = true;
|
|
854
|
+
controller.abort();
|
|
855
|
+
}
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
else {
|
|
859
|
+
// Fallback for browsers without AbortSignal.timeout
|
|
860
|
+
timeoutId = setTimeout(() => {
|
|
861
|
+
if (!controller.signal.aborted) {
|
|
862
|
+
abortedByTimeout = true;
|
|
863
|
+
controller.abort();
|
|
864
|
+
}
|
|
865
|
+
}, this.requestOptions.timeout);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
else {
|
|
869
|
+
timeoutId = setTimeout(() => {
|
|
870
|
+
abortedByTimeout = true;
|
|
871
|
+
controller.abort();
|
|
872
|
+
}, this.requestOptions.timeout);
|
|
873
|
+
}
|
|
729
874
|
}
|
|
730
875
|
try {
|
|
731
876
|
let response;
|
|
@@ -733,11 +878,11 @@ class BaseRequest {
|
|
|
733
878
|
response = await fetch(url, fetchOptions);
|
|
734
879
|
}
|
|
735
880
|
catch (error) {
|
|
736
|
-
// Check if this is an abort error
|
|
737
|
-
if (error instanceof DOMException && error.name === "AbortError" &&
|
|
881
|
+
// Check if this is an abort error that was specifically caused by our timeout
|
|
882
|
+
if (error instanceof DOMException && error.name === "AbortError" && abortedByTimeout) {
|
|
738
883
|
throw RequestError.timeout(url, fetchOptions.method, this.requestOptions.timeout);
|
|
739
884
|
}
|
|
740
|
-
// Otherwise it's a network error
|
|
885
|
+
// Otherwise it's either a user-triggered abort or another network error
|
|
741
886
|
throw RequestError.networkError(url, fetchOptions.method, error);
|
|
742
887
|
}
|
|
743
888
|
if (!response.ok) {
|
|
@@ -758,17 +903,12 @@ class BaseRequest {
|
|
|
758
903
|
* Base class for requests that can have a body (POST, PUT, PATCH)
|
|
759
904
|
*/
|
|
760
905
|
class BodyRequest extends BaseRequest {
|
|
761
|
-
constructor() {
|
|
762
|
-
super(...arguments);
|
|
763
|
-
this.bodyProcessed = false;
|
|
764
|
-
}
|
|
765
906
|
/**
|
|
766
907
|
* Sets the body of the request
|
|
767
908
|
* @param body - The request body
|
|
768
909
|
*/
|
|
769
910
|
withBody(body) {
|
|
770
911
|
this.body = body;
|
|
771
|
-
this.bodyProcessed = false;
|
|
772
912
|
// Set body type and validate
|
|
773
913
|
if (typeof body === "string") {
|
|
774
914
|
this.bodyType = BodyType.STRING;
|
|
@@ -776,11 +916,7 @@ class BodyRequest extends BaseRequest {
|
|
|
776
916
|
}
|
|
777
917
|
else if (body !== null &&
|
|
778
918
|
typeof body === "object" &&
|
|
779
|
-
!(body instanceof FormData ||
|
|
780
|
-
body instanceof Blob ||
|
|
781
|
-
body instanceof ArrayBuffer ||
|
|
782
|
-
body instanceof URLSearchParams ||
|
|
783
|
-
body instanceof ReadableStream)) {
|
|
919
|
+
!(body instanceof FormData || body instanceof Blob || body instanceof ArrayBuffer || body instanceof URLSearchParams || body instanceof ReadableStream)) {
|
|
784
920
|
this.bodyType = BodyType.JSON;
|
|
785
921
|
this.setContentTypeIfNeeded("application/json");
|
|
786
922
|
// Validate JSON is stringifiable early
|
|
@@ -808,15 +944,17 @@ class BodyRequest extends BaseRequest {
|
|
|
808
944
|
* Overrides the base implementation to add body handling
|
|
809
945
|
*/
|
|
810
946
|
sendTo(url) {
|
|
811
|
-
|
|
812
|
-
|
|
947
|
+
if (this.body !== undefined) {
|
|
948
|
+
// Remove previous body if it exists
|
|
949
|
+
if (this.requestOptions.body)
|
|
950
|
+
delete this.requestOptions.body;
|
|
951
|
+
// Process the body based on its type
|
|
813
952
|
if (this.bodyType === BodyType.JSON) {
|
|
814
953
|
this.requestOptions.body = JSON.stringify(this.body);
|
|
815
954
|
}
|
|
816
955
|
else {
|
|
817
956
|
this.requestOptions.body = this.body;
|
|
818
957
|
}
|
|
819
|
-
this.bodyProcessed = true;
|
|
820
958
|
}
|
|
821
959
|
return super.sendTo(url);
|
|
822
960
|
}
|
package/dist/library/index.js
CHANGED
|
@@ -127,72 +127,114 @@
|
|
|
127
127
|
*/
|
|
128
128
|
class ResponseWrapper {
|
|
129
129
|
constructor(response) {
|
|
130
|
+
this.bodyUsed = false;
|
|
130
131
|
this.response = response;
|
|
131
|
-
this.ok = response.status >= 200 && response.status < 300; // Standard Fetch API behavior
|
|
132
132
|
}
|
|
133
|
-
|
|
134
|
-
* Get the raw Response object
|
|
135
|
-
*/
|
|
136
|
-
get raw() {
|
|
137
|
-
return this.response;
|
|
138
|
-
}
|
|
139
|
-
/**
|
|
140
|
-
* Get the HTTP status code
|
|
141
|
-
*/
|
|
133
|
+
// Wrapper properties
|
|
142
134
|
get status() {
|
|
143
135
|
return this.response.status;
|
|
144
136
|
}
|
|
145
|
-
/**
|
|
146
|
-
* Get the HTTP status text
|
|
147
|
-
*/
|
|
148
137
|
get statusText() {
|
|
149
138
|
return this.response.statusText;
|
|
150
139
|
}
|
|
151
|
-
/**
|
|
152
|
-
* Get response headers
|
|
153
|
-
*/
|
|
154
140
|
get headers() {
|
|
155
141
|
return this.response.headers;
|
|
156
142
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
143
|
+
get ok() {
|
|
144
|
+
return this.response.ok;
|
|
145
|
+
}
|
|
146
|
+
get raw() {
|
|
147
|
+
return this.response;
|
|
148
|
+
}
|
|
149
|
+
// Response body methods with caching
|
|
160
150
|
async getJson() {
|
|
161
|
-
|
|
151
|
+
if (this.jsonCache !== undefined) {
|
|
152
|
+
return this.jsonCache;
|
|
153
|
+
}
|
|
154
|
+
if (this.bodyUsed) {
|
|
155
|
+
// If we already have text, try to parse it as JSON
|
|
156
|
+
if (this.textCache !== undefined) {
|
|
157
|
+
try {
|
|
158
|
+
// Fix unsafe assignment by explicitly casting to unknown first
|
|
159
|
+
this.jsonCache = JSON.parse(this.textCache);
|
|
160
|
+
return this.jsonCache;
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
throw new Error("Cannot parse cached text as JSON");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
throw new Error("Response body has already been consumed in a different format");
|
|
167
|
+
}
|
|
168
|
+
this.bodyUsed = true;
|
|
169
|
+
this.jsonCache = (await this.response.json());
|
|
170
|
+
return this.jsonCache;
|
|
162
171
|
}
|
|
163
|
-
/**
|
|
164
|
-
* Get response as text
|
|
165
|
-
*/
|
|
166
172
|
async getText() {
|
|
167
|
-
|
|
173
|
+
if (this.textCache !== undefined) {
|
|
174
|
+
return this.textCache;
|
|
175
|
+
}
|
|
176
|
+
if (this.bodyUsed) {
|
|
177
|
+
// If we already have JSON, convert it to text
|
|
178
|
+
if (this.jsonCache !== undefined) {
|
|
179
|
+
this.textCache = JSON.stringify(this.jsonCache);
|
|
180
|
+
return this.textCache;
|
|
181
|
+
}
|
|
182
|
+
// If we have blob, convert to text
|
|
183
|
+
if (this.blobCache !== undefined) {
|
|
184
|
+
this.textCache = await this.blobCache.text();
|
|
185
|
+
return this.textCache;
|
|
186
|
+
}
|
|
187
|
+
throw new Error("Response body has already been consumed in a format that cannot be converted to text");
|
|
188
|
+
}
|
|
189
|
+
this.bodyUsed = true;
|
|
190
|
+
this.textCache = await this.response.text();
|
|
191
|
+
return this.textCache;
|
|
168
192
|
}
|
|
169
|
-
/**
|
|
170
|
-
* Get response as Blob
|
|
171
|
-
*/
|
|
172
193
|
async getBlob() {
|
|
173
|
-
|
|
194
|
+
if (this.blobCache !== undefined) {
|
|
195
|
+
return this.blobCache;
|
|
196
|
+
}
|
|
197
|
+
if (this.bodyUsed) {
|
|
198
|
+
// If we have text, convert to Blob
|
|
199
|
+
if (this.textCache !== undefined) {
|
|
200
|
+
this.blobCache = new Blob([this.textCache], { type: "text/plain" });
|
|
201
|
+
return this.blobCache;
|
|
202
|
+
}
|
|
203
|
+
throw new Error("Response body has already been consumed in a format that cannot be converted to Blob");
|
|
204
|
+
}
|
|
205
|
+
this.bodyUsed = true;
|
|
206
|
+
this.blobCache = await this.response.blob();
|
|
207
|
+
return this.blobCache;
|
|
174
208
|
}
|
|
175
|
-
/**
|
|
176
|
-
* Get response as ArrayBuffer
|
|
177
|
-
*/
|
|
178
209
|
async getArrayBuffer() {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
210
|
+
if (this.arrayBufferCache !== undefined) {
|
|
211
|
+
return this.arrayBufferCache;
|
|
212
|
+
}
|
|
213
|
+
if (this.bodyUsed) {
|
|
214
|
+
// If we have text, convert to ArrayBuffer
|
|
215
|
+
if (this.textCache !== undefined) {
|
|
216
|
+
const encoder = new TextEncoder();
|
|
217
|
+
// Fix ArrayBufferLike by explicitly casting to ArrayBuffer
|
|
218
|
+
const uint8Array = encoder.encode(this.textCache);
|
|
219
|
+
this.arrayBufferCache = uint8Array.buffer;
|
|
220
|
+
return this.arrayBufferCache;
|
|
221
|
+
}
|
|
222
|
+
// If we have blob, convert to ArrayBuffer
|
|
223
|
+
if (this.blobCache !== undefined) {
|
|
224
|
+
this.arrayBufferCache = await this.blobCache.arrayBuffer();
|
|
225
|
+
return this.arrayBufferCache;
|
|
226
|
+
}
|
|
227
|
+
throw new Error("Response body has already been consumed in a format that cannot be converted to ArrayBuffer");
|
|
228
|
+
}
|
|
229
|
+
this.bodyUsed = true;
|
|
230
|
+
this.arrayBufferCache = await this.response.arrayBuffer();
|
|
231
|
+
return this.arrayBufferCache;
|
|
232
|
+
}
|
|
195
233
|
getBody() {
|
|
234
|
+
if (this.bodyUsed) {
|
|
235
|
+
throw new Error("Response body has already been consumed");
|
|
236
|
+
}
|
|
237
|
+
this.bodyUsed = true;
|
|
196
238
|
return this.response.body;
|
|
197
239
|
}
|
|
198
240
|
}
|
|
@@ -298,15 +340,15 @@
|
|
|
298
340
|
return this.enableAntiCsrf;
|
|
299
341
|
}
|
|
300
342
|
/**
|
|
301
|
-
* Reset configuration to defaults
|
|
343
|
+
* Reset all configuration options to their defaults
|
|
302
344
|
*/
|
|
303
345
|
reset() {
|
|
304
346
|
this.csrfToken = null;
|
|
305
347
|
this.csrfHeaderName = "X-CSRF-Token";
|
|
348
|
+
this.enableAntiCsrf = true;
|
|
306
349
|
this.xsrfCookieName = "XSRF-TOKEN";
|
|
307
350
|
this.xsrfHeaderName = "X-XSRF-TOKEN";
|
|
308
351
|
this.enableAutoXsrf = true;
|
|
309
|
-
this.enableAntiCsrf = true;
|
|
310
352
|
return this;
|
|
311
353
|
}
|
|
312
354
|
}
|
|
@@ -371,12 +413,34 @@
|
|
|
371
413
|
}
|
|
372
414
|
/**
|
|
373
415
|
* Validates if the provided string is a potential CSRF token
|
|
374
|
-
* Checks if the token meets
|
|
416
|
+
* Checks if the token meets security requirements
|
|
375
417
|
* @param token The token to validate
|
|
376
|
-
* @returns Whether the token is
|
|
418
|
+
* @returns Whether the token is valid
|
|
377
419
|
*/
|
|
378
420
|
static isValidToken(token) {
|
|
379
|
-
|
|
421
|
+
// Basic checks for null/undefined and type
|
|
422
|
+
if (typeof token !== "string") {
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
if (token.length < 8)
|
|
426
|
+
return false;
|
|
427
|
+
// If token is longer than 10 chars, perform additional security checks
|
|
428
|
+
if (token.length > 10) {
|
|
429
|
+
// Check for valid character set (alphanumeric & common token symbols)
|
|
430
|
+
const validTokenRegex = /^[A-Za-z0-9\-_=+/.]+$/;
|
|
431
|
+
if (!validTokenRegex.test(token)) {
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
// For longer tokens, check for sufficient entropy (at least 2 character types)
|
|
435
|
+
const hasUpperCase = /[A-Z]/.test(token);
|
|
436
|
+
const hasLowerCase = /[a-z]/.test(token);
|
|
437
|
+
const hasNumbers = /[0-9]/.test(token);
|
|
438
|
+
const hasSpecials = /[-_=+/.]/.test(token);
|
|
439
|
+
const characterTypesCount = [hasUpperCase, hasLowerCase, hasNumbers, hasSpecials].filter(Boolean).length;
|
|
440
|
+
return characterTypesCount >= 2;
|
|
441
|
+
}
|
|
442
|
+
// For shorter tokens (8-10 chars), just return true if we reached here
|
|
443
|
+
return true;
|
|
380
444
|
}
|
|
381
445
|
}
|
|
382
446
|
|
|
@@ -397,10 +461,14 @@
|
|
|
397
461
|
return this;
|
|
398
462
|
}
|
|
399
463
|
withTimeout(timeout) {
|
|
464
|
+
if (!Number.isFinite(timeout) || timeout <= 0)
|
|
465
|
+
throw new Error("Timeout must be a positive number");
|
|
400
466
|
this.requestOptions.timeout = timeout;
|
|
401
467
|
return this;
|
|
402
468
|
}
|
|
403
469
|
withRetries(retries) {
|
|
470
|
+
if (!Number.isInteger(retries) || retries < 0)
|
|
471
|
+
throw new Error("Retry count must be a non-negative integer");
|
|
404
472
|
this.requestOptions.retries = retries;
|
|
405
473
|
return this;
|
|
406
474
|
}
|
|
@@ -485,13 +553,16 @@
|
|
|
485
553
|
/**
|
|
486
554
|
* Adds a single query parameter
|
|
487
555
|
* @param key - The parameter name
|
|
488
|
-
* @param value - The parameter value
|
|
556
|
+
* @param value - The parameter value, can be a single value or array of values
|
|
489
557
|
* @returns The instance for chaining
|
|
490
558
|
*/
|
|
491
559
|
withQueryParam(key, value) {
|
|
492
|
-
if (value
|
|
560
|
+
if (value === null || value === undefined)
|
|
561
|
+
return this;
|
|
562
|
+
if (Array.isArray(value))
|
|
563
|
+
value.forEach(v => this.queryParams.append(key, String(v)));
|
|
564
|
+
else
|
|
493
565
|
this.queryParams.append(key, String(value));
|
|
494
|
-
}
|
|
495
566
|
return this;
|
|
496
567
|
}
|
|
497
568
|
/**
|
|
@@ -530,9 +601,23 @@
|
|
|
530
601
|
* @returns The instance for chaining
|
|
531
602
|
*/
|
|
532
603
|
withBasicAuth(username, password) {
|
|
533
|
-
const credentials =
|
|
604
|
+
const credentials = this.encodeBase64(`${username}:${password}`);
|
|
534
605
|
return this.withAuthorization(`Basic ${credentials}`);
|
|
535
606
|
}
|
|
607
|
+
/**
|
|
608
|
+
* Cross-environment base64 encoding
|
|
609
|
+
* Works in both browser and Node.js environments
|
|
610
|
+
*/
|
|
611
|
+
encodeBase64(str) {
|
|
612
|
+
// Browser environment
|
|
613
|
+
if (typeof btoa === "function")
|
|
614
|
+
return btoa(str);
|
|
615
|
+
// Node.js environment
|
|
616
|
+
if (typeof Buffer !== "undefined")
|
|
617
|
+
return Buffer.from(str).toString("base64");
|
|
618
|
+
// Fallback (should never happen in modern environments)
|
|
619
|
+
throw new Error("Base64 encoding is not supported in this environment");
|
|
620
|
+
}
|
|
536
621
|
/**
|
|
537
622
|
* Sets the bearer token for authentication
|
|
538
623
|
* @param token - The bearer token
|
|
@@ -541,6 +626,15 @@
|
|
|
541
626
|
withBearerToken(token) {
|
|
542
627
|
return this.withAuthorization(`Bearer ${token}`);
|
|
543
628
|
}
|
|
629
|
+
/**
|
|
630
|
+
* Helper function to check for header presence in a case-insensitive way
|
|
631
|
+
* @param headers Headers object
|
|
632
|
+
* @param headerName Header name to check
|
|
633
|
+
* @returns Boolean indicating if the header exists (case-insensitive)
|
|
634
|
+
*/
|
|
635
|
+
hasHeader(headers, headerName) {
|
|
636
|
+
return Object.keys(headers).some(key => key.toLowerCase() === headerName.toLowerCase());
|
|
637
|
+
}
|
|
544
638
|
/**
|
|
545
639
|
* Sets cookies for the request
|
|
546
640
|
* @param cookies Object containing cookie name-value pairs or cookie options
|
|
@@ -553,19 +647,34 @@
|
|
|
553
647
|
}
|
|
554
648
|
// Get current headers or initialize empty object
|
|
555
649
|
const currentHeaders = this.requestOptions.headers || {};
|
|
556
|
-
// Get
|
|
557
|
-
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
650
|
+
// Get all existing cookie headers with different cases
|
|
651
|
+
const cookieValues = [];
|
|
652
|
+
const cookieHeaderKeys = [];
|
|
653
|
+
Object.keys(currentHeaders).forEach(key => {
|
|
654
|
+
if (key.toLowerCase() === "cookie") {
|
|
655
|
+
// Don't add empty cookie values
|
|
656
|
+
if (currentHeaders[key])
|
|
657
|
+
cookieValues.push(currentHeaders[key]);
|
|
658
|
+
cookieHeaderKeys.push(key);
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
// Format the new cookies
|
|
561
662
|
const cookieString = CookieUtils.formatRequestCookies(cookies);
|
|
562
|
-
//
|
|
563
|
-
const
|
|
564
|
-
//
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
663
|
+
// Choose which header name to use - preserve existing case if possible
|
|
664
|
+
const headerName = cookieHeaderKeys.length > 0 ? cookieHeaderKeys[0] : "Cookie";
|
|
665
|
+
// Combine all existing cookie values with the new ones
|
|
666
|
+
const combinedCookieValue = [...cookieValues, cookieString].filter(Boolean).join("; ");
|
|
667
|
+
// Create a new headers object without any cookie headers
|
|
668
|
+
const newHeaders = { ...currentHeaders };
|
|
669
|
+
cookieHeaderKeys.forEach(key => {
|
|
670
|
+
delete newHeaders[key];
|
|
568
671
|
});
|
|
672
|
+
// Set the new combined cookie header
|
|
673
|
+
this.requestOptions.headers = {
|
|
674
|
+
...newHeaders,
|
|
675
|
+
[headerName]: combinedCookieValue,
|
|
676
|
+
};
|
|
677
|
+
return this;
|
|
569
678
|
}
|
|
570
679
|
/**
|
|
571
680
|
* Set a single cookie
|
|
@@ -624,12 +733,13 @@
|
|
|
624
733
|
// Apply global CSRF token if set
|
|
625
734
|
const globalToken = config.getCsrfToken();
|
|
626
735
|
if (globalToken) {
|
|
627
|
-
// Check if local token exists
|
|
736
|
+
// Check if local token exists using case-insensitive comparison
|
|
628
737
|
const headers = this.requestOptions.headers;
|
|
629
|
-
const
|
|
738
|
+
const csrfHeaderName = config.getCsrfHeaderName();
|
|
739
|
+
const hasLocalToken = this.hasHeader(headers, "X-CSRF-Token") || this.hasHeader(headers, csrfHeaderName);
|
|
630
740
|
if (!hasLocalToken) {
|
|
631
741
|
this.withHeaders({
|
|
632
|
-
[
|
|
742
|
+
[csrfHeaderName]: globalToken,
|
|
633
743
|
});
|
|
634
744
|
}
|
|
635
745
|
}
|
|
@@ -638,10 +748,11 @@
|
|
|
638
748
|
const xsrfToken = CsrfUtils.getTokenFromCookie(config.getXsrfCookieName());
|
|
639
749
|
if (xsrfToken && CsrfUtils.isValidToken(xsrfToken)) {
|
|
640
750
|
const headers = this.requestOptions.headers;
|
|
641
|
-
const
|
|
751
|
+
const xsrfHeaderName = config.getXsrfHeaderName();
|
|
752
|
+
const hasLocalToken = this.hasHeader(headers, "X-XSRF-TOKEN") || this.hasHeader(headers, xsrfHeaderName);
|
|
642
753
|
if (!hasLocalToken) {
|
|
643
754
|
this.withHeaders({
|
|
644
|
-
[
|
|
755
|
+
[xsrfHeaderName]: xsrfToken,
|
|
645
756
|
});
|
|
646
757
|
}
|
|
647
758
|
}
|
|
@@ -653,9 +764,7 @@
|
|
|
653
764
|
method: this.method,
|
|
654
765
|
};
|
|
655
766
|
// Create the base promise
|
|
656
|
-
const basePromise = !this.requestOptions.retries
|
|
657
|
-
? this.executeRequest(url, fetchOptions)
|
|
658
|
-
: this.executeWithRetries(url, fetchOptions);
|
|
767
|
+
const basePromise = !this.requestOptions.retries ? this.executeRequest(url, fetchOptions) : this.executeWithRetries(url, fetchOptions);
|
|
659
768
|
const responsePromise = basePromise;
|
|
660
769
|
// Attach convenience methods that begin processing immediately
|
|
661
770
|
responsePromise.getJson = async () => {
|
|
@@ -684,12 +793,21 @@
|
|
|
684
793
|
if (!this.queryParams.toString()) {
|
|
685
794
|
return url;
|
|
686
795
|
}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
796
|
+
try {
|
|
797
|
+
// Try to use the URL constructor (works for absolute URLs)
|
|
798
|
+
const urlObj = new URL(url);
|
|
799
|
+
// Merge our query params with any that might be in the URL already
|
|
800
|
+
this.queryParams.forEach((value, key) => {
|
|
801
|
+
urlObj.searchParams.append(key, value);
|
|
802
|
+
});
|
|
803
|
+
return urlObj.toString();
|
|
804
|
+
}
|
|
805
|
+
catch (error) {
|
|
806
|
+
// Handle relative URLs
|
|
807
|
+
const hasExistingParams = url.includes("?");
|
|
808
|
+
const separator = hasExistingParams ? "&" : "?";
|
|
809
|
+
return `${url}${separator}${this.queryParams.toString()}`;
|
|
810
|
+
}
|
|
693
811
|
}
|
|
694
812
|
/**
|
|
695
813
|
* Executes a request with configured retry logic
|
|
@@ -700,38 +818,65 @@
|
|
|
700
818
|
async executeWithRetries(url, fetchOptions) {
|
|
701
819
|
let attempt = 0;
|
|
702
820
|
const maxRetries = this.requestOptions.retries || 0;
|
|
703
|
-
|
|
704
|
-
while (true) {
|
|
821
|
+
do {
|
|
705
822
|
try {
|
|
706
823
|
return await this.executeRequest(url, fetchOptions);
|
|
707
824
|
}
|
|
708
825
|
catch (error) {
|
|
709
|
-
const requestError = error instanceof RequestError
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
if (attempt >= maxRetries || requestError.timeoutError) {
|
|
826
|
+
const requestError = error instanceof RequestError ? error : RequestError.networkError(url, fetchOptions.method, error);
|
|
827
|
+
// Don't retry on timeout errors or if we've reached the max retries
|
|
828
|
+
if (attempt >= maxRetries || requestError.timeoutError)
|
|
713
829
|
throw requestError;
|
|
714
|
-
}
|
|
715
830
|
attempt++;
|
|
716
831
|
if (this.requestOptions.onRetry) {
|
|
717
832
|
await this.requestOptions.onRetry({ attempt, error: requestError });
|
|
718
833
|
}
|
|
719
834
|
}
|
|
720
|
-
}
|
|
835
|
+
} while (attempt <= maxRetries);
|
|
836
|
+
// If we somehow get here, we throw an error to avoid undefined behavior
|
|
837
|
+
throw new RequestError(`Max retries reached (${maxRetries}) but request still failed`, url, fetchOptions.method);
|
|
721
838
|
}
|
|
722
839
|
async executeRequest(url, fetchOptions) {
|
|
723
840
|
// Create a request controller for timeout if needed, or use the provided one
|
|
724
841
|
const controller = this.abortController || new AbortController();
|
|
725
842
|
const { signal } = controller;
|
|
726
843
|
// Use the controller signal for the fetch request, but don't overwrite an existing signal
|
|
727
|
-
// that might have been set in requestOptions if it exists
|
|
728
844
|
fetchOptions.signal = fetchOptions.signal || signal;
|
|
729
845
|
let timeoutId;
|
|
730
|
-
//
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
846
|
+
// Flag to track if abort was caused by our timeout
|
|
847
|
+
let abortedByTimeout = false;
|
|
848
|
+
// Handle timeout regardless of whether an external controller is provided
|
|
849
|
+
if (this.requestOptions.timeout) {
|
|
850
|
+
if (this.abortController) {
|
|
851
|
+
// If we have an external controller, create a separate timeout controller
|
|
852
|
+
// that won't interfere with the external one
|
|
853
|
+
// Use AbortSignal.timeout if available (modern browsers)
|
|
854
|
+
if (typeof AbortSignal.timeout === "function") {
|
|
855
|
+
const timeoutSignal = AbortSignal.timeout(this.requestOptions.timeout);
|
|
856
|
+
// Add event listener to propagate the abort to the main controller
|
|
857
|
+
timeoutSignal.addEventListener("abort", () => {
|
|
858
|
+
if (!controller.signal.aborted) {
|
|
859
|
+
abortedByTimeout = true;
|
|
860
|
+
controller.abort();
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
else {
|
|
865
|
+
// Fallback for browsers without AbortSignal.timeout
|
|
866
|
+
timeoutId = setTimeout(() => {
|
|
867
|
+
if (!controller.signal.aborted) {
|
|
868
|
+
abortedByTimeout = true;
|
|
869
|
+
controller.abort();
|
|
870
|
+
}
|
|
871
|
+
}, this.requestOptions.timeout);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
else {
|
|
875
|
+
timeoutId = setTimeout(() => {
|
|
876
|
+
abortedByTimeout = true;
|
|
877
|
+
controller.abort();
|
|
878
|
+
}, this.requestOptions.timeout);
|
|
879
|
+
}
|
|
735
880
|
}
|
|
736
881
|
try {
|
|
737
882
|
let response;
|
|
@@ -739,11 +884,11 @@
|
|
|
739
884
|
response = await fetch(url, fetchOptions);
|
|
740
885
|
}
|
|
741
886
|
catch (error) {
|
|
742
|
-
// Check if this is an abort error
|
|
743
|
-
if (error instanceof DOMException && error.name === "AbortError" &&
|
|
887
|
+
// Check if this is an abort error that was specifically caused by our timeout
|
|
888
|
+
if (error instanceof DOMException && error.name === "AbortError" && abortedByTimeout) {
|
|
744
889
|
throw RequestError.timeout(url, fetchOptions.method, this.requestOptions.timeout);
|
|
745
890
|
}
|
|
746
|
-
// Otherwise it's a network error
|
|
891
|
+
// Otherwise it's either a user-triggered abort or another network error
|
|
747
892
|
throw RequestError.networkError(url, fetchOptions.method, error);
|
|
748
893
|
}
|
|
749
894
|
if (!response.ok) {
|
|
@@ -764,17 +909,12 @@
|
|
|
764
909
|
* Base class for requests that can have a body (POST, PUT, PATCH)
|
|
765
910
|
*/
|
|
766
911
|
class BodyRequest extends BaseRequest {
|
|
767
|
-
constructor() {
|
|
768
|
-
super(...arguments);
|
|
769
|
-
this.bodyProcessed = false;
|
|
770
|
-
}
|
|
771
912
|
/**
|
|
772
913
|
* Sets the body of the request
|
|
773
914
|
* @param body - The request body
|
|
774
915
|
*/
|
|
775
916
|
withBody(body) {
|
|
776
917
|
this.body = body;
|
|
777
|
-
this.bodyProcessed = false;
|
|
778
918
|
// Set body type and validate
|
|
779
919
|
if (typeof body === "string") {
|
|
780
920
|
this.bodyType = BodyType.STRING;
|
|
@@ -782,11 +922,7 @@
|
|
|
782
922
|
}
|
|
783
923
|
else if (body !== null &&
|
|
784
924
|
typeof body === "object" &&
|
|
785
|
-
!(body instanceof FormData ||
|
|
786
|
-
body instanceof Blob ||
|
|
787
|
-
body instanceof ArrayBuffer ||
|
|
788
|
-
body instanceof URLSearchParams ||
|
|
789
|
-
body instanceof ReadableStream)) {
|
|
925
|
+
!(body instanceof FormData || body instanceof Blob || body instanceof ArrayBuffer || body instanceof URLSearchParams || body instanceof ReadableStream)) {
|
|
790
926
|
this.bodyType = BodyType.JSON;
|
|
791
927
|
this.setContentTypeIfNeeded("application/json");
|
|
792
928
|
// Validate JSON is stringifiable early
|
|
@@ -814,15 +950,17 @@
|
|
|
814
950
|
* Overrides the base implementation to add body handling
|
|
815
951
|
*/
|
|
816
952
|
sendTo(url) {
|
|
817
|
-
|
|
818
|
-
|
|
953
|
+
if (this.body !== undefined) {
|
|
954
|
+
// Remove previous body if it exists
|
|
955
|
+
if (this.requestOptions.body)
|
|
956
|
+
delete this.requestOptions.body;
|
|
957
|
+
// Process the body based on its type
|
|
819
958
|
if (this.bodyType === BodyType.JSON) {
|
|
820
959
|
this.requestOptions.body = JSON.stringify(this.body);
|
|
821
960
|
}
|
|
822
961
|
else {
|
|
823
962
|
this.requestOptions.body = this.body;
|
|
824
963
|
}
|
|
825
|
-
this.bodyProcessed = true;
|
|
826
964
|
}
|
|
827
965
|
return super.sendTo(url);
|
|
828
966
|
}
|
package/dist/library/types.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export interface CookieOptions {
|
|
|
16
16
|
maxAge?: number;
|
|
17
17
|
}
|
|
18
18
|
export type CookiesRecord = Record<string, string | CookieOptions>;
|
|
19
|
-
export { CacheMode, HttpMethod, RequestMode, RedirectMode, SameSitePolicy, RequestPriority, CredentialsPolicy
|
|
19
|
+
export { CacheMode, HttpMethod, RequestMode, RedirectMode, SameSitePolicy, RequestPriority, CredentialsPolicy };
|
|
20
20
|
export interface RequestOptions extends Omit<RequestInit, "signal" | "body" | "method" | "credentials" | "mode" | "redirect" | "cache" | "priority"> {
|
|
21
21
|
timeout?: number;
|
|
22
22
|
retries?: number;
|
|
@@ -16,9 +16,9 @@ export declare class CsrfUtils {
|
|
|
16
16
|
static getTokenFromCookie(cookieName?: string): string | null;
|
|
17
17
|
/**
|
|
18
18
|
* Validates if the provided string is a potential CSRF token
|
|
19
|
-
* Checks if the token meets
|
|
19
|
+
* Checks if the token meets security requirements
|
|
20
20
|
* @param token The token to validate
|
|
21
|
-
* @returns Whether the token is
|
|
21
|
+
* @returns Whether the token is valid
|
|
22
22
|
*/
|
|
23
23
|
static isValidToken(token: string | null | undefined): boolean;
|
|
24
24
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-request",
|
|
3
|
-
"version": "0.0.1-beta.
|
|
3
|
+
"version": "0.0.1-beta.6",
|
|
4
4
|
"description": "A modern, chainable wrapper for fetch with automatic retries, timeouts, comprehensive error handling, and first-class TypeScript support",
|
|
5
5
|
"main": "dist/library/index.js",
|
|
6
6
|
"module": "dist/library/index.esm.js",
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
"test:coverage": "tsc -p tsconfig.test.json && node --test --experimental-test-coverage dist/tests/test/",
|
|
16
16
|
"pretest": "tsc -p tsconfig.test.json",
|
|
17
17
|
"prepublishOnly": "npm run check && npm run build",
|
|
18
|
-
"publish:beta": "npm version prerelease --preid=beta && git push origin --tags && npm publish --tag
|
|
19
|
-
"publish:patch": "npm version patch && git push origin --tags && npm publish",
|
|
20
|
-
"publish:minor": "npm version minor && git push origin --tags && npm publish",
|
|
21
|
-
"publish:major": "npm version major && git push origin --tags && npm publish",
|
|
18
|
+
"publish:beta": "npm run check && npm version prerelease --preid=beta && git push origin --tags && npm publish --tag latest",
|
|
19
|
+
"disabled:publish:patch": "npm version patch && git push origin --tags && npm publish",
|
|
20
|
+
"disabled:publish:minor": "npm version minor && git push origin --tags && npm publish",
|
|
21
|
+
"disabled:publish:major": "npm version major && git push origin --tags && npm publish",
|
|
22
22
|
"lint": "eslint 'src/**/*.ts' 'test/**/*.ts'",
|
|
23
23
|
"lint:fix": "eslint 'src/**/*.ts' 'test/**/*.ts' --fix",
|
|
24
24
|
"format": "prettier --write 'src/**/*.ts' 'test/**/*.ts'",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"license": "MIT",
|
|
37
37
|
"repository": {
|
|
38
38
|
"type": "git",
|
|
39
|
-
"url": "https://github.com/DanielAmenou/create-request"
|
|
39
|
+
"url": "https://github.com/DanielAmenou/create-request.git"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@rollup/plugin-commonjs": "^22.0.0",
|