1import { parseViteEnvironments } from "../rolldown/options.ts";
2import type { OptionsApi } from "../rolldown/plugins/index.ts";
3import { resolvePluginApi } from "../rolldown/utils.ts";
4import * as NodeFs from "node:fs";
5import * as NodeHttp from "node:http";
6import * as NodePath from "node:path";
7import { URL as NodeURL } from "node:url";
8import type * as vite from "vite";
9import { proxyRequestHeaders } from "./forwarded-host.ts";
10import type { CloudflareVitePluginOptions } from "./plugin.ts";
11import { 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 */
31export 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 */
120const 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};