diff --git a/examples/vercel-blog-starter/open-next.config.ts b/examples/vercel-blog-starter/open-next.config.ts index 0f7794ffd..6337166ea 100644 --- a/examples/vercel-blog-starter/open-next.config.ts +++ b/examples/vercel-blog-starter/open-next.config.ts @@ -1,12 +1,13 @@ import type { OpenNextConfig } from "@opennextjs/aws/types/open-next"; +import cache from "@opennextjs/cloudflare/kvCache"; const config: OpenNextConfig = { default: { override: { wrapper: "cloudflare-node", converter: "edge", - // Unused implementation - incrementalCache: "dummy", + incrementalCache: async () => cache, + // Unused implementations tagCache: "dummy", queue: "dummy", }, diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index b45e30fea..c74250735 100644 --- a/packages/cloudflare/README.md +++ b/packages/cloudflare/README.md @@ -68,7 +68,6 @@ export default config; ## Known issues -- Next cache is not supported in the experimental branch yet - `▲ [WARNING] Suspicious assignment to defined constant "process.env.NODE_ENV" [assign-to-define]` can safely be ignored - Maybe more, still experimental... diff --git a/packages/cloudflare/env.d.ts b/packages/cloudflare/env.d.ts index 28761731a..8303fb30c 100644 --- a/packages/cloudflare/env.d.ts +++ b/packages/cloudflare/env.d.ts @@ -1,14 +1,13 @@ declare global { namespace NodeJS { interface ProcessEnv { - ASSETS: Fetcher; __NEXT_PRIVATE_STANDALONE_CONFIG?: string; SKIP_NEXT_APP_BUILD?: string; NEXT_PRIVATE_DEBUG_CACHE?: string; - __OPENNEXT_KV_BINDING_NAME: string; OPEN_NEXT_ORIGIN: string; NODE_ENV?: string; - __OPENNEXT_PROCESSED_ENV?: string; + // Whether process.env has been populated (on first request). + __PROCESS_ENV_POPULATED?: string; } } } diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 3ae4f7fd0..ac3ceed46 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -22,6 +22,10 @@ ".": { "import": "./dist/api/index.js", "types": "./dist/api/index.d.ts" + }, + "./*": { + "import": "./dist/api/*.js", + "types": "./dist/api/*.d.ts" } }, "files": [ @@ -65,7 +69,7 @@ "@types/mock-fs": "catalog:" }, "dependencies": { - "@opennextjs/aws": "https://pkg.pr.new/@opennextjs/aws@683", + "@opennextjs/aws": "https://pkg.pr.new/@opennextjs/aws@684", "ts-morph": "catalog:", "@dotenvx/dotenvx": "catalog:" }, diff --git a/packages/cloudflare/src/api/get-cloudflare-context.ts b/packages/cloudflare/src/api/get-cloudflare-context.ts index 50944afcf..9cbb4c91f 100644 --- a/packages/cloudflare/src/api/get-cloudflare-context.ts +++ b/packages/cloudflare/src/api/get-cloudflare-context.ts @@ -1,8 +1,8 @@ -import "server-only"; - declare global { - // eslint-disable-next-line @typescript-eslint/no-empty-object-type - interface CloudflareEnv {} + interface CloudflareEnv { + NEXT_CACHE_WORKERS_KV?: KVNamespace; + ASSETS?: Fetcher; + } } export type CloudflareContext< diff --git a/packages/cloudflare/src/api/kvCache.ts b/packages/cloudflare/src/api/kvCache.ts new file mode 100644 index 000000000..6e780a9b1 --- /dev/null +++ b/packages/cloudflare/src/api/kvCache.ts @@ -0,0 +1,152 @@ +import type { KVNamespace } from "@cloudflare/workers-types"; +import type { CacheValue, IncrementalCache, WithLastModified } from "@opennextjs/aws/types/overrides"; +import { IgnorableError, RecoverableError } from "@opennextjs/aws/utils/error.js"; + +import { getCloudflareContext } from "./get-cloudflare-context.js"; + +export const CACHE_ASSET_DIR = "cnd-cgi/_next_cache"; + +export const STATUS_DELETED = 1; + +/** + * Open Next cache based on cloudflare KV and Assets. + * + * Note: The class is instantiated outside of the request context. + * The cloudflare context and process.env are not initialzed yet + * when the constructor is called. + */ +class Cache implements IncrementalCache { + readonly name = "cloudflare-kv"; + protected initialized = false; + protected kv: KVNamespace | undefined; + protected assets: Fetcher | undefined; + + async get( + key: string, + isFetch?: IsFetch + ): Promise>> { + if (!this.initialized) { + await this.init(); + } + + if (!(this.kv || this.assets)) { + throw new IgnorableError(`No KVNamespace nor Fetcher`); + } + + this.debug(`Get ${key}`); + + try { + let entry: { + value?: CacheValue; + lastModified?: number; + status?: number; + } | null = null; + + if (this.kv) { + this.debug(`- From KV`); + const kvKey = this.getKVKey(key, isFetch); + entry = await this.kv.get(kvKey, "json"); + if (entry?.status === STATUS_DELETED) { + return {}; + } + } + + if (!entry && this.assets) { + this.debug(`- From Assets`); + const url = this.getAssetUrl(key, isFetch); + const response = await this.assets.fetch(url); + if (response.ok) { + // TODO: consider populating KV with the asset value if faster. + // This could be optional as KV writes are $$. + // See https://github.com/opennextjs/opennextjs-cloudflare/pull/194#discussion_r1893166026 + entry = { + value: await response.json(), + // __BUILD_TIMESTAMP_MS__ is injected by ESBuild. + lastModified: (globalThis as { __BUILD_TIMESTAMP_MS__?: number }).__BUILD_TIMESTAMP_MS__, + }; + } + } + this.debug(entry ? `-> hit` : `-> miss`); + return { value: entry?.value, lastModified: entry?.lastModified }; + } catch { + throw new RecoverableError(`Failed to get cache [${key}]`); + } + } + + async set( + key: string, + value: CacheValue, + isFetch?: IsFetch + ): Promise { + if (!this.initialized) { + await this.init(); + } + if (!this.kv) { + throw new IgnorableError(`No KVNamespace`); + } + this.debug(`Set ${key}`); + try { + const kvKey = this.getKVKey(key, isFetch); + // Note: We can not set a TTL as we might fallback to assets, + // still removing old data (old BUILD_ID) could help avoiding + // the cache growing too big. + await this.kv.put( + kvKey, + JSON.stringify({ + value, + // Note: `Date.now()` returns the time of the last IO rather than the actual time. + // See https://developers.cloudflare.com/workers/reference/security-model/ + lastModified: Date.now(), + }) + ); + } catch { + throw new RecoverableError(`Failed to set cache [${key}]`); + } + } + + async delete(key: string): Promise { + if (!this.initialized) { + await this.init(); + } + if (!this.kv) { + throw new IgnorableError(`No KVNamespace`); + } + this.debug(`Delete ${key}`); + try { + const kvKey = this.getKVKey(key, /* isFetch= */ false); + // Do not delete the key as we would then fallback to the assets. + await this.kv.put(kvKey, JSON.stringify({ status: STATUS_DELETED })); + } catch { + throw new RecoverableError(`Failed to delete cache [${key}]`); + } + } + + protected getKVKey(key: string, isFetch?: boolean): string { + return `${this.getBuildId()}/${key}.${isFetch ? "fetch" : "cache"}`; + } + + protected getAssetUrl(key: string, isFetch?: boolean): string { + return isFetch + ? `http://assets.local/${CACHE_ASSET_DIR}/__fetch/${this.getBuildId()}/${key}` + : `http://assets.local/${CACHE_ASSET_DIR}/${this.getBuildId()}/${key}.cache`; + } + + protected debug(...args: unknown[]) { + if (process.env.NEXT_PRIVATE_DEBUG_CACHE) { + console.log(`[Cache ${this.name}] `, ...args); + } + } + + protected getBuildId() { + return process.env.NEXT_BUILD_ID ?? "no-build-id"; + } + + protected async init() { + const env = (await getCloudflareContext()).env; + this.kv = env.NEXT_CACHE_WORKERS_KV; + this.assets = env.ASSETS; + this.initialized = true; + } +} + +export default new Cache(); diff --git a/packages/cloudflare/src/cli/build/bundle-server.ts b/packages/cloudflare/src/cli/build/bundle-server.ts index 63fc1e3a6..0cf3a4137 100644 --- a/packages/cloudflare/src/cli/build/bundle-server.ts +++ b/packages/cloudflare/src/cli/build/bundle-server.ts @@ -8,7 +8,6 @@ import { build, Plugin } from "esbuild"; import { Config } from "../config.js"; import * as patches from "./patches/index.js"; -import { copyPrerenderedRoutes } from "./utils/index.js"; /** The dist directory of the Cloudflare adapter package */ const packageDistDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "../.."); @@ -17,9 +16,6 @@ const packageDistDir = path.join(path.dirname(fileURLToPath(import.meta.url)), " * Bundle the Open Next server. */ export async function bundleServer(config: Config, openNextOptions: BuildOptions): Promise { - // Copy over prerendered assets (e.g. SSG routes) - copyPrerenderedRoutes(config); - patches.copyPackageCliFiles(packageDistDir, config, openNextOptions); const nextConfigStr = @@ -113,6 +109,7 @@ globalThis.Request = CustomRequest; Request = globalThis.Request; // Makes the edge converter returns either a Response or a Request. globalThis.__dangerous_ON_edge_converter_returns_request = true; +globalThis.__BUILD_TIMESTAMP_MS__ = ${Date.now()}; `, }, }); diff --git a/packages/cloudflare/src/cli/build/index.ts b/packages/cloudflare/src/cli/build/index.ts index 55da71fba..3530221c6 100644 --- a/packages/cloudflare/src/cli/build/index.ts +++ b/packages/cloudflare/src/cli/build/index.ts @@ -5,7 +5,7 @@ import { dirname, join } from "node:path"; import { buildNextjsApp, setStandaloneBuildMode } from "@opennextjs/aws/build/buildNextApp.js"; import { compileCache } from "@opennextjs/aws/build/compileCache.js"; import { compileOpenNextConfig } from "@opennextjs/aws/build/compileConfig.js"; -import { createStaticAssets } from "@opennextjs/aws/build/createAssets.js"; +import { createCacheAssets, createStaticAssets } from "@opennextjs/aws/build/createAssets.js"; import { createMiddleware } from "@opennextjs/aws/build/createMiddleware.js"; import * as buildHelper from "@opennextjs/aws/build/helper.js"; import { printHeader, showWarningOnWindows } from "@opennextjs/aws/build/utils.js"; @@ -16,6 +16,7 @@ import type { ProjectOptions } from "../config.js"; import { containsDotNextDir, getConfig } from "../config.js"; import { bundleServer } from "./bundle-server.js"; import { compileEnvFiles } from "./open-next/compile-env-files.js"; +import { copyCacheAssets } from "./open-next/copyCacheAssets.js"; import { createServerBundle } from "./open-next/createServerBundle.js"; /** @@ -80,6 +81,11 @@ export async function build(projectOpts: ProjectOptions): Promise { createStaticAssets(options); + if (config.dangerous?.disableIncrementalCache !== true) { + createCacheAssets(options); + copyCacheAssets(options); + } + await createServerBundle(options); // TODO: drop this copy. @@ -103,10 +109,11 @@ function ensureCloudflareConfig(config: OpenNextConfig) { const requirements = { dftUseCloudflareWrapper: config.default?.override?.wrapper === "cloudflare-node", dftUseEdgeConverter: config.default?.override?.converter === "edge", - dftUseDummyCache: - config.default?.override?.incrementalCache === "dummy" && - config.default?.override?.tagCache === "dummy" && - config.default?.override?.queue === "dummy", + dftMaybeUseCache: + config.default?.override?.incrementalCache === "dummy" || + typeof config.default?.override?.incrementalCache === "function", + dftUseDummyTagCacheAndQueue: + config.default?.override?.tagCache === "dummy" && config.default?.override?.queue === "dummy", disableCacheInterception: config.dangerous?.enableCacheInterception !== true, mwIsMiddlewareExternal: config.middleware?.external == true, mwUseCloudflareWrapper: config.middleware?.override?.wrapper === "cloudflare-edge", @@ -121,7 +128,7 @@ function ensureCloudflareConfig(config: OpenNextConfig) { override: { wrapper: "cloudflare-node", converter: "edge", - incrementalCache: "dummy", + incrementalCache: "dummy" | function, tagCache: "dummy", queue: "dummy", }, diff --git a/packages/cloudflare/src/cli/build/open-next/copyCacheAssets.ts b/packages/cloudflare/src/cli/build/open-next/copyCacheAssets.ts new file mode 100644 index 000000000..50b752c1e --- /dev/null +++ b/packages/cloudflare/src/cli/build/open-next/copyCacheAssets.ts @@ -0,0 +1,14 @@ +import { cpSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; + +import * as buildHelper from "@opennextjs/aws/build/helper.js"; + +import { CACHE_ASSET_DIR } from "../../../api/kvCache.js"; + +export function copyCacheAssets(options: buildHelper.BuildOptions) { + const { outputDir } = options; + const srcPath = join(outputDir, "cache"); + const dstPath = join(outputDir, "assets", CACHE_ASSET_DIR); + mkdirSync(dstPath, { recursive: true }); + cpSync(srcPath, dstPath, { recursive: true }); +} diff --git a/packages/cloudflare/src/cli/build/utils/copy-prerendered-routes.ts b/packages/cloudflare/src/cli/build/utils/copy-prerendered-routes.ts deleted file mode 100644 index 6b207a829..000000000 --- a/packages/cloudflare/src/cli/build/utils/copy-prerendered-routes.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; - -import type { PrerenderManifest } from "next/dist/build"; - -import { Config } from "../../config.js"; -import { NEXT_META_SUFFIX, SEED_DATA_DIR } from "../../constants/incremental-cache.js"; -import { readPathsRecursively } from "./read-paths-recursively.js"; - -/** - * 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.output.standaloneAppServer, "app"); - const prerenderManifestPath = join(config.paths.output.standaloneAppDotNext, "prerender-manifest.json"); - const outputPath = join(config.paths.output.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.build.timestamp })); - } 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 2a1c26016..fec50715a 100644 --- a/packages/cloudflare/src/cli/build/utils/index.ts +++ b/packages/cloudflare/src/cli/build/utils/index.ts @@ -1,4 +1,3 @@ -export * from "./copy-prerendered-routes.js"; export * from "./extract-project-env-vars.js"; export * from "./normalize-path.js"; export * from "./ts-parse-file.js"; diff --git a/packages/cloudflare/src/cli/config.ts b/packages/cloudflare/src/cli/config.ts index eb101e0c7..e08889948 100644 --- a/packages/cloudflare/src/cli/config.ts +++ b/packages/cloudflare/src/cli/config.ts @@ -5,8 +5,6 @@ const PACKAGE_NAME = "@opennextjs/cloudflare"; export type Config = { build: { - // Timestamp for when the build was started - timestamp: number; // Whether to skip building the Next.js app or not skipNextBuild: boolean; // Whether minification should be enabled or not @@ -46,10 +44,6 @@ export type Config = { }; }; - cache: { - kvBindingName: string; - }; - // Internal name for the copy of the package internalPackageName: string; }; @@ -74,11 +68,8 @@ export function getConfig(projectOpts: ProjectOptions): Config { const internalPackage = join(nodeModules, ...PACKAGE_NAME.split("/")); const internalTemplates = join(internalPackage, "cli", "templates"); - process.env.__OPENNEXT_KV_BINDING_NAME ??= "NEXT_CACHE_WORKERS_KV"; - return { build: { - timestamp: Date.now(), skipNextBuild: projectOpts.skipNextBuild, shouldMinify: projectOpts.minify, }, @@ -104,10 +95,6 @@ export function getConfig(projectOpts: ProjectOptions): Config { }, }, - cache: { - kvBindingName: process.env.__OPENNEXT_KV_BINDING_NAME, - }, - internalPackageName: PACKAGE_NAME, }; } diff --git a/packages/cloudflare/src/cli/constants/incremental-cache.ts b/packages/cloudflare/src/cli/constants/incremental-cache.ts deleted file mode 100644 index 90b21bbc5..000000000 --- a/packages/cloudflare/src/cli/constants/incremental-cache.ts +++ /dev/null @@ -1,8 +0,0 @@ -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/templates/worker.ts b/packages/cloudflare/src/cli/templates/worker.ts index d4617b355..f5ab47be6 100644 --- a/packages/cloudflare/src/cli/templates/worker.ts +++ b/packages/cloudflare/src/cli/templates/worker.ts @@ -21,35 +21,22 @@ const cloudflareContextALS = new AsyncLocalStorage(); } ); -async function applyProjectEnvVars(mode: string) { - if (process.env.__OPENNEXT_PROCESSED_ENV === "1") return; - - // @ts-expect-error: resolved by wrangler build - const nextEnvVars = await import("./.env.mjs"); - - if (nextEnvVars[mode]) { - for (const key in nextEnvVars[mode]) { - process.env[key] = nextEnvVars[mode][key]; - } - } - - process.env.__OPENNEXT_PROCESSED_ENV = "1"; -} - export default { async fetch(request, env, ctx) { return cloudflareContextALS.run({ env, ctx, cf: request.cf }, async () => { - // Set the default Origin for the origin resolver. const url = new URL(request.url); - process.env.OPEN_NEXT_ORIGIN = JSON.stringify({ - default: { - host: url.hostname, - protocol: url.protocol.slice(0, -1), - port: url.port, - }, - }); - await applyProjectEnvVars(env.NEXTJS_ENV ?? "production"); + if (process.env.__PROCESS_ENV_POPULATED !== "1") { + await populateProcessEnv(url, env.NEXTJS_ENV); + process.env.__PROCESS_ENV_POPULATED = "1"; + } + + if (url.pathname === "/_next/image") { + const imageUrl = url.searchParams.get("url") ?? ""; + return imageUrl.startsWith("/") + ? env.ASSETS.fetch(new URL(imageUrl, request.url)) + : fetch(imageUrl, { cf: { cacheEverything: true } }); + } // The Middleware handler can return either a `Response` or a `Request`: // - `Response`s should be returned early @@ -64,3 +51,36 @@ export default { }); }, } as ExportedHandler<{ ASSETS: Fetcher; NEXTJS_ENV?: string }>; + +/** + * Populate process.env with: + * - the variables from Next .env* files + * - the origin resolver information + * + * Note that cloudflare env string values are copied by the middleware handler. + */ +async function populateProcessEnv(url: URL, nextJsEnv?: string) { + if (process.env.__PROCESS_ENV_POPULATED === "1") { + return; + } + + // @ts-expect-error: resolved by wrangler build + const nextEnvVars = await import("./.env.mjs"); + + const mode = nextJsEnv ?? "production"; + + if (nextEnvVars[mode]) { + for (const key in nextEnvVars[mode]) { + process.env[key] = nextEnvVars[mode][key]; + } + } + + // Set the default Origin for the origin resolver. + process.env.OPEN_NEXT_ORIGIN = JSON.stringify({ + default: { + host: url.hostname, + protocol: url.protocol.slice(0, -1), + port: url.port, + }, + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1917de5c6..75480e704 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -359,8 +359,8 @@ importers: specifier: 'catalog:' version: 1.31.0 '@opennextjs/aws': - specifier: https://pkg.pr.new/@opennextjs/aws@683 - version: https://pkg.pr.new/@opennextjs/aws@683 + specifier: https://pkg.pr.new/@opennextjs/aws@684 + version: https://pkg.pr.new/@opennextjs/aws@684 rimraf: specifier: 'catalog:' version: 6.0.1 @@ -1795,8 +1795,8 @@ packages: '@octokit/types@13.6.1': resolution: {integrity: sha512-PHZE9Z+kWXb23Ndik8MKPirBPziOc0D2/3KH1P+6jK5nGWe96kadZuE4jev2/Jq7FvIfTlT2Ltg8Fv2x1v0a5g==} - '@opennextjs/aws@https://pkg.pr.new/@opennextjs/aws@683': - resolution: {tarball: https://pkg.pr.new/@opennextjs/aws@683} + '@opennextjs/aws@https://pkg.pr.new/@opennextjs/aws@684': + resolution: {tarball: https://pkg.pr.new/@opennextjs/aws@684} version: 3.3.0 hasBin: true @@ -6712,7 +6712,7 @@ snapshots: dependencies: '@octokit/openapi-types': 22.2.0 - '@opennextjs/aws@https://pkg.pr.new/@opennextjs/aws@683': + '@opennextjs/aws@https://pkg.pr.new/@opennextjs/aws@684': dependencies: '@aws-sdk/client-dynamodb': 3.716.0 '@aws-sdk/client-lambda': 3.716.0