Skip to content
Closed
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
22 changes: 22 additions & 0 deletions packages/cloudflare-runtime/src/rolldown/plugins/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
},
};
},
Expand Down
118 changes: 118 additions & 0 deletions packages/cloudflare-runtime/src/rolldown/test/worker-entry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});