1/** @effect-diagnostics anyUnknownInErrorContext:off */
2
3/**
4 * The Lambda Function code-bundling machinery, extracted from the live
5 * provider closure so it is reusable outside `FunctionProvider` — the floci
6 * local provider's watch loop rebuilds the exact same artifact on file
7 * change (see [FlociFunctionProvider](./FlociFunctionProvider.ts)).
8 *
9 * `makeFunctionBundler` resolves the platform services once and returns the
10 * same `bundleCode(id, props)` the live provider always used, plus the
11 * split-out pieces (`resolveBundlePlan` / `finishBundle` / `prebuiltCode`)
12 * that let a watcher run `Bundle.watch` with the identical rolldown config
13 * and post-process each incremental output into a deployable archive.
14 */
15
16import * as Effect from "effect/Effect";
17import * as FileSystem from "effect/FileSystem";
18import type * as Path from "effect/Path";
19import type { PlatformError } from "effect/PlatformError";
20import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner";
21import type * as rolldown from "rolldown";
22import * as Bundle from "../../Bundle/Bundle.ts";
23import {
24 hashPackageInstallIdentity,
25 installResolvedPackages,
26 matchesPackageRoot,
27 normalizeInstallTargets,
28 resolvePackageInstallIdentity,
29} from "../../Bundle/InstalledPackages.ts";
30import * as TempRoot from "../../Bundle/TempRoot.ts";
31import { sha256, sha256Object } from "../../Util/sha256.ts";
32import { zipCode, zipFiles, type ZipFile } from "../../Util/zip.ts";
33import type { FunctionArchitecture, FunctionZipProps } from "./Function.ts";
34
35/**
36 * Evaluates a user-supplied Rolldown `external` option (string, RegExp, array,
37 * or predicate) for a single module id, preserving its original semantics.
38 */
39export const matchesConfiguredExternal = (
40 external: rolldown.InputOptions["external"],
41 moduleId: string,
42 parentId: string | undefined,
43 isResolved: boolean,
44): boolean => {
45 if (external === undefined) return false;
46 if (typeof external === "function") {
47 return external(moduleId, parentId, isResolved) === true;
48 }
49 const matchers = Array.isArray(external) ? external : [external];
50 return matchers.some((matcher) =>
51 typeof matcher === "string" ? matcher === moduleId : matcher.test(moduleId),
52 );
53};
54
55/**
56 * The resolved rolldown configuration for a `bundle: true` Function — the
57 * exact input/output options `bundleCode` hands to `Bundle.build`, reusable
58 * verbatim with `Bundle.watch` for incremental dev rebuilds, plus the
59 * post-processing inputs `finishBundle` needs.
60 */
61export interface FunctionBundlePlan {
62 readonly inputOptions: rolldown.InputOptions;
63 readonly outputOptions: rolldown.OutputOptions;
64 readonly extra: Bundle.BundleExtraOptions | undefined;
65 readonly cwd: string;
66 readonly requested: Record<string, string>;
67 readonly sourcemap: boolean | "inline" | "hidden";
68 readonly uploadSourceMap: boolean;
69 readonly architecture: FunctionArchitecture;
70}
71
72export interface FunctionBundleResult {
73 /** Identity hash driving change detection in `diff` / watch dedupe. */
74 readonly identityHash: string;
75 /** Deferred archive build (performs native-package installs). */
76 readonly buildArchive: Effect.Effect<
77 { archive: Uint8Array<ArrayBufferLike>; archiveHash: string },
78 Bundle.BundleError,
79 BundleServices
80 >;
81}
82
83type BundleServices = FileSystem.FileSystem | Path.Path | ChildProcessSpawner;
84
85export const makeFunctionBundler = Effect.gen(function* () {
86 const fs = yield* FileSystem.FileSystem;
87 const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin;
88
89 // Recursively list every file under `root` as sorted POSIX-relative
90 // paths (prebuilt-directory packaging).
91 const walkFiles = (
92 root: string,
93 ): Effect.Effect<string[], PlatformError, never> =>
94 Effect.gen(function* () {
95 const out: string[] = [];
96 const go: (rel: string) => Effect.Effect<void, PlatformError> = Effect.fn(
97 function* (rel: string) {
98 const absolute = rel === "" ? root : `${root}/${rel}`;
99 const entries = yield* fs.readDirectory(absolute);
100 for (const entry of entries) {
101 const childRel = rel === "" ? entry : `${rel}/${entry}`;
102 const info = yield* fs.stat(`${root}/${childRel}`);
103 if (info.type === "Directory") {
104 yield* go(childRel);
105 } else {
106 out.push(childRel);
107 }
108 }
109 },
110 );
111 yield* go("");
112 return out.sort();
113 });
114
115 // `bundle: false` — ship `main`'s directory as-is. Framework outputs
116 // like nitro's `.output/server` are complete deployment units (entry +
117 // chunks + their own `node_modules`); re-bundling them can orphan
118 // CJS `require`s of exports-mapped subpaths.
119 const prebuiltCode: (
120 realMain: string,
121 ) => Effect.Effect<FunctionBundleResult, PlatformError> = Effect.fn(
122 function* (realMain: string) {
123 const lastSlash = realMain.lastIndexOf("/");
124 const dir = realMain.slice(0, lastSlash);
125 const files = yield* walkFiles(dir);
126 const archiveFiles: ZipFile[] = [];
127 const fileHashes: Record<string, string> = {};
128 for (const rel of files) {
129 const content = yield* fs.readFile(`${dir}/${rel}`);
130 archiveFiles.push({ path: rel, content });
131 fileHashes[rel] = yield* sha256(content);
132 }
133 const identityHash = yield* sha256Object(fileHashes);
134 const buildArchive = Effect.gen(function* () {
135 const archive = yield* zipFiles(archiveFiles);
136 return { archive, archiveHash: identityHash };
137 });
138 return { identityHash, buildArchive };
139 },
140 );
141
142 const resolveBundlePlan: (
143 props: FunctionZipProps,
144 ) => Effect.Effect<
145 FunctionBundlePlan,
146 Bundle.BundleError | PlatformError,
147 FileSystem.FileSystem | Path.Path
148 > = Effect.fn(function* (props: FunctionZipProps) {
149 const {
150 output: buildOutput,
151 install,
152 pure: _pure,
153 bundleAnalyzer: _bundleAnalyzer,
154 ...inputOptions
155 } = props.build ?? {};
156 const sourcemap = buildOutput?.sourcemap ?? true;
157 const uploadSourceMap = props.uploadSourceMap ?? true;
158
159 const realMain = yield* TempRoot.resolveMainPath(props.main);
160 const cwd = yield* TempRoot.findCwdForBundle(realMain);
161
162 const rolldownSourcemap = sourcemap;
163 const architecture = props.architecture ?? "x86_64";
164
165 // Explicit install roots are excluded from the bundle and installed
166 // into the deployment artifact. build.external stays a pure Rolldown
167 // escape hatch and is not installed by Alchemy.
168 const requested = yield* normalizeInstallTargets(install);
169 const installRoots = new Set(Object.keys(requested));
170 const configuredExternal = inputOptions.external;
171 const externalOption = (
172 moduleId: string,
173 parentId: string | undefined,
174 isResolved: boolean,
175 ): boolean => {
176 if (moduleId.startsWith("@aws-sdk/")) return true;
177 for (const root of installRoots) {
178 if (matchesPackageRoot(moduleId, root)) return true;
179 }
180 return matchesConfiguredExternal(
181 configuredExternal,
182 moduleId,
183 parentId,
184 isResolved,
185 );
186 };
187
188 const entryPlugin = props.isExternal
189 ? undefined
190 : virtualEntryPlugin(
191 (importPath) => `
192import { bootstrap } from "alchemy/Runtime/Bootstrap/Lambda";
193import entrypoint from ${JSON.stringify(importPath)};
194
195export default await bootstrap(entrypoint);
196`,
197 );
198
199 return {
200 inputOptions: {
201 ...inputOptions,
202 input: realMain,
203 cwd,
204 external: externalOption,
205 platform: "node",
206 // The zip runtime is Node (`nodejs22.x` | `nodejs24.x`): resolve with
207 // the node conditions and let rolldown supply the import kind. A
208 // `bun` condition here would hand a Node function any package's
209 // bun-specific entry. (Container-image functions are built by the
210 // user's Dockerfile and never pass through this bundler.)
211 resolve: {
212 ...inputOptions.resolve,
213 conditionNames: [
214 ...(inputOptions.resolve?.conditionNames ??
215 Bundle.NODE_CONDITION_NAMES),
216 ],
217 },
218 plugins: [inputOptions.plugins, entryPlugin],
219 },
220 outputOptions: {
221 ...buildOutput,
222 format: "esm",
223 sourcemap: rolldownSourcemap,
224 minify: buildOutput?.minify ?? false,
225 entryFileNames: "index.js",
226 codeSplitting: buildOutput?.codeSplitting ?? false,
227 },
228 extra: props.build,
229 cwd,
230 requested,
231 sourcemap: rolldownSourcemap,
232 uploadSourceMap,
233 architecture,
234 } satisfies FunctionBundlePlan;
235 });
236
237 const finishBundle: (
238 plan: FunctionBundlePlan,
239 bundleOutput: Bundle.BundleOutput,
240 ) => Effect.Effect<FunctionBundleResult, Bundle.BundleError, BundleServices> =
241 Effect.fn(function* (
242 plan: FunctionBundlePlan,
243 bundleOutput: Bundle.BundleOutput,
244 ) {
245 const mainFile = bundleOutput.files[0];
246 const code =
247 typeof mainFile.content === "string"
248 ? new TextEncoder().encode(mainFile.content)
249 : mainFile.content;
250
251 const includeSourceMaps =
252 plan.uploadSourceMap &&
253 (plan.sourcemap === true || plan.sourcemap === "hidden");
254
255 const extraFiles = bundleOutput.files
256 .slice(1)
257 .filter(
258 (f: Bundle.BundleFile) =>
259 includeSourceMaps || !f.path.endsWith(".map"),
260 )
261 .map((f: Bundle.BundleFile) => ({
262 path: f.path,
263 content: f.content,
264 }));
265
266 // Resolve install versions without running npm so `diff` can compare a
267 // stable identity hash. The archive build performs the install.
268 const installIdentity = yield* resolvePackageInstallIdentity({
269 cwd: plan.cwd,
270 requested: plan.requested,
271 });
272 const resolved = installIdentity.resolved;
273 const hasInstalledPackages = Object.keys(resolved).length > 0;
274
275 // Identity hash drives change detection in `diff`. With native packages,
276 // the installed bytes are not captured by the bundle hash, so fold the
277 // resolved versions, package-manager lockfile, and architecture in
278 // instead of installing.
279 const identityHash = hasInstalledPackages
280 ? yield* hashPackageInstallIdentity({
281 bundleHash: bundleOutput.hash,
282 identity: installIdentity,
283 architecture: plan.architecture,
284 })
285 : bundleOutput.hash;
286
287 const buildArchive = Effect.gen(function* () {
288 const installedPackageFiles = hasInstalledPackages
289 ? yield* installResolvedPackages({
290 resolved,
291 overrides: installIdentity.overrides,
292 architecture: plan.architecture,
293 })
294 : [];
295 const archiveFiles = [...extraFiles, ...installedPackageFiles];
296 const archive = yield* zipCode(
297 code,
298 archiveFiles.length > 0 ? archiveFiles : undefined,
299 );
300 // The S3 asset key is content-addressed, so the archive hash must be a
301 // true hash of the bytes when native packages are present.
302 const archiveHash =
303 installedPackageFiles.length > 0
304 ? yield* sha256(archive)
305 : bundleOutput.hash;
306 return { archive, archiveHash };
307 });
308
309 return { identityHash, buildArchive };
310 });
311
312 const bundleCode: (
313 id: string,
314 props: FunctionZipProps,
315 ) => Effect.Effect<
316 FunctionBundleResult,
317 Bundle.BundleError | PlatformError,
318 BundleServices
319 > = Effect.fn(function* (_id: string, props: FunctionZipProps) {
320 if (props.bundle === false) {
321 const realMain = yield* TempRoot.resolveMainPath(props.main);
322 return yield* prebuiltCode(realMain);
323 }
324 const plan = yield* resolveBundlePlan(props);
325 const bundleOutput = yield* Bundle.build(
326 plan.inputOptions,
327 plan.outputOptions,
328 plan.extra,
329 );
330 return yield* finishBundle(plan, bundleOutput);
331 });
332
333 return { bundleCode, prebuiltCode, resolveBundlePlan, finishBundle };
334});