diff --git a/packages/vinext/package.json b/packages/vinext/package.json index 76191ee4..fdaa7d79 100644 --- a/packages/vinext/package.json +++ b/packages/vinext/package.json @@ -48,6 +48,10 @@ "./server/worker-utils": { "types": "./dist/server/worker-utils.d.ts", "import": "./dist/server/worker-utils.js" + }, + "./server/seed-cache-workers": { + "types": "./dist/server/seed-cache-workers.d.ts", + "import": "./dist/server/seed-cache-workers.js" } }, "scripts": { diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index 1d2efaca..af96cacd 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -453,6 +453,7 @@ import { setCacheHandler } from "vinext/shims/cache"; */ import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization"; import type { ImageConfig } from "vinext/server/image-optimization"; +import { seedRouteFromAssets } from "vinext/server/seed-cache-workers"; import handler from "vinext/server/app-router-entry"; ${isrImports} interface Env { @@ -495,6 +496,11 @@ ${isrSetup} const url = new URL(request.url); }, allowedWidths); } + // Seed the memory cache from pre-rendered assets (lazy, per-route). + // Blocking (not waitUntil) so the RSC handler sees the seeded cache. + // seedRouteFromAssets handles errors internally — never throws. + await seedRouteFromAssets(url.pathname, (p) => env.ASSETS.fetch(new Request(new URL(p, request.url)))); + // Delegate everything else to vinext, forwarding ctx so that // ctx.waitUntil() is available to background cache writes and // other deferred work via getRequestExecutionContext(). @@ -1195,6 +1201,29 @@ export function buildWranglerDeployArgs( return { args, env }; } +/** + * Copy pre-rendered files to dist/client/__prerender/ so they're deployed + * as static assets and accessible via env.ASSETS.fetch() at runtime. + * No-op if no prerender manifest exists. + */ +function copyPrerenderToAssets(root: string): void { + const serverDir = path.join(root, "dist", "server"); + const manifestPath = path.join(serverDir, "vinext-prerender.json"); + if (!fs.existsSync(manifestPath)) return; + + const targetDir = path.join(root, "dist", "client", "__prerender"); + fs.mkdirSync(targetDir, { recursive: true }); + + // Copy manifest + fs.copyFileSync(manifestPath, path.join(targetDir, "vinext-prerender.json")); + + // Copy prerendered HTML/RSC files + const sourceDir = path.join(serverDir, "prerendered-routes"); + if (!fs.existsSync(sourceDir)) return; + + fs.cpSync(sourceDir, targetDir, { recursive: true }); +} + function runWranglerDeploy(root: string, options: Pick): string { // Walk up ancestor directories so the binary is found even when node_modules // is hoisted to the workspace root in a monorepo. @@ -1356,6 +1385,12 @@ export async function deploy(options: DeployOptions): Promise { } } + // Step 6c: Copy prerender data to dist/client/__prerender/ so the Worker + // can access it via env.ASSETS.fetch() for lazy per-route cache seeding. + // With not_found_handling: "none", these files are never auto-served — + // only accessible programmatically through the assets binding. + copyPrerenderToAssets(root); + // Step 7: Deploy via wrangler const url = runWranglerDeploy(root, { preview: options.preview ?? false, diff --git a/packages/vinext/src/server/seed-cache-shared.ts b/packages/vinext/src/server/seed-cache-shared.ts new file mode 100644 index 00000000..38bcba70 --- /dev/null +++ b/packages/vinext/src/server/seed-cache-shared.ts @@ -0,0 +1,59 @@ +/** + * Shared types and helpers for seed-cache modules (Node.js and Workers). + * + * Both `seed-cache.ts` (eager, fs-based) and `seed-cache-workers.ts` + * (lazy, fetch-based) read the same vinext-prerender.json manifest and + * produce identical cache entries. This module holds the shared contract. + */ + +import type { CachedAppPageValue } from "../shims/cache.js"; + +// ─── Manifest types ────────────────────────────────────────────────────────── + +export interface PrerenderManifest { + buildId: string; + trailingSlash?: boolean; + routes: PrerenderManifestRoute[]; +} + +export interface PrerenderManifestRoute { + route: string; + status: string; + revalidate?: number | false; + path?: string; + router?: "app" | "pages"; +} + +// ─── Cache value construction ──────────────────────────────────────────────── + +/** + * Build the CacheHandler context object from a revalidate value. + * `revalidate: undefined` (static routes) → empty context → no expiry. + */ +export function revalidateCtx(seconds: number | undefined): Record { + return seconds !== undefined ? { revalidate: seconds } : {}; +} + +/** Build an APP_PAGE cache value for an HTML entry. */ +export function makeHtmlCacheValue(html: string): CachedAppPageValue { + return { + kind: "APP_PAGE", + html, + rscData: undefined, + headers: undefined, + postponed: undefined, + status: undefined, + }; +} + +/** Build an APP_PAGE cache value for an RSC entry. */ +export function makeRscCacheValue(rscData: ArrayBuffer): CachedAppPageValue { + return { + kind: "APP_PAGE", + html: "", + rscData, + headers: undefined, + postponed: undefined, + status: undefined, + }; +} diff --git a/packages/vinext/src/server/seed-cache-workers.ts b/packages/vinext/src/server/seed-cache-workers.ts new file mode 100644 index 00000000..d95aa9d8 --- /dev/null +++ b/packages/vinext/src/server/seed-cache-workers.ts @@ -0,0 +1,191 @@ +/** + * Workers-side lazy cache seeding from pre-rendered assets. + * + * On Cloudflare Workers, pre-rendered HTML/RSC files are deployed as static + * assets in `dist/client/__prerender/`. This module fetches them via the + * assets binding on first request for each route and populates the + * MemoryCacheHandler so subsequent requests are cache HITs. + * + * Seeding is lazy (per-route, on-demand) because: + * - Workers isolates are ephemeral and not guaranteed to serve all routes + * - The 128MB memory limit makes eager seeding of all routes wasteful + * - Concurrent requests to the same cold route are deduped + * + * Consistency model: + * - The manifest and prerendered files are deployed as static assets + * alongside the worker bundle. They are immutable for the lifetime of + * a deployment. A new deployment produces new assets, and old isolates + * are eventually terminated — no cross-deploy staleness is possible. + * - Cache keys include buildId, so even if an old isolate briefly coexists + * with a new deployment, the keys never collide. + * - Seeded entries are indistinguishable from entries created by the ISR + * render path: same cache value shape, same revalidate duration tracking, + * same cache key construction. + * + * Concurrency model: + * - A single Workers isolate can process concurrent requests. The manifest + * load is deduplicated via a cached promise (loadedPromise). Two + * concurrent calls to loadManifest both await the same promise — no + * double-fetch is possible because the assignment to loadedPromise + * happens synchronously before the first await. + * - Per-route seeding is deduplicated via seedInFlight. Between the + * getCacheHandler().get() check (async, yields) and the seedInFlight.get() + * check (sync, no yield), no other microtask can interleave. If two + * requests both pass the cache check, the first creates the in-flight + * promise and the second joins it. + */ + +import { getCacheHandler } from "../shims/cache.js"; +import { isrCacheKey, setRevalidateDuration } from "./isr-cache.js"; +import { getOutputPath, getRscOutputPath } from "../build/prerender.js"; +import { + type PrerenderManifest, + type PrerenderManifestRoute, + revalidateCtx, + makeHtmlCacheValue, + makeRscCacheValue, +} from "./seed-cache-shared.js"; + +/** Loaded manifest + pre-built route lookup. Returned as a unit so they can't desync. */ +interface LoadedManifest { + manifest: PrerenderManifest; + lookup: Map; +} + +// ─── Module-scope state (persists for the life of the isolate) ─────────────── + +/** Cached manifest — loaded once per isolate via the first seedRouteFromAssets call. */ +let loadedPromise: Promise | null = null; + +/** In-flight seeding promises — deduplicates concurrent cold hits for the same route. */ +const seedInFlight = new Map>(); + +// ─── Public API ────────────────────────────────────────────────────────────── + +/** Asset fetcher signature — wraps `env.ASSETS.fetch()` for testability. */ +export type AssetFetcher = (assetPath: string) => Promise; + +/** + * Lazily seed the MemoryCacheHandler for a single route from pre-rendered assets. + * + * Call this in the Worker's fetch handler before delegating to the RSC handler. + * If the route has pre-rendered data and isn't already cached, the HTML/RSC + * files are fetched from the assets binding and inserted into the cache. + * + * @param pathname - The request pathname (e.g. "/about") + * @param fetchAsset - Function that fetches from the assets binding + */ +export async function seedRouteFromAssets( + pathname: string, + fetchAsset: AssetFetcher, +): Promise { + try { + const loaded = await loadManifest(fetchAsset); + if (!loaded) return; + + const route = loaded.lookup.get(pathname); + if (!route) return; + + const baseKey = isrCacheKey("app", pathname, loaded.manifest.buildId); + const htmlKey = baseKey + ":html"; + + // Already in cache — nothing to do + const existing = await getCacheHandler().get(htmlKey); + if (existing) return; + + // Dedup concurrent cold hits — between the await above and this sync check, + // no other microtask can interleave, so at most one caller creates the promise. + const inflight = seedInFlight.get(htmlKey); + if (inflight) { + await inflight; + return; + } + + const revalidateSeconds = typeof route.revalidate === "number" ? route.revalidate : undefined; + const promise = doSeedRoute(loaded.manifest, pathname, baseKey, revalidateSeconds, fetchAsset) + .catch(() => {}) // seeding is best-effort — never propagate to joiners + .finally(() => seedInFlight.delete(htmlKey)); + + seedInFlight.set(htmlKey, promise); + await promise; + } catch { + // Catches errors from loadManifest, getCacheHandler().get(), or any + // unexpected throw before doSeedRoute. Never crash the request. + } +} + +/** + * Reset module-scope state. Only for use in tests. + * @internal + */ +export function _resetForTesting(): void { + loadedPromise = null; + seedInFlight.clear(); +} + +// ─── Internals ─────────────────────────────────────────────────────────────── + +async function loadManifest(fetchAsset: AssetFetcher): Promise { + if (!loadedPromise) { + // Assignment is synchronous — concurrent callers that arrive before the + // first await will see loadedPromise !== null and join the same promise. + loadedPromise = (async () => { + try { + const res = await fetchAsset("/__prerender/vinext-prerender.json"); + if (!res.ok) return null; + const manifest: PrerenderManifest = await res.json(); + if (!manifest.buildId || !Array.isArray(manifest.routes)) return null; + + const lookup = new Map(); + for (const route of manifest.routes) { + if (route.status !== "rendered") continue; + if (route.router !== "app") continue; + lookup.set(route.path ?? route.route, route); + } + + return { manifest, lookup }; + } catch { + // Transient failure (network error, timeout) — allow retry on next request. + // Permanent failures (!res.ok, invalid JSON structure) return null above + // without resetting, since those indicate a malformed deployment. + loadedPromise = null; + return null; + } + })(); + } + return loadedPromise; +} + +async function doSeedRoute( + manifest: PrerenderManifest, + pathname: string, + baseKey: string, + revalidateSeconds: number | undefined, + fetchAsset: AssetFetcher, +): Promise { + const trailingSlash = manifest.trailingSlash ?? false; + const handler = getCacheHandler(); + const ctx = revalidateCtx(revalidateSeconds); + + // Fetch and seed HTML + const htmlRelPath = getOutputPath(pathname, trailingSlash); + const htmlRes = await fetchAsset(`/__prerender/${htmlRelPath}`); + if (!htmlRes.ok) return; + + const htmlKey = baseKey + ":html"; + await handler.set(htmlKey, makeHtmlCacheValue(await htmlRes.text()), ctx); + if (revalidateSeconds !== undefined) { + setRevalidateDuration(htmlKey, revalidateSeconds); + } + + // Fetch and seed RSC (optional) + const rscRelPath = getRscOutputPath(pathname); + const rscRes = await fetchAsset(`/__prerender/${rscRelPath}`); + if (!rscRes.ok) return; + + const rscKey = baseKey + ":rsc"; + await handler.set(rscKey, makeRscCacheValue(await rscRes.arrayBuffer()), ctx); + if (revalidateSeconds !== undefined) { + setRevalidateDuration(rscKey, revalidateSeconds); + } +} diff --git a/packages/vinext/src/server/seed-cache.ts b/packages/vinext/src/server/seed-cache.ts index bffa72e9..225d9ac2 100644 --- a/packages/vinext/src/server/seed-cache.ts +++ b/packages/vinext/src/server/seed-cache.ts @@ -31,25 +31,15 @@ import fs from "node:fs"; import path from "node:path"; -import { getCacheHandler, type CachedAppPageValue } from "../shims/cache.js"; +import { getCacheHandler } from "../shims/cache.js"; import { isrCacheKey, setRevalidateDuration } from "./isr-cache.js"; import { getOutputPath, getRscOutputPath } from "../build/prerender.js"; - -// ─── Manifest types ─────────────────────────────────────────────────────────── - -interface PrerenderManifest { - buildId: string; - trailingSlash?: boolean; - routes: PrerenderManifestRoute[]; -} - -interface PrerenderManifestRoute { - route: string; - status: string; - revalidate?: number | false; - path?: string; - router?: "app" | "pages"; -} +import { + type PrerenderManifest, + revalidateCtx, + makeHtmlCacheValue, + makeRscCacheValue, +} from "./seed-cache-shared.js"; // ─── Public API ─────────────────────────────────────────────────────────────── @@ -103,14 +93,6 @@ export async function seedMemoryCacheFromPrerender(serverDir: string): Promise { - return seconds !== undefined ? { revalidate: seconds } : {}; -} - /** * Seed the HTML cache entry for a single route. * Returns true if the file existed and was seeded. @@ -127,17 +109,12 @@ async function seedHtml( const fullPath = path.join(prerenderDir, relPath); if (!fs.existsSync(fullPath)) return false; - const htmlValue: CachedAppPageValue = { - kind: "APP_PAGE", - html: fs.readFileSync(fullPath, "utf-8"), - rscData: undefined, - headers: undefined, - postponed: undefined, - status: undefined, - }; - const key = baseKey + ":html"; - await handler.set(key, htmlValue, revalidateCtx(revalidateSeconds)); + await handler.set( + key, + makeHtmlCacheValue(fs.readFileSync(fullPath, "utf-8")), + revalidateCtx(revalidateSeconds), + ); if (revalidateSeconds !== undefined) { setRevalidateDuration(key, revalidateSeconds); @@ -162,20 +139,12 @@ async function seedRsc( if (!fs.existsSync(fullPath)) return; const rscBuffer = fs.readFileSync(fullPath); - const rscValue: CachedAppPageValue = { - kind: "APP_PAGE", - html: "", - rscData: rscBuffer.buffer.slice( - rscBuffer.byteOffset, - rscBuffer.byteOffset + rscBuffer.byteLength, - ), - headers: undefined, - postponed: undefined, - status: undefined, - }; - + const rscData = rscBuffer.buffer.slice( + rscBuffer.byteOffset, + rscBuffer.byteOffset + rscBuffer.byteLength, + ); const key = baseKey + ":rsc"; - await handler.set(key, rscValue, revalidateCtx(revalidateSeconds)); + await handler.set(key, makeRscCacheValue(rscData), revalidateCtx(revalidateSeconds)); if (revalidateSeconds !== undefined) { setRevalidateDuration(key, revalidateSeconds); diff --git a/tests/seed-cache-workers.test.ts b/tests/seed-cache-workers.test.ts new file mode 100644 index 00000000..01cd8d04 --- /dev/null +++ b/tests/seed-cache-workers.test.ts @@ -0,0 +1,403 @@ +/** + * Tests for Workers-side lazy cache seeding from pre-rendered assets. + * + * Verifies that seedRouteFromAssets() fetches pre-rendered HTML/RSC from + * the assets binding and populates the MemoryCacheHandler per-route on + * first request, with dedup for concurrent cold hits. + */ +import { describe, it, expect, beforeEach } from "vite-plus/test"; +import { + MemoryCacheHandler, + setCacheHandler, + getCacheHandler, +} from "../packages/vinext/src/shims/cache.js"; +import { isrCacheKey, getRevalidateDuration } from "../packages/vinext/src/server/isr-cache.js"; +import { + seedRouteFromAssets, + _resetForTesting, +} from "../packages/vinext/src/server/seed-cache-workers.js"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const BUILD_ID = "workers-test-build"; + +/** Minimal prerender manifest for testing. */ +function makeManifest(routes: unknown[], trailingSlash = false) { + return { + buildId: BUILD_ID, + trailingSlash, + routes, + }; +} + +/** + * Create a mock asset fetcher backed by an in-memory file map. + * Simulates env.ASSETS.fetch() without a real Workers environment. + */ +function createMockFetcher(files: Record) { + const calls: string[] = []; + const fetcher = async (assetPath: string): Promise => { + calls.push(assetPath); + const content = files[assetPath]; + if (content === undefined) { + return new Response("Not Found", { status: 404 }); + } + return new Response(content, { status: 200 }); + }; + return { fetcher, calls }; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("seedRouteFromAssets", () => { + beforeEach(() => { + setCacheHandler(new MemoryCacheHandler()); + _resetForTesting(); + }); + + // ── Basic seeding ───────────────────────────────────────────────────────── + + it("seeds HTML and RSC from assets on cache miss", async () => { + const manifest = makeManifest([ + { route: "/about", status: "rendered", revalidate: 60, router: "app" }, + ]); + const { fetcher } = createMockFetcher({ + "/__prerender/vinext-prerender.json": JSON.stringify(manifest), + "/__prerender/about.html": "About", + "/__prerender/about.rsc": "RSC about payload", + }); + + await seedRouteFromAssets("/about", fetcher); + + // HTML should be cached + const htmlKey = isrCacheKey("app", "/about", BUILD_ID) + ":html"; + const htmlEntry = await getCacheHandler().get(htmlKey); + expect(htmlEntry).not.toBeNull(); + expect(htmlEntry?.value?.kind).toBe("APP_PAGE"); + if (htmlEntry?.value?.kind === "APP_PAGE") { + expect(htmlEntry.value.html).toBe("About"); + } + + // RSC should be cached + const rscKey = isrCacheKey("app", "/about", BUILD_ID) + ":rsc"; + const rscEntry = await getCacheHandler().get(rscKey); + expect(rscEntry).not.toBeNull(); + if (rscEntry?.value?.kind === "APP_PAGE") { + const rscText = new TextDecoder().decode(rscEntry.value.rscData!); + expect(rscText).toBe("RSC about payload"); + } + }); + + it("seeds the index route", async () => { + const manifest = makeManifest([ + { route: "/", status: "rendered", revalidate: 30, router: "app" }, + ]); + const { fetcher } = createMockFetcher({ + "/__prerender/vinext-prerender.json": JSON.stringify(manifest), + "/__prerender/index.html": "Home", + "/__prerender/index.rsc": "RSC home", + }); + + await seedRouteFromAssets("/", fetcher); + + const htmlKey = isrCacheKey("app", "/", BUILD_ID) + ":html"; + const entry = await getCacheHandler().get(htmlKey); + expect(entry).not.toBeNull(); + }); + + it("seeds dynamic routes using their concrete path", async () => { + const manifest = makeManifest([ + { + route: "/blog/:slug", + status: "rendered", + revalidate: 120, + path: "/blog/hello", + router: "app", + }, + ]); + const { fetcher } = createMockFetcher({ + "/__prerender/vinext-prerender.json": JSON.stringify(manifest), + "/__prerender/blog/hello.html": "Blog", + "/__prerender/blog/hello.rsc": "RSC blog", + }); + + await seedRouteFromAssets("/blog/hello", fetcher); + + const htmlKey = isrCacheKey("app", "/blog/hello", BUILD_ID) + ":html"; + expect(await getCacheHandler().get(htmlKey)).not.toBeNull(); + }); + + // ── No-ops ──────────────────────────────────────────────────────────────── + + it("is a no-op when manifest is missing from assets", async () => { + const { fetcher } = createMockFetcher({}); + + await seedRouteFromAssets("/about", fetcher); + + const htmlKey = isrCacheKey("app", "/about", BUILD_ID) + ":html"; + expect(await getCacheHandler().get(htmlKey)).toBeNull(); + }); + + it("is a no-op for non-prerendered routes", async () => { + const manifest = makeManifest([ + { route: "/about", status: "rendered", revalidate: 60, router: "app" }, + ]); + const { fetcher } = createMockFetcher({ + "/__prerender/vinext-prerender.json": JSON.stringify(manifest), + }); + + await seedRouteFromAssets("/not-prerendered", fetcher); + + const htmlKey = isrCacheKey("app", "/not-prerendered", BUILD_ID) + ":html"; + expect(await getCacheHandler().get(htmlKey)).toBeNull(); + }); + + it("is a no-op when route is already cached", async () => { + const manifest = makeManifest([ + { route: "/about", status: "rendered", revalidate: 60, router: "app" }, + ]); + const { fetcher, calls } = createMockFetcher({ + "/__prerender/vinext-prerender.json": JSON.stringify(manifest), + "/__prerender/about.html": "About", + "/__prerender/about.rsc": "RSC about", + }); + + // Seed once + await seedRouteFromAssets("/about", fetcher); + const callsAfterFirst = calls.length; + + // Second call should not fetch assets again + await seedRouteFromAssets("/about", fetcher); + expect(calls.length).toBe(callsAfterFirst); + }); + + // ── Concurrent dedup ────────────────────────────────────────────────────── + + it("deduplicates concurrent seed requests for the same route", async () => { + const manifest = makeManifest([ + { route: "/about", status: "rendered", revalidate: 60, router: "app" }, + ]); + const { fetcher, calls } = createMockFetcher({ + "/__prerender/vinext-prerender.json": JSON.stringify(manifest), + "/__prerender/about.html": "About", + "/__prerender/about.rsc": "RSC about", + }); + + // Fire two seeds concurrently + await Promise.all([ + seedRouteFromAssets("/about", fetcher), + seedRouteFromAssets("/about", fetcher), + ]); + + // HTML file should only be fetched once (deduped) + const htmlFetches = calls.filter((c) => c === "/__prerender/about.html"); + expect(htmlFetches.length).toBe(1); + }); + + // ── Manifest caching ───────────────────────────────────────────────────── + + it("caches the manifest across multiple routes", async () => { + const manifest = makeManifest([ + { route: "/a", status: "rendered", revalidate: 60, router: "app" }, + { route: "/b", status: "rendered", revalidate: 60, router: "app" }, + ]); + const { fetcher, calls } = createMockFetcher({ + "/__prerender/vinext-prerender.json": JSON.stringify(manifest), + "/__prerender/a.html": "A", + "/__prerender/a.rsc": "RSC a", + "/__prerender/b.html": "B", + "/__prerender/b.rsc": "RSC b", + }); + + await seedRouteFromAssets("/a", fetcher); + await seedRouteFromAssets("/b", fetcher); + + // Manifest should be fetched only once + const manifestFetches = calls.filter((c) => c.includes("vinext-prerender.json")); + expect(manifestFetches.length).toBe(1); + }); + + // ── Revalidate duration tracking ──────────────────────────────────────── + + it("populates revalidate duration map for ISR routes", async () => { + const manifest = makeManifest([ + { route: "/isr", status: "rendered", revalidate: 45, router: "app" }, + ]); + const { fetcher } = createMockFetcher({ + "/__prerender/vinext-prerender.json": JSON.stringify(manifest), + "/__prerender/isr.html": "ISR", + "/__prerender/isr.rsc": "RSC isr", + }); + + await seedRouteFromAssets("/isr", fetcher); + + const baseKey = isrCacheKey("app", "/isr", BUILD_ID); + expect(getRevalidateDuration(baseKey + ":html")).toBe(45); + expect(getRevalidateDuration(baseKey + ":rsc")).toBe(45); + }); + + it("does not set revalidate duration for static routes", async () => { + const manifest = makeManifest([ + { route: "/static", status: "rendered", revalidate: false, router: "app" }, + ]); + const { fetcher } = createMockFetcher({ + "/__prerender/vinext-prerender.json": JSON.stringify(manifest), + "/__prerender/static.html": "Static", + "/__prerender/static.rsc": "RSC static", + }); + + await seedRouteFromAssets("/static", fetcher); + + const baseKey = isrCacheKey("app", "/static", BUILD_ID); + expect(getRevalidateDuration(baseKey + ":html")).toBeUndefined(); + expect(getRevalidateDuration(baseKey + ":rsc")).toBeUndefined(); + }); + + // ── Concurrent dedup (with async yield) ───────────────────────────────── + + it("deduplicates concurrent seed requests even when fetches yield", async () => { + const manifest = makeManifest([ + { route: "/slow", status: "rendered", revalidate: 60, router: "app" }, + ]); + const calls: string[] = []; + const fetcher = async (assetPath: string): Promise => { + calls.push(assetPath); + // Yield to the event loop to simulate real async I/O + await new Promise((resolve) => setTimeout(resolve, 0)); + if (assetPath.includes("vinext-prerender.json")) { + return new Response(JSON.stringify(manifest), { status: 200 }); + } + if (assetPath.includes("slow.html")) { + return new Response("Slow", { status: 200 }); + } + if (assetPath.includes("slow.rsc")) { + return new Response("RSC slow", { status: 200 }); + } + return new Response("Not Found", { status: 404 }); + }; + + await Promise.all([ + seedRouteFromAssets("/slow", fetcher), + seedRouteFromAssets("/slow", fetcher), + ]); + + const htmlFetches = calls.filter((c) => c === "/__prerender/slow.html"); + expect(htmlFetches.length).toBe(1); + }); + + // ── Error resilience ─────────────────────────────────────────────────────── + + it("does not throw when asset fetch fails", async () => { + const manifest = makeManifest([ + { route: "/fail", status: "rendered", revalidate: 60, router: "app" }, + ]); + const fetcher = async (assetPath: string): Promise => { + if (assetPath.includes("vinext-prerender.json")) { + return new Response(JSON.stringify(manifest), { status: 200 }); + } + throw new Error("network failure"); + }; + + // Should not throw — seeding is best-effort + await seedRouteFromAssets("/fail", fetcher); + + const htmlKey = isrCacheKey("app", "/fail", BUILD_ID) + ":html"; + expect(await getCacheHandler().get(htmlKey)).toBeNull(); + }); + + it("does not throw when cache handler fails", async () => { + const manifest = makeManifest([ + { route: "/broken", status: "rendered", revalidate: 60, router: "app" }, + ]); + const { fetcher } = createMockFetcher({ + "/__prerender/vinext-prerender.json": JSON.stringify(manifest), + "/__prerender/broken.html": "Broken", + "/__prerender/broken.rsc": "RSC broken", + }); + + // Sabotage the cache handler + const handler = getCacheHandler(); + handler.set = () => { + throw new Error("cache write failed"); + }; + + await seedRouteFromAssets("/broken", fetcher); + // No assertion needed — test passes if it doesn't throw + }); + + it("retries manifest load after transient fetch failure", async () => { + let manifestCallCount = 0; + const manifest = makeManifest([ + { route: "/retry", status: "rendered", revalidate: 60, router: "app" }, + ]); + + const fetcher = async (assetPath: string): Promise => { + if (assetPath.includes("vinext-prerender.json")) { + manifestCallCount++; + if (manifestCallCount === 1) { + throw new Error("transient network error"); + } + return new Response(JSON.stringify(manifest), { status: 200 }); + } + if (assetPath.includes("retry.html")) { + return new Response("Retry", { status: 200 }); + } + if (assetPath.includes("retry.rsc")) { + return new Response("RSC retry", { status: 200 }); + } + return new Response("Not Found", { status: 404 }); + }; + + // First call — transient failure, no seeding + await seedRouteFromAssets("/retry", fetcher); + expect( + await getCacheHandler().get(isrCacheKey("app", "/retry", BUILD_ID) + ":html"), + ).toBeNull(); + + // Second call — should retry manifest load and succeed + await seedRouteFromAssets("/retry", fetcher); + expect( + await getCacheHandler().get(isrCacheKey("app", "/retry", BUILD_ID) + ":html"), + ).not.toBeNull(); + expect(manifestCallCount).toBe(2); + }); + + it("does not retry manifest load after permanent 404", async () => { + let manifestCallCount = 0; + + const fetcher = async (assetPath: string): Promise => { + if (assetPath.includes("vinext-prerender.json")) { + manifestCallCount++; + return new Response("Not Found", { status: 404 }); + } + return new Response("Not Found", { status: 404 }); + }; + + await seedRouteFromAssets("/a", fetcher); + await seedRouteFromAssets("/b", fetcher); + + // Manifest 404 is permanent — should not retry + expect(manifestCallCount).toBe(1); + }); + + // ── Graceful degradation ────────────────────────────────────────────────── + + it("seeds HTML even when RSC file is missing", async () => { + const manifest = makeManifest([ + { route: "/html-only", status: "rendered", revalidate: 60, router: "app" }, + ]); + const { fetcher } = createMockFetcher({ + "/__prerender/vinext-prerender.json": JSON.stringify(manifest), + "/__prerender/html-only.html": "HTML only", + // No .rsc file + }); + + await seedRouteFromAssets("/html-only", fetcher); + + const htmlKey = isrCacheKey("app", "/html-only", BUILD_ID) + ":html"; + expect(await getCacheHandler().get(htmlKey)).not.toBeNull(); + + const rscKey = isrCacheKey("app", "/html-only", BUILD_ID) + ":rsc"; + expect(await getCacheHandler().get(rscKey)).toBeNull(); + }); +});