query-string 9.5.1 → 10.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/base.d.ts +156 -83
  2. package/base.js +432 -246
  3. package/package.json +11 -22
  4. package/readme.md +89 -45
package/base.js CHANGED
@@ -1,164 +1,125 @@
1
- import decodeComponent from 'decode-uri-component';
2
1
  import {includeKeys} from 'filter-obj';
3
2
  import splitOnFirst from 'split-on-first';
4
3
 
5
- const isNullOrUndefined = value => value === null || value === undefined;
6
-
7
4
  // eslint-disable-next-line unicorn/prefer-code-point
8
- const strictUriEncode = string => encodeURIComponent(string).replaceAll(/[!'()*]/g, x => `%${x.charCodeAt(0).toString(16).toUpperCase()}`);
9
-
10
- const encodeFragmentIdentifier = Symbol('encodeFragmentIdentifier');
5
+ const strictUriEncode = string => encodeURIComponent(string).replaceAll(/[!'\(\)*]/gv, x => `%${x.charCodeAt(0).toString(16).toUpperCase()}`);
6
+
7
+ // A value that is left out of the query string. `undefined` is always left out, and `skipNull` and `skipEmptyString` leave out the two others.
8
+ function isSkipped(value, options) {
9
+ return (
10
+ value === undefined
11
+ || (options.skipNull && value === null)
12
+ || (options.skipEmptyString && value === '')
13
+ );
14
+ }
11
15
 
12
- function encoderForArrayFormat(options) {
13
- switch (options.arrayFormat) {
16
+ // The suffix of the parameter name that `parse()` looks for. `index` numbers it, `bracket` and `colon-list-separator` use a fixed one, and the other formats use none.
17
+ function itemMarkerFor(arrayFormat) {
18
+ switch (arrayFormat) {
14
19
  case 'index': {
15
- return key => (result, value) => {
16
- const index = result.length;
17
-
18
- if (
19
- value === undefined
20
- || (options.skipNull && value === null)
21
- || (options.skipEmptyString && value === '')
22
- ) {
23
- return result;
24
- }
25
-
26
- if (value === null) {
27
- result.push([encode(key, options), '[', index, ']'].join(''));
28
- return result;
29
- }
30
-
31
- result.push([encode(key, options), '[', encode(index, options), ']=', encode(value, options)].join(''));
32
- return result;
33
- };
20
+ return index => `[${index}]`;
34
21
  }
35
22
 
36
23
  case 'bracket': {
37
- return key => (result, value) => {
38
- if (
39
- value === undefined
40
- || (options.skipNull && value === null)
41
- || (options.skipEmptyString && value === '')
42
- ) {
43
- return result;
44
- }
24
+ return () => '[]';
25
+ }
45
26
 
46
- if (value === null) {
47
- result.push([encode(key, options), '[]'].join(''));
48
- return result;
49
- }
27
+ case 'colon-list-separator': {
28
+ return () => ':list';
29
+ }
50
30
 
51
- result.push([encode(key, options), '[]=', encode(value, options)].join(''));
52
- return result;
53
- };
31
+ default: {
32
+ return () => '';
54
33
  }
34
+ }
35
+ }
55
36
 
56
- case 'colon-list-separator': {
57
- return key => (result, value) => {
58
- if (
59
- value === undefined
60
- || (options.skipNull && value === null)
61
- || (options.skipEmptyString && value === '')
62
- ) {
63
- return result;
64
- }
37
+ function encoderForArrayFormat(options) {
38
+ const {arrayFormat} = options;
65
39
 
66
- if (value === null) {
67
- result.push([encode(key, options), ':list='].join(''));
68
- return result;
69
- }
40
+ if (['comma', 'separator', 'bracket-separator'].includes(arrayFormat)) {
41
+ const keyValueSeparator = arrayFormat === 'bracket-separator' ? '[]=' : '=';
70
42
 
71
- result.push([encode(key, options), ':list=', encode(value, options)].join(''));
43
+ return key => (result, value) => {
44
+ if (isSkipped(value, options)) {
72
45
  return result;
73
- };
74
- }
46
+ }
75
47
 
76
- case 'comma':
77
- case 'separator':
78
- case 'bracket-separator': {
79
- const keyValueSeparator = options.arrayFormat === 'bracket-separator'
80
- ? '[]='
81
- : '=';
82
-
83
- return key => (result, value) => {
84
- if (
85
- value === undefined
86
- || (options.skipNull && value === null)
87
- || (options.skipEmptyString && value === '')
88
- ) {
89
- return result;
90
- }
48
+ // Translate null to an empty string so that it doesn't serialize as 'null'
49
+ const item = encodeArrayItem(value === null ? '' : value, options);
91
50
 
92
- // Translate null to an empty string so that it doesn't serialize as 'null'
93
- value = value === null ? '' : value;
51
+ if (result.length === 0) {
52
+ result.push([encode(key, options), keyValueSeparator, item].join(''));
53
+ return result;
54
+ }
94
55
 
95
- if (result.length === 0) {
96
- result.push([encode(key, options), keyValueSeparator, encode(value, options)].join(''));
97
- return result;
98
- }
56
+ result.push(item);
57
+ return result;
58
+ };
59
+ }
99
60
 
100
- result.push(encode(value, options));
101
- return result;
102
- };
103
- }
61
+ // These formats write one parameter per item, with the marker in the name.
62
+ const marker = itemMarkerFor(arrayFormat);
104
63
 
105
- default: {
106
- return key => (result, value) => {
107
- if (
108
- value === undefined
109
- || (options.skipNull && value === null)
110
- || (options.skipEmptyString && value === '')
111
- ) {
112
- return result;
113
- }
64
+ return key => (result, value) => {
65
+ if (isSkipped(value, options)) {
66
+ return result;
67
+ }
114
68
 
115
- if (value === null) {
116
- result.push(encode(key, options));
117
- return result;
118
- }
69
+ const name = encode(key, options) + marker(result.length);
119
70
 
120
- result.push([encode(key, options), '=', encode(value, options)].join(''));
121
- return result;
122
- };
71
+ if (value === null) {
72
+ result.push(name);
73
+ return result;
123
74
  }
124
- }
75
+
76
+ result.push([name, '=', encode(value, options)].join(''));
77
+ return result;
78
+ };
125
79
  }
126
80
 
127
81
  function parserForArrayFormat(options) {
128
- let result;
129
-
130
82
  switch (options.arrayFormat) {
131
83
  case 'index': {
84
+ // The next free index per key for an entry without one, like `foo[]`. That is what PHP and Rack write for an array item, and what `arrayFormat: 'bracket'` produces. It is appended after the highest index seen so far, like in PHP, instead of every such entry overriding the previous one.
85
+ const nextIndexes = new Map();
86
+
132
87
  return (key, value, accumulator) => {
133
- result = /\[(\d*)]$/.exec(key);
88
+ const result = /\[(?<index>\d*)\]$/v.exec(key);
134
89
 
135
- key = key.replace(/\[\d*]$/, '');
90
+ key = key.replace(/\[(?<index>\d*)\]$/v, '');
136
91
 
137
- if (!result) {
92
+ // A repeated key adds its value whether or not it has an index, like the other array formats, instead of silently dropping the earlier values. A plain entry after the first is appended like `foo[]`. A repeated index keeps the last value, like in PHP.
93
+ if (!result && accumulator[key] === undefined) {
138
94
  accumulator[key] = value;
139
95
  return;
140
96
  }
141
97
 
142
98
  if (accumulator[key] === undefined) {
143
99
  accumulator[key] = {};
100
+ } else if (typeof accumulator[key] !== 'object' || accumulator[key] === null) {
101
+ // A scalar seen before the indexed entries becomes the first item, like in the other array formats. The empty key sorts before any index.
102
+ accumulator[key] = {'': accumulator[key]};
103
+ nextIndexes.set(key, 1);
144
104
  }
145
105
 
146
- accumulator[key][result[1]] = value;
106
+ const index = result?.groups?.index || (nextIndexes.get(key) ?? 0);
107
+ accumulator[key][index] = value;
108
+ nextIndexes.set(key, Math.max(nextIndexes.get(key) ?? 0, Number(index) + 1));
147
109
  };
148
110
  }
149
111
 
150
- case 'bracket': {
151
- return (key, value, accumulator) => {
152
- result = /(\[])$/.exec(key);
153
- key = key.replace(/\[]$/, '');
112
+ case 'bracket':
113
+ case 'colon-list-separator': {
114
+ const marker = options.arrayFormat === 'bracket' ? /\[\]$/v : /:list$/v;
154
115
 
155
- if (!result) {
156
- accumulator[key] = value;
157
- return;
158
- }
116
+ return (key, value, accumulator) => {
117
+ const isArray = marker.test(key);
118
+ key = key.replace(marker, '');
159
119
 
120
+ // A repeated key adds its value whether or not it has the marker, like `arrayFormat: 'none'`, instead of silently dropping the earlier values.
160
121
  if (accumulator[key] === undefined) {
161
- accumulator[key] = [value];
122
+ accumulator[key] = isArray ? [value] : value;
162
123
  return;
163
124
  }
164
125
 
@@ -171,55 +132,48 @@ function parserForArrayFormat(options) {
171
132
  };
172
133
  }
173
134
 
174
- case 'colon-list-separator': {
135
+ case 'comma':
136
+ case 'separator': {
175
137
  return (key, value, accumulator) => {
176
- result = /(:list)$/.exec(key);
177
- key = key.replace(/:list$/, '');
178
-
179
- if (!result) {
180
- accumulator[key] = value;
181
- return;
182
- }
138
+ // Split the raw value before decoding. A percent-encoded separator (`%2C`) is part of an item, only a literal separator delimits items. This follows RFC 6570 §3.2.8, where list members are percent-encoded and joined with a literal `,`, and matches `stringify()`, `qs`, and OpenAPI clients such as swagger-js and openapi-fetch.
139
+ const isArray = typeof value === 'string' && value.includes(options.arrayFormatSeparator);
140
+ const newValue = isArray ? value.split(options.arrayFormatSeparator).map(item => decode(item, options)) : (value === null ? value : decode(value, options));
183
141
 
184
142
  if (accumulator[key] === undefined) {
185
- accumulator[key] = [value];
143
+ accumulator[key] = newValue;
186
144
  return;
187
145
  }
188
146
 
147
+ // A repeated key adds its items, like in the other array formats. The items are appended in place, since rebuilding the array for every repetition is quadratic.
189
148
  if (!Array.isArray(accumulator[key])) {
190
- accumulator[key] = [accumulator[key], value];
191
- return;
149
+ accumulator[key] = [accumulator[key]];
192
150
  }
193
151
 
194
- accumulator[key].push(value);
195
- };
196
- }
197
-
198
- case 'comma':
199
- case 'separator': {
200
- return (key, value, accumulator) => {
201
- const isArray = typeof value === 'string' && value.includes(options.arrayFormatSeparator);
202
- const newValue = isArray ? value.split(options.arrayFormatSeparator).map(item => decode(item, options)) : (value === null ? value : decode(value, options));
203
- accumulator[key] = newValue;
152
+ const items = Array.isArray(newValue) ? newValue : [newValue];
153
+ for (const item of items) {
154
+ accumulator[key].push(item);
155
+ }
204
156
  };
205
157
  }
206
158
 
207
159
  case 'bracket-separator': {
208
160
  return (key, value, accumulator) => {
209
- const isArray = /(\[])$/.test(key);
210
- key = key.replace(/\[]$/, '');
161
+ const isArray = /\[\]$/v.test(key);
162
+ key = key.replace(/\[\]$/v, '');
211
163
 
164
+ // Split the raw value before decoding, like `comma` and `separator`, so an item that contains the separator round-trips: `stringify({foo: ['a,b', 'c']})` gives `foo[]=a%2Cb,c`, which must parse back to `['a,b', 'c']`. Decoding first would make the separator ambiguous, which is also why swagger-js rejected encoding the separator (swagger-api/swagger-ui#9289). A value where the client percent-encoded everything, including the separators, is therefore a single item. See https://github.com/sindresorhus/query-string/issues/398
165
+ // A key without the marker is a single item. A repeated key adds its items either way, like `arrayFormat: 'none'`, instead of silently dropping the earlier values.
166
+ let items;
212
167
  if (!isArray) {
213
- accumulator[key] = value ? decode(value, options) : value;
214
- return;
168
+ items = [value === null ? value : decode(value, options)];
169
+ } else if (value === null) {
170
+ items = [];
171
+ } else {
172
+ items = value.split(options.arrayFormatSeparator).map(item => decode(item, options));
215
173
  }
216
174
 
217
- const arrayValue = value === null
218
- ? []
219
- : decode(value, options).split(options.arrayFormatSeparator);
220
-
221
175
  if (accumulator[key] === undefined) {
222
- accumulator[key] = arrayValue;
176
+ accumulator[key] = isArray ? items : items[0];
223
177
  return;
224
178
  }
225
179
 
@@ -227,7 +181,7 @@ function parserForArrayFormat(options) {
227
181
  accumulator[key] = [accumulator[key]];
228
182
  }
229
183
 
230
- for (const item of arrayValue) {
184
+ for (const item of items) {
231
185
  accumulator[key].push(item);
232
186
  }
233
187
  };
@@ -252,39 +206,228 @@ function parserForArrayFormat(options) {
252
206
  }
253
207
 
254
208
  function validateArrayFormatSeparator(value) {
209
+ // A lone surrogate is one UTF-16 code unit, but it is not a character and cannot be percent-encoded, so it would make the encoder throw a `URIError` later instead. Whitespace cannot be a separator because `parse()` ignores leading and trailing whitespace in the query. A separator that only works sometimes, like `%` or a hexadecimal digit, is documented as unsupported instead, so that the encoder can keep its best-effort behavior for it.
255
210
  if (typeof value !== 'string' || value.length !== 1) {
256
- throw new TypeError('arrayFormatSeparator must be single character string');
211
+ throw new TypeError('arrayFormatSeparator must be a single character');
212
+ }
213
+
214
+ if (value.trim() === '') {
215
+ throw new TypeError('arrayFormatSeparator must not be whitespace, since parse() ignores leading and trailing whitespace in the query');
216
+ }
217
+
218
+ if (/\p{Surrogate}/v.test(value)) {
219
+ throw new TypeError('arrayFormatSeparator must not be a lone surrogate, since it cannot be percent-encoded');
257
220
  }
258
221
  }
259
222
 
260
223
  function encode(value, options) {
224
+ const type = typeof value;
225
+ if (type === 'function' || type === 'symbol' || (type === 'object' && value !== null)) {
226
+ throw new TypeError(`Unsupported value type: ${type}. Supported types are string, number, bigint, boolean, null, and undefined. Use the \`replacer\` option to transform other types.`);
227
+ }
228
+
261
229
  if (options.encode) {
262
- return options.strict ? strictUriEncode(value) : encodeURIComponent(value);
230
+ try {
231
+ return options.strict ? strictUriEncode(value) : encodeURIComponent(value);
232
+ } catch {
233
+ // A lone surrogate cannot be encoded as UTF-8, so `encodeURIComponent` throws for it. It is replaced with U+FFFD, like `URLSearchParams` and the `URL` hash setter do. `\p{Surrogate}` matches only a surrogate that is not part of a pair, so a valid pair is left alone.
234
+ return encode(String(value).replaceAll(/\p{Surrogate}/gv, '\u{FFFD}'), options);
235
+ }
263
236
  }
264
237
 
265
238
  return value;
266
239
  }
267
240
 
268
- function decode(value, options) {
269
- if (options.decode) {
270
- return decodeComponent(value);
241
+ // `parse()` only splits on a literal separator, so a separator inside an item is percent-encoded. `encodeURIComponent` leaves some ASCII characters as-is, like `-`, `.`, `_`, and `~`, so the pieces of the item between the separators are encoded separately and joined with the percent-encoded separator. The encoded item is not searched for the separator, since a separator that is a hex digit would clash with the `%XX` escapes.
242
+ function encodeArrayItem(value, options) {
243
+ // `encode()` validates the value type first, since `String()` would hide an unsupported type.
244
+ const encodedValue = encode(value, options);
245
+ const stringValue = String(value);
246
+ const {arrayFormatSeparator} = options;
247
+ if (!options.encode || !stringValue.includes(arrayFormatSeparator)) {
248
+ return encodedValue;
271
249
  }
272
250
 
273
- return value;
251
+ const percentEncodedSeparator = encodeURIComponent(arrayFormatSeparator);
252
+ // `encodeURIComponent` leaves a few characters as-is, like `-`, `.`, `_`, `~`, and `*`, so the separator has to be encoded by hand for those.
253
+ const encodedSeparator = percentEncodedSeparator === arrayFormatSeparator
254
+ ? `%${arrayFormatSeparator.codePointAt(0).toString(16).toUpperCase().padStart(2, '0')}`
255
+ : percentEncodedSeparator;
256
+
257
+ return stringValue.split(arrayFormatSeparator).map(piece => encode(piece, options)).join(encodedSeparator);
274
258
  }
275
259
 
276
- function keysSorter(input) {
277
- if (Array.isArray(input)) {
278
- return input.sort();
260
+ // One percent sign, anywhere. Text between two of these is copied in one go, so a value with no escapes in it costs a single native scan instead of a step per character. A value that holds no escape at all, which is the common case, is returned as it is.
261
+ // The `lastIndex` of this and `percentEscape` is shared state, which is only safe because nothing that a caller controls runs while `decodeComponent()` is on the stack. Every user callback, which is a `types` function, a `sort` comparator, a `pick()` filter, and a `replacer`, runs after the loop that decodes the parameters. A callback, a comparator, or an `await` inside `decodeComponent()` would corrupt the two regexes, so set `lastIndex` right before each `exec` and do not reach for a `matchAll` or a `String#replace` here.
262
+ const percentSign = /%/gv;
263
+
264
+ // One percent escape, like `%C3`. The `y` flag makes it match only where it is anchored, so a `%` that starts nothing is not skipped over. The hexadecimal digits may be in either case, like in `%c3`.
265
+ const percentEscape = /%[\da-f]{2}/ivy;
266
+
267
+ // What a byte that cannot be decoded becomes, like the `URL` parser and `URLSearchParams` do.
268
+ const replacementCharacter = '�';
269
+
270
+ // The byte that the percent escape at `position` stands for, or -1 when there is no escape there. A `%` that is not followed by two hexadecimal digits is not an escape, and neither is one that starts the value.
271
+ function readPercentByte(value, position) {
272
+ percentEscape.lastIndex = position;
273
+ const match = percentEscape.exec(value);
274
+ return match === null ? -1 : Number.parseInt(match[0].slice(1), 16);
275
+ }
276
+
277
+ // The number of bytes in the UTF-8 sequence that starts with this lead byte, or zero when the byte cannot start one. That is a continuation byte (0x80 to 0xBF), and the lead bytes that are only ever invalid: 0xC0 and 0xC1 can only start an overlong encoding, and 0xF5 to 0xFF can only start a code point above U+10FFFF.
278
+ function utf8SequenceLength(leadByte) {
279
+ if (leadByte < 0x80) {
280
+ return 1;
279
281
  }
280
282
 
281
- if (typeof input === 'object') {
282
- return keysSorter(Object.keys(input))
283
- .sort((a, b) => Number(a) - Number(b))
284
- .map(key => input[key]);
283
+ if (leadByte < 0xC2) {
284
+ return 0;
285
285
  }
286
286
 
287
- return input;
287
+ if (leadByte < 0xE0) {
288
+ return 2;
289
+ }
290
+
291
+ if (leadByte < 0xF0) {
292
+ return 3;
293
+ }
294
+
295
+ return leadByte <= 0xF4 ? 4 : 0;
296
+ }
297
+
298
+ // Whether the second byte of a UTF-8 sequence is valid for this lead byte. Any later byte only has to be a continuation byte. The range is narrower for the lead bytes that would otherwise allow an overlong encoding, a surrogate, or a code point above U+10FFFF.
299
+ function isValidSecondByte(leadByte, byte) {
300
+ if (byte < 0x80 || byte > 0xBF) {
301
+ return false;
302
+ }
303
+
304
+ switch (leadByte) {
305
+ case 0xE0: {
306
+ return byte >= 0xA0;
307
+ }
308
+
309
+ case 0xED: {
310
+ return byte <= 0x9F;
311
+ }
312
+
313
+ case 0xF0: {
314
+ return byte >= 0x90;
315
+ }
316
+
317
+ case 0xF4: {
318
+ return byte <= 0x8F;
319
+ }
320
+
321
+ default: {
322
+ return true;
323
+ }
324
+ }
325
+ }
326
+
327
+ // Decode the UTF-8 sequence that starts with the percent escape at `position`. The decoded text and the position just past the sequence are returned.
328
+ function decodeSequence(value, position) {
329
+ const leadByte = readPercentByte(value, position);
330
+
331
+ // A `%` that is not followed by two hexadecimal digits is not an escape, so it is kept as it is.
332
+ if (leadByte === -1) {
333
+ return ['%', position + 1];
334
+ }
335
+
336
+ const length = utf8SequenceLength(leadByte);
337
+
338
+ // A continuation byte with no lead byte in front of it, or a lead byte that can only ever start an invalid sequence.
339
+ if (length === 0) {
340
+ return [replacementCharacter, position + 3];
341
+ }
342
+
343
+ // An ASCII byte is its own character.
344
+ if (length === 1) {
345
+ return [String.fromCodePoint(leadByte), position + 3];
346
+ }
347
+
348
+ // The lead byte holds the top bits of the code point, and each continuation byte holds six more. The sequence stops when a byte is missing, because the value ends, or is not valid where it sits.
349
+ // eslint-disable-next-line no-bitwise -- Unpacking a UTF-8 sequence is bit arithmetic by definition.
350
+ let codePoint = leadByte & (0x7F >> length);
351
+ let offset = 1;
352
+
353
+ while (offset < length) {
354
+ const byte = readPercentByte(value, position + (offset * 3));
355
+ if (!(offset === 1 ? isValidSecondByte(leadByte, byte) : (byte >= 0x80 && byte <= 0xBF))) {
356
+ // What was read before the sequence stopped is consumed together with it, so `%E4%80%41` is one replacement character and then `A` rather than a replacement character for every byte.
357
+ return [replacementCharacter, position + (offset * 3)];
358
+ }
359
+
360
+ // eslint-disable-next-line no-bitwise -- Unpacking a UTF-8 sequence is bit arithmetic by definition.
361
+ codePoint = (codePoint << 6) | (byte & 0x3F);
362
+ offset++;
363
+ }
364
+
365
+ return [String.fromCodePoint(codePoint), position + (length * 3)];
366
+ }
367
+
368
+ // Decode the percent escapes in `value`, like `URLSearchParams` does.
369
+ //
370
+ // `decodeURIComponent()` is not used on its own because it throws away the whole string as soon as it sees one byte that is not valid UTF-8, and a query string from the network often has one. `decodeSequence()` is what runs then, using the algorithm the `URL` parser uses: a byte that cannot be decoded becomes U+FFFD, and the bytes around it are still decoded.
371
+ //
372
+ // It is a single left-to-right pass, so the cost is linear in the length of the value. It replaces `decode-uri-component`, whose fallback for a string that `decodeURIComponent()` rejects collected every distinct percent-encoded run into a replacement map and then replaced each entry across the whole string, so a value with N distinct runs cost N passes over N bytes.
373
+ function decodeComponent(value) {
374
+ try {
375
+ // Almost every value is valid percent-encoded UTF-8, and the built-in decoder handles those in one pass in C++, which is far faster than the step at a time above. It gives the same answer, so this only decides which of the two paths runs.
376
+ return decodeURIComponent(value);
377
+ } catch {
378
+ // One byte that cannot be decoded makes the built-in decoder throw away the whole value, so the value is decoded the lenient way instead.
379
+ }
380
+
381
+ percentSign.lastIndex = 0;
382
+
383
+ let output = '';
384
+ let position = 0;
385
+ let match = percentSign.exec(value);
386
+
387
+ while (match !== null) {
388
+ // Everything between the last escape and this one is copied as it is.
389
+ output += value.slice(position, match.index);
390
+
391
+ const [decoded, nextPosition] = decodeSequence(value, match.index);
392
+ output += decoded;
393
+ position = nextPosition;
394
+
395
+ percentSign.lastIndex = position;
396
+ match = percentSign.exec(value);
397
+ }
398
+
399
+ return output + value.slice(position);
400
+ }
401
+
402
+ function decode(value, options) {
403
+ return options.decode ? decodeComponent(value) : value;
404
+ }
405
+
406
+ // The key as `parse()` would decode it, so two spellings of the same key, like `a+b` and `a%20b`, can be compared.
407
+ function decodeQueryKey(key) {
408
+ return decodeComponent(key.replaceAll('+', ' '));
409
+ }
410
+
411
+ // An option that is explicitly `undefined` uses its default, like an omitted option. That is what a caller passes through with an optional setting, like `{decode: config.decode}`. Spreading it over the defaults would instead override the default with `undefined`, so `{encode: undefined}` would write the values unencoded.
412
+ function definedOptions(options) {
413
+ const result = {};
414
+
415
+ for (const [key, value] of Object.entries(options ?? {})) {
416
+ if (value !== undefined) {
417
+ result[key] = value;
418
+ }
419
+ }
420
+
421
+ return result;
422
+ }
423
+
424
+ // The `index` array format collects the items in an object keyed by the index. The keys are sorted as strings first, so that two spellings of the same index, like `01` and `1`, always come out in the same order, and then numerically. The empty key is the item of a scalar seen before the indexed entries, and it sorts before any index.
425
+ function arrayFromIndexes(object) {
426
+ return Object.keys(object)
427
+ // eslint-disable-next-line unicorn/require-array-sort-compare -- The default string sort is what puts `01` and `1` in the same order.
428
+ .toSorted()
429
+ .toSorted((a, b) => Number(a) - Number(b))
430
+ .map(key => object[key]);
288
431
  }
289
432
 
290
433
  function removeHash(input) {
@@ -333,37 +476,31 @@ function parseValue(value, options, type) {
333
476
  return value.toLowerCase() === '1';
334
477
  }
335
478
 
336
- if (type === 'string[]' && options.arrayFormat !== 'none' && typeof value === 'string') {
337
- return [value];
338
- }
339
-
340
- if (type === 'number[]' && options.arrayFormat !== 'none' && !Number.isNaN(Number(value)) && (typeof value === 'string' && value.trim() !== '')) {
341
- return [Number(value)];
479
+ if ((type === 'string[]' || type === 'number[]') && options.arrayFormat !== 'none' && (typeof value === 'string' || value === null)) {
480
+ // A single value is still an array. The item is parsed with the element type, so a value that is not a number stays a string. A valueless key is an array with a `null` item, like a valueless item of a repeated key.
481
+ return [parseValue(value, options, type.slice(0, -2))];
342
482
  }
343
483
 
344
- if (type === 'number' && !Number.isNaN(Number(value)) && (typeof value === 'string' && value.trim() !== '')) {
484
+ if (type === 'number' && typeof value === 'string' && value.trim() !== '' && !Number.isNaN(Number(value))) {
345
485
  return Number(value);
346
486
  }
347
487
 
348
- if (options.parseBooleans && value !== null && (value.toLowerCase() === 'true' || value.toLowerCase() === 'false')) {
349
- return value.toLowerCase() === 'true';
488
+ // An explicit type takes precedence over `parseNumbers` and `parseBooleans`. A value that does not match its type stays a string. With `arrayFormat: 'none'`, a single value with an array type is not an array and stays a string too.
489
+ if (['boolean', 'number', 'string[]', 'number[]'].includes(type)) {
490
+ return value;
350
491
  }
351
492
 
352
- if (options.parseNumbers && !Number.isNaN(Number(value)) && (typeof value === 'string' && value.trim() !== '')) {
353
- return Number(value);
493
+ if (value !== null && options.parseBooleans && (value.toLowerCase() === 'true' || value.toLowerCase() === 'false')) {
494
+ return value.toLowerCase() === 'true';
354
495
  }
355
496
 
356
- return value;
497
+ return typeof value === 'string' && options.parseNumbers && value.trim() !== '' && !Number.isNaN(Number(value)) ? Number(value) : value;
357
498
  }
358
499
 
359
500
  export function extract(input) {
360
501
  input = removeHash(input);
361
502
  const queryStart = input.indexOf('?');
362
- if (queryStart === -1) {
363
- return '';
364
- }
365
-
366
- return input.slice(queryStart + 1);
503
+ return queryStart === -1 ? '' : input.slice(queryStart + 1);
367
504
  }
368
505
 
369
506
  export function parse(query, options) {
@@ -374,8 +511,8 @@ export function parse(query, options) {
374
511
  arrayFormatSeparator: ',',
375
512
  parseNumbers: false,
376
513
  parseBooleans: false,
377
- types: Object.create(null),
378
- ...options,
514
+ ...definedOptions(options),
515
+ types: {__proto__: null, ...options?.types},
379
516
  };
380
517
 
381
518
  validateArrayFormatSeparator(options.arrayFormatSeparator);
@@ -389,13 +526,9 @@ export function parse(query, options) {
389
526
  return returnValue;
390
527
  }
391
528
 
392
- query = query.trim().replace(/^[?#&]/, '');
393
-
394
- if (!query) {
395
- return returnValue;
396
- }
529
+ query = query.trim().replace(/^[#&?]/v, '');
397
530
 
398
- if (/^&+$/.test(query)) {
531
+ if (!query || /^&+$/v.test(query)) {
399
532
  return returnValue;
400
533
  }
401
534
 
@@ -425,22 +558,32 @@ export function parse(query, options) {
425
558
  }
426
559
 
427
560
  // Missing `=` should be `null`:
428
- // http://w3.org/TR/2012/WD-url-20120524/#collect-url-parameters
561
+ // https://w3.org/TR/2012/WD-url-20120524/#collect-url-parameters
429
562
  value = value === undefined ? null : (['comma', 'separator', 'bracket-separator'].includes(options.arrayFormat) ? value : decode(value, options));
563
+
564
+ // The key is decoded before the array format looks for its marker, so a percent-encoded marker like `foo%5B%5D` is also an array item. This is intentional. `URLSearchParams` and HTML forms percent-encode `[`, `]`, and `:` in keys, and servers like Rack and PHP decode the key before they look for the marker. The trade-off is that a key that itself ends in a marker is not distinguishable from the marker after decoding, so `stringify({'foo[]': 'x'}, {arrayFormat: 'bracket'})` does not round-trip.
430
565
  formatter(decode(key, options), value, returnValue);
431
566
 
432
567
  parameterStart = index + 1;
433
568
  }
434
569
 
435
- for (const [key, value] of Object.entries(returnValue)) {
436
- if (typeof value === 'object' && value !== null && options.types[key] !== 'string') {
437
- for (const [key2, value2] of Object.entries(value)) {
438
- const typeOption = options.types[key];
439
- const type = typeof typeOption === 'function' ? typeOption : (typeOption ? typeOption.replace('[]', '') : undefined);
440
- value[key2] = parseValue(value2, options, type);
570
+ for (let [key, value] of Object.entries(returnValue)) {
571
+ // Only the `index` array format collects the items in an object instead of an array.
572
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
573
+ value = arrayFromIndexes(value);
574
+ returnValue[key] = value;
575
+ }
576
+
577
+ if (Array.isArray(value) && options.types[key] !== 'string') {
578
+ // The items are parsed with the element type. A type that is not a string, like a function, is passed through, and anything else is ignored, like it is for a scalar value.
579
+ const typeOption = options.types[key];
580
+ const type = typeof typeOption === 'string' ? typeOption.replace('[]', '') : typeOption;
581
+
582
+ for (const [index, item] of value.entries()) {
583
+ value[index] = parseValue(item, options, type);
441
584
  }
442
- } else if (typeof value === 'object' && value !== null && options.types[key] === 'string') {
443
- returnValue[key] = Object.values(value).join(options.arrayFormatSeparator);
585
+ } else if (Array.isArray(value) && options.types[key] === 'string') {
586
+ returnValue[key] = value.join(options.arrayFormatSeparator);
444
587
  } else {
445
588
  returnValue[key] = parseValue(value, options, options.types[key]);
446
589
  }
@@ -451,10 +594,10 @@ export function parse(query, options) {
451
594
  }
452
595
 
453
596
  // TODO: Remove the use of `reduce`.
597
+ // The default sort is the documented one, and a test covers its code-unit ordering. A `sort` that is neither `false` nor a function sorts, which is how `stringify()` reads the same option, so that one options object works for both. Passing the option straight to `toSorted()` instead threw for any other value, with a message about a comparison function.
454
598
  // eslint-disable-next-line unicorn/no-array-reduce
455
- return (options.sort === true ? Object.keys(returnValue).sort() : Object.keys(returnValue).sort(options.sort)).reduce((result, key) => {
456
- const value = returnValue[key];
457
- result[key] = Boolean(value) && typeof value === 'object' && !Array.isArray(value) ? keysSorter(value) : value;
599
+ return Object.keys(returnValue).toSorted(typeof options.sort === 'function' ? options.sort : undefined).reduce((result, key) => {
600
+ result[key] = returnValue[key];
458
601
  return result;
459
602
  }, Object.create(null));
460
603
  }
@@ -469,46 +612,39 @@ export function stringify(object, options) {
469
612
  strict: true,
470
613
  arrayFormat: 'none',
471
614
  arrayFormatSeparator: ',',
472
- ...options,
615
+ ...definedOptions(options),
473
616
  };
474
617
 
475
618
  validateArrayFormatSeparator(options.arrayFormatSeparator);
476
619
 
477
- const shouldFilter = key => (
478
- (options.skipNull && isNullOrUndefined(object[key]))
479
- || (options.skipEmptyString && object[key] === '')
480
- );
481
-
482
620
  const formatter = encoderForArrayFormat(options);
483
621
 
622
+ // Copying into a plain object intentionally drops a key named `__proto__`, since assigning it sets the prototype instead of a property.
484
623
  const objectCopy = {};
485
624
 
625
+ // `Object.fromEntries()` would define the key as a real own property instead,
626
+ // which would keep a `__proto__` key that is meant to be dropped here.
627
+ // eslint-disable-next-line unicorn/prefer-object-from-entries
486
628
  for (const [key, value] of Object.entries(object)) {
487
- if (!shouldFilter(key)) {
488
- objectCopy[key] = value;
489
- }
629
+ objectCopy[key] = value;
490
630
  }
491
631
 
492
632
  const keys = Object.keys(objectCopy);
493
633
 
494
634
  if (options.sort !== false) {
495
- keys.sort(options.sort);
635
+ keys.sort(typeof options.sort === 'function' ? options.sort : undefined);
496
636
  }
497
637
 
498
638
  return keys.map(key => {
499
- let value = object[key];
639
+ let value = objectCopy[key];
500
640
 
501
641
  // Apply replacer function if provided
502
642
  if (options.replacer) {
503
643
  value = options.replacer(key, value);
504
-
505
- // If replacer returns undefined, skip this key
506
- if (value === undefined) {
507
- return '';
508
- }
509
644
  }
510
645
 
511
- if (value === undefined) {
646
+ // `skipNull` and `skipEmptyString` look at the replaced value, like they do for array items. A replacer that returns `undefined` skips the key.
647
+ if (isSkipped(value, options)) {
512
648
  return '';
513
649
  }
514
650
 
@@ -517,12 +653,8 @@ export function stringify(object, options) {
517
653
  }
518
654
 
519
655
  if (Array.isArray(value)) {
520
- if (value.length === 0 && options.arrayFormat === 'bracket-separator') {
521
- return encode(key, options) + '[]';
522
- }
523
-
524
656
  // Apply replacer to array elements if provided
525
- // Note: We don't re-apply replacer to the array itself, only to elements
657
+ // Note: The replacer was already applied to the array itself above. Here it is applied to each element.
526
658
  let processedArray = value;
527
659
  if (options.replacer) {
528
660
  processedArray = value.map((item, index) =>
@@ -531,20 +663,31 @@ export function stringify(object, options) {
531
663
  }
532
664
 
533
665
  const result = processedArray.reduce(formatter(key), []);
666
+
667
+ // An array with no items left after skipping is empty. `bracket-separator` writes an empty array as `foo[]`, which `parse()` reads back as `[]`. The other formats have no representation for an empty array, so the key is dropped.
668
+ if (result.length === 0) {
669
+ return options.arrayFormat === 'bracket-separator' ? encode(key, options) + '[]' : '';
670
+ }
671
+
534
672
  const arrayFormatSeparator = ['comma', 'separator', 'bracket-separator'].includes(options.arrayFormat) ? options.arrayFormatSeparator : '&';
535
673
  return result.join(arrayFormatSeparator);
536
674
  }
537
675
 
538
- return encode(key, options) + '=' + encode(value, options);
676
+ // With `comma` and `separator`, `parse()` splits any value on the separator, so a scalar that contains it is encoded like an array item. `bracket-separator` only splits a value with the array marker.
677
+ const encodedValue = options.arrayFormat === 'comma' || options.arrayFormat === 'separator' ? encodeArrayItem(value, options) : encode(value, options);
678
+ return encode(key, options) + '=' + encodedValue;
539
679
  }).filter(x => x.length > 0).join('&');
540
680
  }
541
681
 
542
682
  export function parseUrl(url, options) {
543
683
  options = {
544
684
  decode: true,
545
- ...options,
685
+ ...definedOptions(options),
546
686
  };
547
687
 
688
+ // Leading and trailing whitespace is ignored, like `parse()` does for the query and the URL parser does for the whole URL. Otherwise a newline from pasted input would end up in `fragmentIdentifier`.
689
+ url = url.trim();
690
+
548
691
  let [url_, hash] = splitOnFirst(url, '#');
549
692
 
550
693
  if (url_ === undefined) {
@@ -552,9 +695,9 @@ export function parseUrl(url, options) {
552
695
  }
553
696
 
554
697
  return {
555
- url: getUrlWithoutQuery(url_ ?? ''),
698
+ url: getUrlWithoutQuery(url_),
556
699
  query: parse(extract(url), options),
557
- ...(options && options.parseFragmentIdentifier && hash ? {fragmentIdentifier: decode(hash, options)} : {}),
700
+ ...(options.parseFragmentIdentifier && hash && {fragmentIdentifier: decode(hash, options)}),
558
701
  };
559
702
  }
560
703
 
@@ -562,29 +705,58 @@ export function stringifyUrl(object, options) {
562
705
  options = {
563
706
  encode: true,
564
707
  strict: true,
565
- [encodeFragmentIdentifier]: true,
566
- ...options,
708
+ ...definedOptions(options),
567
709
  };
568
710
 
569
- const url = getUrlWithoutQuery(removeHash(object.url)) || '';
570
- const queryFromUrl = extract(object.url);
711
+ // Leading and trailing whitespace is ignored, like `parseUrl()` does. `parse()` already trims the query, so whitespace after it disappeared, while whitespace before it or after the fragment identifier was written back, like `https://foo.bar/ ?a=1`.
712
+ const inputUrl = object.url.trim();
571
713
 
572
- const query = {
573
- ...parse(queryFromUrl, {sort: false, ...options}),
574
- ...object.query,
575
- };
714
+ const url = getUrlWithoutQuery(removeHash(inputUrl));
715
+ const queryFromUrl = extract(inputUrl);
716
+
717
+ // When the output is not encoded, keep the existing query as-is instead of decoding it. Decoding without re-encoding would corrupt it.
718
+ // The query in the URL is written back from the plain string values, so `parseNumbers`, `parseBooleans`, and `types` passed along with the stringify options, like when the same options object is shared with `parseUrl()`, do not rewrite a value like `007` as `7`.
719
+ let query = parse(queryFromUrl, {
720
+ sort: false,
721
+ ...options,
722
+ decode: options.encode,
723
+ parseNumbers: false,
724
+ parseBooleans: false,
725
+ types: undefined,
726
+ });
727
+
728
+ const queryFromObject = Object.fromEntries(Object.entries(object.query ?? {}));
729
+
730
+ // The query in the URL is not decoded without encoding, so its keys are compared in their decoded form instead, like they are when the output is encoded. Otherwise a key like `%61` in the URL would not be overridden by an `a` in `query`.
731
+ if (!options.encode) {
732
+ const overrides = new Map();
733
+
734
+ for (const [key, value] of Object.entries(queryFromObject)) {
735
+ const decodedKey = decodeQueryKey(key);
736
+ if (!overrides.has(decodedKey)) {
737
+ overrides.set(decodedKey, {key, value});
738
+ }
739
+ }
740
+
741
+ query = Object.fromEntries(Object.entries(query).map(([key, value]) => {
742
+ const override = overrides.get(decodeQueryKey(key));
743
+ return override ? [override.key, override.value] : [key, value];
744
+ }));
745
+ }
576
746
 
577
- let queryString = stringify(query, options);
747
+ let queryString = stringify({...query, ...queryFromObject}, options);
578
748
  queryString &&= `?${queryString}`;
579
749
 
580
- let hash = getHash(object.url);
750
+ let hash = getHash(inputUrl);
581
751
  if (typeof object.fragmentIdentifier === 'string') {
582
- hash = `#${object.fragmentIdentifier}`;
752
+ // An empty fragment identifier removes the fragment, like `url.hash = ''` does.
753
+ hash = object.fragmentIdentifier === '' ? '' : `#${object.fragmentIdentifier}`;
583
754
 
584
- if (options[encodeFragmentIdentifier]) {
585
- // Percent-encode the fragment with a throwaway URL. The fragment percent-encode set does not depend on the scheme, so the caller's URL is deliberately not parsed here; it may not even be parseable.
755
+ if (options.encode) {
756
+ // The fragment identifier is a decoded value, like the query values, so `parseUrl()` reads it back unchanged. That is what the `fragment` component of Go's `net/url`, Java's `URI`, and Swift's `URLComponents` does too. The `URL` hash setter instead expects an already-encoded fragment: it leaves `%` as-is, which would turn `100%` into an invalid URL and `%23top` into `#top` after parsing. So `%` is encoded here first. The URL parser also silently strips ASCII tab and newline from its input, so those are encoded first too. The setter then encodes the rest of the WHATWG fragment percent-encode set (controls, space, `"`, `<`, `>`, and backtick) and keeps `/`, `?`, and `#`, so hash routes like `#/users?id=1` stay readable. With `encode: false`, the fragment identifier is written as-is, like the query.
757
+ // The fragment percent-encode set does not depend on the scheme, so the caller's URL is deliberately not parsed here; it may not even be parseable.
586
758
  const throwawayUrl = new URL('https://query-string.invalid');
587
- throwawayUrl.hash = hash;
759
+ throwawayUrl.hash = hash.replaceAll(/[\t\n\r%]/gv, character => `%${character.codePointAt(0).toString(16).toUpperCase().padStart(2, '0')}`);
588
760
  hash = throwawayUrl.hash;
589
761
  }
590
762
  }
@@ -593,18 +765,32 @@ export function stringifyUrl(object, options) {
593
765
  }
594
766
 
595
767
  export function pick(input, filter, options) {
596
- options = {
597
- parseFragmentIdentifier: true,
598
- [encodeFragmentIdentifier]: false,
599
- ...options,
600
- };
768
+ // The keys array is a filter, not an order. Filtering with a predicate keeps the parsed object's key order, which matters with `sort: false`. Integer-index keys are enumerated first in numeric order, and repeated keys are grouped together.
769
+ if (Array.isArray(filter)) {
770
+ const filterSet = new Set(filter);
771
+ // eslint-disable-next-line unicorn/no-useless-recursion -- The array case is the same operation as the predicate case, so it delegates instead of duplicating the loop.
772
+ return pick(input, key => filterSet.has(key), options);
773
+ }
774
+
775
+ options = definedOptions(options);
601
776
 
602
- const {url, query, fragmentIdentifier} = parseUrl(input, options);
777
+ // `parseNumbers`, `parseBooleans`, and `types` only apply to the `value` the predicate receives. The URL is written back from the plain string values, so a value like `007` is kept as-is instead of being rewritten as `7`.
778
+ // The query is decoded exactly when it is encoded again, so a value like `a%20b` is not percent-encoded a second time. When the output is not encoded, the input is not decoded either, since decoding without re-encoding would corrupt it. The `decode` option is ignored: decoding the predicate's view differently from the output would give the two views different keys, so a percent-encoded key, or a `+` in a key, would not find its value.
779
+ // An `encode` that is not a boolean is read for its truthiness, the same way `stringify()` reads it, so that `null` turns encoding off. Reading it as nullish instead would decode the query and then write it back unencoded, which puts a decoded `&` or `#` into the URL. `definedOptions()` has already dropped an `encode` that is `undefined`, so that is the only way to mean the default.
780
+ const shouldDecode = options.encode === undefined ? true : Boolean(options.encode);
781
+ const {url, query} = parseUrl(input, {
782
+ ...options,
783
+ decode: shouldDecode,
784
+ parseNumbers: false,
785
+ parseBooleans: false,
786
+ types: undefined,
787
+ });
788
+ const typedQuery = parse(extract(input), {...options, decode: shouldDecode});
603
789
 
790
+ // Keep the fragment identifier as-is. Decoding and re-encoding it could change it.
604
791
  return stringifyUrl({
605
- url,
606
- query: includeKeys(query, filter),
607
- fragmentIdentifier,
792
+ url: url + getHash(input),
793
+ query: includeKeys(query, key => filter(key, typedQuery[key])),
608
794
  }, options);
609
795
  }
610
796