Skip to content

Commit

Permalink
feat: ssg support through seeding incremental cache
Browse files Browse the repository at this point in the history
  • Loading branch information
james-elicx committed Oct 6, 2024
1 parent bcaaec6 commit f60dbc3
Show file tree
Hide file tree
Showing 12 changed files with 275 additions and 67 deletions.
1 change: 1 addition & 0 deletions packages/cloudflare/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
4 changes: 4 additions & 0 deletions packages/cloudflare/src/cli/build/build-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -43,6 +44,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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}"];
`
);
Expand Down
46 changes: 46 additions & 0 deletions packages/cloudflare/src/cli/build/utils/copy-prerendered-routes.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
}
1 change: 1 addition & 0 deletions packages/cloudflare/src/cli/build/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./ts-parse-file";
export * from "./copy-prerendered-routes";
23 changes: 23 additions & 0 deletions packages/cloudflare/src/cli/build/utils/read-paths-recursively.ts
Original file line number Diff line number Diff line change
@@ -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 [];
}
}
64 changes: 0 additions & 64 deletions packages/cloudflare/src/cli/cache-handler.ts

This file was deleted.

8 changes: 8 additions & 0 deletions packages/cloudflare/src/cli/cache-handler/constants.ts
Original file line number Diff line number Diff line change
@@ -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";
2 changes: 2 additions & 0 deletions packages/cloudflare/src/cli/cache-handler/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./constants";
export * from "./open-next-cache-handler";
146 changes: 146 additions & 0 deletions packages/cloudflare/src/cli/cache-handler/open-next-cache-handler.ts
Original file line number Diff line number Diff line change
@@ -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 {}
}
41 changes: 41 additions & 0 deletions packages/cloudflare/src/cli/cache-handler/utils.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 1 addition & 1 deletion packages/cloudflare/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down

0 comments on commit f60dbc3

Please sign in to comment.