1import type { BasePluginOptions } from "../rolldown/options.ts";
2import {
3 additionalModulesPlugin,
4 cloudflareExternalsPlugin,
5 nodejsAlsPlugin,
6 nodejsImportWarningPlugin,
7 nodejsUnenvPlugin,
8 optionsPlugin,
9 virtualModulesPlugin,
10 wasmInitPlugin,
11} from "../rolldown/plugins/index.ts";
12import type {
13 BindingHooks,
14 RuntimeServices,
15 RuntimeWorker,
16} from "../core/index.ts";
17import type * as Context from "effect/Context";
18import type * as vite from "vite";
19import { dev } from "./dev-plugin.ts";
20import { preview } from "./preview-plugin.ts";
21
22export interface CloudflareVitePluginDevOptions {
23 /**
24 * Where the Worker request-proxy middleware registers relative to the
25 * `configureServer` post middlewares of other plugins.
26 *
27 * - `"post"` (default): append in plugin order. Required by frameworks whose
28 * own dev middlewares must see requests first (e.g. Astro's prerender
29 * handler and dev handler).
30 * - `"pre"`: insert directly after Vite's internal middlewares, ahead of the
31 * post middlewares other plugins registered. Use this when appending the
32 * Cloudflare plugin after a framework's config-file plugins and the
33 * framework registers its own Node request-bridge middleware that cannot
34 * handle the Worker environment (e.g. Waku's RSC bridge, which assumes a
35 * runnable Node environment).
36 *
37 * @default "post"
38 */
39 middlewareOrder?: "pre" | "post";
40}
41
42export interface CloudflareVitePluginOptions<
43 B extends BindingHooks = BindingHooks,
44> extends BasePluginOptions {
45 worker?: Omit<
46 RuntimeWorker<B>,
47 "compatibilityDate" | "compatibilityFlags" | "modules"
48 >;
49 context?: Context.Context<RuntimeServices>;
50 dev?: CloudflareVitePluginDevOptions;
51}
52
53/**
54 * Official `@cloudflare/vite-plugin` root name. vinext / Next-on-Workers
55 * treat this exact name, or any `vite-plugin-cloudflare:*` prefix, as
56 * "Cloudflare Workers path".
57 */
58export const OFFICIAL_CLOUDFLARE_VITE_PLUGIN_NAME = "vite-plugin-cloudflare";
59
60/**
61 * Alchemy's presence marker. Matches vinext's
62 * `name.startsWith("vite-plugin-cloudflare:")` check without colliding
63 * with the official plugin's exact name.
64 */
65export const ALCHEMY_CLOUDFLARE_VITE_PLUGIN_NAME =
66 "vite-plugin-cloudflare:alchemy";
67
68const officialCloudflareVitePluginPrefix = `${OFFICIAL_CLOUDFLARE_VITE_PLUGIN_NAME}:`;
69
70const isOfficialCloudflareVitePlugin = (plugin: vite.Plugin): boolean =>
71 plugin.name === OFFICIAL_CLOUDFLARE_VITE_PLUGIN_NAME ||
72 (plugin.name.startsWith(officialCloudflareVitePluginPrefix) &&
73 plugin.name !== ALCHEMY_CLOUDFLARE_VITE_PLUGIN_NAME);
74
75const noopHook = (): undefined => undefined;
76
77/**
78 * Neutralize official `@cloudflare/vite-plugin` instances already in the
79 * Vite plugin list. Vite flattens `config.plugins` into `userPlugins`
80 * *before* `config()` hooks run, so splicing the array cannot unregister
81 * them — but those entries are the same objects. Replacing their hooks
82 * with no-ops (not `undefined`: `getSortedPluginsByHook("config")` has
83 * already captured them) stops the official stack from running alongside
84 * Alchemy's `distilled-cloudflare:*` plugins.
85 *
86 * Construction-time side effects inside `cloudflare()` still happen if
87 * the app called the official factory. `ALCHEMY_CLOUDFLARE_VITE_INJECTED`
88 * remains the way to skip that factory.
89 */
90export const disableOfficialCloudflareVitePlugins = (
91 plugins: ReadonlyArray<unknown> | undefined,
92): number => {
93 let disabled = 0;
94 const visit = (entry: unknown): void => {
95 if (Array.isArray(entry)) {
96 for (const child of entry) visit(child);
97 return;
98 }
99 if (entry === null || typeof entry !== "object" || !("name" in entry)) {
100 return;
101 }
102 const plugin = entry as vite.Plugin;
103 if (
104 typeof plugin.name !== "string" ||
105 !isOfficialCloudflareVitePlugin(plugin)
106 ) {
107 return;
108 }
109 disableOfficialCloudflareVitePlugin(plugin);
110 disabled += 1;
111 };
112 for (const entry of plugins ?? []) visit(entry);
113 return disabled;
114};
115
116const disableOfficialCloudflareVitePlugin = (plugin: vite.Plugin): void => {
117 const record = plugin as unknown as Record<string, unknown>;
118 for (const key of Object.keys(record)) {
119 if (
120 key === "name" ||
121 key === "enforce" ||
122 key === "apply" ||
123 key === "applyToEnvironment"
124 ) {
125 continue;
126 }
127 const value = record[key];
128 if (typeof value === "function") {
129 record[key] = noopHook;
130 continue;
131 }
132 if (
133 value !== null &&
134 typeof value === "object" &&
135 "handler" in value &&
136 typeof (value as { handler: unknown }).handler === "function"
137 ) {
138 (value as { handler: typeof noopHook }).handler = noopHook;
139 }
140 }
141};
142
143/**
144 * Named so vinext takes the Workers path, and so a later `config()`
145 * reader cannot mistake us for the official plugin. `order: "pre"`
146 * runs before `vinext:config` scans `config.plugins`.
147 */
148const alchemyCloudflareVitePlugin = (): vite.Plugin => ({
149 name: ALCHEMY_CLOUDFLARE_VITE_PLUGIN_NAME,
150 enforce: "pre",
151 config: {
152 order: "pre",
153 handler(config) {
154 disableOfficialCloudflareVitePlugins(config.plugins);
155 },
156 },
157});
158
159export default function cloudflareVitePlugin(
160 options: CloudflareVitePluginOptions = {},
161): vite.PluginOption {
162 return [
163 alchemyCloudflareVitePlugin(),
164 optionsPlugin.vite(options),
165 cloudflareExternalsPlugin.vite(options),
166 nodejsAlsPlugin.vite(options),
167 nodejsImportWarningPlugin.vite(options),
168 nodejsUnenvPlugin.vite(options),
169 virtualModulesPlugin.vite(options),
170 wasmInitPlugin.vite(options),
171 additionalModulesPlugin.vite(options),
172 {
173 name: "distilled-cloudflare:rsc",
174 enforce: "pre",
175 config() {
176 return { rsc: { serverHandler: false } } as vite.UserConfig;
177 },
178 } as vite.Plugin,
179 ...dev(options),
180 preview(options),
181 // Some of the composed plugins are conditional (e.g. the nodejs-compat
182 // family) and resolve to `null`; filter them out so integrations that
183 // post-process the returned plugins don't have to handle sparse entries.
184 ].filter((plugin): plugin is vite.Plugin => plugin !== null);
185}