Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/api-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,10 @@ function createApiFetcher<
* @param endpointName Endpoint Name
* @returns {Promise}
*/
function handleNonImplemented(endpointName: string): Promise<null> {
async function handleNonImplemented(endpointName: string): Promise<null> {
console.error('Add ' + endpointName + " to 'endpoints'.");

return Promise.resolve(null);
return null;
}

const apiHandler: ApiHandlerDefaultMethods<EndpointTypes> = {
Expand Down Expand Up @@ -98,7 +98,7 @@ function createApiFetcher<
// Any scheme counts here, as e.g. "http:\\host" or "http:host" can also point to another host
const mergedConfig = /^[a-z][a-z\d+\-.]*:/i.test(resolvedUrl)
? // Merge endpoints configs for absolute URLs only if urls match
endpointConfig?.url === url
endpointConfig && endpointConfig.url === url
? mergeConfigs(_endpointConfig, requestConfig)
: requestConfig
: mergeConfigs(mergeConfigs(config, _endpointConfig), requestConfig);
Expand Down
118 changes: 42 additions & 76 deletions src/cache-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,43 +42,20 @@ const CACHE_KEY_NEEDS_SANITIZE = /[^\w\-_|/:@.?=&~%#]/; // Non-global for fast t

/**
* Headers that may affect HTTP response content and should be included in cache key generation.
* All header names must be lowercase to match normalized request headers.
* Header names are matched case-insensitively.
*
* - Content negotiation: accept (response format, e.g. JSON or HTML), accept-language (localization), accept-encoding (compression)
* - Authentication: authorization (access to protected resources)
* - Request body metadata: content-type (how the request body is interpreted)
* - Optional: referer (may influence behavior in some APIs), origin (CORS or tenant-specific APIs),
* user-agent (if the server returns client-specific content), cookie (session-based responses; can fragment the cache heavily)
* - Custom headers that may affect response content: x-api-key (token-based access), x-requested-with (AJAX requests),
* x-client-id (per-client/partner identity), x-tenant-id (multi-tenant segmentation), x-user-id (explicit user context),
* x-app-version (version-specific behavior), x-feature-flag (feature rollout), x-device-id (per device/app instance),
* x-platform (e.g. 'ios', 'android', 'web'), x-session-id (if it affects the response directly), x-locale (like accept-language)
*/
const CACHE_KEY_HEADER_WHITELIST = new Set([
// Content negotiation
'accept', // Affects response format (e.g. JSON, HTML)
'accept-language', // Affects localization of the response
'accept-encoding', // Affects response compression (e.g. gzip, br)

// Authentication
'authorization', // Affects access to protected resources

// Request body metadata
'content-type', // Affects how the request body is interpreted

// Optional headers
'referer', // May influence behavior in some APIs
'origin', // Relevant in CORS or tenant-specific APIs
'user-agent', // Included only for reason if server returns client-specific content

// Cookies — only if server uses session-based responses
'cookie', // Can fragment cache heavily; use only if necessary

// Custom headers that may affect response content
'x-api-key', // Token-based access, often affects authorization
'x-requested-with', // AJAX requests (used historically for distinguishing frontend calls)
'x-client-id', // Per-client/partner identity; often used in multi-tenant APIs
'x-tenant-id', // Multi-tenant segmentation; often changes response per tenant
'x-user-id', // Explicit user context (less common, but may exist)

'x-app-version', // Used for version-specific behavior (e.g. mobile apps)
'x-feature-flag', // Controls feature rollout behavior server-side
'x-device-id', // Used when response varies per device/app instance
'x-platform', // e.g. 'ios', 'android', 'web' — used in apps that serve different content

'x-session-id', // Only if backend uses it to affect the response directly (rare)
'x-locale', // Sometimes used in addition to or instead of `accept-language`
]);
const CACHE_KEY_HEADERS =
/^(accept(-language|-encoding)?|authorization|content-type|referer|origin|user-agent|cookie|x-(api-key|requested-with|client-id|tenant-id|user-id|app-version|feature-flag|device-id|platform|session-id|locale))$/i;

/**
* Generates a unique cache key for a given URL and fetch options, ensuring that key factors
Expand Down Expand Up @@ -150,25 +127,27 @@ export function generateCacheKey(

let str = '';
for (let i = 0; i < len; ++i) {
if (CACHE_KEY_HEADER_WHITELIST.has(keys[i].toLowerCase())) {
if (CACHE_KEY_HEADERS.test(keys[i])) {
str += keys[i] + ':' + obj[keys[i]] + ';';
}
}

headersString = hash(str);
}

// Concatenate all key parts into a cache key string
// Template literals are apparently slower
const cacheStr =
method +
DELIMITER +
url +
DELIMITER +
credentials +
DELIMITER +
headersString;

// For GET requests, return early with shorter cache key
if (method === GET) {
const cacheStr =
method +
DELIMITER +
url +
DELIMITER +
credentials +
DELIMITER +
headersString;

return sanitizeCacheKey(cacheStr);
}

Expand Down Expand Up @@ -204,20 +183,7 @@ export function generateCacheKey(
}
}

// Concatenate all key parts into a cache key string
// Template literals are apparently slower
const cacheStr =
method +
DELIMITER +
url +
DELIMITER +
credentials +
DELIMITER +
headersString +
DELIMITER +
bodyString;

return sanitizeCacheKey(cacheStr);
return sanitizeCacheKey(cacheStr + DELIMITER + bodyString);
}

const objectIds = new WeakMap<object, number>();
Expand Down Expand Up @@ -341,16 +307,20 @@ export function setCache<T = unknown>(
});

if (ttlMs > 0) {
addTimeout(
'c:' + key,
() => {
deleteCache(key, true);
},
ttlMs,
);
scheduleExpiry(key, ttlMs);
}
}

/**
* Deletes a cache entry once it expires.
*
* @param {string} key Cache key to utilize
* @param {number} ms - The time until the entry expires, in milliseconds.
*/
function scheduleExpiry(key: string, ms: number): void {
addTimeout('c:' + key, () => deleteCache(key, true), ms);
}

/**
* Invalidates (deletes) a cache entry.
*
Expand Down Expand Up @@ -475,13 +445,7 @@ export function restoreCache(
stores.set(key, store);

if (entry.expiry) {
addTimeout(
'c:' + key,
() => {
deleteCache(key, true);
},
entry.expiry - timeNow(),
);
scheduleExpiry(key, entry.expiry - timeNow());
}

notifySubscribers(key, entry.data);
Expand Down Expand Up @@ -532,8 +496,10 @@ export async function mutate<
};

// The ETag belongs to the original data, so the changed data must not be revalidated with it
if (updatedResponse.headers?.etag) {
updatedResponse.headers = { ...updatedResponse.headers };
const headers = updatedResponse.headers;

if (headers && headers.etag) {
updatedResponse.headers = { ...headers };
delete updatedResponse.headers.etag;
}

Expand Down
52 changes: 22 additions & 30 deletions src/config-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export const defaultConfig: RequestConfig = {
],

// Idempotent methods, which can be repeated safely, unlike e.g. POST requests that create resources
methods: ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE'],
methods: [GET, HEAD, 'PUT', 'DELETE', 'OPTIONS', 'TRACE'],
},
};

Expand Down Expand Up @@ -191,8 +191,7 @@ function setContentTypeIfNeeded(
// Types that should not have Content-Type set (browser handles these)
if (
body instanceof FormData || // Browser automatically sets multipart/form-data with boundary
(typeof Blob !== UNDEFINED && body instanceof Blob) || // Blob/File already have their own MIME types, don't override
(typeof File !== UNDEFINED && body instanceof File) ||
(typeof Blob !== UNDEFINED && body instanceof Blob) || // Blob/File (a File is a Blob) already have their own MIME types, don't override
(typeof ReadableStream !== UNDEFINED && body instanceof ReadableStream) // Stream type should be determined by the stream source
) {
return;
Expand Down Expand Up @@ -225,6 +224,14 @@ function setContentTypeIfNeeded(
}
}

// The interceptors that are merged instead of replaced
const INTERCEPTORS = [
'onRequest',
'onResponse',
'onError',
'onLoadingSlow',
] as const;

/**
* Merges two request configurations, applying overrides from the second config to the first.
* Handles special merging for nested properties like 'retry' and 'headers' (deep merge),
Expand Down Expand Up @@ -254,10 +261,9 @@ export function mergeConfigs(
mergeConfig('headers', baseConfig, overrideConfig, targetConfig);

// Merge interceptors efficiently
mergeInterceptors('onRequest', baseConfig, overrideConfig, targetConfig);
mergeInterceptors('onResponse', baseConfig, overrideConfig, targetConfig);
mergeInterceptors('onError', baseConfig, overrideConfig, targetConfig);
mergeInterceptors('onLoadingSlow', baseConfig, overrideConfig, targetConfig);
for (const property of INTERCEPTORS) {
mergeInterceptors(property, baseConfig, overrideConfig, targetConfig);
}

return targetConfig;
}
Expand All @@ -277,30 +283,16 @@ function mergeInterceptors<
const baseInterceptor = baseConfig[property];
const newInterceptor = overrideConfig[property];

if (!baseInterceptor && !newInterceptor) {
return;
}

if (!baseInterceptor) {
targetConfig[property] = newInterceptor;
return;
if (baseInterceptor && newInterceptor) {
// This is the only LIFO interceptor, so we apply it after the response is prepared
targetConfig[property] = (
property === 'onResponse'
? [].concat(newInterceptor as never, baseInterceptor as never)
: [].concat(baseInterceptor as never, newInterceptor as never)
) as RequestConfig[K];
} else if (baseInterceptor || newInterceptor) {
targetConfig[property] = baseInterceptor || newInterceptor;
}

if (!newInterceptor) {
targetConfig[property] = baseInterceptor;
return;
}

const baseArr = Array.isArray(baseInterceptor)
? baseInterceptor
: [baseInterceptor];
const newArr = Array.isArray(newInterceptor)
? newInterceptor
: [newInterceptor];

// This is the only LIFO interceptor, so we apply it after the response is prepared
targetConfig[property] =
property === 'onResponse' ? newArr.concat(baseArr) : baseArr.concat(newArr);
}

/**
Expand Down
16 changes: 8 additions & 8 deletions src/error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,10 @@ export async function withErrorHandling<
// Only handle the error if the request was not cancelled, or if it was cancelled and rejectCancelled is true.
const isCancelled = error.isCancelled;

if (!isCancelled && requestConfig.logger?.warn) {
requestConfig.logger.warn(
'FETCH ERROR',
redactError(error as ResponseError),
);
const logger = requestConfig.logger;

if (!isCancelled && logger && logger.warn) {
logger.warn('FETCH ERROR', redactError(error as ResponseError));
}

// The defaultResponse strategy returns the default response in place of the error data.
Expand All @@ -81,7 +80,7 @@ export async function withErrorHandling<
const strategy = requestConfig.strategy;
// Reject the promise
if (strategy === REJECT) {
return Promise.reject(error);
throw error;
}

// Hang the promise
Expand Down Expand Up @@ -155,8 +154,9 @@ export function enhanceError<
RequestBody
>,
): void {
error.status = error.status || response?.status || 0;
error.statusText = error.statusText || response?.statusText || '';
error.status = error.status || (response && response.status) || 0;
error.statusText =
error.statusText || (response && response.statusText) || '';
error.config = error.request = requestConfig;
error.response = response;
error.isCancelled = error.name === ABORT_ERROR;
Expand Down
39 changes: 27 additions & 12 deletions src/errors/fetch-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,33 @@ export class FetchError<
QueryParams = DefaultParams,
PathParams = DefaultUrlParams,
> extends Error {
status: number;
statusText: string;
config: RequestConfig<ResponseData, QueryParams, PathParams, RequestBody>;
isCancelled: boolean;
// Declared only, and set in the constructor, as class fields would need a helper in the ES2018 bundle
declare status: number;
declare statusText: string;
declare config: RequestConfig<
ResponseData,
QueryParams,
PathParams,
RequestBody
>;
declare isCancelled: boolean;
declare request: RequestConfig<
ResponseData,
QueryParams,
PathParams,
RequestBody
>;
declare response: FetchResponse<
ResponseData,
RequestBody,
QueryParams,
PathParams
> | null;

constructor(
message: string,
public request: RequestConfig<
ResponseData,
QueryParams,
PathParams,
RequestBody
>,
public response: FetchResponse<
request: RequestConfig<ResponseData, QueryParams, PathParams, RequestBody>,
response: FetchResponse<
ResponseData,
RequestBody,
QueryParams,
Expand All @@ -38,10 +51,12 @@ export class FetchError<
) {
super(message);

this.name = 'FetchError';
this.request = request;
this.response = response;
this.status = response ? response.status : 0;
this.statusText = response ? response.statusText : '';
this.config = request;
this.isCancelled = false;
this.name = 'FetchError';
}
}
2 changes: 1 addition & 1 deletion src/inflight-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ export async function getController(
): Promise<AbortController | undefined> {
const item = inFlight.get(key);

return item?.[0];
return item && item[0];
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/polling-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export async function withPolling<
await delayInvocation(pollingInterval, signal);

// Aborting the signal of the request stops polling
if (signal?.aborted) {
if (signal && signal.aborted) {
return output;
}
} while (
Expand Down
Loading
Loading