| 1 | import { parseViteEnvironments } from "../rolldown/options.ts"; |
| 2 | import type { OptionsApi } from "../rolldown/plugins/index.ts"; |
| 3 | import { resolvePluginApi } from "../rolldown/utils.ts"; |
| 4 | import * as NodeFs from "node:fs"; |
| 5 | import * as NodeHttp from "node:http"; |
| 6 | import * as NodePath from "node:path"; |
| 7 | import { URL as NodeURL } from "node:url"; |
| 8 | import type * as vite from "vite"; |
| 9 | import { proxyRequestHeaders } from "./forwarded-host.ts"; |
| 10 | import type { CloudflareVitePluginOptions } from "./plugin.ts"; |
| 11 | import { handleWebSocket } from "./websockets.ts"; |
| 12 | |
| 13 | /** |
| 14 | * Preview mode: serve the freshly built worker through workerd. |
| 15 | * |
| 16 | * `vite preview` (and any framework that drives it programmatically — e.g. |
| 17 | * waku's SSG step, which boots a preview server mid-`buildApp` and renders |
| 18 | * every static page through it) resolves the same plugins as the build. This |
| 19 | * plugin's `configurePreviewServer` hook reads the built worker output from |
| 20 | * the entry environment's `build.outDir`, boots workerd over it via |
| 21 | * `cloudflare-runtime`, and registers a proxy middleware ahead of Vite's |
| 22 | * internal middlewares so every request — static assets included, via the |
| 23 | * runtime's assets plugin — is handled exactly as on the deployed worker. |
| 24 | * |
| 25 | * This mirrors what upstream `@cloudflare/vite-plugin` provides via its own |
| 26 | * `configurePreviewServer` (miniflare over the built output), and is what |
| 27 | * makes SSG-in-workerd work: a top-level `import { env } from |
| 28 | * "cloudflare:workers"` in a prerendered page module loads fine, because the |
| 29 | * page renders inside workerd rather than the Node process. |
| 30 | */ |
| 31 | export function preview(options: CloudflareVitePluginOptions): vite.Plugin { |
| 32 | return { |
| 33 | name: "distilled-cloudflare:preview", |
| 34 | async configurePreviewServer(server) { |
| 35 | const config = server.config; |
| 36 | const optionsApi = resolvePluginApi<OptionsApi>( |
| 37 | config.plugins, |
| 38 | "distilled-cloudflare:options", |
| 39 | ); |
| 40 | if (!optionsApi) { |
| 41 | throw new Error( |
| 42 | "Cannot resolve the distilled-cloudflare:options plugin", |
| 43 | ); |
| 44 | } |
| 45 | const input = optionsApi.input(); |
| 46 | const inputNames = Object.keys(input); |
| 47 | if (inputNames.length === 0) { |
| 48 | // SPA mode: no worker entry — leave the preview server to Vite's |
| 49 | // static file serving. |
| 50 | return; |
| 51 | } |
| 52 | if (inputNames.length > 1) { |
| 53 | throw new Error( |
| 54 | `Expected exactly one entry in the input, got ${inputNames.length} entries: ${JSON.stringify(input)}`, |
| 55 | ); |
| 56 | } |
| 57 | const [entryEnvironmentName] = parseViteEnvironments(options); |
| 58 | const entryEnvironment = config.environments[entryEnvironmentName!]; |
| 59 | if (!entryEnvironment) { |
| 60 | throw new Error( |
| 61 | `Cannot resolve the "${entryEnvironmentName}" environment from the preview config`, |
| 62 | ); |
| 63 | } |
| 64 | const directory = NodePath.resolve( |
| 65 | config.root, |
| 66 | entryEnvironment.build.outDir, |
| 67 | ); |
| 68 | const entryModule = findEntryModule(directory, inputNames[0]!); |
| 69 | const clientEnvironment = config.environments["client"]; |
| 70 | const assetsDirectory = clientEnvironment |
| 71 | ? NodePath.resolve(config.root, clientEnvironment.build.outDir) |
| 72 | : undefined; |
| 73 | |
| 74 | const { startPreviewServer } = await import("./preview-server.ts"); |
| 75 | const handle = await startPreviewServer(options, { |
| 76 | directory, |
| 77 | entryModule, |
| 78 | assetsDirectory: |
| 79 | assetsDirectory !== undefined && NodeFs.existsSync(assetsDirectory) |
| 80 | ? assetsDirectory |
| 81 | : undefined, |
| 82 | }); |
| 83 | const address = handle.address; |
| 84 | const removeUpgradeListener = server.httpServer |
| 85 | ? handleWebSocket(server.httpServer, address, handle.proxySharedSecret) |
| 86 | : undefined; |
| 87 | const close = server.close.bind(server); |
| 88 | server.close = async () => { |
| 89 | removeUpgradeListener?.(); |
| 90 | await handle.close(); |
| 91 | await close(); |
| 92 | }; |
| 93 | // Registered directly (not via the returned post hook), so the proxy |
| 94 | // runs ahead of Vite's internal static-file middlewares and of any |
| 95 | // middleware a framework appends afterwards (e.g. waku's Node SSG |
| 96 | // fallback) — the worker handles every request, like in production. |
| 97 | server.middlewares.use( |
| 98 | function distilledCloudflarePreviewMiddleware(req, res) { |
| 99 | const url = new NodeURL(req.url ?? "/", address.toString()); |
| 100 | const request = NodeHttp.request(url, { |
| 101 | method: req.method, |
| 102 | headers: proxyRequestHeaders(req, url, handle.proxySharedSecret), |
| 103 | }); |
| 104 | req.pipe(request); |
| 105 | request.on("response", (response) => { |
| 106 | res.writeHead(response.statusCode ?? 500, response.headers); |
| 107 | response.pipe(res); |
| 108 | }); |
| 109 | }, |
| 110 | ); |
| 111 | }, |
| 112 | }; |
| 113 | } |
| 114 | |
| 115 | /** |
| 116 | * Locate the built entry chunk for the (single) worker input. Entry chunks |
| 117 | * are emitted as `[name].js` (Vite's server-build default `entryFileNames`); |
| 118 | * `.mjs` is accepted for configs that override the extension. |
| 119 | */ |
| 120 | const findEntryModule = (directory: string, inputName: string): string => { |
| 121 | const candidates = [`${inputName}.js`, `${inputName}.mjs`]; |
| 122 | for (const candidate of candidates) { |
| 123 | if (NodeFs.existsSync(NodePath.join(directory, candidate))) { |
| 124 | return candidate; |
| 125 | } |
| 126 | } |
| 127 | throw new Error( |
| 128 | `Cannot find the built worker entry (${candidates.join(" or ")}) in "${directory}". ` + |
| 129 | "Run the build before starting the preview server.", |
| 130 | ); |
| 131 | }; |