-
Notifications
You must be signed in to change notification settings - Fork 72
feat: ssg support through seeding the incremental cache #65
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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); | ||
} | ||
}); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
export * from "./ts-parse-file"; | ||
export * from "./copy-prerendered-routes"; |
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 []; | ||
} | ||
} |
This file was deleted.
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"; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export * from "./constants"; | ||
export * from "./open-next-cache-handler"; |
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"]>) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 {} | ||
} |
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; | ||
} |
There was a problem hiding this comment.
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 ofAPP
?Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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)There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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