g1t/services/deployments/src/domains.ts
| 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 | } |