1import * as Effect from "effect/Effect";
2import * as Path from "effect/Path";
3import * as Queue from "effect/Queue";
4import * as Schema from "effect/Schema";
5import * as Stream from "effect/Stream";
6import assert from "node:assert";
7import type * as rolldown from "rolldown";
8import { sha256, sha256Object } from "../Util/sha256.ts";
9import {
10 bundleAnalyzerPlugin,
11 type BundleAnalyzerPluginOptions,
12} from "./BundleAnalyzerPlugin.ts";
13import { purePlugin, type PurePluginOptions } from "./PurePlugin.ts";
14import { 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 */
31export const BUN_CONDITION_NAMES: readonly string[] = [
32 "bun",
33 "module",
34 "default",
35];
36export 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 */
48const 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 */
54export 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 */
93export 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
104export 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
116export interface BundleFile {
117 readonly path: string;
118 readonly content: string | Uint8Array<ArrayBufferLike>;
119 readonly hash: string;
120}
121
122export class BundleError extends Schema.TaggedError<BundleError>()(
123 "BundleError",
124 {
125 message: Schema.String,
126 cause: Schema.optional(Schema.Defect({ includeStack: true })),
127 },
128) {}
129
130export type BundleWatchEvent =
131 | BundleWatchEvent.Start
132 | BundleWatchEvent.Success
133 | BundleWatchEvent.Error;
134
135export 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 */
164const 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 */
178const 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 */
191const 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 */
214const 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 */
227export 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 */
261export 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
347const ENTRY_PREFIX = "\0virtual:alchemy-entry:";
348// oxlint-disable-next-line no-control-regex
349const 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
356const ENTRY_OR_BARE_REGEX = /^(?:\0virtual:alchemy-entry:|[^./\0])/;
357
358export 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
438export 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 */
467async 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
481export 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
489export 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
503function 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}