| 1 | import type { ExportTypes } from "../rolldown/export-types.ts"; |
| 2 | import { |
| 3 | haveExportTypesChanged, |
| 4 | isExportTypes, |
| 5 | WORKER_EXPORT_TYPES_EVENT, |
| 6 | } from "../rolldown/export-types.ts"; |
| 7 | import { parseViteEnvironments } from "../rolldown/options.ts"; |
| 8 | import type { OptionsApi } from "../rolldown/plugins/index.ts"; |
| 9 | import { workerEntryId } from "../rolldown/plugins/index.ts"; |
| 10 | import { resolvePluginApi } from "../rolldown/utils.ts"; |
| 11 | import type { RuntimeServices } from "../core/index.ts"; |
| 12 | import type * as Context from "effect/Context"; |
| 13 | import * as NodeHttp from "node:http"; |
| 14 | import { URL as NodeURL } from "node:url"; |
| 15 | import * as vite from "vite"; |
| 16 | import { DistilledDevEnvironment } from "./dev-environment.ts"; |
| 17 | import type { ServerHandle } from "./dev-server.ts"; |
| 18 | import { configuredExportTypes, mergeExportTypes } from "./export-types.ts"; |
| 19 | import { proxyRequestHeaders } from "./forwarded-host.ts"; |
| 20 | import type { CloudflareVitePluginOptions } from "./plugin.ts"; |
| 21 | import { handleWebSocket } from "./websockets.ts"; |
| 22 | |
| 23 | let context: Context.Context<RuntimeServices> | undefined; |
| 24 | |
| 25 | export 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 | |
| 329 | const 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 | }; |