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..8243606c0 --- /dev/null +++ b/.changeset/retrieve-compiled-config-build-output-path.md @@ -0,0 +1,21 @@ +--- +"@opennextjs/cloudflare": patch +--- + +fix: make `retrieveCompiledConfig` respect `buildOutputPath` + +`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 — `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 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 `/.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; + + 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 1f78fdce6..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,23 +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"); + 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); - if (!existsSync(configPath)) { - logger.error("Could not find compiled Open Next config, did you run the build command?"); + return { 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); } - const config = await import(url.pathToFileURL(configPath).href).then((mod) => mod.default); - ensureCloudflareConfig(config); + const { config, buildDir } = await compileConfig(sourceConfigPath); - return { config }; + // 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 }; } /**