1import * as appsync from "@distilled.cloud/aws/appsync";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
8import { Unowned } from "../../AdoptPolicy.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 retryConcurrentModification,
12 syncAppSyncTags,
13 tagRecord,
14} from "./common.ts";
15
16export interface DomainNameProps {
17 /**
18 * The custom domain name, e.g. `api.example.com`. Changing it triggers
19 * a replacement.
20 */
21 domainName: string;
22 /**
23 * ARN of an ACM certificate covering the domain. AppSync custom domains
24 * are CloudFront-backed, so the certificate **must live in us-east-1**.
25 * Changing it triggers a replacement.
26 */
27 certificateArn: string;
28 /** Description of the domain. */
29 description?: string;
30 /** Tags to apply. Merged with internal Alchemy tags. */
31 tags?: Record<string, string>;
32}
33
34export interface AppSyncDomainName extends Resource<
35 "AWS.AppSync.DomainName",
36 DomainNameProps,
37 {
38 /** The custom domain name. */
39 domainName: string;
40 /** The domain name ARN. */
41 domainNameArn: string | undefined;
42 /** The certificate ARN. */
43 certificateArn: string;
44 /**
45 * The CloudFront target (`dxxxx.cloudfront.net`) to point a CNAME /
46 * Route53 alias at.
47 */
48 appsyncDomainName: string | undefined;
49 /** The Route53 hosted zone ID for alias records. */
50 hostedZoneId: string | undefined;
51 },
52 never,
53 Providers
54> {}
55
56/**
57 * A custom domain name for AppSync GraphQL APIs.
58 *
59 * Requires an ACM certificate **in us-east-1** (the domain is
60 * CloudFront-backed). Attach an API with {@link ApiAssociation} and point
61 * DNS at the `appsyncDomainName` attribute.
62 * ### Creating Custom Domains
63 * **Example:** Custom domain + API association
64 * ```typescript
65 * const domain = yield* AppSync.DomainName("Domain", {
66 * domainName: "api.example.com",
67 * certificateArn: usEast1Cert.certificateArn,
68 * });
69 * yield* AppSync.ApiAssociation("Assoc", { domain, api });
70 * // CNAME api.example.com → domain.appsyncDomainName
71 * ```
72 *
73 * @resource
74 */
75export const DomainName = Resource<AppSyncDomainName>("AWS.AppSync.DomainName");
76
77export const DomainNameProvider = () =>
78 Provider.effect(
79 DomainName,
80 Effect.gen(function* () {
81 const getDomainSafe = (domainName: string) =>
82 appsync.getDomainName({ domainName }).pipe(
83 Effect.map((response) => response.domainNameConfig),
84 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
85 );
86
87 const toAttributes = (
88 config: appsync.DomainNameConfig,
89 ): AppSyncDomainName["Attributes"] => ({
90 domainName: config.domainName!,
91 domainNameArn: config.domainNameArn,
92 certificateArn: config.certificateArn!,
93 appsyncDomainName: config.appsyncDomainName,
94 hostedZoneId: config.hostedZoneId,
95 });
96
97 return DomainName.Provider.of({
98 stables: [
99 "domainName",
100 "domainNameArn",
101 "appsyncDomainName",
102 "hostedZoneId",
103 ],
104
105 list: () =>
106 Effect.gen(function* () {
107 const pages = yield* appsync.listDomainNames
108 .pages({})
109 .pipe(Stream.runCollect);
110 return Array.from(pages)
111 .flatMap((page) => page.domainNameConfigs ?? [])
112 .filter((config) => config.domainName != null)
113 .map(toAttributes);
114 }),
115
116 read: Effect.fn(function* ({ id, olds, output }) {
117 const domainName = output?.domainName ?? olds?.domainName;
118 if (domainName === undefined) return undefined;
119 const config = yield* getDomainSafe(domainName);
120 if (config?.domainName == null) return undefined;
121 const attrs = toAttributes(config);
122 return (yield* hasAlchemyTags(id, tagRecord(config.tags)))
123 ? attrs
124 : Unowned(attrs);
125 }),
126
127 diff: Effect.fn(function* ({ news, olds }) {
128 if (!isResolved(news)) return undefined;
129 if (
130 news.domainName !== olds.domainName ||
131 news.certificateArn !== olds.certificateArn
132 ) {
133 return { action: "replace" } as const;
134 }
135 // description/tags converge via update
136 }),
137
138 reconcile: Effect.fn(function* ({ id, news, session }) {
139 const internalTags = yield* createInternalTags(id);
140 const desiredTags = { ...news.tags, ...internalTags };
141
142 // 1. OBSERVE
143 let observed = yield* getDomainSafe(news.domainName);
144
145 if (observed?.domainName == null) {
146 // 2. ENSURE
147 const created = yield* retryConcurrentModification(
148 appsync.createDomainName({
149 domainName: news.domainName,
150 certificateArn: news.certificateArn,
151 description: news.description,
152 tags: desiredTags,
153 }),
154 );
155 observed = created.domainNameConfig!;
156 yield* session.note(`Created domain ${news.domainName}`);
157 } else if (
158 news.description !== undefined &&
159 observed.description !== news.description
160 ) {
161 // 3. SYNC
162 const updated = yield* retryConcurrentModification(
163 appsync.updateDomainName({
164 domainName: news.domainName,
165 description: news.description,
166 }),
167 );
168 observed = updated.domainNameConfig ?? observed;
169 yield* session.note(`Updated domain ${news.domainName}`);
170 }
171
172 // 3b. SYNC TAGS — against OBSERVED cloud tags.
173 if (observed.domainNameArn !== undefined) {
174 yield* syncAppSyncTags({
175 resourceArn: observed.domainNameArn,
176 oldTags: tagRecord(observed.tags),
177 newTags: desiredTags,
178 });
179 }
180
181 yield* session.note(news.domainName);
182 return toAttributes(observed);
183 }),
184
185 delete: Effect.fn(function* ({ output }) {
186 // A domain with a live association cannot be deleted; the
187 // association resource is destroyed first (dependency order),
188 // but the detach itself is eventually consistent — the
189 // ConcurrentModification retry rides that out.
190 yield* retryConcurrentModification(
191 appsync
192 .deleteDomainName({ domainName: output.domainName })
193 .pipe(Effect.catchTag("NotFoundException", () => Effect.void)),
194 );
195 }),
196 });
197 }),
198 );