Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
fa74e70
refactor: remove createServerBundle.ts and integrate its functionalit…
conico974 Jun 27, 2026
33a6eff
feat: implement default overrides for bundle configurations and add t…
conico974 Jun 27, 2026
fe002e1
format
conico974 Jun 27, 2026
9ee9515
fix ts issue
conico974 Jun 27, 2026
6fad0c6
feat: add default overrides for AWS adapter and integrate Cloudflare …
conico974 Jun 27, 2026
84397ab
Revert "feat: add default overrides for AWS adapter and integrate Clo…
conico974 Jun 27, 2026
f22d37f
feat: enhance AWS adapter with default overrides and improve middlewa…
conico974 Jun 27, 2026
a3165f0
refactor(core): return ValidateConfigResult from validateConfig inste…
conico974 Jun 28, 2026
6296e94
refactor(core): extract buildOpenNextOutput, export OpenNextOutput type
conico974 Jun 28, 2026
4e82821
refactor(core): branch on ValidateConfigResult in compileOpenNextConfig
conico974 Jun 28, 2026
0af2e73
feat(core): overridable validateConfig and generic generateOutput on …
conico974 Jun 28, 2026
bab47f1
fix(core): use require.resolve for full-path overrides in resolve plugin
conico974 Jun 28, 2026
51b5e3b
format
conico974 Jun 28, 2026
a437de1
feat(core): enforce mandatory externals in serverBundle and update re…
conico974 Aug 1, 2026
cd8f83e
refactor(core): enhance resolve plugin to support dynamic override re…
conico974 Aug 1, 2026
c200d7a
feat(core): run the cache in a dedicated handler function
conico974 Aug 16, 2026
8b63860
feat(cloudflare): add the OpenNextCache entrypoint
conico974 Aug 16, 2026
c606b91
Merge origin/main into cache entrypoint branch
vicb Sep 21, 2026
3c16e0c
test(cloudflare): cover flattened Wrangler environments
vicb Sep 21, 2026
9cdc1b4
fix(cloudflare): surface cache service failures
vicb Sep 21, 2026
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.
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
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 thread
vicb marked this conversation as resolved.
"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
119 changes: 119 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,119 @@
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("rejects unsuccessful responses", async () => {
fetchMock.mockResolvedValue(new Response(null, { status: 500 }));

await expect(
serviceCache.set("key", { kind: "FETCH", data: { headers: {}, body: "b", url: "u" } }, "fetch")
).rejects.toThrow("Failed to set cache entry: cache handler returned 500");
});
});

it("deletes a key", async () => {
await serviceCache.delete("key");

const { url, method } = lastRequest();
expect(method).toBe("DELETE");
expect(url.pathname).toBe("/cache/key");
});

it("rejects an unsuccessful delete", async () => {
fetchMock.mockResolvedValue(new Response(null, { status: 503 }));

await expect(serviceCache.delete("key")).rejects.toThrow(
"Failed to delete cache entry: cache handler returned 503"
);
});

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

it("rejects an unsuccessful tag revalidation", async () => {
fetchMock.mockResolvedValue(new Response(null, { status: 502 }));

await expect(serviceCache.revalidateTags(["tag"])).rejects.toThrow(
"Failed to revalidate cache tags: cache handler returned 502"
);
});
});
Loading
Loading