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.

136 linesCodeBlameRaw
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. */
10const 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 */
16const MULTI_LABEL_SUFFIXES = new Set([
17 "co.uk",
18 "org.uk",
19 "me.uk",
20 "ltd.uk",
21 "plc.uk",
22 "net.uk",
23 "ac.uk",
24 "gov.uk",
25 "com.au",
26 "net.au",
27 "org.au",
28 "edu.au",
29 "id.au",
30 "co.nz",
31 "net.nz",
32 "org.nz",
33 "co.jp",
34 "ne.jp",
35 "or.jp",
36 "co.za",
37 "org.za",
38 "com.br",
39 "net.br",
40 "org.br",
41 "com.mx",
42 "org.mx",
43 "com.ar",
44 "com.co",
45 "com.tr",
46 "com.sg",
47 "com.hk",
48 "com.tw",
49 "com.cn",
50 "com.my",
51 "com.ph",
52 "co.in",
53 "net.in",
54 "org.in",
55 "co.id",
56 "co.il",
57 "co.kr",
58 "or.kr",
59 "com.es",
60 "com.pl",
61 "com.ua",
62]);
63
64const LABEL = /^(?!-)[a-z0-9-]{1,63}(?<!-)$/;
65
66export type HostnameCheck = { ok: true; hostname: string } | { ok: false; reason: string };
67
68/**
69 * `input` as a hostname g1t can serve: lowercase and punycode, with no
70 * scheme, path, port or trailing dot; at least two labels, each a valid
71 * label; never a wildcard, an IP address or one of g1t's own domains.
72 */
73export function checkHostname(input: string): HostnameCheck {
74 let text = String(input ?? "")
75 .trim()
76 .toLowerCase();
77 // What people paste: a URL, or a hostname with a path or a trailing dot.
78 text = text
79 .replace(/^[a-z][a-z0-9+.-]*:\/\//, "")
80 .replace(/[/?#].*$/, "")
81 .replace(/\.$/, "");
82 if (!text) return { ok: false, reason: "Enter a domain, such as example.com or www.example.com." };
83 if (text.includes("*")) return { ok: false, reason: "Wildcard domains are not supported yet; add each hostname on its own." };
84 if (/:\d+$/.test(text)) return { ok: false, reason: "Leave out the port: apps are served on 443." };
85 if (/[\s@:]/.test(text)) return { ok: false, reason: "That is not a domain." };
86 let hostname: string;
87 try {
88 // The URL parser applies IDNA, so `bücher.example` becomes `xn--bcher-kva.example`.
89 hostname = new URL(`http://${text}/`).hostname;
90 } catch {
91 return { ok: false, reason: "That is not a domain." };
92 }
93 if (/^\[|^\d+(\.\d+){3}$/.test(hostname)) return { ok: false, reason: "Use a domain name, not an IP address." };
94 if (hostname.length > 253) return { ok: false, reason: "That domain is too long." };
95 const labels = hostname.split(".");
96 if (labels.length < 2) return { ok: false, reason: "Include the domain's ending, such as .com or .io." };
97 if (labels.some((label) => !LABEL.test(label))) {
98 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." };
99 }
100 if (/^\d+$/.test(labels[labels.length - 1])) return { ok: false, reason: "That is not a domain." };
101 for (const reserved of RESERVED) {
102 if (hostname === reserved || hostname.endsWith(`.${reserved}`)) {
103 return { ok: false, reason: `${reserved} is g1t's own domain; every app already has an address on it.` };
104 }
105 }
106 return { ok: true, hostname };
107}
108
109/** The registrable domain `hostname` is under: `example.com` for `www.example.com`. */
110export function apexOf(hostname: string): string {
111 const labels = hostname.split(".");
112 const two = labels.slice(-2).join(".");
113 const keep = MULTI_LABEL_SUFFIXES.has(two) ? 3 : 2;
114 return labels.slice(-keep).join(".");
115}
116
117/** Whether `hostname` is a registrable domain itself, which DNS cannot point with a CNAME. */
118export function isApex(hostname: string): boolean {
119 return apexOf(hostname) === hostname;
120}
121
122/**
123 * The other half of a www/apex pair: `www.example.com` for `example.com`
124 * and back. Null for any other subdomain.
125 */
126export function twinOf(hostname: string): string | null {
127 if (isApex(hostname)) return `www.${hostname}`;
128 if (hostname.startsWith("www.") && isApex(hostname.slice(4))) return hostname.slice(4);
129 return null;
130}
131
132/** What a hostname's own record is called in its DNS zone: `@` for the apex, else the part before it. */
133export function recordName(hostname: string): string {
134 const apex = apexOf(hostname);
135 return hostname === apex ? "@" : hostname.slice(0, -apex.length - 1);
136}