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
21 changes: 21 additions & 0 deletions .changeset/retrieve-compiled-config-build-output-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@opennextjs/cloudflare": patch
---

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 — `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.
77 changes: 75 additions & 2 deletions packages/cloudflare/src/cli/commands/utils/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
});
});
38 changes: 32 additions & 6 deletions packages/cloudflare/src/cli/commands/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 };
}

/**
Expand Down