1import * as workers from "@distilled.cloud/cloudflare/workers";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { Unowned } from "../../AdoptPolicy.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11import { listAllZones } from "../Zone/lookup.ts";
12
13export interface WorkerRouteProps {
14 /**
15 * Zone the route lives in. Stable — routes are scoped to a zone, so
16 * changing the zone triggers a replacement.
17 */
18 zoneId: string;
19 /**
20 * Pattern to match incoming requests against, e.g.
21 * `api.example.com/*`. The pattern must match a hostname inside the
22 * zone identified by {@link zoneId}.
23 *
24 * Mutable — Cloudflare's PUT endpoint accepts a new pattern in place.
25 * Declared as plain `string` (not `string`) so the reconciler
26 * can locate an existing route by pattern after state loss.
27 */
28 pattern: string;
29 /**
30 * Name of the Worker script to run when the route matches. Accepts a
31 * reference to a deployed Worker's `workerName`. When omitted, the
32 * route disables Workers for matching requests (useful to opt a path
33 * out of a broader wildcard route).
34 *
35 * Mutable — updated in place.
36 */
37 script?: string;
38}
39
40export interface WorkerRouteAttributes {
41 /** Cloudflare-assigned route identifier. */
42 routeId: string;
43 /** Zone that owns this route. */
44 zoneId: string;
45 /** Pattern the route matches. */
46 pattern: string;
47 /** Worker script the route runs, or `undefined` for an opt-out route. */
48 script: string | undefined;
49}
50
51export type WorkerRoute = Resource<
52 "Cloudflare.Workers.Route",
53 WorkerRouteProps,
54 WorkerRouteAttributes,
55 never,
56 Providers
57>;
58
59/**
60 * A Workers Route — a zone-level mapping from a URL pattern to a Worker
61 * script.
62 *
63 * Routes are the classic way to serve a Worker on a zone hostname or
64 * path. The matched hostname must resolve through Cloudflare's proxy,
65 * so pair the route with a proxied DNS record (an `AAAA 100::`
66 * placeholder is the conventional choice when the Worker is the only
67 * origin).
68 *
69 * Safety: routes carry no ownership markers, and Cloudflare enforces
70 * one route per pattern per zone. When there is no prior state, `read`
71 * scans the zone for an existing route with the same pattern and
72 * reports it as `Unowned`, so the engine refuses to take it over unless
73 * `--adopt` (or `adopt(true)`) is set.
74 * ### Routing a hostname to a Worker
75 * **Example:** Route all requests on a subdomain to a Worker
76 * ```typescript
77 * const worker = yield* Cloudflare.Worker("Api", {
78 * main: "./src/api.ts",
79 * });
80 *
81 * yield* Cloudflare.Workers.WorkerRoute("ApiRoute", {
82 * zoneId: zone.zoneId,
83 * pattern: "api.example.com/*",
84 * script: worker.workerName,
85 * });
86 *
87 * // Workers only run on proxied hostnames — give the host an origin.
88 * yield* Cloudflare.DNS.Record("ApiPlaceholder", {
89 * zoneId: zone.zoneId,
90 * name: "api.example.com",
91 * type: "AAAA",
92 * content: "100::",
93 * proxied: true,
94 * });
95 * ```
96 *
97 * ### Disabling Workers on a path
98 * **Example:** Opt a path out of a wildcard route
99 * ```typescript
100 * // No `script` — matching requests bypass Workers entirely.
101 * yield* Cloudflare.Workers.WorkerRoute("AssetsBypass", {
102 * zoneId: zone.zoneId,
103 * pattern: "example.com/assets/*",
104 * });
105 * ```
106 *
107 * @resource
108 * @product Workers
109 * @category Workers & Compute
110 */
111export const WorkerRoute = Resource<WorkerRoute>("Cloudflare.Workers.Route");
112
113export const isWorkerRoute = (value: unknown): value is WorkerRoute =>
114 Predicate.hasProperty(value, "Type") &&
115 value.Type === "Cloudflare.Workers.Route";
116
117export const WorkerRouteProvider = () =>
118 Provider.succeed(WorkerRoute, {
119 stables: ["routeId", "zoneId"],
120
121 diff: Effect.fn(function* ({ olds = {}, news }) {
122 const o = olds as WorkerRouteProps;
123 const n = news as WorkerRouteProps;
124 // zoneId is Input<string>; by diff time both sides are concrete
125 // strings when statically knowable.
126 if (
127 typeof o.zoneId === "string" &&
128 typeof n.zoneId === "string" &&
129 o.zoneId !== n.zoneId
130 ) {
131 return { action: "replace" } as const;
132 }
133 }),
134
135 read: Effect.fn(function* ({ output, olds }) {
136 // Owned path: refresh by our persisted route id.
137 if (output?.routeId) {
138 const observed = yield* observeById(output.zoneId, output.routeId);
139 if (observed) {
140 return toAttributes(observed, output.zoneId);
141 }
142 }
143 // Adoption path: no state of our own, but Cloudflare enforces one
144 // route per pattern per zone, so a `(zoneId, pattern)` match is
145 // the same logical route. Routes carry no ownership markers, so
146 // brand it `Unowned` and let the engine gate takeover behind the
147 // adopt policy.
148 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
149 const pattern = output?.pattern ?? olds?.pattern;
150 if (zoneId && pattern) {
151 const observed = yield* findByPattern(zoneId, pattern);
152 if (observed) {
153 return Unowned(toAttributes(observed, zoneId));
154 }
155 }
156 return undefined;
157 }),
158
159 reconcile: Effect.fn(function* ({ news, output }) {
160 // Inputs have been resolved to concrete strings by Plan.
161 const zoneId = news.zoneId as string;
162 const script = news.script as string | undefined;
163
164 // 1. Observe by cached id first.
165 let observed = output?.routeId
166 ? yield* observeById(zoneId, output.routeId)
167 : undefined;
168
169 // 2. Fall back to scanning the zone for the pattern. Ownership has
170 // already been verified upstream — `read` reports existing
171 // routes as `Unowned` and the engine gates takeover behind the
172 // adopt policy before reconcile ever runs.
173 if (!observed) {
174 observed = yield* findByPattern(zoneId, news.pattern);
175 }
176
177 // 3. Ensure. A duplicate-pattern failure means another actor (or a
178 // crashed previous reconcile) created the route between our
179 // observation and now — treat it as a race and converge via PUT.
180 if (!observed) {
181 observed = yield* workers
182 .createRoute({ zoneId, pattern: news.pattern, script })
183 .pipe(
184 Effect.map(normalizeRoute),
185 Effect.catchTag("InvalidRoute", (originalError) =>
186 Effect.gen(function* () {
187 const match = yield* findByPattern(zoneId, news.pattern);
188 if (!match) {
189 return yield* Effect.fail(originalError);
190 }
191 return match;
192 }),
193 ),
194 );
195 }
196
197 // 4. Sync — PUT resends the full desired body when the observed
198 // route drifts from the desired pattern/script.
199 if (observed.pattern !== news.pattern || observed.script !== script) {
200 observed = normalizeRoute(
201 yield* workers.updateRoute({
202 zoneId,
203 routeId: observed.id,
204 pattern: news.pattern,
205 script,
206 }),
207 );
208 }
209
210 // 5. Return.
211 return toAttributes(observed, zoneId);
212 }),
213
214 delete: Effect.fn(function* ({ output }) {
215 yield* workers
216 .deleteRoute({
217 zoneId: output.zoneId,
218 routeId: output.routeId,
219 })
220 .pipe(Effect.catchTag("RouteNotFound", () => Effect.void));
221 }),
222
223 // Routes are zone-scoped (`/zones/{id}/workers/routes`) with no account-
224 // wide enumeration API. Fan out over every zone via `listAllZones`,
225 // exhaustively paginate `listRoutes` per zone, and hydrate each into the
226 // same Attributes shape `read` returns. Zones the scoped token can't
227 // reach (Forbidden) or that reject the route (InvalidRoute) are skipped
228 // with a typed catch rather than failing the whole enumeration.
229 list: Effect.fn(function* () {
230 const { accountId } = yield* yield* CloudflareEnvironment;
231 const zones = yield* listAllZones(accountId);
232 const rows = yield* Effect.forEach(
233 zones,
234 (zone) =>
235 workers.listRoutes.pages({ zoneId: zone.id }).pipe(
236 Stream.runCollect,
237 Effect.map((chunk) =>
238 Array.from(chunk).flatMap((page) =>
239 (page.result ?? []).map((route) =>
240 toAttributes(normalizeRoute(route), zone.id),
241 ),
242 ),
243 ),
244 Effect.catchTag(["InvalidRoute", "Forbidden"], () =>
245 Effect.succeed([] as WorkerRouteAttributes[]),
246 ),
247 ),
248 { concurrency: 10 },
249 );
250 return rows.flat();
251 }),
252 });
253
254interface ObservedRoute {
255 readonly id: string;
256 readonly pattern: string;
257 readonly script: string | undefined;
258}
259
260/**
261 * Distilled types `script` as `string | null | undefined`; an opt-out
262 * route comes back as `null` (or empty string from older API versions).
263 * Normalize both to `undefined` so drift comparison is stable.
264 */
265const normalizeRoute = (raw: {
266 id: string;
267 pattern: string;
268 script?: string | null;
269}): ObservedRoute => ({
270 id: raw.id,
271 pattern: raw.pattern,
272 script: raw.script == null || raw.script === "" ? undefined : raw.script,
273});
274
275const toAttributes = (
276 observed: ObservedRoute,
277 zoneId: string,
278): WorkerRouteAttributes => ({
279 routeId: observed.id,
280 zoneId,
281 pattern: observed.pattern,
282 script: observed.script,
283});
284
285const observeById = (zoneId: string, routeId: string) =>
286 workers.getRoute({ zoneId, routeId }).pipe(
287 Effect.map(normalizeRoute),
288 // A missing route surfaces as a 404 whose CF error code varies —
289 // `RouteNotFound` (10009 or a bare 404 envelope) or `WorkerNotFound`
290 // (10007, how some API versions tag a GET on a missing route). Both
291 // are in `getRoute`'s typed union; swallow them so the reconciler
292 // falls through to the find-by-pattern path.
293 Effect.catchTag(["RouteNotFound", "WorkerNotFound"], () =>
294 Effect.succeed(undefined),
295 ),
296 );
297
298/**
299 * Locate an existing route by `(zoneId, pattern)`. Cloudflare enforces
300 * pattern uniqueness within a zone, so a match identifies the route.
301 */
302const findByPattern = (zoneId: string, pattern: string) =>
303 workers.listRoutes.items({ zoneId }).pipe(
304 Stream.filter((r) => r.pattern === pattern),
305 Stream.runCollect,
306 Effect.map((chunk) => {
307 const found = Array.from(chunk)[0];
308 return found === undefined ? undefined : normalizeRoute(found);
309 }),
310 );