main
ts 194 lines 5.57 KB
Raw
1 const DEFAULT_TARGET_PORT = "3000";
2 const DEFAULT_TARGET_HOST = "127.0.0.1";
3
4 const exposeNameOpeners = [
5 "arcade", "bouncy", "bravo", "bubble", "candy", "cosmic", "dapper", "electric",
6 "fancy", "fizzy", "flashy", "fuzzy", "gentle", "glitter", "golden", "happy",
7 "hyper", "jazzy", "jolly", "lively", "lucky", "magic", "mellow", "minty",
8 "misty", "moonlit", "mystic", "neon", "nova", "peppy", "pixel", "playful",
9 "poppy", "rapid", "rocket", "rowdy", "snappy", "snazzy", "sparkly", "spicy",
10 "sprightly", "starry", "sunny", "swift", "tangy", "tidy", "toasty", "turbo",
11 "velvet", "vivid", "wavy", "whimsy", "wild", "wonky", "zany", "zesty",
12 ] as const;
13
14 const exposeNameCenters = [
15 "alpaca", "badger", "banjo", "beacon", "biscuit", "capybara", "comet", "cricket",
16 "dragon", "falcon", "feather", "fjord", "fox", "gadget", "gecko", "gizmo",
17 "harbor", "heron", "iguana", "jelly", "koala", "lemur", "mango", "narwhal",
18 "nebula", "noodle", "octopus", "otter", "panda", "pepper", "phoenix", "pickle",
19 "puffin", "quokka", "radar", "ranger", "rocket", "scooter", "seahorse", "skylark",
20 "sprocket", "starling", "sunbeam", "taco", "thimble", "tiger", "toucan", "triton",
21 "walrus", "widget", "willow", "wombat", "yeti", "zeppelin", "zigzag", "zinnia",
22 ] as const;
23
24 const exposeNameClosers = [
25 "arcade", "beacon", "boogie", "bounce", "burst", "cascade", "chorus", "dash",
26 "disco", "drift", "echo", "fiesta", "flare", "flash", "flight", "flip",
27 "glow", "groove", "jam", "jive", "launch", "loop", "march", "orbit",
28 "parade", "party", "pulse", "quest", "rally", "riot", "ripple", "rodeo",
29 "roll", "rush", "serenade", "shuffle", "signal", "sketch", "spark", "sprint",
30 "starlight", "stride", "sway", "swoop", "twirl", "uplift", "vibe", "voyage",
31 "whirl", "wink", "zap", "zenith", "zip", "zoom", "zest", "zone",
32 ] as const;
33
34 export function resolveExposeName(
35 inputName: string,
36 target: string,
37 clientSeed: string
38 ): string {
39 const normalized = normalizeExposeName(inputName);
40 if (normalized !== "") {
41 return normalized;
42 }
43 return buildDefaultExposeName(target, clientSeed);
44 }
45
46 export function buildDefaultExposeName(
47 target: string,
48 clientSeed: string
49 ): string {
50 const seed = normalizeSeed(clientSeed);
51 const normalizedTarget = normalizeExposeTarget(target);
52 const [first, second, third] = pickNameIndexes(`${seed}|${normalizedTarget}`);
53 const label = [
54 exposeNameOpeners[first],
55 exposeNameCenters[second],
56 exposeNameClosers[third],
57 ].join("-");
58
59 return normalizeExposeName(label);
60 }
61
62 export function normalizeExposeName(value: string): string {
63 const cleaned = sanitizeExposeNameInput(value);
64 if (cleaned === "") {
65 return "";
66 }
67
68 if (/^[a-z0-9-]+$/.test(cleaned)) {
69 return cleaned.slice(0, 63);
70 }
71
72 const ascii = toASCIILabel(cleaned);
73 if (ascii === "" || ascii.length > 63) {
74 return "";
75 }
76 return ascii;
77 }
78
79 function normalizeExposeTarget(raw: string): string {
80 const trimmed = raw.trim();
81 const candidate = trimmed === "" ? DEFAULT_TARGET_PORT : trimmed;
82
83 if (/^\d+$/.test(candidate)) {
84 return `${DEFAULT_TARGET_HOST}:${candidate}`;
85 }
86
87 if (candidate.includes("://")) {
88 try {
89 const parsed = new URL(candidate);
90 if (
91 (parsed.protocol === "http:" || parsed.protocol === "https:") &&
92 parsed.host !== "" &&
93 (parsed.pathname === "" || parsed.pathname === "/") &&
94 parsed.search === "" &&
95 parsed.hash === ""
96 ) {
97 return parsed.host;
98 }
99 } catch {
100 return candidate;
101 }
102 }
103
104 try {
105 const parsed = new URL(`tcp://${candidate}`);
106 if (parsed.hostname === "") {
107 return candidate;
108 }
109 const port = parsed.port || "80";
110 if (parsed.hostname.includes(":")) {
111 return `[${parsed.hostname}]:${port}`;
112 }
113 return `${parsed.hostname}:${port}`;
114 } catch {
115 return candidate;
116 }
117 }
118
119 function normalizeSeed(clientSeed: string): string {
120 const trimmed = clientSeed.trim();
121 if (trimmed === "") {
122 return "portal";
123 }
124 if (trimmed.startsWith("cli_")) {
125 return trimmed.slice(4) || "portal";
126 }
127 return trimmed;
128 }
129
130 function sanitizeExposeNameInput(value: string): string {
131 const input = value.trim().toLowerCase().normalize("NFC");
132 if (input === "") {
133 return "";
134 }
135
136 let output = "";
137 let previousHyphen = false;
138
139 for (const char of input) {
140 if (char === "-" || /[\p{L}\p{N}]/u.test(char)) {
141 output += char;
142 previousHyphen = false;
143 continue;
144 }
145
146 if (!previousHyphen) {
147 output += "-";
148 previousHyphen = true;
149 }
150 }
151
152 return output.replace(/^-+|-+$/g, "");
153 }
154
155 function toASCIILabel(label: string): string {
156 const suffix = ".example.test";
157
158 try {
159 const hostname = new URL(`https://${label}${suffix}`).hostname;
160 if (!hostname.endsWith(suffix)) {
161 return "";
162 }
163 return hostname.slice(0, -suffix.length);
164 } catch {
165 return "";
166 }
167 }
168
169 function pickNameIndexes(input: string): [number, number, number] {
170 const [first, second, third] = hashBytes(input);
171 return [
172 first % exposeNameOpeners.length,
173 second % exposeNameCenters.length,
174 third % exposeNameClosers.length,
175 ];
176 }
177
178 function hashBytes(input: string): [number, number, number] {
179 const bytes = new TextEncoder().encode(input);
180 const first = fnv1a32(bytes, 0x811c9dc5);
181 const second = fnv1a32(bytes, 0x9e3779b9);
182 const third = fnv1a32(bytes, 0x85ebca6b);
183
184 return [first & 0xff, second & 0xff, third & 0xff];
185 }
186
187 function fnv1a32(bytes: Uint8Array, seed: number): number {
188 let hash = seed >>> 0;
189 for (const value of bytes) {
190 hash ^= value;
191 hash = Math.imul(hash, 0x01000193) >>> 0;
192 }
193 return hash >>> 0;
194 }