1import * as firebaseapphosting from "@distilled.cloud/gcp/firebaseapphosting_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import * as Schedule from "effect/Schedule";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import { tagRecord } from "../../Tags.ts";
8import { stripInternalLabels } from "../Labels.ts";
9import {
10 waitForOperation as waitForLongRunning,
11 type LongRunningOperation,
12} from "../Operation.ts";
13
14export const DEFAULT_SERVING_LOCALITY = "GLOBAL_ACCESS";
15export const MAX_NAME_LENGTH = 63;
16export const MAX_BACKEND_ID_LENGTH = 30;
17
18export class ResourceNotResolved extends Data.TaggedError(
19 "GCP.FirebaseAppHosting.ResourceNotResolved",
20)<{
21 name: string;
22}> {}
23
24export class ResourceStillExists extends Data.TaggedError(
25 "GCP.FirebaseAppHosting.ResourceStillExists",
26)<{
27 name: string;
28}> {}
29
30export const lastSegment = (value: string) => {
31 const trimmed = value.replace(/\/+$/, "");
32 const parts = trimmed.split("/");
33 return parts[parts.length - 1] || trimmed;
34};
35
36export const rfc1035 = (
37 name: string,
38 fallback = "app-hosting",
39 maxLength = MAX_NAME_LENGTH,
40): string => {
41 let next = name
42 .toLowerCase()
43 .replace(/[^a-z0-9-]/g, "-")
44 .replace(/-+/g, "-")
45 .replace(/^-+|-+$/g, "");
46 if (!/^[a-z]/.test(next)) next = `a${next}`;
47 next = next.slice(0, maxLength).replace(/-+$/g, "");
48 if (next.length === 0) return fallback.slice(0, maxLength);
49 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, maxLength - 1)}0`;
50 return next.slice(0, maxLength);
51};
52
53export const normalizeLocation = (
54 location: string | undefined,
55 defaultLocation: string,
56) => lastSegment(location ?? defaultLocation).toLowerCase();
57
58export const normalizeServingLocality = (value: string | undefined) =>
59 (value ?? DEFAULT_SERVING_LOCALITY).toUpperCase();
60
61export const parentOf = (project: string, location: string) =>
62 `projects/${project}/locations/${lastSegment(location).toLowerCase()}`;
63
64export const defaultServiceAccount = (project: string) =>
65 `firebase-app-hosting-compute@${project}.iam.gserviceaccount.com`;
66
67export const toPhysicalId = (
68 id: string,
69 explicit: string | undefined,
70 existing: string | undefined,
71 fallback = "apphosting",
72 maxLength = MAX_NAME_LENGTH,
73) =>
74 Effect.gen(function* () {
75 if (explicit !== undefined) return rfc1035(explicit, fallback, maxLength);
76 if (existing !== undefined) return existing;
77 return rfc1035(
78 yield* createPhysicalName({
79 id,
80 maxLength,
81 lowercase: true,
82 }),
83 fallback,
84 maxLength,
85 );
86 });
87
88export const toDomainId = (
89 id: string,
90 explicit: string | undefined,
91 existing: string | undefined,
92) =>
93 Effect.gen(function* () {
94 if (explicit !== undefined) return explicit.toLowerCase();
95 if (existing !== undefined) return existing;
96 const generated = rfc1035(
97 yield* createPhysicalName({
98 id,
99 maxLength: 32,
100 lowercase: true,
101 }),
102 "domain",
103 32,
104 );
105 // The API rejects reserved TLDs such as `.example` ("invalid domain name").
106 return `${generated}.example.com`;
107 });
108
109export const parseName = (name: string, collection: string) => {
110 const parts = name.split("/").filter((part) => part.length > 0);
111 const collectionAt = parts.lastIndexOf(collection);
112 const locationsAt = parts.lastIndexOf("locations");
113 const projectsAt = parts.lastIndexOf("projects");
114 return {
115 project:
116 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
117 location:
118 locationsAt >= 0 && parts[locationsAt + 1] ? parts[locationsAt + 1]! : "",
119 id:
120 collectionAt >= 0 && parts[collectionAt + 1]
121 ? parts[collectionAt + 1]!
122 : lastSegment(name),
123 parent:
124 collectionAt > 0
125 ? parts.slice(0, collectionAt).join("/")
126 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
127 };
128};
129
130export const expandParent = (
131 value: string,
132 project: string,
133 location: string,
134 collection: string,
135) => {
136 if (value.includes("/")) return value.replace(/\/+$/, "");
137 return `projects/${project}/locations/${location}/${collection}/${value}`;
138};
139
140export const userLabels = (
141 labels: Record<string, string | undefined> | null | undefined,
142): Record<string, string> => stripInternalLabels(tagRecord(labels));
143
144export const stringMap = (
145 value: Record<string, string | undefined> | null | undefined,
146): Record<string, string> =>
147 Object.fromEntries(
148 Object.entries(value ?? {}).filter(
149 (entry): entry is [string, string] => typeof entry[1] === "string",
150 ),
151 );
152
153export const hasAlchemyLabelMap = (
154 labels: Record<string, string | undefined> | null | undefined,
155) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
156
157export const canonical = (value: unknown): unknown => {
158 if (value === undefined || value === null) return undefined;
159 if (typeof value === "boolean" || typeof value === "number") return value;
160 if (typeof value === "string") {
161 return value.length === 0 ? undefined : value;
162 }
163 if (Array.isArray(value)) {
164 const items = value.map(canonical).filter((item) => item !== undefined);
165 return items.length === 0 ? undefined : items;
166 }
167 if (typeof value === "object") {
168 const entries = Object.entries(value as Record<string, unknown>)
169 .map(([key, item]) => [key, canonical(item)] as const)
170 .filter(([, item]) => item !== undefined)
171 .sort(([left], [right]) => left.localeCompare(right));
172 if (entries.length === 0) return undefined;
173 return Object.fromEntries(entries);
174 }
175 return undefined;
176};
177
178export const fingerprint = (value: unknown): string =>
179 JSON.stringify(canonical(value) ?? null);
180
181export const fieldMask = (fields: Array<string | false | undefined>) =>
182 fields
183 .filter((field): field is string => typeof field === "string")
184 .join(",");
185
186export const sameText = (left: string | undefined, right: string | undefined) =>
187 (left ?? "") === (right ?? "");
188
189export const sameBool = (
190 left: boolean | undefined,
191 right: boolean | undefined,
192) => (left ?? false) === (right ?? false);
193
194export const replaceOnIdentity = (input: {
195 previousId: string | undefined;
196 nextId: string | undefined;
197 previousLocation: string;
198 nextLocation: string;
199 extra?: boolean;
200 previousParent?: string;
201 nextParent?: string;
202}) => {
203 const parentChanged =
204 (input.previousParent ?? "") !== "" &&
205 (input.nextParent ?? "") !== "" &&
206 (input.previousParent ?? "") !== (input.nextParent ?? "");
207 const replace =
208 (input.extra ?? false) ||
209 parentChanged ||
210 (input.previousId !== undefined &&
211 input.nextId !== undefined &&
212 input.nextId !== input.previousId) ||
213 input.previousLocation !== input.nextLocation;
214 if (!replace) return undefined;
215 const samePhysical =
216 input.previousLocation === input.nextLocation &&
217 !parentChanged &&
218 input.previousId !== undefined &&
219 input.nextId === input.previousId;
220 return {
221 action: "replace" as const,
222 deleteFirst: samePhysical,
223 };
224};
225
226export const waitUntilExists = <A, E, R>(
227 get: Effect.Effect<A | undefined, E, R>,
228 name: string,
229): Effect.Effect<NonNullable<A>, E | ResourceNotResolved, R> =>
230 get.pipe(
231 Effect.filterOrFail(
232 (value): value is NonNullable<A> => value !== undefined,
233 () => new ResourceNotResolved({ name }),
234 ),
235 Effect.retry({
236 while: (error) => error instanceof ResourceNotResolved,
237 times: 8,
238 schedule: Schedule.spaced("1 second"),
239 }),
240 );
241
242export const waitUntilGone = <A, E, R>(
243 get: Effect.Effect<A | undefined, E, R>,
244 name: string,
245): Effect.Effect<void, E | ResourceStillExists, R> =>
246 get.pipe(
247 Effect.filterOrFail(
248 (value) => value === undefined,
249 () => new ResourceStillExists({ name }),
250 ),
251 Effect.retry({
252 while: (error) => error instanceof ResourceStillExists,
253 times: 10,
254 schedule: Schedule.spaced("3 seconds"),
255 }),
256 Effect.asVoid,
257 );
258
259/** Every location at once: the API accepts the `locations/-` wildcard. */
260export const listAtLocation = <A, E, R>(
261 project: string,
262 list: (parent: string) => Effect.Effect<A[], E, R>,
263) => list(`projects/${project}/locations/-`);
264
265/** Children of every parent in every location (`locations/-/{nested}`). */
266export const listAtNested = <A, E, R>(
267 project: string,
268 nested: string,
269 list: (parent: string) => Effect.Effect<A[], E, R>,
270) => list(`projects/${project}/locations/-/${nested}`);
271
272export const listLabeledPages = <Page, A, E, R>(
273 pages: Stream.Stream<Page, E, R>,
274 items: (page: Page) => readonly A[] | undefined,
275 labelsOf: (item: A) => Record<string, string | undefined> | null | undefined,
276) =>
277 pages.pipe(
278 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
279 Stream.filter((item) => hasAlchemyLabelMap(labelsOf(item))),
280 Stream.runCollect,
281 Effect.map((chunk) => Array.from(chunk)),
282 );
283
284/** App Hosting builds and rollouts take up to ~15 minutes. */
285const OPERATION_BUDGET = "20 minutes";
286
287/**
288 * Wait for a long-running operation. ALREADY_EXISTS (code 6) means a
289 * concurrent create won the race; reconcile observes the resource next.
290 */
291export const waitForOperation = (operation: LongRunningOperation) =>
292 waitForLongRunning(
293 operation,
294 (name) => firebaseapphosting.getProjectsLocationsOperations({ name }),
295 {
296 budget: OPERATION_BUDGET,
297 },
298 ).pipe(
299 Effect.catchIf(
300 (error) => error._tag === "GCP.OperationFailed" && error.code === 6,
301 () => Effect.succeed(operation),
302 ),
303 );
304
305/**
306 * Wait for a delete operation. A vanished operation or NOT_FOUND (code 5)
307 * means the resource is already gone.
308 */
309export const waitForDeleteOperation = (operation: LongRunningOperation) =>
310 waitForOperation(operation).pipe(
311 Effect.catchIf(
312 (error) =>
313 error._tag === "NotFound" ||
314 (error._tag === "GCP.OperationFailed" && error.code === 5),
315 () => Effect.succeed(operation),
316 ),
317 );