@opennextjs/cloudflare 1.19.11 → 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
  *
@@ -431,8 +546,14 @@ export function addPopulateCacheCommand(y) {
431
546
  .demandCommand(1, 1));
432
547
  }
433
548
  export function withPopulateCacheOptions(args) {
434
- return withWranglerOptions(args).options("cacheChunkSize", {
549
+ return withWranglerOptions(args)
550
+ .options("cacheChunkSize", {
435
551
  type: "number",
436
- 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",
437
558
  });
438
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" });
@@ -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.11",
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",