Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
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();
expect(() => initOpenNextCloudflareForDev()).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();
expect(() => initOpenNextCloudflareForDev()).toThrowError(
expect.objectContaining({
message: expect.stringMatching(/Next\.js config file/),
})
);
expect(() => initOpenNextCloudflareForDev()).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();
expect(() => initOpenNextCloudflareForDev()).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();
});
});
27 changes: 25 additions & 2 deletions packages/cloudflare/src/api/cloudflare-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,14 +238,37 @@ 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
*
* Note: this function should only be called inside the Next.js config file, and although async it doesn't need to be `await`ed
* Note: this function should only be called inside the Next.js config file. Although it returns a
* promise it doesn't need to be `await`ed: the duplicate-call guard throws synchronously, so a second
* unawaited call fails at the call site instead of as an unhandled promise rejection.
* @param options options on how the function should operate and if/where to persist the platform data
*/
export async function initOpenNextCloudflareForDev(options?: GetPlatformProxyOptions) {
export function initOpenNextCloudflareForDev(options?: GetPlatformProxyOptions): Promise<void> {
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`
);
}
initOpenNextCloudflareForDevHasBeenCalled = true;

return initOpenNextCloudflareForDevImpl(options);
}

async function initOpenNextCloudflareForDevImpl(options?: GetPlatformProxyOptions) {
const shouldInitializationRun = shouldContextInitializationRun();
if (!shouldInitializationRun) return;

Expand Down