1import * as cloudConnector from "@distilled.cloud/cloudflare/cloud-connector";
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.CloudConnector.Rules" as const;
14type TypeId = typeof TypeId;
15
16/**
17 * The cloud-provider object storage a Cloud Connector rule routes matching
18 * traffic to.
19 */
20export type CloudConnectorProvider =
21 | "aws_s3"
22 | "cloudflare_r2"
23 | "gcp_storage"
24 | "azure_storage";
25
26/**
27 * A single Cloud Connector rule routing matching traffic directly to a
28 * cloud-provider object-storage bucket.
29 */
30export interface Rule {
31 /**
32 * The cloud provider hosting the bucket the rule routes traffic to.
33 */
34 provider: CloudConnectorProvider;
35 /**
36 * Cloudflare Rules language expression selecting the traffic the rule
37 * applies to, e.g. `http.request.uri.path wildcard "/images/*"`.
38 */
39 expression: string;
40 /**
41 * Host of the target bucket — e.g. `mybucket.s3.amazonaws.com` for S3,
42 * or an R2 bucket's public host. Accepts an `Input` so it can reference
43 * another resource's output (commonly an `Bucket`).
44 */
45 host: string;
46 /**
47 * Whether the rule is enabled.
48 * @default true
49 */
50 enabled?: boolean;
51 /**
52 * Informative description of the rule.
53 */
54 description?: string;
55}
56
57export interface RulesProps {
58 /**
59 * Zone the rules apply to. Stable — changing the zone triggers
60 * replacement.
61 */
62 zoneId: string;
63 /**
64 * Ordered list of Cloud Connector rules. The whole list is owned by
65 * this resource and replaced atomically on every change — rules
66 * managed elsewhere in the zone will be overwritten on deploy.
67 */
68 rules: Rule[];
69}
70
71/**
72 * A Cloud Connector rule as Cloudflare reports it.
73 */
74export interface RuleAttribute {
75 /** Cloudflare-assigned identifier of the rule. */
76 id: string | undefined;
77 /** The cloud provider the rule routes traffic to. */
78 provider: string;
79 /** Rules language expression selecting matching traffic. */
80 expression: string;
81 /** Host of the target bucket. */
82 host: string;
83 /** Whether the rule is enabled. */
84 enabled: boolean;
85 /** Informative description of the rule. */
86 description: string | undefined;
87}
88
89export interface RulesAttributes {
90 /** Zone that owns the rule list. */
91 zoneId: string;
92 /** The ordered rule list as Cloudflare reports it. */
93 rules: RuleAttribute[];
94}
95
96export type Rules = Resource<
97 TypeId,
98 RulesProps,
99 RulesAttributes,
100 never,
101 Providers
102>;
103
104/**
105 * The ordered list of Cloud Connector rules for a Cloudflare zone.
106 *
107 * Cloud Connector routes matching traffic directly from Cloudflare's edge
108 * to a cloud-provider object-storage bucket (Cloudflare R2, Amazon S3,
109 * Google Cloud Storage, or Azure Blob Storage) without an origin server.
110 * Each rule pairs a Rules-language expression with the target bucket host.
111 *
112 * The zone has exactly one rule list — the API only supports replacing the
113 * whole list — so this resource owns it in its entirety (PUT-replace
114 * semantics) and there should be at most one `Rules`
115 * resource per zone. Destroying the resource clears the list.
116 *
117 * Safety: when there is no prior state and the zone already has a
118 * non-empty rule list, `read` reports it as `Unowned` and the engine
119 * refuses to take it over unless `--adopt` (or `adopt(true)`) is set.
120 *
121 * Note: Cloud Connector only takes effect on proxied (orange-cloud) DNS
122 * records, and the number of rules per zone is plan-limited.
123 * ### Routing to object storage
124 * **Example:** Serve a path prefix from an S3 bucket
125 * ```typescript
126 * yield* Cloudflare.CloudConnector.Rules("Rules", {
127 * zoneId: zone.zoneId,
128 * rules: [
129 * {
130 * provider: "aws_s3",
131 * expression: 'http.request.uri.path wildcard "/images/*"',
132 * host: "mybucket.s3.amazonaws.com",
133 * description: "serve images from S3",
134 * },
135 * ],
136 * });
137 * ```
138 *
139 * **Example:** Serve static assets from an R2 bucket
140 * ```typescript
141 * const bucket = yield* Cloudflare.R2.Bucket("Assets", {});
142 *
143 * yield* Cloudflare.CloudConnector.Rules("Rules", {
144 * zoneId: zone.zoneId,
145 * rules: [
146 * {
147 * provider: "cloudflare_r2",
148 * expression: 'http.request.uri.path wildcard "/assets/*"',
149 * // public R2 bucket host (r2.dev or a custom domain)
150 * host: publicBucketHost,
151 * description: "static assets from R2",
152 * },
153 * ],
154 * });
155 * ```
156 *
157 * @see https://developers.cloudflare.com/rules/cloud-connector/
158 *
159 * @resource
160 * @product Cloud Connector
161 * @category Rules & Configuration
162 */
163export const Rules = Resource<Rules>(TypeId);
164
165/**
166 * Returns true if the given value is a Rules resource.
167 */
168export const isRules = (value: unknown): value is Rules =>
169 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
170
171export const RulesProvider = () =>
172 Provider.succeed(Rules, {
173 stables: ["zoneId"],
174
175 list: Effect.fn(function* () {
176 const { accountId } = yield* yield* CloudflareEnvironment;
177 // No account-wide API for Cloud Connector rules — the rule list is
178 // a per-zone singleton, so enumerate every zone and read its list.
179 const zones = yield* listAllZones(accountId);
180 const rows = yield* Effect.forEach(
181 zones,
182 (zone) =>
183 listObservedRules(zone.id).pipe(
184 Effect.map((rules): RulesAttributes | undefined =>
185 // A zone with no rules has nothing to manage — same as
186 // `read` returning `undefined` for an empty list.
187 rules.length === 0 ? undefined : { zoneId: zone.id, rules },
188 ),
189 // Plan-gated zones reject the route; skip them.
190 Effect.catchTag("Forbidden", () => Effect.succeed(undefined)),
191 ),
192 { concurrency: 10 },
193 );
194 return rows.filter((row): row is RulesAttributes => row !== undefined);
195 }),
196
197 diff: Effect.fn(function* ({ olds, news, output }) {
198 const o = olds as RulesProps;
199 const n = news as RulesProps;
200 // zoneId is the resource's identity; compare only once both sides
201 // are concrete.
202 const oldZoneId =
203 output?.zoneId ?? (typeof o.zoneId === "string" ? o.zoneId : undefined);
204 if (
205 typeof n.zoneId === "string" &&
206 oldZoneId !== undefined &&
207 oldZoneId !== n.zoneId
208 ) {
209 return { action: "replace" } as const;
210 }
211 }),
212
213 read: Effect.fn(function* ({ olds, output }) {
214 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
215 if (!zoneId) return undefined;
216 const observed = yield* listObservedRules(zoneId);
217 // An empty list means the resource doesn't exist — the API has no
218 // separate "created but empty" state.
219 if (observed.length === 0) return undefined;
220 const attrs: RulesAttributes = { zoneId, rules: observed };
221 // No prior state of our own but the zone already has rules — they
222 // may be managed by hand or by another tool. Refuse to take over
223 // unless adoption is explicitly allowed.
224 if (output === undefined) return Unowned(attrs);
225 return attrs;
226 }),
227
228 reconcile: Effect.fn(function* ({ news, output }) {
229 // Inputs have been resolved to concrete values by Plan.
230 const zoneId = (output?.zoneId ?? news.zoneId) as string;
231 const desired = news.rules.map((rule) => ({
232 provider: rule.provider,
233 expression: rule.expression,
234 host: rule.host as string,
235 enabled: rule.enabled ?? true,
236 description: rule.description,
237 }));
238
239 // Observe the live rule list and skip the PUT when it already
240 // matches the desired state (ignoring server-assigned rule ids).
241 const observed = yield* listObservedRules(zoneId);
242 if (!rulesEqual(desired, observed)) {
243 yield* cloudConnector.putRule({
244 zoneId,
245 rules: desired.map((rule) => ({
246 provider: rule.provider,
247 expression: rule.expression,
248 parameters: { host: rule.host },
249 enabled: rule.enabled,
250 description: rule.description,
251 })),
252 });
253 }
254
255 // Re-read so attributes carry Cloudflare's canonical view,
256 // including server-assigned rule ids.
257 const synced = yield* listObservedRules(zoneId);
258 return { zoneId, rules: synced } satisfies RulesAttributes;
259 }),
260
261 delete: Effect.fn(function* ({ output }) {
262 // The API has no DELETE endpoint — clearing the list is a PUT with
263 // an empty rule array, which is naturally idempotent. A zone deleted
264 // out-of-band surfaces as InvalidRoute (Cloudflare code 7003) — the
265 // rules are gone with it.
266 yield* cloudConnector
267 .putRule({ zoneId: output.zoneId, rules: [] })
268 .pipe(Effect.catchTag("InvalidRoute", () => Effect.void));
269 }),
270 });
271
272const listObservedRules = (zoneId: string) =>
273 cloudConnector.listRules.items({ zoneId }).pipe(
274 Stream.runCollect,
275 Effect.map((chunk): RuleAttribute[] =>
276 Array.from(chunk).flatMap((rule) =>
277 rule.expression == null ||
278 rule.provider == null ||
279 rule.parameters?.host == null
280 ? []
281 : [
282 {
283 id: rule.id ?? undefined,
284 provider: rule.provider,
285 expression: rule.expression,
286 host: rule.parameters.host,
287 enabled: rule.enabled ?? true,
288 description: rule.description ?? undefined,
289 },
290 ],
291 ),
292 ),
293 // A zone that has never had Cloud Connector rules configured reports
294 // "could not find entrypoint ruleset" (code 10003) — that's just an
295 // empty list.
296 Effect.catchTag("CloudConnectorRulesNotFound", () =>
297 Effect.succeed([] as RuleAttribute[]),
298 ),
299 );
300
301const rulesEqual = (
302 desired: ReadonlyArray<{
303 provider: string;
304 expression: string;
305 host: string;
306 enabled: boolean;
307 description: string | undefined;
308 }>,
309 observed: ReadonlyArray<RuleAttribute>,
310): boolean =>
311 desired.length === observed.length &&
312 desired.every((d, i) => {
313 const o = observed[i];
314 return (
315 d.provider === o.provider &&
316 d.expression === o.expression &&
317 d.host === o.host &&
318 d.enabled === o.enabled &&
319 (d.description ?? undefined) === o.description
320 );
321 });