1/**
2 * Shared ISR / `"use cache"` entry codec for vinext Node adapters.
3 *
4 * Mirrors `@vinext/cloudflare` KV data-cache serialization so Redis and
5 * S3 store the same JSON shape (ArrayBuffers as base64, tag invalidation
6 * timestamps, stale-while-revalidate).
7 */
8const PATH_TAG_PREFIX = "_N_T_";
9const MAX_TAG_LENGTH = 256;
10const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
11const VALID_KINDS = new Set([
12 "FETCH",
13 "APP_PAGE",
14 "PAGES",
15 "APP_ROUTE",
16 "REDIRECT",
17 "IMAGE",
18]);
19
20export const ENTRY_PREFIX = "cache:";
21export const TAG_PREFIX = "__tag:";
22/** Marker for logical keys hashed to fit Cloudflare KV's 512-byte limit. */
23export const HASHED_KEY_PREFIX = "__hash:";
24const KV_KEY_MAX_BYTES = 512;
25const KEY_ENCODER = new TextEncoder();
26
27/** FNV-1a 64-bit (two 32-bit rounds). Same algorithm vinext uses for KV keys. */
28export const fnv1a64 = (input: string): string => {
29 let h1 = 2166136261;
30 for (let i = 0; i < input.length; i++) {
31 h1 ^= input.charCodeAt(i);
32 h1 = (h1 * 16777619) >>> 0;
33 }
34 let h2 = 84696351;
35 for (let i = 0; i < input.length; i++) {
36 h2 ^= input.charCodeAt(i);
37 h2 = (h2 * 16777619) >>> 0;
38 }
39 return h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0");
40};
41
42const normalizeAppPrefix = (appPrefix: string | undefined): string => {
43 if (!appPrefix) return "";
44 const prefix = `${appPrefix}:`;
45 const sample = `${prefix}${ENTRY_PREFIX}${HASHED_KEY_PREFIX}${fnv1a64("")}`;
46 if (KEY_ENCODER.encode(sample).length <= KV_KEY_MAX_BYTES) return prefix;
47 return `__app:${fnv1a64(appPrefix)}:`;
48};
49
50const buildStorageKey = (
51 prefix: string,
52 categoryPrefix: string,
53 logicalKey: string,
54): string => {
55 const key = `${prefix}${categoryPrefix}${logicalKey}`;
56 if (KEY_ENCODER.encode(key).length <= KV_KEY_MAX_BYTES) return key;
57 return `${prefix}${categoryPrefix}${HASHED_KEY_PREFIX}${fnv1a64(logicalKey)}`;
58};
59
60export const validateTag = (tag: string): string | null => {
61 if (
62 typeof tag !== "string" ||
63 tag.length === 0 ||
64 tag.length > MAX_TAG_LENGTH
65 ) {
66 return null;
67 }
68 if (/[\x00-\x1f\\:]/.test(tag)) return null;
69 return tag;
70};
71
72export const validUniqueTags = (tags: ReadonlyArray<string>): Array<string> => {
73 const result: Array<string> = [];
74 const seen = new Set<string>();
75 for (const tag of tags) {
76 const valid = validateTag(tag);
77 if (!valid || seen.has(valid)) continue;
78 seen.add(valid);
79 result.push(valid);
80 }
81 return result;
82};
83
84export const isPathChildOf = (path: string, prefix: string): boolean => {
85 if (prefix === "/") return path.startsWith("/");
86 if (path === prefix) return true;
87 return path.startsWith(`${prefix}/`);
88};
89
90export const pathFromTag = (tag: string): string | undefined => {
91 const raw = tag.startsWith(PATH_TAG_PREFIX)
92 ? tag.slice(PATH_TAG_PREFIX.length)
93 : tag;
94 return raw.startsWith("/") ? raw : undefined;
95};
96
97export const readStringArrayField = (
98 ctx: Record<string, unknown> | undefined,
99 field: string,
100): Array<string> => {
101 const value = ctx?.[field];
102 if (!Array.isArray(value)) return [];
103 return value.filter((item): item is string => typeof item === "string");
104};
105
106export const readPositiveNumberField = (
107 ctx: Record<string, unknown> | undefined,
108 field: string,
109): number | undefined => {
110 const value = ctx?.[field];
111 return typeof value === "number" && value > 0 ? value : undefined;
112};
113
114export const readCacheControlNumberField = (
115 ctx: Record<string, unknown> | undefined,
116 field: string,
117): number | undefined => {
118 const control = ctx?.cacheControl;
119 if (
120 control !== undefined &&
121 typeof control === "object" &&
122 control !== null
123 ) {
124 const nested = (control as Record<string, unknown>)[field];
125 if (typeof nested === "number") return nested;
126 }
127 const value = ctx?.[field];
128 return typeof value === "number" ? value : undefined;
129};
130
131export interface StoredCacheControl {
132 readonly revalidate: number;
133 readonly expire?: number;
134 readonly stale?: number;
135}
136
137export interface StoredCacheEntry {
138 value: unknown;
139 tags: Array<string>;
140 lastModified: number;
141 revalidateAt: number | null;
142 expireAt: number | null;
143 cacheControl?: StoredCacheControl;
144}
145
146export const isUnknownRecord = (
147 value: unknown,
148): value is Record<string, unknown> =>
149 typeof value === "object" && value !== null;
150
151export const validateCacheEntry = (raw: unknown): StoredCacheEntry | null => {
152 if (!isUnknownRecord(raw)) return null;
153 if (typeof raw.lastModified !== "number") return null;
154 if (!Array.isArray(raw.tags)) return null;
155 if (raw.revalidateAt !== null && typeof raw.revalidateAt !== "number") {
156 return null;
157 }
158 if (
159 raw.expireAt !== undefined &&
160 raw.expireAt !== null &&
161 typeof raw.expireAt !== "number"
162 ) {
163 return null;
164 }
165 if (raw.cacheControl !== undefined) {
166 if (!isUnknownRecord(raw.cacheControl)) return null;
167 if (typeof raw.cacheControl.revalidate !== "number") return null;
168 }
169 if (raw.value !== null) {
170 if (!isUnknownRecord(raw.value)) return null;
171 if (
172 typeof raw.value.kind !== "string" ||
173 !VALID_KINDS.has(raw.value.kind)
174 ) {
175 return null;
176 }
177 }
178 return raw as unknown as StoredCacheEntry;
179};
180
181const arrayBufferToBase64 = (buffer: ArrayBuffer): string =>
182 Buffer.from(buffer).toString("base64");
183
184const safeBase64ToArrayBuffer = (base64: string): ArrayBuffer | null => {
185 if (!BASE64_RE.test(base64) || base64.length % 4 !== 0) return null;
186 try {
187 const buf = Buffer.from(base64, "base64");
188 return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
189 } catch {
190 return null;
191 }
192};
193
194export const serializeForJSON = (value: Record<string, unknown>): unknown => {
195 if (value.kind === "APP_PAGE") {
196 return {
197 ...value,
198 rscData:
199 value.rscData instanceof ArrayBuffer
200 ? arrayBufferToBase64(value.rscData)
201 : value.rscData,
202 };
203 }
204 if (value.kind === "APP_ROUTE") {
205 return {
206 ...value,
207 body:
208 value.body instanceof ArrayBuffer
209 ? arrayBufferToBase64(value.body)
210 : value.body,
211 };
212 }
213 if (value.kind === "IMAGE") {
214 return {
215 ...value,
216 buffer:
217 value.buffer instanceof ArrayBuffer
218 ? arrayBufferToBase64(value.buffer)
219 : value.buffer,
220 };
221 }
222 return value;
223};
224
225export const restoreArrayBuffers = (
226 value: Record<string, unknown>,
227): Record<string, unknown> | null => {
228 const restore = (field: string) => {
229 const raw = value[field];
230 if (typeof raw !== "string") return value;
231 const decoded = safeBase64ToArrayBuffer(raw);
232 if (!decoded) return null;
233 return { ...value, [field]: decoded };
234 };
235 if (value.kind === "APP_PAGE") return restore("rscData");
236 if (value.kind === "APP_ROUTE") return restore("body");
237 if (value.kind === "IMAGE") return restore("buffer");
238 return value;
239};
240
241export const keySpace = (appPrefix: string | undefined) => {
242 const prefix = normalizeAppPrefix(appPrefix);
243 return {
244 entryPrefix: `${prefix}${ENTRY_PREFIX}`,
245 entryKey: (key: string) => buildStorageKey(prefix, ENTRY_PREFIX, key),
246 tagKey: (tag: string) => buildStorageKey(prefix, TAG_PREFIX, tag),
247 };
248};
249
250/**
251 * Read a non-empty string from the adapter `env` bag, then `process.env`.
252 * Node `startProdServer` passes `process.env`; Pages Router registration
253 * sometimes calls the factory with no env at all.
254 */
255export const readEnvString = (
256 env: Record<string, unknown> | undefined,
257 key: string,
258): string | undefined => {
259 const fromEnv = env?.[key];
260 if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
261 if (typeof process === "undefined") return undefined;
262 const fromProcess = process.env?.[key];
263 if (typeof fromProcess === "string" && fromProcess.length > 0) {
264 return fromProcess;
265 }
266 return undefined;
267};