From 8faf107a8ff4440d8283bb61db63d435359d4b95 Mon Sep 17 00:00:00 2001 From: Yarchik Date: Sat, 30 May 2026 00:10:00 +0100 Subject: [PATCH 1/2] fix(api): guard initOpenNextCloudflareForDev against duplicate calls Calling initOpenNextCloudflareForDev twice in the same process used to spawn two competing miniflare instances and fail with an obscure workerd SQLITE_BUSY error. Track the invocation at module scope and throw a user-actionable error that points at the Next.js config file the moment the second call happens, instead of letting the failure surface deep inside the Workers runtime. The existing cross-process guard (shouldContextInitializationRun) is unchanged - the new check fires before it, so it catches the same- process case that the cross-process check is not designed to handle. Closes #1251 --- ...cloudflare-for-dev-duplicate-call-guard.md | 9 +++ .../src/api/cloudflare-context.spec.ts | 67 +++++++++++++++++++ .../cloudflare/src/api/cloudflare-context.ts | 17 +++++ 3 files changed, 93 insertions(+) create mode 100644 .changeset/init-open-next-cloudflare-for-dev-duplicate-call-guard.md create mode 100644 packages/cloudflare/src/api/cloudflare-context.spec.ts diff --git a/.changeset/init-open-next-cloudflare-for-dev-duplicate-call-guard.md b/.changeset/init-open-next-cloudflare-for-dev-duplicate-call-guard.md new file mode 100644 index 000000000..9270d9dfc --- /dev/null +++ b/.changeset/init-open-next-cloudflare-for-dev-duplicate-call-guard.md @@ -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 diff --git a/packages/cloudflare/src/api/cloudflare-context.spec.ts b/packages/cloudflare/src/api/cloudflare-context.spec.ts new file mode 100644 index 000000000..e12cb1b20 --- /dev/null +++ b/packages/cloudflare/src/api/cloudflare-context.spec.ts @@ -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(); + }); +}); diff --git a/packages/cloudflare/src/api/cloudflare-context.ts b/packages/cloudflare/src/api/cloudflare-context.ts index cfde3167b..c1d36ece3 100644 --- a/packages/cloudflare/src/api/cloudflare-context.ts +++ b/packages/cloudflare/src/api/cloudflare-context.ts @@ -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 @@ -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` + ); + } + initOpenNextCloudflareForDevHasBeenCalled = true; + const shouldInitializationRun = shouldContextInitializationRun(); if (!shouldInitializationRun) return; From caac90a5cf92d98299978c19f2e719db22c9baa4 Mon Sep 17 00:00:00 2001 From: Yarchik Date: Thu, 18 Jun 2026 19:25:25 +0100 Subject: [PATCH 2/2] refactor(api): throw the duplicate-call guard synchronously The guard lived inside the async function body, so a second call returned a rejected promise. Since the function is documented as not needing to be `await`ed, an unawaited duplicate call surfaced as an unhandledRejection (which crashes Node 15+) rather than a throw at the call site. Move the guard into a synchronous wrapper that delegates the async work to an internal impl, so the duplicate-call error throws at the call site regardless of awaiting. Tests updated to assert a synchronous throw. --- packages/cloudflare/src/api/cloudflare-context.spec.ts | 8 ++++---- packages/cloudflare/src/api/cloudflare-context.ts | 10 ++++++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/cloudflare/src/api/cloudflare-context.spec.ts b/packages/cloudflare/src/api/cloudflare-context.spec.ts index e12cb1b20..0cc9de8b7 100644 --- a/packages/cloudflare/src/api/cloudflare-context.spec.ts +++ b/packages/cloudflare/src/api/cloudflare-context.spec.ts @@ -35,19 +35,19 @@ describe("initOpenNextCloudflareForDev duplicate-call guard", () => { it("throws a user-actionable error on the second call in the same process", async () => { await initOpenNextCloudflareForDev(); - await expect(initOpenNextCloudflareForDev()).rejects.toThrow( + 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(); - await expect(initOpenNextCloudflareForDev()).rejects.toThrowError( + expect(() => initOpenNextCloudflareForDev()).toThrowError( expect.objectContaining({ message: expect.stringMatching(/Next\.js config file/), }) ); - await expect(initOpenNextCloudflareForDev()).rejects.toThrowError( + expect(() => initOpenNextCloudflareForDev()).toThrowError( expect.objectContaining({ message: expect.stringMatching(/SQLITE_BUSY/), }) @@ -56,7 +56,7 @@ describe("initOpenNextCloudflareForDev duplicate-call guard", () => { it("treats each fresh module load as its own process for the purpose of the guard", async () => { await initOpenNextCloudflareForDev(); - await expect(initOpenNextCloudflareForDev()).rejects.toThrow(); + 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. diff --git a/packages/cloudflare/src/api/cloudflare-context.ts b/packages/cloudflare/src/api/cloudflare-context.ts index c1d36ece3..9d77308f9 100644 --- a/packages/cloudflare/src/api/cloudflare-context.ts +++ b/packages/cloudflare/src/api/cloudflare-context.ts @@ -250,10 +250,12 @@ 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 { if (initOpenNextCloudflareForDevHasBeenCalled) { throw new Error( `\n\nERROR: \`initOpenNextCloudflareForDev\` was called more than once in the same process.\n` + @@ -263,6 +265,10 @@ export async function initOpenNextCloudflareForDev(options?: GetPlatformProxyOpt } initOpenNextCloudflareForDevHasBeenCalled = true; + return initOpenNextCloudflareForDevImpl(options); +} + +async function initOpenNextCloudflareForDevImpl(options?: GetPlatformProxyOptions) { const shouldInitializationRun = shouldContextInitializationRun(); if (!shouldInitializationRun) return;