diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index b2522365..8b886ac4 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -612,6 +612,7 @@ async function deployCommand() { tprCoverage: parsed.tprCoverage, tprLimit: parsed.tprLimit, tprWindow: parsed.tprWindow, + noSeedCache: parsed.noSeedCache, }); } diff --git a/packages/vinext/src/cloudflare/seed-kv-cache.ts b/packages/vinext/src/cloudflare/seed-kv-cache.ts new file mode 100644 index 00000000..d868a482 --- /dev/null +++ b/packages/vinext/src/cloudflare/seed-kv-cache.ts @@ -0,0 +1,299 @@ +/** + * Seed Workers KV cache from pre-rendered build output at deploy time. + * + * Reads `vinext-prerender.json` and the corresponding HTML/RSC files from + * `dist/server/prerendered-routes/`, constructs cache entries in the exact + * format that `KVCacheHandler.get()` expects at runtime, then uploads them + * to KV via the Cloudflare bulk REST API. + * + * This runs as a deploy-time step in Node.js — never inside a Worker. + * Deploy-time deps (fs, fetch to Cloudflare API) are not bundled into the + * worker output because this module is only imported by `deploy.ts`. + * + * Cache key format (must match `KVCacheHandler.get()` at runtime): + * KV key = ENTRY_PREFIX + isrCacheKey(router, pathname, buildId) + suffix + * e.g. "cache:app:abc123:/blog/hello:html" + * + * Cache value format (must match `KVCacheEntry` in kv-cache-handler.ts): + * { value: SerializedIncrementalCacheValue, tags: string[], lastModified, revalidateAt } + * ArrayBuffers (rscData) are base64-encoded for JSON storage. + * + * Limitations: + * - Only App Router routes are seeded (Pages Router not yet supported). + * - Tags are not populated (they are computed at render time, not statically + * known). This means `revalidatePath()` calls will not invalidate seeded + * entries until they are overwritten by a live request through the ISR path. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { isrCacheKey } from "../server/isr-cache.js"; +import { getOutputPath, getRscOutputPath } from "../build/prerender.js"; + +// ─── Constants ────────────────────────────────────────────────────────────── + +/** Must match ENTRY_PREFIX in kv-cache-handler.ts */ +const ENTRY_PREFIX = "cache:"; + +/** KV bulk API accepts up to 10,000 pairs per request */ +const BATCH_SIZE = 10_000; + +/** Minimum KV expiration TTL (Cloudflare minimum is 60 seconds) */ +const MIN_KV_TTL_SECONDS = 60; + +/** Default KV expiration TTL: 30 days */ +const DEFAULT_KV_TTL_SECONDS = 30 * 24 * 3600; + +/** Max characters of API error body to include in error messages */ +const MAX_ERROR_BODY_LENGTH = 500; + +// ─── Types ────────────────────────────────────────────────────────────────── + +type PrerenderManifest = { + buildId: string; + trailingSlash?: boolean; + routes: PrerenderManifestRoute[]; +}; + +type PrerenderManifestRoute = { + route: string; + status: string; + revalidate?: number | false; + path?: string; + router?: "app" | "pages"; +}; + +export type SeedKVOptions = { + /** Project root directory (where dist/ lives) */ + root: string; + /** Cloudflare account ID */ + accountId: string; + /** KV namespace ID for VINEXT_CACHE */ + namespaceId: string; + /** Cloudflare API token with KV write permissions */ + apiToken: string; +}; + +export type SeedKVResult = { + /** Number of routes seeded (each route = up to 2 KV entries: html + rsc) */ + seededRoutes: number; + /** Number of KV pairs uploaded */ + kvPairsUploaded: number; + /** Duration in milliseconds */ + durationMs: number; + /** If set, seeding was skipped with this reason */ + skipped?: string; +}; + +// ─── Public API ───────────────────────────────────────────────────────────── + +/** + * Read pre-rendered routes from disk and upload them to Workers KV. + * + * This is the deploy-time equivalent of `seedMemoryCacheFromPrerender()` — + * instead of populating an in-memory CacheHandler, it writes directly to KV + * via the Cloudflare REST API so the entries are available the moment the + * Worker goes live. + */ +export async function seedKVCacheFromPrerender(options: SeedKVOptions): Promise { + const startTime = Date.now(); + const { root, accountId, namespaceId, apiToken } = options; + + const skip = (reason: string): SeedKVResult => ({ + seededRoutes: 0, + kvPairsUploaded: 0, + durationMs: Date.now() - startTime, + skipped: reason, + }); + + // ── 1. Read the prerender manifest ────────────────────────────────────── + const serverDir = path.join(root, "dist", "server"); + const manifestPath = path.join(serverDir, "vinext-prerender.json"); + + let manifestRaw: string; + try { + manifestRaw = fs.readFileSync(manifestPath, "utf-8"); + } catch { + return skip("no vinext-prerender.json found"); + } + + let manifest: PrerenderManifest; + try { + manifest = JSON.parse(manifestRaw); + } catch { + return skip("failed to parse vinext-prerender.json"); + } + + const { buildId, routes } = manifest; + if (!buildId || !Array.isArray(routes)) { + return skip("manifest missing buildId or routes"); + } + + const trailingSlash = manifest.trailingSlash ?? false; + const prerenderDir = path.resolve(serverDir, "prerendered-routes"); + + // ── 2. Build KV pairs from pre-rendered files ─────────────────────────── + const pairs: Array<{ key: string; value: string; expiration_ttl?: number }> = []; + let seededRoutes = 0; + + for (const route of routes) { + if (route.status !== "rendered") continue; + if (route.router !== "app") continue; + + const pathname = route.path ?? route.route; + const baseKey = isrCacheKey("app", pathname, buildId); + + const revalidateSeconds = typeof route.revalidate === "number" ? route.revalidate : undefined; + + const now = Date.now(); + const revalidateAt = + revalidateSeconds !== undefined && revalidateSeconds > 0 + ? now + revalidateSeconds * 1000 + : null; + + // KV TTL: match runtime KVCacheHandler.set() logic — + // ISR routes get 10x revalidate clamped to [60s, 30d]. + // Static routes (revalidateAt === null) get no expiry, matching runtime behavior. + const kvTtl: number | undefined = + revalidateSeconds !== undefined && revalidateSeconds > 0 + ? Math.max(Math.min(revalidateSeconds * 10, DEFAULT_KV_TTL_SECONDS), MIN_KV_TTL_SECONDS) + : undefined; + + // ── HTML entry ────────────────────────────────────────────────────── + const htmlRelPath = getOutputPath(pathname, trailingSlash); + const htmlFullPath = path.resolve(prerenderDir, htmlRelPath); + + // Path traversal guard: ensure resolved path stays within prerenderDir + if (!htmlFullPath.startsWith(prerenderDir + path.sep) && htmlFullPath !== prerenderDir) { + continue; + } + + let html: string; + try { + html = fs.readFileSync(htmlFullPath, "utf-8"); + } catch { + continue; // File missing or unreadable — skip this route + } + + const htmlEntry = { + value: { + kind: "APP_PAGE" as const, + html, + rscData: undefined, + headers: undefined, + postponed: undefined, + status: undefined, + }, + tags: [] as string[], + lastModified: now, + revalidateAt, + }; + + const htmlPair: { key: string; value: string; expiration_ttl?: number } = { + key: ENTRY_PREFIX + baseKey + ":html", + value: JSON.stringify(htmlEntry), + }; + if (kvTtl !== undefined) htmlPair.expiration_ttl = kvTtl; + pairs.push(htmlPair); + + // ── RSC entry ─────────────────────────────────────────────────────── + const rscRelPath = getRscOutputPath(pathname); + const rscFullPath = path.resolve(prerenderDir, rscRelPath); + + // Path traversal guard + if (rscFullPath.startsWith(prerenderDir + path.sep) || rscFullPath === prerenderDir) { + try { + const rscBuffer = fs.readFileSync(rscFullPath); + const rscBase64 = rscBuffer.toString("base64"); + + const rscEntry = { + value: { + kind: "APP_PAGE" as const, + html: "", + rscData: rscBase64, + headers: undefined, + postponed: undefined, + status: undefined, + }, + tags: [] as string[], + lastModified: now, + revalidateAt, + }; + + const rscPair: { key: string; value: string; expiration_ttl?: number } = { + key: ENTRY_PREFIX + baseKey + ":rsc", + value: JSON.stringify(rscEntry), + }; + if (kvTtl !== undefined) rscPair.expiration_ttl = kvTtl; + pairs.push(rscPair); + } catch { + // RSC file missing or unreadable — seed HTML-only + } + } + + seededRoutes++; + } + + if (pairs.length === 0) { + return skip("no pre-rendered App Router routes found"); + } + + // ── 3. Upload to KV in batches ────────────────────────────────────────── + await uploadBulkToKV(pairs, namespaceId, accountId, apiToken); + + return { + seededRoutes, + kvPairsUploaded: pairs.length, + durationMs: Date.now() - startTime, + }; +} + +// ─── Internals ────────────────────────────────────────────────────────────── + +/** + * Upload key-value pairs to KV via the Cloudflare bulk REST API. + * Splits into batches of BATCH_SIZE to respect the 10,000-pair limit. + */ +export async function uploadBulkToKV( + pairs: Array<{ key: string; value: string; expiration_ttl?: number }>, + namespaceId: string, + accountId: string, + apiToken: string, +): Promise { + for (let i = 0; i < pairs.length; i += BATCH_SIZE) { + const batch = pairs.slice(i, i + BATCH_SIZE); + const batchNum = Math.floor(i / BATCH_SIZE) + 1; + const totalBatches = Math.ceil(pairs.length / BATCH_SIZE); + + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/bulk`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(batch), + }, + ); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `KV bulk upload failed (batch ${batchNum}/${totalBatches}): HTTP ${response.status} — ${text.slice(0, MAX_ERROR_BODY_LENGTH)}`, + ); + } + + // Cloudflare can return 200 with success:false for semantic errors + const body = (await response.json()) as { + success?: boolean; + errors?: Array<{ message: string }>; + }; + if (body.success === false) { + const errMsg = body.errors?.map((e) => e.message).join("; ") ?? "unknown error"; + throw new Error( + `KV bulk upload rejected (batch ${batchNum}/${totalBatches}): ${errMsg.slice(0, MAX_ERROR_BODY_LENGTH)}`, + ); + } + } +} diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index 827e7e5e..5663c808 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -27,7 +27,8 @@ import { findInNodeModules as _findInNodeModules, } from "./utils/project.js"; import { getReactUpgradeDeps } from "./init.js"; -import { runTPR } from "./cloudflare/tpr.js"; +import { runTPR, parseWranglerConfig } from "./cloudflare/tpr.js"; +import { seedKVCacheFromPrerender } from "./cloudflare/seed-kv-cache.js"; import { runPrerender } from "./build/run-prerender.js"; import { loadDotenv } from "./config/dotenv.js"; import { loadNextConfig, resolveNextConfig } from "./config/next-config.js"; @@ -57,6 +58,8 @@ export type DeployOptions = { tprLimit?: number; /** TPR: analytics lookback window in hours (default: 24) */ tprWindow?: number; + /** Skip seeding pre-rendered routes into KV cache at deploy time */ + noSeedCache?: boolean; }; // ─── CLI arg parsing (uses Node.js util.parseArgs) ────────────────────────── @@ -74,6 +77,7 @@ const deployArgOptions = { "tpr-coverage": { type: "string" }, "tpr-limit": { type: "string" }, "tpr-window": { type: "string" }, + "no-seed-cache": { type: "boolean", default: false }, } as const; export function parseDeployArgs(args: string[]) { @@ -101,6 +105,7 @@ export function parseDeployArgs(args: string[]) { tprCoverage: parseIntArg("tpr-coverage", values["tpr-coverage"]), tprLimit: parseIntArg("tpr-limit", values["tpr-limit"]), tprWindow: parseIntArg("tpr-window", values["tpr-window"]), + noSeedCache: values["no-seed-cache"], }; } @@ -1360,6 +1365,39 @@ export async function deploy(options: DeployOptions): Promise { } } + // Step 6c: Seed KV cache with pre-rendered routes (automatic when KV is configured) + if (!options.noSeedCache) { + const apiToken = process.env.CLOUDFLARE_API_TOKEN; + if (apiToken) { + const wranglerConfig = parseWranglerConfig(root); + if (wranglerConfig?.kvNamespaceId) { + const accountId = wranglerConfig.accountId ?? process.env.CLOUDFLARE_ACCOUNT_ID; + if (accountId) { + console.log("\n Seeding KV cache with pre-rendered routes..."); + try { + const seedResult = await seedKVCacheFromPrerender({ + root, + accountId, + namespaceId: wranglerConfig.kvNamespaceId, + apiToken, + }); + + if (seedResult.skipped) { + console.log(` KV seed: Skipped (${seedResult.skipped})`); + } else { + console.log( + ` KV seed: ${seedResult.seededRoutes} routes → ${seedResult.kvPairsUploaded} KV pairs in ${(seedResult.durationMs / 1000).toFixed(1)}s`, + ); + } + } catch (err) { + // Non-fatal: deployment continues even if seeding fails + console.warn(` KV seed: Failed (${err instanceof Error ? err.message : String(err)})`); + } + } + } + } + } + // Step 7: Deploy via wrangler const url = runWranglerDeploy(root, { preview: options.preview ?? false, diff --git a/tests/seed-kv-cache.test.ts b/tests/seed-kv-cache.test.ts new file mode 100644 index 00000000..d1d68504 --- /dev/null +++ b/tests/seed-kv-cache.test.ts @@ -0,0 +1,685 @@ +/** + * Tests for seeding Workers KV cache from pre-rendered routes at deploy time. + * + * Verifies that seedKVCacheFromPrerender() reads vinext-prerender.json and + * the corresponding HTML/RSC files from disk, constructs correct ISR cache + * keys, serializes entries in the KVCacheEntry format, and uploads them via + * the Cloudflare KV bulk REST API. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vite-plus/test"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { isrCacheKey } from "../packages/vinext/src/server/isr-cache.js"; +import { + seedKVCacheFromPrerender, + uploadBulkToKV, +} from "../packages/vinext/src/cloudflare/seed-kv-cache.js"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function createTempRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "vinext-seed-kv-")); +} + +/** + * Set up a mock build output with vinext-prerender.json and prerendered files. + */ +function setupFixture( + root: string, + manifest: { buildId: string; trailingSlash?: boolean; routes: unknown[] }, + files: Record, +): void { + const serverDir = path.join(root, "dist", "server"); + fs.mkdirSync(serverDir, { recursive: true }); + + fs.writeFileSync( + path.join(serverDir, "vinext-prerender.json"), + JSON.stringify(manifest, null, 2), + "utf-8", + ); + + const prerenderDir = path.join(serverDir, "prerendered-routes"); + for (const [filePath, content] of Object.entries(files)) { + const fullPath = path.join(prerenderDir, filePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + if (typeof content === "string") { + fs.writeFileSync(fullPath, content, "utf-8"); + } else { + fs.writeFileSync(fullPath, content); + } + } +} + +/** Capture fetch calls for assertions. */ +type CapturedFetch = { + url: string; + method: string; + body: unknown[]; + headers: Record; +}; + +function mockFetchSuccess(): { calls: CapturedFetch[]; restore: () => void } { + const calls: CapturedFetch[] = []; + const originalFetch = globalThis.fetch; + + globalThis.fetch = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const body = init?.body ? JSON.parse(init.body as string) : []; + calls.push({ + url, + method: init?.method ?? "GET", + body, + headers: Object.fromEntries( + Object.entries(init?.headers ?? {}).map(([k, v]) => [k, String(v)]), + ), + }); + // Return a fresh Response each time — Response.json() can only be consumed once + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + + return { + calls, + restore: () => { + globalThis.fetch = originalFetch; + }, + }; +} + +function mockFetchFailure(status: number, text: string): { restore: () => void } { + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async () => new Response(text, { status })) as typeof fetch; + return { + restore: () => { + globalThis.fetch = originalFetch; + }, + }; +} + +// ─── Test constants ─────────────────────────────────────────────────────────── + +const TEST_OPTIONS = { + accountId: "test-account-123", + namespaceId: "test-namespace-456", + apiToken: "test-token-789", +}; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("seedKVCacheFromPrerender", () => { + let root: string; + let fetchMock: ReturnType; + + beforeEach(() => { + root = createTempRoot(); + fetchMock = mockFetchSuccess(); + }); + + afterEach(() => { + fetchMock.restore(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + // ── Basic seeding ───────────────────────────────────────────────────────── + + it("seeds App Router routes with correct KV key format", async () => { + const buildId = "build-001"; + setupFixture( + root, + { + buildId, + routes: [{ route: "/about", status: "rendered", revalidate: 60, router: "app" }], + }, + { + "about.html": "About", + "about.rsc": "RSC payload for about", + }, + ); + + const result = await seedKVCacheFromPrerender({ root, ...TEST_OPTIONS }); + + expect(result.seededRoutes).toBe(1); + expect(result.kvPairsUploaded).toBe(2); // html + rsc + expect(result.skipped).toBeUndefined(); + + // Verify the KV pairs sent to the API + const pairs = fetchMock.calls[0].body as Array<{ key: string; value: string }>; + expect(pairs).toHaveLength(2); + + // HTML key: cache:app:::html + const expectedBaseKey = isrCacheKey("app", "/about", buildId); + expect(pairs[0].key).toBe(`cache:${expectedBaseKey}:html`); + expect(pairs[1].key).toBe(`cache:${expectedBaseKey}:rsc`); + + // Verify HTML entry value format + const htmlEntry = JSON.parse(pairs[0].value); + expect(htmlEntry.value.kind).toBe("APP_PAGE"); + expect(htmlEntry.value.html).toBe("About"); + expect(htmlEntry.value.rscData).toBeUndefined(); + expect(htmlEntry.tags).toEqual([]); + expect(htmlEntry.lastModified).toBeTypeOf("number"); + expect(htmlEntry.revalidateAt).toBeTypeOf("number"); + + // Verify RSC entry value format — rscData is base64-encoded + const rscEntry = JSON.parse(pairs[1].value); + expect(rscEntry.value.kind).toBe("APP_PAGE"); + expect(rscEntry.value.html).toBe(""); + expect(rscEntry.value.rscData).toBe(Buffer.from("RSC payload for about").toString("base64")); + }); + + it("seeds multiple routes in a single upload", async () => { + const buildId = "build-002"; + setupFixture( + root, + { + buildId, + routes: [ + { route: "/about", status: "rendered", revalidate: 60, router: "app" }, + { route: "/contact", status: "rendered", revalidate: false, router: "app" }, + ], + }, + { + "about.html": "About", + "about.rsc": "rsc-about", + "contact.html": "Contact", + "contact.rsc": "rsc-contact", + }, + ); + + const result = await seedKVCacheFromPrerender({ root, ...TEST_OPTIONS }); + expect(result.seededRoutes).toBe(2); + expect(result.kvPairsUploaded).toBe(4); // 2 routes * 2 entries each + }); + + it("seeds index route correctly", async () => { + const buildId = "build-003"; + setupFixture( + root, + { + buildId, + routes: [{ route: "/", status: "rendered", revalidate: false, router: "app" }], + }, + { + "index.html": "Home", + "index.rsc": "rsc-home", + }, + ); + + const result = await seedKVCacheFromPrerender({ root, ...TEST_OPTIONS }); + expect(result.seededRoutes).toBe(1); + + const pairs = fetchMock.calls[0].body as Array<{ key: string; value: string }>; + const expectedBaseKey = isrCacheKey("app", "/", buildId); + expect(pairs[0].key).toBe(`cache:${expectedBaseKey}:html`); + }); + + it("uses path field for dynamic routes", async () => { + const buildId = "build-004"; + setupFixture( + root, + { + buildId, + routes: [ + { + route: "/blog/:slug", + status: "rendered", + revalidate: 120, + router: "app", + path: "/blog/hello-world", + }, + ], + }, + { + "blog/hello-world.html": "Blog Post", + "blog/hello-world.rsc": "rsc-blog", + }, + ); + + const result = await seedKVCacheFromPrerender({ root, ...TEST_OPTIONS }); + expect(result.seededRoutes).toBe(1); + + const pairs = fetchMock.calls[0].body as Array<{ key: string }>; + const expectedBaseKey = isrCacheKey("app", "/blog/hello-world", buildId); + expect(pairs[0].key).toBe(`cache:${expectedBaseKey}:html`); + }); + + // ── Revalidation ────────────────────────────────────────────────────────── + + it("sets revalidateAt for ISR routes", async () => { + setupFixture( + root, + { + buildId: "build-005", + routes: [{ route: "/isr", status: "rendered", revalidate: 300, router: "app" }], + }, + { "isr.html": "ISR" }, + ); + + const before = Date.now(); + const result = await seedKVCacheFromPrerender({ root, ...TEST_OPTIONS }); + const after = Date.now(); + + expect(result.seededRoutes).toBe(1); + + const pairs = fetchMock.calls[0].body as Array<{ + key: string; + value: string; + expiration_ttl: number; + }>; + const entry = JSON.parse(pairs[0].value); + + // revalidateAt should be ~now + 300s + expect(entry.revalidateAt).toBeGreaterThanOrEqual(before + 300_000); + expect(entry.revalidateAt).toBeLessThanOrEqual(after + 300_000); + + // KV TTL: 10x revalidate = 3000s + expect(pairs[0].expiration_ttl).toBe(3000); + }); + + it("sets null revalidateAt and no expiration_ttl for static routes", async () => { + setupFixture( + root, + { + buildId: "build-006", + routes: [{ route: "/static", status: "rendered", revalidate: false, router: "app" }], + }, + { "static.html": "Static" }, + ); + + const result = await seedKVCacheFromPrerender({ root, ...TEST_OPTIONS }); + expect(result.seededRoutes).toBe(1); + + const pairs = fetchMock.calls[0].body as Array<{ + key: string; + value: string; + expiration_ttl?: number; + }>; + const entry = JSON.parse(pairs[0].value); + + expect(entry.revalidateAt).toBeNull(); + // Static routes: no expiry (matches runtime KVCacheHandler.set behavior) + expect(pairs[0].expiration_ttl).toBeUndefined(); + }); + + // ── Skip conditions ─────────────────────────────────────────────────────── + + it("skips when no manifest exists", async () => { + const result = await seedKVCacheFromPrerender({ root, ...TEST_OPTIONS }); + expect(result.skipped).toBe("no vinext-prerender.json found"); + expect(result.seededRoutes).toBe(0); + }); + + it("skips when manifest is corrupt", async () => { + const serverDir = path.join(root, "dist", "server"); + fs.mkdirSync(serverDir, { recursive: true }); + fs.writeFileSync(path.join(serverDir, "vinext-prerender.json"), "NOT JSON", "utf-8"); + + const result = await seedKVCacheFromPrerender({ root, ...TEST_OPTIONS }); + expect(result.skipped).toBe("failed to parse vinext-prerender.json"); + }); + + it("skips when manifest has no buildId", async () => { + setupFixture(root, { buildId: "", routes: [] }, {}); + + const result = await seedKVCacheFromPrerender({ root, ...TEST_OPTIONS }); + expect(result.skipped).toBe("manifest missing buildId or routes"); + }); + + it("skips Pages Router routes", async () => { + setupFixture( + root, + { + buildId: "build-007", + routes: [{ route: "/old-page", status: "rendered", revalidate: false, router: "pages" }], + }, + { "old-page.html": "Pages Router" }, + ); + + const result = await seedKVCacheFromPrerender({ root, ...TEST_OPTIONS }); + expect(result.skipped).toBe("no pre-rendered App Router routes found"); + }); + + it("skips routes with non-rendered status", async () => { + setupFixture( + root, + { + buildId: "build-008", + routes: [ + { route: "/dynamic", status: "skipped", reason: "ssr" }, + { route: "/broken", status: "error", error: "oops" }, + ], + }, + {}, + ); + + const result = await seedKVCacheFromPrerender({ root, ...TEST_OPTIONS }); + expect(result.skipped).toBe("no pre-rendered App Router routes found"); + }); + + it("skips routes when HTML file is missing on disk", async () => { + setupFixture( + root, + { + buildId: "build-009", + routes: [{ route: "/ghost", status: "rendered", revalidate: false, router: "app" }], + }, + {}, // no files on disk + ); + + const result = await seedKVCacheFromPrerender({ root, ...TEST_OPTIONS }); + expect(result.skipped).toBe("no pre-rendered App Router routes found"); + }); + + it("seeds HTML-only when RSC file is missing", async () => { + setupFixture( + root, + { + buildId: "build-010", + routes: [{ route: "/html-only", status: "rendered", revalidate: false, router: "app" }], + }, + { "html-only.html": "HTML Only" }, + ); + + const result = await seedKVCacheFromPrerender({ root, ...TEST_OPTIONS }); + expect(result.seededRoutes).toBe(1); + expect(result.kvPairsUploaded).toBe(1); // only html, no rsc + }); + + // ── Trailing slash ──────────────────────────────────────────────────────── + + it("handles trailingSlash file layout", async () => { + setupFixture( + root, + { + buildId: "build-011", + trailingSlash: true, + routes: [{ route: "/about", status: "rendered", revalidate: false, router: "app" }], + }, + { + "about/index.html": "About with trailing slash", + "about.rsc": "rsc-about", + }, + ); + + const result = await seedKVCacheFromPrerender({ root, ...TEST_OPTIONS }); + expect(result.seededRoutes).toBe(1); + }); + + // ── API interaction ─────────────────────────────────────────────────────── + + it("sends correct auth headers to Cloudflare API", async () => { + setupFixture( + root, + { + buildId: "build-012", + routes: [{ route: "/test", status: "rendered", revalidate: false, router: "app" }], + }, + { "test.html": "Test" }, + ); + + await seedKVCacheFromPrerender({ root, ...TEST_OPTIONS }); + + expect(fetchMock.calls[0].headers.Authorization).toBe(`Bearer ${TEST_OPTIONS.apiToken}`); + expect(fetchMock.calls[0].headers["Content-Type"]).toBe("application/json"); + expect(fetchMock.calls[0].url).toContain(TEST_OPTIONS.accountId); + expect(fetchMock.calls[0].url).toContain(TEST_OPTIONS.namespaceId); + }); + + it("throws on API failure", async () => { + fetchMock.restore(); + const failMock = mockFetchFailure(500, "Internal Server Error"); + + setupFixture( + root, + { + buildId: "build-013", + routes: [{ route: "/fail", status: "rendered", revalidate: false, router: "app" }], + }, + { "fail.html": "Fail" }, + ); + + await expect(seedKVCacheFromPrerender({ root, ...TEST_OPTIONS })).rejects.toThrow( + "KV bulk upload failed", + ); + + failMock.restore(); + }); +}); + +describe("uploadBulkToKV", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = mockFetchSuccess(); + }); + + afterEach(() => { + fetchMock.restore(); + }); + + it("uploads all pairs in a single batch when under limit", async () => { + const pairs = Array.from({ length: 5 }, (_, i) => ({ + key: `key-${i}`, + value: `value-${i}`, + expiration_ttl: 3600, + })); + + await uploadBulkToKV(pairs, "ns-id", "acct-id", "token"); + + expect(fetchMock.calls).toHaveLength(1); + expect(fetchMock.calls[0].body).toHaveLength(5); + }); + + it("splits into multiple batches when over 10,000 pairs", async () => { + const pairs = Array.from({ length: 10_001 }, (_, i) => ({ + key: `key-${i}`, + value: `value-${i}`, + })); + + await uploadBulkToKV(pairs, "ns-id", "acct-id", "token"); + + expect(fetchMock.calls).toHaveLength(2); + expect(fetchMock.calls[0].body).toHaveLength(10_000); + expect(fetchMock.calls[1].body).toHaveLength(1); + }); + + it("preserves key order across batch boundaries", async () => { + const pairs = Array.from({ length: 10_001 }, (_, i) => ({ + key: `key-${i}`, + value: `value-${i}`, + })); + + await uploadBulkToKV(pairs, "ns-id", "acct-id", "token"); + + const firstBatch = fetchMock.calls[0].body as Array<{ key: string }>; + expect(firstBatch[0].key).toBe("key-0"); + expect(firstBatch[9999].key).toBe("key-9999"); + + const secondBatch = fetchMock.calls[1].body as Array<{ key: string }>; + expect(secondBatch[0].key).toBe("key-10000"); + }); + + it("throws when API returns 200 but success:false", async () => { + fetchMock.restore(); + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn( + async () => + new Response( + JSON.stringify({ success: false, errors: [{ message: "namespace not found" }] }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ) as typeof fetch; + + await expect( + uploadBulkToKV([{ key: "k", value: "v" }], "ns-id", "acct-id", "token"), + ).rejects.toThrow("KV bulk upload rejected"); + + globalThis.fetch = originalFetch; + }); +}); + +// ─── Additional coverage tests ──────────────────────────────────────────────── + +describe("seedKVCacheFromPrerender — edge cases", () => { + let root: string; + let fetchMock: ReturnType; + + beforeEach(() => { + root = createTempRoot(); + fetchMock = mockFetchSuccess(); + }); + + afterEach(() => { + fetchMock.restore(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it("uses FNV1a hash for pathnames exceeding 200-char key threshold", async () => { + const buildId = "build-hash"; + const longSlug = "a".repeat(190); + const pathname = `/blog/${longSlug}`; + setupFixture( + root, + { + buildId, + routes: [{ route: pathname, status: "rendered", revalidate: false, router: "app" }], + }, + { [`blog/${longSlug}.html`]: "Long path" }, + ); + + const result = await seedKVCacheFromPrerender({ root, ...TEST_OPTIONS }); + expect(result.seededRoutes).toBe(1); + + const pairs = fetchMock.calls[0].body as Array<{ key: string }>; + const expectedBaseKey = isrCacheKey("app", pathname, buildId); + expect(expectedBaseKey).toContain("__hash:"); + expect(pairs[0].key).toBe(`cache:${expectedBaseKey}:html`); + }); + + it("correctly base64-encodes binary RSC content", async () => { + const binaryRsc = Buffer.from([0x00, 0x80, 0xfe, 0xff, 0x01, 0x02, 0x7f, 0xc0]); + setupFixture( + root, + { + buildId: "build-bin", + routes: [{ route: "/bin", status: "rendered", revalidate: false, router: "app" }], + }, + { + "bin.html": "Binary", + "bin.rsc": binaryRsc, + }, + ); + + const result = await seedKVCacheFromPrerender({ root, ...TEST_OPTIONS }); + expect(result.kvPairsUploaded).toBe(2); + + const pairs = fetchMock.calls[0].body as Array<{ value: string }>; + const rscEntry = JSON.parse(pairs[1].value); + expect(Buffer.from(rscEntry.value.rscData, "base64")).toEqual(binaryRsc); + }); + + it("treats revalidate=0 as static (null revalidateAt, no TTL)", async () => { + setupFixture( + root, + { + buildId: "build-r0", + routes: [{ route: "/r0", status: "rendered", revalidate: 0, router: "app" }], + }, + { "r0.html": "" }, + ); + + const result = await seedKVCacheFromPrerender({ root, ...TEST_OPTIONS }); + expect(result.seededRoutes).toBe(1); + + const pairs = fetchMock.calls[0].body as Array<{ value: string; expiration_ttl?: number }>; + const entry = JSON.parse(pairs[0].value); + expect(entry.revalidateAt).toBeNull(); + expect(pairs[0].expiration_ttl).toBeUndefined(); + }); + + it("clamps very small revalidate to MIN_KV_TTL (60s)", async () => { + setupFixture( + root, + { + buildId: "build-r1", + routes: [{ route: "/r1", status: "rendered", revalidate: 1, router: "app" }], + }, + { "r1.html": "" }, + ); + + const result = await seedKVCacheFromPrerender({ root, ...TEST_OPTIONS }); + expect(result.seededRoutes).toBe(1); + + const pairs = fetchMock.calls[0].body as Array<{ expiration_ttl?: number }>; + // 1 * 10 = 10, clamped to 60 + expect(pairs[0].expiration_ttl).toBe(60); + }); + + it("handles unicode pathnames", async () => { + setupFixture( + root, + { + buildId: "build-uni", + routes: [{ route: "/blog/日本語", status: "rendered", revalidate: false, router: "app" }], + }, + { + "blog/日本語.html": "Japanese", + "blog/日本語.rsc": "rsc-jp", + }, + ); + + const result = await seedKVCacheFromPrerender({ root, ...TEST_OPTIONS }); + expect(result.seededRoutes).toBe(1); + + const pairs = fetchMock.calls[0].body as Array<{ key: string }>; + const expectedBaseKey = isrCacheKey("app", "/blog/日本語", "build-uni"); + expect(pairs[0].key).toBe(`cache:${expectedBaseKey}:html`); + }); + + it("seeds empty HTML file without error", async () => { + setupFixture( + root, + { + buildId: "build-empty", + routes: [{ route: "/empty", status: "rendered", revalidate: false, router: "app" }], + }, + { "empty.html": "" }, + ); + + const result = await seedKVCacheFromPrerender({ root, ...TEST_OPTIONS }); + expect(result.seededRoutes).toBe(1); + + const pairs = fetchMock.calls[0].body as Array<{ value: string }>; + const entry = JSON.parse(pairs[0].value); + expect(entry.value.html).toBe(""); + }); + + it("seeds only App Router rendered routes from mixed manifest", async () => { + setupFixture( + root, + { + buildId: "build-mix", + routes: [ + { route: "/app-page", status: "rendered", revalidate: 60, router: "app" }, + { route: "/pages-page", status: "rendered", revalidate: false, router: "pages" }, + { route: "/skipped", status: "skipped", reason: "ssr" }, + { route: "/errored", status: "error", error: "boom" }, + { route: "/app-static", status: "rendered", revalidate: false, router: "app" }, + ], + }, + { + "app-page.html": "App", + "app-page.rsc": "rsc-app", + "pages-page.html": "Pages", + "app-static.html": "Static App", + }, + ); + + const result = await seedKVCacheFromPrerender({ root, ...TEST_OPTIONS }); + expect(result.seededRoutes).toBe(2); // only the 2 app router rendered routes + expect(result.kvPairsUploaded).toBe(3); // app-page html + rsc, app-static html only + }); +});