g1t/services/deployments/src/hostname.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 | * Hostnames people bring for their apps: checked, spelled the one way | |
| 3 | * Cloudflare and browsers agree on (lowercase, punycode), and paired with | |
| 4 | * their www or apex twin. | |
| 5 | * | |
| 6 | * Kept free of imports so its tests run on Node as they are. | |
| 7 | */ | |
| 8 | ||
| 9 | /** Domains a custom hostname may never be on: g1t's own. */ | |
| 10 | const RESERVED = ["g1t.page", "g1t.sh", "g1t.dev"]; | |
| 11 | ||
| 12 | /** | |
| 13 | * Public suffixes of more than one label, the common ones. A hostname | |
| 14 | * directly under one of these is an apex (`shop.co.uk`), not a subdomain. | |
| 15 | */ | |
| 16 | const MULTI_LABEL_SUFFIXES = new Set([ | |
| 17 | "co.uk", "org.uk", "me.uk", "ltd.uk", "plc.uk", "net.uk", "ac.uk", "gov.uk", | |
| 18 | "com.au", "net.au", "org.au", "edu.au", "id.au", | |
| 19 | "co.nz", "net.nz", "org.nz", | |
| 20 | "co.jp", "ne.jp", "or.jp", | |
| 21 | "co.za", "org.za", | |
| 22 | "com.br", "net.br", "org.br", | |
| 23 | "com.mx", "org.mx", | |
| 24 | "com.ar", "com.co", "com.tr", "com.sg", "com.hk", "com.tw", "com.cn", "com.my", "com.ph", | |
| 25 | "co.in", "net.in", "org.in", "co.id", "co.il", "co.kr", "or.kr", | |
| 26 | "com.es", "com.pl", "com.ua", | |
| 27 | ]); | |
| 28 | ||
| 29 | const LABEL = /^(?!-)[a-z0-9-]{1,63}(?<!-)$/; | |
| 30 | ||
| 31 | export type HostnameCheck = { ok: true; hostname: string } | { ok: false; reason: string }; | |
| 32 | ||
| 33 | /** | |
| 34 | * `input` as a hostname g1t can serve: lowercase and punycode, with no | |
| 35 | * scheme, path, port or trailing dot; at least two labels, each a valid | |
| 36 | * label; never a wildcard, an IP address or one of g1t's own domains. | |
| 37 | */ | |
| 38 | export function checkHostname(input: string): HostnameCheck { | |
| 39 | let text = String(input ?? "").trim().toLowerCase(); | |
| 40 | // What people paste: a URL, or a hostname with a path or a trailing dot. | |
| 41 | text = text.replace(/^[a-z][a-z0-9+.-]*:\/\//, "").replace(/[/?#].*$/, "").replace(/\.$/, ""); | |
| 42 | if (!text) return { ok: false, reason: "Enter a domain, such as example.com or www.example.com." }; | |
| 43 | if (text.includes("*")) return { ok: false, reason: "Wildcard domains are not supported yet; add each hostname on its own." }; | |
| 44 | if (/:\d+$/.test(text)) return { ok: false, reason: "Leave out the port: apps are served on 443." }; | |
| 45 | if (/[\s@:]/.test(text)) return { ok: false, reason: "That is not a domain." }; | |
| 46 | let hostname: string; | |
| 47 | try { | |
| 48 | // The URL parser applies IDNA, so `bücher.example` becomes `xn--bcher-kva.example`. | |
| 49 | hostname = new URL(`http://${text}/`).hostname; | |
| 50 | } catch { | |
| 51 | return { ok: false, reason: "That is not a domain." }; | |
| 52 | } | |
| 53 | if (/^\[|^\d+(\.\d+){3}$/.test(hostname)) return { ok: false, reason: "Use a domain name, not an IP address." }; | |
| 54 | if (hostname.length > 253) return { ok: false, reason: "That domain is too long." }; | |
| 55 | const labels = hostname.split("."); | |
| 56 | if (labels.length < 2) return { ok: false, reason: "Include the domain's ending, such as .com or .io." }; | |
| 57 | if (labels.some((label) => !LABEL.test(label))) { | |
| 58 | return { ok: false, reason: "Each part of a domain is 1 to 63 letters, digits or hyphens, and does not start or end with a hyphen." }; | |
| 59 | } | |
| 60 | if (/^\d+$/.test(labels[labels.length - 1])) return { ok: false, reason: "That is not a domain." }; | |
| 61 | for (const reserved of RESERVED) { | |
| 62 | if (hostname === reserved || hostname.endsWith(`.${reserved}`)) { | |
| 63 | return { ok: false, reason: `${reserved} is g1t's own domain; every app already has an address on it.` }; | |
| 64 | } | |
| 65 | } | |
| 66 | return { ok: true, hostname }; | |
| 67 | } | |
| 68 | ||
| 69 | /** The registrable domain `hostname` is under: `example.com` for `www.example.com`. */ | |
| 70 | export function apexOf(hostname: string): string { | |
| 71 | const labels = hostname.split("."); | |
| 72 | const two = labels.slice(-2).join("."); | |
| 73 | const keep = MULTI_LABEL_SUFFIXES.has(two) ? 3 : 2; | |
| 74 | return labels.slice(-keep).join("."); | |
| 75 | } | |
| 76 | ||
| 77 | /** Whether `hostname` is a registrable domain itself, which DNS cannot point with a CNAME. */ | |
| 78 | export function isApex(hostname: string): boolean { | |
| 79 | return apexOf(hostname) === hostname; | |
| 80 | } | |
| 81 | ||
| 82 | /** | |
| 83 | * The other half of a www/apex pair: `www.example.com` for `example.com` | |
| 84 | * and back. Null for any other subdomain. | |
| 85 | */ | |
| 86 | export function twinOf(hostname: string): string | null { | |
| 87 | if (isApex(hostname)) return `www.${hostname}`; | |
| 88 | if (hostname.startsWith("www.") && isApex(hostname.slice(4))) return hostname.slice(4); | |
| 89 | return null; | |
| 90 | } | |
| 91 | ||
| 92 | /** What a hostname's own record is called in its DNS zone: `@` for the apex, else the part before it. */ | |
| 93 | export function recordName(hostname: string): string { | |
| 94 | const apex = apexOf(hostname); | |
| 95 | return hostname === apex ? "@" : hostname.slice(0, -apex.length - 1); | |
| 96 | } |