UNPKG

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
"use strict"; 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;