@opennextjs/cloudflare 1.19.2 → 1.19.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/durable-objects/queue.js +4 -4
- package/dist/api/overrides/incremental-cache/kv-incremental-cache.js +1 -1
- package/dist/api/overrides/incremental-cache/r2-incremental-cache.js +1 -1
- package/dist/api/overrides/incremental-cache/regional-cache.d.ts +20 -9
- package/dist/api/overrides/incremental-cache/regional-cache.js +23 -10
- package/dist/api/overrides/incremental-cache/static-assets-incremental-cache.js +1 -1
- package/dist/api/overrides/tag-cache/d1-next-tag-cache.js +8 -3
- package/dist/api/overrides/tag-cache/do-sharded-tag-cache.js +7 -2
- package/dist/api/overrides/tag-cache/kv-next-tag-cache.js +8 -3
- package/dist/cli/build/bundle-server.js +10 -5
- package/dist/cli/build/open-next/compileDurableObjects.js +3 -2
- package/dist/cli/build/patches/plugins/load-manifest.d.ts +37 -0
- package/dist/cli/build/patches/plugins/load-manifest.js +185 -10
- package/dist/cli/commands/build.js +7 -0
- package/dist/cli/commands/utils/utils.js +10 -3
- package/dist/cli/templates/init.js +0 -5
- package/dist/cli/utils/create-open-next-config.d.ts +1 -0
- package/dist/cli/utils/create-open-next-config.js +4 -3
- package/dist/cli/utils/is-interactive.d.ts +8 -0
- package/dist/cli/utils/is-interactive.js +13 -0
- package/package.json +5 -4
|
@@ -121,7 +121,7 @@ export class DOQueueHandler extends DurableObject {
|
|
|
121
121
|
if (!this.disableSQLite) {
|
|
122
122
|
this.sql.exec("INSERT OR REPLACE INTO sync (id, lastSuccess, buildId) VALUES (?, unixepoch(), ?)",
|
|
123
123
|
// We cannot use the deduplication id because it's not unique per route - every time a route is revalidated, the deduplication id is different.
|
|
124
|
-
`${host}${url}`, process.env.
|
|
124
|
+
`${host}${url}`, process.env.__OPEN_NEXT_BUILD_ID);
|
|
125
125
|
}
|
|
126
126
|
// If everything went well, we can remove the route from the failed state
|
|
127
127
|
this.routeInFailedState.delete(msg.MessageDeduplicationId);
|
|
@@ -184,7 +184,7 @@ export class DOQueueHandler extends DurableObject {
|
|
|
184
184
|
}
|
|
185
185
|
this.routeInFailedState.set(msg.MessageDeduplicationId, updatedFailedState);
|
|
186
186
|
if (!this.disableSQLite) {
|
|
187
|
-
this.sql.exec("INSERT OR REPLACE INTO failed_state (id, data, buildId) VALUES (?, ?, ?)", msg.MessageDeduplicationId, JSON.stringify(updatedFailedState), process.env.
|
|
187
|
+
this.sql.exec("INSERT OR REPLACE INTO failed_state (id, data, buildId) VALUES (?, ?, ?)", msg.MessageDeduplicationId, JSON.stringify(updatedFailedState), process.env.__OPEN_NEXT_BUILD_ID);
|
|
188
188
|
}
|
|
189
189
|
// We probably want to do something if routeInFailedState is becoming too big, at least log it
|
|
190
190
|
await this.addAlarm();
|
|
@@ -214,8 +214,8 @@ export class DOQueueHandler extends DurableObject {
|
|
|
214
214
|
this.sql.exec("CREATE TABLE IF NOT EXISTS sync (id TEXT PRIMARY KEY, lastSuccess INTEGER, buildId TEXT)");
|
|
215
215
|
// Before doing anything else, we clear the DB for any potential old data
|
|
216
216
|
// TODO: extract this to a function so that it could be called by the user at another time than init
|
|
217
|
-
this.sql.exec("DELETE FROM failed_state WHERE buildId != ?", process.env.
|
|
218
|
-
this.sql.exec("DELETE FROM sync WHERE buildId != ?", process.env.
|
|
217
|
+
this.sql.exec("DELETE FROM failed_state WHERE buildId != ?", process.env.__OPEN_NEXT_BUILD_ID);
|
|
218
|
+
this.sql.exec("DELETE FROM sync WHERE buildId != ?", process.env.__OPEN_NEXT_BUILD_ID);
|
|
219
219
|
const failedStateCursor = this.sql.exec("SELECT * FROM failed_state");
|
|
220
220
|
for (const row of failedStateCursor) {
|
|
221
221
|
this.routeInFailedState.set(row.id, JSON.parse(row.data));
|
|
@@ -17,20 +17,32 @@ type Options = {
|
|
|
17
17
|
*/
|
|
18
18
|
defaultLongLivedTtlSec?: number;
|
|
19
19
|
/**
|
|
20
|
-
* Whether the regional cache entry should be updated in the background
|
|
21
|
-
* a cache hit.
|
|
20
|
+
* Whether the regional cache entry should be updated in the background on regional cache hits.
|
|
22
21
|
*
|
|
23
|
-
*
|
|
22
|
+
* NOTE: Use the default value unless you know what you are doing. It is set to:
|
|
23
|
+
* - Next < 16:
|
|
24
|
+
* `true` in `long-lived` mode when cache purge is not used, `false` otherwise.
|
|
25
|
+
* - Next >= 16:
|
|
26
|
+
* `!bypassTagCacheOnCacheHit`
|
|
24
27
|
*/
|
|
25
28
|
shouldLazilyUpdateOnCacheHit?: boolean;
|
|
26
29
|
/**
|
|
27
|
-
* Whether
|
|
28
|
-
* handled faster,
|
|
30
|
+
* Whether the tagCache should be skipped on regional cache hits.
|
|
29
31
|
*
|
|
30
|
-
* Note:
|
|
31
|
-
*
|
|
32
|
+
* Note:
|
|
33
|
+
* - Skipping the tagCache allows requests to be handled faster
|
|
34
|
+
* - When `true`, make sure the cache gets purged
|
|
35
|
+
* either by enabling the auto cache purging feature or manually
|
|
32
36
|
*
|
|
33
|
-
*
|
|
37
|
+
* `true` is not compatible with SWR types of revalidateTag
|
|
38
|
+
* i.e. on Next 16+, anything different than `revalidateTag("tag", { expire: 0 })`.
|
|
39
|
+
* That's why the default is `false` for Next 16+ which uses SWR by default.
|
|
40
|
+
*
|
|
41
|
+
* NOTE: Use the default value unless you know what you are doing. It is set to:
|
|
42
|
+
* - Next <16:
|
|
43
|
+
* `true` if the auto cache purging is enabled, `false` otherwise.
|
|
44
|
+
* - Next >= 16:
|
|
45
|
+
* `false`
|
|
34
46
|
*/
|
|
35
47
|
bypassTagCacheOnCacheHit?: boolean;
|
|
36
48
|
};
|
|
@@ -52,7 +64,6 @@ interface PutToCacheInput {
|
|
|
52
64
|
* API is refreshed from the cache store on cache hits (for the long-lived mode).
|
|
53
65
|
*/
|
|
54
66
|
declare class RegionalCache implements IncrementalCache {
|
|
55
|
-
#private;
|
|
56
67
|
private store;
|
|
57
68
|
private opts;
|
|
58
69
|
name: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { error } from "@opennextjs/aws/adapters/logger.js";
|
|
2
|
+
import { compareSemver } from "@opennextjs/aws/utils/semver.js";
|
|
2
3
|
import { getCloudflareContext } from "../../cloudflare-context.js";
|
|
3
4
|
import { debugCache, FALLBACK_BUILD_ID, isPurgeCacheEnabled } from "../internal.js";
|
|
4
5
|
const ONE_MINUTE_IN_SECONDS = 60;
|
|
@@ -24,15 +25,27 @@ class RegionalCache {
|
|
|
24
25
|
this.store = store;
|
|
25
26
|
this.opts = opts;
|
|
26
27
|
this.name = this.store.name;
|
|
27
|
-
// `
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
// `globalThis.nextVersion` is only defined at runtime but not when the Open Next build runs.
|
|
29
|
+
// The options do no matter at build time so we can skip setting them.
|
|
30
|
+
const { nextVersion } = globalThis;
|
|
31
|
+
if (nextVersion) {
|
|
32
|
+
if (compareSemver(nextVersion, "<", "16")) {
|
|
33
|
+
// Next < 16
|
|
34
|
+
this.opts.shouldLazilyUpdateOnCacheHit ??= this.opts.mode === "long-lived" && !isPurgeCacheEnabled();
|
|
35
|
+
this.opts.bypassTagCacheOnCacheHit ??= isPurgeCacheEnabled();
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
// Next >= 16
|
|
39
|
+
this.opts.bypassTagCacheOnCacheHit ??= false;
|
|
40
|
+
if (this.opts.bypassTagCacheOnCacheHit) {
|
|
41
|
+
debugCache("RegionalCache", `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.`);
|
|
42
|
+
}
|
|
43
|
+
this.opts.shouldLazilyUpdateOnCacheHit ??= !this.opts.bypassTagCacheOnCacheHit;
|
|
44
|
+
if (this.opts.shouldLazilyUpdateOnCacheHit !== this.opts.bypassTagCacheOnCacheHit) {
|
|
45
|
+
debugCache("RegionalCache", `\`shouldLazilyUpdateOnCacheHit\` and \`bypassTagCacheOnCacheHit\` are mutually exclusive for Next 16+.`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
33
48
|
}
|
|
34
|
-
// When `bypassTagCacheOnCacheHit` is not set, we default to whether the automatic cache purging is enabled or not
|
|
35
|
-
return isPurgeCacheEnabled();
|
|
36
49
|
}
|
|
37
50
|
async get(key, cacheType) {
|
|
38
51
|
try {
|
|
@@ -55,7 +68,7 @@ class RegionalCache {
|
|
|
55
68
|
const responseJson = await cachedResponse.json();
|
|
56
69
|
return {
|
|
57
70
|
...responseJson,
|
|
58
|
-
shouldBypassTagCache: this
|
|
71
|
+
shouldBypassTagCache: this.opts.bypassTagCacheOnCacheHit,
|
|
59
72
|
};
|
|
60
73
|
}
|
|
61
74
|
const rawEntry = await this.store.get(key, cacheType);
|
|
@@ -109,7 +122,7 @@ class RegionalCache {
|
|
|
109
122
|
return this.localCache;
|
|
110
123
|
}
|
|
111
124
|
getCacheUrlKey(key, cacheType) {
|
|
112
|
-
const buildId = process.env.
|
|
125
|
+
const buildId = process.env.OPEN_NEXT_BUILD_ID ?? FALLBACK_BUILD_ID;
|
|
113
126
|
return "http://cache.local" + `/${buildId}/${key}`.replace(/\/+/g, "/") + `.${cacheType ?? "cache"}`;
|
|
114
127
|
}
|
|
115
128
|
async putToCache({ key, cacheType, entry }) {
|
|
@@ -43,7 +43,7 @@ class StaticAssetsIncrementalCache {
|
|
|
43
43
|
if (cacheType === "composable") {
|
|
44
44
|
throw new Error("Composable cache is not supported in static assets incremental cache");
|
|
45
45
|
}
|
|
46
|
-
const buildId = process.env.
|
|
46
|
+
const buildId = process.env.OPEN_NEXT_BUILD_ID ?? FALLBACK_BUILD_ID;
|
|
47
47
|
const name = (cacheType === "fetch"
|
|
48
48
|
? `${CACHE_DIR}/__fetch/${buildId}/${key}`
|
|
49
49
|
: `${CACHE_DIR}/${buildId}/${key}.cache`).replace(/\/+/g, "/");
|
|
@@ -80,8 +80,13 @@ export class D1NextModeTagCache {
|
|
|
80
80
|
const isStale = [...result.values()].some((v) => {
|
|
81
81
|
if (v == null)
|
|
82
82
|
return false;
|
|
83
|
-
const { stale, expire } = v;
|
|
84
|
-
|
|
83
|
+
const { revalidatedAt, stale, expire } = v;
|
|
84
|
+
// A tag is stale when both its stale and revalidatedAt timestamps are newer than the page.
|
|
85
|
+
// revalidatedAt > lastModified ensures the revalidation that set this stale window happened
|
|
86
|
+
// after the page was generated, preventing a stale signal from a previous ISR cycle.
|
|
87
|
+
const lastModifiedOrNow = lastModified ?? now;
|
|
88
|
+
const isInStaleWindow = stale != null && revalidatedAt > lastModifiedOrNow && lastModifiedOrNow <= stale;
|
|
89
|
+
if (!isInStaleWindow)
|
|
85
90
|
return false;
|
|
86
91
|
return expire == null || expire > now;
|
|
87
92
|
});
|
|
@@ -153,7 +158,7 @@ export class D1NextModeTagCache {
|
|
|
153
158
|
return `${this.getBuildId()}/${key}`.replaceAll("//", "/");
|
|
154
159
|
}
|
|
155
160
|
getBuildId() {
|
|
156
|
-
return process.env.
|
|
161
|
+
return process.env.OPEN_NEXT_BUILD_ID ?? FALLBACK_BUILD_ID;
|
|
157
162
|
}
|
|
158
163
|
/**
|
|
159
164
|
* @returns request scoped in-memory cache for tag values, or undefined if ALS is not available.
|
|
@@ -89,8 +89,13 @@ class ShardedDOTagCache {
|
|
|
89
89
|
const result = [...tagData.values()].some((data) => {
|
|
90
90
|
if (data == null)
|
|
91
91
|
return false;
|
|
92
|
-
const { stale, expire } = data;
|
|
93
|
-
|
|
92
|
+
const { revalidatedAt, stale, expire } = data;
|
|
93
|
+
// A tag is stale when both its stale timestamp and its revalidatedAt are newer than the page.
|
|
94
|
+
// revalidatedAt > lastModified ensures the revalidation that set this stale window happened
|
|
95
|
+
// after the page was generated, preventing a stale signal from a previous ISR cycle.
|
|
96
|
+
const lastModifiedOrNow = lastModified ?? now;
|
|
97
|
+
const isInStaleWindow = stale != null && revalidatedAt > lastModifiedOrNow && lastModifiedOrNow <= stale;
|
|
98
|
+
if (!isInStaleWindow)
|
|
94
99
|
return false;
|
|
95
100
|
return expire == null || expire > now;
|
|
96
101
|
});
|
|
@@ -144,9 +144,14 @@ export class KVNextModeTagCache {
|
|
|
144
144
|
if (v == null)
|
|
145
145
|
return false;
|
|
146
146
|
const stale = getStale(v);
|
|
147
|
-
if (stale == null || stale <= (lastModified ?? now))
|
|
148
|
-
return false;
|
|
149
147
|
const expire = getExpire(v);
|
|
148
|
+
// A tag is stale when both its stale timestamp and its revalidatedAt are newer than the page.
|
|
149
|
+
// revalidatedAt > lastModified ensures the revalidation that set this stale window happened
|
|
150
|
+
// after the page was generated, preventing a stale signal from a previous ISR cycle.
|
|
151
|
+
const lastModifiedOrNow = lastModified ?? now;
|
|
152
|
+
const isInStaleWindow = stale != null && getRevalidatedAt(v) > lastModifiedOrNow && lastModifiedOrNow <= stale;
|
|
153
|
+
if (!isInStaleWindow)
|
|
154
|
+
return false;
|
|
150
155
|
return expire == null || expire > now;
|
|
151
156
|
});
|
|
152
157
|
debugCache("KVNextModeTagCache", `isStale tags=${tags} lastModified=${lastModified} -> ${isStale}`);
|
|
@@ -175,7 +180,7 @@ export class KVNextModeTagCache {
|
|
|
175
180
|
return `${this.getBuildId()}/${key}`.replaceAll("//", "/");
|
|
176
181
|
}
|
|
177
182
|
getBuildId() {
|
|
178
|
-
return process.env.
|
|
183
|
+
return process.env.OPEN_NEXT_BUILD_ID ?? FALLBACK_BUILD_ID;
|
|
179
184
|
}
|
|
180
185
|
/**
|
|
181
186
|
* @returns request scoped in-memory cache for tag values, or undefined if ALS is not available.
|
|
@@ -100,12 +100,17 @@ export async function bundleServer(buildOpts, projectOpts) {
|
|
|
100
100
|
// Apply updater updates, must be the last plugin
|
|
101
101
|
updater.plugin,
|
|
102
102
|
],
|
|
103
|
-
external: [
|
|
104
|
-
"./middleware/handler.mjs",
|
|
105
|
-
// Do not bundle og when it is not used
|
|
106
|
-
...(useOg ? [] : ["next/dist/compiled/@vercel/og/index.edge.js"]),
|
|
107
|
-
],
|
|
103
|
+
external: ["./middleware/handler.mjs"],
|
|
108
104
|
alias: {
|
|
105
|
+
// When @vercel/og is not used, alias the edge entry to a throwing shim so the
|
|
106
|
+
// dynamic `import("next/dist/compiled/@vercel/og/index.edge.js")` call site
|
|
107
|
+
// emitted by Next.js does not drag the library (~800 KiB) and its
|
|
108
|
+
// `resvg.wasm` (~1.4 MiB) into the Worker bundle.
|
|
109
|
+
...(useOg
|
|
110
|
+
? {}
|
|
111
|
+
: {
|
|
112
|
+
"next/dist/compiled/@vercel/og/index.edge.js": path.join(buildOpts.outputDir, "cloudflare-templates/shims/throw.js"),
|
|
113
|
+
}),
|
|
109
114
|
// Workers have `fetch` so the `node-fetch` polyfill is not needed
|
|
110
115
|
"next/dist/compiled/node-fetch": path.join(buildOpts.outputDir, "cloudflare-templates/shims/fetch.js"),
|
|
111
116
|
// Workers have builtin Web Sockets
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { loadBuildId, loadPrerenderManifest } from "@opennextjs/aws/adapters/config/util.js";
|
|
3
|
+
import { loadBuildId, loadConfig, loadPrerenderManifest } from "@opennextjs/aws/adapters/config/util.js";
|
|
4
4
|
import { esbuildSync } from "@opennextjs/aws/build/helper.js";
|
|
5
5
|
export function compileDurableObjects(buildOpts) {
|
|
6
6
|
const _require = createRequire(import.meta.url);
|
|
@@ -13,6 +13,7 @@ export function compileDurableObjects(buildOpts) {
|
|
|
13
13
|
const prerenderManifest = loadPrerenderManifest(buildOutputDotNextDir);
|
|
14
14
|
const previewModeId = prerenderManifest?.preview?.previewModeId;
|
|
15
15
|
const BUILD_ID = loadBuildId(buildOutputDotNextDir);
|
|
16
|
+
const nextConfig = loadConfig(buildOutputDotNextDir);
|
|
16
17
|
return esbuildSync({
|
|
17
18
|
entryPoints,
|
|
18
19
|
bundle: true,
|
|
@@ -22,7 +23,7 @@ export function compileDurableObjects(buildOpts) {
|
|
|
22
23
|
external: ["cloudflare:workers"],
|
|
23
24
|
define: {
|
|
24
25
|
"process.env.__NEXT_PREVIEW_MODE_ID": `"${previewModeId}"`,
|
|
25
|
-
"process.env.
|
|
26
|
+
"process.env.__OPEN_NEXT_BUILD_ID": JSON.stringify(nextConfig.deploymentId ?? BUILD_ID),
|
|
26
27
|
},
|
|
27
28
|
}, buildOpts);
|
|
28
29
|
}
|
|
@@ -6,3 +6,40 @@
|
|
|
6
6
|
import { type BuildOptions } from "@opennextjs/aws/build/helper.js";
|
|
7
7
|
import type { ContentUpdater, Plugin } from "@opennextjs/aws/plugins/content-updater.js";
|
|
8
8
|
export declare function inlineLoadManifest(updater: ContentUpdater, buildOpts: BuildOptions): Plugin;
|
|
9
|
+
/**
|
|
10
|
+
* Factor out large manifest values into separate variables.
|
|
11
|
+
*
|
|
12
|
+
* @param manifest The manifest code.
|
|
13
|
+
* @param key The key to factor out.
|
|
14
|
+
* @param values A map to store the factored values (indexed by variable name).
|
|
15
|
+
* @param prefixMap Map of short hash prefix → full hash, updated in place for
|
|
16
|
+
* collision resolution across calls.
|
|
17
|
+
* @returns The manifest code with large values factored out.
|
|
18
|
+
*/
|
|
19
|
+
export declare function factorManifestValue(manifest: string, key: string, values: Map<string, string>, prefixMap: Map<string, string>): string;
|
|
20
|
+
/**
|
|
21
|
+
* Factor out large object values into separate variables.
|
|
22
|
+
*
|
|
23
|
+
* @param valueText The JS source text of the module mapping object.
|
|
24
|
+
* @param sharedVars Map to accumulate shared variable declarations.
|
|
25
|
+
* @param prefixMap Map of short hash prefix → full hash, updated in place for
|
|
26
|
+
* collision resolution across calls.
|
|
27
|
+
* @returns The rewritten value text with chunks arrays replaced by variable refs.
|
|
28
|
+
*/
|
|
29
|
+
export declare function factorObjectValues(valueText: string, sharedVars: Map<string, string>, prefixMap: Map<string, string>): string;
|
|
30
|
+
/**
|
|
31
|
+
* Get or create a short variable name for a value, resolving collisions.
|
|
32
|
+
*
|
|
33
|
+
* Computes a SHA1 hash of the value, then finds the shortest unique prefix
|
|
34
|
+
* (minimum {@link MIN_PREFIX_LENGTH} hex chars). When a new hash collides with
|
|
35
|
+
* an existing prefix, the new entry is given a longer prefix — existing entries
|
|
36
|
+
* are never renamed.
|
|
37
|
+
*
|
|
38
|
+
* This allows saving space in the generated code (A full SHA1 is 40 hex chars) because
|
|
39
|
+
* identifiers are not minimized by the Open Next build process.
|
|
40
|
+
*
|
|
41
|
+
* @param value The value to hash.
|
|
42
|
+
* @param prefixMap Map of short prefix → full hash, updated in place.
|
|
43
|
+
* @returns The variable name (`v<shortPrefix>`).
|
|
44
|
+
*/
|
|
45
|
+
export declare function getOrCreateVarName(value: string, prefixMap: Map<string, string>): string;
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* They rely on `readFileSync` that is not supported by workerd.
|
|
5
5
|
*/
|
|
6
|
+
import crypto from "node:crypto";
|
|
6
7
|
import { readFile } from "node:fs/promises";
|
|
7
8
|
import { join, posix, relative, sep } from "node:path";
|
|
9
|
+
import { Lang, parse } from "@ast-grep/napi";
|
|
8
10
|
import { getPackagePath } from "@opennextjs/aws/build/helper.js";
|
|
9
|
-
import { patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js";
|
|
11
|
+
import { applyRule, patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js";
|
|
10
12
|
import { getCrossPlatformPathRegex } from "@opennextjs/aws/utils/regex.js";
|
|
11
13
|
import { glob } from "glob";
|
|
12
14
|
import { normalizePath } from "../../../utils/normalize-path.js";
|
|
@@ -75,19 +77,62 @@ async function getEvalManifestRule(buildOpts) {
|
|
|
75
77
|
const { outputDir } = buildOpts;
|
|
76
78
|
const baseDir = join(outputDir, "server-functions/default", getPackagePath(buildOpts), ".next");
|
|
77
79
|
const appDir = join(baseDir, "server/app");
|
|
78
|
-
const
|
|
80
|
+
const manifestPaths = await glob(join(baseDir, "**/*_client-reference-manifest.js"), {
|
|
79
81
|
windowsPathsNoEscape: true,
|
|
80
82
|
});
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
83
|
+
// Map of factored large objects (variable name -> {...})
|
|
84
|
+
const factoredObjects = new Map();
|
|
85
|
+
// Map of manifest path -> factored manifest content
|
|
86
|
+
const factoredManifest = new Map();
|
|
87
|
+
// Shared map of short hash prefix -> full SHA1 hash, used for collision resolution.
|
|
88
|
+
const prefixMap = new Map();
|
|
89
|
+
for (const path of manifestPaths) {
|
|
90
|
+
if (path.endsWith("page_client-reference-manifest.js")) {
|
|
91
|
+
// `page_client-reference-manifest.js` files could contain large repeated values.
|
|
92
|
+
// Factor out large values into separate variables to reduce the overall size of the generated code.
|
|
93
|
+
let manifest = await readFile(path, "utf-8");
|
|
94
|
+
for (const key of [
|
|
95
|
+
"clientModules",
|
|
96
|
+
"ssrModuleMapping",
|
|
97
|
+
"edgeSSRModuleMapping",
|
|
98
|
+
"rscModuleMapping",
|
|
99
|
+
"entryCSSFiles",
|
|
100
|
+
"entryJSFiles",
|
|
101
|
+
]) {
|
|
102
|
+
manifest = factorManifestValue(manifest, key, factoredObjects, prefixMap);
|
|
103
|
+
}
|
|
104
|
+
factoredManifest.set(path, manifest);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Map of factored values in an object
|
|
108
|
+
const factoredValues = new Map();
|
|
109
|
+
for (const [varName, value] of factoredObjects) {
|
|
110
|
+
factoredObjects.set(varName, factorObjectValues(value, factoredValues, prefixMap));
|
|
111
|
+
}
|
|
112
|
+
// Prepend chunks variable declarations before the factored values
|
|
113
|
+
const factoredValueCode = [...factoredValues.entries()]
|
|
114
|
+
.map(([name, val]) => `const ${name} = ${val};`)
|
|
115
|
+
.join("\n");
|
|
116
|
+
const factoredObjectCode = [...factoredObjects.entries()]
|
|
117
|
+
.map(([varName, value]) => `const ${varName} = ${value};`)
|
|
118
|
+
.join("\n");
|
|
119
|
+
const returnManifests = manifestPaths
|
|
120
|
+
// Sort by path length descending so longer (more specific) paths match first,
|
|
121
|
+
// preventing suffix collisions in the `.endsWith()` chain (see #1156).
|
|
122
|
+
.toSorted((a, b) => b.length - a.length)
|
|
123
|
+
.map((path) => {
|
|
124
|
+
let manifest;
|
|
125
|
+
if (factoredManifest.has(path)) {
|
|
126
|
+
manifest = factoredManifest.get(path);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
manifest = `require(${JSON.stringify(path)});`;
|
|
130
|
+
}
|
|
131
|
+
const endsWith = normalizePath(relative(baseDir, path));
|
|
132
|
+
const key = normalizePath("/" + relative(appDir, path)).replace("_client-reference-manifest.js", "");
|
|
88
133
|
return `
|
|
89
134
|
if ($PATH.endsWith("${endsWith}")) {
|
|
90
|
-
|
|
135
|
+
${manifest}
|
|
91
136
|
return {
|
|
92
137
|
__RSC_MANIFEST: {
|
|
93
138
|
"${key}": globalThis.__RSC_MANIFEST["${key}"],
|
|
@@ -104,6 +149,9 @@ function evalManifest($PATH, $$$ARGS) {
|
|
|
104
149
|
}`,
|
|
105
150
|
},
|
|
106
151
|
fix: `
|
|
152
|
+
${factoredValueCode}
|
|
153
|
+
${factoredObjectCode}
|
|
154
|
+
|
|
107
155
|
function evalManifest($PATH, $$$ARGS) {
|
|
108
156
|
$PATH = $PATH.replaceAll(${JSON.stringify(sep)}, ${JSON.stringify(posix.sep)});
|
|
109
157
|
${returnManifests}
|
|
@@ -116,3 +164,130 @@ function evalManifest($PATH, $$$ARGS) {
|
|
|
116
164
|
}`,
|
|
117
165
|
};
|
|
118
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Factor out large manifest values into separate variables.
|
|
169
|
+
*
|
|
170
|
+
* @param manifest The manifest code.
|
|
171
|
+
* @param key The key to factor out.
|
|
172
|
+
* @param values A map to store the factored values (indexed by variable name).
|
|
173
|
+
* @param prefixMap Map of short hash prefix → full hash, updated in place for
|
|
174
|
+
* collision resolution across calls.
|
|
175
|
+
* @returns The manifest code with large values factored out.
|
|
176
|
+
*/
|
|
177
|
+
export function factorManifestValue(manifest, key, values, prefixMap) {
|
|
178
|
+
const valueName = "VALUE";
|
|
179
|
+
// ASTGrep rule to extract the value of a specific key from the manifest object in the evalManifest function.
|
|
180
|
+
//
|
|
181
|
+
// globalThis.__RSC_MANIFEST["/path/to/page"] = {
|
|
182
|
+
// // ...
|
|
183
|
+
// key: $VALUE
|
|
184
|
+
// // ...
|
|
185
|
+
// }
|
|
186
|
+
const extractValueRule = `
|
|
187
|
+
rule:
|
|
188
|
+
kind: pair
|
|
189
|
+
all:
|
|
190
|
+
- has:
|
|
191
|
+
field: key
|
|
192
|
+
pattern: '"${key}"'
|
|
193
|
+
- has:
|
|
194
|
+
field: value
|
|
195
|
+
pattern: $${valueName}
|
|
196
|
+
inside:
|
|
197
|
+
pattern: globalThis.__RSC_MANIFEST[$$$_] = { $$$ };
|
|
198
|
+
stopBy: end
|
|
199
|
+
`;
|
|
200
|
+
const rootNode = parse(Lang.JavaScript, manifest).root();
|
|
201
|
+
const { matches } = applyRule(extractValueRule, rootNode, { once: true });
|
|
202
|
+
if (matches.length === 1 && matches[0]?.getMatch(valueName)) {
|
|
203
|
+
const match = matches[0];
|
|
204
|
+
const value = match.getMatch(valueName).text();
|
|
205
|
+
if (value.length > 30) {
|
|
206
|
+
// Factor out large values into separate variables.
|
|
207
|
+
const varName = getOrCreateVarName(value, prefixMap);
|
|
208
|
+
values.set(varName, value);
|
|
209
|
+
// Replace the value in the manifest with the variable reference.
|
|
210
|
+
return rootNode.commitEdits([match.replace(`"${key}": ${varName}`)]);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Return the original manifest if the value is not found or is small enough to not warrant factoring out.
|
|
214
|
+
return manifest;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Factor out large object values into separate variables.
|
|
218
|
+
*
|
|
219
|
+
* @param valueText The JS source text of the module mapping object.
|
|
220
|
+
* @param sharedVars Map to accumulate shared variable declarations.
|
|
221
|
+
* @param prefixMap Map of short hash prefix → full hash, updated in place for
|
|
222
|
+
* collision resolution across calls.
|
|
223
|
+
* @returns The rewritten value text with chunks arrays replaced by variable refs.
|
|
224
|
+
*/
|
|
225
|
+
export function factorObjectValues(valueText, sharedVars, prefixMap) {
|
|
226
|
+
const rootNode = parse(Lang.JavaScript, valueText).root();
|
|
227
|
+
// Find all "chunks": [...] pairs
|
|
228
|
+
const chunksRule = `
|
|
229
|
+
rule:
|
|
230
|
+
kind: pair
|
|
231
|
+
all:
|
|
232
|
+
- has:
|
|
233
|
+
field: key
|
|
234
|
+
pattern: '"chunks"'
|
|
235
|
+
- has:
|
|
236
|
+
field: value
|
|
237
|
+
kind: array
|
|
238
|
+
pattern: $CHUNKS
|
|
239
|
+
`;
|
|
240
|
+
const { matches } = applyRule(chunksRule, rootNode, { once: false });
|
|
241
|
+
const edits = [];
|
|
242
|
+
for (const match of matches) {
|
|
243
|
+
const chunksNode = match.getMatch("CHUNKS");
|
|
244
|
+
if (!chunksNode)
|
|
245
|
+
continue;
|
|
246
|
+
const chunksText = chunksNode.text();
|
|
247
|
+
if (chunksText.length <= 30)
|
|
248
|
+
continue; // Skip small arrays
|
|
249
|
+
const varName = getOrCreateVarName(chunksText, prefixMap);
|
|
250
|
+
sharedVars.set(varName, chunksText);
|
|
251
|
+
edits.push({ match, replacement: `"chunks": ${varName}` });
|
|
252
|
+
}
|
|
253
|
+
return edits.length === 0
|
|
254
|
+
? valueText
|
|
255
|
+
: rootNode.commitEdits(edits.map((e) => e.match.replace(e.replacement)));
|
|
256
|
+
}
|
|
257
|
+
/** Minimum number of hex characters used for short hash prefixes. */
|
|
258
|
+
const MIN_PREFIX_LENGTH = 3;
|
|
259
|
+
/**
|
|
260
|
+
* Get or create a short variable name for a value, resolving collisions.
|
|
261
|
+
*
|
|
262
|
+
* Computes a SHA1 hash of the value, then finds the shortest unique prefix
|
|
263
|
+
* (minimum {@link MIN_PREFIX_LENGTH} hex chars). When a new hash collides with
|
|
264
|
+
* an existing prefix, the new entry is given a longer prefix — existing entries
|
|
265
|
+
* are never renamed.
|
|
266
|
+
*
|
|
267
|
+
* This allows saving space in the generated code (A full SHA1 is 40 hex chars) because
|
|
268
|
+
* identifiers are not minimized by the Open Next build process.
|
|
269
|
+
*
|
|
270
|
+
* @param value The value to hash.
|
|
271
|
+
* @param prefixMap Map of short prefix → full hash, updated in place.
|
|
272
|
+
* @returns The variable name (`v<shortPrefix>`).
|
|
273
|
+
*/
|
|
274
|
+
export function getOrCreateVarName(value, prefixMap) {
|
|
275
|
+
const sha1 = crypto.createHash("sha1").update(value).digest("hex");
|
|
276
|
+
// Find the shortest prefix (>= MIN_PREFIX_LENGTH) that doesn't collide
|
|
277
|
+
// with any existing prefix. Only the new entry is lengthened.
|
|
278
|
+
for (let len = MIN_PREFIX_LENGTH; len <= sha1.length; len++) {
|
|
279
|
+
const candidate = sha1.slice(0, len);
|
|
280
|
+
const existing = prefixMap.get(candidate);
|
|
281
|
+
if (existing === undefined) {
|
|
282
|
+
prefixMap.set(candidate, sha1);
|
|
283
|
+
return `v${candidate}`;
|
|
284
|
+
}
|
|
285
|
+
if (existing === sha1) {
|
|
286
|
+
// Same content seen again — reuse the existing variable.
|
|
287
|
+
return `v${candidate}`;
|
|
288
|
+
}
|
|
289
|
+
// A different hash occupies this exact prefix — lengthen and retry.
|
|
290
|
+
}
|
|
291
|
+
// Unreachable: two different SHA1 hashes always diverge before 40 chars.
|
|
292
|
+
throw new Error("Failed to find a unique prefix");
|
|
293
|
+
}
|
|
@@ -2,6 +2,7 @@ import logger from "@opennextjs/aws/logger.js";
|
|
|
2
2
|
import { build as buildImpl } from "../build/build.js";
|
|
3
3
|
import { askConfirmation } from "../utils/ask-confirmation.js";
|
|
4
4
|
import { createWranglerConfigFile, findWranglerConfig } from "../utils/create-wrangler-config.js";
|
|
5
|
+
import { isNonInteractiveOrCI } from "../utils/is-interactive.js";
|
|
5
6
|
import { compileConfig, getNormalizedOptions, nextAppDir, printHeaders, readWranglerConfig, withWranglerOptions, withWranglerPassthroughArgs, } from "./utils/utils.js";
|
|
6
7
|
/**
|
|
7
8
|
* Implementation of the `opennextjs-cloudflare build` command.
|
|
@@ -18,6 +19,12 @@ export async function buildCommand(args) {
|
|
|
18
19
|
// nor when `--skipWranglerConfigCheck` is used.
|
|
19
20
|
if (!projectOpts.wranglerConfigPath && !args.skipWranglerConfigCheck) {
|
|
20
21
|
if (!findWranglerConfig(projectOpts.sourceDir)) {
|
|
22
|
+
// In non-interactive environments (CI, Cloudflare Workers Builds,
|
|
23
|
+
// Docker, etc.) the prompt would hang or crash. Fail fast with a
|
|
24
|
+
// clear message pointing at the existing escape hatch.
|
|
25
|
+
if (isNonInteractiveOrCI()) {
|
|
26
|
+
throw new Error("No `wrangler.(toml|json|jsonc)` config file found.\n\nCreate one at the project root before running the build, or skip this check with `--skipWranglerConfigCheck` or `SKIP_WRANGLER_CONFIG_CHECK=yes`.");
|
|
27
|
+
}
|
|
21
28
|
const confirmCreate = "No `wrangler.(toml|json|jsonc)` config file found, do you want to create one?";
|
|
22
29
|
if (await askConfirmation(confirmCreate)) {
|
|
23
30
|
await createWranglerConfigFile(projectOpts.sourceDir);
|
|
@@ -9,7 +9,8 @@ import logger from "@opennextjs/aws/logger.js";
|
|
|
9
9
|
import { unstable_readConfig } from "wrangler";
|
|
10
10
|
import { ensureCloudflareConfig } from "../../build/utils/ensure-cf-config.js";
|
|
11
11
|
import { askConfirmation } from "../../utils/ask-confirmation.js";
|
|
12
|
-
import { createOpenNextConfigFile, findOpenNextConfig } from "../../utils/create-open-next-config.js";
|
|
12
|
+
import { createOpenNextConfigFile, findOpenNextConfig, OPEN_NEXT_CONFIG_FILE_NAME, } from "../../utils/create-open-next-config.js";
|
|
13
|
+
import { isNonInteractiveOrCI } from "../../utils/is-interactive.js";
|
|
13
14
|
export const nextAppDir = process.cwd();
|
|
14
15
|
/**
|
|
15
16
|
* Print headers and warnings for the CLI.
|
|
@@ -41,9 +42,15 @@ export async function compileConfig(configPath) {
|
|
|
41
42
|
}
|
|
42
43
|
configPath ??= findOpenNextConfig(nextAppDir);
|
|
43
44
|
if (!configPath) {
|
|
44
|
-
|
|
45
|
+
// In non-interactive environments (CI, Cloudflare Workers Builds,
|
|
46
|
+
// Docker, etc.) there is no TTY to answer a prompt — the build would
|
|
47
|
+
// hang or crash. Fail fast with an actionable message instead.
|
|
48
|
+
if (isNonInteractiveOrCI()) {
|
|
49
|
+
throw new Error(`No \`${OPEN_NEXT_CONFIG_FILE_NAME}\` file was found in the project root.\n\nThis file is required for OpenNext Cloudflare builds.\nRun \`opennextjs-cloudflare migrate\` to create it, or see https://opennext.js.org/cloudflare/get-started for setup guidance.\nCommit it and re-run the build.`);
|
|
50
|
+
}
|
|
51
|
+
const answer = await askConfirmation(`Missing required \`${OPEN_NEXT_CONFIG_FILE_NAME}\` file, do you want to create one?`);
|
|
45
52
|
if (!answer) {
|
|
46
|
-
throw new Error(
|
|
53
|
+
throw new Error(`The \`${OPEN_NEXT_CONFIG_FILE_NAME}\` file is required, aborting!`);
|
|
47
54
|
}
|
|
48
55
|
configPath = createOpenNextConfigFile(nextAppDir, { cache: false });
|
|
49
56
|
}
|
|
@@ -37,11 +37,6 @@ function init(request, env) {
|
|
|
37
37
|
populateProcessEnv(url, env);
|
|
38
38
|
}
|
|
39
39
|
function initRuntime() {
|
|
40
|
-
// Some packages rely on `process.version` and `process.versions.node` (i.e. Jose@4)
|
|
41
|
-
// TODO: Remove when https://github.com/unjs/unenv/pull/493 is merged
|
|
42
|
-
Object.assign(process, { version: process.version || "v22.14.0" });
|
|
43
|
-
// @ts-expect-error Node type does not match workerd
|
|
44
|
-
Object.assign(process.versions, { node: "22.14.0", ...process.versions });
|
|
45
40
|
globalThis.__dirname ??= "";
|
|
46
41
|
globalThis.__filename ??= "";
|
|
47
42
|
// Some packages rely on `import.meta.url` but it is undefined in workerd
|
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js";
|
|
4
4
|
import { getPackageTemplatesDirPath } from "../../utils/get-package-templates-dir-path.js";
|
|
5
|
+
export const OPEN_NEXT_CONFIG_FILE_NAME = "open-next.config.ts";
|
|
5
6
|
/**
|
|
6
7
|
* Finds the path to the OpenNext configuration file if it exists.
|
|
7
8
|
*
|
|
@@ -9,7 +10,7 @@ import { getPackageTemplatesDirPath } from "../../utils/get-package-templates-di
|
|
|
9
10
|
* @returns The full path to open-next.config.ts if it exists, undefined otherwise
|
|
10
11
|
*/
|
|
11
12
|
export function findOpenNextConfig(appDir) {
|
|
12
|
-
const openNextConfigPath = join(appDir,
|
|
13
|
+
const openNextConfigPath = join(appDir, OPEN_NEXT_CONFIG_FILE_NAME);
|
|
13
14
|
if (existsSync(openNextConfigPath)) {
|
|
14
15
|
return openNextConfigPath;
|
|
15
16
|
}
|
|
@@ -23,8 +24,8 @@ export function findOpenNextConfig(appDir) {
|
|
|
23
24
|
* @returns The path to the created configuration file
|
|
24
25
|
*/
|
|
25
26
|
export function createOpenNextConfigFile(appDir, options) {
|
|
26
|
-
const openNextConfigPath = join(appDir,
|
|
27
|
-
let content = readFileSync(join(getPackageTemplatesDirPath(),
|
|
27
|
+
const openNextConfigPath = join(appDir, OPEN_NEXT_CONFIG_FILE_NAME);
|
|
28
|
+
let content = readFileSync(join(getPackageTemplatesDirPath(), OPEN_NEXT_CONFIG_FILE_NAME), "utf8");
|
|
28
29
|
if (!options.cache) {
|
|
29
30
|
content = patchCode(content, commentOutR2ImportRule);
|
|
30
31
|
content = patchCode(content, commentOutIncrementalCacheRule);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import ci from "ci-info";
|
|
2
|
+
/**
|
|
3
|
+
* Whether the current process is running in an interactive terminal.
|
|
4
|
+
*/
|
|
5
|
+
export function isInteractive() {
|
|
6
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Whether prompts should be suppressed.
|
|
10
|
+
*/
|
|
11
|
+
export function isNonInteractiveOrCI() {
|
|
12
|
+
return !isInteractive() || ci.isCI;
|
|
13
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opennextjs/cloudflare",
|
|
3
3
|
"description": "Cloudflare builder for next apps",
|
|
4
|
-
"version": "1.19.
|
|
4
|
+
"version": "1.19.4",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"opennextjs-cloudflare": "dist/cli/index.js"
|
|
@@ -44,7 +44,8 @@
|
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@ast-grep/napi": "^0.40.5",
|
|
46
46
|
"@dotenvx/dotenvx": "1.31.0",
|
|
47
|
-
"@opennextjs/aws": "3.10.
|
|
47
|
+
"@opennextjs/aws": "3.10.4",
|
|
48
|
+
"ci-info": "^4.2.0",
|
|
48
49
|
"cloudflare": "^4.4.1",
|
|
49
50
|
"comment-json": "^4.5.1",
|
|
50
51
|
"enquirer": "^2.4.1",
|
|
@@ -53,7 +54,7 @@
|
|
|
53
54
|
"yargs": "^18.0.0"
|
|
54
55
|
},
|
|
55
56
|
"devDependencies": {
|
|
56
|
-
"@cloudflare/workers-types": "^4.
|
|
57
|
+
"@cloudflare/workers-types": "^4.20260423.1",
|
|
57
58
|
"@eslint/js": "^9.11.1",
|
|
58
59
|
"@tsconfig/strictest": "^2.0.5",
|
|
59
60
|
"@types/mock-fs": "^4.13.4",
|
|
@@ -77,7 +78,7 @@
|
|
|
77
78
|
},
|
|
78
79
|
"peerDependencies": {
|
|
79
80
|
"next": ">=15.5.15 <16 || >=16.2.3",
|
|
80
|
-
"wrangler": "^4.
|
|
81
|
+
"wrangler": "^4.84.1"
|
|
81
82
|
},
|
|
82
83
|
"scripts": {
|
|
83
84
|
"clean": "rimraf dist",
|