1/**
2 * Shared vinext CacheHandler over a string KV-shaped store.
3 *
4 * Redis and S3 adapters only differ in how they persist strings. The
5 * ISR / `"use cache"` codec (tags, stale-while-revalidate, ArrayBuffers)
6 * lives here once so the three runtimes cannot drift.
7 */
8import {
9 isUnknownRecord,
10 keySpace,
11 readCacheControlNumberField,
12 readPositiveNumberField,
13 readStringArrayField,
14 restoreArrayBuffers,
15 serializeForJSON,
16 validateCacheEntry,
17 validateTag,
18 validUniqueTags,
19 type StoredCacheControl,
20} from "./shared.ts";
21
22export interface DataCacheStore {
23 getText(key: string): Promise<string | undefined>;
24 putText(key: string, value: string, ttlMs?: number): Promise<void>;
25 delete(key: string): Promise<void>;
26}
27
28export interface DataCacheHandlerOptions {
29 readonly appPrefix?: string;
30 /** Default key TTL in seconds. @default 2592000 (30 days) */
31 readonly ttlSeconds?: number;
32 readonly tagCacheTtlMs?: number;
33}
34
35const DEFAULT_TTL_SECONDS = 2592000;
36
37export const makeDataCacheHandler = (
38 store: DataCacheStore,
39 options?: DataCacheHandlerOptions,
40) => {
41 const keys = keySpace(options?.appPrefix);
42 const ttlSeconds = options?.ttlSeconds ?? DEFAULT_TTL_SECONDS;
43 const tagCacheTtl = options?.tagCacheTtlMs ?? 5_000;
44 const tagCache = new Map<string, { timestamp: number; fetchedAt: number }>();
45
46 const ttlMsFor = (expireAt: number | null, now: number) =>
47 expireAt !== null ? Math.max(expireAt - now, 1000) : ttlSeconds * 1000;
48
49 const hasRevalidatedTag = async (
50 tags: Array<string>,
51 lastModified: number,
52 ) => {
53 if (tags.length === 0) return false;
54 const now = Date.now();
55 const uncached: Array<string> = [];
56 for (const tag of tags) {
57 const cached = tagCache.get(tag);
58 if (cached && now - cached.fetchedAt < tagCacheTtl) {
59 if (
60 Number.isNaN(cached.timestamp) ||
61 cached.timestamp >= lastModified
62 ) {
63 return true;
64 }
65 } else {
66 uncached.push(tag);
67 }
68 }
69 if (uncached.length === 0) return false;
70 const results = await Promise.all(
71 uncached.map((tag) => store.getText(keys.tagKey(tag))),
72 );
73 for (let i = 0; i < uncached.length; i++) {
74 const raw = results[i];
75 const timestamp = raw !== undefined ? Number(raw) : 0;
76 tagCache.set(uncached[i]!, { timestamp, fetchedAt: now });
77 if (
78 timestamp !== 0 &&
79 (Number.isNaN(timestamp) || timestamp >= lastModified)
80 ) {
81 return true;
82 }
83 }
84 return false;
85 };
86
87 return {
88 async get(key: string, ctx?: Record<string, unknown>) {
89 const storageKey = keys.entryKey(key);
90 const raw = await store.getText(storageKey);
91 if (raw === undefined) return null;
92 let parsed: unknown;
93 try {
94 parsed = JSON.parse(raw);
95 } catch {
96 await store.delete(storageKey);
97 return null;
98 }
99 const entry = validateCacheEntry(parsed);
100 if (!entry) {
101 await store.delete(storageKey);
102 return null;
103 }
104 let restoredValue = null;
105 if (entry.value && isUnknownRecord(entry.value)) {
106 restoredValue = restoreArrayBuffers(entry.value);
107 if (!restoredValue) {
108 await store.delete(storageKey);
109 return null;
110 }
111 }
112 if (
113 await hasRevalidatedTag(validUniqueTags(entry.tags), entry.lastModified)
114 ) {
115 await store.delete(storageKey);
116 return null;
117 }
118 const softTags = validUniqueTags(readStringArrayField(ctx, "softTags"));
119 if (await hasRevalidatedTag(softTags, entry.lastModified)) return null;
120 if (entry.expireAt !== null && Date.now() > entry.expireAt) {
121 await store.delete(storageKey);
122 return null;
123 }
124 const now = Date.now();
125 const requestedRevalidate = readPositiveNumberField(ctx, "revalidate");
126 const requestedRevalidateAt =
127 requestedRevalidate === undefined
128 ? null
129 : entry.lastModified + requestedRevalidate * 1000;
130 if (
131 (entry.revalidateAt !== null && now > entry.revalidateAt) ||
132 (requestedRevalidateAt !== null && now > requestedRevalidateAt)
133 ) {
134 return {
135 lastModified: entry.lastModified,
136 value: restoredValue,
137 cacheState: "stale",
138 cacheControl: entry.cacheControl,
139 };
140 }
141 return {
142 lastModified: entry.lastModified,
143 value: restoredValue,
144 cacheControl: entry.cacheControl,
145 };
146 },
147
148 async set(
149 key: string,
150 data: Record<string, unknown> | null,
151 ctx?: Record<string, unknown>,
152 ) {
153 const tagSet = new Set<string>();
154 if (data && Array.isArray(data.tags)) {
155 for (const tag of data.tags) {
156 if (typeof tag === "string") {
157 const valid = validateTag(tag);
158 if (valid) tagSet.add(valid);
159 }
160 }
161 }
162 if (ctx && Array.isArray(ctx.tags)) {
163 for (const tag of ctx.tags) {
164 if (typeof tag === "string") {
165 const valid = validateTag(tag);
166 if (valid) tagSet.add(valid);
167 }
168 }
169 }
170 let effectiveRevalidate = readCacheControlNumberField(ctx, "revalidate");
171 const effectiveExpire = readCacheControlNumberField(ctx, "expire");
172 const effectiveStale = readCacheControlNumberField(ctx, "stale");
173 if (data && typeof data.revalidate === "number") {
174 effectiveRevalidate = data.revalidate;
175 }
176 if (effectiveRevalidate === 0) return;
177 const now = Date.now();
178 const revalidateAt =
179 typeof effectiveRevalidate === "number" && effectiveRevalidate > 0
180 ? now + effectiveRevalidate * 1000
181 : null;
182 const expireAt =
183 typeof effectiveExpire === "number" && effectiveExpire > 0
184 ? now + effectiveExpire * 1000
185 : null;
186 const cacheControl: StoredCacheControl | undefined =
187 typeof effectiveRevalidate === "number"
188 ? {
189 revalidate: effectiveRevalidate,
190 ...(effectiveExpire === undefined
191 ? {}
192 : { expire: effectiveExpire }),
193 ...(effectiveStale === undefined
194 ? {}
195 : { stale: effectiveStale }),
196 }
197 : undefined;
198 const entry = {
199 value: data ? serializeForJSON(data) : null,
200 tags: [...tagSet],
201 lastModified: now,
202 revalidateAt,
203 expireAt,
204 cacheControl,
205 };
206 await store.putText(
207 keys.entryKey(key),
208 JSON.stringify(entry),
209 ttlMsFor(expireAt, now),
210 );
211 },
212
213 async revalidateTag(tags: string | Array<string>) {
214 const tagList = Array.isArray(tags) ? tags : [tags];
215 const now = Date.now();
216 const validTags = tagList.filter((tag) => validateTag(tag) !== null);
217 const ttlMs = ttlSeconds * 1000;
218 await Promise.all(
219 validTags.map((tag) =>
220 store.putText(keys.tagKey(tag), String(now), ttlMs),
221 ),
222 );
223 for (const tag of validTags) {
224 tagCache.set(tag, { timestamp: now, fetchedAt: now });
225 }
226 },
227
228 resetRequestCache() {
229 tagCache.clear();
230 },
231 };
232};