@zayne-labs/callapi
Version:
A lightweight wrapper over fetch with quality of life improvements like built-in request cancellation, retries, interceptors and more
965 lines (954 loc) • 36.2 kB
JavaScript
import { HTTPError, ValidationError, createCombinedSignal, createTimeoutSignal, defineEnum, deterministicHashFn, extraOptionDefaults, getBody, getFetchImpl, getHeaders, isArray, isFunction, isHTTPErrorInstance, isObject, isPlainObject, isReadableStream, isString, isValidationErrorInstance, requestOptionDefaults, splitBaseConfig, splitConfig, toArray, toQueryString, waitFor } from "./utils-iI2CAZIV.js";
//#region src/result.ts
const getResponseType = (response, parser) => ({
arrayBuffer: () => response.arrayBuffer(),
blob: () => response.blob(),
formData: () => response.formData(),
json: async () => {
const text = await response.text();
return parser(text);
},
stream: () => response.body,
text: () => response.text()
});
const resolveResponseData = (response, responseType, parser) => {
const selectedParser = parser ?? extraOptionDefaults().responseParser;
const selectedResponseType = responseType ?? extraOptionDefaults().responseType;
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) => {
const resultModeMap = {
all: () => details,
allWithException: () => resultModeMap.all(),
onlySuccess: () => details.data,
onlySuccessWithException: () => resultModeMap.onlySuccess()
};
return resultModeMap;
};
const resolveSuccessResult = (data, info) => {
const { response, resultMode } = info;
const details = {
data,
error: null,
response
};
const resultModeMap = getResultModeMap(details);
const successResult = resultModeMap[resultMode ?? "all"]();
return successResult;
};
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,
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 resultModeMap = getResultModeMap(errorDetails);
const generalErrorResult = resultModeMap[resultMode ?? "all"]();
return {
errorDetails,
generalErrorResult
};
};
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 getHookRegistries = () => {
return {
onError: /* @__PURE__ */ new Set(),
onRequest: /* @__PURE__ */ new Set(),
onRequestError: /* @__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()
};
};
const composeAllHooks = (hooksArray, hooksExecutionMode) => {
const mergedHook = 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 mergedHook;
};
const executeHooksInTryBlock = async (...hookResultsOrPromise) => {
await Promise.all(hookResultsOrPromise);
};
const executeHooksInCatchBlock = async (hookResultsOrPromise, hookInfo) => {
const { errorInfo, shouldThrowOnError } = hookInfo;
try {
await Promise.all(hookResultsOrPromise);
return null;
} catch (hookError) {
const { generalErrorResult: hookErrorResult } = resolveErrorResult(hookError, errorInfo);
if (shouldThrowOnError) throw hookError;
return hookErrorResult;
}
};
//#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 executeHooksInTryBlock(options.onRequestStream?.(requestStreamContext));
for await (const chunk of body) {
transferredBytes += chunk.byteLength;
totalBytes = Math.max(totalBytes, transferredBytes);
await executeHooksInTryBlock(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 executeHooksInTryBlock(options.onResponseStream?.(responseStreamContext));
for await (const chunk of body) {
transferredBytes += chunk.byteLength;
totalBytes = Math.max(totalBytes, transferredBytes);
await executeHooksInTryBlock(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 = () => {
const shouldHaveDedupeKey = resolvedDedupeStrategy === "cancel" || resolvedDedupeStrategy === "defer";
if (!shouldHaveDedupeKey) return null;
if (globalOptions.dedupeKey) {
const resolvedDedupeKey = isFunction(globalOptions.dedupeKey) ? globalOptions.dedupeKey(context) : globalOptions.dedupeKey;
return resolvedDedupeKey;
}
return `${globalOptions.fullURL}-${deterministicHashFn({
options: globalOptions,
request: globalRequest
})}`;
};
const dedupeKey = getDedupeKey();
const dedupeCacheScope = globalOptions.dedupeCacheScope ?? extraOptionDefaults().dedupeCacheScope;
const dedupeCacheScopeKey = globalOptions.dedupeCacheScopeKey ?? extraOptionDefaults().dedupeCacheScopeKey;
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;
/******
* == Add a small delay to the execution to ensure proper request deduplication when multiple requests with the same key start simultaneously.
* == This gives time for the cache to be updated with the previous request info before the next request checks it.
******/
if (dedupeKey !== null) await waitFor(.1);
const prevRequestInfo = $RequestInfoCacheOrNull?.get(dedupeKey);
const getAbortErrorMessage = () => {
if (globalOptions.dedupeKey) return `Duplicate request detected - Aborted previous request with key '${dedupeKey}' as a new request was initiated`;
return `Duplicate request detected - Aborted previous request to '${globalOptions.fullURL}' as a new request with identical options was initiated`;
};
const handleRequestCancelStrategy = () => {
const shouldCancelRequest = prevRequestInfo && resolvedDedupeStrategy === "cancel";
if (!shouldCancelRequest) return;
const message = getAbortErrorMessage();
const reason = new DOMException(message, "AbortError");
prevRequestInfo.controller.abort(reason);
return Promise.resolve();
};
const handleRequestDeferStrategy = async (deferContext) => {
const { options: localOptions, request: localRequest } = deferContext;
const fetchApi = getFetchImpl(localOptions.customFetchImpl);
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 streamableResponse = toStreamableResponse({
...streamableContext,
response: await responsePromise
});
return streamableResponse;
};
const removeDedupeKeyFromCache = () => {
$RequestInfoCacheOrNull?.delete(dedupeKey);
};
return {
getAbortErrorMessage,
handleRequestCancelStrategy,
handleRequestDeferStrategy,
removeDedupeKeyFromCache,
resolvedDedupeStrategy
};
};
//#endregion
//#region src/validation.ts
const handleValidatorFunction = async (validator, inputData) => {
try {
const result = await validator(inputData);
return {
issues: void 0,
value: result
};
} catch (error) {
return {
issues: toArray(error),
value: void 0
};
}
};
const standardSchemaParser = async (schema, inputData, response) => {
const result = isFunction(schema) ? await handleValidatorFunction(schema, inputData) : await schema["~standard"].validate(inputData);
if (result.issues) throw new ValidationError({
issues: result.issues,
response: response ?? null
}, { cause: result.issues });
return result.value;
};
const routeKeyMethods = defineEnum([
"delete",
"get",
"patch",
"post",
"put"
]);
const handleSchemaValidation = async (schema, validationOptions) => {
const { inputValue, response, schemaConfig } = validationOptions;
if (!schema || schemaConfig?.disableRuntimeValidation) return inputValue;
const validResult = await standardSchemaParser(schema, inputValue, response);
return validResult;
};
const extraOptionsToBeValidated = [
"meta",
"params",
"query"
];
const handleExtraOptionsValidation = async (validationOptions) => {
const { extraOptions, schema, schemaConfig } = validationOptions;
const validationResultArray = await Promise.all(extraOptionsToBeValidated.map((propertyKey) => handleSchemaValidation(schema?.[propertyKey], {
inputValue: extraOptions[propertyKey],
schemaConfig
})));
const validatedResultObject = {};
for (const [index, propertyKey] of extraOptionsToBeValidated.entries()) {
const validationResult = validationResultArray[index];
if (validationResult === void 0) continue;
validatedResultObject[propertyKey] = validationResult;
}
return validatedResultObject;
};
const requestOptionsToBeValidated = [
"body",
"headers",
"method"
];
const handleRequestOptionsValidation = async (validationOptions) => {
const { requestOptions, schema, schemaConfig } = validationOptions;
const validationResultArray = await Promise.all(requestOptionsToBeValidated.map((propertyKey) => handleSchemaValidation(schema?.[propertyKey], {
inputValue: requestOptions[propertyKey],
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, requestOptions } = validationOptions;
const { currentRouteSchema, resolvedSchema } = getResolvedSchema({
baseExtraOptions,
currentRouteSchemaKey,
extraOptions
});
const resolvedSchemaConfig = getResolvedSchemaConfig({
baseExtraOptions,
extraOptions
});
if (!currentRouteSchema && resolvedSchemaConfig?.strict === true) throw new ValidationError({
issues: [{ message: `Strict Mode - No schema found for route '${currentRouteSchemaKey}' ` }],
response: null
});
if (resolvedSchemaConfig?.disableRuntimeValidation) return {
extraOptionsValidationResult: null,
requestOptionsValidationResult: null,
resolvedSchema,
resolvedSchemaConfig,
shouldApplySchemaOutput: false
};
const [extraOptionsValidationResult, requestOptionsValidationResult] = await Promise.all([handleExtraOptionsValidation({
extraOptions,
schema: resolvedSchema,
schemaConfig: resolvedSchemaConfig
}), handleRequestOptionsValidation({
requestOptions,
schema: resolvedSchema,
schemaConfig: resolvedSchemaConfig
})]);
const shouldApplySchemaOutput = (Boolean(extraOptionsValidationResult) || Boolean(requestOptionsValidationResult)) && !resolvedSchemaConfig?.disableValidationOutputApplication;
return {
extraOptionsValidationResult,
requestOptionsValidationResult,
resolvedSchema,
resolvedSchemaConfig,
shouldApplySchemaOutput
};
};
const fallBackRouteSchemaKey = ".";
const getResolvedSchema = (context) => {
const { baseExtraOptions, currentRouteSchemaKey, extraOptions } = context;
const fallbackRouteSchema = baseExtraOptions.schema?.routes[fallBackRouteSchemaKey];
const currentRouteSchema = baseExtraOptions.schema?.routes[currentRouteSchemaKey];
const resolvedRouteSchema = {
...fallbackRouteSchema,
...currentRouteSchema
};
const resolvedSchema = isFunction(extraOptions.schema) ? extraOptions.schema({
baseSchemaRoutes: baseExtraOptions.schema?.routes ?? {},
currentRouteSchema: resolvedRouteSchema ?? {}
}) : extraOptions.schema ?? resolvedRouteSchema;
return {
currentRouteSchema,
resolvedSchema
};
};
const getResolvedSchemaConfig = (context) => {
const { baseExtraOptions, extraOptions } = context;
const resolvedSchemaConfig = isFunction(extraOptions.schemaConfig) ? extraOptions.schemaConfig({ baseSchemaConfig: baseExtraOptions.schema?.config ?? {} }) : extraOptions.schemaConfig ?? baseExtraOptions.schema?.config;
return resolvedSchemaConfig;
};
const getCurrentRouteSchemaKeyAndMainInitURL = (context) => {
const { baseExtraOptions, extraOptions, initURL } = context;
const schemaConfig = getResolvedSchemaConfig({
baseExtraOptions,
extraOptions
});
let currentRouteSchemaKey = initURL;
let mainInitURL = initURL;
if (schemaConfig?.prefix && currentRouteSchemaKey.startsWith(schemaConfig.prefix)) {
currentRouteSchemaKey = currentRouteSchemaKey.replace(schemaConfig.prefix, "");
mainInitURL = mainInitURL.replace(schemaConfig.prefix, schemaConfig.baseURL ?? "");
}
if (schemaConfig?.baseURL && currentRouteSchemaKey.startsWith(schemaConfig.baseURL)) currentRouteSchemaKey = currentRouteSchemaKey.replace(schemaConfig.baseURL, "");
return {
currentRouteSchemaKey,
mainInitURL
};
};
//#endregion
//#region src/plugins.ts
const getResolvedPlugins = (context) => {
const { baseConfig, options } = context;
const resolvedPlugins = isFunction(options.plugins) ? options.plugins({ basePlugins: baseConfig.plugins ?? [] }) : options.plugins ?? [];
return resolvedPlugins;
};
const initializePlugins = async (context) => {
const { baseConfig, config, initURL, options, request } = context;
const hookRegistries = getHookRegistries();
const hookRegistryKeyArray = Object.keys(hookRegistries);
const addMainHooks = () => {
for (const key of hookRegistryKeyArray) {
const overriddenHook = options[key];
const baseHook = baseConfig[key];
const instanceHook = config[key];
const mainHook = isArray(baseHook) && Boolean(instanceHook) ? [baseHook, instanceHook].flat() : overriddenHook;
if (!mainHook) continue;
hookRegistries[key].add(mainHook);
}
};
const addPluginHooks = (pluginHooks) => {
for (const key of hookRegistryKeyArray) {
const pluginHook = pluginHooks[key];
if (!pluginHook) continue;
hookRegistries[key].add(pluginHook);
}
};
const hookRegistrationOrder = options.hooksRegistrationOrder ?? extraOptionDefaults().hooksRegistrationOrder;
if (hookRegistrationOrder === "mainFirst") addMainHooks();
const { currentRouteSchemaKey, mainInitURL } = getCurrentRouteSchemaKeyAndMainInitURL({
baseExtraOptions: baseConfig,
extraOptions: config,
initURL
});
let resolvedCurrentRouteSchemaKey = currentRouteSchemaKey;
let resolvedInitURL = mainInitURL;
let resolvedOptions = options;
let resolvedRequestOptions = request;
const executePluginSetupFn = async (pluginSetupFn) => {
if (!pluginSetupFn) return;
const initResult = await pluginSetupFn({
baseConfig,
config,
initURL,
options,
request
});
if (!isPlainObject(initResult)) return;
const urlString = initResult.initURL?.toString();
if (isString(urlString)) {
const newResult = getCurrentRouteSchemaKeyAndMainInitURL({
baseExtraOptions: baseConfig,
extraOptions: config,
initURL: urlString
});
resolvedCurrentRouteSchemaKey = newResult.currentRouteSchemaKey;
resolvedInitURL = newResult.mainInitURL;
}
if (isPlainObject(initResult.request)) resolvedRequestOptions = initResult.request;
if (isPlainObject(initResult.options)) resolvedOptions = initResult.options;
};
const resolvedPlugins = getResolvedPlugins({
baseConfig,
options
});
for (const plugin of resolvedPlugins) {
await executePluginSetupFn(plugin.setup);
if (!plugin.hooks) continue;
addPluginHooks(plugin.hooks);
}
if (hookRegistrationOrder === "pluginsFirst") addMainHooks();
const resolvedHooks = {};
for (const [key, hookRegistry] of Object.entries(hookRegistries)) {
if (hookRegistry.size === 0) continue;
const flattenedHookArray = [...hookRegistry].flat();
if (flattenedHookArray.length === 0) continue;
const hooksExecutionMode = options.hooksExecutionMode ?? extraOptionDefaults().hooksExecutionMode;
const composedHook = composeAllHooks(flattenedHookArray, hooksExecutionMode);
resolvedHooks[key] = composedHook;
}
return {
resolvedCurrentRouteSchemaKey,
resolvedHooks,
resolvedInitURL,
resolvedOptions,
resolvedRequestOptions
};
};
//#endregion
//#region src/retry.ts
const getLinearDelay = (currentAttemptCount, options) => {
const retryDelay = options.retryDelay ?? options.retry?.delay;
const resolveRetryDelay = (isFunction(retryDelay) ? retryDelay(currentAttemptCount) : retryDelay) ?? extraOptionDefaults().retryDelay;
return resolveRetryDelay;
};
const getExponentialDelay = (currentAttemptCount, options) => {
const retryDelay = options.retryDelay ?? options.retry?.delay ?? extraOptionDefaults().retryDelay;
const resolvedRetryDelay = isFunction(retryDelay) ? retryDelay(currentAttemptCount) : retryDelay;
const maxDelay = options.retryMaxDelay ?? options.retry?.maxDelay ?? extraOptionDefaults().retryMaxDelay;
const exponentialDelay = resolvedRetryDelay * 2 ** currentAttemptCount;
return Math.min(exponentialDelay, maxDelay);
};
const createRetryStrategy = (ctx) => {
const { options } = ctx;
const currentAttemptCount = options["~retryAttemptCount"] ?? 1;
const retryStrategy = options.retryStrategy ?? options.retry?.strategy ?? 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 () => {
const retryCondition = options.retryCondition ?? options.retry?.condition ?? extraOptionDefaults().retryCondition;
const maximumRetryAttempts = options.retryAttempts ?? options.retry?.attempts ?? extraOptionDefaults().retryAttempts;
const customRetryCondition = await retryCondition(ctx);
const baseShouldRetry = currentAttemptCount <= maximumRetryAttempts && customRetryCondition;
if (!baseShouldRetry) return false;
const retryMethods = new Set(options.retryMethods ?? options.retry?.methods ?? extraOptionDefaults().retryMethods);
const includesMethod = retryMethods.has(ctx.request.method);
const retryStatusCodes = new Set(options.retryStatusCodes ?? options.retry?.statusCodes ?? []);
const includesStatusCodes = Boolean(ctx.response?.status) && (retryStatusCodes.size > 0 ? retryStatusCodes.has(ctx.response.status) : true);
const shouldRetry = includesMethod && includesStatusCodes;
return shouldRetry;
};
return {
currentAttemptCount,
getDelay,
shouldAttemptRetry
};
};
//#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 urlParts = newUrl.split(slash);
const matchedParamsArray = urlParts.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
* // Method extraction from prefixed routes
* extractMethodFromURL("@get/users"); // Returns: "get"
* extractMethodFromURL("@post/users"); // Returns: "post"
* extractMethodFromURL("@put/users/:id"); // Returns: "put"
* extractMethodFromURL("@delete/users/:id"); // Returns: "delete"
* extractMethodFromURL("@patch/users/:id"); // Returns: "patch"
*
* // No method modifier
* extractMethodFromURL("/users"); // Returns: undefined
* extractMethodFromURL("users"); // Returns: undefined
*
* // Invalid or unsupported methods
* extractMethodFromURL("@invalid/users"); // Returns: undefined
* extractMethodFromURL("@/users"); // Returns: undefined
*
* // Edge cases
* extractMethodFromURL(undefined); // Returns: undefined
* extractMethodFromURL(""); // Returns: undefined
* ```
*/
const extractMethodFromURL = (initURL) => {
if (!initURL?.startsWith("@")) return;
const method = initURL.split("@")[1]?.split("/")[0];
if (!method || !routeKeyMethods.includes(method)) return;
return method;
};
const getMethod = (ctx) => {
const { initURL, method } = ctx;
return method?.toUpperCase() ?? extractMethodFromURL(initURL)?.toUpperCase() ?? requestOptionDefaults().method;
};
const normalizeURL = (initURL) => {
const methodFromURL = extractMethodFromURL(initURL);
if (!methodFromURL) return initURL;
const normalizedURL = initURL.replace(`@${methodFromURL}/`, "/");
return normalizedURL;
};
const getFullAndNormalizedURL = (options) => {
const { baseURL, initURL, params, query } = options;
const normalizedInitURL = normalizeURL(initURL);
const urlWithMergedParams = mergeUrlWithParams(normalizedInitURL, params);
const urlWithMergedQueryAndParams = mergeUrlWithQuery(urlWithMergedParams, query);
const shouldPrependBaseURL = !urlWithMergedQueryAndParams.startsWith("http") && baseURL;
const fullURL = shouldPrependBaseURL ? `${baseURL}${urlWithMergedQueryAndParams}` : urlWithMergedQueryAndParams;
return {
fullURL,
normalizedInitURL
};
};
//#endregion
//#region src/createFetchClient.ts
const $GlobalRequestInfoCache = /* @__PURE__ */ new Map();
const createFetchClient = (initBaseConfig = {}) => {
const $LocalRequestInfoCache = /* @__PURE__ */ new Map();
const callApi$1 = async (...parameters) => {
const [initURLOrURLObject, initConfig = {}] = parameters;
const [fetchOptions, extraOptions] = splitConfig(initConfig);
const resolvedBaseConfig = isFunction(initBaseConfig) ? initBaseConfig({
initURL: initURLOrURLObject.toString(),
options: extraOptions,
request: fetchOptions
}) : initBaseConfig;
const baseConfig = resolvedBaseConfig;
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 = {
headers: {},
...baseFetchOptions,
...!shouldSkipAutoMergeForRequest && fetchOptions
};
const { resolvedCurrentRouteSchemaKey, resolvedHooks, resolvedInitURL, resolvedOptions, resolvedRequestOptions } = await initializePlugins({
baseConfig,
config,
initURL: initURLOrURLObject.toString(),
options: mergedExtraOptions,
request: mergedRequestOptions
});
const { fullURL, normalizedInitURL } = getFullAndNormalizedURL({
baseURL: resolvedOptions.baseURL,
initURL: resolvedInitURL,
params: resolvedOptions.params,
query: resolvedOptions.query
});
let options = {
...resolvedOptions,
...resolvedHooks,
fullURL,
initURL: resolvedInitURL,
initURLNormalized: normalizedInitURL
};
const newFetchController = new AbortController();
const timeoutSignal = options.timeout != null ? createTimeoutSignal(options.timeout) : null;
const combinedSignal = createCombinedSignal(resolvedRequestOptions.signal, timeoutSignal, newFetchController.signal);
let request = {
...resolvedRequestOptions,
signal: combinedSignal
};
const { getAbortErrorMessage, handleRequestCancelStrategy, handleRequestDeferStrategy, removeDedupeKeyFromCache, resolvedDedupeStrategy } = await createDedupeStrategy({
$GlobalRequestInfoCache,
$LocalRequestInfoCache,
baseConfig,
config,
newFetchController,
options,
request
});
try {
await handleRequestCancelStrategy();
await executeHooksInTryBlock(options.onRequest?.({
baseConfig,
config,
options,
request
}));
const { extraOptionsValidationResult, requestOptionsValidationResult, resolvedSchema, resolvedSchemaConfig, shouldApplySchemaOutput } = await handleConfigValidation({
baseExtraOptions,
currentRouteSchemaKey: resolvedCurrentRouteSchemaKey,
extraOptions: options,
requestOptions: request
});
if (shouldApplySchemaOutput) options = {
...options,
...extraOptionsValidationResult
};
const rawBody = shouldApplySchemaOutput ? requestOptionsValidationResult?.body : request.body;
const validBody = getBody({
body: rawBody,
bodySerializer: options.bodySerializer
});
const resolvedHeaders = isFunction(fetchOptions.headers) ? fetchOptions.headers({ baseHeaders: baseFetchOptions.headers ?? {} }) : fetchOptions.headers ?? baseFetchOptions.headers;
const validHeaders = await getHeaders({
auth: options.auth,
body: rawBody,
headers: shouldApplySchemaOutput ? requestOptionsValidationResult?.headers : resolvedHeaders
});
const validMethod = getMethod({
initURL: resolvedInitURL,
method: shouldApplySchemaOutput ? requestOptionsValidationResult?.method : request.method,
schemaConfig: resolvedSchemaConfig
});
request = {
...request,
...Boolean(validBody) && { body: validBody },
...Boolean(validHeaders) && { headers: validHeaders },
...Boolean(validMethod) && { method: validMethod }
};
const response = await handleRequestDeferStrategy({
options,
request
});
const shouldCloneResponse = resolvedDedupeStrategy === "defer" || options.cloneResponse;
if (!response.ok) {
const errorData = await resolveResponseData(shouldCloneResponse ? response.clone() : response, options.responseType, options.responseParser);
const validErrorData = await handleSchemaValidation(resolvedSchema?.errorData, {
inputValue: errorData,
response,
schemaConfig: resolvedSchemaConfig
});
throw new HTTPError({
defaultHTTPErrorMessage: options.defaultHTTPErrorMessage,
errorData: validErrorData,
response
}, { cause: validErrorData });
}
const successData = await resolveResponseData(shouldCloneResponse ? response.clone() : response, options.responseType, options.responseParser);
const validSuccessData = await handleSchemaValidation(resolvedSchema?.data, {
inputValue: successData,
response,
schemaConfig: resolvedSchemaConfig
});
const successContext = {
baseConfig,
config,
data: validSuccessData,
options,
request,
response
};
await executeHooksInTryBlock(options.onSuccess?.(successContext), options.onResponse?.({
...successContext,
error: null
}));
const successResult = resolveSuccessResult(successContext.data, {
response: successContext.response,
resultMode: options.resultMode
});
return successResult;
} catch (error) {
const errorInfo = {
cloneResponse: options.cloneResponse,
resultMode: options.resultMode
};
const { errorDetails, generalErrorResult } = resolveErrorResult(error, errorInfo);
const errorContext = {
baseConfig,
config,
error: errorDetails.error,
options,
request,
response: errorDetails.response
};
const shouldThrowOnError = isFunction(options.throwOnError) ? options.throwOnError(errorContext) : options.throwOnError;
const hookInfo = {
errorInfo,
shouldThrowOnError
};
const handleRetryOrGetErrorResult = async () => {
const { currentAttemptCount, getDelay, shouldAttemptRetry } = createRetryStrategy(errorContext);
const shouldRetry = !combinedSignal.aborted && await shouldAttemptRetry();
if (shouldRetry) {
const retryContext = {
...errorContext,
retryAttemptCount: currentAttemptCount
};
const hookError$1 = await executeHooksInCatchBlock([options.onRetry?.(retryContext)], hookInfo);
if (hookError$1) return hookError$1;
const delay = getDelay();
await waitFor(delay);
const updatedOptions = {
...config,
"~retryAttemptCount": currentAttemptCount + 1
};
return callApi$1(initURLOrURLObject, updatedOptions);
}
if (shouldThrowOnError) throw error;
return generalErrorResult;
};
if (isValidationErrorInstance(error)) {
const hookError$1 = await executeHooksInCatchBlock([
options.onValidationError?.(errorContext),
options.onRequestError?.(errorContext),
options.onError?.(errorContext)
], hookInfo);
return hookError$1 ?? await handleRetryOrGetErrorResult();
}
if (isHTTPErrorInstance(error)) {
const hookError$1 = await executeHooksInCatchBlock([
options.onResponseError?.(errorContext),
options.onError?.(errorContext),
options.onResponse?.({
...errorContext,
data: null
})
], hookInfo);
return hookError$1 ?? 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);
}
const hookError = await executeHooksInCatchBlock([options.onRequestError?.(errorContext), options.onError?.(errorContext)], hookInfo);
return hookError ?? getCustomizedErrorResult(await handleRetryOrGetErrorResult(), { message });
} finally {
removeDedupeKeyFromCache();
}
};
return callApi$1;
};
const callApi = createFetchClient();
//#endregion
//#region src/defineHelpers.ts
const defineSchema = (routes, config) => {
return {
config: defineSchemaConfig(config),
routes: defineSchemaRoutes(routes)
};
};
const defineSchemaConfig = (config) => {
return config;
};
const defineSchemaRoutes = (routes) => {
return routes;
};
const definePlugin = (plugin) => {
return plugin;
};
const defineBaseConfig = (baseConfig) => {
return baseConfig;
};
const defineParameters = (...parameters) => {
return parameters;
};
//#endregion
export { HTTPError, ValidationError, callApi, createFetchClient, defineBaseConfig, defineParameters, definePlugin, defineSchema, defineSchemaConfig, defineSchemaRoutes, fallBackRouteSchemaKey };
//# sourceMappingURL=index.js.map