1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Stream from "effect/Stream";
5
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11
12export type RouteProps = {
13 /**
14 * UUID of the `cfd_tunnel` this route attaches to.
15 *
16 * Stable -- changing the tunnel triggers replacement.
17 */
18 tunnelId: string;
19 /**
20 * Private IPv4 or IPv6 range exposed through the tunnel, in CIDR notation
21 * (e.g. `10.4.0.0/16`).
22 *
23 * Stable -- changing the CIDR triggers replacement.
24 *
25 * Declared as a plain `string` (not `string`) so it is statically
26 * knowable inside `diff`.
27 */
28 network: string;
29 /**
30 * Optional human-readable note attached to the route.
31 *
32 * Mutable -- changes are applied in place via PATCH.
33 */
34 comment?: string;
35 /**
36 * Optional Tunnel Virtual Network UUID. Use to disambiguate overlapping
37 * CIDRs that live in separate virtual networks.
38 *
39 * Stable -- changing the virtual network triggers replacement.
40 */
41 virtualNetworkId?: string;
42 /**
43 * Whether to adopt an existing route with the same network (and virtual
44 * network, when set) on the same tunnel if one is already present.
45 *
46 * @default false
47 */
48 adopt?: boolean;
49};
50
51export type Route = Resource<
52 "Cloudflare.Tunnel.Route",
53 RouteProps,
54 {
55 /**
56 * UUID of the route, assigned by Cloudflare.
57 */
58 routeId: string;
59 /**
60 * The CIDR exposed through the tunnel.
61 */
62 network: string;
63 /**
64 * UUID of the tunnel this route attaches to.
65 */
66 tunnelId: string;
67 /**
68 * Cloudflare account that owns the route.
69 */
70 accountId: string;
71 /**
72 * Human-readable note attached to the route, if any.
73 */
74 comment: string | undefined;
75 /**
76 * UUID of the Tunnel Virtual Network the route lives in, if any.
77 */
78 virtualNetworkId: string | undefined;
79 /**
80 * RFC 3339 timestamp of when the route was created (as reported by
81 * Cloudflare), if available.
82 */
83 createdAt: string | undefined;
84 },
85 never,
86 Providers
87>;
88
89/**
90 * A Cloudflare Tunnel Route attaches a private CIDR to a `cfd_tunnel` so that
91 * WARP clients (and other Zero Trust egress paths) can reach private IPs
92 * through the tunnel.
93 * ### Creating a Route
94 * **Example:** Basic route
95 * ```typescript
96 * const tunnel = yield* Cloudflare.Tunnel.Tunnel("MyTunnel");
97 * const route = yield* Cloudflare.Tunnel.Route("PrivateNet", {
98 * tunnelId: tunnel.tunnelId,
99 * network: "10.4.0.0/16",
100 * });
101 * ```
102 *
103 * **Example:** Route with a comment and explicit virtual network
104 * ```typescript
105 * const route = yield* Cloudflare.Tunnel.Route("DcRoute", {
106 * tunnelId: tunnel.tunnelId,
107 * network: "10.50.0.0/16",
108 * comment: "Datacenter A private subnet",
109 * virtualNetworkId: vnet.id,
110 * adopt: true,
111 * });
112 * ```
113 *
114 * @resource
115 * @product Tunnels
116 * @category Cloudflare One (Zero Trust)
117 */
118export const Route = Resource<Route>("Cloudflare.Tunnel.Route", {
119 aliases: ["Cloudflare.TunnelRoute"],
120});
121
122export const RouteProvider = () =>
123 Provider.succeed(Route, {
124 stables: [
125 "routeId",
126 "accountId",
127 "tunnelId",
128 "network",
129 "virtualNetworkId",
130 ],
131 diff: Effect.fn(function* ({ olds = {}, news, output }) {
132 const { accountId } = yield* yield* CloudflareEnvironment;
133
134 if (!isResolved(news)) return undefined;
135 const acct = output?.accountId ?? accountId;
136 if (acct !== accountId) {
137 return { action: "replace" } as const;
138 }
139 if (output?.network !== undefined && output.network !== news.network) {
140 return { action: "replace" } as const;
141 }
142 if (olds.network !== undefined && olds.network !== news.network) {
143 return { action: "replace" } as const;
144 }
145 const oldTunnelId = output?.tunnelId ?? olds.tunnelId;
146 if (oldTunnelId !== undefined && oldTunnelId !== news.tunnelId) {
147 return { action: "replace" } as const;
148 }
149 // virtualNetworkId is auto-assigned by Cloudflare when omitted
150 // (every account has a "default" virtual network that absorbs
151 // routes without an explicit assignment). If the caller didn't
152 // ask for a specific vnet (`news.virtualNetworkId === undefined`),
153 // they're delegating to CF — don't force a replace just because
154 // CF auto-assigned one. Only mark replace when the caller has
155 // EXPLICITLY chosen a vnet and it differs from what's deployed.
156 if (
157 news.virtualNetworkId !== undefined &&
158 (output?.virtualNetworkId ?? olds.virtualNetworkId) !==
159 news.virtualNetworkId
160 ) {
161 return { action: "replace" } as const;
162 }
163 }),
164 reconcile: Effect.fn(function* ({ news, output }) {
165 const { accountId } = yield* yield* CloudflareEnvironment;
166
167 const acct = output?.accountId ?? accountId;
168 const tunnelId = news.tunnelId;
169 const network = news.network;
170 const virtualNetworkId = news.virtualNetworkId;
171
172 // Observe — cached id first, then an account-wide list scan by
173 // network so we recover from out-of-band deletes, partial state
174 // writes, or a route left attached to another tunnel.
175 let observed = yield* observe(
176 acct,
177 network,
178 virtualNetworkId,
179 output?.routeId,
180 );
181
182 // Ensure — create when missing. Cloudflare returns a generic
183 // conflict on duplicate (network, tunnel, vnet) tuples; on any
184 // such race re-observe and either adopt or rethrow.
185 if (!observed) {
186 // Cloudflare returns an untagged generic error on a
187 // duplicate (network, tunnel, vnet) tuple. distilled
188 // surfaces it as the untyped Cloudflare error bucket,
189 // so we can't `catchTag` -- we narrow by re-observing
190 // and only swallowing the error when adoption is
191 // requested AND a matching route actually exists.
192 const createdOrAdopted = yield* zeroTrust
193 .createNetworkRoute({
194 accountId: acct,
195 network,
196 tunnelId,
197 comment: news.comment,
198 virtualNetworkId,
199 })
200 .pipe(
201 Effect.catch((err) =>
202 Effect.gen(function* () {
203 if (!news.adopt) return yield* Effect.fail(err);
204 const existing = yield* observe(
205 acct,
206 network,
207 virtualNetworkId,
208 undefined,
209 );
210 if (!existing) return yield* Effect.fail(err);
211 // Sentinel: undefined means "adoption path; use re-observed value".
212 return undefined;
213 }),
214 ),
215 );
216 observed = createdOrAdopted
217 ? toObserved(createdOrAdopted)
218 : yield* observe(acct, network, virtualNetworkId, undefined);
219 }
220
221 if (!observed) {
222 return yield* Effect.die(
223 `Route create returned no id for network ${network} on tunnel ${tunnelId}`,
224 );
225 }
226
227 // Sync — `comment` is mutable, and an adopted route may be
228 // attached to a different tunnel (the account-wide network
229 // uniqueness means we adopt whatever route owns the CIDR) so we
230 // repoint `tunnelId` too. Skip the PATCH entirely on no-op so we
231 // avoid churn on every reconcile. Clearing a comment must send
232 // `""` explicitly — omitting every body field produces an empty
233 // PATCH body which Cloudflare rejects with a JSON parse error.
234 const desiredComment = news.comment ?? "";
235 const needsComment = (observed.comment ?? "") !== desiredComment;
236 const needsTunnel = observed.tunnelId !== tunnelId;
237 if (needsComment || needsTunnel) {
238 const patched = yield* zeroTrust.patchNetworkRoute({
239 accountId: acct,
240 routeId: observed.id,
241 ...(needsComment ? { comment: desiredComment } : {}),
242 ...(needsTunnel ? { tunnelId } : {}),
243 });
244 observed = {
245 id: patched.id ?? observed.id,
246 network: normalize(patched.network) ?? observed.network,
247 tunnelId: normalize(patched.tunnelId) ?? observed.tunnelId,
248 virtualNetworkId:
249 normalize(patched.virtualNetworkId) ?? observed.virtualNetworkId,
250 comment: normalize(patched.comment),
251 createdAt: normalize(patched.createdAt) ?? observed.createdAt,
252 };
253 }
254
255 return {
256 routeId: observed.id,
257 network: observed.network ?? network,
258 tunnelId: observed.tunnelId ?? tunnelId,
259 accountId: acct,
260 comment: observed.comment,
261 virtualNetworkId: observed.virtualNetworkId ?? virtualNetworkId,
262 createdAt: observed.createdAt,
263 };
264 }),
265 delete: Effect.fn(function* ({ output }) {
266 yield* zeroTrust
267 .deleteNetworkRoute({
268 accountId: output.accountId,
269 routeId: output.routeId,
270 })
271 .pipe(
272 // Idempotent delete: distilled doesn't tag NotFound on
273 // teamnet/routes/{id}, so we swallow read-side failure
274 // wholesale. A "delete a deleted route" is not an error.
275 Effect.catch(() => Effect.succeed(undefined)),
276 );
277 }),
278 list: Effect.fn(function* () {
279 const { accountId } = yield* yield* CloudflareEnvironment;
280 // Account-scoped collection: enumerate every Zero Trust network
281 // route in the account, exhaustively paginating. Drop soft-deleted
282 // routes and anything missing an id, then hydrate into the exact
283 // `read` Attributes shape so each element is delete-ready.
284 return yield* zeroTrust.listNetworkRoutes
285 .pages({ accountId, isDeleted: false })
286 .pipe(
287 Stream.runCollect,
288 Effect.map((chunk) =>
289 Array.from(chunk).flatMap((page) =>
290 (page.result ?? [])
291 .filter(
292 (r): r is typeof r & { id: string } =>
293 r.id != null && !r.deletedAt,
294 )
295 .map((r) => ({
296 routeId: r.id,
297 network: normalize(r.network) ?? "",
298 tunnelId: normalize(r.tunnelId) ?? "",
299 accountId,
300 comment: normalize(r.comment),
301 virtualNetworkId: normalize(r.virtualNetworkId),
302 createdAt: normalize(r.createdAt),
303 })),
304 ),
305 ),
306 );
307 }),
308 read: Effect.fn(function* ({ olds, output }) {
309 const { accountId } = yield* yield* CloudflareEnvironment;
310
311 const acct = output?.accountId ?? accountId;
312 const tunnelId = output?.tunnelId ?? olds?.tunnelId;
313 const network = output?.network ?? olds?.network;
314 const virtualNetworkId =
315 output?.virtualNetworkId ?? olds?.virtualNetworkId;
316 if (!tunnelId || !network) return undefined;
317 const observed = yield* observe(
318 acct,
319 network,
320 virtualNetworkId,
321 output?.routeId,
322 );
323 if (!observed) return undefined;
324 return {
325 routeId: observed.id,
326 network: observed.network ?? network,
327 tunnelId: observed.tunnelId ?? tunnelId,
328 accountId: acct,
329 comment: observed.comment,
330 virtualNetworkId: observed.virtualNetworkId ?? virtualNetworkId,
331 createdAt: observed.createdAt,
332 };
333 }),
334 });
335
336// Route networks are unique per virtual network across the WHOLE
337// account (Cloudflare rejects a duplicate CIDR with a Conflict no
338// matter which tunnel it's attached to), so the scan must NOT be
339// scoped to a tunnel — otherwise a route left on another tunnel is
340// invisible to observation and the create Conflict is unrecoverable.
341const findRouteByNetwork = (
342 accountId: string,
343 network: string,
344 virtualNetworkId: string | undefined,
345) =>
346 zeroTrust.listNetworkRoutes
347 .items({
348 accountId,
349 isDeleted: false,
350 // subset + superset of the same CIDR == exact match
351 networkSubset: network,
352 networkSuperset: network,
353 })
354 .pipe(
355 Stream.filter(
356 (r) =>
357 !r.deletedAt &&
358 r.network === network &&
359 (virtualNetworkId === undefined ||
360 r.virtualNetworkId === virtualNetworkId),
361 ),
362 Stream.runHead,
363 Effect.map(Option.getOrUndefined),
364 // The distilled cloudflare SDK only tags transport-shaped
365 // errors (Unauthorized / 5xx / TooManyRequests / parse
366 // errors) on this endpoint -- there's no `NotFound` or
367 // `Forbidden` tag to discriminate on. Mirror the
368 // canonical `Tunnel.ts` template: swallow read-side
369 // errors so observation falls through to "missing" and
370 // the ensure step can recover.
371 Effect.catch(() => Effect.succeed(undefined)),
372 );
373
374const toObserved = (r: {
375 id?: string | null;
376 network?: string | null;
377 tunnelId?: string | null;
378 virtualNetworkId?: string | null;
379 comment?: string | null;
380 createdAt?: string | null;
381 deletedAt?: string | null;
382}): ObservedRoute | undefined =>
383 r.id
384 ? {
385 id: r.id,
386 network: normalize(r.network),
387 tunnelId: normalize(r.tunnelId),
388 virtualNetworkId: normalize(r.virtualNetworkId),
389 comment: normalize(r.comment),
390 createdAt: normalize(r.createdAt),
391 }
392 : undefined;
393
394const observe = Effect.fn(function* (
395 acct: string,
396 network: string,
397 virtualNetworkId: string | undefined,
398 routeId: string | undefined,
399) {
400 if (routeId) {
401 // See `findRouteByNetwork` -- distilled doesn't tag
402 // NotFound on this endpoint, so we tolerate any read
403 // error and fall through to the list scan.
404 const raw = yield* zeroTrust
405 .getNetworkRoute({ accountId: acct, routeId })
406 .pipe(Effect.catch((_: unknown) => Effect.succeed(undefined)));
407 const got = raw ? toObserved(raw) : undefined;
408 if (got) return got;
409 }
410 const match = yield* findRouteByNetwork(acct, network, virtualNetworkId);
411 if (!match) return undefined;
412 return toObserved(match);
413});
414
415type ObservedRoute = {
416 id: string;
417 network: string | undefined;
418 tunnelId: string | undefined;
419 virtualNetworkId: string | undefined;
420 comment: string | undefined;
421 createdAt: string | undefined;
422};
423
424const normalize = (v: string | null | undefined): string | undefined =>
425 v == null ? undefined : v;