1import { DEFAULT_COMPATIBILITY_DATE } from "../core/internal/constants.ts";
2import { loadInternalWorker } from "../core/internal/internal-worker.ts";
3import type { ExportTypes } from "../rolldown/export-types.ts";
4import { EXPORT_TYPES_MODULE_ID } from "../rolldown/export-types.ts";
5import { MODULE_REFERENCE_REGEX } from "../rolldown/plugins/index.ts";
6import type { BindingHooks, Module } from "../core/index.ts";
7import * as Runtime from "../core/Runtime.ts";
8import * as RuntimeServices from "../core/RuntimeServices.ts";
9import * as DurableObjectNamespace from "../core/bindings/DurableObjectNamespace.ts";
10import * as Json from "../core/bindings/Json.ts";
11import * as Loopback from "../core/bindings/Loopback.ts";
12import * as UnsafeEval from "../core/bindings/UnsafeEval.ts";
13import { PlatformServices } from "../Platform.ts";
14import * as Credentials from "@distilled.cloud/cloudflare/Credentials";
15import type * as Context from "effect/Context";
16import * as Effect from "effect/Effect";
17import * as Exit from "effect/Exit";
18import * as Layer from "effect/Layer";
19import * as Option from "effect/Option";
20import * as Scope from "effect/Scope";
21import * as FetchHttpClient from "effect/http/FetchHttpClient";
22import * as Headers from "effect/http/Headers";
23import * as HttpServerRequest from "effect/http/HttpServerRequest";
24import * as HttpServerResponse from "effect/http/HttpServerResponse";
25import * as NodeFs from "node:fs/promises";
26import * as NodeHttp from "node:http";
27import type * as vite from "vite";
28const ModuleRunnerWorker = {
29 worker: () =>
30 loadInternalWorker(
31 "#cloudflare-runtime-vite-worker/module-runner/module-runner.worker",
32 ),
33};
34const WrapperWorker = {
35 worker: () =>
36 loadInternalWorker(
37 "#cloudflare-runtime-vite-worker/module-runner/wrapper.worker",
38 ),
39};
40import * as ViteAssets from "./assets/ViteAssets.ts";
41import { renderExportWrappers } from "./export-types.ts";
42import type { EntryEnvironment } from "./module-runner/constants.shared.ts";
43import { ENVIRONMENT_NAME_HEADER } from "./module-runner/constants.shared.ts";
44import type { CloudflareVitePluginOptions } from "./plugin.ts";
45
46export type ServerHandle = Awaited<ReturnType<typeof startServer>>;
47
48export const startServer = async <B extends BindingHooks = BindingHooks>(
49 options: CloudflareVitePluginOptions<B>,
50 entryEnvironment: Omit<EntryEnvironment, "exportTypesId">,
51 server: vite.ViteDevServer,
52 context: Context.Context<RuntimeServices.RuntimeServices>,
53 exportTypes: ExportTypes,
54 /** See `RuntimeWorker.onRestart`. */
55 onRestart?: () => void,
56) => {
57 const scope = Scope.makeUnsafe();
58 const proxySharedSecret = crypto.randomUUID();
59 const address = await serve(
60 options,
61 entryEnvironment,
62 server,
63 exportTypes,
64 proxySharedSecret,
65 onRestart,
66 ).pipe(
67 // `provideMerge`: the assets layer's construction reads `Loopback` (and
68 // friends) from the runtime context, so the context must feed the layer,
69 // not just sit beside it.
70 Effect.provide(
71 ViteAssets.ViteAssetsLive(server).pipe(
72 Layer.provideMerge(Layer.succeedContext(context)),
73 ),
74 ),
75 Scope.provide(scope),
76 Effect.runPromise,
77 );
78 return {
79 address,
80 proxySharedSecret,
81 close: () => closeScope(scope),
82 };
83};
84
85export const createDefaultContext = async (): Promise<
86 Context.Context<RuntimeServices.RuntimeServices>
87> => {
88 const scope = Scope.makeUnsafe();
89
90 return await RuntimeServices.layerRuntime({
91 api: {
92 accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
93 },
94 }).pipe(
95 Layer.provideMerge(PlatformServices),
96 Layer.provide(Layer.merge(Credentials.fromEnv(), FetchHttpClient.layer)),
97 Layer.buildWithScope(scope),
98 Effect.runPromise,
99 );
100};
101
102const closeScope = async (scope: Scope.Closeable) => {
103 await Effect.runPromiseExit(
104 Scope.closeUnsafe(scope, Exit.void) ?? Effect.void,
105 );
106};
107
108const makeModuleFallbackService = Effect.gen(function* () {
109 const server = NodeHttp.createServer(async (req, res) => {
110 try {
111 const request = await parseModuleFallbackRequest(req);
112 if (!request) {
113 res.writeHead(400, { "Content-Type": "text/plain" });
114 res.end("Invalid module fallback request");
115 return;
116 }
117
118 const candidate = request.rawSpecifier ?? request.specifier;
119 const match = MODULE_REFERENCE_REGEX.exec(candidate);
120 if (!match) {
121 res.writeHead(400, { "Content-Type": "text/plain" });
122 res.end(`No match for module: ${candidate}`);
123 return;
124 }
125
126 const [, moduleType, modulePath] = match;
127 if (!moduleType || !modulePath) {
128 res.writeHead(400, { "Content-Type": "text/plain" });
129 res.end(`Invalid module type or path: ${match[0]}`);
130 return;
131 }
132
133 let content: Buffer;
134 try {
135 content = await NodeFs.readFile(modulePath);
136 } catch {
137 res.writeHead(404, { "Content-Type": "text/plain" });
138 res.end(`Module not found: ${modulePath}`);
139 return;
140 }
141
142 switch (moduleType) {
143 case "CompiledWasm": {
144 res.writeHead(200, { "Content-Type": "application/json" });
145 res.end(JSON.stringify({ wasm: Array.from(content) }));
146 return;
147 }
148 case "Data": {
149 res.writeHead(200, { "Content-Type": "application/json" });
150 res.end(JSON.stringify({ data: Array.from(content) }));
151 return;
152 }
153 case "Text": {
154 res.writeHead(200, { "Content-Type": "application/json" });
155 res.end(JSON.stringify({ text: content.toString() }));
156 return;
157 }
158 default: {
159 res.writeHead(400, { "Content-Type": "text/plain" });
160 res.end(`Invalid module type: ${moduleType}`);
161 }
162 }
163 } catch (error) {
164 res.writeHead(500, { "Content-Type": "text/plain" });
165 res.end(
166 error instanceof Error ? (error.stack ?? error.message) : String(error),
167 );
168 }
169 });
170
171 // workerd fetches every fallback module over this server. Node's default
172 // 5s `keepAliveTimeout` closes idle sockets between module bursts, forcing
173 // workerd to open a fresh TCP connection per burst — on Windows CI the
174 // resulting TIME_WAIT churn exhausts ephemeral ports (WSAENOBUFS #10055).
175 // Keep idle connections around for a minute so they get reused.
176 server.keepAliveTimeout = 60_000;
177 server.headersTimeout = 65_000;
178 yield* Effect.callback<void>((resume) => {
179 server.listen(0, "127.0.0.1", () => resume(Effect.void));
180 });
181 yield* Effect.addFinalizer(() =>
182 Effect.callback<void>((resume) => {
183 server.close(() => resume(Effect.void));
184 // `close()` waits for in-flight/keep-alive connections; destroy them so
185 // shutdown doesn't hang for up to `keepAliveTimeout`.
186 server.closeAllConnections();
187 }),
188 );
189
190 const address = server.address();
191 if (address === null || typeof address === "string") {
192 return yield* Effect.die(
193 new Error("Module fallback server address unavailable"),
194 );
195 }
196 return `127.0.0.1:${address.port}`;
197});
198
199const serve = Effect.fn(function* <B extends BindingHooks = BindingHooks>(
200 options: CloudflareVitePluginOptions<B>,
201 entryEnvironment: Omit<EntryEnvironment, "exportTypesId">,
202 server: vite.ViteDevServer,
203 exportTypes: ExportTypes,
204 proxySharedSecret: string,
205 onRestart?: () => void,
206) {
207 const runtime = yield* Runtime.Runtime;
208 const moduleFallback = yield* makeModuleFallbackService;
209
210 const name = options.worker?.name ?? `vite-dev-${crypto.randomUUID()}`;
211 return yield* runtime.start({
212 name,
213 proxySharedSecret,
214 onRestart,
215 modules: yield* Effect.promise(() => makeWorkerModules(exportTypes)),
216 compatibilityDate: options.compatibilityDate ?? DEFAULT_COMPATIBILITY_DATE,
217 compatibilityFlags: options.compatibilityFlags ?? [],
218 bindings: [
219 UnsafeEval.local("__DISTILLED_UNSAFE_EVAL__"),
220 DurableObjectNamespace.local({
221 binding: "__DISTILLED_MODULE_RUNNER__",
222 className: "ModuleRunnerDO",
223 }),
224 Json.local("__DISTILLED_ENVIRONMENT__", {
225 ...entryEnvironment,
226 exportTypesId: EXPORT_TYPES_MODULE_ID,
227 } satisfies EntryEnvironment),
228 Loopback.local({
229 binding: "__DISTILLED_INVOKE_MODULE__",
230 name: `vite:invoke-module:${name}`,
231 handler: Effect.gen(function* () {
232 const request = yield* HttpServerRequest.HttpServerRequest;
233 const targetEnvironment = Headers.get(
234 request.headers,
235 ENVIRONMENT_NAME_HEADER,
236 ).pipe(Option.getOrThrow);
237 const json = (yield* request.json) as unknown as vite.CustomPayload;
238 const devEnvironment = server.environments[targetEnvironment];
239 const result = yield* Effect.promise(
240 async () => await devEnvironment.hot.handleInvoke(json),
241 );
242 return HttpServerResponse.jsonUnsafe(result);
243 }),
244 }),
245 ...(options.worker?.bindings ?? []),
246 ],
247 durableObjectNamespaces: [
248 {
249 className: "ModuleRunnerDO",
250 sql: false,
251 ephemeralLocal: true,
252 },
253 ...(options.worker?.durableObjectNamespaces ?? []),
254 ],
255 workflows: options.worker?.workflows,
256 hyperdrives: options.worker?.hyperdrives,
257 // Without this the worker starts with no consumers, so a producer
258 // binding for a queue this worker consumes resolves to the dev-registry
259 // proxy instead of a local broker — and that accepts-and-drops every
260 // message, with `send()` never settling.
261 queueConsumers: options.worker?.queueConsumers,
262 crons: options.worker?.crons,
263 assets: options.worker?.assets,
264 unsafe: {
265 moduleFallback,
266 ...(options.worker?.unsafe ?? {}),
267 },
268 });
269});
270
271type ModuleFallbackRequest =
272 | {
273 protocol: "v1";
274 type: "import" | "require";
275 specifier: string;
276 rawSpecifier?: string;
277 referrer?: string;
278 }
279 | {
280 protocol: "v2";
281 type: "import" | "require" | "internal";
282 specifier: string;
283 rawSpecifier?: string;
284 referrer?: string;
285 attributes?: Array<{ name: string; value: string }>;
286 };
287
288const parseModuleFallbackRequest = async (
289 req: NodeHttp.IncomingMessage,
290): Promise<ModuleFallbackRequest | undefined> => {
291 if (req.method === "GET" && req.headers["x-resolve-method"]) {
292 const url = new URL(req.url ?? "", "http://localhost");
293 const specifier = url.searchParams.get("specifier");
294 if (!specifier) {
295 return;
296 }
297 const resolveMethod = req.headers["x-resolve-method"];
298 return {
299 protocol: "v1",
300 type: resolveMethod === "require" ? "require" : "import",
301 specifier,
302 rawSpecifier: url.searchParams.get("rawSpecifier") ?? undefined,
303 referrer: url.searchParams.get("referrer") ?? undefined,
304 };
305 }
306
307 if (req.method === "POST") {
308 const chunks: Array<Buffer> = [];
309 for await (const chunk of req) {
310 chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
311 }
312 const json: unknown = JSON.parse(Buffer.concat(chunks).toString());
313 if (typeof json === "object" && json !== null && "specifier" in json) {
314 return {
315 protocol: "v2",
316 ...(json as Omit<
317 Extract<ModuleFallbackRequest, { protocol: "v2" }>,
318 "protocol"
319 >),
320 };
321 }
322 }
323};
324
325async function makeWorkerModules(
326 exportTypes: ExportTypes,
327): Promise<Array<Module>> {
328 const [moduleRunnerWorker, wrapperWorker] = await Promise.all([
329 ModuleRunnerWorker.worker(),
330 WrapperWorker.worker(),
331 ]);
332 const modules = {
333 "index.worker.mjs": [
334 `import { createWorkerEntrypointWrapper, createDurableObjectWrapper, createWorkflowEntrypointWrapper } from "./module-runner/wrapper.worker.mjs";`,
335 'export { ModuleRunnerDO } from "./module-runner/module-runner.worker.mjs";',
336 'export default createWorkerEntrypointWrapper("default");',
337 ...renderExportWrappers(exportTypes),
338 ].join("\n"),
339 ...moduleRunnerWorker.modules,
340 ...wrapperWorker.modules,
341 };
342 return Object.entries(modules).map(([name, content]) => ({
343 name,
344 type: "ESModule",
345 content,
346 }));
347}