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
JavaScript
'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