diff --git a/packages/cloudflare-runtime/src/rolldown/plugins/options.ts b/packages/cloudflare-runtime/src/rolldown/plugins/options.ts index f325b084b9..1bb10f3147 100644 --- a/packages/cloudflare-runtime/src/rolldown/plugins/options.ts +++ b/packages/cloudflare-runtime/src/rolldown/plugins/options.ts @@ -248,6 +248,28 @@ export const optionsPlugin = createPlugin<"options", OptionsApi>( }, }; }, + // Vite merges every plugin's `config` result into the config the next + // plugin sees, so the entry input returned above is UNIONED with an + // input the app's own plugins declared for the same environment — a + // framework that names its own server entry (Foldkit's `ssr.build`, + // for one) leaves two entry chunks in the bundle, and the deployed + // Worker becomes whichever one the bundle happens to list first. + // `main` names the Worker entry, so when it is set it replaces rather + // than joins. This hook runs per environment after config resolution, + // where nothing merges over it. + options(options) { + if (pluginOptions.main === undefined) return; + // Only the entry environment carries the Worker's input; children + // (e.g. an RSC build's `ssr`) keep the entries their framework gave + // them. Without a resolvable environment there is nothing to check + // against, so leave the options alone. + const environmentName = this.environment?.name; + if (environmentName === undefined) return; + if (environmentName !== parseViteEnvironments(pluginOptions)[0]) + return; + options.input = wrapInput(input); + return options; + }, }, }; }, diff --git a/packages/cloudflare-runtime/src/rolldown/test/worker-entry.test.ts b/packages/cloudflare-runtime/src/rolldown/test/worker-entry.test.ts index b339be6b28..fe8104916c 100644 --- a/packages/cloudflare-runtime/src/rolldown/test/worker-entry.test.ts +++ b/packages/cloudflare-runtime/src/rolldown/test/worker-entry.test.ts @@ -47,4 +47,122 @@ describe("vite worker entry resolution", () => { "\0distilled:worker-entry:virtual:react-router/server-build", }); }); + + // Vite's `runConfigHook` folds each plugin's result into the config the next + // plugin sees (`conf = mergeConfig(conf, res)`), so an app that declares its + // own server entry for the same environment ends up with both inputs in the + // merged config. The `options` hook is what makes `main` win. + const APP_DECLARED_SSR_INPUT: vite.UserConfig = { + root: "/project", + environments: { + ssr: { + build: { + rollupOptions: { + input: { "entry.server": "/src/entry.server.ts" }, + }, + }, + }, + }, + }; + + const MAIN = "./src/worker.ts"; + + const callOptions = async ( + environment: { name: string } | undefined, + input: unknown, + main: string | null = MAIN, + ) => { + const plugin = optionsPlugin.vite({ + ...(main === null ? {} : { main }), + compatibilityDate: "2025-07-01", + }); + assert( + typeof plugin.config === "function", + "plugin.config is not a function", + ); + // Populates the plugin's resolved input, exactly as a real config pass does. + await plugin.config.call({ meta: {} } as never, APP_DECLARED_SSR_INPUT, { + command: "build", + mode: "production", + } as vite.ConfigEnv); + assert( + typeof plugin.options === "function", + "plugin.options is not a function", + ); + const options = { input }; + const returned = await plugin.options.call( + { + meta: {}, + ...(environment === undefined ? {} : { environment }), + } as never, + options as never, + ); + return (returned ?? options) as { input?: unknown }; + }; + + const workerEntry = `\0distilled:worker-entry:${path + .resolve("/project", MAIN) + .replaceAll("\\", "/")}`; + + it("merges the app's entry input with main before the build sees it", async () => { + const { mergeConfig } = await import("vite"); + const plugin = optionsPlugin.vite({ + main: MAIN, + compatibilityDate: "2025-07-01", + }); + assert( + typeof plugin.config === "function", + "plugin.config is not a function", + ); + const result = (await plugin.config.call( + { meta: {} } as never, + APP_DECLARED_SSR_INPUT, + { command: "build", mode: "production" } as vite.ConfigEnv, + )) as vite.UserConfig; + + // Not the fix — the reason one is needed. Two entry chunks reach the + // bundle, and the deployed Worker is whichever the bundle lists first. + expect( + Object.keys( + mergeConfig(APP_DECLARED_SSR_INPUT, result).environments?.ssr?.build + ?.rollupOptions?.input as object, + ).sort(), + ).toEqual(["entry.server", "worker"]); + }); + + it("replaces an app-declared entry input with main", async () => { + const options = await callOptions( + { name: "ssr" }, + { + "entry.server": "/src/entry.server.ts", + worker: workerEntry, + }, + ); + + expect(options.input).toEqual({ worker: workerEntry }); + }); + + it("leaves child environments alone", async () => { + const input = { "entry.server": "/src/entry.server.ts" }; + + const options = await callOptions({ name: "rsc" }, input); + + expect(options.input).toEqual(input); + }); + + it("leaves the input alone when no main is declared", async () => { + const input = { "entry.server": "/src/entry.server.ts" }; + + const options = await callOptions({ name: "ssr" }, input, null); + + expect(options.input).toEqual(input); + }); + + it("leaves the input alone without a resolvable environment", async () => { + const input = { "entry.server": "/src/entry.server.ts" }; + + const options = await callOptions(undefined, input); + + expect(options.input).toEqual(input); + }); });