1import * as Effect from "effect/Effect";
2import type * as rolldown from "rolldown";
3import { AlchemyContext } from "../AlchemyContext.ts";
4import * as Bundle from "../Bundle/Bundle.ts";
5import {
6 findCwdForBundle,
7 getStableContextDir,
8 resolveMainPath,
9} from "../Bundle/TempRoot.ts";
10import { sha256Object } from "../Util/sha256.ts";
11import { Docker } from "./Docker.ts";
12
13/**
14 * INTERNAL — image machinery for the effectful `Docker.Service` platform.
15 *
16 * NOT exported from the Docker barrel. Bundles a `main` Effect program with
17 * rolldown, bakes it into a generated bun Dockerfile, and builds the image
18 * with the Docker CLI against the service's Docker context — so the image is
19 * present in the exact engine the Swarm service schedules from. There is no
20 * registry push: the built image is content-addressed (`<name>:<hash>`) and
21 * only rebuilt when the bundle, Dockerfile, or environment image changes.
22 *
23 * Multi-node swarms need the image on every node; for those, build with
24 * `Docker.Image` + a `registry` and pass the pushed ref as `image` instead.
25 */
26
27/** The props subset that describes a bundled `main` service image. */
28export interface BundledServiceSource {
29 main: string;
30 image?: string;
31 handler?: string;
32 port?: number;
33 /**
34 * Bundler configuration for `main`. Unused code is tree-shaken.
35 * `effect`, alchemy, and `@distilled.cloud` are marked pure so unused
36 * parts prune more aggressively. List extra packages with
37 * `pure.packages`, or disable with `pure: false`.
38 */
39 build?: Bundle.BundleConfig;
40}
41
42/** The resolved (built) local image. */
43export interface ResolvedServiceImage {
44 /** Full image reference, `<name>:<codeHash>`. */
45 imageRef: string;
46 /** Content hash identifying the image (also the image tag). */
47 codeHash: string;
48}
49
50/**
51 * The generated entry for `Docker.Service` containers: a shim importing only
52 * `alchemy/Runtime/Bootstrap/Docker` plus the user's `main` — see that
53 * module for why the entry never imports alchemy's own dependencies.
54 */
55export const makeServiceBunBootstrap =
56 (handler: string) =>
57 (importPath: string): string =>
58 `
59import { bootstrap } from "alchemy/Runtime/Bootstrap/Docker";
60import { ${handler} as entrypoint } from ${JSON.stringify(importPath)};
61
62await bootstrap(entrypoint);
63`;
64
65/**
66 * Init-time constructor for the service-image resolver. Resolves the services
67 * that are only available at provider-layer construction (Docker, the
68 * `.alchemy` directory, the rolldown virtual-entry plugin) and returns
69 * `resolve` (build the image if its content hash isn't present in the target
70 * engine) and `hash` (content hash only, for `diff`).
71 */
72export const makeServiceImage = Effect.gen(function* () {
73 const docker = yield* Docker;
74 const { dotAlchemy } = yield* AlchemyContext;
75 const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin;
76
77 /** Bundle the Effect program behind a `main` source. */
78 const bundleProgram = Effect.fn(function* (options: {
79 source: BundledServiceSource;
80 isExternal?: boolean;
81 }) {
82 const { source } = options;
83 const realMain = yield* resolveMainPath(source.main);
84 const cwd = yield* findCwdForBundle(realMain);
85 const bootstrap = makeServiceBunBootstrap(source.handler ?? "default");
86
87 const buildBundle = Effect.fn(function* (
88 entry: string,
89 plugins?: rolldown.RolldownPluginOption,
90 ) {
91 return yield* Bundle.build(
92 {
93 ...source.build?.input,
94 input: entry,
95 cwd,
96 platform: "node",
97 // The container runs on `bun`; keep `bun`/`bun:*` external (the
98 // runtime provides them) and resolve the `bun` export condition
99 // so `@effect/platform-bun` picks its Bun implementations.
100 external: [
101 "bun",
102 "bun:*",
103 ...((source.build?.input?.external as string[] | undefined) ?? []),
104 ],
105 resolve: {
106 conditionNames: [...Bundle.BUN_CONDITION_NAMES],
107 ...source.build?.input?.resolve,
108 },
109 plugins: [source.build?.input?.plugins, plugins],
110 },
111 {
112 ...source.build?.output,
113 format: "esm",
114 sourcemap: source.build?.output?.sourcemap ?? false,
115 minify: source.build?.output?.minify ?? false,
116 entryFileNames: "index.mjs",
117 },
118 source.build,
119 );
120 });
121
122 const bundleOutput = options.isExternal
123 ? yield* buildBundle(realMain)
124 : yield* buildBundle(realMain, virtualEntryPlugin(bootstrap));
125
126 // Return every emitted file (entry + shared chunks). Dynamic imports in
127 // the Bun HTTP server split into chunks; dropping any of them crashes the
128 // container with `Cannot find module './chunk-XXX.js'`.
129 const files = bundleOutput.files.map((file) => ({
130 path: file.path,
131 content:
132 typeof file.content === "string"
133 ? new TextEncoder().encode(file.content)
134 : file.content,
135 }));
136
137 return { files, hash: bundleOutput.hash };
138 });
139
140 /**
141 * Generated Dockerfile for a bundled `main` program. The environment
142 * preamble is the `image` ref or the default bun base (`oven/bun` is
143 * Docker-Hub only).
144 */
145 const generateDockerfile = (source: BundledServiceSource) => {
146 const lines = [
147 `FROM ${source.image ?? "oven/bun:1"}`,
148 `WORKDIR /app`,
149 `COPY index.mjs /app/index.mjs`,
150 // Copy any additional rolldown chunks (`chunk-XXX.js`, …). Non-trivial
151 // bundles always emit at least one; minimal bundles emit none and the
152 // COPY no-ops.
153 `COPY *.js /app/`,
154 ];
155 if (source.port !== undefined) {
156 lines.push(
157 `ENV PORT=${String(source.port)}`,
158 `EXPOSE ${String(source.port)}`,
159 );
160 }
161 lines.push(`ENTRYPOINT ["bun", "/app/index.mjs"]`);
162 return `${lines.join("\n")}\n`;
163 };
164
165 /**
166 * Bundle a `main` source and compute its content-addressed code hash. The
167 * hash covers the full image identity — the bundle output (bootstrap entry
168 * included, so bootstrap-template changes invalidate it) and the generated
169 * Dockerfile — so `diff` and `resolve` always agree.
170 */
171 const computeCodeHash = Effect.fn(function* (options: {
172 source: BundledServiceSource;
173 isExternal?: boolean;
174 }) {
175 const bundled = yield* bundleProgram(options);
176 const dockerfile = generateDockerfile(options.source);
177 const codeHash = (yield* sha256Object({
178 bundleHash: bundled.hash,
179 dockerfile,
180 })).slice(0, 16);
181 return { bundled, dockerfile, codeHash };
182 });
183
184 /** Observe a locally-present tag in the target engine. Missing → undefined. */
185 const imageExists = (imageRef: string, context: string | undefined) =>
186 docker.image.inspect(imageRef, context).pipe(
187 Effect.map(() => true),
188 Effect.catchReason("PlatformError", "NotFound", () =>
189 Effect.succeed(false),
190 ),
191 );
192
193 /**
194 * Resolve the image for a bundled service: compute the content-addressed
195 * tag, then build only when that exact tag is not already present in the
196 * target Docker context (crash-safe convergence).
197 */
198 const resolve = Effect.fn(function* (options: {
199 /** Logical resource id — keys the stable build-context directory. */
200 id: string;
201 source: BundledServiceSource;
202 /** Image repository name (the service's physical name). */
203 name: string;
204 /** Docker context the swarm service is deployed to. */
205 context: string | undefined;
206 isExternal?: boolean;
207 /** Plan-status session used to emit build progress notes. */
208 session: { note: (message: string) => Effect.Effect<void> };
209 }) {
210 const { id, source, session } = options;
211 yield* session.note(`Bundling ${id} program...`);
212 const { bundled, dockerfile, codeHash } = yield* computeCodeHash({
213 source,
214 isExternal: options.isExternal,
215 });
216 const imageRef = `${options.name}:${codeHash}`;
217
218 if (yield* imageExists(imageRef, options.context)) {
219 return { imageRef, codeHash } satisfies ResolvedServiceImage;
220 }
221
222 const realMain = yield* resolveMainPath(source.main);
223 const contextDir = yield* getStableContextDir(
224 realMain,
225 dotAlchemy,
226 `${id}-image`,
227 );
228 yield* docker.materialize({
229 context: contextDir,
230 dockerfile,
231 // Entry chunk becomes `index.mjs`; all other chunks keep their emitted
232 // `*.js` names so the entry's relative imports resolve.
233 files: bundled.files.map((file, index) => ({
234 path: index === 0 ? "index.mjs" : file.path,
235 content: file.content,
236 })),
237 });
238 yield* session.note(`Building container image ${imageRef}...`);
239 yield* docker.image.build({
240 context: contextDir,
241 tag: imageRef,
242 engineContext: options.context,
243 });
244 yield* session.note(`Built ${imageRef}`);
245 return { imageRef, codeHash } satisfies ResolvedServiceImage;
246 });
247
248 /**
249 * Content hash without building the image. Runs the bundler (bootstrap
250 * entry included) so the hash reflects the exact image `resolve` would
251 * build — bootstrap-template changes and user-code edits both surface as
252 * drift. The provider calls this from `diff` and compares against
253 * `output.code.hash`.
254 */
255 const hash = Effect.fn(function* (options: {
256 source: BundledServiceSource;
257 isExternal?: boolean;
258 }) {
259 const { codeHash } = yield* computeCodeHash(options);
260 return codeHash;
261 });
262
263 return { resolve, hash };
264});
265
266/** The resolver service returned by {@link makeServiceImage}. */
267export interface ServiceImage extends Effect.Success<typeof makeServiceImage> {}