UNPKG

@zayne-labs/callapi

Version:

A lightweight wrapper over fetch with quality of life improvements like built-in request cancellation, retries, interceptors and more

1,215 lines (1,200 loc) 46.5 kB
import { n as requestOptionDefaults, r as defineEnum, t as extraOptionDefaults } from "./defaults-B-dOt2Dd.js"; import { C as isReadableStream, E as isValidJsonString, S as isQueryString, T as isString, _ as isBoolean, a as isValidationErrorInstance, b as isPlainObject, f as HTTPError, g as isArray, h as toQueryString, n as isHTTPErrorInstance, p as ValidationError, v as isFunction, w as isSerializableObject, x as isPromise, y as isObject } from "./external-DXaCWLPN.js"; import { n as fetchSpecificKeys, t as fallBackRouteSchemaKey } from "./validation-DbbofkNi.js"; //#region src/auth.ts const resolveAuthValue = (value) => isFunction(value) ? value() : value; const getAuthHeader = async (auth) => { if (auth === void 0) return; if (isPromise(auth) || isFunction(auth) || !isObject(auth)) { const authValue = await resolveAuthValue(auth); if (authValue === void 0) return; return { Authorization: `Bearer ${authValue}` }; } switch (auth.type) { case "Basic": { const [username, password] = await Promise.all([resolveAuthValue(auth.username), resolveAuthValue(auth.password)]); if (username === void 0 || password === void 0) return; return { Authorization: `Basic ${globalThis.btoa(`${username}:${password}`)}` }; } case "Bearer": { const value = await resolveAuthValue(auth.value); if (value === void 0) return; return { Authorization: `Bearer ${value}` }; } case "Custom": { const [prefix, value] = await Promise.all([resolveAuthValue(auth.prefix), resolveAuthValue(auth.value)]); if (value === void 0) return; return { Authorization: `${prefix} ${value}` }; } case "Token": { const value = await resolveAuthValue(auth.value); if (value === void 0) return; return { Authorization: `Token ${value}` }; } default: return; } }; //#endregion //#region src/validation.ts const handleValidatorFunction = async (validator, inputData) => { try { return { issues: void 0, value: await validator(inputData) }; } catch (error) { return { issues: toArray(error), value: void 0 }; } }; const standardSchemaParser = async (fullSchema, schemaName, options) => { const { inputValue, response } = options; const schema = fullSchema?.[schemaName]; if (!schema) return inputValue; const result = isFunction(schema) ? await handleValidatorFunction(schema, inputValue) : await schema["~standard"].validate(inputValue); if (result.issues) throw new ValidationError({ issueCause: schemaName, issues: result.issues, response: response ?? null }); return result.value; }; const routeKeyMethods = defineEnum([ "delete", "get", "patch", "post", "put" ]); const handleSchemaValidation = async (fullSchema, schemaName, validationOptions) => { const { inputValue, response, schemaConfig } = validationOptions; const disableRuntimeValidationBooleanObject = isObject(schemaConfig?.disableRuntimeValidation) ? schemaConfig.disableRuntimeValidation : {}; if (schemaConfig?.disableRuntimeValidation === true || disableRuntimeValidationBooleanObject[schemaName] === true) return inputValue; const validResult = await standardSchemaParser(fullSchema, schemaName, { inputValue, response }); const disableResultApplicationBooleanObject = isObject(schemaConfig?.disableRuntimeValidationTransform) ? schemaConfig.disableRuntimeValidationTransform : {}; if (schemaConfig?.disableRuntimeValidationTransform === true || disableResultApplicationBooleanObject[schemaName] === true) return inputValue; return validResult; }; const extraOptionsToBeValidated = [ "meta", "params", "query", "auth" ]; const handleExtraOptionsValidation = async (validationOptions) => { const { options, schema, schemaConfig } = validationOptions; const validationResultArray = await Promise.all(extraOptionsToBeValidated.map((schemaName) => handleSchemaValidation(schema, schemaName, { inputValue: options[schemaName], schemaConfig }))); const validatedResultObject = {}; for (const [index, schemaName] of extraOptionsToBeValidated.entries()) { const validationResult = validationResultArray[index]; if (validationResult === void 0) continue; validatedResultObject[schemaName] = validationResult; } return validatedResultObject; }; const requestOptionsToBeValidated = [ "body", "headers", "method" ]; const handleRequestOptionsValidation = async (validationOptions) => { const { request, schema, schemaConfig } = validationOptions; const validationResultArray = await Promise.all(requestOptionsToBeValidated.map((schemaName) => { return handleSchemaValidation(schema, schemaName, { inputValue: request[schemaName], schemaConfig }); })); const validatedResultObject = {}; for (const [index, propertyKey] of requestOptionsToBeValidated.entries()) { const validationResult = validationResultArray[index]; if (validationResult === void 0) continue; validatedResultObject[propertyKey] = validationResult; } return validatedResultObject; }; const handleConfigValidation = async (validationOptions) => { const { baseExtraOptions, currentRouteSchemaKey, extraOptions, options, request } = validationOptions; const { currentRouteSchema, resolvedSchema } = getResolvedSchema({ baseExtraOptions, currentRouteSchemaKey, extraOptions }); const resolvedSchemaConfig = getResolvedSchemaConfig({ baseExtraOptions, extraOptions }); if (resolvedSchemaConfig?.strict === true && !currentRouteSchema) throw new ValidationError({ issueCause: "schemaConfig-(strict)", issues: [{ message: `Strict Mode - No schema found for route '${currentRouteSchemaKey}' ` }], response: null }); const [extraOptionsValidationResult, requestOptionsValidationResult] = await Promise.all([handleExtraOptionsValidation({ options, schema: resolvedSchema, schemaConfig: resolvedSchemaConfig }), handleRequestOptionsValidation({ request, schema: resolvedSchema, schemaConfig: resolvedSchemaConfig })]); return { extraOptionsValidationResult, requestOptionsValidationResult, resolvedSchema, resolvedSchemaConfig }; }; const getResolvedSchema = (context) => { const { baseExtraOptions, currentRouteSchemaKey, extraOptions } = context; const fallbackRouteSchema = baseExtraOptions.schema?.routes[fallBackRouteSchemaKey]; const currentRouteSchema = baseExtraOptions.schema?.routes[currentRouteSchemaKey]; const resolvedRouteSchema = { ...fallbackRouteSchema, ...currentRouteSchema }; return { currentRouteSchema, resolvedSchema: isFunction(extraOptions.schema) ? extraOptions.schema({ baseSchemaRoutes: baseExtraOptions.schema?.routes ?? {}, currentRouteSchema: resolvedRouteSchema ?? {}, currentRouteSchemaKey }) : extraOptions.schema ?? resolvedRouteSchema }; }; const getResolvedSchemaConfig = (context) => { const { baseExtraOptions, extraOptions } = context; return isFunction(extraOptions.schemaConfig) ? extraOptions.schemaConfig({ baseSchemaConfig: baseExtraOptions.schema?.config ?? {} }) : extraOptions.schemaConfig ?? baseExtraOptions.schema?.config; }; const removeLeadingSlash = (value) => value.startsWith("/") ? value.slice(1) : value; const getCurrentRouteSchemaKeyAndMainInitURL = (context) => { const { baseExtraOptions, extraOptions, initURL } = context; const schemaConfig = getResolvedSchemaConfig({ baseExtraOptions, extraOptions }); let currentRouteSchemaKey = initURL; let mainInitURL = initURL; const methodFromURL = extractMethodFromURL(initURL); const pathWithoutMethod = normalizeURL(initURL, { retainLeadingSlashForRelativeURLs: false }); const prefixWithoutLeadingSlash = schemaConfig?.prefix && removeLeadingSlash(schemaConfig.prefix); if (schemaConfig?.prefix && prefixWithoutLeadingSlash && pathWithoutMethod.startsWith(prefixWithoutLeadingSlash)) { const restOfPathWithoutPrefix = pathWithoutMethod.slice(prefixWithoutLeadingSlash.length); currentRouteSchemaKey = methodFromURL ? `${atSymbol}${methodFromURL}/${removeLeadingSlash(restOfPathWithoutPrefix)}` : restOfPathWithoutPrefix; const pathWithReplacedPrefix = pathWithoutMethod.replace(prefixWithoutLeadingSlash, schemaConfig.baseURL ?? ""); mainInitURL = methodFromURL ? `${atSymbol}${methodFromURL}/${removeLeadingSlash(pathWithReplacedPrefix)}` : pathWithReplacedPrefix; } if (schemaConfig?.baseURL && pathWithoutMethod.startsWith(schemaConfig.baseURL)) { const restOfPathWithoutBaseURL = pathWithoutMethod.slice(schemaConfig.baseURL.length); currentRouteSchemaKey = methodFromURL ? `${atSymbol}${methodFromURL}/${removeLeadingSlash(restOfPathWithoutBaseURL)}` : restOfPathWithoutBaseURL; } return { currentRouteSchemaKey, mainInitURL }; }; //#endregion //#region src/url.ts const slash = "/"; const colon = ":"; const openBrace = "{"; const closeBrace = "}"; const mergeUrlWithParams = (url, params) => { if (!params) return url; let newUrl = url; if (isArray(params)) { const matchedParamsArray = newUrl.split(slash).filter((part) => part.startsWith(colon) || part.startsWith(openBrace) && part.endsWith(closeBrace)); for (const [paramIndex, matchedParam] of matchedParamsArray.entries()) { const stringParamValue = String(params[paramIndex]); newUrl = newUrl.replace(matchedParam, stringParamValue); } return newUrl; } for (const [paramKey, paramValue] of Object.entries(params)) { const colonPattern = `${colon}${paramKey}`; const bracePattern = `${openBrace}${paramKey}${closeBrace}`; const stringValue = String(paramValue); newUrl = newUrl.replace(colonPattern, stringValue); newUrl = newUrl.replace(bracePattern, stringValue); } return newUrl; }; const questionMark = "?"; const ampersand = "&"; const mergeUrlWithQuery = (url, query) => { if (!query) return url; const queryString = toQueryString(query); if (queryString.length === 0) return url; if (url.endsWith(questionMark)) return `${url}${queryString}`; if (url.includes(questionMark)) return `${url}${ampersand}${queryString}`; return `${url}${questionMark}${queryString}`; }; /** * @description Extracts the HTTP method from method-prefixed route patterns. * * Analyzes URLs that start with method modifiers (e.g., "@get/", "@post/") and extracts * the HTTP method for use in API requests. This enables method specification directly * in route definitions. * * @param initURL - The URL string to analyze for method modifiers * @returns The extracted HTTP method (lowercase) if found, otherwise undefined * * @example * ```typescript * extractMethodFromURL("@get/users"); // Returns: "get" * extractMethodFromURL("@post/users"); // Returns: "post" * ``` */ const extractMethodFromURL = (initURL) => { if (!initURL?.startsWith("@")) return; const methodFromURL = routeKeyMethods.find((method) => initURL.startsWith(`${atSymbol}${method}${slash}`)); if (!methodFromURL) return; return methodFromURL; }; const atSymbol = "@"; const normalizeURL = (initURL, options = {}) => { const { retainLeadingSlashForRelativeURLs = true } = options; const methodFromURL = extractMethodFromURL(initURL); if (!methodFromURL) return initURL; return retainLeadingSlashForRelativeURLs && !initURL.includes("http") ? initURL.replace(`${atSymbol}${methodFromURL}`, "") : initURL.replace(`${atSymbol}${methodFromURL}${slash}`, ""); }; const getFullURL = (initURL, baseURL) => { if (!baseURL || initURL.startsWith("http")) return initURL; return initURL.length > 0 && !initURL.startsWith(slash) && !baseURL.endsWith(slash) ? `${baseURL}${slash}${initURL}` : `${baseURL}${initURL}`; }; const getFullAndNormalizedURL = (options) => { const { baseURL, initURL, params, query } = options; const normalizedInitURL = normalizeURL(initURL); const fullURL = getFullURL(mergeUrlWithQuery(mergeUrlWithParams(normalizedInitURL, params), query), baseURL); if (!URL.canParse(fullURL)) { const errorMessage = !baseURL ? `Invalid URL '${initURL}'. Are you passing a relative url to CallApi without setting the 'baseURL' option?` : `Invalid URL '${fullURL}'. Please validate that you are passing the correct url.`; console.error(errorMessage); } return { fullURL, normalizedInitURL }; }; //#endregion //#region src/utils/polyfills/combinedSignal.ts const createCombinedSignalPolyfill = (signals) => { const controller = new AbortController(); const handleAbort = (actualSignal) => { if (controller.signal.aborted) return; controller.abort(actualSignal.reason); }; for (const actualSignal of signals) { if (actualSignal.aborted) { handleAbort(actualSignal); break; } actualSignal.addEventListener("abort", () => handleAbort(actualSignal), { signal: controller.signal }); } return controller.signal; }; //#endregion //#region src/utils/polyfills/timeoutSignal.ts const createTimeoutSignalPolyfill = (milliseconds) => { const controller = new AbortController(); const reason = new DOMException("Request timed out", "TimeoutError"); const timeout = setTimeout(() => controller.abort(reason), milliseconds); controller.signal.addEventListener("abort", () => clearTimeout(timeout)); return controller.signal; }; //#endregion //#region src/utils/common.ts const omitKeys = (initialObject, keysToOmit) => { const updatedObject = {}; const keysToOmitSet = new Set(keysToOmit); for (const [key, value] of Object.entries(initialObject)) if (!keysToOmitSet.has(key)) updatedObject[key] = value; return updatedObject; }; const pickKeys = (initialObject, keysToPick) => { const updatedObject = {}; const keysToPickSet = new Set(keysToPick); for (const [key, value] of Object.entries(initialObject)) if (keysToPickSet.has(key)) updatedObject[key] = value; return updatedObject; }; const splitBaseConfig = (baseConfig) => [pickKeys(baseConfig, fetchSpecificKeys), omitKeys(baseConfig, fetchSpecificKeys)]; const splitConfig = (config) => [pickKeys(config, fetchSpecificKeys), omitKeys(config, fetchSpecificKeys)]; const objectifyHeaders = (headers) => { if (!headers) return {}; if (isPlainObject(headers)) return headers; return Object.fromEntries(headers); }; const getResolvedHeaders = (options) => { const { baseHeaders, headers } = options; return objectifyHeaders(isFunction(headers) ? headers({ baseHeaders: objectifyHeaders(baseHeaders) }) : headers ?? baseHeaders); }; const detectContentTypeHeader = (body) => { if (isQueryString(body)) return { "Content-Type": "application/x-www-form-urlencoded" }; if (isSerializableObject(body) || isValidJsonString(body)) return { Accept: "application/json", "Content-Type": "application/json" }; return null; }; const getHeaders = async (options) => { const { auth, body, resolvedHeaders } = options; const authHeaderObject = await getAuthHeader(auth); const resolvedHeadersObject = objectifyHeaders(resolvedHeaders); if (!(Object.hasOwn(resolvedHeadersObject, "Content-Type") || Object.hasOwn(resolvedHeadersObject, "content-type"))) { const contentTypeHeader = detectContentTypeHeader(body); contentTypeHeader && Object.assign(resolvedHeadersObject, contentTypeHeader); } return { ...authHeaderObject, ...resolvedHeadersObject }; }; const getMethod = (ctx) => { const { initURL, method } = ctx; return method?.toUpperCase() ?? extractMethodFromURL(initURL)?.toUpperCase() ?? requestOptionDefaults.method; }; const getBody = (options) => { const { body, bodySerializer, resolvedHeaders } = options; const existingContentType = new Headers(resolvedHeaders).get("content-type"); if (!existingContentType && isSerializableObject(body)) return (bodySerializer ?? extraOptionDefaults.bodySerializer)(body); if (existingContentType === "application/x-www-form-urlencoded" && isSerializableObject(body)) return toQueryString(body); return body; }; const getInitFetchImpl = (customFetchImpl) => { if (customFetchImpl) return customFetchImpl; if (typeof globalThis !== "undefined" && isFunction(globalThis.fetch)) return globalThis.fetch; throw new Error("No fetch implementation found"); }; const getFetchImpl = (context) => { const { customFetchImpl, fetchMiddleware, requestContext } = context; const initFetchImpl = getInitFetchImpl(customFetchImpl); return fetchMiddleware ? fetchMiddleware({ ...requestContext, fetchImpl: initFetchImpl }) : initFetchImpl; }; const waitFor = (delay) => { if (delay === 0) return; return new Promise((resolve) => setTimeout(resolve, delay)); }; const createCombinedSignal = (...signals) => { const cleanedSignals = signals.filter((signal) => signal != null); if (!("any" in AbortSignal)) return createCombinedSignalPolyfill(cleanedSignals); return AbortSignal.any(cleanedSignals); }; const createTimeoutSignal = (milliseconds) => { if (milliseconds == null) return null; if (!("timeout" in AbortSignal)) return createTimeoutSignalPolyfill(milliseconds); return AbortSignal.timeout(milliseconds); }; const deterministicHashFn = (value) => { return JSON.stringify(value, (_, val) => { if (!isPlainObject(val)) return val; const sortedKeys = Object.keys(val).toSorted(); const result = {}; for (const key of sortedKeys) result[key] = val[key]; return result; }); }; const toArray = (value) => isArray(value) ? value : [value]; //#endregion //#region src/result.ts const getResponseType = (response, parser) => ({ arrayBuffer: () => response.arrayBuffer(), blob: () => response.blob(), formData: () => response.formData(), json: async () => { return parser(await response.text()); }, stream: () => response.body, text: () => response.text() }); const textTypes = new Set([ "image/svg", "application/xml", "application/xhtml", "application/html" ]); const JSON_REGEX = /^application\/(?:[\w!#$%&*.^`~-]*\+)?json(;.+)?$/i; const detectResponseType = (response) => { const initContentType = response.headers.get("content-type"); if (!initContentType) return extraOptionDefaults.responseType; const contentType = initContentType.split(";")[0] ?? ""; if (JSON_REGEX.test(contentType)) return "json"; if (textTypes.has(contentType) || contentType.startsWith("text/")) return "text"; return "blob"; }; const resolveResponseData = (response, responseType, parser) => { const selectedParser = parser ?? extraOptionDefaults.responseParser; const selectedResponseType = responseType ?? detectResponseType(response); const RESPONSE_TYPE_LOOKUP = getResponseType(response, selectedParser); if (!Object.hasOwn(RESPONSE_TYPE_LOOKUP, selectedResponseType)) throw new Error(`Invalid response type: ${responseType}`); return RESPONSE_TYPE_LOOKUP[selectedResponseType](); }; const getResultModeMap = (details) => { return { all: () => details, onlyData: () => details.data, onlyResponse: () => details.response, withoutResponse: () => omitKeys(details, ["response"]) }; }; const resolveSuccessResult = (data, info) => { const { response, resultMode } = info; return getResultModeMap({ data, error: null, response })[resultMode ?? "all"](); }; const resolveErrorResult = (error, info) => { const { cloneResponse, message: customErrorMessage, resultMode } = info; let errorDetails = { data: null, error: { errorData: false, message: customErrorMessage ?? error.message, name: error.name, originalError: error }, response: null }; if (isValidationErrorInstance(error)) { const { errorData, message, response } = error; errorDetails = { data: null, error: { errorData, issueCause: error.issueCause, message, name: "ValidationError", originalError: error }, response }; } if (isHTTPErrorInstance(error)) { const { errorData, message, name, response } = error; errorDetails = { data: null, error: { errorData, message, name, originalError: error }, response: cloneResponse ? response.clone() : response }; } const errorResult = getResultModeMap(errorDetails)[resultMode ?? "all"](); return { errorDetails, errorResult }; }; const getCustomizedErrorResult = (errorResult, customErrorInfo) => { if (!errorResult) return null; const { message = errorResult.error.message } = customErrorInfo; return { ...errorResult, error: { ...errorResult.error, message } }; }; //#endregion //#region src/hooks.ts const getHookRegistriesAndKeys = () => { const hookRegistries = { onError: /* @__PURE__ */ new Set(), onRequest: /* @__PURE__ */ new Set(), onRequestError: /* @__PURE__ */ new Set(), onRequestReady: /* @__PURE__ */ new Set(), onRequestStream: /* @__PURE__ */ new Set(), onResponse: /* @__PURE__ */ new Set(), onResponseError: /* @__PURE__ */ new Set(), onResponseStream: /* @__PURE__ */ new Set(), onRetry: /* @__PURE__ */ new Set(), onSuccess: /* @__PURE__ */ new Set(), onValidationError: /* @__PURE__ */ new Set() }; return { hookRegistries, hookRegistryKeys: Object.keys(hookRegistries) }; }; const composeHooksFromArray = (hooksArray, hooksExecutionMode) => { const composedHook = async (ctx) => { switch (hooksExecutionMode) { case "parallel": await Promise.all(hooksArray.map((uniqueHook) => uniqueHook?.(ctx))); break; case "sequential": for (const hook of hooksArray) await hook?.(ctx); break; default: } }; return composedHook; }; const executeHooks = async (...hookResultsOrPromise) => { await Promise.all(hookResultsOrPromise); }; const executeHooksInCatchBlock = async (hookResultsOrPromise, hookInfo) => { const { errorInfo, shouldThrowOnError } = hookInfo; try { await Promise.all(hookResultsOrPromise); return null; } catch (hookError) { if (shouldThrowOnError) throw hookError; const { errorResult } = resolveErrorResult(hookError, errorInfo); return errorResult; } }; //#endregion //#region src/stream.ts const createProgressEvent = (options) => { const { chunk, totalBytes, transferredBytes } = options; return { chunk, progress: Math.round(transferredBytes / totalBytes * 100) || 0, totalBytes, transferredBytes }; }; const calculateTotalBytesFromBody = async (requestBody, existingTotalBytes) => { let totalBytes = existingTotalBytes; if (!requestBody) return totalBytes; for await (const chunk of requestBody) totalBytes += chunk.byteLength; return totalBytes; }; const toStreamableRequest = async (context) => { const { baseConfig, config, options, request } = context; if (!options.onRequestStream || !isReadableStream(request.body)) return request; const requestInstance = new Request(options.fullURL, { ...request, duplex: "half" }); const contentLength = requestInstance.headers.get("content-length"); let totalBytes = Number(contentLength ?? 0); const shouldForcefullyCalcStreamSize = isObject(options.forcefullyCalculateStreamSize) ? options.forcefullyCalculateStreamSize.request : options.forcefullyCalculateStreamSize; if (!contentLength && shouldForcefullyCalcStreamSize) totalBytes = await calculateTotalBytesFromBody(requestInstance.clone().body, totalBytes); let transferredBytes = 0; const stream = new ReadableStream({ start: async (controller) => { const body = requestInstance.body; if (!body) return; const requestStreamContext = { baseConfig, config, event: createProgressEvent({ chunk: new Uint8Array(), totalBytes, transferredBytes }), options, request, requestInstance }; await executeHooks(options.onRequestStream?.(requestStreamContext)); for await (const chunk of body) { transferredBytes += chunk.byteLength; totalBytes = Math.max(totalBytes, transferredBytes); await executeHooks(options.onRequestStream?.({ ...requestStreamContext, event: createProgressEvent({ chunk, totalBytes, transferredBytes }) })); controller.enqueue(chunk); } controller.close(); } }); return new Request(requestInstance, { body: stream, duplex: "half" }); }; const toStreamableResponse = async (context) => { const { baseConfig, config, options, request, response } = context; if (!options.onResponseStream || !response.body) return response; const contentLength = response.headers.get("content-length"); let totalBytes = Number(contentLength ?? 0); const shouldForceContentLengthCalc = isObject(options.forcefullyCalculateStreamSize) ? options.forcefullyCalculateStreamSize.response : options.forcefullyCalculateStreamSize; if (!contentLength && shouldForceContentLengthCalc) totalBytes = await calculateTotalBytesFromBody(response.clone().body, totalBytes); let transferredBytes = 0; const stream = new ReadableStream({ start: async (controller) => { const body = response.body; if (!body) return; const responseStreamContext = { baseConfig, config, event: createProgressEvent({ chunk: new Uint8Array(), totalBytes, transferredBytes }), options, request, response }; await executeHooks(options.onResponseStream?.(responseStreamContext)); for await (const chunk of body) { transferredBytes += chunk.byteLength; totalBytes = Math.max(totalBytes, transferredBytes); await executeHooks(options.onResponseStream?.({ ...responseStreamContext, event: createProgressEvent({ chunk, totalBytes, transferredBytes }) })); controller.enqueue(chunk); } controller.close(); } }); return new Response(stream, response); }; //#endregion //#region src/dedupe.ts const createDedupeStrategy = async (context) => { const { $GlobalRequestInfoCache: $GlobalRequestInfoCache$1, $LocalRequestInfoCache, baseConfig, config, newFetchController, options: globalOptions, request: globalRequest } = context; const dedupeStrategy = globalOptions.dedupeStrategy ?? extraOptionDefaults.dedupeStrategy; const resolvedDedupeStrategy = isFunction(dedupeStrategy) ? dedupeStrategy(context) : dedupeStrategy; const getDedupeKey = () => { if (!(resolvedDedupeStrategy === "cancel" || resolvedDedupeStrategy === "defer")) return null; const dedupeKey$1 = globalOptions.dedupeKey; const resolvedDedupeKey = isFunction(dedupeKey$1) ? dedupeKey$1(context) : dedupeKey$1; if (resolvedDedupeKey === void 0) return `${globalOptions.fullURL}-${deterministicHashFn({ options: globalOptions, request: globalRequest })}`; return resolvedDedupeKey; }; const getDedupeCacheScopeKey = () => { const dedupeCacheScopeKey$1 = globalOptions.dedupeCacheScopeKey; const resolvedDedupeCacheScopeKey = isFunction(dedupeCacheScopeKey$1) ? dedupeCacheScopeKey$1(context) : dedupeCacheScopeKey$1; if (resolvedDedupeCacheScopeKey === void 0) return extraOptionDefaults.dedupeCacheScopeKey; return resolvedDedupeCacheScopeKey; }; const dedupeKey = getDedupeKey(); const dedupeCacheScope = globalOptions.dedupeCacheScope ?? extraOptionDefaults.dedupeCacheScope; const dedupeCacheScopeKey = getDedupeCacheScopeKey(); if (dedupeCacheScope === "global" && !$GlobalRequestInfoCache$1.has(dedupeCacheScopeKey)) $GlobalRequestInfoCache$1.set(dedupeCacheScopeKey, /* @__PURE__ */ new Map()); const $RequestInfoCache = dedupeCacheScope === "global" ? $GlobalRequestInfoCache$1.get(dedupeCacheScopeKey) : $LocalRequestInfoCache; const $RequestInfoCacheOrNull = dedupeKey !== null ? $RequestInfoCache : null; /** * Force sequential execution of parallel requests to enable proper cache-based deduplication. * * Problem: When Promise.all([callApi(url), callApi(url)]) executes, both requests * start synchronously and reach this point before either can populate the cache. * * Why `await Promise.resolve()` fails: * - All microtasks in a batch resolve together at the next microtask checkpoint * - Both requests resume execution simultaneously after the await * - Both check `prevRequestInfo` at the same time → both see empty cache * - Both proceed to populate cache → deduplication fails * * Why `wait new Promise(()=> setTimeout(resolve, number))` works: * - Each setTimeout creates a separate task in the task queue * - Tasks execute sequentially, not simultaneously * - Request 1's task runs first: checks cache (empty) → continues → populates cache * - Request 2's task runs after: checks cache (populated) → uses cached promise * - Deduplication succeeds * * IMPORTANT: The delay must be non-zero. setTimeout(fn, 0) fails because JavaScript engines * may optimize zero-delay timers by batching them together, causing all requests to resume * simultaneously (same problem as microtasks). Any non-zero value (even 0.0000000001) forces * proper sequential task queue scheduling, ensuring each request gets its own task slot. */ if (dedupeKey !== null) await waitFor(.001); const prevRequestInfo = $RequestInfoCacheOrNull?.get(dedupeKey); const getAbortErrorMessage = () => { if (globalOptions.dedupeKey) return `Duplicate request detected - Aborted previous request with key '${dedupeKey}'`; return `Duplicate request aborted - Aborted previous request to '${globalOptions.fullURL}'`; }; const handleRequestCancelStrategy = () => { if (!(prevRequestInfo && resolvedDedupeStrategy === "cancel")) return; const message = getAbortErrorMessage(); const reason = new DOMException(message, "AbortError"); prevRequestInfo.controller.abort(reason); return Promise.resolve(); }; const handleRequestDeferStrategy = async (deferContext) => { const { fetchApi, options: localOptions, request: localRequest } = deferContext; const shouldUsePromiseFromCache = prevRequestInfo && resolvedDedupeStrategy === "defer"; const streamableContext = { baseConfig, config, options: localOptions, request: localRequest }; const streamableRequest = await toStreamableRequest(streamableContext); const responsePromise = shouldUsePromiseFromCache ? prevRequestInfo.responsePromise : fetchApi(localOptions.fullURL, streamableRequest); $RequestInfoCacheOrNull?.set(dedupeKey, { controller: newFetchController, responsePromise }); const response = await responsePromise; return toStreamableResponse({ ...streamableContext, response }); }; const removeDedupeKeyFromCache = () => { $RequestInfoCacheOrNull?.delete(dedupeKey); }; return { getAbortErrorMessage, handleRequestCancelStrategy, handleRequestDeferStrategy, removeDedupeKeyFromCache, resolvedDedupeStrategy }; }; //#endregion //#region src/middlewares.ts const getMiddlewareRegistriesAndKeys = () => { const middlewareRegistries = { fetchMiddleware: /* @__PURE__ */ new Set() }; return { middlewareRegistries, middlewareRegistryKeys: Object.keys(middlewareRegistries) }; }; const composeMiddlewaresFromArray = (middlewareArray) => { let composedMiddleware; for (const currentMiddleware of middlewareArray) { if (!currentMiddleware) continue; const previousMiddleware = composedMiddleware; if (!previousMiddleware) { composedMiddleware = currentMiddleware; continue; } composedMiddleware = (context) => { const prevFetchImpl = previousMiddleware(context); return currentMiddleware({ ...context, fetchImpl: prevFetchImpl }); }; } return composedMiddleware; }; //#endregion //#region src/plugins.ts const getResolvedPlugins = (context) => { const { baseConfig, options } = context; return isFunction(options.plugins) ? options.plugins({ basePlugins: baseConfig.plugins ?? [] }) : options.plugins ?? []; }; const initializePlugins = async (setupContext) => { const { baseConfig, config, initURL, options, request } = setupContext; const { addMainHooks, addMainMiddlewares, addPluginHooks, addPluginMiddlewares, getResolvedHooks, getResolvedMiddlewares } = setupHooksAndMiddlewares({ baseConfig, config, options }); const initURLResult = getCurrentRouteSchemaKeyAndMainInitURL({ baseExtraOptions: baseConfig, extraOptions: config, initURL }); let resolvedCurrentRouteSchemaKey = initURLResult.currentRouteSchemaKey; let resolvedInitURL = initURLResult.mainInitURL; const resolvedOptions = options; const resolvedRequest = Object.assign(request, { headers: getResolvedHeaders({ baseHeaders: baseConfig.headers, headers: config.headers }), method: getMethod({ initURL: resolvedInitURL, method: request.method }) }); const executePluginSetupFn = async (pluginSetup) => { if (!pluginSetup) return; const initResult = await pluginSetup(setupContext); if (!initResult) return; const urlString = initResult.initURL?.toString(); if (isString(urlString)) { const newURLResult = getCurrentRouteSchemaKeyAndMainInitURL({ baseExtraOptions: baseConfig, extraOptions: config, initURL: urlString }); resolvedCurrentRouteSchemaKey = newURLResult.currentRouteSchemaKey; resolvedInitURL = newURLResult.mainInitURL; } if (initResult.request) Object.assign(resolvedRequest, initResult.request); if (initResult.options) Object.assign(resolvedOptions, initResult.options); }; const resolvedPlugins = getResolvedPlugins({ baseConfig, options }); for (const plugin of resolvedPlugins) { const [, pluginHooks, pluginMiddlewares] = await Promise.all([ executePluginSetupFn(plugin.setup), isFunction(plugin.hooks) ? plugin.hooks(setupContext) : plugin.hooks, isFunction(plugin.middlewares) ? plugin.middlewares(setupContext) : plugin.middlewares ]); pluginHooks && addPluginHooks(pluginHooks); pluginMiddlewares && addPluginMiddlewares(pluginMiddlewares); } addMainHooks(); addMainMiddlewares(); const resolvedHooks = getResolvedHooks(); const resolvedMiddlewares = getResolvedMiddlewares(); return { resolvedCurrentRouteSchemaKey, resolvedHooks, resolvedInitURL, resolvedMiddlewares, resolvedOptions, resolvedRequest }; }; const setupHooksAndMiddlewares = (context) => { const { baseConfig, config, options } = context; const { hookRegistries, hookRegistryKeys } = getHookRegistriesAndKeys(); const { middlewareRegistries, middlewareRegistryKeys } = getMiddlewareRegistriesAndKeys(); const addMainHooks = () => { for (const hookName of hookRegistryKeys) { const overriddenHook = options[hookName]; const baseHook = baseConfig[hookName]; const instanceHook = config[hookName]; const mainHook = isArray(baseHook) && instanceHook ? [baseHook, instanceHook].flat() : overriddenHook; mainHook && hookRegistries[hookName].add(mainHook); } }; const addPluginHooks = (pluginHooks) => { for (const hookName of hookRegistryKeys) { const pluginHook = pluginHooks[hookName]; pluginHook && hookRegistries[hookName].add(pluginHook); } }; const addMainMiddlewares = () => { for (const middlewareName of middlewareRegistryKeys) { const baseMiddleware = baseConfig[middlewareName]; const instanceMiddleware = config[middlewareName]; baseMiddleware && middlewareRegistries[middlewareName].add(baseMiddleware); instanceMiddleware && middlewareRegistries[middlewareName].add(instanceMiddleware); } }; const addPluginMiddlewares = (pluginMiddlewares) => { for (const middlewareName of middlewareRegistryKeys) { const pluginMiddleware = pluginMiddlewares[middlewareName]; if (!pluginMiddleware) continue; middlewareRegistries[middlewareName].add(pluginMiddleware); } }; const getResolvedHooks = () => { const resolvedHooks = {}; for (const [hookName, hookRegistry] of Object.entries(hookRegistries)) { if (hookRegistry.size === 0) continue; const flattenedHookArray = [...hookRegistry].flat(); if (flattenedHookArray.length === 0) continue; resolvedHooks[hookName] = composeHooksFromArray(flattenedHookArray, options.hooksExecutionMode ?? extraOptionDefaults.hooksExecutionMode); } return resolvedHooks; }; const getResolvedMiddlewares = () => { const resolvedMiddlewares = {}; for (const [middlewareName, middlewareRegistry] of Object.entries(middlewareRegistries)) { if (middlewareRegistry.size === 0) continue; const middlewareArray = [...middlewareRegistry]; if (middlewareArray.length === 0) continue; resolvedMiddlewares[middlewareName] = composeMiddlewaresFromArray(middlewareArray); } return resolvedMiddlewares; }; return { addMainHooks, addMainMiddlewares, addPluginHooks, addPluginMiddlewares, getResolvedHooks, getResolvedMiddlewares }; }; //#endregion //#region src/retry.ts const getLinearDelay = (currentAttemptCount, options) => { const retryDelay = options.retryDelay ?? extraOptionDefaults.retryDelay; return isFunction(retryDelay) ? retryDelay(currentAttemptCount) : retryDelay; }; const getExponentialDelay = (currentAttemptCount, options) => { const retryDelay = options.retryDelay ?? extraOptionDefaults.retryDelay; const resolvedRetryDelay = isFunction(retryDelay) ? retryDelay(currentAttemptCount) : retryDelay; const maxDelay = options.retryMaxDelay ?? extraOptionDefaults.retryMaxDelay; const exponentialDelay = resolvedRetryDelay * 2 ** currentAttemptCount; return Math.min(exponentialDelay, maxDelay); }; const createRetryManager = (ctx) => { const { options, request } = ctx; const currentAttemptCount = options["~retryAttemptCount"] ?? 1; const retryStrategy = options.retryStrategy ?? extraOptionDefaults.retryStrategy; const getDelay = () => { switch (retryStrategy) { case "exponential": return getExponentialDelay(currentAttemptCount, options); case "linear": return getLinearDelay(currentAttemptCount, options); default: throw new Error(`Invalid retry strategy: ${String(retryStrategy)}`); } }; const shouldAttemptRetry = async () => { if (isBoolean(request.signal) && request.signal.aborted) return false; const retryCondition = options.retryCondition ?? extraOptionDefaults.retryCondition; const maximumRetryAttempts = options.retryAttempts ?? extraOptionDefaults.retryAttempts; const customRetryCondition = await retryCondition(ctx); if (!(currentAttemptCount <= maximumRetryAttempts && customRetryCondition)) return false; const retryMethods = new Set(options.retryMethods ?? extraOptionDefaults.retryMethods); const includesMethod = isString(ctx.request.method) && retryMethods.size > 0 ? retryMethods.has(ctx.request.method) : true; const retryStatusCodes = new Set(options.retryStatusCodes ?? extraOptionDefaults.retryStatusCodes); const includesStatusCodes = ctx.response != null && retryStatusCodes.size > 0 ? retryStatusCodes.has(ctx.response.status) : true; return includesMethod && includesStatusCodes; }; const handleRetry = async (context) => { const { callApi: callApi$1, callApiArgs, errorContext, hookInfo } = context; const retryContext = { ...errorContext, retryAttemptCount: currentAttemptCount }; const hookError = await executeHooksInCatchBlock([options.onRetry?.(retryContext)], hookInfo); if (hookError) return hookError; await waitFor(getDelay()); const updatedConfig = { ...callApiArgs.config, "~retryAttemptCount": currentAttemptCount + 1 }; return callApi$1(callApiArgs.initURL, updatedConfig); }; return { handleRetry, shouldAttemptRetry }; }; //#endregion //#region src/createFetchClient.ts const $GlobalRequestInfoCache = /* @__PURE__ */ new Map(); const createFetchClientWithContext = () => { const createFetchClient$1 = (initBaseConfig = {}) => { const $LocalRequestInfoCache = /* @__PURE__ */ new Map(); const callApi$1 = async (initURL, initConfig = {}) => { const [fetchOptions, extraOptions] = splitConfig(initConfig); const baseConfig = isFunction(initBaseConfig) ? initBaseConfig({ initURL: initURL.toString(), options: extraOptions, request: fetchOptions }) : initBaseConfig; const config = initConfig; const [baseFetchOptions, baseExtraOptions] = splitBaseConfig(baseConfig); const shouldSkipAutoMergeForOptions = baseExtraOptions.skipAutoMergeFor === "all" || baseExtraOptions.skipAutoMergeFor === "options"; const shouldSkipAutoMergeForRequest = baseExtraOptions.skipAutoMergeFor === "all" || baseExtraOptions.skipAutoMergeFor === "request"; const mergedExtraOptions = { ...baseExtraOptions, ...!shouldSkipAutoMergeForOptions && extraOptions }; const mergedRequestOptions = { ...baseFetchOptions, ...!shouldSkipAutoMergeForRequest && fetchOptions }; const { resolvedCurrentRouteSchemaKey, resolvedHooks, resolvedInitURL, resolvedMiddlewares, resolvedOptions, resolvedRequest } = await initializePlugins({ baseConfig, config, initURL: initURL.toString(), options: mergedExtraOptions, request: mergedRequestOptions }); const { fullURL, normalizedInitURL } = getFullAndNormalizedURL({ baseURL: resolvedOptions.baseURL, initURL: resolvedInitURL, params: resolvedOptions.params, query: resolvedOptions.query }); const options = { ...resolvedOptions, ...resolvedHooks, ...resolvedMiddlewares, fullURL, initURL: resolvedInitURL, initURLNormalized: normalizedInitURL }; const newFetchController = new AbortController(); const combinedSignal = createCombinedSignal(createTimeoutSignal(options.timeout), resolvedRequest.signal, newFetchController.signal); const request = { ...resolvedRequest, signal: combinedSignal }; const { getAbortErrorMessage, handleRequestCancelStrategy, handleRequestDeferStrategy, removeDedupeKeyFromCache, resolvedDedupeStrategy } = await createDedupeStrategy({ $GlobalRequestInfoCache, $LocalRequestInfoCache, baseConfig, config, newFetchController, options, request }); try { await handleRequestCancelStrategy(); await executeHooks(options.onRequest?.({ baseConfig, config, options, request })); const { extraOptionsValidationResult, requestOptionsValidationResult, resolvedSchema, resolvedSchemaConfig } = await handleConfigValidation({ baseExtraOptions, currentRouteSchemaKey: resolvedCurrentRouteSchemaKey, extraOptions, options, request }); Object.assign(options, extraOptionsValidationResult); Object.assign(request, { body: getBody({ body: requestOptionsValidationResult.body, bodySerializer: options.bodySerializer, resolvedHeaders: requestOptionsValidationResult.headers }), headers: await getHeaders({ auth: options.auth, body: requestOptionsValidationResult.body, resolvedHeaders: requestOptionsValidationResult.headers }), method: getMethod({ initURL: resolvedInitURL, method: requestOptionsValidationResult.method }) }); const readyRequestContext = { baseConfig, config, options, request }; await executeHooks(options.onRequestReady?.(readyRequestContext)); const response = await handleRequestDeferStrategy({ fetchApi: getFetchImpl({ customFetchImpl: options.customFetchImpl, fetchMiddleware: options.fetchMiddleware, requestContext: readyRequestContext }), options, request }); const shouldCloneResponse = resolvedDedupeStrategy === "defer" || options.cloneResponse; if (!response.ok) { const validErrorData = await handleSchemaValidation(resolvedSchema, "errorData", { inputValue: await resolveResponseData(shouldCloneResponse ? response.clone() : response, options.responseType, options.responseParser), response, schemaConfig: resolvedSchemaConfig }); throw new HTTPError({ defaultHTTPErrorMessage: options.defaultHTTPErrorMessage, errorData: validErrorData, response }, { cause: validErrorData }); } const successContext = { baseConfig, config, data: await handleSchemaValidation(resolvedSchema, "data", { inputValue: await resolveResponseData(shouldCloneResponse ? response.clone() : response, options.responseType, options.responseParser), response, schemaConfig: resolvedSchemaConfig }), options, request, response }; await executeHooks(options.onSuccess?.(successContext), options.onResponse?.({ ...successContext, error: null })); return resolveSuccessResult(successContext.data, { response: successContext.response, resultMode: options.resultMode }); } catch (error) { const errorInfo = { cloneResponse: options.cloneResponse, resultMode: options.resultMode }; const { errorDetails, errorResult } = resolveErrorResult(error, errorInfo); const errorContext = { baseConfig, config, error: errorDetails.error, options, request, response: errorDetails.response }; const responseContext = Boolean(errorContext.response) ? { ...errorContext, data: null } : null; const shouldThrowOnError = Boolean(isFunction(options.throwOnError) ? options.throwOnError(errorContext) : options.throwOnError); const hookInfo = { errorInfo, shouldThrowOnError }; const { handleRetry, shouldAttemptRetry } = createRetryManager(errorContext); const handleRetryOrGetErrorResult = async () => { if (await shouldAttemptRetry()) return handleRetry({ callApi: callApi$1, callApiArgs: { config, initURL }, errorContext, hookInfo }); if (shouldThrowOnError) throw error; return errorResult; }; if (isValidationErrorInstance(error)) return await executeHooksInCatchBlock([ responseContext ? options.onResponse?.(responseContext) : null, options.onValidationError?.(errorContext), options.onError?.(errorContext) ], hookInfo) ?? await handleRetryOrGetErrorResult(); if (isHTTPErrorInstance(error)) return await executeHooksInCatchBlock([ responseContext ? options.onResponse?.(responseContext) : null, options.onResponseError?.(errorContext), options.onError?.(errorContext) ], hookInfo) ?? await handleRetryOrGetErrorResult(); let message = error?.message; if (error instanceof DOMException && error.name === "AbortError") { message = getAbortErrorMessage(); !shouldThrowOnError && console.error(`${error.name}:`, message); } if (error instanceof DOMException && error.name === "TimeoutError") { message = `Request timed out after ${options.timeout}ms`; !shouldThrowOnError && console.error(`${error.name}:`, message); } return await executeHooksInCatchBlock([ responseContext ? options.onResponse?.(responseContext) : null, options.onRequestError?.(errorContext), options.onError?.(errorContext) ], hookInfo) ?? getCustomizedErrorResult(await handleRetryOrGetErrorResult(), { message }); } finally { removeDedupeKeyFromCache(); } }; return callApi$1; }; return createFetchClient$1; }; const createFetchClient = createFetchClientWithContext(); const callApi = createFetchClient(); //#endregion export { callApi, createFetchClient, createFetchClientWithContext }; //# sourceMappingURL=index.js.map