1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Path from "effect/Path";
4import type * as rolldown from "rolldown";
5import * as Bundle from "../../Bundle/Bundle.ts";
6import { findCwdForBundle } from "../../Bundle/TempRoot.ts";
7import { Stack } from "../../Stack.ts";
8
9/**
10 * The AWS-managed base image MicroVM Dockerfiles build on. The MicroVM build
11 * runs the Dockerfile server-side and snapshots the result with Firecracker.
12 */
13export const MICROVM_BASE_DOCKER_IMAGE =
14 "public.ecr.aws/lambda/microvms:al2023-minimal";
15
16/** The default port the in-VM HTTP server listens on. */
17export const DEFAULT_MICROVM_PORT = 8080;
18
19/**
20 * Build the final Dockerfile for an effectful MicroVM image. Starts from the
21 * user-provided base (or the managed MicroVM base), installs the JS runtime,
22 * copies the bundled program, and runs it as the entrypoint. Mirrors the
23 * Cloudflare Container `buildFinalDockerfile`, but targets the MicroVM base.
24 */
25export const buildMicrovmDockerfile = (
26 userDockerfile: string | undefined,
27 runtime: "bun" | "node",
28 port: number,
29): string => {
30 const base = userDockerfile?.trim() ?? `FROM ${MICROVM_BASE_DOCKER_IMAGE}`;
31 const installRuntime =
32 runtime === "bun"
33 ? // `bun.sh/install` unpacks a zip, so the minimal MicroVM base needs
34 // `unzip` (and `tar`) present before the installer runs.
35 "RUN dnf install -y unzip tar && curl -fsSL https://bun.sh/install | bash && ln -s /root/.bun/bin/bun /usr/local/bin/bun && dnf clean all"
36 : "RUN dnf install -y nodejs && dnf clean all";
37 const runtimeBin = runtime === "bun" ? "bun" : "node";
38 return [
39 base,
40 "",
41 installRuntime,
42 "WORKDIR /app",
43 // The entry (`index.mjs`) and every rolldown chunk are emitted with a
44 // `.mjs` extension, which Node always treats as ESM — so the entry's named
45 // imports of a chunk resolve without needing a `package.json` `"type"`
46 // marker (which would risk clobbering a user-provided base image's file).
47 "COPY *.mjs /app/",
48 `EXPOSE ${port}`,
49 `ENV PORT=${port}`,
50 `ENTRYPOINT ["${runtimeBin}", "/app/index.mjs"]`,
51 "",
52 ].join("\n");
53};
54
55/**
56 * Bundle an Effect-native MicroVM program with Rolldown and wrap it in a
57 * generated bootstrap that boots an HTTP server (the MicroVM endpoint). Returns
58 * every emitted file so the full set can be zipped into the code artifact.
59 *
60 * Mirrors `bundleContainerProgram`; the bootstrap provides AWS runtime services
61 * (FetchHttpClient + region from env) so in-VM HTTP capability bindings
62 * (e.g. S3 `*Http`) resolve against the MicroVM's execution role.
63 */
64export const bundleMicrovmProgram = Effect.fn(function* ({
65 main,
66 runtime,
67 handler = "default",
68 isExternal = false,
69 external = [],
70 port,
71 build,
72}: {
73 main: string;
74 runtime: "bun" | "node";
75 handler?: string | undefined;
76 isExternal?: boolean;
77 external?: string[];
78 port: number;
79 build?: Bundle.BundleConfig;
80}) {
81 const fs = yield* FileSystem.FileSystem;
82 const stack = yield* Stack;
83 const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin;
84
85 const realMain = yield* fs.realPath(main);
86 const cwd = yield* findCwdForBundle(realMain);
87
88 const buildBundle = Effect.fn(function* (
89 entry: string,
90 plugins?: rolldown.RolldownPluginOption,
91 ) {
92 return yield* Bundle.build(
93 {
94 ...build?.input,
95 input: entry,
96 cwd,
97 external: [
98 "@aws-sdk/*",
99 ...(runtime === "bun" ? ["bun", "bun:*"] : []),
100 ...external,
101 ...((build?.input?.external as string[] | undefined) ?? []),
102 ],
103 platform: "node",
104 resolve: {
105 conditionNames:
106 runtime === "bun"
107 ? [...Bundle.BUN_CONDITION_NAMES]
108 : [...Bundle.NODE_CONDITION_NAMES],
109 ...build?.input?.resolve,
110 },
111 plugins: [build?.input?.plugins, plugins],
112 treeshake: true,
113 },
114 {
115 ...build?.output,
116 format: "esm",
117 sourcemap: build?.output?.sourcemap ?? false,
118 minify: build?.output?.minify ?? false,
119 entryFileNames: "index.mjs",
120 // Emit chunks as `.mjs` too so Node treats them as ESM unconditionally
121 // (no `package.json` `"type":"module"` needed in the image).
122 chunkFileNames: "[name]-[hash].mjs",
123 },
124 build,
125 );
126 });
127
128 const bundleOutput = isExternal
129 ? yield* buildBundle(realMain)
130 : yield* buildBundle(
131 realMain,
132 virtualEntryPlugin(
133 (importPath) => `
134import { bootstrap } from ${JSON.stringify(
135 runtime === "bun"
136 ? "alchemy/Runtime/Bootstrap/MicrovmBun"
137 : "alchemy/Runtime/Bootstrap/MicrovmNode",
138 )};
139import ${handler === "default" ? "entrypoint" : `{ ${handler} as entrypoint }`} from ${JSON.stringify(importPath)};
140
141await bootstrap(entrypoint, ${JSON.stringify({
142 port,
143 stack: { name: stack.name, stage: stack.stage },
144 })});
145`,
146 ),
147 );
148
149 const files = bundleOutput.files.map((f) => ({
150 path: f.path,
151 content:
152 typeof f.content === "string"
153 ? new TextEncoder().encode(f.content)
154 : f.content,
155 }));
156
157 return { files, hash: bundleOutput.hash };
158});
159
160export interface ArtifactFile {
161 path: string;
162 content: string | Uint8Array;
163}
164
165/**
166 * Zip a flat list of files into a deterministic (fixed mtime) archive. Used to
167 * package the MicroVM code artifact (Dockerfile + bundled program, or a build
168 * context) before uploading it to S3.
169 */
170export { zipFiles } from "../../Util/zip.ts";
171
172/**
173 * Recursively read a build-context directory into a flat list of files
174 * (relative paths + bytes) for zipping into the code artifact. Used by the
175 * external (bring-your-own-Dockerfile) MicroVM mode.
176 */
177export const readContextDirectory = Effect.fn(function* (dir: string) {
178 const fs = yield* FileSystem.FileSystem;
179 const path = yield* Path.Path;
180 const root = yield* fs.realPath(dir);
181
182 const walk = (
183 current: string,
184 ): Effect.Effect<{ path: string; content: Uint8Array }[], any, never> =>
185 Effect.gen(function* () {
186 const entries = yield* fs.readDirectory(current);
187 const out: { path: string; content: Uint8Array }[] = [];
188 for (const entry of entries) {
189 const abs = path.join(current, entry);
190 const info = yield* fs.stat(abs);
191 if (info.type === "Directory") {
192 out.push(...(yield* walk(abs)));
193 } else {
194 const content = yield* fs.readFile(abs);
195 out.push({ path: path.relative(root, abs), content });
196 }
197 }
198 return out;
199 });
200
201 return yield* walk(root);
202});