| 1 | import * as Effect from "effect/Effect"; |
| 2 | import type * as rolldown from "rolldown"; |
| 3 | import { AlchemyContext } from "../AlchemyContext.ts"; |
| 4 | import * as Bundle from "../Bundle/Bundle.ts"; |
| 5 | import { |
| 6 | findCwdForBundle, |
| 7 | getStableContextDir, |
| 8 | resolveMainPath, |
| 9 | } from "../Bundle/TempRoot.ts"; |
| 10 | import { sha256Object } from "../Util/sha256.ts"; |
| 11 | import { 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. */ |
| 28 | export 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. */ |
| 43 | export 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 | */ |
| 55 | export const makeServiceBunBootstrap = |
| 56 | (handler: string) => |
| 57 | (importPath: string): string => |
| 58 | ` |
| 59 | import { bootstrap } from "alchemy/Runtime/Bootstrap/Docker"; |
| 60 | import { ${handler} as entrypoint } from ${JSON.stringify(importPath)}; |
| 61 | |
| 62 | await 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 | */ |
| 72 | export 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}. */ |
| 267 | export interface ServiceImage extends Effect.Success<typeof makeServiceImage> {} |