Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
42 changes: 42 additions & 0 deletions .changeset/rclone-batch-upload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
"@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`
- `R2_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:**

```bash
# Enable batch upload by setting environment variables (recommended for large caches)
export R2_ACCESS_KEY_ID=your_key
export R2_SECRET_ACCESS_KEY=your_secret
export R2_ACCOUNT_ID=your_account
opennextjs-cloudflare deploy # batch upload automatically used
```
19 changes: 19 additions & 0 deletions packages/cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,22 @@ 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:

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

**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("R2_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("R2_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("R2_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("R2_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