1import * as Cloudflare from "@/Cloudflare";
2import { WorkerBundle } from "@/Cloudflare/Workers/Sources/Rolldown";
3import * as Alchemy from "@/index.ts";
4import * as Test from "@/Test/Alchemy";
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import { describe, expect, layer } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10import * as Schedule from "effect/Schedule";
11import * as Stream from "effect/Stream";
12import * as HttpClient from "effect/http/HttpClient";
13import RequireNodeBuiltinsWorker from "./fixtures/require-node-builtins/worker.ts";
14
15const decode = (content: string | Uint8Array<ArrayBufferLike>) =>
16 typeof content === "string"
17 ? content
18 : new TextDecoder().decode(content as Uint8Array);
19
20/**
21 * Write `files` (paths relative to a fresh temp directory) and return
22 * the temp directory's absolute path.
23 */
24const writeFixture = Effect.fn(function* (files: Record<string, string>) {
25 const fs = yield* FileSystem.FileSystem;
26 const path = yield* Path.Path;
27 const root = yield* fs.makeTempDirectory({
28 prefix: "alchemy-worker-bundle-",
29 });
30 for (const [name, content] of Object.entries(files)) {
31 const file = path.join(root, name);
32 yield* fs.makeDirectory(path.dirname(file), { recursive: true });
33 yield* fs.writeFileString(file, content);
34 }
35 return root;
36});
37
38layer(NodeServices.layer)("WorkerBundle", (it) => {
39 // Regression test for #880: CJS dependencies (like `pg`) that
40 // `require("events")` must have those requires converted into ESM imports
41 // of the workerd-provided Node builtins. Left unconverted, rolldown emits
42 // a throwing `require` fallback and the Worker fails Cloudflare startup
43 // validation with "Calling `require` for \"events\" in an environment
44 // that doesn't expose the `require` function".
45 it.effect(
46 "converts CJS requires of Node builtins into ESM imports under nodejs_compat",
47 () =>
48 Effect.gen(function* () {
49 const path = yield* Path.Path;
50 const root = yield* writeFixture({
51 // Anchors findCwdForBundle so bundle output stays in the temp dir.
52 "package.json": "{}",
53 "dep.cjs": [
54 `const { EventEmitter } = require("events");`,
55 `const util = require("node:util");`,
56 `module.exports = {`,
57 ` Thing: class extends EventEmitter {`,
58 ` describe() { return util.format("thing %s", "one"); }`,
59 ` },`,
60 `};`,
61 ].join("\n"),
62 "worker.mjs": [
63 `import { Thing } from "./dep.cjs";`,
64 `export default {`,
65 ` fetch: () => new Response(new Thing().describe()),`,
66 `};`,
67 ].join("\n"),
68 });
69
70 const bundler = yield* WorkerBundle;
71 const output = yield* bundler.build({
72 id: "worker-bundle-require-events",
73 main: path.join(root, "worker.mjs"),
74 compatibility: { date: "2026-03-17", flags: ["nodejs_compat"] },
75 entry: { kind: "external" },
76 stack: { name: "worker-bundle-test", stage: "test" },
77 extraOptions: undefined,
78 });
79
80 const entry = decode(output.files[0].content);
81 // The CJS requires were rewritten to imports of the builtins.
82 expect(entry).toMatch(/from\s*["'](?:node:)?events["']/);
83 expect(entry).toMatch(/from\s*["']node:util["']/);
84 // No chunk retains rolldown's throwing `require` fallback.
85 for (const file of output.files) {
86 if (!file.path.endsWith(".js")) continue;
87 expect(decode(file.content)).not.toContain("Calling `require` for");
88 }
89 }),
90 {
91 tags: [
92 "unit",
93 "provider:cloudflare",
94 "provider:cloudflare:worker",
95 "local",
96 ],
97 },
98 );
99 it.effect(
100 "applies input aliases before Node compatibility for imports and requires",
101 () =>
102 Effect.gen(function* () {
103 const path = yield* Path.Path;
104 const root = yield* writeFixture({
105 "package.json": "{}",
106 "stub.mjs": `export const createRequire = () => "ALIASED_MODULE"; export default "ALIASED_PACKAGE";`,
107 "worker.mjs": `
108 import { createRequire } from "module";
109 import { createRequire as nodeCreateRequire } from "node:module";
110 import ts from "typescript";
111 import execa from "execa";
112 import readable from "readable-stream";
113 const bare = require("module");
114 const prefixed = require("node:module");
115 export default { fetch: () => Response.json([
116 createRequire(undefined), nodeCreateRequire(undefined),
117 bare.createRequire(undefined), prefixed.createRequire(undefined),
118 ts, execa, readable, BUILD_MARKER,
119 ]) };
120 `,
121 });
122 const bundler = yield* WorkerBundle;
123 const output = yield* bundler.build({
124 id: "worker-bundle-alias",
125 main: path.join(root, "worker.mjs"),
126 compatibility: { date: "2025-04-01", flags: ["nodejs_compat"] },
127 entry: { kind: "external" },
128 stack: { name: "worker-bundle-test", stage: "test" },
129 extraOptions: {
130 input: {
131 resolve: {
132 alias: Object.fromEntries(
133 [
134 "module",
135 "node:module",
136 "typescript",
137 "execa",
138 "readable-stream",
139 ].map((name) => [name, path.join(root, "stub.mjs")]),
140 ),
141 },
142 transform: {
143 define: {
144 BUILD_MARKER: JSON.stringify("INPUT_OPTIONS_MARKER"),
145 },
146 },
147 },
148 },
149 });
150 const entry = decode(output.files[0].content);
151 expect(entry).toContain("ALIASED_MODULE");
152 expect(entry).toContain("ALIASED_PACKAGE");
153 expect(entry).toContain("INPUT_OPTIONS_MARKER");
154 expect(entry).not.toMatch(/from\s*["'](?:node:)?module["']/);
155 expect(entry).not.toContain("Calling `require` for");
156 }),
157 {
158 tags: [
159 "unit",
160 "provider:cloudflare",
161 "provider:cloudflare:worker",
162 "local",
163 ],
164 },
165 );
166 it.effect(
167 "preserves native alias matching, fallback arrays, and disabled modules",
168 () =>
169 Effect.gen(function* () {
170 const path = yield* Path.Path;
171 const root = yield* writeFixture({
172 "package.json": "{}",
173 "stub.mjs": `export default "NATIVE_ALIAS_MARKER";`,
174 "stubs/promises.mjs": `export default "SUBPATH_ALIAS_MARKER";`,
175 "worker.mjs": `
176 import module from "module";
177 import promises from "node:fs/promises";
178 import ignored from "node:inspector";
179 import { createRequire } from "node:module";
180 export default { fetch: () => Response.json([module, promises, ignored, typeof createRequire]) };
181 `,
182 });
183 const bundler = yield* WorkerBundle;
184 for (const fsAlias of ["node:fs", "node:fs/*"]) {
185 const output = yield* bundler.build({
186 id: "worker-bundle-native-alias",
187 main: path.join(root, "worker.mjs"),
188 compatibility: { date: "2025-04-01", flags: ["nodejs_compat"] },
189 entry: { kind: "external" },
190 stack: { name: "worker-bundle-test", stage: "test" },
191 extraOptions: {
192 input: {
193 resolve: {
194 alias: {
195 module$: [
196 path.join(root, "missing.mjs"),
197 path.join(root, "stub.mjs"),
198 ],
199 [fsAlias]: path.join(
200 root,
201 fsAlias.includes("*") ? "stubs/*" : "stubs",
202 ),
203 "node:inspector": false,
204 "node:module": [],
205 },
206 },
207 },
208 },
209 });
210 const entry = decode(output.files[0].content);
211 expect(entry).toContain("NATIVE_ALIAS_MARKER");
212 expect(entry).toContain("SUBPATH_ALIAS_MARKER");
213 expect(entry).toMatch(/from\s*["']node:module["']/);
214 expect(entry).not.toMatch(
215 /from\s*["'](?:module|node:fs\/promises|node:inspector)["']/,
216 );
217 }
218 }),
219 {
220 tags: [
221 "unit",
222 "provider:cloudflare",
223 "provider:cloudflare:worker",
224 "local",
225 ],
226 },
227 );
228
229 it.effect(
230 "uses input plugins and aliases when watching",
231 () =>
232 Effect.gen(function* () {
233 const path = yield* Path.Path;
234 const root = yield* writeFixture({
235 "package.json": "{}",
236 "stub.mjs": `export const createRequire = () => "WATCH_ALIAS_MARKER";`,
237 "worker.mjs": `import { createRequire } from "node:module"; import value from "virtual:custom"; export default { fetch: () => new Response(createRequire(undefined) + value) };`,
238 });
239 const bundler = yield* WorkerBundle;
240 const events = yield* bundler
241 .watch({
242 id: "worker-bundle-watch-alias",
243 main: path.join(root, "worker.mjs"),
244 compatibility: { date: "2025-04-01", flags: ["nodejs_compat"] },
245 entry: { kind: "external" },
246 stack: { name: "worker-bundle-test", stage: "test" },
247 extraOptions: {
248 input: {
249 resolve: {
250 alias: { "node:module": path.join(root, "stub.mjs") },
251 },
252 plugins: [
253 {
254 name: "custom-input-plugin",
255 resolveId(id) {
256 if (id === "virtual:custom") return "\u0000custom";
257 },
258 load(id) {
259 if (id === "\u0000custom")
260 return `export default "INPUT_PLUGIN_MARKER";`;
261 },
262 },
263 ],
264 },
265 },
266 })
267 .pipe(
268 Stream.filter((event) => event._tag !== "Start"),
269 Stream.take(1),
270 Stream.runCollect,
271 );
272 const event = events[0]!;
273 expect(event._tag).toBe("Success");
274 if (event._tag === "Success") {
275 const entry = decode(event.output.files[0].content);
276 expect(entry).toContain("WATCH_ALIAS_MARKER");
277 expect(entry).toContain("INPUT_PLUGIN_MARKER");
278 expect(entry).not.toMatch(/from\s*["']node:module["']/);
279 }
280 }),
281 {
282 tags: [
283 "unit",
284 "provider:cloudflare",
285 "provider:cloudflare:worker",
286 "local",
287 ],
288 },
289 );
290 // `?worker` imports (WorkerModulePlugin.ts): the target is bundled into
291 // one self-contained module and imported as a STRING, ready for a
292 // `WorkerLoader`. Nested `?worker` imports inside the target work too.
293 it.effect(
294 "bundles a ?worker import into a string, nested imports included",
295 () =>
296 Effect.gen(function* () {
297 const path = yield* Path.Path;
298 const root = yield* writeFixture({
299 "package.json": "{}",
300 "stub.mjs": `export const createRequire = () => "NESTED_ALIAS_MARKER";`,
301 "grandchild.mjs": [
302 `import { createRequire } from "node:module";`,
303 `export default { fetch: () => new Response("GRANDCHILD_MARKER" + createRequire(undefined)) };`,
304 ].join("\n"),
305 "child.mjs": [
306 `import grandchild from "./grandchild.mjs?worker";`,
307 `export const MARKER = "CHILD_MARKER_";`,
308 `export default { fetch: () => new Response(MARKER + grandchild) };`,
309 ].join("\n"),
310 "worker.mjs": [
311 `import child from "./child.mjs?worker";`,
312 `export default {`,
313 ` fetch: () => new Response(typeof child + ":" + child.length),`,
314 `};`,
315 ].join("\n"),
316 });
317 const bundler = yield* WorkerBundle;
318 const output = yield* bundler.build({
319 id: "worker-bundle-worker-module",
320 main: path.join(root, "worker.mjs"),
321 compatibility: { date: "2026-03-17", flags: ["nodejs_compat"] },
322 entry: { kind: "external" },
323 stack: { name: "worker-bundle-test", stage: "test" },
324 extraOptions: {
325 input: {
326 resolve: {
327 alias: { "node:module": path.join(root, "stub.mjs") },
328 },
329 },
330 },
331 });
332 const entry = decode(output.files[0].content);
333 // The child's code is embedded as a JSON string literal …
334 expect(entry).toContain("CHILD_MARKER_");
335 // … which itself embeds the grandchild's code (one more level).
336 expect(entry).toContain("GRANDCHILD_MARKER");
337 expect(entry).toContain("NESTED_ALIAS_MARKER");
338 // `child` is a string in the parent, not a module namespace.
339 expect(entry).not.toContain("import child from");
340 }),
341 {
342 tags: [
343 "unit",
344 "provider:cloudflare",
345 "provider:cloudflare:worker",
346 "local",
347 ],
348 },
349 );
350});
351
352describe(
353 "integration",
354 { tags: ["provider:cloudflare", "provider:cloudflare:worker", "live"] },
355 () => {
356 const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
357 providers: Cloudflare.providers(),
358 });
359
360 const Stack = Alchemy.Stack(
361 "NodeBuiltinRequireTestStack",
362 { providers: Cloudflare.providers(), state: Cloudflare.state() },
363 Effect.gen(function* () {
364 const worker = yield* RequireNodeBuiltinsWorker;
365 const path = yield* Path.Path;
366 const stub = yield* path
367 .fromFileUrl(
368 new URL("./fixtures/module-alias/stub.mjs", import.meta.url),
369 )
370 .pipe(Effect.orDie);
371 const aliased = yield* Cloudflare.Worker("AliasedWorker", {
372 main: new URL("./fixtures/module-alias/worker.mjs", import.meta.url)
373 .href,
374 compatibility: {
375 date: "2025-04-01",
376 flags: ["nodejs_compat", "nodejs_compat_populate_process_env"],
377 },
378 build: {
379 input: {
380 resolve: {
381 alias: Object.fromEntries(
382 [
383 "module",
384 "node:module",
385 "typescript",
386 "execa",
387 "readable-stream",
388 ].map((name) => [name, stub]),
389 ),
390 },
391 },
392 },
393 });
394 return {
395 url: worker.url.as<string>(),
396 aliasUrl: aliased.url.as<string>(),
397 };
398 }),
399 );
400
401 const stack = beforeAll(destroy(Stack).pipe(Effect.andThen(deploy(Stack))));
402 afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack));
403
404 test(
405 "worker aliases override Node builtins without breaking other requires",
406 Effect.gen(function* () {
407 const { aliasUrl } = yield* stack;
408 const client = yield* HttpClient.HttpClient;
409 const body = yield* client.get(aliasUrl).pipe(
410 Effect.flatMap((response) => response.json),
411 Effect.retry({ schedule: Schedule.spaced("1 second"), times: 8 }),
412 );
413 expect(body).toEqual({
414 bare: "aliased:module",
415 prefixed: "aliased:module",
416 cjsBare: "aliased:module",
417 cjsPrefixed: "aliased:module",
418 typescript: "aliased:package",
419 execa: "aliased:package",
420 readable: "aliased:package",
421 event: "builtin:ok",
422 });
423 }),
424 );
425
426 // Regression test for #880: the deploy itself is the primary assertion —
427 // a bundle that keeps rolldown's throwing `require` fallback for
428 // `require("events")` fails Cloudflare startup validation at upload time
429 // and `beforeAll(deploy(Stack))` fails the suite.
430 test(
431 "worker with a CJS dep requiring node builtins deploys and serves",
432 Effect.gen(function* () {
433 const { url } = yield* stack;
434 const client = yield* HttpClient.HttpClient;
435
436 const get = client.get(`${url}/?value=pong`).pipe(
437 Effect.flatMap((res) => res.text),
438 Effect.retry({
439 schedule: Schedule.exponential("500 millis"),
440 times: 5,
441 }),
442 Effect.orDie,
443 );
444
445 // Fresh workers.dev URLs can serve placeholder 200s while propagating,
446 // so anchor the readiness poll on the fixture's marker, not the status.
447 const body = yield* get.pipe(
448 Effect.repeat({
449 schedule: Schedule.exponential("500 millis"),
450 until: (b) => b.includes("require-node-builtins:"),
451 times: 10,
452 }),
453 );
454
455 // The converted builtins really work at runtime: the EventEmitter
456 // round-tripped the request's value and util.format made the marker.
457 expect(body).toBe("require-node-builtins:pong");
458 }),
459 { timeout: 180_000 },
460 );
461 },
462);