1/**
2 * `Framework` implementation driving Vocs on Cloudflare Workers.
3 *
4 * Vocs 2.x is built on waku (its `vocs()` vite plugin composes waku's own
5 * `waku/vite-plugins` — environments, adapter-alias, static-build, ... — with
6 * vocs's mdx/config/patch plugins), but it does NOT use waku's
7 * `unstable_combinedPlugins`, so `@alchemy.run/frontend-frameworks/waku`'s Framework layer
8 * cannot drive it directly. This layer mirrors that package's orchestration
9 * (see packages/frontend-frameworks/src/waku/Waku.ts) with vocs's plugin stack swapped in:
10 *
11 * - the deploy-target halves (wrangler-free adapter fork + cloudflare vite
12 * plugin pinned to waku's rsc entry) come from
13 * `@alchemy.run/frontend-frameworks/waku/cloudflare` — vocs uses waku's environments
14 * plugin, so the same rsc/ssr topology applies;
15 * - the plugin stack is the one vocs's own CLI assembles
16 * (`[react(), vocs()]`), with the target's plugins injected ahead of
17 * vocs/waku's (the position where the workerd proxy middleware registers
18 * before waku's Node request bridge) and the adapter selected via vocs's
19 * `unstable_adapter` passthrough;
20 * - the waku-parity vite config that `@alchemy.run/frontend-frameworks/waku` carries through
21 * the in-memory waku config (dedupe, workerd optimizeDeps, neutral rolldown
22 * platform) rides the inline vite config instead, since vocs owns the waku
23 * config it builds internally.
24 */
25import * as FrameworkCore from "../core/index.ts";
26import { WAKU_SERVER_ENTRY_MODULE } from "../waku/Waku.ts";
27import * as Effect from "effect/Effect";
28import * as FileSystem from "effect/FileSystem";
29import * as Layer from "effect/Layer";
30import * as Path from "effect/Path";
31import type * as vite from "vite";
32import {
33 selectVocsTargetInput,
34 type VocsTarget,
35 type VocsTargetOption,
36} from "./Target.ts";
37
38type ReactPluginModule = typeof import("@vitejs/plugin-react");
39type VocsViteModule = typeof import("vocs/vite");
40
41interface VocsProjectModules {
42 readonly bundler: typeof vite;
43 readonly react: ReactPluginModule;
44 readonly vite: VocsViteModule;
45}
46
47export interface VocsFrameworkOptions {
48 /** Deploy target. Defaults to `@alchemy.run/frontend-frameworks/vocs/cloudflare`. */
49 readonly target?: VocsTargetOption | undefined;
50 /** @deprecated Target configuration alias retained for the E2E harness. */
51 readonly vite?: unknown;
52 /** Project root. Defaults to the operation root or current working directory. */
53 readonly root?: string;
54 /**
55 * Default dev-server port, used when `dev` is called without an explicit
56 * port (e.g. the Playwright dev fixture). Non-strict: if the port is taken,
57 * vite falls back to the next free one. A port passed to `dev` directly
58 * (`e2e dev --port N`) is strict and takes precedence.
59 */
60 readonly port?: number | undefined;
61}
62
63/**
64 * `NODE_ENV` as it was when this module first loaded (see
65 * packages/frontend-frameworks/src/waku/Waku.ts for the rationale: build and dev may run in the
66 * same long-lived playwright worker process).
67 */
68const INITIAL_NODE_ENV = process.env.NODE_ENV;
69
70const PREVIEW_SERVER_GLOBAL = "__WAKU_START_PREVIEW_SERVER__";
71
72/** The shape waku's `unstable_startPreviewServer` expects the global to produce. */
73interface WakuPreviewServer {
74 readonly baseUrl: string;
75 readonly middlewares: {
76 readonly use: (
77 fn: (req: unknown, res: unknown, next: (err?: unknown) => void) => void,
78 ) => void;
79 };
80 readonly close: () => Promise<void>;
81}
82
83/**
84 * Waku-parity inline vite config. `@alchemy.run/frontend-frameworks/waku` injects these via
85 * the in-memory waku config's `vite` field; vocs builds that config itself
86 * with `vite: {}`, so they ride the top-level inline config here (vite merges
87 * inline config with plugin-contributed config).
88 */
89const sharedViteConfig = (): vite.InlineConfig => ({
90 resolve: {
91 dedupe: ["waku", "hono"],
92 },
93 environments: {
94 rsc: {
95 optimizeDeps: { include: ["hono/tiny"] },
96 build: { rolldownOptions: { platform: "neutral" } },
97 },
98 ssr: {
99 optimizeDeps: { include: ["waku > rsc-html-stream/server"] },
100 build: { rolldownOptions: { platform: "neutral" } },
101 },
102 },
103});
104
105const VIRTUAL_USER_CONFIG = "virtual:alchemy-vocs/user-config";
106const RESOLVED_VIRTUAL_USER_CONFIG = `\0${VIRTUAL_USER_CONFIG}`;
107
108/**
109 * Bridges vocs's runtime config resolution onto workerd.
110 *
111 * Vocs's server code calls `Config.resolve({ server: true })` at request time
112 * (middleware, api routes, the ssr entry). In production that branch assumes
113 * the Node server layout — `import.meta.dirname` + an on-disk
114 * `dist/server/vocs.config.js` — neither of which exists inside workerd
115 * (upstream vocs has node/vercel/netlify adapters only; there is no workers
116 * deploy path to mirror). This plugin:
117 *
118 * 1. asks Vocs to discover the project's `vocs.config.*` file;
119 * 2. exposes that file through a virtual module, so Vite loads and bundles
120 * the original config module (including its imports and functions);
121 * 3. transforms Vocs's Node-only runtime lookup to import the bundled module
122 * and guards the `process.cwd()` fallback for workerd.
123 *
124 * This follows Vocs's own production design, which emits the config as
125 * `dist/server/vocs.config.js`, without separately resolving or serializing
126 * the user's configuration in Alchemy.
127 */
128const workerdConfigBridge = (configPath: string | undefined): vite.Plugin => {
129 return {
130 name: "@alchemy.run/frontend-frameworks/vocs:workerd-config-bridge",
131 resolveId(id) {
132 if (id === VIRTUAL_USER_CONFIG) return RESOLVED_VIRTUAL_USER_CONFIG;
133 return;
134 },
135 load(id) {
136 if (id === RESOLVED_VIRTUAL_USER_CONFIG) {
137 return configPath === undefined
138 ? "export default {};"
139 : `export { default } from ${JSON.stringify(configPath)};`;
140 }
141 return;
142 },
143 transform(code, id) {
144 // In the production build the modules are vocs's shipped
145 // `dist/internal/*.js`; in dev, vite serves vocs's TS sources
146 // (`src/internal/*.ts`) through the module runner, so both shapes (and
147 // the raw-TS formatting, which user plugins see before vite's esbuild
148 // transform) must match.
149 // Strip the query (dev serves ids like `.../config.js?v=<hash>`).
150 const normalized = (id.split("?")[0] ?? id).replaceAll("\\", "/");
151 const isVocsInternal = (name: string) =>
152 normalized.endsWith(`/vocs/dist/internal/${name}.js`) ||
153 normalized.endsWith(`/vocs/src/internal/${name}.ts`);
154 const mustReplace = (
155 source: string,
156 pattern: RegExp,
157 replacement: string,
158 ): string => {
159 if (!pattern.test(source)) {
160 throw new Error(
161 `@alchemy.run/frontend-frameworks/vocs: ${normalized} no longer matches the workerd config bridge ` +
162 `pattern ${pattern} — update the transform in packages/frontend-frameworks/src/vocs/Vocs.ts ` +
163 "for the installed vocs version",
164 );
165 }
166 return source.replace(pattern, replacement);
167 };
168 // `deserializeFunctions` revives `_vocs-fn_`-serialized config functions
169 // with `new Function`, which workerd forbids. Vocs' server runtime does
170 // not need the browser-side search callbacks that take this path, so
171 // degrade them to `undefined` when dynamic evaluation is unavailable.
172 // Node paths (SSG and dev tooling) still revive them normally.
173 if (isVocsInternal("config-serializer")) {
174 return mustReplace(
175 code,
176 // Avoid the escaped copy inside `deserializeFunctionsStringified`.
177 /return new Function\(`return \$\{value\.slice\(9\)\}`\)\(\);?/,
178 "try { return new Function(`return ${value.slice(9)}`)(); } catch { return undefined; }",
179 );
180 }
181 if (!isVocsInternal("config")) return;
182 let result = mustReplace(
183 code,
184 /const \{ server, rootDir = process\.cwd\(\) \} = options;?/,
185 "const { server } = options;\n" +
186 " const rootDir = options.rootDir ?? (() => { try { return process.cwd(); } catch { return '/'; } })();",
187 );
188 result = mustReplace(
189 result,
190 /if \(server && process\.env\['NODE_ENV'\] === 'production'\) \{\s*const configPath = path\.resolve\(import\.meta\.dirname, '\.\.\/vocs\.config\.js'\);?\s*const resolved = \(await import\(\/\* @vite-ignore \*\/ configPath\)\)\.default( as define\.Options)?;?\s*return define\(\{ \.\.\.resolved, rootDir \}\);?\s*\}/,
191 "if (server) {\n" +
192 ` const resolved = (await import(${JSON.stringify(VIRTUAL_USER_CONFIG)})).default;\n` +
193 " return define(resolved);\n" +
194 " }",
195 );
196 return result;
197 },
198 };
199};
200
201/**
202 * Replicates waku's `cmd-build.ts` `startPreviewServerImpl`: the SSG step of
203 * `builder.buildApp()` (the adapter's `build`) calls
204 * `unstable_startPreviewServer`, which throws unless this global is set. The
205 * preview config omits the cloudflare plugins, so SSG streams through the
206 * adapter's Node fallback middleware (upstream parity: identical to a vocs
207 * build without a platform vite plugin).
208 */
209const setPreviewServerGlobal = (
210 root: string,
211 adapterPath: string,
212 configPath: string | undefined,
213 project: VocsProjectModules,
214 port: number,
215): void => {
216 (globalThis as Record<string, unknown>)[PREVIEW_SERVER_GLOBAL] =
217 async (): Promise<WakuPreviewServer> => {
218 const server = await project.bundler.preview({
219 configFile: false,
220 root,
221 ...sharedViteConfig(),
222 plugins: [
223 project.react.default(),
224 workerdConfigBridge(configPath),
225 project.vite.vocs({ unstable_adapter: adapterPath }),
226 ],
227 // localhost can resolve to another build's listener on the other IP family.
228 preview: { host: "127.0.0.1", port },
229 });
230 const baseUrl = server.resolvedUrls?.local[0];
231 if (!baseUrl) {
232 throw new Error(
233 "Could not determine the URL of the vocs SSG preview server",
234 );
235 }
236 return {
237 baseUrl,
238 middlewares: {
239 use: (fn) => server.middlewares.use(fn as never),
240 },
241 close: () => server.close(),
242 };
243 };
244};
245
246const clearPreviewServerGlobal = (): void => {
247 delete (globalThis as Record<string, unknown>)[PREVIEW_SERVER_GLOBAL];
248};
249
250/**
251 * The vocs implementation of framework-core's `Framework` service.
252 *
253 * - `build` replicates vocs's `vocs build` CLI command (`vite.createBuilder`
254 * with `[react(), vocs()]` + `buildApp`) with the cloudflare target's
255 * plugins/adapter wired in, and collects the `BuildOutput` with a
256 * post-`buildApp` disk re-read (waku writes `__waku_build_metadata.js` and
257 * prunes static-only chunks after the bundler finishes).
258 * - `dev` replicates `vocs dev` (`vite.createServer`) with the same plugin
259 * wiring, so the rsc environment runs in workerd.
260 */
261export const make = (
262 options: VocsFrameworkOptions = {},
263): Layer.Layer<
264 FrameworkCore.Framework,
265 never,
266 FileSystem.FileSystem | Path.Path
267> =>
268 Layer.effect(
269 FrameworkCore.Framework,
270 Effect.gen(function* () {
271 const fs = yield* FileSystem.FileSystem;
272 const path = yield* Path.Path;
273
274 const fail = (message: string) => (cause: unknown) =>
275 new FrameworkCore.FrameworkError({ framework: "vocs", message, cause });
276
277 const resolveRoot = (override: string | undefined) =>
278 Effect.sync(() => override ?? options.root ?? process.cwd());
279
280 const resolveTarget = (root: string) => {
281 const { input, config } = selectVocsTargetInput(options);
282 return FrameworkCore.resolveDeployTarget<VocsTarget, unknown>(
283 root,
284 input,
285 config,
286 ).pipe(Effect.mapError(fail("Failed to resolve the deploy target")));
287 };
288
289 const findConfig = (root: string) =>
290 Effect.gen(function* () {
291 for (const name of [
292 "vocs.config.ts",
293 "vocs.config.js",
294 "vocs.config.mjs",
295 "vocs.config.mts",
296 ]) {
297 const candidate = path.join(root, name);
298 if (yield* fs.exists(candidate)) return candidate;
299 }
300 return undefined;
301 }).pipe(Effect.mapError(fail("Failed to locate the Vocs config")));
302
303 const loadProject = (root: string) =>
304 Effect.all(
305 {
306 bundler: FrameworkCore.loadProjectModule<typeof vite>(root, "vite"),
307 react: FrameworkCore.loadProjectModule<ReactPluginModule>(
308 root,
309 "@vitejs/plugin-react",
310 ),
311 vite: FrameworkCore.loadProjectModule<VocsViteModule>(
312 root,
313 "vocs/vite",
314 ),
315 },
316 { concurrency: "unbounded" },
317 ).pipe(
318 Effect.mapError(
319 fail("Failed to load Vocs from the project dependencies"),
320 ),
321 );
322
323 /** Resolve the target's adapter module + vite plugins for one pass. */
324 const prepareTarget = Effect.fn(function* (
325 target: VocsTarget,
326 root: string,
327 phase: "build" | "dev",
328 ) {
329 const wakuDirectory =
330 yield* FrameworkCore.resolveProjectPackageDirectory(
331 root,
332 "waku",
333 ).pipe(
334 Effect.mapError(
335 fail("Failed to resolve the project's waku package directory"),
336 ),
337 );
338 const context = { root, wakuDirectory, phase } as const;
339 const [adapterPath, plugins] = yield* Effect.all(
340 [target.adapter(context), target.vitePlugins(context)],
341 { concurrency: "unbounded" },
342 ).pipe(
343 Effect.mapError(
344 fail(`The cloudflare target failed preparing the vocs ${phase}`),
345 ),
346 );
347 return { adapterPath, plugins, wakuDirectory };
348 });
349
350 return FrameworkCore.Framework.of({
351 build: Effect.fn(function* (buildOptions) {
352 const root = yield* resolveRoot(buildOptions?.root);
353 const target = yield* resolveTarget(root);
354 if (target.build !== undefined) {
355 return yield* target
356 .build({ root, framework: "vocs", env: buildOptions?.env })
357 .pipe(
358 Effect.provideService(FileSystem.FileSystem, fs),
359 Effect.provideService(Path.Path, path),
360 Effect.mapError(fail("The deploy target's build failed")),
361 );
362 }
363 const { adapterPath, plugins } = yield* prepareTarget(
364 target,
365 root,
366 "build",
367 );
368 const project = yield* loadProject(root);
369 const configPath = yield* findConfig(root);
370 // vocs's CLI (like waku's) runs with NODE_ENV set before loading
371 // anything; waku's environmentsPlugin bakes it into `define`.
372 yield* Effect.sync(() => {
373 process.env.NODE_ENV = INITIAL_NODE_ENV ?? "production";
374 });
375 const collector = yield* FrameworkCore.makeBuildOutputCollector({
376 entryEnvironment: "rsc",
377 selectEntry: (chunk) => chunk.name === WAKU_SERVER_ENTRY_MODULE,
378 }).pipe(Effect.provideService(FileSystem.FileSystem, fs));
379 const previewPort = yield* FrameworkCore.resolveViteDevPort(
380 project.bundler.version,
381 );
382 yield* Effect.tryPromise({
383 try: async () => {
384 const builder = await project.bundler.createBuilder(
385 {
386 configFile: false,
387 root,
388 ...sharedViteConfig(),
389 plugins: [
390 project.react.default(),
391 ...plugins,
392 workerdConfigBridge(configPath),
393 project.vite.vocs({ unstable_adapter: adapterPath }),
394 collector.plugin,
395 ],
396 },
397 null,
398 );
399 setPreviewServerGlobal(
400 root,
401 adapterPath,
402 configPath,
403 project,
404 previewPort,
405 );
406 try {
407 await builder.buildApp();
408 } finally {
409 clearPreviewServerGlobal();
410 }
411 },
412 catch: fail("Failed to build"),
413 });
414 // Disk re-read: waku writes `__waku_build_metadata.js` and prunes
415 // static-only server chunks during `buildApp` hooks, after the
416 // in-memory `writeBundle` capture.
417 const output = yield* collector
418 .collect({ fromDisk: true })
419 .pipe(Effect.mapError((error) => fail(error.message)(error.cause)));
420 return yield* FrameworkCore.applyDeployTargetFinish(target, output, {
421 root,
422 framework: "vocs",
423 }).pipe(
424 Effect.provideService(FileSystem.FileSystem, fs),
425 Effect.provideService(Path.Path, path),
426 Effect.mapError(fail("The deploy target's finishing pass failed")),
427 );
428 }),
429 dev: Effect.fn(function* (devOptions) {
430 const root = yield* resolveRoot(devOptions?.root);
431 const target = yield* resolveTarget(root);
432 const { adapterPath, plugins } = yield* prepareTarget(
433 target,
434 root,
435 "dev",
436 );
437 const project = yield* loadProject(root);
438 const configPath = yield* findConfig(root);
439 yield* Effect.sync(() => {
440 process.env.NODE_ENV = INITIAL_NODE_ENV ?? "development";
441 });
442 const port = devOptions?.port ?? options.port;
443 const server = yield* Effect.acquireRelease(
444 Effect.tryPromise({
445 try: async () => {
446 const server = await project.bundler.createServer({
447 configFile: false,
448 root,
449 ...sharedViteConfig(),
450 plugins: [
451 project.react.default(),
452 ...plugins,
453 workerdConfigBridge(configPath),
454 project.vite.vocs({ unstable_adapter: adapterPath }),
455 ],
456 ...(port !== undefined
457 ? {
458 server: {
459 port,
460 strictPort: devOptions?.port !== undefined,
461 },
462 }
463 : undefined),
464 });
465 return await server.listen();
466 },
467 catch: fail("Failed to start the vocs dev server"),
468 }),
469 (server) =>
470 Effect.promise(async () => {
471 try {
472 (
473 server.httpServer as
474 | { closeAllConnections?: () => void }
475 | null
476 | undefined
477 )?.closeAllConnections?.();
478 await server.close();
479 } catch {
480 // teardown is best-effort
481 }
482 }).pipe(
483 Effect.timeout("3 seconds"),
484 Effect.orElseSucceed(() => undefined),
485 ),
486 );
487 const url = server.resolvedUrls?.local[0];
488 if (url === undefined) {
489 return yield* Effect.fail(
490 fail("Could not determine the URL of the vocs dev server")(
491 undefined,
492 ),
493 );
494 }
495 return { url };
496 }),
497 });
498 }),
499 );
500
501export default make;