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.

113 linesCodeBlameRaw
1/**
2 * Where an app is served, the way Vercel names them: production at
3 * `<project>-<workspace>.g1t.page`, and a branch's preview at
4 * `<project>-git-<branch>-<workspace>.g1t.page`. The first label is also
5 * the app's script name in the dispatch namespace, so the dispatcher needs
6 * nothing but the hostname to find it. The service makes sure no two apps
7 * get the same name; this only spells them. A workspace ending in a domain
8 * ending is written without that hyphen (`hostWorkspace`); an app under the
9 * older spelling is moved to the new one, and its old name redirects.
10 */
11
12import { DEPLOYMENTS_DOMAIN } from "@g1t/contracts";
13
14/** A hostname label is at most this long. */
15const MAX_LABEL = 63;
16
17/** Lowercase letters, digits and single hyphens, as a label allows. */
18export function clean(text: string): string {
19 return text
20 .toLowerCase()
21 .replace(/[^a-z0-9]+/g, "-")
22 .replace(/-{2,}/g, "-")
23 .replace(/^-|-$/g, "");
24}
25
26/**
27 * Domain endings a workspace's name often finishes with, as a company's
28 * domain does (`acme-com`, `flagon-io`). In a hostname they lose their
29 * hyphen: `app-acme-com.g1t.page` reads to browsers as a look-alike of
30 * acme.com, and Chrome warns the people most likely to open it, those who
31 * visit acme.com, that the page "looks fake".
32 */
33const DOMAIN_ENDINGS = new Set([
34 "com",
35 "net",
36 "org",
37 "io",
38 "co",
39 "dev",
40 "app",
41 "ai",
42 "sh",
43 "xyz",
44 "tech",
45 "cloud",
46 "so",
47 "gg",
48 "me",
49 "us",
50 "uk",
51 "de",
52 "ca",
53 "eu",
54 "fr",
55 "nl",
56 "au",
57 "in",
58 "jp",
59 "site",
60 "page",
61 "tools",
62 "studio",
63 "inc",
64]);
65
66/** The workspace as a hostname writes it: `flagon-io` as `flagonio`, `acme-co-uk` as `acmecouk`. */
67export function hostWorkspace(workspace: string): string {
68 const parts = clean(workspace).split("-");
69 let at = parts.length;
70 while (at > 1 && DOMAIN_ENDINGS.has(parts[at - 1])) at--;
71 if (at === parts.length) return parts.join("-");
72 return `${parts
73 .slice(0, at - 1)
74 .concat(parts.slice(at - 1).join(""))
75 .join("-")}`;
76}
77
78/** A short, stable fingerprint of `text`. */
79export async function fingerprint(text: string, bytes = 3): Promise<string> {
80 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
81 return [...new Uint8Array(digest).slice(0, bytes)].map((b) => b.toString(16).padStart(2, "0")).join("");
82}
83
84/**
85 * The label for production (`branch` null) or a branch's preview. Too long
86 * for a label, the branch and then the project are shortened, and a
87 * fingerprint of the whole keeps it unique.
88 */
89export async function label(workspace: string, project: string, branch: string | null): Promise<string> {
90 const w = hostWorkspace(workspace);
91 const p = clean(project);
92 const b = branch == null ? null : clean(branch);
93 const full = b == null ? `${p}-${w}` : `${p}-git-${b}-${w}`;
94 if (full.length <= MAX_LABEL) return full;
95 const tail = `-${await fingerprint(`${workspace}/${project}/${branch ?? ""}`)}-${w}`.slice(0, 40);
96 const room = MAX_LABEL - tail.length;
97 const head = b == null ? p : `${p.slice(0, Math.max(8, Math.floor(room / 2)))}-git-${b}`;
98 return `${head.slice(0, room).replace(/-+$/, "")}${tail}`;
99}
100
101/** The same label made unique, when another app already holds it. */
102export async function uniqueLabel(base: string, key: string): Promise<string> {
103 const suffix = `-${await fingerprint(key, 2)}`;
104 return `${base.slice(0, MAX_LABEL - suffix.length).replace(/-+$/, "")}${suffix}`;
105}
106
107export function appHost(script: string): string {
108 return `${script}.${DEPLOYMENTS_DOMAIN}`;
109}
110
111export function appUrl(script: string): string {
112 return `https://${appHost(script)}`;
113}