1import * as magicTransit from "@distilled.cloud/cloudflare/magic-transit";
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 { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
12import type { Providers } from "../Providers.ts";
13
14const TypeId = "Cloudflare.MagicTransit.Site" as const;
15type TypeId = typeof TypeId;
16
17/**
18 * Geographic location of a Magic WAN site.
19 */
20export interface MagicSiteLocation {
21 /** Latitude of the site. */
22 lat?: string;
23 /** Longitude of the site. */
24 lon?: string;
25}
26
27export interface MagicSiteProps {
28 /**
29 * The name of the site. If omitted, a unique name is generated from the
30 * app, stage, and logical ID.
31 * @default ${app}-${stage}-${id}
32 */
33 name?: string;
34 /**
35 * An optional description of the site.
36 */
37 description?: string;
38 /**
39 * Magic WAN Connector identifier tag to associate with this site.
40 */
41 connectorId?: string;
42 /**
43 * Secondary Magic WAN Connector identifier tag. Used when high
44 * availability mode is on.
45 */
46 secondaryConnectorId?: string;
47 /**
48 * Site high availability mode. If true, the site can have two connectors
49 * and runs in high availability mode. Create-only — changing it triggers
50 * a replacement.
51 * @default false
52 */
53 haMode?: boolean;
54 /**
55 * Location of the site in latitude and longitude.
56 */
57 location?: MagicSiteLocation;
58}
59
60export interface MagicSiteAttributes {
61 /** Cloudflare-assigned identifier of the site. */
62 siteId: string;
63 /** The Cloudflare account the site belongs to. */
64 accountId: string;
65 /** The name of the site. */
66 name: string;
67 /** The site description, if set. */
68 description: string | undefined;
69 /** The associated connector id, if set. */
70 connectorId: string | undefined;
71 /** The associated secondary connector id, if set. */
72 secondaryConnectorId: string | undefined;
73 /** Whether the site runs in high availability mode. */
74 haMode: boolean | undefined;
75 /** Location of the site, if set. */
76 location: MagicSiteLocation | undefined;
77}
78
79export type MagicSite = Resource<
80 TypeId,
81 MagicSiteProps,
82 MagicSiteAttributes,
83 never,
84 Providers
85>;
86
87/**
88 * A Magic WAN site — represents a physical or logical network location
89 * (typically backed by a Magic WAN Connector appliance) under which LANs,
90 * WANs, and ACLs are configured.
91 *
92 * Requires a Magic WAN subscription — accounts without it receive a typed
93 * `MagicWanUnauthorized` error (Cloudflare code 1025).
94 *
95 * `haMode` is create-only — changing it triggers a replacement. Everything
96 * else is updated in place.
97 * ### Creating a site
98 * **Example:** Basic site
99 * ```typescript
100 * const site = yield* Cloudflare.MagicTransit.MagicSite("hq", {
101 * description: "Headquarters",
102 * location: { lat: "37.7749", lon: "-122.4194" },
103 * });
104 * ```
105 *
106 * **Example:** Site with LAN and WAN
107 * ```typescript
108 * const site = yield* Cloudflare.MagicTransit.MagicSite("hq", {});
109 *
110 * const wan = yield* Cloudflare.MagicTransit.MagicSiteWan("hq-wan", {
111 * siteId: site.siteId,
112 * physport: 1,
113 * });
114 *
115 * const lan = yield* Cloudflare.MagicTransit.MagicSiteLan("hq-lan", {
116 * siteId: site.siteId,
117 * physport: 2,
118 * vlanTag: 0,
119 * });
120 * ```
121 *
122 * @see https://developers.cloudflare.com/magic-wan/configuration/connector/
123 *
124 * @resource
125 * @product Magic Transit
126 * @category Network
127 */
128export const MagicSite = Resource<MagicSite>(TypeId);
129
130/**
131 * Returns true if the given value is a MagicSite resource.
132 */
133export const isMagicSite = (value: unknown): value is MagicSite =>
134 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
135
136export const MagicSiteProvider = () =>
137 Provider.succeed(MagicSite, {
138 stables: ["siteId", "accountId", "haMode"],
139
140 // Account collection — Magic WAN sites are account-scoped and enumerated
141 // via the paginated list API. Accounts without a Magic WAN subscription
142 // reject with the typed `MagicWanUnauthorized` (code 1025) → return [].
143 list: Effect.fn(function* () {
144 const { accountId } = yield* yield* CloudflareEnvironment;
145 return yield* magicTransit.listSites.pages({ accountId }).pipe(
146 Stream.runCollect,
147 Effect.map((chunk) =>
148 Array.from(chunk).flatMap((page) =>
149 (page.result ?? []).map((site) => toAttributes(site, accountId)),
150 ),
151 ),
152 Effect.catchTag("MagicWanUnauthorized", () =>
153 Effect.succeed<MagicSiteAttributes[]>([]),
154 ),
155 );
156 }),
157
158 diff: Effect.fn(function* ({ olds, news }) {
159 if (!isResolved(news)) return undefined;
160 if (olds === undefined) return undefined;
161 // haMode is create-only — the update API has no such field.
162 if ((olds.haMode ?? false) !== (news.haMode ?? false)) {
163 return { action: "replace" } as const;
164 }
165 return undefined;
166 }),
167
168 read: Effect.fn(function* ({ id, output, olds }) {
169 const { accountId } = yield* yield* CloudflareEnvironment;
170 const acct = output?.accountId ?? accountId;
171
172 if (output?.siteId) {
173 const observed = yield* getSite(acct, output.siteId);
174 if (observed) return toAttributes(observed, acct);
175 }
176 // Cold read — recover from lost state by matching the deterministic
177 // physical name. Sites carry no ownership markers; report as
178 // Unowned so takeover is gated behind the adopt policy.
179 const name = yield* createSiteName(id, olds?.name);
180 const observed = yield* findByName(acct, name);
181 if (observed) return Unowned(toAttributes(observed, acct));
182 return undefined;
183 }),
184
185 reconcile: Effect.fn(function* ({ id, news, output }) {
186 const { accountId } = yield* yield* CloudflareEnvironment;
187 const name = yield* createSiteName(id, news.name);
188 // Inputs have been resolved to concrete strings by Plan.
189 const connectorId = news.connectorId as string | undefined;
190 const secondaryConnectorId = news.secondaryConnectorId as
191 | string
192 | undefined;
193
194 // Observe — the id on `output` is a hint; fall back to a name scan.
195 let observed = output?.siteId
196 ? yield* getSite(accountId, output.siteId)
197 : undefined;
198 if (!observed) {
199 observed = yield* findByName(accountId, name);
200 }
201
202 // Ensure — create when missing.
203 if (!observed) {
204 const created = yield* magicTransit.createSite({
205 accountId,
206 name,
207 description: news.description,
208 connectorId,
209 secondaryConnectorId,
210 haMode: news.haMode,
211 location: news.location,
212 });
213 return toAttributes(
214 { ...created, id: created.id ?? undefined },
215 accountId,
216 );
217 }
218
219 // Sync — diff observed cloud state against desired; skip on no-op.
220 const dirty =
221 (observed.name ?? undefined) !== name ||
222 (news.description !== undefined &&
223 (observed.description ?? undefined) !== news.description) ||
224 (connectorId !== undefined &&
225 (observed.connectorId ?? undefined) !== connectorId) ||
226 (secondaryConnectorId !== undefined &&
227 (observed.secondaryConnectorId ?? undefined) !==
228 secondaryConnectorId) ||
229 locationDirty(observed.location, news.location);
230 if (dirty) {
231 const updated = yield* magicTransit.updateSite({
232 accountId,
233 siteId: observed.id!,
234 name,
235 description: news.description,
236 connectorId,
237 secondaryConnectorId,
238 location: news.location,
239 });
240 observed = { ...updated, id: updated.id ?? observed.id };
241 }
242
243 return toAttributes(observed, accountId);
244 }),
245
246 delete: Effect.fn(function* ({ output }) {
247 yield* magicTransit
248 .deleteSite({
249 accountId: output.accountId,
250 siteId: output.siteId,
251 })
252 .pipe(Effect.catchTag("SiteNotFound", () => Effect.void));
253 }),
254 });
255
256interface ObservedSite {
257 id?: string | null;
258 name?: string | null;
259 description?: string | null;
260 connectorId?: string | null;
261 secondaryConnectorId?: string | null;
262 haMode?: boolean | null;
263 location?: { lat?: string | null; lon?: string | null } | null;
264}
265
266/**
267 * Read a site by id, mapping "gone" (`SiteNotFound`, HTTP 404) to
268 * `undefined`.
269 */
270const getSite = (accountId: string, siteId: string) =>
271 magicTransit.getSite({ accountId, siteId }).pipe(
272 Effect.map((s): ObservedSite | undefined => s),
273 Effect.catchTag("SiteNotFound", () => Effect.succeed(undefined)),
274 );
275
276/**
277 * Find a site by exact name. Site names are not enforced unique on
278 * Cloudflare's side, so pick the first match deterministically by id.
279 */
280const findByName = (accountId: string, name: string) =>
281 magicTransit.listSites({ accountId }).pipe(
282 Effect.map((r): ObservedSite | undefined =>
283 r.result
284 .filter((s) => s.name === name)
285 .sort((a, b) => (a.id ?? "").localeCompare(b.id ?? ""))
286 .at(0),
287 ),
288 );
289
290const createSiteName = (id: string, name: string | undefined) =>
291 Effect.gen(function* () {
292 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
293 });
294
295const locationDirty = (
296 observed: ObservedSite["location"],
297 desired: MagicSiteLocation | undefined,
298): boolean => {
299 if (desired === undefined) return false;
300 return (
301 (desired.lat !== undefined &&
302 (observed?.lat ?? undefined) !== desired.lat) ||
303 (desired.lon !== undefined && (observed?.lon ?? undefined) !== desired.lon)
304 );
305};
306
307const toAttributes = (
308 site: ObservedSite,
309 accountId: string,
310): MagicSiteAttributes => ({
311 siteId: site.id ?? "",
312 accountId,
313 name: site.name ?? "",
314 description: site.description ?? undefined,
315 connectorId: site.connectorId ?? undefined,
316 secondaryConnectorId: site.secondaryConnectorId ?? undefined,
317 haMode: site.haMode ?? undefined,
318 location: site.location
319 ? {
320 lat: site.location.lat ?? undefined,
321 lon: site.location.lon ?? undefined,
322 }
323 : undefined,
324});