Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d8171c5
feat: add --rcloneBatch flag for faster R2 cache uploads using rclone
krzysztof-palka-monogo Oct 3, 2025
5aefc21
fix(test): remove useless test
krzysztof-palka-monogo Oct 3, 2025
c79d18d
test: add tests for populateCache with R2 cache
krzysztof-palka-monogo Oct 3, 2025
d0c5606
commit to trigger pkg-pr-new action
krzysztof-palka-monogo Oct 6, 2025
15a874f
refactor: not expose rclone to user and use batch upload based on env…
krzysztof-palka-monogo Oct 6, 2025
0d53eac
test: update populateCache tests after implementation changes
krzysztof-palka-monogo Oct 6, 2025
3d181e5
fix: enhance error handling in populateCache for rclone uploads
krzysztof-palka-monogo Oct 6, 2025
32dc294
docs: add links for creating R2 access API tokens in README
krzysztof-palka-monogo Oct 6, 2025
a473d6e
fix: update environment variable references from R2_ACCOUNT_ID to CLO…
krzysztof-palka-monogo Oct 7, 2025
fe8f553
feat: add support for loading environment variables from .env file
krzysztof-palka-monogo Oct 7, 2025
bdf3f92
fix: update environment variable references from CLOUDFLARE_ACCOUNT_I…
krzysztof-palka-monogo Oct 7, 2025
de25a0b
fix: refactor R2 cache population to always try batch uploads before …
krzysztof-palka-monogo Oct 7, 2025
4740a48
Merge branch 'main' of github.com:krzysztof-palka-monogo/opennextjs-c…
krzysztof-palka-monogo Oct 13, 2025
c11df41
fix: use temporary directory for staging cache upload
krzysztof-palka-monogo Oct 13, 2025
09a8d9e
fix: use batch cache upload only for remote R2
krzysztof-palka-monogo Oct 13, 2025
4f93b83
fix: update documentation to specify .env file usage
krzysztof-palka-monogo Oct 13, 2025
5b94213
fix: clarify CF_ACCOUNT_ID comment for skew protection and R2 batch p…
krzysztof-palka-monogo Oct 13, 2025
a25b5cd
fix: cleanup envs setup
krzysztof-palka-monogo Oct 13, 2025
a0b1361
Apply suggestion from @vicb
vicb Oct 13, 2025
6f2c916
fix: remove load-envs
krzysztof-palka-monogo Oct 13, 2025
bd2239f
fix: use rclone.js package instead of rclone directly
krzysztof-palka-monogo Oct 14, 2025
2a3ecff
fix: update rclone.js import to use default export
krzysztof-palka-monogo Oct 14, 2025
3ec1364
fix: run linter
krzysztof-palka-monogo Oct 14, 2025
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
55 changes: 55 additions & 0 deletions .changeset/rclone-batch-upload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
"@opennextjs/cloudflare": minor
---

feature: optional batch upload for faster R2 cache population

This update adds optional batch upload support for R2 cache population, significantly improving upload performance for large caches when enabled via environment variables.

**Key Changes:**

1. **Optional Batch Upload**: Configure R2 credentials via environment variables to enable faster batch uploads:

- `R2_ACCESS_KEY_ID`
- `R2_SECRET_ACCESS_KEY`
- `CLOUDFLARE_ACCOUNT_ID`

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

3. **Smart Fallback**: If credentials are not configured, the CLI falls back to standard Wrangler uploads with a helpful message about enabling batch upload for better performance

**All deployment commands support batch upload:**

- `populateCache` - Explicit cache population
- `deploy` - Deploy with cache population
- `upload` - Upload version with cache population
- `preview` - Preview with cache population

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

- Parallel transfer capabilities (32 concurrent transfers)
- Significantly faster for large caches
- Reduced API calls to Cloudflare

**Usage:**

You can set environment variables directly:

```bash
export R2_ACCESS_KEY_ID=your_key
export R2_SECRET_ACCESS_KEY=your_secret
export CLOUDFLARE_ACCOUNT_ID=your_account
opennextjs-cloudflare deploy # batch upload automatically used
```

Or create a `.env` file in your project root (automatically loaded):

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

**Note:**

You can follow documentation https://developers.cloudflare.com/r2/api/tokens/ for creating API tokens with appropriate permissions for R2 access.
33 changes: 33 additions & 0 deletions packages/cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,36 @@ Deploy your application to production with the following:
# or
bun opennextjs-cloudflare build && bun opennextjs-cloudflare deploy
```

### Batch Cache Population (Optional, Recommended)

For improved performance with large caches, you can enable batch upload by providing R2 credentials via environment variables.

You can either set environment variables directly:

```bash
export R2_ACCESS_KEY_ID=your_access_key_id
export R2_SECRET_ACCESS_KEY=your_secret_access_key
export CLOUDFLARE_ACCOUNT_ID=your_account_id
```

Or create a `.env` file in your project root (automatically loaded by the CLI):

```bash
R2_ACCESS_KEY_ID=your_access_key_id
R2_SECRET_ACCESS_KEY=your_secret_access_key
CLOUDFLARE_ACCOUNT_ID=your_account_id
```

**Note:**

You can follow documentation https://developers.cloudflare.com/r2/api/tokens/ for creating API tokens with appropriate permissions for R2 access.

**Benefits:**

- Significantly faster uploads for large caches using parallel transfers
- Reduced API calls to Cloudflare
- Automatically enabled when credentials are provided

**Fallback:**
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.
1 change: 1 addition & 0 deletions packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"cloudflare": "^4.4.1",
"enquirer": "^2.4.1",
"glob": "catalog:",
"rclone.js": "^0.6.6",
"ts-tqdm": "^0.8.6",
"yargs": "catalog:"
},
Expand Down
182 changes: 180 additions & 2 deletions packages/cloudflare/src/cli/commands/populate-cache.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { spawnSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";

import type { BuildOptions } from "@opennextjs/aws/build/helper.js";
import mockFs from "mock-fs";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest";

import { getCacheAssets } from "./populate-cache.js";
import { getCacheAssets, populateCache } from "./populate-cache.js";

describe("getCacheAssets", () => {
beforeAll(() => {
Expand Down Expand Up @@ -68,3 +69,180 @@ describe("getCacheAssets", () => {
`);
});
});

vi.mock("../utils/run-wrangler.js", () => ({
runWrangler: vi.fn(),
}));

vi.mock("./helpers.js", () => ({
getEnvFromPlatformProxy: vi.fn(async () => ({})),
quoteShellMeta: vi.fn((s) => s),
}));

vi.mock("node:child_process", () => ({
spawnSync: vi.fn(() => ({ status: 0, stderr: Buffer.from("") })),
}));

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": {
cache: {
buildID: {
path: {
to: {
"test.cache": JSON.stringify({ data: "test" }),
},
},
},
},
},
});
};

describe("R2 incremental cache", () => {
afterEach(() => {
mockFs.restore();
vi.unstubAllEnvs();
});

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

// Ensure no batch upload credentials are set
vi.stubEnv("R2_ACCESS_KEY_ID", undefined);
vi.stubEnv("R2_SECRET_ACCESS_KEY", undefined);
vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", undefined);

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

// Test uses partial types for simplicity - full config not needed
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()
);

expect(runWrangler).toHaveBeenCalled();
expect(spawnSync).not.toHaveBeenCalled();
});

test("uses batch upload with temporary config when R2 credentials are provided", async () => {
// Set R2 credentials to enable batch upload
vi.stubEnv("R2_ACCESS_KEY_ID", "test_access_key");
vi.stubEnv("R2_SECRET_ACCESS_KEY", "test_secret_key");
vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "test_account_id");

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

// Test uses partial types for simplicity - full config not needed
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()
);

// Verify batch upload was used with correct parameters and temporary config
expect(spawnSync).toHaveBeenCalledWith(
"rclone",
expect.arrayContaining(["copy", expect.any(String), "r2:test-bucket", "--error-on-no-transfer"]),
expect.objectContaining({
stdio: ["inherit", "inherit", "pipe"],
env: expect.objectContaining({
RCLONE_CONFIG: expect.stringMatching(/rclone-config-\d+\.conf$/),
}),
})
);
});

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

// Set R2 credentials
vi.stubEnv("R2_ACCESS_KEY_ID", "test_access_key");
vi.stubEnv("R2_SECRET_ACCESS_KEY", "test_secret_key");
vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "test_account_id");

setupMockFileSystem();

// Mock rclone failure without stderr output
vi.mocked(spawnSync).mockReturnValueOnce({
status: 7, // Fatal error exit code
stderr: "", // No stderr output
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any

vi.mocked(runWrangler).mockClear();

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()
);

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

test("handles rclone errors with status = 0 and stderr output", async () => {
const { runWrangler } = await import("../utils/run-wrangler.js");

// Set R2 credentials
vi.stubEnv("R2_ACCESS_KEY_ID", "test_access_key");
vi.stubEnv("R2_SECRET_ACCESS_KEY", "test_secret_key");
vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "test_account_id");

setupMockFileSystem();

// Mock rclone error in stderr
vi.mocked(spawnSync).mockReturnValueOnce({
status: 0, // non-error exit code
stderr: Buffer.from("ERROR : Failed to copy: AccessDenied: Access Denied (403)"),
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any

vi.mocked(runWrangler).mockClear();

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()
);

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