1import * as firebaseapphosting from "@distilled.cloud/gcp/firebaseapphosting_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 expandParent,
19 fieldMask,
20 fingerprint,
21 listAtNested,
22 listLabeledPages,
23 normalizeLocation,
24 parseName,
25 replaceOnIdentity,
26 ResourceNotResolved,
27 sameBool,
28 sameText,
29 stringMap,
30 toDomainId,
31 userLabels,
32 waitForDeleteOperation,
33 waitForOperation,
34 waitUntilExists,
35 waitUntilGone,
36} from "./internal.ts";
37
38export type Redirect = {
39 /**
40 * Redirect destination URI. URIs without a scheme are assumed to be
41 * HTTPS. Prepended to the original request path.
42 */
43 uri?: string;
44 /**
45 * HTTP 3xx status used in the redirect response.
46 * @default "302"
47 */
48 status?: string;
49};
50
51export type ServingBehavior = {
52 /** Redirect behavior. When set, the domain does not serve live content. */
53 redirect?: Redirect;
54};
55
56export type BackendsDomainProps = {
57 /**
58 * Parent backend. Full name
59 * `projects/{project}/locations/{location}/backends/{backend}` or the
60 * backend id (combined with `location`). Immutable — changing it
61 * replaces the domain.
62 */
63 backend: string;
64 /**
65 * Region used when `backend` is a bare id.
66 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
67 */
68 location?: string;
69 /**
70 * Domain id — a valid domain name (for example `www.example.com`).
71 * If omitted, a unique `{id}.example.com` name is generated.
72 * Immutable — changing it replaces the domain.
73 */
74 domainId?: string;
75 /**
76 * Serving behavior. When set, the domain serves something other than
77 * the backend's live content (typically a redirect).
78 */
79 serve?: ServingBehavior;
80 /**
81 * Human-readable name. 63 character limit.
82 */
83 displayName?: string;
84 /**
85 * When true, the domain is disabled.
86 * @default false
87 */
88 disabled?: boolean;
89 /**
90 * User annotations (preserved by external tools).
91 */
92 annotations?: Record<string, string>;
93 /**
94 * User labels. Alchemy ownership labels are merged in automatically.
95 */
96 labels?: Record<string, string>;
97};
98
99export type BackendsDomain = Resource<
100 "GCP.FirebaseAppHosting.BackendsDomain",
101 BackendsDomainProps,
102 {
103 /** Full resource name. */
104 name: string;
105 /** Domain id (last path segment, a domain name). */
106 domainId: string;
107 /** Parent backend name. */
108 backend: string;
109 /** Project id. */
110 project: string;
111 /** Location id. */
112 location: string;
113 /** Serving behavior. */
114 serve: ServingBehavior | undefined;
115 /** Human-readable name. */
116 displayName: string | undefined;
117 /** Whether the domain is disabled. */
118 disabled: boolean;
119 /** Domain type (`DEFAULT` or `CUSTOM`). */
120 type: string | undefined;
121 /** Custom-domain linkage status. */
122 customDomainStatus: firebaseapphosting.CustomDomainStatus | undefined;
123 /** True while the domain has an ongoing LRO. */
124 reconciling: boolean;
125 /** User annotations. */
126 annotations: Record<string, string>;
127 /** User labels (Alchemy ownership labels stripped). */
128 labels: Record<string, string>;
129 /** Server-generated resource uid. */
130 uid: string | undefined;
131 /** RFC3339 creation timestamp. */
132 createTime: string | undefined;
133 /** RFC3339 last-update timestamp. */
134 updateTime: string | undefined;
135 /** Server-computed etag. */
136 etag: string | undefined;
137 },
138 never,
139 Providers
140>;
141
142/**
143 * A domain name linked to a Firebase App Hosting backend. Default
144 * domains are created with the backend; this resource manages custom
145 * domains.
146 *
147 * Changing `domainId`, `backend`, or `location` replaces the domain.
148 * Serving behavior, display name, disabled, labels, and annotations
149 * update in place.
150 *
151 * ### Creating a Domain
152 * **Example:** Custom domain
153 * ```typescript
154 * const domain = yield* GCP.FirebaseAppHosting.BackendsDomain("Www", {
155 * backend: backend.name,
156 * domainId: "www.example.com",
157 * labels: { env: "test" },
158 * });
159 * ```
160 *
161 * **Example:** Redirect
162 * ```typescript
163 * const domain = yield* GCP.FirebaseAppHosting.BackendsDomain("Apex", {
164 * backend: backend.name,
165 * domainId: "example.com",
166 * serve: { redirect: { uri: "https://www.example.com", status: "301" } },
167 * });
168 * ```
169 *
170 * ### Updating a Domain
171 * **Example:** Disable and relabel
172 * ```typescript
173 * const domain = yield* GCP.FirebaseAppHosting.BackendsDomain("Www", {
174 * domainId: "www.example.com",
175 * backend: backend.name,
176 * disabled: true,
177 * labels: { env: "prod" },
178 * });
179 * ```
180 *
181 * @resource
182 * @category FirebaseAppHosting
183 */
184export const BackendsDomain = Resource<BackendsDomain>(
185 "GCP.FirebaseAppHosting.BackendsDomain",
186);
187
188const resourceName = (backend: string, domainId: string) =>
189 `${backend}/domains/${domainId}`;
190
191const toServe = (
192 value: firebaseapphosting.ServingBehavior | undefined,
193): ServingBehavior | undefined =>
194 value === undefined
195 ? undefined
196 : {
197 redirect: value.redirect
198 ? { uri: value.redirect.uri, status: value.redirect.status }
199 : undefined,
200 };
201
202const toAttrs = (item: firebaseapphosting.Domain, project: string) => {
203 const name = item.name ?? "";
204 const parsed = parseName(name, "domains");
205 return {
206 name,
207 domainId: parsed.id,
208 backend: parsed.parent,
209 project: parsed.project || project,
210 location: parsed.location,
211 serve: toServe(item.serve),
212 displayName: item.displayName,
213 disabled: item.disabled === true,
214 type: item.type,
215 customDomainStatus: item.customDomainStatus,
216 reconciling: item.reconciling === true,
217 annotations: stringMap(item.annotations),
218 labels: userLabels(item.labels),
219 uid: item.uid,
220 createTime: item.createTime,
221 updateTime: item.updateTime,
222 etag: item.etag,
223 };
224};
225
226const getByName = (name: string) =>
227 firebaseapphosting
228 .getProjectsLocationsBackendsDomains({ name })
229 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
230
231const listOwned = (project: string) =>
232 listAtNested(project, "backends/-", (parent) =>
233 listLabeledPages(
234 firebaseapphosting.listProjectsLocationsBackendsDomains.pages({
235 parent,
236 pageSize: 1000,
237 }),
238 (page) => page.domains,
239 (item) => item.labels,
240 ),
241 );
242
243export const BackendsDomainProvider = () =>
244 Provider.succeed(BackendsDomain, {
245 stables: [
246 "name",
247 "domainId",
248 "backend",
249 "project",
250 "location",
251 "uid",
252 "createTime",
253 ],
254
255 diff: Effect.fn(function* ({ news, olds, output }) {
256 if (!isResolved(news)) return undefined;
257 const env = yield* GcpEnvironment.current;
258 const location = normalizeLocation(
259 news.location ?? olds?.location ?? output?.location,
260 env.region,
261 );
262 const previousLocation = normalizeLocation(
263 olds?.location ?? output?.location,
264 env.region,
265 );
266 const previousParent =
267 (olds?.backend ?? output?.backend)
268 ? expandParent(
269 olds?.backend ?? output?.backend ?? "",
270 env.project,
271 previousLocation,
272 "backends",
273 )
274 : undefined;
275 const nextParent = expandParent(
276 news.backend,
277 env.project,
278 location,
279 "backends",
280 );
281 return replaceOnIdentity({
282 previousId: olds?.domainId ?? output?.domainId,
283 nextId: news.domainId ?? olds?.domainId ?? output?.domainId,
284 previousLocation,
285 nextLocation: location,
286 previousParent,
287 nextParent,
288 });
289 }),
290
291 read: Effect.fn(function* ({ id, olds, output }) {
292 const env = yield* GcpEnvironment.current;
293 const domainId = yield* toDomainId(id, olds?.domainId, output?.domainId);
294 const location = normalizeLocation(
295 olds?.location ?? output?.location,
296 env.region,
297 );
298 const backend =
299 output?.backend ??
300 (olds?.backend
301 ? expandParent(olds.backend, env.project, location, "backends")
302 : undefined);
303 const name =
304 output?.name ?? (backend ? resourceName(backend, domainId) : undefined);
305 if (name === undefined) return undefined;
306 const existing = yield* getByName(name);
307 if (existing === undefined) return undefined;
308 const attrs = toAttrs(existing, env.project);
309 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
310 ? attrs
311 : Unowned(attrs);
312 }),
313
314 list: () =>
315 Effect.gen(function* () {
316 const env = yield* GcpEnvironment.current;
317 const items = yield* listOwned(env.project);
318 return items.map((item) => toAttrs(item, env.project));
319 }),
320
321 reconcile: Effect.fn(function* ({ id, news, output }) {
322 const env = yield* GcpEnvironment.current;
323 const domainId = yield* toDomainId(id, news.domainId, output?.domainId);
324 const location = normalizeLocation(
325 news.location ?? output?.location,
326 env.region,
327 );
328 const backend = expandParent(
329 news.backend,
330 env.project,
331 location,
332 "backends",
333 );
334 const name = resourceName(backend, domainId);
335 const desiredLabels = {
336 ...toLabels(news.labels),
337 ...(yield* createInternalLabels(id)),
338 };
339 const desiredAnnotations = news.annotations ?? {};
340 const desiredDisabled = news.disabled === true;
341
342 let current = yield* getByName(output?.name ?? name);
343
344 if (current === undefined) {
345 const created = yield* firebaseapphosting
346 .createProjectsLocationsBackendsDomains({
347 parent: backend,
348 domainId,
349 body: {
350 serve: news.serve,
351 displayName: news.displayName,
352 disabled: desiredDisabled,
353 annotations: desiredAnnotations,
354 labels: desiredLabels,
355 },
356 })
357 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
358 if (created !== undefined) {
359 yield* waitForOperation(created);
360 }
361 current = yield* waitUntilExists(getByName(name), name);
362 }
363
364 if (current === undefined) {
365 return yield* new ResourceNotResolved({ name });
366 }
367
368 const observedLabels = tagRecord(current.labels);
369 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
370 const mask = fieldMask([
371 (upsert.length > 0 || removed.length > 0) && "labels",
372 fingerprint(stringMap(current.annotations)) !==
373 fingerprint(desiredAnnotations) && "annotations",
374 !sameText(current.displayName, news.displayName) && "displayName",
375 !sameBool(current.disabled, desiredDisabled) && "disabled",
376 fingerprint(toServe(current.serve)) !== fingerprint(news.serve) &&
377 "serve",
378 ]);
379
380 if (mask.length > 0) {
381 const operation =
382 yield* firebaseapphosting.patchProjectsLocationsBackendsDomains({
383 name: current.name ?? name,
384 updateMask: mask,
385 body: {
386 etag: current.etag,
387 serve: news.serve,
388 displayName: news.displayName,
389 disabled: desiredDisabled,
390 annotations: desiredAnnotations,
391 labels: desiredLabels,
392 },
393 });
394 yield* waitForOperation(operation);
395 current = yield* waitUntilExists(
396 getByName(current.name ?? name),
397 current.name ?? name,
398 );
399 }
400
401 return toAttrs(current, env.project);
402 }),
403
404 delete: Effect.fn(function* ({ output }) {
405 const operation = yield* firebaseapphosting
406 .deleteProjectsLocationsBackendsDomains({
407 name: output.name,
408 })
409 .pipe(
410 Effect.retry({
411 while: (error) => error._tag === "Conflict",
412 times: 8,
413 schedule: Schedule.spaced("2 seconds"),
414 }),
415 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
416 );
417 if (operation !== undefined) {
418 yield* waitForDeleteOperation(operation);
419 }
420 yield* waitUntilGone(getByName(output.name), output.name);
421 }),
422 });