Skip to content

Commit d8171c5

Browse files
feat: add --rcloneBatch flag for faster R2 cache uploads using rclone
1 parent 5783285 commit d8171c5

File tree

9 files changed

+202
-10
lines changed

9 files changed

+202
-10
lines changed

.changeset/rclone-batch-upload.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@opennextjs/cloudflare": minor
3+
---
4+
5+
feature: add --rcloneBatch flag for faster R2 cache uploads
6+
7+
This adds a new optional `--rcloneBatch` flag that enables batch uploading to R2 using rclone instead of individual wrangler uploads. This significantly improves upload performance for large caches.
8+
9+
**Supported commands:**
10+
11+
- `populateCache` - Explicit cache population
12+
- `deploy` - Deploy with cache population
13+
- `upload` - Upload version with cache population
14+
- `preview` - Preview with cache population
15+
16+
**Performance improvements:**
17+
18+
- Creates staging directory with all cache files organized by R2 keys
19+
- Uses rclone's parallel transfer capabilities (32 transfers, 16 checkers)
20+
- Reduces API calls to Cloudflare
21+
22+
**Usage:**
23+
24+
```bash
25+
opennextjs-cloudflare deploy --rcloneBatch
26+
opennextjs-cloudflare populateCache remote --rcloneBatch
27+
```
28+
29+
**Requirements:**
30+
31+
- The `rclone.js` package (included as dependency) provides the binary
32+
- An rclone config file at `~/.config/rclone/rclone.conf` with R2 credentials (see README for setup instructions)
33+
34+
The original wrangler-based upload remains the default behavior for backward compatibility.

packages/cloudflare/README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,54 @@ Deploy your application to production with the following:
5555
# or
5656
bun opennextjs-cloudflare build && bun opennextjs-cloudflare deploy
5757
```
58+
59+
## CLI Options
60+
61+
### Batch Cache Population (rclone)
62+
63+
The `--rcloneBatch` flag enables faster R2 cache uploads using rclone batch mode. This flag is supported by the following commands:
64+
65+
- `populateCache` - Explicitly populate cache
66+
- `deploy` - Deploy and populate cache
67+
- `upload` - Upload version and populate cache
68+
- `preview` - Preview and populate cache
69+
70+
**Usage:**
71+
72+
```bash
73+
# Standalone cache population
74+
npx opennextjs-cloudflare populateCache local --rcloneBatch
75+
npx opennextjs-cloudflare populateCache remote --rcloneBatch
76+
77+
# During deployment
78+
npx opennextjs-cloudflare deploy --rcloneBatch
79+
80+
# During upload
81+
npx opennextjs-cloudflare upload --rcloneBatch
82+
83+
# During preview
84+
npx opennextjs-cloudflare preview --rcloneBatch
85+
```
86+
87+
**Requirements:**
88+
89+
1. The `rclone.js` package (included as a dependency) provides the rclone binary automatically
90+
2. An rclone configuration file is required at `~/.config/rclone/rclone.conf` with your R2 credentials
91+
92+
**rclone Configuration:**
93+
94+
Create or update `~/.config/rclone/rclone.conf` with your R2 bucket configuration:
95+
96+
```ini
97+
[r2]
98+
type = s3
99+
provider = Cloudflare
100+
access_key_id = YOUR_ACCESS_KEY_ID
101+
secret_access_key = YOUR_SECRET_ACCESS_KEY
102+
endpoint = https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com
103+
acl = private
104+
```
105+
106+
See [Cloudflare's rclone documentation](https://developers.cloudflare.com/r2/examples/rclone/) for more details.
107+
108+
**Default:** `false` (uses standard wrangler-based uploads)

packages/cloudflare/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"cloudflare": "^4.4.1",
5858
"enquirer": "^2.4.1",
5959
"glob": "catalog:",
60+
"rclone.js": "^0.6.6",
6061
"ts-tqdm": "^0.8.6",
6162
"yargs": "catalog:"
6263
},

packages/cloudflare/src/cli/commands/deploy.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ import {
1919
*
2020
* @param args
2121
*/
22-
export async function deployCommand(args: WithWranglerArgs<{ cacheChunkSize?: number }>): Promise<void> {
22+
export async function deployCommand(
23+
args: WithWranglerArgs<{ cacheChunkSize?: number; rcloneBatch?: boolean }>
24+
): Promise<void> {
2325
printHeaders("deploy");
2426

2527
const { config } = await retrieveCompiledConfig();
@@ -40,6 +42,7 @@ export async function deployCommand(args: WithWranglerArgs<{ cacheChunkSize?: nu
4042
wranglerConfigPath: args.wranglerConfigPath,
4143
cacheChunkSize: args.cacheChunkSize,
4244
shouldUsePreviewId: false,
45+
rcloneBatch: args.rcloneBatch,
4346
});
4447

4548
runWrangler(

packages/cloudflare/src/cli/commands/populate-cache.spec.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { BuildOptions } from "@opennextjs/aws/build/helper.js";
55
import mockFs from "mock-fs";
66
import { afterAll, beforeAll, describe, expect, test } from "vitest";
77

8-
import { getCacheAssets } from "./populate-cache.js";
8+
import { getCacheAssets, withPopulateCacheOptions } from "./populate-cache.js";
99

1010
describe("getCacheAssets", () => {
1111
beforeAll(() => {
@@ -68,3 +68,23 @@ describe("getCacheAssets", () => {
6868
`);
6969
});
7070
});
71+
72+
describe("withPopulateCacheOptions", () => {
73+
test("includes rcloneBatch option with correct defaults", () => {
74+
interface MockYargs {
75+
options: (name: string, config: Record<string, unknown>) => MockYargs;
76+
}
77+
78+
const mockYargs: MockYargs = {
79+
options: (name: string, config: Record<string, unknown>) => {
80+
expect(name).toBeDefined();
81+
expect(config).toBeDefined();
82+
return mockYargs;
83+
},
84+
};
85+
86+
const result = withPopulateCacheOptions(mockYargs as never);
87+
88+
expect(result).toBe(mockYargs);
89+
});
90+
});

packages/cloudflare/src/cli/commands/populate-cache.ts

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { cpSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1+
import { spawnSync } from "node:child_process";
2+
import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
23
import path from "node:path";
34

45
import type { BuildOptions } from "@opennextjs/aws/build/helper.js";
@@ -127,8 +128,54 @@ type PopulateCacheOptions = {
127128
* Instructs Wrangler to use the preview namespace or ID defined in the Wrangler config for the remote target.
128129
*/
129130
shouldUsePreviewId: boolean;
131+
/**
132+
* Use rclone for batch uploading to R2 instead of individual wrangler uploads.
133+
*
134+
* @default false
135+
*/
136+
rcloneBatch?: boolean;
130137
};
131138

139+
async function populateR2IncrementalCacheWithRclone(
140+
options: BuildOptions,
141+
bucket: string,
142+
prefix: string | undefined,
143+
assets: CacheAsset[]
144+
) {
145+
logger.info("\nPopulating R2 incremental cache using rclone batch upload...");
146+
147+
// Create a staging dir with proper key paths
148+
const stagingDir = path.join(options.outputDir, ".r2-staging");
149+
rmSync(stagingDir, { recursive: true, force: true });
150+
mkdirSync(stagingDir, { recursive: true });
151+
152+
for (const { fullPath, key, buildId, isFetch } of assets) {
153+
const cacheKey = computeCacheKey(key, {
154+
prefix,
155+
buildId,
156+
cacheType: isFetch ? "fetch" : "cache",
157+
});
158+
const destPath = path.join(stagingDir, cacheKey);
159+
mkdirSync(path.dirname(destPath), { recursive: true });
160+
copyFileSync(fullPath, destPath);
161+
}
162+
163+
// Use rclone to sync the R2
164+
const remote = `r2:${bucket}`;
165+
const args = ["copy", stagingDir, remote, "--progress", "--transfers=32", "--checkers=16"];
166+
167+
const result = spawnSync("rclone", args, {
168+
stdio: "inherit",
169+
env: process.env,
170+
});
171+
172+
if (result.status !== 0) {
173+
throw new Error("rclone copy failed");
174+
}
175+
176+
logger.info(`Successfully uploaded ${assets.length} assets to R2`);
177+
}
178+
132179
async function populateR2IncrementalCache(
133180
options: BuildOptions,
134181
config: WranglerConfig,
@@ -151,6 +198,10 @@ async function populateR2IncrementalCache(
151198

152199
const assets = getCacheAssets(options);
153200

201+
if (populateCacheOptions.rcloneBatch) {
202+
return populateR2IncrementalCacheWithRclone(options, bucket, prefix, assets);
203+
}
204+
154205
for (const { fullPath, key, buildId, isFetch } of tqdm(assets)) {
155206
const cacheKey = computeCacheKey(key, {
156207
prefix,
@@ -329,7 +380,7 @@ export async function populateCache(
329380
*/
330381
async function populateCacheCommand(
331382
target: "local" | "remote",
332-
args: WithWranglerArgs<{ cacheChunkSize?: number }>
383+
args: WithWranglerArgs<{ cacheChunkSize?: number; rcloneBatch?: boolean }>
333384
) {
334385
printHeaders(`populate cache - ${target}`);
335386

@@ -344,6 +395,7 @@ async function populateCacheCommand(
344395
wranglerConfigPath: args.wranglerConfigPath,
345396
cacheChunkSize: args.cacheChunkSize,
346397
shouldUsePreviewId: false,
398+
rcloneBatch: args.rcloneBatch,
347399
});
348400
}
349401

@@ -372,8 +424,14 @@ export function addPopulateCacheCommand<T extends yargs.Argv>(y: T) {
372424
}
373425

374426
export function withPopulateCacheOptions<T extends yargs.Argv>(args: T) {
375-
return withWranglerOptions(args).options("cacheChunkSize", {
376-
type: "number",
377-
desc: "Number of entries per chunk when populating the cache",
378-
});
427+
return withWranglerOptions(args)
428+
.options("cacheChunkSize", {
429+
type: "number",
430+
desc: "Number of entries per chunk when populating the cache",
431+
})
432+
.options("rcloneBatch", {
433+
type: "boolean",
434+
desc: "Use rclone for batch uploading to R2 (faster for large caches)",
435+
default: false,
436+
});
379437
}

packages/cloudflare/src/cli/commands/preview.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
* @param args
1818
*/
1919
export async function previewCommand(
20-
args: WithWranglerArgs<{ cacheChunkSize?: number; remote: boolean }>
20+
args: WithWranglerArgs<{ cacheChunkSize?: number; rcloneBatch?: boolean; remote: boolean }>
2121
): Promise<void> {
2222
printHeaders("preview");
2323

@@ -32,6 +32,7 @@ export async function previewCommand(
3232
wranglerConfigPath: args.wranglerConfigPath,
3333
cacheChunkSize: args.cacheChunkSize,
3434
shouldUsePreviewId: args.remote,
35+
rcloneBatch: args.rcloneBatch,
3536
});
3637

3738
runWrangler(options, ["dev", ...args.wranglerArgs], { logging: "all" });

packages/cloudflare/src/cli/commands/upload.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ import {
1919
*
2020
* @param args
2121
*/
22-
export async function uploadCommand(args: WithWranglerArgs<{ cacheChunkSize?: number }>): Promise<void> {
22+
export async function uploadCommand(
23+
args: WithWranglerArgs<{ cacheChunkSize?: number; rcloneBatch?: boolean }>
24+
): Promise<void> {
2325
printHeaders("upload");
2426

2527
const { config } = await retrieveCompiledConfig();
@@ -40,6 +42,7 @@ export async function uploadCommand(args: WithWranglerArgs<{ cacheChunkSize?: nu
4042
wranglerConfigPath: args.wranglerConfigPath,
4143
cacheChunkSize: args.cacheChunkSize,
4244
shouldUsePreviewId: false,
45+
rcloneBatch: args.rcloneBatch,
4346
});
4447

4548
runWrangler(

pnpm-lock.yaml

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)