1import * as Cloudflare from "@/Cloudflare";
2import * as Test from "@/Test/Alchemy";
3import { expect } from "alchemy-test";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as Schedule from "effect/Schedule";
7import * as HttpClient from "effect/http/HttpClient";
8import Stack from "./fixtures/dynamic-worker-loader/stack.ts";
9
10const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
11 providers: Cloudflare.providers(),
12});
13
14class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{
15 status: number;
16 body: string;
17}> {}
18
19// Fresh workers.dev URLs serve Cloudflare's placeholder page for a few seconds
20// after deploy. Retry until the worker (and its dynamically-loaded child)
21// answer 200, surfacing the body if not so a real failure isn't masked.
22const readJson = (url: string) =>
23 HttpClient.HttpClient.pipe(
24 Effect.flatMap((client) => client.get(url)),
25 Effect.flatMap((res) =>
26 res.status === 200
27 ? res.json
28 : res.text.pipe(
29 Effect.flatMap((body) =>
30 Effect.fail(new WorkerNotReady({ status: res.status, body })),
31 ),
32 ),
33 ),
34 Effect.retry({
35 while: (e): e is WorkerNotReady => e instanceof WorkerNotReady,
36 schedule: Schedule.max([
37 Schedule.exponential("500 millis"),
38 Schedule.recurs(20),
39 ]),
40 }),
41 );
42
43// The dynamic worker's hit counter lives in module scope, so an isolate id
44// reused across test attempts (the runner retries) or runs keeps counting.
45// Mint a fresh id per attempt so `hits` always starts at 1.
46const freshId = (prefix: string) =>
47 Effect.sync(() => `${prefix}-${crypto.randomUUID().slice(0, 8)}`);
48
49const stack = beforeAll(deploy(Stack));
50afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack));
51
52test(
53 "async worker loads and proxies to a dynamic worker via env binding",
54 Effect.gen(function* () {
55 const { asyncWorkerUrl } = yield* stack;
56 const body = yield* readJson(asyncWorkerUrl);
57 expect(body).toMatchObject({ mode: "async", ok: true });
58 }),
59 {
60 tags: ["provider:cloudflare", "provider:cloudflare:worker", "live"],
61 timeout: 180_000,
62 },
63);
64
65test(
66 "effect worker loads and proxies to a dynamic worker via yield* loader",
67 Effect.gen(function* () {
68 const { effectWorkerUrl } = yield* stack;
69 const body = yield* readJson(effectWorkerUrl);
70 expect(body).toMatchObject({ mode: "effect", ok: true });
71 }),
72 {
73 tags: ["provider:cloudflare", "provider:cloudflare:worker", "live"],
74 timeout: 180_000,
75 },
76);
77
78test(
79 "effect worker reaches a named dynamic worker via loader.get",
80 Effect.gen(function* () {
81 const { effectWorkerUrl } = yield* stack;
82 const body = yield* readJson(`${effectWorkerUrl}/get`);
83 expect(body).toMatchObject({ mode: "get", ok: true });
84 }),
85 {
86 tags: ["provider:cloudflare", "provider:cloudflare:worker", "live"],
87 timeout: 180_000,
88 },
89);
90
91test(
92 "effect worker get() fetch proxies to a cached dynamic worker",
93 Effect.gen(function* () {
94 const { getWorkerUrl } = yield* stack;
95 const id = yield* freshId("stub");
96 const body = yield* readJson(`${getWorkerUrl}/?id=${id}`);
97 expect(body).toMatchObject({ id, hits: 1 });
98 }),
99 {
100 tags: ["provider:cloudflare", "provider:cloudflare:worker", "live"],
101 timeout: 180_000,
102 },
103);
104
105test(
106 "effect worker get() reuses the isolate for the same id",
107 Effect.gen(function* () {
108 const { getWorkerUrl } = yield* stack;
109 const id = yield* freshId("reuse");
110 const first = yield* readJson(`${getWorkerUrl}/?id=${id}`);
111 expect(first).toMatchObject({ id, hits: 1 });
112 // `get()` only reuses an isolate within the same colo/process, so two
113 // back-to-back requests can occasionally be served by two fresh
114 // isolates (both reporting `hits: 1`). Any response with `hits >= 2`
115 // proves module state survived across requests, i.e. the isolate was
116 // reused — poll for that instead of asserting the second request exactly.
117 const reused = yield* readJson(`${getWorkerUrl}/?id=${id}`).pipe(
118 Effect.repeat({
119 until: (body) => (body as { hits: number }).hits >= 2,
120 schedule: Schedule.spaced("500 millis"),
121 times: 10,
122 }),
123 );
124 expect(reused).toMatchObject({ id });
125 expect((reused as { hits: number }).hits).toBeGreaterThanOrEqual(2);
126 }),
127 {
128 tags: ["provider:cloudflare", "provider:cloudflare:worker", "live"],
129 timeout: 180_000,
130 },
131);
132
133test(
134 "effect worker get() getEntrypoint() fetch works",
135 Effect.gen(function* () {
136 const { getWorkerUrl } = yield* stack;
137 const id = yield* freshId("named");
138 const body = yield* readJson(`${getWorkerUrl}/?id=${id}&entrypoint=1`);
139 expect(body).toMatchObject({ id, hits: 1 });
140 }),
141 {
142 tags: ["provider:cloudflare", "provider:cloudflare:worker", "live"],
143 timeout: 180_000,
144 },
145);
146
147// `globalOutbound: null` must reach the runtime as `null` — coercing it to
148// `undefined` (the old `?.raw` behavior) silently restores default outbound
149// access for workers meant to be sandboxed (#746). The fixture's dynamic
150// worker attempts an outbound fetch and reports whether the runtime allowed
151// it.
152test(
153 "dynamic worker loaded with globalOutbound: null cannot reach the network",
154 Effect.gen(function* () {
155 const { effectWorkerUrl } = yield* stack;
156 const body = yield* readJson(`${effectWorkerUrl}/outbound/sandboxed`);
157 expect(body).toMatchObject({ outbound: "blocked" });
158 }),
159 {
160 tags: ["provider:cloudflare", "provider:cloudflare:worker", "live"],
161 timeout: 180_000,
162 },
163);
164
165test(
166 "dynamic worker loaded without globalOutbound has default network access",
167 Effect.gen(function* () {
168 const { effectWorkerUrl } = yield* stack;
169 const body = yield* readJson(`${effectWorkerUrl}/outbound/open`);
170 expect(body).toMatchObject({ outbound: "allowed", status: 200 });
171 }),
172 {
173 tags: ["provider:cloudflare", "provider:cloudflare:worker", "live"],
174 timeout: 180_000,
175 },
176);