g1t/services/deployments/src/domains.ts
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.
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 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 | ||
| 21 | import { CUSTOM_DOMAIN_TARGET, DEPLOYMENTS_ALLOWANCE, newId, type Domain, type DomainRecord, type DomainStatus } from "@g1t/contracts"; | |
| 22 | ||
| 23 | import { CustomHostnames, NotEnabled, Refused, recordsOf, statusOf, type CustomHostname } from "./custom-hostnames"; | |
| 24 | import { checkHostname, isApex, twinOf } from "./hostname"; | |
| 25 | ||
| 26 | /** What the UI says while Cloudflare for SaaS is not on for the zone yet. */ | |
| 27 | export 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. */ | |
| 31 | const PER_PROJECT = 20; | |
| 32 | /** How long the answer to "is it switched on" is trusted, in this isolate. */ | |
| 33 | const AVAILABILITY_TTL_MS = 5 * 60 * 1000; | |
| 34 | /** An active domain is checked again this often, in case its DNS moved away. */ | |
| 35 | const RECHECK_ACTIVE_MS = 24 * 60 * 60 * 1000; | |
| 36 | const SWEEP_LIMIT = 100; | |
| 37 | /** A pending domain is read again from Cloudflare when the page is, at most this often. */ | |
| 38 | const CATCH_UP_MS = 10 * 1000; | |
| 39 | ||
| 40 | let availability: { at: number; available: boolean } | null = null; | |
| 41 | ||
| 42 | export 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 | ||
| 62 | const now = () => new Date().toISOString(); | |
| 63 | ||
| 64 | /** The record that sends a hostname's traffic to g1t. */ | |
| 65 | export 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 | ||
| 71 | export 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. */ | |
| 89 | export type DomainEntry = { script: string; redirect: string | null; project: string }; | |
| 90 | ||
| 91 | export 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 | } | |
| 426 | ||
| 427 | /** Custom domains past the plan's, for a month's charge. */ | |
| 428 | export function extraDomains(peak: number): number { | |
| 429 | return Math.max(0, peak - DEPLOYMENTS_ALLOWANCE.customDomains); | |
| 430 | } |