1import * as pages from "@distilled.cloud/cloudflare/pages";
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 type { Input } from "../../Input.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { arrayEqualsUnordered } from "../../Util/equal.ts";
13import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
14import type { Providers } from "../Providers.ts";
15
16const TypeId = "Cloudflare.Pages.Project" as const;
17type TypeId = typeof TypeId;
18
19/**
20 * Build configuration for a Pages project. All fields are mutable in place.
21 */
22export interface BuildConfig {
23 /**
24 * Enable build caching for the project.
25 */
26 buildCaching?: boolean;
27 /**
28 * Command used to build the project (e.g. `npm run build`).
29 */
30 buildCommand?: string;
31 /**
32 * Output directory of the build, relative to `rootDir` (e.g. `dist`).
33 */
34 destinationDir?: string;
35 /**
36 * Directory to run the build command in. Defaults to the repository root.
37 */
38 rootDir?: string;
39}
40
41/**
42 * A single environment variable on a Pages deployment environment.
43 */
44export interface EnvVar {
45 /**
46 * Whether the value is stored as plain text or encrypted as a secret.
47 * @default "plain_text"
48 */
49 type?: "plain_text" | "secret_text";
50 /**
51 * The environment variable value.
52 */
53 value: string;
54}
55
56/**
57 * Per-environment (preview / production) deployment configuration.
58 *
59 * Record-shaped fields (`envVars`, `kvNamespaces`, `d1Databases`,
60 * `r2Buckets`) are reconciled key-by-key: Cloudflare's PATCH endpoint
61 * deep-merges, so keys observed on the project but absent from the desired
62 * config are explicitly removed.
63 */
64export interface DeploymentConfig {
65 /**
66 * Environment variables, keyed by variable name.
67 */
68 envVars?: Record<string, EnvVar>;
69 /**
70 * KV namespace bindings, keyed by binding name. The value is the KV
71 * namespace id (e.g. `kvNamespace.namespaceId`).
72 */
73 kvNamespaces?: Record<string, string>;
74 /**
75 * D1 database bindings, keyed by binding name. The value is the D1
76 * database UUID (e.g. `database.databaseId`).
77 */
78 d1Databases?: Record<string, string>;
79 /**
80 * R2 bucket bindings, keyed by binding name. The value is the bucket
81 * name (e.g. `bucket.bucketName`).
82 */
83 r2Buckets?: Record<string, string>;
84 /**
85 * Compatibility date used by Pages Functions (e.g. `2025-01-01`).
86 */
87 compatibilityDate?: string;
88 /**
89 * Compatibility flags used by Pages Functions.
90 */
91 compatibilityFlags?: string[];
92 /**
93 * Whether requests are served (`true`) or rejected (`false`) when a
94 * Pages Function exceeds its limits.
95 */
96 failOpen?: boolean;
97 /**
98 * Placement settings for Pages Functions (e.g. `{ mode: "smart" }`).
99 */
100 placement?: { mode: string };
101}
102
103/**
104 * Deployment configuration for both Pages environments.
105 */
106export interface DeploymentConfigs {
107 /**
108 * Configuration applied to preview (non-production-branch) deployments.
109 */
110 preview?: DeploymentConfig;
111 /**
112 * Configuration applied to production deployments.
113 */
114 production?: DeploymentConfig;
115}
116
117export interface ProjectProps {
118 /**
119 * Name of the project. Forms the `<name>.pages.dev` subdomain, so it must
120 * be unique across all of Cloudflare Pages and contain only lowercase
121 * letters, numbers, and dashes. The name is the project's identity —
122 * changing it triggers a replacement.
123 * @default ${app}-${stage}-${id}
124 */
125 name?: string;
126 /**
127 * Production branch of the project. Deployments to this branch are
128 * production deployments; any other branch produces a preview deployment.
129 * Mutable in place.
130 * @default "main"
131 */
132 productionBranch?: string;
133 /**
134 * Configs for the project build process. Mutable in place.
135 */
136 buildConfig?: BuildConfig;
137 /**
138 * Per-environment deployment configuration (env vars, bindings,
139 * compatibility settings). Mutable in place.
140 */
141 deploymentConfigs?: DeploymentConfigs;
142}
143
144export interface ProjectAttributes {
145 /**
146 * Cloudflare-assigned UUID of the project.
147 */
148 projectId: string;
149 /**
150 * The Cloudflare account the project belongs to.
151 */
152 accountId: string;
153 /**
154 * Name of the project.
155 */
156 name: string;
157 /**
158 * The `*.pages.dev` subdomain serving the project (e.g.
159 * `my-project.pages.dev`).
160 */
161 subdomain: string;
162 /**
163 * All domains attached to the project, including the `*.pages.dev`
164 * subdomain and any custom domains.
165 */
166 domains: string[];
167 /**
168 * Production branch of the project.
169 */
170 productionBranch: string;
171 /**
172 * When the project was created.
173 */
174 createdOn: string;
175}
176
177export type Project = Resource<
178 TypeId,
179 ProjectProps,
180 ProjectAttributes,
181 never,
182 Providers
183>;
184
185/**
186 * A Cloudflare Pages project (direct-upload).
187 *
188 * The project is the container for Pages deployments, custom domains, and
189 * per-environment configuration. Created without git `source` integration,
190 * it is a direct-upload project — deployments are pushed via the API or
191 * `wrangler pages deploy`.
192 *
193 * The project `name` is its identity (it forms the `<name>.pages.dev`
194 * subdomain), so renaming triggers a replacement. `productionBranch`,
195 * `buildConfig`, and `deploymentConfigs` are all mutable in place.
196 * ### Creating a Project
197 * **Example:** Minimal project (generated name)
198 * ```typescript
199 * const project = yield* Cloudflare.Pages.Project("site", {});
200 * // project.subdomain === "<generated-name>.pages.dev"
201 * ```
202 *
203 * **Example:** Named project with a build config
204 * ```typescript
205 * const project = yield* Cloudflare.Pages.Project("site", {
206 * name: "my-site",
207 * productionBranch: "main",
208 * buildConfig: {
209 * buildCommand: "npm run build",
210 * destinationDir: "dist",
211 * },
212 * });
213 * ```
214 *
215 * ### Deployment Configuration
216 * **Example:** Environment variables and bindings
217 * ```typescript
218 * const project = yield* Cloudflare.Pages.Project("site", {
219 * deploymentConfigs: {
220 * production: {
221 * compatibilityDate: "2026-08-31",
222 * envVars: {
223 * API_URL: { value: "https://api.example.com" },
224 * API_KEY: { type: "secret_text", value: apiKey },
225 * },
226 * kvNamespaces: {
227 * CACHE: kvNamespace.namespaceId,
228 * },
229 * },
230 * },
231 * });
232 * ```
233 *
234 * ### Custom Domains
235 * **Example:** Attach a custom domain
236 * ```typescript
237 * const domain = yield* Cloudflare.Pages.Domain("site-domain", {
238 * projectName: project.name,
239 * name: "www.example.com",
240 * });
241 * ```
242 *
243 * @see https://developers.cloudflare.com/pages/
244 *
245 * @resource
246 * @product Pages
247 * @category Workers & Compute
248 */
249export const Project = Resource<Project>(TypeId);
250
251/**
252 * Returns true if the given value is a Project resource.
253 */
254export const isProject = (value: unknown): value is Project =>
255 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
256
257export const ProjectProvider = () =>
258 Provider.succeed(Project, {
259 stables: ["projectId", "accountId", "name", "subdomain", "createdOn"],
260 diff: Effect.fn(function* ({ id, olds, news, output }) {
261 const { accountId } = yield* yield* CloudflareEnvironment;
262 if (!isResolved(news)) return undefined;
263 if ((output?.accountId ?? accountId) !== accountId) {
264 return { action: "replace" } as const;
265 }
266 // The name is the project's identity (it forms the *.pages.dev
267 // subdomain) — renames are a delete + create.
268 const oldName =
269 output?.name ?? (yield* createProjectName(id, olds?.name));
270 // Auto-generated names are engine-owned: the deployed name stays
271 // authoritative even if the generator would name this id differently
272 // today. Only an explicit user-provided name can force a replace.
273 const name = news.name ?? oldName;
274 if (name !== oldName) {
275 return { action: "replace" } as const;
276 }
277 return undefined;
278 }),
279 read: Effect.fn(function* ({ id, output, olds }) {
280 const { accountId } = yield* yield* CloudflareEnvironment;
281 const acct = output?.accountId ?? accountId;
282 if (output?.name) {
283 const observed = yield* getProject(acct, output.name);
284 return observed ? toAttributes(observed, acct) : undefined;
285 }
286 // Cold read — the project name is deterministic, so look it up
287 // directly. Pages projects carry no ownership markers we can
288 // inspect, so an existing match is reported as Unowned and takeover
289 // is gated behind the adopt policy.
290 const name = yield* createProjectName(id, olds?.name);
291 const observed = yield* getProject(acct, name);
292 return observed ? Unowned(toAttributes(observed, acct)) : undefined;
293 }),
294 reconcile: Effect.fn(function* ({ id, news, output }) {
295 const { accountId } = yield* yield* CloudflareEnvironment;
296 const name = news.name ?? output?.name ?? (yield* createProjectName(id));
297
298 // 1. Observe — the project name is its identity; a 404 falls
299 // through to "missing" and we (re)create.
300 let observed = yield* getProject(accountId, name);
301
302 // 2. Ensure — create when missing, tolerating the AlreadyExists
303 // race by re-reading.
304 if (!observed) {
305 observed = yield* pages
306 .createProject({
307 accountId,
308 name,
309 productionBranch: news.productionBranch ?? "main",
310 buildConfig: toApiBuildConfig(news.buildConfig),
311 deploymentConfigs: toApiDeploymentConfigs(
312 news.deploymentConfigs,
313 undefined,
314 ),
315 })
316 .pipe(
317 Effect.catchTag("ProjectAlreadyExists", (originalError) =>
318 Effect.gen(function* () {
319 const existing = yield* getProject(accountId, name);
320 if (!existing) return yield* Effect.fail(originalError);
321 return existing;
322 }),
323 ),
324 );
325 }
326
327 // 3. Sync — diff observed cloud state against the desired props and
328 // PATCH only the delta. Cloudflare's PATCH deep-merges
329 // `deploymentConfigs`, so removed record keys are sent as
330 // explicit nulls.
331 const patch = buildProjectPatch(news, observed);
332 if (patch !== undefined) {
333 observed = yield* pages.patchProject({
334 accountId,
335 projectName: name,
336 ...patch,
337 });
338 }
339
340 // 4. Return fresh attributes.
341 return toAttributes(observed, accountId);
342 }),
343 delete: Effect.fn(function* ({ output }) {
344 // Deleting a project deletes its deployments and detaches custom
345 // domains. A missing project is success — delete is idempotent.
346 yield* pages
347 .deleteProject({
348 accountId: output.accountId,
349 projectName: output.name,
350 })
351 .pipe(Effect.catchTag("ProjectNotFound", () => Effect.void));
352 }),
353 // Pages projects are account-scoped; enumerate every project in the
354 // account, paginating exhaustively, and hydrate each into the same
355 // Attributes shape `read` returns.
356 list: Effect.fn(function* () {
357 const { accountId } = yield* yield* CloudflareEnvironment;
358 return yield* pages.listProjects.pages({ accountId }).pipe(
359 Stream.runCollect,
360 Effect.map((chunk) =>
361 Array.from(chunk).flatMap((page) =>
362 (page.result ?? []).map((project): ProjectAttributes => ({
363 projectId: project.id,
364 accountId,
365 name: project.name,
366 subdomain: project.subdomain ?? `${project.name}.pages.dev`,
367 domains: [...(project.domains ?? [])],
368 productionBranch: project.productionBranch,
369 createdOn: project.createdOn,
370 })),
371 ),
372 ),
373 );
374 }),
375 });
376
377// ---------------------------------------------------------------------------
378// Observation
379// ---------------------------------------------------------------------------
380
381type ObservedProject =
382 | pages.GetProjectResponse
383 | pages.CreateProjectResponse
384 | pages.PatchProjectResponse;
385
386/**
387 * Read a project by name, mapping "gone" (`ProjectNotFound`, Cloudflare
388 * error code 8000007) to `undefined`.
389 */
390const getProject = (accountId: string, projectName: string) =>
391 pages
392 .getProject({ accountId, projectName })
393 .pipe(Effect.catchTag("ProjectNotFound", () => Effect.succeed(undefined)));
394
395/**
396 * Pages project names form the `<name>.pages.dev` subdomain: lowercase
397 * alphanumerics and dashes, at most 58 characters.
398 */
399const createProjectName = (id: string, name?: string) =>
400 Effect.gen(function* () {
401 return (
402 name ??
403 (yield* createPhysicalName({ id, lowercase: true, maxLength: 58 }))
404 );
405 });
406
407const toAttributes = (
408 project: ObservedProject,
409 accountId: string,
410): ProjectAttributes => ({
411 projectId: project.id,
412 accountId,
413 name: project.name,
414 subdomain: project.subdomain ?? `${project.name}.pages.dev`,
415 domains: [...(project.domains ?? [])],
416 productionBranch: project.productionBranch,
417 createdOn: project.createdOn,
418});
419
420// ---------------------------------------------------------------------------
421// Desired-state mapping
422// ---------------------------------------------------------------------------
423
424type ApiBuildConfig = NonNullable<pages.CreateProjectRequest["buildConfig"]>;
425type ApiDeploymentConfigs = NonNullable<
426 pages.CreateProjectRequest["deploymentConfigs"]
427>;
428type ApiEnvConfig = NonNullable<ApiDeploymentConfigs["production"]>;
429type ObservedEnvConfig =
430 pages.GetProjectResponse["deploymentConfigs"]["production"];
431
432const toApiBuildConfig = (
433 config: BuildConfig | undefined,
434): ApiBuildConfig | undefined =>
435 config === undefined
436 ? undefined
437 : {
438 buildCaching: config.buildCaching,
439 buildCommand: config.buildCommand,
440 destinationDir: config.destinationDir,
441 rootDir: config.rootDir,
442 };
443
444/**
445 * Map the desired deployment config of one environment to the API shape.
446 * When `observed` is provided, record-shaped fields include explicit
447 * `null`s for observed keys absent from the desired config — Cloudflare's
448 * PATCH endpoint deep-merges, so this is how keys are removed.
449 */
450const toApiEnvConfig = (
451 desired: DeploymentConfig,
452 observed: ObservedEnvConfig | undefined,
453): ApiEnvConfig => ({
454 envVars: mergeRecord(
455 desired.envVars === undefined
456 ? undefined
457 : Object.fromEntries(
458 Object.entries(desired.envVars).map(([key, envVar]) => [
459 key,
460 {
461 type: envVar.type ?? "plain_text",
462 value: envVar.value as string,
463 },
464 ]),
465 ),
466 observed?.envVars,
467 ),
468 kvNamespaces: mergeRecord(
469 mapBindingRecord(desired.kvNamespaces, "namespace_id"),
470 observed?.kvNamespaces,
471 ),
472 d1Databases: mergeRecord(
473 mapBindingRecord(desired.d1Databases, "id"),
474 observed?.d1Databases,
475 ),
476 r2Buckets: mergeRecord(
477 mapBindingRecord(desired.r2Buckets, "name"),
478 observed?.r2Buckets,
479 ),
480 compatibilityDate: desired.compatibilityDate,
481 compatibilityFlags: desired.compatibilityFlags,
482 failOpen: desired.failOpen,
483 placement: desired.placement,
484});
485
486const mapBindingRecord = (
487 record: Record<string, Input<string>> | undefined,
488 idKey: string,
489): Record<string, unknown> | undefined =>
490 record === undefined
491 ? undefined
492 : Object.fromEntries(
493 Object.entries(record).map(([binding, id]) => [
494 binding,
495 { [idKey]: id as string },
496 ]),
497 );
498
499/**
500 * Overlay the desired record on top of the observed one, nulling observed
501 * keys that are absent from the desired record so PATCH removes them.
502 * `undefined` desired means "unmanaged" — leave the cloud state alone.
503 */
504const mergeRecord = (
505 desired: Record<string, unknown> | undefined,
506 observed: Record<string, unknown> | null | undefined,
507): Record<string, unknown> | undefined => {
508 if (desired === undefined) return undefined;
509 const removals = Object.fromEntries(
510 Object.keys(observed ?? {})
511 .filter((key) => !(key in desired))
512 .map((key) => [key, null]),
513 );
514 return { ...removals, ...desired };
515};
516
517const toApiDeploymentConfigs = (
518 desired: DeploymentConfigs | undefined,
519 observed: pages.GetProjectResponse["deploymentConfigs"] | undefined,
520): ApiDeploymentConfigs | undefined => {
521 if (desired === undefined) return undefined;
522 const configs: ApiDeploymentConfigs = {};
523 if (desired.preview !== undefined) {
524 configs.preview = toApiEnvConfig(desired.preview, observed?.preview);
525 }
526 if (desired.production !== undefined) {
527 configs.production = toApiEnvConfig(
528 desired.production,
529 observed?.production,
530 );
531 }
532 return configs;
533};
534
535// ---------------------------------------------------------------------------
536// Drift detection
537// ---------------------------------------------------------------------------
538
539type ProjectPatchBody = Pick<
540 pages.PatchProjectRequest,
541 "productionBranch" | "buildConfig" | "deploymentConfigs"
542>;
543
544/**
545 * Compute the PATCH body needed to converge the observed project to the
546 * desired props, or `undefined` when the project is already converged.
547 * Props left `undefined` are unmanaged and never touched.
548 */
549const buildProjectPatch = (
550 news: ProjectProps,
551 observed: ObservedProject,
552): ProjectPatchBody | undefined => {
553 const patch: ProjectPatchBody = {};
554 let dirty = false;
555
556 const desiredBranch = news.productionBranch ?? "main";
557 if (observed.productionBranch !== desiredBranch) {
558 patch.productionBranch = desiredBranch;
559 dirty = true;
560 }
561
562 if (
563 news.buildConfig !== undefined &&
564 buildConfigDirty(news.buildConfig, observed.buildConfig)
565 ) {
566 patch.buildConfig = toApiBuildConfig(news.buildConfig);
567 dirty = true;
568 }
569
570 if (news.deploymentConfigs !== undefined) {
571 const desired = toApiDeploymentConfigs(
572 news.deploymentConfigs,
573 observedDeploymentConfigs(observed),
574 );
575 if (
576 desired !== undefined &&
577 deploymentConfigsDirty(desired, observedDeploymentConfigs(observed))
578 ) {
579 patch.deploymentConfigs = desired;
580 dirty = true;
581 }
582 }
583
584 return dirty ? patch : undefined;
585};
586
587const observedDeploymentConfigs = (
588 observed: ObservedProject,
589): pages.GetProjectResponse["deploymentConfigs"] => observed.deploymentConfigs;
590
591const buildConfigDirty = (
592 desired: BuildConfig,
593 observed: ObservedProject["buildConfig"],
594): boolean => {
595 const fields: (keyof BuildConfig)[] = [
596 "buildCaching",
597 "buildCommand",
598 "destinationDir",
599 "rootDir",
600 ];
601 return fields.some(
602 (field) =>
603 desired[field] !== undefined &&
604 desired[field] !== (observed?.[field] ?? undefined),
605 );
606};
607
608const deploymentConfigsDirty = (
609 desired: ApiDeploymentConfigs,
610 observed: pages.GetProjectResponse["deploymentConfigs"] | undefined,
611): boolean =>
612 (desired.preview !== undefined &&
613 envConfigDirty(desired.preview, observed?.preview)) ||
614 (desired.production !== undefined &&
615 envConfigDirty(desired.production, observed?.production));
616
617const envConfigDirty = (
618 desired: ApiEnvConfig,
619 observed: ObservedEnvConfig | undefined,
620): boolean => {
621 if (
622 desired.compatibilityDate !== undefined &&
623 desired.compatibilityDate !== observed?.compatibilityDate
624 ) {
625 return true;
626 }
627 if (
628 desired.compatibilityFlags !== undefined &&
629 !arrayEqualsUnordered(
630 desired.compatibilityFlags,
631 observed?.compatibilityFlags ?? [],
632 )
633 ) {
634 return true;
635 }
636 if (
637 desired.failOpen !== undefined &&
638 desired.failOpen !== observed?.failOpen
639 ) {
640 return true;
641 }
642 if (
643 desired.placement !== undefined &&
644 desired.placement.mode !== observed?.placement?.mode
645 ) {
646 return true;
647 }
648 return (
649 recordDirty(desired.envVars, observed?.envVars, envVarEquals) ||
650 recordDirty(desired.kvNamespaces, observed?.kvNamespaces, deepEquals) ||
651 recordDirty(desired.d1Databases, observed?.d1Databases, deepEquals) ||
652 recordDirty(desired.r2Buckets, observed?.r2Buckets, deepEquals)
653 );
654};
655
656/**
657 * A merged desired record (which already contains `null` removal markers)
658 * is dirty when any non-null entry differs from the observed value or any
659 * null marker still has an observed value to remove.
660 */
661const recordDirty = (
662 desired: Record<string, unknown> | undefined,
663 observed: Record<string, unknown> | null | undefined,
664 equals: (a: unknown, b: unknown) => boolean,
665): boolean => {
666 if (desired === undefined) return false;
667 return Object.entries(desired).some(([key, value]) =>
668 value === null
669 ? observed?.[key] !== undefined && observed[key] !== null
670 : !equals(value, observed?.[key]),
671 );
672};
673
674/**
675 * Secrets read back as `{ type: "secret_text" }` without a value, so a
676 * desired secret with a value can never be proven converged — treat
677 * matching-type secrets as equal and rely on PATCH idempotency.
678 */
679const envVarEquals = (desired: unknown, observed: unknown): boolean => {
680 const d = desired as { type?: string; value?: string };
681 const o = observed as { type?: string; value?: string } | undefined;
682 if (o === undefined) return false;
683 if (d.type === "secret_text") return o.type === "secret_text";
684 return (
685 (o.type ?? "plain_text") === (d.type ?? "plain_text") && o.value === d.value
686 );
687};
688
689const deepEquals = (a: unknown, b: unknown): boolean =>
690 JSON.stringify(a) === JSON.stringify(b);