1import * as Effect from "effect/Effect";
2import * as Equal from "effect/Equal";
3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";
5import * as Redacted from "effect/Redacted";
6import { havePropsChanged, isResolved } from "../Diff.ts";
7import * as Provider from "../Provider.ts";
8import { Resource } from "../Resource.ts";
9import { initialCwd } from "../Util/Node.ts";
10import { sha256Object } from "../Util/sha256.ts";
11import {
12 CommandExecutor,
13 OutputNotFound,
14 makeCommandError,
15 type CommandRunProps,
16} from "./Command.ts";
17import { hashDirectory, type MemoOptions } from "./Memo.ts";
18
19export interface BuildProps extends CommandRunProps {
20 /**
21 * The output path (file or directory) produced by the build.
22 * This path is relative to the working directory.
23 * @example "dist"
24 */
25 outdir: string;
26 /**
27 * Controls which files are hashed to decide whether the build should re-run.
28 * By default every non-gitignored file in `cwd` is hashed, plus the nearest
29 * lockfile. Provide explicit globs to narrow the scope, or set `false` to
30 * disable memoization and rebuild on every deploy.
31 *
32 * @see {@link MemoOptions}
33 * @default true
34 */
35 memo?: MemoOptions | boolean;
36}
37
38export interface Build extends Resource<
39 "Command.Build",
40 BuildProps,
41 {
42 /**
43 * Path to the build output, relative to the process's initial working
44 * directory (`initialCwd` — captured at startup, immune to transient
45 * chdir by tools sharing the process).
46 *
47 * Stored relative (rather than absolute) so the value is portable across
48 * machines — state written by a CI runner
49 * (`/home/runner/work/.../dist`) resolves correctly on a local laptop and
50 * vice versa. Consumers should resolve it against `initialCwd` to obtain
51 * an absolute path.
52 */
53 outdir: string;
54 hash: {
55 /**
56 * Hash of the input files that produced this build.
57 */
58 input: string | undefined;
59 /**
60 * Hash of the output files from this build.
61 */
62 output: string | undefined;
63 };
64 }
65> {}
66
67/**
68 * A `Build` runs a shell command that produces an output asset (a file or
69 * directory) and tracks that asset in state. Unlike `Exec`, a `Build` has an
70 * output contract: `reconcile` verifies the command actually produced `outdir`
71 * and exposes its location so downstream resources (e.g. a `Cloudflare.Worker`'s
72 * static assets) can consume it.
73 *
74 * Inputs are content-hashed by default so an unchanged project skips the
75 * rebuild entirely; set `memo: false` to rebuild on every deploy.
76 *
77 * ### Building a Vite App
78 * **Example:** Basic Vite Build
79 * ```typescript
80 * const build = yield* Build("vite-build", {
81 * command: "npm run build",
82 * cwd: "./frontend",
83 * outdir: "dist",
84 * });
85 * yield* Console.log(build.outdir); // path to the dist directory, relative to the initial cwd
86 * yield* Console.log(build.hash.output); // hash of the output files (when memo is enabled)
87 * ```
88 *
89 * ### Building with Custom Environment
90 * **Example:** Build with Environment Variables
91 * ```typescript
92 * const build = yield* Build("production-build", {
93 * command: "npm run build",
94 * cwd: "./app",
95 * outdir: "dist",
96 * env: {
97 * NODE_ENV: "production",
98 * API_URL: "https://api.example.com",
99 * },
100 * });
101 * ```
102 *
103 * ### Customizing Memoization
104 * **Example:** Customize Memoization
105 * ```typescript
106 * const build = yield* Build("custom-build", {
107 * command: "npm run build",
108 * cwd: "./app",
109 * outdir: "dist",
110 * memo: { include: ["src/**", "package.json"], exclude: ["node_modules", "dist"] },
111 * });
112 * ```
113 *
114 * @resource
115 */
116export const Build = Resource<Build>("Command.Build");
117
118/**
119 * Resolves `Redacted` env values to their plain string so that a change in a
120 * secret's value still busts the memo hash (the hash is one-way, so the secret
121 * itself is never recoverable from state). `undefined`-valued entries are
122 * dropped (they mean "unset").
123 */
124const resolveEnv = (env: CommandRunProps["env"]) =>
125 env
126 ? Object.fromEntries(
127 Object.entries(env)
128 .filter(
129 (entry): entry is [string, string | Redacted.Redacted<string>] =>
130 entry[1] !== undefined,
131 )
132 .map(([key, value]) => [
133 key,
134 Redacted.isRedacted(value) ? Redacted.value(value) : value,
135 ]),
136 )
137 : undefined;
138
139export const BuildProvider = () =>
140 Provider.effect(
141 Build,
142 Effect.gen(function* () {
143 const path = yield* Path.Path;
144 const fs = yield* FileSystem.FileSystem;
145 const { run } = yield* CommandExecutor;
146
147 const makeOutput = Effect.fn(function* (props: BuildProps) {
148 // Anchored to the initial cwd: a live `process.cwd()` read can race
149 // a concurrent tool's transient chdir (framework source builds),
150 // and the relative `outdir` stored here is resolved again later
151 // (assets read) — mismatched bases corrupt the path.
152 const cwd = path.resolve(initialCwd, props.cwd ?? ".");
153 const outdir = path.resolve(cwd, props.outdir);
154 if (!(yield* fs.exists(outdir))) {
155 return yield* makeCommandError(
156 props,
157 new OutputNotFound({
158 outdir: props.outdir,
159 }),
160 );
161 }
162 return {
163 outdir: path.relative(initialCwd, outdir),
164 hash:
165 props.memo === false
166 ? { input: undefined, output: undefined }
167 : yield* Effect.all(
168 {
169 // Fold the resolved command + env into the input hash so
170 // two builds that share the same source tree + `outdir`
171 // but differ in their command or environment (e.g.
172 // per-stage builds baking different `EXPO_PUBLIC_*` /
173 // API ids) are never judged reusable. Without this the
174 // second build silently reuses the first's stale output.
175 input: hashDirectory({
176 cwd,
177 memo: props.memo === true ? {} : props.memo,
178 }).pipe(
179 Effect.flatMap((files) =>
180 sha256Object({
181 files,
182 command: props.command,
183 env: resolveEnv(props.env),
184 }),
185 ),
186 ),
187 output: hashDirectory({
188 cwd: outdir,
189 memo: {
190 exclude: [],
191 lockfile: false,
192 },
193 }),
194 },
195 { concurrency: "unbounded" },
196 ),
197 };
198 });
199
200 return {
201 list: () => Effect.succeed([]),
202 diff: Effect.fn(function* ({ olds, news, output }) {
203 if (!output || !isResolved(news)) return undefined;
204
205 // Always update if memoization is disabled or hashes are not available.
206 if (news.memo === false || !output.hash.input || !output.hash.output)
207 return { action: "update" };
208
209 // Optimization: short-circuit if props have changed to avoid unnecessary file system operations.
210 if (havePropsChanged(olds, news)) return { action: "update" };
211
212 const newOutput = yield* makeOutput(news).pipe(
213 Effect.catchReason(
214 "CommandError",
215 "OutputNotFound",
216 () => Effect.undefined,
217 ),
218 );
219 return {
220 action: Equal.equals(newOutput, output) ? "noop" : "update",
221 };
222 }),
223 reconcile: ({ news, session }) =>
224 run(news, session).pipe(Effect.andThen(makeOutput(news))),
225 delete: Effect.fn(function* ({ output }) {
226 // `output.outdir` is persisted relative to the initial cwd.
227 const outdir = path.resolve(initialCwd, output.outdir);
228 if (!(yield* fs.exists(outdir))) return;
229 yield* fs.remove(outdir, { recursive: true });
230 }),
231 };
232 }),
233 );