1import * as pages from "@distilled.cloud/cloudflare/pages";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Predicate from "effect/Predicate";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7
8import { isResolved } from "../../Diff.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.Pages.Deployment" as const;
15type TypeId = typeof TypeId;
16
17/**
18 * Raised when a Pages deployment reaches a `failure`/`canceled` stage, or
19 * does not reach a successful `deploy` stage within the bounded wait.
20 */
21export class DeploymentFailed extends Data.TaggedError("DeploymentFailed")<{
22 readonly projectName: string;
23 readonly deploymentId: string;
24 readonly stageName: string;
25 readonly stageStatus: string;
26}> {}
27
28export interface DeploymentProps {
29 /**
30 * Name of the Pages project to deploy to (e.g. `project.name`).
31 * Deployments are immutable and belong to exactly one project —
32 * changing the project triggers a replacement.
33 */
34 projectName: string;
35 /**
36 * The branch the deployment is attributed to. Deploying to the project's
37 * production branch produces a production deployment; any other branch
38 * produces a preview deployment. Deployments are immutable — changing
39 * the branch triggers a replacement (a new deployment).
40 * @default the project's production branch
41 */
42 branch?: string;
43}
44
45export interface DeploymentAttributes {
46 /**
47 * Cloudflare-assigned UUID of the deployment.
48 */
49 deploymentId: string;
50 /**
51 * Short (8-character) id of the deployment. Forms the deployment's
52 * preview URL (`<shortId>.<project>.pages.dev`).
53 */
54 shortId: string;
55 /**
56 * The Cloudflare account the project belongs to.
57 */
58 accountId: string;
59 /**
60 * Name of the Pages project the deployment belongs to.
61 */
62 projectName: string;
63 /**
64 * Whether this is a `production` or `preview` deployment, derived from
65 * the branch it was created on.
66 */
67 environment: string;
68 /**
69 * The live URL serving this specific deployment.
70 */
71 url: string;
72 /**
73 * The branch the deployment was created from.
74 */
75 branch: string;
76 /**
77 * Name of the deployment's latest pipeline stage (e.g. `deploy`).
78 */
79 latestStageName: string;
80 /**
81 * Status of the deployment's latest pipeline stage (e.g. `success`).
82 */
83 latestStageStatus: string;
84 /**
85 * When the deployment was created.
86 */
87 createdOn: string;
88}
89
90export type Deployment = Resource<
91 TypeId,
92 DeploymentProps,
93 DeploymentAttributes,
94 never,
95 Providers
96>;
97
98/**
99 * A direct-upload deployment on a Cloudflare Pages project.
100 *
101 * Creating the resource POSTs a new deployment to the project and waits
102 * (bounded) for it to reach a successful `deploy` stage. Deployments are
103 * immutable — every prop change triggers a replacement (a brand-new
104 * deployment that supersedes the previous one).
105 *
106 * The deployment is created with an **empty asset manifest**: the full
107 * direct-upload protocol (asset upload sessions driven by
108 * `wrangler pages deploy`) is not part of the public REST surface, so this
109 * resource cannot push file contents. It is useful for provisioning an
110 * initial/placeholder deployment on a direct-upload project (so the
111 * project's `*.pages.dev` subdomain starts serving) and for rolling the
112 * active deployment from IaC. For deploying real static sites prefer
113 * `Cloudflare.Website` (Workers Assets).
114 *
115 * Deleting the resource deletes the deployment (with `force`), except when
116 * it is the project's active production deployment — Cloudflare refuses to
117 * delete the live deployment, so delete tolerates that case and the
118 * deployment is cleaned up when the project itself is deleted.
119 * ### Creating a Deployment
120 * **Example:** Production deployment on a direct-upload project
121 * ```typescript
122 * const project = yield* Cloudflare.Pages.Project("site", {});
123 *
124 * const deployment = yield* Cloudflare.Pages.Deployment("site-deploy", {
125 * projectName: project.name,
126 * });
127 * // deployment.url === "https://<shortId>.<project>.pages.dev"
128 * // deployment.environment === "production"
129 * ```
130 *
131 * **Example:** Preview deployment from a non-production branch
132 * ```typescript
133 * const preview = yield* Cloudflare.Pages.Deployment("site-preview", {
134 * projectName: project.name,
135 * branch: "feature-x",
136 * });
137 * // preview.environment === "preview"
138 * ```
139 *
140 * @see https://developers.cloudflare.com/pages/
141 *
142 * @resource
143 * @product Pages
144 * @category Workers & Compute
145 */
146export const Deployment = Resource<Deployment>(TypeId);
147
148/**
149 * Returns true if the given value is a Deployment resource.
150 */
151export const isDeployment = (value: unknown): value is Deployment =>
152 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
153
154export const DeploymentProvider = () =>
155 Provider.succeed(Deployment, {
156 stables: [
157 "deploymentId",
158 "shortId",
159 "accountId",
160 "projectName",
161 "environment",
162 "url",
163 "branch",
164 "createdOn",
165 ],
166 diff: Effect.fn(function* ({ olds, news, output }) {
167 const { accountId } = yield* yield* CloudflareEnvironment;
168 if (!isResolved(news)) return undefined;
169 if ((output?.accountId ?? accountId) !== accountId) {
170 return { action: "replace" } as const;
171 }
172 // Deployments are immutable: a different project or branch means a
173 // brand-new deployment.
174 const oldProject =
175 output?.projectName ??
176 (typeof olds?.projectName === "string" ? olds.projectName : undefined);
177 if (oldProject !== undefined && oldProject !== news.projectName) {
178 return { action: "replace" } as const;
179 }
180 const oldBranch = olds?.branch;
181 if (oldBranch !== news.branch) {
182 return { action: "replace" } as const;
183 }
184 return undefined;
185 }),
186 read: Effect.fn(function* ({ output }) {
187 // Deployment ids are server-assigned and carry no deterministic
188 // identity, so there is no cold lookup — without persisted state the
189 // deployment is reported missing and reconcile creates a fresh one.
190 if (!output?.deploymentId) return undefined;
191 const observed = yield* getDeployment(
192 output.accountId,
193 output.projectName,
194 output.deploymentId,
195 );
196 return observed
197 ? toAttributes(observed, output.accountId, output.projectName)
198 : undefined;
199 }),
200 list: Effect.fn(function* () {
201 const { accountId } = yield* yield* CloudflareEnvironment;
202 // Deployments are sub-resources keyed by Pages project, with no
203 // account-wide deployment enumeration API. Enumerate every project
204 // (account-scoped) first, then fan out and list each project's
205 // deployments, paginating exhaustively.
206 const projects = yield* pages.listProjects.pages({ accountId }).pipe(
207 Stream.runCollect,
208 Effect.map((chunk) =>
209 Array.from(chunk).flatMap((page) => page.result ?? []),
210 ),
211 // Account without a Pages entitlement can't list projects → nothing.
212 Effect.catchTag("Forbidden", () => Effect.succeed([])),
213 );
214 const rows = yield* Effect.forEach(
215 projects,
216 (project) =>
217 pages.listProjectDeployments
218 .pages({ accountId, projectName: project.name })
219 .pipe(
220 Stream.runCollect,
221 Effect.map((chunk) =>
222 Array.from(chunk).flatMap((page) =>
223 (page.result ?? []).map((deployment) =>
224 toAttributes(deployment, accountId, project.name),
225 ),
226 ),
227 ),
228 ),
229 { concurrency: 10 },
230 );
231 return rows.flat();
232 }),
233 reconcile: Effect.fn(function* ({ news, output }) {
234 const { accountId } = yield* yield* CloudflareEnvironment;
235 // Inputs have been resolved to concrete strings by Plan.
236 const projectName = news.projectName as string;
237
238 // 1. Observe — `output.deploymentId` is a cache of the only handle we
239 // have on the deployment; a vanished deployment falls through to
240 // "missing" and we create a fresh one.
241 let observed = output?.deploymentId
242 ? yield* getDeployment(accountId, projectName, output.deploymentId)
243 : undefined;
244
245 // 2. Ensure — create the deployment when missing. Deployments are
246 // immutable, so there is no sync step beyond waiting for the
247 // pipeline to finish; an empty manifest means no assets to upload.
248 if (!observed) {
249 observed = yield* pages.createProjectDeployment({
250 accountId,
251 projectName,
252 branch: news.branch,
253 manifest: "{}",
254 });
255 }
256
257 // 3. Wait (bounded) for the deployment to reach a terminal stage and
258 // fail with a typed error if it didn't succeed.
259 observed = yield* awaitDeployment(accountId, projectName, observed);
260
261 // 4. Return fresh attributes.
262 return toAttributes(observed, accountId, projectName);
263 }),
264 delete: Effect.fn(function* ({ output }) {
265 // Idempotent: the deployment may already be gone (DeploymentNotFound)
266 // or the whole project deleted (ProjectNotFound) — both are success.
267 // The active production deployment cannot be deleted even with
268 // `force` (Cloudflare error 8000034) — it is removed when the project
269 // itself is deleted, so tolerate and move on.
270 yield* pages
271 .deleteProjectDeployment({
272 accountId: output.accountId,
273 projectName: output.projectName,
274 deploymentId: output.deploymentId,
275 force: true,
276 })
277 .pipe(
278 Effect.catchTag("DeploymentNotFound", () => Effect.void),
279 Effect.catchTag("ProjectNotFound", () => Effect.void),
280 Effect.catchTag("ActiveProductionDeployment", () =>
281 Effect.logWarning(
282 `Pages deployment ${output.deploymentId} is the active production deployment of project ${output.projectName}; it will be deleted with the project.`,
283 ),
284 ),
285 );
286 }),
287 });
288
289// ---------------------------------------------------------------------------
290// Observation
291// ---------------------------------------------------------------------------
292
293type ObservedDeployment =
294 | pages.GetProjectDeploymentResponse
295 | pages.CreateProjectDeploymentResponse;
296
297/**
298 * Read a deployment by id, mapping "gone" to `undefined` — either the
299 * deployment was deleted (`DeploymentNotFound`, code 8000009) or the whole
300 * project no longer exists (`ProjectNotFound`, code 8000007).
301 */
302const getDeployment = (
303 accountId: string,
304 projectName: string,
305 deploymentId: string,
306) =>
307 pages.getProjectDeployment({ accountId, projectName, deploymentId }).pipe(
308 Effect.catchTag("DeploymentNotFound", () => Effect.succeed(undefined)),
309 Effect.catchTag("ProjectNotFound", () => Effect.succeed(undefined)),
310 );
311
312const isTerminalStage = (stage: ObservedDeployment["latestStage"]): boolean =>
313 (stage.name === "deploy" && stage.status === "success") ||
314 stage.status === "failure" ||
315 stage.status === "canceled";
316
317/**
318 * Poll the deployment until its pipeline reaches a terminal stage —
319 * bounded so a stuck deployment fails fast instead of hanging the engine.
320 * Anything other than a successful `deploy` stage is a typed failure.
321 */
322const awaitDeployment = (
323 accountId: string,
324 projectName: string,
325 created: ObservedDeployment,
326) =>
327 Effect.gen(function* () {
328 const observed: ObservedDeployment = isTerminalStage(created.latestStage)
329 ? created
330 : yield* pages
331 .getProjectDeployment({
332 accountId,
333 projectName,
334 deploymentId: created.id,
335 })
336 .pipe(
337 Effect.repeat({
338 schedule: Schedule.spaced("2 seconds"),
339 until: (d) => isTerminalStage(d.latestStage),
340 times: 25,
341 }),
342 );
343 if (
344 observed.latestStage.status !== "success" ||
345 observed.latestStage.name !== "deploy"
346 ) {
347 return yield* Effect.fail(
348 new DeploymentFailed({
349 projectName,
350 deploymentId: observed.id,
351 stageName: observed.latestStage.name,
352 stageStatus: observed.latestStage.status,
353 }),
354 );
355 }
356 return observed;
357 });
358
359/**
360 * Minimal structural shape `toAttributes` reads — satisfied by both the
361 * get/create deployment responses and a `listProjectDeployments` row.
362 */
363type DeploymentAttributesSource = {
364 readonly id: string;
365 readonly shortId: string;
366 readonly environment: string;
367 readonly url: string;
368 readonly deploymentTrigger: {
369 readonly metadata: { readonly branch: string };
370 };
371 readonly latestStage: { readonly name: string; readonly status: string };
372 readonly createdOn: string;
373};
374
375const toAttributes = (
376 deployment: DeploymentAttributesSource,
377 accountId: string,
378 projectName: string,
379): DeploymentAttributes => ({
380 deploymentId: deployment.id,
381 shortId: deployment.shortId,
382 accountId,
383 projectName,
384 environment: deployment.environment,
385 url: deployment.url,
386 branch: deployment.deploymentTrigger.metadata.branch,
387 latestStageName: deployment.latestStage.name,
388 latestStageStatus: deployment.latestStage.status,
389 createdOn: deployment.createdOn,
390});