1import { virtualEntryPlugin } from "@/Bundle/Bundle";
2import * as Cloudflare from "@/Cloudflare";
3import * as Bridge from "@/Cloudflare/Bridge";
4import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
5import { CloudflareEnvironment as RuntimeCloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironmentService";
6import * as WorkerRuntime from "@/Cloudflare/Workers/WorkerRuntime";
7import * as WorkflowRuntime from "@/Cloudflare/Workflows/WorkflowRuntime";
8import { Stack } from "@/Stack";
9import { StackContext } from "@/StackContext";
10import * as Telemetry from "@/Telemetry";
11import * as TelemetryRuntime from "@/TelemetryRuntime";
12import { makeEffectVirtualEntry } from "@/Cloudflare/Workers/Sources/Rolldown";
13import * as Test from "@/Test/Alchemy";
14import * as NodeServices from "@effect/platform-node/NodeServices";
15import { describe, expect, layer } from "alchemy-test";
16import * as Effect from "effect/Effect";
17import * as Path from "effect/Path";
18import * as HttpClientRequest from "effect/http/HttpClientRequest";
19import { requestWorker } from "../Utils/WorkerRequest.ts";
20import RuntimeEntryWorker from "./fixtures/runtime-entry/worker.ts";
21
22const plannerSources = [
23 "src/Auth/",
24 "src/Bundle/",
25 "src/Local/",
26 "src/Resource.ts",
27 "src/Stack.ts",
28 "src/Telemetry.ts",
29 "src/Cloudflare/CloudflareEnvironment.ts",
30 "src/Cloudflare/D1/LocalD1Gateway.ts",
31 "src/Cloudflare/Local",
32 "src/Cloudflare/SecretsStore/LocalSecretsStoreGateway.ts",
33 "src/Cloudflare/Workers/Worker.ts",
34 "src/Cloudflare/Workflows/Workflow.ts",
35 "src/Cloudflare/Containers/ContainerBundle.ts",
36 "src/Cloudflare/Website/Vite.ts",
37 "src/Cloudflare/Workers/LocalWorkerProvider.ts",
38 "src/Cloudflare/Workers/Sources/",
39 "src/Cloudflare/Workers/ViteChild",
40 "src/Cloudflare/Workers/WorkerProvider.ts",
41];
42
43const toolchainPackages = ["esbuild", "rolldown", "vite", "workerd"];
44
45const packageOf = (id: string) => {
46 const segments = id.split(/[\\/]node_modules[\\/]/);
47 if (segments.length < 2) return undefined;
48 const rest = segments[segments.length - 1]!.split(/[\\/]/);
49 return rest[0]!.startsWith("@") ? `${rest[0]}/${rest[1]}` : rest[0];
50};
51
52const bundleWorkerEntry = Effect.fn(function* (
53 entry: string,
54 generated = false,
55) {
56 const path = yield* Path.Path;
57 const packageRoot = yield* path.fromFileUrl(
58 new URL("../../../", import.meta.url),
59 );
60 const { default: cloudflare } = yield* Effect.promise(
61 () => import("@alchemy.run/cloudflare-runtime/rolldown"),
62 );
63 const { rolldown } = yield* Effect.promise(() => import("rolldown"));
64 const virtualEntry = yield* virtualEntryPlugin;
65 const graph = new Map<
66 string,
67 { isEntry: boolean; importedIds: readonly string[] }
68 >();
69 return yield* Effect.acquireUseRelease(
70 Effect.promise(() =>
71 rolldown({
72 input: path.join(packageRoot, entry),
73 cwd: packageRoot,
74 external: [
75 "lightningcss",
76 "fsevents",
77 // Isolate dependencies added by the generator from the application's imports.
78 ...(generated ? [path.join(packageRoot, entry)] : []),
79 ],
80 plugins: [
81 cloudflare({
82 compatibilityDate: "2025-04-01",
83 compatibilityFlags: ["nodejs_compat"],
84 }),
85 generated
86 ? virtualEntry(
87 makeEffectVirtualEntry(
88 {},
89 { name: "runtime-entry", stage: "test" },
90 ),
91 )
92 : undefined,
93 {
94 name: "collect-modules",
95 moduleParsed(info) {
96 graph.set(info.id, {
97 isEntry: info.isEntry,
98 importedIds: [
99 ...info.importedIds,
100 ...info.dynamicallyImportedIds,
101 ],
102 });
103 },
104 },
105 ],
106 checks: { unresolvedImport: false, ineffectiveDynamicImport: false },
107 logLevel: "silent",
108 }),
109 ),
110 (bundle) =>
111 Effect.gen(function* () {
112 const { output } = yield* Effect.promise(() =>
113 bundle.generate({ format: "esm" }),
114 );
115 const chunks = new Map(
116 output
117 .filter((item) => item.type === "chunk")
118 .map((chunk) => [chunk.fileName, chunk] as const),
119 );
120 const entryChunk = [...chunks.values()].find((chunk) => chunk.isEntry)!;
121 // Dynamic imports must not conceal deployment dependencies either.
122 const reachableChunks = new Set<string>();
123 const chunkQueue = [entryChunk.fileName];
124 for (
125 let f = chunkQueue.shift();
126 f !== undefined;
127 f = chunkQueue.shift()
128 ) {
129 if (reachableChunks.has(f) || !chunks.has(f)) continue;
130 reachableChunks.add(f);
131 chunkQueue.push(
132 ...chunks.get(f)!.imports,
133 ...chunks.get(f)!.dynamicImports,
134 );
135 }
136 const code = [...reachableChunks]
137 .map((f) => chunks.get(f)!.code)
138 .join("\n");
139 const modules = new Set<string>();
140 const queue = [...graph].filter(([, m]) => m.isEntry).map(([id]) => id);
141 for (let id = queue.shift(); id !== undefined; id = queue.shift()) {
142 if (modules.has(id)) continue;
143 modules.add(id);
144 queue.push(...(graph.get(id)?.importedIds ?? []));
145 }
146 const sources = [...modules]
147 .filter((id) => id.startsWith(path.join(packageRoot, "src")))
148 .map((id) => path.relative(packageRoot, id).replaceAll("\\", "/"));
149 const packages = new Set(
150 [...modules].map(packageOf).filter((name) => name !== undefined),
151 );
152 return { code, exports: entryChunk.exports, sources, packages };
153 }),
154 (bundle) => Effect.promise(() => bundle.close()),
155 );
156});
157
158const isPlannerSource = (source: string) =>
159 plannerSources.some((prefix) => source.startsWith(prefix));
160
161layer(NodeServices.layer)(
162 "alchemy/Cloudflare/Bridge (runtime-only entry)",
163 (it) => {
164 it.effect(
165 "exports the bridge factories the generated Worker entry imports",
166 () =>
167 Effect.gen(function* () {
168 const { exports } = yield* bundleWorkerEntry(
169 "src/Cloudflare/Bridge.ts",
170 );
171 expect(exports).toEqual(
172 expect.arrayContaining([
173 "makeWorkerBridge",
174 "makeDurableObjectBridge",
175 "makeWorkflowBridge",
176 "fromCloudflareFetcher",
177 "toRpcAsync",
178 ]),
179 );
180 }),
181 {
182 tags: [
183 "unit",
184 "provider:cloudflare",
185 "provider:cloudflare:worker",
186 "local",
187 ],
188 },
189 );
190
191 it.effect(
192 "keeps planner tooling out of /Bridge's dependency graph",
193 () =>
194 Effect.gen(function* () {
195 const { code, sources, packages } = yield* bundleWorkerEntry(
196 "src/Cloudflare/Bridge.ts",
197 );
198 expect(sources.filter(isPlannerSource)).toEqual([]);
199 expect(
200 toolchainPackages.filter((name) => packages.has(name)),
201 ).toEqual([]);
202 expect(code).not.toContain("require.resolve");
203 }),
204 {
205 tags: [
206 "unit",
207 "provider:cloudflare",
208 "provider:cloudflare:worker",
209 "local",
210 ],
211 },
212 );
213
214 it.effect(
215 "keeps planner tooling out of imports added by the generated wrapper",
216 () =>
217 Effect.gen(function* () {
218 const { code, sources, packages, exports } = yield* bundleWorkerEntry(
219 "test/Cloudflare/Workers/fixtures/runtime-entry/worker.ts",
220 true,
221 );
222 expect(exports).toContain("default");
223 expect(sources).toContain("src/Cloudflare/Bridge.ts");
224 expect(sources.filter(isPlannerSource)).toEqual([]);
225 expect(
226 toolchainPackages.filter((name) => packages.has(name)),
227 ).toEqual([]);
228 expect(code).not.toContain("require.resolve");
229 }),
230 {
231 tags: [
232 "unit",
233 "provider:cloudflare",
234 "provider:cloudflare:worker",
235 "local",
236 ],
237 },
238 );
239
240 it.effect(
241 "the alchemy/Cloudflare namespace does pull that tooling",
242 () =>
243 Effect.gen(function* () {
244 const { code, sources, packages } = yield* bundleWorkerEntry(
245 "src/Cloudflare/index.ts",
246 );
247 expect(sources).toContain(
248 "src/Cloudflare/Workers/Sources/Rolldown.ts",
249 );
250 expect(packages.has("workerd")).toBe(true);
251 expect(code).toContain("require.resolve");
252 }),
253 {
254 tags: [
255 "unit",
256 "provider:cloudflare",
257 "provider:cloudflare:worker",
258 "local",
259 ],
260 },
261 );
262
263 it.effect(
264 "detects local tooling behind a dynamic import",
265 () =>
266 Effect.gen(function* () {
267 const { code, sources, packages } = yield* bundleWorkerEntry(
268 "test/Cloudflare/Workers/fixtures/runtime-entry/dynamic-tooling.ts",
269 );
270 expect(sources.filter(isPlannerSource)).toContain(
271 "src/Cloudflare/LocalRuntime.ts",
272 );
273 expect(packages.has("workerd")).toBe(true);
274 expect(code).toContain("require.resolve");
275 }),
276 {
277 tags: [
278 "unit",
279 "provider:cloudflare",
280 "provider:cloudflare:worker",
281 "local",
282 ],
283 },
284 );
285
286 it.effect(
287 "preserves the public runtime service identities",
288 () =>
289 Effect.sync(() => {
290 expect(Cloudflare.WorkerEnvironment).toBe(
291 WorkerRuntime.WorkerEnvironment,
292 );
293 expect(Cloudflare.WorkerExecutionContext).toBe(
294 WorkerRuntime.WorkerExecutionContext,
295 );
296 expect(Cloudflare.Workflows.WorkflowEvent).toBe(
297 WorkflowRuntime.WorkflowEvent,
298 );
299 expect(Cloudflare.Workflows.WorkflowStep).toBe(
300 WorkflowRuntime.WorkflowStep,
301 );
302 expect(Cloudflare.Workflows.WorkflowStepContext).toBe(
303 WorkflowRuntime.WorkflowStepContext,
304 );
305 expect(CloudflareEnvironment).toBe(RuntimeCloudflareEnvironment);
306 expect(Stack.key).toBe(StackContext.key);
307 expect(Telemetry.Telemetry).toBe(TelemetryRuntime.Telemetry);
308 }),
309 {
310 tags: [
311 "unit",
312 "provider:cloudflare",
313 "provider:cloudflare:worker",
314 "provider:cloudflare:workflow",
315 "local",
316 ],
317 },
318 );
319
320 it.effect(
321 "alchemy/Cloudflare still re-exports the same factories",
322 () =>
323 Effect.sync(() => {
324 expect(Cloudflare.makeWorkerBridge).toBe(Bridge.makeWorkerBridge);
325 expect(Cloudflare.makeDurableObjectBridge).toBe(
326 Bridge.makeDurableObjectBridge,
327 );
328 expect(Cloudflare.makeWorkflowBridge).toBe(Bridge.makeWorkflowBridge);
329 }),
330 {
331 tags: [
332 "unit",
333 "provider:cloudflare",
334 "provider:cloudflare:worker",
335 "local",
336 ],
337 },
338 );
339 },
340);
341
342for (const dev of [false, true]) {
343 describe(
344 `runtime entry (${dev ? "local" : "live"})`,
345 { tags: ["provider:cloudflare", "provider:cloudflare:worker"] },
346 () => {
347 const { test } = Test.make({ providers: Cloudflare.providers(), dev });
348
349 test.provider(
350 "application importing Worker.ts serves requests",
351 (stack) =>
352 Effect.gen(function* () {
353 yield* stack.destroy();
354 const worker = yield* stack.deploy(RuntimeEntryWorker);
355 const response = yield* requestWorker(
356 HttpClientRequest.get(worker.url!),
357 { retryDelay: "3 seconds" },
358 );
359 expect(response.status).toBe(200);
360 expect(yield* response.text).toBe("runtime-entry:ok");
361 yield* stack.destroy();
362 }),
363 { tags: [...(dev ? ["local"] : ["live"])] },
364 );
365 },
366 );
367}