| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Path from "effect/Path"; |
| 3 | import * as Redacted from "effect/Redacted"; |
| 4 | import type * as rolldown from "rolldown"; |
| 5 | import { AlchemyContext } from "../../AlchemyContext.ts"; |
| 6 | import * as Bundle from "../../Bundle/Bundle.ts"; |
| 7 | import { |
| 8 | findCwdForBundle, |
| 9 | getStableContextDir, |
| 10 | resolveMainPath, |
| 11 | } from "../../Bundle/TempRoot.ts"; |
| 12 | import { Docker } from "../../Docker/Docker.ts"; |
| 13 | import { isInlineDockerfile } from "../../Docker/Dockerfile.ts"; |
| 14 | import * as Output from "../../Output.ts"; |
| 15 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 16 | import { Stack } from "../../Stack.ts"; |
| 17 | import { sha256Object } from "../../Util/sha256.ts"; |
| 18 | import type { AnyContainerApplicationProps } from "./ContainerApplication.ts"; |
| 19 | |
| 20 | /** |
| 21 | * Fold the runtime-context `env` map (populated by `Binding.Service`s and |
| 22 | * `Config` injection — see `ContainerPlatform.set`) into the application's |
| 23 | * `environmentVariables`. |
| 24 | * |
| 25 | * Unlike Cloudflare Workers, container `secrets` are *references to the |
| 26 | * account Secrets Store by name* — they cannot carry an inline value — so |
| 27 | * runtime-bound values (e.g. a minted API token) must travel as plain |
| 28 | * `environmentVariables`. The value is the JSON-encoded payload produced by |
| 29 | * `ContainerPlatform.set` (a `{_tag:"Redacted",value}` marker for secrets, |
| 30 | * a JSON string otherwise), which `ContainerPlatform.get` parses back into |
| 31 | * the original `Redacted`/plain value at runtime. |
| 32 | * |
| 33 | * `precreate` receives the raw, unevaluated props (the engine only resolves |
| 34 | * Output expressions for the real `reconcile`/create), so env values that |
| 35 | * reference other resources are still unresolved `Output`s there — skip them. |
| 36 | * They are applied when reconcile runs against the resolved props. |
| 37 | * |
| 38 | * When `accountId` is provided it is injected as `ALCHEMY_CLOUDFLARE_ACCOUNT_ID` |
| 39 | * (mirroring the Worker runtime) so the container bootstrap can build |
| 40 | * `CloudflareEnvironment` for HTTP capability bindings (R2/KV/Queue `*Http`). |
| 41 | * |
| 42 | * `bindings` carries the resource's binding contract — the `{ env }` a |
| 43 | * `Binding.Service` attaches with ``host.bind`${resource}`({ env })`` when the |
| 44 | * container is the host (`Prisma.Connect`, and any other capability whose |
| 45 | * runtime config travels as environment variables). Bindings are resolved by |
| 46 | * the engine before `reconcile`, so they land here already evaluated. They are |
| 47 | * applied FIRST, at the lowest precedence: an explicitly declared `env` or |
| 48 | * `environmentVariables` entry always wins over a capability-injected one. |
| 49 | */ |
| 50 | export const makeContainerEnv = ( |
| 51 | props: AnyContainerApplicationProps, |
| 52 | accountId: string, |
| 53 | bindings: readonly { |
| 54 | data?: { env?: Record<string, any> } | undefined; |
| 55 | }[] = [], |
| 56 | ) => { |
| 57 | const env: Record<string, string | Redacted.Redacted<string>> = {}; |
| 58 | for (const binding of bindings) { |
| 59 | for (const [name, value] of Object.entries(binding.data?.env ?? {})) { |
| 60 | if (Output.isOutput(value) || value === undefined) { |
| 61 | continue; |
| 62 | } |
| 63 | env[name] = value; |
| 64 | } |
| 65 | } |
| 66 | for (const [name, value] of Object.entries(props.env ?? {})) { |
| 67 | if (Output.isOutput(value) || value === undefined) { |
| 68 | continue; |
| 69 | } |
| 70 | env[name] = value; |
| 71 | } |
| 72 | for (const value of props.environmentVariables ?? []) { |
| 73 | env[value.name] = value.value; |
| 74 | } |
| 75 | if (!env.ALCHEMY_CLOUDFLARE_ACCOUNT_ID) { |
| 76 | env.ALCHEMY_CLOUDFLARE_ACCOUNT_ID = accountId; |
| 77 | } |
| 78 | return env; |
| 79 | }; |
| 80 | |
| 81 | /** |
| 82 | * Derive the physical name for a container application. Shared between the |
| 83 | * live and local providers so they agree on the deterministic name. |
| 84 | */ |
| 85 | export const createContainerApplicationName = ( |
| 86 | id: string, |
| 87 | name: string | undefined, |
| 88 | ) => |
| 89 | Effect.suspend(() => { |
| 90 | if (name) return Effect.succeed(name); |
| 91 | return createPhysicalName({ |
| 92 | id, |
| 93 | lowercase: true, |
| 94 | }); |
| 95 | }); |
| 96 | |
| 97 | /** |
| 98 | * Validate the image-source composition on container props. Exactly one |
| 99 | * environment/source may be declared: |
| 100 | * |
| 101 | * - `main` + optional `image` (environment base) OR inline `dockerfile` |
| 102 | * (environment preamble) — never both, and never a `context`. |
| 103 | * - `image` alone — a pre-built remote image, exclusive with `dockerfile` |
| 104 | * and `context`. |
| 105 | * - `dockerfile` (string path against `context`, or inline content with no |
| 106 | * `context`) — the user-Dockerfile build. |
| 107 | * |
| 108 | * Invalid combinations are programmer errors, surfaced as plan-time defects |
| 109 | * (`Effect.die`) rather than typed errors. |
| 110 | */ |
| 111 | export const validateContainerImageProps = ( |
| 112 | props: Pick< |
| 113 | AnyContainerApplicationProps, |
| 114 | "main" | "image" | "dockerfile" | "context" |
| 115 | >, |
| 116 | ): Effect.Effect<void> => { |
| 117 | const df = props.dockerfile; |
| 118 | const hasInline = df !== undefined && isInlineDockerfile(df); |
| 119 | if (props.main) { |
| 120 | if (props.image !== undefined && df !== undefined) { |
| 121 | return Effect.die( |
| 122 | new Error( |
| 123 | "`image` and `dockerfile` are mutually exclusive with `main` — both pick the environment for the bundled program; declare one.", |
| 124 | ), |
| 125 | ); |
| 126 | } |
| 127 | if (df !== undefined && !hasInline) { |
| 128 | return Effect.die( |
| 129 | new Error( |
| 130 | "A `dockerfile` PATH cannot be combined with `main` on Cloudflare containers — use `Dockerfile.inline` content or `image` to pick the bundled program's environment.", |
| 131 | ), |
| 132 | ); |
| 133 | } |
| 134 | if (props.context !== undefined) { |
| 135 | return Effect.die( |
| 136 | new Error( |
| 137 | "`context` cannot be combined with `main` — the build context for a bundled program is generated by Alchemy.", |
| 138 | ), |
| 139 | ); |
| 140 | } |
| 141 | return Effect.void; |
| 142 | } |
| 143 | if (props.image !== undefined && df !== undefined) { |
| 144 | return Effect.die( |
| 145 | new Error( |
| 146 | "`image` (pre-built remote image) and `dockerfile` (build your own) are mutually exclusive — declare one.", |
| 147 | ), |
| 148 | ); |
| 149 | } |
| 150 | if (props.image !== undefined && props.context !== undefined) { |
| 151 | return Effect.die( |
| 152 | new Error( |
| 153 | "`image` (pre-built remote image) and `context` (Dockerfile build) are mutually exclusive — declare one.", |
| 154 | ), |
| 155 | ); |
| 156 | } |
| 157 | if (hasInline && props.context !== undefined) { |
| 158 | return Effect.die( |
| 159 | new Error( |
| 160 | "Inline `dockerfile` content cannot be combined with `context` — inline content has no build context; use a `dockerfile` PATH for context-relative builds.", |
| 161 | ), |
| 162 | ); |
| 163 | } |
| 164 | return Effect.void; |
| 165 | }; |
| 166 | |
| 167 | /** |
| 168 | * Resolve the environment preamble for a generated (Effect-native) container |
| 169 | * Dockerfile from the props' `image` / inline `dockerfile` composition: |
| 170 | * |
| 171 | * - inline `dockerfile` content → used verbatim as the preamble (it carries |
| 172 | * its own `FROM` and any extra build steps), |
| 173 | * - `image` → a synthesized `FROM <image>` line, |
| 174 | * - neither → `undefined` (callers fall back to the runtime default base). |
| 175 | */ |
| 176 | export const containerEnvPreamble = ( |
| 177 | props: Pick<AnyContainerApplicationProps, "image" | "dockerfile">, |
| 178 | ): Effect.Effect<string | undefined> => { |
| 179 | const df = props.dockerfile; |
| 180 | if (df !== undefined && isInlineDockerfile(df)) { |
| 181 | const content = df.content; |
| 182 | if (typeof content !== "string") { |
| 183 | return Effect.die( |
| 184 | new Error( |
| 185 | "Inline `dockerfile` content is an unresolved Output at image-build time. " + |
| 186 | "Outputs in `Dockerfile.inline` resolve during a normal deploy; this container is being built before its dependencies resolved (e.g. during precreate of a circular binding). Break the cycle or inline the resolved value.", |
| 187 | ), |
| 188 | ); |
| 189 | } |
| 190 | return Effect.succeed(content.trimEnd()); |
| 191 | } |
| 192 | const ref = props.image?.trim(); |
| 193 | if (ref) { |
| 194 | if (/\s/.test(ref)) { |
| 195 | // A registry reference never contains whitespace — catch Dockerfile |
| 196 | // content early with an actionable message instead of producing a |
| 197 | // broken build. |
| 198 | return Effect.die( |
| 199 | new Error( |
| 200 | `\`image\` must be a plain image reference (e.g. "oven/bun:latest"), got: ${JSON.stringify(props.image)}. ` + |
| 201 | "For inline Dockerfile content use `dockerfile: Dockerfile.inline`.", |
| 202 | ), |
| 203 | ); |
| 204 | } |
| 205 | return Effect.succeed(`FROM ${ref}`); |
| 206 | } |
| 207 | return Effect.succeed(undefined); |
| 208 | }; |
| 209 | |
| 210 | /** |
| 211 | * Build the final Dockerfile used for a generated (Effect-native) container |
| 212 | * image. Starts from the environment preamble (see |
| 213 | * {@link containerEnvPreamble}) — or a runtime-appropriate default base — |
| 214 | * then appends the statements that copy the bundled program and set the |
| 215 | * entrypoint. |
| 216 | */ |
| 217 | export const buildFinalDockerfile = ( |
| 218 | envPreamble: string | undefined, |
| 219 | runtime: "bun" | "node", |
| 220 | external: string[] = [], |
| 221 | autoInstallExternals = true, |
| 222 | ): string => { |
| 223 | const base = |
| 224 | envPreamble ?? |
| 225 | (runtime === "bun" ? "FROM oven/bun:1" : "FROM node:22-slim"); |
| 226 | const runtimeBin = runtime === "bun" ? "bun" : "node"; |
| 227 | const installCmd = runtime === "bun" ? "bun add" : "npm install"; |
| 228 | const installStep = |
| 229 | autoInstallExternals && external.length > 0 |
| 230 | ? `RUN ${installCmd} ${external.join(" ")}` |
| 231 | : ""; |
| 232 | return [ |
| 233 | base, |
| 234 | "", |
| 235 | "WORKDIR /app", |
| 236 | ...(installStep ? [installStep, ""] : []), |
| 237 | "COPY index.mjs /app/index.mjs", |
| 238 | // Copy any additional rolldown chunks (`chunk-XXX.js`, |
| 239 | // `BunServices-YYY.js`, …). The glob matches zero or more files; |
| 240 | // non-trivial bundles always emit at least one chunk, minimal |
| 241 | // bundles emit none and the COPY no-ops. |
| 242 | "COPY *.js /app/", |
| 243 | "EXPOSE 3000", |
| 244 | `ENTRYPOINT ["${runtimeBin}", "/app/index.mjs"]`, |
| 245 | "", |
| 246 | ].join("\n"); |
| 247 | }; |
| 248 | |
| 249 | /** |
| 250 | * Materialize resolved inline `dockerfile` content into a stable, |
| 251 | * deterministic build-context directory (containing only the Dockerfile) so |
| 252 | * both the live provider and the local dev runtime can `docker build` it. |
| 253 | * Shared by the live and local providers so they agree on the path. |
| 254 | */ |
| 255 | export const materializeInlineDockerfileContext = Effect.fn(function* ( |
| 256 | id: string, |
| 257 | content: string, |
| 258 | ) { |
| 259 | const { dotAlchemy } = yield* AlchemyContext; |
| 260 | const docker = yield* Docker; |
| 261 | const path = yield* Path.Path; |
| 262 | const context = yield* getStableContextDir( |
| 263 | dotAlchemy, |
| 264 | dotAlchemy, |
| 265 | `${id}-dockerfile`, |
| 266 | ); |
| 267 | yield* docker.materialize({ context, dockerfile: content, files: [] }); |
| 268 | return { context, dockerfile: path.join(context, "Dockerfile") }; |
| 269 | }); |
| 270 | |
| 271 | /** |
| 272 | * Bundle the container entrypoint program with rolldown. Returns every emitted |
| 273 | * file (entry chunk plus shared chunks) so the full set can be materialized |
| 274 | * into the Docker build context, along with a content hash of the bundle. |
| 275 | * |
| 276 | * Shared between the live provider (which builds + pushes a Cloudflare image) |
| 277 | * and the local provider (which writes the context to disk for the runtime to |
| 278 | * `docker build`). |
| 279 | */ |
| 280 | export const bundleContainerProgram = Effect.fn(function* ({ |
| 281 | main, |
| 282 | runtime, |
| 283 | handler = "default", |
| 284 | isExternal = false, |
| 285 | external = [], |
| 286 | outdir, |
| 287 | build, |
| 288 | }: { |
| 289 | id: string; |
| 290 | main: string; |
| 291 | runtime: "bun" | "node"; |
| 292 | handler?: string | undefined; |
| 293 | isExternal?: boolean; |
| 294 | external?: string[]; |
| 295 | outdir?: string; |
| 296 | build?: Bundle.BundleConfig; |
| 297 | }) { |
| 298 | const stack = yield* Stack; |
| 299 | const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin; |
| 300 | |
| 301 | const realMain = yield* resolveMainPath(main); |
| 302 | const cwd = yield* findCwdForBundle(realMain); |
| 303 | |
| 304 | const buildBundle = Effect.fn(function* ( |
| 305 | entry: string, |
| 306 | plugins?: rolldown.RolldownPluginOption, |
| 307 | ) { |
| 308 | return yield* Bundle.build( |
| 309 | { |
| 310 | ...build?.input, |
| 311 | input: entry, |
| 312 | cwd, |
| 313 | external: [ |
| 314 | "cloudflare:workers", |
| 315 | "cloudflare:workflows", |
| 316 | ...(runtime === "bun" ? ["bun", "bun:*"] : []), |
| 317 | ...external, |
| 318 | ...((build?.input?.external as string[] | undefined) ?? []), |
| 319 | ], |
| 320 | platform: "node", |
| 321 | resolve: { |
| 322 | conditionNames: |
| 323 | runtime === "bun" |
| 324 | ? [...Bundle.BUN_CONDITION_NAMES] |
| 325 | : [...Bundle.NODE_CONDITION_NAMES], |
| 326 | ...build?.input?.resolve, |
| 327 | }, |
| 328 | plugins: [build?.input?.plugins, plugins], |
| 329 | treeshake: true, |
| 330 | }, |
| 331 | { |
| 332 | ...build?.output, |
| 333 | format: "esm", |
| 334 | sourcemap: build?.output?.sourcemap ?? false, |
| 335 | minify: build?.output?.minify ?? false, |
| 336 | dir: outdir, |
| 337 | entryFileNames: "index.mjs", |
| 338 | }, |
| 339 | build, |
| 340 | ); |
| 341 | }); |
| 342 | |
| 343 | const bundleOutput = isExternal |
| 344 | ? yield* buildBundle(realMain) |
| 345 | : yield* buildBundle( |
| 346 | realMain, |
| 347 | virtualEntryPlugin( |
| 348 | (importPath) => ` |
| 349 | import { bootstrap } from ${JSON.stringify( |
| 350 | runtime === "bun" |
| 351 | ? "alchemy/Runtime/Bootstrap/CloudflareContainerBun" |
| 352 | : "alchemy/Runtime/Bootstrap/CloudflareContainerNode", |
| 353 | )}; |
| 354 | import ${handler === "default" ? "entrypoint" : `{ ${handler} as entrypoint }`} from ${JSON.stringify(importPath)}; |
| 355 | |
| 356 | await bootstrap(entrypoint, ${JSON.stringify({ |
| 357 | stack: { name: stack.name, stage: stack.stage }, |
| 358 | })}); |
| 359 | `, |
| 360 | ), |
| 361 | ); |
| 362 | |
| 363 | // Rolldown can emit multiple chunk files (entry + shared chunks). |
| 364 | // Return every file so downstream code can materialize all of them |
| 365 | // into the Docker build context — dropping any of them produces a |
| 366 | // `Cannot find module './chunk-XXX.js'` runtime crash inside the |
| 367 | // container (with zero stdout, because it crashes before any user |
| 368 | // code runs). |
| 369 | const files = bundleOutput.files.map((f) => ({ |
| 370 | path: f.path, |
| 371 | content: |
| 372 | typeof f.content === "string" |
| 373 | ? new TextEncoder().encode(f.content) |
| 374 | : f.content, |
| 375 | })); |
| 376 | |
| 377 | return { files, hash: bundleOutput.hash }; |
| 378 | }); |
| 379 | |
| 380 | /** |
| 381 | * Bundle an Effect-native container `main` and materialize it (plus the |
| 382 | * generated Dockerfile) into a stable Docker build context directory, then |
| 383 | * return the paths + content hash of that context. |
| 384 | * |
| 385 | * This is the local-dev image shape (`ContainerImage.Build`) that |
| 386 | * `@alchemy.run/cloudflare-runtime/core` consumes: it `docker build`s the |
| 387 | * `dockerfile` against the `context` directory. Shared between the local |
| 388 | * provider (which serves this context to the runtime as the `dev` image) and |
| 389 | * the live provider (which persists the same deterministic context path as |
| 390 | * `dev` so a subsequent `alchemy dev` run has an image to build even though the |
| 391 | * live deploy pushed to Cloudflare's registry instead). |
| 392 | * |
| 393 | * The context directory is deterministic for a given resource id, so live and |
| 394 | * local agree on the path. Callers that want to skip re-bundling on an |
| 395 | * unchanged resource should wrap this in {@link Artifacts.cached}. |
| 396 | */ |
| 397 | export const prepareContainerBuildContext = Effect.fn(function* ( |
| 398 | id: string, |
| 399 | news: AnyContainerApplicationProps, |
| 400 | ) { |
| 401 | const { dotAlchemy } = yield* AlchemyContext; |
| 402 | const docker = yield* Docker; |
| 403 | const path = yield* Path.Path; |
| 404 | |
| 405 | const main = news.main; |
| 406 | if (!main) { |
| 407 | return yield* Effect.die( |
| 408 | new Error("Container requires a `main` entrypoint."), |
| 409 | ); |
| 410 | } |
| 411 | yield* validateContainerImageProps(news); |
| 412 | const runtime = news.runtime ?? "bun"; |
| 413 | const context = yield* getStableContextDir( |
| 414 | process.cwd(), |
| 415 | dotAlchemy, |
| 416 | `${id}-container`, |
| 417 | ); |
| 418 | const dockerfileContent = buildFinalDockerfile( |
| 419 | yield* containerEnvPreamble(news), |
| 420 | runtime, |
| 421 | news.external, |
| 422 | news.autoInstallExternals, |
| 423 | ); |
| 424 | const [bundle] = yield* Effect.all( |
| 425 | [ |
| 426 | bundleContainerProgram({ |
| 427 | id, |
| 428 | main, |
| 429 | runtime, |
| 430 | handler: news.handler, |
| 431 | isExternal: news.isExternal, |
| 432 | external: news.external, |
| 433 | outdir: context, |
| 434 | build: news.build, |
| 435 | }), |
| 436 | docker.materialize({ |
| 437 | context, |
| 438 | dockerfile: dockerfileContent, |
| 439 | files: [], |
| 440 | }), |
| 441 | ], |
| 442 | { concurrency: "unbounded" }, |
| 443 | ); |
| 444 | return { |
| 445 | context, |
| 446 | dockerfile: path.join(context, "Dockerfile"), |
| 447 | hash: yield* sha256Object({ |
| 448 | bundle: bundle.hash, |
| 449 | dockerfileContent, |
| 450 | }), |
| 451 | }; |
| 452 | }); |