Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/recovered-rclone-batch-upload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
"@opennextjs/cloudflare": minor
---

feature: optional batch upload via rclone for fast R2 cache population that bypasses Account Level Rate Limits
Comment thread
grabmateusz marked this conversation as resolved.
Outdated

This update recovers optional opt in for batch upload support for R2 cache population via rclone, which bypasses Account Level Rate Limits.
Comment thread
grabmateusz marked this conversation as resolved.
Outdated

**Key Changes:**

1. **Optional Batch Upload**: Configure R2 credentials via .env or environment variables to opt in to rclone based batch uploads:
Comment thread
grabmateusz marked this conversation as resolved.
Outdated

- `R2_ACCESS_KEY_ID`
- `R2_SECRET_ACCESS_KEY`
- `CF_ACCOUNT_ID`

2. **Automatic Detection**: When credentials are detected, batch upload is automatically used

3. **Smart Fallback**: If credentials are not configured, the CLI falls back to standard Wrangler uploads with a helpful message about enabling batch upload to bypass Account Level Rate Limits

**Benefits (when batch upload is enabled):**

- Parallel transfer capabilities (32 concurrent transfers)
- Reduced API calls to Cloudflare
- Bypassing Account Level Rate Limits

**Usage:**

Add the credentials in a `.env`/`.dev.vars` file in your project root:

```bash
R2_ACCESS_KEY_ID=your_key
R2_SECRET_ACCESS_KEY=your_secret
CF_ACCOUNT_ID=your_account
```

You can also set the environment variables for CI builds.

**Note:**

You can follow documentation https://developers.cloudflare.com/r2/api/tokens/ for creating API tokens with appropriate permissions for R2 access.
2 changes: 2 additions & 0 deletions packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"cloudflare": "^4.4.1",
"enquirer": "^2.4.1",
"glob": "catalog:",
"rclone.js": "^0.6.6",
"ts-tqdm": "^0.8.6",
"yargs": "catalog:"
},
Expand All @@ -68,6 +69,7 @@
"@types/mock-fs": "catalog:",
"@types/node": "catalog:",
"@types/picomatch": "^4.0.0",
"@types/rclone.js": "^0.6.1",
"@types/yargs": "catalog:",
"diff": "^8.0.2",
"esbuild": "catalog:",
Expand Down
7 changes: 6 additions & 1 deletion packages/cloudflare/src/api/cloudflare-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,13 @@ declare global {
CF_PREVIEW_DOMAIN?: string;
// Should have the `Workers Scripts:Read` permission
CF_WORKERS_SCRIPTS_API_TOKEN?: string;
// Cloudflare account id - needed for skew protection

// Cloudflare account id - needed for skew protection and R2 batch population
CF_ACCOUNT_ID?: string;

// R2 API credentials for batch cache population (optional, enables faster uploads)
Comment thread
grabmateusz marked this conversation as resolved.
Outdated
R2_ACCESS_KEY_ID?: string;
R2_SECRET_ACCESS_KEY?: string;
}
}

Expand Down
267 changes: 186 additions & 81 deletions packages/cloudflare/src/cli/commands/populate-cache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,44 @@ vi.mock("./helpers.js", () => ({
quoteShellMeta: vi.fn((s) => s),
}));

// Mock rclone.js promises API to simulate successful copy operations by default
vi.mock("rclone.js", () => ({
default: {
promises: {
copy: vi.fn(() => Promise.resolve("")),
},
},
}));

describe("populateCache", () => {
// Test fixtures
const createTestBuildOptions = (): BuildOptions =>
({
outputDir: "/test/output",
}) as BuildOptions;

const createTestOpenNextConfig = () => ({
default: {
override: {
incrementalCache: "cf-r2-incremental-cache",
},
},
});

const createTestWranglerConfig = () => ({
r2_buckets: [
{
binding: "NEXT_INC_CACHE_R2_BUCKET",
bucket_name: "test-bucket",
},
],
});

const createTestPopulateCacheOptions = () => ({
target: "local" as const,
shouldUsePreviewId: false,
});

const setupMockFileSystem = () => {
mockFs({
"/test/output": {
Expand All @@ -95,85 +132,153 @@ describe("populateCache", () => {
});
};

describe.each([{ target: "local" as const }, { target: "remote" as const }])(
"R2 incremental cache",
({ target }) => {
afterEach(() => {
mockFs.restore();
});

test(target, async () => {
const { runWrangler } = await import("../utils/run-wrangler.js");

setupMockFileSystem();
vi.mocked(runWrangler).mockClear();

await populateCache(
{
outputDir: "/test/output",
} as BuildOptions,
{
default: {
override: {
incrementalCache: "cf-r2-incremental-cache",
},
},
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
{
r2_buckets: [
{
binding: "NEXT_INC_CACHE_R2_BUCKET",
bucket_name: "test-bucket",
},
],
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
{ target, shouldUsePreviewId: false },
{} as any // eslint-disable-line @typescript-eslint/no-explicit-any
);

expect(runWrangler).toHaveBeenCalledWith(
expect.anything(),
expect.arrayContaining(["r2 bulk put", "test-bucket"]),
expect.objectContaining({ target })
);
});

test(`${target} using jurisdiction`, async () => {
const { runWrangler } = await import("../utils/run-wrangler.js");

setupMockFileSystem();
vi.mocked(runWrangler).mockClear();

await populateCache(
{
outputDir: "/test/output",
} as BuildOptions,
{
default: {
override: {
incrementalCache: "cf-r2-incremental-cache",
},
},
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
{
r2_buckets: [
{
binding: "NEXT_INC_CACHE_R2_BUCKET",
bucket_name: "test-bucket",
jurisdiction: "eu",
},
],
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
{ target, shouldUsePreviewId: false },
{} as any // eslint-disable-line @typescript-eslint/no-explicit-any
);

expect(runWrangler).toHaveBeenCalledWith(
expect.anything(),
expect.arrayContaining(["r2 bulk put", "test-bucket", "--jurisdiction eu"]),
expect.objectContaining({ target })
);
});
}
);
describe("R2 incremental cache", () => {
afterEach(() => {
mockFs.restore();
vi.unstubAllEnvs();
});

test("uses sequential upload for local target (skips batch upload)", async () => {
const { runWrangler } = await import("../utils/run-wrangler.js");
const rcloneModule = (await import("rclone.js")).default;

setupMockFileSystem();
vi.mocked(runWrangler).mockClear();
vi.mocked(rcloneModule.promises.copy).mockClear();

// Test with local target - should skip batch upload even with credentials
await populateCache(
createTestBuildOptions(),
createTestOpenNextConfig() as any, // eslint-disable-line @typescript-eslint/no-explicit-any
createTestWranglerConfig() as any, // eslint-disable-line @typescript-eslint/no-explicit-any
{ target: "local" as const, shouldUsePreviewId: false },
{
R2_ACCESS_KEY_ID: "test_access_key",
R2_SECRET_ACCESS_KEY: "test_secret_key",
CF_ACCOUNT_ID: "test_account_id",
} as any // eslint-disable-line @typescript-eslint/no-explicit-any
);

// Should use sequential upload (runWrangler), not batch upload (rclone.js)
expect(runWrangler).toHaveBeenCalled();
expect(rcloneModule.promises.copy).not.toHaveBeenCalled();
});

test("uses sequential upload when R2 credentials are not provided", async () => {
const { runWrangler } = await import("../utils/run-wrangler.js");
const rcloneModule = (await import("rclone.js")).default;

setupMockFileSystem();
vi.mocked(runWrangler).mockClear();
vi.mocked(rcloneModule.promises.copy).mockClear();

// Test uses partial types for simplicity - full config not needed
// Pass empty envVars to simulate no R2 credentials
await populateCache(
createTestBuildOptions(),
createTestOpenNextConfig() as any, // eslint-disable-line @typescript-eslint/no-explicit-any
createTestWranglerConfig() as any, // eslint-disable-line @typescript-eslint/no-explicit-any
createTestPopulateCacheOptions(),
{} as any // eslint-disable-line @typescript-eslint/no-explicit-any
);

expect(runWrangler).toHaveBeenCalled();
expect(rcloneModule.promises.copy).not.toHaveBeenCalled();
});

test("uses batch upload with temporary config for remote target when R2 credentials are provided", async () => {
const rcloneModule = (await import("rclone.js")).default;

setupMockFileSystem();
vi.mocked(rcloneModule.promises.copy).mockClear();

// Test uses partial types for simplicity - full config not needed
// Pass envVars with R2 credentials and remote target to enable batch upload
await populateCache(
createTestBuildOptions(),
createTestOpenNextConfig() as any, // eslint-disable-line @typescript-eslint/no-explicit-any
createTestWranglerConfig() as any, // eslint-disable-line @typescript-eslint/no-explicit-any
{ target: "remote" as const, shouldUsePreviewId: false },
{
R2_ACCESS_KEY_ID: "test_access_key",
R2_SECRET_ACCESS_KEY: "test_secret_key",
CF_ACCOUNT_ID: "test_account_id",
} as any // eslint-disable-line @typescript-eslint/no-explicit-any
);

// Verify batch upload was used with correct parameters and temporary config
expect(rcloneModule.promises.copy).toHaveBeenCalledWith(
expect.any(String), // staging directory
"r2:test-bucket",
expect.objectContaining({
progress: true,
transfers: 16,
checkers: 8,
env: expect.objectContaining({
RCLONE_CONFIG: expect.stringMatching(/rclone-config-\d+\.conf$/),
}),
})
);
});

test("handles rclone errors with status > 0 for remote target", async () => {
const { runWrangler } = await import("../utils/run-wrangler.js");
const rcloneModule = (await import("rclone.js")).default;

setupMockFileSystem();

// Mock rclone failure - Promise rejection
vi.mocked(rcloneModule.promises.copy).mockRejectedValueOnce(
new Error("rclone copy failed with exit code 7")
);

vi.mocked(runWrangler).mockClear();

// Pass envVars with R2 credentials and remote target to enable batch upload (which will fail)
await populateCache(
createTestBuildOptions(),
createTestOpenNextConfig() as any, // eslint-disable-line @typescript-eslint/no-explicit-any
createTestWranglerConfig() as any, // eslint-disable-line @typescript-eslint/no-explicit-any
{ target: "remote" as const, shouldUsePreviewId: false },
{
R2_ACCESS_KEY_ID: "test_access_key",
R2_SECRET_ACCESS_KEY: "test_secret_key",
CF_ACCOUNT_ID: "test_account_id",
} as any // eslint-disable-line @typescript-eslint/no-explicit-any
);

// Should fall back to sequential upload when batch upload fails
expect(runWrangler).toHaveBeenCalled();
});

test("handles rclone errors with stderr output for remote target", async () => {
const { runWrangler } = await import("../utils/run-wrangler.js");
const rcloneModule = (await import("rclone.js")).default;

setupMockFileSystem();

// Mock rclone error - Promise rejection with stderr message
vi.mocked(rcloneModule.promises.copy).mockRejectedValueOnce(
new Error("ERROR : Failed to copy: AccessDenied: Access Denied (403)")
);

vi.mocked(runWrangler).mockClear();

// Pass envVars with R2 credentials and remote target to enable batch upload (which will fail)
await populateCache(
createTestBuildOptions(),
createTestOpenNextConfig() as any, // eslint-disable-line @typescript-eslint/no-explicit-any
createTestWranglerConfig() as any, // eslint-disable-line @typescript-eslint/no-explicit-any
{ target: "remote" as const, shouldUsePreviewId: false },
{
R2_ACCESS_KEY_ID: "test_access_key",
R2_SECRET_ACCESS_KEY: "test_secret_key",
CF_ACCOUNT_ID: "test_account_id",
} as any // eslint-disable-line @typescript-eslint/no-explicit-any
);

// Should fall back to standard upload when batch upload fails
expect(runWrangler).toHaveBeenCalled();
});
});
});
Loading