Repository navigation
fix(openapi): prefer static routes when matching paths that need normalization - #2211
Conversation
…alization
OpenAPIMatcher looked up the raw pathname first and only retried with the
normalized pathname on a miss. Route literals are stored normalized, so a
dynamic sibling could catch a raw path meant for a static route: `fetch`
sends `/v1^beta/items` raw, which hit `/{version}/items` with
`version: 'v1^beta'`, and `/users/m%65` hit `/users/{id}` instead of
`/users/me`. Lazy routers behind such a prefix were never loaded.
Normalize the pathname once, before resolving lazy routers and matching.
Paths that need no normalization keep the fast path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLzZxXd7GQrxmCVXCabGwt
Both callers tested NORMALIZABLE_PATHNAME_REGEX before normalizing, so move the check into normalizeRou3Path and also skip segments that are already in rou3's form. Drop test assertions that passed with or without the fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLzZxXd7GQrxmCVXCabGwt
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
orpc | 251bae2 | Commit Preview URL Branch Preview URL |
Oct 12 2026, 10:36 AM |
More templates
@orpc/ai-sdk
@orpc/arktype
@orpc/bun
@orpc/client
@orpc/cloudflare
@orpc/contract
@orpc/experimental-effect
@orpc/evlog
@orpc/hibernation
@orpc/json-schema
@orpc/experimental-lock
@orpc/experimental-msw
@orpc/nest
@orpc/next
@orpc/node
@orpc/openapi
@orpc/opentelemetry
@orpc/pinia-colada
@orpc/pino
@orpc/publisher
@orpc/ratelimit
@orpc/server
@orpc/shared
@orpc/swr
@orpc/tanstack-query
@orpc/trpc
@orpc/valibot
@orpc/zod
commit: |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Merging this PR will not alter performance
|
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
OpenAPIMatcher.matchnormalizes before matching (packages/openapi/src/adapters/standard/openapi-matcher.ts:130-142) — the raw-first lookup, the conditional retry, and the second lazy-resolution pass are replaced by a singlenormalizeRou3Pathup front, then one lazy resolution and onefindMatch. This is what lets a static route (/v1^beta/items) win over a dynamic sibling (/{version}/items) that previously caught the raw path.normalizeRou3Pathowns the normalization guard (openapi-matcher.ts:264-276) — theNORMALIZABLE_PATHNAME_REGEXshort-circuit moved inside the function, plus a per-segment skip;toRou3Literalnow calls it unconditionally. The per-segment skip is equivalent to always normalizing, since a segment with no normalizable char contains no%and only raw chars, so decode/re-encode are no-ops.- Static-vs-dynamic precedence tests (
openapi-matcher.test.ts:273-311) — raw^,%5E,%5e, andm%65/%6D%65all resolve to the static route, while the dynamic siblings still match other paths with decoded params. - Lazy-router-under-normalizable-prefix test (
openapi-matcher.test.ts:619-635) — a/v1^betalazy router resolves once, ahead of a/{version}/infosibling. - End-to-end link-to-handler round trip (
openapi-handler.test.ts:53-79) — anOpenAPILinkcall reaches the static procedure and assertsfetchsends the^unencoded, which is what real clients do.
The change is well-scoped and correct: routing literals are stored normalized, normalization is idempotent, so dropping the raw-first lookup does not regress literal matches — it only changes static-vs-dynamic precedence, which is the intent. Verified locally: pnpm vitest run packages/openapi (497 passed). The documented perf tradeoff (an extra guard regex per request, normalization on percent-encoded paths) is inherent to preferring the static route and already noted by the author.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Problem
OpenAPIMatcher.matchlooked up the request path exactly as it arrived. It tried the normalized form only when that lookup found nothing. Route literals are stored in normalized form, so a{param}route could catch a request meant for a static route before the static route was ever checked. The wrong procedure then ran, with a bogus param value.Real clients hit this.
fetchsends^unencoded, soOpenAPILinkcalling/v1^beta/itemshit the dynamic route. A lazy router mounted under such a prefix (e.g.prefix: '/v1^beta') was never loaded either, because its prefix matcher only matches the normalized form.Fix
The matcher now normalizes the path once, right after stripping the handler prefix. It then resolves pending lazy routers against that path and calls
findMatchonce. The raw-first lookup, the retry and the second lazy-resolution pass are gone. The regex guard now lives insidenormalizeRou3Path, so request paths and stored routes go through the same function. Segments already in rou3's form are left untouched.RPCMatcherkeeps its retry, because it does exactMaplookups with no dynamic routes, so it isn't affected.Tests
openapi-matcher.test.ts: a static route next to a dynamic sibling, for a raw^(plus%5Eand%5e) and form%65/%6D%65. Dynamic routes still match other paths, with decoded params.openapi-matcher.test.ts,lazy routers: a/v1^betaprefixed lazy router next to a/{version}/infosibling, assertingloaderis called once.openapi-handler.test.ts: anOpenAPILink→OpenAPIHandlerround trip. It asserts that fetch sends/api/v1^beta/itemswith the^unencoded and that the call reaches the static procedure.All of these failed before the fix. The existing matcher tests pass unchanged, including the ASCII-char and encoded-
%2Ftests.pnpm vitest run packages/openapi tests/openapi,pnpm type:checkandpnpm lintare clean.Performance
Paths with nothing to normalize (no
%and no char rou3 stores encoded) skip normalization and cost the same as before. Paths with percent-encoding are now always normalized before the lookup, adding about 1–2.5 µs permatchin a benchmark on a slow cloud container (e.g./accounts/a%40b.com: 0.66 → 1.45 µs). The matcher can't prefer a static route correctly without that step. Paths that match no route are no slower, since they now take one lookup instead of two.🤖 Generated with Claude Code
https://claude.ai/code/session_01CLzZxXd7GQrxmCVXCabGwt
Generated by Claude Code