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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/cloudflare-cache-entrypoint.md
Original file line number Diff line number Diff line change
@@ -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": "<your-worker-name>",
"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.
8 changes: 8 additions & 0 deletions create-cloudflare/next/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
5 changes: 5 additions & 0 deletions examples-cloudflare/e2e/app-pages-router/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
{
"binding": "WORKER_SELF_REFERENCE",
"service": "app-pages-router"
},
{
"binding": "NEXT_CACHE_SERVICE",
"service": "app-pages-router",
"entrypoint": "OpenNextCache"
}
],
"vars": {
Expand Down
2 changes: 1 addition & 1 deletion examples-cloudflare/e2e/app-router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions examples-cloudflare/e2e/app-router/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@
{
"binding": "WORKER_SELF_REFERENCE",
"service": "app-router"
},
{
"binding": "NEXT_CACHE_SERVICE",
"service": "app-router",
"entrypoint": "OpenNextCache"
}
],
"vars": {
Expand Down
5 changes: 5 additions & 0 deletions examples-cloudflare/e2e/experimental/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@
{
"binding": "WORKER_SELF_REFERENCE",
"service": "experimental"
},
{
"binding": "NEXT_CACHE_SERVICE",
"service": "experimental",
"entrypoint": "OpenNextCache"
}
]
}
5 changes: 5 additions & 0 deletions examples-cloudflare/e2e/pages-router/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
{
"binding": "WORKER_SELF_REFERENCE",
"service": "pages-router"
},
{
"binding": "NEXT_CACHE_SERVICE",
"service": "pages-router",
"entrypoint": "OpenNextCache"
}
],
"vars": {
Expand Down
7 changes: 7 additions & 0 deletions examples-cloudflare/overrides/d1-tag-next/wrangler.e2e.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand Down
7 changes: 7 additions & 0 deletions examples-cloudflare/overrides/kv-tag-next/wrangler.e2e.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand Down
5 changes: 5 additions & 0 deletions examples-cloudflare/overrides/memory-queue/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
{
"binding": "WORKER_SELF_REFERENCE",
"service": "memory-queue"
},
{
"binding": "NEXT_CACHE_SERVICE",
"service": "memory-queue",
"entrypoint": "OpenNextCache"
}
]
}
15 changes: 15 additions & 0 deletions examples-cloudflare/overrides/r2-incremental-cache/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@
"directory": ".open-next/assets",
"binding": "ASSETS"
},
"services": [
{
"binding": "NEXT_CACHE_SERVICE",
"service": "r2-incremental-cache",
"entrypoint": "OpenNextCache"
}
],
Comment on lines +11 to +17

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Cache service binding is missing for the R2 example when an environment is used

The cache binding is declared only at the top level of the R2 example's configuration (services block at examples-cloudflare/overrides/r2-incremental-cache/wrangler.jsonc:11-17) while each named environment declares its own service list, so the binding disappears whenever the example is deployed or previewed with an environment.
Impact: The R2 example run under its e2e or prod environment has no cache service binding, so once the cache override is enabled there the cache calls would fail at runtime.

Wrangler environments do not inherit binding lists

In wrangler, binding sections (services, kv_namespaces, r2_buckets, ...) are not inheritable: when an environment defines its own services (here env.e2e.services with only WORKER_SELF_REFERENCE), the top-level list is replaced entirely. The newly added top-level entry also points at "r2-incremental-cache", while the worker name under the e2e environment is r2-incremental-cache-e2e, so even if it were inherited it would not self-reference correctly (compare with the WORKER_SELF_REFERENCE entry inside env.e2e, which uses the suffixed name).

The binding should be added inside each environment's services list (with the environment-suffixed worker name) rather than only at the top level.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

"env": {
"e2e": {
"d1_databases": [
Expand All @@ -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": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,12 @@
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
}
},
"services": [
{
"binding": "NEXT_CACHE_SERVICE",
"service": "static-assets-incremental-cache",
"entrypoint": "OpenNextCache"
}
]
}
5 changes: 5 additions & 0 deletions examples-cloudflare/playground16/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
{
"binding": "WORKER_SELF_REFERENCE",
"service": "playground16"
},
{
"binding": "NEXT_CACHE_SERVICE",
"service": "playground16",
"entrypoint": "OpenNextCache"
}
],
"durable_objects": {
Expand Down
7 changes: 7 additions & 0 deletions examples-cloudflare/prisma/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@
"directory": ".open-next/assets",
"binding": "ASSETS"
},
"services": [
{
"binding": "NEXT_CACHE_SERVICE",
"service": "api",
"entrypoint": "OpenNextCache"
}
],
"d1_databases": [
{
"binding": "DB",
Expand Down
51 changes: 37 additions & 14 deletions packages/cloudflare/src/api/cloudflare-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -337,24 +343,41 @@ async function getCloudflareContextFromWrangler<
Context = ExecutionContext,
>(options?: GetPlatformProxyOptions): Promise<CloudflareContext<CfProperties, Context>> {
// 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.
Expand Down
95 changes: 95 additions & 0 deletions packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Response>>();
const env: Record<string, unknown> = {};

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"] });
});
});
Loading
Loading