main
ts 148 lines 5.3 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(inputName: string, target: string, clientSeed: string): string {
35 const normalized = normalizeExposeName(inputName);
36 if (normalized !== '') return normalized;
37 return buildDefaultExposeName(target, clientSeed);
38 }
39
40 export function buildDefaultExposeName(target: string, clientSeed: string): string {
41 const seed = normalizeSeed(clientSeed);
42 const normalizedTarget = normalizeExposeTarget(target);
43 const [first, second, third] = pickNameIndexes(`${seed}|${normalizedTarget}`);
44 const label = [
45 exposeNameOpeners[first],
46 exposeNameCenters[second],
47 exposeNameClosers[third]
48 ].join('-');
49 return normalizeExposeName(label);
50 }
51
52 export function normalizeExposeName(value: string): string {
53 const cleaned = sanitizeExposeNameInput(value);
54 if (cleaned === '') return '';
55 if (/^[a-z0-9-]+$/.test(cleaned)) return cleaned.slice(0, 63);
56 const ascii = toASCIILabel(cleaned);
57 if (ascii === '' || ascii.length > 63) return '';
58 return ascii;
59 }
60
61 function normalizeExposeTarget(raw: string): string {
62 const trimmed = raw.trim();
63 const candidate = trimmed === '' ? DEFAULT_TARGET_PORT : trimmed;
64 if (/^\d+$/.test(candidate)) return `${DEFAULT_TARGET_HOST}:${candidate}`;
65 if (candidate.includes('://')) {
66 try {
67 const parsed = new URL(candidate);
68 if (
69 (parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
70 parsed.host !== '' &&
71 (parsed.pathname === '' || parsed.pathname === '/') &&
72 parsed.search === '' &&
73 parsed.hash === ''
74 ) return parsed.host;
75 } catch { return candidate; }
76 }
77 try {
78 const parsed = new URL(`tcp://${candidate}`);
79 if (parsed.hostname === '') return candidate;
80 return formatHostPort(parsed.hostname, parsed.port || '80');
81 } catch { return candidate; }
82 }
83
84 function normalizeSeed(clientSeed: string): string {
85 const trimmed = clientSeed.trim();
86 if (trimmed === '') return 'portal';
87 if (trimmed.startsWith('cli_')) return trimmed.slice(4) || 'portal';
88 return trimmed;
89 }
90
91 function sanitizeExposeNameInput(value: string): string {
92 const input = value.trim().toLowerCase().normalize('NFC');
93 if (input === '') return '';
94 let output = '';
95 let previousHyphen = false;
96 for (const char of input) {
97 if (char === '-' || /[\p{L}\p{N}]/u.test(char)) {
98 output += char;
99 previousHyphen = false;
100 continue;
101 }
102 if (!previousHyphen) {
103 output += '-';
104 previousHyphen = true;
105 }
106 }
107 return output.replace(/^-+|-+$/g, '');
108 }
109
110 function toASCIILabel(label: string): string {
111 const suffix = '.example.test';
112 try {
113 const hostname = new URL(`https://${label}${suffix}`).hostname;
114 if (!hostname.endsWith(suffix)) return '';
115 return hostname.slice(0, -suffix.length);
116 } catch { return ''; }
117 }
118
119 function pickNameIndexes(input: string): [number, number, number] {
120 const [first, second, third] = hashBytes(input);
121 return [
122 first % exposeNameOpeners.length,
123 second % exposeNameCenters.length,
124 third % exposeNameClosers.length
125 ];
126 }
127
128 function hashBytes(input: string): [number, number, number] {
129 const bytes = new TextEncoder().encode(input);
130 const first = fnv1a32(bytes, 0x811c9dc5);
131 const second = fnv1a32(bytes, 0x9e3779b9);
132 const third = fnv1a32(bytes, 0x85ebca6b);
133 return [first & 0xff, second & 0xff, third & 0xff];
134 }
135
136 function fnv1a32(bytes: Uint8Array, seed: number): number {
137 let hash = seed >>> 0;
138 for (const value of bytes) {
139 hash ^= value;
140 hash = Math.imul(hash, 0x01000193) >>> 0;
141 }
142 return hash >>> 0;
143 }
144
145 function formatHostPort(hostname: string, port: string): string {
146 if (hostname.includes(':')) return `[${hostname}]:${port}`;
147 return `${hostname}:${port}`;
148 }