UNPKG

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