create-request 0.0.1-beta.5 → 0.0.1-beta.7

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.
@@ -121,73 +121,440 @@ 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
  }
137
+ get ok() {
138
+ return this.response.ok;
139
+ }
140
+ get raw() {
141
+ return this.response;
142
+ }
143
+ // Response body methods with caching
144
+ async getJson() {
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;
165
+ }
166
+ async getText() {
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;
186
+ }
187
+ async getBlob() {
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;
202
+ }
203
+ async getArrayBuffer() {
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
+ }
227
+ getBody() {
228
+ if (this.bodyUsed) {
229
+ throw new Error("Response body has already been consumed");
230
+ }
231
+ this.bodyUsed = true;
232
+ return this.response.body;
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Parse size strings like "10MB" into bytes
238
+ * @param size Size as number or string with units
239
+ */
240
+ function parseSize(size) {
241
+ var _a;
242
+ if (typeof size === "number")
243
+ return size;
244
+ const match = size.match(/^(\d+(?:\.\d+)?)\s*(KB|MB|GB)?$/i);
245
+ if (!match)
246
+ return 0;
247
+ const num = parseFloat(match[1]);
248
+ const unit = (_a = match[2]) === null || _a === void 0 ? void 0 : _a.toUpperCase();
249
+ switch (unit) {
250
+ case "KB":
251
+ return num * 1024;
252
+ case "MB":
253
+ return num * 1024 * 1024;
254
+ case "GB":
255
+ return num * 1024 * 1024 * 1024;
256
+ default:
257
+ return num;
258
+ }
259
+ }
260
+ /**
261
+ * Create a memory storage provider using Map
262
+ */
263
+ function createMemoryStorage() {
264
+ const store = new Map();
265
+ return {
266
+ get(key) {
267
+ return store.get(key) || null;
268
+ },
269
+ set(key, value) {
270
+ store.set(key, value);
271
+ },
272
+ has(key) {
273
+ return store.has(key);
274
+ },
275
+ delete(key) {
276
+ store.delete(key);
277
+ },
278
+ clear() {
279
+ store.clear();
280
+ },
281
+ };
282
+ }
283
+ /**
284
+ * Create a localStorage-based storage provider
285
+ */
286
+ function createLocalStorageStorage() {
287
+ return {
288
+ get(key) {
289
+ if (typeof localStorage === "undefined")
290
+ return null;
291
+ return localStorage.getItem(key);
292
+ },
293
+ set(key, value) {
294
+ if (typeof localStorage === "undefined")
295
+ return;
296
+ try {
297
+ localStorage.setItem(key, value);
298
+ }
299
+ catch (e) {
300
+ // Handle quota exceeded errors
301
+ console.warn("Cache storage quota exceeded, clearing cache");
302
+ void this.clear();
303
+ try {
304
+ localStorage.setItem(key, value);
305
+ }
306
+ catch (e) {
307
+ console.error("Failed to set cache item even after clearing cache");
308
+ }
309
+ }
310
+ },
311
+ has(key) {
312
+ if (typeof localStorage === "undefined")
313
+ return false;
314
+ return localStorage.getItem(key) !== null;
315
+ },
316
+ delete(key) {
317
+ if (typeof localStorage === "undefined")
318
+ return;
319
+ localStorage.removeItem(key);
320
+ },
321
+ clear() {
322
+ if (typeof localStorage === "undefined")
323
+ return;
324
+ localStorage.clear();
325
+ },
326
+ };
327
+ }
328
+ /**
329
+ * Create a sessionStorage-based storage provider
330
+ */
331
+ function createSessionStorageStorage() {
332
+ return {
333
+ get(key) {
334
+ if (typeof sessionStorage === "undefined")
335
+ return null;
336
+ return sessionStorage.getItem(key);
337
+ },
338
+ set(key, value) {
339
+ if (typeof sessionStorage === "undefined")
340
+ return;
341
+ try {
342
+ sessionStorage.setItem(key, value);
343
+ }
344
+ catch (e) {
345
+ // Handle quota exceeded errors
346
+ console.warn("Cache storage quota exceeded, clearing cache");
347
+ void this.clear();
348
+ try {
349
+ sessionStorage.setItem(key, value);
350
+ }
351
+ catch (e) {
352
+ console.error("Failed to set cache item even after clearing cache");
353
+ }
354
+ }
355
+ },
356
+ has(key) {
357
+ if (typeof sessionStorage === "undefined")
358
+ return false;
359
+ return sessionStorage.getItem(key) !== null;
360
+ },
361
+ delete(key) {
362
+ if (typeof sessionStorage === "undefined")
363
+ return;
364
+ sessionStorage.removeItem(key);
365
+ },
366
+ clear() {
367
+ if (typeof sessionStorage === "undefined")
368
+ return;
369
+ sessionStorage.clear();
370
+ },
371
+ };
372
+ }
373
+ /**
374
+ * Create a storage provider from a cache options object
375
+ */
376
+ function createStorageProvider(options) {
377
+ const { storage } = options || {};
378
+ if (!storage) {
379
+ // Default to memory storage
380
+ return createMemoryStorage();
381
+ }
382
+ // Check if storage is already a StorageProvider
383
+ if (typeof storage.get === "function" && typeof storage.set === "function") {
384
+ return storage;
385
+ }
386
+ // Check if it's localStorage or sessionStorage
387
+ if (storage === localStorage) {
388
+ return createLocalStorageStorage();
389
+ }
390
+ if (storage === sessionStorage) {
391
+ return createSessionStorageStorage();
392
+ }
393
+ // Check if it's a Map
394
+ if (storage instanceof Map) {
395
+ return {
396
+ get(key) {
397
+ return storage.get(key) || null;
398
+ },
399
+ set(key, value, _ttl) {
400
+ storage.set(key, value);
401
+ },
402
+ has(key) {
403
+ return storage.has(key);
404
+ },
405
+ delete(key) {
406
+ storage.delete(key);
407
+ },
408
+ clear() {
409
+ storage.clear();
410
+ },
411
+ };
412
+ }
413
+ // Default to memory storage
414
+ console.warn("Unsupported storage type, falling back to memory storage");
415
+ return createMemoryStorage();
416
+ }
417
+
418
+ /**
419
+ * Cache manager that handles storage operations
420
+ */
421
+ class CacheManager {
422
+ constructor(options = {}) {
423
+ this.entryCount = 0;
424
+ this.totalSize = 0;
425
+ this.options = options;
426
+ this.storage = createStorageProvider(options);
427
+ }
151
428
  /**
152
- * Parse response as JSON
429
+ * Generate a cache key based on request details
153
430
  */
154
- async getJson() {
155
- return this.response.json();
431
+ generateKey(url, method, headers, body) {
432
+ // Use custom key generator if provided
433
+ if (this.options.keyGenerator) {
434
+ const customKey = this.options.keyGenerator(url, method, headers, body);
435
+ return this.options.keyPrefix ? `${this.options.keyPrefix}:${customKey}` : customKey;
436
+ }
437
+ // Default key generation
438
+ let key = `${method}:${url}`;
439
+ // Add vary headers to key if specified
440
+ if (this.options.varyByHeaders && headers) {
441
+ const varyHeaders = {};
442
+ this.options.varyByHeaders.forEach(header => {
443
+ const normalizedHeader = header.toLowerCase();
444
+ // Find the header in case-insensitive way
445
+ Object.keys(headers).forEach(h => {
446
+ if (h.toLowerCase() === normalizedHeader) {
447
+ varyHeaders[normalizedHeader] = headers[h];
448
+ }
449
+ });
450
+ });
451
+ if (Object.keys(varyHeaders).length > 0) {
452
+ key += `:${JSON.stringify(varyHeaders)}`;
453
+ }
454
+ }
455
+ // Include body in cache key for non-GET requests if it's a simple value
456
+ if (method !== "GET" && body !== undefined && body !== null) {
457
+ try {
458
+ if (typeof body === "string" || typeof body === "number" || typeof body === "boolean") {
459
+ key += `:${String(body)}`;
460
+ }
461
+ else if (typeof body === "object") {
462
+ key += `:${JSON.stringify(body)}`;
463
+ }
464
+ }
465
+ catch (e) {
466
+ console.warn("Failed to stringify request body for cache key");
467
+ }
468
+ }
469
+ // Add prefix if specified
470
+ return this.options.keyPrefix ? `${this.options.keyPrefix}:${key}` : key;
156
471
  }
157
472
  /**
158
- * Get response as text
473
+ * Get a value from cache
159
474
  */
160
- async getText() {
161
- return this.response.text();
475
+ async get(key) {
476
+ const value = await Promise.resolve(this.storage.get(key));
477
+ if (!value)
478
+ return null;
479
+ try {
480
+ const entry = JSON.parse(value);
481
+ // Check if the entry has expired
482
+ if (entry.expiry && entry.expiry < Date.now()) {
483
+ await Promise.resolve(this.storage.delete(key));
484
+ return null;
485
+ }
486
+ return entry;
487
+ }
488
+ catch (e) {
489
+ // If we can't parse the cached value, remove it
490
+ await Promise.resolve(this.storage.delete(key));
491
+ return null;
492
+ }
162
493
  }
163
494
  /**
164
- * Get response as Blob
495
+ * Set a value in cache
165
496
  */
166
- async getBlob() {
167
- return this.response.blob();
497
+ async set(key, value, headers) {
498
+ const now = Date.now();
499
+ const ttl = this.options.ttl;
500
+ const entry = {
501
+ value,
502
+ timestamp: now,
503
+ expiry: ttl ? now + ttl : null,
504
+ headers,
505
+ };
506
+ const serialized = JSON.stringify(entry);
507
+ // Check size constraints if applicable
508
+ if (this.options.maxSize) {
509
+ const maxBytes = parseSize(this.options.maxSize);
510
+ const entrySize = new TextEncoder().encode(serialized).length;
511
+ // If a single entry exceeds the max size, reject it without clearing the cache
512
+ if (entrySize > maxBytes) {
513
+ console.warn(`Cache entry size (${entrySize} bytes) exceeds maximum cache size (${maxBytes} bytes)`);
514
+ return;
515
+ }
516
+ // Only clear if adding this entry would exceed the total limit
517
+ if (this.totalSize + entrySize > maxBytes) {
518
+ // Simple strategy: clear everything
519
+ // A more sophisticated strategy would remove oldest entries first
520
+ await Promise.resolve(this.storage.clear());
521
+ this.totalSize = 0;
522
+ this.entryCount = 0;
523
+ }
524
+ this.totalSize += entrySize;
525
+ }
526
+ // Check max entries constraint
527
+ if (this.options.maxEntries) {
528
+ this.entryCount++;
529
+ if (this.entryCount > this.options.maxEntries) {
530
+ // Simple strategy: clear everything
531
+ // A more sophisticated strategy would use LRU cache
532
+ await Promise.resolve(this.storage.clear());
533
+ this.entryCount = 1;
534
+ }
535
+ }
536
+ // Pass ttl to storage provider if available
537
+ await Promise.resolve(this.storage.set(key, serialized, ttl));
168
538
  }
169
539
  /**
170
- * Get response as ArrayBuffer
540
+ * Check if a key exists in the cache
171
541
  */
172
- async getArrayBuffer() {
173
- return this.response.arrayBuffer();
174
- }
175
- /**
176
- * Get response as ReadableStream for true streaming consumption.
177
- * This allows you to process data incrementally as it arrives.
178
- *
179
- * @example
180
- * const stream = await request.sendTo('https://example.com/large-file').getBody();
181
- * const reader = stream.getReader();
182
- *
183
- * while (true) {
184
- * const { done, value } = await reader.read();
185
- * if (done) break;
186
- * // Process chunk of data in 'value'
187
- * }
542
+ async has(key) {
543
+ return Promise.resolve(this.storage.has(key));
544
+ }
545
+ /**
546
+ * Delete a key from the cache
188
547
  */
189
- getBody() {
190
- return this.response.body;
548
+ async delete(key) {
549
+ return Promise.resolve(this.storage.delete(key));
550
+ }
551
+ /**
552
+ * Clear the entire cache
553
+ */
554
+ async clear() {
555
+ this.entryCount = 0;
556
+ this.totalSize = 0;
557
+ return Promise.resolve(this.storage.clear());
191
558
  }
192
559
  }
193
560
 
@@ -292,15 +659,15 @@ class Config {
292
659
  return this.enableAntiCsrf;
293
660
  }
294
661
  /**
295
- * Reset configuration to defaults
662
+ * Reset all configuration options to their defaults
296
663
  */
297
664
  reset() {
298
665
  this.csrfToken = null;
299
666
  this.csrfHeaderName = "X-CSRF-Token";
667
+ this.enableAntiCsrf = true;
300
668
  this.xsrfCookieName = "XSRF-TOKEN";
301
669
  this.xsrfHeaderName = "X-XSRF-TOKEN";
302
670
  this.enableAutoXsrf = true;
303
- this.enableAntiCsrf = true;
304
671
  return this;
305
672
  }
306
673
  }
@@ -365,12 +732,34 @@ class CsrfUtils {
365
732
  }
366
733
  /**
367
734
  * Validates if the provided string is a potential CSRF token
368
- * Checks if the token meets minimum security requirements (non-empty, not too short)
735
+ * Checks if the token meets security requirements
369
736
  * @param token The token to validate
370
- * @returns Whether the token is potentially valid
737
+ * @returns Whether the token is valid
371
738
  */
372
739
  static isValidToken(token) {
373
- return typeof token === "string" && token.length >= 8;
740
+ // Basic checks for null/undefined and type
741
+ if (typeof token !== "string") {
742
+ return false;
743
+ }
744
+ if (token.length < 8)
745
+ return false;
746
+ // If token is longer than 10 chars, perform additional security checks
747
+ if (token.length > 10) {
748
+ // Check for valid character set (alphanumeric & common token symbols)
749
+ const validTokenRegex = /^[A-Za-z0-9\-_=+/.]+$/;
750
+ if (!validTokenRegex.test(token)) {
751
+ return false;
752
+ }
753
+ // For longer tokens, check for sufficient entropy (at least 2 character types)
754
+ const hasUpperCase = /[A-Z]/.test(token);
755
+ const hasLowerCase = /[a-z]/.test(token);
756
+ const hasNumbers = /[0-9]/.test(token);
757
+ const hasSpecials = /[-_=+/.]/.test(token);
758
+ const characterTypesCount = [hasUpperCase, hasLowerCase, hasNumbers, hasSpecials].filter(Boolean).length;
759
+ return characterTypesCount >= 2;
760
+ }
761
+ // For shorter tokens (8-10 chars), just return true if we reached here
762
+ return true;
374
763
  }
375
764
  }
376
765
 
@@ -382,6 +771,7 @@ class BaseRequest {
382
771
  };
383
772
  this.queryParams = new URLSearchParams();
384
773
  this.autoApplyCsrfProtection = true;
774
+ this.cacheEnabled = false;
385
775
  }
386
776
  withHeaders(headers) {
387
777
  this.requestOptions.headers = {
@@ -391,10 +781,14 @@ class BaseRequest {
391
781
  return this;
392
782
  }
393
783
  withTimeout(timeout) {
784
+ if (!Number.isFinite(timeout) || timeout <= 0)
785
+ throw new Error("Timeout must be a positive number");
394
786
  this.requestOptions.timeout = timeout;
395
787
  return this;
396
788
  }
397
789
  withRetries(retries) {
790
+ if (!Number.isInteger(retries) || retries < 0)
791
+ throw new Error("Retry count must be a non-negative integer");
398
792
  this.requestOptions.retries = retries;
399
793
  return this;
400
794
  }
@@ -479,13 +873,16 @@ class BaseRequest {
479
873
  /**
480
874
  * Adds a single query parameter
481
875
  * @param key - The parameter name
482
- * @param value - The parameter value
876
+ * @param value - The parameter value, can be a single value or array of values
483
877
  * @returns The instance for chaining
484
878
  */
485
879
  withQueryParam(key, value) {
486
- if (value !== null && value !== undefined) {
880
+ if (value === null || value === undefined)
881
+ return this;
882
+ if (Array.isArray(value))
883
+ value.forEach(v => this.queryParams.append(key, String(v)));
884
+ else
487
885
  this.queryParams.append(key, String(value));
488
- }
489
886
  return this;
490
887
  }
491
888
  /**
@@ -524,9 +921,23 @@ class BaseRequest {
524
921
  * @returns The instance for chaining
525
922
  */
526
923
  withBasicAuth(username, password) {
527
- const credentials = btoa(`${username}:${password}`);
924
+ const credentials = this.encodeBase64(`${username}:${password}`);
528
925
  return this.withAuthorization(`Basic ${credentials}`);
529
926
  }
927
+ /**
928
+ * Cross-environment base64 encoding
929
+ * Works in both browser and Node.js environments
930
+ */
931
+ encodeBase64(str) {
932
+ // Browser environment
933
+ if (typeof btoa === "function")
934
+ return btoa(str);
935
+ // Node.js environment
936
+ if (typeof Buffer !== "undefined")
937
+ return Buffer.from(str).toString("base64");
938
+ // Fallback (should never happen in modern environments)
939
+ throw new Error("Base64 encoding is not supported in this environment");
940
+ }
530
941
  /**
531
942
  * Sets the bearer token for authentication
532
943
  * @param token - The bearer token
@@ -535,6 +946,15 @@ class BaseRequest {
535
946
  withBearerToken(token) {
536
947
  return this.withAuthorization(`Bearer ${token}`);
537
948
  }
949
+ /**
950
+ * Helper function to check for header presence in a case-insensitive way
951
+ * @param headers Headers object
952
+ * @param headerName Header name to check
953
+ * @returns Boolean indicating if the header exists (case-insensitive)
954
+ */
955
+ hasHeader(headers, headerName) {
956
+ return Object.keys(headers).some(key => key.toLowerCase() === headerName.toLowerCase());
957
+ }
538
958
  /**
539
959
  * Sets cookies for the request
540
960
  * @param cookies Object containing cookie name-value pairs or cookie options
@@ -547,19 +967,34 @@ class BaseRequest {
547
967
  }
548
968
  // Get current headers or initialize empty object
549
969
  const currentHeaders = this.requestOptions.headers || {};
550
- // Get the existing cookie header in a case-insensitive way
551
- let existingCookies = "";
552
- const cookieHeaderName = Object.keys(currentHeaders).find(header => header.toLowerCase() === "cookie");
553
- if (cookieHeaderName)
554
- existingCookies = currentHeaders[cookieHeaderName];
970
+ // Get all existing cookie headers with different cases
971
+ const cookieValues = [];
972
+ const cookieHeaderKeys = [];
973
+ Object.keys(currentHeaders).forEach(key => {
974
+ if (key.toLowerCase() === "cookie") {
975
+ // Don't add empty cookie values
976
+ if (currentHeaders[key])
977
+ cookieValues.push(currentHeaders[key]);
978
+ cookieHeaderKeys.push(key);
979
+ }
980
+ });
981
+ // Format the new cookies
555
982
  const cookieString = CookieUtils.formatRequestCookies(cookies);
556
- // Combine with existing cookies if present
557
- const newCookieValue = existingCookies ? `${existingCookies}; ${cookieString}` : cookieString;
558
- // Set the Cookie header, preserving original case if it exists
559
- const headerName = cookieHeaderName || "Cookie";
560
- return this.withHeaders({
561
- [headerName]: newCookieValue,
983
+ // Choose which header name to use - preserve existing case if possible
984
+ const headerName = cookieHeaderKeys.length > 0 ? cookieHeaderKeys[0] : "Cookie";
985
+ // Combine all existing cookie values with the new ones
986
+ const combinedCookieValue = [...cookieValues, cookieString].filter(Boolean).join("; ");
987
+ // Create a new headers object without any cookie headers
988
+ const newHeaders = { ...currentHeaders };
989
+ cookieHeaderKeys.forEach(key => {
990
+ delete newHeaders[key];
562
991
  });
992
+ // Set the new combined cookie header
993
+ this.requestOptions.headers = {
994
+ ...newHeaders,
995
+ [headerName]: combinedCookieValue,
996
+ };
997
+ return this;
563
998
  }
564
999
  /**
565
1000
  * Set a single cookie
@@ -599,6 +1034,16 @@ class BaseRequest {
599
1034
  "X-Requested-With": "XMLHttpRequest",
600
1035
  });
601
1036
  }
1037
+ /**
1038
+ * Enables caching for the request with the specified options
1039
+ * @param options The cache configuration options
1040
+ * @returns The instance for chaining
1041
+ */
1042
+ withCache(options = {}) {
1043
+ this.cacheEnabled = true;
1044
+ this.cacheManager = new CacheManager(options);
1045
+ return this;
1046
+ }
602
1047
  /**
603
1048
  * Send the request to the specified URL
604
1049
  * @param url The URL to send the request to
@@ -618,12 +1063,13 @@ class BaseRequest {
618
1063
  // Apply global CSRF token if set
619
1064
  const globalToken = config.getCsrfToken();
620
1065
  if (globalToken) {
621
- // Check if local token exists
1066
+ // Check if local token exists using case-insensitive comparison
622
1067
  const headers = this.requestOptions.headers;
623
- const hasLocalToken = Object.keys(headers).some(key => key === "X-CSRF-Token" || key === config.getCsrfHeaderName());
1068
+ const csrfHeaderName = config.getCsrfHeaderName();
1069
+ const hasLocalToken = this.hasHeader(headers, "X-CSRF-Token") || this.hasHeader(headers, csrfHeaderName);
624
1070
  if (!hasLocalToken) {
625
1071
  this.withHeaders({
626
- [config.getCsrfHeaderName()]: globalToken,
1072
+ [csrfHeaderName]: globalToken,
627
1073
  });
628
1074
  }
629
1075
  }
@@ -632,10 +1078,11 @@ class BaseRequest {
632
1078
  const xsrfToken = CsrfUtils.getTokenFromCookie(config.getXsrfCookieName());
633
1079
  if (xsrfToken && CsrfUtils.isValidToken(xsrfToken)) {
634
1080
  const headers = this.requestOptions.headers;
635
- const hasLocalToken = Object.keys(headers).some(key => key === "X-XSRF-TOKEN" || key === config.getXsrfHeaderName());
1081
+ const xsrfHeaderName = config.getXsrfHeaderName();
1082
+ const hasLocalToken = this.hasHeader(headers, "X-XSRF-TOKEN") || this.hasHeader(headers, xsrfHeaderName);
636
1083
  if (!hasLocalToken) {
637
1084
  this.withHeaders({
638
- [config.getXsrfHeaderName()]: xsrfToken,
1085
+ [xsrfHeaderName]: xsrfToken,
639
1086
  });
640
1087
  }
641
1088
  }
@@ -646,10 +1093,15 @@ class BaseRequest {
646
1093
  ...this.requestOptions,
647
1094
  method: this.method,
648
1095
  };
649
- // Create the base promise
650
- const basePromise = !this.requestOptions.retries
651
- ? this.executeRequest(url, fetchOptions)
652
- : this.executeWithRetries(url, fetchOptions);
1096
+ // Create the base promise - handle caching if enabled
1097
+ let basePromise;
1098
+ if (this.cacheEnabled && this.cacheManager) {
1099
+ const cacheKey = this.cacheManager.generateKey(url, this.method, this.requestOptions.headers, this.requestOptions.body);
1100
+ basePromise = this.executeWithCache(url, fetchOptions, cacheKey);
1101
+ }
1102
+ else {
1103
+ basePromise = !this.requestOptions.retries ? this.executeRequest(url, fetchOptions) : this.executeWithRetries(url, fetchOptions);
1104
+ }
653
1105
  const responsePromise = basePromise;
654
1106
  // Attach convenience methods that begin processing immediately
655
1107
  responsePromise.getJson = async () => {
@@ -678,12 +1130,21 @@ class BaseRequest {
678
1130
  if (!this.queryParams.toString()) {
679
1131
  return url;
680
1132
  }
681
- const urlObj = new URL(url);
682
- // Merge our query params with any that might be in the URL already
683
- this.queryParams.forEach((value, key) => {
684
- urlObj.searchParams.append(key, value);
685
- });
686
- return urlObj.toString();
1133
+ try {
1134
+ // Try to use the URL constructor (works for absolute URLs)
1135
+ const urlObj = new URL(url);
1136
+ // Merge our query params with any that might be in the URL already
1137
+ this.queryParams.forEach((value, key) => {
1138
+ urlObj.searchParams.append(key, value);
1139
+ });
1140
+ return urlObj.toString();
1141
+ }
1142
+ catch (error) {
1143
+ // Handle relative URLs
1144
+ const hasExistingParams = url.includes("?");
1145
+ const separator = hasExistingParams ? "&" : "?";
1146
+ return `${url}${separator}${this.queryParams.toString()}`;
1147
+ }
687
1148
  }
688
1149
  /**
689
1150
  * Executes a request with configured retry logic
@@ -694,38 +1155,65 @@ class BaseRequest {
694
1155
  async executeWithRetries(url, fetchOptions) {
695
1156
  let attempt = 0;
696
1157
  const maxRetries = this.requestOptions.retries || 0;
697
- // eslint-disable-next-line no-constant-condition
698
- while (true) {
1158
+ do {
699
1159
  try {
700
1160
  return await this.executeRequest(url, fetchOptions);
701
1161
  }
702
1162
  catch (error) {
703
- const requestError = error instanceof RequestError
704
- ? error
705
- : RequestError.networkError(url, fetchOptions.method, error);
706
- if (attempt >= maxRetries || requestError.timeoutError) {
1163
+ const requestError = error instanceof RequestError ? error : RequestError.networkError(url, fetchOptions.method, error);
1164
+ // Don't retry on timeout errors or if we've reached the max retries
1165
+ if (attempt >= maxRetries || requestError.timeoutError)
707
1166
  throw requestError;
708
- }
709
1167
  attempt++;
710
1168
  if (this.requestOptions.onRetry) {
711
1169
  await this.requestOptions.onRetry({ attempt, error: requestError });
712
1170
  }
713
1171
  }
714
- }
1172
+ } while (attempt <= maxRetries);
1173
+ // If we somehow get here, we throw an error to avoid undefined behavior
1174
+ throw new RequestError(`Max retries reached (${maxRetries}) but request still failed`, url, fetchOptions.method);
715
1175
  }
716
1176
  async executeRequest(url, fetchOptions) {
717
1177
  // Create a request controller for timeout if needed, or use the provided one
718
1178
  const controller = this.abortController || new AbortController();
719
1179
  const { signal } = controller;
720
1180
  // 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
1181
  fetchOptions.signal = fetchOptions.signal || signal;
723
1182
  let timeoutId;
724
- // If we have a timeout and we're using our own controller (not provided externally)
725
- if (this.requestOptions.timeout && !this.abortController) {
726
- timeoutId = setTimeout(() => {
727
- controller.abort();
728
- }, this.requestOptions.timeout);
1183
+ // Flag to track if abort was caused by our timeout
1184
+ let abortedByTimeout = false;
1185
+ // Handle timeout regardless of whether an external controller is provided
1186
+ if (this.requestOptions.timeout) {
1187
+ if (this.abortController) {
1188
+ // If we have an external controller, create a separate timeout controller
1189
+ // that won't interfere with the external one
1190
+ // Use AbortSignal.timeout if available (modern browsers)
1191
+ if (typeof AbortSignal.timeout === "function") {
1192
+ const timeoutSignal = AbortSignal.timeout(this.requestOptions.timeout);
1193
+ // Add event listener to propagate the abort to the main controller
1194
+ timeoutSignal.addEventListener("abort", () => {
1195
+ if (!controller.signal.aborted) {
1196
+ abortedByTimeout = true;
1197
+ controller.abort();
1198
+ }
1199
+ });
1200
+ }
1201
+ else {
1202
+ // Fallback for browsers without AbortSignal.timeout
1203
+ timeoutId = setTimeout(() => {
1204
+ if (!controller.signal.aborted) {
1205
+ abortedByTimeout = true;
1206
+ controller.abort();
1207
+ }
1208
+ }, this.requestOptions.timeout);
1209
+ }
1210
+ }
1211
+ else {
1212
+ timeoutId = setTimeout(() => {
1213
+ abortedByTimeout = true;
1214
+ controller.abort();
1215
+ }, this.requestOptions.timeout);
1216
+ }
729
1217
  }
730
1218
  try {
731
1219
  let response;
@@ -733,11 +1221,11 @@ class BaseRequest {
733
1221
  response = await fetch(url, fetchOptions);
734
1222
  }
735
1223
  catch (error) {
736
- // Check if this is an abort error from our timeout
737
- if (error instanceof DOMException && error.name === "AbortError" && timeoutId) {
1224
+ // Check if this is an abort error that was specifically caused by our timeout
1225
+ if (error instanceof DOMException && error.name === "AbortError" && abortedByTimeout) {
738
1226
  throw RequestError.timeout(url, fetchOptions.method, this.requestOptions.timeout);
739
1227
  }
740
- // Otherwise it's a network error
1228
+ // Otherwise it's either a user-triggered abort or another network error
741
1229
  throw RequestError.networkError(url, fetchOptions.method, error);
742
1230
  }
743
1231
  if (!response.ok) {
@@ -752,23 +1240,60 @@ class BaseRequest {
752
1240
  }
753
1241
  }
754
1242
  }
1243
+ /**
1244
+ * Executes a request using cache when possible
1245
+ * @param url The formatted URL to send the request to
1246
+ * @param fetchOptions The fetch options to use
1247
+ * @param cacheKey The cache key for this request
1248
+ */
1249
+ async executeWithCache(url, fetchOptions, cacheKey) {
1250
+ // Try to get from cache first
1251
+ if (this.cacheManager) {
1252
+ const cachedEntry = await this.cacheManager.get(cacheKey);
1253
+ if (cachedEntry) {
1254
+ // Create a synthetic Response object from cached data
1255
+ const cachedResponse = new Response(JSON.stringify(cachedEntry.value), {
1256
+ status: 200,
1257
+ headers: new Headers(cachedEntry.headers || {}),
1258
+ });
1259
+ return new ResponseWrapper(cachedResponse);
1260
+ }
1261
+ }
1262
+ // Not in cache, make the actual request
1263
+ const response = await (!this.requestOptions.retries ? this.executeRequest(url, fetchOptions) : this.executeWithRetries(url, fetchOptions));
1264
+ // Cache the successful response if caching is enabled
1265
+ // Only cache successful responses
1266
+ if (response.ok && this.cacheManager) {
1267
+ // Clone the response so we don't consume it
1268
+ try {
1269
+ const responseData = await response.getJson();
1270
+ // Extract headers to store in cache
1271
+ const headers = {};
1272
+ response.headers.forEach((value, key) => {
1273
+ headers[key] = value;
1274
+ });
1275
+ // Store in cache
1276
+ await this.cacheManager.set(cacheKey, responseData, headers);
1277
+ }
1278
+ catch (error) {
1279
+ // Failed to cache, but we can still return the response
1280
+ console.warn("Failed to cache response:", error);
1281
+ }
1282
+ }
1283
+ return response;
1284
+ }
755
1285
  }
756
1286
 
757
1287
  /**
758
1288
  * Base class for requests that can have a body (POST, PUT, PATCH)
759
1289
  */
760
1290
  class BodyRequest extends BaseRequest {
761
- constructor() {
762
- super(...arguments);
763
- this.bodyProcessed = false;
764
- }
765
1291
  /**
766
1292
  * Sets the body of the request
767
1293
  * @param body - The request body
768
1294
  */
769
1295
  withBody(body) {
770
1296
  this.body = body;
771
- this.bodyProcessed = false;
772
1297
  // Set body type and validate
773
1298
  if (typeof body === "string") {
774
1299
  this.bodyType = BodyType.STRING;
@@ -776,11 +1301,7 @@ class BodyRequest extends BaseRequest {
776
1301
  }
777
1302
  else if (body !== null &&
778
1303
  typeof body === "object" &&
779
- !(body instanceof FormData ||
780
- body instanceof Blob ||
781
- body instanceof ArrayBuffer ||
782
- body instanceof URLSearchParams ||
783
- body instanceof ReadableStream)) {
1304
+ !(body instanceof FormData || body instanceof Blob || body instanceof ArrayBuffer || body instanceof URLSearchParams || body instanceof ReadableStream)) {
784
1305
  this.bodyType = BodyType.JSON;
785
1306
  this.setContentTypeIfNeeded("application/json");
786
1307
  // Validate JSON is stringifiable early
@@ -808,15 +1329,17 @@ class BodyRequest extends BaseRequest {
808
1329
  * Overrides the base implementation to add body handling
809
1330
  */
810
1331
  sendTo(url) {
811
- // Process body only once
812
- if (!this.bodyProcessed && this.body !== undefined && !this.requestOptions.body) {
1332
+ if (this.body !== undefined) {
1333
+ // Remove previous body if it exists
1334
+ if (this.requestOptions.body)
1335
+ delete this.requestOptions.body;
1336
+ // Process the body based on its type
813
1337
  if (this.bodyType === BodyType.JSON) {
814
1338
  this.requestOptions.body = JSON.stringify(this.body);
815
1339
  }
816
1340
  else {
817
1341
  this.requestOptions.body = this.body;
818
1342
  }
819
- this.bodyProcessed = true;
820
1343
  }
821
1344
  return super.sendTo(url);
822
1345
  }
@@ -924,4 +1447,4 @@ const create = {
924
1447
  config: Config.getInstance(),
925
1448
  };
926
1449
 
927
- export { CacheMode, CookieUtils, CredentialsPolicy, DeleteRequest, GetRequest, HeadRequest, HttpMethod, OptionsRequest, PatchRequest, PostRequest, PutRequest, RedirectMode, RequestError, RequestMode, RequestPriority, ResponseWrapper, SameSitePolicy, create as default };
1450
+ export { CacheManager, CacheMode, CookieUtils, CredentialsPolicy, DeleteRequest, GetRequest, HeadRequest, HttpMethod, OptionsRequest, PatchRequest, PostRequest, PutRequest, RedirectMode, RequestError, RequestMode, RequestPriority, ResponseWrapper, SameSitePolicy, createLocalStorageStorage, createMemoryStorage, createSessionStorageStorage, create as default };