diff --git a/.changeset/cloudflare-cache-entrypoint.md b/.changeset/cloudflare-cache-entrypoint.md new file mode 100644 index 00000000..2230059d --- /dev/null +++ b/.changeset/cloudflare-cache-entrypoint.md @@ -0,0 +1,27 @@ +--- +"@opennextjs/cloudflare": patch +--- + +Add the `OpenNextCache` named entrypoint + +The build now emits the OpenNext cache handler function and exposes it from the worker as the +`OpenNextCache` named entrypoint, together with a `service-cache` override that reaches it over a +service binding. + +This requires a self referencing service binding in the wrangler configuration: + +```jsonc +"services": [ + { + "binding": "NEXT_CACHE_SERVICE", + "service": "", + "entrypoint": "OpenNextCache" + } +] +``` + +The cache runs in the same worker by default. Pointing the binding at another worker is enough to +run the cache as a service of its own. + +Nothing selects this override yet - `defineCloudflareConfig` still runs the cache in the server +function. diff --git a/create-cloudflare/next/wrangler.jsonc b/create-cloudflare/next/wrangler.jsonc index 1c90bdb4..0819a091 100644 --- a/create-cloudflare/next/wrangler.jsonc +++ b/create-cloudflare/next/wrangler.jsonc @@ -23,6 +23,14 @@ // see https://opennext.js.org/cloudflare/caching "binding": "WORKER_SELF_REFERENCE", "service": "worker_name" + }, + { + // The OpenNext cache runs behind a named entrypoint of this worker. + // The service name must match the worker name. + // see https://opennext.js.org/cloudflare/caching + "binding": "NEXT_CACHE_SERVICE", + "service": "worker_name", + "entrypoint": "OpenNextCache" } ], "observability": { diff --git a/examples-cloudflare/e2e/app-pages-router/wrangler.jsonc b/examples-cloudflare/e2e/app-pages-router/wrangler.jsonc index b636b43a..1ab5ec7f 100644 --- a/examples-cloudflare/e2e/app-pages-router/wrangler.jsonc +++ b/examples-cloudflare/e2e/app-pages-router/wrangler.jsonc @@ -18,6 +18,11 @@ { "binding": "WORKER_SELF_REFERENCE", "service": "app-pages-router" + }, + { + "binding": "NEXT_CACHE_SERVICE", + "service": "app-pages-router", + "entrypoint": "OpenNextCache" } ], "vars": { diff --git a/examples-cloudflare/e2e/app-router/package.json b/examples-cloudflare/e2e/app-router/package.json index d1924fbb..3bab5883 100644 --- a/examples-cloudflare/e2e/app-router/package.json +++ b/examples-cloudflare/e2e/app-router/package.json @@ -11,7 +11,7 @@ "clean": "rm -rf .turbo node_modules .next .open-next", "build:worker:cf": "pnpm opennextjs-cloudflare build", "preview:worker": "pnpm opennextjs-cloudflare preview", - "preview": "pnpm build:worker && pnpm preview:worker", + "preview": "pnpm build:worker:cf && pnpm preview:worker", "e2e:cf": "playwright test -c e2e/playwright.config.ts", "build:worker-turbopack": "pnpm build:worker --openNextConfigPath open-next.turbopack.config.ts", "e2e-turbopack": "playwright test -c e2e/playwright.turbopack.config.ts" diff --git a/examples-cloudflare/e2e/app-router/wrangler.jsonc b/examples-cloudflare/e2e/app-router/wrangler.jsonc index 6ec6cc0d..eaf0fced 100644 --- a/examples-cloudflare/e2e/app-router/wrangler.jsonc +++ b/examples-cloudflare/e2e/app-router/wrangler.jsonc @@ -40,6 +40,11 @@ { "binding": "WORKER_SELF_REFERENCE", "service": "app-router" + }, + { + "binding": "NEXT_CACHE_SERVICE", + "service": "app-router", + "entrypoint": "OpenNextCache" } ], "vars": { diff --git a/examples-cloudflare/e2e/experimental/wrangler.jsonc b/examples-cloudflare/e2e/experimental/wrangler.jsonc index 4348c424..0ca406a9 100644 --- a/examples-cloudflare/e2e/experimental/wrangler.jsonc +++ b/examples-cloudflare/e2e/experimental/wrangler.jsonc @@ -39,6 +39,11 @@ { "binding": "WORKER_SELF_REFERENCE", "service": "experimental" + }, + { + "binding": "NEXT_CACHE_SERVICE", + "service": "experimental", + "entrypoint": "OpenNextCache" } ] } diff --git a/examples-cloudflare/e2e/pages-router/wrangler.jsonc b/examples-cloudflare/e2e/pages-router/wrangler.jsonc index 80f9372b..92f56c40 100644 --- a/examples-cloudflare/e2e/pages-router/wrangler.jsonc +++ b/examples-cloudflare/e2e/pages-router/wrangler.jsonc @@ -18,6 +18,11 @@ { "binding": "WORKER_SELF_REFERENCE", "service": "pages-router" + }, + { + "binding": "NEXT_CACHE_SERVICE", + "service": "pages-router", + "entrypoint": "OpenNextCache" } ], "vars": { diff --git a/examples-cloudflare/overrides/d1-tag-next/wrangler.e2e.jsonc b/examples-cloudflare/overrides/d1-tag-next/wrangler.e2e.jsonc index 1fe491ce..5d53c198 100644 --- a/examples-cloudflare/overrides/d1-tag-next/wrangler.e2e.jsonc +++ b/examples-cloudflare/overrides/d1-tag-next/wrangler.e2e.jsonc @@ -8,6 +8,13 @@ "directory": ".open-next/assets", "binding": "ASSETS", }, + "services": [ + { + "binding": "NEXT_CACHE_SERVICE", + "service": "ssg-app", + "entrypoint": "OpenNextCache", + }, + ], "vars": { "APP_VERSION": "1.2.345", }, diff --git a/examples-cloudflare/overrides/kv-tag-next/wrangler.e2e.jsonc b/examples-cloudflare/overrides/kv-tag-next/wrangler.e2e.jsonc index 0f8b5557..bbc50d20 100644 --- a/examples-cloudflare/overrides/kv-tag-next/wrangler.e2e.jsonc +++ b/examples-cloudflare/overrides/kv-tag-next/wrangler.e2e.jsonc @@ -8,6 +8,13 @@ "directory": ".open-next/assets", "binding": "ASSETS", }, + "services": [ + { + "binding": "NEXT_CACHE_SERVICE", + "service": "ssg-app", + "entrypoint": "OpenNextCache", + }, + ], "vars": { "APP_VERSION": "1.2.345", }, diff --git a/examples-cloudflare/overrides/memory-queue/wrangler.jsonc b/examples-cloudflare/overrides/memory-queue/wrangler.jsonc index ccb375df..1cd31f32 100644 --- a/examples-cloudflare/overrides/memory-queue/wrangler.jsonc +++ b/examples-cloudflare/overrides/memory-queue/wrangler.jsonc @@ -18,6 +18,11 @@ { "binding": "WORKER_SELF_REFERENCE", "service": "memory-queue" + }, + { + "binding": "NEXT_CACHE_SERVICE", + "service": "memory-queue", + "entrypoint": "OpenNextCache" } ] } diff --git a/examples-cloudflare/overrides/r2-incremental-cache/wrangler.jsonc b/examples-cloudflare/overrides/r2-incremental-cache/wrangler.jsonc index 3fb880df..bf3bb70b 100644 --- a/examples-cloudflare/overrides/r2-incremental-cache/wrangler.jsonc +++ b/examples-cloudflare/overrides/r2-incremental-cache/wrangler.jsonc @@ -8,6 +8,13 @@ "directory": ".open-next/assets", "binding": "ASSETS" }, + "services": [ + { + "binding": "NEXT_CACHE_SERVICE", + "service": "r2-incremental-cache", + "entrypoint": "OpenNextCache" + } + ], "env": { "e2e": { "d1_databases": [ @@ -21,6 +28,14 @@ { "binding": "WORKER_SELF_REFERENCE", "service": "r2-incremental-cache-e2e" + }, + { + // Binding lists are not inherited: an environment declaring its own `services` + // replaces the top level one, so the cache binding has to be repeated here with + // the environment suffixed worker name. + "binding": "NEXT_CACHE_SERVICE", + "service": "r2-incremental-cache-e2e", + "entrypoint": "OpenNextCache" } ], "r2_buckets": [ diff --git a/examples-cloudflare/overrides/static-assets-incremental-cache/wrangler.jsonc b/examples-cloudflare/overrides/static-assets-incremental-cache/wrangler.jsonc index 9adf243a..817baad5 100644 --- a/examples-cloudflare/overrides/static-assets-incremental-cache/wrangler.jsonc +++ b/examples-cloudflare/overrides/static-assets-incremental-cache/wrangler.jsonc @@ -7,5 +7,12 @@ "assets": { "directory": ".open-next/assets", "binding": "ASSETS" - } + }, + "services": [ + { + "binding": "NEXT_CACHE_SERVICE", + "service": "static-assets-incremental-cache", + "entrypoint": "OpenNextCache" + } + ] } diff --git a/examples-cloudflare/playground16/wrangler.jsonc b/examples-cloudflare/playground16/wrangler.jsonc index e8506d3c..3dfe7a54 100644 --- a/examples-cloudflare/playground16/wrangler.jsonc +++ b/examples-cloudflare/playground16/wrangler.jsonc @@ -21,6 +21,11 @@ { "binding": "WORKER_SELF_REFERENCE", "service": "playground16" + }, + { + "binding": "NEXT_CACHE_SERVICE", + "service": "playground16", + "entrypoint": "OpenNextCache" } ], "durable_objects": { diff --git a/examples-cloudflare/prisma/wrangler.jsonc b/examples-cloudflare/prisma/wrangler.jsonc index 5c242191..7f9dd8c1 100644 --- a/examples-cloudflare/prisma/wrangler.jsonc +++ b/examples-cloudflare/prisma/wrangler.jsonc @@ -8,6 +8,13 @@ "directory": ".open-next/assets", "binding": "ASSETS" }, + "services": [ + { + "binding": "NEXT_CACHE_SERVICE", + "service": "api", + "entrypoint": "OpenNextCache" + } + ], "d1_databases": [ { "binding": "DB", diff --git a/packages/cloudflare/src/api/cloudflare-context.ts b/packages/cloudflare/src/api/cloudflare-context.ts index d48e4a50..3c1c14c1 100644 --- a/packages/cloudflare/src/api/cloudflare-context.ts +++ b/packages/cloudflare/src/api/cloudflare-context.ts @@ -24,6 +24,12 @@ declare global { // Service binding for the worker itself to be able to call itself from within the worker WORKER_SELF_REFERENCE?: Service; + // Optional service binding to a worker where the OpenNext cache is deployed on its own, + // exposed by its `OpenNextCache` named entrypoint. When unset, the cache runs in this worker. + // Note: it can not reference the worker itself, wrangler can not resolve a named entrypoint + // of the worker being configured. + NEXT_CACHE_SERVICE?: Service; + // KV used for the incremental cache NEXT_INC_CACHE_KV?: KVNamespace; // Prefix used for the KV incremental cache key @@ -337,24 +343,41 @@ async function getCloudflareContextFromWrangler< Context = ExecutionContext, >(options?: GetPlatformProxyOptions): Promise> { // Note: we never want wrangler to be bundled in the Next.js app, that's why the import below looks like it does - const { getPlatformProxy } = await import(/* webpackIgnore: true */ `${"__wrangler".replaceAll("_", "")}`); + const { getPlatformProxy, unstable_readConfig } = await import( + /* webpackIgnore: true */ `${"__wrangler".replaceAll("_", "")}` + ); + + // Same as above: this helper uses node builtins and is only ever needed when running `next dev`. + const { withoutSelfEntrypointServices } = await import( + /* webpackIgnore: true */ `${"../utils/wrangler__config.js".replaceAll("__", "-")}` + ); // This allows the selection of a wrangler environment while running in next dev mode const environment = options?.environment ?? process.env.NEXT_DEV_WRANGLER_ENV; - const { env, cf, ctx } = await getPlatformProxy({ - ...options, - // The `env` passed to the fetch handler does not contain variables from `.env*` files. - // because we invoke wrangler with `CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV`=`"false"`. - // Initializing `envFiles` with an empty list is the equivalent for this API call. - envFiles: [], - environment, - }); - return { - env, - cf: cf as unknown as CfProperties, - ctx: ctx as Context, - }; + const { configPath, isFlattened, cleanup } = withoutSelfEntrypointServices( + unstable_readConfig({ env: environment, config: options?.configPath }) + ); + + try { + const { env, cf, ctx } = await getPlatformProxy({ + ...options, + configPath, + // The `env` passed to the fetch handler does not contain variables from `.env*` files. + // because we invoke wrangler with `CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV`=`"false"`. + // Initializing `envFiles` with an empty list is the equivalent for this API call. + envFiles: [], + // The rewritten configuration is already flattened to `environment`. + environment: isFlattened ? undefined : environment, + }); + return { + env, + cf: cf as unknown as CfProperties, + ctx: ctx as Context, + }; + } finally { + cleanup(); + } } // In production the cloudflare context is initialized by the worker so it is always available. diff --git a/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts b/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts new file mode 100644 index 00000000..6006df2a --- /dev/null +++ b/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import serviceCache, { BINDING_NAME } from "./service-cache.js"; + +const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise>(); +const env: Record = {}; + +vi.mock("../../cloudflare-context.js", () => ({ + getCloudflareContext: () => ({ env }), +})); + +function lastRequest() { + const [url, init] = fetchMock.mock.calls.at(-1)!; + return { url: new URL(url), method: init?.method ?? "GET", body: init?.body }; +} + +describe("serviceCache", () => { + beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockResolvedValue(new Response("", { headers: { "x-opennext-cache-found": "false" } })); + env[BINDING_NAME] = { fetch: fetchMock }; + }); + + it("throws when the service is not bound", async () => { + delete env[BINDING_NAME]; + + await expect(serviceCache.get("key")).rejects.toThrow(BINDING_NAME); + }); + + describe("get", () => { + it("requests the key and the cache type", async () => { + await serviceCache.get("key/with/slashes", "fetch"); + + const { url, method } = lastRequest(); + expect(method).toBe("GET"); + expect(url.pathname).toBe(`/cache/${encodeURIComponent("key/with/slashes")}`); + expect(url.searchParams.get("type")).toBe("fetch"); + }); + + it("returns null on a cache miss", async () => { + await expect(serviceCache.get("key")).resolves.toBeNull(); + }); + + it("parses a cache hit", async () => { + fetchMock.mockResolvedValue( + new Response("body", { + headers: { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-last-modified": "1234", + }, + }) + ); + + await expect(serviceCache.get("key")).resolves.toEqual({ + lastModified: 1234, + value: expect.objectContaining({ type: "route", body: "body" }), + }); + }); + }); + + describe("set", () => { + // The cache type is part of the key for the incremental caches, it has to be forwarded + // or entries would be written where they are not read from. + it("sends the value and the cache type", async () => { + await serviceCache.set("key", { kind: "FETCH", data: { headers: {}, body: "b", url: "u" } }, "fetch"); + + const { url, method, body } = lastRequest(); + expect(method).toBe("PUT"); + expect(url.pathname).toBe("/cache/key"); + expect(url.searchParams.get("type")).toBe("fetch"); + expect(JSON.parse(body as string)).toEqual({ + value: { kind: "FETCH", data: { headers: {}, body: "b", url: "u" } }, + }); + }); + }); + + it("deletes a key", async () => { + await serviceCache.delete("key"); + + const { url, method } = lastRequest(); + expect(method).toBe("DELETE"); + expect(url.pathname).toBe("/cache/key"); + }); + + it("revalidates tags", async () => { + await serviceCache.revalidateTags(["tag1", "tag2"]); + + const { url, method, body } = lastRequest(); + expect(method).toBe("POST"); + expect(url.pathname).toBe("/cache/revalidate-tags"); + expect(JSON.parse(body as string)).toEqual({ tags: ["tag1", "tag2"] }); + }); +}); diff --git a/packages/cloudflare/src/api/overrides/cache/service-cache.ts b/packages/cloudflare/src/api/overrides/cache/service-cache.ts new file mode 100644 index 00000000..2f7931aa --- /dev/null +++ b/packages/cloudflare/src/api/overrides/cache/service-cache.ts @@ -0,0 +1,90 @@ +import type { Cache, CacheEntryType } from "@opennextjs/core/types/overrides.js"; +import { parseCacheGetResponse } from "@opennextjs/core/utils/cache-get.js"; + +import { getCloudflareContext } from "../../cloudflare-context.js"; + +export const NAME = "cf-service-cache"; + +export const BINDING_NAME = "NEXT_CACHE_SERVICE"; + +/** + * The origin is irrelevant: the requests are sent to the service binding, they never hit the network. + */ +const CACHE_ORIGIN = "https://cache.opennext"; + +/** + * Returns the cache handler bound to `NEXT_CACHE_SERVICE`. + * + * The binding points at the worker itself by default: the cache handler runs in the same worker, + * behind the `OpenNextCache` named entrypoint. It can be pointed at another worker to run the + * cache as a service of its own. + */ +function getCacheService(): Service { + const service = getCloudflareContext().env[BINDING_NAME]; + + if (!service) { + throw new Error( + `No \`${BINDING_NAME}\` service binding for the OpenNext cache.\n\n` + + `Add the following to your wrangler configuration:\n\n` + + ` "services": [\n` + + ` { "binding": "${BINDING_NAME}", "service": "", "entrypoint": "OpenNextCache" }\n` + + ` ]\n` + ); + } + + return service; +} + +function getCacheUrl(key: string, cacheType?: CacheEntryType) { + const url = new URL(`/cache/${encodeURIComponent(key)}`, CACHE_ORIGIN); + + if (cacheType) { + url.searchParams.set("type", cacheType); + } + return url.href; +} + +/** + * Cache client for the cache handler function. + * + * It talks to the `OpenNextCache` entrypoint over the service binding, using the HTTP API of + * the cache handler function. + */ +const serviceCache = { + name: NAME, + + get: async (key, cacheType) => { + const response = await getCacheService().fetch(getCacheUrl(key, cacheType)); + + const body = await response.text(); + const headers: Record = {}; + response.headers.forEach((value, name) => { + headers[name] = value; + }); + + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + return parseCacheGetResponse(headers, body) as any; + }, + + set: async (key, value, cacheType) => { + await getCacheService().fetch(getCacheUrl(key, cacheType), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value }), + }); + }, + + delete: async (key) => { + await getCacheService().fetch(getCacheUrl(key), { method: "DELETE" }); + }, + + revalidateTags: async (tags) => { + await getCacheService().fetch(new URL("/cache/revalidate-tags", CACHE_ORIGIN).href, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tags }), + }); + }, +} satisfies Cache; + +export default serviceCache; diff --git a/packages/cloudflare/src/cli/adapter.ts b/packages/cloudflare/src/cli/adapter.ts index bfb1e073..baabc2cb 100644 --- a/packages/cloudflare/src/cli/adapter.ts +++ b/packages/cloudflare/src/cli/adapter.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { buildAdapter } from "@opennextjs/core/build/adapter.js"; +import { createCacheBundle } from "@opennextjs/core/build/createCacheBundle.js"; import type { BuildOptions } from "@opennextjs/core/build/helper.js"; import * as buildHelper from "@opennextjs/core/build/helper.js"; import type { ContentUpdater } from "@opennextjs/core/plugins/content-updater.js"; @@ -14,6 +15,7 @@ import type { OpenNextConfig } from "@opennextjs/core/types/open-next.js"; import { normalizePath } from "@opennextjs/core/utils/normalize-path.js"; import { bundleServer } from "./build/bundle-server.js"; +import { compileCacheEntrypoint } from "./build/open-next/compile-cache-entrypoint.js"; import { compileEnvFiles } from "./build/open-next/compile-env-files.js"; import { compileImages } from "./build/open-next/compile-images.js"; import { compileInit } from "./build/open-next/compile-init.js"; @@ -31,6 +33,9 @@ export default buildAdapter((config: OpenNextConfig, buildOpts: BuildOptions) => skipImageOptimization: true, skipWarmer: true, skipGenerateOutput: true, + // The cache function is bundled by `beforeServerBundle` instead: it has to be emitted + // before the worker is bundled, and unconditionally as the worker always imports it. + skipCache: true, middlewareOptions: { forceOnlyBuildOnce: true }, beforeServerBundle: async (buildOpts, _config) => { // Import edge-compiled config for skew protection @@ -45,6 +50,7 @@ export default buildAdapter((config: OpenNextConfig, buildOpts: BuildOptions) => await compileInit(buildOpts, {} as any); await compileImages(buildOpts); await compileSkewProtection(buildOpts, openNextConfig); + await buildCacheFunction(buildOpts); }, serverBundle: { useEdgeConfig: true, @@ -76,3 +82,30 @@ export default buildAdapter((config: OpenNextConfig, buildOpts: BuildOptions) => }, }; }); + +/** + * Bundles the cache handler function and the named entrypoint exposing it. + * + * The cache function runs in the workerd runtime, as part of the worker, so it uses the + * edge flavour of the config - as the server bundle does. + */ +async function buildCacheFunction(buildOpts: BuildOptions) { + // The cache handler is served by the `OpenNextCache` named entrypoint, it is bundled as a + // regular fetch handler. + await createCacheBundle(buildOpts, { + wrapper: "@opennextjs/core/overrides/wrappers/cloudflare-edge.js", + converter: "@opennextjs/core/overrides/converters/edge.js", + }); + + // `createCacheBundle` copies the node config, replace it with the edge one when available. + const useEdgeConfig = fs.existsSync(path.join(buildOpts.buildDir, "open-next.config.edge.mjs")); + if (useEdgeConfig) { + buildHelper.copyOpenNextConfig( + buildOpts.buildDir, + path.join(buildOpts.outputDir, "cache-function"), + true + ); + } + + await compileCacheEntrypoint(buildOpts); +} diff --git a/packages/cloudflare/src/cli/build/open-next/compile-cache-assets-manifest.ts b/packages/cloudflare/src/cli/build/open-next/compile-cache-assets-manifest.ts deleted file mode 100644 index a7d2899d..00000000 --- a/packages/cloudflare/src/cli/build/open-next/compile-cache-assets-manifest.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; -import path from "node:path"; - -import type { BuildOptions } from "@opennextjs/core/build/helper.js"; -import type { TagCacheMetaFile } from "@opennextjs/core/types/cache.js"; - -/** - * Generates SQL statements that can be used to initialize the cache assets manifest in an SQL data store. - */ -export function compileCacheAssetsManifestSqlFile(options: BuildOptions, metaFiles: TagCacheMetaFile[]) { - const outputPath = path.join(options.outputDir, "cloudflare/cache-assets-manifest.sql"); - - mkdirSync(path.dirname(outputPath), { recursive: true }); - writeFileSync( - outputPath, - `CREATE TABLE IF NOT EXISTS tags (tag TEXT NOT NULL, path TEXT NOT NULL, UNIQUE(tag, path) ON CONFLICT REPLACE); - CREATE TABLE IF NOT EXISTS revalidations (tag TEXT NOT NULL, revalidatedAt INTEGER NOT NULL, UNIQUE(tag) ON CONFLICT REPLACE);\n` - ); - - const values = metaFiles.map(({ tag, path }) => `(${JSON.stringify(tag.S)}, ${JSON.stringify(path.S)})`); - - if (values.length) { - appendFileSync(outputPath, `INSERT INTO tags (tag, path) VALUES ${values.join(", ")};`); - } -} diff --git a/packages/cloudflare/src/cli/build/open-next/compile-cache-entrypoint.ts b/packages/cloudflare/src/cli/build/open-next/compile-cache-entrypoint.ts new file mode 100644 index 00000000..c22ee773 --- /dev/null +++ b/packages/cloudflare/src/cli/build/open-next/compile-cache-entrypoint.ts @@ -0,0 +1,29 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { BuildOptions } from "@opennextjs/core/build/helper.js"; +import { build } from "esbuild"; + +/** + * Compiles the `OpenNextCache` named entrypoint. + * + * `./init.js` and `../cache-function/index.mjs` are kept external: they are emitted next to the + * entrypoint in the output directory and resolved when wrangler bundles the worker. Inlining + * `./init.js` would duplicate the `AsyncLocalStorage` holding the Cloudflare context. + */ +export async function compileCacheEntrypoint(options: BuildOptions) { + const currentDir = path.join(path.dirname(fileURLToPath(import.meta.url))); + const templatesDir = path.join(currentDir, "../../templates"); + const entrypointPath = path.join(templatesDir, "cache-entrypoint.js"); + + await build({ + entryPoints: [entrypointPath], + outdir: path.join(options.outputDir, "cloudflare"), + bundle: true, + minify: false, + format: "esm", + target: "esnext", + platform: "node", + external: ["cloudflare:workers", "./init.js", "../cache-function/index.mjs"], + }); +} diff --git a/packages/cloudflare/src/cli/commands/utils/helpers.ts b/packages/cloudflare/src/cli/commands/utils/helpers.ts index fe3859da..fc9b2a1e 100644 --- a/packages/cloudflare/src/cli/commands/utils/helpers.ts +++ b/packages/cloudflare/src/cli/commands/utils/helpers.ts @@ -1,6 +1,7 @@ import { type BuildOptions } from "@opennextjs/core/build/helper.js"; -import { getPlatformProxy, type GetPlatformProxyOptions } from "wrangler"; +import { getPlatformProxy, type GetPlatformProxyOptions, unstable_readConfig } from "wrangler"; +import { withoutSelfEntrypointServices } from "../../../utils/wrangler-config.js"; import { extractProjectEnvVars } from "../../utils/extract-project-env-vars.js"; export type WorkerEnvVar = Record; @@ -8,18 +9,29 @@ export type WorkerEnvVar = Record; export async function getEnvFromPlatformProxy(options: GetPlatformProxyOptions, buildOpts: BuildOptions) { const envVars = process.env; - const proxy = await getPlatformProxy({ - ...options, - envFiles: [], - }); + const { configPath, isFlattened, cleanup } = withoutSelfEntrypointServices( + unstable_readConfig({ env: options.environment, config: options.configPath }) + ); - Object.entries(proxy.env).forEach(([key, value]) => { - if (typeof value === "string") { - envVars[key as keyof CloudflareEnv] = value; - } - }); + try { + const proxy = await getPlatformProxy({ + ...options, + configPath, + // The rewritten configuration is already flattened to `options.environment`. + ...(isFlattened ? { environment: undefined } : {}), + envFiles: [], + }); + + Object.entries(proxy.env).forEach(([key, value]) => { + if (typeof value === "string") { + envVars[key as keyof CloudflareEnv] = value; + } + }); - await proxy.dispose(); + await proxy.dispose(); + } finally { + cleanup(); + } let mode: "production" | "development" | "test" = "production"; if (envVars.NEXTJS_ENV === "development") { diff --git a/packages/cloudflare/src/cli/templates/cache-entrypoint.ts b/packages/cloudflare/src/cli/templates/cache-entrypoint.ts new file mode 100644 index 00000000..bbb999c9 --- /dev/null +++ b/packages/cloudflare/src/cli/templates/cache-entrypoint.ts @@ -0,0 +1,27 @@ +/** + * The OpenNext cache handler, exposed as a named entrypoint of the worker. + * + * The cache handler function is bundled by `createCacheBundle` as a regular fetch handler. The + * server and the middleware reach it through the `NEXT_CACHE_SERVICE` binding, which points at + * this worker by default so that the cache runs in the same worker, and can point at another + * worker instead. + * + * See https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/rpc/#named-entrypoints + */ + +import { WorkerEntrypoint } from "cloudflare:workers"; + +// @ts-expect-error: resolved by wrangler build +import { handler } from "../cache-function/index.mjs"; + +import { runWithCloudflareContext } from "./init.js"; + +type CacheHandler = (request: Request, env: CloudflareEnv, ctx: ExecutionContext) => Promise; + +export class OpenNextCache extends WorkerEntrypoint { + override fetch(request: Request): Promise { + return runWithCloudflareContext(this.env, this.ctx, () => + (handler as CacheHandler)(request, this.env, this.ctx) + ); + } +} diff --git a/packages/cloudflare/src/cli/templates/init.ts b/packages/cloudflare/src/cli/templates/init.ts index 5cf9c315..b2d745e4 100644 --- a/packages/cloudflare/src/cli/templates/init.ts +++ b/packages/cloudflare/src/cli/templates/init.ts @@ -23,33 +23,56 @@ Object.defineProperty(globalThis, Symbol.for("__cloudflare-context__"), { /** * Executes the handler with the Cloudflare context. */ -export async function runWithCloudflareRequestContext( +export async function runWithCloudflareRequestContext( request: Request, env: CloudflareEnv, ctx: ExecutionContext, - handler: () => Promise -): Promise { - init(request, env); + handler: () => Promise +): Promise { + init(env, new URL(request.url)); return cloudflareContextALS.run({ env, ctx, cf: request.cf }, handler); } +/** + * Executes the handler with the Cloudflare context, outside of a request. + * + * Used by the named entrypoints (i.e. the cache handler) which are invoked via RPC + * and therefore have no incoming `Request` to derive the origin from. + */ +export async function runWithCloudflareContext( + env: CloudflareEnv, + ctx: ExecutionContext, + handler: () => Promise +): Promise { + init(env); + + return cloudflareContextALS.run({ env, ctx, cf: undefined }, handler); +} + let initialized = false; +let originInitialized = false; /** * Initializes the runtime on the first call, * no-op on subsequent invocations. + * + * The origin is only known when a `Request` is available, so it is populated on the first + * call made from the fetch handler - which might not be the first call overall. */ -function init(request: Request, env: CloudflareEnv) { - if (initialized) { - return; +function init(env: CloudflareEnv, url?: URL) { + if (!initialized) { + initialized = true; + + initRuntime(); + populateProcessEnv(env); } - initialized = true; - const url = new URL(request.url); + if (url && !originInitialized) { + originInitialized = true; - initRuntime(); - populateProcessEnv(url, env); + populateOriginEnv(url); + } } function initRuntime() { @@ -109,9 +132,8 @@ function initRuntime() { * Populate process.env with: * - the environment variables and secrets from the cloudflare platform * - the variables from Next .env* files - * - the origin resolver information */ -function populateProcessEnv(url: URL, env: CloudflareEnv) { +function populateProcessEnv(env: CloudflareEnv) { for (const [key, value] of Object.entries(env)) { if (typeof value === "string") { process.env[key] = value; @@ -125,6 +147,16 @@ function populateProcessEnv(url: URL, env: CloudflareEnv) { } } + // `__DEPLOYMENT_ID__` is a string (passed via ESBuild). + if (__DEPLOYMENT_ID__) { + process.env.DEPLOYMENT_ID = __DEPLOYMENT_ID__; + } +} + +/** + * Populate process.env with the origin resolver information. + */ +function populateOriginEnv(url: URL) { // Set the default Origin for the origin resolver. // This is only needed for an external middleware bundle process.env.OPEN_NEXT_ORIGIN = JSON.stringify({ @@ -140,11 +172,6 @@ function populateProcessEnv(url: URL, env: CloudflareEnv) { * https://github.com/vercel/next.js/blob/6b1e48080e896e0d44a05fe009cb79d2d3f91774/packages/next/src/server/app-render/action-handler.ts#L307-L316 */ process.env.__NEXT_PRIVATE_ORIGIN = url.origin; - - // `__DEPLOYMENT_ID__` is a string (passed via ESBuild). - if (__DEPLOYMENT_ID__) { - process.env.DEPLOYMENT_ID = __DEPLOYMENT_ID__; - } } declare global { diff --git a/packages/cloudflare/src/cli/templates/worker.ts b/packages/cloudflare/src/cli/templates/worker.ts index 4465dbf6..6c43e353 100644 --- a/packages/cloudflare/src/cli/templates/worker.ts +++ b/packages/cloudflare/src/cli/templates/worker.ts @@ -14,6 +14,8 @@ export { DOQueueHandler } from "./.build/durable-objects/queue.js"; export { DOShardedTagCache } from "./.build/durable-objects/sharded-tag-cache.js"; //@ts-expect-error: Will be resolved by wrangler build export { BucketCachePurge } from "./.build/durable-objects/bucket-cache-purge.js"; +//@ts-expect-error: Will be resolved by wrangler build +export { OpenNextCache } from "./cloudflare/cache-entrypoint.js"; export default { async fetch(request, env, ctx) { diff --git a/packages/cloudflare/src/utils/wrangler-config.ts b/packages/cloudflare/src/utils/wrangler-config.ts new file mode 100644 index 00000000..21a23b81 --- /dev/null +++ b/packages/cloudflare/src/utils/wrangler-config.ts @@ -0,0 +1,54 @@ +import fs from "node:fs"; +import path from "node:path"; + +type ServiceBinding = { + binding: string; + service: string; + entrypoint?: string; +}; + +/** The subset of the resolved wrangler configuration used here. */ +type ResolvedConfig = { + configPath?: string; + name?: string; + services?: ServiceBinding[]; +}; + +/** + * `getPlatformProxy` starts the worker without its script, so it is not able to resolve a service + * binding referencing a named entrypoint of the worker itself - which is how the OpenNext cache is + * wired - and fails to start. + * + * Those bindings are only used at runtime by the generated worker, dropping them has no effect on + * what `getPlatformProxy` is used for, so the configuration is rewritten without them. + * + * @returns the configuration path to pass to `getPlatformProxy` and a cleanup function to call + * once the proxy has been disposed of. + */ +export function withoutSelfEntrypointServices(config: ResolvedConfig): { + configPath: string | undefined; + /** + * `true` when the configuration was rewritten. The rewritten file is the already resolved + * configuration, flattened to the selected environment, so the environment must not be selected + * a second time when passing it on - wrangler would warn about the missing environment section. + */ + isFlattened: boolean; + cleanup: () => void; +} { + const services = config.services ?? []; + const keptServices = services.filter((service) => !(service.entrypoint && service.service === config.name)); + + if (!config.configPath || keptServices.length === services.length) { + return { configPath: config.configPath, isFlattened: false, cleanup: () => {} }; + } + + // `unsafe` is dropped as wrangler warns about it being experimental, even when it is empty. + const { unsafe: _unsafe, ...rest } = config as ResolvedConfig & { unsafe?: unknown }; + + // The file has to sit next to the original one: relative paths are resolved from its directory. + const configPath = path.join(path.dirname(config.configPath), `.wrangler.opennext.${process.pid}.json`); + + fs.writeFileSync(configPath, JSON.stringify({ ...rest, services: keptServices })); + + return { configPath, isFlattened: true, cleanup: () => fs.rmSync(configPath, { force: true }) }; +} diff --git a/packages/cloudflare/templates/wrangler.jsonc b/packages/cloudflare/templates/wrangler.jsonc index 7cf1ce87..702c0aea 100644 --- a/packages/cloudflare/templates/wrangler.jsonc +++ b/packages/cloudflare/templates/wrangler.jsonc @@ -14,6 +14,14 @@ // see https://opennext.js.org/cloudflare/caching "binding": "WORKER_SELF_REFERENCE", "service": "" + }, + { + // The OpenNext cache runs behind a named entrypoint of this worker. + // The service name must match the worker name. + // see https://opennext.js.org/cloudflare/caching + "binding": "NEXT_CACHE_SERVICE", + "service": "", + "entrypoint": "OpenNextCache" } ], "r2_buckets": [