fix(core): URL normalization that survives non-ASCII and malformed input
Replace nanorouter's regex+decodeURI normalization with the WHATWG URL parser. Path segments are percent-decoded exactly once in the trie, with a keep-raw fallback instead of the URIError that crashed v7 on a literal '%'. Route definitions and locations are NFC-normalized so 'café' matches regardless of composition. Hash-to-slash rewriting now handles every hash, not just the first, and state.href is decoded for humans (raw on failure). Behavior changes from v7: params are no longer double-decoded (%2540 → '%40', not '@'), and unmatchable encodings fall back to raw segments instead of silently 404ing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
This commit is contained in:
co-authored by
Claude Fable 5
parent
4ad3d02207
commit
501e8f5927
@@ -0,0 +1,114 @@
|
||||
// URL normalization: the non-ASCII and malformed-percent cases that made
|
||||
// v7 routing crash or mismatch. See lib/router.js header for the rules.
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
|
||||
import html from '@choojs/html'
|
||||
import choo from '@choojs/core'
|
||||
|
||||
function view (state) {
|
||||
return html`<div></div>`
|
||||
}
|
||||
|
||||
test('a literal % in the path does not crash routing', () => {
|
||||
const app = choo()
|
||||
let seen
|
||||
app.route('/deals/:label', (state) => {
|
||||
seen = state
|
||||
return view(state)
|
||||
})
|
||||
app.toString('/deals/50%off')
|
||||
assert.strictEqual(seen.params.label, '50%off', 'raw segment kept when not decodable')
|
||||
assert.strictEqual(seen.href, '/deals/50%off', 'href kept raw when not decodable')
|
||||
})
|
||||
|
||||
test('percent-encoded UTF-8 params decode exactly once', () => {
|
||||
const app = choo()
|
||||
let seen
|
||||
app.route('/user/:name', (state) => {
|
||||
seen = state
|
||||
return view(state)
|
||||
})
|
||||
app.toString('/user/%F0%9F%9A%82')
|
||||
assert.strictEqual(seen.params.name, '🚂', 'param decoded')
|
||||
assert.strictEqual(seen.href, '/user/🚂', 'href decoded for humans')
|
||||
})
|
||||
|
||||
test('double-encoded input is not double-decoded', () => {
|
||||
const app = choo()
|
||||
let seen
|
||||
app.route('/user/:name', (state) => {
|
||||
seen = state
|
||||
return view(state)
|
||||
})
|
||||
// %2540 is '%40' encoded once; v7 double-decoded it all the way to '@'
|
||||
app.toString('/user/%2540')
|
||||
assert.strictEqual(seen.params.name, '%40', 'decoded exactly once')
|
||||
})
|
||||
|
||||
test('non-ASCII literal routes match their encoded locations', () => {
|
||||
const app = choo()
|
||||
let calls = 0
|
||||
app.route('/café', (state) => {
|
||||
calls++
|
||||
return view(state)
|
||||
})
|
||||
app.toString('/caf%C3%A9')
|
||||
assert.strictEqual(calls, 1, 'encoded location matched unencoded route')
|
||||
})
|
||||
|
||||
test('NFD input matches an NFC route definition', () => {
|
||||
const app = choo()
|
||||
let calls = 0
|
||||
app.route('/café', (state) => { // composed é
|
||||
calls++
|
||||
return view(state)
|
||||
})
|
||||
app.toString('/café') // decomposed e + combining acute
|
||||
assert.strictEqual(calls, 1, 'unicode-normalized before matching')
|
||||
})
|
||||
|
||||
test('unencoded non-ASCII locations route fine', () => {
|
||||
const app = choo()
|
||||
let seen
|
||||
app.route('/user/:name', (state) => {
|
||||
seen = state
|
||||
return view(state)
|
||||
})
|
||||
app.toString('/user/日本語')
|
||||
assert.strictEqual(seen.params.name, '日本語')
|
||||
})
|
||||
|
||||
test('wildcards decode per segment', () => {
|
||||
const app = choo()
|
||||
let seen
|
||||
app.route('/files/*', (state) => {
|
||||
seen = state
|
||||
return view(state)
|
||||
})
|
||||
app.toString('/files/caf%C3%A9/na%C3%AFve.txt')
|
||||
assert.strictEqual(seen.params.wildcard, 'café/naïve.txt')
|
||||
})
|
||||
|
||||
test('hash routing survives multiple hashes', () => {
|
||||
const app = choo({ hash: true })
|
||||
let calls = 0
|
||||
app.route('/docs/api/intro', (state) => {
|
||||
calls++
|
||||
return view(state)
|
||||
})
|
||||
app.toString('/docs#api#intro')
|
||||
assert.strictEqual(calls, 1, 'every hash became a slash')
|
||||
})
|
||||
|
||||
test('query strings decode + and percent-encoding via URLSearchParams', () => {
|
||||
const app = choo()
|
||||
let seen
|
||||
app.route('/', (state) => {
|
||||
seen = state
|
||||
return view(state)
|
||||
})
|
||||
app.toString('/?q=caf%C3%A9+au+lait&tags=a&tags=b')
|
||||
assert.deepStrictEqual(seen.query, { q: 'café au lait', tags: ['a', 'b'] })
|
||||
})
|
||||
Reference in New Issue
Block a user