Skip to content

fix(openapi): prefer static routes when matching paths that need normalization - #2211

Merged
dinwwwh merged 2 commits into
mainfrom
claude/sleepy-einstein-eyrywb
Oct 13, 2026
Merged

dinwwwh merged 2 commits into
mainfrom
claude/sleepy-einstein-eyrywb

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 13, 2026

Copy link
Copy Markdown
Member

Problem

OpenAPIMatcher.match looked 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.

// routes: GET /v1^beta/items, GET /{version}/items
matcher.match('GET', '/v1^beta/items')  // ran /{version}/items with { version: 'v1^beta' }

// routes: GET /users/me, GET /users/{id}
matcher.match('GET', '/users/m%65')     // ran /users/{id} with { id: 'me' }

Real clients hit this. fetch sends ^ unencoded, so OpenAPILink calling /v1^beta/items hit 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 findMatch once. The raw-first lookup, the retry and the second lazy-resolution pass are gone. The regex guard now lives inside normalizeRou3Path, so request paths and stored routes go through the same function. Segments already in rou3's form are left untouched.

RPCMatcher keeps its retry, because it does exact Map lookups 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 %5E and %5e) and for m%65 / %6D%65. Dynamic routes still match other paths, with decoded params.
  • openapi-matcher.test.ts, lazy routers: a /v1^beta prefixed lazy router next to a /{version}/info sibling, asserting loader is called once.
  • openapi-handler.test.ts: an OpenAPILink → OpenAPIHandler round trip. It asserts that fetch sends /api/v1^beta/items with 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-%2F tests. pnpm vitest run packages/openapi tests/openapi, pnpm type:check and pnpm lint are 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 per match in 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

…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
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

@pkg-pr-new

pkg-pr-new Bot commented Oct 13, 2026

Copy link
Copy Markdown
More templates

@orpc/ai-sdk

npm i https://pkg.pr.new/@orpc/ai-sdk@2211

@orpc/arktype

npm i https://pkg.pr.new/@orpc/arktype@2211

@orpc/bun

npm i https://pkg.pr.new/@orpc/bun@2211

@orpc/client

npm i https://pkg.pr.new/@orpc/client@2211

@orpc/cloudflare

npm i https://pkg.pr.new/@orpc/cloudflare@2211

@orpc/contract

npm i https://pkg.pr.new/@orpc/contract@2211

@orpc/experimental-effect

npm i https://pkg.pr.new/@orpc/experimental-effect@2211

@orpc/evlog

npm i https://pkg.pr.new/@orpc/evlog@2211

@orpc/hibernation

npm i https://pkg.pr.new/@orpc/hibernation@2211

@orpc/json-schema

npm i https://pkg.pr.new/@orpc/json-schema@2211

@orpc/experimental-lock

npm i https://pkg.pr.new/@orpc/experimental-lock@2211

@orpc/experimental-msw

npm i https://pkg.pr.new/@orpc/experimental-msw@2211

@orpc/nest

npm i https://pkg.pr.new/@orpc/nest@2211

@orpc/next

npm i https://pkg.pr.new/@orpc/next@2211

@orpc/node

npm i https://pkg.pr.new/@orpc/node@2211

@orpc/openapi

npm i https://pkg.pr.new/@orpc/openapi@2211

@orpc/opentelemetry

npm i https://pkg.pr.new/@orpc/opentelemetry@2211

@orpc/pinia-colada

npm i https://pkg.pr.new/@orpc/pinia-colada@2211

@orpc/pino

npm i https://pkg.pr.new/@orpc/pino@2211

@orpc/publisher

npm i https://pkg.pr.new/@orpc/publisher@2211

@orpc/ratelimit

npm i https://pkg.pr.new/@orpc/ratelimit@2211

@orpc/server

npm i https://pkg.pr.new/@orpc/server@2211

@orpc/shared

npm i https://pkg.pr.new/@orpc/shared@2211

@orpc/swr

npm i https://pkg.pr.new/@orpc/swr@2211

@orpc/tanstack-query

npm i https://pkg.pr.new/@orpc/tanstack-query@2211

@orpc/trpc

npm i https://pkg.pr.new/@orpc/trpc@2211

@orpc/valibot

npm i https://pkg.pr.new/@orpc/valibot@2211

@orpc/zod

npm i https://pkg.pr.new/@orpc/zod@2211

commit: 251bae2

@codecov

codecov Bot commented Oct 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

⚠️ 5 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

✅ 28 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing claude/sleepy-einstein-eyrywb (251bae2) with main (5f441da)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

  • OpenAPIMatcher.match normalizes 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 single normalizeRou3Path up front, then one lazy resolution and one findMatch. This is what lets a static route (/v1^beta/items) win over a dynamic sibling (/{version}/items) that previously caught the raw path.
  • normalizeRou3Path owns the normalization guard (openapi-matcher.ts:264-276) — the NORMALIZABLE_PATHNAME_REGEX short-circuit moved inside the function, plus a per-segment skip; toRou3Literal now 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, and m%65/%6D%65 all 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^beta lazy router resolves once, ahead of a /{version}/info sibling.
  • End-to-end link-to-handler round trip (openapi-handler.test.ts:53-79) — an OpenAPILink call reaches the static procedure and asserts fetch sends 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.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@dinwwwh
dinwwwh merged commit f0c1825 into main Oct 13, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants