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:
Bendik Aagaard Lynghaug
2026-09-08 16:50:34 +02:00
co-authored by Claude Fable 5
parent 4ad3d02207
commit 501e8f5927
3 changed files with 181 additions and 43 deletions
+56 -42
View File
@@ -1,7 +1,14 @@
// Ported from nanorouter 4.0.0 and wayfarer 7.0.1 with its trie (MIT)
// https://github.com/choojs/nanorouter — https://github.com/yoshuawuyts/wayfarer
// Consolidated into a single module: trie-based router with :params and
// * wildcards, plus nanorouter's URL normalization on top.
// * wildcards, plus URL normalization on top.
//
// Normalization differs deliberately from v7: locations are parsed with the
// WHATWG URL parser and each path segment is percent-decoded exactly once,
// falling back to the raw segment instead of throwing on malformed input
// (v7's decodeURI crashed on a literal '%'). Both route definitions and
// incoming segments are NFC-normalized so 'café' matches 'café' regardless
// of how the é was composed.
import { ok, equal, notEqual } from './assert.js'
@@ -9,6 +16,16 @@ function has (object, property) {
return Object.prototype.hasOwnProperty.call(object, property)
}
// Decode a path segment exactly once; keep the raw segment when it isn't
// valid percent-encoding. Always NFC-normalize.
export function decodeSegment (segment) {
try {
return decodeURIComponent(segment).normalize('NFC')
} catch (e) {
return segment.normalize('NFC')
}
}
class Trie {
constructor () {
this.trie = { nodes: {} }
@@ -40,11 +57,16 @@ class Trie {
}
trie.name = thisRoute.replace(/^:|^\*/, '')
} else if (!has(trie.nodes, thisRoute)) {
node = { nodes: {} }
trie.nodes[thisRoute] = node
} else {
node = trie.nodes[thisRoute]
// Literal segment: store it decoded + NFC-normalized so route
// definitions and incoming URLs compare in one canonical form.
const key = decodeSegment(thisRoute)
if (!has(trie.nodes, key)) {
node = { nodes: {} }
trie.nodes[key] = node
} else {
node = trie.nodes[key]
}
}
return createNode(index + 1, node)
@@ -63,27 +85,23 @@ class Trie {
function search (index, trie) {
// either there's no match, or we're done searching
if (trie === undefined) return undefined
const thisRoute = routes[index]
if (thisRoute === undefined) return trie
if (routes[index] === undefined) return trie
// Segments arrive percent-encoded from the URL parser; decode each
// exactly once (create() stored literal keys in the same form).
const thisRoute = decodeSegment(routes[index])
if (has(trie.nodes, thisRoute)) {
// match regular routes first
return search(index + 1, trie.nodes[thisRoute])
} else if (trie.name) {
// match named routes
try {
params[trie.name] = decodeURIComponent(thisRoute)
} catch (e) {
return search(index, undefined)
}
params[trie.name] = thisRoute
return search(index + 1, trie.nodes.$$)
} else if (trie.wildcard) {
// match wildcards
try {
params.wildcard = decodeURIComponent(routes.slice(index).join('/'))
} catch (e) {
return search(index, undefined)
}
// match wildcards; decode per segment so an encoded '/' can't
// change the segment structure mid-decode
params.wildcard = routes.slice(index).map(decodeSegment).join('/')
// return early, or else search may keep recursing through the wildcard
return trie.nodes.$$
} else {
@@ -183,30 +201,26 @@ function wayfarer (dft) {
}
}
// electron support
const isLocalFile = /file:\/\//.test(
typeof window === 'object' &&
window.location &&
window.location.origin
)
// Reduce a location (path, full URL, or electron file:// URL) to a
// matchable path: pathname plus any hash segments rewritten to slashes,
// query dropped. Percent-decoding is NOT done here — the trie decodes per
// segment — so a malformed sequence can never throw during routing.
function pathname (routename) {
let url
try {
url = new URL(routename, 'http://localhost')
} catch (e) {
return routename
}
/* eslint-disable no-useless-escape */
const electron = '^(file:\/\/|\/)(.*\.html?\/?)?'
const protocol = '^(http(s)?(:\/\/))?(www\.)?'
const domain = '[a-zA-Z0-9-_\.]+(:[0-9]{1,5})?(\/{1})?'
const qs = '[\?].*$'
/* eslint-enable no-useless-escape */
let path = url.pathname
// electron support: file:///path/to/index.html routes as '/'
if (url.protocol === 'file:') {
path = path.replace(/\/[^/]*\.html?$/, '') || '/'
}
const stripElectron = new RegExp(electron)
const prefix = new RegExp(protocol + domain)
const normalize = new RegExp('#')
const suffix = new RegExp(qs)
// replace everything in a route but the pathname and hash
function pathname (routename, isElectron) {
if (isElectron) routename = routename.replace(stripElectron, '')
else routename = routename.replace(prefix, '')
return decodeURI(routename.replace(suffix, '').replace(normalize, '/'))
const hash = url.hash.replace(/^#/, '/').replaceAll('#', '/')
return path + hash
}
export default class Nanorouter {
@@ -223,13 +237,13 @@ export default class Nanorouter {
emit (routename) {
equal(typeof routename, 'string')
routename = pathname(routename, isLocalFile)
routename = pathname(routename)
return this.router.emit(routename)
}
match (routename) {
equal(typeof routename, 'string')
routename = pathname(routename, isLocalFile)
routename = pathname(routename)
return this.router.match(routename)
}
}