| 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 | } |