1import * as FrameworkCore from "../core/index.ts";
2import type {
3 BuildOutput,
4 DeployTarget,
5 DeployTargetBuildContext,
6 DeployTargetError,
7 DeployTargetInput,
8 DeployTargetServices,
9} from "../core/index.ts";
10import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";
13import * as Path from "effect/Path";
14import * as Schedule from "effect/Schedule";
15import * as Semaphore from "effect/Semaphore";
16import * as NodePath from "node:path";
17import type * as ViteModule from "vite";
18import type { Config as WakuConfig } from "waku/config";
19import type * as WakuInternals from "waku/internals";
20import type * as WakuVitePlugins from "waku/vite-plugins";
21
22type WakuInternalsModule = typeof WakuInternals;
23type WakuVitePluginsModule = typeof WakuVitePlugins;
24type ResolvedWakuConfig = ReturnType<
25 WakuInternalsModule["unstable_resolveConfig"]
26>;
27
28/**
29 * Waku's rsc-environment worker entry, relative to the installed `waku`
30 * package directory. Its default export is the adapter's `ExportedHandler`
31 * (the same module waku wires as the rsc env's `index` input). The deep path
32 * is not in waku's exports map, so it is joined onto the resolved package
33 * directory.
34 *
35 * Deploy targets whose bundler plugin needs an explicit entry (e.g. the
36 * cloudflare target's `main`) pin it to this path: waku's rsc environment
37 * declares two rolldown inputs (`index` + `build`) while such plugins assert
38 * exactly one entry.
39 */
40export const WAKU_SERVER_ENTRY_PATH = "dist/lib/vite-entries/entry.server.js";
41
42/**
43 * The server module that must become `serverModules[0]` when the app has no
44 * user worker entry: waku's own `index` input of the rsc environment
45 * (`dist/server/index.js`), whose default export is the adapter's
46 * `ExportedHandler`. The entry environment emits multiple entry chunks
47 * (`index`, `build`, and any wrapped worker entry), so entry selection is
48 * pinned to this name. When the deploy target carries a user entry
49 * (`target.entry`), the chunk built from the user's `main` is pinned instead
50 * (framework-core's `selectEntryByFacade`) and this module remains an
51 * ordinary chunk the user entry imports.
52 */
53export const WAKU_SERVER_ENTRY_MODULE = NodePath.join("server", "index.js");
54
55/**
56 * The stable importable specifier for waku's server handler — the module a
57 * USER worker entry wraps (the deploy-target user-entry seam; precedent:
58 * React Router's `virtual:react-router/server-build`). Resolves to waku's
59 * rsc server entry ({@link WAKU_SERVER_ENTRY_PATH}), whose default export is
60 * the adapter's `ExportedHandler`, in dev and build alike:
61 *
62 * ```ts
63 * // src/worker-entry.ts
64 * import wakuHandler from "virtual:waku/server-entry";
65 * export class Counter extends DurableObject {}
66 * export default {
67 * fetch: (request, env, ctx) => wakuHandler.fetch(request, env, ctx),
68 * };
69 * ```
70 */
71export const WAKU_SERVER_ENTRY_ID = "virtual:waku/server-entry";
72
73/**
74 * The vite plugin resolving {@link WAKU_SERVER_ENTRY_ID} to the project's
75 * installed waku server entry. Platform-neutral (the id maps to the same
76 * framework module for every deploy target) and always injected first in
77 * `vite.plugins`, so the id resolves for the dev module runner, the
78 * production build, and the SSG preview server alike.
79 */
80export const makeWakuServerEntryPlugin = (
81 wakuDirectory: string,
82): ViteModule.Plugin => ({
83 name: "distilled-waku:server-entry",
84 resolveId(id) {
85 if (id === WAKU_SERVER_ENTRY_ID) {
86 return NodePath.join(wakuDirectory, WAKU_SERVER_ENTRY_PATH);
87 }
88 return undefined;
89 },
90});
91
92/**
93 * The inputs a {@link WakuTarget} hook receives: enough to synthesize the
94 * target's bundler plugins and adapter selection for one waku pass.
95 */
96export interface WakuTargetContext {
97 /** Absolute project root. */
98 readonly root: string;
99 /** Directory of the project's installed `waku` package. */
100 readonly wakuDirectory: string;
101 /** Which waku pass the hook feeds: the production build or the HMR dev server. */
102 readonly phase: "build" | "dev";
103}
104
105/**
106 * The waku-specific deploy-target contract: the generic
107 * `framework-core` {@link DeployTarget} seams plus the two
108 * hooks a target must implement to slot into waku's toolchain:
109 *
110 * - `adapter` — the absolute path of the waku server-entry adapter module the
111 * in-memory config selects via `unstable_adapter`. Always required: leaving
112 * `unstable_adapter` unset makes waku silently pick `waku/adapters/node`,
113 * which cannot run on a non-Node target runtime.
114 * - `vitePlugins` — vite plugins injected *first* inside waku's
115 * `config.vite.plugins` (waku's `extraPlugins` places user plugins first,
116 * ahead of waku's own environments plugin) — the position upstream
117 * documents for `@cloudflare/vite-plugin`, and the only one where a
118 * target's request-proxy middleware registers before waku's Node request
119 * bridge.
120 *
121 * The Cloudflare implementation ships at `@alchemy.run/frontend-frameworks/waku/cloudflare`
122 * (the default). A future target (e.g. AWS) is a new module implementing the
123 * same two hooks — no change to this package's orchestration.
124 */
125export interface WakuTarget<Config = unknown> extends DeployTarget<Config> {
126 /** Absolute path of the adapter module waku's config selects via `unstable_adapter`. */
127 readonly adapter: (
128 context: WakuTargetContext,
129 ) => Effect.Effect<string, DeployTargetError>;
130 /** Vite plugins injected first inside waku's `vite.plugins` (dev + build). */
131 readonly vitePlugins: (
132 context: WakuTargetContext,
133 ) => Effect.Effect<ReadonlyArray<ViteModule.PluginOption>, DeployTargetError>;
134 /**
135 * Optional *wholesale* build takeover (see `DeployTarget.build`), with
136 * the waku-specific {@link WakuTargetBuildContext}.
137 */
138 readonly build?:
139 | ((
140 context: WakuTargetBuildContext,
141 ) => Effect.Effect<BuildOutput, DeployTargetError, DeployTargetServices>)
142 | undefined;
143}
144
145/**
146 * The context a waku target's wholesale `build` takeover receives: the
147 * generic build context plus the inline waku options the framework was
148 * constructed with ({@link WakuFrameworkOptions.waku}) — carried so
149 * wholesale targets that re-run the framework in a child process (the AWS
150 * target) can reconstruct it. Only JSON-serializable fields survive that
151 * process boundary.
152 */
153export interface WakuTargetBuildContext extends DeployTargetBuildContext {
154 readonly waku?: WakuConfig | undefined;
155}
156
157/**
158 * Structural mirror of the e2e harness's target-scoped config carriage
159 * (`Options.TargetOptions`): a plain selection object whose `cloudflare.worker`
160 * carries the cloudflare target's configuration. Distinguished from a
161 * {@link DeployTargetInput} structurally (it is neither a
162 * string, a function, nor a `DeployTarget` value).
163 */
164export interface WakuHarnessTargetOptions {
165 /** Which platform target drives the integration. @default "cloudflare" */
166 readonly name?: string | undefined;
167 readonly cloudflare?: { readonly worker?: unknown } | undefined;
168}
169
170/**
171 * How the deploy target is passed to the waku integration:
172 *
173 * - a target value / factory / module-specifier string (a
174 * {@link DeployTargetInput}) — resolved with
175 * `resolveDeployTarget`
176 * - the e2e harness's target-scoped carriage
177 * ({@link WakuHarnessTargetOptions}) — selects the default cloudflare
178 * target module and hands it `cloudflare.worker` as its config
179 * - omitted — the default cloudflare target module with the deprecated
180 * `vite` alias as its config
181 */
182export type WakuTargetOption =
183 | DeployTargetInput<WakuTarget, unknown>
184 | WakuHarnessTargetOptions;
185
186/**
187 * The module specifier of the default deploy target, loaded from the
188 * *project's* `node_modules` when no explicit target is passed.
189 */
190export const DEFAULT_TARGET_SPECIFIER =
191 "@alchemy.run/frontend-frameworks/waku/cloudflare";
192
193/**
194 * Options for the Waku `Framework` integration.
195 */
196export interface WakuFrameworkOptions {
197 /**
198 * The deploy target the build is produced for. Accepts a target value
199 * (build it by importing the target module, e.g.
200 * `@alchemy.run/frontend-frameworks/waku/cloudflare`, for full config type safety), a
201 * `(config) => target` factory, a module specifier string, or the e2e
202 * harness's target-scoped carriage. Defaults to the cloudflare target
203 * module ({@link DEFAULT_TARGET_SPECIFIER}).
204 */
205 readonly target?: WakuTargetOption | undefined;
206 /**
207 * @deprecated Alias for the deploy target's configuration (the harness's
208 * pre-target `vite` field, carrying the cloudflare worker configuration).
209 * Prefer passing a fully-built target via {@link target}, or the harness's
210 * target-scoped `target.cloudflare.worker`.
211 */
212 readonly vite?: unknown;
213 /**
214 * Extra waku config (`srcDir`, `distDir`, `vite`, ...) merged OVER the
215 * project's own `waku.config.ts`/`waku.config.js`, which is always loaded
216 * natively first (same semantics as waku's CLI — see
217 * {@link mergeUserWakuConfig}). `unstable_adapter` is owned by the deploy
218 * target and must not be set here or in the config file.
219 */
220 readonly waku?: WakuConfig | undefined;
221 /** Project root. Defaults to the process working directory. */
222 readonly root?: string | undefined;
223 /**
224 * Default dev-server port, used when `dev` is called without an explicit
225 * port (e.g. the e2e harness's Playwright dev fixture). Non-strict: if the
226 * port is taken, vite falls back to the next free one. A port passed to
227 * `dev` directly (`e2e dev --port N`) is strict and takes precedence.
228 */
229 readonly port?: number | undefined;
230}
231
232/**
233 * The config files waku's CLI probes for, in probe order (see waku's
234 * `lib/vite-rsc/loader.ts` `loadConfig`). Only the existence probe uses these
235 * names — the actual import goes through vite's resolver as `/waku.config`,
236 * exactly like upstream.
237 */
238export const WAKU_CONFIG_FILES = ["waku.config.ts", "waku.config.js"] as const;
239
240/** Strip `undefined`-valued keys so an inline options object with explicit
241 * `undefined` entries cannot clobber values from the user's config file. */
242const definedConfig = (
243 config: WakuConfig | undefined,
244): WakuConfig | undefined => {
245 if (config === undefined) {
246 return undefined;
247 }
248 const entries = Object.entries(config).filter(
249 ([, value]) => value !== undefined,
250 );
251 return entries.length === 0
252 ? undefined
253 : (Object.fromEntries(entries) as WakuConfig);
254};
255
256/** Inputs for {@link mergeUserWakuConfig} (exported for testing). */
257export interface MergeUserWakuConfigInputs {
258 /** The natively loaded `waku.config.ts`/`waku.config.js` default export. */
259 readonly file: WakuConfig | undefined;
260 /** The integration's inline waku options ({@link WakuFrameworkOptions.waku}). */
261 readonly inline: WakuConfig | undefined;
262 /** vite's `mergeConfig` (from the project's vite installation). */
263 readonly mergeViteConfig: (
264 defaults: ViteModule.UserConfig,
265 overrides: ViteModule.UserConfig,
266 ) => ViteModule.UserConfig;
267}
268
269/**
270 * Merge the project's natively loaded waku config file with the integration's
271 * inline waku options: the file is the base, inline options win per key
272 * (mirroring vite's file-config-vs-inline-config precedence), and the `vite`
273 * fields are combined with vite's own `mergeConfig` (arrays like `plugins`
274 * concatenate — file plugins first, inline plugins after).
275 */
276export const mergeUserWakuConfig = (
277 inputs: MergeUserWakuConfigInputs,
278): WakuConfig | undefined => {
279 const file = definedConfig(inputs.file);
280 const inline = definedConfig(inputs.inline);
281 if (file === undefined || inline === undefined) {
282 return file ?? inline;
283 }
284 const vite =
285 file.vite !== undefined && inline.vite !== undefined
286 ? inputs.mergeViteConfig(file.vite, inline.vite)
287 : (inline.vite ?? file.vite);
288 return { ...file, ...inline, ...(vite !== undefined ? { vite } : undefined) };
289};
290
291const isDeployTargetInput = (
292 value: unknown,
293): value is DeployTargetInput<WakuTarget, unknown> =>
294 typeof value === "string" ||
295 typeof value === "function" ||
296 FrameworkCore.isDeployTarget(value);
297
298/** The normalized `(input, config)` pair fed to `resolveDeployTarget`. */
299export interface WakuTargetInputSelection {
300 readonly input: DeployTargetInput<WakuTarget, unknown>;
301 readonly config: unknown;
302}
303
304/**
305 * Normalize {@link WakuFrameworkOptions} into the `(input, config)` pair for
306 * `resolveDeployTarget`:
307 *
308 * - an explicit `DeployTargetInput` is used as-is, with the deprecated `vite`
309 * alias as the config a factory/specifier receives
310 * - the harness carriage (or no target at all) selects the default target
311 * module with `target.cloudflare.worker ?? vite` as its config
312 */
313export const selectWakuTargetInput = (
314 options?: WakuFrameworkOptions,
315): WakuTargetInputSelection => {
316 const raw = options?.target;
317 if (raw !== undefined && isDeployTargetInput(raw)) {
318 return { input: raw, config: options?.vite };
319 }
320 return {
321 input: DEFAULT_TARGET_SPECIFIER,
322 config: raw?.cloudflare?.worker ?? options?.vite,
323 };
324};
325
326/** Inputs for {@link makeWakuConfigInput} (exported for testing). */
327export interface WakuConfigInputs {
328 /** Absolute path of the adapter module (the target's `adapter` hook). */
329 readonly adapterPath: string;
330 /**
331 * Deploy-target vite plugins injected first inside `vite.plugins` (the
332 * target's `vitePlugins` hook). The SSG preview server resolves the same
333 * config — upstream parity: waku's CLI reuses the loaded config's plugin
334 * instances for both `createBuilder` and `vite.preview` — so a target
335 * whose plugins implement `configurePreviewServer` (the cloudflare
336 * target's does) serves SSG through its own runtime.
337 */
338 readonly plugins?: ReadonlyArray<ViteModule.PluginOption> | undefined;
339 /**
340 * The user's waku config — the natively loaded `waku.config.ts`/`.js`
341 * merged with the integration's inline waku options (see
342 * {@link mergeUserWakuConfig}). Its `vite` config is preserved; a user
343 * `unstable_adapter` is rejected by the caller before this point (the
344 * deploy target owns the adapter), so this function pins it
345 * unconditionally.
346 */
347 readonly userConfig?: WakuConfig | undefined;
348}
349
350const mergeWorkerEnvironment = (
351 user: ViteModule.EnvironmentOptions | undefined,
352 optimizeDepsInclude: Array<string>,
353): ViteModule.EnvironmentOptions => ({
354 ...user,
355 optimizeDeps: {
356 ...user?.optimizeDeps,
357 include: [...optimizeDepsInclude, ...(user?.optimizeDeps?.include ?? [])],
358 },
359 build: {
360 ...user?.build,
361 rolldownOptions: {
362 platform: "neutral",
363 ...user?.build?.rolldownOptions,
364 },
365 },
366});
367
368/**
369 * Build the waku `Config` fed to waku's `unstable_resolveConfig`: the USER's
370 * config (their natively loaded `waku.config.*`, plus any inline waku
371 * options) with the deploy target's required fields merged over it:
372 *
373 * - `unstable_adapter` is pinned to the deploy target's adapter module.
374 * Leaving it unset would silently select `waku/adapters/node` (no
375 * `CLOUDFLARE` env var), which cannot run on a non-Node target runtime;
376 * a user-set adapter is rejected upstream of this function.
377 * - The target's vite plugins are injected INSIDE `vite.plugins` (waku's
378 * `extraPlugins` places user plugins first, ahead of waku's own
379 * environments plugin) — the position upstream documents for
380 * `@cloudflare/vite-plugin`, and the only one where the target's proxy
381 * middleware registers before waku's Node request bridge.
382 * - `waku` and `hono` are deduped so the adapter module (which lives outside
383 * the project) resolves the project's copies.
384 * - rsc/ssr get the documented `optimizeDeps.include` entries and
385 * `platform: "neutral"`.
386 */
387export const makeWakuConfigInput = (inputs: WakuConfigInputs): WakuConfig => {
388 const user = inputs.userConfig;
389 const userVite = user?.vite;
390 const environments = (userVite?.environments ?? {}) as Record<
391 string,
392 ViteModule.EnvironmentOptions | undefined
393 >;
394 return {
395 ...user,
396 unstable_adapter: inputs.adapterPath,
397 vite: {
398 ...userVite,
399 resolve: {
400 ...userVite?.resolve,
401 dedupe: [
402 ...new Set(["waku", "hono", ...(userVite?.resolve?.dedupe ?? [])]),
403 ],
404 },
405 environments: {
406 ...userVite?.environments,
407 rsc: mergeWorkerEnvironment(environments.rsc, ["hono/tiny"]),
408 ssr: mergeWorkerEnvironment(environments.ssr, [
409 "waku > rsc-html-stream/server",
410 ]),
411 },
412 plugins: [...(inputs.plugins ?? []), ...(userVite?.plugins ?? [])],
413 },
414 };
415};
416
417/**
418 * `NODE_ENV` as it was when this module first loaded. Waku's CLI sets
419 * `NODE_ENV` before loading anything (waku's environmentsPlugin bakes
420 * `process.env.NODE_ENV` into `define`), which we replicate — but a
421 * long-lived process may run both `build` and `dev` (e.g. a playwright worker
422 * driving the live and dev servers), and a plain `??=` would leak the first
423 * operation's value into the second. Capturing the initial value preserves an
424 * explicit user override while keeping the two operations independent.
425 */
426const INITIAL_NODE_ENV = process.env.NODE_ENV;
427
428/**
429 * Serializes cwd swaps across concurrent builds in one process (the same
430 * discipline as the source provider's dev-server startup lock).
431 */
432const cwdLock = Semaphore.makeUnsafe(1);
433
434/**
435 * Run `effect` with `process.cwd()` set to the project root, restoring the
436 * previous cwd afterwards. Waku's toolchain assumes cwd == root: the
437 * html-shell plugin declares a cwd-relative `index.html` client input (a
438 * root outside the cwd makes `vite:build-html` emit an invalid
439 * `../index.html` asset name) and reads the html shell with bare relative
440 * paths. A no-op when the cwd already is the root.
441 */
442const withRootCwd = <A, E, R>(
443 root: string,
444 effect: Effect.Effect<A, E, R>,
445): Effect.Effect<A, E, R> =>
446 Effect.flatMap(
447 Effect.sync(() => process.cwd() === NodePath.resolve(root)),
448 (isRoot) =>
449 isRoot
450 ? effect
451 : Semaphore.withPermits(
452 cwdLock,
453 1,
454 )(
455 Effect.acquireUseRelease(
456 Effect.sync(() => {
457 const previous = process.cwd();
458 process.chdir(root);
459 return previous;
460 }),
461 () => effect,
462 (previous) => Effect.sync(() => process.chdir(previous)),
463 ),
464 ),
465 );
466
467const PREVIEW_SERVER_GLOBAL = "__WAKU_START_PREVIEW_SERVER__";
468
469/** The shape waku's `unstable_startPreviewServer` expects the global to produce. */
470interface WakuPreviewServer {
471 readonly baseUrl: string;
472 readonly middlewares: {
473 readonly use: (
474 fn: (req: unknown, res: unknown, next: (err?: unknown) => void) => void,
475 ) => void;
476 };
477 readonly close: () => Promise<void>;
478}
479
480/**
481 * Replicates waku's `cmd-build.ts` `startPreviewServerImpl`: the SSG step of
482 * `builder.buildApp()` (the adapter's `build`) calls
483 * `unstable_startPreviewServer`, which throws unless this global is set. The
484 * preview server resolves the SAME waku config as the build — upstream
485 * parity: waku's CLI calls `combinedPlugins(config)` for both
486 * `createBuilder` and `vite.preview`, reusing the loaded config's plugin
487 * instances across the two servers. A deploy target whose vite plugins
488 * implement `configurePreviewServer` (the cloudflare target serves the
489 * freshly built worker through workerd) therefore hosts the SSG rendering on
490 * its own runtime; waku's adapter registers its Node fallback middleware
491 * behind it, which only fires for targets without a preview mode.
492 */
493const setPreviewServerGlobal = (
494 vite: typeof ViteModule,
495 vitePlugins: WakuVitePluginsModule,
496 root: string,
497 previewConfig: ResolvedWakuConfig,
498 port: number,
499): void => {
500 (globalThis as Record<string, unknown>)[PREVIEW_SERVER_GLOBAL] =
501 async (): Promise<WakuPreviewServer> => {
502 const server = await vite.preview({
503 configFile: false,
504 root,
505 plugins: [vitePlugins.unstable_combinedPlugins(previewConfig)],
506 // localhost can resolve to another build's listener on the other IP family.
507 preview: { host: "127.0.0.1", port },
508 });
509 const baseUrl = server.resolvedUrls?.local[0];
510 if (!baseUrl) {
511 throw new Error(
512 "Could not determine the URL of the waku SSG preview server",
513 );
514 }
515 return {
516 baseUrl,
517 middlewares: {
518 use: (fn) => server.middlewares.use(fn as never),
519 },
520 close: () => server.close(),
521 };
522 };
523};
524
525const clearPreviewServerGlobal = (): void => {
526 delete (globalThis as Record<string, unknown>)[PREVIEW_SERVER_GLOBAL];
527};
528
529interface ProjectModules {
530 readonly vite: typeof ViteModule;
531 readonly internals: WakuInternalsModule;
532 readonly vitePlugins: WakuVitePluginsModule;
533 readonly wakuDirectory: string;
534}
535
536/**
537 * The Waku implementation of framework-core's `Framework` service.
538 *
539 * - `build` replicates waku's `runBuild` (`vite.createBuilder` +
540 * `combinedPlugins` + the SSG preview-server global) and collects the
541 * `BuildOutput` with a post-`buildApp` disk re-read (waku writes
542 * `__waku_build_metadata.js` and prunes static-only chunks after the
543 * bundler finishes). The result is returned in-memory only, then passed
544 * through the deploy target's finishing pass (if any).
545 * - `dev` replicates waku's `runDev` with the deploy target's vite plugins
546 * injected inside waku's `config.vite.plugins`, so the rsc environment runs
547 * on the target's dev runtime (workerd for cloudflare) with in-memory
548 * bindings.
549 *
550 * Deploy-target resolution happens per operation: platform-specific halves
551 * (adapter module, bundler plugins) come exclusively from the resolved
552 * {@link WakuTarget} — this module contains no platform imports.
553 *
554 * Returns the service implementation as an Effect (the alchemy-side
555 * `FrameworkModule` contract); use {@link layer} for the `Layer` form the
556 * e2e harness consumes.
557 */
558export const make = (
559 options?: WakuFrameworkOptions,
560): Effect.Effect<
561 FrameworkCore.Framework["Service"],
562 never,
563 FileSystem.FileSystem | Path.Path
564> =>
565 Effect.gen(function* () {
566 const fs = yield* FileSystem.FileSystem;
567 const path = yield* Path.Path;
568
569 const fail = (message: string) => (cause: unknown) =>
570 new FrameworkCore.FrameworkError({ framework: "waku", message, cause });
571
572 const resolveRoot = (override: string | undefined) =>
573 Effect.sync(() => override ?? options?.root ?? process.cwd());
574
575 const resolveTarget: (
576 root: string,
577 ) => Effect.Effect<WakuTarget, FrameworkCore.FrameworkError> = Effect.fn(
578 function* (root: string) {
579 const { input, config } = selectWakuTargetInput(options);
580 return yield* FrameworkCore.resolveDeployTarget<WakuTarget, unknown>(
581 root,
582 input,
583 config,
584 ).pipe(Effect.mapError(fail("Failed to resolve the deploy target")));
585 },
586 );
587
588 /** Run the target's waku hooks, validating they exist (a dynamically
589 * loaded module may satisfy `DeployTarget` without the waku hooks). */
590 const useTargetHooks: (
591 target: WakuTarget,
592 context: WakuTargetContext,
593 ) => Effect.Effect<
594 {
595 adapterPath: string;
596 plugins: ReadonlyArray<ViteModule.PluginOption>;
597 },
598 FrameworkCore.FrameworkError
599 > = Effect.fn(function* (target: WakuTarget, context: WakuTargetContext) {
600 if (
601 typeof target.adapter !== "function" ||
602 typeof target.vitePlugins !== "function"
603 ) {
604 return yield* Effect.fail(
605 fail(
606 `Deploy target "${target.platform}" does not implement the waku target hooks ` +
607 "(adapter, vitePlugins) required to drive waku " +
608 context.phase,
609 )(undefined),
610 );
611 }
612 const [adapterPath, plugins] = yield* Effect.all(
613 [target.adapter(context), target.vitePlugins(context)],
614 { concurrency: "unbounded" },
615 ).pipe(
616 Effect.mapError(
617 fail(`The deploy target failed preparing the waku ${context.phase}`),
618 ),
619 );
620 return { adapterPath, plugins };
621 });
622
623 const loadProject: (
624 root: string,
625 ) => Effect.Effect<ProjectModules, FrameworkCore.FrameworkError> =
626 Effect.fn(function* (root: string) {
627 const [vite, internals, vitePlugins, wakuDirectory] = yield* Effect.all(
628 [
629 FrameworkCore.loadProjectModule<typeof ViteModule>(
630 root,
631 "vite",
632 ).pipe(Effect.mapError(fail("Failed to load the project's vite"))),
633 FrameworkCore.loadProjectModule<WakuInternalsModule>(
634 root,
635 "waku/internals",
636 ).pipe(
637 Effect.mapError(
638 fail("Failed to load the project's waku/internals"),
639 ),
640 ),
641 FrameworkCore.loadProjectModule<WakuVitePluginsModule>(
642 root,
643 "waku/vite-plugins",
644 ).pipe(
645 Effect.mapError(
646 fail("Failed to load the project's waku/vite-plugins"),
647 ),
648 ),
649 FrameworkCore.resolveProjectPackageDirectory(root, "waku").pipe(
650 Effect.mapError(
651 fail("Failed to resolve the project's waku package directory"),
652 ),
653 ),
654 ],
655 { concurrency: "unbounded" },
656 );
657 return { vite, internals, vitePlugins, wakuDirectory };
658 });
659
660 /**
661 * Load the project's `waku.config.ts`/`waku.config.js` exactly the way
662 * waku's own CLI does. Waku's loader (`lib/vite-rsc/loader.ts`
663 * `loadConfig`) is NOT exported, so its semantics are replicated: an
664 * existence probe for {@link WAKU_CONFIG_FILES} at the project root
665 * (waku probes the cwd — its CLI always runs from the project),
666 * followed by `vite.runnerImport("/waku.config")` with the project's
667 * own vite. `runnerImport`'s inline config pins `root` so the
668 * root-relative id resolves identically when root !== cwd. `NODE_ENV`
669 * is set before this runs, matching waku's CLI ordering (vite#20299).
670 *
671 * Deliberate divergences from the CLI (host-process concerns):
672 * - no `loadDotEnv()` — the CLI mutates `process.env` from
673 * `.env.local`/`.env`; here the host (alchemy/e2e) owns the process
674 * environment and worker env comes from bindings.
675 * - no dev-server restart on config-file change — the dev handle is a
676 * plain URL; edits to `waku.config.*` need a dev restart.
677 */
678 const loadUserConfigFile: (
679 project: ProjectModules,
680 root: string,
681 ) => Effect.Effect<WakuConfig | undefined, FrameworkCore.FrameworkError> =
682 Effect.fn(function* (project: ProjectModules, root: string) {
683 const exists = yield* Effect.all(
684 WAKU_CONFIG_FILES.map((file) => fs.exists(path.join(root, file))),
685 { concurrency: "unbounded" },
686 ).pipe(
687 Effect.mapError(
688 fail("Failed to probe for the project's waku.config file"),
689 ),
690 );
691 if (!exists.some(Boolean)) {
692 return undefined;
693 }
694 return yield* Effect.tryPromise({
695 try: async () => {
696 const imported = await project.vite.runnerImport<{
697 default: WakuConfig;
698 }>("/waku.config", { root });
699 return imported.module.default;
700 },
701 catch: fail("Failed to load the project's waku.config file"),
702 });
703 });
704
705 const makeConfig: (
706 project: ProjectModules,
707 root: string,
708 inputs: {
709 adapterPath: string;
710 plugins: ReadonlyArray<ViteModule.PluginOption>;
711 },
712 ) => Effect.Effect<ResolvedWakuConfig, FrameworkCore.FrameworkError> =
713 Effect.fn(function* (
714 project: ProjectModules,
715 root: string,
716 inputs: {
717 adapterPath: string;
718 plugins: ReadonlyArray<ViteModule.PluginOption>;
719 },
720 ) {
721 const fileConfig = yield* loadUserConfigFile(project, root);
722 const userConfig = mergeUserWakuConfig({
723 file: fileConfig,
724 inline: options?.waku,
725 mergeViteConfig: project.vite.mergeConfig,
726 });
727 if (userConfig?.unstable_adapter !== undefined) {
728 return yield* Effect.fail(
729 fail(
730 `The waku config sets unstable_adapter (${JSON.stringify(userConfig.unstable_adapter)}), ` +
731 "but the deploy target owns the server adapter " +
732 `(it injects ${JSON.stringify(inputs.adapterPath)}). ` +
733 "Remove unstable_adapter from waku.config.ts/waku.config.js (and from the " +
734 "integration's waku options); to change the deploy platform, pass a different " +
735 "deploy target to the integration instead.",
736 )(undefined),
737 );
738 }
739 return project.internals.unstable_resolveConfig(
740 makeWakuConfigInput({
741 adapterPath: inputs.adapterPath,
742 // The server-entry resolver goes first so
743 // `virtual:waku/server-entry` (the wrappable-handler seam a user
744 // worker entry imports) resolves in dev, build, and the SSG
745 // preview server alike.
746 plugins: [
747 makeWakuServerEntryPlugin(project.wakuDirectory),
748 ...inputs.plugins,
749 ],
750 userConfig,
751 }),
752 );
753 });
754
755 const inProcess = FrameworkCore.Framework.of({
756 build: Effect.fn(function* (buildOptions) {
757 const root = yield* resolveRoot(buildOptions?.root);
758 const target = yield* resolveTarget(root);
759 if (target.build !== undefined) {
760 // Wholesale build takeover: the target owns the entire pipeline.
761 // The inline waku options ride along so a child-process build can
762 // reconstruct the framework with the same options.
763 return yield* target
764 .build({
765 root,
766 framework: "waku",
767 env: buildOptions?.env,
768 waku: options?.waku,
769 })
770 .pipe(
771 Effect.provideService(FileSystem.FileSystem, fs),
772 Effect.provideService(Path.Path, path),
773 Effect.mapError(fail("The deploy target's build failed")),
774 );
775 }
776 const project = yield* loadProject(root);
777 const hooks = yield* useTargetHooks(target, {
778 root,
779 wakuDirectory: project.wakuDirectory,
780 phase: "build",
781 });
782 // waku's CLI sets this before loading anything; waku's
783 // environmentsPlugin bakes `process.env.NODE_ENV` into `define`.
784 yield* Effect.sync(() => {
785 process.env.NODE_ENV = INITIAL_NODE_ENV ?? "production";
786 });
787 const wakuConfig = yield* makeConfig(project, root, hooks);
788 const previewPort = yield* FrameworkCore.resolveViteDevPort(
789 project.vite.version,
790 );
791 // Entry selection (the user-entry seam): when the deploy target
792 // carries a user worker entry, the chunk built from it must become
793 // `serverModules[0]` — waku's own `server/index.js` remains an
794 // ordinary chunk the user entry imports (via
795 // `virtual:waku/server-entry`). Without one, waku's own `index`
796 // entry chunk is pinned by name as before.
797 const userEntry = FrameworkCore.resolveDeployTargetEntry(target, {
798 root,
799 });
800 const collector = yield* FrameworkCore.makeBuildOutputCollector({
801 entryEnvironment: "rsc",
802 selectEntry:
803 userEntry !== undefined
804 ? FrameworkCore.selectEntryByFacade(userEntry)
805 : (chunk) => chunk.name === WAKU_SERVER_ENTRY_MODULE,
806 }).pipe(Effect.provideService(FileSystem.FileSystem, fs));
807 // The whole build runs under the project root cwd: waku's
808 // html-shell plugin declares a cwd-relative `index.html` client
809 // input and reads the shell with bare relative paths (see
810 // withRootCwd).
811 yield* withRootCwd(
812 root,
813 Effect.tryPromise({
814 try: async () => {
815 const builder = await project.vite.createBuilder(
816 {
817 configFile: false,
818 root,
819 // Warn-level: rolldown-vite's native progress reporter
820 // ("transforming...") writes straight to the fd, which
821 // corrupts hosting-process reporters (e.g. alchemy-test)
822 // that can only intercept JS-level writers.
823 logLevel: "warn",
824 plugins: [
825 project.vitePlugins.unstable_combinedPlugins(wakuConfig),
826 collector.plugin,
827 ],
828 },
829 null,
830 );
831 setPreviewServerGlobal(
832 project.vite,
833 project.vitePlugins,
834 root,
835 wakuConfig,
836 previewPort,
837 );
838 try {
839 await builder.buildApp();
840 } finally {
841 clearPreviewServerGlobal();
842 }
843 },
844 catch: fail("Failed to build"),
845 }),
846 );
847 // Disk re-read: waku writes `__waku_build_metadata.js` and prunes
848 // static-only server chunks during `buildApp` hooks, after the
849 // in-memory `writeBundle` capture.
850 const output = yield* collector
851 .collect({ fromDisk: true })
852 .pipe(Effect.mapError((error) => fail(error.message)(error.cause)));
853 return yield* FrameworkCore.applyDeployTargetFinish(target, output, {
854 root,
855 framework: "waku",
856 }).pipe(
857 Effect.provideService(FileSystem.FileSystem, fs),
858 Effect.provideService(Path.Path, path),
859 Effect.mapError(fail("The deploy target's finishing pass failed")),
860 );
861 }),
862 dev: Effect.fn(function* (devOptions) {
863 const root = yield* resolveRoot(devOptions?.root);
864 const target = yield* resolveTarget(root);
865 const project = yield* loadProject(root);
866 const hooks = yield* useTargetHooks(target, {
867 root,
868 wakuDirectory: project.wakuDirectory,
869 phase: "dev",
870 });
871 yield* Effect.sync(() => {
872 process.env.NODE_ENV = INITIAL_NODE_ENV ?? "development";
873 });
874 const wakuConfig = yield* makeConfig(project, root, hooks);
875 // `port: 0` (true OS-assigned) on Vite >= 8.2.1, probed ephemeral
876 // port on older Vite — see `resolveViteDevPort`. strictPort stays
877 // off for allocated ports so a probe race just moves to the next
878 // ephemeral port.
879 const explicitPort = devOptions?.port || options?.port;
880 const port = yield* FrameworkCore.resolveViteDevPort(
881 project.vite.version,
882 explicitPort,
883 );
884 const host = devOptions?.host;
885 // The dev server *starts* under the project root cwd (waku
886 // resolves its html shell and relative inputs from the cwd at
887 // startup); the cwd is restored once the server is listening.
888 const server = yield* Effect.acquireRelease(
889 withRootCwd(
890 root,
891 Effect.tryPromise({
892 try: async () => {
893 const server = await project.vite.createServer({
894 configFile: false,
895 root,
896 plugins: [
897 project.vitePlugins.unstable_combinedPlugins(wakuConfig),
898 ],
899 server: {
900 port,
901 strictPort: !!explicitPort,
902 ...(host !== undefined ? { host } : undefined),
903 },
904 });
905 return await server.listen();
906 },
907 catch: fail("Failed to start the waku dev server"),
908 }),
909 ),
910 (server) => Effect.promise(async () => await server.close()),
911 );
912 const resolved = server.resolvedUrls?.local[0];
913 if (resolved === undefined) {
914 return yield* Effect.fail(
915 fail("Could not determine the URL of the waku dev server")(
916 undefined,
917 ),
918 );
919 }
920 // Vite's `resolvedUrls.local[0]` is `http://host:port/` — strip
921 // the trailing slash so callers can concatenate paths (`${url}/`)
922 // without producing `//`, which waku's router does not match.
923 const url = resolved.replace(/\/+$/, "");
924 // Bounded readiness probe: any HTTP response counts (waku's
925 // first RSC compile is lazy; we only need the listener to answer).
926 yield* Effect.tryPromise({
927 try: async () => {
928 const response = await fetch(`${url}/`, { redirect: "manual" });
929 await response.arrayBuffer().catch(() => {});
930 },
931 catch: fail("The waku dev server did not become reachable"),
932 }).pipe(
933 Effect.retry({ schedule: Schedule.spaced("250 millis"), times: 40 }),
934 );
935 return { url };
936 }),
937 });
938
939 // Waku's toolchain assumes `cwd === root` at dev startup (the html shell
940 // and relative inputs resolve from the cwd), so its startup window
941 // chdirs — which must not happen in a process hosting other sites' dev
942 // servers (the alchemy dev sidecar). Run the dev server in a dedicated
943 // child whose cwd IS the root instead (see core/DevChild.ts). Inside
944 // that child — or when the options cannot cross the process boundary
945 // (e.g. a deploy-target VALUE from the e2e harness) — the in-process
946 // path runs directly.
947 const dev: FrameworkCore.Framework["Service"]["dev"] = (devOptions) => {
948 if (
949 FrameworkCore.isInsideDevChild() ||
950 !FrameworkCore.isJsonSerializable(options)
951 ) {
952 return inProcess.dev(devOptions);
953 }
954 const root = devOptions?.root ?? options?.root ?? process.cwd();
955 const port = devOptions?.port ?? options?.port;
956 return FrameworkCore.runDevChild({
957 framework: "waku",
958 module: "@alchemy.run/frontend-frameworks/waku",
959 callerUrl: import.meta.url,
960 rootDir: root,
961 makeOptions: { ...options, root },
962 devOptions: {
963 root,
964 ...(port !== undefined ? { port } : {}),
965 ...(devOptions?.host !== undefined ? { host: devOptions.host } : {}),
966 },
967 });
968 };
969
970 return FrameworkCore.Framework.of({ build: inProcess.build, dev });
971 });
972
973/**
974 * The `Layer` form of {@link make} — the shape the e2e harness's framework
975 * factory contract expects (`(options) => Layer<Framework>`).
976 */
977export const layer = (
978 options?: WakuFrameworkOptions,
979): Layer.Layer<
980 FrameworkCore.Framework,
981 never,
982 FileSystem.FileSystem | Path.Path
983> => Layer.effect(FrameworkCore.Framework, make(options));