| 1 | import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; |
| 2 | import * as Cloudflare from "@/Cloudflare/index.ts"; |
| 3 | import { findZoneByName } from "@/Cloudflare/Zone/lookup"; |
| 4 | import * as Test from "@/Test/Alchemy"; |
| 5 | import * as dns from "@distilled.cloud/cloudflare/dns"; |
| 6 | import * as workers from "@distilled.cloud/cloudflare/workers"; |
| 7 | import { expect } from "alchemy-test"; |
| 8 | import * as Cause from "effect/Cause"; |
| 9 | import * as Effect from "effect/Effect"; |
| 10 | import { MinimumLogLevel } from "effect/References"; |
| 11 | import * as Schedule from "effect/Schedule"; |
| 12 | import * as Stream from "effect/Stream"; |
| 13 | import * as pathe from "pathe"; |
| 14 | import { expectUrlAbsent, expectUrlContains } from "../Utils/Http.ts"; |
| 15 | import { waitForWorkerToBeDeleted } from "../Utils/Worker.ts"; |
| 16 | |
| 17 | const { test } = Test.make({ providers: Cloudflare.providers() }); |
| 18 | |
| 19 | const logLevel = Effect.provideService( |
| 20 | MinimumLogLevel, |
| 21 | process.env.DEBUG ? "Debug" : "Info", |
| 22 | ); |
| 23 | |
| 24 | const main = pathe.resolve(import.meta.dirname, "fixtures/worker.ts"); |
| 25 | |
| 26 | const 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()). |
| 34 | const routeSuffix = `alchemy-worker-route-${process.env.PULL_REQUEST ?? process.env.USER}`; |
| 35 | |
| 36 | const workerMarker = "Hello from TestWorker"; |
| 37 | |
| 38 | const 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. |
| 52 | const forbiddenRetrySchedule = Schedule.exponential("500 millis"); |
| 53 | |
| 54 | const 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 | |
| 66 | const 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. |
| 73 | const 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). |
| 102 | const 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 | |
| 117 | const T1_V1 = `${zoneName}/${routeSuffix}/t1/api/*`; |
| 118 | const T1_V2 = `${zoneName}/${routeSuffix}/t1/api/v2/*`; |
| 119 | const T1_ADDED = `${zoneName}/${routeSuffix}/t1/other/*`; |
| 120 | const T1_MATCH_URL = `https://${zoneName}/${routeSuffix}/t1/api/ping`; |
| 121 | const T1_MISS_URL = `https://${zoneName}/${routeSuffix}/t1/unknown`; |
| 122 | |
| 123 | test.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 | |
| 245 | const T2_KEPT = `${zoneName}/${routeSuffix}/t2/api/*`; |
| 246 | const T2_DRIFT = `${zoneName}/${routeSuffix}/t2/drift/*`; |
| 247 | |
| 248 | test.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 | |
| 338 | const T3_PATTERN = `${zoneName}/${routeSuffix}/t3/api/*`; |
| 339 | |
| 340 | test.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 | */ |
| 408 | const 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 | ); |