Skip to content

Commit f2c9f13

Browse files
committed
Merge branch 'main' into pr/314systems/1216
2 parents 956082d + df7f890 commit f2c9f13

30 files changed

Lines changed: 1940 additions & 873 deletions

.editorconfig

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,7 @@ root = true
44
[*]
55
indent_style = tab
66
end_of_line = lf
7+
8+
[*.{yml,yaml}]
9+
indent_style = space
10+
indent_size = 2

AGENTS.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# What this is
2+
3+
`@opennextjs/cloudflare` is an adapter that takes a Next.js `standalone` build and runs it on Cloudflare Workers via the Node.js compatibility layer. It sits on top of `@opennextjs/aws`, which provides the generic OpenNext build/runtime core; this package plugs Cloudflare-specific bindings (KV, R2, D1, Durable Objects, Assets, Images) into the override points that `@opennextjs/aws` exposes, and also contains the esbuild plugins and AST grep patches needed to rewrite Next's emitted code to run on Workers.
4+
5+
# Layout
6+
7+
```
8+
packages/cloudflare/ # the adapter
9+
src/api/ # runtime surface users import (small)
10+
src/cli/ # the `opennextjs-cloudflare` build CLI
11+
commands/ # build, deploy, preview, etc. commands live here
12+
build/patches/ # esbuild plugins + ast-grep patches applied to Next's output
13+
templates/ # starter configs copied by `migrate` command
14+
examples/ # sample Next apps used for manual + e2e testing
15+
create-cloudflare/ # templates for the `create-cloudflare` CLI
16+
benchmarking/ # perf harness
17+
```
18+
19+
Two things to keep separate in your head: **`src/api`** is the tiny surface users import at runtime; **`src/cli`** is the much larger build tool. Changes to `src/api` are user-visible; changes in `src/cli/build/patches` are user-invisible but the riskiest code in the repo.
20+
21+
# Commands
22+
23+
Use pnpm. Run from the repo root.
24+
25+
| Command | What it does |
26+
| -------------------------------------- | -------------------------------------------------------------------------------------------------------- |
27+
| `pnpm install` | also triggers a `postinstall` build of the adapter. |
28+
| `pnpm build` | build `packages/cloudflare`. |
29+
| `pnpm --filter cloudflare build:watch` | rebuild on change. |
30+
| `pnpm test` | builds, then runs all vitest suites. |
31+
| `pnpm code:checks` | prettier + eslint + tsc. |
32+
| `pnpm fix` | auto-fix prettier + eslint. |
33+
| `pnpm --filter <example> preview` | build + preview an example app end-to-end. Add `SKIP_NEXT_APP_BUILD=true` when only the adapter changed. |
34+
| `pnpm e2e` / `pnpm e2e:dev` | Playwright suites against the example apps. |
35+
| `pnpm --filter <example> e2e` | Run a specific example's Playwright suite. |
36+
| `pnpm changeset` | create a changeset for changes. |
37+
38+
# Conventions
39+
40+
- **Strict TypeScript**. Don't loosen; reach for generics or narrowing.
41+
- **ESM only**. Internal imports use the `.js` extension (`./foo.js`) even though the source is `.ts` - this is required for bundling, not a mistake.
42+
- **Unit tests are `*.spec.ts` colocated with source**, run with Vitest. Use `mock-fs` for filesystem-heavy tests. E2E coverage lives in `examples/` and runs under Playwright.
43+
- **Formatting is prettier**. Don't fight it; `pnpm fix`.
44+
- **Imports are sorted by `simple-import-sort`.** Let eslint reorder them.
45+
- **Dependency versions live in `pnpm-workspace.yaml` under `catalog:`.** When adding a shared dep, add it to the catalog and reference it as `"catalog:"` in the package.json. Don't pin versions inline when a catalog entry already exists.
46+
- **`packages/cloudflare` ships to users**. Be deliberate about adding runtime `dependencies`. Prefer `devDependencies`, inlining small helpers, or moving logic into code that only runs in the CLI.
47+
- **`CloudflareEnv` is augmented globally** in `src/api/cloudflare-context.ts`. New bindings that users configure should be declared there with a comment explaining what they're for.
48+
- **User-facing logs** go through `@opennextjs/aws`'s logger, not `console.*`. Warn (don't throw) when experimental features are used.
49+
50+
## Where things tend to go wrong
51+
52+
- **`src/cli/build/patches/`** contains esbuild plugins and `@ast-grep/napi` transforms that rewrite Next's emitted code to run on Workers. Every patch needs a spec, and ideally a minimal fixture of the input it's matching. Upstream Next changes break these; when a patch stops matching, fix the matcher, don't widen it blindly.
53+
- **Overrides in `src/api/overrides/`** implement contracts defined in `@opennextjs/aws`. Check the upstream type before changing a signature. `@opennextjs/aws` is pinned in `package.json`, so bumping it is a deliberate change with its own changeset.
54+
55+
# Pre-PR checklist
56+
57+
1. `pnpm code:checks` is clean.
58+
2. `pnpm test` passes.
59+
3. Changeset included if necessary.
60+
61+
## Changesets
62+
63+
Any behavioural change to `packages/cloudflare` needs one. Skip for internal refactors, test-only changes, example/doc tweaks.
64+
65+
```sh
66+
pnpm changeset
67+
```
68+
69+
Format:
70+
71+
```
72+
<type>: <imperative title>
73+
74+
<body explaining the why>
75+
```
76+
77+
- `type` is one of `feature | fix | refactor | docs | chore`.
78+
- Bugfixes and experimental work -> `patch`.
79+
- New feature -> `minor`.
80+
- Breaking changes -> `major`.
81+
82+
Full rules in [CONTRIBUTING.md](CONTRIBUTING.md).

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
@AGENTS.md

examples/e2e/app-router/e2e/revalidateTag.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ test("Revalidate tag - stale data served first", async ({ page, request }) => {
105105
const staleResponse = await responsePromise;
106106
const staleHeaders = staleResponse.headers();
107107
const staleCache = staleHeaders["x-nextjs-cache"] ?? staleHeaders["x-opennext-cache"];
108-
expect(staleCache).toMatch(/^(STALE|HIT)$/);
108+
expect(staleCache).toMatch(/^(STALE)$/);
109109

110110
const staleTime = await page.getByTestId("cached-time").textContent();
111111
// Stale content must match the pre-revalidation value
@@ -129,6 +129,19 @@ test("Revalidate tag - stale data served first", async ({ page, request }) => {
129129
// After background regen the cached value must have been updated
130130
expect(freshTime).not.toBeNull();
131131
expect(freshTime).not.toEqual(originalTime);
132+
133+
// Now we want to verfiy that the next entries stays fresh (HIT) after the first stale entry
134+
responsePromise = page.waitForResponse(
135+
(response) => response.url().includes("/revalidate-tag/stale") && response.status() === 200
136+
);
137+
await page.goto("/revalidate-tag/stale");
138+
const finalResponse = await responsePromise;
139+
const finalHeaders = finalResponse.headers();
140+
const finalCache = finalHeaders["x-nextjs-cache"] ?? finalHeaders["x-opennext-cache"];
141+
expect(finalCache).toEqual("HIT");
142+
143+
const finalTime = await page.getByTestId("cached-time").textContent();
144+
expect(finalTime).toEqual(freshTime);
132145
});
133146

134147
test("Revalidate path", async ({ page, request }) => {

packages/cloudflare/CHANGELOG.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,50 @@
11
# @opennextjs/cloudflare
22

3+
## 1.19.4
4+
5+
### Patch Changes
6+
7+
- [#1221](https://github.com/opennextjs/opennextjs-cloudflare/pull/1221) [`a2679bf`](https://github.com/opennextjs/opennextjs-cloudflare/commit/a2679bf9549f620e1ab0e1900dcc7a6b6ac03e0a) Thanks [@mushan0x0](https://github.com/mushan0x0)! - Stop bundling `@vercel/og` (and its ~1.4 MiB `resvg.wasm`) when the app does not use it.
8+
9+
Next.js's `externalImport` helper keeps a dynamic `import("next/dist/compiled/@vercel/og/index.edge.js")` in the emitted handler even for apps that never use `ImageResponse` / `opengraph-image`. Previously this module was marked as `external` when `useOg` was `false`, which left Wrangler to resolve and bundle it — pulling in ~800 KiB of JS plus `resvg.wasm` and pushing many Workers over the Cloudflare free-tier 3 MiB gzip limit.
10+
11+
When `useOg` is `false`, the edge entry is now aliased to the existing `throw.js` shim, so the unreachable dynamic import resolves to a tiny module and the real `@vercel/og` library is no longer pulled into the Worker bundle.
12+
13+
- [#1208](https://github.com/opennextjs/opennextjs-cloudflare/pull/1208) [`2c5b472`](https://github.com/opennextjs/opennextjs-cloudflare/commit/2c5b4729b6a48560b550af820c46c2350e149fa6) Thanks [@edmundhung](https://github.com/edmundhung)! - Use `OPEN_NEXT_BUILD_ID` instead of `NEXT_BUILD_ID` in the cache keys.
14+
15+
As of Next 16.2 `NEXT_BUILD_ID` is a fixed value when deploymentId is set explicitly.
16+
17+
See <https://github.com/opennextjs/opennextjs-aws/pull/1144>
18+
19+
- [#1193](https://github.com/opennextjs/opennextjs-cloudflare/pull/1193) [`1e8d232`](https://github.com/opennextjs/opennextjs-cloudflare/commit/1e8d232672353920a8e05e468cf3a5890b82b0f6) Thanks [@conico974](https://github.com/conico974)! - Fix tag cache stale logic
20+
21+
## 1.19.3
22+
23+
### Patch Changes
24+
25+
- [#1215](https://github.com/opennextjs/opennextjs-cloudflare/pull/1215) [`608893e`](https://github.com/opennextjs/opennextjs-cloudflare/commit/608893e63e1ee16d07c7ec42da979657cf2a62bd) Thanks [@vicb](https://github.com/vicb)! - Factor large repeated values in manifests
26+
27+
This reduce the size of the generated code.
28+
29+
- [#1218](https://github.com/opennextjs/opennextjs-cloudflare/pull/1218) [`f0d0226`](https://github.com/opennextjs/opennextjs-cloudflare/commit/f0d022685b24881a142bb01005ff78089be8c8d3) Thanks [@314systems](https://github.com/314systems)! - remove `process.version` override
30+
31+
Remove process.version / process.versions.node overrides now that [unjs/unenv#493](https://github.com/unjs/unenv/pull/493) is merged and shipped in [[email protected]](https://github.com/unjs/unenv/releases/tag/v2.0.0-rc.16) (project uses 2.0.0-rc.24)
32+
33+
- [#1199](https://github.com/opennextjs/opennextjs-cloudflare/pull/1199) [`32594d6`](https://github.com/opennextjs/opennextjs-cloudflare/commit/32594d6a921c5ebdbe25f38635bb2c9dabdcbff1) Thanks [@SdSadat](https://github.com/SdSadat)! - fix(cli): fail fast in non-TTY environments instead of hanging on config-creation prompts
34+
35+
When `open-next.config.ts` (or `wrangler.(toml|json|jsonc)`) is missing, the CLI
36+
prompts the user to auto-create it. In non-TTY environments (Cloudflare Workers
37+
Builds, Docker, CI) the Enquirer prompt can't read stdin, so the build hangs or
38+
fails with a truncated prompt and a cryptic exit code — the user sees
39+
`? Missing required open-next.config.ts file, do you want to create one? (Y/n)`
40+
and then ` ELIFECYCLE Command failed with exit code 13`, with no hint at what
41+
to do next.
42+
43+
Now, in non-interactive environments, both prompts throw an actionable error
44+
with the exact template to paste (for `open-next.config.ts`) or point at the
45+
existing `--skipWranglerConfigCheck` / `SKIP_WRANGLER_CONFIG_CHECK` escape
46+
hatch (for the wrangler config). Interactive behavior is unchanged.
47+
348
## 1.19.2
449

550
### Patch Changes

packages/cloudflare/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@opennextjs/cloudflare",
33
"description": "Cloudflare builder for next apps",
4-
"version": "1.19.2",
4+
"version": "1.19.4",
55
"type": "module",
66
"scripts": {
77
"clean": "rimraf dist",
@@ -54,7 +54,8 @@
5454
"dependencies": {
5555
"@ast-grep/napi": "^0.40.5",
5656
"@dotenvx/dotenvx": "catalog:",
57-
"@opennextjs/aws": "3.10.2",
57+
"@opennextjs/aws": "3.10.4",
58+
"ci-info": "^4.2.0",
5859
"cloudflare": "^4.4.1",
5960
"comment-json": "^4.5.1",
6061
"enquirer": "^2.4.1",

packages/cloudflare/src/api/durable-objects/queue.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ export class DOQueueHandler extends DurableObject<CloudflareEnv> {
165165
"INSERT OR REPLACE INTO sync (id, lastSuccess, buildId) VALUES (?, unixepoch(), ?)",
166166
// We cannot use the deduplication id because it's not unique per route - every time a route is revalidated, the deduplication id is different.
167167
`${host}${url}`,
168-
process.env.__NEXT_BUILD_ID
168+
process.env.__OPEN_NEXT_BUILD_ID
169169
);
170170
}
171171
// If everything went well, we can remove the route from the failed state
@@ -238,7 +238,7 @@ export class DOQueueHandler extends DurableObject<CloudflareEnv> {
238238
"INSERT OR REPLACE INTO failed_state (id, data, buildId) VALUES (?, ?, ?)",
239239
msg.MessageDeduplicationId,
240240
JSON.stringify(updatedFailedState),
241-
process.env.__NEXT_BUILD_ID
241+
process.env.__OPEN_NEXT_BUILD_ID
242242
);
243243
}
244244
// We probably want to do something if routeInFailedState is becoming too big, at least log it
@@ -273,8 +273,8 @@ export class DOQueueHandler extends DurableObject<CloudflareEnv> {
273273

274274
// Before doing anything else, we clear the DB for any potential old data
275275
// TODO: extract this to a function so that it could be called by the user at another time than init
276-
this.sql.exec("DELETE FROM failed_state WHERE buildId != ?", process.env.__NEXT_BUILD_ID);
277-
this.sql.exec("DELETE FROM sync WHERE buildId != ?", process.env.__NEXT_BUILD_ID);
276+
this.sql.exec("DELETE FROM failed_state WHERE buildId != ?", process.env.__OPEN_NEXT_BUILD_ID);
277+
this.sql.exec("DELETE FROM sync WHERE buildId != ?", process.env.__OPEN_NEXT_BUILD_ID);
278278

279279
const failedStateCursor = this.sql.exec<{ id: string; data: string }>("SELECT * FROM failed_state");
280280
for (const row of failedStateCursor) {

packages/cloudflare/src/api/overrides/incremental-cache/kv-incremental-cache.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ class KVIncrementalCache implements IncrementalCache {
102102
protected getKVKey(key: string, cacheType?: CacheEntryType): string {
103103
return computeCacheKey(key, {
104104
prefix: getCloudflareContext().env[PREFIX_ENV_NAME],
105-
buildId: process.env.NEXT_BUILD_ID,
105+
buildId: process.env.OPEN_NEXT_BUILD_ID,
106106
cacheType,
107107
});
108108
}

packages/cloudflare/src/api/overrides/incremental-cache/r2-incremental-cache.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ class R2IncrementalCache implements IncrementalCache {
8282
protected getR2Key(key: string, cacheType?: CacheEntryType): string {
8383
return computeCacheKey(key, {
8484
prefix: getCloudflareContext().env[PREFIX_ENV_NAME],
85-
buildId: process.env.NEXT_BUILD_ID,
85+
buildId: process.env.OPEN_NEXT_BUILD_ID,
8686
cacheType,
8787
});
8888
}

packages/cloudflare/src/api/overrides/incremental-cache/regional-cache.ts

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
IncrementalCache,
66
WithLastModified,
77
} from "@opennextjs/aws/types/overrides.js";
8+
import { compareSemver } from "@opennextjs/aws/utils/semver.js";
89

910
import { getCloudflareContext } from "../../cloudflare-context.js";
1011
import { debugCache, FALLBACK_BUILD_ID, IncrementalCacheEntry, isPurgeCacheEnabled } from "../internal.js";
@@ -31,21 +32,33 @@ type Options = {
3132
defaultLongLivedTtlSec?: number;
3233

3334
/**
34-
* Whether the regional cache entry should be updated in the background or not when it experiences
35-
* a cache hit.
35+
* Whether the regional cache entry should be updated in the background on regional cache hits.
3636
*
37-
* @default `true` in `long-lived` mode when cache purge is not used, `false` otherwise.
37+
* NOTE: Use the default value unless you know what you are doing. It is set to:
38+
* - Next < 16:
39+
* `true` in `long-lived` mode when cache purge is not used, `false` otherwise.
40+
* - Next >= 16:
41+
* `!bypassTagCacheOnCacheHit`
3842
*/
3943
shouldLazilyUpdateOnCacheHit?: boolean;
4044

4145
/**
42-
* Whether on cache hits the tagCache should be skipped or not. Skipping the tagCache allows requests to be
43-
* handled faster,
46+
* Whether the tagCache should be skipped on regional cache hits.
4447
*
45-
* Note: When this is enabled, make sure that the cache gets purged
46-
* either by enabling the auto cache purging feature or manually.
48+
* Note:
49+
* - Skipping the tagCache allows requests to be handled faster
50+
* - When `true`, make sure the cache gets purged
51+
* either by enabling the auto cache purging feature or manually
4752
*
48-
* @default `true` if the auto cache purging is enabled, `false` otherwise.
53+
* `true` is not compatible with SWR types of revalidateTag
54+
* i.e. on Next 16+, anything different than `revalidateTag("tag", { expire: 0 })`.
55+
* That's why the default is `false` for Next 16+ which uses SWR by default.
56+
*
57+
* NOTE: Use the default value unless you know what you are doing. It is set to:
58+
* - Next <16:
59+
* `true` if the auto cache purging is enabled, `false` otherwise.
60+
* - Next >= 16:
61+
* `false`
4962
*/
5063
bypassTagCacheOnCacheHit?: boolean;
5164
};
@@ -78,17 +91,33 @@ class RegionalCache implements IncrementalCache {
7891
private opts: Options
7992
) {
8093
this.name = this.store.name;
81-
// `shouldLazilyUpdateOnCacheHit` is not needed when cache purge is enabled.
82-
this.opts.shouldLazilyUpdateOnCacheHit ??= this.opts.mode === "long-lived" && !isPurgeCacheEnabled();
83-
}
8494

85-
get #bypassTagCacheOnCacheHit(): boolean {
86-
if (this.opts.bypassTagCacheOnCacheHit !== undefined) {
87-
return this.opts.bypassTagCacheOnCacheHit;
95+
// `globalThis.nextVersion` is only defined at runtime but not when the Open Next build runs.
96+
// The options do no matter at build time so we can skip setting them.
97+
const { nextVersion } = globalThis;
98+
if (nextVersion) {
99+
if (compareSemver(nextVersion, "<", "16")) {
100+
// Next < 16
101+
this.opts.shouldLazilyUpdateOnCacheHit ??= this.opts.mode === "long-lived" && !isPurgeCacheEnabled();
102+
this.opts.bypassTagCacheOnCacheHit ??= isPurgeCacheEnabled();
103+
} else {
104+
// Next >= 16
105+
this.opts.bypassTagCacheOnCacheHit ??= false;
106+
if (this.opts.bypassTagCacheOnCacheHit) {
107+
debugCache(
108+
"RegionalCache",
109+
`bypassTagCacheOnCacheHit is not recommended for Next 16+ as it is not compatible with SWR tags. Make sure to always use \`revalidateTag\` with \`{ expire: 0 }\` if you want to bypass the tag cache.`
110+
);
111+
}
112+
this.opts.shouldLazilyUpdateOnCacheHit ??= !this.opts.bypassTagCacheOnCacheHit;
113+
if (this.opts.shouldLazilyUpdateOnCacheHit !== this.opts.bypassTagCacheOnCacheHit) {
114+
debugCache(
115+
"RegionalCache",
116+
`\`shouldLazilyUpdateOnCacheHit\` and \`bypassTagCacheOnCacheHit\` are mutually exclusive for Next 16+.`
117+
);
118+
}
119+
}
88120
}
89-
90-
// When `bypassTagCacheOnCacheHit` is not set, we default to whether the automatic cache purging is enabled or not
91-
return isPurgeCacheEnabled();
92121
}
93122

94123
async get<CacheType extends CacheEntryType = "cache">(
@@ -123,7 +152,7 @@ class RegionalCache implements IncrementalCache {
123152

124153
return {
125154
...responseJson,
126-
shouldBypassTagCache: this.#bypassTagCacheOnCacheHit,
155+
shouldBypassTagCache: this.opts.bypassTagCacheOnCacheHit,
127156
};
128157
}
129158

@@ -190,7 +219,7 @@ class RegionalCache implements IncrementalCache {
190219
}
191220

192221
protected getCacheUrlKey(key: string, cacheType?: CacheEntryType) {
193-
const buildId = process.env.NEXT_BUILD_ID ?? FALLBACK_BUILD_ID;
222+
const buildId = process.env.OPEN_NEXT_BUILD_ID ?? FALLBACK_BUILD_ID;
194223
return "http://cache.local" + `/${buildId}/${key}`.replace(/\/+/g, "/") + `.${cacheType ?? "cache"}`;
195224
}
196225

0 commit comments

Comments
 (0)