diff --git a/lib/internal/url.js b/lib/internal/url.js index e96df6148f98..dcd48d15a7a4 100644 --- a/lib/internal/url.js +++ b/lib/internal/url.js @@ -74,14 +74,10 @@ const { }, } = require('internal/errors'); const { - CHAR_AMPERSAND, CHAR_BACKWARD_SLASH, - CHAR_EQUAL, CHAR_FORWARD_SLASH, CHAR_LOWERCASE_A, CHAR_LOWERCASE_Z, - CHAR_PERCENT, - CHAR_PLUS, CHAR_COLON, } = require('internal/constants'); const path = require('path'); @@ -331,12 +327,17 @@ class URLSearchParams { // "associated url object" #context; + // Cached application/x-www-form-urlencoded serialization. Cleared on + // mutation so repeated toString()/URL.href reads stay cheap. + #serialized; + static { setURLSearchParamsContext = (obj, ctx) => { obj.#context = ctx; }; getURLSearchParamsList = (obj) => obj.#searchParams; setURLSearchParams = (obj, query) => { + obj.#serialized = undefined; if (query === undefined) { obj.#searchParams = []; } else { @@ -345,6 +346,13 @@ class URLSearchParams { }; } + #markUpdated() { + this.#serialized = undefined; + if (this.#context) { + setURLSearchParamsModified(this.#context); + } + } + // URL Standard says the default value is '', but as undefined and '' have // the same result, undefined is used to prevent unnecessary parsing. // Default parameter is necessary to keep URLSearchParams.length === 0 in @@ -361,6 +369,7 @@ class URLSearchParams { // shortcut to avoid having to go through the costly generic iterator. const childParams = init.#searchParams; this.#searchParams = childParams.slice(); + this.#serialized = init.#serialized; } else if (method != null) { // Sequence> if (typeof method !== 'function') { @@ -388,8 +397,8 @@ class URLSearchParams { // Append (innerSequence[0], innerSequence[1]) to query's list. ArrayPrototypePush( this.#searchParams, - StringPrototypeToWellFormed(`${pair[0]}`), - StringPrototypeToWellFormed(`${pair[1]}`), + toUSVString(pair[0]), + toUSVString(pair[1]), ); } else { if (((typeof pair !== 'object' && typeof pair !== 'function') || @@ -401,7 +410,7 @@ class URLSearchParams { for (const element of pair) { length++; - ArrayPrototypePush(this.#searchParams, StringPrototypeToWellFormed(`${element}`)); + ArrayPrototypePush(this.#searchParams, toUSVString(element)); } // If innerSequence's size is not 2, then throw a TypeError. @@ -419,8 +428,8 @@ class URLSearchParams { const key = keys[i]; const desc = ReflectGetOwnPropertyDescriptor(init, key); if (desc !== undefined && desc.enumerable) { - const typedKey = StringPrototypeToWellFormed(key); - const typedValue = StringPrototypeToWellFormed(`${init[key]}`); + const typedKey = toUSVString(key); + const typedValue = toUSVString(init[key]); // Two different keys may become the same USVString after normalization. // In that case, we retain the later one. Refer to WPT. @@ -437,7 +446,7 @@ class URLSearchParams { } } else { // https://url.spec.whatwg.org/#dom-urlsearchparams-urlsearchparams - init = StringPrototypeToWellFormed(`${init}`); + init = toUSVString(init); this.#searchParams = init ? parseParams(init) : []; } } @@ -491,13 +500,10 @@ class URLSearchParams { throw new ERR_MISSING_ARGS('name', 'value'); } - name = StringPrototypeToWellFormed(`${name}`); - value = StringPrototypeToWellFormed(`${value}`); + name = toUSVString(name); + value = toUSVString(value); ArrayPrototypePush(this.#searchParams, name, value); - - if (this.#context) { - setURLSearchParamsModified(this.#context); - } + this.#markUpdated(); } delete(name, value = undefined) { @@ -509,12 +515,12 @@ class URLSearchParams { } const list = this.#searchParams; - name = StringPrototypeToWellFormed(`${name}`); + name = toUSVString(name); const { length } = list; let write = 0; if (value !== undefined) { - value = StringPrototypeToWellFormed(`${value}`); + value = toUSVString(value); for (let i = 0; i < length; i += 2) { if (list[i] === name && list[i + 1] === value) { continue; @@ -538,8 +544,10 @@ class URLSearchParams { } } - if (write !== length) + if (write !== length) { list.length = write; + this.#serialized = undefined; + } if (this.#context) { setURLSearchParamsModified(this.#context); @@ -555,8 +563,9 @@ class URLSearchParams { } const list = this.#searchParams; - name = StringPrototypeToWellFormed(`${name}`); - for (let i = 0; i < list.length; i += 2) { + name = toUSVString(name); + const { length } = list; + for (let i = 0; i < length; i += 2) { if (list[i] === name) { return list[i + 1]; } @@ -574,10 +583,11 @@ class URLSearchParams { const list = this.#searchParams; const values = []; - name = StringPrototypeToWellFormed(`${name}`); - for (let i = 0; i < list.length; i += 2) { + name = toUSVString(name); + const { length } = list; + for (let i = 0; i < length; i += 2) { if (list[i] === name) { - values.push(list[i + 1]); + ArrayPrototypePush(values, list[i + 1]); } } return values; @@ -592,13 +602,14 @@ class URLSearchParams { } const list = this.#searchParams; - name = StringPrototypeToWellFormed(`${name}`); + name = toUSVString(name); if (value !== undefined) { - value = StringPrototypeToWellFormed(`${value}`); + value = toUSVString(value); } - for (let i = 0; i < list.length; i += 2) { + const { length } = list; + for (let i = 0; i < length; i += 2) { if (list[i] === name) { if (value === undefined || list[i + 1] === value) { return true; @@ -618,8 +629,8 @@ class URLSearchParams { } const list = this.#searchParams; - name = StringPrototypeToWellFormed(`${name}`); - value = StringPrototypeToWellFormed(`${value}`); + name = toUSVString(name); + value = toUSVString(value); const { length } = list; // If there are any name-value pairs whose name is `name`, in `list`, set @@ -656,9 +667,7 @@ class URLSearchParams { ArrayPrototypePush(list, name, value); } - if (this.#context) { - setURLSearchParamsModified(this.#context); - } + this.#markUpdated(); } sort() { @@ -705,9 +714,7 @@ class URLSearchParams { } } - if (this.#context) { - setURLSearchParamsModified(this.#context); - } + this.#markUpdated(); } // https://heycam.github.io/webidl/#es-iterators @@ -760,7 +767,12 @@ class URLSearchParams { if (typeof this !== 'object' || this === null || !(#searchParams in this)) throw new ERR_INVALID_THIS('URLSearchParams'); - return serializeParams(this.#searchParams); + if (this.#serialized !== undefined) { + return this.#serialized; + } + const serialized = serializeParams(this.#searchParams); + this.#serialized = serialized; + return serialized; } } @@ -1276,102 +1288,82 @@ function installObjectURLMethods() { }); } +function toUSVString(value) { + return typeof value === 'string' ? + StringPrototypeToWellFormed(value) : + StringPrototypeToWellFormed(`${value}`); +} + +function unescapeFormComponent(s) { + try { + return decodeURIComponent(s); + } catch { + return querystring.unescapeBuffer(s).toString(); + } +} + +function hasPercentHex(s) { + const end = s.length - 2; + for (let i = 0; i < end; i++) { + if (StringPrototypeCharCodeAt(s, i) === 37 && // '%' + isHexTable[StringPrototypeCharCodeAt(s, i + 1)] === 1 && + isHexTable[StringPrototypeCharCodeAt(s, i + 2)] === 1) { + return true; + } + } + return false; +} + +function decodeFormComponent(qs, start, end) { + if (start >= end) { + return ''; + } + const s = qs.slice(start, end); + const plus = s.indexOf('+'); + const pct = s.indexOf('%'); + if (plus === -1 && pct === -1) { + return s; + } + const replaced = plus === -1 ? s : s.replaceAll('+', ' '); + // Only percent-decode when a complete %HH sequence exists. A lone '%' or + // a '%' followed by a non-hex character must be left intact so later + // serialization can encode the raw bytes. + if (pct === -1 || !hasPercentHex(replaced)) { + return replaced; + } + return unescapeFormComponent(replaced); +} + // application/x-www-form-urlencoded parser // Ref: https://url.spec.whatwg.org/#concept-urlencoded-parser function parseParams(qs) { - const out = []; - let seenSep = false; - let buf = ''; - let encoded = false; - let encodeCheck = 0; + const len = qs.length; let i = qs[0] === '?' ? 1 : 0; - let pairStart = i; - let lastPos = i; - for (; i < qs.length; ++i) { - const code = StringPrototypeCharCodeAt(qs, i); - - // Try matching key/value pair separator - if (code === CHAR_AMPERSAND) { - if (pairStart === i) { - // We saw an empty substring between pair separators - lastPos = pairStart = i + 1; - continue; - } + if (i >= len) { + return []; + } - if (lastPos < i) - buf += qs.slice(lastPos, i); - if (encoded) - buf = querystring.unescape(buf); - out.push(buf); - - // If `buf` is the key, add an empty value. - if (!seenSep) - out.push(''); - - seenSep = false; - buf = ''; - encoded = false; - encodeCheck = 0; - lastPos = pairStart = i + 1; - continue; - } - - // Try matching key/value separator (e.g. '=') if we haven't already - if (!seenSep && code === CHAR_EQUAL) { - // Key/value separator match! - if (lastPos < i) - buf += qs.slice(lastPos, i); - if (encoded) - buf = querystring.unescape(buf); - out.push(buf); - - seenSep = true; - buf = ''; - encoded = false; - encodeCheck = 0; - lastPos = i + 1; - continue; - } - - // Handle + and percent decoding. - if (code === CHAR_PLUS) { - if (lastPos < i) - buf += StringPrototypeSlice(qs, lastPos, i); - buf += ' '; - lastPos = i + 1; - } else if (!encoded) { - // Try to match an (valid) encoded byte (once) to minimize unnecessary - // calls to string decoding functions - if (code === CHAR_PERCENT) { - encodeCheck = 1; - } else if (encodeCheck > 0) { - if (isHexTable[code] === 1) { - if (++encodeCheck === 3) { - encoded = true; - } - } else { - encodeCheck = 0; - } + const out = []; + // Native indexOf/slice/push outperform primordials on this tight loop. + const encoded = qs.indexOf('+', i) !== -1 || qs.indexOf('%', i) !== -1; + while (i < len) { + let amp = qs.indexOf('&', i); + if (amp === -1) { + amp = len; + } + if (amp !== i) { + const eq = qs.indexOf('=', i); + if (eq === -1 || eq > amp) { + out.push(encoded ? decodeFormComponent(qs, i, amp) : qs.slice(i, amp), ''); + } else { + out.push( + encoded ? decodeFormComponent(qs, i, eq) : qs.slice(i, eq), + encoded ? decodeFormComponent(qs, eq + 1, amp) : qs.slice(eq + 1, amp), + ); } } + i = amp + 1; } - - // Deal with any leftover key or value data - - // There is a trailing &. No more processing is needed. - if (pairStart === i) - return out; - - if (lastPos < i) - buf += StringPrototypeSlice(qs, lastPos, i); - if (encoded) - buf = querystring.unescape(buf); - ArrayPrototypePush(out, buf); - - // If `buf` is the key, add an empty value. - if (!seenSep) - ArrayPrototypePush(out, ''); - return out; } @@ -1402,17 +1394,17 @@ function serializeParams(array) { if (len === 0) return ''; - const firstEncodedParam = encodeStr(array[0], noEscape, paramHexTable); - const firstEncodedValue = encodeStr(array[1], noEscape, paramHexTable); - let output = `${firstEncodedParam}=${firstEncodedValue}`; - - for (let i = 2; i < len; i += 2) { - const encodedParam = encodeStr(array[i], noEscape, paramHexTable); - const encodedValue = encodeStr(array[i + 1], noEscape, paramHexTable); - output += `&${encodedParam}=${encodedValue}`; + if (len === 2) { + return encodeStr(array[0], noEscape, paramHexTable) + '=' + + encodeStr(array[1], noEscape, paramHexTable); } - return output; + const pairs = new Array(len / 2); + for (let i = 0, j = 0; i < len; i += 2, ++j) { + pairs[j] = encodeStr(array[i], noEscape, paramHexTable) + '=' + + encodeStr(array[i + 1], noEscape, paramHexTable); + } + return ArrayPrototypeJoin(pairs, '&'); } // for merge sort diff --git a/test/parallel/test-whatwg-url-searchparams-fast-path.js b/test/parallel/test-whatwg-url-searchparams-fast-path.js new file mode 100644 index 000000000000..2dbb38f88aa5 --- /dev/null +++ b/test/parallel/test-whatwg-url-searchparams-fast-path.js @@ -0,0 +1,116 @@ +'use strict'; + +// Tests for the URLSearchParams parse / serialize / toUSVString fast paths. + +require('../common'); +const assert = require('assert'); + +{ + const params = new URLSearchParams('?a=b'); + assert.strictEqual(params.toString(), 'a=b'); + assert.strictEqual(params.get('a'), 'b'); +} + +{ + const params = new URLSearchParams('a=b&c'); + assert.deepStrictEqual([...params], [['a', 'b'], ['c', '']]); +} + +{ + const params = new URLSearchParams('&a&&& &&&&&a+b=& c&m%c3%b8%c3%b8'); + assert.ok(params.has('a')); + assert.ok(params.has('a b')); + assert.ok(params.has(' ')); + assert.ok(params.has(' c')); + assert.ok(params.has('møø')); + assert.strictEqual(params.get('a+b'), null); +} + +{ + const params = new URLSearchParams('id=0&value=%'); + assert.strictEqual(params.get('id'), '0'); + assert.strictEqual(params.get('value'), '%'); +} + +{ + const params = new URLSearchParams('b=%2sf%2a'); + assert.strictEqual(params.get('b'), '%2sf*'); +} + +{ + const params = new URLSearchParams('a=b=c&d='); + assert.strictEqual(params.get('a'), 'b=c'); + assert.strictEqual(params.get('d'), ''); +} + +{ + const params = new URLSearchParams('foo=bar&baz=quux'); + assert.strictEqual(params.toString(), 'foo=bar&baz=quux'); + assert.strictEqual(params.toString(), 'foo=bar&baz=quux'); + params.append('xyzzy', 'thud'); + assert.strictEqual(params.toString(), 'foo=bar&baz=quux&xyzzy=thud'); + params.set('baz', 'updated'); + assert.strictEqual(params.toString(), 'foo=bar&baz=updated&xyzzy=thud'); + params.delete('foo'); + assert.strictEqual(params.toString(), 'baz=updated&xyzzy=thud'); + params.sort(); + assert.strictEqual(params.toString(), 'baz=updated&xyzzy=thud'); +} + +{ + const original = new URLSearchParams('a=1&b=2'); + assert.strictEqual(original.toString(), 'a=1&b=2'); + const copy = new URLSearchParams(original); + assert.strictEqual(copy.toString(), 'a=1&b=2'); + original.append('c', '3'); + assert.strictEqual(original.toString(), 'a=1&b=2&c=3'); + assert.strictEqual(copy.toString(), 'a=1&b=2'); +} + +{ + const params = new URLSearchParams({ foo: 'bar', baz: 1, xyzzy: false }); + assert.strictEqual(params.get('foo'), 'bar'); + assert.strictEqual(params.get('baz'), '1'); + assert.strictEqual(params.get('xyzzy'), 'false'); + assert.strictEqual(params.toString(), 'foo=bar&baz=1&xyzzy=false'); +} + +{ + const params = new URLSearchParams([['foo', 'bar'], ['baz', 'quux']]); + assert.strictEqual(params.toString(), 'foo=bar&baz=quux'); + assert.ok(params.has('foo', 'bar')); + assert.deepStrictEqual(params.getAll('foo'), ['bar']); +} + +{ + const params = new URLSearchParams('\uD83D'); + assert.strictEqual(params.keys().next().value, '\uFFFD'); + assert.strictEqual(params.toString(), '%EF%BF%BD='); +} + +{ + const params = new URLSearchParams('a=b+c&d=%20'); + assert.strictEqual(params.get('a'), 'b c'); + assert.strictEqual(params.get('d'), ' '); + assert.strictEqual(params.toString(), 'a=b+c&d=+'); +} + +{ + // Fake percent-encoding must not be UTF-8-decoded into U+FFFD. + const params = new URLSearchParams('foo=%©ar&baz=%A©uux&xyzzy=%©ud'); + assert.deepStrictEqual([...params], [ + ['foo', '%©ar'], + ['baz', '%A©uux'], + ['xyzzy', '%©ud'], + ]); + assert.strictEqual(params.toString(), 'foo=%25%C2%A9ar&baz=%25A%C2%A9uux&xyzzy=%25%C2%A9ud'); +} + +{ + const url = new URL('https://example.org/?foo=bar'); + const params = url.searchParams; + assert.strictEqual(params.toString(), 'foo=bar'); + params.append('baz', 'quux'); + assert.strictEqual(url.search, '?foo=bar&baz=quux'); + assert.strictEqual(params.toString(), 'foo=bar&baz=quux'); +}