@opennextjs/cloudflare 1.10.0 → 1.11.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.
package/README.md CHANGED
@@ -55,3 +55,30 @@ Deploy your application to production with the following:
55
55
  # or
56
56
  bun opennextjs-cloudflare build && bun opennextjs-cloudflare deploy
57
57
  ```
58
+
59
+ ### Batch Cache Population (Optional, Recommended)
60
+
61
+ For improved performance with large caches, you can enable batch upload by providing R2 credentials via .env or environment variables.
62
+
63
+ Create a `.env` file in your project root (automatically loaded by the CLI):
64
+
65
+ ```bash
66
+ R2_ACCESS_KEY_ID=your_access_key_id
67
+ R2_SECRET_ACCESS_KEY=your_secret_access_key
68
+ CF_ACCOUNT_ID=your_account_id
69
+ ```
70
+
71
+ You can also set the environment variables for CI builds.
72
+
73
+ **Note:**
74
+
75
+ You can follow documentation https://developers.cloudflare.com/r2/api/tokens/ for creating API tokens with appropriate permissions for R2 access.
76
+
77
+ **Benefits:**
78
+
79
+ - Significantly faster uploads for large caches using parallel transfers
80
+ - Reduced API calls to Cloudflare
81
+ - Automatically enabled when credentials are provided
82
+
83
+ **Fallback:**
84
+ If these environment variables are not set, the CLI will use standard Wrangler uploads. Both methods work correctly - batch upload is simply faster for large caches.
@@ -31,6 +31,8 @@ declare global {
31
31
  CF_PREVIEW_DOMAIN?: string;
32
32
  CF_WORKERS_SCRIPTS_API_TOKEN?: string;
33
33
  CF_ACCOUNT_ID?: string;
34
+ R2_ACCESS_KEY_ID?: string;
35
+ R2_SECRET_ACCESS_KEY?: string;
34
36
  }
35
37
  }
36
38
  export type CloudflareContext<CfProperties extends Record<string, unknown> = IncomingRequestCfProperties, Context = ExecutionContext> = {
@@ -13,7 +13,6 @@ import { compileInit } from "./open-next/compile-init.js";
13
13
  import { compileSkewProtection } from "./open-next/compile-skew-protection.js";
14
14
  import { compileDurableObjects } from "./open-next/compileDurableObjects.js";
15
15
  import { createServerBundle } from "./open-next/createServerBundle.js";
16
- import { createWranglerConfigIfNotExistent } from "./utils/index.js";
17
16
  import { getVersion } from "./utils/version.js";
18
17
  /**
19
18
  * Builds the application in a format that can be passed to workerd
@@ -65,9 +64,6 @@ export async function build(options, config, projectOpts, wranglerConfig) {
65
64
  await createServerBundle(options);
66
65
  await compileDurableObjects(options);
67
66
  await bundleServer(options, projectOpts);
68
- if (!projectOpts.skipWranglerConfigCheck) {
69
- await createWranglerConfigIfNotExistent(projectOpts);
70
- }
71
67
  logger.info("OpenNext build complete.");
72
68
  }
73
69
  function ensureNextjsVersionSupported(options) {
@@ -1,4 +1,5 @@
1
1
  import { build as buildImpl } from "../build/build.js";
2
+ import { createWranglerConfigIfNotExistent } from "../build/utils/index.js";
2
3
  import { compileConfig, getNormalizedOptions, nextAppDir, printHeaders, readWranglerConfig, withWranglerOptions, withWranglerPassthroughArgs, } from "./utils.js";
3
4
  /**
4
5
  * Implementation of the `opennextjs-cloudflare build` command.
@@ -9,8 +10,15 @@ async function buildCommand(args) {
9
10
  printHeaders("build");
10
11
  const { config, buildDir } = await compileConfig(args.openNextConfigPath);
11
12
  const options = getNormalizedOptions(config, buildDir);
13
+ const projectOpts = { ...args, minify: !args.noMinify, sourceDir: nextAppDir };
14
+ // Ask whether a `wrangler.jsonc` should be created when no config file exists.
15
+ // Note: We don't ask when a custom config file is specified via `--config`
16
+ // nor when `--skipWranglerConfigCheck` is used.
17
+ if (!projectOpts.wranglerConfigPath && !args.skipWranglerConfigCheck) {
18
+ await createWranglerConfigIfNotExistent(projectOpts);
19
+ }
12
20
  const wranglerConfig = readWranglerConfig(args);
13
- await buildImpl(options, config, { ...args, minify: !args.noMinify, sourceDir: nextAppDir }, wranglerConfig);
21
+ await buildImpl(options, config, projectOpts, wranglerConfig);
14
22
  }
15
23
  /**
16
24
  * Add the `build` command to yargs configuration.
@@ -1,7 +1,9 @@
1
- import { cpSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
2
3
  import path from "node:path";
3
4
  import logger from "@opennextjs/aws/logger.js";
4
5
  import { globSync } from "glob";
6
+ import rclone from "rclone.js";
5
7
  import { tqdm } from "ts-tqdm";
6
8
  import { BINDING_NAME as KV_CACHE_BINDING_NAME, NAME as KV_CACHE_NAME, PREFIX_ENV_NAME as KV_CACHE_PREFIX_ENV_NAME, } from "../../api/overrides/incremental-cache/kv-incremental-cache.js";
7
9
  import { BINDING_NAME as R2_CACHE_BINDING_NAME, NAME as R2_CACHE_NAME, PREFIX_ENV_NAME as R2_CACHE_PREFIX_ENV_NAME, } from "../../api/overrides/incremental-cache/r2-incremental-cache.js";
@@ -103,18 +105,112 @@ export function getCacheAssets(opts) {
103
105
  }
104
106
  return assets;
105
107
  }
106
- async function populateR2IncrementalCache(buildOpts, config, populateCacheOptions, envVars) {
107
- logger.info("\nPopulating R2 incremental cache...");
108
- const binding = config.r2_buckets.find(({ binding }) => binding === R2_CACHE_BINDING_NAME);
109
- if (!binding) {
110
- throw new Error(`No R2 binding ${JSON.stringify(R2_CACHE_BINDING_NAME)} found!`);
108
+ /**
109
+ * Create a temporary configuration file for batch upload from environment variables
110
+ * @returns Path to the temporary config file or null if env vars not available
111
+ */
112
+ function createTempRcloneConfig(accessKey, secretKey, accountId) {
113
+ const tempDir = tmpdir();
114
+ const tempConfigPath = path.join(tempDir, `rclone-config-${Date.now()}.conf`);
115
+ const configContent = `[r2]
116
+ type = s3
117
+ provider = Cloudflare
118
+ access_key_id = ${accessKey}
119
+ secret_access_key = ${secretKey}
120
+ endpoint = https://${accountId}.r2.cloudflarestorage.com
121
+ acl = private
122
+ `;
123
+ /**
124
+ * 0o600 is an octal number (the 0o prefix indicates octal in JavaScript)
125
+ * that represents Unix file permissions:
126
+ *
127
+ * - 6 (owner): read (4) + write (2) = readable and writable by the file owner
128
+ * - 0 (group): no permissions for the group
129
+ * - 0 (others): no permissions for anyone else
130
+ *
131
+ * In symbolic notation, this is: rw-------
132
+ */
133
+ writeFileSync(tempConfigPath, configContent, { mode: 0o600 });
134
+ return tempConfigPath;
135
+ }
136
+ /**
137
+ * Populate R2 incremental cache using batch upload for better performance
138
+ * Uses parallel transfers to significantly speed up cache population
139
+ */
140
+ async function populateR2IncrementalCacheWithBatchUpload(bucket, prefix, assets, envVars) {
141
+ const accessKey = envVars.R2_ACCESS_KEY_ID || null;
142
+ const secretKey = envVars.R2_SECRET_ACCESS_KEY || null;
143
+ const accountId = envVars.CF_ACCOUNT_ID || null;
144
+ // Ensure all required env vars are set correctly
145
+ if (!accessKey || !secretKey || !accountId) {
146
+ throw new Error("Please set R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, and CF_ACCOUNT_ID environment variables to enable faster batch upload for remote R2.");
111
147
  }
112
- const bucket = binding.bucket_name;
113
- if (!bucket) {
114
- throw new Error(`R2 binding ${JSON.stringify(R2_CACHE_BINDING_NAME)} should have a 'bucket_name'`);
148
+ logger.info("\nPopulating remote R2 incremental cache using batch upload...");
149
+ // Create temporary config from env vars - required for batch upload
150
+ const tempConfigPath = createTempRcloneConfig(accessKey, secretKey, accountId);
151
+ if (!tempConfigPath) {
152
+ throw new Error("Failed to create temporary rclone config for R2 batch upload.");
115
153
  }
116
- const prefix = envVars[R2_CACHE_PREFIX_ENV_NAME];
117
- const assets = getCacheAssets(buildOpts);
154
+ const env = {
155
+ ...process.env,
156
+ RCLONE_CONFIG: tempConfigPath,
157
+ };
158
+ logger.info("Using batch upload with R2 credentials from environment variables");
159
+ // Create a staging dir in temp directory with proper key paths
160
+ const tempDir = tmpdir();
161
+ const stagingDir = path.join(tempDir, `.r2-staging-${Date.now()}`);
162
+ // Track success to ensure cleanup happens correctly
163
+ let success = null;
164
+ try {
165
+ mkdirSync(stagingDir, { recursive: true });
166
+ for (const { fullPath, key, buildId, isFetch } of assets) {
167
+ const cacheKey = computeCacheKey(key, {
168
+ prefix,
169
+ buildId,
170
+ cacheType: isFetch ? "fetch" : "cache",
171
+ });
172
+ const destPath = path.join(stagingDir, cacheKey);
173
+ mkdirSync(path.dirname(destPath), { recursive: true });
174
+ copyFileSync(fullPath, destPath);
175
+ }
176
+ // Use rclone.js to sync the R2
177
+ const remote = `r2:${bucket}`;
178
+ // Using rclone.js Promise-based API for the copy operation
179
+ await rclone.promises.copy(stagingDir, remote, {
180
+ progress: true,
181
+ transfers: 16,
182
+ checkers: 8,
183
+ env,
184
+ });
185
+ logger.info(`Successfully uploaded ${assets.length} assets to R2 using batch upload`);
186
+ success = true;
187
+ }
188
+ finally {
189
+ try {
190
+ // Cleanup temporary staging directory
191
+ rmSync(stagingDir, { recursive: true, force: true });
192
+ }
193
+ catch {
194
+ console.warn(`Failed to remove temporary staging directory at ${stagingDir}`);
195
+ }
196
+ try {
197
+ // Cleanup temporary config file
198
+ rmSync(tempConfigPath);
199
+ }
200
+ catch {
201
+ console.warn(`Failed to remove temporary config at ${tempConfigPath}`);
202
+ }
203
+ }
204
+ if (!success) {
205
+ throw new Error("R2 batch upload failed, falling back to sequential uploads...");
206
+ }
207
+ }
208
+ /**
209
+ * Populate R2 incremental cache using sequential Wrangler uploads
210
+ * Falls back to this method when batch upload is not available or fails
211
+ */
212
+ async function populateR2IncrementalCacheWithSequentialUpload(buildOpts, bucket, prefix, assets, populateCacheOptions) {
213
+ logger.info("Using sequential cache uploads.");
118
214
  for (const { fullPath, key, buildId, isFetch } of tqdm(assets)) {
119
215
  const cacheKey = computeCacheKey(key, {
120
216
  prefix,
@@ -136,6 +232,34 @@ async function populateR2IncrementalCache(buildOpts, config, populateCacheOption
136
232
  }
137
233
  logger.info(`Successfully populated cache with ${assets.length} assets`);
138
234
  }
235
+ async function populateR2IncrementalCache(buildOpts, config, populateCacheOptions, envVars) {
236
+ logger.info("\nPopulating R2 incremental cache...");
237
+ const binding = config.r2_buckets.find(({ binding }) => binding === R2_CACHE_BINDING_NAME);
238
+ if (!binding) {
239
+ throw new Error(`No R2 binding ${JSON.stringify(R2_CACHE_BINDING_NAME)} found!`);
240
+ }
241
+ const bucket = binding.bucket_name;
242
+ if (!bucket) {
243
+ throw new Error(`R2 binding ${JSON.stringify(R2_CACHE_BINDING_NAME)} should have a 'bucket_name'`);
244
+ }
245
+ const prefix = envVars[R2_CACHE_PREFIX_ENV_NAME];
246
+ const assets = getCacheAssets(buildOpts);
247
+ // Force sequential upload for local target
248
+ if (populateCacheOptions.target === "local") {
249
+ logger.info("Using sequential upload for local R2 (batch upload only works with remote R2)");
250
+ return await populateR2IncrementalCacheWithSequentialUpload(buildOpts, bucket, prefix, assets, populateCacheOptions);
251
+ }
252
+ try {
253
+ // Attempt batch upload first (using rclone) - only for remote target
254
+ return await populateR2IncrementalCacheWithBatchUpload(bucket, prefix, assets, envVars);
255
+ }
256
+ catch (error) {
257
+ logger.warn(`Batch upload failed: ${error instanceof Error ? error.message : error}`);
258
+ logger.info("Falling back to sequential uploads...");
259
+ // Sequential upload fallback (using Wrangler)
260
+ return await populateR2IncrementalCacheWithSequentialUpload(buildOpts, bucket, prefix, assets, populateCacheOptions);
261
+ }
262
+ }
139
263
  async function populateKVIncrementalCache(buildOpts, config, populateCacheOptions, envVars) {
140
264
  logger.info("\nPopulating KV incremental cache...");
141
265
  const binding = config.kv_namespaces.find(({ binding }) => binding === KV_CACHE_BINDING_NAME);
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.10.0",
4
+ "version": "1.11.0",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "opennextjs-cloudflare": "dist/cli/index.js"
@@ -43,10 +43,12 @@
43
43
  "homepage": "https://github.com/opennextjs/opennextjs-cloudflare",
44
44
  "dependencies": {
45
45
  "@dotenvx/dotenvx": "1.31.0",
46
- "@opennextjs/aws": "3.8.2",
46
+ "@opennextjs/aws": "3.8.5",
47
+ "@types/rclone.js": "^0.6.3",
47
48
  "cloudflare": "^4.4.1",
48
49
  "enquirer": "^2.4.1",
49
50
  "glob": "^11.0.0",
51
+ "rclone.js": "^0.6.6",
50
52
  "ts-tqdm": "^0.8.6",
51
53
  "yargs": "^18.0.0"
52
54
  },