1import * as Effect from "effect/Effect";
2import { dotAlchemyDirectory } from "../../../AlchemyContext.ts";
3import { isPathWithin } from "../../../Util/isPathWithin.ts";
4import * as FileSystem from "effect/FileSystem";
5import * as Stream from "effect/Stream";
6import { fileURLToPath } from "node:url";
7import path from "pathe";
8import type * as Bundle from "../../../Bundle/Bundle.ts";
9import type {
10 DevContext,
11 SourceContext,
12 SourceDevHandle,
13 SourceError,
14 SourceProvider,
15 SourceServices,
16} from "../Source.ts";
17
18/**
19 * Convert a Worker `main` entry (a plain path or a `file://` URL) to a
20 * filesystem path, without resolving it.
21 *
22 * Internal to `Workers/Sources/` — not exported from the package index.
23 */
24export const mainToPath = (main: string) =>
25 Effect.sync(() => {
26 try {
27 return fileURLToPath(main);
28 } catch {
29 return main;
30 }
31 });
32
33/**
34 * Resolve a Worker `main` entry (path or `file://` URL) to an absolute
35 * path without following symlinks (Alchemy v1 parity): the module walk
36 * happens in the directory the user pointed at, not the entry's
37 * canonical location.
38 *
39 * Deliberately distinct from `Bundle/TempRoot.ts`'s same-named helper,
40 * which canonicalizes through `fs.realPath` — the rolldown bundler wants
41 * the real path, the byte-for-byte readers (python, prebuilt) do not.
42 *
43 * Internal to `Workers/Sources/` — not exported from the package index.
44 */
45export const resolveMainPath = (main: string) =>
46 mainToPath(main).pipe(Effect.map((p) => path.resolve(p)));
47
48/**
49 * A {@link SourceProvider} that only produces a server bundle: no static
50 * assets, and `bundle` as its single hash slot. Every built-in source
51 * except vite has this shape — they differ only in how they produce the
52 * bundle and how they watch it for local dev.
53 *
54 * `build` is used for both `build()` and `hash()`; implementations that
55 * are expensive route it through `Artifacts.cached` so diff and reconcile
56 * build once per run.
57 *
58 * Internal to `Workers/Sources/` — not exported from the package index.
59 */
60export const bundleSource = (spec: {
61 readonly build: (
62 ctx: SourceContext,
63 ) => Effect.Effect<Bundle.BundleOutput, SourceError, SourceServices>;
64 readonly watch: (
65 ctx: DevContext,
66 ) => Effect.Effect<
67 Stream.Stream<Bundle.BundleWatchEvent, SourceError, any>,
68 SourceError,
69 any
70 >;
71}): SourceProvider => ({
72 ownsAssets: false,
73 build: (ctx) =>
74 spec.build(ctx).pipe(
75 Effect.map((bundle) => ({
76 bundle,
77 assets: undefined,
78 hash: { bundle: bundle.hash },
79 })),
80 ),
81 hash: (ctx) =>
82 spec.build(ctx).pipe(Effect.map((bundle) => ({ bundle: bundle.hash }))),
83 dev: (ctx) =>
84 spec
85 .watch(ctx)
86 .pipe(
87 Effect.map(
88 (bundles) => ({ mode: "bundle", bundles }) as SourceDevHandle,
89 ),
90 ),
91});
92
93/**
94 * Watch the directory containing a Worker's entry module and re-read the
95 * bundle on change (debounced), emitting the
96 * {@link Bundle.BundleWatchEvent} protocol the rolldown watcher speaks so
97 * local dev consumes either stream interchangeably.
98 *
99 * Used by the sources that read their bundle byte-for-byte off disk
100 * (python, prebuilt) rather than bundling it — the re-read never goes
101 * through rolldown, preserving their byte-for-byte contract.
102 *
103 * Internal to `Workers/Sources/` — not exported from the package index.
104 */
105export const watchBundleDirectory = <R>(options: {
106 readonly main: string;
107 readonly read: Effect.Effect<Bundle.BundleOutput, Bundle.BundleError, R>;
108 /** Paths for which a change should NOT trigger a re-read. */
109 readonly ignore?: (path: string) => boolean;
110}) =>
111 Stream.unwrap(
112 Effect.gen(function* () {
113 const fs = yield* FileSystem.FileSystem;
114 const main = yield* resolveMainPath(options.main);
115 const runtimeBase = process.cwd();
116 const dotAlchemy = yield* dotAlchemyDirectory;
117 const root = path.dirname(main);
118 const read = options.read.pipe(
119 Effect.map((output): Bundle.BundleWatchEvent => ({
120 _tag: "Success",
121 output,
122 })),
123 Effect.catch((error) =>
124 Effect.succeed<Bundle.BundleWatchEvent>({ _tag: "Error", error }),
125 ),
126 );
127 const rebuilds = fs.watch(root).pipe(
128 Stream.filter(
129 (event) =>
130 isPathWithin(dotAlchemy, root, runtimeBase) ||
131 !isPathWithin(
132 dotAlchemy,
133 path.resolve(root, event.path),
134 runtimeBase,
135 ),
136 ),
137 options.ignore
138 ? Stream.filter((event) => !options.ignore!(event.path))
139 : (self) => self,
140 Stream.debounce("200 millis"),
141 Stream.flatMap(() =>
142 Stream.make({ _tag: "Start" } as Bundle.BundleWatchEvent).pipe(
143 Stream.concat(Stream.fromEffect(read)),
144 ),
145 ),
146 Stream.catchCause(() => Stream.empty),
147 );
148 return Stream.fromEffect(read).pipe(Stream.concat(rebuilds));
149 }),
150 );