/** * Matches an ASCII tab (U+0009) or newline (U+000A / U+000D). The WHATWG URL * parser strips these before parsing; we only allocate a cleaned copy (and * re-parse) on the rare input that actually contains one. */ const CONTROL_CHARS = /[\t\n\r]/g; // Set by `extractHostname` (a module-scope flag, read synchronously by // `parseImpl` right after the call — same pattern as the reused RESULT object). // `true` ONLY when extraction validated the returned host inline (a confirmed- // valid, "simple" authority) so `parseImpl` can skip the separate // `isValidHostname` pass. `false` in every other case (validation disabled, a // complex authority — userinfo/port/brackets/trailing-dot/control — an invalid // host, or a non-main return path); `parseImpl` then validates as usual. The // fast path can only ever SKIP a redundant scan for hosts already known valid, // never accept an invalid one. export let extractedHostnameValidated = false; /** * True if char `code` is a valid hostname character. This is the per-char half * of `is-valid.ts`'s `isValidAscii` (a-z, 0-9, > U+007F) PLUS three additions: * A-Z (the host is lowercased before validation, so uppercase ≡ a valid * lowercase letter) and '-' / '_' (valid inside a label). KEEP IN SYNC with * `is-valid.ts`: these rules are deliberately duplicated to validate during * extraction, so any change to the accepted character set there must be * mirrored here (and vice-versa). */ function isValidHostnameChar(code: number): boolean { return ( (code >= 97 && code <= 122) || // a-z (code >= 48 && code <= 57) || // 0-9 code > 127 || // non-ASCII (accepted, not punycode-checked) (code >= 65 && code <= 90) || // A-Z (becomes valid once lowercased) code === 45 || // '-' code === 95 // '_' ); } /** * Classify scheme `url.slice(schemeStart, colonIndex)` as a WHATWG special * scheme without allocating a substring (case-insensitive via `| 32`). * Special schemes: ftp, file, http, https, ws, wss * (https://url.spec.whatwg.org/#special-scheme). * * @returns 0 = not special, 1 = special, 2 = file (its host sits only between * "//" and the next slash). */ function getSpecialScheme( url: string, schemeStart: number, colonIndex: number, ): number { const length = colonIndex - schemeStart; const c0 = url.charCodeAt(schemeStart) | 32; if (length === 2) { return c0 === 119 && (url.charCodeAt(schemeStart + 1) | 32) === 115 ? 1 : 0; // ws } else if (length === 3) { const c1 = url.charCodeAt(schemeStart + 1) | 32; const c2 = url.charCodeAt(schemeStart + 2) | 32; if (c0 === 119 && c1 === 115 && c2 === 115) return 1; // wss if (c0 === 102 && c1 === 116 && c2 === 112) return 1; // ftp return 0; } else if (length === 4) { const c1 = url.charCodeAt(schemeStart + 1) | 32; const c2 = url.charCodeAt(schemeStart + 2) | 32; const c3 = url.charCodeAt(schemeStart + 3) | 32; if (c0 === 104 && c1 === 116 && c2 === 116 && c3 === 112) return 1; // http if (c0 === 102 && c1 === 105 && c2 === 108 && c3 === 101) return 2; // file return 0; } else if (length === 5) { return c0 === 104 && (url.charCodeAt(schemeStart + 1) | 32) === 116 && (url.charCodeAt(schemeStart + 2) | 32) === 116 && (url.charCodeAt(schemeStart + 3) | 32) === 112 && (url.charCodeAt(schemeStart + 4) | 32) === 115 ? 1 : 0; // https } return 0; } /** * Extract a hostname from `url`, matching a WHATWG URL parser's host-boundary * behaviour (https://url.spec.whatwg.org/#concept-basic-url-parser) for tldts' * scope. It deliberately does NOT normalise the host (no IDNA/punycode or IPv4 * canonicalisation; IPv6 brackets are stripped, not compressed), strips trailing * dots, and stays lenient where a strict parser rejects (bare host:port, * out-of-range port, user@host) — all documented deviations. * * @param urlIsValidHostname - when true, `url` is already a valid hostname and is * returned by the same reference (factory.ts skips re-validation on that * identity), keeping the common path allocation-free. * @param validate - when true, validate the host inline during the authority * scan and publish the verdict via `extractedHostnameValidated` so `parseImpl` * can skip the redundant `isValidHostname` pass for simple authorities. */ export default function extractHostname( url: string, urlIsValidHostname: boolean, validate = false, ): string | null { let start = 0; let end: number = url.length; let hasUpper = false; let isSpecial = false; extractedHostnameValidated = false; if (!urlIsValidHostname) { // Data URLs never carry a host (and may be huge — short-circuit them). if (url.startsWith('data:')) { return null; } // WHATWG step 1: trim leading/trailing C0 control or space (<= U+0020). // Tab/newline elsewhere are handled lazily below. while (start < url.length && url.charCodeAt(start) <= 32) { start += 1; } while (end > start + 1 && url.charCodeAt(end - 1) <= 32) { end -= 1; } if ( url.charCodeAt(start) === 47 /* '/' */ && url.charCodeAt(start + 1) === 47 /* '/' */ ) { // Scheme-relative reference ("//host/path"). start += 2; } else { const indexOfProtocol = url.indexOf(':/', start); if (indexOfProtocol !== -1) { // "scheme://…". Classify the scheme, then position `start` at the host. const special = getSpecialScheme(url, start, indexOfProtocol); if (special === 1) { // Special scheme: skip the run of '/' and '\' after it // (special-authority-(ignore-)slashes states; '\' acts as '/'). isSpecial = true; start = indexOfProtocol + 2; while ( url.charCodeAt(start) === 47 /* '/' */ || url.charCodeAt(start) === 92 /* '\' */ ) { start += 1; } } else if (special === 2) { // file: the host is only what sits between "//" and the next slash, so // "file://h/x" => "h" but "file:///x" / "file:/x" => no host. isSpecial = true; start = indexOfProtocol + 1; let slashes = 0; while ( (url.charCodeAt(start) === 47 || url.charCodeAt(start) === 92) && slashes < 2 ) { start += 1; slashes += 1; } if (slashes < 2) { return null; } } else { // Unknown scheme: validate the WHATWG scheme grammar [A-Za-z0-9+.-]; // a control char means it was split by a tab/newline (strip + re-parse). for (let i = start; i < indexOfProtocol; i += 1) { const code = url.charCodeAt(i) | 32; if ( !( ( (code >= 97 && code <= 122) || // [a, z] (code >= 48 && code <= 57) || // [0, 9] code === 46 || // '.' code === 45 || // '-' code === 43 ) // '+' ) ) { const raw = url.charCodeAt(i); if (raw === 9 || raw === 10 || raw === 13) { return extractHostname( url.replace(CONTROL_CHARS, ''), urlIsValidHostname, validate, ); } return null; } } // A non-special scheme has an authority only after "//" (else it is an // opaque path with no host). `indexOf(':/')` already gave the first '/'. if (url.charCodeAt(indexOfProtocol + 2) === 47 /* '/' */) { start = indexOfProtocol + 3; } else { return null; } } } else if (url.charCodeAt(start) !== 91 /* '[' */) { // Cold path: no scheme "://", and not a bare IPv6 literal (whose first // ':' would otherwise look like a scheme separator; "[…]" falls through // to the ipv6 handling below). May be a bare host, a host:port, a // user@host, a slash-less special scheme ("https:host"), or an opaque // URI ("mailto:", "tel:", "urn:…"). let indexOfColon = -1; for (let i = start; i < end; i += 1) { const code = url.charCodeAt(i); if (code === 9 || code === 10 || code === 13) { return extractHostname( url.replace(CONTROL_CHARS, ''), urlIsValidHostname, validate, ); } if (code === 58 /* ':' */) { indexOfColon = i; break; } if (code === 47 || code === 92 || code === 63 || code === 35) { break; } } if (indexOfColon !== -1) { // An '@' before the next delimiter => the ':' is userinfo, not a // scheme ("user:pass@host", "mailto:a@b"): keep the whole authority. let hasIdentifier = false; for (let i = indexOfColon + 1; i < end; i += 1) { const code = url.charCodeAt(i); if (code === 47 || code === 92 || code === 63 || code === 35) { break; } if (code === 64 /* '@' */) { hasIdentifier = true; break; } } if (!hasIdentifier) { // All-digits after ':' => a bare "host:port" (tldts accepts // hostnames too); keep `start` and let the port handling trim it. let allDigits = true; let i = indexOfColon + 1; for (; i < end; i += 1) { const code = url.charCodeAt(i); if (code === 47 || code === 92 || code === 63 || code === 35) { break; } if (code < 48 /* '0' */ || code > 57 /* '9' */) { allDigits = false; break; } } if (i === indexOfColon + 1) { allDigits = false; // nothing after ':' => not a port } if (!allDigits) { const special = getSpecialScheme(url, start, indexOfColon); if (special === 0) { // No "://" anywhere on the cold path and not a special scheme. // A second ':' before the host's end marks a bare, unbracketed // IPv6 literal ("2a01:e35::1"): fall through and let the host // loop + isIp classify it. Without one this is an opaque path // with no host ("mailto:x", "foo:bar"). let isBareIpv6 = false; for (let j = indexOfColon + 1; j < end; j += 1) { const code = url.charCodeAt(j); if ( code === 47 || code === 92 || code === 63 || code === 35 ) { break; } if (code === 58 /* ':' */) { isBareIpv6 = true; break; } } if (!isBareIpv6) { return null; } } else { isSpecial = true; start = indexOfColon + 1; if (special === 2) { // file (e.g. "file:\\host"): host only between "//" and next slash. let slashes = 0; while ( (url.charCodeAt(start) === 47 || url.charCodeAt(start) === 92) && slashes < 2 ) { start += 1; slashes += 1; } if (slashes < 2) { return null; } } else { while ( url.charCodeAt(start) === 47 || url.charCodeAt(start) === 92 ) { start += 1; } } } } } } } } // Find the host's end: first '/', '?' or '#' (and '\' for special URLs, // which WHATWG treats like '/'). Track the last '@', ']' and ':' for // userinfo, ipv6 and port, plus the first ':' of the host (reset at each // '@') to tell a bare IPv6 (>= 2 colons) from a host:port (exactly one); // flag uppercase and a stray tab/newline. The loop is split on `code < 64` // so common host characters take fewer comparisons. // // When `validate`, also accumulate `is-valid.ts`'s checks over the scanned // run so a simple authority's host can be validated in this single pass. // `vValid` only stays meaningful for a "simple" authority (no userinfo, port, // brackets, control or trailing dot); those cases clear it / are rejected by // the guard below, falling back to `isValidHostname`. let indexOfIdentifier = -1; let indexOfClosingBracket = -1; let indexOfPort = -1; let indexOfFirstColon = -1; let hasControl = false; let vValid = validate; // seeded true when validating; cleared on the first invalid char let vLastDot = start - 1; // mirrors is-valid.ts `lastDotIndex = -1` at host start let vLastCode = -1; if (validate && start < end) { // First-char rule: must be a valid host char, '.', or '_' (NOT '-'). const c0 = url.charCodeAt(start); if ( !( /*@__INLINE__*/ ( isValidHostnameChar(c0) || c0 === 46 /* '.' */ || c0 === 95 /* '_' */ ) ) || c0 === 45 /* '-' (isValidHostnameChar allows it mid-label, not first) */ ) { vValid = false; } } for (let i = start; i < end; i += 1) { const code: number = url.charCodeAt(i); if (code < 64) { if (code === 47 || code === 35 || code === 63) { end = i; break; } else if (code === 58 /* ':' */) { if (indexOfFirstColon === -1) { indexOfFirstColon = i; } indexOfPort = i; } else if (code === 9 || code === 10 || code === 13) { hasControl = true; } else if (validate) { if (code === 46 /* '.' */) { if (i - vLastDot > 64 || vLastCode === 46 || vLastCode === 45) { vValid = false; } vLastDot = i; } else if (code < 48 || code > 57) { // < 64 and not a delimiter/dot/digit => only '-' (45) is a valid // host char here; everything else (space, %, !, etc.) is invalid. // A '-' must also not START a label (the byte right after a '.') — // mirrors is-valid.ts; the first label is covered by the first-char // rule above. (RFC 1034 §3.5 / RFC 1035 §2.3.1 LDH.) if (code !== 45 || vLastCode === 46 /* label-leading '-' */) { vValid = false; } } } } else if (isSpecial && code === 92 /* '\' */) { end = i; break; } else if (code === 64 /* '@' */) { indexOfIdentifier = i; indexOfFirstColon = -1; // colons before '@' are userinfo, not the host } else if (code === 93 /* ']' */) { indexOfClosingBracket = i; } else if (code >= 65 && code <= 90) { hasUpper = true; } else if (validate && !(/*@__INLINE__*/ isValidHostnameChar(code))) { // >= 64, not '@'/']'/upper: valid only if a-z, '_', or non-ASCII. vValid = false; } if (validate) { vLastCode = code; } } // A tab/newline inside the authority: strip everything and re-parse (rare). if (hasControl) { return extractHostname( url.replace(CONTROL_CHARS, ''), urlIsValidHostname, validate, ); } // Skip userinfo. '>= start' so an empty userinfo ("http://@host") works too. if ( indexOfIdentifier !== -1 && indexOfIdentifier >= start && indexOfIdentifier < end ) { start = indexOfIdentifier + 1; } if (url.charCodeAt(start) === 91 /* '[' */) { // ipv6 address: return what is between the brackets, or null if unclosed. if (indexOfClosingBracket !== -1) { return url.slice(start + 1, indexOfClosingBracket).toLowerCase(); } return null; } else if ( indexOfPort !== -1 && indexOfPort > start && indexOfPort < end && // A host:port has exactly one ':' in the host (so its first ':' is its // last); a bare, unbracketed IPv6 literal ("2a01:e35::1") has >= 2, so // its first ':' precedes the last. Only the former has a ':port' to trim. indexOfFirstColon === indexOfPort ) { end = indexOfPort; // trim ':port' } // Empty authority ("http://", "file:///path", "//"); only reachable here via // extraction — a bare valid hostname never lands here. if (start >= end) { return null; } // Publish the inline-validation verdict — but only for a "simple" authority, // where the scanned run equals the final host: no userinfo skip, no port // trim, no brackets, no trailing dot (trimmed below), and length within RFC // limits. Anything else leaves it `false` so `parseImpl` re-validates. // // Every clause below is load-bearing for CORRECTNESS, not just speed: the // loop accumulates `vValid` over the whole scanned run (it does not stop at // ':' or '@', so any port/userinfo bytes are included), so the verdict is // only sound when that run equals the final host. Do not drop a clause as // "redundant" — e.g. without `indexOfPort === -1`, `host:8080` would be // wrongly accepted. if ( validate && vValid && indexOfIdentifier === -1 && indexOfPort === -1 && indexOfClosingBracket === -1 && url.charCodeAt(end - 1) !== 46 /* no trailing dot */ && end - start <= 255 && // total length end - vLastDot - 1 <= 63 && // last label length vLastCode !== 45 /* last char not '-' */ ) { extractedHostnameValidated = true; } } // Trim trailing dots while (end > start + 1 && url.charCodeAt(end - 1) === 46 /* '.' */) { end -= 1; } const hostname: string = start !== 0 || end !== url.length ? url.slice(start, end) : url; if (hasUpper) { return hostname.toLowerCase(); } return hostname; }