diff --git a/distilled b/distilled index bb54823f2a..8e6eb0c86c 160000 --- a/distilled +++ b/distilled @@ -1 +1 @@ -Subproject commit bb54823f2a1bac1710e12c72b2e198782f55894a +Subproject commit 8e6eb0c86cd29345da2718aeab77ad84fbfaa10c diff --git a/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts b/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts index f8f38b27e6..9380ab428a 100644 --- a/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts +++ b/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts @@ -478,6 +478,7 @@ export const LocalWorkerProvider = () => ]), ), domains: [url], + routes: [], crons: Array.from( new Set([...getCronBindings(bindings), ...(props.crons ?? [])]), ), @@ -545,6 +546,7 @@ export const LocalWorkerProvider = () => tags: [], durableObjectNamespaces, domains: url ? [url] : [], + routes: [], crons: Array.from( new Set([...getCronBindings(bindings), ...(news.crons ?? [])]), ), @@ -576,6 +578,7 @@ export const LocalWorkerProvider = () => durableObjectNamespaces: {}, accountId, domains: [], + routes: [], crons: news.crons ?? [], } satisfies Worker["Attributes"]; } diff --git a/packages/alchemy/src/Cloudflare/Workers/Worker.ts b/packages/alchemy/src/Cloudflare/Workers/Worker.ts index e6b3d6cafc..1ba7a4cf21 100644 --- a/packages/alchemy/src/Cloudflare/Workers/Worker.ts +++ b/packages/alchemy/src/Cloudflare/Workers/Worker.ts @@ -35,6 +35,7 @@ import type { DevOrigin } from "../Hyperdrive/Connection.ts"; import type { Providers } from "../Providers.ts"; import type { DispatchNamespace } from "../WorkersForPlatforms/DispatchNamespace.ts"; import type { WorkflowExport } from "../Workflows/Workflow.ts"; +import type { Reference as ZoneReference } from "../Zone/lookup.ts"; import { type Assets, type AssetsProps } from "./Assets.ts"; import { type DurableObjectExport } from "./DurableObject.ts"; import { Request } from "./Request.ts"; @@ -295,6 +296,28 @@ export type NormalizedBindings< export type WorkerAssetsConfig = string | AssetsProps | AssetsWithHash; +export interface WorkerRouteConfig { + /** + * URL pattern to match incoming requests against, e.g. + * `"subdomain.example.com/*"` or `"example.com/api/*"`. + */ + pattern: string; + /** + * Cloudflare zone ID. Equivalent to Wrangler's `zone_id`. + */ + zoneId?: string; + /** + * Cloudflare zone name, e.g. `"example.com"`. Equivalent to Wrangler's + * `zone_name`. + */ + zoneName?: string; + /** + * Zone reference — a zone ID, zone name, or `{ zoneId, name? }` object. + * Alternative to `zoneId` / `zoneName`. + */ + zone?: ZoneReference; +} + export interface WorkerProps< Bindings extends WorkerBindingProps = any, Assets extends WorkerAssetsConfig | undefined = @@ -441,6 +464,13 @@ export interface WorkerProps< * already exist in the account. */ domain?: string | string[]; + /** + * Zone routes that map URL patterns to this Worker. Equivalent to Wrangler's + * `routes` array — provide `zoneName` or `zoneId` (or `zone`) alongside each + * `pattern`. When the zone is omitted, it is inferred from the pattern's + * hostname. + */ + routes?: WorkerRouteConfig[]; /** * Extra bundler options applied on top of the standard rolldown input/output * options used to build this Worker. See {@link Bundle.BundleExtraOptions}. @@ -579,6 +609,7 @@ export type Worker = Resource< durableObjectNamespaces: Record; accountId: string; domains: string[]; + routes: { id: string; pattern: string; zoneId: string }[]; crons: string[]; hash?: { assets: string | undefined; @@ -796,6 +827,17 @@ export type Worker = Resource< * } * ``` * + * @example Zone routes + * ```typescript + * { + * main: import.meta.filename, + * routes: [ + * { pattern: "api.example.com/*", zoneName: "example.com" }, + * { pattern: "example.com/api/*", zoneId: "" }, + * ], + * } + * ``` + * * @example Deploying a prebuilt Worker without bundling * When `main` already points at a complete, runtime-ready ESM bundle * produced by an external tool (e.g. OpenNext), set `bundle: false` to diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts index 8423e6ca22..ac4b550849 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts @@ -21,11 +21,12 @@ import { Stack } from "../../Stack.ts"; import { sha256Object } from "../../Util/sha256.ts"; import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; import { CloudflareLogs } from "../Logs.ts"; +import { listAllZones, resolveZoneId } from "../Zone/lookup.ts"; import { readAssets, uploadAssets } from "./Assets.ts"; import { getCompatibility } from "./Compatibility.ts"; import { isDurableObjectExport } from "./DurableObject.ts"; import { LocalWorkerProvider } from "./LocalWorkerProvider.ts"; -import { Worker, type WorkerProps } from "./Worker.ts"; +import { Worker, type WorkerProps, type WorkerRouteConfig } from "./Worker.ts"; import { getCacheBinding, getCronBindings } from "./WorkerAsyncBindings.ts"; import type { WorkerBinding, WorkerSettingsBinding } from "./WorkerBinding.ts"; import { readPrebuiltWorkerBundle, WorkerBundle } from "./WorkerBundle.ts"; @@ -657,6 +658,226 @@ export const LiveWorkerProvider = () => return applied; }); + type NormalizedWorkerRoute = { + pattern: string; + zoneId: string; + }; + + const routeKey = (route: { pattern: string; zoneId: string }) => + `${route.zoneId}:${route.pattern}`; + + // Derive a concrete hostname inside the zone from a route pattern so + // zone inference can walk the DNS label hierarchy. A wildcard label + // (`*.example.com/*`) is replaced with a stand-in label — only the + // parent labels matter for finding the zone. + const hostnameFromPattern = (pattern: string): string => { + const hostPart = pattern.split("/")[0] ?? pattern; + return hostPart.startsWith("*.") + ? `routes.${hostPart.slice(2)}` + : hostPart; + }; + + // Resolve each route's zone to a concrete zone id: an explicit + // `zoneId` wins, then `zone` / `zoneName` via `resolveZoneId`, and + // finally inference from the pattern's hostname. Duplicate + // `(zoneId, pattern)` pairs are dropped — Cloudflare enforces one + // route per pattern per zone. + const normalizeRoutes = (routes: WorkerRouteConfig[] | undefined) => + Effect.gen(function* () { + if (!routes?.length) return [] as NormalizedWorkerRoute[]; + const { accountId } = yield* yield* CloudflareEnvironment; + const zoneCache = new Map(); + const normalized: NormalizedWorkerRoute[] = []; + const seen = new Set(); + for (const route of routes) { + const pattern = route.pattern.trim(); + const zoneId = route.zoneId + ? route.zoneId + : route.zone || route.zoneName + ? yield* resolveZoneId({ + accountId, + zone: route.zone ?? route.zoneName!, + hostname: hostnameFromPattern(pattern), + }) + : yield* inferZoneIdForHostname( + hostnameFromPattern(pattern), + zoneCache, + ); + const key = routeKey({ pattern, zoneId }); + if (seen.has(key)) continue; + seen.add(key); + normalized.push({ pattern, zoneId }); + } + return normalized; + }); + + // List the routes attached to `scriptName` across the given zones. + // Routes without an id/pattern or owned by another script are + // ignored. Zones the token can't read are skipped rather than + // failing the whole listing. + const listWorkerRoutesInZones = ( + scriptName: string, + zoneIds: readonly string[], + ) => { + const uniqueZoneIds = Array.from(new Set(zoneIds)); + if (uniqueZoneIds.length === 0) { + return Effect.succeed([] as Worker["Attributes"]["routes"]); + } + + const routesByZone = Effect.all( + uniqueZoneIds.map((zoneId) => + workers.listRoutes({ zoneId }).pipe( + Effect.map((response) => + (response.result ?? []).flatMap((route) => + route.id && route.pattern && route.script === scriptName + ? [{ id: route.id, pattern: route.pattern, zoneId }] + : [], + ), + ), + Effect.catch(() => Effect.succeed([])), + ), + ), + { concurrency: "unbounded" }, + ); + + return Effect.map(routesByZone, (routes) => routes.flat()); + }; + + // Observe every route attached to `scriptName` account-wide. Routes + // are zone-scoped with no account-level enumeration API, so fan out + // over all of the account's zones. Any failure to enumerate zones + // (e.g. a token without zone read scope) degrades to "no routes" + // rather than failing the read. + const readWorkerRoutes = (scriptName: string) => + Effect.gen(function* () { + const { accountId } = yield* yield* CloudflareEnvironment; + const accountZones = yield* listAllZones(accountId).pipe( + Effect.catch(() => Effect.succeed([])), + ); + return yield* listWorkerRoutesInZones( + scriptName, + accountZones.map((zone) => zone.id), + ); + }); + + // Converge the zone routes attached to `scriptName` to `desired`. + // Observed cloud state (not `previous`) is the diff baseline — + // `previous` only contributes zone ids so routes moved out of a zone + // are still cleaned up after state loss or an interrupted apply. + const reconcileRoutes = ( + scriptName: string, + desired: NormalizedWorkerRoute[], + previous: Worker["Attributes"]["routes"], + ) => + Effect.gen(function* () { + const zoneIds = Array.from( + new Set([ + ...desired.map((route) => route.zoneId), + ...previous.map((route) => route.zoneId), + ]), + ); + const liveAll = yield* listWorkerRoutesInZones(scriptName, zoneIds); + const desiredKeys = new Set(desired.map(routeKey)); + const liveByKey = new Map( + liveAll.map((route) => [routeKey(route), route]), + ); + + const toRemove = liveAll.filter( + (route) => !desiredKeys.has(routeKey(route)), + ); + yield* Effect.all( + toRemove.map((route) => + workers + .deleteRoute({ zoneId: route.zoneId, routeId: route.id }) + .pipe(Effect.catchTag("RouteNotFound", () => Effect.void)), + ), + { concurrency: "unbounded" }, + ); + + if (desired.length === 0) return []; + + const attachRoute = Effect.fn(function* ( + route: NormalizedWorkerRoute, + ) { + const existing = liveByKey.get(routeKey(route)); + if (existing) return existing; + + const zoneRoutes = yield* workers + .listRoutes({ zoneId: route.zoneId }) + .pipe( + Effect.map((response) => response.result ?? []), + Effect.catch(() => Effect.succeed([])), + ); + const otherOwner = zoneRoutes.find( + (candidate) => + candidate.pattern === route.pattern && + candidate.script && + candidate.script !== scriptName, + ); + if (otherOwner) { + return yield* Effect.die( + new Error( + `Cannot attach route '${route.pattern}' to Worker '${scriptName}': ` + + `it is already attached to Worker '${otherOwner.script}'. ` + + `Remove it from that Worker first, or pick a different pattern.`, + ), + ); + } + + // A duplicate-pattern failure means another actor (or a crashed + // previous reconcile) created the route between our observation + // and now — re-list and converge if it points at this script. + const created = yield* workers + .createRoute({ + zoneId: route.zoneId, + pattern: route.pattern, + script: scriptName, + }) + .pipe( + // Same eventual-consistency window as `putDomain`: creating + // a route right after `putScript` can race Cloudflare's + // script registry, which rejects with code 10019 ("Cannot + // configure a route for a Worker which does not exist") — + // typed as `RouteScriptNotFound` via the createRoute patch. + Effect.retry({ + while: (error) => error._tag === "RouteScriptNotFound", + schedule: Schedule.exponential(200).pipe( + Schedule.both(Schedule.recurs(15)), + ), + }), + Effect.catchTag("InvalidRoute", (originalError) => + Effect.gen(function* () { + const match = yield* workers + .listRoutes({ zoneId: route.zoneId }) + .pipe( + Effect.map((response) => + (response.result ?? []).find( + (candidate) => + candidate.pattern === route.pattern && + candidate.script === scriptName, + ), + ), + Effect.catch(() => Effect.succeed(undefined)), + ); + if (!match?.id) { + return yield* Effect.fail(originalError); + } + return { id: match.id, pattern: match.pattern }; + }), + ), + ); + return { + id: created.id, + pattern: created.pattern, + zoneId: route.zoneId, + }; + }); + + return yield* Effect.all(desired.map(attachRoute), { + concurrency: "unbounded", + }); + }); + const createAlchemyWorkerTags = (id: string) => [ `alchemy:stack:${stack.name}`, `alchemy:stage:${stack.stage}`, @@ -1351,7 +1572,8 @@ export const LiveWorkerProvider = () => ); // Workers for Platforms user workers are invoked via dynamic dispatch, // never routed directly — they have no workers.dev subdomain, custom - // domains, or cron triggers. Skip all of that reconciliation. + // domains, zone routes, or cron triggers. Skip all of that + // reconciliation. if (dispatchNamespace) { return { workerId: worker.id ?? name, @@ -1363,6 +1585,7 @@ export const LiveWorkerProvider = () => durableObjectNamespaces, accountId, domains: [], + routes: [], crons: [], hash, } satisfies Worker["Attributes"]; @@ -1429,6 +1652,18 @@ export const LiveWorkerProvider = () => ...reconciled.map((d) => `https://${d.hostname}`), ...(workersDevUrl ? [workersDevUrl] : []), ]; + const desiredRoutes = yield* normalizeRoutes(news.routes); + const previousRoutes = output?.routes ?? []; + if (desiredRoutes.length > 0 || previousRoutes.length > 0) { + yield* session.note( + `Reconciling worker routes (${desiredRoutes.length}) ...`, + ); + } + const routes = yield* reconcileRoutes( + name, + desiredRoutes, + previousRoutes, + ); const crons = yield* reconcileCrons( name, normalizeCrons([...getCronBindings(bindings), ...(news.crons ?? [])]), @@ -1445,6 +1680,7 @@ export const LiveWorkerProvider = () => durableObjectNamespaces, accountId, domains, + routes, crons, hash, } satisfies Worker["Attributes"]; @@ -1575,6 +1811,7 @@ export const LiveWorkerProvider = () => tags: script.tags ?? undefined, durableObjectNamespaces: {}, domains: [], + routes: [], crons: [], }, ] @@ -1630,6 +1867,13 @@ export const LiveWorkerProvider = () => const cronsChanged = newCrons.length !== oldCrons.length || newCrons.some((cron, index) => cron !== oldCrons[index]); + const newRouteKeys = (yield* normalizeRoutes(news.routes)) + .map(routeKey) + .sort(); + const oldRouteKeys = (output?.routes ?? []).map(routeKey).sort(); + const routesChanged = + newRouteKeys.length !== oldRouteKeys.length || + newRouteKeys.some((key, index) => key !== oldRouteKeys[index]); // `url` is `domains[0]`: the first custom domain in user order if // any, otherwise the workers.dev URL (derived from the stable // worker name + account subdomain). It's stable across this update @@ -1683,6 +1927,7 @@ export const LiveWorkerProvider = () => newDoClassNames.every((name, i) => name === oldDoClassNames[i]); if ( domainsChanged || + routesChanged || cronsChanged || (yield* hasChanged( id, @@ -1739,6 +1984,7 @@ export const LiveWorkerProvider = () => durableObjectNamespaces: {}, accountId, domains: [], + routes: [], crons: [], } satisfies Worker["Attributes"]; } @@ -1922,6 +2168,7 @@ export const LiveWorkerProvider = () => durableObjectNamespaces, accountId, domains: [], + routes: [], crons: [], } satisfies Worker["Attributes"]; }), @@ -1958,6 +2205,7 @@ export const LiveWorkerProvider = () => tags: settings.tags ?? undefined, durableObjectNamespaces: getDurableObjects(settings.bindings), domains: [], + routes: [], crons: [], } satisfies Worker["Attributes"]; return hasAlchemyWorkerTags(id, settings.tags ?? []) @@ -1973,22 +2221,27 @@ export const LiveWorkerProvider = () => // `WorkerNotFound` if the script doesn't exist, which the // surrounding `Effect.catchTag` turns into `undefined` — that's // all the existence check we need. - const [subdomain, settings, domainsList] = yield* Effect.all([ - workers.getScriptSubdomain({ - accountId, - scriptName: workerName, - }), - workers.getScriptScriptAndVersionSetting({ - accountId, - scriptName: workerName, - }), - workers - .listDomains({ - accountId, - service: workerName, - }) - .pipe(Effect.map((r) => r.result ?? [])), - ]); + const [subdomain, settings, domainsList, routesList] = + yield* Effect.all( + [ + workers.getScriptSubdomain({ + accountId, + scriptName: workerName, + }), + workers.getScriptScriptAndVersionSetting({ + accountId, + scriptName: workerName, + }), + workers + .listDomains({ + accountId, + service: workerName, + }) + .pipe(Effect.map((r) => r.result ?? [])), + readWorkerRoutes(workerName), + ], + { concurrency: "unbounded" }, + ); // Preserve the order the user provided in `olds.domain`. The // Cloudflare API returns domains in non-deterministic order, // which would cause downstream `worker.domains[0]` reads to flip @@ -2027,6 +2280,7 @@ export const LiveWorkerProvider = () => tags: settings.tags ?? undefined, durableObjectNamespaces: getDurableObjects(settings.bindings), domains, + routes: routesList, crons, } satisfies Worker["Attributes"]; @@ -2193,6 +2447,22 @@ export const LiveWorkerProvider = () => { concurrency: "unbounded" }, ); } + // Routes are zone-scoped; enumerating every zone live is + // expensive, so trust the persisted route ids (refreshed by + // `read`) and tolerate already-deleted routes. + if (output.routes?.length) { + yield* Effect.all( + output.routes.map((route) => + workers + .deleteRoute({ + zoneId: route.zoneId, + routeId: route.id, + }) + .pipe(Effect.catchTag("RouteNotFound", () => Effect.void)), + ), + { concurrency: "unbounded" }, + ); + } yield* deleteWorkerScript( output.accountId, output.workerName, diff --git a/packages/alchemy/test/Cloudflare/Utils/Http.ts b/packages/alchemy/test/Cloudflare/Utils/Http.ts index a347b73441..9d079d4326 100644 --- a/packages/alchemy/test/Cloudflare/Utils/Http.ts +++ b/packages/alchemy/test/Cloudflare/Utils/Http.ts @@ -33,6 +33,12 @@ export class HttpFetchFailed extends Data.TaggedError("HttpFetchFailed")<{ message: string; }> {} +export class HttpMarkerPresent extends Data.TaggedError("HttpMarkerPresent")<{ + url: string; + marker: string; + bodyExcerpt: string; +}> {} + export interface ExpectUrlContainsOptions { /** Maximum total time to retry before failing. Default 90s. */ timeout?: Duration.Input; @@ -139,3 +145,75 @@ export const expectUrlContains = ( ), ); }; + +const fetchOnceAbsent = (url: string, marker: string) => + Effect.tryPromise({ + try: async (signal) => { + const u = new URL(url); + u.searchParams.set("__alchemy_cb", String(Date.now())); + const res = await fetch(u, { + signal, + cache: "no-store", + headers: { + "cache-control": "no-cache", + pragma: "no-cache", + accept: "*/*", + }, + }); + const body = await res.text(); + if (body.includes(marker)) { + throw new HttpMarkerPresent({ + url, + marker, + bodyExcerpt: body.slice(0, 240), + }); + } + return body; + }, + catch: (e) => + e instanceof HttpMarkerPresent + ? e + : new HttpFetchFailed({ + url, + message: e instanceof Error ? e.message : String(e), + }), + }); + +/** + * Fetch `url` and assert the response body does *not* contain `marker`. + * Retries while the marker is still present so a briefly-overbroad route + * fails loudly instead of slipping through on the first fetch. + */ +export const expectUrlAbsent = ( + url: string, + marker: string, + options: ExpectUrlContainsOptions = {}, +) => { + const totalTimeout = Duration.fromInputUnsafe( + options.timeout ?? "90 seconds", + ); + const initial = options.initialBackoff ?? "750 millis"; + const label = options.label ?? "url"; + + return fetchOnceAbsent(url, marker).pipe( + Effect.retry({ + schedule: Schedule.exponential(initial, 1.5).pipe( + Schedule.either(Schedule.spaced("8 seconds")), + ), + }), + Effect.timeoutOrElse({ + duration: totalTimeout, + orElse: () => + Effect.fail( + new HttpMarkerPresent({ + url, + marker, + bodyExcerpt: `[timed out after ${Duration.toMillis(totalTimeout)}ms waiting for marker "${marker}" to disappear]`, + }), + ), + }), + Effect.tapError((error) => + Effect.logError(`expectUrlAbsent(${label}) failed`, error), + ), + ); +}; diff --git a/packages/alchemy/test/Cloudflare/Workers/WorkerRoutes.test.ts b/packages/alchemy/test/Cloudflare/Workers/WorkerRoutes.test.ts new file mode 100644 index 0000000000..a130afbfa6 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Workers/WorkerRoutes.test.ts @@ -0,0 +1,394 @@ +import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; +import * as Cloudflare from "@/Cloudflare/index.ts"; +import { findZoneByName } from "@/Cloudflare/Zone/lookup"; +import * as Test from "@/Test/Vitest"; +import * as dns from "@distilled.cloud/cloudflare/dns"; +import * as workers from "@distilled.cloud/cloudflare/workers"; +import { expect } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as Stream from "effect/Stream"; +import * as pathe from "pathe"; +import { expectUrlAbsent, expectUrlContains } from "../Utils/Http.ts"; +import { waitForWorkerToBeDeleted } from "../Utils/Worker.ts"; + +const { test } = Test.make({ providers: Cloudflare.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const main = pathe.resolve(import.meta.dirname, "fixtures/worker.ts"); + +const zoneName = + process.env.CLOUDFLARE_TEST_WORKER_ROUTE_ZONE_NAME ?? + process.env.CLOUDFLARE_TEST_R2_DOMAIN_ZONE_NAME ?? + "alchemy-test-2.us"; + +// Deterministic per-test path prefixes on the zone apex. Each test owns a +// disjoint prefix so reruns and parallel runs never collide, and the same +// patterns are reused on every run (never derive names from Date.now()). +const routeSuffix = `alchemy-worker-route-${process.env.PULL_REQUEST ?? process.env.USER}`; + +const workerMarker = "Hello from TestWorker"; + +const resolveZoneId = Effect.gen(function* () { + const { accountId } = yield* yield* CloudflareEnvironment; + const zone = yield* findZoneByName({ accountId, name: zoneName }); + if (!zone) { + return yield* Effect.die( + new Error(`zone "${zoneName}" not found in account`), + ); + } + return zone.id; +}); + +// A freshly-minted scoped API token propagates eventually-consistently +// across Cloudflare's edge — retry the typed `Forbidden` blips on the +// tests' own out-of-band verification calls. +const forbiddenRetrySchedule = Schedule.exponential("500 millis"); + +const listByPattern = (zoneId: string, pattern: string) => + workers.listRoutes.items({ zoneId }).pipe( + Stream.filter((r) => r.pattern === pattern), + Stream.runCollect, + Effect.map((chunk) => Array.from(chunk)), + Effect.retry({ + while: (e) => e._tag === "Forbidden", + schedule: forbiddenRetrySchedule, + times: 8, + }), + ); + +const findRoute = (zoneId: string, pattern: string) => + listByPattern(zoneId, pattern).pipe(Effect.map((rs) => rs[0])); + +// Workers only run on proxied hostnames, so the zone apex needs a proxied +// placeholder record for route-matched requests to reach Cloudflare's edge +// at all. The record is standing test-zone infrastructure (like the zone +// itself): ensure it exists out-of-band, never tear it down. +const ensureApexPlaceholder = (zoneId: string) => + Effect.gen(function* () { + const existing = yield* dns.listRecords.items({ zoneId }).pipe( + Stream.filter( + (r) => r.name === zoneName && (r.type === "A" || r.type === "AAAA"), + ), + Stream.runCollect, + Effect.map((chunk) => Array.from(chunk)[0]), + Effect.retry({ + while: (e) => e._tag === "Forbidden", + schedule: forbiddenRetrySchedule, + times: 8, + }), + ); + if (existing) return; + yield* dns.createRecord({ + zoneId, + name: zoneName, + type: "AAAA", + content: "100::", + proxied: true, + ttl: 1, + comment: "standing placeholder so Workers routes serve on the zone apex", + }); + }); + +// Delete every route matching the pattern — purge leftovers from +// interrupted runs so tests start from a clean slate (Cloudflare enforces +// one route per pattern per zone). +const purgeRoutes = (zoneId: string, ...patterns: string[]) => + Effect.forEach(patterns, (pattern) => + listByPattern(zoneId, pattern).pipe( + Effect.flatMap( + Effect.forEach((r) => + workers + .deleteRoute({ zoneId, routeId: r.id }) + .pipe(Effect.catch(() => Effect.void)), + ), + ), + ), + ); + +// --- lifecycle: create → no-op → update (change + add) → remove --------- + +const T1_V1 = `${zoneName}/${routeSuffix}/t1/api/*`; +const T1_V2 = `${zoneName}/${routeSuffix}/t1/api/v2/*`; +const T1_ADDED = `${zoneName}/${routeSuffix}/t1/other/*`; +const T1_MATCH_URL = `https://${zoneName}/${routeSuffix}/t1/api/ping`; +const T1_MISS_URL = `https://${zoneName}/${routeSuffix}/t1/unknown`; + +test.provider.skipIf(!zoneName)( + "creates, keeps, updates, and removes worker zone routes", + (stack) => + Effect.gen(function* () { + const { accountId } = yield* yield* CloudflareEnvironment; + const zoneId = yield* resolveZoneId; + + yield* stack.destroy(); + yield* purgeRoutes(zoneId, T1_V1, T1_V2, T1_ADDED); + yield* ensureApexPlaceholder(zoneId); + + let workerName: string | undefined; + + yield* Effect.gen(function* () { + // Create — zone resolved from `zoneName`. + const worker = yield* stack.deploy( + Effect.gen(function* () { + return yield* Cloudflare.Worker("RouteWorker", { + main, + url: false, + routes: [{ pattern: T1_V1, zoneName }], + }); + }), + ); + workerName = worker.workerName; + + expect(worker.routes).toHaveLength(1); + expect(worker.routes[0]?.pattern).toEqual(T1_V1); + expect(worker.routes[0]?.zoneId).toEqual(zoneId); + const initialRouteId = worker.routes[0]!.id; + + const liveRoute = yield* findRoute(zoneId, T1_V1); + expect(liveRoute?.script).toEqual(worker.workerName); + + yield* expectUrlContains(T1_MATCH_URL, workerMarker, { + label: "worker route match", + timeout: "60 seconds", + }); + yield* expectUrlAbsent(T1_MISS_URL, workerMarker, { + label: "worker route miss", + timeout: "30 seconds", + }); + + // No-op — identical props must not churn the route (same id). + const noop = yield* stack.deploy( + Effect.gen(function* () { + return yield* Cloudflare.Worker("RouteWorker", { + main, + url: false, + routes: [{ pattern: T1_V1, zoneName }], + }); + }), + ); + expect(noop.routes).toHaveLength(1); + expect(noop.routes[0]?.id).toEqual(initialRouteId); + + // Update — change the first pattern (delete + create, addressed by + // explicit `zoneId`) and add a second route whose zone is inferred + // from the pattern's hostname. + const updated = yield* stack.deploy( + Effect.gen(function* () { + return yield* Cloudflare.Worker("RouteWorker", { + main, + url: false, + routes: [{ pattern: T1_V2, zoneId }, { pattern: T1_ADDED }], + }); + }), + ); + + const updatedPatterns = updated.routes.map((r) => r.pattern).sort(); + expect(updatedPatterns).toEqual([T1_V2, T1_ADDED].sort()); + expect(updated.routes.every((r) => r.zoneId === zoneId)).toBe(true); + + expect(yield* findRoute(zoneId, T1_V1)).toBeUndefined(); + expect((yield* findRoute(zoneId, T1_V2))?.script).toEqual( + worker.workerName, + ); + expect((yield* findRoute(zoneId, T1_ADDED))?.script).toEqual( + worker.workerName, + ); + + // Remove — omitting the `routes` prop entirely detaches everything + // recorded in the previous state. + const removed = yield* stack.deploy( + Effect.gen(function* () { + return yield* Cloudflare.Worker("RouteWorker", { + main, + url: false, + }); + }), + ); + expect(removed.routes).toHaveLength(0); + + expect(yield* findRoute(zoneId, T1_V2)).toBeUndefined(); + expect(yield* findRoute(zoneId, T1_ADDED)).toBeUndefined(); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + yield* stack.destroy().pipe(Effect.ignore); + if (workerName) { + yield* waitForWorkerToBeDeleted(workerName, accountId).pipe( + Effect.ignore, + ); + } + }), + ), + ); + }).pipe(logLevel), + { timeout: 300_000 }, +); + +// --- observed state is the baseline: drift removal + destroy cleanup ---- + +const T2_KEPT = `${zoneName}/${routeSuffix}/t2/api/*`; +const T2_DRIFT = `${zoneName}/${routeSuffix}/t2/drift/*`; + +test.provider.skipIf(!zoneName)( + "removes out-of-band routes on update and detaches routes on destroy", + (stack) => + Effect.gen(function* () { + const { accountId } = yield* yield* CloudflareEnvironment; + const zoneId = yield* resolveZoneId; + + yield* stack.destroy(); + yield* purgeRoutes(zoneId, T2_KEPT, T2_DRIFT); + + let workerName: string | undefined; + + yield* Effect.gen(function* () { + const worker = yield* stack.deploy( + Effect.gen(function* () { + return yield* Cloudflare.Worker("DriftRouteWorker", { + main, + url: false, + compatibility: { date: "2024-01-01" }, + routes: [{ pattern: T2_KEPT, zoneName }], + }); + }), + ); + workerName = worker.workerName; + const keptRouteId = worker.routes[0]!.id; + + // Attach a route to the same script out-of-band — the reconciler + // never recorded it, so it is pure drift. + const drift = yield* workers + .createRoute({ + zoneId, + pattern: T2_DRIFT, + script: worker.workerName, + }) + .pipe( + Effect.retry({ + while: (e) => e._tag === "Forbidden", + schedule: forbiddenRetrySchedule, + times: 8, + }), + ); + expect(drift.id).toBeDefined(); + + // Force an update (compat date bump) with the same desired routes: + // the kept route must survive untouched (same id) and the drift + // route — observed in the same zone, attached to this script, not + // desired — must be removed. + const updated = yield* stack.deploy( + Effect.gen(function* () { + return yield* Cloudflare.Worker("DriftRouteWorker", { + main, + url: false, + compatibility: { date: "2024-01-02" }, + routes: [{ pattern: T2_KEPT, zoneName }], + }); + }), + ); + + expect(updated.routes).toHaveLength(1); + expect(updated.routes[0]?.id).toEqual(keptRouteId); + expect(yield* findRoute(zoneId, T2_DRIFT)).toBeUndefined(); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + yield* stack.destroy().pipe(Effect.ignore); + if (workerName) { + yield* waitForWorkerToBeDeleted(workerName, accountId).pipe( + Effect.ignore, + ); + } + }), + ), + ); + + // Destroying the worker detaches its routes from the zone. + expect(yield* findRoute(zoneId, T2_KEPT)).toBeUndefined(); + }).pipe(logLevel), + { timeout: 300_000 }, +); + +// --- refusal: a pattern already routed to a different Worker ------------ + +const T3_PATTERN = `${zoneName}/${routeSuffix}/t3/api/*`; + +test.provider.skipIf(!zoneName)( + "refuses to steal a route pattern attached to another Worker", + (stack) => + Effect.gen(function* () { + const zoneId = yield* resolveZoneId; + + yield* stack.destroy(); + yield* purgeRoutes(zoneId, T3_PATTERN); + + yield* Effect.gen(function* () { + const owner = yield* stack.deploy( + Effect.gen(function* () { + return yield* Cloudflare.Worker("RouteOwnerWorker", { + main, + url: false, + routes: [{ pattern: T3_PATTERN, zoneName }], + }); + }), + ); + + const error = yield* stack + .deploy( + Effect.gen(function* () { + const owner = yield* Cloudflare.Worker("RouteOwnerWorker", { + main, + url: false, + routes: [{ pattern: T3_PATTERN, zoneName }], + }); + const thief = yield* Cloudflare.Worker("RouteThiefWorker", { + main, + url: false, + routes: [{ pattern: T3_PATTERN, zoneName }], + }); + return { owner, thief }; + }), + ) + .pipe( + Effect.as(undefined), + Effect.catchCause((cause) => + Effect.succeed(findAttachRefusal(cause)), + ), + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("already attached to Worker"); + + // The pattern still routes to its original owner. + const live = yield* findRoute(zoneId, T3_PATTERN); + expect(live?.script).toEqual(owner.workerName); + }).pipe(Effect.ensuring(stack.destroy().pipe(Effect.ignore))); + }).pipe(logLevel), + { timeout: 300_000 }, +); + +/** + * Pull the attach-refusal `Error` out of a Cause regardless of whether the + * engine surfaced it as a typed failure or a defect (`Effect.die`). + */ +const findAttachRefusal = (cause: Cause.Cause): Error | undefined => + cause.reasons + .map((reason) => + Cause.isFailReason(reason) + ? reason.error + : Cause.isDieReason(reason) + ? reason.defect + : undefined, + ) + .find( + (value): value is Error => + value instanceof Error && + value.message.includes("already attached to Worker"), + );