From afada27eadcc445df027b43f2d24fc87bcc6f1ec Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 19 Dec 2024 18:50:41 +0100 Subject: [PATCH 1/8] feat: kv cache --- .../vercel-blog-starter/open-next.config.ts | 5 +- packages/cloudflare/env.d.ts | 5 +- packages/cloudflare/package.json | 4 + .../src/api/get-cloudflare-context.ts | 8 +- packages/cloudflare/src/api/kvCache.ts | 126 ++++++++++++++++++ .../cloudflare/src/cli/build/bundle-server.ts | 4 - packages/cloudflare/src/cli/build/index.ts | 19 ++- .../cli/build/open-next/copyCacheAssets.ts | 15 +++ .../build/utils/copy-prerendered-routes.ts | 48 ------- .../cloudflare/src/cli/build/utils/index.ts | 1 - packages/cloudflare/src/cli/config.ts | 10 -- .../src/cli/constants/incremental-cache.ts | 8 -- .../cloudflare/src/cli/templates/worker.ts | 68 ++++++---- 13 files changed, 211 insertions(+), 110 deletions(-) create mode 100644 packages/cloudflare/src/api/kvCache.ts create mode 100644 packages/cloudflare/src/cli/build/open-next/copyCacheAssets.ts delete mode 100644 packages/cloudflare/src/cli/build/utils/copy-prerendered-routes.ts delete mode 100644 packages/cloudflare/src/cli/constants/incremental-cache.ts diff --git a/examples/vercel-blog-starter/open-next.config.ts b/examples/vercel-blog-starter/open-next.config.ts index 0f7794ff..6337166e 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/env.d.ts b/packages/cloudflare/env.d.ts index 28761731..8303fb30 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 3ae4f7fd..99343648 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": [ diff --git a/packages/cloudflare/src/api/get-cloudflare-context.ts b/packages/cloudflare/src/api/get-cloudflare-context.ts index 50944afc..9cbb4c91 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 00000000..8a2b6a78 --- /dev/null +++ b/packages/cloudflare/src/api/kvCache.ts @@ -0,0 +1,126 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import type { KVNamespace } from "@cloudflare/workers-types"; +import type { Extension } from "@opennextjs/aws/types/cache"; +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"; + +/** + * 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 { + this.debug(`- From KV`); + const kvKey = this.getKVKey(key, isFetch ? "fetch" : "cache"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let value: any = await this.kv?.get(kvKey, "json"); + if (!value && this.assets) { + const url = this.getAssetUrl(key); + const response = await this.assets.fetch(url); + this.debug(`- From Assets`); + if (response.ok) { + value = await response.json(); + } + } + if (value) { + this.debug(`-> hit`); + return { value }; + } + } catch { + throw new RecoverableError(`Failed to get cache [${key}]`); + } + + this.debug(`-> miss`); + throw new RecoverableError(`Not found [${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 ? "fetch" : "cache"); + // TODO: add TTL to avoid cache growing too big ? + await this.kv.put(kvKey, JSON.stringify(value)); + } 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, "cache"); + await this.kv.delete(kvKey); + } catch (e) { + throw new RecoverableError(`Failed to delete cache [${key}]`); + } + } + + protected getKVKey(key: string, extension: Extension): string { + return `${this.getBuildId()}/${key}.${extension}`; + } + + protected getAssetUrl(key: string): string { + return `http://assets.local/${CACHE_ASSET_DIR}/${this.getBuildId()}/${key}.cache`.replace(/\/\//g, "/"); + } + + 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 63fc1e3a..7fb199bf 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 = diff --git a/packages/cloudflare/src/cli/build/index.ts b/packages/cloudflare/src/cli/build/index.ts index 55da71fb..d5b91d67 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", + dftUseDummyTagCache: + 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 00000000..2ae8f3e3 --- /dev/null +++ b/packages/cloudflare/src/cli/build/open-next/copyCacheAssets.ts @@ -0,0 +1,15 @@ +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 { appBuildOutputPath, outputDir } = options; + const buildId = buildHelper.getBuildId(appBuildOutputPath); + const srcPath = join(outputDir, "cache", buildId); + const dstPath = join(outputDir, "assets", CACHE_ASSET_DIR, buildId); + 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 6b207a82..00000000 --- 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 2a1c2601..fec50715 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 eb101e0c..e7e1f2e9 100644 --- a/packages/cloudflare/src/cli/config.ts +++ b/packages/cloudflare/src/cli/config.ts @@ -46,10 +46,6 @@ export type Config = { }; }; - cache: { - kvBindingName: string; - }; - // Internal name for the copy of the package internalPackageName: string; }; @@ -74,8 +70,6 @@ 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(), @@ -104,10 +98,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 90b21bbc..00000000 --- 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 d4617b35..f5ab47be 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, + }, + }); +} From 4b9ad3c24f9dc6a3f996fc4fb0e297dbf2e57700 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 20 Dec 2024 07:20:07 +0100 Subject: [PATCH 2/8] Update packages/cloudflare/src/cli/build/index.ts Co-authored-by: conico974 --- packages/cloudflare/src/cli/build/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cloudflare/src/cli/build/index.ts b/packages/cloudflare/src/cli/build/index.ts index d5b91d67..3530221c 100644 --- a/packages/cloudflare/src/cli/build/index.ts +++ b/packages/cloudflare/src/cli/build/index.ts @@ -112,7 +112,7 @@ function ensureCloudflareConfig(config: OpenNextConfig) { dftMaybeUseCache: config.default?.override?.incrementalCache === "dummy" || typeof config.default?.override?.incrementalCache === "function", - dftUseDummyTagCache: + dftUseDummyTagCacheAndQueue: config.default?.override?.tagCache === "dummy" && config.default?.override?.queue === "dummy", disableCacheInterception: config.dangerous?.enableCacheInterception !== true, mwIsMiddlewareExternal: config.middleware?.external == true, From 8ca0c0792aad7fb2572891002d9347565d002a22 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 20 Dec 2024 12:14:17 +0100 Subject: [PATCH 3/8] fixup! delete, lastModified --- packages/cloudflare/src/api/kvCache.ts | 45 +++++++++++++------ .../cloudflare/src/cli/build/bundle-server.ts | 2 + packages/cloudflare/src/cli/config.ts | 3 -- 3 files changed, 33 insertions(+), 17 deletions(-) diff --git a/packages/cloudflare/src/api/kvCache.ts b/packages/cloudflare/src/api/kvCache.ts index 8a2b6a78..968fe405 100644 --- a/packages/cloudflare/src/api/kvCache.ts +++ b/packages/cloudflare/src/api/kvCache.ts @@ -8,6 +8,8 @@ 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. * @@ -38,26 +40,32 @@ class Cache implements IncrementalCache { try { this.debug(`- From KV`); const kvKey = this.getKVKey(key, isFetch ? "fetch" : "cache"); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let value: any = await this.kv?.get(kvKey, "json"); - if (!value && this.assets) { + + let entry = (await this.kv?.get(kvKey, "json")) as { + value?: CacheValue; + lastModified?: number; + status?: number; + } | null; + if (entry?.status === STATUS_DELETED) { + return {}; + } + if (!entry && this.assets) { const url = this.getAssetUrl(key); const response = await this.assets.fetch(url); this.debug(`- From Assets`); if (response.ok) { - value = await response.json(); + entry = { + value: await response.json(), + // __BUILD_TIMESTAMP_MS__ is injected by ESBuild. + lastModified: (globalThis as { __BUILD_TIMESTAMP_MS__?: number }).__BUILD_TIMESTAMP_MS__, + }; } } - if (value) { - this.debug(`-> hit`); - return { value }; - } + this.debug(entry ? `-> hit` : `-> miss`); + return { value: entry?.value, lastModified: entry?.lastModified }; } catch { throw new RecoverableError(`Failed to get cache [${key}]`); } - - this.debug(`-> miss`); - throw new RecoverableError(`Not found [${key}]`); } async set( @@ -74,8 +82,16 @@ class Cache implements IncrementalCache { this.debug(`Set ${key}`); try { const kvKey = this.getKVKey(key, isFetch ? "fetch" : "cache"); - // TODO: add TTL to avoid cache growing too big ? - await this.kv.put(kvKey, JSON.stringify(value)); + // 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, + lastModified: Date.now(), + }) + ); } catch { throw new RecoverableError(`Failed to set cache [${key}]`); } @@ -91,7 +107,8 @@ class Cache implements IncrementalCache { this.debug(`Delete ${key}`); try { const kvKey = this.getKVKey(key, "cache"); - await this.kv.delete(kvKey); + // Do not delete the key as we will then fallback to the assets. + await this.kv.put(kvKey, JSON.stringify({ status: STATUS_DELETED })); } catch (e) { throw new RecoverableError(`Failed to delete cache [${key}]`); } diff --git a/packages/cloudflare/src/cli/build/bundle-server.ts b/packages/cloudflare/src/cli/build/bundle-server.ts index 7fb199bf..2e27a523 100644 --- a/packages/cloudflare/src/cli/build/bundle-server.ts +++ b/packages/cloudflare/src/cli/build/bundle-server.ts @@ -69,6 +69,7 @@ export async function bundleServer(config: Config, openNextOptions: BuildOptions "process.env.NEXT_RUNTIME": '"nodejs"', "process.env.NODE_ENV": '"production"', "process.env.NEXT_MINIMAL": "true", + "process.env.__BUILD_TIMESTAMP_MS__": String(Date.now()), }, // We need to set platform to node so that esbuild doesn't complain about the node imports platform: "node", @@ -109,6 +110,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/config.ts b/packages/cloudflare/src/cli/config.ts index e7e1f2e9..e0888994 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 @@ -72,7 +70,6 @@ export function getConfig(projectOpts: ProjectOptions): Config { return { build: { - timestamp: Date.now(), skipNextBuild: projectOpts.skipNextBuild, shouldMinify: projectOpts.minify, }, From e6c6deda81fa09158d9ece255a41cb3be4a915d6 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 20 Dec 2024 12:18:30 +0100 Subject: [PATCH 4/8] fixup! feedback comment --- packages/cloudflare/src/api/kvCache.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cloudflare/src/api/kvCache.ts b/packages/cloudflare/src/api/kvCache.ts index 968fe405..028512b7 100644 --- a/packages/cloudflare/src/api/kvCache.ts +++ b/packages/cloudflare/src/api/kvCache.ts @@ -54,6 +54,9 @@ class Cache implements IncrementalCache { const response = await this.assets.fetch(url); this.debug(`- From Assets`); 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. From 05e8ae4240f5f229d3b097398831f0d0745bd4a6 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 20 Dec 2024 13:13:14 +0100 Subject: [PATCH 5/8] fixup! fetch cache --- packages/cloudflare/package.json | 4 +- packages/cloudflare/src/api/kvCache.ts | 42 ++++++++++--------- .../cloudflare/src/cli/build/bundle-server.ts | 1 - .../cli/build/open-next/copyCacheAssets.ts | 7 ++-- pnpm-lock.yaml | 10 ++--- 5 files changed, 33 insertions(+), 31 deletions(-) diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 99343648..3a5c298e 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -69,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:" }, @@ -77,4 +77,4 @@ "rimraf": "catalog:", "wrangler": "catalog:" } -} +} \ No newline at end of file diff --git a/packages/cloudflare/src/api/kvCache.ts b/packages/cloudflare/src/api/kvCache.ts index 028512b7..19e1431f 100644 --- a/packages/cloudflare/src/api/kvCache.ts +++ b/packages/cloudflare/src/api/kvCache.ts @@ -1,6 +1,4 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ import type { KVNamespace } from "@cloudflare/workers-types"; -import type { Extension } from "@opennextjs/aws/types/cache"; import type { CacheValue, IncrementalCache, WithLastModified } from "@opennextjs/aws/types/overrides"; import { IgnorableError, RecoverableError } from "@opennextjs/aws/utils/error.js"; @@ -38,21 +36,25 @@ class Cache implements IncrementalCache { this.debug(`Get ${key}`); try { - this.debug(`- From KV`); - const kvKey = this.getKVKey(key, isFetch ? "fetch" : "cache"); - - let entry = (await this.kv?.get(kvKey, "json")) as { + let entry: { value?: CacheValue; lastModified?: number; status?: number; - } | null; - if (entry?.status === STATUS_DELETED) { - return {}; + } | 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) { - const url = this.getAssetUrl(key); - const response = await this.assets.fetch(url); 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 $$. @@ -84,7 +86,7 @@ class Cache implements IncrementalCache { } this.debug(`Set ${key}`); try { - const kvKey = this.getKVKey(key, isFetch ? "fetch" : "cache"); + 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. @@ -109,20 +111,22 @@ class Cache implements IncrementalCache { } this.debug(`Delete ${key}`); try { - const kvKey = this.getKVKey(key, "cache"); - // Do not delete the key as we will then fallback to the assets. + 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 (e) { + } catch { throw new RecoverableError(`Failed to delete cache [${key}]`); } } - protected getKVKey(key: string, extension: Extension): string { - return `${this.getBuildId()}/${key}.${extension}`; + protected getKVKey(key: string, isFetch?: boolean): string { + return `${this.getBuildId()}/${key}.${isFetch ? "fetch" : "cache"}`; } - protected getAssetUrl(key: string): string { - return `http://assets.local/${CACHE_ASSET_DIR}/${this.getBuildId()}/${key}.cache`.replace(/\/\//g, "/"); + 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[]) { diff --git a/packages/cloudflare/src/cli/build/bundle-server.ts b/packages/cloudflare/src/cli/build/bundle-server.ts index 2e27a523..0cf3a413 100644 --- a/packages/cloudflare/src/cli/build/bundle-server.ts +++ b/packages/cloudflare/src/cli/build/bundle-server.ts @@ -69,7 +69,6 @@ export async function bundleServer(config: Config, openNextOptions: BuildOptions "process.env.NEXT_RUNTIME": '"nodejs"', "process.env.NODE_ENV": '"production"', "process.env.NEXT_MINIMAL": "true", - "process.env.__BUILD_TIMESTAMP_MS__": String(Date.now()), }, // We need to set platform to node so that esbuild doesn't complain about the node imports platform: "node", diff --git a/packages/cloudflare/src/cli/build/open-next/copyCacheAssets.ts b/packages/cloudflare/src/cli/build/open-next/copyCacheAssets.ts index 2ae8f3e3..50b752c1 100644 --- a/packages/cloudflare/src/cli/build/open-next/copyCacheAssets.ts +++ b/packages/cloudflare/src/cli/build/open-next/copyCacheAssets.ts @@ -6,10 +6,9 @@ import * as buildHelper from "@opennextjs/aws/build/helper.js"; import { CACHE_ASSET_DIR } from "../../../api/kvCache.js"; export function copyCacheAssets(options: buildHelper.BuildOptions) { - const { appBuildOutputPath, outputDir } = options; - const buildId = buildHelper.getBuildId(appBuildOutputPath); - const srcPath = join(outputDir, "cache", buildId); - const dstPath = join(outputDir, "assets", CACHE_ASSET_DIR, buildId); + 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/pnpm-lock.yaml b/pnpm-lock.yaml index 1917de5c..75480e70 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 From cf3212ed6f2090a0ec0306983abf1f5eeeccc379 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 20 Dec 2024 13:31:57 +0100 Subject: [PATCH 6/8] fixup! cache support --- packages/cloudflare/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index b45e30fe..c7425073 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... From 7f72f9e3289389a87d339477b496c55ec3a25856 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 20 Dec 2024 13:44:28 +0100 Subject: [PATCH 7/8] fixup! format --- packages/cloudflare/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 3a5c298e..ac3ceed4 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -77,4 +77,4 @@ "rimraf": "catalog:", "wrangler": "catalog:" } -} \ No newline at end of file +} From 0612b61412204cbeb0db17b1466aeb9712fe9289 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 20 Dec 2024 14:02:11 +0100 Subject: [PATCH 8/8] fixup! Date.now() --- packages/cloudflare/src/api/kvCache.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cloudflare/src/api/kvCache.ts b/packages/cloudflare/src/api/kvCache.ts index 19e1431f..6e780a9b 100644 --- a/packages/cloudflare/src/api/kvCache.ts +++ b/packages/cloudflare/src/api/kvCache.ts @@ -94,6 +94,8 @@ class Cache implements IncrementalCache { 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(), }) );