1import { adopt, OwnedBySomeoneElse } from "@/AdoptPolicy";
2import * as Cloudflare from "@/Cloudflare";
3import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
4import { findZoneByName } from "@/Cloudflare/Zone/lookup";
5import * as Provider from "@/Provider";
6import * as Test from "@/Test/Alchemy";
7import * as workers from "@distilled.cloud/cloudflare/workers";
8import { expect } from "alchemy-test";
9import * as Cause from "effect/Cause";
10import * as Effect from "effect/Effect";
11import { MinimumLogLevel } from "effect/References";
12import * as Schedule from "effect/Schedule";
13import * as Stream from "effect/Stream";
14import * as pathe from "pathe";
15
16const { test } = Test.make({ providers: Cloudflare.providers() });
17
18const logLevel = Effect.provideService(
19 MinimumLogLevel,
20 process.env.DEBUG ? "Debug" : "Info",
21);
22
23const zoneName =
24 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
25
26const main = pathe.resolve(import.meta.dirname, "fixtures", "route-worker.ts");
27
28// Deterministic per-test route patterns. Each test owns a disjoint
29// subdomain so reruns and parallel runs never collide, and the same
30// pattern is reused on every run (never derive physical names from
31// Date.now()/random).
32const PATTERN_DEFAULT = `alchemy-route-default.${zoneName}/*`;
33const PATTERN_UPDATE_V1 = `alchemy-route-update.${zoneName}/*`;
34const PATTERN_UPDATE_V2 = `alchemy-route-update.${zoneName}/api/*`;
35const PATTERN_ADOPT = `alchemy-route-adopt.${zoneName}/*`;
36
37const resolveZoneId = Effect.gen(function* () {
38 const { accountId } = yield* yield* CloudflareEnvironment;
39 const zone = yield* findZoneByName({ accountId, name: zoneName });
40 if (!zone) {
41 return yield* Effect.die(
42 new Error(`zone "${zoneName}" not found in account`),
43 );
44 }
45 return zone.id;
46});
47
48// The scoped API token the test harness mints propagates eventually-
49// consistently across Cloudflare's edge — a fresh token intermittently
50// 403s with "Unable to authenticate request". Ride out the blips on the
51// test's own out-of-band verification calls by retrying the typed
52// `Forbidden` error (part of each route operation's error union via
53// distilled patches).
54const forbiddenRetrySchedule = Schedule.exponential("500 millis");
55
56const listByPattern = (zoneId: string, pattern: string) =>
57 workers.listRoutes.items({ zoneId }).pipe(
58 Stream.filter((r) => r.pattern === pattern),
59 Stream.runCollect,
60 Effect.map((chunk) => Array.from(chunk)),
61 Effect.retry({
62 while: (e) => e._tag === "Forbidden",
63 schedule: forbiddenRetrySchedule,
64 times: 8,
65 }),
66 );
67
68const findRoute = (zoneId: string, pattern: string) =>
69 listByPattern(zoneId, pattern).pipe(Effect.map((rs) => rs[0]));
70
71const getRoute = (zoneId: string, routeId: string) =>
72 workers.getRoute({ zoneId, routeId }).pipe(
73 Effect.retry({
74 while: (e) => e._tag === "Forbidden",
75 schedule: forbiddenRetrySchedule,
76 times: 8,
77 }),
78 );
79
80// Delete every route matching the pattern — used to purge leftovers from
81// interrupted runs so tests start from a clean slate (Cloudflare enforces
82// one route per pattern per zone, so a leaked route would conflict).
83const purgeRoutes = (zoneId: string, pattern: string) =>
84 listByPattern(zoneId, pattern).pipe(
85 Effect.flatMap(
86 Effect.forEach((r) =>
87 workers
88 .deleteRoute({ zoneId, routeId: r.id })
89 .pipe(Effect.catch(() => Effect.void)),
90 ),
91 ),
92 );
93
94test.provider(
95 "create and delete an opt-out route (no script)",
96 (stack) =>
97 Effect.gen(function* () {
98 const zoneId = yield* resolveZoneId;
99
100 yield* stack.destroy();
101 yield* purgeRoutes(zoneId, PATTERN_DEFAULT);
102
103 const route = yield* stack.deploy(
104 Effect.gen(function* () {
105 return yield* Cloudflare.Workers.WorkerRoute("DefaultRoute", {
106 zoneId,
107 pattern: PATTERN_DEFAULT,
108 }).pipe(adopt(true));
109 }),
110 );
111
112 expect(route.routeId).toBeDefined();
113 expect(route.zoneId).toEqual(zoneId);
114 expect(route.pattern).toEqual(PATTERN_DEFAULT);
115 // No script — the route opts matching requests out of Workers.
116 expect(route.script).toBeUndefined();
117
118 const live = yield* getRoute(zoneId, route.routeId);
119 expect(live.id).toEqual(route.routeId);
120 expect(live.pattern).toEqual(PATTERN_DEFAULT);
121
122 yield* stack.destroy();
123
124 const gone = yield* findRoute(zoneId, PATTERN_DEFAULT);
125 expect(gone).toBeUndefined();
126 }).pipe(logLevel),
127 {
128 tags: [
129 "provider:cloudflare",
130 "provider:cloudflare:worker",
131 "provider:cloudflare:zone",
132 "live",
133 ],
134 timeout: 300_000,
135 },
136);
137
138test.provider(
139 "route to a Worker, then update pattern and script in place",
140 (stack) =>
141 Effect.gen(function* () {
142 const zoneId = yield* resolveZoneId;
143
144 yield* stack.destroy();
145 yield* purgeRoutes(zoneId, PATTERN_UPDATE_V1);
146 yield* purgeRoutes(zoneId, PATTERN_UPDATE_V2);
147
148 const initial = yield* stack.deploy(
149 Effect.gen(function* () {
150 const worker = yield* Cloudflare.Worker("RouteWorker", {
151 main,
152 compatibility: { date: "2024-01-01" },
153 });
154 const route = yield* Cloudflare.Workers.WorkerRoute("Route", {
155 zoneId,
156 pattern: PATTERN_UPDATE_V1,
157 script: worker.workerName,
158 }).pipe(adopt(true));
159 return { worker, route };
160 }),
161 );
162
163 expect(initial.route.pattern).toEqual(PATTERN_UPDATE_V1);
164 expect(initial.route.script).toEqual(initial.worker.workerName);
165
166 const liveInitial = yield* getRoute(zoneId, initial.route.routeId);
167 expect(liveInitial.pattern).toEqual(PATTERN_UPDATE_V1);
168 expect(liveInitial.script).toEqual(initial.worker.workerName);
169
170 // Pattern and script are both mutable — the same physical route is
171 // updated in place (PUT), not replaced.
172 const updated = yield* stack.deploy(
173 Effect.gen(function* () {
174 const worker = yield* Cloudflare.Worker("RouteWorker", {
175 main,
176 compatibility: { date: "2024-01-01" },
177 });
178 const route = yield* Cloudflare.Workers.WorkerRoute("Route", {
179 zoneId,
180 pattern: PATTERN_UPDATE_V2,
181 // Drop the script — the route becomes an opt-out route.
182 }).pipe(adopt(true));
183 return { worker, route };
184 }),
185 );
186
187 expect(updated.route.routeId).toEqual(initial.route.routeId);
188 expect(updated.route.pattern).toEqual(PATTERN_UPDATE_V2);
189 expect(updated.route.script).toBeUndefined();
190
191 const liveUpdated = yield* getRoute(zoneId, updated.route.routeId);
192 expect(liveUpdated.pattern).toEqual(PATTERN_UPDATE_V2);
193
194 yield* stack.destroy();
195
196 const gone = yield* findRoute(zoneId, PATTERN_UPDATE_V2);
197 expect(gone).toBeUndefined();
198 }).pipe(logLevel),
199 {
200 tags: [
201 "provider:cloudflare",
202 "provider:cloudflare:worker",
203 "provider:cloudflare:zone",
204 "live",
205 ],
206 timeout: 300_000,
207 },
208);
209
210test.provider(
211 "adoption — existing route errors without adopt, takes over with adopt(true)",
212 (stack) =>
213 Effect.gen(function* () {
214 const zoneId = yield* resolveZoneId;
215
216 yield* stack.destroy();
217 yield* purgeRoutes(zoneId, PATTERN_ADOPT);
218
219 // Create the route out-of-band so the stack has no state of its own
220 // for it — exactly the "the route already exists" scenario.
221 const pre = yield* workers
222 .createRoute({ zoneId, pattern: PATTERN_ADOPT })
223 .pipe(
224 Effect.retry({
225 while: (e) => e._tag === "Forbidden",
226 schedule: forbiddenRetrySchedule,
227 times: 8,
228 }),
229 );
230 expect(pre.id).toBeDefined();
231
232 // Without `adopt`: routes carry no ownership markers, so the engine
233 // cannot prove we created it and refuses to take it over.
234 const error = yield* stack
235 .deploy(
236 Effect.gen(function* () {
237 return yield* Cloudflare.Workers.WorkerRoute("AdoptedRoute", {
238 zoneId,
239 pattern: PATTERN_ADOPT,
240 });
241 }),
242 )
243 .pipe(
244 Effect.as(undefined),
245 Effect.catchCause((cause) => Effect.succeed(findOwnedError(cause))),
246 );
247 expect(error).toBeInstanceOf(OwnedBySomeoneElse);
248
249 // With `adopt(true)`: the engine takes over the pre-existing route
250 // (same physical id).
251 const adopted = yield* stack.deploy(
252 Effect.gen(function* () {
253 return yield* Cloudflare.Workers.WorkerRoute("AdoptedRoute", {
254 zoneId,
255 pattern: PATTERN_ADOPT,
256 }).pipe(adopt(true));
257 }),
258 );
259
260 expect(adopted.routeId).toEqual(pre.id);
261 expect(adopted.pattern).toEqual(PATTERN_ADOPT);
262
263 yield* stack.destroy();
264
265 const gone = yield* findRoute(zoneId, PATTERN_ADOPT);
266 expect(gone).toBeUndefined();
267 }).pipe(logLevel),
268 {
269 tags: [
270 "provider:cloudflare",
271 "provider:cloudflare:worker",
272 "provider:cloudflare:zone",
273 "live",
274 ],
275 timeout: 300_000,
276 },
277);
278
279const PATTERN_LIST = `alchemy-route-list.${zoneName}/*`;
280
281// Canonical `list()` test (zone-scoped collection): routes have no account-
282// wide enumeration API, so `list()` fans out over every zone via
283// `listAllZones`, paginates `listRoutes` per zone, and hydrates each into the
284// `read` Attributes shape. Deploy a real route and assert it appears in the
285// exhaustively-enumerated result.
286test.provider(
287 "list enumerates the deployed route across all zones",
288 (stack) =>
289 Effect.gen(function* () {
290 const zoneId = yield* resolveZoneId;
291
292 yield* stack.destroy();
293 yield* purgeRoutes(zoneId, PATTERN_LIST);
294
295 const deployed = yield* stack.deploy(
296 Effect.gen(function* () {
297 const worker = yield* Cloudflare.Worker("ListRouteWorker", {
298 main,
299 compatibility: { date: "2024-01-01" },
300 });
301 const route = yield* Cloudflare.Workers.WorkerRoute("ListRoute", {
302 zoneId,
303 pattern: PATTERN_LIST,
304 script: worker.workerName,
305 }).pipe(adopt(true));
306 return { worker, route };
307 }),
308 );
309
310 const provider = yield* Provider.findProvider(
311 Cloudflare.Workers.WorkerRoute,
312 );
313 const all = yield* provider.list();
314
315 expect(
316 all.some(
317 (r) =>
318 r.routeId === deployed.route.routeId &&
319 r.zoneId === zoneId &&
320 r.pattern === PATTERN_LIST,
321 ),
322 ).toBe(true);
323
324 yield* stack.destroy();
325
326 const gone = yield* findRoute(zoneId, PATTERN_LIST);
327 expect(gone).toBeUndefined();
328 }).pipe(logLevel),
329 {
330 tags: [
331 "provider:cloudflare",
332 "provider:cloudflare:worker",
333 "provider:cloudflare:zone",
334 "live",
335 ],
336 timeout: 300_000,
337 },
338);
339
340/**
341 * Pull the {@link OwnedBySomeoneElse} value out of a Cause regardless of
342 * whether the engine raised it as a typed failure or a defect.
343 */
344const findOwnedError = (
345 cause: Cause.Cause<unknown>,
346): OwnedBySomeoneElse | undefined =>
347 cause.reasons
348 .map((reason) =>
349 Cause.isFailReason(reason)
350 ? reason.error
351 : Cause.isDieReason(reason)
352 ? reason.defect
353 : undefined,
354 )
355 .find(
356 (value): value is OwnedBySomeoneElse =>
357 value instanceof OwnedBySomeoneElse,
358 );