1import fs from "node:fs";
2import nodePath from "node:path";
3import { pathToFileURL } from "node:url";
4import { keySpace as makeKeySpace } from "./cache/shared.ts";
5
6export const VINEXT_KV_CACHE_BINDING = "VINEXT_KV_CACHE";
7export const VINEXT_CACHE_BINDING = "VINEXT_CACHE";
8
9const DEFAULT_KV_TTL_SECONDS = 30 * 24 * 3600;
10
11type CacheControlMetadata = {
12 revalidate: number;
13 expire?: number;
14 stale?: number;
15};
16
17export type VinextPrerenderKVPair = {
18 key: string;
19 value: string;
20 expirationTtl?: number;
21 metadata?: Record<string, unknown>;
22};
23
24export 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 */
47export 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
229const 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
248const 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
276const buildMetadata = (tags: string[]): Record<string, unknown> | undefined => {
277 const metadata = { tags };
278 return JSON.stringify(metadata).length <= 1024 ? metadata : undefined;
279};
280
281const formatUnknownError = (error: unknown): string => {
282 if (error instanceof Error && error.message) return error.message;
283 return String(error);
284};