diff --git a/packages/taler-util/src/punycode.ts b/packages/taler-util/src/punycode.ts new file mode 100644 index 000000000..353e3bf25 --- /dev/null +++ b/packages/taler-util/src/punycode.ts @@ -0,0 +1,468 @@ +/* +Copyright Mathias Bynens +Copyright (c) 2022 Taler Systems S.A. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +/** Highest positive signed 32-bit float value */ +const maxInt = 2147483647; // aka. 0x7FFFFFFF or 2^31-1 + +/** Bootstring parameters */ +const base = 36; +const tMin = 1; +const tMax = 26; +const skew = 38; +const damp = 700; +const initialBias = 72; +const initialN = 128; // 0x80 +const delimiter = "-"; // '\x2D' + +/** Regular expressions */ +const regexPunycode = /^xn--/; +const regexNonASCII = /[^\0-\x7E]/; // non-ASCII chars +const regexSeparators = /[\x2E\u3002\uFF0E\uFF61]/g; // RFC 3490 separators + +/** Error messages */ +const errors = { + overflow: "Overflow: input needs wider integers to process", + "not-basic": "Illegal input >= 0x80 (not a basic code point)", + "invalid-input": "Invalid input", +} as { [x: string]: string }; + +/** Convenience shortcuts */ +const baseMinusTMin = base - tMin; +const floor = Math.floor; +const stringFromCharCode = String.fromCharCode; + +/*--------------------------------------------------------------------------*/ + +/** + * A generic error utility function. + * @private + * @param {String} type The error type. + * @returns {Error} Throws a `RangeError` with the applicable error message. + */ +function error(type: string) { + throw new RangeError(errors[type]); +} + +/** + * A generic `Array#map` utility function. + * @private + * @param {Array} array The array to iterate over. + * @param {Function} callback The function that gets called for every array + * item. + * @returns {Array} A new array of values returned by the callback function. + */ +function map(array: any[], fn: (arg0: any) => any) { + const result = []; + let length = array.length; + while (length--) { + result[length] = fn(array[length]); + } + return result; +} + +/** + * A simple `Array#map`-like wrapper to work with domain name strings or email + * addresses. + * @private + * @param {String} domain The domain name or email address. + * @param {Function} callback The function that gets called for every + * character. + * @returns {Array} A new string of characters returned by the callback + * function. + */ +function mapDomain( + string: string, + fn: { (string: any): any; (string: any): any; (arg0: any): any }, +) { + const parts = string.split("@"); + let result = ""; + if (parts.length > 1) { + // In email addresses, only the domain name should be punycoded. Leave + // the local part (i.e. everything up to `@`) intact. + result = parts[0] + "@"; + string = parts[1]; + } + // Avoid `split(regex)` for IE8 compatibility. See #17. + string = string.replace(regexSeparators, "\x2E"); + const labels = string.split("."); + const encoded = map(labels, fn).join("."); + return result + encoded; +} + +/** + * Creates an array containing the numeric code points of each Unicode + * character in the string. While JavaScript uses UCS-2 internally, + * this function will convert a pair of surrogate halves (each of which + * UCS-2 exposes as separate characters) into a single code point, + * matching UTF-16. + * @see `punycode.ucs2.encode` + * @see + * @memberOf punycode.ucs2 + * @name decode + * @param {String} string The Unicode input string (UCS-2). + * @returns {Array} The new array of code points. + */ +function ucs2decode(string: string) { + const output = []; + let counter = 0; + const length = string.length; + while (counter < length) { + const value = string.charCodeAt(counter++); + if (value >= 0xd800 && value <= 0xdbff && counter < length) { + // It's a high surrogate, and there is a next character. + const extra = string.charCodeAt(counter++); + if ((extra & 0xfc00) == 0xdc00) { + // Low surrogate. + output.push(((value & 0x3ff) << 10) + (extra & 0x3ff) + 0x10000); + } else { + // It's an unmatched surrogate; only append this code unit, in case the + // next code unit is the high surrogate of a surrogate pair. + output.push(value); + counter--; + } + } else { + output.push(value); + } + } + return output; +} + +/** + * Creates a string based on an array of numeric code points. + * @see `punycode.ucs2.decode` + * @memberOf punycode.ucs2 + * @name encode + * @param {Array} codePoints The array of numeric code points. + * @returns {String} The new Unicode string (UCS-2). + */ +const ucs2encode = (array: any): string => String.fromCodePoint(...array); + +/** + * Converts a basic code point into a digit/integer. + * @see `digitToBasic()` + * @private + * @param {Number} codePoint The basic numeric code point value. + * @returns {Number} The numeric value of a basic code point (for use in + * representing integers) in the range `0` to `base - 1`, or `base` if + * the code point does not represent a value. + */ +const basicToDigit = function (codePoint: number) { + if (codePoint - 0x30 < 0x0a) { + return codePoint - 0x16; + } + if (codePoint - 0x41 < 0x1a) { + return codePoint - 0x41; + } + if (codePoint - 0x61 < 0x1a) { + return codePoint - 0x61; + } + return base; +}; + +/** + * Converts a digit/integer into a basic code point. + * @see `basicToDigit()` + * @private + * @param {Number} digit The numeric value of a basic code point. + * @returns {Number} The basic code point whose value (when used for + * representing integers) is `digit`, which needs to be in the range + * `0` to `base - 1`. If `flag` is non-zero, the uppercase form is + * used; else, the lowercase form is used. The behavior is undefined + * if `flag` is non-zero and `digit` has no uppercase form. + */ +const digitToBasic = function (digit: number, flag: number) { + // 0..25 map to ASCII a..z or A..Z + // 26..35 map to ASCII 0..9 + return digit + 22 + 75 * Number(digit < 26) - (Number(flag != 0) << 5); +}; + +/** + * Bias adaptation function as per section 3.4 of RFC 3492. + * https://tools.ietf.org/html/rfc3492#section-3.4 + * @private + */ +const adapt = function (delta: number, numPoints: number, firstTime: boolean) { + let k = 0; + delta = firstTime ? floor(delta / damp) : delta >> 1; + delta += floor(delta / numPoints); + for ( + ; + /* no initialization */ delta > (baseMinusTMin * tMax) >> 1; + k += base + ) { + delta = floor(delta / baseMinusTMin); + } + return floor(k + ((baseMinusTMin + 1) * delta) / (delta + skew)); +}; + +/** + * Converts a Punycode string of ASCII-only symbols to a string of Unicode + * symbols. + * @memberOf punycode + * @param {String} input The Punycode string of ASCII-only symbols. + * @returns {String} The resulting string of Unicode symbols. + */ +const decode = function (input: string) { + // Don't use UCS-2. + const output = []; + const inputLength = input.length; + let i = 0; + let n = initialN; + let bias = initialBias; + + // Handle the basic code points: let `basic` be the number of input code + // points before the last delimiter, or `0` if there is none, then copy + // the first basic code points to the output. + + let basic = input.lastIndexOf(delimiter); + if (basic < 0) { + basic = 0; + } + + for (let j = 0; j < basic; ++j) { + // if it's not a basic code point + if (input.charCodeAt(j) >= 0x80) { + error("not-basic"); + } + output.push(input.charCodeAt(j)); + } + + // Main decoding loop: start just after the last delimiter if any basic code + // points were copied; start at the beginning otherwise. + + for ( + let index = basic > 0 ? basic + 1 : 0; + index < inputLength /* no final expression */; + + ) { + // `index` is the index of the next character to be consumed. + // Decode a generalized variable-length integer into `delta`, + // which gets added to `i`. The overflow checking is easier + // if we increase `i` as we go, then subtract off its starting + // value at the end to obtain `delta`. + let oldi = i; + for (let w = 1, k = base /* no condition */; ; k += base) { + if (index >= inputLength) { + error("invalid-input"); + } + + const digit = basicToDigit(input.charCodeAt(index++)); + + if (digit >= base || digit > floor((maxInt - i) / w)) { + error("overflow"); + } + + i += digit * w; + const t = k <= bias ? tMin : k >= bias + tMax ? tMax : k - bias; + + if (digit < t) { + break; + } + + const baseMinusT = base - t; + if (w > floor(maxInt / baseMinusT)) { + error("overflow"); + } + + w *= baseMinusT; + } + + const out = output.length + 1; + bias = adapt(i - oldi, out, oldi == 0); + + // `i` was supposed to wrap around from `out` to `0`, + // incrementing `n` each time, so we'll fix that now: + if (floor(i / out) > maxInt - n) { + error("overflow"); + } + + n += floor(i / out); + i %= out; + + // Insert `n` at position `i` of the output. + output.splice(i++, 0, n); + } + + return String.fromCodePoint(...output); +}; + +/** + * Converts a string of Unicode symbols (e.g. a domain name label) to a + * Punycode string of ASCII-only symbols. + * @memberOf punycode + * @param {String} input The string of Unicode symbols. + * @returns {String} The resulting Punycode string of ASCII-only symbols. + */ +const encode = function (inputArg: string) { + const output = []; + + // Convert the input in UCS-2 to an array of Unicode code points. + let input = ucs2decode(inputArg); + + // Cache the length. + let inputLength = input.length; + + // Initialize the state. + let n = initialN; + let delta = 0; + let bias = initialBias; + + // Handle the basic code points. + for (const currentValue of input) { + if (currentValue < 0x80) { + output.push(stringFromCharCode(currentValue)); + } + } + + let basicLength = output.length; + let handledCPCount = basicLength; + + // `handledCPCount` is the number of code points that have been handled; + // `basicLength` is the number of basic code points. + + // Finish the basic string with a delimiter unless it's empty. + if (basicLength) { + output.push(delimiter); + } + + // Main encoding loop: + while (handledCPCount < inputLength) { + // All non-basic code points < n have been handled already. Find the next + // larger one: + let m = maxInt; + for (const currentValue of input) { + if (currentValue >= n && currentValue < m) { + m = currentValue; + } + } + + // Increase `delta` enough to advance the decoder's state to , + // but guard against overflow. + const handledCPCountPlusOne = handledCPCount + 1; + if (m - n > floor((maxInt - delta) / handledCPCountPlusOne)) { + error("overflow"); + } + + delta += (m - n) * handledCPCountPlusOne; + n = m; + + for (const currentValue of input) { + if (currentValue < n && ++delta > maxInt) { + error("overflow"); + } + if (currentValue == n) { + // Represent delta as a generalized variable-length integer. + let q = delta; + for (let k = base /* no condition */; ; k += base) { + const t = k <= bias ? tMin : k >= bias + tMax ? tMax : k - bias; + if (q < t) { + break; + } + const qMinusT = q - t; + const baseMinusT = base - t; + output.push( + stringFromCharCode(digitToBasic(t + (qMinusT % baseMinusT), 0)), + ); + q = floor(qMinusT / baseMinusT); + } + + output.push(stringFromCharCode(digitToBasic(q, 0))); + bias = adapt( + delta, + handledCPCountPlusOne, + handledCPCount == basicLength, + ); + delta = 0; + ++handledCPCount; + } + } + + ++delta; + ++n; + } + return output.join(""); +}; + +/** + * Converts a Punycode string representing a domain name or an email address + * to Unicode. Only the Punycoded parts of the input will be converted, i.e. + * it doesn't matter if you call it on a string that has already been + * converted to Unicode. + * @memberOf punycode + * @param {String} input The Punycoded domain name or email address to + * convert to Unicode. + * @returns {String} The Unicode representation of the given Punycode + * string. + */ +const toUnicode = function (input: string) { + return mapDomain(input, function (string) { + return regexPunycode.test(string) + ? decode(string.slice(4).toLowerCase()) + : string; + }); +}; + +/** + * Converts a Unicode string representing a domain name or an email address to + * Punycode. Only the non-ASCII parts of the domain name will be converted, + * i.e. it doesn't matter if you call it with a domain that's already in + * ASCII. + * @memberOf punycode + * @param {String} input The domain name or email address to convert, as a + * Unicode string. + * @returns {String} The Punycode representation of the given domain name or + * email address. + */ +const toASCII = function (input: string) { + return mapDomain(input, function (string) { + return regexNonASCII.test(string) ? "xn--" + encode(string) : string; + }); +}; + +/*--------------------------------------------------------------------------*/ + +/** Define the public API */ +export const punycode = { + /** + * A string representing the current Punycode.js version number. + * @memberOf punycode + * @type String + */ + version: "2.1.0", + /** + * An object of methods to convert from JavaScript's internal character + * representation (UCS-2) to Unicode code points, and back. + * @see + * @memberOf punycode + * @type Object + */ + ucs2: { + decode: ucs2decode, + encode: ucs2encode, + }, + decode: decode, + encode: encode, + toASCII: toASCII, + toUnicode: toUnicode, +}; \ No newline at end of file diff --git a/packages/taler-util/src/url.ts b/packages/taler-util/src/url.ts index 7c5298ea0..245c4f8f7 100644 --- a/packages/taler-util/src/url.ts +++ b/packages/taler-util/src/url.ts @@ -14,6 +14,8 @@ GNU Taler; see the file COPYING. If not, see */ +import { URLImpl, URLSearchParamsImpl } from "./whatwg-url.js"; + interface URL { hash: string; host: string; @@ -83,7 +85,8 @@ export interface URLCtor { // @ts-ignore const _URL = globalThis.URL; if (!_URL) { - throw Error("FATAL: URL not available"); + // @ts-ignore + globalThis.URL = URLImpl; } export const URL: URLCtor = _URL; @@ -92,7 +95,8 @@ export const URL: URLCtor = _URL; const _URLSearchParams = globalThis.URLSearchParams; if (!_URLSearchParams) { - throw Error("FATAL: URLSearchParams not available"); + // @ts-ignore + globalThis.URL = URLSearchParamsImpl; } export const URLSearchParams: URLSearchParamsCtor = _URLSearchParams; diff --git a/packages/taler-util/src/whatwg-url.ts b/packages/taler-util/src/whatwg-url.ts new file mode 100644 index 000000000..a0fe55d8f --- /dev/null +++ b/packages/taler-util/src/whatwg-url.ts @@ -0,0 +1,2107 @@ +/* +The MIT License (MIT) + +Copyright (c) Sebastian Mayr +Copyright (c) 2022 Taler Systems S.A. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +// Vendored with modifications (TypeScript etc.) from https://github.com/jsdom/whatwg-url + +const utf8Encoder = new TextEncoder(); +const utf8Decoder = new TextDecoder("utf-8", { ignoreBOM: true }); + +function utf8Encode(string: string | undefined) { + return utf8Encoder.encode(string); +} + +function utf8DecodeWithoutBOM( + bytes: DataView | ArrayBuffer | null | undefined, +) { + return utf8Decoder.decode(bytes); +} + +// https://url.spec.whatwg.org/#concept-urlencoded-parser +function parseUrlencoded(input: Uint8Array) { + const sequences = strictlySplitByteSequence(input, p("&")); + const output = []; + for (const bytes of sequences) { + if (bytes.length === 0) { + continue; + } + + let name, value; + const indexOfEqual = bytes.indexOf(p("=")!); + + if (indexOfEqual >= 0) { + name = bytes.slice(0, indexOfEqual); + value = bytes.slice(indexOfEqual + 1); + } else { + name = bytes; + value = new Uint8Array(0); + } + + name = replaceByteInByteSequence(name, 0x2b, 0x20); + value = replaceByteInByteSequence(value, 0x2b, 0x20); + + const nameString = utf8DecodeWithoutBOM(percentDecodeBytes(name)); + const valueString = utf8DecodeWithoutBOM(percentDecodeBytes(value)); + + output.push([nameString, valueString]); + } + return output; +} + +// https://url.spec.whatwg.org/#concept-urlencoded-string-parser +function parseUrlencodedString(input: string | undefined) { + return parseUrlencoded(utf8Encode(input)); +} + +// https://url.spec.whatwg.org/#concept-urlencoded-serializer +function serializeUrlencoded(tuples: any[], encodingOverride = undefined) { + let encoding = "utf-8"; + if (encodingOverride !== undefined) { + // TODO "get the output encoding", i.e. handle encoding labels vs. names. + encoding = encodingOverride; + } + + let output = ""; + for (const [i, tuple] of tuples.entries()) { + // TODO: handle encoding override + + const name = utf8PercentEncodeString( + tuple[0], + isURLEncodedPercentEncode, + true, + ); + + let value = tuple[1]; + if (tuple.length > 2 && tuple[2] !== undefined) { + if (tuple[2] === "hidden" && name === "_charset_") { + value = encoding; + } else if (tuple[2] === "file") { + // value is a File object + value = value.name; + } + } + + value = utf8PercentEncodeString(value, isURLEncodedPercentEncode, true); + + if (i !== 0) { + output += "&"; + } + output += `${name}=${value}`; + } + return output; +} + +function strictlySplitByteSequence(buf: Uint8Array, cp: any) { + const list = []; + let last = 0; + let i = buf.indexOf(cp); + while (i >= 0) { + list.push(buf.slice(last, i)); + last = i + 1; + i = buf.indexOf(cp, last); + } + if (last !== buf.length) { + list.push(buf.slice(last)); + } + return list; +} + +function replaceByteInByteSequence(buf: Uint8Array, from: number, to: number) { + let i = buf.indexOf(from); + while (i >= 0) { + buf[i] = to; + i = buf.indexOf(from, i + 1); + } + return buf; +} + +function p(char: string) { + return char.codePointAt(0); +} + +// https://url.spec.whatwg.org/#percent-encode +function percentEncode(c: number) { + let hex = c.toString(16).toUpperCase(); + if (hex.length === 1) { + hex = `0${hex}`; + } + + return `%${hex}`; +} + +// https://url.spec.whatwg.org/#percent-decode +function percentDecodeBytes(input: Uint8Array) { + const output = new Uint8Array(input.byteLength); + let outputIndex = 0; + for (let i = 0; i < input.byteLength; ++i) { + const byte = input[i]; + if (byte !== 0x25) { + output[outputIndex++] = byte; + } else if ( + byte === 0x25 && + (!isASCIIHex(input[i + 1]) || !isASCIIHex(input[i + 2])) + ) { + output[outputIndex++] = byte; + } else { + const bytePoint = parseInt( + String.fromCodePoint(input[i + 1], input[i + 2]), + 16, + ); + output[outputIndex++] = bytePoint; + i += 2; + } + } + + return output.slice(0, outputIndex); +} + +// https://url.spec.whatwg.org/#string-percent-decode +function percentDecodeString(input: string) { + const bytes = utf8Encode(input); + return percentDecodeBytes(bytes); +} + +// https://url.spec.whatwg.org/#c0-control-percent-encode-set +function isC0ControlPercentEncode(c: number) { + return c <= 0x1f || c > 0x7e; +} + +// https://url.spec.whatwg.org/#fragment-percent-encode-set +const extraFragmentPercentEncodeSet = new Set([ + p(" "), + p('"'), + p("<"), + p(">"), + p("`"), +]); + +function isFragmentPercentEncode(c: number) { + return isC0ControlPercentEncode(c) || extraFragmentPercentEncodeSet.has(c); +} + +// https://url.spec.whatwg.org/#query-percent-encode-set +const extraQueryPercentEncodeSet = new Set([ + p(" "), + p('"'), + p("#"), + p("<"), + p(">"), +]); + +function isQueryPercentEncode(c: number) { + return isC0ControlPercentEncode(c) || extraQueryPercentEncodeSet.has(c); +} + +// https://url.spec.whatwg.org/#special-query-percent-encode-set +function isSpecialQueryPercentEncode(c: number) { + return isQueryPercentEncode(c) || c === p("'"); +} + +// https://url.spec.whatwg.org/#path-percent-encode-set +const extraPathPercentEncodeSet = new Set([p("?"), p("`"), p("{"), p("}")]); +function isPathPercentEncode(c: number) { + return isQueryPercentEncode(c) || extraPathPercentEncodeSet.has(c); +} + +// https://url.spec.whatwg.org/#userinfo-percent-encode-set +const extraUserinfoPercentEncodeSet = new Set([ + p("/"), + p(":"), + p(";"), + p("="), + p("@"), + p("["), + p("\\"), + p("]"), + p("^"), + p("|"), +]); +function isUserinfoPercentEncode(c: number) { + return isPathPercentEncode(c) || extraUserinfoPercentEncodeSet.has(c); +} + +// https://url.spec.whatwg.org/#component-percent-encode-set +const extraComponentPercentEncodeSet = new Set([ + p("$"), + p("%"), + p("&"), + p("+"), + p(","), +]); +function isComponentPercentEncode(c: number) { + return isUserinfoPercentEncode(c) || extraComponentPercentEncodeSet.has(c); +} + +// https://url.spec.whatwg.org/#application-x-www-form-urlencoded-percent-encode-set +const extraURLEncodedPercentEncodeSet = new Set([ + p("!"), + p("'"), + p("("), + p(")"), + p("~"), +]); + +function isURLEncodedPercentEncode(c: number) { + return isComponentPercentEncode(c) || extraURLEncodedPercentEncodeSet.has(c); +} + +// https://url.spec.whatwg.org/#code-point-percent-encode-after-encoding +// https://url.spec.whatwg.org/#utf-8-percent-encode +// Assuming encoding is always utf-8 allows us to trim one of the logic branches. TODO: support encoding. +// The "-Internal" variant here has code points as JS strings. The external version used by other files has code points +// as JS numbers, like the rest of the codebase. +function utf8PercentEncodeCodePointInternal( + codePoint: string, + percentEncodePredicate: (arg0: number) => any, +) { + const bytes = utf8Encode(codePoint); + let output = ""; + for (const byte of bytes) { + // Our percentEncodePredicate operates on bytes, not code points, so this is slightly different from the spec. + if (!percentEncodePredicate(byte)) { + output += String.fromCharCode(byte); + } else { + output += percentEncode(byte); + } + } + + return output; +} + +function utf8PercentEncodeCodePoint( + codePoint: number, + percentEncodePredicate: (arg0: number) => any, +) { + return utf8PercentEncodeCodePointInternal( + String.fromCodePoint(codePoint), + percentEncodePredicate, + ); +} + +// https://url.spec.whatwg.org/#string-percent-encode-after-encoding +// https://url.spec.whatwg.org/#string-utf-8-percent-encode +function utf8PercentEncodeString( + input: string, + percentEncodePredicate: { + (c: number): boolean; + (c: number): boolean; + (arg0: number): any; + }, + spaceAsPlus = false, +) { + let output = ""; + for (const codePoint of input) { + if (spaceAsPlus && codePoint === " ") { + output += "+"; + } else { + output += utf8PercentEncodeCodePointInternal( + codePoint, + percentEncodePredicate, + ); + } + } + return output; +} + +// Note that we take code points as JS numbers, not JS strings. + +function isASCIIDigit(c: number) { + return c >= 0x30 && c <= 0x39; +} + +function isASCIIAlpha(c: number) { + return (c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a); +} + +function isASCIIAlphanumeric(c: number) { + return isASCIIAlpha(c) || isASCIIDigit(c); +} + +function isASCIIHex(c: number) { + return ( + isASCIIDigit(c) || (c >= 0x41 && c <= 0x46) || (c >= 0x61 && c <= 0x66) + ); +} + +export class URLSearchParamsImpl { + _list: any[]; + _url: any; + constructor(constructorArgs: any[], { doNotStripQMark = false }: any) { + let init = constructorArgs[0]; + this._list = []; + this._url = null; + + if (!doNotStripQMark && typeof init === "string" && init[0] === "?") { + init = init.slice(1); + } + + if (Array.isArray(init)) { + for (const pair of init) { + if (pair.length !== 2) { + throw new TypeError( + "Failed to construct 'URLSearchParams': parameter 1 sequence's element does not " + + "contain exactly two elements.", + ); + } + this._list.push([pair[0], pair[1]]); + } + } else if ( + typeof init === "object" && + Object.getPrototypeOf(init) === null + ) { + for (const name of Object.keys(init)) { + const value = init[name]; + this._list.push([name, value]); + } + } else { + this._list = parseUrlencodedString(init); + } + } + + _updateSteps() { + if (this._url !== null) { + let query: string | null = serializeUrlencoded(this._list); + if (query === "") { + query = null; + } + this._url._url.query = query; + } + } + + append(name: string, value: string) { + this._list.push([name, value]); + this._updateSteps(); + } + + delete(name: string) { + let i = 0; + while (i < this._list.length) { + if (this._list[i][0] === name) { + this._list.splice(i, 1); + } else { + i++; + } + } + this._updateSteps(); + } + + get(name: string) { + for (const tuple of this._list) { + if (tuple[0] === name) { + return tuple[1]; + } + } + return null; + } + + getAll(name: string) { + const output = []; + for (const tuple of this._list) { + if (tuple[0] === name) { + output.push(tuple[1]); + } + } + return output; + } + + has(name: string) { + for (const tuple of this._list) { + if (tuple[0] === name) { + return true; + } + } + return false; + } + + set(name: string, value: string) { + let found = false; + let i = 0; + while (i < this._list.length) { + if (this._list[i][0] === name) { + if (found) { + this._list.splice(i, 1); + } else { + found = true; + this._list[i][1] = value; + i++; + } + } else { + i++; + } + } + if (!found) { + this._list.push([name, value]); + } + this._updateSteps(); + } + + sort() { + this._list.sort((a, b) => { + if (a[0] < b[0]) { + return -1; + } + if (a[0] > b[0]) { + return 1; + } + return 0; + }); + + this._updateSteps(); + } + + [Symbol.iterator]() { + return this._list[Symbol.iterator](); + } + + toString() { + return serializeUrlencoded(this._list); + } +} + +const specialSchemes = { + ftp: 21, + file: null, + http: 80, + https: 443, + ws: 80, + wss: 443, +} as { [x: string]: number | null }; + +const failure = Symbol("failure"); + +function countSymbols(str: any) { + return [...str].length; +} + +function at(input: any, idx: any) { + const c = input[idx]; + return isNaN(c) ? undefined : String.fromCodePoint(c); +} + +function isSingleDot(buffer: string) { + return buffer === "." || buffer.toLowerCase() === "%2e"; +} + +function isDoubleDot(buffer: string) { + buffer = buffer.toLowerCase(); + return ( + buffer === ".." || + buffer === "%2e." || + buffer === ".%2e" || + buffer === "%2e%2e" + ); +} + +function isWindowsDriveLetterCodePoints(cp1: number, cp2: number) { + return isASCIIAlpha(cp1) && (cp2 === p(":") || cp2 === p("|")); +} + +function isWindowsDriveLetterString(string: string) { + return ( + string.length === 2 && + isASCIIAlpha(string.codePointAt(0)!) && + (string[1] === ":" || string[1] === "|") + ); +} + +function isNormalizedWindowsDriveLetterString(string: string) { + return ( + string.length === 2 && + isASCIIAlpha(string.codePointAt(0)!) && + string[1] === ":" + ); +} + +function containsForbiddenHostCodePoint(string: string) { + return ( + string.search( + /\u0000|\u0009|\u000A|\u000D|\u0020|#|\/|:|<|>|\?|@|\[|\\|\]|\^|\|/u, + ) !== -1 + ); +} + +function containsForbiddenDomainCodePoint(string: string) { + return ( + containsForbiddenHostCodePoint(string) || + string.search(/[\u0000-\u001F]|%|\u007F/u) !== -1 + ); +} + +function isSpecialScheme(scheme: string) { + return specialSchemes[scheme] !== undefined; +} + +function isSpecial(url: any) { + return isSpecialScheme(url.scheme); +} + +function isNotSpecial(url: UrlObj) { + return !isSpecialScheme(url.scheme); +} + +function defaultPort(scheme: string) { + return specialSchemes[scheme]; +} + +function parseIPv4Number(input: string) { + if (input === "") { + return failure; + } + + let R = 10; + + if ( + input.length >= 2 && + input.charAt(0) === "0" && + input.charAt(1).toLowerCase() === "x" + ) { + input = input.substring(2); + R = 16; + } else if (input.length >= 2 && input.charAt(0) === "0") { + input = input.substring(1); + R = 8; + } + + if (input === "") { + return 0; + } + + let regex = /[^0-7]/u; + if (R === 10) { + regex = /[^0-9]/u; + } + if (R === 16) { + regex = /[^0-9A-Fa-f]/u; + } + + if (regex.test(input)) { + return failure; + } + + return parseInt(input, R); +} + +function parseIPv4(input: string) { + const parts = input.split("."); + if (parts[parts.length - 1] === "") { + if (parts.length > 1) { + parts.pop(); + } + } + + if (parts.length > 4) { + return failure; + } + + const numbers = []; + for (const part of parts) { + const n = parseIPv4Number(part); + if (n === failure) { + return failure; + } + + numbers.push(n); + } + + for (let i = 0; i < numbers.length - 1; ++i) { + if (numbers[i] > 255) { + return failure; + } + } + if (numbers[numbers.length - 1] >= 256 ** (5 - numbers.length)) { + return failure; + } + + let ipv4 = numbers.pop(); + let counter = 0; + + for (const n of numbers) { + ipv4! += n * 256 ** (3 - counter); + ++counter; + } + + return ipv4; +} + +function serializeIPv4(address: number) { + let output = ""; + let n = address; + + for (let i = 1; i <= 4; ++i) { + output = String(n % 256) + output; + if (i !== 4) { + output = `.${output}`; + } + n = Math.floor(n / 256); + } + + return output; +} + +function parseIPv6(inputArg: string) { + const address = [0, 0, 0, 0, 0, 0, 0, 0]; + let pieceIndex = 0; + let compress = null; + let pointer = 0; + + const input = Array.from(inputArg, (c) => c.codePointAt(0)); + + if (input[pointer] === p(":")) { + if (input[pointer + 1] !== p(":")) { + return failure; + } + + pointer += 2; + ++pieceIndex; + compress = pieceIndex; + } + + while (pointer < input.length) { + if (pieceIndex === 8) { + return failure; + } + + if (input[pointer] === p(":")) { + if (compress !== null) { + return failure; + } + ++pointer; + ++pieceIndex; + compress = pieceIndex; + continue; + } + + let value = 0; + let length = 0; + + while (length < 4 && isASCIIHex(input[pointer]!)) { + value = value * 0x10 + parseInt(at(input, pointer)!, 16); + ++pointer; + ++length; + } + + if (input[pointer] === p(".")) { + if (length === 0) { + return failure; + } + + pointer -= length; + + if (pieceIndex > 6) { + return failure; + } + + let numbersSeen = 0; + + while (input[pointer] !== undefined) { + let ipv4Piece = null; + + if (numbersSeen > 0) { + if (input[pointer] === p(".") && numbersSeen < 4) { + ++pointer; + } else { + return failure; + } + } + + if (!isASCIIDigit(input[pointer]!)) { + return failure; + } + + while (isASCIIDigit(input[pointer]!)) { + const number = parseInt(at(input, pointer)!); + if (ipv4Piece === null) { + ipv4Piece = number; + } else if (ipv4Piece === 0) { + return failure; + } else { + ipv4Piece = ipv4Piece * 10 + number; + } + if (ipv4Piece > 255) { + return failure; + } + ++pointer; + } + + address[pieceIndex] = address[pieceIndex] * 0x100 + ipv4Piece!; + + ++numbersSeen; + + if (numbersSeen === 2 || numbersSeen === 4) { + ++pieceIndex; + } + } + + if (numbersSeen !== 4) { + return failure; + } + + break; + } else if (input[pointer] === p(":")) { + ++pointer; + if (input[pointer] === undefined) { + return failure; + } + } else if (input[pointer] !== undefined) { + return failure; + } + + address[pieceIndex] = value; + ++pieceIndex; + } + + if (compress !== null) { + let swaps = pieceIndex - compress; + pieceIndex = 7; + while (pieceIndex !== 0 && swaps > 0) { + const temp = address[compress + swaps - 1]; + address[compress + swaps - 1] = address[pieceIndex]; + address[pieceIndex] = temp; + --pieceIndex; + --swaps; + } + } else if (compress === null && pieceIndex !== 8) { + return failure; + } + + return address; +} + +function serializeIPv6(address: any[]) { + let output = ""; + const compress = findLongestZeroSequence(address); + let ignore0 = false; + + for (let pieceIndex = 0; pieceIndex <= 7; ++pieceIndex) { + if (ignore0 && address[pieceIndex] === 0) { + continue; + } else if (ignore0) { + ignore0 = false; + } + + if (compress === pieceIndex) { + const separator = pieceIndex === 0 ? "::" : ":"; + output += separator; + ignore0 = true; + continue; + } + + output += address[pieceIndex].toString(16); + + if (pieceIndex !== 7) { + output += ":"; + } + } + + return output; +} + +function parseHost(input: string, isNotSpecialArg = false) { + if (input[0] === "[") { + if (input[input.length - 1] !== "]") { + return failure; + } + + return parseIPv6(input.substring(1, input.length - 1)); + } + + if (isNotSpecialArg) { + return parseOpaqueHost(input); + } + + const domain = utf8DecodeWithoutBOM(percentDecodeString(input)); + const asciiDomain = domainToASCII(domain); + if (asciiDomain === failure) { + return failure; + } + + if (containsForbiddenDomainCodePoint(asciiDomain)) { + return failure; + } + + if (endsInANumber(asciiDomain)) { + return parseIPv4(asciiDomain); + } + + return asciiDomain; +} + +function endsInANumber(input: string) { + const parts = input.split("."); + if (parts[parts.length - 1] === "") { + if (parts.length === 1) { + return false; + } + parts.pop(); + } + + const last = parts[parts.length - 1]; + if (parseIPv4Number(last) !== failure) { + return true; + } + + if (/^[0-9]+$/u.test(last)) { + return true; + } + + return false; +} + +function parseOpaqueHost(input: string) { + if (containsForbiddenHostCodePoint(input)) { + return failure; + } + + return utf8PercentEncodeString(input, isC0ControlPercentEncode); +} + +function findLongestZeroSequence(arr: number[]) { + let maxIdx = null; + let maxLen = 1; // only find elements > 1 + let currStart = null; + let currLen = 0; + + for (let i = 0; i < arr.length; ++i) { + if (arr[i] !== 0) { + if (currLen > maxLen) { + maxIdx = currStart; + maxLen = currLen; + } + + currStart = null; + currLen = 0; + } else { + if (currStart === null) { + currStart = i; + } + ++currLen; + } + } + + // if trailing zeros + if (currLen > maxLen) { + return currStart; + } + + return maxIdx; +} + +function serializeHost(host: number | number[] | string) { + if (typeof host === "number") { + return serializeIPv4(host); + } + + // IPv6 serializer + if (host instanceof Array) { + return `[${serializeIPv6(host)}]`; + } + + return host; +} + +import { punycode } from "./punycode.js"; + +function domainToASCII(domain: string, beStrict = false) { + // const result = tr46.toASCII(domain, { + // checkBidi: true, + // checkHyphens: false, + // checkJoiners: true, + // useSTD3ASCIIRules: beStrict, + // verifyDNSLength: beStrict, + // }); + let result; + try { + result = punycode.toASCII(domain); + } catch (e) { + return failure; + } + if (result === null || result === "") { + return failure; + } + return result; +} + +function trimControlChars(url: string) { + return url.replace(/^[\u0000-\u001F\u0020]+|[\u0000-\u001F\u0020]+$/gu, ""); +} + +function trimTabAndNewline(url: string) { + return url.replace(/\u0009|\u000A|\u000D/gu, ""); +} + +function shortenPath(url: UrlObj) { + const { path } = url; + if (path.length === 0) { + return; + } + if ( + url.scheme === "file" && + path.length === 1 && + isNormalizedWindowsDriveLetter(path[0]) + ) { + return; + } + + path.pop(); +} + +function includesCredentials(url: UrlObj) { + return url.username !== "" || url.password !== ""; +} + +function cannotHaveAUsernamePasswordPort(url: UrlObj) { + return url.host === null || url.host === "" || url.scheme === "file"; +} + +function hasAnOpaquePath(url: UrlObj) { + return typeof url.path === "string"; +} + +function isNormalizedWindowsDriveLetter(string: string) { + return /^[A-Za-z]:$/u.test(string); +} + +export interface UrlObj { + scheme: string; + username: string; + password: string; + host: string | number[] | number | null | undefined; + port: number | null; + path: string[]; + query: any; + fragment: any; +} + +class URLStateMachine { + pointer: number; + input: number[]; + base: any; + encodingOverride: string; + url: UrlObj; + state: string; + stateOverride: string; + failure: boolean; + parseError: boolean; + buffer: string; + atFlag: boolean; + arrFlag: boolean; + passwordTokenSeenFlag: boolean; + + constructor( + input: string, + base: any, + encodingOverride: string, + url: UrlObj, + stateOverride: string, + ) { + this.pointer = 0; + this.base = base || null; + this.encodingOverride = encodingOverride || "utf-8"; + this.url = url; + this.failure = false; + this.parseError = false; + + if (!this.url) { + this.url = { + scheme: "", + username: "", + password: "", + host: null, + port: null, + path: [], + query: null, + fragment: null, + }; + + const res = trimControlChars(input); + if (res !== input) { + this.parseError = true; + } + input = res; + } + + const res = trimTabAndNewline(input); + if (res !== input) { + this.parseError = true; + } + input = res; + + this.state = stateOverride || "scheme start"; + + this.buffer = ""; + this.atFlag = false; + this.arrFlag = false; + this.passwordTokenSeenFlag = false; + + this.input = Array.from(input, (c) => c.codePointAt(0)!); + + for (; this.pointer <= this.input.length; ++this.pointer) { + const c = this.input[this.pointer]; + const cStr = isNaN(c) ? undefined : String.fromCodePoint(c); + + // exec state machine + const ret = this.table[`parse ${this.state}`].call(this, c, cStr!); + if (!ret) { + break; // terminate algorithm + } else if (ret === failure) { + this.failure = true; + break; + } + } + } + + table = { + "parse scheme start": this.parseSchemeStart, + "parse scheme": this.parseScheme, + "parse no scheme": this.parseNoScheme, + "parse special relative or authority": this.parseSpecialRelativeOrAuthority, + "parse path or authority": this.parsePathOrAuthority, + "parse relative": this.parseRelative, + "parse relative slash": this.parseRelativeSlash, + "parse special authority slashes": this.parseSpecialAuthoritySlashes, + "parse special authority ignore slashes": + this.parseSpecialAuthorityIgnoreSlashes, + "parse authority": this.parseAuthority, + "parse host": this.parseHostName, + "parse hostname": this.parseHostName /* intentional duplication */, + "parse port": this.parsePort, + "parse file": this.parseFile, + "parse file slash": this.parseFileSlash, + "parse file host": this.parseFileHost, + "parse path start": this.parsePathStart, + "parse path": this.parsePath, + "parse opaque path": this.parseOpaquePath, + "parse query": this.parseQuery, + "parse fragment": this.parseFragment, + } as { [x: string]: (c: number, cStr: string) => any }; + + parseSchemeStart(c: number, cStr: string) { + if (isASCIIAlpha(c)) { + this.buffer += cStr.toLowerCase(); + this.state = "scheme"; + } else if (!this.stateOverride) { + this.state = "no scheme"; + --this.pointer; + } else { + this.parseError = true; + return failure; + } + + return true; + } + + parseScheme(c: number, cStr: string) { + if ( + isASCIIAlphanumeric(c) || + c === p("+") || + c === p("-") || + c === p(".") + ) { + this.buffer += cStr.toLowerCase(); + } else if (c === p(":")) { + if (this.stateOverride) { + if (isSpecial(this.url) && !isSpecialScheme(this.buffer)) { + return false; + } + + if (!isSpecial(this.url) && isSpecialScheme(this.buffer)) { + return false; + } + + if ( + (includesCredentials(this.url) || this.url.port !== null) && + this.buffer === "file" + ) { + return false; + } + + if (this.url.scheme === "file" && this.url.host === "") { + return false; + } + } + this.url.scheme = this.buffer; + if (this.stateOverride) { + if (this.url.port === defaultPort(this.url.scheme)) { + this.url.port = null; + } + return false; + } + this.buffer = ""; + if (this.url.scheme === "file") { + if ( + this.input[this.pointer + 1] !== p("/") || + this.input[this.pointer + 2] !== p("/") + ) { + this.parseError = true; + } + this.state = "file"; + } else if ( + isSpecial(this.url) && + this.base !== null && + this.base.scheme === this.url.scheme + ) { + this.state = "special relative or authority"; + } else if (isSpecial(this.url)) { + this.state = "special authority slashes"; + } else if (this.input[this.pointer + 1] === p("/")) { + this.state = "path or authority"; + ++this.pointer; + } else { + this.url.path = [""]; + this.state = "opaque path"; + } + } else if (!this.stateOverride) { + this.buffer = ""; + this.state = "no scheme"; + this.pointer = -1; + } else { + this.parseError = true; + return failure; + } + + return true; + } + + parseNoScheme(c: number) { + if (this.base === null || (hasAnOpaquePath(this.base) && c !== p("#"))) { + return failure; + } else if (hasAnOpaquePath(this.base) && c === p("#")) { + this.url.scheme = this.base.scheme; + this.url.path = this.base.path; + this.url.query = this.base.query; + this.url.fragment = ""; + this.state = "fragment"; + } else if (this.base.scheme === "file") { + this.state = "file"; + --this.pointer; + } else { + this.state = "relative"; + --this.pointer; + } + + return true; + } + + parseSpecialRelativeOrAuthority(c: number) { + if (c === p("/") && this.input[this.pointer + 1] === p("/")) { + this.state = "special authority ignore slashes"; + ++this.pointer; + } else { + this.parseError = true; + this.state = "relative"; + --this.pointer; + } + + return true; + } + + parsePathOrAuthority(c: number) { + if (c === p("/")) { + this.state = "authority"; + } else { + this.state = "path"; + --this.pointer; + } + + return true; + } + + parseRelative(c: number) { + this.url.scheme = this.base.scheme; + if (c === p("/")) { + this.state = "relative slash"; + } else if (isSpecial(this.url) && c === p("\\")) { + this.parseError = true; + this.state = "relative slash"; + } else { + this.url.username = this.base.username; + this.url.password = this.base.password; + this.url.host = this.base.host; + this.url.port = this.base.port; + this.url.path = this.base.path.slice(); + this.url.query = this.base.query; + if (c === p("?")) { + this.url.query = ""; + this.state = "query"; + } else if (c === p("#")) { + this.url.fragment = ""; + this.state = "fragment"; + } else if (!isNaN(c)) { + this.url.query = null; + this.url.path.pop(); + this.state = "path"; + --this.pointer; + } + } + + return true; + } + + parseRelativeSlash(c: number) { + if (isSpecial(this.url) && (c === p("/") || c === p("\\"))) { + if (c === p("\\")) { + this.parseError = true; + } + this.state = "special authority ignore slashes"; + } else if (c === p("/")) { + this.state = "authority"; + } else { + this.url.username = this.base.username; + this.url.password = this.base.password; + this.url.host = this.base.host; + this.url.port = this.base.port; + this.state = "path"; + --this.pointer; + } + + return true; + } + + parseSpecialAuthoritySlashes(c: number) { + if (c === p("/") && this.input[this.pointer + 1] === p("/")) { + this.state = "special authority ignore slashes"; + ++this.pointer; + } else { + this.parseError = true; + this.state = "special authority ignore slashes"; + --this.pointer; + } + + return true; + } + + parseSpecialAuthorityIgnoreSlashes(c: number) { + if (c !== p("/") && c !== p("\\")) { + this.state = "authority"; + --this.pointer; + } else { + this.parseError = true; + } + + return true; + } + + parseAuthority(c: number, cStr: string) { + if (c === p("@")) { + this.parseError = true; + if (this.atFlag) { + this.buffer = `%40${this.buffer}`; + } + this.atFlag = true; + + // careful, this is based on buffer and has its own pointer (this.pointer != pointer) and inner chars + const len = countSymbols(this.buffer); + for (let pointer = 0; pointer < len; ++pointer) { + const codePoint = this.buffer.codePointAt(pointer); + + if (codePoint === p(":") && !this.passwordTokenSeenFlag) { + this.passwordTokenSeenFlag = true; + continue; + } + const encodedCodePoints = utf8PercentEncodeCodePoint( + codePoint!, + isUserinfoPercentEncode, + ); + if (this.passwordTokenSeenFlag) { + this.url.password += encodedCodePoints; + } else { + this.url.username += encodedCodePoints; + } + } + this.buffer = ""; + } else if ( + isNaN(c) || + c === p("/") || + c === p("?") || + c === p("#") || + (isSpecial(this.url) && c === p("\\")) + ) { + if (this.atFlag && this.buffer === "") { + this.parseError = true; + return failure; + } + this.pointer -= countSymbols(this.buffer) + 1; + this.buffer = ""; + this.state = "host"; + } else { + this.buffer += cStr; + } + + return true; + } + + parseHostName(c: number, cStr: string) { + if (this.stateOverride && this.url.scheme === "file") { + --this.pointer; + this.state = "file host"; + } else if (c === p(":") && !this.arrFlag) { + if (this.buffer === "") { + this.parseError = true; + return failure; + } + + if (this.stateOverride === "hostname") { + return false; + } + + const host = parseHost(this.buffer, isNotSpecial(this.url)); + if (host === failure) { + return failure; + } + + this.url.host = host; + this.buffer = ""; + this.state = "port"; + } else if ( + isNaN(c) || + c === p("/") || + c === p("?") || + c === p("#") || + (isSpecial(this.url) && c === p("\\")) + ) { + --this.pointer; + if (isSpecial(this.url) && this.buffer === "") { + this.parseError = true; + return failure; + } else if ( + this.stateOverride && + this.buffer === "" && + (includesCredentials(this.url) || this.url.port !== null) + ) { + this.parseError = true; + return false; + } + + const host = parseHost(this.buffer, isNotSpecial(this.url)); + if (host === failure) { + return failure; + } + + this.url.host = host; + this.buffer = ""; + this.state = "path start"; + if (this.stateOverride) { + return false; + } + } else { + if (c === p("[")) { + this.arrFlag = true; + } else if (c === p("]")) { + this.arrFlag = false; + } + this.buffer += cStr; + } + + return true; + } + + parsePort(c: number, cStr: any) { + if (isASCIIDigit(c)) { + this.buffer += cStr; + } else if ( + isNaN(c) || + c === p("/") || + c === p("?") || + c === p("#") || + (isSpecial(this.url) && c === p("\\")) || + this.stateOverride + ) { + if (this.buffer !== "") { + const port = parseInt(this.buffer); + if (port > 2 ** 16 - 1) { + this.parseError = true; + return failure; + } + this.url.port = port === defaultPort(this.url.scheme) ? null : port; + this.buffer = ""; + } + if (this.stateOverride) { + return false; + } + this.state = "path start"; + --this.pointer; + } else { + this.parseError = true; + return failure; + } + + return true; + } + + parseFile(c: number) { + this.url.scheme = "file"; + this.url.host = ""; + + if (c === p("/") || c === p("\\")) { + if (c === p("\\")) { + this.parseError = true; + } + this.state = "file slash"; + } else if (this.base !== null && this.base.scheme === "file") { + this.url.host = this.base.host; + this.url.path = this.base.path.slice(); + this.url.query = this.base.query; + if (c === p("?")) { + this.url.query = ""; + this.state = "query"; + } else if (c === p("#")) { + this.url.fragment = ""; + this.state = "fragment"; + } else if (!isNaN(c)) { + this.url.query = null; + if (!startsWithWindowsDriveLetter(this.input, this.pointer)) { + shortenPath(this.url); + } else { + this.parseError = true; + this.url.path = []; + } + + this.state = "path"; + --this.pointer; + } + } else { + this.state = "path"; + --this.pointer; + } + + return true; + } + + parseFileSlash(c: number) { + if (c === p("/") || c === p("\\")) { + if (c === p("\\")) { + this.parseError = true; + } + this.state = "file host"; + } else { + if (this.base !== null && this.base.scheme === "file") { + if ( + !startsWithWindowsDriveLetter(this.input, this.pointer) && + isNormalizedWindowsDriveLetterString(this.base.path[0]) + ) { + this.url.path.push(this.base.path[0]); + } + this.url.host = this.base.host; + } + this.state = "path"; + --this.pointer; + } + + return true; + } + + parseFileHost(c: number, cStr: string) { + if ( + isNaN(c) || + c === p("/") || + c === p("\\") || + c === p("?") || + c === p("#") + ) { + --this.pointer; + if (!this.stateOverride && isWindowsDriveLetterString(this.buffer)) { + this.parseError = true; + this.state = "path"; + } else if (this.buffer === "") { + this.url.host = ""; + if (this.stateOverride) { + return false; + } + this.state = "path start"; + } else { + let host = parseHost(this.buffer, isNotSpecial(this.url)); + if (host === failure) { + return failure; + } + if (host === "localhost") { + host = ""; + } + this.url.host = host as any; + + if (this.stateOverride) { + return false; + } + + this.buffer = ""; + this.state = "path start"; + } + } else { + this.buffer += cStr; + } + + return true; + } + + parsePathStart(c: number) { + if (isSpecial(this.url)) { + if (c === p("\\")) { + this.parseError = true; + } + this.state = "path"; + + if (c !== p("/") && c !== p("\\")) { + --this.pointer; + } + } else if (!this.stateOverride && c === p("?")) { + this.url.query = ""; + this.state = "query"; + } else if (!this.stateOverride && c === p("#")) { + this.url.fragment = ""; + this.state = "fragment"; + } else if (c !== undefined) { + this.state = "path"; + if (c !== p("/")) { + --this.pointer; + } + } else if (this.stateOverride && this.url.host === null) { + this.url.path.push(""); + } + + return true; + } + + parsePath(c: number) { + if ( + isNaN(c) || + c === p("/") || + (isSpecial(this.url) && c === p("\\")) || + (!this.stateOverride && (c === p("?") || c === p("#"))) + ) { + if (isSpecial(this.url) && c === p("\\")) { + this.parseError = true; + } + + if (isDoubleDot(this.buffer)) { + shortenPath(this.url); + if (c !== p("/") && !(isSpecial(this.url) && c === p("\\"))) { + this.url.path.push(""); + } + } else if ( + isSingleDot(this.buffer) && + c !== p("/") && + !(isSpecial(this.url) && c === p("\\")) + ) { + this.url.path.push(""); + } else if (!isSingleDot(this.buffer)) { + if ( + this.url.scheme === "file" && + this.url.path.length === 0 && + isWindowsDriveLetterString(this.buffer) + ) { + this.buffer = `${this.buffer[0]}:`; + } + this.url.path.push(this.buffer); + } + this.buffer = ""; + if (c === p("?")) { + this.url.query = ""; + this.state = "query"; + } + if (c === p("#")) { + this.url.fragment = ""; + this.state = "fragment"; + } + } else { + // TODO: If c is not a URL code point and not "%", parse error. + + if ( + c === p("%") && + (!isASCIIHex(this.input[this.pointer + 1]) || + !isASCIIHex(this.input[this.pointer + 2])) + ) { + this.parseError = true; + } + + this.buffer += utf8PercentEncodeCodePoint(c, isPathPercentEncode); + } + + return true; + } + + parseOpaquePath(c: number) { + if (c === p("?")) { + this.url.query = ""; + this.state = "query"; + } else if (c === p("#")) { + this.url.fragment = ""; + this.state = "fragment"; + } else { + // TODO: Add: not a URL code point + if (!isNaN(c) && c !== p("%")) { + this.parseError = true; + } + + if ( + c === p("%") && + (!isASCIIHex(this.input[this.pointer + 1]) || + !isASCIIHex(this.input[this.pointer + 2])) + ) { + this.parseError = true; + } + + if (!isNaN(c)) { + // @ts-ignore + this.url.path += utf8PercentEncodeCodePoint( + c, + isC0ControlPercentEncode, + ); + } + } + + return true; + } + + parseQuery(c: number, cStr: string) { + if ( + !isSpecial(this.url) || + this.url.scheme === "ws" || + this.url.scheme === "wss" + ) { + this.encodingOverride = "utf-8"; + } + + if ((!this.stateOverride && c === p("#")) || isNaN(c)) { + const queryPercentEncodePredicate = isSpecial(this.url) + ? isSpecialQueryPercentEncode + : isQueryPercentEncode; + this.url.query += utf8PercentEncodeString( + this.buffer, + queryPercentEncodePredicate, + ); + + this.buffer = ""; + + if (c === p("#")) { + this.url.fragment = ""; + this.state = "fragment"; + } + } else if (!isNaN(c)) { + // TODO: If c is not a URL code point and not "%", parse error. + + if ( + c === p("%") && + (!isASCIIHex(this.input[this.pointer + 1]) || + !isASCIIHex(this.input[this.pointer + 2])) + ) { + this.parseError = true; + } + + this.buffer += cStr; + } + + return true; + } + + parseFragment(c: number) { + if (!isNaN(c)) { + // TODO: If c is not a URL code point and not "%", parse error. + if ( + c === p("%") && + (!isASCIIHex(this.input[this.pointer + 1]) || + !isASCIIHex(this.input[this.pointer + 2])) + ) { + this.parseError = true; + } + + this.url.fragment += utf8PercentEncodeCodePoint( + c, + isFragmentPercentEncode, + ); + } + + return true; + } +} + +const fileOtherwiseCodePoints = new Set([p("/"), p("\\"), p("?"), p("#")]); + +function startsWithWindowsDriveLetter(input: number[], pointer: number) { + const length = input.length - pointer; + return ( + length >= 2 && + isWindowsDriveLetterCodePoints(input[pointer], input[pointer + 1]) && + (length === 2 || fileOtherwiseCodePoints.has(input[pointer + 2])) + ); +} + +function serializeURL(url: any, excludeFragment?: boolean) { + let output = `${url.scheme}:`; + if (url.host !== null) { + output += "//"; + + if (url.username !== "" || url.password !== "") { + output += url.username; + if (url.password !== "") { + output += `:${url.password}`; + } + output += "@"; + } + + output += serializeHost(url.host); + + if (url.port !== null) { + output += `:${url.port}`; + } + } + + if ( + url.host === null && + !hasAnOpaquePath(url) && + url.path.length > 1 && + url.path[0] === "" + ) { + output += "/."; + } + output += serializePath(url); + + if (url.query !== null) { + output += `?${url.query}`; + } + + if (!excludeFragment && url.fragment !== null) { + output += `#${url.fragment}`; + } + + return output; +} + +function serializeOrigin(tuple: { + scheme: string; + port: number; + host: number | number[] | string; +}) { + let result = `${tuple.scheme}://`; + result += serializeHost(tuple.host); + + if (tuple.port !== null) { + result += `:${tuple.port}`; + } + + return result; +} + +function serializePath(url: UrlObj): string { + if (typeof url.path === "string") { + return url.path; + } + + let output = ""; + for (const segment of url.path) { + output += `/${segment}`; + } + return output; +} + +function serializeURLOrigin(url: any): any { + // https://url.spec.whatwg.org/#concept-url-origin + switch (url.scheme) { + case "blob": + try { + return serializeURLOrigin(parseURL(serializePath(url))); + } catch (e) { + // serializing an opaque origin returns "null" + return "null"; + } + case "ftp": + case "http": + case "https": + case "ws": + case "wss": + return serializeOrigin({ + scheme: url.scheme, + host: url.host, + port: url.port, + }); + case "file": + // The spec says: + // > Unfortunate as it is, this is left as an exercise to the reader. When in doubt, return a new opaque origin. + // Browsers tested so far: + // - Chrome says "file://", but treats file: URLs as cross-origin for most (all?) purposes; see e.g. + // https://bugs.chromium.org/p/chromium/issues/detail?id=37586 + // - Firefox says "null", but treats file: URLs as same-origin sometimes based on directory stuff; see + // https://developer.mozilla.org/en-US/docs/Archive/Misc_top_level/Same-origin_policy_for_file:_URIs + return "null"; + default: + // serializing an opaque origin returns "null" + return "null"; + } +} + +export function basicURLParse(input: string, options?: any) { + if (options === undefined) { + options = {}; + } + + const usm = new URLStateMachine( + input, + options.baseURL, + options.encodingOverride, + options.url, + options.stateOverride, + ); + + if (usm.failure) { + return null; + } + + return usm.url; +} + +function setTheUsername(url: UrlObj, username: string) { + url.username = utf8PercentEncodeString(username, isUserinfoPercentEncode); +} + +function setThePassword(url: UrlObj, password: string) { + url.password = utf8PercentEncodeString(password, isUserinfoPercentEncode); +} + +function serializeInteger(integer: number) { + return String(integer); +} + +function parseURL( + input: any, + options?: { baseURL?: any; encodingOverride?: any }, +) { + if (options === undefined) { + options = {}; + } + + // We don't handle blobs, so this just delegates: + return basicURLParse(input, { + baseURL: options.baseURL, + encodingOverride: options.encodingOverride, + }); +} + +export class URLImpl { + constructor(url: string, base?: string) { + let parsedBase = null; + if (base !== undefined) { + parsedBase = basicURLParse(base); + if (parsedBase === null) { + throw new TypeError(`Invalid base URL: ${base}`); + } + } + + const parsedURL = basicURLParse(url, { baseURL: parsedBase }); + if (parsedURL === null) { + throw new TypeError(`Invalid URL: ${url}`); + } + + const query = parsedURL.query !== null ? parsedURL.query : ""; + + this._url = parsedURL; + + // We cannot invoke the "new URLSearchParams object" algorithm without going through the constructor, which strips + // question mark by default. Therefore the doNotStripQMark hack is used. + this._query = new URLSearchParamsImpl([query], { + doNotStripQMark: true, + }); + this._query._url = this; + } + + get href() { + return serializeURL(this._url); + } + + set href(v) { + const parsedURL = basicURLParse(v); + if (parsedURL === null) { + throw new TypeError(`Invalid URL: ${v}`); + } + + this._url = parsedURL; + + this._query._list.splice(0); + const { query } = parsedURL; + if (query !== null) { + this._query._list = parseUrlencodedString(query); + } + } + + get origin() { + return serializeURLOrigin(this._url); + } + + get protocol() { + return `${this._url.scheme}:`; + } + + set protocol(v) { + basicURLParse(`${v}:`, { + url: this._url, + stateOverride: "scheme start", + }); + } + + get username() { + return this._url.username; + } + + set username(v) { + if (cannotHaveAUsernamePasswordPort(this._url)) { + return; + } + + setTheUsername(this._url, v); + } + + get password() { + return this._url.password; + } + + set password(v) { + if (cannotHaveAUsernamePasswordPort(this._url)) { + return; + } + + setThePassword(this._url, v); + } + + get host() { + const url = this._url; + + if (url.host === null) { + return ""; + } + + if (url.port === null) { + return serializeHost(url.host); + } + + return `${serializeHost(url.host)}:${serializeInteger(url.port)}`; + } + + set host(v) { + if (hasAnOpaquePath(this._url)) { + return; + } + + basicURLParse(v, { url: this._url, stateOverride: "host" }); + } + + get hostname() { + if (this._url.host === null) { + return ""; + } + + return serializeHost(this._url.host); + } + + set hostname(v) { + if (hasAnOpaquePath(this._url)) { + return; + } + + basicURLParse(v, { url: this._url, stateOverride: "hostname" }); + } + + get port() { + if (this._url.port === null) { + return ""; + } + + return serializeInteger(this._url.port); + } + + set port(v) { + if (cannotHaveAUsernamePasswordPort(this._url)) { + return; + } + + if (v === "") { + this._url.port = null; + } else { + basicURLParse(v, { url: this._url, stateOverride: "port" }); + } + } + + get pathname() { + return serializePath(this._url); + } + + set pathname(v: string) { + if (hasAnOpaquePath(this._url)) { + return; + } + + this._url.path = []; + basicURLParse(v, { url: this._url, stateOverride: "path start" }); + } + + get search() { + if (this._url.query === null || this._url.query === "") { + return ""; + } + + return `?${this._url.query}`; + } + + set search(v) { + const url = this._url; + + if (v === "") { + url.query = null; + this._query._list = []; + return; + } + + const input = v[0] === "?" ? v.substring(1) : v; + url.query = ""; + basicURLParse(input, { url, stateOverride: "query" }); + this._query._list = parseUrlencodedString(input); + } + + get searchParams() { + return this._query; + } + + get hash() { + if (this._url.fragment === null || this._url.fragment === "") { + return ""; + } + + return `#${this._url.fragment}`; + } + + set hash(v) { + if (v === "") { + this._url.fragment = null; + return; + } + + const input = v[0] === "#" ? v.substring(1) : v; + this._url.fragment = ""; + basicURLParse(input, { url: this._url, stateOverride: "fragment" }); + } + + toJSON() { + return this.href; + } + + // FIXME: type! + _url: any; + _query: any; +} \ No newline at end of file