1import * as Effect from "effect/Effect";
2import { dotAlchemyDirectory } from "../../../AlchemyContext.ts";
3import * as FileSystem from "effect/FileSystem";
4import { flow } from "effect/Function";
5import type * as Path from "effect/Path";
6import * as Stream from "effect/Stream";
7import path from "pathe";
8import type * as rolldown from "rolldown";
9import * as Artifacts from "../../../Artifacts.ts";
10import * as Bundle from "../../../Bundle/Bundle.ts";
11import { findCwdForBundle, resolveMainPath } from "../../../Bundle/TempRoot.ts";
12import { isWorkflowExport } from "../../Workflows/Workflow.ts";
13import { isDurableObjectExport } from "../DurableObject.ts";
14import type { SourceContext, SourceProvider } from "../Source.ts";
15import { bundleSource } from "./shared.ts";
16import { workerModulePlugin } from "./WorkerModulePlugin.ts";
17import type { WorkerExport } from "../WorkerRuntimeContext.ts";
18import type { SqlMigrationSnapshot } from "../SqlMigrationsRuntime.ts";
19
20/**
21 * Bundler options for a Worker: Rolldown input/output overrides and
22 * {@link Bundle.BundleExtraOptions}, merged over Alchemy's defaults.
23 */
24export interface WorkerBuildOptions extends Bundle.BundleConfig {
25 /**
26 * Rolldown output options merged over Alchemy's defaults. Use this to
27 * control chunking (`codeSplitting`), minification, etc.
28 */
29 output?: rolldown.OutputOptions;
30 /**
31 * Forwarded to rolldown's `preserveEntrySignatures` input option. Some
32 * `output.codeSplitting` configurations require relaxing it (e.g.
33 * `includeDependenciesRecursively: false` needs `"allow-extension"`).
34 * Workers must keep their entry exports, so never pass `false`.
35 */
36 preserveEntrySignatures?: rolldown.InputOptions["preserveEntrySignatures"];
37}
38
39export interface WorkerBundleOptions {
40 id: string;
41 main: string;
42 compatibility: {
43 date: string;
44 flags: string[];
45 };
46 entry:
47 | {
48 kind: "external";
49 }
50 | {
51 kind: "effect";
52 exports: Record<string, WorkerExport>;
53 };
54 stack: { name: string; stage: string };
55 extraOptions: WorkerBuildOptions | undefined;
56}
57
58/**
59 * Rebuild any `builtin:esm-external-require` plugin instance with OUR copy of
60 * rolldown.
61 *
62 * A separately installed or bundled `@alchemy.run/cloudflare-runtime/rolldown`
63 * can resolve a physically different copy of rolldown than the one Alchemy
64 * invokes. Builtin plugins — here `builtin:esm-external-require`, which
65 * rewrites CJS `require`s of Node builtins into ESM imports under
66 * `nodejs_compat` — are `BuiltinPlugin` class instances that rolldown
67 * recognizes with an `instanceof` check. An instance constructed by the
68 * foreign rolldown copy fails that check and is silently treated as a
69 * hookless JS plugin, so CJS requires keep rolldown's throwing `require`
70 * shim and the Worker fails Cloudflare startup validation (#880).
71 * Reconstructing the builtin from its `_options` with the rolldown copy we
72 * actually invoke makes the identity check pass regardless of which copy the
73 * plugin package resolved.
74 */
75const configureCloudflarePlugins = (
76 plugins: Array<rolldown.Plugin | null>,
77 esmExternalRequirePlugin: (typeof import("rolldown/plugins"))["esmExternalRequirePlugin"],
78 aliases: NonNullable<rolldown.InputOptions["resolve"]>["alias"],
79): Array<rolldown.Plugin | null> => {
80 const aliasKeys = Object.entries(aliases ?? {})
81 .filter(([, value]) => !Array.isArray(value) || value.length > 0)
82 .map(([key]) => key);
83 const isAliased = (id: string) =>
84 aliasKeys.some((key) => {
85 if (key.endsWith("$")) return id === key.slice(0, -1);
86 const wildcard = key.indexOf("*");
87 if (wildcard !== -1) {
88 const prefix = key.slice(0, wildcard);
89 const suffix = key.slice(wildcard + 1);
90 return (
91 id.length >= prefix.length + suffix.length &&
92 id.startsWith(prefix) &&
93 id.endsWith(suffix)
94 );
95 }
96 return (
97 id === key || id.startsWith(`${key}/`) || id.startsWith(`${key}\\`)
98 );
99 });
100 return plugins.map((plugin) => {
101 if (
102 typeof plugin === "object" &&
103 plugin !== null &&
104 "name" in plugin &&
105 plugin.name === "builtin:esm-external-require" &&
106 "_options" in plugin
107 ) {
108 const options = plugin._options as Parameters<
109 typeof esmExternalRequirePlugin
110 >[0];
111 return esmExternalRequirePlugin(
112 options && {
113 ...options,
114 external: options.external.filter(
115 (id) => typeof id !== "string" || !isAliased(id),
116 ),
117 },
118 ) as rolldown.Plugin;
119 }
120 // Let Rolldown resolve user aliases before compatibility polyfills.
121 if (plugin?.resolveId && aliasKeys.length > 0) {
122 const hook = plugin.resolveId;
123 const handler = typeof hook === "function" ? hook : hook.handler;
124 return {
125 ...plugin,
126 resolveId: {
127 ...(typeof hook === "function" ? {} : hook),
128 handler(source, importer, options) {
129 if (isAliased(source)) return null;
130 return handler.call(this, source, importer, options);
131 },
132 },
133 };
134 }
135 return plugin;
136 });
137};
138
139export const WorkerBundle = Effect.gen(function* () {
140 const context = yield* Effect.context<FileSystem.FileSystem | Path.Path>();
141 const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin;
142 const dotAlchemy = yield* dotAlchemyDirectory;
143
144 const makeOptions = Effect.fn(function* (options: WorkerBundleOptions) {
145 // Loaded lazily so importing the Cloudflare provider (or the CLI, whose
146 // command tree reaches this module) never loads rolldown's native
147 // binding — only actually bundling a Worker does (#562).
148 const [{ default: cloudflareRolldown }, { esmExternalRequirePlugin }] =
149 yield* Effect.promise(() =>
150 Promise.all([
151 import("@alchemy.run/cloudflare-runtime/rolldown"),
152 import("rolldown/plugins"),
153 ]),
154 );
155 const realMain = yield* sanitizeMain(options.main);
156 const cwd = yield* findCwdForBundle(realMain).pipe(
157 Effect.mapError(
158 (cause) =>
159 new Bundle.BundleError({
160 message: `Failed to find cwd for bundle: ${realMain}`,
161 cause,
162 }),
163 ),
164 Effect.provide(context),
165 );
166 // The Cloudflare-flavored plugin set, shared by the parent build and
167 // by every nested `?worker` module build (see WorkerModulePlugin.ts).
168 const overrides = options.extraOptions?.input;
169 const cloudflarePlugins = () =>
170 configureCloudflarePlugins(
171 cloudflareRolldown({
172 compatibilityDate: options.compatibility.date,
173 compatibilityFlags: options.compatibility.flags,
174 }),
175 esmExternalRequirePlugin,
176 overrides?.resolve?.alias,
177 );
178 const workerModules: rolldown.Plugin = workerModulePlugin({
179 loadRolldown: () => import("rolldown"),
180 nested: async () => ({
181 external: ["lightningcss", "fsevents"],
182 cwd,
183 ...overrides,
184 plugins: [overrides?.plugins, cloudflarePlugins(), workerModules],
185 checks: {
186 unresolvedImport: false,
187 ineffectiveDynamicImport: false,
188 ...overrides?.checks,
189 },
190 }),
191 });
192 const inputOptions: rolldown.InputOptions = {
193 preserveEntrySignatures: options.extraOptions?.preserveEntrySignatures,
194 // Forever-devtool native modules that vite/chokidar reference behind
195 // runtime guards. Rolldown resolves before tree-shaking, so the dead
196 // `require('../pkg')` (lightningcss < 1.32) and `require('fsevents')`
197 // (darwin-only) trip [UNRESOLVED_IMPORT] before DCE can prune them.
198 // See rolldown/tsdown#212.
199 external: ["lightningcss", "fsevents"],
200 cwd,
201 ...overrides,
202 input: realMain,
203 plugins: [
204 overrides?.plugins,
205 cloudflarePlugins(),
206 workerModules,
207 options.entry.kind === "effect"
208 ? [
209 virtualEntryPlugin(
210 makeEffectVirtualEntry(options.entry.exports, options.stack),
211 ),
212 ]
213 : undefined,
214 ],
215 checks: {
216 // Suppress unresolved import warnings for unrelated AWS packages
217 unresolvedImport: false,
218 // The shared platform boundary keeps guarded Node/Bun adapter imports.
219 ineffectiveDynamicImport: false,
220 ...overrides?.checks,
221 },
222 };
223 const outputOptions: rolldown.OutputOptions = {
224 format: "esm",
225 sourcemap: "hidden",
226 minify: true,
227 keepNames: true,
228 // Rolldown's default chunking can split top-level initializer modules
229 // (e.g. Drizzle `pgTable` schemas) away from the classes they read,
230 // and workerd then evaluates a reader before its imported binding is
231 // initialized — the script fails Cloudflare startup validation with
232 // `ScriptStartupError: Cannot access '<minified>' before
233 // initialization` (#749). `strictExecutionOrder` wraps cross-chunk
234 // modules so evaluation follows ESM semantics regardless of how the
235 // graph was chunked. See DrizzleSchemaChunks.test.ts.
236 strictExecutionOrder: true,
237 dir: path.join(dotAlchemy, "bundles", options.id),
238 ...options.extraOptions?.output,
239 };
240 return { inputOptions, outputOptions, extraOptions: options.extraOptions };
241 });
242
243 const sanitizeMain = (main: string) =>
244 resolveMainPath(main).pipe(
245 Effect.mapError(
246 (cause) =>
247 new Bundle.BundleError({
248 message: `Failed to find real path for bundle: ${main}`,
249 cause,
250 }),
251 ),
252 Effect.provide(context),
253 );
254
255 return {
256 build: flow(
257 makeOptions,
258 Effect.flatMap((resolved) =>
259 Bundle.build(
260 resolved.inputOptions,
261 resolved.outputOptions,
262 resolved.extraOptions,
263 ),
264 ),
265 ),
266 watch: flow(
267 makeOptions,
268 Stream.fromEffect,
269 Stream.flatMap((resolved) =>
270 Bundle.watch(
271 resolved.inputOptions,
272 resolved.outputOptions,
273 resolved.extraOptions,
274 ),
275 ),
276 ),
277 };
278});
279
280export const makeEffectVirtualEntry = (
281 exports: Record<string, WorkerExport>,
282 stack: { name: string; stage: string },
283) => {
284 const doClasses: string[] = [];
285 const wfClasses: string[] = [];
286 const migrations: Record<string, SqlMigrationSnapshot> = {};
287 for (const [className, entry] of Object.entries(exports)) {
288 if (isDurableObjectExport(entry)) {
289 doClasses.push(className);
290 } else if (isWorkflowExport(entry)) {
291 wfClasses.push(className);
292 } else if (entry.kind === "sqlMigrations") {
293 migrations[className] = entry.snapshot;
294 }
295 }
296 const hasMigrations = Object.keys(migrations).length > 0;
297 const hasDoClasses = doClasses.length > 0;
298 const hasWfClasses = wfClasses.length > 0;
299 return (importPath: string) => `
300import * as Effect from "effect/Effect";
301
302import { env, DurableObject, WorkerEntrypoint${hasWfClasses ? ", WorkflowEntrypoint" : ""} } from "cloudflare:workers";
303import { makeDurableObjectBridge, makeWorkerBridge${hasWfClasses ? ", makeWorkflowBridge" : ""}${hasMigrations ? ", withSqlMigrations" : ""} } from "alchemy/Cloudflare/Bridge";
304import { makeEntrypointLayer } from "alchemy/Runtime";
305
306import entrypoint from ${JSON.stringify(importPath)};
307
308const meta = {
309 entrypoint: ${hasMigrations ? `withSqlMigrations(entrypoint, ${JSON.stringify(migrations)})` : "entrypoint"},
310 stack: {
311 name: ${JSON.stringify(stack.name)},
312 stage: ${JSON.stringify(stack.stage)},
313 },
314};
315
316export default makeWorkerBridge(WorkerEntrypoint, meta);
317
318// export class proxy stubs for Durable Objects and Workflows
319${[
320 ...(hasDoClasses
321 ? [
322 "const DurableObjectBridge = makeDurableObjectBridge(DurableObject, meta);",
323 ...doClasses.map(
324 (id) => `export class ${id} extends DurableObjectBridge("${id}") {}`,
325 ),
326 ]
327 : []),
328 ...(hasWfClasses
329 ? [
330 "const WorkflowBridgeFn = makeWorkflowBridge(WorkflowEntrypoint, meta);",
331 ...wfClasses.map(
332 (id) => `export class ${id} extends WorkflowBridgeFn("${id}") {}`,
333 ),
334 ]
335 : []),
336].join("\n")}
337`;
338};
339
340/**
341 * The default source provider: bundle `props.main` with rolldown.
342 *
343 * `hash()` deliberately builds — recomputing "without building" is
344 * impossible for rolldown without a source-tree memo hash, and today's
345 * semantics accept that. The build routes through `Artifacts.cached`
346 * under the same key as `build()`, so a diff that had to build shares
347 * its output with the reconcile in the same run.
348 */
349export const makeRolldownSource = (options: {
350 main: string;
351}): SourceProvider => {
352 const bundleOptions = (ctx: SourceContext): WorkerBundleOptions => ({
353 id: ctx.id,
354 main: options.main,
355 compatibility: ctx.compatibility,
356 entry: ctx.entry,
357 stack: ctx.stack,
358 extraOptions: ctx.extraOptions,
359 });
360 return bundleSource({
361 build: (ctx) =>
362 Effect.gen(function* () {
363 const bundler = yield* WorkerBundle;
364 return yield* bundler.build(bundleOptions(ctx));
365 }).pipe(Artifacts.cached("build")),
366 watch: (ctx) =>
367 Effect.gen(function* () {
368 const bundler = yield* WorkerBundle;
369 return bundler.watch(bundleOptions(ctx));
370 }),
371 });
372};