Skip to content

fix(router): support a wildcard in the middle of a path - #5243

Merged
yusukebe merged 11 commits into
honojs:v5from
Kjubikstronk:fix-mid-path-wildcard
Oct 9, 2026
Merged

yusukebe merged 11 commits into
honojs:v5from
Kjubikstronk:fix-mid-path-wildcard

Conversation

@Kjubikstronk

@Kjubikstronk Kjubikstronk commented Aug 15, 2026 •

Copy link
Copy Markdown

Rebased onto main now that #5236 and #5252 have landed. This was stacked on two earlier PRs of mine covering the trailing-wildcard cases; those are superseded and closed, so this is a single commit against current main and only covers the mid-path form.

Sitting on top of the refactored trie made it much smaller — six lines of source instead of the twenty-five it took before.

The gap

RegExpRouter supports a wildcard in the middle of a path. The other three do not, each differently:

route path RegExp Trie Linear Pattern
/x*/y /xz/y match — match —
/x*/y /x/y — — match —
/a/b*/c /a/bz/c match — match —

Taking RegExpRouter as the reference, a mid-path * takes one or more characters and stays inside its own part. /x/y does not match because the wildcard would take nothing, and /x/z/y does not because it would cross a /. That is different from a trailing *, which takes anything including / and may be empty.

The change

  • TrieRouter registered the prefix* form only when it was the last part (nextP === undefined). Registering it everywhere, and continuing the walk when the part is longer than the prefix, is enough — the existing per-part matching already prevents it crossing a /.
  • PatternRouter escaped the * into the compiled regex, so it looked for a literal asterisk. A prefix* part now compiles to the escaped prefix followed by [^/]+.
  • LinearRouter searched for the next / from the current position, which let the wildcard take zero characters. It now rejects a zero-length take.

Verification

Cross-router matrix over 13 route shapes × 25 paths, covering mid-path, trailing, bare *, params and static routes:

  • before: 7 of 325 divergent
  • after: 0 of 325 — all four routers agree everywhere

Mid-path wildcard added to the shared router cases, covering the rest-of-part match, the at-least-one-character rule, that it does not run past its part, and the same below the root. All four routers pass with no skip entries.

pnpm vitest --project main: 123 files, 3880 passed, 28 skipped, 0 failed. tsc -p tsconfig.spec.json, ESLint and Prettier clean.

Earlier, before the rebase, I measured the trie hot path since this adds a condition inside search: 1.32–1.35 M matches/sec against 1.34–1.36 without it, within run-to-run noise. The condition here is a comparison on a value already loaded, so the shape is the same.

TrieRouter stored `/assets*` as a literal segment named `assets*`, so it
never matched anything. RegExpRouter, PatternRouter and LinearRouter all
support the pattern, so whether such a route worked depended on which
router SmartRouter happened to settle on.

The prefix is now registered on the parent node at insert time and
matched against the rest of the path, which is what a trailing `*` means
in the other routers. No params are captured, matching them as well.

The shared router cases cover it. LinearRouter and PatternRouter skip the
nested case, which they get wrong independently of this change.
LinearRouter and PatternRouter both dropped two characters when a route
ended in `*`, which removed the last character of the segment as well as
the star. `/a/b*` became the prefix `/a/`, so it matched `/a/c`, `/a/`
and everything else below `/a/`, and `/assets*` matched `/asset`.

Slicing one character keeps the intended prefix. Because that prefix now
retains its trailing slash for `/a/b/*`, LinearRouter gets an explicit
check so that form still matches `/a/b`, and PatternRouter anchors the
wildcard with `(?:/|$)` so `/assets/*` no longer matches `/assetsfoo`.

The shared router cases cover both, and the two skips added for this in
the previous commit are no longer needed.
RegExpRouter matches `/x*/y` against `/xzz/y`, taking one or more
characters within a single part. TrieRouter and PatternRouter did not
support the form at all, and LinearRouter accepted `/x/y` and `/x/z/y`,
letting the wildcard take nothing or run across a `/`.

TrieRouter records the prefix on the parent node and continues from the
matching part. PatternRouter compiles the prefix followed by `[^/]+`.
LinearRouter now stops the wildcard at the end of its part and requires
it to take at least one character.

All four routers agree across the shared router cases.
@yusukebe

Copy link
Copy Markdown
Member

Hi @usualoma

What do you think of these changes? We recently changed some router behaviors, but I have been thinking we have not said anything about wildcard-related behaviors. For example, we don't say that the route /x*/y matches /xz/y in our doc:

https://hono.dev/docs/api/routing

So, we can refuse these changes because it's not in the spec. But on the other hand, we should remove inconsistencies.

@usualoma

Copy link
Copy Markdown
Member

Hi @Kjubikstronk,

Thank you for putting this PR together.

As @yusukebe pointed out, I agree that eliminating behavioral differences between routers is desirable.

I think the expected behavior should be as follows:

Route Path Expected
/x*/y /x/y Match
/x*/y /xz/y Match
/x*/y /xzz/y Match
/x*/y /x/z/y No match
/a/b*/c /a/b/c Match
/a/b*/c /a/bz/c Match
/a/b*/c /a/b/z/c No match
/wild/*/card /wild//card No match
/wild/*/card /wild/x/card Match
/wild/*/card /wild/x/y/card No match
/assets* /assets Match
/assets* /assets123 Match
/assets* /assets/foo Match

A wildcard appended to a non-empty prefix should be able to consume zero or more characters. When it appears in the middle of a route, it should stay within that path segment; when it is route-final, it may consume the rest of the path, including /.

A standalone wildcard segment is slightly different: when another segment follows it, it should consume at least one character. Therefore, /wild/*/card should not match /wild//card. A route-final standalone wildcard may continue to match an empty remainder, as it does today.

This keeps the zero-character behavior of prefix wildcards consistent with the existing route-final /assets* behavior, while preserving the meaning of * as a complete path segment when it has no prefix.

Also, since this is not an urgent fix and necessarily changes existing route-matching behavior, it may be better to land it as part of v5. We could describe it in the v5 release notes as something like: “Improved consistency in routing behavior across router implementations.”

@usualoma

Copy link
Copy Markdown
Member

@yusukebe What do you think about the proposed routing behavior described above?

Separately, I believe we can implement it with only a very small increase in code size, particularly for the default RegExpRouter + TrieRouter combination.

@yusukebe

Copy link
Copy Markdown
Member

Hi @usualoma

I checked your behavior rule, and it makes sense. Some behaviors may confuse users, but it's fine to describe the rule on the website.

We could describe it in the v5 release notes as something like: “Improved consistency in routing behavior across router implementations.”

Agree! Let's do it. Can you create another PR for this? Then I'll label it v5.

@Kjubikstronk

Copy link
Copy Markdown
Author

That table is a better answer than this PR. I only went after the one case where the routers disagreed, and the distinction you draw — a wildcard with a prefix consuming zero or more characters within its segment, a standalone * segment needing at least one when something follows it — covers the cases I didn't think about, /wild//card in particular.

Happy to close this in favour of the v5 PR. If it would save @usualoma time I can put the table into tests and implement against it instead, but it's your change to make and I don't want to get in the way of it.

@usualoma

Copy link
Copy Markdown
Member

Hi @Kjubikstronk,
Thank you for reviewing this!
Would it be okay if you implemented this in this PR to align with the new spec sheet? (Alternatively, if the implementation isn't extensive, feel free to create a separate PR derived from the main branch.)

A wildcard with a non-empty prefix now consumes zero or more characters and
stays inside its own segment, and a standalone `*` segment consumes at least
one character when a segment follows it. Either still consumes the rest of the
path when it ends the route.

The routers disagreed in different ways. RegExpRouter translated every mid-path
`*` to [^/]+, so a prefixed one could not consume zero characters. TrieRouter
only registered a prefixed wildcard as a pattern when it ended the route, so a
mid-path one was stored as a literal key and never continued to the next
segment. PatternRouter escaped it to a literal asterisk. LinearRouter let a
standalone `*` match an empty segment.

The spec sheet from honojs#5243 is now a table in the shared router tests, so all five
routers are held to it.
@Kjubikstronk
Kjubikstronk force-pushed the fix-mid-path-wildcard branch from 7b64ab1 to 5f9da9d Compare August 27, 2026 21:54
@Kjubikstronk

Copy link
Copy Markdown
Author

Done — the table is now a case list in the shared router tests, so all five routers run it.

Each router was failing it differently:

  • RegExpRouter turned every mid-path * into [^/]+, so a prefixed one couldn't consume zero characters. It now compiles [^/]* when the * has a prefix and keeps [^/]+ for a standalone segment.
  • TrieRouter only registered a prefixed wildcard as a pattern when it ended the route, so mid-path it was stored as a literal key and traversal never continued past it. It now registers one anywhere and carries on from the matching part.
  • PatternRouter escaped the star into the compiled regex, so the form never worked.
  • LinearRouter let a standalone * match an empty part.

18 failures before, all green now, with the rest of the suite unchanged.

One thing I deliberately left out: buildWildcardRegExp in reg-exp-router/router.ts, which backs findMiddleware, only handles a route-final wildcard — a mid-path * is escaped to a literal there. So app.use('/x*/y', …) doesn't follow the table even after this. Happy to fix it here or leave it as a follow-up; it seemed like a separate change from route matching.

@usualoma

Copy link
Copy Markdown
Member

@Kjubikstronk
Thank you for updating this! The updated implementation looks good to me overall.

Sorry, I missed one more inconsistency: routes such as /x*y and /x*y/index are still handled differently between routers. I don't think we need to support * in the middle of a segment as a wildcard. I would prefer to align the routers by treating * as a wildcard only when it appears at the end of a segment.

The expected behavior would be:

Route Path Expected
/x*/y /x/y Match
/x*/y /xz/y Match
/x*/y /xzz/y Match
/x*/y /x/z/y No match
/x*y /xy No match
/x*y /xay No match
/x*y/index /xy/index No match
/x*y/index /xay/index No match
/a/b*/c /a/b/c Match
/a/b*/c /a/bz/c Match
/a/b*/c /a/b/z/c No match
/wild/*/card /wild//card No match
/wild/*/card /wild/x/card Match
/wild/*/card /wild/x/y/card No match
/assets* /assets Match
/assets* /assets123 Match
/assets* /assets/foo Match

Whether /x*y or /x*y/index should work as literal routes is not important here. The important point is that the * in this position should not be interpreted as a wildcard.

Something like the following should align the routers:

diff --git a/src/router/pattern-router/router.ts b/src/router/pattern-router/router.ts
index 0b4755e..a9fa7cc 100644
--- a/src/router/pattern-router/router.ts
+++ b/src/router/pattern-router/router.ts
@@ -24,7 +24,9 @@ export class PatternRouter<T> implements Router<T> {
           ? `/(?<${match[1]}>${match[2] || '[^/]+'})`
           : part === '/*'
             ? '/[^/]+'
-            : part.replace(/[.\\+[^\]$()]/g, '\\$&').replace(/\*/g, '[^/]*')
+            : part.endsWith('*')
+              ? part.slice(0, -1).replace(/[.\\+*[^\]$()]/g, '\\$&') + '[^/]*'
+              : part.replace(/[.\\+*[^\]$()]/g, '\\$&')
       }
     )
 
diff --git a/src/router/reg-exp-router/node.ts b/src/router/reg-exp-router/node.ts
index 2dce7ac..18c88c1 100644
--- a/src/router/reg-exp-router/node.ts
+++ b/src/router/reg-exp-router/node.ts
@@ -67,9 +67,11 @@ export class Node {
           ? token === '*'
             ? i === len - 1
               ? ['', '', ONLY_WILDCARD_REG_EXP_STR] // '*' matches to all the trailing paths
-              : tokens[i - 1] === '/'
-                ? ['', '', LABEL_REG_EXP_STR] // a standalone '*' takes a whole segment
-                : ['', '', PREFIXED_WILDCARD_REG_EXP_STR] // 'x*' may take nothing
+              : tokens[i + 1] !== '/'
+                ? null
+                : tokens[i - 1] === '/'
+                  ? ['', '', LABEL_REG_EXP_STR] // a standalone '*' takes a whole segment
+                  : ['', '', PREFIXED_WILDCARD_REG_EXP_STR] // 'x*' may take nothing
             : null
           : token === '/*'
             ? ['', '', TAIL_WILDCARD_REG_EXP_STR] // '/path/to/*' is /\\/path\\/to(?:|/.*)$/

@codecov

codecov Bot commented Aug 28, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.47619% with 2 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (v5@1fc7c65). Learn more about missing BASE report.

Files with missing lines Patch % Lines
src/router/trie-router/node.ts 50.00% 2 Missing ⚠️
Additional details and impacted files
@@          Coverage Diff          @@
##             v5    #5243   +/-   ##
=====================================
  Coverage      ?   79.77%           
=====================================
  Files         ?      130           
  Lines         ?    10082           
  Branches      ?     2213           
=====================================
  Hits          ?     8043           
  Misses        ?     2039           
  Partials      ?        0           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

`/x*y` and `/x*y/index` were still handled differently between routers.
PatternRouter and RegExpRouter read the `*` as a wildcard wherever it appeared
in the segment, so `/x*y` matched `/xay`; TrieRouter and LinearRouter already
required it to end the segment.

Both now ignore a `*` with anything after it in the same segment, which is what
the other two were already doing. The four cases from the spec sheet are in the
shared tests.
@Kjubikstronk

Copy link
Copy Markdown
Author

Applied, thanks — your diffs were right. Both routers now ignore a * that has anything after it in the same segment, and the four new cases are in the shared table.

Worth noting the other two already behaved that way: TrieRouter's p.indexOf('*') === p.length - 1 check and LinearRouter's split both required the * to end the segment, so they passed the new cases before I touched anything. Only PatternRouter and RegExpRouter needed the change.

All 17 cases pass on all five routers, full suite green.

@usualoma

Copy link
Copy Markdown
Member

Hi @Kjubikstronk,
Thanks for the update.

One thing I deliberately left out: buildWildcardRegExp in reg-exp-router/router.ts, which backs findMiddleware, only handles a route-final wildcard — a mid-path * is escaped to a literal there. So app.use('/x*/y', …) doesn't follow the table even after this. Happy to fix it here or leave it as a follow-up; it seemed like a separate change from route matching.

Right, I had overlooked this point in my previous comment. I took another look at it, and rather than extending buildWildcardRegExp to associate middleware containing a mid-path *, I would like to make RegExpRouter return UnsupportedPathError when such an association cannot be represented safely.

In particular, these two patterns only partially overlap:

app.use('/x/*', middleware)
app.get('/x*/y', handler)
Path /x/* /x*/y
/x/y match match
/xz/y no match match
/x/z/y match no match

Because the overlap is conditional, associating middleware with the entire /x*/y route would incorrectly run it for /xz/y. The current RegExpRouter instead silently omits one of the handlers for /x/y.

I would therefore like to make a PREFIXED_WILDCARD_REG_EXP_STR ([^/]*) and a TAIL_WILDCARD_REG_EXP_STR ((?:|/.*)) conflict when they are sibling nodes. It should throw UnsupportedPathError regardless of registration order, allowing SmartRouter to fall back to TrieRouter.

The resulting RegExpRouter behavior would be:

Registered patterns Result Change
/x*/y only Supported —
/x*/y + the same /x*/y Supported —
app.use('/x*/y', …) + app.get('/x*/y', …) Supported —
/x*/y + /x*/z Supported —
/x*/y + /x* Supported —
/x*/y + /xy/y UnsupportedPathError Already unsupported
/x*/y + /:capture/* UnsupportedPathError Already unsupported
/x*/y + /x/* UnsupportedPathError New

The result would not depend on registration order.

For reference, this is the change I have in mind:

diff --git a/src/router/reg-exp-router/node.ts b/src/router/reg-exp-router/node.ts
index aed623d3..8955467f 100644
--- a/src/router/reg-exp-router/node.ts
+++ b/src/router/reg-exp-router/node.ts
@@ -98,13 +98,18 @@ export class Node {
 
         nextNode = node.#children[regexpStr]
         if (!nextNode) {
-          if (regexpStr !== ONLY_WILDCARD_REG_EXP_STR && regexpStr !== TAIL_WILDCARD_REG_EXP_STR) {
+          if (
+            regexpStr !== ONLY_WILDCARD_REG_EXP_STR &&
+            (regexpStr !== TAIL_WILDCARD_REG_EXP_STR ||
+              node.#children[PREFIXED_WILDCARD_REG_EXP_STR])
+          ) {
             for (const k in node.#children) {
               if (
                 // a single-char pattern coexists with single-char literals as a literal does
                 (regexpStr.length > 1 || k.length > 1) &&
                 k !== ONLY_WILDCARD_REG_EXP_STR &&
-                k !== TAIL_WILDCARD_REG_EXP_STR
+                (k !== TAIL_WILDCARD_REG_EXP_STR ||
+                  regexpStr === PREFIXED_WILDCARD_REG_EXP_STR)
               ) {
                 throw PATH_ERROR
               }

With this approach, we do not need to expand buildWildcardRegExp to support arbitrary mid-path wildcards. RegExpRouter either handles the route combination safely or explicitly rejects it, while SmartRouter can fall back to TrieRouter.

The minified bundle increase for this additional conflict check is about 20 bytes compared with the current PR. Would you mind updating the PR in this direction?

RegExpRouter cannot represent `/x*/y` and `/x/*` together: they overlap only
conditionally, so associating middleware with the whole route would run it for
paths it does not cover. Throw UnsupportedPathError for that pair regardless of
registration order and let SmartRouter fall back to TrieRouter.
@Kjubikstronk

Copy link
Copy Markdown
Author

Done, implemented as you described.

I checked every row of your table in both registration orders and they all come out the way you predicted, including /x*/y + /x/* throwing whichever one is registered first.

Added three cases to router.test.ts: the new conflict both ways round, and /x*/y + /x* still coexisting, so the route-final wildcard does not get caught by the new rule. That last one is worth a note: a trailing * is a tail wildcard rather than a prefixed one, so both handlers still match /x/y, which is what your table says.

Full test suite passes.

@usualoma

Copy link
Copy Markdown
Member

Hi @Kjubikstronk,
Thank you for the update.

From my perspective, this PR is now complete for its intended scope, and the current implementation looks good to me.

@yusukebe yusukebe added the v5 label Aug 31, 2026
@Kjubikstronk

Copy link
Copy Markdown
Author

Thanks for working through all of that with me — appreciate the patience on the back and forth.

@usualoma

Copy link
Copy Markdown
Member

Hi @Kjubikstronk,

Thanks again. During a final check, I found one regression, so I used the “Maintainers are allowed to edit this pull request” option and pushed a small follow-up fix directly to this branch.

/:tenant/*/* is tokenized as:

['/', ':tenant', '/', '*', '/*']

The tokens[i + 1] !== '/' check treated the middle * as a literal because the next token was '/*', even though it begins a new segment. Since add() did not throw, SmartRouter could not fall back, and the handler was silently omitted.

I changed the check to:

tokens[i + 1][0] !== '/'

and added regression coverage for:

  • /:tenant/*/* → /acme/x/y, including the tenant parameter
  • /foo/*/* → /foo/x/y
  • /x*/* → /xz/y
  • /x*/* → /x/y

@Kjubikstronk

Copy link
Copy Markdown
Author

Thanks for catching that and fixing it on the branch. I had only thought about the next token being exactly /, not starting with one, so /:tenant/*/* slipped through. The four cases you added cover it better than mine did.

@Kjubikstronk

Copy link
Copy Markdown
Author

v5 has moved since this was labelled, and #5318 touches the same trie-router gate and test block. Should I rebase this onto v5 and update the description to the zero-or-more behaviour it has now?

@yusukebe
yusukebe changed the base branch from main to v5 October 9, 2026 05:02

@yusukebe yusukebe left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@yusukebe

yusukebe commented Oct 9, 2026

Copy link
Copy Markdown
Member

@Kjubikstronk

I rebased and updated the migration guide. I'll merge into v5. Thanks!

@yusukebe
yusukebe merged commit 466f779 into honojs:v5 Oct 9, 2026
19 checks passed
@yusukebe yusukebe mentioned this pull request Oct 17, 2026
2 of 4 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants