| 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 | |
| 16 | import * as Effect from "effect/Effect"; |
| 17 | import * as FileSystem from "effect/FileSystem"; |
| 18 | import type * as Path from "effect/Path"; |
| 19 | import type { PlatformError } from "effect/PlatformError"; |
| 20 | import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; |
| 21 | import type * as rolldown from "rolldown"; |
| 22 | import * as Bundle from "../../Bundle/Bundle.ts"; |
| 23 | import { |
| 24 | hashPackageInstallIdentity, |
| 25 | installResolvedPackages, |
| 26 | matchesPackageRoot, |
| 27 | normalizeInstallTargets, |
| 28 | resolvePackageInstallIdentity, |
| 29 | } from "../../Bundle/InstalledPackages.ts"; |
| 30 | import * as TempRoot from "../../Bundle/TempRoot.ts"; |
| 31 | import { sha256, sha256Object } from "../../Util/sha256.ts"; |
| 32 | import { zipCode, zipFiles, type ZipFile } from "../../Util/zip.ts"; |
| 33 | import 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 | */ |
| 39 | export 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 | */ |
| 61 | export 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 | |
| 72 | export 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 | |
| 83 | type BundleServices = FileSystem.FileSystem | Path.Path | ChildProcessSpawner; |
| 84 | |
| 85 | export 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) => ` |
| 192 | import { bootstrap } from "alchemy/Runtime/Bootstrap/Lambda"; |
| 193 | import entrypoint from ${JSON.stringify(importPath)}; |
| 194 | |
| 195 | export 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 | }); |