1import * as addressing from "@distilled.cloud/cloudflare/addressing";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
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
12const TypeId = "Cloudflare.Addressing.ServiceBinding" as const;
13type TypeId = typeof TypeId;
14
15export interface ServiceBindingProps {
16 /**
17 * Identifier of the parent BYOIP prefix. Changing it forces a
18 * replacement.
19 */
20 prefixId: string;
21 /**
22 * IP Prefix in Classless Inter-Domain Routing format to bind. Must be
23 * contained in the parent prefix. Changing it forces a replacement.
24 */
25 cidr: string;
26 /**
27 * Identifier of the Cloudflare service (CDN, Spectrum, Magic Transit) to
28 * bind the CIDR to. Service IDs are discoverable via the
29 * `addressing.listServices` catalog. Changing it forces a replacement.
30 */
31 serviceId: string;
32}
33
34export interface ServiceBindingAttributes {
35 /** Cloudflare-assigned identifier of the service binding. */
36 bindingId: string;
37 /** Identifier of the parent BYOIP prefix. */
38 prefixId: string;
39 /** The Cloudflare account the prefix belongs to. */
40 accountId: string;
41 /** Bound IP Prefix in CIDR format. */
42 cidr: string;
43 /** Identifier of the bound Cloudflare service. */
44 serviceId: string;
45 /** Name of the bound Cloudflare service. */
46 serviceName: string | undefined;
47 /**
48 * Deployment status of the binding on the Cloudflare network.
49 * Provisioning is asynchronous — `state` flips from `provisioning` to
50 * `active` after a few minutes.
51 */
52 provisioning: { state: string | undefined };
53}
54
55export type ServiceBinding = Resource<
56 TypeId,
57 ServiceBindingProps,
58 ServiceBindingAttributes,
59 never,
60 Providers
61>;
62
63/**
64 * Binds part of a BYOIP prefix to a Cloudflare service (CDN, Spectrum, or
65 * Magic Transit), routing traffic for the bound CIDR to that service.
66 *
67 * Bindings are create/delete only — every prop change forces a
68 * replacement. Provisioning to the edge is asynchronous: the binding is
69 * returned immediately with `provisioning.state: "provisioning"` and flips
70 * to `"active"` on Cloudflare's side; the resource does not wait for it.
71 * ### Binding a Prefix to a Service
72 * **Example:** Bind a /24 to the CDN
73 * ```typescript
74 * const binding = yield* Cloudflare.Addressing.ServiceBinding("cdn", {
75 * prefixId: prefix.prefixId,
76 * cidr: "192.0.2.0/24",
77 * serviceId: cdnServiceId,
78 * });
79 * ```
80 *
81 * @see https://developers.cloudflare.com/byoip/concepts/service-bindings/
82 *
83 * @resource
84 * @product Addressing
85 * @category Network
86 */
87export const ServiceBinding = Resource<ServiceBinding>(TypeId);
88
89/**
90 * Returns true if the given value is an ServiceBinding resource.
91 */
92export const isServiceBinding = (value: unknown): value is ServiceBinding =>
93 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
94
95export const ServiceBindingProvider = () =>
96 Provider.succeed(ServiceBinding, {
97 stables: ["bindingId", "prefixId", "accountId", "cidr", "serviceId"],
98
99 diff: Effect.fn(function* ({ olds, news, output }) {
100 if (olds === undefined) return undefined;
101 if (!isResolved(news) || !isResolved(olds)) return undefined;
102 // Create/delete only — any change forces a replacement.
103 const oldPrefixId = output?.prefixId ?? olds.prefixId;
104 if (
105 typeof oldPrefixId === "string" &&
106 typeof news.prefixId === "string" &&
107 news.prefixId !== oldPrefixId
108 ) {
109 return { action: "replace" } as const;
110 }
111 if (news.cidr !== (output?.cidr ?? olds.cidr)) {
112 return { action: "replace" } as const;
113 }
114 const oldServiceId = output?.serviceId ?? olds.serviceId;
115 if (
116 typeof oldServiceId === "string" &&
117 typeof news.serviceId === "string" &&
118 news.serviceId !== oldServiceId
119 ) {
120 return { action: "replace" } as const;
121 }
122 return undefined;
123 }),
124
125 read: Effect.fn(function* ({ output, olds }) {
126 const { accountId } = yield* yield* CloudflareEnvironment;
127 const acct = output?.accountId ?? accountId;
128 const prefixId =
129 output?.prefixId ??
130 (typeof olds?.prefixId === "string" ? olds.prefixId : undefined);
131 if (!prefixId) return undefined;
132
133 if (output?.bindingId) {
134 const observed = yield* getBinding(acct, prefixId, output.bindingId);
135 return observed ? toAttributes(observed, prefixId, acct) : undefined;
136 }
137 // Cold read — (cidr, serviceId) identify a binding uniquely.
138 const cidr = output?.cidr ?? olds?.cidr;
139 if (typeof cidr !== "string") return undefined;
140 const match = yield* findByCidr(acct, prefixId, cidr);
141 return match ? toAttributes(match, prefixId, acct) : undefined;
142 }),
143
144 // Service bindings are children of a BYOIP prefix with no account-wide
145 // enumeration API — fan out across every account prefix and list each
146 // prefix's bindings, hydrating into the exact `read` shape.
147 list: Effect.fn(function* () {
148 const { accountId } = yield* yield* CloudflareEnvironment;
149 const prefixIds = yield* addressing.listPrefixes
150 .items({ accountId })
151 .pipe(
152 Stream.runCollect,
153 Effect.map((chunk) =>
154 Array.from(chunk)
155 .map((p) => p.id)
156 .filter((id): id is string => typeof id === "string"),
157 ),
158 );
159 const perPrefix = yield* Effect.forEach(
160 prefixIds,
161 (prefixId) =>
162 addressing.listPrefixServiceBindings
163 .items({ accountId, prefixId })
164 .pipe(
165 Stream.runCollect,
166 Effect.map((chunk) =>
167 Array.from(chunk).map((b) =>
168 toAttributes(b, prefixId, accountId),
169 ),
170 ),
171 // The parent prefix vanished between enumeration and listing.
172 Effect.catchTag("PrefixNotFound", () =>
173 Effect.succeed<ServiceBindingAttributes[]>([]),
174 ),
175 ),
176 { concurrency: 10 },
177 );
178 return perPrefix.flat();
179 }),
180
181 reconcile: Effect.fn(function* ({ news, output }) {
182 const { accountId } = yield* yield* CloudflareEnvironment;
183 const acct = output?.accountId ?? accountId;
184 const prefixId = news.prefixId as string;
185 const serviceId = news.serviceId as string;
186
187 // 1. Observe — by cached id, then by CIDR.
188 let observed = output?.bindingId
189 ? yield* getBinding(acct, prefixId, output.bindingId)
190 : undefined;
191 if (!observed) {
192 observed = yield* findByCidr(acct, prefixId, news.cidr);
193 }
194
195 // 2. Ensure — nothing is mutable, so an observed binding is already
196 // converged (a serviceId mismatch is a replacement, handled by
197 // diff). Provisioning is async; return immediately.
198 if (observed) {
199 return toAttributes(observed, prefixId, acct);
200 }
201 const created = yield* addressing.createPrefixServiceBinding({
202 accountId: acct,
203 prefixId,
204 cidr: news.cidr,
205 serviceId,
206 });
207 return toAttributes(created, prefixId, acct);
208 }),
209
210 delete: Effect.fn(function* ({ output }) {
211 yield* addressing
212 .deletePrefixServiceBinding({
213 accountId: output.accountId,
214 prefixId: output.prefixId,
215 bindingId: output.bindingId,
216 })
217 .pipe(
218 Effect.catchTag(
219 ["BindingNotFound", "PrefixNotFound"],
220 () => Effect.void,
221 ),
222 );
223 }),
224 });
225
226type ObservedBinding = addressing.GetPrefixServiceBindingResponse;
227
228/**
229 * Read a service binding by id, mapping "gone" (`BindingNotFound` /
230 * `PrefixNotFound`) to `undefined`.
231 */
232const getBinding = (accountId: string, prefixId: string, bindingId: string) =>
233 addressing
234 .getPrefixServiceBinding({ accountId, prefixId, bindingId })
235 .pipe(
236 Effect.catchTag(["BindingNotFound", "PrefixNotFound"], () =>
237 Effect.succeed(undefined),
238 ),
239 );
240
241/**
242 * Find a service binding by exact CIDR — unique within a parent prefix.
243 * The parent prefix being gone reads as "no match".
244 */
245const findByCidr = (accountId: string, prefixId: string, cidr: string) =>
246 addressing.listPrefixServiceBindings.items({ accountId, prefixId }).pipe(
247 Stream.runCollect,
248 Effect.map((chunk) => Array.from(chunk).find((b) => b.cidr === cidr)),
249 Effect.catchTag("PrefixNotFound", () => Effect.succeed(undefined)),
250 );
251
252const toAttributes = (
253 binding: ObservedBinding,
254 prefixId: string,
255 accountId: string,
256): ServiceBindingAttributes => ({
257 bindingId: binding.id ?? "",
258 prefixId,
259 accountId,
260 cidr: binding.cidr ?? "",
261 serviceId: binding.serviceId ?? "",
262 serviceName: binding.serviceName ?? undefined,
263 provisioning: { state: binding.provisioning?.state ?? undefined },
264});