Skip to content

Commit b1085e0

Browse files
feat: use remote dev for r2 cache population
Use Wrangler remote dev for remote R2 cache population, ensure the target bucket exists before uploading cache entries, and isolate the helper worker from project config discovery. Co-authored-by: Victor Berchet <victor@suumit.com> Co-authored-by: Isaac Rowntree <isaac@rowntree.me>
1 parent bf33735 commit b1085e0

11 files changed

Lines changed: 983 additions & 346 deletions

File tree

.changeset/fix-r2-cache-upload.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@opennextjs/cloudflare": minor
3+
---
4+
5+
Use remote dev for R2 cache population
6+
7+
Using remote dev is not subject the Cloudflare API rate limit of 1,200 requests per 5 minutes that caused failures for large applications with thousands of prerendered pages.

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

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,19 +30,24 @@ export async function deployCommand(args: WithWranglerArgs<{ cacheChunkSize?: nu
3030

3131
const envVars = await getEnvFromPlatformProxy(config, buildOpts);
3232

33-
await populateCache(
34-
buildOpts,
35-
config,
36-
wranglerConfig,
37-
{
38-
target: "remote",
39-
environment: args.env,
40-
wranglerConfigPath: args.wranglerConfigPath,
41-
cacheChunkSize: args.cacheChunkSize,
42-
shouldUsePreviewId: false,
43-
},
44-
envVars
45-
);
33+
try {
34+
await populateCache(
35+
buildOpts,
36+
config,
37+
wranglerConfig,
38+
{
39+
target: "remote",
40+
environment: args.env,
41+
wranglerConfigPath: args.wranglerConfigPath,
42+
cacheChunkSize: args.cacheChunkSize,
43+
shouldUsePreviewId: false,
44+
},
45+
envVars
46+
);
47+
} catch (error) {
48+
logger.error(error instanceof Error ? error.message : String(error));
49+
process.exit(1);
50+
}
4651

4752
const deploymentMapping = await getDeploymentMapping(buildOpts, config, envVars);
4853

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

Lines changed: 126 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ import { mkdirSync, writeFileSync } from "node:fs";
22
import path from "node:path";
33

44
import type { BuildOptions } from "@opennextjs/aws/build/helper.js";
5+
import { OpenNextConfig } from "@opennextjs/aws/types/open-next.js";
56
import mockFs from "mock-fs";
67
import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest";
8+
import type { Unstable_Config as WranglerConfig } from "wrangler";
9+
import { unstable_startWorker } from "wrangler";
710

8-
import { getCacheAssets, populateCache } from "./populate-cache.js";
11+
import { ensureR2Bucket } from "../utils/ensure-r2-bucket.js";
12+
import { getCacheAssets, populateCache, PopulateCacheOptions } from "./populate-cache.js";
13+
import { WorkerEnvVar } from "./utils/helpers.js";
914

1015
describe("getCacheAssets", () => {
1116
beforeAll(() => {
@@ -78,7 +83,37 @@ vi.mock("./utils/helpers.js", () => ({
7883
quoteShellMeta: vi.fn((s) => s),
7984
}));
8085

86+
vi.mock("../utils/ensure-r2-bucket.js");
87+
vi.mock("wrangler");
88+
8189
describe("populateCache", () => {
90+
// @ts-expect-error - Partial mock of OpenNextConfig for testing
91+
const buildOptions: BuildOptions = {
92+
appPath: "/test/app",
93+
outputDir: "/test/output",
94+
};
95+
const config: OpenNextConfig = {
96+
default: {
97+
override: {
98+
// @ts-expect-error - Use R2 incremental cache
99+
incrementalCache: "cf-r2-incremental-cache",
100+
},
101+
},
102+
};
103+
// @ts-expect-error - Partial mock of WranglerConfig for testing
104+
const wranglerConfig: WranglerConfig = {
105+
r2_buckets: [
106+
{
107+
binding: "NEXT_INC_CACHE_R2_BUCKET",
108+
bucket_name: "test-bucket",
109+
preview_bucket_name: "preview-bucket",
110+
jurisdiction: "eu",
111+
},
112+
],
113+
};
114+
// @ts-expect-error - Use partial WorkerEnvVar for testing
115+
const envVars: WorkerEnvVar = {};
116+
82117
const setupMockFileSystem = () => {
83118
mockFs({
84119
"/test/output": {
@@ -95,85 +130,103 @@ describe("populateCache", () => {
95130
});
96131
};
97132

98-
describe.each([{ target: "local" as const }, { target: "remote" as const }])(
99-
"R2 incremental cache",
100-
({ target }) => {
101-
afterEach(() => {
102-
mockFs.restore();
103-
});
133+
describe("R2 incremental cache", () => {
134+
afterEach(() => {
135+
vi.resetAllMocks();
136+
mockFs.restore();
137+
});
104138

105-
test(target, async () => {
106-
const { runWrangler } = await import("./utils/run-wrangler.js");
139+
test.each<PopulateCacheOptions>([
140+
{ target: "local", shouldUsePreviewId: false },
141+
{ target: "remote", shouldUsePreviewId: false },
142+
{ target: "remote", shouldUsePreviewId: true },
143+
])(
144+
`$target (shouldUsePreviewId: $shouldUsePreviewId) - starts worker and sends individual cache entries via FormData`,
145+
async (populateCacheOptions) => {
146+
const bucketName =
147+
populateCacheOptions.target === "remote" && populateCacheOptions.shouldUsePreviewId
148+
? "preview-bucket"
149+
: "test-bucket";
150+
const mockWorkerDispose = vi.fn();
107151

108152
setupMockFileSystem();
109-
vi.mocked(runWrangler).mockClear();
110-
111-
await populateCache(
112-
{
113-
outputDir: "/test/output",
114-
} as BuildOptions,
115-
{
116-
default: {
117-
override: {
118-
incrementalCache: "cf-r2-incremental-cache",
119-
},
120-
},
121-
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
122-
{
123-
r2_buckets: [
124-
{
125-
binding: "NEXT_INC_CACHE_R2_BUCKET",
126-
bucket_name: "test-bucket",
127-
},
128-
],
129-
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
130-
{ target, shouldUsePreviewId: false },
131-
{} as any // eslint-disable-line @typescript-eslint/no-explicit-any
153+
// @ts-expect-error - Mock unstable_startWorker to return a mock worker instance
154+
vi.mocked(unstable_startWorker).mockResolvedValueOnce({
155+
ready: Promise.resolve(),
156+
url: Promise.resolve(new URL("http://localhost:12345")),
157+
dispose: mockWorkerDispose,
158+
});
159+
vi.mocked(ensureR2Bucket).mockResolvedValueOnce({ success: true, bucketName });
160+
161+
// Mock fetch to return a successful response for each individual entry.
162+
const fetchMock = vi.spyOn(global, "fetch").mockResolvedValue(
163+
new Response(JSON.stringify({ success: true }), {
164+
status: 200,
165+
headers: { "Content-Type": "application/json" },
166+
})
132167
);
133168

134-
expect(runWrangler).toHaveBeenCalledWith(
135-
expect.anything(),
136-
expect.arrayContaining(["r2 bulk put", "test-bucket"]),
137-
expect.objectContaining({ target })
138-
);
139-
});
140-
141-
test(`${target} using jurisdiction`, async () => {
142-
const { runWrangler } = await import("./utils/run-wrangler.js");
169+
await populateCache(buildOptions, config, wranglerConfig, populateCacheOptions, envVars);
143170

144-
setupMockFileSystem();
145-
vi.mocked(runWrangler).mockClear();
146-
147-
await populateCache(
148-
{
149-
outputDir: "/test/output",
150-
} as BuildOptions,
151-
{
152-
default: {
153-
override: {
154-
incrementalCache: "cf-r2-incremental-cache",
155-
},
156-
},
157-
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
158-
{
159-
r2_buckets: [
160-
{
161-
binding: "NEXT_INC_CACHE_R2_BUCKET",
162-
bucket_name: "test-bucket",
171+
expect(unstable_startWorker).toHaveBeenCalledWith(
172+
expect.objectContaining({
173+
bindings: expect.objectContaining({
174+
R2: expect.objectContaining({
175+
type: "r2_bucket",
176+
bucket_name: bucketName,
163177
jurisdiction: "eu",
164-
},
165-
],
166-
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
167-
{ target, shouldUsePreviewId: false },
168-
{} as any // eslint-disable-line @typescript-eslint/no-explicit-any
178+
}),
179+
}),
180+
dev: expect.objectContaining({
181+
remote: populateCacheOptions.target === "remote",
182+
}),
183+
})
169184
);
170185

171-
expect(runWrangler).toHaveBeenCalledWith(
172-
expect.anything(),
173-
expect.arrayContaining(["r2 bulk put", "test-bucket", "--jurisdiction eu"]),
174-
expect.objectContaining({ target })
175-
);
176-
});
177-
}
178-
);
186+
if (populateCacheOptions.target === "remote") {
187+
expect(ensureR2Bucket).toHaveBeenCalledWith("/test/app", bucketName, "eu");
188+
} else {
189+
expect(ensureR2Bucket).not.toHaveBeenCalled();
190+
}
191+
192+
expect(fetchMock).toBeCalled();
193+
194+
for (const [input, init] of fetchMock.mock.calls) {
195+
expect(input).toBe("http://localhost:12345/populate");
196+
expect(init?.method).toBe("POST");
197+
198+
const formData = init?.body;
199+
if (formData instanceof FormData) {
200+
// Verify the body is FormData containing key and value fields.
201+
expect(formData.get("key")).toBeTypeOf("string");
202+
expect(formData.get("value")).toBeTypeOf("string");
203+
} else {
204+
expect.unreachable("Expected request body to be FormData");
205+
}
206+
}
207+
208+
// Verify worker was disposed after sending entries.
209+
expect(mockWorkerDispose).toHaveBeenCalled();
210+
}
211+
);
212+
213+
test("remote - exits when bucket provisioning fails", async () => {
214+
setupMockFileSystem();
215+
vi.mocked(ensureR2Bucket).mockResolvedValueOnce({ success: false });
216+
217+
const result = populateCache(
218+
buildOptions,
219+
config,
220+
wranglerConfig,
221+
{ target: "remote", shouldUsePreviewId: false },
222+
envVars
223+
);
224+
225+
await expect(result).rejects.toThrow(
226+
'Failed to provision remote R2 bucket "test-bucket" for binding "NEXT_INC_CACHE_R2_BUCKET".'
227+
);
228+
229+
expect(unstable_startWorker).not.toHaveBeenCalled();
230+
});
231+
});
179232
});

0 commit comments

Comments
 (0)