1import type {
2 BindingHook,
3 BindingServices,
4 HyperdriveOrigin,
5 Assets as RuntimeAssets,
6 DurableObjectNamespace as RuntimeDurableObject,
7 QueueConsumer as RuntimeQueueConsumer,
8 RuntimeServices,
9} from "@alchemy.run/cloudflare-runtime/core";
10import * as FrameworkCore from "../core/index.ts";
11import type * as Context from "effect/Context";
12import * as Data from "effect/Data";
13import * as Effect from "effect/Effect";
14import * as FileSystem from "effect/FileSystem";
15import * as Layer from "effect/Layer";
16import * as Path from "effect/Path";
17import type { PlatformError } from "effect/PlatformError";
18import type * as Scope from "effect/Scope";
19import * as Semaphore from "effect/Semaphore";
20import * as NodeCrypto from "node:crypto";
21import { createRequire } from "node:module";
22import { runBuildChild } from "../core/BuildChild.ts";
23import { makeWakuCloudflareTarget } from "./cloudflare.ts";
24import { layer as wakuFrameworkLayer } from "./Waku.ts";
25
26// ─────────────────────────────────────────────────────────────────────────────
27// Structural mirror of alchemy's Worker source-provider contract.
28//
29// This module is loaded dynamically by alchemy's `loadSource` (see
30// `packages/alchemy/src/Cloudflare/Workers/Source.ts` — `WorkerSourceModule`,
31// `SourceProvider`, `SourceContext`, `DevContext`, `SourceBuildOutput`). The
32// contract is deliberately implemented STRUCTURALLY — no alchemy import — so
33// version skew degrades to a load-time `SourceProviderError` instead of a
34// crash. Keep these shapes in sync with the alchemy interfaces.
35// ─────────────────────────────────────────────────────────────────────────────
36
37/** Mirror of alchemy `Cloudflare/Workers/Source.SourceHash`. */
38export interface SourceHash {
39 readonly bundle: string | undefined;
40 readonly assets: string | undefined;
41 readonly input: string | undefined;
42 readonly additionalWorkspaces: Array<string> | undefined;
43}
44
45/** Mirror of alchemy `Bundle/Bundle.BundleFile`. */
46export interface BundleFile {
47 readonly path: string;
48 readonly content: string | Uint8Array;
49 readonly hash: string;
50}
51
52/** Mirror of alchemy `Bundle/Bundle.BundleOutput`. */
53export interface BundleOutput {
54 readonly files: [BundleFile, ...Array<BundleFile>];
55 readonly hash: string;
56}
57
58/** Mirror of alchemy `Cloudflare/Workers/Assets.AssetReadResult`. */
59export interface AssetReadResult {
60 directory: string;
61 config: Record<string, unknown> | undefined;
62 manifest: Record<string, { hash: string; size: number }>;
63 _headers: string | undefined;
64 _redirects: string | undefined;
65 hash: string;
66}
67
68/** Mirror of alchemy `Cloudflare/Workers/Source.SourceBuildOutput`. */
69export interface SourceBuildOutput {
70 readonly bundle: BundleOutput | undefined;
71 readonly assets: AssetReadResult | undefined;
72 readonly hash: SourceHash;
73}
74
75/** Mirror of alchemy `Cloudflare/Workers/Source.SourceContext`. */
76export interface SourceContext {
77 readonly id: string;
78 readonly workerName: string;
79 readonly compatibility: {
80 readonly date: string;
81 readonly flags: Array<string>;
82 };
83 readonly entry:
84 | { readonly kind: "external" }
85 | { readonly kind: "effect"; readonly exports: Record<string, unknown> };
86 readonly stack: { readonly name: string; readonly stage: string };
87 readonly env: Record<string, unknown> | undefined;
88 readonly extraOptions: unknown;
89 /** Raw `props.assets` (routing config; sources that own assets merge it in). */
90 readonly assets: unknown;
91}
92
93/** Mirror of alchemy `Cloudflare/Workers/Source.DevContext`. */
94export interface DevContext extends SourceContext {
95 readonly worker: {
96 readonly bindings: Array<BindingHook<BindingServices>>;
97 readonly durableObjectNamespaces: Array<
98 RuntimeDurableObject & { uniqueKey: string }
99 >;
100 readonly hyperdrives: Record<string, Required<HyperdriveOrigin>>;
101 readonly queueConsumers: Effect.Effect<Array<RuntimeQueueConsumer>>;
102 readonly assets: RuntimeAssets | undefined;
103 };
104 readonly runtimeContext: Context.Context<RuntimeServices>;
105}
106
107/** The `server`-mode arm of alchemy's `SourceDevHandle` (waku dev is a vite dev server). */
108export interface ServerDevHandle {
109 readonly mode: "server";
110 readonly url: URL;
111}
112
113/**
114 * Tag-compatible mirror of alchemy's
115 * `Cloudflare.Workers.SourceProviderError` — same `_tag`, same fields, so
116 * `Effect.catchTag` on the alchemy side matches errors raised here.
117 */
118export class SourceProviderError extends Data.TaggedError(
119 "Cloudflare.Workers.SourceProviderError",
120)<{
121 readonly provider: string;
122 readonly message: string;
123 readonly cause?: unknown;
124}> {}
125
126export type WakuSourceError = SourceProviderError | PlatformError;
127
128export type SourceRequirements = FileSystem.FileSystem | Path.Path;
129
130/** Mirror of alchemy `Cloudflare/Workers/Source.SourceProvider` (narrowed E/R). */
131export interface SourceProvider {
132 readonly ownsAssets: boolean;
133 readonly build: (
134 ctx: SourceContext,
135 ) => Effect.Effect<SourceBuildOutput, WakuSourceError, SourceRequirements>;
136 readonly hash: (
137 ctx: SourceContext,
138 previous: SourceHash | undefined,
139 ) => Effect.Effect<Partial<SourceHash>, WakuSourceError, SourceRequirements>;
140 readonly dev: (
141 ctx: DevContext,
142 ) => Effect.Effect<
143 ServerDevHandle,
144 WakuSourceError,
145 SourceRequirements | Scope.Scope
146 >;
147}
148
149/**
150 * Serializable options of the waku source descriptor
151 * (`{ provider: "@alchemy.run/frontend-frameworks/waku/source", options }` on alchemy's
152 * `WorkerProps.source`). MUST stay plain JSON data — the descriptor persists
153 * in alchemy state and participates in the Worker metadata hash.
154 */
155export interface WakuSourceOptions {
156 /** Project root. Defaults to the process working directory. */
157 readonly rootDir?: string | undefined;
158 /**
159 * The user's own worker entry (the user-entry seam,
160 * `DeployTarget.entry`): a module that wraps waku's handler — imported
161 * from `virtual:waku/server-entry` — and re-exports extras (Durable
162 * Object classes, additional handlers). Relative paths resolve against
163 * the root. When unset, waku's own rsc server entry is deployed.
164 */
165 readonly main?: string | undefined;
166 /** Waku `srcDir` (relative to the root). @default "src" */
167 readonly srcDir?: string | undefined;
168 /**
169 * Waku `distDir` (relative to the root). If `waku.config.ts` customizes
170 * `distDir`, mirror it here (or add it to `memo.exclude`) so the build
171 * output stays excluded from the rebuild-deciding input hash.
172 * @default "dist"
173 */
174 readonly distDir?: string | undefined;
175 /** Waku `basePath`. @default "/" */
176 readonly basePath?: string | undefined;
177 /** Controls which files feed the rebuild-deciding `input` hash. */
178 readonly memo?: WakuMemoOptions | undefined;
179}
180
181const wakuConfigFor = (options: WakuSourceOptions) => ({
182 ...(options.srcDir !== undefined ? { srcDir: options.srcDir } : undefined),
183 ...(options.distDir !== undefined ? { distDir: options.distDir } : undefined),
184 ...(options.basePath !== undefined
185 ? { basePath: options.basePath }
186 : undefined),
187});
188
189/**
190 * Input-hash scoping, mirroring the semantics of alchemy's `MemoOptions`
191 * (`Command/Memo.ts`): by default every non-gitignored file under the root is
192 * hashed plus the nearest package-manager lockfile; explicit `include` /
193 * `exclude` globs narrow the scope.
194 *
195 * The glob support is a self-contained subset (`**`, `*`, `?`; gitignore-style
196 * anchoring). Negated (`!`) gitignore rules are ignored (files they would
197 * re-include stay excluded from the hash).
198 */
199export interface WakuMemoOptions {
200 /** Glob patterns of files to hash, relative to the root. @default all files */
201 readonly include?: Array<string> | undefined;
202 /** Glob patterns to exclude. @default gitignore rules from the root up to the repo root */
203 readonly exclude?: Array<string> | undefined;
204 /**
205 * Whether to include the nearest package-manager lockfile in the hash.
206 * @default true when both `include` and `exclude` are unset; false otherwise
207 */
208 readonly lockfile?: boolean | undefined;
209}
210
211// ─────────────────────────────────────────────────────────────────────────────
212// Hashing primitives
213// ─────────────────────────────────────────────────────────────────────────────
214
215const sha256 = (input: string | Uint8Array): string =>
216 NodeCrypto.createHash("sha256").update(input).digest("hex");
217
218const stableValue = (value: unknown): unknown => {
219 if (Array.isArray(value)) {
220 return value.map(stableValue);
221 }
222 if (
223 value !== null &&
224 typeof value === "object" &&
225 value.constructor === Object
226 ) {
227 return Object.fromEntries(
228 Object.entries(value as Record<string, unknown>)
229 .sort(([a], [b]) => a.localeCompare(b))
230 .map(([key, nested]) => [key, stableValue(nested)]),
231 );
232 }
233 return value;
234};
235
236const sha256Object = (value: unknown): string =>
237 sha256(JSON.stringify(stableValue(value)));
238
239/** The integration's own version — included in the input hash so upgrading the
240 * adapter busts the build memo even when project sources are unchanged. */
241const PACKAGE_VERSION: string = (
242 createRequire(import.meta.url)("../../package.json") as { version: string }
243).version;
244
245// ─────────────────────────────────────────────────────────────────────────────
246// Minimal gitignore-style matching (documented subset, zero dependencies)
247// ─────────────────────────────────────────────────────────────────────────────
248
249const escapeRegExpChar = (char: string): string =>
250 /[.*+?^${}()|[\]\\]/.test(char) ? `\\${char}` : char;
251
252const globBodyToRegExpSource = (glob: string): string => {
253 let source = "";
254 let i = 0;
255 while (i < glob.length) {
256 const char = glob[i]!;
257 if (char === "*") {
258 if (glob[i + 1] === "*") {
259 if (glob[i + 2] === "/") {
260 source += "(?:[^/]+/)*";
261 i += 3;
262 } else {
263 source += ".*";
264 i += 2;
265 }
266 } else {
267 source += "[^/]*";
268 i += 1;
269 }
270 } else if (char === "?") {
271 source += "[^/]";
272 i += 1;
273 } else {
274 source += escapeRegExpChar(char);
275 i += 1;
276 }
277 }
278 return source;
279};
280
281/**
282 * Compile one gitignore-style rule into a RegExp over root-relative posix
283 * paths. The RegExp matches the path itself and everything under it (so a
284 * rule matching a directory also matches its files). Returns `undefined` for
285 * blank lines, comments, and negated rules (unsupported).
286 */
287const compileIgnoreRule = (raw: string): RegExp | undefined => {
288 let rule = raw.trim();
289 if (rule.length === 0 || rule.startsWith("#") || rule.startsWith("!")) {
290 return undefined;
291 }
292 if (rule.endsWith("/")) {
293 rule = rule.slice(0, -1);
294 }
295 if (rule.length === 0) {
296 return undefined;
297 }
298 // Anchored when the rule contains a `/` (the trailing directory `/` was
299 // already stripped above); otherwise it matches a basename at any depth.
300 const anchored = rule.includes("/");
301 if (rule.startsWith("/")) {
302 rule = rule.slice(1);
303 }
304 const body = globBodyToRegExpSource(rule);
305 const prefix = anchored ? "^" : "^(?:.*/)?";
306 return new RegExp(`${prefix}${body}(?:/.*)?$`);
307};
308
309const compileIgnoreRules = (rules: ReadonlyArray<string>): Array<RegExp> =>
310 rules
311 .map(compileIgnoreRule)
312 .filter((regex): regex is RegExp => regex !== undefined);
313
314const compileIncludeGlobs = (globs: ReadonlyArray<string>): Array<RegExp> =>
315 globs.map((glob) => new RegExp(`^${globBodyToRegExpSource(glob)}$`));
316
317const matchesAny = (regexes: ReadonlyArray<RegExp>, path: string): boolean =>
318 regexes.some((regex) => regex.test(path));
319
320// ─────────────────────────────────────────────────────────────────────────────
321// Input-tree hashing (the rebuild-free change signal, mirroring the semantics
322// of alchemy's `hashViteInput` / `hashDirectory`)
323// ─────────────────────────────────────────────────────────────────────────────
324
325const LOCKFILE_NAMES = [
326 "bun.lock",
327 "bun.lockb",
328 "package-lock.json",
329 "pnpm-lock.yaml",
330 "yarn.lock",
331];
332
333const findUp = Effect.fn(function* (
334 startDir: string,
335 filenames: ReadonlyArray<string>,
336) {
337 const fs = yield* FileSystem.FileSystem;
338 const path = yield* Path.Path;
339 let dir = startDir;
340 for (;;) {
341 for (const filename of filenames) {
342 const candidate = path.join(dir, filename);
343 if (yield* fs.exists(candidate)) {
344 return candidate;
345 }
346 }
347 const parent = path.dirname(dir);
348 if (parent === dir) {
349 return undefined;
350 }
351 dir = parent;
352 }
353});
354
355/** Collect `.gitignore` rules from `rootDir` up to the repo root (the first
356 * directory containing `.git`), outermost first — same walk as alchemy's
357 * `Command/Memo.ts`. Rules apply relative to `rootDir` (simplification). */
358const readGitIgnoreRules = Effect.fn(function* (rootDir: string) {
359 const fs = yield* FileSystem.FileSystem;
360 const path = yield* Path.Path;
361 const chain: Array<Array<string>> = [];
362 let dir = rootDir;
363 for (;;) {
364 const gitignore = path.join(dir, ".gitignore");
365 if (yield* fs.exists(gitignore)) {
366 const content = yield* fs.readFileString(gitignore);
367 chain.unshift(content.split("\n"));
368 }
369 if (yield* fs.exists(path.join(dir, ".git"))) {
370 break;
371 }
372 const parent = path.dirname(dir);
373 if (parent === dir) {
374 break;
375 }
376 dir = parent;
377 }
378 return chain.flat();
379});
380
381interface ResolvedMemo {
382 readonly include: Array<RegExp> | undefined;
383 readonly exclude: Array<RegExp>;
384 readonly lockfile: boolean;
385}
386
387const resolveMemo = Effect.fn(function* (
388 rootDir: string,
389 distDir: string,
390 memo: WakuMemoOptions | undefined,
391) {
392 const exclude =
393 memo?.exclude !== undefined
394 ? compileIgnoreRules(memo.exclude)
395 : compileIgnoreRules(yield* readGitIgnoreRules(rootDir));
396 return {
397 include:
398 memo?.include !== undefined
399 ? compileIncludeGlobs(memo.include)
400 : undefined,
401 exclude: [
402 // Always excluded regardless of gitignore: dependency trees, VCS
403 // metadata, and the build output waku itself writes (hashing `distDir`
404 // would make every build bust its own memo).
405 ...compileIgnoreRules(["node_modules", ".git", `/${distDir}`]),
406 ...exclude,
407 ],
408 lockfile: memo?.lockfile ?? !(memo?.include || memo?.exclude),
409 } satisfies ResolvedMemo;
410});
411
412/** Deterministic, machine-independent content hash of one directory tree:
413 * sorted `[relativePosixPath, sha256(content)]` pairs (never absolute paths). */
414const hashDirectory = Effect.fn(function* (
415 rootDir: string,
416 memo: ResolvedMemo,
417) {
418 const fs = yield* FileSystem.FileSystem;
419 const path = yield* Path.Path;
420
421 const files: Array<string> = [];
422 const walk: (dir: string, rel: string) => Effect.Effect<void, PlatformError> =
423 Effect.fn(function* (dir: string, rel: string) {
424 const entries = (yield* fs.readDirectory(dir)).sort();
425 for (const entry of entries) {
426 const relPath = rel === "" ? entry : `${rel}/${entry}`;
427 if (matchesAny(memo.exclude, relPath)) {
428 continue;
429 }
430 const absPath = path.join(dir, entry);
431 const stat = yield* fs.stat(absPath);
432 if (stat.type === "Directory") {
433 yield* walk(absPath, relPath);
434 } else if (stat.type === "File") {
435 if (
436 memo.include !== undefined &&
437 !matchesAny(memo.include, relPath)
438 ) {
439 continue;
440 }
441 files.push(relPath);
442 }
443 }
444 });
445 yield* walk(rootDir, "");
446
447 if (memo.lockfile) {
448 const lockfile = yield* findUp(rootDir, LOCKFILE_NAMES);
449 if (lockfile !== undefined) {
450 const relative = path.relative(rootDir, lockfile).replaceAll("\\", "/");
451 if (!files.includes(relative)) {
452 files.push(relative);
453 }
454 }
455 }
456
457 const hashes = yield* Effect.forEach(
458 files.sort(),
459 Effect.fn(function* (file) {
460 const content = yield* fs.readFile(path.join(rootDir, file));
461 return `${file}:${sha256(content)}`;
462 }),
463 { concurrency: 16 },
464 );
465 return sha256Object(hashes);
466});
467
468/**
469 * The `input` hash slot: the project tree (+ workspaces + lockfile), the
470 * integration's own package version, and the build-affecting waku options.
471 * Deterministic for an unchanged tree and stable across `rootDir` moves.
472 */
473const hashWakuInput = Effect.fn(function* (params: {
474 rootDir: string;
475 distDir: string;
476 memo: WakuMemoOptions | undefined;
477 options: WakuSourceOptions;
478 /** Workspace directories, absolute. */
479 workspaces: Iterable<string>;
480}) {
481 const path = yield* Path.Path;
482 const memo = yield* resolveMemo(params.rootDir, params.distDir, params.memo);
483 const workspaceMemo = yield* resolveMemo(
484 params.rootDir,
485 params.distDir,
486 undefined,
487 );
488 const [root, ...workspaceHashes] = yield* Effect.all(
489 [
490 hashDirectory(params.rootDir, memo),
491 ...Array.from(params.workspaces).map((workspace) =>
492 Effect.gen(function* () {
493 const resolved = path.resolve(params.rootDir, workspace);
494 const rules = yield* readGitIgnoreRules(resolved);
495 const hash = yield* hashDirectory(resolved, {
496 ...workspaceMemo,
497 exclude: [...workspaceMemo.exclude, ...compileIgnoreRules(rules)],
498 });
499 return `${path.relative(params.rootDir, resolved).replaceAll("\\", "/")}:${hash}`;
500 }),
501 ),
502 ],
503 { concurrency: 4 },
504 );
505 const hash = sha256Object({
506 version: PACKAGE_VERSION,
507 root,
508 workspaces: workspaceHashes.sort(),
509 options: {
510 // Build-affecting: a changed user entry must bust the memo.
511 main: params.options.main,
512 srcDir: params.options.srcDir,
513 distDir: params.options.distDir,
514 basePath: params.options.basePath,
515 },
516 });
517 const workspaces = Array.from(params.workspaces).map((workspace) =>
518 path
519 .relative(params.rootDir, path.resolve(params.rootDir, workspace))
520 .replaceAll("\\", "/"),
521 );
522 return { hash, workspaces };
523});
524
525// ─────────────────────────────────────────────────────────────────────────────
526// Client-asset reading (mirror of alchemy `Cloudflare/Workers/Assets.readAssets`)
527// ─────────────────────────────────────────────────────────────────────────────
528
529const SPECIAL_ASSET_FILES = [".assetsignore", "_headers", "_redirects"];
530
531const maybeReadString = Effect.fn(function* (file: string) {
532 const fs = yield* FileSystem.FileSystem;
533 return yield* fs.readFileString(file).pipe(
534 Effect.catchIf(
535 (error) =>
536 error._tag === "PlatformError" && error.reason._tag === "NotFound",
537 () => Effect.succeed(undefined),
538 ),
539 );
540});
541
542/**
543 * Read the client output directory into the manifest shape alchemy's
544 * `putWorker`/`uploadAssets` consume: per-file `sha256(content).slice(0, 32)`
545 * + byte size, keyed by `/`-prefixed posix path; the aggregate hash covers
546 * only upload-affecting inputs (manifest, config, `_headers`, `_redirects` —
547 * never the absolute directory path).
548 */
549const readClientAssets = Effect.fn(function* (
550 directory: string,
551 config: Record<string, unknown> | undefined,
552) {
553 const fs = yield* FileSystem.FileSystem;
554 const path = yield* Path.Path;
555 const [entries, ignoreFile, _headers, _redirects] = yield* Effect.all([
556 fs.readDirectory(directory, { recursive: true }),
557 maybeReadString(path.join(directory, ".assetsignore")),
558 maybeReadString(path.join(directory, "_headers")),
559 maybeReadString(path.join(directory, "_redirects")),
560 ]);
561 const ignores = compileIgnoreRules([
562 ...SPECIAL_ASSET_FILES,
563 ...(ignoreFile?.split("\n") ?? []),
564 ]);
565 const manifest = new Map<string, { hash: string; size: number }>();
566 yield* Effect.forEach(
567 entries,
568 Effect.fn(function* (name) {
569 const relPath = name.replaceAll("\\", "/");
570 if (matchesAny(ignores, relPath)) {
571 return;
572 }
573 const file = path.join(directory, name);
574 const stat = yield* fs.stat(file);
575 if (stat.type !== "File") {
576 return;
577 }
578 const content = yield* fs.readFile(file);
579 manifest.set(relPath.startsWith("/") ? relPath : `/${relPath}`, {
580 hash: sha256(content).slice(0, 32),
581 size: Number(stat.size),
582 });
583 }),
584 { concurrency: 16 },
585 );
586 const sortedManifest = Object.fromEntries(
587 Array.from(manifest.entries()).sort((a, b) => a[0].localeCompare(b[0])),
588 );
589 return {
590 directory,
591 config,
592 manifest: sortedManifest,
593 _headers,
594 _redirects,
595 hash: sha256Object({
596 config,
597 manifest: sortedManifest,
598 _headers,
599 _redirects,
600 }),
601 } satisfies AssetReadResult;
602});
603
604// ─────────────────────────────────────────────────────────────────────────────
605// The provider
606// ─────────────────────────────────────────────────────────────────────────────
607
608const PROVIDER = "@alchemy.run/frontend-frameworks/waku/source";
609
610export interface WakuLikeSourceIntegration {
611 readonly provider: string;
612 readonly framework: string;
613 readonly displayName: string;
614 readonly buildModule: string;
615 readonly layer: (options: {
616 root: string;
617 target: ReturnType<typeof makeWakuCloudflareTarget>;
618 }) => Layer.Layer<FrameworkCore.Framework, never, SourceRequirements>;
619}
620
621/**
622 * Waku assumes `process.cwd()` is the project root in several places (the
623 * html-shell plugin's `index.html` input resolves against the cwd, and
624 * rolldown resolves relative inputs from the cwd), exactly like its CLI,
625 * which always runs from the project.
626 *
627 * The production **build** therefore runs in a disposable child process
628 * whose working directory IS the project root (see `core/BuildChild.ts`) —
629 * no in-process `chdir`, no lock, unbounded build concurrency. The shared `core/BuildChildRunner`
630 * entry imports this module in the child and calls the exported
631 * {@link buildInChild}.
632 *
633 * The **dev server** cannot cross a process boundary (its inputs —
634 * `ctx.worker.bindings` hooks and `ctx.runtimeContext` — are live objects),
635 * so only its startup window still chdirs in-process, serialized through
636 * {@link cwdLock}; the cwd is restored as soon as the server is listening.
637 */
638const cwdLock = Semaphore.makeUnsafe(1);
639
640const withRootCwd = <A, E, R>(
641 rootDir: string,
642 effect: Effect.Effect<A, E, R>,
643): Effect.Effect<A, E, R> =>
644 Semaphore.withPermits(
645 cwdLock,
646 1,
647 )(
648 Effect.acquireUseRelease(
649 Effect.sync(() => {
650 const previous = process.cwd();
651 process.chdir(rootDir);
652 return previous;
653 }),
654 () => effect,
655 (previous) => Effect.sync(() => process.chdir(previous)),
656 ),
657 );
658
659/** JSON config the build child reconstructs the waku framework from. */
660export interface WakuBuildChildConfig {
661 readonly rootDir: string;
662 readonly compatibilityDate: string;
663 readonly compatibilityFlags: Array<string>;
664 readonly main: string | undefined;
665 readonly waku: {
666 readonly srcDir?: string;
667 readonly distDir?: string;
668 readonly basePath?: string;
669 };
670}
671
672export const buildInChild = (config: WakuBuildChildConfig) =>
673 Effect.gen(function* () {
674 const waku = yield* FrameworkCore.Framework;
675 return yield* waku.build({ root: config.rootDir });
676 }).pipe(
677 Effect.provide(
678 wakuFrameworkLayer({
679 root: config.rootDir,
680 waku: config.waku,
681 target: makeWakuCloudflareTarget({
682 compatibilityDate: config.compatibilityDate,
683 compatibilityFlags: config.compatibilityFlags,
684 ...(config.main !== undefined ? { main: config.main } : undefined),
685 }),
686 }),
687 ),
688 );
689
690/** Routing config from the raw `props.assets` (directory/hash keys are
691 * path-level concerns owned by alchemy; only the routing config passes through). */
692const assetsConfigOf = (
693 assets: unknown,
694): Record<string, unknown> | undefined => {
695 if (assets === undefined || assets === null || typeof assets !== "object") {
696 return undefined;
697 }
698 const {
699 directory: _directory,
700 hash: _hash,
701 ...config
702 } = assets as Record<string, unknown>;
703 return Object.keys(config).length > 0 ? config : undefined;
704};
705
706export const makeWakuSourceProvider = (
707 options: WakuSourceOptions,
708 integration: WakuLikeSourceIntegration = {
709 provider: PROVIDER,
710 framework: "waku",
711 displayName: "Waku",
712 buildModule: import.meta.url,
713 layer: ({ root, target }) =>
714 wakuFrameworkLayer({ root, waku: wakuConfigFor(options), target }),
715 },
716): SourceProvider => {
717 const rootDirOf = (path: Path.Path): string =>
718 options.rootDir !== undefined
719 ? path.resolve(options.rootDir)
720 : process.cwd();
721 const distDir = options.distDir ?? "dist";
722 const wakuConfig = wakuConfigFor(options);
723
724 return {
725 // Waku's assets are a build product (`dist/public`), not a props-level
726 // directory — the provider reads and diffs them itself.
727 ownsAssets: true,
728
729 build: Effect.fn(function* (ctx) {
730 const path = yield* Path.Path;
731 const rootDir = rootDirOf(path);
732 // The build runs in a child process with cwd = rootDir (waku resolves
733 // inputs relative to the cwd); `buildInChild` reconstructs the
734 // framework + cloudflare target from this JSON config on the far side.
735 const output = yield* runBuildChild({
736 module: integration.buildModule,
737 rootDir,
738 framework: integration.framework,
739 config: {
740 rootDir,
741 compatibilityDate: ctx.compatibility.date,
742 compatibilityFlags: ctx.compatibility.flags,
743 // The user-entry seam: surfaced as `DeployTarget.entry` and made
744 // the vite plugin's `main` (resolved against the root by
745 // `makeWakuPluginOptions`).
746 main: options.main,
747 waku: wakuConfig,
748 } satisfies WakuBuildChildConfig,
749 }).pipe(
750 Effect.mapError(
751 (error) =>
752 new SourceProviderError({
753 provider: integration.provider,
754 message: `${integration.displayName} build failed`,
755 cause: error.cause ?? error,
756 }),
757 ),
758 );
759
760 if (
761 output.serverModules === undefined ||
762 output.serverModules.length === 0
763 ) {
764 return yield* Effect.fail(
765 new SourceProviderError({
766 provider: integration.provider,
767 message: `${integration.displayName} build produced no server modules`,
768 }),
769 );
770 }
771 const [entry, ...rest] = output.serverModules;
772 const files: [BundleFile, ...Array<BundleFile>] = [
773 ...[entry!, ...rest].map((file) => ({
774 path: file.name.replaceAll("\\", "/"),
775 content: file.content,
776 hash: file.hash,
777 })),
778 ] as [BundleFile, ...Array<BundleFile>];
779 const bundle: BundleOutput = {
780 files,
781 hash: sha256Object(files.map((file) => [file.path, file.hash])),
782 };
783
784 const [assets, input] = yield* Effect.all(
785 [
786 output.clientDirectory !== undefined
787 ? readClientAssets(
788 output.clientDirectory,
789 assetsConfigOf(ctx.assets),
790 )
791 : Effect.succeed(undefined),
792 hashWakuInput({
793 rootDir,
794 distDir,
795 memo: options.memo,
796 options,
797 workspaces: output.externalWorkspaces,
798 }),
799 ],
800 { concurrency: "unbounded" },
801 );
802
803 return {
804 bundle,
805 assets,
806 hash: {
807 bundle: bundle.hash,
808 assets: assets?.hash,
809 input: input.hash,
810 additionalWorkspaces: input.workspaces,
811 },
812 } satisfies SourceBuildOutput;
813 }),
814
815 // Rebuild-free: re-hash the project tree (+ previously-discovered
816 // workspaces) — the same recipe as alchemy's vite source. `previous` is
817 // only used for the auxiliary workspace list, never to skip recomputation.
818 hash: Effect.fn(function* (_ctx, previous) {
819 const path = yield* Path.Path;
820 const rootDir = rootDirOf(path);
821 const input = yield* hashWakuInput({
822 rootDir,
823 distDir,
824 memo: options.memo,
825 options,
826 workspaces: (previous?.additionalWorkspaces ?? []).map((workspace) =>
827 path.resolve(rootDir, workspace),
828 ),
829 });
830 return { input: input.hash, additionalWorkspaces: input.workspaces };
831 }),
832
833 // Waku dev is a vite dev server with the cloudflare plugin embedded in
834 // waku's own plugin stack (the rsc environment runs in workerd against
835 // the host-provided worker wiring) — a `server`-mode dev handle.
836 dev: Effect.fn(function* (ctx) {
837 const path = yield* Path.Path;
838 const rootDir = rootDirOf(path);
839 const queueConsumers = yield* ctx.worker.queueConsumers;
840 const framework = integration.layer({
841 root: rootDir,
842 target: makeWakuCloudflareTarget({
843 compatibilityDate: ctx.compatibility.date,
844 compatibilityFlags: ctx.compatibility.flags,
845 // Dev serves the wrapped user entry too, so DO classes exported
846 // from it exist in dev.
847 ...(options.main !== undefined ? { main: options.main } : undefined),
848 worker: {
849 name: ctx.workerName,
850 bindings: ctx.worker.bindings,
851 durableObjectNamespaces: ctx.worker.durableObjectNamespaces,
852 hyperdrives: ctx.worker.hyperdrives,
853 queueConsumers,
854 assets: ctx.worker.assets,
855 },
856 context: ctx.runtimeContext,
857 }),
858 });
859 // The dev server *starts* under the project cwd (waku resolves its
860 // html shell and relative inputs from the cwd at startup); the cwd is
861 // restored once the server is listening.
862 const server = yield* withRootCwd(
863 rootDir,
864 Effect.gen(function* () {
865 const waku = yield* FrameworkCore.Framework;
866 return yield* waku.dev({ root: rootDir });
867 }).pipe(
868 Effect.provide(framework),
869 Effect.mapError(
870 (cause) =>
871 new SourceProviderError({
872 provider: integration.provider,
873 message: `Failed to start the ${integration.framework} dev server`,
874 cause,
875 }),
876 ),
877 ),
878 );
879 return {
880 mode: "server",
881 url: new URL(server.url),
882 } satisfies ServerDevHandle;
883 }),
884 };
885};
886
887/**
888 * The dynamic-module contract alchemy's `loadSource` expects: the default
889 * export's `make(options)` builds a `SourceProvider` from the descriptor's
890 * JSON-serializable options.
891 */
892const sourceModule = {
893 make: (
894 options: unknown,
895 ): Effect.Effect<SourceProvider, SourceProviderError> => {
896 if (
897 options !== undefined &&
898 (typeof options !== "object" || options === null)
899 ) {
900 return Effect.fail(
901 new SourceProviderError({
902 provider: PROVIDER,
903 message: `Invalid options for ${PROVIDER}: expected an object, got ${typeof options}`,
904 }),
905 );
906 }
907 return Effect.succeed(
908 makeWakuSourceProvider((options ?? {}) as WakuSourceOptions),
909 );
910 },
911};
912
913export default sourceModule;