1/*
2 * MIT License
3 *
4 * Copyright (c) 2017-2019 Tomas Della Vedova
5 *
6 * Permission is hereby granted, free of charge, to any person obtaining a copy
7 * of this software and associated documentation files (the "Software"), to deal
8 * in the Software without restriction, including without limitation the rights
9 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 * copies of the Software, and to permit persons to whom the Software is
11 * furnished to do so, subject to the following conditions:
12 *
13 * The above copyright notice and this permission notice shall be included in all
14 * copies or substantial portions of the Software.
15 *
16 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 * SOFTWARE.
23 */
24
25/**
26 * @since 1.0.0
27 */
28// Taken from https://github.com/anonrig/fast-querystring under MIT License
29const plusRegex = /\+/g
30const Empty: new() => Record<string, any> = function() {} as any
31Empty.prototype = Object.create(null)
32
33/**
34 * @category parsing
35 * @since 1.0.0
36 */
37export function parse(input: string) {
38 // Optimization: Use new Empty() instead of Object.create(null) for performance
39 // v8 has a better optimization for initializing functions compared to Object
40 const result = new Empty()
41
42 if (typeof input !== "string") {
43 return result
44 }
45
46 const inputLength = input.length
47 let key = ""
48 let value = ""
49 let startingIndex = -1
50 let equalityIndex = -1
51 let shouldDecodeKey = false
52 let shouldDecodeValue = false
53 let keyHasPlus = false
54 let valueHasPlus = false
55 let hasBothKeyValuePair = false
56 let c = 0
57
58 // Have a boundary of input.length + 1 to access last pair inside the loop.
59 for (let i = 0; i < inputLength + 1; i++) {
60 c = i !== inputLength ? input.charCodeAt(i) : 38
61
62 // Handle '&' and end of line to pass the current values to result
63 if (c === 38) {
64 hasBothKeyValuePair = equalityIndex > startingIndex
65
66 // Optimization: Reuse equality index to store the end of key
67 if (!hasBothKeyValuePair) {
68 equalityIndex = i
69 }
70
71 key = input.slice(startingIndex + 1, equalityIndex)
72
73 // Add key/value pair only if the range size is greater than 1; a.k.a. contains at least "="
74 if (hasBothKeyValuePair || key.length > 0) {
75 // Optimization: Replace '+' with space
76 if (keyHasPlus) {
77 key = key.replace(plusRegex, " ")
78 }
79
80 // Optimization: Do not decode if it's not necessary.
81 if (shouldDecodeKey) {
82 try {
83 key = decodeURIComponent(key) || key
84 } catch {}
85 }
86
87 if (hasBothKeyValuePair) {
88 value = input.slice(equalityIndex + 1, i)
89
90 if (valueHasPlus) {
91 value = value.replace(plusRegex, " ")
92 }
93
94 if (shouldDecodeValue) {
95 try {
96 value = decodeURIComponent(value) || value
97 } catch {}
98 }
99 }
100 const currentValue = result[key]
101
102 if (currentValue === undefined) {
103 result[key] = value
104 } else {
105 // Optimization: value.pop is faster than Array.isArray(value)
106 if (currentValue.pop) {
107 currentValue.push(value)
108 } else {
109 result[key] = [currentValue, value]
110 }
111 }
112 }
113
114 // Reset reading key value pairs
115 value = ""
116 startingIndex = i
117 equalityIndex = i
118 shouldDecodeKey = false
119 shouldDecodeValue = false
120 keyHasPlus = false
121 valueHasPlus = false
122 } // Check '='
123 else if (c === 61) {
124 if (equalityIndex <= startingIndex) {
125 equalityIndex = i
126 } // If '=' character occurs again, we should decode the input.
127 else {
128 shouldDecodeValue = true
129 }
130 } // Check '+', and remember to replace it with empty space.
131 else if (c === 43) {
132 if (equalityIndex > startingIndex) {
133 valueHasPlus = true
134 } else {
135 keyHasPlus = true
136 }
137 } // Check '%' character for encoding
138 else if (c === 37) {
139 if (equalityIndex > startingIndex) {
140 shouldDecodeValue = true
141 } else {
142 shouldDecodeKey = true
143 }
144 }
145 }
146
147 return result
148}
149
150function getAsPrimitive(value: any) {
151 const type = typeof value
152
153 if (type === "string") {
154 // Length check is handled inside encodeString function
155 return encodeString(value)
156 } else if (type === "bigint" || type === "boolean") {
157 return "" + value
158 } else if (type === "number" && Number.isFinite(value)) {
159 return value < 1e21 ? "" + value : encodeString("" + value)
160 }
161
162 return ""
163}
164
165/**
166 * @category encoding
167 * @since 1.0.0
168 */
169export function stringify(input: Record<string, any>): string {
170 let result = ""
171
172 if (input === null || typeof input !== "object") {
173 return result
174 }
175
176 const separator = "&"
177 const keys = Object.keys(input)
178 const keyLength = keys.length
179 let valueLength = 0
180
181 for (let i = 0; i < keyLength; i++) {
182 const key = keys[i]
183 const value = input[key]
184 const encodedKey = encodeString(key) + "="
185
186 if (i) {
187 result += separator
188 }
189
190 if (Array.isArray(value)) {
191 valueLength = value.length
192 for (let j = 0; j < valueLength; j++) {
193 if (j) {
194 result += separator
195 }
196
197 // Optimization: Dividing into multiple lines improves the performance.
198 // Since v8 does not need to care about the '+' character if it was one-liner.
199 result += encodedKey
200 result += getAsPrimitive(value[j])
201 }
202 } else {
203 result += encodedKey
204 result += getAsPrimitive(value)
205 }
206 }
207
208 return result
209}
210
211// -----------------------------------------------------------------------------
212
213// This has been taken from Node.js project.
214// Full implementation can be found from https://github.com/nodejs/node/blob/main/lib/internal/querystring.js
215
216const hexTable = Array.from(
217 { length: 256 },
218 (_, i) => "%" + ((i < 16 ? "0" : "") + i.toString(16)).toUpperCase()
219)
220
221// These characters do not need escaping when generating query strings:
222// ! - . _ ~
223// ' ( ) *
224// digits
225// alpha (uppercase)
226// alpha (lowercase)
227// biome-ignore format: the array should not be formatted
228const noEscape = new Int8Array([
229 0,
230 0,
231 0,
232 0,
233 0,
234 0,
235 0,
236 0,
237 0,
238 0,
239 0,
240 0,
241 0,
242 0,
243 0,
244 0, // 0 - 15
245 0,
246 0,
247 0,
248 0,
249 0,
250 0,
251 0,
252 0,
253 0,
254 0,
255 0,
256 0,
257 0,
258 0,
259 0,
260 0, // 16 - 31
261 0,
262 1,
263 0,
264 0,
265 0,
266 0,
267 0,
268 1,
269 1,
270 1,
271 1,
272 0,
273 0,
274 1,
275 1,
276 0, // 32 - 47
277 1,
278 1,
279 1,
280 1,
281 1,
282 1,
283 1,
284 1,
285 1,
286 1,
287 0,
288 0,
289 0,
290 0,
291 0,
292 0, // 48 - 63
293 0,
294 1,
295 1,
296 1,
297 1,
298 1,
299 1,
300 1,
301 1,
302 1,
303 1,
304 1,
305 1,
306 1,
307 1,
308 1, // 64 - 79
309 1,
310 1,
311 1,
312 1,
313 1,
314 1,
315 1,
316 1,
317 1,
318 1,
319 1,
320 0,
321 0,
322 0,
323 0,
324 1, // 80 - 95
325 0,
326 1,
327 1,
328 1,
329 1,
330 1,
331 1,
332 1,
333 1,
334 1,
335 1,
336 1,
337 1,
338 1,
339 1,
340 1, // 96 - 111
341 1,
342 1,
343 1,
344 1,
345 1,
346 1,
347 1,
348 1,
349 1,
350 1,
351 1,
352 0,
353 0,
354 0,
355 1,
356 0 // 112 - 127
357])
358
359function encodeString(str: string) {
360 const len = str.length
361 if (len === 0) return ""
362
363 let out = ""
364 let lastPos = 0
365 let i = 0
366
367 outer: for (; i < len; i++) {
368 let c = str.charCodeAt(i)
369
370 // ASCII
371 while (c < 0x80) {
372 if (noEscape[c] !== 1) {
373 if (lastPos < i) out += str.slice(lastPos, i)
374 lastPos = i + 1
375 out += hexTable[c]
376 }
377
378 if (++i === len) break outer
379
380 c = str.charCodeAt(i)
381 }
382
383 if (lastPos < i) out += str.slice(lastPos, i)
384
385 // Multi-byte characters ...
386 if (c < 0x800) {
387 lastPos = i + 1
388 out += hexTable[0xc0 | (c >> 6)] + hexTable[0x80 | (c & 0x3f)]
389 continue
390 }
391 if (c < 0xd800 || c >= 0xe000) {
392 lastPos = i + 1
393 out += hexTable[0xe0 | (c >> 12)] +
394 hexTable[0x80 | ((c >> 6) & 0x3f)] +
395 hexTable[0x80 | (c & 0x3f)]
396 continue
397 }
398 // Surrogate pair
399 ++i
400
401 // This branch should never happen because all URLSearchParams entries
402 // should already be converted to USVString. But, included for
403 // completion's sake anyway.
404 if (i >= len) {
405 throw new Error("URI malformed")
406 }
407
408 const c2 = str.charCodeAt(i) & 0x3ff
409
410 lastPos = i + 1
411 c = 0x10000 + (((c & 0x3ff) << 10) | c2)
412 out += hexTable[0xf0 | (c >> 18)] +
413 hexTable[0x80 | ((c >> 12) & 0x3f)] +
414 hexTable[0x80 | ((c >> 6) & 0x3f)] +
415 hexTable[0x80 | (c & 0x3f)]
416 }
417 if (lastPos === 0) return str
418 if (lastPos < len) return out + str.slice(lastPos)
419 return out
420}