diff --git a/packages/cloudflare/env.d.ts b/packages/cloudflare/env.d.ts index 6f9b9d17..bea42567 100644 --- a/packages/cloudflare/env.d.ts +++ b/packages/cloudflare/env.d.ts @@ -4,6 +4,7 @@ declare global { ASSETS: Fetcher; __NEXT_PRIVATE_STANDALONE_CONFIG?: string; SKIP_NEXT_APP_BUILD?: string; + NEXT_PRIVATE_DEBUG_CACHE?: string; [key: string]: string | Fetcher; } } diff --git a/packages/cloudflare/src/cli/build/build-worker.ts b/packages/cloudflare/src/cli/build/build-worker.ts index 2acdd63e..95c1d089 100644 --- a/packages/cloudflare/src/cli/build/build-worker.ts +++ b/packages/cloudflare/src/cli/build/build-worker.ts @@ -3,6 +3,7 @@ import { cp, readFile, writeFile } from "node:fs/promises"; import { existsSync, readFileSync } from "node:fs"; import { Config } from "../config"; import { copyPackageCliFiles } from "./patches/investigated/copy-package-cli-files"; +import { copyPrerenderedRoutes } from "./utils"; import { fileURLToPath } from "node:url"; import { inlineEvalManifest } from "./patches/to-investigate/inline-eval-manifest"; import { inlineNextRequire } from "./patches/to-investigate/inline-next-require"; @@ -43,6 +44,9 @@ export async function buildWorker(config: Config): Promise { }); } + // Copy over prerendered assets (e.g. SSG routes) + copyPrerenderedRoutes(config); + copyPackageCliFiles(packageDistDir, config); const templateDir = path.join(config.paths.internalPackage, "cli", "templates"); diff --git a/packages/cloudflare/src/cli/build/patches/investigated/patch-cache.ts b/packages/cloudflare/src/cli/build/patches/investigated/patch-cache.ts index 3c521d2f..fcd14271 100644 --- a/packages/cloudflare/src/cli/build/patches/investigated/patch-cache.ts +++ b/packages/cloudflare/src/cli/build/patches/investigated/patch-cache.ts @@ -7,12 +7,12 @@ import path from "node:path"; export function patchCache(code: string, config: Config): string { console.log("# patchCached"); - const cacheHandler = path.join(config.paths.internalPackage, "cli", "cache-handler.mjs"); + const cacheHandler = path.join(config.paths.internalPackage, "cli", "cache-handler", "index.mjs"); const patchedCode = code.replace( "const { cacheHandler } = this.nextConfig;", `const cacheHandler = null; -CacheHandler = (await import('${cacheHandler}')).default; +CacheHandler = (await import('${cacheHandler}')).OpenNextCacheHandler; CacheHandler.maybeKVNamespace = process.env["${config.cache.kvBindingName}"]; ` ); diff --git a/packages/cloudflare/src/cli/build/utils/copy-prerendered-routes.ts b/packages/cloudflare/src/cli/build/utils/copy-prerendered-routes.ts new file mode 100644 index 00000000..b1b7aced --- /dev/null +++ b/packages/cloudflare/src/cli/build/utils/copy-prerendered-routes.ts @@ -0,0 +1,46 @@ +import { NEXT_META_SUFFIX, SEED_DATA_DIR } from "../../cache-handler"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { Config } from "../../config"; +import type { PrerenderManifest } from "next/dist/build"; +import { readPathsRecursively } from "./read-paths-recursively"; + +/** + * Copies all prerendered routes from the standalone output directory to the OpenNext static assets + * output directory. + * + * Updates metadata configs with the current time as a modified date, so that it can be re-used in + * the incremental cache to determine whether an entry is _fresh_ or not. + * + * @param config Build config. + */ +export function copyPrerenderedRoutes(config: Config) { + console.log("# copyPrerenderedRoutes"); + + const serverAppDirPath = join(config.paths.standaloneAppServer, "app"); + const prerenderManifestPath = join(config.paths.standaloneAppDotNext, "prerender-manifest.json"); + const outputPath = join(config.paths.builderOutput, "assets", SEED_DATA_DIR); + + const prerenderManifest: PrerenderManifest = existsSync(prerenderManifestPath) + ? JSON.parse(readFileSync(prerenderManifestPath, "utf8")) + : {}; + const prerenderedRoutes = Object.keys(prerenderManifest.routes); + + const prerenderedAssets = readPathsRecursively(serverAppDirPath) + .map((fullPath) => ({ fullPath, relativePath: fullPath.replace(serverAppDirPath, "") })) + .filter(({ relativePath }) => + prerenderedRoutes.includes(relativePath.replace(/\.\w+$/, "").replace(/^\/index$/, "/")) + ); + + prerenderedAssets.forEach(({ fullPath, relativePath }) => { + const destPath = join(outputPath, relativePath); + mkdirSync(dirname(destPath), { recursive: true }); + + if (fullPath.endsWith(NEXT_META_SUFFIX)) { + const data = JSON.parse(readFileSync(fullPath, "utf8")); + writeFileSync(destPath, JSON.stringify({ ...data, lastModified: Date.now() })); + } else { + copyFileSync(fullPath, destPath); + } + }); +} diff --git a/packages/cloudflare/src/cli/build/utils/index.ts b/packages/cloudflare/src/cli/build/utils/index.ts index 2dff0596..dca46d68 100644 --- a/packages/cloudflare/src/cli/build/utils/index.ts +++ b/packages/cloudflare/src/cli/build/utils/index.ts @@ -1 +1,2 @@ export * from "./ts-parse-file"; +export * from "./copy-prerendered-routes"; diff --git a/packages/cloudflare/src/cli/build/utils/read-paths-recursively.ts b/packages/cloudflare/src/cli/build/utils/read-paths-recursively.ts new file mode 100644 index 00000000..b85d128d --- /dev/null +++ b/packages/cloudflare/src/cli/build/utils/read-paths-recursively.ts @@ -0,0 +1,23 @@ +import { join } from "node:path"; +import { readdirSync } from "node:fs"; + +/** + * Recursively reads all file paths in a directory. + * + * @param dir Directory to recursively read from. + * @returns Array of all paths for all files in a directory. + */ +export function readPathsRecursively(dir: string): string[] { + try { + const files = readdirSync(dir, { withFileTypes: true }); + + const paths = files.map((file) => { + const filePath = join(dir, file.name); + return file.isDirectory() ? readPathsRecursively(filePath) : [filePath]; + }); + + return paths.flat(); + } catch { + return []; + } +} diff --git a/packages/cloudflare/src/cli/cache-handler.ts b/packages/cloudflare/src/cli/cache-handler.ts deleted file mode 100644 index 38193444..00000000 --- a/packages/cloudflare/src/cli/cache-handler.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { type CacheHandler, type CacheHandlerContext } from "next/dist/server/lib/incremental-cache"; -import type { IncrementalCacheEntry, IncrementalCacheValue } from "next/dist/server/response-cache"; -import { KVNamespace } from "@cloudflare/workers-types"; - -export default class CfWorkersKvCacheHandler implements CacheHandler { - static maybeKVNamespace: KVNamespace | undefined = undefined; - - constructor(protected ctx: CacheHandlerContext) {} - - async get(key: string): Promise { - if (CfWorkersKvCacheHandler.maybeKVNamespace === undefined) { - return null; - } - - console.log(`[Cf] Getting cache[${key}]`); - - try { - return (await CfWorkersKvCacheHandler.maybeKVNamespace.get(key, "json")) ?? null; - } catch (e) { - console.error(`Failed to get value for key = ${key}: ${e}`); - return null; - } - } - - async set( - key: string, - entry: IncrementalCacheValue | null, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _ctx: { - revalidate?: number | false; - fetchCache?: boolean; - fetchUrl?: string; - fetchIdx?: number; - tags?: string[]; - } - ) { - if (CfWorkersKvCacheHandler.maybeKVNamespace === undefined) { - return; - } - - console.log(`[Cf] Setting cache[${key}]`); - - try { - const data = { - lastModified: Date.now(), - value: entry, - }; - await CfWorkersKvCacheHandler.maybeKVNamespace.put(key, JSON.stringify(data)); - } catch (e) { - console.error(`Failed to set value for key = ${key}: ${e}`); - } - } - - async revalidateTag(tags: string | string[]) { - if (CfWorkersKvCacheHandler.maybeKVNamespace === undefined) { - return; - } - - tags = [tags].flat(); - console.log(`[Cf] revalidateTag ${JSON.stringify(tags)}}`); - } - - resetRequestCache(): void {} -} diff --git a/packages/cloudflare/src/cli/cache-handler/constants.ts b/packages/cloudflare/src/cli/cache-handler/constants.ts new file mode 100644 index 00000000..22fb32a0 --- /dev/null +++ b/packages/cloudflare/src/cli/cache-handler/constants.ts @@ -0,0 +1,8 @@ +export const RSC_PREFETCH_SUFFIX = ".prefetch.rsc"; +export const RSC_SUFFIX = ".rsc"; +export const NEXT_DATA_SUFFIX = ".json"; +export const NEXT_META_SUFFIX = ".meta"; +export const NEXT_BODY_SUFFIX = ".body"; +export const NEXT_HTML_SUFFIX = ".html"; + +export const SEED_DATA_DIR = "_cf_seed_data"; diff --git a/packages/cloudflare/src/cli/cache-handler/index.ts b/packages/cloudflare/src/cli/cache-handler/index.ts new file mode 100644 index 00000000..5d23b49d --- /dev/null +++ b/packages/cloudflare/src/cli/cache-handler/index.ts @@ -0,0 +1,2 @@ +export * from "./constants"; +export * from "./open-next-cache-handler"; diff --git a/packages/cloudflare/src/cli/cache-handler/open-next-cache-handler.ts b/packages/cloudflare/src/cli/cache-handler/open-next-cache-handler.ts new file mode 100644 index 00000000..46a664a8 --- /dev/null +++ b/packages/cloudflare/src/cli/cache-handler/open-next-cache-handler.ts @@ -0,0 +1,146 @@ +import type { + CacheHandler, + CacheHandlerContext, + CacheHandlerValue, +} from "next/dist/server/lib/incremental-cache"; +import { + NEXT_BODY_SUFFIX, + NEXT_DATA_SUFFIX, + NEXT_HTML_SUFFIX, + RSC_PREFETCH_SUFFIX, + RSC_SUFFIX, + SEED_DATA_DIR, +} from "./constants"; +import { getSeedBodyFile, getSeedMetaFile, getSeedTextFile, parseCtx } from "./utils"; +import type { IncrementalCacheValue } from "next/dist/server/response-cache"; +import { KVNamespace } from "@cloudflare/workers-types"; + +type CacheEntry = { + lastModified: number; + value: IncrementalCacheValue | null; +}; + +export class OpenNextCacheHandler implements CacheHandler { + static maybeKVNamespace: KVNamespace | undefined = undefined; + + protected debug: boolean = !!process.env.NEXT_PRIVATE_DEBUG_CACHE; + + constructor(protected ctx: CacheHandlerContext) {} + + async get(...args: Parameters): Promise { + const [key, _ctx] = args; + const ctx = parseCtx(_ctx); + + if (this.debug) console.log(`cache - get: ${key}, ${ctx?.kind}`); + + if (OpenNextCacheHandler.maybeKVNamespace !== undefined) { + try { + const value = await OpenNextCacheHandler.maybeKVNamespace.get(key, "json"); + if (value) return value; + } catch (e) { + console.error(`Failed to get value for key = ${key}: ${e}`); + } + } + + // Check for seed data from the file-system. + + // we don't check for seed data for fetch or image cache entries + if (ctx?.kind === "FETCH" || ctx?.kind === "IMAGE") return null; + + const seedKey = `http://assets.local/${SEED_DATA_DIR}/${key}`.replace(/\/\//g, "/"); + + if (ctx?.kind === "APP" || ctx?.kind === "APP_ROUTE") { + const fallbackBody = await getSeedBodyFile(seedKey, NEXT_BODY_SUFFIX); + if (fallbackBody) { + const meta = await getSeedMetaFile(seedKey); + return { + lastModified: meta?.lastModified, + value: { + kind: (ctx.kind === "APP_ROUTE" ? ctx.kind : "ROUTE") as Extract< + IncrementalCacheValue["kind"], + "ROUTE" + >, + body: fallbackBody, + status: meta?.status ?? 200, + headers: meta?.headers ?? {}, + }, + }; + } + + if (ctx.kind === "APP_ROUTE") { + return null; + } + } + + const seedHtml = await getSeedTextFile(seedKey, NEXT_HTML_SUFFIX); + if (!seedHtml) return null; // we're only checking for prerendered routes at the moment + + if (ctx?.kind === "PAGES" || ctx?.kind === "APP" || ctx?.kind === "APP_PAGE") { + const metaPromise = getSeedMetaFile(seedKey); + + let pageDataPromise: Promise = Promise.resolve(undefined); + if (!ctx.isFallback) { + const rscSuffix = ctx.isRoutePPREnabled ? RSC_PREFETCH_SUFFIX : RSC_SUFFIX; + + if (ctx.kind === "APP_PAGE") { + pageDataPromise = getSeedBodyFile(seedKey, rscSuffix); + } else { + pageDataPromise = getSeedTextFile(seedKey, ctx.kind === "APP" ? rscSuffix : NEXT_DATA_SUFFIX); + } + } + + const [meta, pageData] = await Promise.all([metaPromise, pageDataPromise]); + + return { + lastModified: meta?.lastModified, + value: { + kind: (ctx.kind === "APP_PAGE" ? "APP_PAGE" : "PAGE") as Extract< + IncrementalCacheValue["kind"], + "PAGE" + >, + html: seedHtml, + pageData: pageData ?? "", + ...(ctx.kind === "APP_PAGE" && { rscData: pageData }), + postponed: meta?.postponed, + status: meta?.status, + headers: meta?.headers, + }, + }; + } + + return null; + } + + async set(...args: Parameters) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [key, entry, _ctx] = args; + + if (OpenNextCacheHandler.maybeKVNamespace === undefined) { + return; + } + + if (this.debug) console.log(`cache - set: ${key}`); + + const data: CacheEntry = { + lastModified: Date.now(), + value: entry, + }; + + try { + await OpenNextCacheHandler.maybeKVNamespace.put(key, JSON.stringify(data)); + } catch (e) { + console.error(`Failed to set value for key = ${key}: ${e}`); + } + } + + async revalidateTag(...args: Parameters) { + const [tags] = args; + if (OpenNextCacheHandler.maybeKVNamespace === undefined) { + return; + } + + if (this.debug) console.log(`cache - revalidateTag: ${JSON.stringify([tags].flat())}`); + } + + resetRequestCache(): void {} +} diff --git a/packages/cloudflare/src/cli/cache-handler/utils.ts b/packages/cloudflare/src/cli/cache-handler/utils.ts new file mode 100644 index 00000000..b9ab6eb3 --- /dev/null +++ b/packages/cloudflare/src/cli/cache-handler/utils.ts @@ -0,0 +1,41 @@ +import { IncrementalCache } from "next/dist/server/lib/incremental-cache"; +import { NEXT_META_SUFFIX } from "./constants"; + +type PrerenderedRouteMeta = { + lastModified: number; + status?: number; + headers?: Record; + postponed?: string; +}; + +type EntryKind = + | "APP" // .body, .html - backwards compat + | "PAGES" + | "FETCH" + | "APP_ROUTE" // .body + | "APP_PAGE" // .html + | "IMAGE" + | undefined; + +async function getAsset(key: string, cb: (resp: Response) => T): Promise | undefined> { + const resp = await process.env.ASSETS.fetch(key); + return resp.status === 200 ? await cb(resp) : undefined; +} + +export function getSeedBodyFile(key: string, suffix: string) { + return getAsset(`${key}${suffix}`, (resp) => resp.arrayBuffer() as Promise); +} + +export function getSeedTextFile(key: string, suffix: string) { + return getAsset(`${key}${suffix}`, (resp) => resp.text()); +} + +export function getSeedMetaFile(key: string) { + return getAsset(`${key}${NEXT_META_SUFFIX}`, (resp) => resp.json()); +} + +export function parseCtx(ctx: Parameters[1] = {}) { + return { ...ctx, kind: ctx?.kindHint?.toUpperCase() } as + | (typeof ctx & { kind?: EntryKind; isFallback?: boolean; isRoutePPREnabled?: boolean }) + | undefined; +} diff --git a/packages/cloudflare/tsup.config.ts b/packages/cloudflare/tsup.config.ts index 4fe78752..ade3b9fa 100644 --- a/packages/cloudflare/tsup.config.ts +++ b/packages/cloudflare/tsup.config.ts @@ -2,7 +2,7 @@ import { cp } from "node:fs/promises"; import { defineConfig } from "tsup"; const cliConfig = defineConfig({ - entry: ["src/cli/index.ts", "src/cli/cache-handler.ts"], + entry: ["src/cli/index.ts", "src/cli/cache-handler/index.ts"], outDir: "dist/cli", dts: false, format: ["esm"],