Skip to content

Commit 04be59b

Browse files
authored
feat: ssg support through seeding the incremental cache (#65)
1 parent ba9af72 commit 04be59b

File tree

13 files changed

+278
-67
lines changed

13 files changed

+278
-67
lines changed

packages/cloudflare/env.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ declare global {
44
ASSETS: Fetcher;
55
__NEXT_PRIVATE_STANDALONE_CONFIG?: string;
66
SKIP_NEXT_APP_BUILD?: string;
7+
NEXT_PRIVATE_DEBUG_CACHE?: string;
78
[key: string]: string | Fetcher;
89
}
910
}

packages/cloudflare/src/cli/build/build-worker.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { cp, readFile, writeFile } from "node:fs/promises";
33
import { existsSync, readFileSync } from "node:fs";
44
import { Config } from "../config";
55
import { copyPackageCliFiles } from "./patches/investigated/copy-package-cli-files";
6+
import { copyPrerenderedRoutes } from "./utils";
67
import { fileURLToPath } from "node:url";
78
import { inlineEvalManifest } from "./patches/to-investigate/inline-eval-manifest";
89
import { inlineMiddlewareManifestRequire } from "./patches/to-investigate/inline-middleware-manifest-require";
@@ -45,6 +46,9 @@ export async function buildWorker(config: Config): Promise<void> {
4546
});
4647
}
4748

49+
// Copy over prerendered assets (e.g. SSG routes)
50+
copyPrerenderedRoutes(config);
51+
4852
copyPackageCliFiles(packageDistDir, config);
4953

5054
const templateDir = path.join(config.paths.internalPackage, "cli", "templates");

packages/cloudflare/src/cli/build/patches/investigated/patch-cache.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ import path from "node:path";
77
export function patchCache(code: string, config: Config): string {
88
console.log("# patchCache");
99

10-
const cacheHandler = path.join(config.paths.internalPackage, "cli", "cache-handler.mjs");
10+
const cacheHandler = path.join(config.paths.internalPackage, "cli", "cache-handler", "index.mjs");
1111

1212
const patchedCode = code.replace(
1313
"const { cacheHandler } = this.nextConfig;",
1414
`const cacheHandler = null;
15-
CacheHandler = (await import('${cacheHandler}')).default;
15+
CacheHandler = (await import('${cacheHandler}')).OpenNextCacheHandler;
1616
CacheHandler.maybeKVNamespace = process.env["${config.cache.kvBindingName}"];
1717
`
1818
);
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { NEXT_META_SUFFIX, SEED_DATA_DIR } from "../../cache-handler";
2+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3+
import { dirname, join } from "node:path";
4+
import { Config } from "../../config";
5+
import type { PrerenderManifest } from "next/dist/build";
6+
import { readPathsRecursively } from "./read-paths-recursively";
7+
8+
/**
9+
* Copies all prerendered routes from the standalone output directory to the OpenNext static assets
10+
* output directory.
11+
*
12+
* Updates metadata configs with the current time as a modified date, so that it can be re-used in
13+
* the incremental cache to determine whether an entry is _fresh_ or not.
14+
*
15+
* @param config Build config.
16+
*/
17+
export function copyPrerenderedRoutes(config: Config) {
18+
console.log("# copyPrerenderedRoutes");
19+
20+
const serverAppDirPath = join(config.paths.standaloneAppServer, "app");
21+
const prerenderManifestPath = join(config.paths.standaloneAppDotNext, "prerender-manifest.json");
22+
const outputPath = join(config.paths.builderOutput, "assets", SEED_DATA_DIR);
23+
24+
const prerenderManifest: PrerenderManifest = existsSync(prerenderManifestPath)
25+
? JSON.parse(readFileSync(prerenderManifestPath, "utf8"))
26+
: {};
27+
const prerenderedRoutes = Object.keys(prerenderManifest.routes);
28+
29+
const prerenderedAssets = readPathsRecursively(serverAppDirPath)
30+
.map((fullPath) => ({ fullPath, relativePath: fullPath.replace(serverAppDirPath, "") }))
31+
.filter(({ relativePath }) =>
32+
prerenderedRoutes.includes(relativePath.replace(/\.\w+$/, "").replace(/^\/index$/, "/"))
33+
);
34+
35+
prerenderedAssets.forEach(({ fullPath, relativePath }) => {
36+
const destPath = join(outputPath, relativePath);
37+
mkdirSync(dirname(destPath), { recursive: true });
38+
39+
if (fullPath.endsWith(NEXT_META_SUFFIX)) {
40+
const data = JSON.parse(readFileSync(fullPath, "utf8"));
41+
writeFileSync(destPath, JSON.stringify({ ...data, lastModified: config.buildTimestamp }));
42+
} else {
43+
copyFileSync(fullPath, destPath);
44+
}
45+
});
46+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from "./ts-parse-file";
2+
export * from "./copy-prerendered-routes";
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { join } from "node:path";
2+
import { readdirSync } from "node:fs";
3+
4+
/**
5+
* Recursively reads all file paths in a directory.
6+
*
7+
* @param dir Directory to recursively read from.
8+
* @returns Array of all paths for all files in a directory.
9+
*/
10+
export function readPathsRecursively(dir: string): string[] {
11+
try {
12+
const files = readdirSync(dir, { withFileTypes: true });
13+
14+
return files.flatMap((file) => {
15+
const filePath = join(dir, file.name);
16+
return file.isDirectory() ? readPathsRecursively(filePath) : filePath;
17+
});
18+
} catch {
19+
return [];
20+
}
21+
}

packages/cloudflare/src/cli/cache-handler.ts

Lines changed: 0 additions & 64 deletions
This file was deleted.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export const RSC_PREFETCH_SUFFIX = ".prefetch.rsc";
2+
export const RSC_SUFFIX = ".rsc";
3+
export const NEXT_DATA_SUFFIX = ".json";
4+
export const NEXT_META_SUFFIX = ".meta";
5+
export const NEXT_BODY_SUFFIX = ".body";
6+
export const NEXT_HTML_SUFFIX = ".html";
7+
8+
export const SEED_DATA_DIR = "cdn-cgi/_cf_seed_data";
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from "./constants";
2+
export * from "./open-next-cache-handler";
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import type {
2+
CacheHandler,
3+
CacheHandlerContext,
4+
CacheHandlerValue,
5+
} from "next/dist/server/lib/incremental-cache";
6+
import {
7+
NEXT_BODY_SUFFIX,
8+
NEXT_DATA_SUFFIX,
9+
NEXT_HTML_SUFFIX,
10+
RSC_PREFETCH_SUFFIX,
11+
RSC_SUFFIX,
12+
SEED_DATA_DIR,
13+
} from "./constants";
14+
import { getSeedBodyFile, getSeedMetaFile, getSeedTextFile, parseCtx } from "./utils";
15+
import type { IncrementalCacheValue } from "next/dist/server/response-cache";
16+
import { KVNamespace } from "@cloudflare/workers-types";
17+
18+
type CacheEntry = {
19+
lastModified: number;
20+
value: IncrementalCacheValue | null;
21+
};
22+
23+
export class OpenNextCacheHandler implements CacheHandler {
24+
static maybeKVNamespace: KVNamespace | undefined = undefined;
25+
26+
protected debug: boolean = !!process.env.NEXT_PRIVATE_DEBUG_CACHE;
27+
28+
constructor(protected ctx: CacheHandlerContext) {}
29+
30+
async get(...args: Parameters<CacheHandler["get"]>): Promise<CacheHandlerValue | null> {
31+
const [key, _ctx] = args;
32+
const ctx = parseCtx(_ctx);
33+
34+
if (this.debug) console.log(`cache - get: ${key}, ${ctx?.kind}`);
35+
36+
if (OpenNextCacheHandler.maybeKVNamespace !== undefined) {
37+
try {
38+
const value = await OpenNextCacheHandler.maybeKVNamespace.get<CacheEntry>(key, "json");
39+
if (value) return value;
40+
} catch (e) {
41+
console.error(`Failed to get value for key = ${key}: ${e}`);
42+
}
43+
}
44+
45+
// Check for seed data from the file-system.
46+
47+
// we don't check for seed data for fetch or image cache entries
48+
if (ctx?.kind === "FETCH" || ctx?.kind === "IMAGE") return null;
49+
50+
const seedKey = `http://assets.local/${SEED_DATA_DIR}/${key}`.replace(/\/\//g, "/");
51+
52+
if (ctx?.kind === "APP" || ctx?.kind === "APP_ROUTE") {
53+
const fallbackBody = await getSeedBodyFile(seedKey, NEXT_BODY_SUFFIX);
54+
if (fallbackBody) {
55+
const meta = await getSeedMetaFile(seedKey);
56+
return {
57+
lastModified: meta?.lastModified,
58+
value: {
59+
kind: (ctx.kind === "APP_ROUTE" ? ctx.kind : "ROUTE") as Extract<
60+
IncrementalCacheValue["kind"],
61+
"ROUTE"
62+
>,
63+
body: fallbackBody,
64+
status: meta?.status ?? 200,
65+
headers: meta?.headers ?? {},
66+
},
67+
};
68+
}
69+
70+
if (ctx.kind === "APP_ROUTE") {
71+
return null;
72+
}
73+
}
74+
75+
const seedHtml = await getSeedTextFile(seedKey, NEXT_HTML_SUFFIX);
76+
if (!seedHtml) return null; // we're only checking for prerendered routes at the moment
77+
78+
if (ctx?.kind === "PAGES" || ctx?.kind === "APP" || ctx?.kind === "APP_PAGE") {
79+
const metaPromise = getSeedMetaFile(seedKey);
80+
81+
let pageDataPromise: Promise<Buffer | string | undefined> = Promise.resolve(undefined);
82+
if (!ctx.isFallback) {
83+
const rscSuffix = ctx.isRoutePPREnabled ? RSC_PREFETCH_SUFFIX : RSC_SUFFIX;
84+
85+
if (ctx.kind === "APP_PAGE") {
86+
pageDataPromise = getSeedBodyFile(seedKey, rscSuffix);
87+
} else {
88+
pageDataPromise = getSeedTextFile(seedKey, ctx.kind === "APP" ? rscSuffix : NEXT_DATA_SUFFIX);
89+
}
90+
}
91+
92+
const [meta, pageData] = await Promise.all([metaPromise, pageDataPromise]);
93+
94+
return {
95+
lastModified: meta?.lastModified,
96+
value: {
97+
kind: (ctx.kind === "APP_PAGE" ? "APP_PAGE" : "PAGE") as Extract<
98+
IncrementalCacheValue["kind"],
99+
"PAGE"
100+
>,
101+
html: seedHtml,
102+
pageData: pageData ?? "",
103+
...(ctx.kind === "APP_PAGE" && { rscData: pageData }),
104+
postponed: meta?.postponed,
105+
status: meta?.status,
106+
headers: meta?.headers,
107+
},
108+
};
109+
}
110+
111+
return null;
112+
}
113+
114+
async set(...args: Parameters<CacheHandler["set"]>) {
115+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
116+
const [key, entry, _ctx] = args;
117+
118+
if (OpenNextCacheHandler.maybeKVNamespace === undefined) {
119+
return;
120+
}
121+
122+
if (this.debug) console.log(`cache - set: ${key}`);
123+
124+
const data: CacheEntry = {
125+
lastModified: Date.now(),
126+
value: entry,
127+
};
128+
129+
try {
130+
await OpenNextCacheHandler.maybeKVNamespace.put(key, JSON.stringify(data));
131+
} catch (e) {
132+
console.error(`Failed to set value for key = ${key}: ${e}`);
133+
}
134+
}
135+
136+
async revalidateTag(...args: Parameters<CacheHandler["revalidateTag"]>) {
137+
const [tags] = args;
138+
if (OpenNextCacheHandler.maybeKVNamespace === undefined) {
139+
return;
140+
}
141+
142+
if (this.debug) console.log(`cache - revalidateTag: ${JSON.stringify([tags].flat())}`);
143+
}
144+
145+
resetRequestCache(): void {}
146+
}

0 commit comments

Comments
 (0)