| 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 | */ |
| 8 | const PATH_TAG_PREFIX = "_N_T_"; |
| 9 | const MAX_TAG_LENGTH = 256; |
| 10 | const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/; |
| 11 | const VALID_KINDS = new Set([ |
| 12 | "FETCH", |
| 13 | "APP_PAGE", |
| 14 | "PAGES", |
| 15 | "APP_ROUTE", |
| 16 | "REDIRECT", |
| 17 | "IMAGE", |
| 18 | ]); |
| 19 | |
| 20 | export const ENTRY_PREFIX = "cache:"; |
| 21 | export const TAG_PREFIX = "__tag:"; |
| 22 | /** Marker for logical keys hashed to fit Cloudflare KV's 512-byte limit. */ |
| 23 | export const HASHED_KEY_PREFIX = "__hash:"; |
| 24 | const KV_KEY_MAX_BYTES = 512; |
| 25 | const KEY_ENCODER = new TextEncoder(); |
| 26 | |
| 27 | /** FNV-1a 64-bit (two 32-bit rounds). Same algorithm vinext uses for KV keys. */ |
| 28 | export 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 | |
| 42 | const 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 | |
| 50 | const 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 | |
| 60 | export 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 | |
| 72 | export 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 | |
| 84 | export 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 | |
| 90 | export 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 | |
| 97 | export 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 | |
| 106 | export 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 | |
| 114 | export 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 | |
| 131 | export interface StoredCacheControl { |
| 132 | readonly revalidate: number; |
| 133 | readonly expire?: number; |
| 134 | readonly stale?: number; |
| 135 | } |
| 136 | |
| 137 | export 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 | |
| 146 | export const isUnknownRecord = ( |
| 147 | value: unknown, |
| 148 | ): value is Record<string, unknown> => |
| 149 | typeof value === "object" && value !== null; |
| 150 | |
| 151 | export 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 | |
| 181 | const arrayBufferToBase64 = (buffer: ArrayBuffer): string => |
| 182 | Buffer.from(buffer).toString("base64"); |
| 183 | |
| 184 | const 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 | |
| 194 | export 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 | |
| 225 | export 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 | |
| 241 | export 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 | */ |
| 255 | export 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 | }; |