1/**
2 * Alchemy Worker source provider for Astro projects.
3 *
4 * The default export implements alchemy's `WorkerSourceModule` contract
5 * (`packages/alchemy/src/Cloudflare/Workers/Source.ts`) **structurally** —
6 * this package deliberately does not depend on `alchemy`, so the types
7 * below mirror the shapes of `SourceProvider` / `SourceContext` /
8 * `DevContext` / `SourceBuildOutput` / `SourceHash` / `SourceDevHandle`
9 * instead of importing them. The contract is structural on purpose:
10 * version skew degrades to a load-time `SourceProviderError`, not a
11 * crash.
12 *
13 * Semantics mirror alchemy's built-in Vite source
14 * (`Workers/Sources/Vite.ts`):
15 *
16 * - `build()` runs the programmatic Astro build via this package's
17 * `Framework` service, maps `serverModules` (entry first) to the
18 * bundle files, reads `clientDirectory` into an asset manifest, and
19 * computes a rebuild-free `input` hash over the project tree.
20 * - `hash()` recomputes only the `input` hash (never builds) — the memo
21 * fast path that lets an unchanged project skip the build entirely.
22 * - `dev()` starts `astro dev` with the `ssr` environment executing
23 * inside workerd (via `@alchemy.run/cloudflare-runtime/vite`) and
24 * returns a `server`-mode handle.
25 *
26 * The `input` hash includes this package's version so upgrading the
27 * integration (which can change build output) busts the memo even when
28 * project sources are unchanged.
29 */
30import type { CloudflareVitePluginOptions } from "@alchemy.run/cloudflare-runtime/vite";
31import { runBuildChild } from "../core/BuildChild.ts";
32import * as FrameworkCore from "../core/index.ts";
33import * as Data from "effect/Data";
34import * as Effect from "effect/Effect";
35import * as FileSystem from "effect/FileSystem";
36import * as Path from "effect/Path";
37import * as Redacted from "effect/Redacted";
38import type { PlatformError } from "effect/PlatformError";
39import type * as Scope from "effect/Scope";
40import * as NodeCrypto from "node:crypto";
41import { createRequire } from "node:module";
42import * as Astro from "./Astro.ts";
43import cloudflareTarget from "./cloudflare.ts";
44
45// ─────────────────────────────────────────────────────────────────────
46// Structural mirrors of alchemy's source-provider contract
47// (packages/alchemy/src/Cloudflare/Workers/Source.ts). Keep in sync.
48// ─────────────────────────────────────────────────────────────────────
49
50/** Mirror of alchemy's `SourceHash`. */
51export interface SourceHash {
52 readonly bundle: string | undefined;
53 readonly assets: string | undefined;
54 readonly input: string | undefined;
55 readonly additionalWorkspaces: Array<string> | undefined;
56}
57
58/** Mirror of alchemy's `Bundle.BundleFile`. */
59export interface BundleFile {
60 readonly path: string;
61 readonly content: string | Uint8Array;
62 readonly hash: string;
63}
64
65/** Mirror of alchemy's `Bundle.BundleOutput`. */
66export interface BundleOutput {
67 readonly files: [BundleFile, ...Array<BundleFile>];
68 readonly hash: string;
69}
70
71/** Mirror of alchemy's `AssetReadResult` (Workers/Assets.ts). */
72export interface AssetReadResult {
73 readonly directory: string;
74 readonly config: Record<string, unknown> | undefined;
75 readonly manifest: Record<string, { hash: string; size: number }>;
76 readonly _headers: string | undefined;
77 readonly _redirects: string | undefined;
78 readonly hash: string;
79}
80
81/** Mirror of alchemy's `SourceBuildOutput`. */
82export interface SourceBuildOutput {
83 readonly bundle: BundleOutput | undefined;
84 readonly assets: AssetReadResult | undefined;
85 readonly hash: SourceHash;
86}
87
88/** Mirror of alchemy's `SourceContext` (the slots this source reads). */
89export interface SourceContext {
90 readonly id: string;
91 readonly workerName: string;
92 readonly compatibility: {
93 readonly date: string;
94 readonly flags: Array<string>;
95 };
96 readonly stack: { readonly name: string; readonly stage: string };
97 readonly env: Record<string, unknown> | undefined;
98 /** Raw `props.assets` — a directory string or an asset-config object. */
99 readonly assets: string | Record<string, unknown> | undefined;
100}
101
102type PluginWorker = Exclude<CloudflareVitePluginOptions["worker"], undefined>;
103
104/** Mirror of alchemy's `DevContext` (the slots this source reads). */
105export interface DevContext extends SourceContext {
106 readonly worker: {
107 readonly bindings: PluginWorker["bindings"];
108 readonly durableObjectNamespaces: PluginWorker["durableObjectNamespaces"];
109 readonly hyperdrives: PluginWorker["hyperdrives"];
110 readonly queueConsumers: Effect.Effect<
111 Exclude<PluginWorker["queueConsumers"], undefined>
112 >;
113 readonly assets: PluginWorker["assets"] | undefined;
114 };
115 readonly runtimeContext: CloudflareVitePluginOptions["context"];
116}
117
118/** Mirror of alchemy's `SourceDevHandle` (`server` mode only). */
119export interface SourceDevHandle {
120 readonly mode: "server";
121 readonly url: URL;
122}
123
124/**
125 * Structural twin of alchemy's `Cloudflare.Workers.SourceProviderError`
126 * — same `_tag` and fields, so `Effect.catchTag` on the alchemy side
127 * matches errors raised here.
128 */
129export class SourceProviderError extends Data.TaggedError(
130 "Cloudflare.Workers.SourceProviderError",
131)<{
132 readonly provider: string;
133 readonly message: string;
134 readonly cause?: unknown;
135}> {}
136
137export type SourceError = PlatformError | SourceProviderError;
138
139type SourceServices = FileSystem.FileSystem | Path.Path;
140
141/** Mirror of alchemy's `SourceProvider` (with this module's closed error/requirement channels). */
142export interface SourceProvider {
143 readonly ownsAssets: boolean;
144 readonly build: (
145 ctx: SourceContext,
146 ) => Effect.Effect<SourceBuildOutput, SourceError, SourceServices>;
147 readonly hash: (
148 ctx: SourceContext,
149 previous: SourceHash | undefined,
150 ) => Effect.Effect<Partial<SourceHash>, SourceError, SourceServices>;
151 readonly dev: (
152 ctx: DevContext,
153 ) => Effect.Effect<
154 SourceDevHandle,
155 SourceError,
156 SourceServices | Scope.Scope
157 >;
158}
159
160// ─────────────────────────────────────────────────────────────────────
161// Options
162// ─────────────────────────────────────────────────────────────────────
163
164/**
165 * Controls which files are hashed to decide whether a rebuild is
166 * needed. Mirrors alchemy's `MemoOptions` semantics: by default every
167 * non-gitignored file under the root is hashed, plus the nearest
168 * package-manager lockfile.
169 */
170export interface MemoOptions {
171 /** Glob patterns of files to hash, relative to the directory. @default all files */
172 readonly include?: Array<string>;
173 /** Glob patterns to exclude. @default gitignore rules up to the repo root */
174 readonly exclude?: Array<string>;
175 /**
176 * Whether to include the nearest lockfile in the hash.
177 * @default true when both `include` and `exclude` are unset
178 */
179 readonly lockfile?: boolean;
180}
181
182/**
183 * Provider options carried on the Worker's `source` descriptor. Must be
184 * JSON-serializable and JSON-stable (they persist in state and
185 * participate in the Worker's metadata hash).
186 */
187export interface AstroSourceOptions {
188 /** Astro project root. Defaults to the process working directory. */
189 readonly rootDir?: string;
190 /** Rebuild-scope configuration (see {@link MemoOptions}). */
191 readonly memo?: MemoOptions & {
192 /**
193 * Additional workspace directories to hash. `"auto"` (default)
194 * auto-detects workspaces from the build's module graph; an explicit
195 * array pins them.
196 */
197 readonly workspaces?:
198 | "auto"
199 | Array<MemoOptions & { readonly cwd: string }>;
200 };
201 /**
202 * The name of the KV binding injected into Astro's session config
203 * when present on the Worker env.
204 * @default "SESSION"
205 */
206 readonly sessionKVBindingName?: string;
207 /**
208 * Zero-config sessions: default Astro's session driver to Cloudflare KV
209 * (binding `sessionKVBindingName`) when none is configured. `false`
210 * leaves the session config untouched.
211 * @default true
212 */
213 readonly sessions?: boolean;
214 /**
215 * Auto-inject an in-memory local KV namespace for the session binding: in
216 * dev on the dev worker, at build time on the workerd prerender worker
217 * (so session-touching pages can prerender before the Worker's real
218 * namespace exists). Disable once the Worker's own bindings deliver a KV
219 * binding with that name (e.g. when alchemy provisions the session
220 * namespace and passes it through the dev bindings).
221 * @default true
222 */
223 readonly sessionDevKV?: boolean;
224 /**
225 * Runtime used to prerender static pages.
226 * @default "workerd"
227 */
228 readonly prerenderEnvironment?: "workerd" | "node";
229 /**
230 * JSON-serializable subset of Astro config merged into the in-memory
231 * `AstroInlineConfig`. The project's `astro.config.*` file loads
232 * natively; astro merges this inline overlay OVER it (scalars here
233 * override the file). The file itself participates in the `input`
234 * memo hash like any other project file, so these options stay
235 * JSON-serializable while the config file stays fully expressive.
236 */
237 readonly astro?: {
238 readonly site?: string;
239 readonly base?: string;
240 readonly output?: "server" | "static";
241 readonly srcDir?: string;
242 readonly publicDir?: string;
243 readonly outDir?: string;
244 readonly trailingSlash?: "always" | "never" | "ignore";
245 };
246 /**
247 * Path to an alternate Astro config file, resolved against
248 * {@link rootDir} when relative. Defaults to astro's own config
249 * discovery (`astro.config.*` in the project root).
250 */
251 readonly config?: string;
252}
253
254const PROVIDER = "@alchemy.run/frontend-frameworks/astro/source";
255
256const packageMeta: { version: string; root: string | undefined } = (() => {
257 try {
258 const require = createRequire(import.meta.url);
259 const path = require.resolve("../../package.json");
260 return {
261 version: (require(path) as { version: string }).version,
262 root: path.slice(0, path.length - "package.json".length - 1),
263 };
264 } catch {
265 return { version: "unknown", root: undefined };
266 }
267})();
268
269const packageVersion = packageMeta.version;
270
271// ─────────────────────────────────────────────────────────────────────
272// Hashing primitives (mirror alchemy's Util/sha256 + Command/Memo)
273// ─────────────────────────────────────────────────────────────────────
274
275const sha256Hex = (input: string | Uint8Array): Effect.Effect<string> =>
276 Effect.sync(() =>
277 NodeCrypto.createHash("sha256").update(input).digest("hex"),
278 );
279
280/** Recursively sort object keys so JSON.stringify is order-stable. */
281const stableValue = (value: unknown): unknown => {
282 if (Array.isArray(value)) {
283 return value.map(stableValue);
284 }
285 if (
286 value !== null &&
287 typeof value === "object" &&
288 !ArrayBuffer.isView(value)
289 ) {
290 return Object.fromEntries(
291 Object.entries(value as Record<string, unknown>)
292 .sort(([a], [b]) => a.localeCompare(b))
293 .map(([key, nested]) => [key, stableValue(nested)]),
294 );
295 }
296 return value;
297};
298
299const sha256Object = (input: unknown): Effect.Effect<string> =>
300 Effect.flatMap(
301 Effect.sync(() => JSON.stringify(stableValue(input))),
302 sha256Hex,
303 );
304
305// ─────────────────────────────────────────────────────────────────────
306// Glob / gitignore matching (self-contained approximation of the
307// glob + gitignore semantics alchemy's `hashDirectory` uses)
308// ─────────────────────────────────────────────────────────────────────
309
310const escapeRegex = (char: string): string =>
311 /[\\^$.|+()[\]{}]/.test(char) ? `\\${char}` : char;
312
313/**
314 * Convert a glob body (no negation/anchoring — handled by callers) to a
315 * regex source. Supports `**`, `*`, `?`; everything else is literal.
316 */
317const globToRegexSource = (glob: string): string => {
318 let out = "";
319 let i = 0;
320 while (i < glob.length) {
321 const c = glob[i]!;
322 if (c === "*" && glob[i + 1] === "*") {
323 const prevBoundary = i === 0 || glob[i - 1] === "/";
324 let j = i + 2;
325 if (prevBoundary && glob[j] === "/") {
326 // `**/` segment — zero or more whole path segments.
327 out += "(?:[^/]+/)*";
328 i = j + 1;
329 continue;
330 }
331 out += ".*";
332 i = j;
333 continue;
334 }
335 if (c === "*") {
336 out += "[^/]*";
337 i++;
338 continue;
339 }
340 if (c === "?") {
341 out += "[^/]";
342 i++;
343 continue;
344 }
345 out += escapeRegex(c);
346 i++;
347 }
348 return out;
349};
350
351interface IgnoreRule {
352 readonly regex: RegExp;
353 readonly dirOnly: boolean;
354 readonly negated: boolean;
355}
356
357/** Compile one gitignore-style rule line. Returns `undefined` for blanks/comments. */
358const compileIgnoreRule = (raw: string): IgnoreRule | undefined => {
359 let line = raw.trim();
360 if (line.length === 0 || line.startsWith("#")) {
361 return undefined;
362 }
363 let negated = false;
364 if (line.startsWith("!")) {
365 negated = true;
366 line = line.slice(1).trim();
367 if (line.length === 0) return undefined;
368 }
369 let dirOnly = false;
370 if (line.endsWith("/")) {
371 dirOnly = true;
372 line = line.slice(0, -1);
373 }
374 line = line.replaceAll("\\", "/");
375 // A slash anywhere (after stripping the trailing one) anchors the
376 // pattern to the ignore root; otherwise it matches at any depth.
377 const anchored = line.startsWith("/") || line.includes("/");
378 if (line.startsWith("/")) {
379 line = line.slice(1);
380 }
381 if (line.length === 0) return undefined;
382 const body = globToRegexSource(line);
383 const source = anchored ? `^${body}$` : `(?:^|/)${body}$`;
384 try {
385 return { regex: new RegExp(source), dirOnly, negated };
386 } catch {
387 return undefined;
388 }
389};
390
391const compileIgnoreRules = (rules: ReadonlyArray<string>): Array<IgnoreRule> =>
392 rules
393 .map(compileIgnoreRule)
394 .filter((rule): rule is IgnoreRule => rule !== undefined);
395
396/**
397 * Gitignore matching: the LAST matching rule wins; `dirOnly` rules only
398 * match directories. Paths are `/`-separated and relative (no leading
399 * slash).
400 */
401const isIgnored = (
402 rules: ReadonlyArray<IgnoreRule>,
403 path: string,
404 isDirectory: boolean,
405): boolean => {
406 let ignored = false;
407 for (const rule of rules) {
408 if (rule.dirOnly && !isDirectory) continue;
409 if (rule.regex.test(path)) {
410 ignored = !rule.negated;
411 }
412 }
413 return ignored;
414};
415
416/** Compile positive glob patterns (memo include/exclude), anchored to the root. */
417const compileGlobs = (globs: ReadonlyArray<string>): Array<RegExp> =>
418 globs.flatMap((glob) => {
419 try {
420 return [new RegExp(`^${globToRegexSource(glob.replace(/^\.\//, ""))}$`)];
421 } catch {
422 return [];
423 }
424 });
425
426const matchesAny = (regexes: ReadonlyArray<RegExp>, path: string): boolean =>
427 regexes.some((regex) => regex.test(path));
428
429// ─────────────────────────────────────────────────────────────────────
430// Directory walking + input hashing (mirror of alchemy's hashDirectory
431// / hashViteInput recipe: `${relative}:${hash}` per workspace, sorted,
432// hashed together — machine-independent, never hashes absolute paths)
433// ─────────────────────────────────────────────────────────────────────
434
435const LOCKFILES = [
436 "bun.lock",
437 "bun.lockb",
438 "package-lock.json",
439 "pnpm-lock.yaml",
440 "yarn.lock",
441];
442
443const readGitIgnoreRules = (
444 cwd: string,
445): Effect.Effect<Array<string>, PlatformError, SourceServices> =>
446 Effect.gen(function* () {
447 const fs = yield* FileSystem.FileSystem;
448 const path = yield* Path.Path;
449 const rules = yield* fs.readFileString(path.join(cwd, ".gitignore")).pipe(
450 Effect.map((file) => file.split("\n")),
451 Effect.catchTag("PlatformError", () =>
452 Effect.succeed([] as Array<string>),
453 ),
454 );
455 const parent = path.dirname(cwd);
456 if (parent === cwd || (yield* fs.exists(path.join(cwd, ".git")))) {
457 return rules;
458 }
459 return [...(yield* readGitIgnoreRules(parent)), ...rules];
460 });
461
462const findUp = (
463 cwd: string,
464 filenames: ReadonlyArray<string>,
465): Effect.Effect<string | undefined, PlatformError, SourceServices> =>
466 Effect.gen(function* () {
467 const fs = yield* FileSystem.FileSystem;
468 const path = yield* Path.Path;
469 for (const filename of filenames) {
470 const candidate = path.join(cwd, filename);
471 if (yield* fs.exists(candidate)) {
472 return candidate;
473 }
474 }
475 const parent = path.dirname(cwd);
476 if (parent === cwd) {
477 return undefined;
478 }
479 return yield* findUp(parent, filenames);
480 });
481
482/**
483 * List files under `cwd` (relative, `/`-separated, sorted), pruning
484 * ignored directories during the walk so `node_modules`-scale trees are
485 * never traversed.
486 */
487const listFiles = (
488 cwd: string,
489 options: {
490 readonly include: ReadonlyArray<RegExp> | undefined;
491 readonly ignoreRules: ReadonlyArray<IgnoreRule>;
492 readonly exclude: ReadonlyArray<RegExp>;
493 },
494): Effect.Effect<Array<string>, PlatformError, SourceServices> =>
495 Effect.gen(function* () {
496 const fs = yield* FileSystem.FileSystem;
497 const path = yield* Path.Path;
498 const files: Array<string> = [];
499 const walk = (relative: string): Effect.Effect<void, PlatformError> =>
500 Effect.gen(function* () {
501 const absolute = relative === "" ? cwd : path.join(cwd, relative);
502 const entries = yield* fs.readDirectory(absolute);
503 for (const entry of entries.sort()) {
504 const rel = relative === "" ? entry : `${relative}/${entry}`;
505 const stat = yield* fs
506 .stat(path.join(cwd, rel))
507 .pipe(
508 Effect.catchTag("PlatformError", () => Effect.succeed(undefined)),
509 );
510 if (stat === undefined) continue;
511 if (stat.type === "Directory") {
512 if (
513 isIgnored(options.ignoreRules, rel, true) ||
514 matchesAny(options.exclude, rel) ||
515 matchesAny(options.exclude, `${rel}/`)
516 ) {
517 continue;
518 }
519 yield* walk(rel);
520 continue;
521 }
522 if (stat.type !== "File") continue;
523 if (isIgnored(options.ignoreRules, rel, false)) continue;
524 if (matchesAny(options.exclude, rel)) continue;
525 if (
526 options.include !== undefined &&
527 !matchesAny(options.include, rel)
528 ) {
529 continue;
530 }
531 files.push(rel);
532 }
533 });
534 yield* walk("");
535 return files.sort();
536 });
537
538/**
539 * Deterministic content hash of a directory per the given memo options —
540 * the structural mirror of alchemy's `hashDirectory`.
541 */
542const hashDirectory = (
543 cwd: string,
544 memo: MemoOptions | undefined,
545): Effect.Effect<string, PlatformError, SourceServices> =>
546 Effect.gen(function* () {
547 const fs = yield* FileSystem.FileSystem;
548 const path = yield* Path.Path;
549 const include = memo?.include ? compileGlobs(memo.include) : undefined;
550 const exclude = memo?.exclude ? compileGlobs(memo.exclude) : [];
551 // Gitignore-based exclusion applies whenever no explicit exclude
552 // globs are given (matching alchemy's resolveMemoOptions).
553 const ignoreRules = memo?.exclude
554 ? []
555 : compileIgnoreRules([".git/", ...(yield* readGitIgnoreRules(cwd))]);
556 const useLockfile = memo?.lockfile ?? !(memo?.include || memo?.exclude);
557 const files = yield* listFiles(cwd, { include, ignoreRules, exclude });
558 if (useLockfile) {
559 const lockfile = yield* findUp(cwd, LOCKFILES);
560 if (lockfile !== undefined) {
561 const rel = path.relative(cwd, lockfile).replaceAll("\\", "/");
562 if (!files.includes(rel)) {
563 files.push(rel);
564 }
565 }
566 }
567 const hashes = yield* Effect.forEach(
568 files,
569 (file) =>
570 fs.readFile(path.resolve(cwd, file)).pipe(
571 Effect.flatMap(sha256Hex),
572 Effect.map((hash) => `${file}:${hash}`),
573 ),
574 { concurrency: 16 },
575 );
576 return yield* sha256Object(hashes);
577 });
578
579/**
580 * The `input` hash: root tree + workspace trees + this package's
581 * version. Mirrors alchemy's `hashViteInput` recipe (workspace hashes
582 * are `${relativePath}:${hash}`, sorted) with the provider version as
583 * extra material so integration upgrades bust the memo.
584 */
585const hashAstroInput = (
586 rootDir: string,
587 memo: AstroSourceOptions["memo"],
588 additionalWorkspaces: Effect.Effect<
589 Iterable<string>,
590 PlatformError,
591 SourceServices
592 >,
593): Effect.Effect<
594 { hash: string; workspaces: Array<string> | undefined },
595 PlatformError,
596 SourceServices
597> =>
598 Effect.gen(function* () {
599 const path = yield* Path.Path;
600 const salt = `${PROVIDER}@${packageVersion}`;
601 // Normalize workspace directories to a root-relative, `/`-separated
602 // form so the hash label is identical whether the workspace came
603 // from the build (absolute) or from persisted state (relative) —
604 // and machine-independent (never hashes absolute paths).
605 const toRelative = (cwd: string): string =>
606 (path.isAbsolute(cwd)
607 ? path.relative(rootDir, cwd)
608 : path.normalize(cwd)
609 ).replaceAll("\\", "/");
610 const hashWorkspaceDirectory = (
611 cwdRelative: string,
612 options?: MemoOptions,
613 ) =>
614 hashDirectory(path.resolve(rootDir, cwdRelative), options).pipe(
615 Effect.map((hash) => `${cwdRelative}:${hash}`),
616 );
617 const hashRoot = hashDirectory(rootDir, memo).pipe(
618 Effect.map((hash) => `:${hash}`),
619 );
620 if (Array.isArray(memo?.workspaces)) {
621 const [root, ...workspaces] = yield* Effect.all(
622 [
623 hashRoot,
624 ...memo.workspaces.map(({ cwd, ...options }) =>
625 hashWorkspaceDirectory(toRelative(cwd), options),
626 ),
627 ],
628 { concurrency: "unbounded" },
629 );
630 const hash = yield* sha256Object([salt, root, ...workspaces.sort()]);
631 return { hash, workspaces: undefined };
632 }
633 const [root, workspaces] = yield* Effect.all(
634 [hashRoot, additionalWorkspaces],
635 {
636 concurrency: "unbounded",
637 },
638 );
639 // Exclude this integration package's own tree from auto-detected
640 // workspaces (it escapes the collector's node_modules filter when
641 // workspace-linked): its influence on the build is covered by the
642 // version salt, and hashing its build artifacts would churn.
643 const isOwnPackage = (cwd: string): boolean => {
644 if (packageMeta.root === undefined) return false;
645 const resolved = path.resolve(rootDir, cwd);
646 return (
647 resolved === packageMeta.root ||
648 resolved.startsWith(packageMeta.root + path.sep)
649 );
650 };
651 const relativeWorkspaces = [
652 ...new Set(
653 Array.from(workspaces)
654 .filter((cwd) => !isOwnPackage(cwd))
655 .map(toRelative),
656 ),
657 ].sort();
658 const workspaceHashes = yield* Effect.forEach(
659 relativeWorkspaces,
660 (cwd) => hashWorkspaceDirectory(cwd),
661 { concurrency: "unbounded" },
662 );
663 const hash = yield* sha256Object([salt, root, ...workspaceHashes.sort()]);
664 return { hash, workspaces: relativeWorkspaces };
665 });
666
667// ─────────────────────────────────────────────────────────────────────
668// Asset reading (mirror of alchemy's `readAssets`: per-file sha256
669// truncated to 32 hex chars — the hash format Cloudflare's asset-upload
670// sessions expect — with `.assetsignore` / `_headers` / `_redirects`
671// handling, and a manifest-shaped overall hash)
672// ─────────────────────────────────────────────────────────────────────
673
674const MAX_ASSET_SIZE = 1024 * 1024 * 25; // 25MB
675const MAX_ASSET_COUNT = 20_000;
676
677const maybeReadString = (
678 file: string,
679): Effect.Effect<string | undefined, never, SourceServices> =>
680 Effect.flatMap(FileSystem.FileSystem, (fs) =>
681 fs
682 .readFileString(file)
683 .pipe(Effect.catchTag("PlatformError", () => Effect.succeed(undefined))),
684 );
685
686const readClientAssets = (
687 directory: string,
688 config: Record<string, unknown> | undefined,
689): Effect.Effect<AssetReadResult, SourceError, SourceServices> =>
690 Effect.gen(function* () {
691 const fs = yield* FileSystem.FileSystem;
692 const path = yield* Path.Path;
693 const [ignoreFile, _headers, _redirects] = yield* Effect.all([
694 maybeReadString(path.join(directory, ".assetsignore")),
695 maybeReadString(path.join(directory, "_headers")),
696 maybeReadString(path.join(directory, "_redirects")),
697 ]);
698 const ignoreRules = compileIgnoreRules([
699 ".assetsignore",
700 "_headers",
701 "_redirects",
702 ...(ignoreFile?.split("\n") ?? []),
703 ]);
704 const files = yield* listFiles(directory, {
705 include: undefined,
706 ignoreRules,
707 exclude: [],
708 });
709 if (files.length > MAX_ASSET_COUNT) {
710 return yield* new SourceProviderError({
711 provider: PROVIDER,
712 message: `Too many assets in ${directory} (maximum is ${MAX_ASSET_COUNT}, found ${files.length})`,
713 });
714 }
715 const manifest: Record<string, { hash: string; size: number }> = {};
716 yield* Effect.forEach(
717 files,
718 Effect.fn(function* (name) {
719 const file = path.join(directory, name);
720 const stat = yield* fs.stat(file);
721 const size = Number(stat.size);
722 if (size > MAX_ASSET_SIZE) {
723 return yield* new SourceProviderError({
724 provider: PROVIDER,
725 message: `Asset ${name} is too large (${(size / 1024 / 1024).toFixed(1)} MB; the maximum is ${MAX_ASSET_SIZE / 1024 / 1024} MB)`,
726 });
727 }
728 const content = yield* fs.readFile(file);
729 const hash = (yield* sha256Hex(content)).slice(0, 32);
730 manifest[`/${name}`] = { hash, size };
731 }),
732 { concurrency: 16 },
733 );
734 const sortedManifest = Object.fromEntries(
735 Object.entries(manifest).sort(([a], [b]) => a.localeCompare(b)),
736 );
737 const hash = yield* sha256Object({
738 config,
739 manifest: sortedManifest,
740 _headers,
741 _redirects,
742 });
743 return {
744 directory,
745 config,
746 manifest: sortedManifest,
747 _headers,
748 _redirects,
749 hash,
750 };
751 });
752
753// ─────────────────────────────────────────────────────────────────────
754// The provider
755// ─────────────────────────────────────────────────────────────────────
756
757/**
758 * Feed the Worker's literal env values into `process.env` so `astro:env`
759 * server secrets (`getSecret`) resolve while Astro runs in the node process
760 * — config loading and node prerendering read env from `process.env`. This
761 * is the role upstream's `loadWranglerEnv` (wrangler `vars` + `.dev.vars`)
762 * plays at `astro:config:done`. Strings pass through, `Redacted<string>`s
763 * are unwrapped, numbers/booleans are stringified; everything else
764 * (Effects, resource bindings) is skipped — those are delivered as real
765 * bindings at runtime, not build-time values. Exported for testing.
766 */
767export const applyWorkerEnvToProcess = (
768 env: Record<string, unknown> | undefined,
769): Effect.Effect<void> =>
770 Effect.sync(() => {
771 for (const [key, value] of Object.entries(env ?? {})) {
772 if (typeof value === "string") {
773 process.env[key] = value;
774 } else if (
775 Redacted.isRedacted(value) &&
776 typeof Redacted.value(value) === "string"
777 ) {
778 process.env[key] = Redacted.value(value) as string;
779 } else if (typeof value === "number" || typeof value === "boolean") {
780 process.env[key] = String(value);
781 }
782 }
783 });
784
785const failWith =
786 (message: string) =>
787 (cause: unknown): SourceProviderError =>
788 new SourceProviderError({
789 provider: PROVIDER,
790 message:
791 cause instanceof Error && cause.message
792 ? `${message}: ${cause.message}`
793 : message,
794 cause,
795 });
796
797/** Extract the asset-routing config from raw `props.assets` (drop non-config keys). */
798const assetConfigFromProps = (
799 assets: SourceContext["assets"],
800): Record<string, unknown> | undefined => {
801 if (assets === undefined || typeof assets === "string") {
802 return undefined;
803 }
804 const { directory: _directory, path: _path, hash: _hash, ...config } = assets;
805 return Object.keys(config).length > 0 ? config : undefined;
806};
807
808/**
809 * Resolve the Worker env into the plain strings the build child applies to
810 * its `process.env` — same filter as {@link applyWorkerEnvToProcess}, but
811 * producing JSON-safe data that can cross the process boundary.
812 */
813const resolveBuildEnv = (
814 env: Record<string, unknown> | undefined,
815): Record<string, string> => {
816 const out: Record<string, string> = {};
817 for (const [key, value] of Object.entries(env ?? {})) {
818 if (typeof value === "string") {
819 out[key] = value;
820 } else if (
821 Redacted.isRedacted(value) &&
822 typeof Redacted.value(value) === "string"
823 ) {
824 out[key] = Redacted.value(value) as string;
825 } else if (typeof value === "number" || typeof value === "boolean") {
826 out[key] = String(value);
827 }
828 }
829 return out;
830};
831
832/**
833 * The production build runs in a disposable child process with
834 * `cwd = project root` (see `core/BuildChild.ts`): the natively-loaded
835 * `astro.config.*` executes user plugins/integrations that may read the
836 * cwd, mutate `process.env`, or `process.chdir` — none of which may touch
837 * the engine process. The shared `core/BuildChildRunner`
838 * entry imports this module in the child and calls the exported
839 * {@link buildInChild}.
840 */
841export interface AstroBuildChildConfig {
842 readonly rootDir: string;
843 readonly compatibilityDate: string;
844 readonly compatibilityFlags: Array<string>;
845 readonly env: Record<string, string>;
846 readonly sessionKVBindingName: string | undefined;
847 readonly sessions: boolean | undefined;
848 readonly sessionDevKV: boolean | undefined;
849 readonly prerenderEnvironment: "workerd" | "node" | undefined;
850 readonly astro: AstroSourceOptions["astro"];
851 readonly config: string | undefined;
852}
853
854export const buildInChild = (config: AstroBuildChildConfig) =>
855 Effect.gen(function* () {
856 // Worker env → process.env, confined to this disposable child (astro's
857 // config loading and integrations read env at build time).
858 yield* Effect.sync(() => {
859 for (const [key, value] of Object.entries(config.env)) {
860 process.env[key] = value;
861 }
862 });
863 const astro = yield* FrameworkCore.Framework.pipe(
864 Effect.provide(
865 Astro.layer({
866 root: config.rootDir,
867 target: cloudflareTarget({
868 worker: {
869 compatibilityDate: config.compatibilityDate,
870 compatibilityFlags: config.compatibilityFlags,
871 },
872 sessionKVBindingName: config.sessionKVBindingName,
873 sessions: config.sessions,
874 sessionDevKV: config.sessionDevKV,
875 prerenderEnvironment: config.prerenderEnvironment,
876 }),
877 astro: config.astro,
878 config: config.config,
879 }),
880 ),
881 );
882 return yield* astro.build({ root: config.rootDir });
883 });
884
885const makeAstroSourceProvider = (
886 options: AstroSourceOptions,
887): SourceProvider => {
888 /** Canonical absolute project root (a relative `rootDir` resolves from cwd). */
889 const resolveRoot = Effect.map(Path.Path, (path) =>
890 path.resolve(options.rootDir ?? process.cwd()),
891 );
892
893 const framework = (rootDir: string, vite: CloudflareVitePluginOptions) =>
894 FrameworkCore.Framework.pipe(
895 Effect.provide(
896 Astro.layer({
897 root: rootDir,
898 // This module IS the alchemy Cloudflare Worker source provider, so
899 // it constructs the Cloudflare deploy target directly (as a value)
900 // rather than resolving the default specifier.
901 target: cloudflareTarget({
902 worker: vite,
903 sessionKVBindingName: options.sessionKVBindingName,
904 sessions: options.sessions,
905 sessionDevKV: options.sessionDevKV,
906 prerenderEnvironment: options.prerenderEnvironment,
907 }),
908 astro: options.astro,
909 config: options.config,
910 }),
911 ),
912 );
913
914 return {
915 ownsAssets: true,
916
917 build: (ctx) =>
918 Effect.gen(function* () {
919 const path = yield* Path.Path;
920 const rootDir = yield* resolveRoot;
921 const output = yield* runBuildChild({
922 module: import.meta.url,
923 rootDir,
924 framework: "astro",
925 config: {
926 rootDir,
927 compatibilityDate: ctx.compatibility.date,
928 compatibilityFlags: ctx.compatibility.flags,
929 env: resolveBuildEnv(ctx.env),
930 sessionKVBindingName: options.sessionKVBindingName,
931 sessions: options.sessions,
932 sessionDevKV: options.sessionDevKV,
933 prerenderEnvironment: options.prerenderEnvironment,
934 astro: options.astro,
935 config: options.config,
936 } satisfies AstroBuildChildConfig,
937 }).pipe(
938 Effect.mapError((error) =>
939 failWith("Astro build failed")(error.cause ?? error),
940 ),
941 );
942 const files = (output.serverModules ?? []).map(
943 (module): BundleFile => ({
944 path: module.name,
945 content: module.content,
946 hash: module.hash,
947 }),
948 );
949 const [bundleHash, assets, input] = yield* Effect.all(
950 [
951 sha256Object(
952 files.map((file) => ({ path: file.path, hash: file.hash })),
953 ),
954 output.clientDirectory
955 ? readClientAssets(
956 path.resolve(rootDir, output.clientDirectory),
957 assetConfigFromProps(ctx.assets),
958 )
959 : Effect.succeed(undefined),
960 hashAstroInput(
961 rootDir,
962 options.memo,
963 Effect.succeed(output.externalWorkspaces ?? []),
964 ),
965 ],
966 { concurrency: "unbounded" },
967 );
968 const bundle: BundleOutput | undefined =
969 files.length > 0
970 ? {
971 files: files as [BundleFile, ...Array<BundleFile>],
972 hash: bundleHash,
973 }
974 : undefined;
975 if (bundle === undefined && assets === undefined) {
976 return yield* new SourceProviderError({
977 provider: PROVIDER,
978 message: "Astro build produced neither assets nor server output",
979 });
980 }
981 return {
982 bundle,
983 assets,
984 hash: {
985 bundle: bundle?.hash,
986 assets: assets?.hash,
987 input: input.hash,
988 additionalWorkspaces: input.workspaces,
989 },
990 } satisfies SourceBuildOutput;
991 }),
992
993 hash: (_ctx, previous) =>
994 Effect.gen(function* () {
995 const rootDir = yield* resolveRoot;
996 const { hash, workspaces } = yield* hashAstroInput(
997 rootDir,
998 options.memo,
999 Effect.succeed(previous?.additionalWorkspaces ?? []),
1000 );
1001 return { input: hash, additionalWorkspaces: workspaces };
1002 }),
1003
1004 dev: (ctx) =>
1005 Effect.gen(function* () {
1006 const rootDir = yield* resolveRoot;
1007 yield* applyWorkerEnvToProcess(ctx.env);
1008 const queueConsumers = yield* ctx.worker.queueConsumers;
1009 const astro = yield* framework(rootDir, {
1010 compatibilityDate: ctx.compatibility.date,
1011 compatibilityFlags: ctx.compatibility.flags,
1012 worker: {
1013 name: ctx.workerName,
1014 bindings: ctx.worker.bindings,
1015 durableObjectNamespaces: ctx.worker.durableObjectNamespaces,
1016 hyperdrives: ctx.worker.hyperdrives,
1017 queueConsumers,
1018 assets: ctx.worker.assets,
1019 },
1020 context: ctx.runtimeContext,
1021 });
1022 const server = yield* astro
1023 .dev({ root: rootDir, port: 0 })
1024 .pipe(Effect.mapError(failWith("Astro dev server failed to start")));
1025 return {
1026 mode: "server",
1027 url: new URL(server.url),
1028 } satisfies SourceDevHandle;
1029 }),
1030 };
1031};
1032
1033/**
1034 * Mirror of alchemy's `WorkerSourceModule`: the default export's
1035 * `make(options)` builds a {@link SourceProvider} from the descriptor's
1036 * JSON-serializable options.
1037 */
1038const make = (
1039 options: unknown,
1040): Effect.Effect<SourceProvider, SourceProviderError, SourceServices> =>
1041 Effect.sync(() =>
1042 makeAstroSourceProvider((options ?? {}) as AstroSourceOptions),
1043 );
1044
1045export default { make };