From 2346c351da2bf559e522fe1aed16f50b8fa2f8e2 Mon Sep 17 00:00:00 2001 From: utopy <30875147+utopyin@users.noreply.github.com> Date: Tue, 26 May 2026 13:27:03 +0200 Subject: [PATCH 1/5] feat(cloudflare/worker): add zone routes prop --- .../Cloudflare/Workers/LocalWorkerProvider.ts | 2 + .../alchemy/src/Cloudflare/Workers/Worker.ts | 349 +++++++++++++++--- .../alchemy/test/Cloudflare/Utils/Http.ts | 78 ++++ .../Cloudflare/Workers/WorkerRoutes.test.ts | 119 ++++++ 4 files changed, 499 insertions(+), 49 deletions(-) create mode 100644 packages/alchemy/test/Cloudflare/Workers/WorkerRoutes.test.ts diff --git a/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts b/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts index 32ab1c0882..e09dfeeb1c 100644 --- a/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts +++ b/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts @@ -437,6 +437,7 @@ export const LocalWorkerProvider = () => tags: [], durableObjectNamespaces: config.durableObjectNamespaces, domains: [url], + routes: [], crons: Array.from( new Set([...getCronBindings(bindings), ...(props.crons ?? [])]), ), @@ -485,6 +486,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 93db31b1a3..72d68912d7 100644 --- a/packages/alchemy/src/Cloudflare/Workers/Worker.ts +++ b/packages/alchemy/src/Cloudflare/Workers/Worker.ts @@ -28,6 +28,7 @@ import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; import type { HyperdriveDevOrigin } from "../Hyperdrive/Hyperdrive.ts"; import { CloudflareLogs } from "../Logs.ts"; import type { Providers } from "../Providers.ts"; +import { resolveZoneId, type ZoneReference } from "../Zone/index.ts"; import { readAssets, uploadAssets, @@ -175,6 +176,28 @@ export type NormalizedBindings< export type WorkerAssetsConfig = string | AssetsProps | AssetsWithHash; +export interface WorkerRouteProps { + /** + * 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 = @@ -271,6 +294,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?: WorkerRouteProps[]; /** * Extra bundler options applied on top of the standard rolldown input/output * options used to build this Worker. See {@link Bundle.BundleExtraOptions}. @@ -320,6 +350,7 @@ export type Worker = Resource< durableObjectNamespaces: Record; accountId: string; domains: string[]; + routes: { id: string; pattern: string; zoneId: string }[]; crons: string[]; hash?: { assets: string | undefined; @@ -521,6 +552,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: "" }, + * ], + * } + * ``` + * * @section Observability * Cloudflare Workers Observability is on by default — `logs.enabled` and * `logs.invocationLogs` are turned on if you don't pass an `observability` @@ -787,6 +829,9 @@ export const LiveWorkerProvider = () => const putDomain = yield* workers.putDomain; const listDomains = yield* workers.listDomains; const deleteDomain = yield* workers.deleteDomain; + const createRoute = yield* workers.createRoute; + const deleteRoute = yield* workers.deleteRoute; + const listRoutes = yield* workers.listRoutes; const listZones = yield* zones.listZones; const telemetry = yield* CloudflareLogs; @@ -831,6 +876,212 @@ export const LiveWorkerProvider = () => ), ); + type NormalizedWorkerRoute = { + pattern: string; + zoneId: string; + }; + + const routeKey = (route: { pattern: string; zoneId: string }) => + `${route.zoneId}:${route.pattern}`; + + const hostnameFromPattern = (pattern: string): string => { + const hostPart = pattern.split("/")[0] ?? pattern; + return hostPart.startsWith("*.") + ? `routes.${hostPart.slice(2)}` + : hostPart; + }; + + /** + * Infer the Cloudflare Zone ID for a given hostname by listing the + * account's zones and matching the hostname against each zone's name — + * walking up the DNS label hierarchy until a match is found. + */ + const inferZoneIdForHostname = ( + hostname: string, + zoneCache: Map, + ) => + Effect.gen(function* () { + const cached = zoneCache.get(hostname); + if (cached) return cached; + + const zoneList = yield* listZones({}).pipe( + Effect.map((response) => response.result ?? []), + ); + for (const zone of zoneList) { + zoneCache.set(zone.name, zone.id); + } + + const parts = hostname.split("."); + for (let i = 0; i < parts.length - 1; i++) { + const candidate = parts.slice(i).join("."); + const match = zoneList.find((z) => z.name === candidate); + if (match) { + zoneCache.set(hostname, match.id); + return match.id; + } + } + return yield* Effect.die( + `Could not infer Cloudflare Zone for hostname "${hostname}". ` + + "Ensure the parent zone exists in this account.", + ); + }); + + const normalizeRoutes = (routes: WorkerRouteProps[] | undefined) => + Effect.gen(function* () { + if (!routes?.length) return [] as NormalizedWorkerRoute[]; + 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; + }); + + 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) => + listRoutes({ zoneId }).pipe( + Effect.map((response) => { + const routes = response.result ?? []; + return routes.flatMap((route) => { + const shouldDelete = + !route.id || !route.pattern || route.script !== scriptName; + return shouldDelete + ? [] + : [{ id: route.id, pattern: route.pattern, zoneId }]; + }); + }), + Effect.catch(() => Effect.succeed([])), + ), + ), + { concurrency: "unbounded" }, + ); + + return Effect.map(routesByZone, (routes) => routes.flat()); + }; + + const readWorkerRoutes = (scriptName: string) => + Effect.gen(function* () { + const zoneList = yield* listZones({}).pipe( + Effect.map((response) => response.result ?? []), + ); + const accountZones = zoneList.filter( + (zone) => zone.account?.id === accountId, + ); + return yield* listWorkerRoutesInZones( + scriptName, + accountZones.map((zone) => zone.id), + ); + }); + + 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) => + deleteRoute({ zoneId: route.zoneId, routeId: route.id }).pipe( + Effect.catchTag("RouteNotFound", () => Effect.void), + ), + ), + { concurrency: "unbounded" }, + ); + + if (desired.length === 0) return []; + + const attachRoute = Effect.fnUntraced(function* ( + route: NormalizedWorkerRoute, + ) { + const existing = liveByKey.get(routeKey(route)); + if (existing) return existing; + + const zoneRoutes = yield* 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.`, + ), + ); + } + + const created = yield* createRoute({ + zoneId: route.zoneId, + pattern: route.pattern, + script: scriptName, + }).pipe( + Effect.retry({ + while: (error: { _tag?: string }) => + error?._tag === "WorkerNotFound", + schedule: Schedule.exponential(200).pipe( + Schedule.both(Schedule.recurs(15)), + ), + }), + ); + return { + id: created.id, + pattern: created.pattern, + zoneId: route.zoneId, + }; + }); + + return yield* Effect.all(desired.map(attachRoute), { + concurrency: "unbounded", + }); + }); + const normalizeCrons = (crons: string[] | undefined): string[] => Array.from(new Set(crons ?? [])); @@ -885,41 +1136,6 @@ export const LiveWorkerProvider = () => ); }); - /** - * Infer the Cloudflare Zone ID for a given hostname by listing the - * account's zones and matching the hostname against each zone's name — - * walking up the DNS label hierarchy until a match is found. - */ - const inferZoneIdForHostname = ( - hostname: string, - zoneCache: Map, - ) => - Effect.gen(function* () { - const cached = zoneCache.get(hostname); - if (cached) return cached; - - const zoneList = yield* listZones({}).pipe( - Effect.map((response) => response.result ?? []), - ); - for (const zone of zoneList) { - zoneCache.set(zone.name, zone.id); - } - - const parts = hostname.split("."); - for (let i = 0; i < parts.length - 1; i++) { - const candidate = parts.slice(i).join("."); - const match = zoneList.find((z) => z.name === candidate); - if (match) { - zoneCache.set(hostname, match.id); - return match.id; - } - } - return yield* Effect.die( - `Could not infer Cloudflare Zone for hostname "${hostname}". ` + - "Ensure the parent zone exists in this account.", - ); - }); - const reconcileDomains = (scriptName: string, desired: string[]) => Effect.gen(function* () { // Always query the live state of domains attached to *this* @@ -1716,6 +1932,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 ?? [])]), @@ -1731,6 +1959,7 @@ export const LiveWorkerProvider = () => durableObjectNamespaces, accountId, domains, + routes, crons, hash, } satisfies Worker["Attributes"]; @@ -1830,6 +2059,12 @@ export const LiveWorkerProvider = () => const domainsChanged = newDomains.length !== oldDomains.length || newDomains.some((d, i) => d !== oldDomains[i]); + const newRoutes = yield* normalizeRoutes(news.routes); + const oldRoutes = (output?.routes ?? []).map(routeKey).sort(); + const newRouteKeys = newRoutes.map(routeKey).sort(); + const routesChanged = + newRouteKeys.length !== oldRoutes.length || + newRouteKeys.some((key, index) => key !== oldRoutes[index]); const newCrons = normalizeCrons([ ...(Array.isArray(newBindings) ? getCronBindings( @@ -1844,6 +2079,7 @@ export const LiveWorkerProvider = () => newCrons.some((cron, index) => cron !== oldCrons[index]); if ( domainsChanged || + routesChanged || cronsChanged || (yield* hasChanged(id, news, output)) ) { @@ -1988,6 +2224,7 @@ export const LiveWorkerProvider = () => durableObjectNamespaces, accountId, domains: [], + routes: [], crons: [], } satisfies Worker["Attributes"]; }), @@ -2006,20 +2243,22 @@ 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([ - getScriptSubdomain({ - accountId, - scriptName: workerName, - }), - getScriptSettings({ - accountId, - scriptName: workerName, - }), - listDomains({ - accountId, - service: workerName, - }).pipe(Effect.map((r) => r.result ?? [])), - ]); + const [subdomain, settings, domainsList, routesList] = + yield* Effect.all([ + getScriptSubdomain({ + accountId, + scriptName: workerName, + }), + getScriptSettings({ + accountId, + scriptName: workerName, + }), + listDomains({ + accountId, + service: workerName, + }).pipe(Effect.map((r) => r.result ?? [])), + readWorkerRoutes(workerName), + ]); // 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 @@ -2059,6 +2298,7 @@ export const LiveWorkerProvider = () => settings.bindings, ), domains, + routes: routesList, crons, } satisfies Worker["Attributes"]; @@ -2171,6 +2411,17 @@ export const LiveWorkerProvider = () => { concurrency: "unbounded" }, ); } + if (output.routes?.length) { + yield* Effect.all( + output.routes.map((route) => + deleteRoute({ + zoneId: route.zoneId, + routeId: route.id, + }).pipe(Effect.catchTag("RouteNotFound", () => Effect.void)), + ), + { concurrency: "unbounded" }, + ); + } yield* deleteScript({ accountId: output.accountId, scriptName: 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..9c4e75553e --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Workers/WorkerRoutes.test.ts @@ -0,0 +1,119 @@ +import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; +import * as Cloudflare from "@/Cloudflare/index.ts"; +import * as Test from "@/Test/Vitest"; +import * as workers from "@distilled.cloud/cloudflare/workers"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +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"; + +const routeSuffix = `alchemy-worker-route-${process.env.PULL_REQUEST ?? process.env.USER}`; +const routePattern = `${zoneName}/${routeSuffix}/api/*`; +const routeMatchUrl = `https://${zoneName}/${routeSuffix}/api/ping`; +const routeMissUrl = `https://${zoneName}/${routeSuffix}/unknown`; +const workerMarker = "Hello from TestWorker"; + +const findRoute = (zoneId: string, pattern: string, scriptName: string) => + workers + .listRoutes({ zoneId }) + .pipe( + Effect.map((response) => + (response.result ?? []).find( + (route) => route.pattern === pattern && route.script === scriptName, + ), + ), + ); + +test.provider.skipIf(!zoneName)( + "creates, updates, and deletes worker zone routes", + (stack) => + Effect.gen(function* () { + const { accountId } = yield* CloudflareEnvironment; + + yield* stack.destroy(); + + let workerName: string | undefined; + + yield* Effect.gen(function* () { + const worker = yield* stack.deploy( + Effect.gen(function* () { + return yield* Cloudflare.Worker("RouteWorker", { + main, + url: false, + routes: [{ pattern: routePattern, zoneName }], + }); + }), + ); + workerName = worker.workerName; + + expect(worker.routes).toHaveLength(1); + expect(worker.routes[0]?.pattern).toEqual(routePattern); + + const zoneId = worker.routes[0]!.zoneId; + const liveRoute = yield* findRoute( + zoneId, + routePattern, + worker.workerName, + ); + expect(liveRoute?.pattern).toEqual(routePattern); + expect(liveRoute?.script).toEqual(worker.workerName); + + yield* expectUrlContains(routeMatchUrl, workerMarker, { + label: "worker route match", + timeout: "60 seconds", + }); + + yield* expectUrlAbsent(routeMissUrl, workerMarker, { + label: "worker route miss", + timeout: "30 seconds", + }); + + const updated = yield* stack.deploy( + Effect.gen(function* () { + return yield* Cloudflare.Worker("RouteWorker", { + main, + url: false, + routes: [], + }); + }), + ); + + expect(updated.routes).toHaveLength(0); + + const deletedRoute = yield* findRoute( + zoneId, + routePattern, + worker.workerName, + ); + expect(deletedRoute).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: 240_000 }, +); From d23ec944fb28b49d8d34c6a5bde85d18d75fbc96 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Tue, 7 Jul 2026 22:53:15 -0700 Subject: [PATCH 2/5] test(cloudflare/workers): cover all worker routes reconciliation paths - no-op redeploy keeps the same route id - pattern change (delete+create) + added route with inferred zone - explicit zoneId, zoneName, and inference spellings - removal via omitting the routes prop - out-of-band drift removal with observed state as baseline - destroy detaches routes from the zone - refusal to steal a pattern attached to another Worker Also fix LocalWorkerProvider precreate attrs (routes: []) and the CloudflareEnvironment double-yield idiom. Co-Authored-By: Claude Fable 5 --- .../Cloudflare/Workers/LocalWorkerProvider.ts | 1 + .../Cloudflare/Workers/WorkerRoutes.test.ts | 309 ++++++++++++++++-- 2 files changed, 278 insertions(+), 32 deletions(-) diff --git a/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts b/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts index f8ba9535b1..9380ab428a 100644 --- a/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts +++ b/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts @@ -546,6 +546,7 @@ export const LocalWorkerProvider = () => tags: [], durableObjectNamespaces, domains: url ? [url] : [], + routes: [], crons: Array.from( new Set([...getCronBindings(bindings), ...(news.crons ?? [])]), ), diff --git a/packages/alchemy/test/Cloudflare/Workers/WorkerRoutes.test.ts b/packages/alchemy/test/Cloudflare/Workers/WorkerRoutes.test.ts index 9c4e75553e..94fbc9a2f8 100644 --- a/packages/alchemy/test/Cloudflare/Workers/WorkerRoutes.test.ts +++ b/packages/alchemy/test/Cloudflare/Workers/WorkerRoutes.test.ts @@ -1,10 +1,14 @@ 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 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"; @@ -23,85 +27,162 @@ const zoneName = 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 routePattern = `${zoneName}/${routeSuffix}/api/*`; -const routeMatchUrl = `https://${zoneName}/${routeSuffix}/api/ping`; -const routeMissUrl = `https://${zoneName}/${routeSuffix}/unknown`; + const workerMarker = "Hello from TestWorker"; -const findRoute = (zoneId: string, pattern: string, scriptName: string) => - workers - .listRoutes({ zoneId }) - .pipe( - Effect.map((response) => - (response.result ?? []).find( - (route) => route.pattern === pattern && route.script === scriptName, +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])); + +// 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, updates, and deletes worker zone routes", + "creates, keeps, updates, and removes worker zone routes", (stack) => Effect.gen(function* () { - const { accountId } = yield* CloudflareEnvironment; + const { accountId } = yield* yield* CloudflareEnvironment; + const zoneId = yield* resolveZoneId; yield* stack.destroy(); + yield* purgeRoutes(zoneId, T1_V1, T1_V2, T1_ADDED); 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: routePattern, zoneName }], + routes: [{ pattern: T1_V1, zoneName }], }); }), ); workerName = worker.workerName; expect(worker.routes).toHaveLength(1); - expect(worker.routes[0]?.pattern).toEqual(routePattern); + expect(worker.routes[0]?.pattern).toEqual(T1_V1); + expect(worker.routes[0]?.zoneId).toEqual(zoneId); + const initialRouteId = worker.routes[0]!.id; - const zoneId = worker.routes[0]!.zoneId; - const liveRoute = yield* findRoute( - zoneId, - routePattern, - worker.workerName, - ); - expect(liveRoute?.pattern).toEqual(routePattern); + const liveRoute = yield* findRoute(zoneId, T1_V1); expect(liveRoute?.script).toEqual(worker.workerName); - yield* expectUrlContains(routeMatchUrl, workerMarker, { + yield* expectUrlContains(T1_MATCH_URL, workerMarker, { label: "worker route match", timeout: "60 seconds", }); - - yield* expectUrlAbsent(routeMissUrl, workerMarker, { + 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: [], + routes: [{ pattern: T1_V2, zoneId }, { pattern: T1_ADDED }], }); }), ); - expect(updated.routes).toHaveLength(0); + 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); - const deletedRoute = yield* findRoute( - zoneId, - routePattern, + expect(yield* findRoute(zoneId, T1_V1)).toBeUndefined(); + expect((yield* findRoute(zoneId, T1_V2))?.script).toEqual( worker.workerName, ); - expect(deletedRoute).toBeUndefined(); + 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* () { @@ -115,5 +196,169 @@ test.provider.skipIf(!zoneName)( ), ); }).pipe(logLevel), - { timeout: 240_000 }, + { 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* () { + 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).toContain("RouteOwnerWorker"); + }).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"), + ); From 0d22fef155724deae7963dc863cf2d3846990cd5 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Tue, 7 Jul 2026 23:07:32 -0700 Subject: [PATCH 3/5] test(cloudflare/workers): self-heal the zone-apex placeholder record Workers routes only serve on proxied hostnames; the standing test zone lost its apex record (and universal cert) to a nuke. Ensure a proxied AAAA 100:: placeholder exists out-of-band before the HTTP assertions, and fix the conflict-test assertion to compare physical worker names. Co-Authored-By: Claude Fable 5 --- .../Cloudflare/Workers/WorkerRoutes.test.ts | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/alchemy/test/Cloudflare/Workers/WorkerRoutes.test.ts b/packages/alchemy/test/Cloudflare/Workers/WorkerRoutes.test.ts index 94fbc9a2f8..05b770316a 100644 --- a/packages/alchemy/test/Cloudflare/Workers/WorkerRoutes.test.ts +++ b/packages/alchemy/test/Cloudflare/Workers/WorkerRoutes.test.ts @@ -2,6 +2,7 @@ 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"; @@ -65,6 +66,36 @@ const listByPattern = (zoneId: string, pattern: string) => 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). @@ -98,6 +129,7 @@ test.provider.skipIf(!zoneName)( yield* stack.destroy(); yield* purgeRoutes(zoneId, T1_V1, T1_V2, T1_ADDED); + yield* ensureApexPlaceholder(zoneId); let workerName: string | undefined; @@ -298,7 +330,7 @@ test.provider.skipIf(!zoneName)( yield* purgeRoutes(zoneId, T3_PATTERN); yield* Effect.gen(function* () { - yield* stack.deploy( + const owner = yield* stack.deploy( Effect.gen(function* () { return yield* Cloudflare.Worker("RouteOwnerWorker", { main, @@ -336,7 +368,7 @@ test.provider.skipIf(!zoneName)( // The pattern still routes to its original owner. const live = yield* findRoute(zoneId, T3_PATTERN); - expect(live?.script).toContain("RouteOwnerWorker"); + expect(live?.script).toEqual(owner.workerName); }).pipe( Effect.ensuring(stack.destroy().pipe(Effect.ignore)), ); From e4f14ebf84d9845f1e68c480915fe79ee61781d9 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Tue, 7 Jul 2026 23:17:40 -0700 Subject: [PATCH 4/5] fix(cloudflare/workers): retry createRoute on typed WorkerNotFound Probed live: creating a route for a not-yet-propagated script rejects with code 10019, now typed as WorkerNotFound via distilled patch (alchemy-run/distilled#368). Drop the speculative InternalServerError / UnknownCloudflareError retry. Co-Authored-By: Claude Fable 5 --- .../src/Cloudflare/Workers/WorkerProvider.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts index b0d17ecf34..1ac945b275 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts @@ -26,11 +26,7 @@ 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, - type WorkerRouteConfig, -} 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"; @@ -840,11 +836,11 @@ export const LiveWorkerProvider = () => .pipe( // Same eventual-consistency window as `putDomain`: creating // a route right after `putScript` can race Cloudflare's - // script registry. Retry the transient tags. + // script registry, which rejects with code 10019 ("Cannot + // configure a route for a Worker which does not exist") — + // typed as `WorkerNotFound` via the createRoute patch. Effect.retry({ - while: (error) => - error._tag === "InternalServerError" || - error._tag === "UnknownCloudflareError", + while: (error) => error._tag === "WorkerNotFound", schedule: Schedule.exponential(200).pipe( Schedule.both(Schedule.recurs(15)), ), From 69edb8ae473293aa0ba18be94c0e0ce12bff0ae3 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Tue, 7 Jul 2026 23:24:23 -0700 Subject: [PATCH 5/5] fix(cloudflare/workers): retry createRoute on dedicated RouteScriptNotFound tag Scoped tag instead of widening the shared WorkerNotFound matchers (alchemy-run/distilled#368). Co-Authored-By: Claude Fable 5 --- distilled | 2 +- packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts | 4 ++-- packages/alchemy/test/Cloudflare/Workers/WorkerRoutes.test.ts | 4 +--- 3 files changed, 4 insertions(+), 6 deletions(-) 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/WorkerProvider.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts index 1ac945b275..ac4b550849 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts @@ -838,9 +838,9 @@ export const LiveWorkerProvider = () => // 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 `WorkerNotFound` via the createRoute patch. + // typed as `RouteScriptNotFound` via the createRoute patch. Effect.retry({ - while: (error) => error._tag === "WorkerNotFound", + while: (error) => error._tag === "RouteScriptNotFound", schedule: Schedule.exponential(200).pipe( Schedule.both(Schedule.recurs(15)), ), diff --git a/packages/alchemy/test/Cloudflare/Workers/WorkerRoutes.test.ts b/packages/alchemy/test/Cloudflare/Workers/WorkerRoutes.test.ts index 05b770316a..a130afbfa6 100644 --- a/packages/alchemy/test/Cloudflare/Workers/WorkerRoutes.test.ts +++ b/packages/alchemy/test/Cloudflare/Workers/WorkerRoutes.test.ts @@ -369,9 +369,7 @@ test.provider.skipIf(!zoneName)( // 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(Effect.ensuring(stack.destroy().pipe(Effect.ignore))); }).pipe(logLevel), { timeout: 300_000 }, );