Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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: config.buildTimestamp }));
} 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";
21 changes: 21 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,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 [];
}
}
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 = "cdn-cgi/_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") {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be ctx?.kind === "ROUTE" instead of APP ?

Copy link
Collaborator Author

@james-elicx james-elicx Oct 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in app directory in older versions of next 14, it uses APP for requests that should be able to match .body (e.g. sitemaps)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(not checked pages directory, so that might apply there, but we list pages dir as unsupported at the moment)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, i missed the parseCtx function.
The .body is only for cached app route anyway, so that's fine

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"]>) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ISR revalidation runs in the background, it doesn't look like there is anything awaiting for the revalidation to finish

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i haven't changed this part - this pr was only implementing seed data. we dont support isr yet from my testing

// 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;
}
5 changes: 5 additions & 0 deletions packages/cloudflare/src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading