Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@opennextjs/cloudflare": patch
---

fix: throw a user-actionable error when `initOpenNextCloudflareForDev` is called more than once in the same process

Calling `initOpenNextCloudflareForDev` twice in the Next.js config file used to spawn two competing miniflare instances and fail with an obscure workerd `SQLITE_BUSY` error. The function now tracks its own invocation and throws an error that points the user at the config file the moment the second call happens, instead of letting the failure surface deep inside the Workers runtime.

Closes #1251
67 changes: 67 additions & 0 deletions packages/cloudflare/src/api/cloudflare-context.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import type { initOpenNextCloudflareForDev as InitOpenNextCloudflareForDev } from "./cloudflare-context.js";

/**
* Regression test for https://github.com/opennextjs/opennextjs-cloudflare/issues/1251.
*
* Calling `initOpenNextCloudflareForDev` more than once in the same Node.js process previously
* surfaced as an obscure `SQLITE_BUSY` workerd error because two miniflare instances raced for the
* same on-disk state. The function now tracks its own invocation and throws a user-actionable
* error on the second call.
*/
describe("initOpenNextCloudflareForDev duplicate-call guard", () => {
let initOpenNextCloudflareForDev: typeof InitOpenNextCloudflareForDev;

beforeEach(async () => {
// Re-import fresh per test so the module-level "has been called" flag resets between cases.
vi.resetModules();

// AsyncLocalStorage must be absent on globalThis so `shouldContextInitializationRun` short-
// circuits before any wrangler / miniflare setup tries to run, isolating the duplicate-call
// guard from the rest of the initialization path.
vi.stubGlobal("AsyncLocalStorage", undefined);

({ initOpenNextCloudflareForDev } = await import("./cloudflare-context.js"));
});

afterEach(() => {
vi.unstubAllGlobals();
});

it("resolves on the first call", async () => {
await expect(initOpenNextCloudflareForDev()).resolves.toBeUndefined();
});

it("throws a user-actionable error on the second call in the same process", async () => {
await initOpenNextCloudflareForDev();
await expect(initOpenNextCloudflareForDev()).rejects.toThrow(
/`initOpenNextCloudflareForDev` was called more than once in the same process/
);
});

it("mentions the SQLITE_BUSY workerd symptom and points the user at the config file", async () => {
await initOpenNextCloudflareForDev();
await expect(initOpenNextCloudflareForDev()).rejects.toThrowError(
expect.objectContaining({
message: expect.stringMatching(/Next\.js config file/),
})
);
await expect(initOpenNextCloudflareForDev()).rejects.toThrowError(
expect.objectContaining({
message: expect.stringMatching(/SQLITE_BUSY/),
})
);
});

it("treats each fresh module load as its own process for the purpose of the guard", async () => {
await initOpenNextCloudflareForDev();
await expect(initOpenNextCloudflareForDev()).rejects.toThrow();

// Simulating a new process boundary: re-import the module and call once - it should resolve
// because the module-level flag is fresh.
vi.resetModules();
const fresh = await import("./cloudflare-context.js");
await expect(fresh.initOpenNextCloudflareForDev()).resolves.toBeUndefined();
});
});
17 changes: 17 additions & 0 deletions packages/cloudflare/src/api/cloudflare-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,14 @@ async function getCloudflareContextAsync<
throw new Error(initOpenNextCloudflareForDevErrorMsg);
}

/**
* Tracks whether {@link initOpenNextCloudflareForDev} has already been called in the current Node.js
* process. Calling it more than once spawns competing workerd instances and surfaces as an obscure
* `SQLITE_BUSY` failure (see https://github.com/opennextjs/opennextjs-cloudflare/issues/1251), so we
* surface a user-actionable error before any setup runs.
*/
let initOpenNextCloudflareForDevHasBeenCalled = false;

/**
* Performs some initial setup to integrate as best as possible the local Next.js dev server (run via `next dev`)
* with the open-next Cloudflare adapter
Expand All @@ -246,6 +254,15 @@ async function getCloudflareContextAsync<
* @param options options on how the function should operate and if/where to persist the platform data
*/
export async function initOpenNextCloudflareForDev(options?: GetPlatformProxyOptions) {
if (initOpenNextCloudflareForDevHasBeenCalled) {
throw new Error(
`\n\nERROR: \`initOpenNextCloudflareForDev\` was called more than once in the same process.\n` +
`It should be called exactly once, at the top of your Next.js config file.\n` +
`Multiple calls start competing Workers runtimes, which fail with an obscure workerd \`SQLITE_BUSY\` error.\n`
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Unhandled rejection when the guard throws in unawaited calls

The function's JSDoc at line 253 says "although async it doesn't need to be awaited". If a user accidentally writes initOpenNextCloudflareForDev() twice without await, the second call returns a rejected promise that is never caught, producing an unhandledRejection event (which crashes Node.js 15+ by default). This is arguably the desired behavior — a loud crash forces the user to notice and fix their config — but it's a change from the previous behavior where a duplicate no-op call would silently succeed. Worth confirming this is the intended DX tradeoff.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks. Fixed in caac90a: the guard now lives in a synchronous wrapper that delegates the async work to an internal impl, so a duplicate call throws at the call site instead of producing an unhandled rejection when the call isn't awaited. The duplicate-call tests now assert a synchronous throw.

initOpenNextCloudflareForDevHasBeenCalled = true;

const shouldInitializationRun = shouldContextInitializationRun();
if (!shouldInitializationRun) return;

Expand Down