UNPKG

create-request

Version:

A modern, chainable wrapper for fetch with automatic retries, timeouts, comprehensive error handling, and first-class TypeScript support

1,406 lines (1,399 loc) 109 kB
'use strict'; /** * Enum for HTTP methods */ exports.HttpMethod = void 0; (function (HttpMethod) { HttpMethod["GET"] = "GET"; HttpMethod["PUT"] = "PUT"; HttpMethod["POST"] = "POST"; HttpMethod["HEAD"] = "HEAD"; HttpMethod["PATCH"] = "PATCH"; HttpMethod["DELETE"] = "DELETE"; HttpMethod["OPTIONS"] = "OPTIONS"; })(exports.HttpMethod || (exports.HttpMethod = {})); /** * Enum for request priorities */ exports.RequestPriority = void 0; (function (RequestPriority) { RequestPriority["LOW"] = "low"; RequestPriority["HIGH"] = "high"; RequestPriority["AUTO"] = "auto"; })(exports.RequestPriority || (exports.RequestPriority = {})); /** * Enum for credentials policies */ exports.CredentialsPolicy = void 0; (function (CredentialsPolicy) { CredentialsPolicy["OMIT"] = "omit"; CredentialsPolicy["INCLUDE"] = "include"; CredentialsPolicy["SAME_ORIGIN"] = "same-origin"; })(exports.CredentialsPolicy || (exports.CredentialsPolicy = {})); /** * Enum for request modes */ exports.RequestMode = void 0; (function (RequestMode) { RequestMode["CORS"] = "cors"; RequestMode["NO_CORS"] = "no-cors"; RequestMode["SAME_ORIGIN"] = "same-origin"; RequestMode["NAVIGATE"] = "navigate"; })(exports.RequestMode || (exports.RequestMode = {})); /** * Enum for redirect modes */ exports.RedirectMode = void 0; (function (RedirectMode) { RedirectMode["ERROR"] = "error"; RedirectMode["FOLLOW"] = "follow"; RedirectMode["MANUAL"] = "manual"; })(exports.RedirectMode || (exports.RedirectMode = {})); /** * Enum for cookie SameSite policies */ exports.SameSitePolicy = void 0; (function (SameSitePolicy) { SameSitePolicy["LAX"] = "Lax"; SameSitePolicy["NONE"] = "None"; SameSitePolicy["STRICT"] = "Strict"; })(exports.SameSitePolicy || (exports.SameSitePolicy = {})); /** * Enum for body types */ var BodyType; (function (BodyType) { BodyType["JSON"] = "json"; BodyType["STRING"] = "string"; BodyType["BINARY"] = "binary"; })(BodyType || (BodyType = {})); /** * Referrer policies for fetch requests */ exports.ReferrerPolicy = void 0; (function (ReferrerPolicy) { ReferrerPolicy["ORIGIN"] = "origin"; ReferrerPolicy["UNSAFE_URL"] = "unsafe-url"; ReferrerPolicy["SAME_ORIGIN"] = "same-origin"; ReferrerPolicy["NO_REFERRER"] = "no-referrer"; ReferrerPolicy["STRICT_ORIGIN"] = "strict-origin"; ReferrerPolicy["ORIGIN_WHEN_CROSS_ORIGIN"] = "origin-when-cross-origin"; ReferrerPolicy["NO_REFERRER_WHEN_DOWNGRADE"] = "no-referrer-when-downgrade"; ReferrerPolicy["STRICT_ORIGIN_WHEN_CROSS_ORIGIN"] = "strict-origin-when-cross-origin"; })(exports.ReferrerPolicy || (exports.ReferrerPolicy = {})); /** * Cache modes for fetch requests */ exports.CacheMode = void 0; (function (CacheMode) { CacheMode["RELOAD"] = "reload"; CacheMode["DEFAULT"] = "default"; CacheMode["NO_CACHE"] = "no-cache"; CacheMode["NO_STORE"] = "no-store"; CacheMode["FORCE_CACHE"] = "force-cache"; CacheMode["ONLY_IF_CACHED"] = "only-if-cached"; })(exports.CacheMode || (exports.CacheMode = {})); /** * Error class for HTTP request failures. * Extends the standard Error class with additional context about the failed request. * * @example * ```typescript * try { * await create.get('/api/users').getJson(); * } catch (error) { * console.log(`Request failed: ${error.message}`); * console.log(`URL: ${error.url}`); * console.log(`Method: ${error.method}`); * console.log(`Status: ${error.status}`); * console.log(`Is timeout: ${error.isTimeout}`); * console.log(`Is aborted: ${error.isAborted}`); * } * ``` */ class RequestError extends Error { /** HTTP status code if the request received a response (e.g., 404, 500) */ status; /** The Response object if the request received a response before failing */ response; /** The URL that was requested */ url; /** The HTTP method that was used (e.g., 'GET', 'POST') */ method; /** Whether the request failed due to a timeout */ isTimeout; /** Whether the request was aborted (cancelled) */ isAborted; /** * Creates a new RequestError instance. * * @param message - Error message describing what went wrong * @param url - The URL that was requested * @param method - The HTTP method that was used * @param options - Additional error context * @param options.status - HTTP status code if available * @param options.response - The Response object if available * @param options.isTimeout - Whether this was a timeout error * @param options.isAborted - Whether the request was aborted * @param options.cause - The underlying error that caused this error */ constructor(message, url, method, options = {}) { super(message, { cause: options.cause }); this.name = "RequestError"; this.url = url; this.method = method; this.status = options.status; this.response = options.response; this.isTimeout = !!options.isTimeout; this.isAborted = !!options.isAborted; // For better stack traces in modern environments if (Error.captureStackTrace) { Error.captureStackTrace(this, RequestError); } // Maintains proper prototype chain for instanceof checks Object.setPrototypeOf(this, RequestError.prototype); } /** * Creates a RequestError for a timeout failure. * * @param url - The URL that timed out * @param method - The HTTP method that was used * @param timeoutMs - The timeout duration in milliseconds * @returns A RequestError with `isTimeout` set to `true` * * @example * ```typescript * throw RequestError.timeout('/api/data', 'GET', 5000); * ``` */ static timeout(url, method, timeoutMs) { return new RequestError(`Timeout:${timeoutMs}`, url, method, { isTimeout: true, }); } /** * Creates a RequestError from an HTTP error response. * Used when the server returns a non-2xx status code. * * @param response - The Response object from the failed request * @param url - The URL that was requested * @param method - The HTTP method that was used * @returns A RequestError with the status code and response object * * @example * ```typescript * const response = await fetch('/api/users'); * if (!response.ok) { * throw RequestError.fromResponse(response, '/api/users', 'GET'); * } * ``` */ static fromResponse(response, url, method) { return new RequestError(`HTTP ${response.status}`, url, method, { status: response.status, response, }); } /** * Creates a RequestError from a network-level error. * Automatically detects and categorizes common network errors (timeouts, DNS errors, connection errors). * * @param url - The URL that failed * @param method - The HTTP method that was used * @param originalError - The original error that occurred (e.g., from fetch) * @returns A RequestError with enhanced error message and context * * @example * ```typescript * try { * await fetch('/api/data'); * } catch (error) { * if (error instanceof Error) { * throw RequestError.networkError('/api/data', 'GET', error); * } * } * ``` */ static networkError(url, method, originalError) { // Provide more descriptive error messages for common network errors let message = originalError.message; // Check for Node.js error codes (e.g., from undici/dns errors) const errorCode = originalError.code; const errorName = originalError.name; const stack = originalError.stack || ""; const errorMessageLower = message.toLowerCase(); // Check for timeout errors (Node.js/undici TimeoutError) // Note: While explicit timeouts set via withTimeout() are handled in BaseRequest, // this detection serves as a safety net for: // 1. Timeout errors from external AbortControllers (e.g., AbortSignal.timeout()) // 2. Different runtime implementations that may throw timeout errors differently // 3. Network-level timeouts (ETIMEDOUT) const isTimeoutError = errorName === "TimeoutError" || errorMessageLower.includes("timeout") || errorMessageLower.includes("aborted due to timeout") || errorCode === "ETIMEDOUT" || stack.includes("TimeoutError") || stack.includes("timeout"); // If the error message is generic "fetch failed", provide more context if (message === "fetch failed" || message === "Failed to fetch") { // Check for DNS resolution errors const isDnsError = errorCode === "ENOTFOUND" || errorCode === "EAI_AGAIN" || errorCode === "EAI_NODATA" || stack.includes("getaddrinfo") || stack.includes("ENOTFOUND") || stack.includes("EAI_AGAIN"); // Check for connection errors (but not timeout errors) const isConnectionError = !isTimeoutError && (errorCode === "ECONNREFUSED" || errorCode === "ECONNRESET" || stack.includes("ECONNREFUSED") || stack.includes("connect")); if (isTimeoutError) { message = `Timeout:${url}`; } else if (isDnsError) { message = `DNS:${url}`; } else if (isConnectionError) { message = `Conn:${url}`; } else { message = `Net:${url}`; } } const error = new RequestError(message, url, method, { ...(isTimeoutError ? { isTimeout: true } : {}), }); // Create a proper RequestError stack trace, but append the original stack for debugging // This way Node.js will show "RequestError: ..." instead of "TypeError: ..." if (originalError.stack) { // Get the current stack (which will start with RequestError) const currentStack = error.stack || ""; // Append the original error's stack as "Caused by:" for debugging context error.stack = `${currentStack}\n\nCaused by: ${originalError.stack}`; } return error; } /** * Creates a RequestError for an aborted (cancelled) request. * * @param url - The URL that was aborted * @param method - The HTTP method that was used * @returns A RequestError with `isAborted` set to `true` * * @example * ```typescript * const controller = new AbortController(); * controller.abort(); * throw RequestError.abortError('/api/data', 'GET'); * ``` */ static abortError(url, method) { return new RequestError("Aborted", url, method, { isAborted: true, }); } } /** * Wrapper for HTTP responses with methods to transform the response data. * Provides convenient methods to parse the response body in different formats. * Response bodies are cached after the first read, so you can call multiple methods * (e.g., `getJson()` and `getText()`) on the same response. * * @example * ```typescript * const response = await create.get('/api/users').getResponse(); * console.log(response.status); // 200 * console.log(response.ok); // true * const data = await response.getJson(); * ``` */ class ResponseWrapper { /** The URL that was requested (if available) */ url; /** The HTTP method that was used (if available) */ method; response; graphQLOptions; // Cache the body as the last used method cachedBlob; cachedText; cachedJson; cachedArrayBuffer; constructor(response, url, method, graphQLOptions) { this.response = response; this.url = url; this.method = method; if (graphQLOptions) { this.graphQLOptions = { throwOnError: graphQLOptions.throwOnError, }; } } /** * HTTP status code (e.g., 200, 404, 500) */ get status() { return this.response.status; } /** * HTTP status text (e.g., "OK", "Not Found", "Internal Server Error") */ get statusText() { return this.response.statusText; } /** * Response headers as a Headers object */ get headers() { return this.response.headers; } /** * Whether the response status is in the 200-299 range (successful) */ get ok() { return this.response.ok; } /** * The raw Response object from the fetch API. * Use this if you need direct access to the underlying Response. */ get raw() { return this.response; } /** * Check if the response body has already been consumed and throw an error if so * @throws RequestError if the body has already been consumed */ checkBodyNotConsumed() { if (this.response.bodyUsed) { throw new RequestError("Body used", this.url || "", this.method || "", { status: this.response.status, response: this.response, }); } } /** * Check for GraphQL errors and throw if throwOnError is enabled * @param data - The parsed JSON data * @throws RequestError if GraphQL response contains errors and throwOnError is enabled */ checkGraphQLErrors(data) { if (!this.graphQLOptions?.throwOnError || typeof data !== "object" || data === null) return; const responseData = data; if (!Array.isArray(responseData.errors) || responseData.errors.length === 0) return; const errors = responseData.errors; const errorMessages = errors.map(x => { if (typeof x === "string") return x; if (x && typeof x === "object" && "message" in x) { const message = x.message; if (message == null) return "Unknown error"; if (typeof message === "string") return message; if (typeof message === "object") { try { return JSON.stringify(message); } catch { return "Unknown error"; } } // For primitives (number, boolean, etc.), safe to convert // eslint-disable-next-line @typescript-eslint/no-base-to-string return String(message); } return String(x); }); const errorMessage = errorMessages.join(", "); throw new RequestError(`GQL: ${errorMessage}`, this.url || "", this.method || "", { status: this.response.status, response: this.response, }); } /** * Parse the response body as JSON * If GraphQL options are set with throwOnError=true, will check for GraphQL errors and throw. * * @returns The parsed JSON data * @throws {RequestError} When the request fails, JSON parsing fails, or GraphQL errors occur (if throwOnError enabled). * * @example * const data = await response.getJson(); * console.log(data.items); * * @example * // Error handling - errors are always RequestError * try { * const data = await response.getJson(); * } catch (error) { * if (error instanceof RequestError) { * console.log(error.status, error.url, error.method); * } * } */ async getJson() { if (this.cachedJson !== undefined) return this.cachedJson; this.checkBodyNotConsumed(); try { const parsed = await this.response.json(); this.cachedJson = parsed; this.checkGraphQLErrors(parsed); return parsed; } catch (error) { if (error instanceof RequestError) { throw error; } throw new RequestError(`Bad JSON: ${error instanceof Error ? error.message : String(error)}`, this.url || "", this.method || "", { status: this.response.status, response: this.response, }); } } /** * Get the response body as text. * The result is cached, so subsequent calls return the same value without re-reading the body. * * @returns A promise that resolves to the response body as a string * @throws {RequestError} When the body has already been consumed or reading fails * * @example * ```typescript * const text = await response.getText(); * console.log(text); // "Hello, world!" * ``` */ async getText() { if (this.cachedText !== undefined) return this.cachedText; this.checkBodyNotConsumed(); try { const text = await this.response.text(); this.cachedText = text; return text; } catch (e) { throw new RequestError(`Read: ${e instanceof Error ? e.message : String(e)}`, this.url || "", this.method || "", { status: this.response.status, response: this.response, }); } } /** * Get the response body as a Blob. * Useful for downloading files or handling binary data. * The result is cached, so subsequent calls return the same value without re-reading the body. * * @returns A promise that resolves to the response body as a Blob * @throws {RequestError} When the body has already been consumed or reading fails * * @example * ```typescript * const blob = await response.getBlob(); * const url = URL.createObjectURL(blob); * // Use the blob URL for downloading or displaying * ``` */ async getBlob() { if (this.cachedBlob !== undefined) return this.cachedBlob; this.checkBodyNotConsumed(); try { const blob = await this.response.blob(); this.cachedBlob = blob; return blob; } catch (e) { throw new RequestError(`Read: ${e instanceof Error ? e.message : String(e)}`, this.url || "", this.method || "", { status: this.response.status, response: this.response, }); } } /** * Get the response body as an ArrayBuffer. * Useful for processing binary data at a low level. * The result is cached, so subsequent calls return the same value without re-reading the body. * * @returns A promise that resolves to the response body as an ArrayBuffer * @throws {RequestError} When the body has already been consumed or reading fails * * @example * ```typescript * const buffer = await response.getArrayBuffer(); * const uint8Array = new Uint8Array(buffer); * // Process the binary data * ``` */ async getArrayBuffer() { if (this.cachedArrayBuffer !== undefined) { return this.cachedArrayBuffer; } this.checkBodyNotConsumed(); try { const arrayBuffer = await this.response.arrayBuffer(); this.cachedArrayBuffer = arrayBuffer; return arrayBuffer; } catch (e) { throw new RequestError(`Read: ${e instanceof Error ? e.message : String(e)}`, this.url || "", this.method || "", { status: this.response.status, response: this.response, }); } } /** * Get the raw response body as a ReadableStream * Note: This consumes the response body and should only be called once. * Unlike other methods, streams cannot be cached, so this will throw if the body is already consumed. * * @returns The response body as a ReadableStream or null * @throws {RequestError} When the response body has already been consumed * * @example * const stream = response.getBody(); * if (stream) { * const reader = stream.getReader(); * // Process the stream * } */ getBody() { this.checkBodyNotConsumed(); return this.response.body; } /** * Extract specific data using a selector function * If no selector is provided, returns the full JSON response. * * @param selector - Optional function to extract and transform data * @returns A promise that resolves to the selected data * @throws {RequestError} When the request fails, JSON parsing fails, or the selector throws an error * * @example * // Get full response * const data = await response.getData(); * * // Extract specific data * const users = await response.getData(data => data.results.users); * * @example * // Error handling - errors are always RequestError * try { * const data = await response.getData(); * } catch (error) { * if (error instanceof RequestError) { * console.log(error.status, error.url, error.method); * } * } */ async getData(selector) { try { const data = await this.getJson(); // If no selector is provided, return the raw JSON data if (!selector) return data; // Apply the selector if provided return selector(data); } catch (error) { // If it's already a RequestError, re-throw it if (error instanceof RequestError) { throw error; } // Enhance selector errors with context if (selector) { throw new RequestError(`Selector: ${error instanceof Error ? error.message : String(error)}`, this.url || "", this.method || "", { status: this.response.status, response: this.response, }); } // If we get here and it's not a RequestError, wrap it // This should rarely happen as getJson() should throw RequestError const errorObj = error instanceof Error ? error : new Error(String(error)); throw RequestError.networkError(this.url || "", this.method || "", errorObj); } } } class CookieUtils { /** * Formats cookies for a request * @param cookies Object containing cookie name-value pairs or cookie options * @returns Formatted cookie string for the Cookie header */ static formatRequestCookies(cookies) { const cookiePairs = []; Object.entries(cookies).forEach(([name, valueOrOptions]) => { let value; if (typeof valueOrOptions === "string") { value = valueOrOptions; } else { // Extract value from options object without validation value = valueOrOptions.value; } // Add the cookie to the request cookiePairs.push(`${encodeURIComponent(name)}=${encodeURIComponent(value)}`); }); return cookiePairs.join("; "); } } /** * Utility class for CSRF token management */ class CsrfUtils { /** * Extracts CSRF token from a meta tag in the document head * @param metaName The name attribute of the meta tag (default: "csrf-token") * @returns The CSRF token or null if not found */ static getTokenFromMeta(metaName = "csrf-token") { if (typeof document === "undefined") { return null; } const meta = document.querySelector(`meta[name="${metaName}"]`); return meta?.getAttribute("content") || null; } /** * Extracts CSRF token from a cookie * @param cookieName The name of the cookie containing the CSRF token * @returns The CSRF token or null if not found */ static getTokenFromCookie(cookieName = "csrf-token") { if (typeof document === "undefined") { return null; } const cookies = document.cookie.split(";"); for (const cookie of cookies) { const [name, value] = cookie.trim().split("="); if (name === cookieName) { return decodeURIComponent(value); } } return null; } /** * Validates if the provided string is a potential CSRF token * Checks if the token meets security requirements * @param token The token to validate * @returns Whether the token is valid */ static isValidToken(token) { // Basic checks for null/undefined and type if (typeof token !== "string") { return false; } if (token.length < 8) return false; // If token is longer than 10 chars, perform additional security checks if (token.length > 10) { // Check for valid character set (alphanumeric & common token symbols) const validTokenRegex = /^[A-Za-z0-9\-_=+/.]+$/; if (!validTokenRegex.test(token)) { return false; } // For longer tokens, check for sufficient entropy (at least 2 character types) const hasUpperCase = /[A-Z]/.test(token); const hasLowerCase = /[a-z]/.test(token); const hasNumbers = /[0-9]/.test(token); const hasSpecials = /[-_=+/.]/.test(token); const characterTypesCount = [hasUpperCase, hasLowerCase, hasNumbers, hasSpecials].filter(Boolean).length; return characterTypesCount >= 2; } // For shorter tokens (8-10 chars), just return true if we reached here return true; } } /** * Global configuration for create-request */ class Config { static instance; // CSRF configuration csrfHeaderName = "X-CSRF-Token"; xsrfCookieName = "XSRF-TOKEN"; xsrfHeaderName = "X-XSRF-TOKEN"; csrfToken = null; enableAutoXsrf = true; enableAntiCsrf = true; // X-Requested-With header // Interceptor configuration requestInterceptors = []; responseInterceptors = []; errorInterceptors = []; nextInterceptorId = 1; constructor() { } /** * Get the singleton instance of the Config class * * @returns The global configuration instance * * @example * const config = Config.getInstance(); * config.setCsrfToken('token123'); */ static getInstance() { if (!Config.instance) { Config.instance = new Config(); } return Config.instance; } /** * Set a global CSRF token to be used for all requests * * @param token - The CSRF token value * @returns The config instance for chaining * * @example * Config.getInstance().setCsrfToken('myToken123'); */ setCsrfToken(token) { this.csrfToken = token; return this; } /** * Get the global CSRF token that will be automatically applied to requests * * @returns The current CSRF token or null if not set */ getCsrfToken() { return this.csrfToken; } /** * Set the CSRF header name used when sending the token * * @param name - The header name to use * @returns The config instance for chaining * * @example * Config.getInstance().setCsrfHeaderName('X-My-CSRF-Token'); */ setCsrfHeaderName(name) { this.csrfHeaderName = name; return this; } /** * Get the configured CSRF header name * * @returns The current CSRF header name */ getCsrfHeaderName() { return this.csrfHeaderName; } /** * Set the XSRF cookie name to look for when extracting tokens from cookies * * @param name - The cookie name to look for * @returns The config instance for chaining * * @example * Config.getInstance().setXsrfCookieName('MY-XSRF-COOKIE'); */ setXsrfCookieName(name) { this.xsrfCookieName = name; return this; } /** * Get the configured XSRF cookie name * * @returns The current XSRF cookie name */ getXsrfCookieName() { return this.xsrfCookieName; } /** * Set the XSRF header name for sending tokens extracted from cookies * * @param name - The header name to use * @returns The config instance for chaining */ setXsrfHeaderName(name) { this.xsrfHeaderName = name; return this; } /** * Get the configured XSRF header name * * @returns The current XSRF header name */ getXsrfHeaderName() { return this.xsrfHeaderName; } /** * Enable or disable automatic extraction of XSRF tokens from cookies * When enabled, the library will look for XSRF tokens in cookies and * automatically add them to request headers. * * @param enable - Whether to enable this feature * @returns The config instance for chaining * * @example * Config.getInstance().setEnableAutoXsrf(false); // Disable XSRF extraction */ setEnableAutoXsrf(enable) { this.enableAutoXsrf = enable; return this; } /** * Check if automatic XSRF token extraction is enabled * * @returns True if automatic XSRF is enabled */ isAutoXsrfEnabled() { return this.enableAutoXsrf; } /** * Enable or disable automatic addition of anti-CSRF headers * When enabled, X-Requested-With: XMLHttpRequest will be added to all requests. * * @param enable - Whether to enable this feature * @returns The config instance for chaining */ setEnableAntiCsrf(enable) { this.enableAntiCsrf = enable; return this; } /** * Check if anti-CSRF protection is enabled * * @returns True if anti-CSRF protection is enabled */ isAntiCsrfEnabled() { return this.enableAntiCsrf; } /** * Add a global request interceptor * Request interceptors can modify the request configuration or return an early response * * @param interceptor - The request interceptor function * @returns The interceptor ID for later removal * * @example * const id = Config.getInstance().addRequestInterceptor((config) => { * config.headers['X-Custom'] = 'value'; * return config; * }); */ addRequestInterceptor(interceptor) { const id = this.nextInterceptorId++; this.requestInterceptors.push({ id, interceptor }); return id; } /** * Add a global response interceptor * Response interceptors can transform the response * * @param interceptor - The response interceptor function * @returns The interceptor ID for later removal * * @example * const id = Config.getInstance().addResponseInterceptor((response) => { * console.log('Response received:', response.status); * return response; * }); */ addResponseInterceptor(interceptor) { const id = this.nextInterceptorId++; this.responseInterceptors.push({ id, interceptor }); return id; } /** * Add a global error interceptor * Error interceptors can handle or transform errors * * @param interceptor - The error interceptor function * @returns The interceptor ID for later removal * * @example * const id = Config.getInstance().addErrorInterceptor((error) => { * console.error('Request failed:', error); * throw error; * }); */ addErrorInterceptor(interceptor) { const id = this.nextInterceptorId++; this.errorInterceptors.push({ id, interceptor }); return id; } /** * Remove a request interceptor by its ID * * @param id - The interceptor ID returned from addRequestInterceptor * * @example * Config.getInstance().removeRequestInterceptor(id); */ removeRequestInterceptor(id) { this.requestInterceptors = this.requestInterceptors.filter(item => item.id !== id); } /** * Remove a response interceptor by its ID * * @param id - The interceptor ID returned from addResponseInterceptor * * @example * Config.getInstance().removeResponseInterceptor(id); */ removeResponseInterceptor(id) { this.responseInterceptors = this.responseInterceptors.filter(item => item.id !== id); } /** * Remove an error interceptor by its ID * * @param id - The interceptor ID returned from addErrorInterceptor * * @example * Config.getInstance().removeErrorInterceptor(id); */ removeErrorInterceptor(id) { this.errorInterceptors = this.errorInterceptors.filter(item => item.id !== id); } /** * Clear all interceptors (request, response, and error) * * @example * Config.getInstance().clearInterceptors(); */ clearInterceptors() { this.requestInterceptors = []; this.responseInterceptors = []; this.errorInterceptors = []; } /** * Get all global request interceptors (in registration order) * @internal */ getRequestInterceptors() { return this.requestInterceptors.map(item => item.interceptor); } /** * Get all global response interceptors (in registration order) * @internal */ getResponseInterceptors() { return this.responseInterceptors.map(item => item.interceptor); } /** * Get all global error interceptors (in registration order) * @internal */ getErrorInterceptors() { return this.errorInterceptors.map(item => item.interceptor); } /** * Reset all configuration options to their default values * * @returns The config instance for chaining * * @example * Config.getInstance().reset(); */ reset() { this.csrfToken = null; this.csrfHeaderName = "X-CSRF-Token"; this.enableAntiCsrf = true; this.xsrfCookieName = "XSRF-TOKEN"; this.xsrfHeaderName = "X-XSRF-TOKEN"; this.enableAutoXsrf = true; this.clearInterceptors(); return this; } } /** * Base class with common functionality for all request types * Provides the core request building and execution capabilities. */ class BaseRequest { url; requestOptions = { headers: {}, }; abortController; queryParams = new URLSearchParams(); autoApplyCsrfProtection = true; // Per-request interceptors requestInterceptors = []; responseInterceptors = []; errorInterceptors = []; constructor(url) { this.url = url; } /** * Get GraphQL options if set (only for BodyRequest subclasses) * @returns GraphQL options or undefined */ getGraphQLOptions() { return undefined; } /** * Creates a fluent API for setting enum-based options * Combines direct setter with convenience methods */ createFluentSetter(optionName, options) { const fluent = {}; // Create convenience methods for each enum value Object.entries(options).forEach(([key, value]) => { fluent[key] = () => { this.requestOptions[optionName] = value; return this; }; }); // Create the callable setter const callable = (value) => { this.requestOptions[optionName] = value; return this; }; return Object.assign(callable, fluent); } validateUrl(url) { const errorMessage = "Bad URL"; if (!url?.trim()) throw new RequestError(errorMessage, url, this.method); if (url.includes("\0") || url.includes("\r") || url.includes("\n")) { throw new RequestError(errorMessage, url, this.method); } const trimmed = url.trim(); if (/^https?:\/\//.test(trimmed)) { try { new URL(trimmed); } catch { throw new RequestError(errorMessage, trimmed, this.method); } } } /** * Add multiple HTTP headers to the request * * @param headers - Key-value pairs of header names and values * @returns The request instance for chaining * * @example * request.withHeaders({ * 'Accept': 'application/json', * 'X-Custom-Header': 'value' * }); */ withHeaders(headers) { // Filter out null and undefined values const filteredHeaders = {}; Object.entries(headers).forEach(([key, value]) => { if (value !== null && value !== undefined) { filteredHeaders[key] = value; } }); this.requestOptions.headers = { ...this.getHeadersRecord(), ...filteredHeaders, }; return this; } /** * Add a single HTTP header to the request * * @param key - The header name * @param value - The header value * @returns The request instance for chaining * * @example * request.withHeader('Accept', 'application/json'); */ withHeader(key, value) { return this.withHeaders({ [key]: value }); } /** * Set a timeout for the request * If the request takes longer than the specified timeout, it will be aborted. * * @param timeout - The timeout in milliseconds * @returns The request instance for chaining * @throws RequestError if timeout is not a positive number * * @example * request.withTimeout(5000); // 5 seconds timeout */ withTimeout(timeout) { if (!Number.isFinite(timeout) || timeout <= 0) throw new RequestError("Bad timeout", this.url, this.method); this.requestOptions.timeout = timeout; return this; } /** * Configure automatic retry behavior for failed requests * * @param retries - Number of retry attempts before failing, or a configuration object * @returns The request instance for chaining * @throws RequestError if retries is not a non-negative integer or invalid config * * @example * // Simple number (backward compatible) * request.withRetries(3); // Retry up to 3 times * * @example * // With fixed delay * request.withRetries({ attempts: 3, delay: 1000 }); // Retry 3 times with 1 second delay * * @example * // With exponential backoff function * request.withRetries({ * attempts: 3, * delay: ({ attempt }) => Math.min(1000 * Math.pow(2, attempt - 1), 10000) * }); * * @example * // With delay function based on error * request.withRetries({ * attempts: 3, * delay: ({ attempt, error }) => { * if (error.status === 429) return 5000; // Rate limited, wait longer * return attempt * 1000; // Exponential backoff * } * }); */ withRetries(retries) { if (typeof retries === "number") { if (!Number.isInteger(retries) || retries < 0) { throw new RequestError(`Bad retries: ${retries}`, this.url, this.method); } this.requestOptions.retries = retries; } else { // Validate RetryConfig if (!Number.isInteger(retries.attempts) || retries.attempts < 0) { throw new RequestError(`Bad attempts: ${retries.attempts}`, this.url, this.method); } // Validate delay if provided if (retries.delay !== undefined) { if (typeof retries.delay === "number") { if (!Number.isFinite(retries.delay) || retries.delay < 0) { throw new RequestError(`Bad delay: ${retries.delay}`, this.url, this.method); } } else if (typeof retries.delay !== "function") { throw new RequestError(`Bad delay: ${typeof retries.delay}`, this.url, this.method); } } this.requestOptions.retries = retries; } return this; } /** * Set a callback to be invoked before each retry attempt * Useful for implementing backoff strategies or logging retry attempts. * * @param callback - Function to call before retrying * @returns The request instance for chaining * * @example * request.onRetry(({ attempt, error }) => { * console.log(`Retry attempt ${attempt} after error: ${error.message}`); * return new Promise(resolve => setTimeout(resolve, attempt * 1000)); * }); */ onRetry(callback) { this.requestOptions.onRetry = callback; return this; } /** * Sets the credentials policy for the request, controlling whether cookies and authentication * headers are sent with cross-origin requests. * * @param credentialsPolicy - The credentials policy to use: * - `"include"` or `CredentialsPolicy.INCLUDE`: Always send credentials (cookies, authorization headers) with the request, even for cross-origin requests. * - `"omit"` or `CredentialsPolicy.OMIT`: Never send credentials, even for same-origin requests. * - `"same-origin"` or `CredentialsPolicy.SAME_ORIGIN`: Only send credentials for same-origin requests (default behavior in most browsers). * * @returns The request instance for chaining * * @example * // Using string values * request.withCredentials("include") * * @example * // Using enum values * request.withCredentials(CredentialsPolicy.INCLUDE) * * @example * // Using fluent API * request.withCredentials.INCLUDE() */ get withCredentials() { return this.createFluentSetter("credentials", { INCLUDE: exports.CredentialsPolicy.INCLUDE, OMIT: exports.CredentialsPolicy.OMIT, SAME_ORIGIN: exports.CredentialsPolicy.SAME_ORIGIN, }); } /** * Allows providing an external AbortController to cancel the request. * This is useful when you need to cancel a request from outside the request chain, * * @param controller - The AbortController to use for this request. When `controller.abort()` is called, * the request will be cancelled and throw an abort error. * * @returns The request instance for chaining * * @example * const controller = new AbortController(); * const request = createRequest('/api/data') * .withAbortController(controller) * .getJson(); * * // Later, cancel the request * controller.abort(); * * @example * // Share abort controller across multiple requests * const controller = new AbortController(); * request1.withAbortController(controller).getJson(); * request2.withAbortController(controller).getJson(); * // Aborting will cancel both requests * controller.abort(); */ withAbortController(controller) { this.abortController = controller; return this; } /** * Sets the referrer URL for the request. The referrer is the URL of the page that initiated the request. * This can be used to override the default referrer that the browser would normally send. * * @param referrer - The referrer URL to send with the request. Can be: * - A full URL (e.g., "https://example.com/page") * - An empty string to omit the referrer * - A relative URL (will be resolved relative to the current page) * * @returns The request instance for chaining * * @example * request.withReferrer("https://example.com/previous-page") * * @example * // Omit referrer * request.withReferrer("") */ withReferrer(referrer) { this.requestOptions.referrer = referrer; return this; } /** * Sets the referrer policy for the request, controlling how much referrer information * is sent with the request. This helps balance privacy and functionality. * * @param policy - The referrer policy to use: * - `"no-referrer"` or `ReferrerPolicy.NO_REFERRER`: Never send the referrer header. * - `"no-referrer-when-downgrade"` or `ReferrerPolicy.NO_REFERRER_WHEN_DOWNGRADE`: Send full referrer for same-origin or HTTPS→HTTPS, omit for HTTPS→HTTP (default in most browsers). * - `"origin"` or `ReferrerPolicy.ORIGIN`: Only send the origin (scheme, host, port), not the full URL. * - `"origin-when-cross-origin"` or `ReferrerPolicy.ORIGIN_WHEN_CROSS_ORIGIN`: Send full referrer for same-origin, only origin for cross-origin. * - `"same-origin"` or `ReferrerPolicy.SAME_ORIGIN`: Send full referrer for same-origin requests only, omit for cross-origin. * - `"strict-origin"` or `ReferrerPolicy.STRICT_ORIGIN`: Send origin for HTTPS→HTTPS or HTTP→HTTP, omit for HTTPS→HTTP. * - `"strict-origin-when-cross-origin"` or `ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN`: Send full referrer for same-origin, origin for cross-origin HTTPS→HTTPS, omit for HTTPS→HTTP. * - `"unsafe-url"` or `ReferrerPolicy.UNSAFE_URL`: Always send the full referrer URL (may leak sensitive information). * * @returns The request instance for chaining * * @example * // Using string values * request.withReferrerPolicy("no-referrer") * * @example * // Using enum values * request.withReferrerPolicy(ReferrerPolicy.NO_REFERRER) * * @example * // Using fluent API * request.withReferrerPolicy.NO_REFERRER() */ get withReferrerPolicy() { return this.createFluentSetter("referrerPolicy", { ORIGIN: exports.ReferrerPolicy.ORIGIN, UNSAFE_URL: exports.ReferrerPolicy.UNSAFE_URL, SAME_ORIGIN: exports.ReferrerPolicy.SAME_ORIGIN, NO_REFERRER: exports.ReferrerPolicy.NO_REFERRER, STRICT_ORIGIN: exports.ReferrerPolicy.STRICT_ORIGIN, ORIGIN_WHEN_CROSS_ORIGIN: exports.ReferrerPolicy.ORIGIN_WHEN_CROSS_ORIGIN, NO_REFERRER_WHEN_DOWNGRADE: exports.ReferrerPolicy.NO_REFERRER_WHEN_DOWNGRADE, STRICT_ORIGIN_WHEN_CROSS_ORIGIN: exports.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN, }); } /** * Sets how the request handles HTTP redirects (3xx status codes). * * @param redirect - The redirect handling mode: * - `"follow"` or `RedirectMode.FOLLOW`: Automatically follow redirects. The fetch will transparently follow redirects and return the final response (default behavior). * - `"error"` or `RedirectMode.ERROR`: Treat redirects as errors. If a redirect occurs, the request will fail with an error. * - `"manual"` or `RedirectMode.MANUAL`: Return the redirect response without following it. The response will have a `type` of "opaqueredirect" and you can manually handle the redirect. * * @returns The request instance for chaining * * @example * // Using string values * request.withRedirect("follow") * * @example * // Using enum values * request.withRedirect(RedirectMode.FOLLOW) * * @example * // Using fluent API * request.withRedirect.FOLLOW() * * @example * // Fail on redirects * request.withRedirect.ERROR() */ get withRedirect() { return this.createFluentSetter("redirect", { FOLLOW: exports.RedirectMode.FOLLOW, ERROR: exports.RedirectMode.ERROR, MANUAL: exports.RedirectMode.MANUAL, }); } /** * Sets the keepalive flag for the request. When enabled, the request can continue * even after the page that initiated it is closed. This is useful for analytics, * logging, or other background requests that should complete even if the user navigates away. * * @param keepalive - Whether to allow the request to outlive the page: * - `true`: The request will continue even if the page is closed or navigated away. * - `false`: The request will be cancelled if the page is closed (default). * * @returns The request instance for chaining * * @example * // Send analytics event that should complete even if user navigates away * request.withKeepAlive(true) */ withKeepAlive(keepalive) { this.requestOptions.keepalive = keepalive; return this; } /** * Sets the priority hint for the request, indicating to the browser how important * this request is relative to other requests. This helps the browser optimize resource loading. * * @param priority - The request priority: * - `"high"` or `RequestPriority.HIGH`: High priority - the browser should prioritize this request. * - `"low"` or `RequestPriority.LOW`: Low priority - the browser can defer this request if needed. * - `"auto"` or `RequestPriority.AUTO`: Automatic priority based on the request type (default). * * @returns The request instance for chaining * * @example * // Using string values * request.withPriority("high") * * @example * // Using enum values * request.withPriority(RequestPriority.HIGH) * * @example * // Using fluent API * request.withPriority.HIGH() * * @example * // Low priority for non-critical requests * request.withPriority.LOW() */ get withPriority() { return this.crea