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.

g1t.sh
425 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1/**
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
187 .prepare("SELECT COUNT(*) AS n FROM domains WHERE project_id = ?")
188 .bind(input.project.id)
189 .first<{ n: number }>();
190 if ((count?.n ?? 0) + fresh.length > PER_PROJECT) {
191 return { ok: false, code: "conflict", message: `A project can have up to ${PER_PROJECT} domains.` };
192 }
193
194 const at = now();
195 const added: DomainRow[] = [];
196 for (const name of fresh) {
197 const row: DomainRow = {
198 id: newId("dom"),
199 project_id: input.project.id,
200 workspace: input.project.workspace,
201 slug: input.project.slug,
202 hostname: name,
203 target: "production",
204 cf_hostname_id: null,
205 status: "pending",
206 ssl_status: null,
207 records: "[]",
208 redirect_to: name === hostname ? null : hostname,
209 script: null,
210 error: null,
211 created_by: input.by,
212 created_at: at,
213 verified_at: null,
214 checked_at: null,
215 };
216 try {
217 await this.db
218 .prepare(
219 `INSERT INTO domains (id, project_id, workspace, slug, hostname, target, status, records, redirect_to, created_by, created_at)
220 VALUES (?, ?, ?, ?, ?, 'production', 'pending', '[]', ?, ?, ?)`,
221 )
222 .bind(row.id, row.project_id, row.workspace, row.slug, row.hostname, row.redirect_to, row.created_by, row.created_at)
223 .run();
224 } catch (error) {
225 if (/UNIQUE/i.test(String(error))) return { ok: false, code: "conflict", message: `${name} is already in use on g1t.` };
226 throw error;
227 }
228 await this.point(row, input.script);
229 added.push(await this.register(row, input.script));
230 }
231 // Pairing with a domain the project already had: it now redirects too.
232 if (twin && !fresh.includes(twin)) {
233 await this.db
234 .prepare("UPDATE domains SET redirect_to = ? WHERE hostname = ? AND project_id = ?")
235 .bind(hostname, twin, input.project.id)
236 .run();
237 const row = await this.byHostname(twin);
238 if (row) await this.point(row, input.script);
239 }
240 return { ok: true, rows: added };
241 }
242
243 private async byHostname(hostname: string): Promise<DomainRow | null> {
244 return this.db.prepare("SELECT * FROM domains WHERE hostname = ?").bind(hostname).first<DomainRow>();
245 }
246
247 async byId(projectId: string, id: string): Promise<DomainRow | null> {
248 return this.db.prepare("SELECT * FROM domains WHERE id = ? AND project_id = ?").bind(id, projectId).first<DomainRow>();
249 }
250
251 /** Writes what the dispatcher reads for the hostname, and remembers the script it names. */
252 private async point(row: DomainRow, script: string): Promise<void> {
253 const entry: DomainEntry = { script, redirect: row.redirect_to, project: row.project_id };
254 await this.kv?.put(row.hostname, JSON.stringify(entry));
255 await this.db.prepare("UPDATE domains SET script = ? WHERE id = ?").bind(script, row.id).run();
256 }
257
258 /**
259 * Adds the hostname at Cloudflare, or finds it there from an add that
260 * half-finished, and records what Cloudflare asks for. While Cloudflare
261 * for SaaS is off, the row stays pending with a note.
262 */
263 private async register(row: DomainRow, script: string): Promise<DomainRow> {
264 if (!this.hostnames) {
265 return this.note(row, "Custom domains are not set up on this g1t: it has no Cloudflare token or zone.");
266 }
267 let found: CustomHostname;
268 try {
269 found =
270 (await this.hostnames.find(row.hostname)) ??
271 (await this.hostnames.create(row.hostname, { script, project: row.project_id, domain: row.id }));
272 } catch (error) {
273 if (error instanceof NotEnabled) {
274 availability = { at: Date.now(), available: false };
275 return this.note(row, NOT_ENABLED_NOTICE);
276 }
277 if (error instanceof Refused && error.status < 500) {
278 return this.save(row, { status: "failed", error: error.message.replace(/^Cloudflare answered \d+: /, "Cloudflare refused it: ") });
279 }
280 console.error("could not add custom hostname", row.hostname, error);
281 return this.note(row, "Cloudflare could not be reached; it is tried again in a few minutes.");
282 }
283 availability = { at: Date.now(), available: true };
284 await this.db.prepare("UPDATE domains SET cf_hostname_id = ? WHERE id = ?").bind(found.id, row.id).run();
285 return this.apply({ ...row, cf_hostname_id: found.id }, found);
286 }
287
288 private async note(row: DomainRow, error: string): Promise<DomainRow> {
289 return this.save(row, { status: "pending", error });
290 }
291
292 private async save(row: DomainRow, changes: Partial<DomainRow>): Promise<DomainRow> {
293 const next = { ...row, ...changes, checked_at: now() };
294 await this.db
295 .prepare(
296 `UPDATE domains SET status = ?, ssl_status = ?, records = ?, error = ?, verified_at = ?, checked_at = ? WHERE id = ?`,
297 )
298 .bind(next.status, next.ssl_status, next.records, next.error, next.verified_at, next.checked_at, row.id)
299 .run();
300 return next;
301 }
302
303 /** Cloudflare's view of the hostname, onto its row. */
304 private async apply(row: DomainRow, found: CustomHostname): Promise<DomainRow> {
305 const seen = statusOf(found);
306 return this.save(row, {
307 status: seen.status,
308 ssl_status: seen.ssl,
309 records: JSON.stringify(recordsOf(found)),
310 error: seen.status === "active" ? null : seen.error,
311 verified_at: seen.status === "active" ? (row.verified_at ?? now()) : row.verified_at,
312 });
313 }
314
315 /**
316 * Reads the domain's state from Cloudflare; with `recheck`, first asks
317 * Cloudflare to check its DNS and certificate again now.
318 */
319 async refresh(row: DomainRow, script: string, recheck = false): Promise<DomainRow> {
320 if (!row.cf_hostname_id) return this.register(row, script);
321 if (!this.hostnames) return row;
322 try {
323 const found =
324 recheck && row.status !== "active"
325 ? await this.hostnames.recheck(row.cf_hostname_id)
326 : await this.hostnames.get(row.cf_hostname_id);
327 return this.apply(row, found);
328 } catch (error) {
329 if (error instanceof Refused && error.status === 404) {
330 // Gone at Cloudflare: add it again.
331 await this.db.prepare("UPDATE domains SET cf_hostname_id = NULL WHERE id = ?").bind(row.id).run();
332 return this.register({ ...row, cf_hostname_id: null }, script);
333 }
334 if (error instanceof NotEnabled) return this.note(row, NOT_ENABLED_NOTICE);
335 console.error("could not check custom hostname", row.hostname, error);
336 return row;
337 }
338 }
339
340 /**
341 * Removes the domain, and any of the project's domains redirecting to it:
342 * from the dispatcher at once, then from Cloudflare. A removal Cloudflare
343 * does not take now is left `removing`, for the sweep to finish.
344 */
345 async remove(row: DomainRow): Promise<void> {
346 const redirecting = await this.db
347 .prepare("SELECT * FROM domains WHERE project_id = ? AND redirect_to = ?")
348 .bind(row.project_id, row.hostname)
349 .all<DomainRow>();
350 for (const target of [row, ...redirecting.results]) await this.drop(target);
351 }
352
353 private async drop(row: DomainRow): Promise<void> {
354 await this.kv?.delete(row.hostname);
355 if (row.cf_hostname_id) {
356 try {
357 if (!this.hostnames) throw new Error("no Cloudflare token");
358 await this.hostnames.delete(row.cf_hostname_id);
359 } catch (error) {
360 console.error("could not remove custom hostname; the sweep tries again", row.hostname, error);
361 await this.db.prepare("UPDATE domains SET status = 'removing' WHERE id = ?").bind(row.id).run();
362 return;
363 }
364 }
365 await this.db.prepare("DELETE FROM domains WHERE id = ?").bind(row.id).run();
366 }
367
368 /** Every domain of the project's, removed: its deployments were turned off for good, or its plan ended. */
369 async removeWhere(column: "project_id" | "workspace", value: string): Promise<void> {
370 const rows = await this.db.prepare(`SELECT * FROM domains WHERE ${column} = ?`).bind(value).all<DomainRow>();
371 for (const row of rows.results) await this.drop(row);
372 }
373
374 /**
375 * Production is up under `script`: the project's domains serve it. Only
376 * entries naming another script are written, so a redeploy under the
377 * same name writes nothing.
378 */
379 async follow(projectId: string, script: string): Promise<void> {
380 const rows = await this.db
381 .prepare("SELECT * FROM domains WHERE project_id = ? AND status != 'removing' AND script IS NOT ?")
382 .bind(projectId, script)
383 .all<DomainRow>();
384 for (const row of rows.results) await this.point(row, script);
385 }
386
387 /**
388 * From the sweep: domains not yet at Cloudflare are added (as soon as
389 * custom hostnames are switched on), pending ones are checked, active
390 * ones now and then, and removals that failed are tried again.
391 */
392 async sweep(productionScript: (projectId: string) => Promise<string | null>): Promise<void> {
393 const recheck = new Date(Date.now() - RECHECK_ACTIVE_MS).toISOString();
394 const rows = await this.db
395 .prepare(
396 `SELECT * FROM domains
397 WHERE status IN ('pending', 'verifying', 'removing')
398 OR (status = 'active' AND COALESCE(checked_at, '') < ?)
399 ORDER BY checked_at IS NOT NULL, checked_at LIMIT ?`,
400 )
401 .bind(recheck, SWEEP_LIMIT)
402 .all<DomainRow>();
403 let enabled = true;
404 for (const row of rows.results) {
405 try {
406 if (row.status === "removing") {
407 await this.drop(row);
408 continue;
409 }
410 if (!row.cf_hostname_id && !enabled) continue;
411 const script = row.script ?? (await productionScript(row.project_id));
412 if (!script) continue;
413 const after = await this.refresh(row, script);
414 if (!after.cf_hostname_id && after.error === NOT_ENABLED_NOTICE) enabled = false;
415 } catch (error) {
416 console.error("could not sweep domain", row.hostname, error);
417 }
418 }
419 }
420
421 /** The workspace's slug changed; the rows follow. Their KV entries name scripts, which `follow` moves. */
422 rename(from: string, to: string): D1PreparedStatement {
423 return this.db.prepare("UPDATE domains SET workspace = ?1 WHERE workspace = ?2").bind(to, from);
424 }
425}

This file's history is long; its oldest lines are credited to the oldest commit read.