1import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
2import * as Cloudflare from "@/Cloudflare/index.ts";
3import { findZoneByName } from "@/Cloudflare/Zone/lookup";
4import * as Test from "@/Test/Alchemy";
5import * as dns from "@distilled.cloud/cloudflare/dns";
6import * as workers from "@distilled.cloud/cloudflare/workers";
7import { expect } from "alchemy-test";
8import * as Cause from "effect/Cause";
9import * as Effect from "effect/Effect";
10import { MinimumLogLevel } from "effect/References";
11import * as Schedule from "effect/Schedule";
12import * as Stream from "effect/Stream";
13import * as pathe from "pathe";
14import { expectUrlAbsent, expectUrlContains } from "../Utils/Http.ts";
15import { waitForWorkerToBeDeleted } from "../Utils/Worker.ts";
16
17const { test } = Test.make({ providers: Cloudflare.providers() });
18
19const logLevel = Effect.provideService(
20 MinimumLogLevel,
21 process.env.DEBUG ? "Debug" : "Info",
22);
23
24const main = pathe.resolve(import.meta.dirname, "fixtures/worker.ts");
25
26const zoneName =
27 process.env.CLOUDFLARE_TEST_WORKER_ROUTE_ZONE_NAME ??
28 process.env.CLOUDFLARE_TEST_R2_DOMAIN_ZONE_NAME ??
29 "alchemy-test-2.us";
30
31// Deterministic per-test path prefixes on the zone apex. Each test owns a
32// disjoint prefix so reruns and parallel runs never collide, and the same
33// patterns are reused on every run (never derive names from Date.now()).
34const routeSuffix = `alchemy-worker-route-${process.env.PULL_REQUEST ?? process.env.USER}`;
35
36const workerMarker = "Hello from TestWorker";
37
38const resolveZoneId = Effect.gen(function* () {
39 const { accountId } = yield* yield* CloudflareEnvironment;
40 const zone = yield* findZoneByName({ accountId, name: zoneName });
41 if (!zone) {
42 return yield* Effect.die(
43 new Error(`zone "${zoneName}" not found in account`),
44 );
45 }
46 return zone.id;
47});
48
49// A freshly-minted scoped API token propagates eventually-consistently
50// across Cloudflare's edge — retry the typed `Forbidden` blips on the
51// tests' own out-of-band verification calls.
52const forbiddenRetrySchedule = Schedule.exponential("500 millis");
53
54const listByPattern = (zoneId: string, pattern: string) =>
55 workers.listRoutes.items({ zoneId }).pipe(
56 Stream.filter((r) => r.pattern === pattern),
57 Stream.runCollect,
58 Effect.map((chunk) => Array.from(chunk)),
59 Effect.retry({
60 while: (e) => e._tag === "Forbidden",
61 schedule: forbiddenRetrySchedule,
62 times: 8,
63 }),
64 );
65
66const findRoute = (zoneId: string, pattern: string) =>
67 listByPattern(zoneId, pattern).pipe(Effect.map((rs) => rs[0]));
68
69// Workers only run on proxied hostnames, so the zone apex needs a proxied
70// placeholder record for route-matched requests to reach Cloudflare's edge
71// at all. The record is standing test-zone infrastructure (like the zone
72// itself): ensure it exists out-of-band, never tear it down.
73const ensureApexPlaceholder = (zoneId: string) =>
74 Effect.gen(function* () {
75 const existing = yield* dns.listRecords.items({ zoneId }).pipe(
76 Stream.filter(
77 (r) => r.name === zoneName && (r.type === "A" || r.type === "AAAA"),
78 ),
79 Stream.runCollect,
80 Effect.map((chunk) => Array.from(chunk)[0]),
81 Effect.retry({
82 while: (e) => e._tag === "Forbidden",
83 schedule: forbiddenRetrySchedule,
84 times: 8,
85 }),
86 );
87 if (existing) return;
88 yield* dns.createRecord({
89 zoneId,
90 name: zoneName,
91 type: "AAAA",
92 content: "100::",
93 proxied: true,
94 ttl: 1,
95 comment: "standing placeholder so Workers routes serve on the zone apex",
96 });
97 });
98
99// Delete every route matching the pattern — purge leftovers from
100// interrupted runs so tests start from a clean slate (Cloudflare enforces
101// one route per pattern per zone).
102const purgeRoutes = (zoneId: string, ...patterns: string[]) =>
103 Effect.forEach(patterns, (pattern) =>
104 listByPattern(zoneId, pattern).pipe(
105 Effect.flatMap(
106 Effect.forEach((r) =>
107 workers
108 .deleteRoute({ zoneId, routeId: r.id })
109 .pipe(Effect.catch(() => Effect.void)),
110 ),
111 ),
112 ),
113 );
114
115// --- lifecycle: create → no-op → update (change + add) → remove ---------
116
117const T1_V1 = `${zoneName}/${routeSuffix}/t1/api/*`;
118const T1_V2 = `${zoneName}/${routeSuffix}/t1/api/v2/*`;
119const T1_ADDED = `${zoneName}/${routeSuffix}/t1/other/*`;
120const T1_MATCH_URL = `https://${zoneName}/${routeSuffix}/t1/api/ping`;
121const T1_MISS_URL = `https://${zoneName}/${routeSuffix}/t1/unknown`;
122
123test.provider(
124 "creates, keeps, updates, and removes worker zone routes",
125 (stack) =>
126 Effect.gen(function* () {
127 const { accountId } = yield* yield* CloudflareEnvironment;
128 const zoneId = yield* resolveZoneId;
129
130 yield* stack.destroy();
131 yield* purgeRoutes(zoneId, T1_V1, T1_V2, T1_ADDED);
132 yield* ensureApexPlaceholder(zoneId);
133
134 let workerName: string | undefined;
135
136 yield* Effect.gen(function* () {
137 // Create — zone resolved from `zoneName`.
138 const worker = yield* stack.deploy(
139 Effect.gen(function* () {
140 return yield* Cloudflare.Worker("RouteWorker", {
141 main,
142 workersDev: false,
143 routes: [{ pattern: T1_V1, zoneName }],
144 });
145 }),
146 );
147 workerName = worker.workerName;
148
149 expect(worker.routes).toHaveLength(1);
150 expect(worker.routes[0]?.pattern).toEqual(T1_V1);
151 expect(worker.routes[0]?.zoneId).toEqual(zoneId);
152 const initialRouteId = worker.routes[0]!.id;
153
154 const liveRoute = yield* findRoute(zoneId, T1_V1);
155 expect(liveRoute?.script).toEqual(worker.workerName);
156
157 yield* expectUrlContains(T1_MATCH_URL, workerMarker, {
158 label: "worker route match",
159 timeout: "60 seconds",
160 });
161 yield* expectUrlAbsent(T1_MISS_URL, workerMarker, {
162 label: "worker route miss",
163 timeout: "30 seconds",
164 });
165
166 // No-op — identical props must not churn the route (same id).
167 const noop = yield* stack.deploy(
168 Effect.gen(function* () {
169 return yield* Cloudflare.Worker("RouteWorker", {
170 main,
171 workersDev: false,
172 routes: [{ pattern: T1_V1, zoneName }],
173 });
174 }),
175 );
176 expect(noop.routes).toHaveLength(1);
177 expect(noop.routes[0]?.id).toEqual(initialRouteId);
178
179 // Update — change the first pattern (delete + create, addressed by
180 // explicit `zoneId`) and add a second route whose zone is inferred
181 // from the pattern's hostname.
182 const updated = yield* stack.deploy(
183 Effect.gen(function* () {
184 return yield* Cloudflare.Worker("RouteWorker", {
185 main,
186 workersDev: false,
187 routes: [{ pattern: T1_V2, zoneId }, { pattern: T1_ADDED }],
188 });
189 }),
190 );
191
192 const updatedPatterns = updated.routes.map((r) => r.pattern).sort();
193 expect(updatedPatterns).toEqual([T1_V2, T1_ADDED].sort());
194 expect(updated.routes.every((r) => r.zoneId === zoneId)).toBe(true);
195
196 expect(yield* findRoute(zoneId, T1_V1)).toBeUndefined();
197 expect((yield* findRoute(zoneId, T1_V2))?.script).toEqual(
198 worker.workerName,
199 );
200 expect((yield* findRoute(zoneId, T1_ADDED))?.script).toEqual(
201 worker.workerName,
202 );
203
204 // Remove — omitting the `routes` prop entirely detaches everything
205 // recorded in the previous state.
206 const removed = yield* stack.deploy(
207 Effect.gen(function* () {
208 return yield* Cloudflare.Worker("RouteWorker", {
209 main,
210 workersDev: false,
211 });
212 }),
213 );
214 expect(removed.routes).toHaveLength(0);
215
216 expect(yield* findRoute(zoneId, T1_V2)).toBeUndefined();
217 expect(yield* findRoute(zoneId, T1_ADDED)).toBeUndefined();
218 }).pipe(
219 Effect.ensuring(
220 Effect.gen(function* () {
221 yield* stack.destroy().pipe(Effect.ignore);
222 if (workerName) {
223 yield* waitForWorkerToBeDeleted(workerName, accountId).pipe(
224 Effect.ignore,
225 );
226 }
227 }),
228 ),
229 );
230 }).pipe(logLevel),
231 {
232 tags: [
233 "provider:cloudflare",
234 "provider:cloudflare:dns",
235 "provider:cloudflare:worker",
236 "provider:cloudflare:zone",
237 "live",
238 ],
239 timeout: 300_000,
240 },
241);
242
243// --- observed state is the baseline: drift removal + destroy cleanup ----
244
245const T2_KEPT = `${zoneName}/${routeSuffix}/t2/api/*`;
246const T2_DRIFT = `${zoneName}/${routeSuffix}/t2/drift/*`;
247
248test.provider(
249 "removes out-of-band routes on update and detaches routes on destroy",
250 (stack) =>
251 Effect.gen(function* () {
252 const { accountId } = yield* yield* CloudflareEnvironment;
253 const zoneId = yield* resolveZoneId;
254
255 yield* stack.destroy();
256 yield* purgeRoutes(zoneId, T2_KEPT, T2_DRIFT);
257
258 let workerName: string | undefined;
259
260 yield* Effect.gen(function* () {
261 const worker = yield* stack.deploy(
262 Effect.gen(function* () {
263 return yield* Cloudflare.Worker("DriftRouteWorker", {
264 main,
265 workersDev: false,
266 compatibility: { date: "2024-01-01" },
267 routes: [{ pattern: T2_KEPT, zoneName }],
268 });
269 }),
270 );
271 workerName = worker.workerName;
272 const keptRouteId = worker.routes[0]!.id;
273
274 // Attach a route to the same script out-of-band — the reconciler
275 // never recorded it, so it is pure drift.
276 const drift = yield* workers
277 .createRoute({
278 zoneId,
279 pattern: T2_DRIFT,
280 script: worker.workerName,
281 })
282 .pipe(
283 Effect.retry({
284 while: (e) => e._tag === "Forbidden",
285 schedule: forbiddenRetrySchedule,
286 times: 8,
287 }),
288 );
289 expect(drift.id).toBeDefined();
290
291 // Force an update (compat date bump) with the same desired routes:
292 // the kept route must survive untouched (same id) and the drift
293 // route — observed in the same zone, attached to this script, not
294 // desired — must be removed.
295 const updated = yield* stack.deploy(
296 Effect.gen(function* () {
297 return yield* Cloudflare.Worker("DriftRouteWorker", {
298 main,
299 workersDev: false,
300 compatibility: { date: "2024-01-02" },
301 routes: [{ pattern: T2_KEPT, zoneName }],
302 });
303 }),
304 );
305
306 expect(updated.routes).toHaveLength(1);
307 expect(updated.routes[0]?.id).toEqual(keptRouteId);
308 expect(yield* findRoute(zoneId, T2_DRIFT)).toBeUndefined();
309 }).pipe(
310 Effect.ensuring(
311 Effect.gen(function* () {
312 yield* stack.destroy().pipe(Effect.ignore);
313 if (workerName) {
314 yield* waitForWorkerToBeDeleted(workerName, accountId).pipe(
315 Effect.ignore,
316 );
317 }
318 }),
319 ),
320 );
321
322 // Destroying the worker detaches its routes from the zone.
323 expect(yield* findRoute(zoneId, T2_KEPT)).toBeUndefined();
324 }).pipe(logLevel),
325 {
326 tags: [
327 "provider:cloudflare",
328 "provider:cloudflare:worker",
329 "provider:cloudflare:zone",
330 "live",
331 ],
332 timeout: 300_000,
333 },
334);
335
336// --- refusal: a pattern already routed to a different Worker ------------
337
338const T3_PATTERN = `${zoneName}/${routeSuffix}/t3/api/*`;
339
340test.provider(
341 "refuses to steal a route pattern attached to another Worker",
342 (stack) =>
343 Effect.gen(function* () {
344 const zoneId = yield* resolveZoneId;
345
346 yield* stack.destroy();
347 yield* purgeRoutes(zoneId, T3_PATTERN);
348
349 yield* Effect.gen(function* () {
350 const owner = yield* stack.deploy(
351 Effect.gen(function* () {
352 return yield* Cloudflare.Worker("RouteOwnerWorker", {
353 main,
354 workersDev: false,
355 routes: [{ pattern: T3_PATTERN, zoneName }],
356 });
357 }),
358 );
359
360 const error = yield* stack
361 .deploy(
362 Effect.gen(function* () {
363 const owner = yield* Cloudflare.Worker("RouteOwnerWorker", {
364 main,
365 workersDev: false,
366 routes: [{ pattern: T3_PATTERN, zoneName }],
367 });
368 const thief = yield* Cloudflare.Worker("RouteThiefWorker", {
369 main,
370 workersDev: false,
371 routes: [{ pattern: T3_PATTERN, zoneName }],
372 });
373 return { owner, thief };
374 }),
375 )
376 .pipe(
377 Effect.as(undefined),
378 Effect.catchCause((cause) =>
379 Effect.succeed(findAttachRefusal(cause)),
380 ),
381 );
382
383 expect(error).toBeDefined();
384 expect(error?.message).toContain("already attached to Worker");
385
386 // The pattern still routes to its original owner.
387 const live = yield* findRoute(zoneId, T3_PATTERN);
388 expect(live?.script).toEqual(owner.workerName);
389 }).pipe(
390 Effect.ensuring(stack.destroy().pipe(Effect.orDie).pipe(Effect.ignore)),
391 );
392 }).pipe(logLevel),
393 {
394 tags: [
395 "provider:cloudflare",
396 "provider:cloudflare:worker",
397 "provider:cloudflare:zone",
398 "live",
399 ],
400 timeout: 300_000,
401 },
402);
403
404/**
405 * Pull the attach-refusal `Error` out of a Cause regardless of whether the
406 * engine surfaced it as a typed failure or a defect (`Effect.die`).
407 */
408const findAttachRefusal = (cause: Cause.Cause<unknown>): Error | undefined =>
409 cause.reasons
410 .map((reason) =>
411 Cause.isFailReason(reason)
412 ? reason.error
413 : Cause.isDieReason(reason)
414 ? reason.defect
415 : undefined,
416 )
417 .find(
418 (value): value is Error =>
419 value instanceof Error &&
420 value.message.includes("already attached to Worker"),
421 );