From 04be59bfe1b2d9e48a545086dd01056c34f71706 Mon Sep 17 00:00:00 2001
From: James Anderson <james@eli.cx>
Date: Mon, 7 Oct 2024 09:51:39 +0100
Subject: [PATCH] feat: ssg support through seeding the incremental cache (#65)

---
 packages/cloudflare/env.d.ts                  |   1 +
 .../cloudflare/src/cli/build/build-worker.ts  |   4 +
 .../build/patches/investigated/patch-cache.ts |   4 +-
 .../build/utils/copy-prerendered-routes.ts    |  46 ++++++
 .../cloudflare/src/cli/build/utils/index.ts   |   1 +
 .../cli/build/utils/read-paths-recursively.ts |  21 +++
 packages/cloudflare/src/cli/cache-handler.ts  |  64 --------
 .../src/cli/cache-handler/constants.ts        |   8 +
 .../cloudflare/src/cli/cache-handler/index.ts |   2 +
 .../cache-handler/open-next-cache-handler.ts  | 146 ++++++++++++++++++
 .../cloudflare/src/cli/cache-handler/utils.ts |  41 +++++
 packages/cloudflare/src/cli/config.ts         |   5 +
 packages/cloudflare/tsup.config.ts            |   2 +-
 13 files changed, 278 insertions(+), 67 deletions(-)
 create mode 100644 packages/cloudflare/src/cli/build/utils/copy-prerendered-routes.ts
 create mode 100644 packages/cloudflare/src/cli/build/utils/read-paths-recursively.ts
 delete mode 100644 packages/cloudflare/src/cli/cache-handler.ts
 create mode 100644 packages/cloudflare/src/cli/cache-handler/constants.ts
 create mode 100644 packages/cloudflare/src/cli/cache-handler/index.ts
 create mode 100644 packages/cloudflare/src/cli/cache-handler/open-next-cache-handler.ts
 create mode 100644 packages/cloudflare/src/cli/cache-handler/utils.ts

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 85b358f8..becb7ba6 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 { inlineMiddlewareManifestRequire } from "./patches/to-investigate/inline-middleware-manifest-require";
@@ -45,6 +46,9 @@ export async function buildWorker(config: Config): Promise<void> {
     });
   }
 
+  // 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 0d8bd4f8..9b5ef064 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("# patchCache");
 
-  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..96577394
--- /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: config.buildTimestamp }));
+    } 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..aaa0a93d
--- /dev/null
+++ b/packages/cloudflare/src/cli/build/utils/read-paths-recursively.ts
@@ -0,0 +1,21 @@
+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 });
+
+    return files.flatMap((file) => {
+      const filePath = join(dir, file.name);
+      return file.isDirectory() ? readPathsRecursively(filePath) : filePath;
+    });
+  } 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<IncrementalCacheEntry | null> {
-    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..90b21bbc
--- /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 = "cdn-cgi/_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<CacheHandler["get"]>): Promise<CacheHandlerValue | null> {
+    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<CacheEntry>(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<Buffer | string | undefined> = 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<CacheHandler["set"]>) {
+    // 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<CacheHandler["revalidateTag"]>) {
+    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..2241eecc
--- /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<string, string>;
+  postponed?: string;
+};
+
+type EntryKind =
+  | "APP" // .body, .html - backwards compat
+  | "PAGES"
+  | "FETCH"
+  | "APP_ROUTE" // .body
+  | "APP_PAGE" // .html
+  | "IMAGE"
+  | undefined;
+
+async function getAsset<T>(key: string, cb: (resp: Response) => T): Promise<Awaited<T> | 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<Buffer>);
+}
+
+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<PrerenderedRouteMeta>());
+}
+
+export function parseCtx(ctx: Parameters<IncrementalCache["get"]>[1] = {}) {
+  return { ...ctx, kind: ctx?.kindHint?.toUpperCase() } as
+    | (typeof ctx & { kind?: EntryKind; isFallback?: boolean; isRoutePPREnabled?: boolean })
+    | undefined;
+}
diff --git a/packages/cloudflare/src/cli/config.ts b/packages/cloudflare/src/cli/config.ts
index 65c34343..05d2b6fc 100644
--- a/packages/cloudflare/src/cli/config.ts
+++ b/packages/cloudflare/src/cli/config.ts
@@ -11,6 +11,9 @@ const UserConfig = {
 };
 
 export type Config = {
+  // Timestamp for when the build was started
+  buildTimestamp: number;
+
   paths: {
     // Path to the next application
     nextApp: string;
@@ -58,6 +61,8 @@ export function getConfig(appDir: string, outputDir: string): Config {
   const internalPackage = path.join(nodeModules, ...PACKAGE_NAME.split("/"));
 
   return {
+    buildTimestamp: Date.now(),
+
     paths: {
       nextApp: appDir,
       builderOutput: outputDir,
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"],