Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1import { EventEmitter } from "events";
2import _ from "lodash";
3
4import {
5 type Entry,
6 type NormalizeParamName,
7 type NormalizeParamValue,
8} from "./types/06_Normalization.js";
9import {
10 type AnyParams,
11 type AnyParamValue,
12 type AnyValidators,
13 type ConsumerRequest,
14 type Logger,
15 type ProducerResultResource,
16 type Store,
17 type Vary,
18} from "./types/index.js";
19import { type Bind1 } from "./types/utils.js";
20import {
21 normalizeParams,
22 normalizeProducerResultResource,
23 normalizeVary,
24} from "./utils/normalization.js";
25import * as entryUtils from "./utils/normalizedProducerResultResourceHelpers.js";
26import { defaultLoggersByComponent } from "./utils/utils.js";
27
28const { sortBy, groupBy } = _;
29
30type OnRequestAfterClose = "throw" | "return-nothing";
31
32/**
33 * This class implements a cache using a generalized version of HTTP's
34 * underlying caching model, but w/o encoding HTTP-specific details (like header
35 * parsing), so that it can be useful in more contexts. As part of this
36 * generalization, this class talks about a cached value's "id and request
37 * params" rather than its "URI and request headers", and cache directives are
38 * provided as explicit arguments (not header strings). Similarly, it refers to
39 * the "producer and consumer" of cached values, rather than the "server and the
40 * client". Beyond renaming, it leaves open the set of available validators for
41 * users to define (e.g., db row version numbers), rather than hard-coding HTTP
42 * validators like etags and last-modified dates, and it supports a set of
43 * directives somewhat more general than their HTTP equivalents.
44 *
45 * For (critical) background details on the HTTP caching model, see the docs.
46 *
47 * TODO: support the concept of warnings.
48 * See https://tools.ietf.org/html/rfc7234#section-5.5
49 */
50export default class Cache<
51 Content,
52 Validators extends AnyValidators = AnyValidators,
53 Params extends AnyParams = AnyParams,
54 Id extends string = string,
55> {
56 private readonly logger: Bind1<Logger, "cache">;
57 public readonly emitter = new EventEmitter();
58 private closed = false;
59 private readonly onGetAfterClose: OnRequestAfterClose;
60 private readonly onStoreAfterClose: OnRequestAfterClose;
61 public readonly normalizeParamName: NormalizeParamName<Params>;
62 public readonly normalizeParamValue: NormalizeParamValue<Params>;
63
64 /**
65 * @param dataStore The backing store that will actually hold cache entries.
66 */
67 constructor(
68 private readonly dataStore: Store<Content, Validators, Params>,
69 options: {
70 logger?: Logger;
71 onGetAfterClose?: OnRequestAfterClose;
72 onStoreAfterClose?: OnRequestAfterClose;
73 normalizeParamName?: NormalizeParamName<Params>;
74 normalizeParamValue?: NormalizeParamValue<Params>;
75 } = {},
76 ) {
77 const unboundLogger = options.logger ?? defaultLoggersByComponent.cache;
78 this.logger = unboundLogger.bind(null, "cache");
79 this.onGetAfterClose = options.onGetAfterClose ?? "throw";
80 this.onStoreAfterClose = options.onStoreAfterClose ?? "throw";
81 this.normalizeParamName = options.normalizeParamName ?? ((it) => it);
82 this.normalizeParamValue =
83 options.normalizeParamValue ??
84 (<K extends keyof Params>(_name: K, v: AnyParamValue) =>
85 v as Params[K] & AnyParamValue);
86 }
87
88 private static bestEntry<
89 Content,
90 Validators extends AnyValidators,
91 Params extends AnyParams,
92 >(suitableEntries: readonly Entry<Content, Validators, Params>[]) {
93 // "When more than one suitable response is stored, a cache MUST use
94 // the most recent response (as determined by the Date header field)."
95 // https://tools.ietf.org/html/rfc7234#section-4
96 return sortBy(suitableEntries, [(it) => entryUtils.birthDate(it)]).at(-1);
97 }
98
99 // Create this as an instance member to get `this` binding
100 private normalizeParams = (params: Partial<Params>) =>
101 normalizeParams(this.normalizeParamName, this.normalizeParamValue, params);
102
103 // Create this as an instance member to get `this` binding
104 private normalizeVary = (vary: Vary<Params>) =>
105 normalizeVary(this.normalizeParamName, this.normalizeParamValue, vary);
106
107 /**
108 * Gets relevant items from the cache, always returning a promise for an
109 * object with four possible keys:
110 *
111 * - `usable`: this is the cached value (if any) that satisfies the consumer's
112 * request, given its cache directives, without requiring even background
113 * revalidation. **If this key holds a value, all other keys in this object
114 * will be undefined/empty.** This value will almost always be fresh, since
115 * stale values aren't usable by defualt; the exception is if the consumer
116 * allowed stale responses (sans revalidation) through the `maxStale`
117 * directive. If multiple cached values would've have been suitable, this
118 * holds the preferred one (which currently means the newest).
119 *
120 * - `usableWhileRevalidate`: this holds the preferred response (if any)
121 * that's usable to satisfy the client's request, but that must be
122 * (re-)validated in the background.
123 *
124 * - `usableIfError`: holds an entry (if any) that's usable only in case of an
125 * error reaching the producer while trying to fetch/revalidate the cached
126 * value. If there's a `usableWhileRevalidate` response, this key will
127 * always be empty [because the usableWhileRevalidate response should be
128 * returned before calling the producer, so there's no chance on an error.]
129 *
130 * - `validatable`: when validation is necessary (either because no usable
131 * response is held by the cache, or the usable response requires
132 * background re-validation), this array holds all entries in the cache
133 * that have validation info -- including, possibly, responses present in
134 * the other returned keys -- and that would be usable were the producer
135 * to confirm (revalidate) that the resource's current state matches the
136 * state identified by the validation info. Otherwise, this array is empty.
137 * These are returned so that the user can make a conditional request for
138 * the latest content that takes into account the validation info (e.g.,
139 * the etags w/ `If-None-Match`) of these saved responses. These responses
140 * are probably stale, but it's possible they're not (e.g., if consumer
141 * used a maxAge directive shorter than the producer's freshness lifetime).
142 */
143 public async get(req: ConsumerRequest<Params, Id>): Promise<{
144 usable?: Entry<Content, Validators, Params> | undefined;
145 usableWhileRevalidate?: Entry<Content, Validators, Params> | undefined;
146 usableIfError?: Entry<Content, Validators, Params> | undefined;
147 validatable: Entry<Content, Validators, Params>[];
148 }> {
149 if (this.closed) {
150 if (this.onGetAfterClose === "throw") {
151 this.logger("trace", "received request when closed and throwing");
152 throw new Error("Store has been closed...");
153 }
154 this.logger(
155 "trace",
156 "received request when closed, so returning no entries",
157 );
158 return {
159 validatable: [],
160 };
161 }
162
163 const { id, params, directives } = req;
164 const now = new Date();
165 const normalizedParams = this.normalizeParams(params);
166
167 this.logger("trace", "received request", { id, params, normalizedParams });
168 this.logger("trace", "requested entries from the store");
169
170 const cacheEntries = await this.dataStore.get(id, normalizedParams);
171 const classifiedEntries = groupBy(cacheEntries, (it) =>
172 entryUtils.classify(it, directives, now),
173 );
174
175 this.logger(
176 "trace",
177 "received entries from the store, and classified them",
178 classifiedEntries,
179 );
180
181 const usableEntries =
182 classifiedEntries[entryUtils.EntryClassification.Usable];
183
184 if (usableEntries) {
185 const res = {
186 // Non-null assertion is safe because of lodash groupBy mechanics.
187 usable: Cache.bestEntry(usableEntries)!,
188 validatable: [],
189 };
190
191 this.logger("trace", "chose/returned this data", res);
192 return res;
193 }
194
195 const validatableEntries = cacheEntries.filter(entryUtils.isValidatable);
196
197 const usableWhileRevalidateEntries =
198 classifiedEntries[entryUtils.EntryClassification.UsableWhileRevalidate];
199
200 if (usableWhileRevalidateEntries) {
201 const res = {
202 // Non-null assertion is safe because of lodash groupBy mechanics.
203 usableWhileRevalidate: Cache.bestEntry(usableWhileRevalidateEntries)!,
204 validatable: validatableEntries,
205 };
206
207 this.logger("trace", "chose/returned this data", res);
208 return res;
209 }
210
211 const usableIfErrorEntries =
212 classifiedEntries[entryUtils.EntryClassification.UsableIfError];
213
214 const res = {
215 usableIfError: usableIfErrorEntries
216 ? // Non-null assertion is safe because of lodash groupBy mechanics.
217 Cache.bestEntry(usableIfErrorEntries)!
218 : undefined,
219 validatable: validatableEntries,
220 };
221
222 this.logger("trace", "chose/returned this data", res);
223 return res;
224 }
225
226 /**
227 * Stores ProducerResultResources that it assumes were _just now_ retreived
228 * from the producer. If the result wasn't retreived just now, its retreival
229 * time can be specified.
230 */
231 public async store(
232 data: readonly ProducerResultResource<Content, Validators, Params>[],
233 ) {
234 if (this.closed) {
235 if (this.onStoreAfterClose === "throw") {
236 this.logger("trace", "received store request when closed and throwing");
237 throw new Error("Store has been closed...");
238 }
239 this.logger(
240 "trace",
241 "received store request after throwing and doing nothing",
242 );
243 return;
244 }
245
246 const now = new Date();
247 const entriesWithTimes = data.map((it) => {
248 const entry = normalizeProducerResultResource(
249 this.normalizeVary,
250 it,
251 now,
252 );
253 return { entry, maxStoreForSeconds: calculateStoreFor(entry, now) };
254 });
255
256 this.logger(
257 "trace",
258 "storing the following entries with (possibly inferred) storeFor times",
259 entriesWithTimes,
260 );
261
262 entriesWithTimes.forEach(({ entry, maxStoreForSeconds }) => {
263 this.emitter.emit("store", entry, maxStoreForSeconds);
264 });
265
266 return this.dataStore.store(entriesWithTimes);
267 }
268
269 /**
270 * Deletes all stored entries for the given resource id. Used for cache
271 * invalidation (e.g. when a signing key is rotated).
272 */
273 public async delete(id: Id): Promise<void> {
274 return this.dataStore.delete(id);
275 }
276
277 public async close(timeout?: number) {
278 this.closed = true;
279 return this.dataStore.close(timeout);
280 }
281}
282
283/**
284 * Calculates the maximum amount of time -- in seconds! -- that the backing
285 * store may store the entry. It considers the producer's requested storeFor
286 * time, and when the data will become definitively useless.
287 *
288 * @param entry The entry who's time-to-store should be calculated
289 * @param at The date when the entry will be stored. This effects how long it
290 * should be stored for because, as entries get closer to the end of their
291 * freshness lifetime, the suggested storeFor time may go down (when it isn't
292 * dictated by the producer's directives).
293 */
294function calculateStoreFor(
295 entry: Entry<unknown, AnyValidators, AnyParams>,
296 at: Date,
297) {
298 const producerStoreFor = entry.directives.storeFor;
299 const requestedStoreFor =
300 producerStoreFor !== undefined
301 ? producerStoreFor - entry.initialAge
302 : Infinity;
303
304 return Math.max(
305 0,
306 Math.min(requestedStoreFor, entryUtils.potentiallyUsefulFor(entry, at)),
307 );
308}