1// Alchemy modifications are licensed under Apache-2.0.
2// This file includes third-party code; see /THIRD_PARTY_LICENSES.md.
3import { loadInternalWorker } from "../../core/internal/internal-worker.ts";
4import * as Assets from "../../core/bindings/assets/Assets.ts";
5import { DEFAULT_COMPATIBILITY_DATE } from "../../core/internal/constants.ts";
6import * as Loopback from "../../core/globals/Loopback.ts";
7import { PluginContext } from "../../core/PluginContext.ts";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import * as NodeFs from "node:fs/promises";
11import type { IncomingMessage, ServerResponse } from "node:http";
12import type * as vite from "vite";
13const AssetsWorker = {
14 worker: () =>
15 loadInternalWorker("#cloudflare-runtime-vite-worker/assets/assets.worker"),
16};
17const RouterWorker = {
18 worker: () =>
19 loadInternalWorker("#cloudflare-runtime-vite-worker/assets/router.worker"),
20};
21/**
22 * Marker prefix used by the vite-aware asset worker to indicate that an
23 * HTML path lives inside Vite's `publicDir` (and so should be served
24 * verbatim, without running through `transformIndexHtml`).
25 */
26const PUBLIC_DIR_PREFIX = "__VITE_PUBLIC_DIR__";
27
28/**
29 * Asset plugin layer wired to a Vite dev server.
30 *
31 * Unlike {@link Assets.AssetsLive}, this implementation does not build a
32 * disk-backed asset manifest or KV namespace at all: the vite-aware asset
33 * worker overrides `unstable_exists` / `unstable_getByETag` to delegate
34 * every HTML lookup back to Vite via Loopback bindings, so HMR,
35 * `transformIndexHtml`, virtual routes, and files in `publicDir` all
36 * resolve through the dev server instead of through a static snapshot.
37 *
38 * Mirrors the dev-mode asset wiring in
39 * `upstream/workers-sdk/packages/vite-plugin-cloudflare/src/miniflare-options.ts`.
40 */
41export const ViteAssetsLive = (viteDevServer: vite.ViteDevServer) =>
42 Layer.effect(
43 Assets.Assets,
44 Effect.gen(function* () {
45 const loopback = yield* Loopback.Loopback;
46
47 if (Effect.isEffect(loopback)) {
48 return yield* Effect.die("Expected loopback to be initialized");
49 }
50
51 return Assets.Assets.of(
52 Effect.gen(function* () {
53 const { worker } = yield* PluginContext;
54 const { assetsConfig, routerConfig } =
55 yield* Assets.buildAssetConfigs(worker);
56
57 const prefix = `vite-assets:${encodeURIComponent(worker.name)}`;
58 const htmlExistsService = yield* loopback.api.route(
59 `${prefix}:html-exists`,
60 viteHtmlExistsHandler(viteDevServer),
61 );
62 const fetchHtmlService = yield* loopback.api.route(
63 `${prefix}:fetch-html`,
64 viteFetchHtmlHandler(viteDevServer),
65 );
66 const [assetsWorker, routerWorker] = yield* Effect.forEach(
67 [AssetsWorker, RouterWorker],
68 (worker) =>
69 Effect.map(Effect.promise(worker.worker), ({ modules }) =>
70 modulesToWorkerd(modules),
71 ),
72 { concurrency: "unbounded" },
73 );
74
75 return {
76 services: [
77 {
78 name: "assets:worker",
79 worker: {
80 compatibilityDate: DEFAULT_COMPATIBILITY_DATE,
81 // Node.js compatibility is supplied by the 2026-08-31
82 // internal compatibility date.
83 bindings: [
84 {
85 name: "CONFIG",
86 json: JSON.stringify(assetsConfig),
87 },
88 {
89 name: "__VITE_HEADERS__",
90 json: JSON.stringify(
91 viteDevServer.config.server.headers ?? {},
92 ),
93 },
94 {
95 name: "__VITE_HTML_EXISTS__",
96 service: htmlExistsService,
97 },
98 {
99 name: "__VITE_FETCH_HTML__",
100 service: fetchHtmlService,
101 },
102 ],
103 modules: assetsWorker,
104 },
105 },
106 ],
107 middlewares: [
108 {
109 name: "assets:router",
110 worker: {
111 compatibilityDate: DEFAULT_COMPATIBILITY_DATE,
112 // The date supplies nodejs_compat; this worker deliberately
113 // retains only its v2 opt-out.
114 compatibilityFlags: ["no_nodejs_compat_v2"],
115 bindings: [
116 {
117 name: "ASSET_WORKER",
118 service: { name: "assets:worker" },
119 },
120 {
121 name: "CONFIG",
122 json: JSON.stringify(routerConfig),
123 },
124 ],
125 modules: routerWorker,
126 },
127 upstreamBindingName: "USER_WORKER",
128 order: -1,
129 },
130 ],
131 api: { isConfigured: true },
132 };
133 }),
134 );
135 }),
136 );
137
138/**
139 * Returns the resolved on-disk path for an HTML pathname (or `null`).
140 * Files under Vite's `publicDir` are prefixed with `PUBLIC_DIR_PREFIX` so
141 * the companion handler knows to skip `transformIndexHtml`.
142 */
143const viteHtmlExistsHandler =
144 (viteDevServer: vite.ViteDevServer) =>
145 async (req: IncomingMessage, res: ServerResponse) => {
146 const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
147 const json = (body: unknown) => {
148 res.writeHead(200, { "content-type": "application/json" });
149 res.end(JSON.stringify(body));
150 };
151
152 if (!pathname.endsWith(".html")) {
153 return json(null);
154 }
155
156 const { root, publicDir } = viteDevServer.config;
157 const publicDirInRoot = publicDir.startsWith(withTrailingSlash(root));
158 const publicPath = withTrailingSlash(publicDir.slice(root.length));
159
160 // Files served from the public dir are addressed from the root path.
161 if (publicDirInRoot && pathname.startsWith(publicPath)) {
162 return json(null);
163 }
164
165 const publicDirFilePath = `${publicDir}${pathname}`;
166 const rootDirFilePath = `${root}${pathname}`;
167
168 for (const resolved of [publicDirFilePath, rootDirFilePath]) {
169 try {
170 const stats = await NodeFs.stat(resolved);
171 if (stats.isFile()) {
172 return json(
173 resolved === publicDirFilePath
174 ? `${PUBLIC_DIR_PREFIX}${pathname}`
175 : pathname,
176 );
177 }
178 } catch {
179 // continue
180 }
181 }
182 return json(null);
183 };
184
185/**
186 * Returns the HTML body for a path previously resolved by
187 * {@link viteHtmlExistsHandler}. Non-public HTML is run through
188 * `viteDevServer.transformIndexHtml` so that HMR and plugin transforms
189 * apply.
190 */
191const viteFetchHtmlHandler =
192 (viteDevServer: vite.ViteDevServer) =>
193 async (req: IncomingMessage, res: ServerResponse) => {
194 const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
195 const { root, publicDir } = viteDevServer.config;
196 const isInPublicDir = pathname.startsWith(PUBLIC_DIR_PREFIX);
197 const resolved = isInPublicDir
198 ? `${publicDir}${pathname.slice(PUBLIC_DIR_PREFIX.length)}`
199 : `${root}${pathname}`;
200
201 try {
202 let html = await NodeFs.readFile(resolved, "utf-8");
203 if (!isInPublicDir) {
204 html = await viteDevServer.transformIndexHtml(resolved, html);
205 }
206 res.writeHead(200, { "content-type": "text/html" });
207 res.end(html);
208 } catch {
209 res.writeHead(500, { "content-type": "text/plain" });
210 res.end(`Unexpected error. Failed to load "${pathname}".`);
211 }
212 };
213
214const withTrailingSlash = (s: string) => (s.endsWith("/") ? s : `${s}/`);
215
216const modulesToWorkerd = (modules: Record<string, string>) =>
217 Object.entries(modules).map(([name, esModule]) => ({
218 name,
219 esModule,
220 }));