1import * as amplify from "@distilled.cloud/aws/amplify";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import type * as Redacted from "effect/Redacted";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved, somePropsAreDifferent } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import {
13 createInternalTags,
14 diffTags,
15 hasAlchemyTags,
16 tagRecord,
17} from "../../Tags.ts";
18import { toWireSeconds } from "../../Util/Duration.ts";
19import type { Providers } from "../Providers.ts";
20
21/**
22 * Deployment stage of an Amplify branch.
23 */
24export type BranchStage =
25 | "PRODUCTION"
26 | "BETA"
27 | "DEVELOPMENT"
28 | "EXPERIMENTAL"
29 | "PULL_REQUEST";
30
31export interface BranchProps {
32 /**
33 * ID of the Amplify {@link App} the branch belongs to. Changing it replaces
34 * the branch.
35 */
36 appId: string;
37 /**
38 * Name of the branch (e.g. `main`). For an app without a connected
39 * repository this is just a label for manual deployments. If omitted, a
40 * unique name is generated. Changing it replaces the branch.
41 */
42 branchName?: string;
43 /**
44 * Description of the branch.
45 */
46 description?: string;
47 /**
48 * Display name shown in the Amplify console.
49 */
50 displayName?: string;
51 /**
52 * Deployment stage of the branch.
53 */
54 stage?: BranchStage;
55 /**
56 * Framework label for the branch (e.g. `React`, `Next.js`).
57 */
58 framework?: string;
59 /**
60 * Whether pushing to the (repo-connected) branch automatically triggers a
61 * build. Branches of repo-less apps should set this to `false`.
62 */
63 enableAutoBuild?: boolean;
64 /**
65 * Whether to enable deployment skew protection for the branch.
66 */
67 enableSkewProtection?: boolean;
68 /**
69 * Whether to enable performance mode (longer edge cache intervals).
70 */
71 enablePerformanceMode?: boolean;
72 /**
73 * Environment variables available to builds of this branch.
74 */
75 environmentVariables?: Record<string, string>;
76 /**
77 * Whether to require basic auth to view the branch's site.
78 */
79 enableBasicAuth?: boolean;
80 /**
81 * Basic auth credentials for the branch, as base64 of `user:password`.
82 */
83 basicAuthCredentials?: Redacted.Redacted<string>;
84 /**
85 * Build specification (amplify.yml contents) overriding the app's.
86 */
87 buildSpec?: string;
88 /**
89 * Content Time-To-Live for the branch's website (wire unit: seconds).
90 */
91 ttl?: Duration.Input;
92 /**
93 * Whether pull requests to the (repo-connected) branch create previews.
94 */
95 enablePullRequestPreview?: boolean;
96 /**
97 * Amplify environment name used for pull request previews.
98 */
99 pullRequestEnvironmentName?: string;
100 /**
101 * User-defined tags to apply to the branch.
102 */
103 tags?: Record<string, string>;
104}
105
106export interface Branch extends Resource<
107 "AWS.Amplify.Branch",
108 BranchProps,
109 {
110 appId: string;
111 branchName: string;
112 branchArn: string;
113 tags: Record<string, string>;
114 },
115 never,
116 Providers
117> {}
118
119/**
120 * A branch of an AWS Amplify Hosting app.
121 *
122 * For apps without a connected Git repository, a branch is the target of the
123 * manual-deploy pipeline: stage a zip with `CreateDeployment`, upload it to
124 * the pre-signed URL, and release it with `StartDeployment`.
125 *
126 * ### Creating Branches
127 * **Example:** Manual-Deploy Branch
128 * ```typescript
129 * const app = yield* App("MySite", { platform: "WEB" });
130 * const branch = yield* Branch("Main", {
131 * appId: app.appId,
132 * branchName: "main",
133 * stage: "PRODUCTION",
134 * enableAutoBuild: false,
135 * });
136 * ```
137 *
138 * **Example:** Password-Protected Branch with Content TTL
139 * ```typescript
140 * const branch = yield* Branch("Preview", {
141 * appId: app.appId,
142 * branchName: "preview",
143 * stage: "DEVELOPMENT",
144 * ttl: "10 minutes",
145 * enableBasicAuth: true,
146 * // base64 of "user:password"
147 * basicAuthCredentials: Redacted.make(credentials),
148 * });
149 * ```
150 *
151 * @resource
152 */
153export const Branch = Resource<Branch>("AWS.Amplify.Branch");
154
155// Amplify throttles control-plane writes account-wide in roughly per-minute
156// buckets and surfaces it as a BadRequestException with a "Rate exceeded"
157// message (not a throttling error class). A burst elsewhere in the account
158// (e.g. a mass delete) can keep the bucket exhausted for most of a minute, so
159// spread bounded retries evenly across ~80s instead of front-loading an
160// exponential that spends its whole budget in the first few seconds.
161const amplifyWriteRetrySchedule = Schedule.max([
162 Schedule.spaced("8 seconds"),
163 Schedule.recurs(10),
164]);
165
166export const BranchProvider = () =>
167 Provider.effect(
168 Branch,
169 Effect.gen(function* () {
170 const toName = (id: string, props: { branchName?: string } = {}) =>
171 props.branchName
172 ? Effect.succeed(props.branchName)
173 : createPhysicalName({ id, maxLength: 100 });
174
175 const observe = (appId: string, branchName: string) =>
176 amplify.getBranch({ appId, branchName }).pipe(
177 Effect.map((r) => r.branch),
178 // Amplify's API Gateway front door can time out a slow
179 // control-plane call ("Endpoint request timed out"); reads are
180 // side-effect free, so retry the typed tag with bounded backoff.
181 Effect.retry({
182 while: (e): boolean => e._tag === "TimeoutException",
183 schedule: Schedule.exponential("2 seconds"),
184 times: 3,
185 }),
186 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
187 );
188
189 const syncTags = Effect.fn(function* (
190 branchArn: string,
191 desiredTags: Record<string, string>,
192 observedTags: Record<string, string>,
193 ) {
194 const { removed, upsert } = diffTags(observedTags, desiredTags);
195 if (upsert.length > 0) {
196 yield* amplify.tagResource({
197 resourceArn: branchArn,
198 tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
199 });
200 }
201 if (removed.length > 0) {
202 yield* amplify.untagResource({
203 resourceArn: branchArn,
204 tagKeys: removed,
205 });
206 }
207 });
208
209 return {
210 stables: ["appId", "branchName", "branchArn"],
211 diff: Effect.fn(function* ({ news, olds }) {
212 if (!isResolved(news)) return;
213 if (somePropsAreDifferent(olds, news, ["appId", "branchName"])) {
214 return { action: "replace" };
215 }
216 }),
217 read: Effect.fn(function* ({ id, output }) {
218 if (!output?.appId || !output.branchName) return undefined;
219 const branch = yield* observe(output.appId, output.branchName);
220 if (!branch) return undefined;
221 const attrs = {
222 appId: output.appId,
223 branchName: branch.branchName,
224 branchArn: branch.branchArn,
225 tags: tagRecord(branch.tags),
226 };
227 return (yield* hasAlchemyTags(id, branch.tags))
228 ? attrs
229 : Unowned(attrs);
230 }),
231 reconcile: Effect.fn(function* ({ id, news, output, session }) {
232 const branchName = yield* toName(id, news);
233 const internalTags = yield* createInternalTags(id);
234 const desiredTags = { ...internalTags, ...news.tags };
235 const ttl =
236 news.ttl === undefined
237 ? undefined
238 : String(toWireSeconds(news.ttl));
239
240 const settings = {
241 description: news.description,
242 displayName: news.displayName,
243 stage: news.stage,
244 framework: news.framework,
245 enableAutoBuild: news.enableAutoBuild,
246 enableSkewProtection: news.enableSkewProtection,
247 enablePerformanceMode: news.enablePerformanceMode,
248 environmentVariables: news.environmentVariables,
249 enableBasicAuth: news.enableBasicAuth,
250 basicAuthCredentials: news.basicAuthCredentials,
251 buildSpec: news.buildSpec,
252 ttl,
253 enablePullRequestPreview: news.enablePullRequestPreview,
254 pullRequestEnvironmentName: news.pullRequestEnvironmentName,
255 };
256
257 let branch = yield* observe(news.appId, branchName);
258
259 if (!branch) {
260 // CreateBranch is subject to the account-wide "Rate exceeded"
261 // throttle (see amplifyWriteRetrySchedule). Its API Gateway
262 // front door can also time out a slow CreateBranch (typed
263 // TimeoutException, "Endpoint request timed out") with the
264 // outcome unknown — re-observe before every (re)try so a
265 // retry converges on the branch the timed-out call created.
266 branch = yield* observe(news.appId, branchName).pipe(
267 Effect.flatMap((existing) =>
268 existing
269 ? Effect.succeed(existing)
270 : amplify
271 .createBranch({
272 appId: news.appId,
273 branchName,
274 ...settings,
275 tags: desiredTags,
276 })
277 .pipe(Effect.map((r) => r.branch)),
278 ),
279 Effect.retry({
280 while: (e): boolean =>
281 e._tag === "TimeoutException" ||
282 (e._tag === "BadRequestException" &&
283 (e.message ?? "").includes("Rate exceeded")),
284 schedule: amplifyWriteRetrySchedule,
285 }),
286 );
287 } else {
288 // UpdateBranch is idempotent — retry front-door timeouts and
289 // the "Rate exceeded" throttle with bounded backoff.
290 const updated = yield* amplify
291 .updateBranch({
292 appId: news.appId,
293 branchName,
294 ...settings,
295 })
296 .pipe(
297 Effect.retry({
298 while: (e): boolean =>
299 e._tag === "TimeoutException" ||
300 (e._tag === "BadRequestException" &&
301 (e.message ?? "").includes("Rate exceeded")),
302 schedule: amplifyWriteRetrySchedule,
303 }),
304 );
305 yield* syncTags(
306 updated.branch.branchArn,
307 desiredTags,
308 tagRecord(branch.tags),
309 );
310 branch = updated.branch;
311 }
312
313 yield* session.note(branch.branchArn);
314 return {
315 appId: news.appId,
316 branchName: branch.branchName,
317 branchArn: branch.branchArn,
318 tags: desiredTags,
319 };
320 }),
321 list: () =>
322 Effect.gen(function* () {
323 const apps = yield* amplify.listApps.pages({}).pipe(
324 Stream.runCollect,
325 Effect.map((chunk) =>
326 Array.from(chunk).flatMap((page) => page.apps ?? []),
327 ),
328 );
329 const branches: Array<{
330 appId: string;
331 branchName: string;
332 branchArn: string;
333 tags: Record<string, string>;
334 }> = [];
335 for (const app of apps) {
336 const appBranches = yield* amplify.listBranches
337 .pages({ appId: app.appId })
338 .pipe(
339 Stream.runCollect,
340 Effect.map((chunk) =>
341 Array.from(chunk).flatMap((page) => page.branches ?? []),
342 ),
343 );
344 for (const branch of appBranches) {
345 branches.push({
346 appId: app.appId,
347 branchName: branch.branchName,
348 branchArn: branch.branchArn,
349 tags: tagRecord(branch.tags),
350 });
351 }
352 }
353 return branches;
354 }),
355 delete: Effect.fn(function* ({ output }) {
356 // DeleteBranch is idempotent — retry front-door timeouts and the
357 // account-wide "Rate exceeded" throttle; a timed-out delete that
358 // actually landed resolves to NotFoundException on the retry,
359 // which is swallowed below.
360 yield* amplify
361 .deleteBranch({
362 appId: output.appId,
363 branchName: output.branchName,
364 })
365 .pipe(
366 Effect.retry({
367 while: (e): boolean =>
368 e._tag === "TimeoutException" ||
369 (e._tag === "BadRequestException" &&
370 (e.message ?? "").includes("Rate exceeded")),
371 schedule: amplifyWriteRetrySchedule,
372 }),
373 Effect.catchTag("NotFoundException", () => Effect.void),
374 );
375 }),
376 };
377 }),
378 );