Skip to content

Commit c515a98

Browse files
committed
url: spec-compliant URLSearchParams parser
The entire `URLSearchParams` class is now fully spec-compliant. PR-URL: #11858 Fixes: #10821 Reviewed-By: James M Snell <[email protected]> Reviewed-By: Joyee Cheung <[email protected]> Reviewed-By: Daijiro Wachi <[email protected]>
1 parent c98a802 commit c515a98

File tree

4 files changed

+197
-17
lines changed

4 files changed

+197
-17
lines changed

benchmark/url/legacy-vs-whatwg-url-searchparams-parse.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const inputs = require('../fixtures/url-inputs.js').searchParams;
77
const bench = common.createBenchmark(main, {
88
type: Object.keys(inputs),
99
method: ['legacy', 'whatwg'],
10-
n: [1e5]
10+
n: [1e6]
1111
});
1212

1313
function useLegacy(n, input) {

lib/internal/url.js

+101-14
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
'use strict';
22

33
const util = require('util');
4-
const { hexTable, StorageObject } = require('internal/querystring');
4+
const {
5+
hexTable,
6+
isHexTable,
7+
StorageObject
8+
} = require('internal/querystring');
59
const binding = process.binding('url');
610
const context = Symbol('context');
711
const cannotBeBase = Symbol('cannot-be-base');
@@ -585,23 +589,106 @@ function initSearchParams(url, init) {
585589
url[searchParams] = [];
586590
return;
587591
}
588-
url[searchParams] = getParamsFromObject(querystring.parse(init));
592+
url[searchParams] = parseParams(init);
589593
}
590594

591-
function getParamsFromObject(obj) {
592-
const keys = Object.keys(obj);
593-
const values = [];
594-
for (var i = 0; i < keys.length; i++) {
595-
const name = keys[i];
596-
const value = obj[name];
597-
if (Array.isArray(value)) {
598-
for (const item of value)
599-
values.push(name, item);
600-
} else {
601-
values.push(name, value);
595+
// application/x-www-form-urlencoded parser
596+
// Ref: https://url.spec.whatwg.org/#concept-urlencoded-parser
597+
function parseParams(qs) {
598+
const out = [];
599+
var pairStart = 0;
600+
var lastPos = 0;
601+
var seenSep = false;
602+
var buf = '';
603+
var encoded = false;
604+
var encodeCheck = 0;
605+
var i;
606+
for (i = 0; i < qs.length; ++i) {
607+
const code = qs.charCodeAt(i);
608+
609+
// Try matching key/value pair separator
610+
if (code === 38/*&*/) {
611+
if (pairStart === i) {
612+
// We saw an empty substring between pair separators
613+
lastPos = pairStart = i + 1;
614+
continue;
615+
}
616+
617+
if (lastPos < i)
618+
buf += qs.slice(lastPos, i);
619+
if (encoded)
620+
buf = querystring.unescape(buf);
621+
out.push(buf);
622+
623+
// If `buf` is the key, add an empty value.
624+
if (!seenSep)
625+
out.push('');
626+
627+
seenSep = false;
628+
buf = '';
629+
encoded = false;
630+
encodeCheck = 0;
631+
lastPos = pairStart = i + 1;
632+
continue;
633+
}
634+
635+
// Try matching key/value separator (e.g. '=') if we haven't already
636+
if (!seenSep && code === 61/*=*/) {
637+
// Key/value separator match!
638+
if (lastPos < i)
639+
buf += qs.slice(lastPos, i);
640+
if (encoded)
641+
buf = querystring.unescape(buf);
642+
out.push(buf);
643+
644+
seenSep = true;
645+
buf = '';
646+
encoded = false;
647+
encodeCheck = 0;
648+
lastPos = i + 1;
649+
continue;
650+
}
651+
652+
// Handle + and percent decoding.
653+
if (code === 43/*+*/) {
654+
if (lastPos < i)
655+
buf += qs.slice(lastPos, i);
656+
buf += ' ';
657+
lastPos = i + 1;
658+
} else if (!encoded) {
659+
// Try to match an (valid) encoded byte (once) to minimize unnecessary
660+
// calls to string decoding functions
661+
if (code === 37/*%*/) {
662+
encodeCheck = 1;
663+
} else if (encodeCheck > 0) {
664+
// eslint-disable-next-line no-extra-boolean-cast
665+
if (!!isHexTable[code]) {
666+
if (++encodeCheck === 3)
667+
encoded = true;
668+
} else {
669+
encodeCheck = 0;
670+
}
671+
}
602672
}
603673
}
604-
return values;
674+
675+
// Deal with any leftover key or value data
676+
677+
// There is a trailing &. No more processing is needed.
678+
if (pairStart === i)
679+
return out;
680+
681+
if (lastPos < i)
682+
buf += qs.slice(lastPos, i);
683+
if (encoded)
684+
buf = querystring.unescape(buf);
685+
out.push(buf);
686+
687+
// If `buf` is the key, add an empty value.
688+
if (!seenSep)
689+
out.push('');
690+
691+
return out;
605692
}
606693

607694
// Adapted from querystring's implementation.

test/fixtures/url-searchparams.js

+68
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
module.exports = [
2+
['', '', []],
3+
[
4+
'foo=918854443121279438895193',
5+
'foo=918854443121279438895193',
6+
[['foo', '918854443121279438895193']]
7+
],
8+
['foo=bar', 'foo=bar', [['foo', 'bar']]],
9+
['foo=bar&foo=quux', 'foo=bar&foo=quux', [['foo', 'bar'], ['foo', 'quux']]],
10+
['foo=1&bar=2', 'foo=1&bar=2', [['foo', '1'], ['bar', '2']]],
11+
[
12+
"my%20weird%20field=q1!2%22'w%245%267%2Fz8)%3F",
13+
'my+weird+field=q1%212%22%27w%245%267%2Fz8%29%3F',
14+
[['my weird field', 'q1!2"\'w$5&7/z8)?']]
15+
],
16+
['foo%3Dbaz=bar', 'foo%3Dbaz=bar', [['foo=baz', 'bar']]],
17+
['foo=baz=bar', 'foo=baz%3Dbar', [['foo', 'baz=bar']]],
18+
[
19+
'str=foo&arr=1&somenull&arr=2&undef=&arr=3',
20+
'str=foo&arr=1&somenull=&arr=2&undef=&arr=3',
21+
[
22+
['str', 'foo'],
23+
['arr', '1'],
24+
['somenull', ''],
25+
['arr', '2'],
26+
['undef', ''],
27+
['arr', '3']
28+
]
29+
],
30+
[' foo = bar ', '+foo+=+bar+', [[' foo ', ' bar ']]],
31+
['foo=%zx', 'foo=%25zx', [['foo', '%zx']]],
32+
['foo=%EF%BF%BD', 'foo=%EF%BF%BD', [['foo', '\ufffd']]],
33+
// See: https://github.com/joyent/node/issues/3058
34+
['foo&bar=baz', 'foo=&bar=baz', [['foo', ''], ['bar', 'baz']]],
35+
['a=b&c&d=e', 'a=b&c=&d=e', [['a', 'b'], ['c', ''], ['d', 'e']]],
36+
['a=b&c=&d=e', 'a=b&c=&d=e', [['a', 'b'], ['c', ''], ['d', 'e']]],
37+
['a=b&=c&d=e', 'a=b&=c&d=e', [['a', 'b'], ['', 'c'], ['d', 'e']]],
38+
['a=b&=&d=e', 'a=b&=&d=e', [['a', 'b'], ['', ''], ['d', 'e']]],
39+
['&&foo=bar&&', 'foo=bar', [['foo', 'bar']]],
40+
['&', '', []],
41+
['&&&&', '', []],
42+
['&=&', '=', [['', '']]],
43+
['&=&=', '=&=', [['', ''], ['', '']]],
44+
['=', '=', [['', '']]],
45+
['+', '+=', [[' ', '']]],
46+
['+=', '+=', [[' ', '']]],
47+
['=+', '=+', [['', ' ']]],
48+
['+=&', '+=', [[' ', '']]],
49+
['a&&b', 'a=&b=', [['a', ''], ['b', '']]],
50+
['a=a&&b=b', 'a=a&b=b', [['a', 'a'], ['b', 'b']]],
51+
['&a', 'a=', [['a', '']]],
52+
['&=', '=', [['', '']]],
53+
['a&a&', 'a=&a=', [['a', ''], ['a', '']]],
54+
['a&a&a&', 'a=&a=&a=', [['a', ''], ['a', ''], ['a', '']]],
55+
['a&a&a&a&', 'a=&a=&a=&a=', [['a', ''], ['a', ''], ['a', ''], ['a', '']]],
56+
['a=&a=value&a=', 'a=&a=value&a=', [['a', ''], ['a', 'value'], ['a', '']]],
57+
['foo%20bar=baz%20quux', 'foo+bar=baz+quux', [['foo bar', 'baz quux']]],
58+
['+foo=+bar', '+foo=+bar', [[' foo', ' bar']]],
59+
[
60+
// fake percent encoding
61+
'foo=%©ar&baz=%A©uux&xyzzy=%©ud',
62+
'foo=%25%C2%A9ar&baz=%25A%C2%A9uux&xyzzy=%25%C2%A9ud',
63+
[['foo', '%©ar'], ['baz', '%A©uux'], ['xyzzy', '%©ud']]
64+
],
65+
// always preserve order of key-value pairs
66+
['a=1&b=2&a=3', 'a=1&b=2&a=3', [['a', '1'], ['b', '2'], ['a', '3']]],
67+
['?a', '%3Fa=', [['?a', '']]]
68+
];

test/parallel/test-whatwg-url-searchparams.js

+27-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
'use strict';
22

3-
require('../common');
3+
const common = require('../common');
44
const assert = require('assert');
5-
const URL = require('url').URL;
5+
const path = require('path');
6+
const { URL, URLSearchParams } = require('url');
67

78
// Tests below are not from WPT.
89
const serialized = 'a=a&a=1&a=true&a=undefined&a=null&a=%EF%BF%BD' +
@@ -77,3 +78,27 @@ assert.throws(() => sp.forEach(1),
7778

7879
m.search = '?a=a&b=b';
7980
assert.strictEqual(sp.toString(), 'a=a&b=b');
81+
82+
const tests = require(path.join(common.fixturesDir, 'url-searchparams.js'));
83+
84+
for (const [input, expected, parsed] of tests) {
85+
if (input[0] !== '?') {
86+
const sp = new URLSearchParams(input);
87+
assert.strictEqual(String(sp), expected);
88+
assert.deepStrictEqual(Array.from(sp), parsed);
89+
90+
m.search = input;
91+
assert.strictEqual(String(m.searchParams), expected);
92+
assert.deepStrictEqual(Array.from(m.searchParams), parsed);
93+
}
94+
95+
{
96+
const sp = new URLSearchParams(`?${input}`);
97+
assert.strictEqual(String(sp), expected);
98+
assert.deepStrictEqual(Array.from(sp), parsed);
99+
100+
m.search = `?${input}`;
101+
assert.strictEqual(String(m.searchParams), expected);
102+
assert.deepStrictEqual(Array.from(m.searchParams), parsed);
103+
}
104+
}

0 commit comments

Comments
 (0)