1import * as connectivity from "@distilled.cloud/cloudflare/connectivity";
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 { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { isResourceOfType, Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12
13export type VpcServiceProps = {
14 /**
15 * Display name for the VPC service. If omitted, a unique name is generated.
16 *
17 * @default ${app}-${stage}-${id}
18 */
19 name?: string;
20 /**
21 * Service protocol. Currently only `"http"` is supported.
22 *
23 * @default "http"
24 */
25 serviceType?: "http";
26 /**
27 * Port that Workers should reach for plain HTTP traffic.
28 */
29 httpPort?: number;
30 /**
31 * Port that Workers should reach for HTTPS traffic.
32 */
33 httpsPort?: number;
34 /**
35 * Host the service is reachable at -- IPv4, IPv6, dual stack, or hostname.
36 */
37 host: VpcService.Host;
38 /**
39 * Whether to adopt an existing VPC service with the same name when create
40 * fails because of a name conflict.
41 *
42 * @default false
43 */
44 adopt?: boolean;
45};
46
47export declare namespace VpcService {
48 /**
49 * Host the VPC service is reachable at.
50 */
51 export type Host = IPv4Host | IPv6Host | DualStackHost | HostnameHost;
52 export interface IPv4Host {
53 ipv4: string;
54 network: Network;
55 }
56 export interface IPv6Host {
57 ipv6: string;
58 network: Network;
59 }
60 export interface DualStackHost {
61 ipv4: string;
62 ipv6: string;
63 network: Network;
64 }
65 export interface HostnameHost {
66 hostname: string;
67 resolverNetwork: ResolverNetwork;
68 }
69 export interface Network {
70 tunnelId: string;
71 }
72 export interface ResolverNetwork extends Network {
73 resolverIps?: string[];
74 }
75}
76
77export type Attributes = {
78 serviceId: string;
79 serviceName: string;
80 serviceType: "http" | "tcp";
81 httpPort: number | undefined;
82 httpsPort: number | undefined;
83 host: VpcService.Host;
84 accountId: string;
85 createdAt: number | undefined;
86 updatedAt: number | undefined;
87};
88
89export type VpcService = Resource<
90 "Cloudflare.VpcService.VpcService",
91 VpcServiceProps,
92 Attributes,
93 never,
94 Providers
95>;
96
97/**
98 * A Cloudflare VPC service that exposes a private host (IP or hostname)
99 * reachable through a Cloudflare Tunnel for Workers VPC.
100 * ### Creating a VPC Service
101 * **Example:** Hostname through a tunnel
102 * ```typescript
103 * const tunnel = yield* Cloudflare.Tunnel.Tunnel("MyTunnel");
104 * const service = yield* Cloudflare.VpcService.VpcService("Internal", {
105 * host: {
106 * hostname: "internal.example.com",
107 * resolverNetwork: { tunnelId: tunnel.tunnelId, resolverIps: ["10.0.0.53"] },
108 * },
109 * });
110 * ```
111 *
112 * **Example:** IPv4 with explicit ports
113 * ```typescript
114 * const service = yield* Cloudflare.VpcService.VpcService("DevServer", {
115 * httpPort: 5173,
116 * host: { ipv4: "192.168.1.100", network: { tunnelId: tunnel.tunnelId } },
117 * });
118 * ```
119 *
120 * @resource
121 * @product Workers VPC
122 * @category Network
123 */
124export const VpcService = Resource<VpcService>(
125 "Cloudflare.VpcService.VpcService",
126 { aliases: ["Cloudflare.VpcService"] },
127);
128
129export const isVpcService = (value: unknown): value is VpcService =>
130 isResourceOfType(value, "Cloudflare.VpcService.VpcService");
131
132const createServiceName = (id: string, name: string | undefined) =>
133 Effect.gen(function* () {
134 if (name) return name;
135 return yield* createPhysicalName({
136 id,
137 lowercase: true,
138 maxLength: 63,
139 });
140 });
141
142const findServiceByName = Effect.fn(function* (name: string) {
143 const { accountId } = yield* yield* CloudflareEnvironment;
144 return yield* connectivity.listDirectoryServices.items({ accountId }).pipe(
145 Stream.filter((s) => s.name === name),
146 Stream.runHead,
147 Effect.map(Option.getOrUndefined),
148 );
149});
150
151export const VpcServiceProvider = () =>
152 Provider.succeed(VpcService, {
153 stables: ["serviceId", "accountId"],
154 list: Effect.fn(function* () {
155 const { accountId } = yield* yield* CloudflareEnvironment;
156 return yield* connectivity.listDirectoryServices
157 .pages({ accountId })
158 .pipe(
159 Stream.runCollect,
160 Effect.map((chunk) =>
161 Array.from(chunk).flatMap((page) =>
162 (page.result ?? [])
163 .filter(
164 (s): s is typeof s & { serviceId: string } =>
165 s.serviceId != null,
166 )
167 .map((s) => formatVpcService(s, accountId)),
168 ),
169 ),
170 // VPC/Workers connectivity is plan-gated; an un-entitled
171 // account rejects the list route. Treat as nothing to enumerate.
172 Effect.catchTag("Forbidden", () => Effect.succeed([])),
173 );
174 }),
175 diff: Effect.fn(function* ({ id, olds, news, output }) {
176 const { accountId } = yield* yield* CloudflareEnvironment;
177 if (!isResolved(news)) return undefined;
178 if ((output?.accountId ?? accountId) !== accountId) {
179 return { action: "replace" } as const;
180 }
181 const oldName =
182 output?.serviceName ?? (yield* createServiceName(id, olds?.name));
183 // Auto-generated names are engine-owned: the deployed name stays
184 // authoritative even if the generator would name this id differently
185 // today. Only an explicit user-provided name can force a rename.
186 const name = news.name ?? oldName;
187 if (name !== oldName) {
188 return { action: "update" } as const;
189 }
190 }),
191 reconcile: Effect.fn(function* ({ id, news, output }) {
192 const { accountId } = yield* yield* CloudflareEnvironment;
193 // Prefer the deployed name: regenerating would rename the deployed
194 // service if the generator's output for this id ever drifts. An
195 // explicit user-provided name still wins (renames apply in place).
196 const name =
197 news.name ??
198 output?.serviceName ??
199 (yield* createServiceName(id, news.name));
200 const acct = output?.accountId ?? accountId;
201
202 // Observe — re-fetch the cached service; fall back to a name
203 // scan so we recover from out-of-band deletes or partial state
204 // persistence failures.
205 let observed: connectivity.GetDirectoryServiceResponse | undefined;
206 if (output?.serviceId) {
207 observed = yield* connectivity
208 .getDirectoryService({
209 accountId: acct,
210 serviceId: output.serviceId,
211 })
212 .pipe(Effect.catch(() => Effect.succeed(undefined)));
213 }
214 if (!observed) {
215 const match = yield* findServiceByName(name);
216 observed = match as
217 | connectivity.GetDirectoryServiceResponse
218 | undefined;
219 }
220
221 // Ensure — create if missing. Cloudflare rejects a duplicate
222 // name with a generic error; tolerate by adopting the existing
223 // service (when the caller opted in) and re-applying the
224 // desired configuration.
225 if (!observed || !observed.serviceId) {
226 const result = yield* connectivity
227 .createDirectoryService({
228 accountId: acct,
229 name,
230 type: news.serviceType ?? "http",
231 httpPort: news.httpPort,
232 httpsPort: news.httpsPort,
233 host: news.host,
234 })
235 .pipe(
236 Effect.catch((err: unknown) =>
237 Effect.gen(function* () {
238 if (!news.adopt) return yield* Effect.fail(err as never);
239 const existing = yield* findServiceByName(name);
240 if (!existing || !existing.serviceId) {
241 return yield* Effect.fail(err as never);
242 }
243 return yield* connectivity.updateDirectoryService({
244 accountId: acct,
245 serviceId: existing.serviceId,
246 name,
247 type: news.serviceType ?? "http",
248 httpPort: news.httpPort,
249 httpsPort: news.httpsPort,
250 host: news.host,
251 });
252 }),
253 ),
254 );
255 return formatVpcService(result, acct);
256 }
257
258 // Sync — the Cloudflare update API replaces all mutable fields
259 // (name, ports, host) atomically, so always issue it so
260 // adoption and routine updates converge.
261 const result = yield* connectivity.updateDirectoryService({
262 accountId: acct,
263 serviceId: observed.serviceId,
264 name,
265 type: news.serviceType ?? observed.type ?? "http",
266 httpPort: news.httpPort,
267 httpsPort: news.httpsPort,
268 host: news.host,
269 });
270 return formatVpcService(result, acct);
271 }),
272 delete: Effect.fn(function* ({ output }) {
273 yield* connectivity
274 .deleteDirectoryService({
275 accountId: output.accountId,
276 serviceId: output.serviceId,
277 })
278 .pipe(Effect.catch(() => Effect.void));
279 }),
280 read: Effect.fn(function* ({ id, output, olds }) {
281 const { accountId } = yield* yield* CloudflareEnvironment;
282 if (output?.serviceId) {
283 return yield* connectivity
284 .getDirectoryService({
285 accountId: output.accountId,
286 serviceId: output.serviceId,
287 })
288 .pipe(
289 Effect.map((s) => formatVpcService(s, output.accountId)),
290 Effect.catch(() => Effect.succeed(undefined)),
291 );
292 }
293 const name = yield* createServiceName(id, olds?.name);
294 const existing = yield* findServiceByName(name);
295 if (!existing || !existing.serviceId) return undefined;
296 return formatVpcService(existing, accountId);
297 }),
298 });
299
300export const formatVpcService = (
301 service: {
302 serviceId?: string | null;
303 name: string;
304 // Distilled widened generated string enums to open unions (`string & {}`);
305 // the API only ever returns the known variants here.
306 type: string;
307 createdAt?: string | null;
308 updatedAt?: string | null;
309 httpPort?: number | null;
310 httpsPort?: number | null;
311 host: connectivity.GetDirectoryServiceResponse["host"];
312 },
313 accountId: string,
314): Attributes => {
315 let host: VpcService.Host;
316 if ("hostname" in service.host) {
317 host = {
318 hostname: service.host.hostname,
319 resolverNetwork: {
320 tunnelId: service.host.resolverNetwork.tunnelId,
321 resolverIps: service.host.resolverNetwork.resolverIps ?? undefined,
322 },
323 };
324 } else if ("ipv4" in service.host && "ipv6" in service.host) {
325 host = {
326 ipv4: service.host.ipv4,
327 ipv6: service.host.ipv6,
328 network: { tunnelId: service.host.network.tunnelId },
329 };
330 } else if ("ipv4" in service.host) {
331 host = {
332 ipv4: service.host.ipv4,
333 network: { tunnelId: service.host.network.tunnelId },
334 };
335 } else {
336 host = {
337 ipv6: service.host.ipv6,
338 network: { tunnelId: service.host.network.tunnelId },
339 };
340 }
341 return {
342 serviceId: service.serviceId!,
343 serviceName: service.name,
344 serviceType: service.type as "http" | "tcp",
345 httpPort: service.httpPort ?? undefined,
346 httpsPort: service.httpsPort ?? undefined,
347 host,
348 accountId,
349 createdAt: service.createdAt
350 ? new Date(service.createdAt).getTime()
351 : undefined,
352 updatedAt: service.updatedAt
353 ? new Date(service.updatedAt).getTime()
354 : undefined,
355 };
356};