import { 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; public readonly emitter = new EventEmitter(); private closed = false; private readonly onGetAfterClose: OnRequestAfterClose; private readonly onStoreAfterClose: OnRequestAfterClose; public readonly normalizeParamName: NormalizeParamName; public readonly normalizeParamValue: NormalizeParamValue; /** * @param dataStore The backing store that will actually hold cache entries. */ constructor( private readonly dataStore: Store, options: { logger?: Logger; onGetAfterClose?: OnRequestAfterClose; onStoreAfterClose?: OnRequestAfterClose; normalizeParamName?: NormalizeParamName; normalizeParamValue?: NormalizeParamValue; } = {}, ) { 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 ?? ((_name: K, v: AnyParamValue) => v as Params[K] & AnyParamValue); } private static bestEntry< Content, Validators extends AnyValidators, Params extends AnyParams, >(suitableEntries: readonly Entry[]) { // "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) => normalizeParams(this.normalizeParamName, this.normalizeParamValue, params); // Create this as an instance member to get `this` binding private normalizeVary = (vary: Vary) => 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): Promise<{ usable?: Entry | undefined; usableWhileRevalidate?: Entry | undefined; usableIfError?: Entry | undefined; validatable: Entry[]; }> { 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[], ) { 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 { 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, 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)), ); }