1import * as connectivity from "@distilled.cloud/cloudflare/connectivity";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Predicate from "effect/Predicate";
5import * as Stream from "effect/Stream";
6
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
13import type { Providers } from "../Providers.ts";
14
15const TypeId = "Cloudflare.Connectivity.DirectoryService" as const;
16type TypeId = typeof TypeId;
17
18/**
19 * Protocol of a Connectivity Directory service.
20 */
21export type DirectoryServiceType = "tcp" | "http";
22
23/**
24 * Application protocol hint for `tcp` services.
25 */
26export type DirectoryServiceAppProtocol =
27 | "postgresql"
28 | "mysql"
29 | (string & {});
30
31export declare namespace DirectoryService {
32 /**
33 * Network a private IP host is reachable through — identified by the
34 * Cloudflare Tunnel that carries the traffic.
35 */
36 export interface Network {
37 /**
38 * UUID of the `cfd_tunnel` that provides connectivity to the host.
39 * Accepts a reference to a `Cloudflare.Tunnel.Tunnel` output.
40 */
41 tunnelId: string;
42 }
43
44 /**
45 * Network a private hostname is resolved and reached through.
46 */
47 export interface ResolverNetwork {
48 /**
49 * UUID of the `cfd_tunnel` that provides connectivity to the host.
50 * Accepts a reference to a `Cloudflare.Tunnel.Tunnel` output.
51 */
52 tunnelId: string;
53 /**
54 * IP addresses of the DNS resolvers used to resolve the hostname
55 * inside the private network.
56 */
57 resolverIps?: string[];
58 }
59
60 /**
61 * Private IPv4 host reached through a tunnel.
62 */
63 export interface Ipv4Host {
64 /** Private IPv4 address of the service. */
65 ipv4: string;
66 /** Tunnel-backed network the address lives in. */
67 network: Network;
68 }
69
70 /**
71 * Private IPv6 host reached through a tunnel.
72 */
73 export interface Ipv6Host {
74 /** Private IPv6 address of the service. */
75 ipv6: string;
76 /** Tunnel-backed network the address lives in. */
77 network: Network;
78 }
79
80 /**
81 * Dual-stack (IPv4 + IPv6) host reached through a tunnel.
82 */
83 export interface DualStackHost {
84 /** Private IPv4 address of the service. */
85 ipv4: string;
86 /** Private IPv6 address of the service. */
87 ipv6: string;
88 /** Tunnel-backed network the addresses live in. */
89 network: Network;
90 }
91
92 /**
93 * Private hostname resolved and reached through a tunnel.
94 */
95 export interface HostnameHost {
96 /** Private hostname of the service (e.g. `db.internal`). */
97 hostname: string;
98 /** Tunnel-backed resolver network used to resolve and reach the host. */
99 resolverNetwork: ResolverNetwork;
100 }
101
102 /**
103 * Host a directory service is reachable at — a private IPv4/IPv6
104 * address or a hostname, always reached through a Cloudflare Tunnel.
105 */
106 export type Host = Ipv4Host | Ipv6Host | DualStackHost | HostnameHost;
107
108 /**
109 * Fully-resolved host shape returned in Output Attributes (all tunnel
110 * references resolved to concrete UUID strings).
111 */
112 export type HostAttributes =
113 | { ipv4: string; network: { tunnelId: string } }
114 | { ipv6: string; network: { tunnelId: string } }
115 | { ipv4: string; ipv6: string; network: { tunnelId: string } }
116 | {
117 hostname: string;
118 resolverNetwork: { tunnelId: string; resolverIps?: string[] };
119 };
120}
121
122export type DirectoryServiceProps = {
123 /**
124 * Display name of the directory service. Must be unique within the
125 * account. If omitted, a unique name is generated from the app, stage,
126 * and logical ID.
127 * @default ${app}-${stage}-${id}
128 */
129 name?: string;
130 /**
131 * Service protocol: `tcp` (arbitrary TCP, e.g. a database) or `http`
132 * (plain HTTP/HTTPS origins).
133 */
134 type: DirectoryServiceType;
135 /**
136 * Host the service is reachable at — IPv4, IPv6, dual-stack, or
137 * hostname, reached through a Cloudflare Tunnel.
138 */
139 host: DirectoryService.Host;
140 /**
141 * Port the service listens on. Only valid for `type: "tcp"` services.
142 */
143 tcpPort?: number;
144 /**
145 * Port for plain HTTP traffic. Only valid for `type: "http"` services.
146 * @default 80
147 */
148 httpPort?: number;
149 /**
150 * Port for HTTPS traffic. Only valid for `type: "http"` services.
151 * @default 443
152 */
153 httpsPort?: number;
154 /**
155 * Application protocol hint for `tcp` services (e.g. `postgresql`).
156 */
157 appProtocol?: DirectoryServiceAppProtocol;
158 /**
159 * TLS settings used when connecting to the service.
160 * @default { certVerificationMode: "verify_full" }
161 */
162 tlsSettings?: {
163 /** Certificate verification mode (e.g. `verify_full`). */
164 certVerificationMode: string;
165 };
166};
167
168export type DirectoryServiceAttributes = {
169 /**
170 * Unique identifier of the directory service.
171 */
172 serviceId: string;
173 /**
174 * The Cloudflare account the service belongs to.
175 */
176 accountId: string;
177 /**
178 * Display name of the service.
179 */
180 name: string;
181 /**
182 * Service protocol (`tcp` or `http`).
183 */
184 type: DirectoryServiceType;
185 /**
186 * Host the service is reachable at, with tunnel references resolved.
187 */
188 host: DirectoryService.HostAttributes;
189 /**
190 * TCP port (only set for `tcp` services).
191 */
192 tcpPort: number | undefined;
193 /**
194 * HTTP port (only set for `http` services).
195 */
196 httpPort: number | undefined;
197 /**
198 * HTTPS port (only set for `http` services).
199 */
200 httpsPort: number | undefined;
201 /**
202 * Application protocol hint (only set for `tcp` services).
203 */
204 appProtocol: DirectoryServiceAppProtocol | undefined;
205 /**
206 * Certificate verification mode in effect.
207 */
208 certVerificationMode: string | undefined;
209 /**
210 * When the service was created.
211 */
212 createdAt: string | undefined;
213 /**
214 * When the service was last updated.
215 */
216 updatedAt: string | undefined;
217};
218
219export type DirectoryService = Resource<
220 TypeId,
221 DirectoryServiceProps,
222 DirectoryServiceAttributes,
223 never,
224 Providers
225>;
226
227/**
228 * A Cloudflare Connectivity Directory service — a named entry in the
229 * account's private-network service directory that maps a service name to
230 * a private host (IP or hostname) reachable through a Cloudflare Tunnel.
231 *
232 * Directory services are the registry behind Workers VPC and Zero Trust
233 * private-network connectivity: a `tcp` service describes a database-style
234 * origin (with an optional `appProtocol` hint), an `http` service describes
235 * an HTTP/HTTPS origin with explicit ports.
236 *
237 * Names are unique within the account. All properties — including the host
238 * and even the protocol type — are mutable in place via a full PUT; nothing
239 * forces a replacement except moving accounts.
240 * ### Creating a Directory Service
241 * **Example:** TCP database service through a tunnel
242 * ```typescript
243 * const tunnel = yield* Cloudflare.Tunnel.Tunnel("DbTunnel", {
244 * ingress: [{ service: "tcp://localhost:5432" }],
245 * });
246 * const db = yield* Cloudflare.Connectivity.DirectoryService("Postgres", {
247 * type: "tcp",
248 * tcpPort: 5432,
249 * appProtocol: "postgresql",
250 * host: { ipv4: "10.0.0.21", network: { tunnelId: tunnel.tunnelId } },
251 * });
252 * ```
253 *
254 * **Example:** HTTP service on a private hostname
255 * ```typescript
256 * const api = yield* Cloudflare.Connectivity.DirectoryService("InternalApi", {
257 * type: "http",
258 * httpPort: 8080,
259 * httpsPort: 8443,
260 * host: {
261 * hostname: "api.internal",
262 * resolverNetwork: { tunnelId: tunnel.tunnelId, resolverIps: ["10.0.0.53"] },
263 * },
264 * });
265 * ```
266 *
267 * ### Updating
268 * **Example:** Changing the host in place
269 * ```typescript
270 * // Host, ports, name, and TLS settings are all mutable — the service
271 * // keeps its serviceId across updates.
272 * const db = yield* Cloudflare.Connectivity.DirectoryService("Postgres", {
273 * type: "tcp",
274 * tcpPort: 5432,
275 * host: {
276 * hostname: "db.internal",
277 * resolverNetwork: { tunnelId: tunnel.tunnelId },
278 * },
279 * });
280 * ```
281 *
282 * @see https://developers.cloudflare.com/cloudflare-one/
283 *
284 * @resource
285 * @product Connectivity
286 * @category Cloudflare One (Zero Trust)
287 */
288export const DirectoryService = Resource<DirectoryService>(TypeId);
289
290/**
291 * Returns true if the given value is a DirectoryService resource.
292 */
293export const isDirectoryService = (value: unknown): value is DirectoryService =>
294 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
295
296export const DirectoryServiceProvider = () =>
297 Provider.succeed(DirectoryService, {
298 stables: ["serviceId", "accountId", "createdAt"],
299 diff: Effect.fn(function* ({ news, output }) {
300 const { accountId } = yield* yield* CloudflareEnvironment;
301 if (!isResolved(news)) return undefined;
302 // Directory services are account-scoped; moving accounts replaces.
303 if ((output?.accountId ?? accountId) !== accountId) {
304 return { action: "replace" } as const;
305 }
306 // Everything else (name, type, host, ports, appProtocol, TLS) is
307 // mutable via the full-body PUT — let the engine default to update.
308 return undefined;
309 }),
310 read: Effect.fn(function* ({ id, output, olds }) {
311 const { accountId } = yield* yield* CloudflareEnvironment;
312 const acct = output?.accountId ?? accountId;
313
314 // Owned path: refresh by the persisted service id.
315 if (output?.serviceId) {
316 const observed = yield* getService(acct, output.serviceId);
317 return observed ? toAttributes(observed, acct) : undefined;
318 }
319
320 // Cold read — recover from lost state by matching the deterministic
321 // physical name. Names are unique within the account (the API
322 // rejects duplicates), but the service carries no ownership markers,
323 // so brand the match `Unowned` and let the engine gate takeover
324 // behind the adopt policy.
325 const name = yield* createServiceName(id, olds?.name);
326 const match = yield* findByName(acct, name);
327 if (match) return Unowned(toAttributes(match, acct));
328 return undefined;
329 }),
330 reconcile: Effect.fn(function* ({ id, news, output }) {
331 const { accountId } = yield* yield* CloudflareEnvironment;
332 const acct = output?.accountId ?? accountId;
333 const name = yield* createServiceName(id, news.name);
334
335 // Observe — the serviceId cached on `output` is a hint, not a
336 // guarantee: a missing service falls through to a name scan
337 // (adoption / lost-state recovery) and then to create.
338 let observed = output?.serviceId
339 ? yield* getService(acct, output.serviceId)
340 : undefined;
341 if (!observed) {
342 observed = yield* findByName(acct, name);
343 }
344
345 // Ensure — create when missing. A concurrent create surfaces as
346 // the typed `VpcServiceNameAlreadyExists` (Cloudflare code 5101):
347 // converge by re-scanning for the service that won the race.
348 if (!observed) {
349 const created = yield* connectivity
350 .createDirectoryService({
351 accountId: acct,
352 name,
353 ...toRequestBody(news),
354 })
355 .pipe(
356 Effect.catchTag("VpcServiceNameAlreadyExists", (error) =>
357 findByName(acct, name).pipe(
358 Effect.flatMap((existing) =>
359 existing ? Effect.succeed(existing) : Effect.fail(error),
360 ),
361 ),
362 ),
363 );
364 observed = created;
365 }
366
367 // Sync — diff observed cloud state against desired. The update API
368 // is a full-body PUT, so send everything, but skip the call
369 // entirely on a no-op.
370 const current = toAttributes(observed, acct);
371 if (!isDirty(current, name, news)) {
372 return current;
373 }
374 const updated = yield* connectivity.updateDirectoryService({
375 accountId: acct,
376 serviceId: current.serviceId,
377 name,
378 ...toRequestBody(news),
379 });
380 return toAttributes(updated, acct);
381 }),
382 delete: Effect.fn(function* ({ output }) {
383 // Idempotent — an already-gone service surfaces as the typed
384 // `VpcServiceNotFound` (Cloudflare code 5104).
385 yield* connectivity
386 .deleteDirectoryService({
387 accountId: output.accountId,
388 serviceId: output.serviceId,
389 })
390 .pipe(Effect.catchTag("VpcServiceNotFound", () => Effect.void));
391 }),
392 // Account-scoped collection: enumerate every directory service in the
393 // account, exhaustively paginating the list API, and hydrate each into
394 // the same Attributes shape `read` returns.
395 list: Effect.fn(function* () {
396 const { accountId } = yield* yield* CloudflareEnvironment;
397 return yield* connectivity.listDirectoryServices
398 .pages({ accountId })
399 .pipe(
400 Stream.runCollect,
401 Effect.map((chunk) =>
402 Array.from(chunk).flatMap((page) =>
403 (page.result ?? []).map((service) =>
404 toAttributes(service, accountId),
405 ),
406 ),
407 ),
408 );
409 }),
410 });
411
412type ObservedService = connectivity.GetDirectoryServiceResponse;
413
414const createServiceName = (id: string, name: string | undefined) =>
415 Effect.gen(function* () {
416 if (name) return name;
417 return yield* createPhysicalName({ id, lowercase: true, maxLength: 63 });
418 });
419
420/**
421 * Read a directory service by id, mapping "gone" (`VpcServiceNotFound`,
422 * Cloudflare error code 5104) to `undefined`.
423 */
424const getService = (accountId: string, serviceId: string) =>
425 connectivity
426 .getDirectoryService({ accountId, serviceId })
427 .pipe(
428 Effect.catchTag("VpcServiceNotFound", () => Effect.succeed(undefined)),
429 );
430
431/**
432 * Find a directory service by its (account-unique) name.
433 */
434const findByName = (accountId: string, name: string) =>
435 connectivity.listDirectoryServices.items({ accountId }).pipe(
436 Stream.filter((s) => s.name === name),
437 Stream.runHead,
438 Effect.map(Option.getOrUndefined),
439 );
440
441/**
442 * Build the shared create/update request body from desired props. Inputs
443 * (tunnel references) have been resolved to concrete strings by the Plan
444 * layer before reconcile runs.
445 */
446const toRequestBody = (news: DirectoryServiceProps) => {
447 const host = news.host;
448 const requestHost =
449 "hostname" in host
450 ? {
451 hostname: host.hostname,
452 resolverNetwork: {
453 tunnelId: host.resolverNetwork.tunnelId,
454 resolverIps: host.resolverNetwork.resolverIps,
455 },
456 }
457 : "ipv4" in host && "ipv6" in host
458 ? {
459 ipv4: host.ipv4,
460 ipv6: host.ipv6,
461 network: { tunnelId: host.network.tunnelId },
462 }
463 : "ipv4" in host
464 ? { ipv4: host.ipv4, network: { tunnelId: host.network.tunnelId } }
465 : { ipv6: host.ipv6, network: { tunnelId: host.network.tunnelId } };
466 return {
467 type: news.type,
468 host: requestHost,
469 tcpPort: news.tcpPort,
470 httpPort: news.httpPort,
471 httpsPort: news.httpsPort,
472 appProtocol: news.appProtocol,
473 tlsSettings: news.tlsSettings,
474 };
475};
476
477/**
478 * Compare observed attributes against the desired props (with the resolved
479 * physical name) to decide whether the full-body PUT can be skipped.
480 */
481const isDirty = (
482 current: DirectoryServiceAttributes,
483 name: string,
484 news: DirectoryServiceProps,
485): boolean => {
486 if (current.name !== name) return true;
487 if (current.type !== news.type) return true;
488 if (news.tcpPort !== undefined && current.tcpPort !== news.tcpPort) {
489 return true;
490 }
491 if (news.httpPort !== undefined && current.httpPort !== news.httpPort) {
492 return true;
493 }
494 if (news.httpsPort !== undefined && current.httpsPort !== news.httpsPort) {
495 return true;
496 }
497 if (
498 news.appProtocol !== undefined &&
499 current.appProtocol !== news.appProtocol
500 ) {
501 return true;
502 }
503 if (
504 news.tlsSettings !== undefined &&
505 current.certVerificationMode !== news.tlsSettings.certVerificationMode
506 ) {
507 return true;
508 }
509 return !sameHost(current.host, news.host);
510};
511
512/**
513 * Structural host equality between the observed (resolved) host and the
514 * desired host props.
515 */
516const sameHost = (
517 observed: DirectoryService.HostAttributes,
518 desired: DirectoryService.Host,
519): boolean => {
520 const canon = (h: {
521 hostname?: string;
522 ipv4?: string;
523 ipv6?: string;
524 tunnelId?: unknown;
525 resolverIps?: string[] | undefined;
526 }) =>
527 JSON.stringify([
528 h.hostname,
529 h.ipv4,
530 h.ipv6,
531 h.tunnelId,
532 [...(h.resolverIps ?? [])].sort(),
533 ]);
534 const flat = (h: DirectoryService.HostAttributes | DirectoryService.Host) =>
535 Predicate.hasProperty(h, "hostname")
536 ? {
537 hostname: h.hostname,
538 tunnelId: h.resolverNetwork.tunnelId,
539 resolverIps: h.resolverNetwork.resolverIps ?? undefined,
540 }
541 : {
542 ...(Predicate.hasProperty(h, "ipv4") ? { ipv4: h.ipv4 } : {}),
543 ...(Predicate.hasProperty(h, "ipv6") ? { ipv6: h.ipv6 } : {}),
544 tunnelId: h.network.tunnelId,
545 };
546 return canon(flat(observed)) === canon(flat(desired));
547};
548
549const toHostAttributes = (
550 host: ObservedService["host"],
551): DirectoryService.HostAttributes => {
552 if ("hostname" in host) {
553 return {
554 hostname: host.hostname,
555 resolverNetwork: {
556 tunnelId: host.resolverNetwork.tunnelId,
557 ...(host.resolverNetwork.resolverIps
558 ? { resolverIps: [...host.resolverNetwork.resolverIps] }
559 : {}),
560 },
561 };
562 }
563 if ("ipv4" in host && "ipv6" in host) {
564 return {
565 ipv4: host.ipv4,
566 ipv6: host.ipv6,
567 network: { tunnelId: host.network.tunnelId },
568 };
569 }
570 if ("ipv4" in host) {
571 return { ipv4: host.ipv4, network: { tunnelId: host.network.tunnelId } };
572 }
573 return { ipv6: host.ipv6, network: { tunnelId: host.network.tunnelId } };
574};
575
576const toAttributes = (
577 service: {
578 serviceId?: string | null;
579 name: string;
580 // Distilled widens generated string enums to open unions (`string & {}`);
581 // the API only ever returns the known variants here.
582 type: string;
583 host: ObservedService["host"];
584 tcpPort?: number | null;
585 httpPort?: number | null;
586 httpsPort?: number | null;
587 appProtocol?: DirectoryServiceAppProtocol | null;
588 tlsSettings?: { certVerificationMode: string } | null;
589 createdAt?: string | null;
590 updatedAt?: string | null;
591 },
592 accountId: string,
593): DirectoryServiceAttributes => ({
594 serviceId: service.serviceId!,
595 accountId,
596 name: service.name,
597 type: service.type as DirectoryServiceType,
598 host: toHostAttributes(service.host),
599 tcpPort: service.tcpPort ?? undefined,
600 httpPort: service.httpPort ?? undefined,
601 httpsPort: service.httpsPort ?? undefined,
602 appProtocol: service.appProtocol ?? undefined,
603 certVerificationMode: service.tlsSettings?.certVerificationMode ?? undefined,
604 createdAt: service.createdAt ?? undefined,
605 updatedAt: service.updatedAt ?? undefined,
606});