diff --git a/packages/router-core/src/qss.ts b/packages/router-core/src/qss.ts index a52fd958a1..3889dadecf 100644 --- a/packages/router-core/src/qss.ts +++ b/packages/router-core/src/qss.ts @@ -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. @@ -26,16 +173,24 @@ export function encode( obj: Record, 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 } /** @@ -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 } /** @@ -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 = 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] } } diff --git a/packages/router-core/tests/qss.test.ts b/packages/router-core/tests/qss.test.ts index ce92857ab5..91e4f1161a 100644 --- a/packages/router-core/tests/qss.test.ts +++ b/packages/router-core/tests/qss.test.ts @@ -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 = { key: 'one' } + expect(encode(obj)).toEqual('key=one') + obj.key = 'two' + expect(encode(obj)).toEqual('key=two') + }) }) describe('decode function', () => { @@ -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, + }) + }) })