1import type { ExportTypes } from "../rolldown/export-types.ts";
2import {
3 haveExportTypesChanged,
4 isExportTypes,
5 WORKER_EXPORT_TYPES_EVENT,
6} from "../rolldown/export-types.ts";
7import { parseViteEnvironments } from "../rolldown/options.ts";
8import type { OptionsApi } from "../rolldown/plugins/index.ts";
9import { workerEntryId } from "../rolldown/plugins/index.ts";
10import { resolvePluginApi } from "../rolldown/utils.ts";
11import type { RuntimeServices } from "../core/index.ts";
12import type * as Context from "effect/Context";
13import * as NodeHttp from "node:http";
14import { URL as NodeURL } from "node:url";
15import * as vite from "vite";
16import { DistilledDevEnvironment } from "./dev-environment.ts";
17import type { ServerHandle } from "./dev-server.ts";
18import { configuredExportTypes, mergeExportTypes } from "./export-types.ts";
19import { proxyRequestHeaders } from "./forwarded-host.ts";
20import type { CloudflareVitePluginOptions } from "./plugin.ts";
21import { handleWebSocket } from "./websockets.ts";
22
23let context: Context.Context<RuntimeServices> | undefined;
24
25export function dev(options: CloudflareVitePluginOptions): Array<vite.Plugin> {
26 const environmentNames = parseViteEnvironments(options);
27 const configured = configuredExportTypes(options);
28 // Which exports the running Worker was generated for. Kept across dev server
29 // restarts so a restart does not undo what was detected.
30 let exportTypes: ExportTypes = configured;
31 let handle: ServerHandle | undefined;
32 let isServerRestarting = false;
33 let removeUpgradeListener: (() => void) | undefined;
34 const close = async () => {
35 removeUpgradeListener?.();
36 removeUpgradeListener = undefined;
37 await handle?.close();
38 handle = undefined;
39 };
40 let optionsApi: OptionsApi | undefined;
41 const plugins: Array<vite.Plugin> = [];
42 // The proxy middleware registers in a `configureServer` post callback, which
43 // Vite runs in plugin order — so when this plugin is appended after a
44 // framework's plugins, the framework's own post middlewares (e.g. a Node
45 // request bridge that assumes a runnable environment) would see requests
46 // first. With `middlewareOrder: "pre"` the proxy is instead inserted
47 // directly after Vite's internal middlewares, ahead of every other plugin's
48 // post middlewares. This companion plugin records that insertion point: its
49 // post callback runs before all normal plugins' post callbacks (it is
50 // `enforce: "pre"`), i.e. right after Vite registered its internal
51 // middlewares and before any other plugin registered post middlewares.
52 let middlewareBoundary: number | undefined;
53 if (options.dev?.middlewareOrder === "pre") {
54 plugins.push({
55 name: "distilled-cloudflare:dev-middleware-boundary",
56 enforce: "pre",
57 configureServer(server) {
58 middlewareBoundary = undefined;
59 return () => {
60 middlewareBoundary = server.middlewares.stack.length;
61 };
62 },
63 });
64 }
65 const plugin: vite.Plugin = {
66 name: "distilled-cloudflare:dev",
67 configResolved({ plugins }) {
68 optionsApi = resolvePluginApi<OptionsApi>(
69 plugins ?? [],
70 "distilled-cloudflare:options",
71 );
72 },
73 config() {
74 const environment: vite.EnvironmentOptions = {
75 dev: {
76 createEnvironment(name, config) {
77 // Framework integrations strip `configureServer` off this plugin
78 // when they create throwaway dev servers (e.g. Astro's type-gen
79 // during `build`/`sync`) so we don't boot workerd mid-build. In
80 // that case there is no runtime to proxy to — degrade to Vite's
81 // default runnable environment. Check this exact plugin instance
82 // (not the resolved plugin list) so multiple instances and
83 // renamed/wrapped plugins behave predictably.
84 if (!hasConfigureServerHook(plugin)) {
85 return vite.createRunnableDevEnvironment(name, config);
86 }
87 return new DistilledDevEnvironment(name, config);
88 },
89 },
90 };
91 return {
92 environments: Object.fromEntries(
93 environmentNames.map((name) => [name, environment]),
94 ),
95 };
96 },
97 async buildEnd() {
98 if (!isServerRestarting) {
99 await close();
100 }
101 },
102 async closeBundle() {
103 if (!isServerRestarting) {
104 await close();
105 }
106 },
107 async configureServer(server) {
108 const restartServer = server.restart.bind(server);
109 server.restart = async () => {
110 try {
111 isServerRestarting = true;
112 await restartServer();
113 } finally {
114 isServerRestarting = false;
115 }
116 };
117 if (!optionsApi) {
118 throw new Error("Cannot resolve the cloudflare-runtime:options plugin");
119 }
120 // Frameworks can register multiple names for the same Worker entry.
121 const inputs = [...new Set(Object.values(optionsApi.input()))];
122 if (inputs.length > 1) {
123 throw new Error(
124 `Expected exactly one entry in the input, got ${inputs.length} entries: ${JSON.stringify(inputs)}`,
125 );
126 }
127 const { createDefaultContext, startServer } =
128 await import("./dev-server.ts");
129 if (!options.context) {
130 context ??= await createDefaultContext();
131 }
132 const [input] = inputs;
133 const entryEnvironment = {
134 environmentName: environmentNames[0],
135 // The module runner imports the generated Worker entry rather than the
136 // user entry: it is the module that re-exports everything the Worker
137 // needs and that reports its export types over `import.meta.hot`.
138 entryId: input ? workerEntryId(input) : input,
139 entryName: input,
140 };
141 const entryEnvironments = () =>
142 environmentNames
143 .map((name) => server.environments[name])
144 .filter(
145 (environment) => environment instanceof DistilledDevEnvironment,
146 );
147
148 const connect = async (address: ServerHandle["address"]) => {
149 for (const environment of entryEnvironments()) {
150 await environment.depsOptimizer?.init();
151 await environment.connect(address);
152 }
153 };
154 // Match Cloudflare's Vite plugin: rebuild the dev server's environments,
155 // hot channels and module runners after the runtime recovers. Framework
156 // state may still refer to the crashed process even with the same ports.
157 const onRuntimeRestart = () => {
158 server.config.logger.warn(
159 "The Worker runtime recovered after a crash; restarting the Vite dev server.",
160 { timestamp: true },
161 );
162 if (!handle) return;
163 void server.restart().catch((error: unknown) => {
164 server.config.logger.error(
165 `Failed to restart the Vite dev server after Worker recovery: ${String(error)}`,
166 {
167 error: error instanceof Error ? error : undefined,
168 timestamp: true,
169 },
170 );
171 });
172 };
173 handle ??= await startServer(
174 options,
175 entryEnvironment,
176 server,
177 options.context ?? context!,
178 exportTypes,
179 onRuntimeRestart,
180 );
181 await connect(handle.address);
182 let address = handle.address;
183 let proxySharedSecret = handle.proxySharedSecret;
184
185 const bindWebSocket = () => {
186 removeUpgradeListener?.();
187 removeUpgradeListener = server.httpServer
188 ? handleWebSocket(server.httpServer, address, proxySharedSecret)
189 : undefined;
190 };
191
192 /**
193 * Replaces the Worker runtime so it is regenerated for `exportTypes`.
194 * workerd needs a named export for every entrypoint, Durable Object, and
195 * Workflow class, and those exports are baked into the Worker's entry
196 * module at startup.
197 */
198 const restartRuntime = async () => {
199 await handle?.close();
200 handle = await startServer(
201 options,
202 entryEnvironment,
203 server,
204 options.context ?? context!,
205 exportTypes,
206 onRuntimeRestart,
207 );
208 await connect(handle.address);
209 address = handle.address;
210 proxySharedSecret = handle.proxySharedSecret;
211 bindWebSocket();
212 };
213
214 /**
215 * Applies export types reported by the Worker. Returns `true` when they
216 * no longer match what the running Worker was generated for, in which
217 * case a runtime restart has been queued on `applying`.
218 */
219 let applying: Promise<void> = Promise.resolve();
220 const applyExportTypes = (detected: ExportTypes): boolean => {
221 const next = mergeExportTypes(configured, detected);
222 if (!haveExportTypesChanged(exportTypes, next)) {
223 return false;
224 }
225 exportTypes = next;
226 applying = applying
227 .catch(() => {})
228 .then(restartRuntime)
229 .catch((error: unknown) => {
230 server.config.logger.error(
231 `Failed to reload the Worker runtime: ${String(error)}`,
232 {
233 error: error instanceof Error ? error : undefined,
234 timestamp: true,
235 },
236 );
237 });
238 return true;
239 };
240
241 if (input) {
242 // Vite's internal CSS plugins rely on `buildStart` having run for the
243 // client environment before a server environment transforms a module.
244 // Vite does that while initializing the dev server, which is after
245 // this hook, and evaluating the entry now would fail without it.
246 await server.environments.client.pluginContainer.buildStart();
247 // Detect up front so that the first request already reaches a Worker
248 // exposing every entrypoint the entry module defines.
249 const detected = await entryEnvironments()[0]?.requestExportTypes();
250 if (detected && applyExportTypes(detected)) {
251 await applying;
252 }
253 // From here on the entry reports its exports over HMR every time it is
254 // re-evaluated, which is how entrypoints added later get picked up.
255 for (const environment of entryEnvironments()) {
256 environment.hot.on(WORKER_EXPORT_TYPES_EVENT, (data: unknown) => {
257 if (!isExportTypes(data) || !applyExportTypes(data)) {
258 return;
259 }
260 server.config.logger.info(
261 "Worker exports changed, reloading the Worker runtime.",
262 {
263 timestamp: true,
264 },
265 );
266 });
267 }
268 }
269
270 if (!input) {
271 // If there is no input, we are in SPA mode, so we don't need to route requests to the server.
272 return;
273 }
274 bindWebSocket();
275 return () => {
276 server.middlewares.use(
277 function distilledCloudflareProxyMiddleware(req, res) {
278 const url = new NodeURL(
279 req.originalUrl ?? req.url ?? "/",
280 address.toString(),
281 );
282 const request = NodeHttp.request(url, {
283 method: req.method,
284 headers: proxyRequestHeaders(req, url, proxySharedSecret),
285 });
286 req.pipe(request);
287 request.on("response", (response) => {
288 res.writeHead(response.statusCode ?? 500, response.headers);
289 response.pipe(res);
290 });
291 // Without a listener a connection error is an unhandled `error`
292 // event, which takes down the dev server. Requests in flight while
293 // the Worker runtime is being replaced hit exactly that.
294 request.on("error", (error) => {
295 server.config.logger.error(
296 `Worker request failed: ${error.message}`,
297 {
298 error,
299 timestamp: true,
300 },
301 );
302 if (!res.headersSent) {
303 res.writeHead(502, { "content-type": "text/plain" });
304 }
305 res.end("Bad Gateway");
306 });
307 },
308 );
309 if (
310 options.dev?.middlewareOrder === "pre" &&
311 middlewareBoundary !== undefined
312 ) {
313 // Move the proxy middleware from the end of the stack to directly
314 // after Vite's internal middlewares, ahead of the post middlewares
315 // other plugins registered.
316 const stack = server.middlewares.stack;
317 const entry = stack.pop();
318 if (entry) {
319 stack.splice(middlewareBoundary, 0, entry);
320 }
321 }
322 };
323 },
324 };
325 plugins.push(plugin);
326 return plugins;
327}
328
329const hasConfigureServerHook = (plugin: vite.Plugin): boolean => {
330 const hook = plugin.configureServer;
331 if (hook == null) return false;
332 return typeof hook === "function" || typeof hook.handler === "function";
333};