1import * as web3 from "@distilled.cloud/cloudflare/web3";
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
13const TypeId = "Cloudflare.Web3.Hostname" as const;
14type TypeId = typeof TypeId;
15
16/**
17 * The gateway a Web3 hostname resolves through: an Ethereum gateway, an
18 * IPFS gateway pinned to a single DNSLink, or an IPFS universal-path
19 * gateway that can serve any CID under `/ipfs/...` / `/ipns/...`.
20 */
21export type HostnameTarget = "ethereum" | "ipfs" | "ipfs_universal_path";
22
23/**
24 * Activation status of a Web3 hostname. Hostnames start `pending` until the
25 * CNAME is verified at the edge; deletion is asynchronous (`deleting`).
26 */
27export type HostnameStatus = "active" | "pending" | "deleting" | "error";
28
29export interface HostnameProps {
30 /**
31 * The zone the hostname is created in. The hostname must be a subdomain
32 * of (or equal to) the zone's name.
33 *
34 * Stable — moving a hostname to another zone triggers a replacement.
35 */
36 zoneId: string;
37 /**
38 * The hostname that points to the target gateway via CNAME, e.g.
39 * `gateway.example.com`.
40 *
41 * Immutable — the hostname is the resource's identity and cannot be
42 * renamed, so changing it triggers a replacement.
43 */
44 name: string;
45 /**
46 * The target gateway of the hostname.
47 *
48 * Immutable — the API cannot change a hostname's target after creation,
49 * so changing it triggers a replacement.
50 */
51 target: HostnameTarget;
52 /**
53 * The DNSLink value used when `target` is `ipfs`, e.g.
54 * `/ipns/onboarding.ipfs.cloudflare.com`. Mutable — patched in place.
55 */
56 dnslink?: string;
57 /**
58 * An optional description of the hostname. Mutable — patched in place.
59 */
60 description?: string;
61}
62
63export interface HostnameAttributes {
64 /** Cloudflare-assigned identifier of the Web3 hostname. */
65 hostnameId: string;
66 /** The zone the hostname belongs to. */
67 zoneId: string;
68 /** The hostname that points to the target gateway via CNAME. */
69 name: string;
70 /** The target gateway of the hostname. */
71 target: HostnameTarget;
72 /** The DNSLink value, if the target is `ipfs`. */
73 dnslink: string | undefined;
74 /** The hostname's description, if set. */
75 description: string | undefined;
76 /** Activation status of the hostname. */
77 status: HostnameStatus;
78 /** ISO8601 creation timestamp. */
79 createdOn: string | undefined;
80 /** ISO8601 last-modified timestamp. */
81 modifiedOn: string | undefined;
82}
83
84export type Hostname = Resource<
85 TypeId,
86 HostnameProps,
87 HostnameAttributes,
88 never,
89 Providers
90>;
91
92/**
93 * A Cloudflare Web3 gateway hostname — serve Ethereum or IPFS content from
94 * a hostname on your zone via Cloudflare's distributed web gateways.
95 *
96 * A hostname's identity is its `name` within the zone; only `dnslink` and
97 * `description` are mutable, so changing `name`, `target`, or `zoneId`
98 * triggers a replacement. Hostnames activate asynchronously: they start in
99 * `pending` status and flip to `active` once the CNAME is verified.
100 *
101 * Note: Cloudflare has restricted Web3 gateways for new customers — on
102 * accounts without the entitlement, creation fails with the typed
103 * `Web3HostnameNotEntitled` error.
104 *
105 * Safety: Web3 hostnames carry no ownership markers. When there is no prior
106 * state, `read` scans the zone for an existing hostname with the same name
107 * and reports it as `Unowned`, so the engine refuses to take it over unless
108 * `--adopt` (or `adopt(true)`) is set.
109 * ### IPFS gateway
110 * **Example:** IPFS hostname pinned to a DNSLink
111 * ```typescript
112 * const gateway = yield* Cloudflare.Web3.Hostname("IpfsGateway", {
113 * zoneId: zone.zoneId,
114 * name: "ipfs.example.com",
115 * target: "ipfs",
116 * dnslink: "/ipns/onboarding.ipfs.cloudflare.com",
117 * description: "IPFS gateway for example.com",
118 * });
119 * ```
120 *
121 * **Example:** IPFS universal-path gateway
122 * ```typescript
123 * // Serves any CID under /ipfs/... and /ipns/... paths.
124 * const universal = yield* Cloudflare.Web3.Hostname("UniversalGateway", {
125 * zoneId: zone.zoneId,
126 * name: "gateway.example.com",
127 * target: "ipfs_universal_path",
128 * });
129 * ```
130 *
131 * ### Ethereum gateway
132 * **Example:** Ethereum RPC hostname
133 * ```typescript
134 * yield* Cloudflare.Web3.Hostname("EthGateway", {
135 * zoneId: zone.zoneId,
136 * name: "eth.example.com",
137 * target: "ethereum",
138 * });
139 * ```
140 *
141 * @see https://developers.cloudflare.com/web3/
142 *
143 * @resource
144 * @product Web3
145 * @category Domains & DNS
146 */
147export const Hostname = Resource<Hostname>(TypeId, {
148 aliases: ["Cloudflare.Web3Hostname"],
149});
150
151/**
152 * Returns true if the given value is a Hostname resource.
153 */
154export const isHostname = (value: unknown): value is Hostname =>
155 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
156
157export const HostnameProvider = () =>
158 Provider.succeed(Hostname, {
159 stables: ["hostnameId", "zoneId", "name", "target", "createdOn"],
160
161 // Web3 hostnames live inside a zone (`/zones/{id}/web3/hostnames`) with no
162 // account-wide enumeration API, so fan out over every zone via
163 // `listAllZones` and exhaustively paginate the per-zone list. Zones without
164 // the Web3 entitlement (or where the scoped token lacks access) answer
165 // `Forbidden` — skip them rather than failing the whole enumeration.
166 list: Effect.fn(function* () {
167 const { accountId } = yield* yield* CloudflareEnvironment;
168 const zones = yield* listAllZones(accountId);
169 const rows = yield* Effect.forEach(
170 zones,
171 (zone) =>
172 web3.listHostnames.pages({ zoneId: zone.id }).pipe(
173 Stream.runCollect,
174 Effect.map((chunk) =>
175 Array.from(chunk).flatMap((page) =>
176 (page.result ?? [])
177 .filter((raw) => raw.status !== "deleting")
178 .map((raw): HostnameAttributes => toAttributes(raw, zone.id)),
179 ),
180 ),
181 Effect.catchTag("Forbidden", () => Effect.succeed([])),
182 ),
183 { concurrency: 10 },
184 );
185 return rows.flat();
186 }),
187
188 diff: Effect.fn(function* ({ olds = {}, news, output }) {
189 const o = olds as Partial<HostnameProps>;
190 const n = news as HostnameProps;
191 const oldName = output?.name ?? o.name;
192 const oldTarget = output?.target ?? o.target;
193 if (oldName === undefined && oldTarget === undefined) return undefined;
194 // The hostname is the resource's identity — no rename API exists.
195 if (oldName !== undefined && oldName !== n.name) {
196 return { action: "replace" } as const;
197 }
198 // The target gateway is immutable after creation.
199 if (oldTarget !== undefined && oldTarget !== n.target) {
200 return { action: "replace" } as const;
201 }
202 // zoneId is Input<string>; compare only once both are concrete.
203 const oldZone = output?.zoneId ?? o.zoneId;
204 if (
205 typeof oldZone === "string" &&
206 typeof n.zoneId === "string" &&
207 oldZone !== n.zoneId
208 ) {
209 return { action: "replace" } as const;
210 }
211 return undefined;
212 }),
213
214 read: Effect.fn(function* ({ output, olds }) {
215 const zoneId =
216 output?.zoneId ??
217 (typeof olds?.zoneId === "string" ? olds.zoneId : undefined);
218 if (zoneId === undefined) return undefined;
219
220 // Owned path: refresh by our persisted hostname id.
221 if (output?.hostnameId) {
222 const observed = yield* getHostname(zoneId, output.hostnameId);
223 if (observed) return toAttributes(observed, zoneId);
224 }
225
226 // Cold lookup: a hostname with this name may already exist in the
227 // zone. Web3 hostnames carry no ownership markers, so we cannot
228 // prove we created it — brand it `Unowned` so the engine refuses
229 // to take over unless `adopt` is set.
230 const name = output?.name ?? olds?.name;
231 if (name !== undefined) {
232 const observed = yield* findByName(zoneId, name);
233 if (observed) return Unowned(toAttributes(observed, zoneId));
234 }
235 return undefined;
236 }),
237
238 reconcile: Effect.fn(function* ({ news, output }) {
239 // Inputs have been resolved to concrete strings by Plan.
240 const zoneId = news.zoneId as string;
241
242 // 1. Observe — the hostname id cached on `output` is a hint, not a
243 // guarantee: a missing hostname falls through to the name scan
244 // and then to create.
245 let observed = output?.hostnameId
246 ? yield* getHostname(zoneId, output.hostnameId)
247 : undefined;
248
249 // 2. Fall back to scanning the zone for a name match. Ownership has
250 // already been verified upstream — `read` reports existing
251 // hostnames as `Unowned` and the engine gates takeover behind
252 // the adopt policy before reconcile ever runs.
253 if (!observed) {
254 observed = yield* findByName(zoneId, news.name);
255 }
256
257 // 3. Ensure — create when missing. Hostnames activate
258 // asynchronously; return the `pending` state as-is rather than
259 // polling for `active` (activation depends on CNAME/edge
260 // propagation outside our control).
261 if (!observed) {
262 const created = yield* web3.createHostname({
263 zoneId,
264 name: news.name,
265 target: news.target,
266 dnslink: news.dnslink,
267 description: news.description,
268 });
269 return toAttributes(created, zoneId);
270 }
271
272 // 4. Sync — diff observed dnslink/description against desired;
273 // skip the patch call entirely on a no-op.
274 const dirty =
275 (news.dnslink !== undefined &&
276 (observed.dnslink ?? undefined) !== news.dnslink) ||
277 (news.description !== undefined &&
278 (observed.description ?? undefined) !== news.description);
279 if (dirty) {
280 observed = yield* web3.patchHostname({
281 zoneId,
282 identifier: observed.id ?? "",
283 dnslink: news.dnslink,
284 description: news.description,
285 });
286 }
287
288 return toAttributes(observed, zoneId);
289 }),
290
291 delete: Effect.fn(function* ({ output }) {
292 // Deletion is asynchronous (the hostname lingers in `deleting`
293 // status); a hostname that is already gone answers 404 with the
294 // typed Web3HostnameNotFound tag — treat it as done.
295 yield* web3
296 .deleteHostname({
297 zoneId: output.zoneId,
298 identifier: output.hostnameId,
299 })
300 .pipe(Effect.catchTag("Web3HostnameNotFound", () => Effect.void));
301 }),
302 });
303
304type ObservedHostname = web3.GetHostnameResponse;
305
306/**
307 * Read a hostname by id, mapping "gone" (`Web3HostnameNotFound`, Cloudflare
308 * error code 1002 "Hostname does not exist.") to `undefined`. A hostname in
309 * `deleting` status is treated as already gone — it cannot be converged.
310 */
311const getHostname = (zoneId: string, hostnameId: string) =>
312 web3.getHostname({ zoneId, identifier: hostnameId }).pipe(
313 Effect.map((hostname): ObservedHostname | undefined =>
314 hostname.status === "deleting" ? undefined : hostname,
315 ),
316 Effect.catchTag("Web3HostnameNotFound", () => Effect.succeed(undefined)),
317 );
318
319/**
320 * Find a hostname by exact name within the zone. The name is a hostname's
321 * identity — Cloudflare rejects duplicates — so at most one (non-deleting)
322 * hostname can match.
323 */
324const findByName = (zoneId: string, name: string) =>
325 web3.listHostnames.items({ zoneId }).pipe(
326 Stream.runCollect,
327 Effect.map((chunk) =>
328 Array.from(chunk).find(
329 (hostname): hostname is ObservedHostname =>
330 hostname.name === name && hostname.status !== "deleting",
331 ),
332 ),
333 );
334
335const toAttributes = (
336 hostname: ObservedHostname,
337 zoneId: string,
338): HostnameAttributes => ({
339 // Cloudflare always echoes id/name/target/status for a persisted
340 // hostname — distilled just types every response field as optional.
341 hostnameId: hostname.id ?? "",
342 zoneId,
343 name: hostname.name ?? "",
344 target: (hostname.target ?? "ipfs") as HostnameTarget,
345 dnslink: hostname.dnslink ?? undefined,
346 description: hostname.description ?? undefined,
347 status: (hostname.status ?? "pending") as HostnameStatus,
348 createdOn: hostname.createdOn ?? undefined,
349 modifiedOn: hostname.modifiedOn ?? undefined,
350});