import type { AsyncFunc, AsyncResult, Params } from '@js/types'; export interface MemoAsyncOptions { /** * The function to call and cache results from */ func: T; /** * Time-to-live for records in the cache. After this amount of time, * a value is considered "stale", and by default, will be evicted * from the cache. * * This value can be a number (ms), or a function which receives the * params and result of the call and returns a number for each record. */ ttl: number | MemoAsyncTTLFunc; /** * Custom function for generating a cache key. If not provided, a * built-in default will be used instead. (At the moment, that * default is `JSON.stringify()`, which is not guranteed to be * stable or particularly fast; Providing a better implementation * for most functions is recommended) */ key?: MemoAsyncKeyFunc; /** * Callback to run when evicting a record from the cache (usually * because it expired) */ on_evict?: MemoAsyncOnEvict; /** * If provided and non-zero, the cache will keep values in storage * for this long (ms) past their expiration times. During this window, * a subsequent request for the value will result in a new call to the * source to refresh the cache, but the stale value in cache will still * be served immediately to reduce cache misses */ stale_ttl?: number; /** * If provided and non-zero, outstanding (not yet fulfilled) promises * will be cached and returned to subsequent callers for the given * amount of time (ms). If the promise does not resolve before the TTL * passes, it will be evicted from the cache to allow for another attempt. * If the promise rejects, it will be dropped from the cache. If the * promise resolves, the actual result will then be stored */ promise_ttl?: number; } export interface MemoAsyncKeyFunc { (params: Params) : string; } export interface MemoAsyncTTLFunc { (params: Params, result: ReturnType) : number; } export interface MemoAsyncOnEvict { (evicted: MemoAsyncStoredResult) : any; } export interface MemoAsyncStoredResult { key: string; created: number; resolved: number; expires: number; promise: ReturnType; result: AsyncResult; hits: number; timer: ReturnType; } export function memo_async(opts: MemoAsyncOptions) : T { const get_key = opts.key ?? default_key; const storage: Record> = Object.create(null); const refreshing: Record> = Object.create(null); function memoized(...params: Params) : ReturnType { const key = get_key(params); if (storage[key]) { storage[key].hits++; // If the record is stale (but still stored because of `stale_ttl`), start // a new refresh call in the background if (Date.now() >= storage[key].expires) { refresh_stale(storage[key], params); } return storage[key].result; } const promise = opts.func(...params) as ReturnType; store_record(key, params, promise); return promise; } function evict(record: MemoAsyncStoredResult) { if (storage[record.key] === record) { delete storage[record.key]; if (refreshing[record.key]?.timer) { // If another call is already working on refreshing this record and // it has an active `promise_ttl` timer, promote it into the main // storage now storage[record.key] = refreshing[record.key]; delete refreshing[record.key]; } if (opts.on_evict) { opts.on_evict(record); } } } function refresh_stale(old_record: MemoAsyncStoredResult, params: Params) { const record: MemoAsyncStoredResult = { key: old_record.key, created: Date.now(), resolved: null, expires: null, promise: opts.func(...params) as ReturnType, result: null, hits: 0, timer: null, }; refreshing[record.key] = record; if (opts.promise_ttl) { record.timer = setTimeout(() => on_timeout(record), opts.promise_ttl); } record.promise.then( (result) => on_refresh_resolve(record, params, result), (reason) => on_reject(record, reason) ); } function store_record(key: string, params: Params, promise: ReturnType) { const record: MemoAsyncStoredResult = { key, created: Date.now(), resolved: null, expires: null, promise, result: null, hits: 0, timer: null, }; if (opts.promise_ttl) { storage[key] = record; record.timer = setTimeout(() => on_timeout(record), opts.promise_ttl); } promise.then( (result) => on_resolve(record, params, result), (reason) => on_reject(record, reason) ); } function on_timeout(record: MemoAsyncStoredResult) { record.timer = null; if (storage[record.key] === record) { delete storage[record.key]; } } function on_resolve(record: MemoAsyncStoredResult, params: Params, result: AsyncResult) { if (record.timer) { clearTimeout(record.timer); } const ttl = typeof opts.ttl === 'function' ? opts.ttl(params, result) : opts.ttl; const evict_after = opts.stale_ttl ? ttl + opts.stale_ttl : ttl; record.result = result; record.resolved = Date.now(); record.expires = record.resolved + ttl; record.timer = setTimeout(() => evict(record), evict_after); storage[record.key] = record; } function on_refresh_resolve(record: MemoAsyncStoredResult, params: Params, result: AsyncResult) { if (record.timer) { clearTimeout(record.timer); } const ttl = typeof opts.ttl === 'function' ? opts.ttl(params, result) : opts.ttl; const evict_after = opts.stale_ttl ? ttl + opts.stale_ttl : ttl; record.result = result; record.resolved = Date.now(); record.expires = record.resolved + ttl; record.timer = setTimeout(() => evict(record), evict_after); if (storage[record.key]?.timer) { clearTimeout(storage[record.key].timer); evict(storage[record.key]); } storage[record.key] = record; } function on_reject(record: MemoAsyncStoredResult, reason: any) { if (record.timer) { // If a timeout timer was set due to `promise_ttl`, clear it now clearTimeout(record.timer); } if (storage[record.key] === record) { delete storage[record.key]; } } return memoized as unknown as T; } function default_key(...params: any[]) : string { return JSON.stringify(params); }