1import * as amplify from "@distilled.cloud/aws/amplify";
2import * as Effect from "effect/Effect";
3import type * as Redacted from "effect/Redacted";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import {
11 createInternalTags,
12 diffTags,
13 hasAlchemyTags,
14 tagRecord,
15} from "../../Tags.ts";
16import type { Providers } from "../Providers.ts";
17
18/**
19 * Hosting platform for the Amplify app. `WEB` = static site, `WEB_DYNAMIC` =
20 * server-side rendered, `WEB_COMPUTE` = SSR with compute (Next.js 12+).
21 */
22export type Platform = "WEB" | "WEB_DYNAMIC" | "WEB_COMPUTE";
23
24/**
25 * A URL redirect/rewrite rule for the Amplify app.
26 */
27export interface CustomRule {
28 /** Source URL pattern. */
29 source: string;
30 /** Target URL. */
31 target: string;
32 /** HTTP status / rule type (e.g. `"200"`, `"301"`, `"404"`). */
33 status?: string;
34 /** Condition (e.g. country code) for the rule to apply. */
35 condition?: string;
36}
37
38export interface AppProps {
39 /**
40 * Name of the app. If omitted, a unique name is generated.
41 */
42 name?: string;
43 /**
44 * Description of the app.
45 */
46 description?: string;
47 /**
48 * Hosting platform.
49 * @default "WEB"
50 */
51 platform?: Platform;
52 /**
53 * Environment variables available to the build.
54 */
55 environmentVariables?: Record<string, string>;
56 /**
57 * Build specification (amplify.yml contents) for the app.
58 */
59 buildSpec?: string;
60 /**
61 * URL redirect and rewrite rules.
62 */
63 customRules?: CustomRule[];
64 /**
65 * Whether to require basic auth to view the app's branches by default.
66 */
67 enableBasicAuth?: boolean;
68 /**
69 * Default basic auth credentials for the app's branches, as base64 of
70 * `user:password`.
71 */
72 basicAuthCredentials?: Redacted.Redacted<string>;
73 /**
74 * User-defined tags to apply to the app.
75 */
76 tags?: Record<string, string>;
77}
78
79export interface App extends Resource<
80 "AWS.Amplify.App",
81 AppProps,
82 {
83 appId: string;
84 appArn: string;
85 name: string;
86 platform: Platform;
87 defaultDomain: string;
88 tags: Record<string, string>;
89 },
90 never,
91 Providers
92> {}
93
94/**
95 * An AWS Amplify Hosting app. This resource provisions the app container; it
96 * does **not** connect a Git repository.
97 *
98 * Connecting a repository requires an OAuth handshake (via CodeConnections or a
99 * personal access token) that is inherently human-in-the-loop and cannot be
100 * automated from infrastructure code. To wire a repo, create the app with this
101 * resource, then connect the repository from the Amplify console or CLI.
102 * Manual-deploy branches (no repo required) are code-driven via the
103 * {@link Branch} resource plus the `CreateDeployment`/`StartDeployment`
104 * bindings. For a fully code-driven static/SSR site on AWS, prefer Alchemy's
105 * own `Website` composites (S3 + CloudFront).
106 *
107 * ### Creating Amplify Apps
108 * **Example:** Basic App
109 * ```typescript
110 * const app = yield* App("MyApp", {
111 * description: "Marketing site",
112 * platform: "WEB",
113 * });
114 * ```
115 *
116 * **Example:** App with Build Config and Redirects
117 * ```typescript
118 * const app = yield* App("MyApp", {
119 * platform: "WEB_COMPUTE",
120 * environmentVariables: { NODE_ENV: "production" },
121 * customRules: [
122 * { source: "/<*>", target: "/index.html", status: "404-200" },
123 * ],
124 * buildSpec: "version: 1\nfrontend:\n phases:\n build:\n commands: []\n",
125 * });
126 * ```
127 *
128 * ### Deploying and Observing From a Function
129 * **Example:** Manual Deploy Pipeline (CreateDeployment + StartDeployment)
130 * ```typescript
131 * // init — bind the deployment operations to the app
132 * const createDeployment = yield* AWS.Amplify.CreateDeployment(app);
133 * const startDeployment = yield* AWS.Amplify.StartDeployment(app);
134 *
135 * // runtime — stage, upload, release
136 * const { jobId, zipUploadUrl } = yield* createDeployment({
137 * branchName: "main",
138 * });
139 * // PUT the site zip to zipUploadUrl, then:
140 * yield* startDeployment({ branchName: "main", jobId });
141 * ```
142 *
143 * **Example:** React to Deployment Status Changes
144 * ```typescript
145 * yield* AWS.Amplify.consumeDeploymentStatusChanges(
146 * { jobStatus: ["FAILED"] },
147 * (events) =>
148 * Stream.runForEach(events, (event) =>
149 * Effect.log(`build failed on ${event.detail.branchName}`),
150 * ),
151 * );
152 * ```
153 *
154 * @resource
155 */
156export const App = Resource<App>("AWS.Amplify.App");
157
158// Amplify throttles control-plane writes account-wide in roughly per-minute
159// buckets and surfaces it as a BadRequestException with a "Rate exceeded"
160// message (not a throttling error class). A burst elsewhere in the account
161// (e.g. a mass delete) can keep the bucket exhausted for most of a minute, so
162// spread bounded retries evenly across ~80s instead of front-loading an
163// exponential that spends its whole budget in the first few seconds.
164const amplifyWriteRetrySchedule = Schedule.max([
165 Schedule.spaced("8 seconds"),
166 Schedule.recurs(10),
167]);
168
169export const AppProvider = () =>
170 Provider.effect(
171 App,
172 Effect.gen(function* () {
173 const toName = (id: string, props: { name?: string } = {}) =>
174 props.name
175 ? Effect.succeed(props.name)
176 : createPhysicalName({ id, maxLength: 100 });
177
178 const observe = (appId: string) =>
179 amplify.getApp({ appId }).pipe(
180 Effect.map((r) => r.app),
181 // Amplify's API Gateway front door can time out a slow
182 // control-plane call ("Endpoint request timed out"); reads are
183 // side-effect free, so retry the typed tag with bounded backoff.
184 Effect.retry({
185 while: (e): boolean => e._tag === "TimeoutException",
186 schedule: Schedule.exponential("2 seconds"),
187 times: 3,
188 }),
189 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
190 );
191
192 // Find an app we own (matching physical name + internal Alchemy tags).
193 // Used to recover from a timed-out CreateApp whose outcome is unknown —
194 // the backend may have created the app even though the front door gave
195 // up on the response.
196 const findOwnApp = Effect.fn(function* (id: string, name: string) {
197 const apps = yield* amplify.listApps.pages({}).pipe(
198 Stream.runCollect,
199 Effect.map((chunk) =>
200 Array.from(chunk).flatMap((page) => page.apps ?? []),
201 ),
202 );
203 for (const app of apps) {
204 if (app.name !== name) continue;
205 if (yield* hasAlchemyTags(id, app.tags)) return app;
206 }
207 return undefined;
208 });
209
210 const syncTags = Effect.fn(function* (
211 appArn: string,
212 desiredTags: Record<string, string>,
213 observedTags: Record<string, string>,
214 ) {
215 const { removed, upsert } = diffTags(observedTags, desiredTags);
216 if (upsert.length > 0) {
217 yield* amplify.tagResource({
218 resourceArn: appArn,
219 tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
220 });
221 }
222 if (removed.length > 0) {
223 yield* amplify.untagResource({
224 resourceArn: appArn,
225 tagKeys: removed,
226 });
227 }
228 });
229
230 return {
231 stables: ["appId", "appArn"],
232 read: Effect.fn(function* ({ id, output }) {
233 if (!output?.appId) return undefined;
234 const app = yield* observe(output.appId);
235 if (!app) return undefined;
236 const attrs = {
237 appId: app.appId,
238 appArn: app.appArn,
239 name: app.name,
240 platform: (app.platform as Platform) ?? "WEB",
241 defaultDomain: app.defaultDomain ?? "",
242 tags: tagRecord(app.tags),
243 };
244 return (yield* hasAlchemyTags(id, app.tags)) ? attrs : Unowned(attrs);
245 }),
246 reconcile: Effect.fn(function* ({ id, news, output, session }) {
247 const name = yield* toName(id, news);
248 const internalTags = yield* createInternalTags(id);
249 const desiredTags = { ...internalTags, ...news.tags };
250
251 let app = output?.appId ? yield* observe(output.appId) : undefined;
252
253 if (!app) {
254 // CreateApp is subject to the account-wide "Rate exceeded"
255 // throttle (see amplifyWriteRetrySchedule). Its API Gateway
256 // front door can also time out a slow CreateApp (typed
257 // TimeoutException, "Endpoint request timed out") with the
258 // outcome unknown — the backend may still have created the
259 // app. Observe by name + internal tags before every (re)try so
260 // a retry never creates a duplicate app.
261 app = yield* findOwnApp(id, name).pipe(
262 Effect.flatMap((existing) =>
263 existing
264 ? Effect.succeed(existing)
265 : amplify
266 .createApp({
267 name,
268 description: news.description,
269 platform: news.platform ?? "WEB",
270 environmentVariables: news.environmentVariables,
271 buildSpec: news.buildSpec,
272 customRules: news.customRules,
273 enableBasicAuth: news.enableBasicAuth,
274 basicAuthCredentials: news.basicAuthCredentials,
275 tags: desiredTags,
276 })
277 .pipe(Effect.map((r) => r.app)),
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 // UpdateApp is idempotent — retry front-door timeouts and the
289 // "Rate exceeded" throttle with bounded backoff.
290 yield* amplify
291 .updateApp({
292 appId: app.appId,
293 name,
294 description: news.description,
295 platform: news.platform ?? "WEB",
296 environmentVariables: news.environmentVariables,
297 buildSpec: news.buildSpec,
298 customRules: news.customRules,
299 enableBasicAuth: news.enableBasicAuth,
300 basicAuthCredentials: news.basicAuthCredentials,
301 })
302 .pipe(
303 Effect.retry({
304 while: (e): boolean =>
305 e._tag === "TimeoutException" ||
306 (e._tag === "BadRequestException" &&
307 (e.message ?? "").includes("Rate exceeded")),
308 schedule: amplifyWriteRetrySchedule,
309 }),
310 );
311 yield* syncTags(app.appArn, desiredTags, tagRecord(app.tags));
312 }
313
314 yield* session.note(app.appArn);
315 return {
316 appId: app.appId,
317 appArn: app.appArn,
318 name,
319 platform: (news.platform as Platform) ?? "WEB",
320 defaultDomain: app.defaultDomain ?? "",
321 tags: desiredTags,
322 };
323 }),
324 list: () =>
325 Effect.gen(function* () {
326 const apps = yield* amplify.listApps.pages({}).pipe(
327 Stream.runCollect,
328 Effect.map((chunk) =>
329 Array.from(chunk).flatMap((page) => page.apps ?? []),
330 ),
331 );
332 return apps.map((app) => ({
333 appId: app.appId,
334 appArn: app.appArn,
335 name: app.name,
336 platform: (app.platform as Platform) ?? "WEB",
337 defaultDomain: app.defaultDomain ?? "",
338 tags: tagRecord(app.tags),
339 }));
340 }),
341 delete: Effect.fn(function* ({ output }) {
342 // DeleteApp is idempotent — retry front-door timeouts and the
343 // account-wide "Rate exceeded" throttle; a timed-out delete that
344 // actually landed resolves to NotFoundException on the retry,
345 // which is swallowed below.
346 yield* amplify.deleteApp({ appId: output.appId }).pipe(
347 Effect.retry({
348 while: (e): boolean =>
349 e._tag === "TimeoutException" ||
350 (e._tag === "BadRequestException" &&
351 (e.message ?? "").includes("Rate exceeded")),
352 schedule: amplifyWriteRetrySchedule,
353 }),
354 Effect.catchTag("NotFoundException", () => Effect.void),
355 );
356 }),
357 };
358 }),
359 );