| 1 | import fs from "node:fs"; |
| 2 | import nodePath from "node:path"; |
| 3 | import { pathToFileURL } from "node:url"; |
| 4 | import { keySpace as makeKeySpace } from "./cache/shared.ts"; |
| 5 | |
| 6 | export const VINEXT_KV_CACHE_BINDING = "VINEXT_KV_CACHE"; |
| 7 | export const VINEXT_CACHE_BINDING = "VINEXT_CACHE"; |
| 8 | |
| 9 | const DEFAULT_KV_TTL_SECONDS = 30 * 24 * 3600; |
| 10 | |
| 11 | type CacheControlMetadata = { |
| 12 | revalidate: number; |
| 13 | expire?: number; |
| 14 | stale?: number; |
| 15 | }; |
| 16 | |
| 17 | export type VinextPrerenderKVPair = { |
| 18 | key: string; |
| 19 | value: string; |
| 20 | expirationTtl?: number; |
| 21 | metadata?: Record<string, unknown>; |
| 22 | }; |
| 23 | |
| 24 | export const vinextCacheNamespaceFromEnv = ( |
| 25 | env: Record<string, unknown>, |
| 26 | ): { namespaceId: string; accountId?: string } | undefined => { |
| 27 | const raw = env[VINEXT_KV_CACHE_BINDING] ?? env[VINEXT_CACHE_BINDING]; |
| 28 | if (!raw || typeof raw !== "object") { |
| 29 | return undefined; |
| 30 | } |
| 31 | const namespaceId = (raw as { namespaceId?: unknown }).namespaceId; |
| 32 | const accountId = (raw as { accountId?: unknown }).accountId; |
| 33 | if (typeof namespaceId !== "string") { |
| 34 | return undefined; |
| 35 | } |
| 36 | return { |
| 37 | namespaceId, |
| 38 | accountId: typeof accountId === "string" ? accountId : undefined, |
| 39 | }; |
| 40 | }; |
| 41 | |
| 42 | /** |
| 43 | * Walk prerender artifacts with Node fs. vinext's internals are Promise |
| 44 | * APIs; the walk stays async and reports skip warnings for the Effect |
| 45 | * caller to log. |
| 46 | */ |
| 47 | export const buildVinextPrerenderKVPairs = async ( |
| 48 | vinextRoot: string, |
| 49 | serverDir: string, |
| 50 | ): Promise<{ |
| 51 | routeCount: number; |
| 52 | pairs: VinextPrerenderKVPair[]; |
| 53 | warnings: string[]; |
| 54 | }> => { |
| 55 | const importVinextDist = (rel: string) => |
| 56 | import(pathToFileURL(nodePath.join(vinextRoot, rel)).href); |
| 57 | |
| 58 | const [ |
| 59 | { readPrerenderManifest, getRenderedAppRoutes, getRenderedMetadataRoutes }, |
| 60 | { appIsrCacheKey }, |
| 61 | { buildAppPageCacheTags, buildAppRouteCacheTags }, |
| 62 | { normalizePregeneratedPathname }, |
| 63 | { getAppRouteOutputPath, getOutputPath, getRscOutputPath }, |
| 64 | ] = await Promise.all([ |
| 65 | importVinextDist("dist/server/prerender-manifest.js"), |
| 66 | importVinextDist("dist/server/isr-cache.js"), |
| 67 | importVinextDist("dist/server/app-page-cache.js"), |
| 68 | importVinextDist("dist/server/pregenerated-concrete-paths.js"), |
| 69 | importVinextDist("dist/utils/prerender-output-paths.js"), |
| 70 | ]); |
| 71 | |
| 72 | const manifestPath = nodePath.join(serverDir, "vinext-prerender.json"); |
| 73 | const manifest = readPrerenderManifest(manifestPath); |
| 74 | if (!manifest?.buildId || !Array.isArray(manifest.routes)) { |
| 75 | return { routeCount: 0, pairs: [], warnings: [] }; |
| 76 | } |
| 77 | |
| 78 | const prerenderDir = nodePath.join(serverDir, "prerendered-routes"); |
| 79 | if (!fs.existsSync(prerenderDir)) { |
| 80 | return { routeCount: 0, pairs: [], warnings: [] }; |
| 81 | } |
| 82 | |
| 83 | const pairs: VinextPrerenderKVPair[] = []; |
| 84 | const warnings: string[] = []; |
| 85 | const now = Date.now(); |
| 86 | const trailingSlash = manifest.trailingSlash ?? false; |
| 87 | const keySpace = makeKeySpace(undefined); |
| 88 | let routeCount = 0; |
| 89 | |
| 90 | for (const route of getRenderedAppRoutes(manifest.routes)) { |
| 91 | const artifactPathname = route.path ?? route.route; |
| 92 | const cachePathname = normalizePregeneratedPathname(artifactPathname); |
| 93 | let htmlPath: string; |
| 94 | let rscPath: string; |
| 95 | try { |
| 96 | htmlPath = resolveContainedFile( |
| 97 | prerenderDir, |
| 98 | getOutputPath(artifactPathname, trailingSlash), |
| 99 | ); |
| 100 | rscPath = resolveContainedFile( |
| 101 | prerenderDir, |
| 102 | getRscOutputPath(artifactPathname), |
| 103 | ); |
| 104 | } catch (error) { |
| 105 | warnings.push( |
| 106 | `[vinext] Skipping prerender KV seed for ${artifactPathname}: ${formatUnknownError(error)}`, |
| 107 | ); |
| 108 | continue; |
| 109 | } |
| 110 | if (!fs.existsSync(htmlPath)) continue; |
| 111 | |
| 112 | if (typeof route.revalidate === "number" && route.revalidate <= 0) continue; |
| 113 | const revalidateSeconds = |
| 114 | typeof route.revalidate === "number" ? route.revalidate : undefined; |
| 115 | const expireSeconds = |
| 116 | typeof route.expire === "number" ? route.expire : undefined; |
| 117 | const staleSeconds = |
| 118 | typeof route.stale === "number" && route.stale >= 0 |
| 119 | ? route.stale |
| 120 | : undefined; |
| 121 | const expirationTtl = |
| 122 | revalidateSeconds === undefined ? undefined : DEFAULT_KV_TTL_SECONDS; |
| 123 | const tags = buildAppPageCacheTags(cachePathname, route.tags ?? []); |
| 124 | const metadata = buildMetadata(tags); |
| 125 | const htmlKey = appIsrCacheKey(cachePathname, "html", manifest.buildId); |
| 126 | const rscKey = appIsrCacheKey(cachePathname, "rsc", manifest.buildId); |
| 127 | |
| 128 | pairs.push({ |
| 129 | key: keySpace.entryKey(htmlKey), |
| 130 | value: buildCacheEntry( |
| 131 | { |
| 132 | kind: "APP_PAGE", |
| 133 | html: fs.readFileSync(htmlPath, "utf-8"), |
| 134 | headers: route.headers, |
| 135 | }, |
| 136 | tags, |
| 137 | now, |
| 138 | revalidateSeconds, |
| 139 | expireSeconds, |
| 140 | staleSeconds, |
| 141 | ), |
| 142 | ...(expirationTtl !== undefined ? { expirationTtl } : {}), |
| 143 | ...(metadata ? { metadata } : {}), |
| 144 | }); |
| 145 | |
| 146 | if (fs.existsSync(rscPath)) { |
| 147 | const rscData = fs.readFileSync(rscPath, "base64"); |
| 148 | pairs.push({ |
| 149 | key: keySpace.entryKey(rscKey), |
| 150 | value: buildCacheEntry( |
| 151 | { |
| 152 | kind: "APP_PAGE", |
| 153 | html: "", |
| 154 | rscData, |
| 155 | }, |
| 156 | tags, |
| 157 | now, |
| 158 | revalidateSeconds, |
| 159 | expireSeconds, |
| 160 | staleSeconds, |
| 161 | ), |
| 162 | ...(expirationTtl !== undefined ? { expirationTtl } : {}), |
| 163 | ...(metadata ? { metadata } : {}), |
| 164 | }); |
| 165 | } |
| 166 | |
| 167 | routeCount++; |
| 168 | } |
| 169 | |
| 170 | for (const route of getRenderedMetadataRoutes(manifest.routes)) { |
| 171 | const pathname = route.path ?? route.route; |
| 172 | let artifactPath: string; |
| 173 | try { |
| 174 | artifactPath = resolveContainedFile( |
| 175 | prerenderDir, |
| 176 | getAppRouteOutputPath(pathname), |
| 177 | ); |
| 178 | } catch (error) { |
| 179 | warnings.push( |
| 180 | `[vinext] Skipping metadata prerender KV seed for ${pathname}: ${formatUnknownError(error)}`, |
| 181 | ); |
| 182 | continue; |
| 183 | } |
| 184 | if (!fs.existsSync(artifactPath)) continue; |
| 185 | |
| 186 | const cachePathname = normalizePregeneratedPathname(pathname); |
| 187 | if (typeof route.revalidate === "number" && route.revalidate <= 0) continue; |
| 188 | const revalidateSeconds = |
| 189 | typeof route.revalidate === "number" ? route.revalidate : undefined; |
| 190 | const expireSeconds = |
| 191 | typeof route.expire === "number" ? route.expire : undefined; |
| 192 | const staleSeconds = |
| 193 | typeof route.stale === "number" && route.stale >= 0 |
| 194 | ? route.stale |
| 195 | : undefined; |
| 196 | const expirationTtl = |
| 197 | revalidateSeconds === undefined ? undefined : DEFAULT_KV_TTL_SECONDS; |
| 198 | const tags = buildAppRouteCacheTags( |
| 199 | cachePathname, |
| 200 | route.tags ?? [], |
| 201 | route.routeSegments ?? [], |
| 202 | ); |
| 203 | const metadata = buildMetadata(tags); |
| 204 | const routeKey = appIsrCacheKey(cachePathname, "route", manifest.buildId); |
| 205 | pairs.push({ |
| 206 | key: keySpace.entryKey(routeKey), |
| 207 | value: buildCacheEntry( |
| 208 | { |
| 209 | kind: "APP_ROUTE", |
| 210 | body: fs.readFileSync(artifactPath, "base64"), |
| 211 | headers: route.headers ?? {}, |
| 212 | status: route.responseStatus ?? 200, |
| 213 | }, |
| 214 | tags, |
| 215 | now, |
| 216 | revalidateSeconds, |
| 217 | expireSeconds, |
| 218 | staleSeconds, |
| 219 | ), |
| 220 | ...(expirationTtl !== undefined ? { expirationTtl } : {}), |
| 221 | ...(metadata ? { metadata } : {}), |
| 222 | }); |
| 223 | routeCount++; |
| 224 | } |
| 225 | |
| 226 | return { routeCount, pairs, warnings }; |
| 227 | }; |
| 228 | |
| 229 | const resolveContainedFile = ( |
| 230 | rootDir: string, |
| 231 | relativePath: string, |
| 232 | ): string => { |
| 233 | const resolvedRoot = nodePath.resolve(rootDir); |
| 234 | const resolvedFile = nodePath.resolve(resolvedRoot, relativePath); |
| 235 | const relative = nodePath.relative(resolvedRoot, resolvedFile); |
| 236 | if ( |
| 237 | relative === "" || |
| 238 | relative.startsWith("..") || |
| 239 | nodePath.isAbsolute(relative) |
| 240 | ) { |
| 241 | throw new Error( |
| 242 | `[vinext] Refusing to read prerender artifact outside ${resolvedRoot}`, |
| 243 | ); |
| 244 | } |
| 245 | return resolvedFile; |
| 246 | }; |
| 247 | |
| 248 | const buildCacheEntry = ( |
| 249 | value: Record<string, unknown>, |
| 250 | tags: string[], |
| 251 | now: number, |
| 252 | revalidateSeconds: number | undefined, |
| 253 | expireSeconds: number | undefined, |
| 254 | staleSeconds: number | undefined, |
| 255 | ): string => { |
| 256 | const cacheControl: CacheControlMetadata | undefined = |
| 257 | revalidateSeconds === undefined |
| 258 | ? undefined |
| 259 | : { |
| 260 | revalidate: revalidateSeconds, |
| 261 | ...(expireSeconds === undefined ? {} : { expire: expireSeconds }), |
| 262 | ...(staleSeconds === undefined ? {} : { stale: staleSeconds }), |
| 263 | }; |
| 264 | |
| 265 | return JSON.stringify({ |
| 266 | value, |
| 267 | tags, |
| 268 | lastModified: now, |
| 269 | revalidateAt: |
| 270 | revalidateSeconds === undefined ? null : now + revalidateSeconds * 1000, |
| 271 | expireAt: expireSeconds === undefined ? null : now + expireSeconds * 1000, |
| 272 | ...(cacheControl ? { cacheControl } : {}), |
| 273 | }); |
| 274 | }; |
| 275 | |
| 276 | const buildMetadata = (tags: string[]): Record<string, unknown> | undefined => { |
| 277 | const metadata = { tags }; |
| 278 | return JSON.stringify(metadata).length <= 1024 ? metadata : undefined; |
| 279 | }; |
| 280 | |
| 281 | const formatUnknownError = (error: unknown): string => { |
| 282 | if (error instanceof Error && error.message) return error.message; |
| 283 | return String(error); |
| 284 | }; |