@opennextjs/cloudflare 1.19.10 → 1.20.0

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.
@@ -32,6 +32,8 @@ declare global {
32
32
  CF_PREVIEW_DOMAIN?: string;
33
33
  CF_WORKERS_SCRIPTS_API_TOKEN?: string;
34
34
  CF_ACCOUNT_ID?: string;
35
+ R2_ACCESS_KEY_ID?: string;
36
+ R2_SECRET_ACCESS_KEY?: string;
35
37
  }
36
38
  }
37
39
  export type CloudflareContext<CfProperties extends Record<string, unknown> = IncomingRequestCfProperties, Context = ExecutionContext> = {
@@ -25,7 +25,7 @@ export class BucketCachePurge extends DurableObject {
25
25
  // Insert the tag into the sql table
26
26
  this.ctx.storage.sql.exec(`
27
27
  INSERT OR REPLACE INTO cache_purge (tag)
28
- VALUES (?)`, [tag]);
28
+ VALUES (?)`, tag);
29
29
  }
30
30
  const nextAlarm = await this.ctx.storage.getAlarm();
31
31
  if (!nextAlarm) {
@@ -57,7 +57,7 @@ export class BucketCachePurge extends DurableObject {
57
57
  this.ctx.storage.sql.exec(`
58
58
  DELETE FROM cache_purge
59
59
  WHERE tag IN (${tags.map(() => "?").join(",")})
60
- `, tags.map((row) => row.tag));
60
+ `, ...tags.map((row) => row.tag));
61
61
  if (tags.length < MAX_NUMBER_OF_TAGS_PER_PURGE) {
62
62
  // If we have less than MAX_NUMBER_OF_TAGS_PER_PURGE tags, we can stop
63
63
  tags = [];
@@ -70,6 +70,6 @@ export class BucketCachePurge extends DurableObject {
70
70
  `)
71
71
  .toArray();
72
72
  }
73
- } while (tags.length >= 0);
73
+ } while (tags.length > 0);
74
74
  }
75
75
  }
@@ -7,6 +7,7 @@ import type { WithWranglerArgs } from "./utils/utils.js";
7
7
  */
8
8
  export declare function deployCommand(args: WithWranglerArgs<{
9
9
  cacheChunkSize?: number;
10
+ rclone: boolean;
10
11
  }>): Promise<void>;
11
12
  /**
12
13
  * Add the `deploy` command to yargs configuration.
@@ -24,6 +24,7 @@ export async function deployCommand(args) {
24
24
  environment: args.env,
25
25
  wranglerConfigPath: args.wranglerConfigPath,
26
26
  cacheChunkSize: args.cacheChunkSize,
27
+ useRclone: args.rclone,
27
28
  shouldUsePreviewId: false,
28
29
  }, envVars);
29
30
  const deploymentMapping = await getDeploymentMapping(buildOpts, config, envVars);
@@ -32,16 +32,25 @@ export type PopulateCacheOptions = {
32
32
  /**
33
33
  * Number of concurrent requests when populating the cache.
34
34
  * For KV this is the chunk size passed to `wrangler kv bulk put`.
35
- * For R2 this is the number of concurrent requests to the local worker.
35
+ * For R2 this is the number of concurrent requests to the local worker or rclone transfers.
36
36
  *
37
37
  * @default 25
38
38
  */
39
39
  cacheChunkSize?: number;
40
+ /**
41
+ * Whether to use `rclone` instead of the worker-based R2 cache population path.
42
+ */
43
+ useRclone?: boolean;
40
44
  /**
41
45
  * Instructs Wrangler to use the preview namespace or ID defined in the Wrangler config for the remote target.
42
46
  */
43
47
  shouldUsePreviewId: boolean;
44
48
  };
49
+ export declare function loadRclone(importRclone?: () => Promise<{
50
+ default: typeof import("rclone.js");
51
+ }>): Promise<((...args: Array<string | object>) => import("child_process").ChildProcess) & Record<"test" | "checksum" | "link" | "version" | "config" | "copy" | "delete" | "about" | "authorize" | "backend" | "cat" | "check" | "cleanup" | "config create" | "config delete" | "config disconnect" | "config dump" | "config edit" | "config file" | "config password" | "config providers" | "config reconnect" | "config show" | "config update" | "config userinfo" | "copyto" | "copyurl" | "cryptcheck" | "cryptdecode" | "dedupe" | "deletefile" | "genautocomplete" | "genautocomplete bash" | "genautocomplete fish" | "genautocomplete zsh" | "gendocs" | "hashsum" | "help" | "listremotes" | "ls" | "lsd" | "lsf" | "lsjson" | "lsl" | "md5sum" | "mkdir" | "mount" | "move" | "moveto" | "ncdu" | "obscure" | "purge" | "rc" | "rcat" | "rcd" | "rmdir" | "rmdirs" | "selfupdate" | "serve" | "serve dlna" | "serve ftp" | "serve http" | "serve restic" | "serve sftp" | "serve webdav" | "settier" | "sha1sum" | "size" | "sync" | "touch" | "tree", (...args: Array<string | object>) => import("child_process").ChildProcess> & {
52
+ promises: ((...args: Array<string | object>) => Promise<Buffer<ArrayBufferLike>>) & Record<"test" | "checksum" | "link" | "version" | "config" | "copy" | "delete" | "about" | "authorize" | "backend" | "cat" | "check" | "cleanup" | "config create" | "config delete" | "config disconnect" | "config dump" | "config edit" | "config file" | "config password" | "config providers" | "config reconnect" | "config show" | "config update" | "config userinfo" | "copyto" | "copyurl" | "cryptcheck" | "cryptdecode" | "dedupe" | "deletefile" | "genautocomplete" | "genautocomplete bash" | "genautocomplete fish" | "genautocomplete zsh" | "gendocs" | "hashsum" | "help" | "listremotes" | "ls" | "lsd" | "lsf" | "lsjson" | "lsl" | "md5sum" | "mkdir" | "mount" | "move" | "moveto" | "ncdu" | "obscure" | "purge" | "rc" | "rcat" | "rcd" | "rmdir" | "rmdirs" | "selfupdate" | "serve" | "serve dlna" | "serve ftp" | "serve http" | "serve restic" | "serve sftp" | "serve webdav" | "settier" | "sha1sum" | "size" | "sync" | "touch" | "tree", (...args: Array<string | object>) => Promise<Buffer<ArrayBufferLike>>>;
53
+ }>;
45
54
  /**
46
55
  * Add the `populateCache` command to yargs configuration, with nested commands for `local` and `remote`.
47
56
  *
@@ -56,4 +65,6 @@ export declare function withPopulateCacheOptions<T extends yargs.Argv>(args: T):
56
65
  env: string | undefined;
57
66
  } & {
58
67
  cacheChunkSize: number | undefined;
68
+ } & {
69
+ rclone: boolean;
59
70
  }>;
@@ -46,6 +46,7 @@ async function populateCacheCommand(target, args) {
46
46
  environment: args.env,
47
47
  wranglerConfigPath: args.wranglerConfigPath,
48
48
  cacheChunkSize: args.cacheChunkSize,
49
+ useRclone: args.rclone,
49
50
  shouldUsePreviewId: false,
50
51
  }, envVars);
51
52
  }
@@ -150,6 +151,13 @@ async function populateR2IncrementalCache(buildOpts, config, populateCacheOption
150
151
  logger.info("No cache assets to populate");
151
152
  return;
152
153
  }
154
+ if (populateCacheOptions.useRclone) {
155
+ if (populateCacheOptions.target !== "remote") {
156
+ throw new Error("The `--rclone` option can only be used when populating a remote R2 cache.");
157
+ }
158
+ await populateR2IncrementalCacheWithRclone(bucketName, prefix, assets, envVars, populateCacheOptions.cacheChunkSize);
159
+ return;
160
+ }
153
161
  const currentDir = path.dirname(fileURLToPath(import.meta.url));
154
162
  const handlerPath = path.join(currentDir, "../workers/r2-cache.js");
155
163
  const isRemote = populateCacheOptions.target === "remote";
@@ -204,6 +212,113 @@ async function populateR2IncrementalCache(buildOpts, config, populateCacheOption
204
212
  }
205
213
  logger.info(`Successfully populated cache with ${assets.length} entries`);
206
214
  }
215
+ async function populateR2IncrementalCacheWithRclone(bucketName, prefix, assets, envVars, cacheChunkSize) {
216
+ const accessKey = envVars.R2_ACCESS_KEY_ID;
217
+ const secretKey = envVars.R2_SECRET_ACCESS_KEY;
218
+ const accountId = envVars.CF_ACCOUNT_ID;
219
+ if (!accessKey || !secretKey || !accountId) {
220
+ throw new Error("R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, and CF_ACCOUNT_ID must be provided to use `rclone`");
221
+ }
222
+ const rclone = await loadRclone();
223
+ logger.info("\nPopulating remote R2 incremental cache using `rclone`...");
224
+ const configDir = await fsp.mkdtemp(path.join(os.tmpdir(), "rclone-config-"));
225
+ const configPath = path.join(configDir, "rclone.conf");
226
+ const stagingDir = await fsp.mkdtemp(path.join(os.tmpdir(), "r2-staging-"));
227
+ const transfers = Math.max(1, cacheChunkSize ?? 25);
228
+ const checkers = Math.max(1, Math.floor(transfers / 2));
229
+ try {
230
+ await fsp.writeFile(configPath, `[r2]\ntype = s3\nprovider = Cloudflare\naccess_key_id = ${accessKey}\nsecret_access_key = ${secretKey}\nendpoint = https://${accountId}.r2.cloudflarestorage.com\nacl = private\n`, { mode: 0o600 });
231
+ const rcloneEnv = {
232
+ ...process.env,
233
+ RCLONE_CONFIG: configPath,
234
+ };
235
+ await ensureRcloneExecutable(rclone, rcloneEnv);
236
+ await stageCacheAssets(assets, stagingDir, prefix, transfers);
237
+ await runRcloneCopy(rclone, stagingDir, `r2:${bucketName}`, {
238
+ progress: true,
239
+ transfers,
240
+ checkers,
241
+ env: rcloneEnv,
242
+ stdio: "inherit",
243
+ });
244
+ }
245
+ finally {
246
+ await Promise.allSettled([
247
+ fsp.rm(stagingDir, { recursive: true, force: true }),
248
+ fsp.rm(configDir, { recursive: true, force: true }),
249
+ ]);
250
+ }
251
+ logger.info(`Successfully populated cache with ${assets.length} entries`);
252
+ }
253
+ async function runRcloneCopy(rclone, source, destination, options) {
254
+ await new Promise((resolve, reject) => {
255
+ const child = rclone.copy(source, destination, options);
256
+ child.once("error", reject);
257
+ child.once("close", (code, signal) => {
258
+ if (code === 0) {
259
+ resolve();
260
+ return;
261
+ }
262
+ reject(new Error(`rclone exited ${signal ? `after receiving signal ${signal}` : `with code ${code ?? "unknown"}`}`));
263
+ });
264
+ });
265
+ }
266
+ async function stageCacheAssets(assets, stagingDir, prefix, maxConcurrency) {
267
+ const pending = new Set();
268
+ for (const asset of assets) {
269
+ const task = stageCacheAsset(asset, stagingDir, prefix).finally(() => pending.delete(task));
270
+ pending.add(task);
271
+ if (pending.size >= maxConcurrency) {
272
+ await Promise.race(pending);
273
+ }
274
+ }
275
+ await Promise.all(pending);
276
+ }
277
+ async function stageCacheAsset(asset, stagingDir, prefix) {
278
+ const cacheKey = computeCacheKey(asset.key, {
279
+ prefix,
280
+ buildId: asset.buildId,
281
+ cacheType: asset.isFetch ? "fetch" : "cache",
282
+ });
283
+ const destination = path.resolve(stagingDir, cacheKey);
284
+ const relativeDestination = path.relative(stagingDir, destination);
285
+ if (relativeDestination.startsWith(`..${path.sep}`) || path.isAbsolute(relativeDestination)) {
286
+ throw new Error(`Cannot stage R2 cache key outside the temporary directory: ${JSON.stringify(cacheKey)}`);
287
+ }
288
+ await fsp.mkdir(path.dirname(destination), { recursive: true });
289
+ try {
290
+ await fsp.link(asset.fullPath, destination);
291
+ }
292
+ catch (error) {
293
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "EXDEV") {
294
+ throw error;
295
+ }
296
+ await fsp.copyFile(asset.fullPath, destination);
297
+ }
298
+ }
299
+ async function ensureRcloneExecutable(rclone, env) {
300
+ try {
301
+ await rclone.promises.version({ env });
302
+ }
303
+ catch (error) {
304
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
305
+ throw new Error("The `rclone.js` executable is unavailable. pnpm users must allow its install script with `pnpm approve-builds`, select `rclone.js`, then run `pnpm rebuild rclone.js`.");
306
+ }
307
+ throw error;
308
+ }
309
+ }
310
+ export async function loadRclone(importRclone = () => import("rclone.js")) {
311
+ try {
312
+ return (await importRclone()).default;
313
+ }
314
+ catch (error) {
315
+ if (error instanceof Error &&
316
+ ("code" in error ? error.code === "ERR_MODULE_NOT_FOUND" : error.message.includes("rclone.js"))) {
317
+ throw new Error("The `--rclone` option requires the optional `rclone.js` peer dependency. Install it in your project before using this option.");
318
+ }
319
+ throw error;
320
+ }
321
+ }
207
322
  /**
208
323
  * Sends cache entries to the R2 worker, one entry per request.
209
324
  *
@@ -262,11 +377,17 @@ async function sendEntryToR2Worker(options) {
262
377
  "x-opennext-cache-key": key,
263
378
  "content-length": fs.statSync(filename).size.toString(),
264
379
  // Include Access Client ID and Secret if they are set in the environment,
265
- // to allow the worker to authenticate with the Cloudflare API when writing to R2.
380
+ // so the helper worker can be reached through Cloudflare Access.
266
381
  //
267
- // The Application at "open-next-cache-populate.<account>.workers.dev" should have a policy with:
268
- // - "Action" set to "Service Auth"
269
- // - "Any Access Service Token" or "Service Token" + a specific service token
382
+ // If the workers.dev subdomain (or a parent route) is behind Cloudflare Access,
383
+ // attach a "Service Auth" policy to the *existing* Access application that already
384
+ // covers "open-next-cache-populate.<account>.workers.dev" typically the
385
+ // "*.<account>.workers.dev" wildcard application. Creating a separate application
386
+ // scoped to this hostname has been observed to block the upload, even alongside
387
+ // the wildcard app. The policy should have:
388
+ // - Action set to "Service Auth"
389
+ // - An Include rule for "Any Access Service Token" or a specific Service Token
390
+ // See: https://opennext.js.org/cloudflare/cli#populating-remote-bindings-when-workers-are-protected-by-cloudflare-access
270
391
  ...(process.env.CLOUDFLARE_ACCESS_CLIENT_ID && process.env.CLOUDFLARE_ACCESS_CLIENT_SECRET
271
392
  ? {
272
393
  "CF-Access-Client-Id": process.env.CLOUDFLARE_ACCESS_CLIENT_ID,
@@ -425,8 +546,14 @@ export function addPopulateCacheCommand(y) {
425
546
  .demandCommand(1, 1));
426
547
  }
427
548
  export function withPopulateCacheOptions(args) {
428
- return withWranglerOptions(args).options("cacheChunkSize", {
549
+ return withWranglerOptions(args)
550
+ .options("cacheChunkSize", {
429
551
  type: "number",
430
- desc: "Number of entries per chunk when populating the cache",
552
+ desc: "Number of concurrent cache entries to process",
553
+ })
554
+ .options("rclone", {
555
+ type: "boolean",
556
+ default: false,
557
+ desc: "Use rclone to populate a remote R2 incremental cache",
431
558
  });
432
559
  }
@@ -8,6 +8,7 @@ import type { WithWranglerArgs } from "./utils/utils.js";
8
8
  export declare function previewCommand(args: WithWranglerArgs<{
9
9
  cacheChunkSize?: number;
10
10
  remote: boolean;
11
+ rclone: boolean;
11
12
  }>): Promise<void>;
12
13
  /**
13
14
  * Add the `preview` command to yargs configuration.
@@ -22,6 +22,7 @@ export async function previewCommand(args) {
22
22
  environment: args.env,
23
23
  wranglerConfigPath: args.wranglerConfigPath,
24
24
  cacheChunkSize: args.cacheChunkSize,
25
+ useRclone: args.rclone,
25
26
  shouldUsePreviewId: args.remote,
26
27
  }, envVars);
27
28
  const result = runWrangler(buildOpts, ["dev", ...args.wranglerArgs], { logging: "all" });
@@ -31,6 +31,8 @@ const MAX_NUMBER_OF_VERSIONS = 20;
31
31
  /** Maximum age of versions to list */
32
32
  const MAX_VERSION_AGE_DAYS = 7;
33
33
  const MS_PER_DAY = 24 * 3600 * 1000;
34
+ /** Worker-version trigger types that produce a full upload (assets + code). */
35
+ const UPLOAD_TRIGGER_TYPES = new Set(["upload", "version_upload"]);
34
36
  /**
35
37
  * Compute the deployment mapping for a deployment.
36
38
  *
@@ -174,11 +176,18 @@ export async function listWorkerVersions(scriptName, options) {
174
176
  })) {
175
177
  const id = version.id;
176
178
  const createdOn = version.metadata?.created_on;
179
+ const triggeredBy = version.metadata?.annotations?.["workers/triggered_by"];
177
180
  if (id && createdOn) {
178
181
  const createdOnMs = new Date(createdOn).getTime();
179
182
  if (createdOnMs < afterTimeMs) {
180
183
  break;
181
184
  }
185
+ // Skip metadata-only versions (e.g. secret/service_token triggers)
186
+ // that lack the static assets bundle. Versions with no annotation
187
+ // are kept for backward compatibility.
188
+ if (triggeredBy !== undefined && !UPLOAD_TRIGGER_TYPES.has(triggeredBy)) {
189
+ continue;
190
+ }
182
191
  versions.push({ id, createdOnMs });
183
192
  if (versions.length >= maxNumberOfVersions) {
184
193
  break;
@@ -7,6 +7,7 @@ import type { WithWranglerArgs } from "./utils/utils.js";
7
7
  */
8
8
  export declare function uploadCommand(args: WithWranglerArgs<{
9
9
  cacheChunkSize?: number;
10
+ rclone: boolean;
10
11
  }>): Promise<void>;
11
12
  /**
12
13
  * Add the `upload` command to yargs configuration.
@@ -25,6 +25,7 @@ export async function uploadCommand(args) {
25
25
  environment: args.env,
26
26
  wranglerConfigPath: args.wranglerConfigPath,
27
27
  cacheChunkSize: args.cacheChunkSize,
28
+ useRclone: args.rclone,
28
29
  shouldUsePreviewId: false,
29
30
  }, envVars);
30
31
  const result = runWrangler(buildOpts, [
@@ -106,8 +106,15 @@ export async function ensureR2Bucket(projectDir, bucketName, jurisdiction) {
106
106
  }
107
107
  }
108
108
  const client = authCredentials.type === "api_key"
109
- ? new Cloudflare({ apiKey: authCredentials.apiKey, apiEmail: authCredentials.apiEmail })
110
- : new Cloudflare({ apiToken: authCredentials.token });
109
+ ? new Cloudflare({
110
+ apiKey: authCredentials.apiKey,
111
+ apiEmail: authCredentials.apiEmail,
112
+ defaultHeaders: { "Accept-Encoding": "identity" },
113
+ })
114
+ : new Cloudflare({
115
+ apiToken: authCredentials.token,
116
+ defaultHeaders: { "Accept-Encoding": "identity" },
117
+ });
111
118
  const accountId = await getAccountId(client);
112
119
  if (!accountId) {
113
120
  return { success: false, error: "Could not determine Cloudflare account ID" };
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.10",
4
+ "version": "1.20.0",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "opennextjs-cloudflare": "dist/cli/index.js"
@@ -60,6 +60,7 @@
60
60
  "@types/mock-fs": "^4.13.4",
61
61
  "@types/node": "^22.12.0",
62
62
  "@types/picomatch": "^4.0.0",
63
+ "@types/rclone.js": "^0.6.1",
63
64
  "@types/yargs": "^17.0.33",
64
65
  "diff": "^8.0.2",
65
66
  "esbuild": "^0.27.0",
@@ -71,6 +72,7 @@
71
72
  "mock-fs": "^5.4.1",
72
73
  "next": "^15.5.18",
73
74
  "picomatch": "^4.0.2",
75
+ "rclone.js": "^0.6.6",
74
76
  "rimraf": "^6.0.1",
75
77
  "typescript": "^5.9.3",
76
78
  "typescript-eslint": "^8.48.0",
@@ -78,8 +80,14 @@
78
80
  },
79
81
  "peerDependencies": {
80
82
  "next": ">=15.5.18 <16 || >=16.2.6",
83
+ "rclone.js": "^0.6.6",
81
84
  "wrangler": "^4.86.0"
82
85
  },
86
+ "peerDependenciesMeta": {
87
+ "rclone.js": {
88
+ "optional": true
89
+ }
90
+ },
83
91
  "scripts": {
84
92
  "clean": "rimraf dist",
85
93
  "build": "pnpm clean && tsc",