create-request 0.0.1-beta.8 → 1.0.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.
@@ -48,18 +48,6 @@ var RedirectMode;
48
48
  RedirectMode["FOLLOW"] = "follow";
49
49
  RedirectMode["MANUAL"] = "manual";
50
50
  })(RedirectMode || (RedirectMode = {}));
51
- /**
52
- * Enum for cache modes
53
- */
54
- var CacheMode;
55
- (function (CacheMode) {
56
- CacheMode["DEFAULT"] = "default";
57
- CacheMode["RELOAD"] = "reload";
58
- CacheMode["NO_STORE"] = "no-store";
59
- CacheMode["NO_CACHE"] = "no-cache";
60
- CacheMode["FORCE_CACHE"] = "force-cache";
61
- CacheMode["ONLY_IF_CACHED"] = "only-if-cached";
62
- })(CacheMode || (CacheMode = {}));
63
51
  /**
64
52
  * Enum for cookie SameSite policies
65
53
  */
@@ -101,7 +89,8 @@ class RequestError extends Error {
101
89
  this.method = method;
102
90
  this.status = options.status;
103
91
  this.response = options.response;
104
- this.timeoutError = options.timeoutError;
92
+ this.isTimeout = options.isTimeout;
93
+ this.isAborted = options.isAborted;
105
94
  // For better stack traces in modern environments
106
95
  if (Error.captureStackTrace) {
107
96
  Error.captureStackTrace(this, RequestError);
@@ -113,30 +102,85 @@ class RequestError extends Error {
113
102
  * Static methods for creating specific types of RequestError
114
103
  */
115
104
  static timeout(url, method, timeoutMs) {
116
- return new RequestError(`Request timed out after ${timeoutMs}ms`, url, method, {
117
- timeoutError: true,
105
+ return new RequestError(`Timeout: ${timeoutMs}ms`, url, method, {
106
+ isTimeout: true,
118
107
  });
119
108
  }
120
109
  static fromResponse(response, url, method) {
121
- return new RequestError(`Request failed with status ${response.status}`, url, method, {
110
+ return new RequestError(`HTTP ${response.status}`, url, method, {
122
111
  status: response.status,
123
112
  response,
124
113
  });
125
114
  }
126
115
  static networkError(url, method, originalError) {
127
- const error = new RequestError(`Network error: ${originalError.message}`, url, method);
128
- error.stack = originalError.stack;
116
+ // Provide more descriptive error messages for common network errors
117
+ let message = originalError.message;
118
+ // Check for Node.js error codes (e.g., from undici/dns errors)
119
+ const errorCode = originalError.code;
120
+ const errorName = originalError.name;
121
+ const stack = originalError.stack || "";
122
+ const errorMessageLower = message.toLowerCase();
123
+ // Check for timeout errors (Node.js/undici TimeoutError)
124
+ const isTimeoutError = errorName === "TimeoutError" ||
125
+ errorMessageLower.includes("timeout") ||
126
+ errorMessageLower.includes("aborted due to timeout") ||
127
+ errorCode === "ETIMEDOUT" ||
128
+ stack.includes("TimeoutError") ||
129
+ stack.includes("timeout");
130
+ // If the error message is generic "fetch failed", provide more context
131
+ if (message === "fetch failed" || message === "Failed to fetch") {
132
+ // Check for DNS resolution errors
133
+ const isDnsError = errorCode === "ENOTFOUND" ||
134
+ errorCode === "EAI_AGAIN" ||
135
+ errorCode === "EAI_NODATA" ||
136
+ stack.includes("getaddrinfo") ||
137
+ stack.includes("ENOTFOUND") ||
138
+ stack.includes("EAI_AGAIN");
139
+ // Check for connection errors (but not timeout errors)
140
+ const isConnectionError = !isTimeoutError && (errorCode === "ECONNREFUSED" || errorCode === "ECONNRESET" || stack.includes("ECONNREFUSED") || stack.includes("connect"));
141
+ if (isTimeoutError) {
142
+ message = `Network error: Request timeout for ${url}`;
143
+ }
144
+ else if (isDnsError) {
145
+ message = `Network error: Unable to resolve hostname for ${url}`;
146
+ }
147
+ else if (isConnectionError) {
148
+ message = `Network error: Connection refused for ${url}`;
149
+ }
150
+ else {
151
+ message = `Network error: Failed to fetch ${url}`;
152
+ }
153
+ }
154
+ const error = new RequestError(message, url, method, {
155
+ ...(isTimeoutError ? { isTimeout: true } : {}),
156
+ });
157
+ // Create a proper RequestError stack trace, but append the original stack for debugging
158
+ // This way Node.js will show "RequestError: ..." instead of "TypeError: ..."
159
+ if (originalError.stack) {
160
+ // Get the current stack (which will start with RequestError)
161
+ const currentStack = error.stack || "";
162
+ // Append the original error's stack as "Caused by:" for debugging context
163
+ error.stack = `${currentStack}\n\nCaused by: ${originalError.stack}`;
164
+ }
129
165
  return error;
130
166
  }
167
+ static abortError(url, method) {
168
+ return new RequestError("Aborted", url, method, {
169
+ isAborted: true,
170
+ });
171
+ }
131
172
  }
132
173
 
133
174
  /**
134
175
  * Wrapper for HTTP responses with methods to transform the response data
176
+ * Provides caching and conversion between different response formats.
135
177
  */
136
178
  class ResponseWrapper {
137
- constructor(response) {
179
+ constructor(response, url, method) {
138
180
  this.bodyUsed = false;
139
181
  this.response = response;
182
+ this.url = url;
183
+ this.method = method;
140
184
  }
141
185
  // Wrapper properties
142
186
  get status() {
@@ -154,7 +198,17 @@ class ResponseWrapper {
154
198
  get raw() {
155
199
  return this.response;
156
200
  }
157
- // Response body methods with caching
201
+ /**
202
+ * Parse the response body as JSON
203
+ * Caches the result for subsequent calls.
204
+ *
205
+ * @returns The parsed JSON data
206
+ * @throws Error if the response has already been consumed in a different format
207
+ *
208
+ * @example
209
+ * const data = await response.getJson();
210
+ * console.log(data.items);
211
+ */
158
212
  async getJson() {
159
213
  if (this.jsonCache !== undefined) {
160
214
  return this.jsonCache;
@@ -163,20 +217,55 @@ class ResponseWrapper {
163
217
  // If we already have text, try to parse it as JSON
164
218
  if (this.textCache !== undefined) {
165
219
  try {
166
- // Fix unsafe assignment by explicitly casting to unknown first
167
- this.jsonCache = JSON.parse(this.textCache);
168
- return this.jsonCache;
220
+ const parsed = JSON.parse(this.textCache);
221
+ this.jsonCache = parsed;
222
+ return parsed;
169
223
  }
170
224
  catch (e) {
171
- throw new Error("Cannot parse cached text as JSON");
225
+ const errorMessage = e instanceof Error ? e.message : String(e);
226
+ if (this.url && this.method) {
227
+ throw new RequestError(`Invalid JSON: ${errorMessage}`, this.url, this.method, {
228
+ response: this.response,
229
+ });
230
+ }
231
+ throw new Error(`Invalid JSON: ${errorMessage}`);
172
232
  }
173
233
  }
174
- throw new Error("Response body has already been consumed in a different format");
234
+ if (this.url && this.method) {
235
+ throw new RequestError("Body already consumed", this.url, this.method, {
236
+ response: this.response,
237
+ });
238
+ }
239
+ throw new Error("Body already consumed");
175
240
  }
176
241
  this.bodyUsed = true;
177
- this.jsonCache = (await this.response.json());
178
- return this.jsonCache;
242
+ try {
243
+ const parsed = await this.response.json();
244
+ this.jsonCache = parsed;
245
+ return parsed;
246
+ }
247
+ catch (e) {
248
+ const errorMessage = e instanceof Error ? e.message : String(e);
249
+ if (this.url && this.method) {
250
+ throw new RequestError(`Invalid JSON: ${errorMessage}`, this.url, this.method, {
251
+ status: this.response.status,
252
+ response: this.response,
253
+ });
254
+ }
255
+ throw e;
256
+ }
179
257
  }
258
+ /**
259
+ * Get the response body as text
260
+ * Caches the result for subsequent calls. Attempts to convert from JSON or Blob
261
+ * if the body has already been consumed in those formats.
262
+ *
263
+ * @returns The response text
264
+ * @throws Error if the response has already been consumed in an incompatible format
265
+ *
266
+ * @example
267
+ * const text = await response.getText();
268
+ */
180
269
  async getText() {
181
270
  if (this.textCache !== undefined) {
182
271
  return this.textCache;
@@ -189,15 +278,55 @@ class ResponseWrapper {
189
278
  }
190
279
  // If we have blob, convert to text
191
280
  if (this.blobCache !== undefined) {
192
- this.textCache = await this.blobCache.text();
193
- return this.textCache;
281
+ try {
282
+ this.textCache = await this.blobCache.text();
283
+ return this.textCache;
284
+ }
285
+ catch (e) {
286
+ const errorMessage = e instanceof Error ? e.message : String(e);
287
+ if (this.url && this.method) {
288
+ throw new RequestError(`Cannot convert to text: ${errorMessage}`, this.url, this.method, {
289
+ response: this.response,
290
+ });
291
+ }
292
+ throw e;
293
+ }
294
+ }
295
+ if (this.url && this.method) {
296
+ throw new RequestError("Cannot convert to text", this.url, this.method, {
297
+ response: this.response,
298
+ });
194
299
  }
195
- throw new Error("Response body has already been consumed in a format that cannot be converted to text");
300
+ throw new Error("Cannot convert to text");
196
301
  }
197
302
  this.bodyUsed = true;
198
- this.textCache = await this.response.text();
199
- return this.textCache;
303
+ try {
304
+ this.textCache = await this.response.text();
305
+ return this.textCache;
306
+ }
307
+ catch (e) {
308
+ const errorMessage = e instanceof Error ? e.message : String(e);
309
+ if (this.url && this.method) {
310
+ throw new RequestError(`Read failed: ${errorMessage}`, this.url, this.method, {
311
+ status: this.response.status,
312
+ response: this.response,
313
+ });
314
+ }
315
+ throw e;
316
+ }
200
317
  }
318
+ /**
319
+ * Get the response body as a Blob
320
+ * Caches the result for subsequent calls. Attempts to convert from text
321
+ * if the body has already been consumed as text.
322
+ *
323
+ * @returns The response as a Blob
324
+ * @throws Error if the response has already been consumed in an incompatible format
325
+ *
326
+ * @example
327
+ * const blob = await response.getBlob();
328
+ * const url = URL.createObjectURL(blob);
329
+ */
201
330
  async getBlob() {
202
331
  if (this.blobCache !== undefined) {
203
332
  return this.blobCache;
@@ -208,367 +337,192 @@ class ResponseWrapper {
208
337
  this.blobCache = new Blob([this.textCache], { type: "text/plain" });
209
338
  return this.blobCache;
210
339
  }
211
- throw new Error("Response body has already been consumed in a format that cannot be converted to Blob");
340
+ if (this.url && this.method) {
341
+ throw new RequestError("Cannot convert to Blob", this.url, this.method, {
342
+ response: this.response,
343
+ });
344
+ }
345
+ throw new Error("Cannot convert to Blob");
212
346
  }
213
347
  this.bodyUsed = true;
214
- this.blobCache = await this.response.blob();
215
- return this.blobCache;
216
- }
217
- async getArrayBuffer() {
218
- if (this.arrayBufferCache !== undefined) {
219
- return this.arrayBufferCache;
348
+ try {
349
+ this.blobCache = await this.response.blob();
350
+ return this.blobCache;
220
351
  }
221
- if (this.bodyUsed) {
222
- // If we have text, convert to ArrayBuffer
223
- if (this.textCache !== undefined) {
224
- const encoder = new TextEncoder();
225
- // Fix ArrayBufferLike by explicitly casting to ArrayBuffer
226
- const uint8Array = encoder.encode(this.textCache);
227
- this.arrayBufferCache = uint8Array.buffer;
228
- return this.arrayBufferCache;
229
- }
230
- // If we have blob, convert to ArrayBuffer
231
- if (this.blobCache !== undefined) {
232
- this.arrayBufferCache = await this.blobCache.arrayBuffer();
233
- return this.arrayBufferCache;
352
+ catch (e) {
353
+ const errorMessage = e instanceof Error ? e.message : String(e);
354
+ if (this.url && this.method) {
355
+ throw new RequestError(`Read failed: ${errorMessage}`, this.url, this.method, {
356
+ status: this.response.status,
357
+ response: this.response,
358
+ });
234
359
  }
235
- throw new Error("Response body has already been consumed in a format that cannot be converted to ArrayBuffer");
360
+ throw e;
236
361
  }
237
- this.bodyUsed = true;
238
- this.arrayBufferCache = await this.response.arrayBuffer();
239
- return this.arrayBufferCache;
240
362
  }
363
+ /**
364
+ * Get the raw response body as a ReadableStream
365
+ * This method consumes the body and should only be called once.
366
+ *
367
+ * @returns The response body as a ReadableStream or null
368
+ * @throws Error if the response body has already been consumed
369
+ *
370
+ * @example
371
+ * const stream = response.getBody();
372
+ * if (stream) {
373
+ * const reader = stream.getReader();
374
+ * // Process the stream
375
+ * }
376
+ */
241
377
  getBody() {
242
378
  if (this.bodyUsed) {
243
- throw new Error("Response body has already been consumed");
379
+ if (this.url && this.method) {
380
+ throw new RequestError("Body already consumed", this.url, this.method, {
381
+ response: this.response,
382
+ });
383
+ }
384
+ throw new Error("Body already consumed");
244
385
  }
245
386
  this.bodyUsed = true;
246
387
  return this.response.body;
247
388
  }
248
- }
249
-
250
- /**
251
- * Parse size strings like "10MB" into bytes
252
- * @param size Size as number or string with units
253
- */
254
- function parseSize(size) {
255
- var _a;
256
- if (typeof size === "number")
257
- return size;
258
- const match = size.match(/^(\d+(?:\.\d+)?)\s*(KB|MB|GB)?$/i);
259
- if (!match)
260
- return 0;
261
- const num = parseFloat(match[1]);
262
- const unit = (_a = match[2]) === null || _a === void 0 ? void 0 : _a.toUpperCase();
263
- switch (unit) {
264
- case "KB":
265
- return num * 1024;
266
- case "MB":
267
- return num * 1024 * 1024;
268
- case "GB":
269
- return num * 1024 * 1024 * 1024;
270
- default:
271
- return num;
272
- }
273
- }
274
- /**
275
- * Create a memory storage provider using Map
276
- */
277
- function createMemoryStorage() {
278
- const store = new Map();
279
- return {
280
- get(key) {
281
- return store.get(key) || null;
282
- },
283
- set(key, value) {
284
- store.set(key, value);
285
- },
286
- has(key) {
287
- return store.has(key);
288
- },
289
- delete(key) {
290
- store.delete(key);
291
- },
292
- clear() {
293
- store.clear();
294
- },
295
- };
296
- }
297
- /**
298
- * Create a localStorage-based storage provider
299
- */
300
- function createLocalStorageStorage() {
301
- return {
302
- get(key) {
303
- if (typeof localStorage === "undefined")
304
- return null;
305
- return localStorage.getItem(key);
306
- },
307
- set(key, value) {
308
- if (typeof localStorage === "undefined")
309
- return;
310
- try {
311
- localStorage.setItem(key, value);
389
+ /**
390
+ * Extract specific data using a selector function
391
+ * If no selector is provided, returns the full JSON response.
392
+ *
393
+ * @param selector - Optional function to extract and transform data
394
+ * @returns A promise that resolves to the selected data
395
+ *
396
+ * @example
397
+ * // Get full response
398
+ * const data = await response.getData();
399
+ *
400
+ * // Extract specific data
401
+ * const users = await response.getData(data => data.results.users);
402
+ */
403
+ async getData(selector) {
404
+ try {
405
+ const data = await this.getJson();
406
+ // If no selector is provided, return the raw JSON data
407
+ if (!selector)
408
+ return data;
409
+ // Apply the selector if provided
410
+ return selector(data);
411
+ }
412
+ catch (error) {
413
+ // If it's already a RequestError, re-throw it
414
+ if (error instanceof RequestError) {
415
+ throw error;
312
416
  }
313
- catch (e) {
314
- // Handle quota exceeded errors
315
- console.warn("Cache storage quota exceeded, clearing cache");
316
- void this.clear();
317
- try {
318
- localStorage.setItem(key, value);
319
- }
320
- catch (e) {
321
- console.error("Failed to set cache item even after clearing cache");
417
+ // Enhance selector errors with context
418
+ if (selector) {
419
+ const errorMessage = error instanceof Error ? error.message : String(error);
420
+ if (this.url && this.method) {
421
+ throw new RequestError(`Data selector failed: ${errorMessage}`, this.url, this.method, {
422
+ response: this.response,
423
+ });
322
424
  }
425
+ throw new Error(`Data selector failed: ${errorMessage}`);
323
426
  }
324
- },
325
- has(key) {
326
- if (typeof localStorage === "undefined")
327
- return false;
328
- return localStorage.getItem(key) !== null;
329
- },
330
- delete(key) {
331
- if (typeof localStorage === "undefined")
332
- return;
333
- localStorage.removeItem(key);
334
- },
335
- clear() {
336
- if (typeof localStorage === "undefined")
337
- return;
338
- localStorage.clear();
339
- },
340
- };
427
+ // If we get here and it's not a RequestError, wrap it
428
+ // This should rarely happen as getJson() should throw RequestError
429
+ const errorObj = error instanceof Error ? error : new Error(String(error));
430
+ if (this.url && this.method) {
431
+ throw RequestError.networkError(this.url, this.method, errorObj);
432
+ }
433
+ throw errorObj;
434
+ }
435
+ }
341
436
  }
342
- /**
343
- * Create a sessionStorage-based storage provider
344
- */
345
- function createSessionStorageStorage() {
346
- return {
347
- get(key) {
348
- if (typeof sessionStorage === "undefined")
349
- return null;
350
- return sessionStorage.getItem(key);
351
- },
352
- set(key, value) {
353
- if (typeof sessionStorage === "undefined")
354
- return;
355
- try {
356
- sessionStorage.setItem(key, value);
437
+
438
+ class CookieUtils {
439
+ /**
440
+ * Formats cookies for a request
441
+ * @param cookies Object containing cookie name-value pairs or cookie options
442
+ * @returns Formatted cookie string for the Cookie header
443
+ */
444
+ static formatRequestCookies(cookies) {
445
+ const cookiePairs = [];
446
+ Object.entries(cookies).forEach(([name, valueOrOptions]) => {
447
+ let value;
448
+ if (typeof valueOrOptions === "string") {
449
+ value = valueOrOptions;
357
450
  }
358
- catch (e) {
359
- // Handle quota exceeded errors
360
- console.warn("Cache storage quota exceeded, clearing cache");
361
- void this.clear();
362
- try {
363
- sessionStorage.setItem(key, value);
364
- }
365
- catch (e) {
366
- console.error("Failed to set cache item even after clearing cache");
367
- }
451
+ else {
452
+ // Extract value from options object without validation
453
+ value = valueOrOptions.value;
368
454
  }
369
- },
370
- has(key) {
371
- if (typeof sessionStorage === "undefined")
372
- return false;
373
- return sessionStorage.getItem(key) !== null;
374
- },
375
- delete(key) {
376
- if (typeof sessionStorage === "undefined")
377
- return;
378
- sessionStorage.removeItem(key);
379
- },
380
- clear() {
381
- if (typeof sessionStorage === "undefined")
382
- return;
383
- sessionStorage.clear();
384
- },
385
- };
386
- }
387
- /**
388
- * Create a storage provider from a cache options object
389
- */
390
- function createStorageProvider(options) {
391
- const { storage } = options || {};
392
- if (!storage) {
393
- // Default to memory storage
394
- return createMemoryStorage();
395
- }
396
- // Check if storage is already a StorageProvider
397
- if (typeof storage.get === "function" && typeof storage.set === "function") {
398
- return storage;
399
- }
400
- // Check if it's localStorage or sessionStorage
401
- if (storage === localStorage) {
402
- return createLocalStorageStorage();
403
- }
404
- if (storage === sessionStorage) {
405
- return createSessionStorageStorage();
406
- }
407
- // Check if it's a Map
408
- if (storage instanceof Map) {
409
- return {
410
- get(key) {
411
- return storage.get(key) || null;
412
- },
413
- set(key, value, _ttl) {
414
- storage.set(key, value);
415
- },
416
- has(key) {
417
- return storage.has(key);
418
- },
419
- delete(key) {
420
- storage.delete(key);
421
- },
422
- clear() {
423
- storage.clear();
424
- },
425
- };
455
+ // Add the cookie to the request
456
+ cookiePairs.push(`${encodeURIComponent(name)}=${encodeURIComponent(value)}`);
457
+ });
458
+ return cookiePairs.join("; ");
426
459
  }
427
- // Default to memory storage
428
- console.warn("Unsupported storage type, falling back to memory storage");
429
- return createMemoryStorage();
430
460
  }
431
461
 
432
462
  /**
433
- * Cache manager that handles storage operations
463
+ * Utility class for CSRF token management
434
464
  */
435
- class CacheManager {
436
- constructor(options = {}) {
437
- this.entryCount = 0;
438
- this.totalSize = 0;
439
- this.options = options;
440
- this.storage = createStorageProvider(options);
441
- }
465
+ class CsrfUtils {
442
466
  /**
443
- * Generate a cache key based on request details
467
+ * Extracts CSRF token from a meta tag in the document head
468
+ * @param metaName The name attribute of the meta tag (default: "csrf-token")
469
+ * @returns The CSRF token or null if not found
444
470
  */
445
- generateKey(url, method, headers, body) {
446
- // Use custom key generator if provided
447
- if (this.options.keyGenerator) {
448
- const customKey = this.options.keyGenerator(url, method, headers, body);
449
- return this.options.keyPrefix ? `${this.options.keyPrefix}:${customKey}` : customKey;
450
- }
451
- // Default key generation
452
- let key = `${method}:${url}`;
453
- // Add vary headers to key if specified
454
- if (this.options.varyByHeaders && headers) {
455
- const varyHeaders = {};
456
- this.options.varyByHeaders.forEach(header => {
457
- const normalizedHeader = header.toLowerCase();
458
- // Find the header in case-insensitive way
459
- Object.keys(headers).forEach(h => {
460
- if (h.toLowerCase() === normalizedHeader) {
461
- varyHeaders[normalizedHeader] = headers[h];
462
- }
463
- });
464
- });
465
- if (Object.keys(varyHeaders).length > 0) {
466
- key += `:${JSON.stringify(varyHeaders)}`;
467
- }
468
- }
469
- // Include body in cache key for non-GET requests if it's a simple value
470
- if (method !== "GET" && body !== undefined && body !== null) {
471
- try {
472
- if (typeof body === "string" || typeof body === "number" || typeof body === "boolean") {
473
- key += `:${String(body)}`;
474
- }
475
- else if (typeof body === "object") {
476
- key += `:${JSON.stringify(body)}`;
477
- }
478
- }
479
- catch (e) {
480
- console.warn("Failed to stringify request body for cache key");
481
- }
471
+ static getTokenFromMeta(metaName = "csrf-token") {
472
+ if (typeof document === "undefined") {
473
+ return null;
482
474
  }
483
- // Add prefix if specified
484
- return this.options.keyPrefix ? `${this.options.keyPrefix}:${key}` : key;
475
+ const meta = document.querySelector(`meta[name="${metaName}"]`);
476
+ return (meta === null || meta === void 0 ? void 0 : meta.getAttribute("content")) || null;
485
477
  }
486
478
  /**
487
- * Get a value from cache
479
+ * Extracts CSRF token from a cookie
480
+ * @param cookieName The name of the cookie containing the CSRF token
481
+ * @returns The CSRF token or null if not found
488
482
  */
489
- async get(key) {
490
- const value = await Promise.resolve(this.storage.get(key));
491
- if (!value)
483
+ static getTokenFromCookie(cookieName = "csrf-token") {
484
+ if (typeof document === "undefined") {
492
485
  return null;
493
- try {
494
- const entry = JSON.parse(value);
495
- // Check if the entry has expired
496
- if (entry.expiry && entry.expiry < Date.now()) {
497
- await Promise.resolve(this.storage.delete(key));
498
- return null;
499
- }
500
- return entry;
501
486
  }
502
- catch (e) {
503
- // If we can't parse the cached value, remove it
504
- await Promise.resolve(this.storage.delete(key));
505
- return null;
487
+ const cookies = document.cookie.split(";");
488
+ for (const cookie of cookies) {
489
+ const [name, value] = cookie.trim().split("=");
490
+ if (name === cookieName) {
491
+ return decodeURIComponent(value);
492
+ }
506
493
  }
494
+ return null;
507
495
  }
508
496
  /**
509
- * Set a value in cache
497
+ * Validates if the provided string is a potential CSRF token
498
+ * Checks if the token meets security requirements
499
+ * @param token The token to validate
500
+ * @returns Whether the token is valid
510
501
  */
511
- async set(key, value, headers) {
512
- const now = Date.now();
513
- const ttl = this.options.ttl;
514
- const entry = {
515
- value,
516
- timestamp: now,
517
- expiry: ttl ? now + ttl : null,
518
- headers,
519
- };
520
- const serialized = JSON.stringify(entry);
521
- // Check size constraints if applicable
522
- if (this.options.maxSize) {
523
- const maxBytes = parseSize(this.options.maxSize);
524
- const entrySize = new TextEncoder().encode(serialized).length;
525
- // If a single entry exceeds the max size, reject it without clearing the cache
526
- if (entrySize > maxBytes) {
527
- console.warn(`Cache entry size (${entrySize} bytes) exceeds maximum cache size (${maxBytes} bytes)`);
528
- return;
529
- }
530
- // Only clear if adding this entry would exceed the total limit
531
- if (this.totalSize + entrySize > maxBytes) {
532
- // Simple strategy: clear everything
533
- // A more sophisticated strategy would remove oldest entries first
534
- await Promise.resolve(this.storage.clear());
535
- this.totalSize = 0;
536
- this.entryCount = 0;
537
- }
538
- this.totalSize += entrySize;
502
+ static isValidToken(token) {
503
+ // Basic checks for null/undefined and type
504
+ if (typeof token !== "string") {
505
+ return false;
539
506
  }
540
- // Check max entries constraint
541
- if (this.options.maxEntries) {
542
- this.entryCount++;
543
- if (this.entryCount > this.options.maxEntries) {
544
- // Simple strategy: clear everything
545
- // A more sophisticated strategy would use LRU cache
546
- await Promise.resolve(this.storage.clear());
547
- this.entryCount = 1;
507
+ if (token.length < 8)
508
+ return false;
509
+ // If token is longer than 10 chars, perform additional security checks
510
+ if (token.length > 10) {
511
+ // Check for valid character set (alphanumeric & common token symbols)
512
+ const validTokenRegex = /^[A-Za-z0-9\-_=+/.]+$/;
513
+ if (!validTokenRegex.test(token)) {
514
+ return false;
548
515
  }
516
+ // For longer tokens, check for sufficient entropy (at least 2 character types)
517
+ const hasUpperCase = /[A-Z]/.test(token);
518
+ const hasLowerCase = /[a-z]/.test(token);
519
+ const hasNumbers = /[0-9]/.test(token);
520
+ const hasSpecials = /[-_=+/.]/.test(token);
521
+ const characterTypesCount = [hasUpperCase, hasLowerCase, hasNumbers, hasSpecials].filter(Boolean).length;
522
+ return characterTypesCount >= 2;
549
523
  }
550
- // Pass ttl to storage provider if available
551
- await Promise.resolve(this.storage.set(key, serialized, ttl));
552
- }
553
- /**
554
- * Check if a key exists in the cache
555
- */
556
- async has(key) {
557
- return Promise.resolve(this.storage.has(key));
558
- }
559
- /**
560
- * Delete a key from the cache
561
- */
562
- async delete(key) {
563
- return Promise.resolve(this.storage.delete(key));
564
- }
565
- /**
566
- * Clear the entire cache
567
- */
568
- async clear() {
569
- this.entryCount = 0;
570
- this.totalSize = 0;
571
- return Promise.resolve(this.storage.clear());
524
+ // For shorter tokens (8-10 chars), just return true if we reached here
525
+ return true;
572
526
  }
573
527
  }
574
528
 
@@ -584,9 +538,20 @@ class Config {
584
538
  this.csrfToken = null;
585
539
  this.enableAutoXsrf = true;
586
540
  this.enableAntiCsrf = true; // X-Requested-With header
541
+ // Interceptor configuration
542
+ this.requestInterceptors = [];
543
+ this.responseInterceptors = [];
544
+ this.errorInterceptors = [];
545
+ this.nextInterceptorId = 1;
587
546
  }
588
547
  /**
589
- * Get the singleton instance
548
+ * Get the singleton instance of the Config class
549
+ *
550
+ * @returns The global configuration instance
551
+ *
552
+ * @example
553
+ * const config = Config.getInstance();
554
+ * config.setCsrfToken('token123');
590
555
  */
591
556
  static getInstance() {
592
557
  if (!Config.instance) {
@@ -596,228 +561,425 @@ class Config {
596
561
  }
597
562
  /**
598
563
  * Set a global CSRF token to be used for all requests
564
+ *
565
+ * @param token - The CSRF token value
566
+ * @returns The config instance for chaining
567
+ *
568
+ * @example
569
+ * Config.getInstance().setCsrfToken('myToken123');
599
570
  */
600
571
  setCsrfToken(token) {
601
572
  this.csrfToken = token;
602
573
  return this;
603
574
  }
604
575
  /**
605
- * Get the global CSRF token
576
+ * Get the global CSRF token that will be automatically applied to requests
577
+ *
578
+ * @returns The current CSRF token or null if not set
606
579
  */
607
580
  getCsrfToken() {
608
581
  return this.csrfToken;
609
582
  }
610
583
  /**
611
- * Set the CSRF header name (default is X-CSRF-Token)
584
+ * Set the CSRF header name used when sending the token
585
+ *
586
+ * @param name - The header name to use
587
+ * @returns The config instance for chaining
588
+ *
589
+ * @example
590
+ * Config.getInstance().setCsrfHeaderName('X-My-CSRF-Token');
612
591
  */
613
592
  setCsrfHeaderName(name) {
614
593
  this.csrfHeaderName = name;
615
594
  return this;
616
595
  }
617
596
  /**
618
- * Get the CSRF header name
597
+ * Get the configured CSRF header name
598
+ *
599
+ * @returns The current CSRF header name
619
600
  */
620
601
  getCsrfHeaderName() {
621
602
  return this.csrfHeaderName;
622
603
  }
623
604
  /**
624
- * Set the XSRF cookie name to look for (default is XSRF-TOKEN)
605
+ * Set the XSRF cookie name to look for when extracting tokens from cookies
606
+ *
607
+ * @param name - The cookie name to look for
608
+ * @returns The config instance for chaining
609
+ *
610
+ * @example
611
+ * Config.getInstance().setXsrfCookieName('MY-XSRF-COOKIE');
625
612
  */
626
613
  setXsrfCookieName(name) {
627
614
  this.xsrfCookieName = name;
628
615
  return this;
629
616
  }
630
617
  /**
631
- * Get the XSRF cookie name
618
+ * Get the configured XSRF cookie name
619
+ *
620
+ * @returns The current XSRF cookie name
632
621
  */
633
622
  getXsrfCookieName() {
634
623
  return this.xsrfCookieName;
635
624
  }
636
625
  /**
637
- * Set the XSRF header name to send the token in (default is X-XSRF-TOKEN)
626
+ * Set the XSRF header name for sending tokens extracted from cookies
627
+ *
628
+ * @param name - The header name to use
629
+ * @returns The config instance for chaining
638
630
  */
639
631
  setXsrfHeaderName(name) {
640
632
  this.xsrfHeaderName = name;
641
633
  return this;
642
634
  }
643
635
  /**
644
- * Get the XSRF header name
636
+ * Get the configured XSRF header name
637
+ *
638
+ * @returns The current XSRF header name
645
639
  */
646
640
  getXsrfHeaderName() {
647
641
  return this.xsrfHeaderName;
648
642
  }
649
643
  /**
650
- * Enable or disable automatic XSRF token extraction from cookies
644
+ * Enable or disable automatic extraction of XSRF tokens from cookies
645
+ * When enabled, the library will look for XSRF tokens in cookies and
646
+ * automatically add them to request headers.
647
+ *
648
+ * @param enable - Whether to enable this feature
649
+ * @returns The config instance for chaining
650
+ *
651
+ * @example
652
+ * Config.getInstance().setEnableAutoXsrf(false); // Disable XSRF extraction
651
653
  */
652
654
  setEnableAutoXsrf(enable) {
653
655
  this.enableAutoXsrf = enable;
654
656
  return this;
655
657
  }
656
658
  /**
657
- * Check if automatic XSRF is enabled
659
+ * Check if automatic XSRF token extraction is enabled
660
+ *
661
+ * @returns True if automatic XSRF is enabled
658
662
  */
659
663
  isAutoXsrfEnabled() {
660
664
  return this.enableAutoXsrf;
661
665
  }
662
666
  /**
663
667
  * Enable or disable automatic addition of anti-CSRF headers
668
+ * When enabled, X-Requested-With: XMLHttpRequest will be added to all requests.
669
+ *
670
+ * @param enable - Whether to enable this feature
671
+ * @returns The config instance for chaining
664
672
  */
665
673
  setEnableAntiCsrf(enable) {
666
674
  this.enableAntiCsrf = enable;
667
675
  return this;
668
676
  }
669
677
  /**
670
- * Check if anti-CSRF is enabled
678
+ * Check if anti-CSRF protection is enabled
679
+ *
680
+ * @returns True if anti-CSRF protection is enabled
671
681
  */
672
682
  isAntiCsrfEnabled() {
673
683
  return this.enableAntiCsrf;
674
684
  }
675
685
  /**
676
- * Reset all configuration options to their defaults
686
+ * Add a global request interceptor
687
+ * Request interceptors can modify the request configuration or return an early response
688
+ *
689
+ * @param interceptor - The request interceptor function
690
+ * @returns The interceptor ID for later removal
691
+ *
692
+ * @example
693
+ * const id = Config.getInstance().addRequestInterceptor((config) => {
694
+ * config.headers['X-Custom'] = 'value';
695
+ * return config;
696
+ * });
677
697
  */
678
- reset() {
679
- this.csrfToken = null;
680
- this.csrfHeaderName = "X-CSRF-Token";
681
- this.enableAntiCsrf = true;
682
- this.xsrfCookieName = "XSRF-TOKEN";
683
- this.xsrfHeaderName = "X-XSRF-TOKEN";
684
- this.enableAutoXsrf = true;
685
- return this;
698
+ addRequestInterceptor(interceptor) {
699
+ const id = this.nextInterceptorId++;
700
+ this.requestInterceptors.push({ id, interceptor });
701
+ return id;
686
702
  }
687
- }
688
-
689
- class CookieUtils {
690
703
  /**
691
- * Formats cookies for a request
692
- * @param cookies Object containing cookie name-value pairs or cookie options
693
- * @returns Formatted cookie string for the Cookie header
704
+ * Add a global response interceptor
705
+ * Response interceptors can transform the response
706
+ *
707
+ * @param interceptor - The response interceptor function
708
+ * @returns The interceptor ID for later removal
709
+ *
710
+ * @example
711
+ * const id = Config.getInstance().addResponseInterceptor((response) => {
712
+ * console.log('Response received:', response.status);
713
+ * return response;
714
+ * });
694
715
  */
695
- static formatRequestCookies(cookies) {
696
- const cookiePairs = [];
697
- Object.entries(cookies).forEach(([name, valueOrOptions]) => {
698
- let value;
699
- if (typeof valueOrOptions === "string") {
700
- value = valueOrOptions;
701
- }
702
- else {
703
- // Extract value from options object without validation
704
- value = valueOrOptions.value;
705
- }
706
- // Add the cookie to the request
707
- cookiePairs.push(`${encodeURIComponent(name)}=${encodeURIComponent(value)}`);
708
- });
709
- return cookiePairs.join("; ");
716
+ addResponseInterceptor(interceptor) {
717
+ const id = this.nextInterceptorId++;
718
+ this.responseInterceptors.push({ id, interceptor });
719
+ return id;
710
720
  }
711
- }
712
-
713
- /**
714
- * Utility class for CSRF token management
715
- */
716
- class CsrfUtils {
717
721
  /**
718
- * Extracts CSRF token from a meta tag in the document head
719
- * @param metaName The name attribute of the meta tag (default: "csrf-token")
720
- * @returns The CSRF token or null if not found
722
+ * Add a global error interceptor
723
+ * Error interceptors can handle or transform errors
724
+ *
725
+ * @param interceptor - The error interceptor function
726
+ * @returns The interceptor ID for later removal
727
+ *
728
+ * @example
729
+ * const id = Config.getInstance().addErrorInterceptor((error) => {
730
+ * console.error('Request failed:', error);
731
+ * throw error;
732
+ * });
721
733
  */
722
- static getTokenFromMeta(metaName = "csrf-token") {
723
- if (typeof document === "undefined") {
724
- return null;
725
- }
726
- const meta = document.querySelector(`meta[name="${metaName}"]`);
727
- return (meta === null || meta === void 0 ? void 0 : meta.getAttribute("content")) || null;
734
+ addErrorInterceptor(interceptor) {
735
+ const id = this.nextInterceptorId++;
736
+ this.errorInterceptors.push({ id, interceptor });
737
+ return id;
728
738
  }
729
739
  /**
730
- * Extracts CSRF token from a cookie
731
- * @param cookieName The name of the cookie containing the CSRF token
732
- * @returns The CSRF token or null if not found
740
+ * Remove a request interceptor by its ID
741
+ *
742
+ * @param id - The interceptor ID returned from addRequestInterceptor
743
+ *
744
+ * @example
745
+ * Config.getInstance().removeRequestInterceptor(id);
733
746
  */
734
- static getTokenFromCookie(cookieName = "csrf-token") {
735
- if (typeof document === "undefined") {
736
- return null;
737
- }
738
- const cookies = document.cookie.split(";");
739
- for (const cookie of cookies) {
740
- const [name, value] = cookie.trim().split("=");
741
- if (name === cookieName) {
742
- return decodeURIComponent(value);
743
- }
744
- }
745
- return null;
746
- }
747
- /**
748
- * Validates if the provided string is a potential CSRF token
749
- * Checks if the token meets security requirements
750
- * @param token The token to validate
751
- * @returns Whether the token is valid
752
- */
753
- static isValidToken(token) {
754
- // Basic checks for null/undefined and type
755
- if (typeof token !== "string") {
756
- return false;
757
- }
758
- if (token.length < 8)
759
- return false;
760
- // If token is longer than 10 chars, perform additional security checks
761
- if (token.length > 10) {
762
- // Check for valid character set (alphanumeric & common token symbols)
763
- const validTokenRegex = /^[A-Za-z0-9\-_=+/.]+$/;
764
- if (!validTokenRegex.test(token)) {
765
- return false;
766
- }
767
- // For longer tokens, check for sufficient entropy (at least 2 character types)
768
- const hasUpperCase = /[A-Z]/.test(token);
769
- const hasLowerCase = /[a-z]/.test(token);
770
- const hasNumbers = /[0-9]/.test(token);
771
- const hasSpecials = /[-_=+/.]/.test(token);
772
- const characterTypesCount = [hasUpperCase, hasLowerCase, hasNumbers, hasSpecials].filter(Boolean).length;
773
- return characterTypesCount >= 2;
774
- }
775
- // For shorter tokens (8-10 chars), just return true if we reached here
776
- return true;
747
+ removeRequestInterceptor(id) {
748
+ this.requestInterceptors = this.requestInterceptors.filter(item => item.id !== id);
749
+ }
750
+ /**
751
+ * Remove a response interceptor by its ID
752
+ *
753
+ * @param id - The interceptor ID returned from addResponseInterceptor
754
+ *
755
+ * @example
756
+ * Config.getInstance().removeResponseInterceptor(id);
757
+ */
758
+ removeResponseInterceptor(id) {
759
+ this.responseInterceptors = this.responseInterceptors.filter(item => item.id !== id);
760
+ }
761
+ /**
762
+ * Remove an error interceptor by its ID
763
+ *
764
+ * @param id - The interceptor ID returned from addErrorInterceptor
765
+ *
766
+ * @example
767
+ * Config.getInstance().removeErrorInterceptor(id);
768
+ */
769
+ removeErrorInterceptor(id) {
770
+ this.errorInterceptors = this.errorInterceptors.filter(item => item.id !== id);
771
+ }
772
+ /**
773
+ * Clear all interceptors (request, response, and error)
774
+ *
775
+ * @example
776
+ * Config.getInstance().clearInterceptors();
777
+ */
778
+ clearInterceptors() {
779
+ this.requestInterceptors = [];
780
+ this.responseInterceptors = [];
781
+ this.errorInterceptors = [];
782
+ }
783
+ /**
784
+ * Get all global request interceptors (in registration order)
785
+ * @internal
786
+ */
787
+ getRequestInterceptors() {
788
+ return this.requestInterceptors.map(item => item.interceptor);
789
+ }
790
+ /**
791
+ * Get all global response interceptors (in registration order)
792
+ * @internal
793
+ */
794
+ getResponseInterceptors() {
795
+ return this.responseInterceptors.map(item => item.interceptor);
796
+ }
797
+ /**
798
+ * Get all global error interceptors (in registration order)
799
+ * @internal
800
+ */
801
+ getErrorInterceptors() {
802
+ return this.errorInterceptors.map(item => item.interceptor);
803
+ }
804
+ /**
805
+ * Reset all configuration options to their default values
806
+ *
807
+ * @returns The config instance for chaining
808
+ *
809
+ * @example
810
+ * Config.getInstance().reset();
811
+ */
812
+ reset() {
813
+ this.csrfToken = null;
814
+ this.csrfHeaderName = "X-CSRF-Token";
815
+ this.enableAntiCsrf = true;
816
+ this.xsrfCookieName = "XSRF-TOKEN";
817
+ this.xsrfHeaderName = "X-XSRF-TOKEN";
818
+ this.enableAutoXsrf = true;
819
+ this.clearInterceptors();
820
+ return this;
777
821
  }
778
822
  }
779
823
 
780
- // Base class with common functionality for all request types
824
+ /**
825
+ * Base class with common functionality for all request types
826
+ * Provides the core request building and execution capabilities.
827
+ */
781
828
  class BaseRequest {
782
- constructor() {
829
+ constructor(url) {
783
830
  this.requestOptions = {
784
831
  headers: {},
785
832
  };
786
833
  this.queryParams = new URLSearchParams();
787
834
  this.autoApplyCsrfProtection = true;
788
- this.cacheEnabled = false;
835
+ // Per-request interceptors
836
+ this.requestInterceptors = [];
837
+ this.responseInterceptors = [];
838
+ this.errorInterceptors = [];
839
+ this.url = url;
840
+ }
841
+ /**
842
+ * Creates a fluent API for setting enum-based options
843
+ * Combines direct setter with convenience methods
844
+ */
845
+ createFluentSetter(optionName, options) {
846
+ const fluent = {};
847
+ // Create convenience methods for each enum value
848
+ Object.entries(options).forEach(([key, value]) => {
849
+ fluent[key] = () => {
850
+ this.requestOptions[optionName] = value;
851
+ return this;
852
+ };
853
+ });
854
+ // Create the callable setter
855
+ const callable = (value) => {
856
+ this.requestOptions[optionName] = value;
857
+ return this;
858
+ };
859
+ return Object.assign(callable, fluent);
860
+ }
861
+ validateUrl(url) {
862
+ if (!(url === null || url === void 0 ? void 0 : url.trim()))
863
+ throw new RequestError("URL cannot be empty", url, this.method);
864
+ if (url.includes("\0") || url.includes("\r") || url.includes("\n")) {
865
+ throw new RequestError("Invalid URL (control chars)", url, this.method);
866
+ }
867
+ const trimmed = url.trim();
868
+ if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
869
+ try {
870
+ new URL(trimmed);
871
+ }
872
+ catch (_a) {
873
+ throw new RequestError(`Invalid URL: ${trimmed}`, trimmed, this.method);
874
+ }
875
+ }
789
876
  }
877
+ /**
878
+ * Add multiple HTTP headers to the request
879
+ *
880
+ * @param headers - Key-value pairs of header names and values
881
+ * @returns The request instance for chaining
882
+ *
883
+ * @example
884
+ * request.withHeaders({
885
+ * 'Accept': 'application/json',
886
+ * 'X-Custom-Header': 'value'
887
+ * });
888
+ */
790
889
  withHeaders(headers) {
890
+ // Filter out null and undefined values
891
+ const filteredHeaders = {};
892
+ Object.entries(headers).forEach(([key, value]) => {
893
+ if (value !== null && value !== undefined) {
894
+ filteredHeaders[key] = value;
895
+ }
896
+ });
791
897
  this.requestOptions.headers = {
792
- ...this.requestOptions.headers,
793
- ...headers,
898
+ ...this.getHeadersRecord(),
899
+ ...filteredHeaders,
794
900
  };
795
901
  return this;
796
902
  }
903
+ /**
904
+ * Add a single HTTP header to the request
905
+ *
906
+ * @param key - The header name
907
+ * @param value - The header value
908
+ * @returns The request instance for chaining
909
+ *
910
+ * @example
911
+ * request.withHeader('Accept', 'application/json');
912
+ */
913
+ withHeader(key, value) {
914
+ // Ignore null and undefined values
915
+ if (value !== null && value !== undefined) {
916
+ return this.withHeaders({ [key]: value });
917
+ }
918
+ return this;
919
+ }
920
+ /**
921
+ * Set a timeout for the request
922
+ * If the request takes longer than the specified timeout, it will be aborted.
923
+ *
924
+ * @param timeout - The timeout in milliseconds
925
+ * @returns The request instance for chaining
926
+ * @throws Error if timeout is not a positive number
927
+ *
928
+ * @example
929
+ * request.withTimeout(5000); // 5 seconds timeout
930
+ */
797
931
  withTimeout(timeout) {
798
932
  if (!Number.isFinite(timeout) || timeout <= 0)
799
933
  throw new Error("Timeout must be a positive number");
800
934
  this.requestOptions.timeout = timeout;
801
935
  return this;
802
936
  }
937
+ /**
938
+ * Configure automatic retry behavior for failed requests
939
+ *
940
+ * @param retries - Number of retry attempts before failing
941
+ * @returns The request instance for chaining
942
+ * @throws Error if retries is not a non-negative integer
943
+ *
944
+ * @example
945
+ * request.withRetries(3); // Retry up to 3 times
946
+ */
803
947
  withRetries(retries) {
804
948
  if (!Number.isInteger(retries) || retries < 0)
805
949
  throw new Error("Retry count must be a non-negative integer");
806
950
  this.requestOptions.retries = retries;
807
951
  return this;
808
952
  }
953
+ /**
954
+ * Set a callback to be invoked before each retry attempt
955
+ * Useful for implementing backoff strategies or logging retry attempts.
956
+ *
957
+ * @param callback - Function to call before retrying
958
+ * @returns The request instance for chaining
959
+ *
960
+ * @example
961
+ * request.onRetry(({ attempt, error }) => {
962
+ * console.log(`Retry attempt ${attempt} after error: ${error.message}`);
963
+ * return new Promise(resolve => setTimeout(resolve, attempt * 1000));
964
+ * });
965
+ */
809
966
  onRetry(callback) {
810
967
  this.requestOptions.onRetry = callback;
811
968
  return this;
812
969
  }
813
970
  /**
814
- * Sets the credentials policy for the request
815
- * @param credentialsPolicy - Can be 'omit', 'same-origin', or 'include'
816
- * @returns The instance for chaining
971
+ * Sets credentials policy - supports both direct call and fluent API
972
+ * @example
973
+ * request.withCredentials("include")
974
+ * request.withCredentials(CredentialsPolicy.INCLUDE)
975
+ * request.withCredentials.INCLUDE()
817
976
  */
818
- withCredentials(credentialsPolicy = CredentialsPolicy.SAME_ORIGIN) {
819
- this.requestOptions.credentials = credentialsPolicy;
820
- return this;
977
+ get withCredentials() {
978
+ return this.createFluentSetter("credentials", {
979
+ INCLUDE: CredentialsPolicy.INCLUDE,
980
+ OMIT: CredentialsPolicy.OMIT,
981
+ SAME_ORIGIN: CredentialsPolicy.SAME_ORIGIN,
982
+ });
821
983
  }
822
984
  /**
823
985
  * Allows providing an external AbortController to cancel the request
@@ -838,22 +1000,37 @@ class BaseRequest {
838
1000
  return this;
839
1001
  }
840
1002
  /**
841
- * Sets the referrer policy for the request
842
- * @param policy - The referrer policy to use
843
- * @returns The instance for chaining
1003
+ * Sets referrer policy - supports both direct call and fluent API
1004
+ * @example
1005
+ * request.withReferrerPolicy("no-referrer")
1006
+ * request.withReferrerPolicy(ReferrerPolicy.NO_REFERRER)
1007
+ * request.withReferrerPolicy.NO_REFERRER()
844
1008
  */
845
- withReferrerPolicy(policy) {
846
- this.requestOptions.referrerPolicy = policy;
847
- return this;
1009
+ get withReferrerPolicy() {
1010
+ return this.createFluentSetter("referrerPolicy", {
1011
+ ORIGIN: ReferrerPolicy.ORIGIN,
1012
+ UNSAFE_URL: ReferrerPolicy.UNSAFE_URL,
1013
+ SAME_ORIGIN: ReferrerPolicy.SAME_ORIGIN,
1014
+ NO_REFERRER: ReferrerPolicy.NO_REFERRER,
1015
+ STRICT_ORIGIN: ReferrerPolicy.STRICT_ORIGIN,
1016
+ ORIGIN_WHEN_CROSS_ORIGIN: ReferrerPolicy.ORIGIN_WHEN_CROSS_ORIGIN,
1017
+ NO_REFERRER_WHEN_DOWNGRADE: ReferrerPolicy.NO_REFERRER_WHEN_DOWNGRADE,
1018
+ STRICT_ORIGIN_WHEN_CROSS_ORIGIN: ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN,
1019
+ });
848
1020
  }
849
1021
  /**
850
- * Sets the redirect policy for the request
851
- * @param redirect - Can be 'follow', 'error', or 'manual'
852
- * @returns The instance for chaining
1022
+ * Sets redirect mode - supports both direct call and fluent API
1023
+ * @example
1024
+ * request.withRedirect("follow")
1025
+ * request.withRedirect(RedirectMode.FOLLOW)
1026
+ * request.withRedirect.FOLLOW()
853
1027
  */
854
- withRedirect(redirect) {
855
- this.requestOptions.redirect = redirect;
856
- return this;
1028
+ get withRedirect() {
1029
+ return this.createFluentSetter("redirect", {
1030
+ FOLLOW: RedirectMode.FOLLOW,
1031
+ ERROR: RedirectMode.ERROR,
1032
+ MANUAL: RedirectMode.MANUAL,
1033
+ });
857
1034
  }
858
1035
  /**
859
1036
  * Sets the keepalive flag for the request
@@ -865,13 +1042,18 @@ class BaseRequest {
865
1042
  return this;
866
1043
  }
867
1044
  /**
868
- * Sets the priority of the request
869
- * @param priority - Can be 'high', 'low', or 'auto'
870
- * @returns The instance for chaining
1045
+ * Sets request priority - supports both direct call and fluent API
1046
+ * @example
1047
+ * request.withPriority("high")
1048
+ * request.withPriority(RequestPriority.HIGH)
1049
+ * request.withPriority.HIGH()
871
1050
  */
872
- withPriority(priority) {
873
- this.requestOptions.priority = priority;
874
- return this;
1051
+ get withPriority() {
1052
+ return this.createFluentSetter("priority", {
1053
+ HIGH: RequestPriority.HIGH,
1054
+ LOW: RequestPriority.LOW,
1055
+ AUTO: RequestPriority.AUTO,
1056
+ });
875
1057
  }
876
1058
  /**
877
1059
  * Adds query parameters to the request URL
@@ -909,13 +1091,19 @@ class BaseRequest {
909
1091
  return this;
910
1092
  }
911
1093
  /**
912
- * Sets the mode of the request
913
- * @param mode - The mode for the request (cors, no-cors, same-origin, navigate)
914
- * @returns The instance for chaining
1094
+ * Sets request mode - supports both direct call and fluent API
1095
+ * @example
1096
+ * request.withMode("cors")
1097
+ * request.withMode(RequestMode.CORS)
1098
+ * request.withMode.CORS()
915
1099
  */
916
- withMode(mode) {
917
- this.requestOptions.mode = mode;
918
- return this;
1100
+ get withMode() {
1101
+ return this.createFluentSetter("mode", {
1102
+ CORS: RequestMode.CORS,
1103
+ NO_CORS: RequestMode.NO_CORS,
1104
+ SAME_ORIGIN: RequestMode.SAME_ORIGIN,
1105
+ NAVIGATE: RequestMode.NAVIGATE,
1106
+ });
919
1107
  }
920
1108
  /**
921
1109
  * Shorthand for setting the Content-Type header
@@ -965,7 +1153,7 @@ class BaseRequest {
965
1153
  if (typeof Buffer !== "undefined")
966
1154
  return Buffer.from(str).toString("base64");
967
1155
  // Fallback (should never happen in modern environments)
968
- throw new Error("Base64 encoding is not supported in this environment");
1156
+ throw new Error("Base64 encoding is not supported");
969
1157
  }
970
1158
  /**
971
1159
  * Sets the bearer token for authentication
@@ -975,13 +1163,23 @@ class BaseRequest {
975
1163
  withBearerToken(token) {
976
1164
  return this.withAuthorization(`Bearer ${token}`);
977
1165
  }
1166
+ /**
1167
+ * Safely get headers as a Record<string, string>
1168
+ * @returns The headers object
1169
+ */
1170
+ getHeadersRecord() {
1171
+ if (typeof this.requestOptions.headers === "object" && this.requestOptions.headers !== null) {
1172
+ return this.requestOptions.headers;
1173
+ }
1174
+ return {};
1175
+ }
978
1176
  /**
979
1177
  * Helper function to check for header presence in a case-insensitive way
980
- * @param headers Headers object
981
1178
  * @param headerName Header name to check
982
1179
  * @returns Boolean indicating if the header exists (case-insensitive)
983
1180
  */
984
- hasHeader(headers, headerName) {
1181
+ hasHeader(headerName) {
1182
+ const headers = this.getHeadersRecord();
985
1183
  return Object.keys(headers).some(key => key.toLowerCase() === headerName.toLowerCase());
986
1184
  }
987
1185
  /**
@@ -990,12 +1188,11 @@ class BaseRequest {
990
1188
  * @returns The instance for chaining
991
1189
  */
992
1190
  withCookies(cookies) {
993
- const cookieEntries = Object.entries(cookies);
1191
+ const cookieEntries = Object.entries(cookies || {});
994
1192
  if (cookieEntries.length === 0) {
995
1193
  return this;
996
1194
  }
997
- // Get current headers or initialize empty object
998
- const currentHeaders = this.requestOptions.headers || {};
1195
+ const currentHeaders = this.getHeadersRecord();
999
1196
  // Get all existing cookie headers with different cases
1000
1197
  const cookieValues = [];
1001
1198
  const cookieHeaderKeys = [];
@@ -1064,110 +1261,178 @@ class BaseRequest {
1064
1261
  });
1065
1262
  }
1066
1263
  /**
1067
- * Enables caching for the request with the specified options
1068
- * @param options The cache configuration options
1264
+ * Add a request interceptor for this specific request
1265
+ * Request interceptors can modify the request configuration or return an early response
1266
+ *
1267
+ * @param interceptor - The request interceptor function
1268
+ * @returns The instance for chaining
1269
+ *
1270
+ * @example
1271
+ * request.withRequestInterceptor((config) => {
1272
+ * config.headers['X-Custom'] = 'value';
1273
+ * return config;
1274
+ * });
1275
+ */
1276
+ withRequestInterceptor(interceptor) {
1277
+ this.requestInterceptors.push(interceptor);
1278
+ return this;
1279
+ }
1280
+ /**
1281
+ * Add a response interceptor for this specific request
1282
+ * Response interceptors can transform the response
1283
+ *
1284
+ * @param interceptor - The response interceptor function
1285
+ * @returns The instance for chaining
1286
+ *
1287
+ * @example
1288
+ * request.withResponseInterceptor((response) => {
1289
+ * console.log('Status:', response.status);
1290
+ * return response;
1291
+ * });
1292
+ */
1293
+ withResponseInterceptor(interceptor) {
1294
+ this.responseInterceptors.push(interceptor);
1295
+ return this;
1296
+ }
1297
+ /**
1298
+ * Add an error interceptor for this specific request
1299
+ * Error interceptors can handle or transform errors
1300
+ *
1301
+ * @param interceptor - The error interceptor function
1069
1302
  * @returns The instance for chaining
1303
+ *
1304
+ * @example
1305
+ * request.withErrorInterceptor((error) => {
1306
+ * console.error('Request failed:', error);
1307
+ * throw error;
1308
+ * });
1070
1309
  */
1071
- withCache(options = {}) {
1072
- this.cacheEnabled = true;
1073
- this.cacheManager = new CacheManager(options);
1310
+ withErrorInterceptor(interceptor) {
1311
+ this.errorInterceptors.push(interceptor);
1074
1312
  return this;
1075
1313
  }
1076
1314
  /**
1077
- * Send the request to the specified URL
1078
- * @param url The URL to send the request to
1315
+ * Execute the request and return the ResponseWrapper
1316
+ * This is the base method for getting the full response.
1317
+ *
1318
+ * @returns A promise that resolves to the ResponseWrapper
1319
+ *
1320
+ * @example
1321
+ * const response = await request.getResponse();
1322
+ * console.log(response.status);
1323
+ */
1324
+ async getResponse() {
1325
+ const url = this.formatUrlWithQueryParams(this.url);
1326
+ this.applyCsrfProtection();
1327
+ const fetchOptions = {
1328
+ ...this.requestOptions,
1329
+ method: this.method,
1330
+ };
1331
+ return !this.requestOptions.retries ? this.executeRequest(url, fetchOptions) : this.executeWithRetries(url, fetchOptions);
1332
+ }
1333
+ /**
1334
+ * Execute the request and parse the response as JSON
1335
+ *
1336
+ * @returns A promise that resolves to the parsed JSON data
1337
+ *
1338
+ * @example
1339
+ * const users = await request.getJson<User[]>();
1079
1340
  */
1080
- sendTo(url) {
1081
- // Format the URL with query parameters
1082
- url = this.formatUrlWithQueryParams(url);
1083
- // Apply automatic CSRF protection if enabled
1084
- if (this.autoApplyCsrfProtection) {
1085
- const config = Config.getInstance();
1086
- // Apply anti-CSRF headers if enabled
1087
- if (config.isAntiCsrfEnabled()) {
1341
+ async getJson() {
1342
+ const response = await this.getResponse();
1343
+ return response.getJson();
1344
+ }
1345
+ /**
1346
+ * Execute the request and get the response as text
1347
+ *
1348
+ * @returns A promise that resolves to the response text
1349
+ *
1350
+ * @example
1351
+ * const text = await request.getText();
1352
+ */
1353
+ async getText() {
1354
+ const response = await this.getResponse();
1355
+ return response.getText();
1356
+ }
1357
+ /**
1358
+ * Execute the request and get the response as a Blob
1359
+ *
1360
+ * @returns A promise that resolves to the response Blob
1361
+ *
1362
+ * @example
1363
+ * const blob = await request.getBlob();
1364
+ */
1365
+ async getBlob() {
1366
+ const response = await this.getResponse();
1367
+ return response.getBlob();
1368
+ }
1369
+ /**
1370
+ * Execute the request and get the response body as a ReadableStream
1371
+ *
1372
+ * @returns A promise that resolves to the response body stream
1373
+ *
1374
+ * @example
1375
+ * const stream = await request.getBody();
1376
+ */
1377
+ async getBody() {
1378
+ const response = await this.getResponse();
1379
+ return response.getBody();
1380
+ }
1381
+ /**
1382
+ * Execute the request and extract specific data using a selector function
1383
+ * If no selector is provided, returns the full JSON response.
1384
+ *
1385
+ * @param selector - Optional function to extract and transform data
1386
+ * @returns A promise that resolves to the selected data
1387
+ *
1388
+ * @example
1389
+ * // Get full response
1390
+ * const data = await request.getData();
1391
+ *
1392
+ * // Extract specific data
1393
+ * const users = await request.getData(data => data.results.users);
1394
+ */
1395
+ async getData(selector) {
1396
+ const response = await this.getResponse();
1397
+ return response.getData(selector);
1398
+ }
1399
+ /**
1400
+ * Apply CSRF protection headers based on configuration
1401
+ */
1402
+ applyCsrfProtection() {
1403
+ if (!this.autoApplyCsrfProtection)
1404
+ return;
1405
+ const config = Config.getInstance();
1406
+ // Apply anti-CSRF headers if enabled
1407
+ if (config.isAntiCsrfEnabled()) {
1408
+ this.withHeaders({
1409
+ "X-Requested-With": "XMLHttpRequest",
1410
+ });
1411
+ }
1412
+ // Apply global CSRF token if set
1413
+ const globalToken = config.getCsrfToken();
1414
+ if (globalToken) {
1415
+ const csrfHeaderName = config.getCsrfHeaderName();
1416
+ const hasLocalToken = this.hasHeader("X-CSRF-Token") || this.hasHeader(csrfHeaderName);
1417
+ if (!hasLocalToken) {
1088
1418
  this.withHeaders({
1089
- "X-Requested-With": "XMLHttpRequest",
1419
+ [csrfHeaderName]: globalToken,
1090
1420
  });
1091
1421
  }
1092
- // Apply global CSRF token if set
1093
- const globalToken = config.getCsrfToken();
1094
- if (globalToken) {
1095
- // Check if local token exists using case-insensitive comparison
1096
- const headers = this.requestOptions.headers;
1097
- const csrfHeaderName = config.getCsrfHeaderName();
1098
- const hasLocalToken = this.hasHeader(headers, "X-CSRF-Token") || this.hasHeader(headers, csrfHeaderName);
1422
+ }
1423
+ // Check for XSRF token in cookies and send it as a header
1424
+ if (config.isAutoXsrfEnabled() && typeof document !== "undefined") {
1425
+ const xsrfToken = CsrfUtils.getTokenFromCookie(config.getXsrfCookieName());
1426
+ if (xsrfToken && CsrfUtils.isValidToken(xsrfToken)) {
1427
+ const xsrfHeaderName = config.getXsrfHeaderName();
1428
+ const hasLocalToken = this.hasHeader("X-XSRF-TOKEN") || this.hasHeader(xsrfHeaderName);
1099
1429
  if (!hasLocalToken) {
1100
1430
  this.withHeaders({
1101
- [csrfHeaderName]: globalToken,
1431
+ [xsrfHeaderName]: xsrfToken,
1102
1432
  });
1103
1433
  }
1104
1434
  }
1105
- // check for XSRF token in cookies and send it as a header
1106
- if (config.isAutoXsrfEnabled() && typeof document !== "undefined") {
1107
- const xsrfToken = CsrfUtils.getTokenFromCookie(config.getXsrfCookieName());
1108
- if (xsrfToken && CsrfUtils.isValidToken(xsrfToken)) {
1109
- const headers = this.requestOptions.headers;
1110
- const xsrfHeaderName = config.getXsrfHeaderName();
1111
- const hasLocalToken = this.hasHeader(headers, "X-XSRF-TOKEN") || this.hasHeader(headers, xsrfHeaderName);
1112
- if (!hasLocalToken) {
1113
- this.withHeaders({
1114
- [xsrfHeaderName]: xsrfToken,
1115
- });
1116
- }
1117
- }
1118
- }
1119
- }
1120
- // Cast the request options to RequestInit to ensure compatibility
1121
- const fetchOptions = {
1122
- ...this.requestOptions,
1123
- method: this.method,
1124
- };
1125
- // Create the base promise - handle caching if enabled
1126
- let basePromise;
1127
- if (this.cacheEnabled && this.cacheManager) {
1128
- const cacheKey = this.cacheManager.generateKey(url, this.method, this.requestOptions.headers, this.requestOptions.body);
1129
- basePromise = this.executeWithCache(url, fetchOptions, cacheKey);
1130
- }
1131
- else {
1132
- basePromise = !this.requestOptions.retries ? this.executeRequest(url, fetchOptions) : this.executeWithRetries(url, fetchOptions);
1133
1435
  }
1134
- const responsePromise = basePromise;
1135
- // Attach convenience methods that begin processing immediately
1136
- responsePromise.getJson = async () => {
1137
- return basePromise.then(response => response.getJson());
1138
- };
1139
- responsePromise.getText = async () => {
1140
- return basePromise.then(response => response.getText());
1141
- };
1142
- responsePromise.getBlob = async () => {
1143
- return basePromise.then(response => response.getBlob());
1144
- };
1145
- responsePromise.getArrayBuffer = async () => {
1146
- return basePromise.then(response => response.getArrayBuffer());
1147
- };
1148
- responsePromise.getBody = async () => {
1149
- return basePromise.then(response => response.getBody());
1150
- };
1151
- responsePromise.getData = async (selector) => {
1152
- try {
1153
- const data = await basePromise.then(response => response.getJson());
1154
- // If no selector is provided, return the raw JSON data
1155
- if (!selector)
1156
- return data;
1157
- // Apply the selector if provided
1158
- return selector(data);
1159
- }
1160
- catch (error) {
1161
- if (error instanceof Error) {
1162
- // If selector fails, enhance the error with the original data
1163
- const enhancedError = new Error(`Data selector failed: ${error.message}. Original data: ${JSON.stringify(await basePromise.then(response => response.getJson()), null, 2)}`);
1164
- enhancedError.stack = error.stack;
1165
- throw enhancedError;
1166
- }
1167
- throw error;
1168
- }
1169
- };
1170
- return responsePromise;
1171
1436
  }
1172
1437
  /**
1173
1438
  * Formats the URL with any query parameters
@@ -1199,136 +1464,295 @@ class BaseRequest {
1199
1464
  * @param url The formatted URL to send the request to
1200
1465
  * @param fetchOptions The fetch options to use
1201
1466
  * @returns A wrapped response object
1467
+ * @throws RequestError if the request fails after all retries
1202
1468
  */
1203
1469
  async executeWithRetries(url, fetchOptions) {
1204
- let attempt = 0;
1205
1470
  const maxRetries = this.requestOptions.retries || 0;
1206
- do {
1471
+ const method = typeof fetchOptions.method === "string" ? fetchOptions.method : "GET";
1472
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
1207
1473
  try {
1208
1474
  return await this.executeRequest(url, fetchOptions);
1209
1475
  }
1210
1476
  catch (error) {
1211
- const requestError = error instanceof RequestError ? error : RequestError.networkError(url, fetchOptions.method, error);
1212
- // Don't retry on timeout errors or if we've reached the max retries
1213
- if (attempt >= maxRetries || requestError.timeoutError)
1477
+ const requestError = error instanceof RequestError ? error : RequestError.networkError(url, method, error instanceof Error ? error : new Error(String(error)));
1478
+ if (attempt >= maxRetries)
1214
1479
  throw requestError;
1215
- attempt++;
1216
1480
  if (this.requestOptions.onRetry) {
1217
- await this.requestOptions.onRetry({ attempt, error: requestError });
1481
+ await this.requestOptions.onRetry({ attempt: attempt + 1, error: requestError });
1218
1482
  }
1219
1483
  }
1220
- } while (attempt <= maxRetries);
1221
- // If we somehow get here, we throw an error to avoid undefined behavior
1222
- throw new RequestError(`Max retries reached (${maxRetries}) but request still failed`, url, fetchOptions.method);
1484
+ }
1485
+ // This should never happen but is needed for type safety
1486
+ throw new RequestError(`Max retries reached`, url, method);
1223
1487
  }
1224
- async executeRequest(url, fetchOptions) {
1225
- // Create a request controller for timeout if needed, or use the provided one
1226
- const controller = this.abortController || new AbortController();
1227
- const { signal } = controller;
1228
- // Use the controller signal for the fetch request, but don't overwrite an existing signal
1229
- fetchOptions.signal = fetchOptions.signal || signal;
1230
- let timeoutId;
1231
- // Flag to track if abort was caused by our timeout
1232
- let abortedByTimeout = false;
1233
- // Handle timeout regardless of whether an external controller is provided
1234
- if (this.requestOptions.timeout) {
1235
- if (this.abortController) {
1236
- // If we have an external controller, create a separate timeout controller
1237
- // that won't interfere with the external one
1238
- // Use AbortSignal.timeout if available (modern browsers)
1239
- if (typeof AbortSignal.timeout === "function") {
1240
- const timeoutSignal = AbortSignal.timeout(this.requestOptions.timeout);
1241
- // Add event listener to propagate the abort to the main controller
1242
- timeoutSignal.addEventListener("abort", () => {
1243
- if (!controller.signal.aborted) {
1244
- abortedByTimeout = true;
1245
- controller.abort();
1246
- }
1247
- });
1488
+ /**
1489
+ * Run request interceptors in order: global interceptors first, then per-request
1490
+ * @param configParam - The request configuration
1491
+ * @returns Modified config or a Response to short-circuit
1492
+ */
1493
+ async runRequestInterceptors(configParam) {
1494
+ const globalConfig = Config.getInstance();
1495
+ const allInterceptors = [...globalConfig.getRequestInterceptors(), ...this.requestInterceptors];
1496
+ let currentConfig = configParam;
1497
+ for (let i = 0; i < allInterceptors.length; i++) {
1498
+ try {
1499
+ const result = await allInterceptors[i](currentConfig);
1500
+ // If interceptor returns a Response, short-circuit
1501
+ if (result instanceof Response) {
1502
+ return result;
1503
+ }
1504
+ currentConfig = result;
1505
+ }
1506
+ catch (error) {
1507
+ const errorMessage = error instanceof Error ? error.message : String(error);
1508
+ // Use RequestError when we have request context
1509
+ throw new RequestError(`Interceptor failed: ${errorMessage}`, currentConfig.url, currentConfig.method);
1510
+ }
1511
+ }
1512
+ return currentConfig;
1513
+ }
1514
+ /**
1515
+ * Run response interceptors in reverse order: per-request interceptors first, then global in reverse
1516
+ * @param response - The response wrapper
1517
+ * @returns Modified response wrapper
1518
+ */
1519
+ async runResponseInterceptors(response) {
1520
+ const globalConfig = Config.getInstance();
1521
+ const globalInterceptors = globalConfig.getResponseInterceptors();
1522
+ // Per-request in order, then global in reverse
1523
+ const allInterceptors = [...this.responseInterceptors, ...globalInterceptors.reverse()];
1524
+ let currentResponse = response;
1525
+ for (let i = 0; i < allInterceptors.length; i++) {
1526
+ try {
1527
+ currentResponse = await allInterceptors[i](currentResponse);
1528
+ }
1529
+ catch (error) {
1530
+ const errorMessage = error instanceof Error ? error.message : String(error);
1531
+ // Use RequestError when we have response context (url/method from ResponseWrapper)
1532
+ if (currentResponse.url && currentResponse.method) {
1533
+ throw new RequestError(`Interceptor failed: ${errorMessage}`, currentResponse.url, currentResponse.method);
1534
+ }
1535
+ // Fallback to generic Error if no context available (shouldn't happen in practice)
1536
+ throw new Error(`Interceptor failed: ${errorMessage}`);
1537
+ }
1538
+ }
1539
+ return currentResponse;
1540
+ }
1541
+ /**
1542
+ * Run error interceptors in reverse order: per-request interceptors first, then global in reverse
1543
+ * @param error - The error that occurred
1544
+ * @returns Modified error or a ResponseWrapper to recover
1545
+ */
1546
+ async runErrorInterceptors(error) {
1547
+ const globalConfig = Config.getInstance();
1548
+ const globalInterceptors = globalConfig.getErrorInterceptors();
1549
+ // Per-request in order, then global in reverse
1550
+ const allInterceptors = [...this.errorInterceptors, ...globalInterceptors.reverse()];
1551
+ let currentError = error;
1552
+ for (let i = 0; i < allInterceptors.length; i++) {
1553
+ try {
1554
+ // If previous interceptor recovered with a response, stop processing
1555
+ if (currentError instanceof ResponseWrapper) {
1556
+ return currentError;
1557
+ }
1558
+ const result = await allInterceptors[i](currentError);
1559
+ currentError = result;
1560
+ }
1561
+ catch (interceptorError) {
1562
+ // If an error interceptor throws, that becomes the new error
1563
+ if (interceptorError instanceof RequestError) {
1564
+ currentError = interceptorError;
1248
1565
  }
1249
1566
  else {
1250
- // Fallback for browsers without AbortSignal.timeout
1251
- timeoutId = setTimeout(() => {
1252
- if (!controller.signal.aborted) {
1253
- abortedByTimeout = true;
1254
- controller.abort();
1255
- }
1256
- }, this.requestOptions.timeout);
1567
+ const errorMessage = interceptorError instanceof Error ? interceptorError.message : String(interceptorError);
1568
+ // Always wrap in RequestError when we have context
1569
+ if (currentError instanceof RequestError) {
1570
+ currentError = new RequestError(`Error interceptor ${i + 1} failed: ${errorMessage}`, currentError.url, currentError.method);
1571
+ }
1572
+ else if (currentError instanceof ResponseWrapper && currentError.url && currentError.method) {
1573
+ // If it's a ResponseWrapper, we can still create a proper RequestError from its context
1574
+ currentError = new RequestError(`Error interceptor ${i + 1} failed: ${errorMessage}`, currentError.url, currentError.method);
1575
+ }
1576
+ else {
1577
+ // Last resort: if we have no context at all, use the original error's context
1578
+ // This shouldn't happen in practice, but handle it gracefully
1579
+ const errorObj = interceptorError instanceof Error ? interceptorError : new Error(String(interceptorError));
1580
+ currentError = RequestError.networkError(error.url, error.method, errorObj);
1581
+ }
1257
1582
  }
1258
1583
  }
1584
+ }
1585
+ return currentError;
1586
+ }
1587
+ /**
1588
+ * Helper to create an abort signal with timeout support
1589
+ * Handles various AbortSignal API levels gracefully
1590
+ */
1591
+ createAbortSignal(timeoutMs, externalController) {
1592
+ let timeoutId;
1593
+ let timeoutController;
1594
+ let isTimeout = false;
1595
+ // No timeout - just use external controller
1596
+ if (!timeoutMs) {
1597
+ return {
1598
+ signal: externalController === null || externalController === void 0 ? void 0 : externalController.signal,
1599
+ cleanup: () => { },
1600
+ wasTimeout: () => false,
1601
+ };
1602
+ }
1603
+ // Check if AbortSignal.any() is available (modern browsers)
1604
+ const hasAbortSignalAny = typeof AbortSignal.any === "function";
1605
+ const hasAbortSignalTimeout = typeof AbortSignal.timeout === "function";
1606
+ // Create timeout signal
1607
+ const createTimeoutSignal = () => {
1608
+ if (hasAbortSignalTimeout) {
1609
+ const signal = AbortSignal.timeout(timeoutMs);
1610
+ signal.addEventListener("abort", () => (isTimeout = true), { once: true });
1611
+ return signal;
1612
+ }
1259
1613
  else {
1614
+ timeoutController = new AbortController();
1260
1615
  timeoutId = setTimeout(() => {
1261
- abortedByTimeout = true;
1262
- controller.abort();
1263
- }, this.requestOptions.timeout);
1616
+ isTimeout = true;
1617
+ timeoutController.abort();
1618
+ }, timeoutMs);
1619
+ return timeoutController.signal;
1264
1620
  }
1621
+ };
1622
+ const timeoutSignal = createTimeoutSignal();
1623
+ // Combine signals if we have both timeout and external controller
1624
+ const finalSignal = externalController && hasAbortSignalAny
1625
+ ? AbortSignal.any([externalController.signal, timeoutSignal])
1626
+ : externalController
1627
+ ? this.combineSignalsManually(externalController.signal, timeoutSignal)
1628
+ : timeoutSignal;
1629
+ return {
1630
+ signal: finalSignal,
1631
+ cleanup: () => {
1632
+ if (timeoutId !== undefined)
1633
+ clearTimeout(timeoutId);
1634
+ },
1635
+ wasTimeout: () => isTimeout,
1636
+ };
1637
+ }
1638
+ /**
1639
+ * Manually combine two abort signals for older environments
1640
+ * Returns the first signal and listens to the second
1641
+ */
1642
+ combineSignalsManually(signal1, signal2) {
1643
+ // If either is already aborted, use that one
1644
+ if (signal1.aborted)
1645
+ return signal1;
1646
+ if (signal2.aborted)
1647
+ return signal2;
1648
+ // Use a controller to create a combined signal
1649
+ const controller = new AbortController();
1650
+ const abort = () => controller.abort();
1651
+ signal1.addEventListener("abort", abort, { once: true });
1652
+ signal2.addEventListener("abort", abort, { once: true });
1653
+ return controller.signal;
1654
+ }
1655
+ /**
1656
+ * Convert fetchOptions to RequestConfig with proper typing
1657
+ */
1658
+ createRequestConfig(url, fetchOptions) {
1659
+ const method = typeof fetchOptions.method === "string" ? fetchOptions.method : "GET";
1660
+ const headers = this.getHeadersRecord();
1661
+ const extendedOptions = fetchOptions;
1662
+ return {
1663
+ url,
1664
+ method,
1665
+ headers,
1666
+ body: fetchOptions.body,
1667
+ signal: fetchOptions.signal || undefined,
1668
+ credentials: fetchOptions.credentials,
1669
+ mode: fetchOptions.mode,
1670
+ redirect: fetchOptions.redirect,
1671
+ referrer: fetchOptions.referrer,
1672
+ referrerPolicy: fetchOptions.referrerPolicy,
1673
+ keepalive: fetchOptions.keepalive,
1674
+ priority: extendedOptions.priority,
1675
+ };
1676
+ }
1677
+ /**
1678
+ * Apply interceptor results back to fetchOptions
1679
+ */
1680
+ applyRequestConfig(config, fetchOptions) {
1681
+ fetchOptions.headers = config.headers;
1682
+ if (config.body !== undefined) {
1683
+ fetchOptions.body = config.body;
1265
1684
  }
1685
+ }
1686
+ async executeRequest(url, fetchOptions) {
1687
+ const method = typeof fetchOptions.method === "string" ? fetchOptions.method : "GET";
1688
+ // Setup abort signal with timeout
1689
+ const abortSignal = this.createAbortSignal(this.requestOptions.timeout, this.abortController);
1266
1690
  try {
1691
+ // Run request interceptors before making the request
1692
+ const requestConfig = this.createRequestConfig(url, fetchOptions);
1693
+ const interceptorResult = await this.runRequestInterceptors(requestConfig);
1694
+ // If interceptor returned a Response, short-circuit and wrap it
1695
+ if (interceptorResult instanceof Response) {
1696
+ const wrappedResponse = new ResponseWrapper(interceptorResult, this.url, this.method);
1697
+ return await this.runResponseInterceptors(wrappedResponse);
1698
+ }
1699
+ // Update fetchOptions with interceptor modifications
1700
+ url = interceptorResult.url;
1701
+ this.validateUrl(url);
1702
+ this.applyRequestConfig(interceptorResult, fetchOptions);
1703
+ // Set the combined abort signal
1704
+ fetchOptions.signal = abortSignal.signal;
1705
+ // Execute fetch
1267
1706
  let response;
1268
1707
  try {
1269
1708
  response = await fetch(url, fetchOptions);
1270
1709
  }
1271
1710
  catch (error) {
1272
- // Check if this is an abort error that was specifically caused by our timeout
1273
- if (error instanceof DOMException && error.name === "AbortError" && abortedByTimeout) {
1274
- throw RequestError.timeout(url, fetchOptions.method, this.requestOptions.timeout);
1711
+ // Check if it's an abort error (DOMException in browsers, or AbortSignal abort)
1712
+ if (error instanceof DOMException && error.name === "AbortError") {
1713
+ if (abortSignal.wasTimeout()) {
1714
+ throw RequestError.timeout(url, method, this.requestOptions.timeout);
1715
+ }
1716
+ throw RequestError.abortError(url, method);
1717
+ }
1718
+ // Check for Node.js/undici TimeoutError
1719
+ const errorObj = error instanceof Error ? error : new Error(String(error));
1720
+ const errorName = errorObj.name;
1721
+ const errorMessage = errorObj.message.toLowerCase();
1722
+ // Detect timeout errors from Node.js/undici (TimeoutError)
1723
+ const isTimeoutError = errorName === "TimeoutError" || errorMessage.includes("timeout") || errorMessage.includes("aborted due to timeout") || abortSignal.wasTimeout();
1724
+ if (isTimeoutError && this.requestOptions.timeout) {
1725
+ throw RequestError.timeout(url, method, this.requestOptions.timeout);
1275
1726
  }
1276
- // Otherwise it's either a user-triggered abort or another network error
1277
- throw RequestError.networkError(url, fetchOptions.method, error);
1727
+ throw RequestError.networkError(url, method, errorObj);
1278
1728
  }
1279
1729
  if (!response.ok) {
1280
- throw RequestError.fromResponse(response, url, fetchOptions.method);
1281
- }
1282
- // Return a wrapped response instead of parsing it
1283
- return new ResponseWrapper(response);
1284
- }
1285
- finally {
1286
- if (timeoutId) {
1287
- clearTimeout(timeoutId);
1730
+ throw RequestError.fromResponse(response, url, method);
1288
1731
  }
1732
+ const wrappedResponse = new ResponseWrapper(response, url, method);
1733
+ return await this.runResponseInterceptors(wrappedResponse);
1289
1734
  }
1290
- }
1291
- /**
1292
- * Executes a request using cache when possible
1293
- * @param url The formatted URL to send the request to
1294
- * @param fetchOptions The fetch options to use
1295
- * @param cacheKey The cache key for this request
1296
- */
1297
- async executeWithCache(url, fetchOptions, cacheKey) {
1298
- // Try to get from cache first
1299
- if (this.cacheManager) {
1300
- const cachedEntry = await this.cacheManager.get(cacheKey);
1301
- if (cachedEntry) {
1302
- // Create a synthetic Response object from cached data
1303
- const cachedResponse = new Response(JSON.stringify(cachedEntry.value), {
1304
- status: 200,
1305
- headers: new Headers(cachedEntry.headers || {}),
1306
- });
1307
- return new ResponseWrapper(cachedResponse);
1735
+ catch (error) {
1736
+ // Convert to RequestError if needed
1737
+ let requestError;
1738
+ if (error instanceof RequestError) {
1739
+ requestError = error;
1308
1740
  }
1309
- }
1310
- // Not in cache, make the actual request
1311
- const response = await (!this.requestOptions.retries ? this.executeRequest(url, fetchOptions) : this.executeWithRetries(url, fetchOptions));
1312
- // Cache the successful response if caching is enabled
1313
- // Only cache successful responses
1314
- if (response.ok && this.cacheManager) {
1315
- // Clone the response so we don't consume it
1316
- try {
1317
- const responseData = await response.getJson();
1318
- // Extract headers to store in cache
1319
- const headers = {};
1320
- response.headers.forEach((value, key) => {
1321
- headers[key] = value;
1322
- });
1323
- // Store in cache
1324
- await this.cacheManager.set(cacheKey, responseData, headers);
1741
+ else {
1742
+ const errorObj = error instanceof Error ? error : new Error(String(error));
1743
+ requestError = RequestError.networkError(url, method, errorObj);
1325
1744
  }
1326
- catch (error) {
1327
- // Failed to cache, but we can still return the response
1328
- console.warn("Failed to cache response:", error);
1745
+ // Run error interceptors
1746
+ const interceptorResult = await this.runErrorInterceptors(requestError);
1747
+ // If error interceptor returned a ResponseWrapper, recover from error
1748
+ if (interceptorResult instanceof ResponseWrapper) {
1749
+ return interceptorResult;
1329
1750
  }
1751
+ throw interceptorResult;
1752
+ }
1753
+ finally {
1754
+ abortSignal.cleanup();
1330
1755
  }
1331
- return response;
1332
1756
  }
1333
1757
  }
1334
1758
 
@@ -1336,6 +1760,9 @@ class BaseRequest {
1336
1760
  * Base class for requests that can have a body (POST, PUT, PATCH)
1337
1761
  */
1338
1762
  class BodyRequest extends BaseRequest {
1763
+ constructor(url) {
1764
+ super(url);
1765
+ }
1339
1766
  /**
1340
1767
  * Sets the body of the request
1341
1768
  * @param body - The request body
@@ -1357,7 +1784,7 @@ class BodyRequest extends BaseRequest {
1357
1784
  JSON.stringify(body);
1358
1785
  }
1359
1786
  catch (error) {
1360
- throw new Error(`Failed to stringify request body: ${String(error)}`);
1787
+ throw new RequestError(`Stringify failed: ${String(error)}`, this.url, this.method);
1361
1788
  }
1362
1789
  }
1363
1790
  else {
@@ -1365,18 +1792,68 @@ class BodyRequest extends BaseRequest {
1365
1792
  }
1366
1793
  return this;
1367
1794
  }
1368
- setContentTypeIfNeeded(contentType) {
1369
- // Only set if not already set
1795
+ /**
1796
+ * Sets a GraphQL query or mutation as the request body
1797
+ * Automatically formats the body as JSON and sets Content-Type to application/json
1798
+ *
1799
+ * @param query - The GraphQL query or mutation string
1800
+ * @param variables - Optional variables object to pass with the query
1801
+ * @returns The request instance for chaining
1802
+ *
1803
+ * @example
1804
+ * const request = new PostRequest('/graphql')
1805
+ * .withGraphQL('query { user(id: $id) { name email } }', { id: '123' });
1806
+ *
1807
+ * @example
1808
+ * const request = new PostRequest('/graphql')
1809
+ * .withGraphQL('mutation { createUser(name: $name) { id } }', { name: 'John' });
1810
+ */
1811
+ withGraphQL(query, variables) {
1812
+ if (typeof query !== "string" || query.trim().length === 0) {
1813
+ throw new RequestError("Invalid GraphQL query", this.url, this.method);
1814
+ }
1815
+ const graphQLBody = {
1816
+ query: query.trim(),
1817
+ };
1818
+ if (variables !== undefined) {
1819
+ if (typeof variables !== "object" || variables === null || Array.isArray(variables)) {
1820
+ throw new RequestError("Invalid GraphQL variables", this.url, this.method);
1821
+ }
1822
+ graphQLBody.variables = variables;
1823
+ }
1824
+ // Validate JSON is stringifiable early
1825
+ try {
1826
+ JSON.stringify(graphQLBody);
1827
+ }
1828
+ catch (error) {
1829
+ throw new RequestError(`Stringify failed: ${String(error)}`, this.url, this.method);
1830
+ }
1831
+ this.body = graphQLBody;
1832
+ this.bodyType = BodyType.JSON;
1833
+ this.setContentTypeIfNeeded("application/json");
1834
+ return this;
1835
+ }
1836
+ /**
1837
+ * Check if Content-Type header is already set (case-insensitive)
1838
+ */
1839
+ hasContentType() {
1370
1840
  const headers = this.requestOptions.headers;
1371
- if (!Object.keys(headers).some(header => header.toLowerCase() === "content-type")) {
1841
+ if (typeof headers === "object" && headers !== null) {
1842
+ const headersObj = headers;
1843
+ return Object.keys(headersObj).some(header => header.toLowerCase() === "content-type");
1844
+ }
1845
+ return false;
1846
+ }
1847
+ setContentTypeIfNeeded(contentType) {
1848
+ if (!this.hasContentType()) {
1372
1849
  this.withContentType(contentType);
1373
1850
  }
1374
1851
  }
1375
1852
  /**
1376
- * Send the request to the specified URL
1853
+ * Execute the request and return the ResponseWrapper
1377
1854
  * Overrides the base implementation to add body handling
1378
1855
  */
1379
- sendTo(url) {
1856
+ async getResponse() {
1380
1857
  if (this.body !== undefined) {
1381
1858
  // Remove previous body if it exists
1382
1859
  if (this.requestOptions.body)
@@ -1389,100 +1866,222 @@ class BodyRequest extends BaseRequest {
1389
1866
  this.requestOptions.body = this.body;
1390
1867
  }
1391
1868
  }
1392
- return super.sendTo(url);
1869
+ return super.getResponse();
1393
1870
  }
1394
1871
  }
1395
1872
 
1396
- // Methods without body
1873
+ /**
1874
+ * HTTP GET request implementation
1875
+ * Used for retrieving data from an API endpoint.
1876
+ *
1877
+ * @example
1878
+ * const request = new GetRequest('/api/users')
1879
+ * .withQueryParams({ id: '123' });
1880
+ * const data = await request.getData();
1881
+ */
1397
1882
  class GetRequest extends BaseRequest {
1398
- constructor() {
1399
- super(...arguments);
1883
+ constructor(url) {
1884
+ super(url);
1400
1885
  this.method = HttpMethod.GET;
1401
1886
  }
1402
1887
  }
1888
+ /**
1889
+ * HTTP HEAD request implementation
1890
+ * Similar to GET but returns only headers without a response body.
1891
+ *
1892
+ * @example
1893
+ * const request = new HeadRequest('/api/users/123');
1894
+ * const response = await request.getResponse();
1895
+ */
1403
1896
  class HeadRequest extends BaseRequest {
1404
- constructor() {
1405
- super(...arguments);
1897
+ constructor(url) {
1898
+ super(url);
1406
1899
  this.method = HttpMethod.HEAD;
1407
1900
  }
1408
1901
  }
1902
+ /**
1903
+ * HTTP OPTIONS request implementation
1904
+ * Used to describe the communication options for the target resource.
1905
+ *
1906
+ * @example
1907
+ * const request = new OptionsRequest('/api/users');
1908
+ * const response = await request.getResponse();
1909
+ */
1409
1910
  class OptionsRequest extends BaseRequest {
1410
- constructor() {
1411
- super(...arguments);
1911
+ constructor(url) {
1912
+ super(url);
1412
1913
  this.method = HttpMethod.OPTIONS;
1413
1914
  }
1414
1915
  }
1916
+ /**
1917
+ * HTTP DELETE request implementation
1918
+ * Used to delete a resource on the server.
1919
+ *
1920
+ * @example
1921
+ * const request = new DeleteRequest('/api/users/123');
1922
+ * await request.getData();
1923
+ */
1415
1924
  class DeleteRequest extends BaseRequest {
1416
- constructor() {
1417
- super(...arguments);
1925
+ constructor(url) {
1926
+ super(url);
1418
1927
  this.method = HttpMethod.DELETE;
1419
1928
  }
1420
1929
  }
1421
- // Methods with body
1930
+ /**
1931
+ * HTTP POST request implementation
1932
+ * Used to create a new resource or submit data for processing.
1933
+ *
1934
+ * @example
1935
+ * const request = new PostRequest('/api/users')
1936
+ * .withJson({ name: 'John', email: 'john@example.com' });
1937
+ * const data = await request.getData();
1938
+ */
1422
1939
  class PostRequest extends BodyRequest {
1423
- constructor() {
1424
- super(...arguments);
1940
+ constructor(url) {
1941
+ super(url);
1425
1942
  this.method = HttpMethod.POST;
1426
1943
  }
1427
1944
  }
1945
+ /**
1946
+ * HTTP PUT request implementation
1947
+ * Used to replace or update an existing resource.
1948
+ *
1949
+ * @example
1950
+ * const request = new PutRequest('/api/users/123')
1951
+ * .withJson({ id: '123', name: 'John', email: 'john@example.com' });
1952
+ * const data = await request.getData();
1953
+ */
1428
1954
  class PutRequest extends BodyRequest {
1429
- constructor() {
1430
- super(...arguments);
1955
+ constructor(url) {
1956
+ super(url);
1431
1957
  this.method = HttpMethod.PUT;
1432
1958
  }
1433
1959
  }
1960
+ /**
1961
+ * HTTP PATCH request implementation
1962
+ * Used to apply partial modifications to a resource.
1963
+ *
1964
+ * @example
1965
+ * const request = new PatchRequest('/api/users/123')
1966
+ * .withJson({ email: 'new.email@example.com' });
1967
+ * const data = await request.getData();
1968
+ */
1434
1969
  class PatchRequest extends BodyRequest {
1435
- constructor() {
1436
- super(...arguments);
1970
+ constructor(url) {
1971
+ super(url);
1437
1972
  this.method = HttpMethod.PATCH;
1438
1973
  }
1439
1974
  }
1440
1975
 
1441
1976
  /**
1442
1977
  * Create a GET request
1978
+ * Used for retrieving resources from the server.
1979
+ *
1980
+ * @param url - The URL to send the request to
1981
+ * @returns A new GET request instance
1982
+ *
1983
+ * @example
1984
+ * const request = get('/api/users')
1985
+ * .withQueryParams({ limit: 10 });
1986
+ * const users = await request.getData();
1443
1987
  */
1444
- function get() {
1445
- return new GetRequest();
1988
+ function get(url) {
1989
+ return new GetRequest(url);
1446
1990
  }
1447
1991
  /**
1448
1992
  * Create a POST request
1993
+ * Used for creating new resources or submitting data.
1994
+ *
1995
+ * @param url - The URL to send the request to
1996
+ * @returns A new POST request instance
1997
+ *
1998
+ * @example
1999
+ * const request = post('/api/users')
2000
+ * .withJson({ name: 'John', email: 'john@example.com' });
2001
+ * const newUser = await request.getData();
1449
2002
  */
1450
- function post() {
1451
- return new PostRequest();
2003
+ function post(url) {
2004
+ return new PostRequest(url);
1452
2005
  }
1453
2006
  /**
1454
2007
  * Create a PUT request
2008
+ * Used for replacing or updating an existing resource.
2009
+ *
2010
+ * @param url - The URL to send the request to
2011
+ * @returns A new PUT request instance
2012
+ *
2013
+ * @example
2014
+ * const request = put('/api/users/123')
2015
+ * .withJson({ name: 'John Updated', email: 'john@example.com' });
2016
+ * const updatedUser = await request.getData();
1455
2017
  */
1456
- function put() {
1457
- return new PutRequest();
2018
+ function put(url) {
2019
+ return new PutRequest(url);
1458
2020
  }
1459
2021
  /**
1460
2022
  * Create a DELETE request
1461
- * Note: We use 'del' to avoid JS keyword conflict
2023
+ * Used for removing resources from the server.
2024
+ *
2025
+ * @param url - The URL to send the request to
2026
+ * @returns A new DELETE request instance
2027
+ *
2028
+ * @example
2029
+ * const request = del('/api/users/123');
2030
+ * await request.getData();
1462
2031
  */
1463
- function del() {
1464
- return new DeleteRequest();
2032
+ function del(url) {
2033
+ return new DeleteRequest(url);
1465
2034
  }
1466
2035
  /**
1467
2036
  * Create a PATCH request
2037
+ * Used for applying partial modifications to a resource.
2038
+ *
2039
+ * @param url - The URL to send the request to
2040
+ * @returns A new PATCH request instance
2041
+ *
2042
+ * @example
2043
+ * const request = patch('/api/users/123')
2044
+ * .withJson({ status: 'active' });
2045
+ * const patchedUser = await request.getData();
1468
2046
  */
1469
- function patch() {
1470
- return new PatchRequest();
2047
+ function patch(url) {
2048
+ return new PatchRequest(url);
1471
2049
  }
1472
2050
  /**
1473
2051
  * Create a HEAD request
2052
+ * Similar to GET but returns only headers without a body.
2053
+ *
2054
+ * @param url - The URL to send the request to
2055
+ * @returns A new HEAD request instance
2056
+ *
2057
+ * @example
2058
+ * const request = head('/api/users/123');
2059
+ * const response = await request.getResponse();
2060
+ * console.log(response.headers.get('Last-Modified'));
1474
2061
  */
1475
- function head() {
1476
- return new HeadRequest();
2062
+ function head(url) {
2063
+ return new HeadRequest(url);
1477
2064
  }
1478
2065
  /**
1479
2066
  * Create an OPTIONS request
2067
+ * Used to describe the communication options for a resource.
2068
+ *
2069
+ * @param url - The URL to send the request to
2070
+ * @returns A new OPTIONS request instance
2071
+ *
2072
+ * @example
2073
+ * const request = options('/api/users');
2074
+ * const response = await request.getResponse();
2075
+ * console.log(response.headers.get('Allow'));
1480
2076
  */
1481
- function options() {
1482
- return new OptionsRequest();
2077
+ function options(url) {
2078
+ return new OptionsRequest(url);
1483
2079
  }
1484
2080
 
1485
- // Create the main API object with configuration
2081
+ /**
2082
+ * Main API object for creating HTTP requests
2083
+ * Provides factory methods for all HTTP methods and access to global configuration.
2084
+ */
1486
2085
  const create = {
1487
2086
  get,
1488
2087
  post,
@@ -1491,8 +2090,8 @@ const create = {
1491
2090
  patch,
1492
2091
  head,
1493
2092
  options,
1494
- // Add configuration access
1495
2093
  config: Config.getInstance(),
1496
2094
  };
1497
2095
 
1498
- 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 };
2096
+ export { CookieUtils, CredentialsPolicy, DeleteRequest, GetRequest, HeadRequest, HttpMethod, OptionsRequest, PatchRequest, PostRequest, PutRequest, RedirectMode, ReferrerPolicy, RequestError, RequestMode, RequestPriority, ResponseWrapper, SameSitePolicy, create as default };
2097
+ //# sourceMappingURL=index.esm.js.map