Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce cache #56

Merged
merged 14 commits into from
Sep 20, 2024
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
90 changes: 46 additions & 44 deletions README.md

Large diffs are not rendered by default.

216 changes: 216 additions & 0 deletions src/cache-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { hash } from './hash';
import { fetchf } from './index';
import type { FetcherConfig } from './types/request-handler';
import type { CacheEntry } from './types/cache-manager';
import { GET, OBJECT, UNDEFINED } from './const';
import { shallowSerialize, sortObject } from './utils';

const cache = new Map<string, CacheEntry<any>>();

/**
* Generates a cache key for a given URL and fetch options, ensuring that key factors
* like method, headers, body, and other options are included in the cache key.
* Headers and other objects are sorted by key to ensure consistent cache keys.
*
* @param options - The fetch options that may affect the request. The most important are:
* @property {string} [method="GET"] - The HTTP method (GET, POST, etc.).
* @property {HeadersInit} [headers={}] - The request headers.
* @property {BodyInit | null} [body=""] - The body of the request (only for methods like POST, PUT).
* @property {RequestMode} [mode="cors"] - The mode for the request (e.g., cors, no-cors, include).
* @property {RequestCredentials} [credentials="include"] - Whether to include credentials like cookies.
* @property {RequestCache} [cache="default"] - The cache mode (e.g., default, no-store, reload).
* @property {RequestRedirect} [redirect="follow"] - How to handle redirects (e.g., follow, error, manual).
* @property {string} [referrer=""] - The referrer URL to send with the request.
* @property {string} [integrity=""] - Subresource integrity value (a cryptographic hash for resource validation).
* @returns {string} - A unique cache key based on the URL and request options. Empty if cache is to be burst.
*
* @example
* const cacheKey = generateCacheKey({
* url: 'https://api.example.com/data',
* method: 'POST',
* headers: { 'Content-Type': 'application/json' },
* body: JSON.stringify({ name: 'Alice' }),
* mode: 'cors',
* credentials: 'include',
* });
* console.log(cacheKey);
*/
export function generateCacheKey(options: FetcherConfig): string {
const {
url = '',
method = GET,
headers = {},
body = '',
mode = 'cors',
credentials = 'include',
cache = 'default',
redirect = 'follow',
referrer = '',
integrity = '',
} = options;

// Bail early if cache should be burst
if (cache === 'reload') {
return '';
}

// Sort headers and body + convert sorted to strings for hashing purposes
// Native serializer is on avg. 3.5x faster than a Fast Hash or FNV-1a
const headersString = shallowSerialize(sortObject(headers));

let bodyString = '';

// In majority of cases we do not cache body
if (body !== null) {
if (typeof body === 'string') {
bodyString = hash(body);
} else if (body instanceof FormData) {
body.forEach((value, key) => {
// Append key=value and '&' directly to the result
bodyString += key + '=' + value + '&';
});
bodyString = hash(bodyString);
} else if (
(typeof Blob !== UNDEFINED && body instanceof Blob) ||
(typeof File !== UNDEFINED && body instanceof File)
) {
bodyString = 'BF' + body.size + body.type;
} else if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {
bodyString = 'AB' + body.byteLength;
} else {
const o = typeof body === OBJECT ? sortObject(body) : String(body);
bodyString = hash(JSON.stringify(o));
}
}

// Concatenate all key parts into a cache key string
// Template literals are apparently slower
return (
method +
url +
mode +
credentials +
cache +
redirect +
referrer +
integrity +
headersString +
bodyString
);
}

/**
* Checks if the cache entry is expired based on its timestamp and the maximum stale time.
*
* @param {number} timestamp - The timestamp of the cache entry.
* @param {number} maxStaleTime - The maximum stale time in seconds.
* @returns {boolean} - Returns true if the cache entry is expired, false otherwise.
*/
function isCacheExpired(timestamp: number, maxStaleTime: number): boolean {
if (!maxStaleTime) {
return false;
}

return Date.now() - timestamp > maxStaleTime * 1000;
}

/**
* Retrieves a cache entry if it exists and is not expired.
*
* @param {string} key Cache key to utilize
* @param {FetcherConfig} cacheTime - Maximum time to cache entry.
* @returns {CacheEntry<T> | null} - The cache entry if it exists and is not expired, null otherwise.
*/
export function getCache<T>(
key: string,
cacheTime: number,
): CacheEntry<T> | null {
const entry = cache.get(key);

if (entry) {
if (!isCacheExpired(entry.timestamp, cacheTime)) {
return entry;
}

cache.delete(key);
}

return null;
}

/**
* Sets a new cache entry or updates an existing one.
*
* @param {string} key Cache key to utilize
* @param {T} data - The data to be cached.
* @param {boolean} isLoading - Indicates if the data is currently being fetched.
*/
export function setCache<T = unknown>(
key: string,
data: T,
isLoading: boolean = false,
): void {
cache.set(key, {
data,
isLoading,
timestamp: Date.now(),
});
}

/**
* Revalidates a cache entry by fetching fresh data and updating the cache.
*
* @param {string} key Cache key to utilize
* @param {FetcherConfig} config - The request configuration object.
* @returns {Promise<void>} - A promise that resolves when the revalidation is complete.
*/
export async function revalidate(
key: string,
config: FetcherConfig,
): Promise<void> {
try {
// Fetch fresh data
const newData = await fetchf(config.url, {
...config,
cache: 'reload',
});

setCache(key, newData);
} catch (error) {
console.error(`Error revalidating ${config.url}:`, error);

// Rethrow the error to forward it
throw error;
}
}

/**
* Invalidates (deletes) a cache entry.
*
* @param {string} key Cache key to utilize
*/
export function deleteCache(key: string): void {
cache.delete(key);
}

/**
* Mutates a cache entry with new data and optionally revalidates it.
*
* @param {string} key Cache key to utilize
* @param {FetcherConfig} config - The request configuration object.
* @param {T} newData - The new data to be cached.
* @param {boolean} revalidateAfter - If true, triggers revalidation after mutation.
*/
export function mutate<T>(
key: string,
config: FetcherConfig,
newData: T,
revalidateAfter: boolean = false,
): void {
setCache(key, newData);

if (revalidateAfter) {
revalidate(key, config);
}
}
4 changes: 4 additions & 0 deletions src/const.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
export const APPLICATION_JSON = 'application/json';
export const CONTENT_TYPE = 'Content-Type';

export const UNDEFINED = 'undefined';
export const OBJECT = 'object';
export const STRING = 'string';

export const ABORT_ERROR = 'AbortError';
export const TIMEOUT_ERROR = 'TimeoutError';
export const CANCELLED_ERROR = 'CanceledError';

export const GET = 'GET';
export const HEAD = 'HEAD';
70 changes: 11 additions & 59 deletions src/hash.ts
Original file line number Diff line number Diff line change
@@ -1,68 +1,20 @@
import { UNDEFINED } from './const';
import { RequestConfig } from './types';

// Garbage collected hash cache table
// Since we use hashtable, it is really fast
const hashCache = new WeakMap<object>();
const PRIME_MULTIPLIER = 31;

/**
* FNV-1a Hash Function Implementation
* It's non-crypto and very fast
* We avoid BigInt here due to compatibility issues (ES2020), and the bundle size
* @url https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function
* Computes a hash value for a given string using the variant of djb2 hash function.
* This hash function is non-cryptographic and designed for speed.
* @author Daniel J. Bernstein (of djb2)
*
* @param input Input string to hash
* @param str Input string to hash
* @returns {string} Hash
*/
export function hash(input: string): string {
// FNV-1a 32-bit offset basis
let hash = 2166136261;

// FNV-1a 32-bit prime
const FNV_32_PRIME = 16777619;

for (const char of input) {
hash ^= char.charCodeAt(0);
hash *= FNV_32_PRIME;
// Ensure the hash stays within 32-bit unsigned integer range
hash = hash >>> 0;
}

// Convert hash to hexadecimal string, pad to ensure it's always 8 chars long
return hash.toString(16).padStart(8, '0');
}

/**
* Computes and retrieves a hash for a given `RequestConfig` object.
*
* This function first checks if the hash for the provided `requestConfig` object is
* already cached. If it is not found in the cache, it serializes the `requestConfig`
* object into a JSON string, computes a hash for that string using the FNV-1a algorithm,
* and stores the resulting hash in the cache for future use. The function utilizes a
* `WeakMap` to manage the cache, ensuring automatic garbage collection of cache entries
* when the associated `requestConfig` objects are no longer in use.
*
* @param {RequestConfig} requestConfig - The configuration object to hash. This object
* should have the following properties:
* - `method` {string} - The HTTP method (e.g., 'GET', 'POST').
* - `baseURL` {string} - The base URL of the API.
* - `url` {string} - The specific URL path.
* - `params` {object} - The query parameters as key-value pairs.
* - `data` {object} - The request body data as key-value pairs.
*
* @returns {string} The computed hash for the `requestConfig` object. The hash is
* represented as a hexadecimal string of 8 characters, ensuring a fixed length for
* consistency and easier comparison.
*/
export function hashFromConfig(requestConfig: RequestConfig): string {
let key = hashCache.get(requestConfig);

if (typeof key === UNDEFINED) {
const keyString = JSON.stringify(requestConfig);
export function hash(str: string): string {
let hash = 0;

key = hash(keyString);
hashCache.set(requestConfig, key);
for (let i = 0, len = str.length; i < len; i++) {
const char = str.charCodeAt(i);
hash = (hash * PRIME_MULTIPLIER + char) | 0;
}

return key;
return String(hash);
}
71 changes: 23 additions & 48 deletions src/interceptor-manager.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,34 @@
import type { RequestHandlerConfig, FetchResponse } from './types';
import type {
RequestInterceptor,
ResponseInterceptor,
} from './types/interceptor-manager';
/* eslint-disable @typescript-eslint/no-explicit-any */
type InterceptorFunction<T> = (object: T) => Promise<T>;

/**
* Applies a series of request interceptors to the provided configuration.
* @param {RequestHandlerConfig} config - The initial request configuration.
* @param {RequestInterceptor | RequestInterceptor[]} interceptors - The request interceptor function(s) to apply.
* @returns {Promise<RequestHandlerConfig>} - The modified request configuration.
* Applies interceptors to the object. Interceptors can be a single function or an array of functions.
*
* @template T - Type of the object.
* @template I - Type of interceptors.
*
* @param {T} object - The object to process.
* @param {InterceptorFunction<T> | InterceptorFunction<T>[]} [interceptors] - Interceptor function(s).
*
* @returns {Promise<T>} - The modified object.
*/
export async function interceptRequest(
config: RequestHandlerConfig,
interceptors?: RequestInterceptor | RequestInterceptor[],
): Promise<RequestHandlerConfig> {
export async function applyInterceptor<
T = any,
I = InterceptorFunction<T> | InterceptorFunction<T>[],
>(object: T, interceptors?: I): Promise<T> {
if (!interceptors) {
return config;
return object;
}

const interceptorList = Array.isArray(interceptors)
? interceptors
: [interceptors];

let interceptedConfig = { ...config };

for (const interceptor of interceptorList) {
interceptedConfig = await interceptor(interceptedConfig);
if (typeof interceptors === 'function') {
return await interceptors(object);
}

return interceptedConfig;
}

/**
* Applies a series of response interceptors to the provided response.
* @param {FetchResponse<ResponseData>} response - The initial response object.
* @param {ResponseInterceptor | ResponseInterceptor[]} interceptors - The response interceptor function(s) to apply.
* @returns {Promise<FetchResponse<ResponseData>>} - The modified response object.
*/
export async function interceptResponse<ResponseData = unknown>(
response: FetchResponse<ResponseData>,
interceptors?: ResponseInterceptor | ResponseInterceptor[],
): Promise<FetchResponse<ResponseData>> {
if (!interceptors) {
return response;
}

const interceptorList = Array.isArray(interceptors)
? interceptors
: [interceptors];

let interceptedResponse = response;

for (const interceptor of interceptorList) {
interceptedResponse = await interceptor(interceptedResponse);
if (Array.isArray(interceptors)) {
for (const interceptor of interceptors) {
object = await interceptor(object);
}
}

return interceptedResponse;
return object;
}
Loading
Loading