1import * as Effect from "effect/Effect";
2import * as Path from "effect/Path";
3import * as Redacted from "effect/Redacted";
4import type * as rolldown from "rolldown";
5import { AlchemyContext } from "../../AlchemyContext.ts";
6import * as Bundle from "../../Bundle/Bundle.ts";
7import {
8 findCwdForBundle,
9 getStableContextDir,
10 resolveMainPath,
11} from "../../Bundle/TempRoot.ts";
12import { Docker } from "../../Docker/Docker.ts";
13import { isInlineDockerfile } from "../../Docker/Dockerfile.ts";
14import * as Output from "../../Output.ts";
15import { createPhysicalName } from "../../PhysicalName.ts";
16import { Stack } from "../../Stack.ts";
17import { sha256Object } from "../../Util/sha256.ts";
18import 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 */
50export 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 */
85export 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 */
111export 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 */
176export 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 */
217export 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 */
255export 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 */
280export 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) => `
349import { bootstrap } from ${JSON.stringify(
350 runtime === "bun"
351 ? "alchemy/Runtime/Bootstrap/CloudflareContainerBun"
352 : "alchemy/Runtime/Bootstrap/CloudflareContainerNode",
353 )};
354import ${handler === "default" ? "entrypoint" : `{ ${handler} as entrypoint }`} from ${JSON.stringify(importPath)};
355
356await 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 */
397export 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});