| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Path from "effect/Path"; |
| 3 | import * as Queue from "effect/Queue"; |
| 4 | import * as Schema from "effect/Schema"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | import assert from "node:assert"; |
| 7 | import type * as rolldown from "rolldown"; |
| 8 | import { sha256, sha256Object } from "../Util/sha256.ts"; |
| 9 | import { |
| 10 | bundleAnalyzerPlugin, |
| 11 | type BundleAnalyzerPluginOptions, |
| 12 | } from "./BundleAnalyzerPlugin.ts"; |
| 13 | import { purePlugin, type PurePluginOptions } from "./PurePlugin.ts"; |
| 14 | import { rawPlugin } from "./RawPlugin.ts"; |
| 15 | |
| 16 | /** |
| 17 | * `resolve.conditionNames` for a bundle that runs on bun / node. |
| 18 | * |
| 19 | * Rolldown's DEFAULT conditions are import-kind specific: `import` for |
| 20 | * `import` statements and `require` for `require()` calls. An explicit |
| 21 | * `conditionNames` list is applied as one set to BOTH kinds, and rolldown |
| 22 | * adds only the current kind on top. Listing `"import"` here therefore |
| 23 | * made every `require()` also match a package's `"import"` export — and |
| 24 | * `exports` maps are matched in the PACKAGE's key order, so for `pg-pool` |
| 25 | * (`{ import, require }`) a `require("pg-pool")` received the ESM namespace |
| 26 | * and `pg` died with `TypeError: The superclass is not a constructor`. |
| 27 | * |
| 28 | * So: never put `import` or `require` in these lists. Only the runtime |
| 29 | * condition and the kind-agnostic ones; rolldown supplies the kind. |
| 30 | */ |
| 31 | export const BUN_CONDITION_NAMES: readonly string[] = [ |
| 32 | "bun", |
| 33 | "module", |
| 34 | "default", |
| 35 | ]; |
| 36 | export const NODE_CONDITION_NAMES: readonly string[] = [ |
| 37 | "node", |
| 38 | "module", |
| 39 | "default", |
| 40 | ]; |
| 41 | |
| 42 | /** |
| 43 | * Rolldown is loaded lazily on first {@link build}/{@link watch} so that |
| 44 | * merely importing alchemy — the CLI command tree, the Cloudflare provider |
| 45 | * barrel — never loads `@rolldown/binding-*`. A stack that bundles |
| 46 | * nothing must not require the native bundler to be loadable (#562). |
| 47 | */ |
| 48 | const loadRolldown = () => import("rolldown"); |
| 49 | |
| 50 | /** |
| 51 | * Extra options accepted by {@link build} / {@link watch} on top of the |
| 52 | * standard rolldown input/output options. |
| 53 | */ |
| 54 | export interface BundleExtraOptions { |
| 55 | /** |
| 56 | * Configures the {@link purePlugin} which annotates top-level |
| 57 | * call/new expressions in matching packages with `/*#__PURE__*\/` |
| 58 | * so rolldown can tree-shake them. |
| 59 | * |
| 60 | * - `undefined` (default): plugin is enabled with the default packages |
| 61 | * (`effect`, `@effect/*`, `alchemy`, `@alchemy.run/*`, |
| 62 | * `@distilled.cloud/*` — see `DEFAULT_PURE_PACKAGES`). |
| 63 | * - `PurePluginOptions`: plugin is enabled with the provided options. |
| 64 | * `packages` extends the defaults; set `replaceDefaults: true` to |
| 65 | * replace them instead. |
| 66 | * - `false`: plugin is disabled. |
| 67 | */ |
| 68 | readonly pure?: PurePluginOptions | false; |
| 69 | /** |
| 70 | * Configures the {@link bundleAnalyzerPlugin} which emits a bundle analysis |
| 71 | * report alongside the bundle output, describing chunks, modules, and the |
| 72 | * import graph reachable from each entry point. |
| 73 | * |
| 74 | * - `undefined` / `false` (default): plugin is disabled. |
| 75 | * - `true`: plugin is enabled with default options. |
| 76 | * - `BundleAnalyzerPluginOptions`: plugin is enabled with the provided options. |
| 77 | */ |
| 78 | readonly bundleAnalyzer?: BundleAnalyzerPluginOptions | boolean; |
| 79 | } |
| 80 | |
| 81 | /** |
| 82 | * The user-facing bundler configuration shared by every resource that |
| 83 | * bundles an entrypoint with rolldown (AWS Lambda Function, Batch |
| 84 | * JobDefinition, App Runner Service, ECR image sources, EC2 hosted |
| 85 | * instances, Docker service images, Cloudflare container images, …): |
| 86 | * rolldown input/output overrides plus the {@link BundleExtraOptions} |
| 87 | * (pure-annotation packages via `pure`, bundle analyzer). |
| 88 | * |
| 89 | * Unused code is tree-shaken. `effect`, alchemy, and `@distilled.cloud` |
| 90 | * are marked pure so unused parts prune more aggressively. List extra |
| 91 | * packages with `pure.packages`, or disable with `pure: false`. |
| 92 | */ |
| 93 | export interface BundleConfig extends BundleExtraOptions { |
| 94 | /** |
| 95 | * Rolldown input options overrides. |
| 96 | */ |
| 97 | readonly input?: Partial<rolldown.InputOptions>; |
| 98 | /** |
| 99 | * Rolldown output options overrides. |
| 100 | */ |
| 101 | readonly output?: Partial<rolldown.OutputOptions>; |
| 102 | } |
| 103 | |
| 104 | export interface BundleOutput { |
| 105 | /** |
| 106 | * The files in the bundle. |
| 107 | * The first file is the entry. |
| 108 | */ |
| 109 | readonly files: [BundleFile, ...BundleFile[]]; |
| 110 | /** |
| 111 | * The SHA-256 hash of all files in the bundle. |
| 112 | */ |
| 113 | readonly hash: string; |
| 114 | } |
| 115 | |
| 116 | export interface BundleFile { |
| 117 | readonly path: string; |
| 118 | readonly content: string | Uint8Array<ArrayBufferLike>; |
| 119 | readonly hash: string; |
| 120 | } |
| 121 | |
| 122 | export class BundleError extends Schema.TaggedError<BundleError>()( |
| 123 | "BundleError", |
| 124 | { |
| 125 | message: Schema.String, |
| 126 | cause: Schema.optional(Schema.Defect({ includeStack: true })), |
| 127 | }, |
| 128 | ) {} |
| 129 | |
| 130 | export type BundleWatchEvent = |
| 131 | | BundleWatchEvent.Start |
| 132 | | BundleWatchEvent.Success |
| 133 | | BundleWatchEvent.Error; |
| 134 | |
| 135 | export declare namespace BundleWatchEvent { |
| 136 | interface Start { |
| 137 | readonly _tag: "Start"; |
| 138 | } |
| 139 | interface Success { |
| 140 | readonly _tag: "Success"; |
| 141 | readonly output: BundleOutput; |
| 142 | } |
| 143 | interface Error { |
| 144 | readonly _tag: "Error"; |
| 145 | readonly error: BundleError; |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | /** |
| 150 | * Compile-time constants substituted into every bundle via rolldown's |
| 151 | * `transform.define`. |
| 152 | * |
| 153 | * `globalThis.__ALCHEMY_RUNTIME__` is the runtime-phase flag: it is folded to |
| 154 | * `true` in all bundled (runtime) artifacts — deployed Workers, Lambdas, |
| 155 | * Containers, etc. — so any plan-only code guarded by |
| 156 | * `if (!globalThis.__ALCHEMY_RUNTIME__)` becomes `if (!true)` and is |
| 157 | * dead-code-eliminated from what ships. |
| 158 | * |
| 159 | * When the same source runs WITHOUT the bundler (the plan process executing |
| 160 | * `.ts` directly with bun/node), `globalThis.__ALCHEMY_RUNTIME__` reads as |
| 161 | * `undefined` (falsy) rather than throwing, so plan-only branches run. |
| 162 | * See `ALCHEMY_PHASE` in `Phase.ts`. |
| 163 | */ |
| 164 | const ALCHEMY_DEFINE: Record<string, string> = { |
| 165 | "globalThis.__ALCHEMY_RUNTIME__": "true", |
| 166 | }; |
| 167 | |
| 168 | /** |
| 169 | * Default rolldown `moduleTypes` applied to every bundle, mirroring the |
| 170 | * `Text` module rules Wrangler applies to Workers (and alchemy's own |
| 171 | * {@link defaultModuleRules} for prebuilt bundles): importing a `.sql`, |
| 172 | * `.txt`, or `.html` file default-exports its contents as a string. |
| 173 | * |
| 174 | * `.sql` is what makes drizzle-kit's generated Durable Object migrations |
| 175 | * bundle (`migrations.js` does `import m0000 from './0000_x.sql'`) work |
| 176 | * without a wrangler-style rules config or a codegen step. |
| 177 | */ |
| 178 | const ALCHEMY_MODULE_TYPES: NonNullable<rolldown.InputOptions["moduleTypes"]> = |
| 179 | { |
| 180 | ".sql": "text", |
| 181 | ".txt": "text", |
| 182 | ".html": "text", |
| 183 | }; |
| 184 | |
| 185 | /** |
| 186 | * Merge {@link ALCHEMY_DEFINE} into the caller's `transform.define` (the |
| 187 | * framework flags win over any caller-provided keys) and |
| 188 | * {@link ALCHEMY_MODULE_TYPES} into the caller's `moduleTypes` (caller |
| 189 | * keys win, so an extension can be remapped or disabled per bundle). |
| 190 | */ |
| 191 | const withAlchemyDefine = ( |
| 192 | inputOptions: rolldown.InputOptions, |
| 193 | ): rolldown.InputOptions => ({ |
| 194 | ...inputOptions, |
| 195 | transform: { |
| 196 | ...inputOptions.transform, |
| 197 | define: { |
| 198 | ...inputOptions.transform?.define, |
| 199 | ...ALCHEMY_DEFINE, |
| 200 | }, |
| 201 | }, |
| 202 | moduleTypes: { |
| 203 | ...ALCHEMY_MODULE_TYPES, |
| 204 | ...inputOptions.moduleTypes, |
| 205 | }, |
| 206 | }); |
| 207 | |
| 208 | /** |
| 209 | * Default `minify` to `"dce-only"` when the caller hasn't chosen a mode, so the |
| 210 | * `if (false) { … }` branches produced by {@link ALCHEMY_DEFINE} are physically |
| 211 | * removed from every bundle (define alone only folds the condition; removal |
| 212 | * needs DCE). Callers that opt into full minification (e.g. Workers) keep it. |
| 213 | */ |
| 214 | const withDceDefault = ( |
| 215 | outputOptions?: rolldown.OutputOptions, |
| 216 | ): rolldown.OutputOptions => ({ |
| 217 | ...outputOptions, |
| 218 | minify: outputOptions?.minify ?? "dce-only", |
| 219 | }); |
| 220 | |
| 221 | /** |
| 222 | * Build a bundle using rolldown from the given input options and output options. |
| 223 | * @param inputOptions - The input options for the bundle. |
| 224 | * @param outputOptions - The output options for the bundle. |
| 225 | * @returns The bundle output. |
| 226 | */ |
| 227 | export const build = ( |
| 228 | inputOptions: rolldown.InputOptions, |
| 229 | outputOptions?: rolldown.OutputOptions, |
| 230 | extra?: BundleExtraOptions, |
| 231 | ): Effect.Effect<BundleOutput, BundleError> => |
| 232 | Effect.tryPromise({ |
| 233 | try: async () => { |
| 234 | const rolldown = await loadRolldown(); |
| 235 | const bundle = await rolldown.rolldown({ |
| 236 | ...withAlchemyDefine(inputOptions), |
| 237 | plugins: [inputOptions.plugins, await builtInPlugins(extra)], |
| 238 | optimization: inputOptions.optimization ?? { |
| 239 | inlineConst: { |
| 240 | mode: "smart", |
| 241 | pass: 3, |
| 242 | }, |
| 243 | }, |
| 244 | }); |
| 245 | const result = await bundle.write(withDceDefault(outputOptions)); |
| 246 | await bundle.close(); |
| 247 | return result.output; |
| 248 | }, |
| 249 | catch: bundleErrorFromUnknown, |
| 250 | }).pipe( |
| 251 | Effect.flatMap(Effect.forEach(bundleFileFromOutputChunk)), |
| 252 | Effect.flatMap(bundleOutputFromFiles), |
| 253 | ); |
| 254 | |
| 255 | /** |
| 256 | * Watch for changes in the bundle and return a stream of bundle output. |
| 257 | * @param inputOptions - The input options for the bundle. |
| 258 | * @param outputOptions - The output options for the bundle. |
| 259 | * @returns A stream of Result instances containing either the bundle output or an error. |
| 260 | */ |
| 261 | export const watch = ( |
| 262 | inputOptions: rolldown.InputOptions, |
| 263 | outputOptions?: rolldown.OutputOptions, |
| 264 | extra?: BundleExtraOptions, |
| 265 | ): Stream.Stream<BundleWatchEvent> => |
| 266 | Stream.callback< |
| 267 | | BundleWatchEvent.Start |
| 268 | | BundleWatchEvent.Error |
| 269 | | { |
| 270 | readonly _tag: "Success"; |
| 271 | readonly output: rolldown.OutputBundle; |
| 272 | } |
| 273 | >((queue) => |
| 274 | Effect.acquireRelease( |
| 275 | Effect.promise(async () => { |
| 276 | const rolldown = await loadRolldown(); |
| 277 | const watcher = rolldown.watch({ |
| 278 | ...withAlchemyDefine(inputOptions), |
| 279 | plugins: [ |
| 280 | inputOptions.plugins, |
| 281 | await builtInPlugins(extra), |
| 282 | // The watcher event listener does not receive the bundle output, so we grab it using a plugin. |
| 283 | { |
| 284 | name: "alchemy:watch-bundle", |
| 285 | watchChange() { |
| 286 | Queue.offerUnsafe(queue, { |
| 287 | _tag: "Start", |
| 288 | }); |
| 289 | }, |
| 290 | generateBundle(_outputOptions, bundle) { |
| 291 | Queue.offerUnsafe(queue, { |
| 292 | _tag: "Success", |
| 293 | output: bundle, |
| 294 | }); |
| 295 | }, |
| 296 | }, |
| 297 | ], |
| 298 | watch: { |
| 299 | // Watching the full module graph of an Effect worker (all of |
| 300 | // `effect`, `alchemy`, `@distilled.cloud/*`) registers thousands |
| 301 | // of OS watch handles *per worker*; with several Effect workers |
| 302 | // this exhausts the process fd table and the next `posix_spawn` |
| 303 | // (workerd / docker) fails with `spawn EBADF`. Workspace packages |
| 304 | // resolve to their real source paths (outside `node_modules`), so |
| 305 | // they stay watched and local HMR is unaffected. |
| 306 | exclude: ["**/node_modules/**"], |
| 307 | }, |
| 308 | output: withDceDefault(outputOptions), |
| 309 | }); |
| 310 | watcher.on("event", (event) => { |
| 311 | if (event.code === "ERROR") { |
| 312 | Queue.offerUnsafe(queue, { |
| 313 | _tag: "Error", |
| 314 | error: bundleErrorFromUnknown(event.error), |
| 315 | }); |
| 316 | } else if (event.code === "BUNDLE_END") { |
| 317 | // This must be called to avoid resource leaks. |
| 318 | event.result.close().catch(() => {}); |
| 319 | } |
| 320 | }); |
| 321 | return watcher; |
| 322 | }), |
| 323 | (watcher) => Effect.promise(() => watcher.close()), |
| 324 | ), |
| 325 | ).pipe( |
| 326 | Stream.mapEffect((event) => |
| 327 | Effect.gen(function* () { |
| 328 | if (event._tag !== "Success") { |
| 329 | return event; |
| 330 | } |
| 331 | return yield* bundleOutputFromRolldownOutputBundle(event.output).pipe( |
| 332 | Effect.map((output): BundleWatchEvent.Success => ({ |
| 333 | _tag: "Success", |
| 334 | output, |
| 335 | })), |
| 336 | Effect.catch((error) => |
| 337 | Effect.succeed<BundleWatchEvent.Error>({ |
| 338 | _tag: "Error", |
| 339 | error: bundleErrorFromUnknown(error), |
| 340 | }), |
| 341 | ), |
| 342 | ); |
| 343 | }), |
| 344 | ), |
| 345 | ); |
| 346 | |
| 347 | const ENTRY_PREFIX = "\0virtual:alchemy-entry:"; |
| 348 | // oxlint-disable-next-line no-control-regex |
| 349 | const ENTRY_REGEX = /^\0virtual:alchemy-entry:/; |
| 350 | /** |
| 351 | * Ids the `resolveId` hook looks at: the virtual entry ids themselves, plus |
| 352 | * bare package specifiers (not starting with `.`, `/` or `\0`) so an |
| 353 | * unresolvable import in a generated entry fails the build (below). |
| 354 | */ |
| 355 | // oxlint-disable-next-line no-control-regex |
| 356 | const ENTRY_OR_BARE_REGEX = /^(?:\0virtual:alchemy-entry:|[^./\0])/; |
| 357 | |
| 358 | export const virtualEntryPlugin = Effect.gen(function* () { |
| 359 | const path = yield* Path.Path; |
| 360 | |
| 361 | const normalizeInput = ( |
| 362 | input: rolldown.InputOption, |
| 363 | ): Record<string, string> => { |
| 364 | if (typeof input === "string") { |
| 365 | return { [path.parse(input).name || "index"]: input }; |
| 366 | } else if (Array.isArray(input)) { |
| 367 | return Object.fromEntries(input.map((p) => [path.parse(p).name, p])); |
| 368 | } else { |
| 369 | return input; |
| 370 | } |
| 371 | }; |
| 372 | |
| 373 | return (content: (importPath: string) => string) => { |
| 374 | const entries = new Map<string, string>(); |
| 375 | return { |
| 376 | name: "alchemy:virtual-entry", |
| 377 | options: { |
| 378 | order: "pre", |
| 379 | handler(inputOptions) { |
| 380 | const cwd = inputOptions.cwd ?? process.cwd(); |
| 381 | const input = normalizeInput(inputOptions.input ?? {}); |
| 382 | inputOptions.input = Object.fromEntries( |
| 383 | Object.entries(input).map(([name, p]) => { |
| 384 | const id = `${ENTRY_PREFIX}${name}`; |
| 385 | entries.set(id, path.resolve(cwd, p)); |
| 386 | return [name, id]; |
| 387 | }), |
| 388 | ); |
| 389 | }, |
| 390 | }, |
| 391 | resolveId: { |
| 392 | filter: { id: ENTRY_OR_BARE_REGEX }, |
| 393 | async handler(id, importer) { |
| 394 | if (ENTRY_REGEX.test(id)) { |
| 395 | return entries.has(id) ? { id } : null; |
| 396 | } |
| 397 | // A bare import made BY a generated entry. Rolldown only WARNS on |
| 398 | // an unresolved import and leaves it external, which for a |
| 399 | // generated entry means the deployed process dies at boot with |
| 400 | // `Cannot find module` — the class of bug the shim design |
| 401 | // (alchemy/Runtime/Bootstrap/*) exists to prevent. Make it a build |
| 402 | // error with the cause spelled out instead. Externals resolve to |
| 403 | // `{ external: true }`, not null, so they pass through. |
| 404 | if (importer === undefined || !ENTRY_REGEX.test(importer)) { |
| 405 | return null; |
| 406 | } |
| 407 | const resolved = await this.resolve(id, importer, { |
| 408 | skipSelf: true, |
| 409 | }); |
| 410 | if (resolved === null) { |
| 411 | const entry = entries.get(importer); |
| 412 | this.error( |
| 413 | new Error( |
| 414 | `The generated entry for ${entry} imports "${id}", which cannot be resolved from the project. ` + |
| 415 | "A generated entry may only import `alchemy/*` (resolvable from any project that depends on alchemy) and the entry itself; " + |
| 416 | "check that `alchemy` is installed in the project containing the entry.", |
| 417 | ), |
| 418 | ); |
| 419 | } |
| 420 | return resolved; |
| 421 | }, |
| 422 | }, |
| 423 | load: { |
| 424 | filter: { id: ENTRY_REGEX }, |
| 425 | handler(id) { |
| 426 | const entry = entries.get(id); |
| 427 | assert(entry !== undefined, `unknown alchemy entry: ${id}`); |
| 428 | return { |
| 429 | code: content(entry), |
| 430 | moduleType: "ts", |
| 431 | }; |
| 432 | }, |
| 433 | }, |
| 434 | } satisfies rolldown.Plugin; |
| 435 | }; |
| 436 | }); |
| 437 | |
| 438 | export function bundleOutputFromRolldownOutputBundle( |
| 439 | bundle: rolldown.OutputBundle, |
| 440 | ): Effect.Effect<BundleOutput, BundleError> { |
| 441 | const files = Object.values(bundle); |
| 442 | // These are sanity checks - with rolldown, the first file is always an entry chunk. |
| 443 | if (!files[0] || files[0].type !== "chunk" || !files[0].isEntry) { |
| 444 | return Effect.fail( |
| 445 | new BundleError({ |
| 446 | message: "Invalid bundle output", |
| 447 | }), |
| 448 | ); |
| 449 | } |
| 450 | return Effect.forEach( |
| 451 | files as [ |
| 452 | rolldown.OutputChunk, |
| 453 | ...(rolldown.OutputChunk | rolldown.OutputAsset)[], |
| 454 | ], |
| 455 | bundleFileFromOutputChunk, |
| 456 | ).pipe(Effect.flatMap(bundleOutputFromFiles)); |
| 457 | } |
| 458 | |
| 459 | /** |
| 460 | * Returns the built-in plugins appended after the user-provided plugin chain, |
| 461 | * configured by {@link BundleExtraOptions}. |
| 462 | * |
| 463 | * These run LAST so they see module ids that have already been resolved into |
| 464 | * `node_modules/<pkg>/...` by upstream resolver plugins such as |
| 465 | * `@alchemy.run/cloudflare-runtime/rolldown`. |
| 466 | */ |
| 467 | async function builtInPlugins( |
| 468 | extra?: BundleExtraOptions, |
| 469 | ): Promise<rolldown.RolldownPluginOption> { |
| 470 | return [ |
| 471 | extra?.bundleAnalyzer |
| 472 | ? await bundleAnalyzerPlugin( |
| 473 | extra.bundleAnalyzer === true ? {} : extra.bundleAnalyzer, |
| 474 | ) |
| 475 | : undefined, |
| 476 | extra?.pure !== false ? purePlugin(extra?.pure ?? {}) : undefined, |
| 477 | rawPlugin(), |
| 478 | ]; |
| 479 | } |
| 480 | |
| 481 | export function bundleErrorFromUnknown(error: unknown): BundleError { |
| 482 | const message = error instanceof Error ? error.message : String(error); |
| 483 | return new BundleError({ |
| 484 | message, |
| 485 | cause: error, |
| 486 | }); |
| 487 | } |
| 488 | |
| 489 | export function bundleOutputFromFiles( |
| 490 | files: [BundleFile, ...BundleFile[]], |
| 491 | ): Effect.Effect<BundleOutput> { |
| 492 | return Effect.map( |
| 493 | sha256Object( |
| 494 | files.map((file) => ({ |
| 495 | path: file.path, |
| 496 | hash: file.hash, |
| 497 | })), |
| 498 | ), |
| 499 | (hash) => ({ files, hash }), |
| 500 | ); |
| 501 | } |
| 502 | |
| 503 | function bundleFileFromOutputChunk( |
| 504 | chunk: rolldown.OutputChunk | rolldown.OutputAsset, |
| 505 | ): Effect.Effect<BundleFile> { |
| 506 | switch (chunk.type) { |
| 507 | case "chunk": |
| 508 | return Effect.map(sha256(chunk.code), (hash) => ({ |
| 509 | path: chunk.fileName, |
| 510 | content: chunk.code, |
| 511 | hash, |
| 512 | })); |
| 513 | case "asset": |
| 514 | return Effect.map(sha256(chunk.source), (hash) => ({ |
| 515 | path: chunk.fileName, |
| 516 | content: chunk.source, |
| 517 | hash, |
| 518 | })); |
| 519 | } |
| 520 | } |