1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6
7import { Unowned } from "../../AdoptPolicy.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12
13const TypeId = "Cloudflare.Tunnel.HostnameRoute" as const;
14type TypeId = typeof TypeId;
15
16export interface HostnameRouteProps {
17 /**
18 * The hostname to route through the tunnel (e.g.
19 * `app.internal.example.com`). Unique per account. Mutable — updated
20 * in place via PATCH.
21 */
22 hostname: string;
23 /**
24 * UUID of the `cfd_tunnel` that traffic for the hostname egresses
25 * through. Mutable — updated in place via PATCH.
26 */
27 tunnelId: string;
28 /**
29 * Optional human-readable note attached to the route. Mutable.
30 */
31 comment?: string;
32}
33
34export type HostnameRouteAttributes = {
35 /** API UUID of the hostname route. */
36 hostnameRouteId: string;
37 /** Account that owns the route. */
38 accountId: string;
39 /** The hostname routed through the tunnel. */
40 hostname: string;
41 /** UUID of the tunnel the hostname routes to. */
42 tunnelId: string;
43 /** Human-readable note attached to the route, if any. */
44 comment: string | undefined;
45 /** RFC 3339 timestamp of when the route was created, if reported. */
46 createdAt: string | undefined;
47};
48
49export type HostnameRoute = Resource<
50 TypeId,
51 HostnameRouteProps,
52 HostnameRouteAttributes,
53 never,
54 Providers
55>;
56
57/**
58 * A Cloudflare Zero Trust **hostname route** — routes traffic for a
59 * private hostname through a `cfd_tunnel`, so WARP clients can reach
60 * internal apps by name without publishing a public DNS record.
61 *
62 * All fields (hostname, tunnel, comment) are mutable in place via PATCH.
63 * ### Creating a hostname route
64 * **Example:** Route an internal hostname through a tunnel
65 * ```typescript
66 * const tunnel = yield* Cloudflare.Tunnel.Tunnel("MyTunnel");
67 * const route = yield* Cloudflare.Tunnel.HostnameRoute("AppRoute", {
68 * hostname: "app.internal.example.com",
69 * tunnelId: tunnel.tunnelId,
70 * });
71 * ```
72 *
73 * **Example:** Add a comment
74 * ```typescript
75 * const route = yield* Cloudflare.Tunnel.HostnameRoute("AppRoute", {
76 * hostname: "app.internal.example.com",
77 * tunnelId: tunnel.tunnelId,
78 * comment: "Internal wiki behind the datacenter tunnel",
79 * });
80 * ```
81 *
82 * @see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/private-net/cloudflared/
83 *
84 * @resource
85 * @product Tunnels
86 * @category Cloudflare One (Zero Trust)
87 */
88export const HostnameRoute = Resource<HostnameRoute>(TypeId);
89
90/**
91 * Returns true if the given value is a HostnameRoute resource.
92 */
93export const isHostnameRoute = (value: unknown): value is HostnameRoute =>
94 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
95
96export const HostnameRouteProvider = () =>
97 Provider.succeed(HostnameRoute, {
98 stables: ["hostnameRouteId", "accountId"],
99
100 read: Effect.fn(function* ({ output, olds }) {
101 const { accountId } = yield* yield* CloudflareEnvironment;
102 const acct = output?.accountId ?? accountId;
103
104 if (output?.hostnameRouteId) {
105 const observed = yield* observeRoute(acct, output.hostnameRouteId);
106 return observed ? toAttributes(observed, acct) : undefined;
107 }
108
109 // Cold lookup: hostnames are unique per account, but the route
110 // carries no ownership markers — brand the match `Unowned`.
111 const hostname = olds?.hostname;
112 if (hostname === undefined) return undefined;
113 const match = yield* findByHostname(acct, hostname);
114 if (match) return Unowned(toAttributes(match, acct));
115 return undefined;
116 }),
117
118 reconcile: Effect.fn(function* ({ news, output }) {
119 const { accountId } = yield* yield* CloudflareEnvironment;
120
121 // 1. Observe — cached id is a hint; fall back to a hostname scan so
122 // a crashed prior run converges. A hostname scan also surfaces a
123 // leftover/foreign route squatting on the hostname, which the sync
124 // step below re-routes to our tunnel.
125 let observed: ObservedRoute | undefined = output?.hostnameRouteId
126 ? yield* observeRoute(accountId, output.hostnameRouteId)
127 : undefined;
128 if (!observed) {
129 observed = yield* findByHostname(accountId, news.hostname);
130 }
131
132 // 2. Ensure — create when missing, tolerating the "already routed to
133 // another tunnel" race: a concurrent create won, or a leftover route
134 // not yet visible to our scan owns the hostname. Re-observe (the list
135 // is eventually consistent right after the conflicting create) so the
136 // sync step takes the squatting route over.
137 if (!observed) {
138 observed = yield* zeroTrust
139 .createNetworkHostnameRoute({
140 accountId,
141 hostname: news.hostname,
142 tunnelId: news.tunnelId,
143 ...(news.comment !== undefined ? { comment: news.comment } : {}),
144 })
145 .pipe(
146 Effect.catchTag("HostnameRouteAlreadyRouted", (conflict) =>
147 findByHostname(accountId, news.hostname).pipe(
148 Effect.repeat({
149 schedule: Schedule.spaced("1 second"),
150 until: (route) => route !== undefined,
151 times: 5,
152 }),
153 Effect.flatMap((route) =>
154 route ? Effect.succeed(route) : Effect.fail(conflict),
155 ),
156 ),
157 ),
158 );
159 }
160
161 // 3. Sync — PATCH only when the observed state differs (this is also how
162 // a taken-over foreign route gets re-routed to our tunnel).
163 const dirty =
164 (observed.hostname ?? "") !== news.hostname ||
165 (observed.tunnelId ?? "") !== news.tunnelId ||
166 (observed.comment || undefined) !== news.comment;
167 if (!dirty) {
168 return toAttributes(observed, accountId);
169 }
170 const updated = yield* zeroTrust.patchNetworkHostnameRoute({
171 accountId,
172 hostnameRouteId: observed.id!,
173 hostname: news.hostname,
174 tunnelId: news.tunnelId,
175 comment: news.comment ?? "",
176 });
177 return toAttributes(updated, accountId);
178 }),
179
180 delete: Effect.fn(function* ({ output }) {
181 yield* zeroTrust
182 .deleteNetworkHostnameRoute({
183 accountId: output.accountId,
184 hostnameRouteId: output.hostnameRouteId,
185 })
186 .pipe(Effect.catchTag("HostnameRouteNotFound", () => Effect.void));
187 }),
188
189 // Account collection — hostname routes are enumerated account-wide via
190 // the Zero Trust list API. Paginate exhaustively, drop tombstoned/idless
191 // rows, and hydrate each into the exact `read` Attributes shape.
192 list: Effect.fn(function* () {
193 const { accountId } = yield* yield* CloudflareEnvironment;
194 return yield* zeroTrust.listNetworkHostnameRoutes
195 .pages({ accountId })
196 .pipe(
197 Stream.runCollect,
198 Effect.map((chunk) =>
199 Array.from(chunk).flatMap((page) =>
200 (page.result ?? [])
201 .filter((r) => r.id != null && r.deletedAt == null)
202 .map((r) => toAttributes(r, accountId)),
203 ),
204 ),
205 );
206 }),
207 });
208
209/**
210 * Structural shape shared by get/list/create/patch responses.
211 */
212type ObservedRoute = {
213 id?: string | null;
214 comment?: string | null;
215 createdAt?: string | null;
216 deletedAt?: string | null;
217 hostname?: string | null;
218 tunnelId?: string | null;
219};
220
221/**
222 * Read a hostname route by id, mapping "gone" (404 or a tombstoned
223 * `deletedAt`) to `undefined`.
224 */
225const observeRoute = (accountId: string, hostnameRouteId: string) =>
226 zeroTrust.getNetworkHostnameRoute({ accountId, hostnameRouteId }).pipe(
227 Effect.map((route) => (route.deletedAt != null ? undefined : route)),
228 Effect.catchTag("HostnameRouteNotFound", () => Effect.succeed(undefined)),
229 );
230
231/**
232 * Find a live hostname route by exact hostname (unique per account).
233 */
234const findByHostname = (accountId: string, hostname: string) =>
235 zeroTrust
236 .listNetworkHostnameRoutes({ accountId })
237 .pipe(
238 Effect.map((list) =>
239 (list.result ?? []).find(
240 (r) => r.hostname === hostname && r.id != null && r.deletedAt == null,
241 ),
242 ),
243 );
244
245const toAttributes = (
246 route: ObservedRoute,
247 accountId: string,
248): HostnameRouteAttributes => ({
249 hostnameRouteId: route.id ?? "",
250 accountId,
251 hostname: route.hostname ?? "",
252 tunnelId: route.tunnelId ?? "",
253 comment: route.comment || undefined,
254 createdAt: route.createdAt ?? undefined,
255});