1import { DEFAULT_COMPATIBILITY_DATE } from "../core/internal/constants.ts";
2import type { BindingHooks, Module } from "../core/index.ts";
3import * as Runtime from "../core/Runtime.ts";
4import * as RuntimeServices from "../core/RuntimeServices.ts";
5import { PlatformServices } from "../Platform.ts";
6import * as Credentials from "@distilled.cloud/cloudflare/Credentials";
7import * as Effect from "effect/Effect";
8import * as Exit from "effect/Exit";
9import * as Layer from "effect/Layer";
10import * as Scope from "effect/Scope";
11import * as FetchHttpClient from "effect/http/FetchHttpClient";
12import * as NodeFs from "node:fs/promises";
13import * as NodePath from "node:path";
14import type { CloudflareVitePluginOptions } from "./plugin.ts";
15
16/**
17 * The freshly built worker a preview server hosts: which directory holds the
18 * server modules, which of them is the entry, and where the client assets
19 * live. Derived by the preview plugin from the resolved Vite config.
20 */
21export interface PreviewWorkerBuild {
22 /**
23 * Absolute directory containing the built server modules (the entry
24 * environment's `build.outDir`). Every file under it (recursively) is
25 * loaded into workerd as a module, so relative imports between chunks —
26 * including nested child-environment output like waku's `server/ssr` —
27 * resolve exactly as they would on the deployed worker.
28 */
29 readonly directory: string;
30 /** The entry module's name, relative to `directory` (POSIX separators). */
31 readonly entryModule: string;
32 /** Absolute directory of the built client assets, if any. */
33 readonly assetsDirectory?: string | undefined;
34}
35
36export interface PreviewServerHandle {
37 readonly address: URL;
38 readonly proxySharedSecret: string;
39 readonly close: () => Promise<void>;
40}
41
42/**
43 * Boot workerd (via `cloudflare-runtime`'s `Runtime.start`) over the built
44 * worker output on disk. Unlike the dev server there is no module runner and
45 * no module fallback: the build is self-contained, so every module is read
46 * from the output directory upfront (entry first — workerd treats the first
47 * module as the main module) and handed to workerd directly. Assets are
48 * served by the runtime's disk-backed assets plugin from the client build
49 * directory.
50 *
51 * When the plugin options carry no `context`, a runtime context is built
52 * into the preview's own scope and torn down by `close()` — unlike the dev
53 * server's process-lifetime cached context. This matters for build-time SSG
54 * (waku boots a preview server mid-`buildApp`): a lingering context's open
55 * handles would keep the build process alive after the build completes.
56 */
57export const startPreviewServer = async <B extends BindingHooks = BindingHooks>(
58 options: CloudflareVitePluginOptions<B>,
59 build: PreviewWorkerBuild,
60): Promise<PreviewServerHandle> => {
61 const scope = Scope.makeUnsafe();
62 const proxySharedSecret = crypto.randomUUID();
63 // Only sweep handles for a context we build (and tear down) ourselves; a
64 // caller-provided context is process-lifetime by design (dev semantics).
65 const sweep = options.context === undefined ? makeHandleSweep() : undefined;
66 try {
67 const context =
68 options.context ??
69 (await makePreviewContext().pipe(
70 Layer.buildWithScope(scope),
71 Effect.runPromise,
72 ));
73 const address = await serve(options, build, proxySharedSecret).pipe(
74 Effect.provide(context),
75 Scope.provide(scope),
76 Effect.runPromise,
77 );
78 return {
79 address,
80 proxySharedSecret,
81 close: async () => {
82 await closeScope(scope);
83 sweep?.();
84 },
85 };
86 } catch (error) {
87 await closeScope(scope);
88 sweep?.();
89 throw error;
90 }
91};
92
93/**
94 * Workaround for a `cloudflare-runtime` teardown gap: building the runtime
95 * context spawns a detached, uninterruptible fiber (`DockerLive`) that binds
96 * a Docker proxy server (`server.listen(0)`) and probes the local Docker
97 * daemon with no finalizer — so a build process that booted a preview server
98 * for SSG can never exit on its own, even after the runtime scope closes.
99 *
100 * Until the runtime tears that fiber down (or starts it lazily), snapshot
101 * the process's libuv handles before the context is built and, on close,
102 * `unref()` whatever appeared since and survived scope teardown. `unref` is
103 * strictly about process exit: leaked handles keep working for as long as
104 * the process lives, they just stop pinning the event loop. Handles we own
105 * are already closed by the scope; the caller's preview http server is
106 * closed by the caller immediately after, so unref-ing it is harmless.
107 */
108const makeHandleSweep = (): (() => void) => {
109 const getHandles = (): Array<{ unref?: () => void }> => {
110 const process_ = process as unknown as {
111 _getActiveHandles?: () => Array<{ unref?: () => void }>;
112 };
113 try {
114 return process_._getActiveHandles?.() ?? [];
115 } catch {
116 return [];
117 }
118 };
119 const before = new Set(getHandles());
120 return () => {
121 for (const handle of getHandles()) {
122 if (before.has(handle)) continue;
123 try {
124 handle.unref?.();
125 } catch {
126 // best-effort: exiting cleanly matters more than the odd handle
127 }
128 }
129 };
130};
131
132const makePreviewContext = () =>
133 RuntimeServices.layerRuntime({
134 api: {
135 accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
136 },
137 }).pipe(
138 Layer.provideMerge(PlatformServices),
139 Layer.provide(Layer.merge(Credentials.fromEnv(), FetchHttpClient.layer)),
140 );
141
142const closeScope = async (scope: Scope.Closeable) => {
143 await Effect.runPromiseExit(
144 Scope.closeUnsafe(scope, Exit.void) ?? Effect.void,
145 );
146};
147
148// Deliberately non-generic: `CloudflareVitePluginOptions<BindingHooks>` is a
149// supertype of every instantiation, and `BindingRequirements<BindingHooks>`
150// collapses to `never`, so the effect's requirements are fully discharged by
151// the provided runtime context + scope. Keeping the caller's `B` generic here
152// would leave a deferred `BindingRequirements<B>` in the requirements that
153// `Effect.runPromise` cannot prove away.
154const serve = Effect.fn(function* (
155 options: CloudflareVitePluginOptions,
156 build: PreviewWorkerBuild,
157 proxySharedSecret: string,
158) {
159 const runtime = yield* Runtime.Runtime;
160 const modules = yield* Effect.promise(() => readWorkerModules(build));
161 const assetsDirectory =
162 options.worker?.assets?.directory ?? build.assetsDirectory;
163 return yield* runtime.start({
164 name: options.worker?.name ?? `vite-preview-${crypto.randomUUID()}`,
165 modules,
166 proxySharedSecret,
167 compatibilityDate: options.compatibilityDate ?? DEFAULT_COMPATIBILITY_DATE,
168 compatibilityFlags: options.compatibilityFlags ?? [],
169 bindings: options.worker?.bindings ?? [],
170 durableObjectNamespaces: options.worker?.durableObjectNamespaces,
171 hyperdrives: options.worker?.hyperdrives,
172 queueConsumers: options.worker?.queueConsumers,
173 assets:
174 options.worker?.assets !== undefined || assetsDirectory !== undefined
175 ? { ...options.worker?.assets, directory: assetsDirectory }
176 : undefined,
177 logging: options.worker?.logging,
178 unsafe: options.worker?.unsafe,
179 });
180});
181
182/**
183 * Read the built server output into workerd modules, entry first. Source maps
184 * are skipped; everything else is typed by extension so wasm/text/data
185 * modules emitted next to the chunks keep working.
186 */
187export const readWorkerModules = async (
188 build: PreviewWorkerBuild,
189): Promise<Array<Module>> => {
190 const entries = await NodeFs.readdir(build.directory, {
191 recursive: true,
192 withFileTypes: true,
193 });
194 const modules = await Promise.all(
195 entries
196 .filter((entry) => entry.isFile())
197 .map((entry) => {
198 const file = NodePath.join(entry.parentPath, entry.name);
199 const name = NodePath.relative(build.directory, file).replaceAll(
200 "\\",
201 "/",
202 );
203 return readWorkerModule(file, name);
204 }),
205 );
206 const found = modules.filter(
207 (module): module is Module => module !== undefined,
208 );
209 const entryIndex = found.findIndex(
210 (module) => module.name === build.entryModule,
211 );
212 if (entryIndex === -1) {
213 throw new Error(
214 `Cannot find the worker entry module "${build.entryModule}" in "${build.directory}". ` +
215 "Run the build before starting the preview server.",
216 );
217 }
218 const [entry] = found.splice(entryIndex, 1);
219 return [entry!, ...found];
220};
221
222const readWorkerModule = async (
223 file: string,
224 name: string,
225): Promise<Module | undefined> => {
226 switch (NodePath.extname(file)) {
227 case ".map":
228 return undefined;
229 case ".js":
230 case ".mjs":
231 return {
232 name,
233 type: "ESModule",
234 content: await NodeFs.readFile(file, "utf8"),
235 };
236 case ".cjs":
237 return {
238 name,
239 type: "CommonJsModule",
240 content: await NodeFs.readFile(file, "utf8"),
241 };
242 case ".json":
243 return {
244 name,
245 type: "Json",
246 content: await NodeFs.readFile(file, "utf8"),
247 };
248 case ".txt":
249 case ".html":
250 case ".css":
251 case ".sql":
252 return {
253 name,
254 type: "Text",
255 content: await NodeFs.readFile(file, "utf8"),
256 };
257 case ".wasm":
258 return {
259 name,
260 type: "Wasm",
261 content: new Uint8Array(await NodeFs.readFile(file)),
262 };
263 default:
264 return {
265 name,
266 type: "Data",
267 content: new Uint8Array(await NodeFs.readFile(file)),
268 };
269 }
270};