advanced-http-client
Version:
Universal HTTP client library using fetch for JS/TS projects (React, Next.js, Vue, Node.js, Bun, etc.)
463 lines (462 loc) • 17.5 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.HttpClient = void 0;
class InterceptorManagerImpl {
constructor() {
this._handlers = [];
this.nextId = 0;
}
use(_fulfilled, _rejected) {
this._handlers.push({
_fulfilled,
_rejected,
});
return this.nextId++;
}
eject(_id) {
if (this._handlers[_id]) {
this._handlers[_id] = {};
}
}
clear() {
this._handlers = [];
}
get handlers() {
return this._handlers;
}
}
// Separate error interceptor manager
class ErrorInterceptorManagerImpl {
constructor() {
this._handlers = [];
this.nextId = 0;
}
use(_fulfilled, _rejected) {
this._handlers.push({
_fulfilled,
_rejected,
});
return this.nextId++;
}
eject(_id) {
if (this._handlers[_id]) {
this._handlers[_id] = {};
}
}
clear() {
this._handlers = [];
}
get handlers() {
return this._handlers;
}
}
// Constants for content types
const CONTENT_TYPES = {
JSON: "application/json",
TEXT: "text/",
FORM: "form",
BLOB: "blob",
ARRAY_BUFFER: "arraybuffer",
};
// Constants for HTTP methods
const HTTP_METHODS = {
GET: "GET",
POST: "POST",
PATCH: "PATCH",
DELETE: "DELETE",
};
// Special key used internally for requests that don't specify a controlKey
const ANONYMOUS_KEY = "__anonymous__";
class HttpClient {
constructor(config) {
this.controllers = new Map();
this.baseURL = config?.baseURL;
this.instanceHeaders = { ...(config?.headers || {}) };
const { baseURL: _baseURL, headers: _headers, ...rest } = config || {};
this.instanceOptions = rest;
// Initialize interceptors
this.interceptors = {
request: new InterceptorManagerImpl(),
response: new InterceptorManagerImpl(),
error: new ErrorInterceptorManagerImpl(),
};
// Track instance for global cancellation capability
HttpClient.allInstances.add(this);
}
/**
* Set a global header for all requests (e.g., for authorization).
*/
static setHeader(key, value) {
this.globalHeaders[key] = value;
}
/**
* Generate a random 20-character alphanumeric string suitable for use as a controlKey.
*/
static generateControlKey() {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const bytes = new Uint8Array(20);
const gCrypto = globalThis.crypto;
if (gCrypto && typeof gCrypto.getRandomValues === "function") {
gCrypto.getRandomValues(bytes);
}
else {
// Try Node.js crypto as a fallback (works in CJS & ESM)
const nodeCrypto = globalThis.require?.("crypto");
if (nodeCrypto && typeof nodeCrypto.randomBytes === "function") {
const buf = nodeCrypto.randomBytes(20);
buf.forEach((b, i) => (bytes[i] = b));
}
else {
throw new Error("Secure random number generation is not available in this environment. Please provide a controlKey manually.");
}
}
let result = "";
bytes.forEach((b) => {
result += chars[b % chars.length];
});
return result;
}
/**
* Create a new HttpClient instance with custom default configuration.
* @param config - Default configuration for this instance (e.g., baseURL, headers, credentials, etc.)
* @returns A new HttpClient instance with the provided defaults.
*
* Example:
* ```js
* const api = HttpClient.create({
* baseURL: 'https://api.example.com',
* headers: { Authorization: 'Bearer token' }
* });
* api.get('/users'); // GET https://api.example.com/users
* ```
*/
static create(config) {
return new HttpClient(config);
}
static async parseResponseBody(response) {
let data = undefined;
const contentType = response.headers.get("content-type") ?? "";
if (contentType && contentType.indexOf(CONTENT_TYPES.JSON) !== -1) {
data = await response.json();
}
else if (contentType && contentType.indexOf(CONTENT_TYPES.TEXT) !== -1) {
data = await response.text();
}
else if (contentType && contentType.indexOf(CONTENT_TYPES.FORM) !== -1) {
data = await response.formData();
}
else if (contentType && contentType.indexOf(CONTENT_TYPES.BLOB) !== -1) {
data = await response.blob();
}
else if (contentType && contentType.indexOf(CONTENT_TYPES.ARRAY_BUFFER) !== -1) {
data = await response.arrayBuffer();
}
else {
data = await response.text();
}
return data;
}
mergeConfig(options) {
if (options?.isolated) {
const headers = {};
// If includeHeaders is set, pull those from global/instance headers
if (Array.isArray(options.includeHeaders)) {
const include = options.includeHeaders;
// Pull from instanceHeaders first, then globalHeaders
for (const key of include) {
if (this.instanceHeaders?.[key] !== undefined) {
headers[key] = this.instanceHeaders[key];
}
else if (HttpClient.globalHeaders[key] !== undefined) {
headers[key] = HttpClient.globalHeaders[key];
}
}
}
// Merge in provided headers (overrides included ones)
if (options.headers) {
if (options.headers instanceof Headers) {
options.headers.forEach((v, k) => (headers[k] = v));
}
else if (Array.isArray(options.headers)) {
options.headers.forEach(([k, v]) => (headers[k] = v));
}
else {
Object.assign(headers, options.headers);
}
}
return {
...options,
headers,
};
}
// Merge instance headers, global headers, and per-request headers
const mergedHeaders = {
...this.instanceHeaders,
...(options?.headers instanceof Headers
? this.convertHeadersToObject(options.headers)
: options?.headers || {}),
};
// Merge global headers after user headers, so user headers take precedence
Object.entries(HttpClient.globalHeaders).forEach(([k, v]) => {
if (!(k in mergedHeaders))
mergedHeaders[k] = v;
});
// Set default Accept header if not already set
if (!mergedHeaders["Accept"]) {
mergedHeaders["Accept"] = CONTENT_TYPES.JSON;
}
return {
...this.instanceOptions,
...options,
headers: mergedHeaders,
};
}
convertHeadersToObject(headers) {
const obj = {};
headers.forEach((v, k) => {
obj[k] = v;
});
return obj;
}
buildURL(url) {
if (this.baseURL && !/^https?:\/\//i.test(url)) {
return this.baseURL.replace(/\/$/, "") + "/" + url.replace(/^\//, "");
}
return url;
}
async executeRequestInterceptors(config) {
let promise = Promise.resolve(config);
const chain = this.interceptors.request.handlers;
for (const { _fulfilled, _rejected } of chain) {
promise = promise.then(_fulfilled ? _fulfilled : (_c) => _c, _rejected ? _rejected : (_e) => Promise.reject(_e));
}
return promise;
}
async executeResponseInterceptors(response) {
let promise = Promise.resolve(response);
const chain = this.interceptors.response.handlers;
for (const { _fulfilled, _rejected } of chain) {
promise = promise.then(_fulfilled ? _fulfilled : (_r) => _r, _rejected ? _rejected : (_e) => Promise.reject(_e));
}
return promise;
}
async executeErrorInterceptors(error) {
let currentError = error;
// Execute error interceptors
for (const interceptor of this.interceptors.error.handlers) {
if (interceptor._fulfilled) {
try {
const result = await interceptor._fulfilled(currentError);
// If the interceptor returns a response, it means the error was handled
if (result && typeof result === 'object' && 'data' in result && 'status' in result) {
return result;
}
// If it returns an error, continue the chain
if (result && typeof result === 'object' && 'message' in result) {
currentError = result;
}
}
catch (interceptorError) {
currentError = interceptorError;
}
}
}
throw currentError;
}
async request(url, options) {
if (typeof fetch === "undefined") {
throw new Error("fetch is not available in this environment. For Node.js <18, install a fetch polyfill.");
}
const finalOptions = this.mergeConfig(options);
const fullUrl = this.buildURL(url);
// Execute request interceptors
const interceptedOptions = await this.executeRequestInterceptors(finalOptions);
// Determine if we have to create an AbortController (for timeout or controlKey)
const needsController = (!interceptedOptions.signal) || (typeof interceptedOptions.timeout === "number" && interceptedOptions.timeout > 0) || interceptedOptions.controlKey;
let timeoutId;
let controller;
let currentControlKey = interceptedOptions.controlKey;
if (needsController) {
if (!interceptedOptions.signal) {
controller = new AbortController();
interceptedOptions.signal = controller.signal;
}
}
if (typeof interceptedOptions.timeout === "number" && interceptedOptions.timeout > 0) {
// If a signal already exists, we cannot attach our AbortController.
timeoutId = globalThis.setTimeout(() => {
controller?.abort();
}, interceptedOptions.timeout);
// timeout should not be passed to fetch API
delete interceptedOptions.timeout;
}
// Handle controlKey registration (no duplicates)
if (currentControlKey) {
const key = currentControlKey;
delete interceptedOptions.controlKey;
let map;
if (this._isStaticInstance) {
map = HttpClient.globalControllers;
}
else {
map = this.controllers;
}
if (map.has(key)) {
throw new Error(`controlKey '${key}' is already in use.`);
}
if (!controller) {
controller = new AbortController();
interceptedOptions.signal = controller.signal;
}
map.set(key, controller);
}
// Handle requests without a controlKey by using a shared anonymous key
if (!currentControlKey) {
const mapAnon = this._isStaticInstance ? HttpClient.globalControllers : this.controllers;
const existingCtrl = mapAnon.get(ANONYMOUS_KEY);
if (existingCtrl) {
// Reuse existing controller
interceptedOptions.signal = existingCtrl.signal;
controller = existingCtrl;
}
else {
if (!controller) {
controller = new AbortController();
interceptedOptions.signal = controller.signal;
}
mapAnon.set(ANONYMOUS_KEY, controller);
}
}
try {
const response = await fetch(fullUrl, interceptedOptions);
if (timeoutId)
globalThis.clearTimeout(timeoutId);
// Remove controlKey mapping after completion
if (currentControlKey) {
const map = this.controllers.has(currentControlKey) ? this.controllers : HttpClient.globalControllers;
map.delete(currentControlKey);
}
const data = await HttpClient.parseResponseBody(response);
const headers = {};
response.headers.forEach((value, key) => {
headers[key] = value;
});
const result = {
data: data,
status: response.status,
statusText: response.statusText,
headers,
config: {
url: fullUrl,
options: interceptedOptions,
method: interceptedOptions?.method ?? HTTP_METHODS.GET,
body: interceptedOptions?.body,
},
request: response,
};
if (!response.ok) {
const error = new Error(`Request failed with status code ${response.status}`);
error.response = result;
throw error;
}
// Execute response interceptors
return await this.executeResponseInterceptors(result);
}
catch (error) {
// Ensure we clean up controllers even on error
if (currentControlKey) {
const map = this.controllers.has(currentControlKey) ? this.controllers : HttpClient.globalControllers;
map.delete(currentControlKey);
}
// Execute error interceptors
return await this.executeErrorInterceptors(error);
}
}
async get(url, options) {
return this.request(url, { ...options, method: HTTP_METHODS.GET });
}
async requestWithBody(method, url, body, options) {
const opts = this.mergeConfig(options);
opts.method = method;
if (body !== undefined) {
opts.body = JSON.stringify(body);
if (!opts.headers["Content-Type"]) {
opts.headers["Content-Type"] = CONTENT_TYPES.JSON;
}
}
return this.request(url, opts);
}
async post(url, body, options) {
return this.requestWithBody(HTTP_METHODS.POST, url, body, options);
}
async patch(url, body, options) {
return this.requestWithBody(HTTP_METHODS.PATCH, url, body, options);
}
async delete(url, body, options) {
return this.requestWithBody(HTTP_METHODS.DELETE, url, body, options);
}
// Internal helper to abort controllers for cleanup
_abortAllControllers() {
this.controllers.forEach((c) => c.abort());
this.controllers.clear();
}
// ---------------------------------------------------------------------------
// Static helper methods (backward compatibility)
// These create a temporary client marked as a "static" instance so that any
// controlKey registered will be placed in the globalControllers map. After
// completion, users can cancel with HttpClient.cancelRequest / cancelAllRequests.
// ---------------------------------------------------------------------------
static async get(url, options) {
const client = new HttpClient();
client._isStaticInstance = true;
return client.get(url, options);
}
static async post(url, body, options) {
const client = new HttpClient();
client._isStaticInstance = true;
return client.post(url, body, options);
}
static async patch(url, body, options) {
const client = new HttpClient();
client._isStaticInstance = true;
return client.patch(url, body, options);
}
static async delete(url, body, options) {
const client = new HttpClient();
client._isStaticInstance = true;
return client.delete(url, body, options);
}
static cancelRequest(controlKey) {
// First look in global map
const ctrl = HttpClient.globalControllers.get(controlKey);
if (ctrl) {
ctrl.abort();
HttpClient.globalControllers.delete(controlKey);
return;
}
// Otherwise, search all instances
for (const inst of HttpClient.allInstances) {
const c = inst.controllers.get(controlKey);
if (c) {
c.abort();
inst.controllers.delete(controlKey);
break;
}
}
}
static cancelAllRequests() {
// Abort global controllers
HttpClient.globalControllers.forEach((c) => c.abort());
HttpClient.globalControllers.clear();
// Abort controllers in every instance
for (const inst of HttpClient.allInstances) {
inst._abortAllControllers();
}
}
}
exports.HttpClient = HttpClient;
HttpClient.globalHeaders = {};
HttpClient.globalControllers = new Map();
HttpClient.allInstances = new Set();
// Keep static default for backward compatibility
exports.default = HttpClient;