Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
222 changes: 205 additions & 17 deletions packages/router-core/src/qss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,161 @@
* Copyright (c) Luke Edwards luke.edwards05@gmail.com, MIT License
* https://github.com/lukeed/qss/blob/master/license.md
*
* This reimplementation uses modern browser APIs
* (namely URLSearchParams) and TypeScript while still
* maintaining the original functionality and interface.
* This reimplementation matches `URLSearchParams` encode/decode
* (application/x-www-form-urlencoded) without constructing one on
* every call.
*
* Update: this implementation has also been mangled to
* fit exactly our use-case (single value per key in encoding).
*/

function percentEncodeByte(code: number): string {
return '%' + (code + 256).toString(16).toUpperCase().slice(1)
}

function hexValue(code: number): number {
if (code >= 48 && code <= 57) {
return code - 48
}
if (code >= 65 && code <= 70) {
return code - 55
}
if (code >= 97 && code <= 102) {
return code - 87
}
return -1
}

/**
* Replace unpaired surrogates with U+FFFD so `encodeURIComponent` matches
* `URLSearchParams` instead of throwing.
*/
function replaceLoneSurrogates(str: string): string {
let out = ''
const len = str.length
for (let i = 0; i < len; i++) {
const c = str.charCodeAt(i)
if (c >= 0xd800 && c <= 0xdbff) {
const next = i + 1 < len ? str.charCodeAt(i + 1) : 0
if (next >= 0xdc00 && next <= 0xdfff) {
out += String.fromCharCode(c, next)
i++
} else {
out += '\uFFFD'
}
} else if (c >= 0xdc00 && c <= 0xdfff) {
out += '\uFFFD'
} else {
out += String.fromCharCode(c)
}
}
return out
}

function encodeURIComponentForm(str: string): string {
let encoded: string
try {
encoded = encodeURIComponent(str)
} catch {
encoded = encodeURIComponent(replaceLoneSurrogates(str))
}
return encoded
.replace(/%20/g, '+')
.replace(/[!'()~]/g, (ch) => percentEncodeByte(ch.charCodeAt(0)))
}

/**
* Encode one component the way `URLSearchParams` does:
* unreserved is `*-.0-9A-Z_a-z`, space becomes `+`, everything else is %HH.
*/
function encodeFormComponent(value: unknown): string {
const str = typeof value === 'string' ? value : String(value)
const len = str.length
let i = 0
for (; i < len; i++) {
const c = str.charCodeAt(i)
if (
(c >= 48 && c <= 57) ||
(c >= 65 && c <= 90) ||
(c >= 97 && c <= 122) ||
c === 42 ||
c === 45 ||
c === 46 ||
c === 95
) {
continue
}
break
}
if (i === len) {
return str
}

let out = str.slice(0, i)
for (; i < len; i++) {
const c = str.charCodeAt(i)
if (
(c >= 48 && c <= 57) ||
(c >= 65 && c <= 90) ||
(c >= 97 && c <= 122) ||
c === 42 ||
c === 45 ||
c === 46 ||
c === 95
) {
out += String.fromCharCode(c)
} else if (c === 32) {
out += '+'
} else if (c < 128) {
out += percentEncodeByte(c)
} else {
return out + encodeURIComponentForm(str.slice(i))
}
}
return out
}

/**
* WHATWG form-urlencoded percent-decode: valid `%HH` becomes a byte, malformed
* `%` stays literal, then the bytes are UTF-8 decoded with replacement.
*/
function decodeFormComponentLenient(input: string): string {
const utf8 = new TextEncoder().encode(input)
const out = new Uint8Array(utf8.length)
let n = 0
for (let i = 0; i < utf8.length; i++) {
const b = utf8[i]!
if (b === 0x25 && i + 2 < utf8.length) {
const h1 = hexValue(utf8[i + 1]!)
const h2 = hexValue(utf8[i + 2]!)
if (h1 >= 0 && h2 >= 0) {
out[n++] = (h1 << 4) | h2
i += 2
continue
}
}
out[n++] = b
}
return new TextDecoder().decode(out.subarray(0, n))
}

function decodeFormComponent(str: string): string {
const plus = str.indexOf('+')
const pct = str.indexOf('%')
if (plus === -1 && pct === -1) {
return str
}
const input = plus === -1 ? str : str.replace(/\+/g, ' ')
if (pct === -1) {
return input
}
try {
return decodeURIComponent(input)
} catch {
return decodeFormComponentLenient(input)
}
}

/**
* Encodes an object into a query string.
* @param obj - The object to encode into a query string.
Expand All @@ -26,16 +173,24 @@ export function encode(
obj: Record<string, any>,
stringify: (value: any) => string = String,
): string {
const result = new URLSearchParams()

let out = ''
let first = true
for (const key in obj) {
const val = obj[key]
if (val !== undefined) {
result.set(key, stringify(val))
if (val === undefined) {
continue
}
if (!first) {
out += '&'
} else {
first = false
}
out += encodeFormComponent(key)
out += '='
out += encodeFormComponent(stringify(val))
}

return result.toString()
return out
}

/**
Expand All @@ -47,10 +202,16 @@ export function encode(
* // Expected output: 123
*/
function toValue(str: unknown) {
if (!str) return ''
if (!str) {
return ''
}

if (str === 'false') return false
if (str === 'true') return true
if (str === 'false') {
return false
}
if (str === 'true') {
return true
}
return +str * 0 === 0 && +str + '' === str ? +str : str
}
/**
Expand All @@ -62,18 +223,45 @@ function toValue(str: unknown) {
* // Expected output: { "token": "foo", "key": "value" }
*/
export function decode(str: any): any {
const searchParams = new URLSearchParams(str)

const result: Record<string, unknown> = Object.create(null)
if (str == null) {
return result
}
if (typeof str !== 'string') {
str = String(str)
}
if (!str) {
return result
}

let offset = str.charCodeAt(0) === 63 ? 1 : 0
const len = str.length
while (offset < len) {
let amp = str.indexOf('&', offset)
if (amp === -1) {
amp = len
}
if (amp === offset) {
offset++
continue
}

const eq = str.indexOf('=', offset)
const rawKey =
eq === -1 || eq > amp ? str.slice(offset, amp) : str.slice(offset, eq)
const rawVal = eq === -1 || eq > amp ? '' : str.slice(eq + 1, amp)
offset = amp + 1

const key = decodeFormComponent(rawKey)
const value = toValue(decodeFormComponent(rawVal))

for (const [key, value] of searchParams.entries()) {
const previousValue = result[key]
if (previousValue == null) {
result[key] = toValue(value)
result[key] = value
} else if (Array.isArray(previousValue)) {
previousValue.push(toValue(value))
previousValue.push(value)
} else {
result[key] = [previousValue, toValue(value)]
result[key] = [previousValue, value]
}
}

Expand Down
42 changes: 42 additions & 0 deletions packages/router-core/tests/qss.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,35 @@ describe('encode function', () => {
const queryString = encode(obj)
expect(queryString).toEqual('foo%3Dbar=1')
})

it('should match URLSearchParams encoding for form-urlencoded reserved characters', () => {
const obj = { q: 'a b', x: '!~*()~', n: 1 }
const expected = new URLSearchParams(
Object.entries(obj).map(([key, value]) => [key, String(value)]),
).toString()
expect(encode(obj)).toEqual(expected)
})

it('should return the same string when encoding the same object again', () => {
const obj = { token: 'foo', key: 'value' }
expect(encode(obj)).toEqual(encode(obj))
})

it('should encode lone surrogates the way URLSearchParams does', () => {
const obj = { q: '\uD800' }
const expected = new URLSearchParams({ q: '\uD800' }).toString()
expect(encode(obj)).toEqual(expected)
expect(encode({ q: 'a\uD800b' })).toEqual(
new URLSearchParams({ q: 'a\uD800b' }).toString(),
)
})

it('should not reuse a previous encode after the object is mutated', () => {
const obj: Record<string, string> = { key: 'one' }
expect(encode(obj)).toEqual('key=one')
obj.key = 'two'
expect(encode(obj)).toEqual('key=two')
})
})

describe('decode function', () => {
Expand Down Expand Up @@ -98,4 +127,17 @@ describe('decode function', () => {
const decodedObj = decode(queryString)
expect(decodedObj).toEqual({ q: '%40' })
})

it('should decode malformed percent escapes the way URLSearchParams does', () => {
expect(decode('q=%E0%A4%A')).toEqual({
q: new URLSearchParams('q=%E0%A4%A').get('q'),
})
expect(decode('q=%20%')).toEqual({
q: new URLSearchParams('q=%20%').get('q'),
})
expect(decode('q=%E0%A4%A&x=1')).toEqual({
q: new URLSearchParams('q=%E0%A4%A&x=1').get('q'),
x: 1,
})
})
})