Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

412 linesCodeBlameRaw
1/**
2 * Custom domains: a project's production served at a hostname of its own,
3 * such as `example.com` or `www.example.com`, beside its address on
4 * g1t.page.
5 *
6 * Each hostname is a Cloudflare for SaaS custom hostname on the g1t.page
7 * zone. Its owner points it at `domains.g1t.page` (a CNAME, or a flattened
8 * CNAME at the apex); Cloudflare checks it, issues its certificate, and
9 * sends its traffic to the dispatcher, which reads the `DOMAINS` KV
10 * namespace to find the app: `{ script, redirect }` under the hostname.
11 *
12 * A domain maps to the project, not to a build: production redeploys
13 * leave it alone, and the KV entry follows the production script if its
14 * name ever changes (see `follow`).
15 *
16 * Until Cloudflare for SaaS is switched on for the zone, domains are kept
17 * as pending with a note saying so, and the sweep adds them at Cloudflare
18 * the moment it is.
19 */
20
21import { CUSTOM_DOMAIN_TARGET, newId, type Domain, type DomainRecord, type DomainStatus } from "@g1t/contracts";
22
23import { CustomHostnames, NotEnabled, Refused, recordsOf, statusOf, type CustomHostname } from "./custom-hostnames";
24import { checkHostname, isApex, twinOf } from "./hostname";
25
26/** What the UI says while Cloudflare for SaaS is not on for the zone yet. */
27export const NOT_ENABLED_NOTICE =
28 "Custom domains are being switched on for g1t.page. Domains you add now are kept, and set up by themselves the moment they are.";
29
30/** A project has at most this many hostnames. */
31const PER_PROJECT = 20;
32/** How long the answer to "is it switched on" is trusted, in this isolate. */
33const AVAILABILITY_TTL_MS = 5 * 60 * 1000;
34/** An active domain is checked again this often, in case its DNS moved away. */
35const RECHECK_ACTIVE_MS = 24 * 60 * 60 * 1000;
36const SWEEP_LIMIT = 100;
37/** A pending domain is read again from Cloudflare when the page is, at most this often. */
38const CATCH_UP_MS = 10 * 1000;
39
40let availability: { at: number; available: boolean } | null = null;
41
42export type DomainRow = {
43 id: string;
44 project_id: string;
45 workspace: string;
46 slug: string;
47 hostname: string;
48 target: string;
49 cf_hostname_id: string | null;
50 status: DomainStatus;
51 ssl_status: string | null;
52 records: string;
53 redirect_to: string | null;
54 script: string | null;
55 error: string | null;
56 created_by: string;
57 created_at: string;
58 verified_at: string | null;
59 checked_at: string | null;
60};
61
62const now = () => new Date().toISOString();
63
64/** The record that sends a hostname's traffic to g1t. */
65export function routingRecord(hostname: string): DomainRecord {
66 return isApex(hostname)
67 ? { type: "ALIAS", name: hostname, value: CUSTOM_DOMAIN_TARGET, purpose: "Sends traffic to your app (a flattened CNAME, or ALIAS)" }
68 : { type: "CNAME", name: hostname, value: CUSTOM_DOMAIN_TARGET, purpose: "Sends traffic to your app" };
69}
70
71export function toDomain(row: DomainRow): Domain {
72 return {
73 id: row.id,
74 hostname: row.hostname,
75 target: "production",
76 status: row.status,
77 sslStatus: row.ssl_status,
78 apex: isApex(row.hostname),
79 records: [routingRecord(row.hostname), ...(JSON.parse(row.records || "[]") as DomainRecord[])],
80 redirectTo: row.redirect_to,
81 error: row.error,
82 createdBy: row.created_by,
83 createdAt: row.created_at,
84 verifiedAt: row.verified_at,
85 };
86}
87
88/** What the dispatcher reads under a hostname. */
89export type DomainEntry = { script: string; redirect: string | null; project: string };
90
91export class Domains {
92 constructor(
93 private readonly db: D1Database,
94 private readonly kv: KVNamespace | undefined,
95 private readonly hostnames: CustomHostnames | null,
96 ) {}
97
98 /**
99 * The project's domains still on their way to active, read again from
100 * Cloudflare if not read in the last few seconds: what keeps the page's
101 * live view moving between sweeps.
102 */
103 async catchUp(projectId: string): Promise<void> {
104 const since = new Date(Date.now() - CATCH_UP_MS).toISOString();
105 const rows = await this.db
106 .prepare(
107 `SELECT * FROM domains WHERE project_id = ? AND status IN ('pending', 'verifying')
108 AND cf_hostname_id IS NOT NULL AND COALESCE(checked_at, '') < ? LIMIT 5`,
109 )
110 .bind(projectId, since)
111 .all<DomainRow>();
112 await Promise.all(rows.results.map((row) => (row.script ? this.refresh(row, row.script) : null)));
113 }
114
115 async forProject(projectId: string): Promise<DomainRow[]> {
116 const rows = await this.db
117 .prepare("SELECT * FROM domains WHERE project_id = ? ORDER BY redirect_to IS NOT NULL, created_at")
118 .bind(projectId)
119 .all<DomainRow>();
120 return rows.results;
121 }
122
123 /** The workspace's custom domains that Cloudflare holds, which are what is charged. */
124 async countFor(workspace: string): Promise<number> {
125 const row = await this.db
126 .prepare("SELECT COUNT(*) AS n FROM domains WHERE workspace = ? AND cf_hostname_id IS NOT NULL")
127 .bind(workspace)
128 .first<{ n: number }>();
129 return row?.n ?? 0;
130 }
131
132 /** The first active domain that serves the app itself. */
133 async primary(projectId: string): Promise<string | null> {
134 const row = await this.db
135 .prepare(
136 `SELECT hostname FROM domains WHERE project_id = ? AND status = 'active' AND redirect_to IS NULL
137 ORDER BY created_at LIMIT 1`,
138 )
139 .bind(projectId)
140 .first<{ hostname: string }>();
141 return row?.hostname ?? null;
142 }
143
144 /** Whether Cloudflare for SaaS is on for the zone, asked at most every few minutes. */
145 async available(): Promise<boolean> {
146 if (!this.hostnames) return false;
147 if (availability && Date.now() - availability.at < AVAILABILITY_TTL_MS) return availability.available;
148 let available = true;
149 try {
150 await this.hostnames.find("probe.invalid");
151 } catch (error) {
152 if (error instanceof NotEnabled) available = false;
153 else console.error("could not ask whether custom hostnames are on", error);
154 }
155 availability = { at: Date.now(), available };
156 return available;
157 }
158
159 /**
160 * Adds `input` for the project's production, and with `twin` its www or
161 * apex twin, redirecting to it. Answers with what was added, or why not.
162 */
163 async add(input: {
164 project: { id: string; workspace: string; slug: string };
165 script: string;
166 hostname: string;
167 twin: boolean;
168 by: string;
169 }): Promise<{ ok: true; rows: DomainRow[] } | { ok: false; code: "invalid" | "conflict"; message: string }> {
170 const checked = checkHostname(input.hostname);
171 if (!checked.ok) return { ok: false, code: "invalid", message: checked.reason };
172 const hostname = checked.hostname;
173 const twin = input.twin ? twinOf(hostname) : null;
174 if (input.twin && !twin) {
175 return { ok: false, code: "invalid", message: "Only a domain and its www subdomain can be paired. Add other subdomains on their own." };
176 }
177 const wanted = twin ? [hostname, twin] : [hostname];
178 const taken = await this.db
179 .prepare(`SELECT hostname, project_id FROM domains WHERE hostname IN (${wanted.map(() => "?").join(", ")})`)
180 .bind(...wanted)
181 .all<{ hostname: string; project_id: string }>();
182 const fresh = wanted.filter((h) => !taken.results.some((t) => t.hostname === h));
183 const elsewhere = taken.results.find((t) => t.project_id !== input.project.id);
184 if (elsewhere) return { ok: false, code: "conflict", message: `${elsewhere.hostname} is already in use on g1t.` };
185 if (!fresh.includes(hostname) && !twin) return { ok: false, code: "conflict", message: `${hostname} is already added to this project.` };
186 const count = await this.db.prepare("SELECT COUNT(*) AS n FROM domains WHERE project_id = ?").bind(input.project.id).first<{ n: number }>();
187 if ((count?.n ?? 0) + fresh.length > PER_PROJECT) {
188 return { ok: false, code: "conflict", message: `A project can have up to ${PER_PROJECT} domains.` };
189 }
190
191 const at = now();
192 const added: DomainRow[] = [];
193 for (const name of fresh) {
194 const row: DomainRow = {
195 id: newId("dom"),
196 project_id: input.project.id,
197 workspace: input.project.workspace,
198 slug: input.project.slug,
199 hostname: name,
200 target: "production",
201 cf_hostname_id: null,
202 status: "pending",
203 ssl_status: null,
204 records: "[]",
205 redirect_to: name === hostname ? null : hostname,
206 script: null,
207 error: null,
208 created_by: input.by,
209 created_at: at,
210 verified_at: null,
211 checked_at: null,
212 };
213 try {
214 await this.db
215 .prepare(
216 `INSERT INTO domains (id, project_id, workspace, slug, hostname, target, status, records, redirect_to, created_by, created_at)
217 VALUES (?, ?, ?, ?, ?, 'production', 'pending', '[]', ?, ?, ?)`,
218 )
219 .bind(row.id, row.project_id, row.workspace, row.slug, row.hostname, row.redirect_to, row.created_by, row.created_at)
220 .run();
221 } catch (error) {
222 if (/UNIQUE/i.test(String(error))) return { ok: false, code: "conflict", message: `${name} is already in use on g1t.` };
223 throw error;
224 }
225 await this.point(row, input.script);
226 added.push(await this.register(row, input.script));
227 }
228 // Pairing with a domain the project already had: it now redirects too.
229 if (twin && !fresh.includes(twin)) {
230 await this.db.prepare("UPDATE domains SET redirect_to = ? WHERE hostname = ? AND project_id = ?").bind(hostname, twin, input.project.id).run();
231 const row = await this.byHostname(twin);
232 if (row) await this.point(row, input.script);
233 }
234 return { ok: true, rows: added };
235 }
236
237 private async byHostname(hostname: string): Promise<DomainRow | null> {
238 return this.db.prepare("SELECT * FROM domains WHERE hostname = ?").bind(hostname).first<DomainRow>();
239 }
240
241 async byId(projectId: string, id: string): Promise<DomainRow | null> {
242 return this.db.prepare("SELECT * FROM domains WHERE id = ? AND project_id = ?").bind(id, projectId).first<DomainRow>();
243 }
244
245 /** Writes what the dispatcher reads for the hostname, and remembers the script it names. */
246 private async point(row: DomainRow, script: string): Promise<void> {
247 const entry: DomainEntry = { script, redirect: row.redirect_to, project: row.project_id };
248 await this.kv?.put(row.hostname, JSON.stringify(entry));
249 await this.db.prepare("UPDATE domains SET script = ? WHERE id = ?").bind(script, row.id).run();
250 }
251
252 /**
253 * Adds the hostname at Cloudflare, or finds it there from an add that
254 * half-finished, and records what Cloudflare asks for. While Cloudflare
255 * for SaaS is off, the row stays pending with a note.
256 */
257 private async register(row: DomainRow, script: string): Promise<DomainRow> {
258 if (!this.hostnames) {
259 return this.note(row, "Custom domains are not set up on this g1t: it has no Cloudflare token or zone.");
260 }
261 let found: CustomHostname;
262 try {
263 found = (await this.hostnames.find(row.hostname)) ?? (await this.hostnames.create(row.hostname, { script, project: row.project_id, domain: row.id }));
264 } catch (error) {
265 if (error instanceof NotEnabled) {
266 availability = { at: Date.now(), available: false };
267 return this.note(row, NOT_ENABLED_NOTICE);
268 }
269 if (error instanceof Refused && error.status < 500) {
270 return this.save(row, { status: "failed", error: error.message.replace(/^Cloudflare answered \d+: /, "Cloudflare refused it: ") });
271 }
272 console.error("could not add custom hostname", row.hostname, error);
273 return this.note(row, "Cloudflare could not be reached; it is tried again in a few minutes.");
274 }
275 availability = { at: Date.now(), available: true };
276 await this.db.prepare("UPDATE domains SET cf_hostname_id = ? WHERE id = ?").bind(found.id, row.id).run();
277 return this.apply({ ...row, cf_hostname_id: found.id }, found);
278 }
279
280 private async note(row: DomainRow, error: string): Promise<DomainRow> {
281 return this.save(row, { status: "pending", error });
282 }
283
284 private async save(row: DomainRow, changes: Partial<DomainRow>): Promise<DomainRow> {
285 const next = { ...row, ...changes, checked_at: now() };
286 await this.db
287 .prepare(`UPDATE domains SET status = ?, ssl_status = ?, records = ?, error = ?, verified_at = ?, checked_at = ? WHERE id = ?`)
288 .bind(next.status, next.ssl_status, next.records, next.error, next.verified_at, next.checked_at, row.id)
289 .run();
290 return next;
291 }
292
293 /** Cloudflare's view of the hostname, onto its row. */
294 private async apply(row: DomainRow, found: CustomHostname): Promise<DomainRow> {
295 const seen = statusOf(found);
296 return this.save(row, {
297 status: seen.status,
298 ssl_status: seen.ssl,
299 records: JSON.stringify(recordsOf(found)),
300 error: seen.status === "active" ? null : seen.error,
301 verified_at: seen.status === "active" ? (row.verified_at ?? now()) : row.verified_at,
302 });
303 }
304
305 /**
306 * Reads the domain's state from Cloudflare; with `recheck`, first asks
307 * Cloudflare to check its DNS and certificate again now.
308 */
309 async refresh(row: DomainRow, script: string, recheck = false): Promise<DomainRow> {
310 if (!row.cf_hostname_id) return this.register(row, script);
311 if (!this.hostnames) return row;
312 try {
313 const found = recheck && row.status !== "active" ? await this.hostnames.recheck(row.cf_hostname_id) : await this.hostnames.get(row.cf_hostname_id);
314 return this.apply(row, found);
315 } catch (error) {
316 if (error instanceof Refused && error.status === 404) {
317 // Gone at Cloudflare: add it again.
318 await this.db.prepare("UPDATE domains SET cf_hostname_id = NULL WHERE id = ?").bind(row.id).run();
319 return this.register({ ...row, cf_hostname_id: null }, script);
320 }
321 if (error instanceof NotEnabled) return this.note(row, NOT_ENABLED_NOTICE);
322 console.error("could not check custom hostname", row.hostname, error);
323 return row;
324 }
325 }
326
327 /**
328 * Removes the domain, and any of the project's domains redirecting to it:
329 * from the dispatcher at once, then from Cloudflare. A removal Cloudflare
330 * does not take now is left `removing`, for the sweep to finish.
331 */
332 async remove(row: DomainRow): Promise<void> {
333 const redirecting = await this.db
334 .prepare("SELECT * FROM domains WHERE project_id = ? AND redirect_to = ?")
335 .bind(row.project_id, row.hostname)
336 .all<DomainRow>();
337 for (const target of [row, ...redirecting.results]) await this.drop(target);
338 }
339
340 private async drop(row: DomainRow): Promise<void> {
341 await this.kv?.delete(row.hostname);
342 if (row.cf_hostname_id) {
343 try {
344 if (!this.hostnames) throw new Error("no Cloudflare token");
345 await this.hostnames.delete(row.cf_hostname_id);
346 } catch (error) {
347 console.error("could not remove custom hostname; the sweep tries again", row.hostname, error);
348 await this.db.prepare("UPDATE domains SET status = 'removing' WHERE id = ?").bind(row.id).run();
349 return;
350 }
351 }
352 await this.db.prepare("DELETE FROM domains WHERE id = ?").bind(row.id).run();
353 }
354
355 /** Every domain of the project's, removed: its deployments were turned off for good, or its plan ended. */
356 async removeWhere(column: "project_id" | "workspace", value: string): Promise<void> {
357 const rows = await this.db.prepare(`SELECT * FROM domains WHERE ${column} = ?`).bind(value).all<DomainRow>();
358 for (const row of rows.results) await this.drop(row);
359 }
360
361 /**
362 * Production is up under `script`: the project's domains serve it. Only
363 * entries naming another script are written, so a redeploy under the
364 * same name writes nothing.
365 */
366 async follow(projectId: string, script: string): Promise<void> {
367 const rows = await this.db
368 .prepare("SELECT * FROM domains WHERE project_id = ? AND status != 'removing' AND script IS NOT ?")
369 .bind(projectId, script)
370 .all<DomainRow>();
371 for (const row of rows.results) await this.point(row, script);
372 }
373
374 /**
375 * From the sweep: domains not yet at Cloudflare are added (as soon as
376 * custom hostnames are switched on), pending ones are checked, active
377 * ones now and then, and removals that failed are tried again.
378 */
379 async sweep(productionScript: (projectId: string) => Promise<string | null>): Promise<void> {
380 const recheck = new Date(Date.now() - RECHECK_ACTIVE_MS).toISOString();
381 const rows = await this.db
382 .prepare(
383 `SELECT * FROM domains
384 WHERE status IN ('pending', 'verifying', 'removing')
385 OR (status = 'active' AND COALESCE(checked_at, '') < ?)
386 ORDER BY checked_at IS NOT NULL, checked_at LIMIT ?`,
387 )
388 .bind(recheck, SWEEP_LIMIT)
389 .all<DomainRow>();
390 let enabled = true;
391 for (const row of rows.results) {
392 try {
393 if (row.status === "removing") {
394 await this.drop(row);
395 continue;
396 }
397 if (!row.cf_hostname_id && !enabled) continue;
398 const script = row.script ?? (await productionScript(row.project_id));
399 if (!script) continue;
400 const after = await this.refresh(row, script);
401 if (!after.cf_hostname_id && after.error === NOT_ENABLED_NOTICE) enabled = false;
402 } catch (error) {
403 console.error("could not sweep domain", row.hostname, error);
404 }
405 }
406 }
407
408 /** The workspace's slug changed; the rows follow. Their KV entries name scripts, which `follow` moves. */
409 rename(from: string, to: string): D1PreparedStatement {
410 return this.db.prepare("UPDATE domains SET workspace = ?1 WHERE workspace = ?2").bind(to, from);
411 }
412}