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