Something went wrong. Try again.
Mirrored from GitHub github.com/roostorg/coop
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309import { EventEmitter } from 'events';import _ from 'lodash';
import { type Entry, type NormalizeParamName, type NormalizeParamValue,} from './types/06_Normalization.js';import { type AnyParams, type AnyParamValue, type AnyValidators, type ConsumerRequest, type Logger, type ProducerResultResource, type Store, type Vary,} from './types/index.js';import { type Bind1 } from './types/utils.js';import { normalizeParams, normalizeProducerResultResource, normalizeVary,} from './utils/normalization.js';import * as entryUtils from './utils/normalizedProducerResultResourceHelpers.js';import { defaultLoggersByComponent } from './utils/utils.js';
const { sortBy, groupBy } = _;
type OnRequestAfterClose = 'throw' | 'return-nothing';
/** * This class implements a cache using a generalized version of HTTP's * underlying caching model, but w/o encoding HTTP-specific details (like header * parsing), so that it can be useful in more contexts. As part of this * generalization, this class talks about a cached value's "id and request * params" rather than its "URI and request headers", and cache directives are * provided as explicit arguments (not header strings). Similarly, it refers to * the "producer and consumer" of cached values, rather than the "server and the * client". Beyond renaming, it leaves open the set of available validators for * users to define (e.g., db row version numbers), rather than hard-coding HTTP * validators like etags and last-modified dates, and it supports a set of * directives somewhat more general than their HTTP equivalents. * * For (critical) background details on the HTTP caching model, see the docs. * * TODO: support the concept of warnings. * See https://tools.ietf.org/html/rfc7234#section-5.5 */export default class Cache< Content, Validators extends AnyValidators = AnyValidators, Params extends AnyParams = AnyParams, Id extends string = string,> { private readonly logger: Bind1<Logger, 'cache'>; public readonly emitter = new EventEmitter(); private closed = false; private readonly onGetAfterClose: OnRequestAfterClose; private readonly onStoreAfterClose: OnRequestAfterClose; public readonly normalizeParamName: NormalizeParamName<Params>; public readonly normalizeParamValue: NormalizeParamValue<Params>;
/** * @param dataStore The backing store that will actually hold cache entries. */ constructor( private readonly dataStore: Store<Content, Validators, Params>, options: { logger?: Logger; onGetAfterClose?: OnRequestAfterClose; onStoreAfterClose?: OnRequestAfterClose; normalizeParamName?: NormalizeParamName<Params>; normalizeParamValue?: NormalizeParamValue<Params>; } = {}, ) { const unboundLogger = options.logger ?? defaultLoggersByComponent.cache; this.logger = unboundLogger.bind(null, 'cache'); this.onGetAfterClose = options.onGetAfterClose ?? 'throw'; this.onStoreAfterClose = options.onStoreAfterClose ?? 'throw'; this.normalizeParamName = options.normalizeParamName ?? ((it) => it); this.normalizeParamValue = options.normalizeParamValue ?? (<K extends keyof Params>(_name: K, v: AnyParamValue) => v as Params[K] & AnyParamValue); }
private static bestEntry< Content, Validators extends AnyValidators, Params extends AnyParams, >(suitableEntries: readonly Entry<Content, Validators, Params>[]) { // "When more than one suitable response is stored, a cache MUST use // the most recent response (as determined by the Date header field)." // https://tools.ietf.org/html/rfc7234#section-4 return sortBy(suitableEntries, [(it) => entryUtils.birthDate(it)]).at(-1); }
// Create this as an instance member to get `this` binding private normalizeParams = (params: Partial<Params>) => normalizeParams(this.normalizeParamName, this.normalizeParamValue, params);
// Create this as an instance member to get `this` binding private normalizeVary = (vary: Vary<Params>) => normalizeVary(this.normalizeParamName, this.normalizeParamValue, vary);
/** * Gets relevant items from the cache, always returning a promise for an * object with four possible keys: * * - `usable`: this is the cached value (if any) that satisfies the consumer's * request, given its cache directives, without requiring even background * revalidation. **If this key holds a value, all other keys in this object * will be undefined/empty.** This value will almost always be fresh, since * stale values aren't usable by defualt; the exception is if the consumer * allowed stale responses (sans revalidation) through the `maxStale` * directive. If multiple cached values would've have been suitable, this * holds the preferred one (which currently means the newest). * * - `usableWhileRevalidate`: this holds the preferred response (if any) * that's usable to satisfy the client's request, but that must be * (re-)validated in the background. * * - `usableIfError`: holds an entry (if any) that's usable only in case of an * error reaching the producer while trying to fetch/revalidate the cached * value. If there's a `usableWhileRevalidate` response, this key will * always be empty [because the usableWhileRevalidate response should be * returned before calling the producer, so there's no chance on an error.] * * - `validatable`: when validation is necessary (either because no usable * response is held by the cache, or the usable response requires * background re-validation), this array holds all entries in the cache * that have validation info -- including, possibly, responses present in * the other returned keys -- and that would be usable were the producer * to confirm (revalidate) that the resource's current state matches the * state identified by the validation info. Otherwise, this array is empty. * These are returned so that the user can make a conditional request for * the latest content that takes into account the validation info (e.g., * the etags w/ `If-None-Match`) of these saved responses. These responses * are probably stale, but it's possible they're not (e.g., if consumer * used a maxAge directive shorter than the producer's freshness lifetime). */ public async get(req: ConsumerRequest<Params, Id>): Promise<{ usable?: Entry<Content, Validators, Params> | undefined; usableWhileRevalidate?: Entry<Content, Validators, Params> | undefined; usableIfError?: Entry<Content, Validators, Params> | undefined; validatable: Entry<Content, Validators, Params>[]; }> { if (this.closed) { if (this.onGetAfterClose === 'throw') { this.logger('trace', 'received request when closed and throwing'); throw new Error('Store has been closed...'); } this.logger( 'trace', 'received request when closed, so returning no entries', ); return { validatable: [], }; }
const { id, params, directives } = req; const now = new Date(); const normalizedParams = this.normalizeParams(params);
this.logger('trace', 'received request', { id, params, normalizedParams }); this.logger('trace', 'requested entries from the store');
const cacheEntries = await this.dataStore.get(id, normalizedParams); const classifiedEntries = groupBy(cacheEntries, (it) => entryUtils.classify(it, directives, now), );
this.logger( 'trace', 'received entries from the store, and classified them', classifiedEntries, );
const usableEntries = classifiedEntries[entryUtils.EntryClassification.Usable];
if (usableEntries) { const res = { // Non-null assertion is safe because of lodash groupBy mechanics. usable: Cache.bestEntry(usableEntries)!, validatable: [], };
this.logger('trace', 'chose/returned this data', res); return res; }
const validatableEntries = cacheEntries.filter(entryUtils.isValidatable);
const usableWhileRevalidateEntries = classifiedEntries[entryUtils.EntryClassification.UsableWhileRevalidate];
if (usableWhileRevalidateEntries) { const res = { // Non-null assertion is safe because of lodash groupBy mechanics. usableWhileRevalidate: Cache.bestEntry(usableWhileRevalidateEntries)!, validatable: validatableEntries, };
this.logger('trace', 'chose/returned this data', res); return res; }
const usableIfErrorEntries = classifiedEntries[entryUtils.EntryClassification.UsableIfError];
const res = { usableIfError: usableIfErrorEntries ? // Non-null assertion is safe because of lodash groupBy mechanics. Cache.bestEntry(usableIfErrorEntries)! : undefined, validatable: validatableEntries, };
this.logger('trace', 'chose/returned this data', res); return res; }
/** * Stores ProducerResultResources that it assumes were _just now_ retreived * from the producer. If the result wasn't retreived just now, its retreival * time can be specified. */ public async store( data: readonly ProducerResultResource<Content, Validators, Params>[], ) { if (this.closed) { if (this.onStoreAfterClose === 'throw') { this.logger('trace', 'received store request when closed and throwing'); throw new Error('Store has been closed...'); } this.logger( 'trace', 'received store request after throwing and doing nothing', ); return; }
const now = new Date(); const entriesWithTimes = data.map((it) => { const entry = normalizeProducerResultResource( this.normalizeVary, it, now, ); return { entry, maxStoreForSeconds: calculateStoreFor(entry, now) }; });
this.logger( 'trace', 'storing the following entries with (possibly inferred) storeFor times', entriesWithTimes, );
entriesWithTimes.forEach(({ entry, maxStoreForSeconds }) => { this.emitter.emit('store', entry, maxStoreForSeconds); });
return this.dataStore.store(entriesWithTimes); }
/** * Deletes all stored entries for the given resource id. Used for cache * invalidation (e.g. when a signing key is rotated). */ public async delete(id: Id): Promise<void> { return this.dataStore.delete(id); }
public async close(timeout?: number) { this.closed = true; return this.dataStore.close(timeout); }}
/** * Calculates the maximum amount of time -- in seconds! -- that the backing * store may store the entry. It considers the producer's requested storeFor * time, and when the data will become definitively useless. * * @param entry The entry who's time-to-store should be calculated * @param at The date when the entry will be stored. This effects how long it * should be stored for because, as entries get closer to the end of their * freshness lifetime, the suggested storeFor time may go down (when it isn't * dictated by the producer's directives). */function calculateStoreFor( entry: Entry<unknown, AnyValidators, AnyParams>, at: Date,) { const producerStoreFor = entry.directives.storeFor; const requestedStoreFor = producerStoreFor !== undefined ? producerStoreFor - entry.initialAge : Infinity;
return Math.max( 0, Math.min(requestedStoreFor, entryUtils.potentiallyUsefulFor(entry, at)), );}