1/** @effect-diagnostics anyUnknownInErrorContext:off */
2
3/**
4 * INTERNAL — the "image source changed" trigger stream shared by the floci
5 * dev providers that rebuild a Docker image on file change
6 * ([ECS FlociTaskProvider](../ECS/FlociTaskProvider.ts),
7 * [ECS FlociServiceProvider](../ECS/FlociServiceProvider.ts),
8 * [Lambda FlociMicrovmImageProvider](../Lambda/FlociMicrovmImageProvider.ts)).
9 * NOT exported from any service `index.ts`.
10 */
11
12import * as Effect from "effect/Effect";
13import * as FileSystem from "effect/FileSystem";
14import * as Path from "effect/Path";
15import * as Stream from "effect/Stream";
16import * as Bundle from "../../Bundle/Bundle.ts";
17import { isInlineDockerfile } from "../../Docker/Dockerfile.ts";
18import {
19 imageSourceKind,
20 makeBunBootstrap,
21 makeImageSource,
22 type BundledImageSource,
23 type ImageSourceLike,
24} from "../ECR/ImageSource.ts";
25
26/**
27 * A stream of "the image source changed" triggers for a props bag:
28 *
29 * - `main` — a `Bundle.watch` over the exact module graph the deploy
30 * bundles (rebuild successes are the trigger; rebuild errors are logged
31 * and swallowed);
32 * - `context` — a debounced recursive `fs.watch` of the build context (and
33 * the Dockerfile's directory when it lives outside the context);
34 * - `image` (registry ref) / inline-dockerfile-only — nothing local to
35 * watch: an empty stream.
36 */
37export const imageSourceTrigger = Effect.fn(function* (options: {
38 /** Logical id, for log prefixes. */
39 id: string;
40 source: ImageSourceLike;
41 isExternal: boolean | undefined;
42}) {
43 const { id, source } = options;
44 const kind = imageSourceKind(source);
45
46 if (kind === "main") {
47 const imageSource = yield* makeImageSource;
48 const plan = yield* imageSource.watchMain({
49 source: source as BundledImageSource,
50 isExternal: options.isExternal,
51 bootstrap: makeBunBootstrap(source.handler ?? "default"),
52 });
53 return Bundle.watch(plan.inputOptions, plan.outputOptions, plan.extra).pipe(
54 Stream.tap((event) =>
55 event._tag === "Error"
56 ? Effect.logWarning(
57 `[alchemy dev] ${id}: rebuild failed: ${event.error.message}`,
58 )
59 : Effect.void,
60 ),
61 Stream.filter((event) => event._tag === "Success"),
62 Stream.debounce("200 millis"),
63 Stream.map(() => undefined),
64 ) as Stream.Stream<void>;
65 }
66
67 if (kind === "context") {
68 if (source.context === undefined) {
69 // Inline-dockerfile-only builds have no local files to watch — the
70 // content is a prop, and prop changes flow through the dev diff.
71 return Stream.empty as Stream.Stream<void>;
72 }
73 const fs = yield* FileSystem.FileSystem;
74 const path = yield* Path.Path;
75 const context = path.resolve(source.context);
76 const streams: Stream.Stream<unknown, any>[] = [
77 fs.watch(context, { recursive: true }),
78 ];
79 // A path Dockerfile living OUTSIDE the context dir is watched too.
80 if (
81 source.dockerfile !== undefined &&
82 !isInlineDockerfile(source.dockerfile)
83 ) {
84 const dockerfile = path.resolve(source.dockerfile);
85 if (!dockerfile.startsWith(`${context}/`)) {
86 streams.push(fs.watch(path.dirname(dockerfile)));
87 }
88 }
89 return Stream.mergeAll(streams, { concurrency: streams.length }).pipe(
90 Stream.debounce("300 millis"),
91 Stream.map(() => undefined),
92 Stream.catchCause((cause) =>
93 Stream.fromEffect(
94 Effect.logWarning(`[alchemy dev] ${id}: context watch failed`, cause),
95 ).pipe(Stream.drain),
96 ),
97 ) as Stream.Stream<void>;
98 }
99
100 // Registry `image` refs have nothing local to watch.
101 return Stream.empty as Stream.Stream<void>;
102});