From 034e48c21dbf91fdc4a35c7ab7e7f936b6f9e02b Mon Sep 17 00:00:00 2001 From: yuanzhixiang Date: Fri, 7 Aug 2026 07:49:54 +0800 Subject: [PATCH 1/2] fix: retrieveCompiledConfig should respect buildOutputPath retrieveCompiledConfig looked for the compiled config under a hardcoded /.open-next/.build/, which does not follow the buildOutputPath config. Every command going through it (deploy, preview, upload, populateCache) exited with "Could not find compiled Open Next config" right after a successful build. The compiled path cannot simply be prefixed with buildOutputPath -- that value lives in the very config being loaded. Recompile from the source config instead when the file is missing, which is what build already does. --- ...etrieve-compiled-config-build-output-path.md | 17 +++++++++++++++++ .../cloudflare/src/cli/commands/utils/utils.ts | 7 +++++-- 2 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 .changeset/retrieve-compiled-config-build-output-path.md diff --git a/.changeset/retrieve-compiled-config-build-output-path.md b/.changeset/retrieve-compiled-config-build-output-path.md new file mode 100644 index 000000000..92b1bb4f8 --- /dev/null +++ b/.changeset/retrieve-compiled-config-build-output-path.md @@ -0,0 +1,17 @@ +--- +"@opennextjs/cloudflare": patch +--- + +Fix `deploy`/`preview`/`upload`/`populateCache` failing when `buildOutputPath` is set + +`retrieveCompiledConfig` looked for the compiled config under a hardcoded +`/.open-next/.build/`, which does not follow the `buildOutputPath` config. +Every command that goes through it therefore exited with `Could not find +compiled Open Next config, did you run the build command?` right after a +successful build. `build` itself was unaffected because it compiles the config +from source, so the failure only showed up at deploy time. + +The compiled path cannot simply be prefixed with `buildOutputPath` — that value +lives in the very config being loaded. When the file is missing, the config is +now recompiled from source instead (the same path `build` takes; +`compileOpenNextConfig` emits to a temp dir, so nothing lands in the project). diff --git a/packages/cloudflare/src/cli/commands/utils/utils.ts b/packages/cloudflare/src/cli/commands/utils/utils.ts index 1f78fdce6..ef0c197bb 100644 --- a/packages/cloudflare/src/cli/commands/utils/utils.ts +++ b/packages/cloudflare/src/cli/commands/utils/utils.ts @@ -98,8 +98,11 @@ export async function retrieveCompiledConfig() { const configPath = path.join(nextAppDir, ".open-next/.build/open-next.config.edge.mjs"); if (!existsSync(configPath)) { - logger.error("Could not find compiled Open Next config, did you run the build command?"); - process.exit(1); + // The path above does not follow a custom `buildOutputPath`, which moves the compiled + // config out of `/.open-next`. It cannot be resolved here either -- it lives in + // the very config we are trying to load. Recompile from the source config instead: + // `compileOpenNextConfig` emits to a temp dir, so nothing lands in the project. + return compileConfig(undefined); } const config = await import(url.pathToFileURL(configPath).href).then((mod) => mod.default); From 28aed561657c63852add07597deb889c47497dad Mon Sep 17 00:00:00 2001 From: yuanzhixiang Date: Fri, 7 Aug 2026 07:56:36 +0800 Subject: [PATCH 2/2] fix: keep the missing-build error when falling back to the source config Devin Review flagged that the fallback conflated "buildOutputPath moved the compiled config" with "the build was never run": the latter used to fail with an actionable message, but now took the recompile path and either failed later with an obscure error or, in an interactive shell, prompted to create an open-next.config.ts in the project -- a write side effect deploy never had. Look up the source config with findOpenNextConfig first so compileConfig can never reach its create-a-config branch, and check the resolved output directory for the built worker afterwards. Running these commands without building still reports the same error, whether the source config is missing or the build was never run. Also reformat the changeset to the : format CONTRIBUTING.md requires, and cover retrieveCompiledConfig with unit tests. --- ...rieve-compiled-config-build-output-path.md | 20 +++-- .../src/cli/commands/utils/utils.spec.ts | 77 ++++++++++++++++++- .../src/cli/commands/utils/utils.ts | 45 ++++++++--- 3 files changed, 121 insertions(+), 21 deletions(-) diff --git a/.changeset/retrieve-compiled-config-build-output-path.md b/.changeset/retrieve-compiled-config-build-output-path.md index 92b1bb4f8..8243606c0 100644 --- a/.changeset/retrieve-compiled-config-build-output-path.md +++ b/.changeset/retrieve-compiled-config-build-output-path.md @@ -2,16 +2,20 @@ "@opennextjs/cloudflare": patch --- -Fix `deploy`/`preview`/`upload`/`populateCache` failing when `buildOutputPath` is set +fix: make `retrieveCompiledConfig` respect `buildOutputPath` `retrieveCompiledConfig` looked for the compiled config under a hardcoded `<cwd>/.open-next/.build/`, which does not follow the `buildOutputPath` config. -Every command that goes through it therefore exited with `Could not find -compiled Open Next config, did you run the build command?` right after a -successful build. `build` itself was unaffected because it compiles the config -from source, so the failure only showed up at deploy time. +Every command that goes through it — `deploy`, `preview`, `upload` and +`populateCache` — therefore exited with `Could not find compiled Open Next +config, did you run the build command?` right after a successful build. `build` +itself was unaffected because it compiles the config from source, so the failure +only showed up at deploy time. The compiled path cannot simply be prefixed with `buildOutputPath` — that value -lives in the very config being loaded. When the file is missing, the config is -now recompiled from source instead (the same path `build` takes; -`compileOpenNextConfig` emits to a temp dir, so nothing lands in the project). +lives in the very config being loaded. When the compiled file is missing, the +config is now recompiled from source instead (the same path `build` takes; +`compileOpenNextConfig` emits to a temp dir, so nothing lands in the project), +and the resolved output directory is then checked for the built worker. Running +these commands without building still fails with the same actionable error, +whether the source config is missing or the build was never run. diff --git a/packages/cloudflare/src/cli/commands/utils/utils.spec.ts b/packages/cloudflare/src/cli/commands/utils/utils.spec.ts index d2e7bb32e..b1c5570e5 100644 --- a/packages/cloudflare/src/cli/commands/utils/utils.spec.ts +++ b/packages/cloudflare/src/cli/commands/utils/utils.spec.ts @@ -1,9 +1,10 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import logger from "@opennextjs/aws/logger.js"; +import { beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; import { askConfirmation } from "../../utils/ask-confirmation.js"; import { createOpenNextConfigFile, findOpenNextConfig } from "../../utils/create-open-next-config.js"; import { isNonInteractiveOrCI } from "../../utils/is-interactive.js"; -import { compileConfig } from "./utils.js"; +import { compileConfig, retrieveCompiledConfig } from "./utils.js"; const { mockExistsSync } = vi.hoisted(() => ({ mockExistsSync: vi.fn(), @@ -76,6 +77,11 @@ vi.mock("@opennextjs/aws/build/helper.js", () => ({ normalizeOptions: vi.fn(() => ({})), })); +// Mock the worker path helper +vi.mock("../../build/bundle-server.js", () => ({ + getOutputWorkerPath: vi.fn(() => "/build-output/worker.js"), +})); + describe("compileConfig", () => { beforeEach(() => { vi.mocked(isNonInteractiveOrCI).mockReturnValue(false); @@ -162,3 +168,70 @@ describe("compileConfig", () => { expect(createOpenNextConfigFile).toHaveBeenCalledOnce(); }); }); + +describe("retrieveCompiledConfig", () => { + // The compiled config only lives under `<cwd>/.open-next/.build/` when `buildOutputPath` is + // left at its default, so these tests drive the two lookups independently. + function mockPaths({ compiledConfig, worker }: { compiledConfig: boolean; worker: boolean }) { + mockExistsSync.mockImplementation((p: string) => { + if (String(p).includes(".open-next/.build/")) return compiledConfig; + if (String(p).endsWith("worker.js")) return worker; + // The source config, checked by `compileConfig`. + return true; + }); + } + + let exitSpy: MockInstance<typeof process.exit>; + + beforeEach(() => { + exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + }); + + it("should recompile from source when a custom buildOutputPath moved the compiled config", async () => { + mockPaths({ compiledConfig: false, worker: true }); + vi.mocked(findOpenNextConfig).mockReturnValue("/app/open-next.config.ts"); + + const result = await retrieveCompiledConfig(); + + expect(mockCompileOpenNextConfig).toHaveBeenCalledWith("/app/open-next.config.ts", { + compileEdge: true, + }); + expect(result.config).toEqual({ default: {} }); + }); + + it("should report a missing build when there is no source config either", async () => { + mockPaths({ compiledConfig: false, worker: false }); + vi.mocked(findOpenNextConfig).mockReturnValue(undefined); + + await expect(retrieveCompiledConfig()).rejects.toThrowError("process.exit"); + + expect(logger.error).toHaveBeenCalledWith( + "Could not find compiled Open Next config, did you run the build command?" + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("should report a missing build when the source config exists but the app was never built", async () => { + mockPaths({ compiledConfig: false, worker: false }); + vi.mocked(findOpenNextConfig).mockReturnValue("/app/open-next.config.ts"); + + await expect(retrieveCompiledConfig()).rejects.toThrowError("process.exit"); + + expect(logger.error).toHaveBeenCalledWith( + "Could not find compiled Open Next config, did you run the build command?" + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("should never create a config file — these commands must not write to the project", async () => { + mockPaths({ compiledConfig: false, worker: false }); + vi.mocked(findOpenNextConfig).mockReturnValue(undefined); + + await expect(retrieveCompiledConfig()).rejects.toThrowError("process.exit"); + + expect(askConfirmation).not.toHaveBeenCalled(); + expect(createOpenNextConfigFile).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cloudflare/src/cli/commands/utils/utils.ts b/packages/cloudflare/src/cli/commands/utils/utils.ts index ef0c197bb..1d91c6051 100644 --- a/packages/cloudflare/src/cli/commands/utils/utils.ts +++ b/packages/cloudflare/src/cli/commands/utils/utils.ts @@ -11,6 +11,7 @@ import { unstable_readConfig } from "wrangler"; import type yargs from "yargs"; import type { OpenNextConfig } from "../../../api/config.js"; +import { getOutputWorkerPath } from "../../build/bundle-server.js"; import { ensureCloudflareConfig } from "../../build/utils/ensure-cf-config.js"; import { askConfirmation } from "../../utils/ask-confirmation.js"; import { @@ -89,26 +90,48 @@ export async function compileConfig(configPath: string | undefined) { return { config, buildDir }; } +const MISSING_BUILD_ERROR = "Could not find compiled Open Next config, did you run the build command?"; + /** * Retrieve a compiled OpenNext config, and ensure it is for Cloudflare. * * @returns OpenNext config. */ export async function retrieveCompiledConfig() { - const configPath = path.join(nextAppDir, ".open-next/.build/open-next.config.edge.mjs"); - - if (!existsSync(configPath)) { - // The path above does not follow a custom `buildOutputPath`, which moves the compiled - // config out of `<cwd>/.open-next`. It cannot be resolved here either -- it lives in - // the very config we are trying to load. Recompile from the source config instead: - // `compileOpenNextConfig` emits to a temp dir, so nothing lands in the project. - return compileConfig(undefined); + const compiledConfigPath = path.join(nextAppDir, ".open-next/.build/open-next.config.edge.mjs"); + + if (existsSync(compiledConfigPath)) { + const config = await import(url.pathToFileURL(compiledConfigPath).href).then((mod) => mod.default); + ensureCloudflareConfig(config); + + return { config }; } - const config = await import(url.pathToFileURL(configPath).href).then((mod) => mod.default); - ensureCloudflareConfig(config); + // The path above does not follow a custom `buildOutputPath`, and that value cannot be resolved + // here -- it lives in the very config we are trying to load. Recompile from the source config + // to find out where the build output actually is; `compileOpenNextConfig` emits to a temp dir, + // so nothing lands in the project. + // + // `findOpenNextConfig` is checked first so that a missing source config never reaches + // `compileConfig`, which would offer to create one -- these commands must not write to the + // project, and "no config at all" means the app was never built. + const sourceConfigPath = findOpenNextConfig(nextAppDir); + + if (!sourceConfigPath) { + logger.error(MISSING_BUILD_ERROR); + process.exit(1); + } - return { config }; + const { config, buildDir } = await compileConfig(sourceConfigPath); + + // A source config on its own does not mean the app was built, so check for the worker the + // build emits. Without this, forgetting to build would surface as an obscure failure later. + if (!existsSync(getOutputWorkerPath(getNormalizedOptions(config)))) { + logger.error(MISSING_BUILD_ERROR); + process.exit(1); + } + + return { config, buildDir }; } /**