Skip to content

Bun.serve: only treat a route segment as a parameter when it starts with ':' - #37983

Open
robobun wants to merge 1 commit into
mainfrom
farm/fb850c6c/route-param-segment-grammar
Open

robobun wants to merge 1 commit into
mainfrom
farm/fb850c6c/route-param-segment-grammar

Conversation

@robobun

@robobun robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Bun.serve({ routes }) builds req.params with shifted values when a route key has a : that is not the first byte of a segment:
    • "/files:batchGet/:id" served /files:batchGet/123 gives { batchGet: "123", id: "" } instead of { id: "123" }
    • "/v1:beta/:name/x" gives { beta: "bob", name: "" }
    • "/n/:id:undelete" gives { undelete: "42:undelete" }, and also matches /n/42
  • Cause: two grammars for the same route key.
    • The matcher (packages/bun-uws/src/HttpRouter.h, add() / executeHandlers()) splits on / and captures a segment only when its first byte is :. files:batchGet is a literal; :id:undelete is one whole-segment parameter.
    • The params builder (src/jsc/bindings/ServerRouteList.cpp, finishCreation) took every : in the key as the start of a name, so it produced more names than the router captures, and paramsObjectForRoute fills names positionally from req->getParameter(i) (out of range reads as "").
    • validate_route_name (src/runtime/server/ServerConfig.rs) used a third variant of the same scan, so it rejected "/v1:2beta/:id" (literal segment "starting with a digit") and "/a:x/:x" (as a "duplicate") while accepting the keys above.

Fix

  • ServerRouteList.cpp: build the identifier list per segment with the router's rule (segment starts with :), so there is exactly one name per captured value. This is the line that fixes the reported output.
  • ServerConfig.rs: validate_route_name uses the same split. The leading-digit and duplicate checks now only look at real parameter names, and a parameter segment containing a second : (":id:undelete", ":from-:to") throws Invalid route "...". A route parameter must span the whole path segment, ..., since the router can only capture the whole segment and there is no correct params object for such a key. This is the one behavior change for previously accepted keys; those keys never worked as written. The check stays in validate_route_name (handler routes), which is where the existing parameter-name rules live; static Response/HTML routes have no params object and are unchanged.
  • Prior art for the throw (survey output below): Express 5 and @koa/router (path-to-regexp 8) also reject /:id:undelete at registration; Fastify and Hono accept it as one parameter literally named "id:undelete"; Express 4 splits it into id: "4", undelete: "2". Express and Fastify support /:from-:to as two parameters, which Bun's router cannot match, so throwing now keeps that spelling available for a later addition instead of giving it a different meaning. A : inside a literal segment is a parameter in Express/Fastify (they spell a literal colon \: / ::) but a literal in Bun's router, Hono's TrieRouter and PatternRouter; this PR keeps it literal because static routes already match such paths today.
  • packages/bun-types/serve.d.ts: ExtractRouteParams only extracts names preceded by /, matching the runtime (BunRequest<"/files:batchGet/:id">["params"] is { id: string }, previously { batchGet: string; id: string }). Checked with tsc and tsgo; existing fixtures are unaffected.
  • docs/runtime/http/routing.mdx: one paragraph stating the rule.
  • Verified:
    • test/js/bun/http/bun-serve-routes.test.ts: new ':' that does not start a segment is literal text and route parameter validation uses the segment grammar blocks. 9 of the new tests fail on the released 1.4.0 (shifted params, missing throw, and the digit / duplicate false rejections); the whole file (68 tests) passes with this change.
    • test/integration/bun-types/fixture/serve.ts: added expectType cases for the literal-colon keys; bun test test/integration/bun-types/bun-types.test.ts type checks pass.
    • cargo clippy -p bun_runtime, cargo fmt --check, clang-format check, and the byte-search source lint are clean.

Background

  • A route key such as "/users/:id" is registered verbatim with uWS's HttpRouter, which stores it as a tree of path segments. While matching a request, each segment of the request path is compared against the tree; a : node matches any non-empty segment and pushes that segment onto a parameter stack. The handler only sees that stack, indexed by position, through req->getParameter(i).
  • ServerRouteList is the JS-side companion: for each route key it precomputes the list of parameter names and a JSC Structure for the params object, and on each request it fills that object by position from the router's stack. The two sides agree only if both derive the same number of parameters from the key, which is what this change makes true.
Repro before / after
const s = Bun.serve({
  port: 0,
  routes: {
    "/files:batchGet/:id": req => Response.json(req.params),
    "/v1:beta/:name/x": req => Response.json(req.params),
    "/anon/:/:id": req => Response.json(req.params),
  },
  fetch: () => new Response("fallback"),
});
request before after
/files:batchGet/123 {"batchGet":"123","id":""} {"id":"123"}
/v1:beta/bob/x {"beta":"bob","name":""} {"name":"bob"}
/anon/a/b {"id":"a"} {"":"a","id":"b"}

"/n/:id:undelete" and "/range/:from-:to" previously registered silently; they now throw a TypeError from Bun.serve(). "/v1:2beta/:id" and "/a:x/:x" previously threw (digit / duplicate) and now register with params { id } / { x }.

Survey of other routers (Express 4.22, Express 5.2, @koa/router 15.7, Fastify 5.11 / find-my-way 9.7, Hono 4.13); each route registered on its own, then probed
## route "/files:batchGet/:id"
- find-my-way (Fastify's router)
    /files:batchGet/123 -> {"batchGet":":batchGet","id":"123"}
    /filesXYZ/123 -> {"batchGet":"XYZ","id":"123"}
    /files/123 -> {"batchGet":"","id":"123"}
- fastify
    /files:batchGet/123 -> {"batchGet":":batchGet","id":"123"}
    /filesXYZ/123 -> {"batchGet":"XYZ","id":"123"}
    /files/123 -> {"batchGet":"","id":"123"}
- hono
    /files:batchGet/123 -> {"batchGet":":batchGet"}
    /filesXYZ/123 -> {"batchGet":"XYZ"}
    /files/123 -> no match
- express 4 (path-to-regexp 0.1)
    /files:batchGet/123 -> {"batchGet":":batchGet","id":"123"}
    /filesXYZ/123 -> {"batchGet":"XYZ","id":"123"}
    /files/123 -> no match
- express 5 (path-to-regexp 8)
    /files:batchGet/123 -> {"batchGet":":batchGet","id":"123"}
    /filesXYZ/123 -> {"batchGet":"XYZ","id":"123"}
    /files/123 -> no match
- @koa/router 15 (path-to-regexp 8)
    /files:batchGet/123 -> {"batchGet":":batchGet","id":"123"}
    /filesXYZ/123 -> {"batchGet":"XYZ","id":"123"}
    /files/123 -> no match

## route "/n/:id:undelete"
- find-my-way (Fastify's router)
    /n/42:undelete -> {"id:undelete":"42:undelete"}
    /n/42 -> {"id:undelete":"42"}
    /n/42undelete -> {"id:undelete":"42undelete"}
- fastify
    /n/42:undelete -> {"id:undelete":"42:undelete"}
    /n/42 -> {"id:undelete":"42"}
    /n/42undelete -> {"id:undelete":"42undelete"}
- hono
    /n/42:undelete -> {"id:undelete":"42:undelete"}
    /n/42 -> {"id:undelete":"42"}
    /n/42undelete -> {"id:undelete":"42undelete"}
- express 4 (path-to-regexp 0.1)
    /n/42:undelete -> {"id":"4","undelete":"2:undelete"}
    /n/42 -> {"id":"4","undelete":"2"}
    /n/42undelete -> {"id":"4","undelete":"2undelete"}
- express 5 (path-to-regexp 8)
    throws at registration: Missing text before "undelete" param: /n/:id:undelete; visit https://git.new/pathToRegexpError for info
- @koa/router 15 (path-to-regexp 8)
    throws at registration: Missing text before "undelete" param: /n/:id:undelete; visit https://git.new/pathToRegexpError for info

## route "/range/:from-:to"
- find-my-way (Fastify's router)
    /range/1-2 -> {"from":"1","to":"2"}
    /range/1 -> no match
- fastify
    /range/1-2 -> {"from":"1","to":"2"}
    /range/1 -> no match
- hono
    /range/1-2 -> {"from-:to":"1-2"}
    /range/1 -> {"from-:to":"1"}
- express 4 (path-to-regexp 0.1)
    /range/1-2 -> {"from":"1","to":"2"}
    /range/1 -> no match
- express 5 (path-to-regexp 8)
    /range/1-2 -> {"from":"1","to":"2"}
    /range/1 -> no match
- @koa/router 15 (path-to-regexp 8)
    /range/1-2 -> {"from":"1","to":"2"}
    /range/1 -> no match

## route "/files::batchGet/:id"
- find-my-way (Fastify's router)
    /files:batchGet/123 -> {"id":"123"}
    /filesXYZ/123 -> no match
- fastify
    /files:batchGet/123 -> {"id":"123"}
    /filesXYZ/123 -> no match
- hono
    /files:batchGet/123 -> {":batchGet":":batchGet"}
    /filesXYZ/123 -> {":batchGet":"XYZ"}
- express 4 (path-to-regexp 0.1)
    /files:batchGet/123 -> {"batchGet":"batchGet","id":"123"}
    /filesXYZ/123 -> no match
- express 5 (path-to-regexp 8)
    throws at registration: Missing parameter name at index 7: /files::batchGet/:id; visit https://git.new/pathToRegexpError for info
- @koa/router 15 (path-to-regexp 8)
    throws at registration: Missing parameter name at index 7: /files::batchGet/:id; visit https://git.new/pathToRegexpError for info

## route "/files\\:batchGet/:id"
- find-my-way (Fastify's router)
    /files:batchGet/123 -> no match
    /filesXYZ/123 -> no match
- fastify
    /files:batchGet/123 -> no match
    /filesXYZ/123 -> no match
- hono
    /files:batchGet/123 -> no match
    /filesXYZ/123 -> no match
- express 4 (path-to-regexp 0.1)
    /files:batchGet/123 -> {"id":"123"}
    /filesXYZ/123 -> no match
- express 5 (path-to-regexp 8)
    /files:batchGet/123 -> {"id":"123"}
    /filesXYZ/123 -> no match
- @koa/router 15 (path-to-regexp 8)
    /files:batchGet/123 -> {"id":"123"}
    /filesXYZ/123 -> no match

Hono per router (its default picks RegExpRouter here):

## hono default
  /files:batchGet/:id  /files:batchGet/123 -> {"params":{"batchGet":":batchGet"},"routePath":"/files:batchGet/:id"}
  /files:batchGet/:id  /filesXYZ/123 -> {"params":{"batchGet":"XYZ"},"routePath":"/files:batchGet/:id"}
  /files:batchGet/:id  /files/123 -> no match (404)
  /files:batchGet/:id  /files:batchGet -> no match (404)
  /n/:id:undelete  /n/42:undelete -> {"params":{"id:undelete":"42:undelete"},"routePath":"/n/:id:undelete"}
  /n/:id:undelete  /n/42 -> {"params":{"id:undelete":"42"},"routePath":"/n/:id:undelete"}
  /range/:from-:to  /range/1-2 -> {"params":{"from-:to":"1-2"},"routePath":"/range/:from-:to"}
  /range/:from-:to  /range/1 -> {"params":{"from-:to":"1"},"routePath":"/range/:from-:to"}

## hono RegExpRouter
  /files:batchGet/:id  /files:batchGet/123 -> {"params":{"batchGet":":batchGet"},"routePath":"/files:batchGet/:id"}
  /files:batchGet/:id  /filesXYZ/123 -> {"params":{"batchGet":"XYZ"},"routePath":"/files:batchGet/:id"}
  /files:batchGet/:id  /files/123 -> no match (404)
  /files:batchGet/:id  /files:batchGet -> no match (404)
  /n/:id:undelete  /n/42:undelete -> {"params":{"id:undelete":"42:undelete"},"routePath":"/n/:id:undelete"}
  /n/:id:undelete  /n/42 -> {"params":{"id:undelete":"42"},"routePath":"/n/:id:undelete"}
  /range/:from-:to  /range/1-2 -> {"params":{"from-:to":"1-2"},"routePath":"/range/:from-:to"}
  /range/:from-:to  /range/1 -> {"params":{"from-:to":"1"},"routePath":"/range/:from-:to"}

## hono TrieRouter
  /files:batchGet/:id  /files:batchGet/123 -> {"params":{"id":"123"},"routePath":"/files:batchGet/:id"}
  /files:batchGet/:id  /filesXYZ/123 -> no match (404)
  /files:batchGet/:id  /files/123 -> no match (404)
  /files:batchGet/:id  /files:batchGet -> no match (404)
  /n/:id:undelete  /n/42:undelete -> {"params":{"id:undelete":"42:undelete"},"routePath":"/n/:id:undelete"}
  /n/:id:undelete  /n/42 -> {"params":{"id:undelete":"42"},"routePath":"/n/:id:undelete"}
  /range/:from-:to  /range/1-2 -> {"params":{"from-:to":"1-2"},"routePath":"/range/:from-:to"}
  /range/:from-:to  /range/1 -> {"params":{"from-:to":"1"},"routePath":"/range/:from-:to"}

## hono PatternRouter
  /files:batchGet/:id  /files:batchGet/123 -> {"params":{"id":"123"},"routePath":"/files:batchGet/:id"}
  /files:batchGet/:id  /filesXYZ/123 -> no match (404)
  /files:batchGet/:id  /files/123 -> no match (404)
  /files:batchGet/:id  /files:batchGet -> no match (404)
  /n/:id:undelete  /n/42:undelete -> {"params":{"id":"42"},"routePath":"/n/:id:undelete"}
  /n/:id:undelete  /n/42 -> no match (404)
  /range/:from-:to  /range/1-2 -> no match (404)
  /range/:from-:to  /range/1 -> no match (404)

@coderabbitai

coderabbitai Bot commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 10 minutes

Limit details: You’ve used all 5 included reviews currently available under your plan.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 45f3f826-1786-435b-bdec-c45fae40b40e

📥 Commits

Reviewing files that changed from the base of the PR and between 8326d1b and 0b359bf.

📒 Files selected for processing (6)
  • docs/runtime/http/routing.mdx
  • packages/bun-types/serve.d.ts
  • src/jsc/bindings/ServerRouteList.cpp
  • src/runtime/server/ServerConfig.rs
  • test/integration/bun-types/fixture/serve.ts
  • test/js/bun/http/bun-serve-routes.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on the released 1.4.0 with the ledger snippet (/files:batchGet/123 -> {"batchGet":"123","id":""}, /n/42 reaching the :id:undelete handler); the new cases in test/js/bun/http/bun-serve-routes.test.ts fail there and pass with this branch. Diff is ready for review. The :a:b question was settled against other routers (survey in the description and in the comment below): Express 5 also throws for it, so the PR keeps the throw; no code change resulted.

CI (build 93820, finished): 179 jobs passed, 0 test failures against this diff (the handful of flagged tests passed on retry and are unrelated to routing). The build is marked failed only because the two darwin 26 aarch64 - test-bun jobs were canceled before an agent picked them up; that lane has a single box and the same cancellation hit nearly every branch build over the same hours (main builds 93967, 94079 and 94234 ran it and passed), so a retrigger would not add information. Nothing further planned from my side; this is ready for a maintainer.

@claude claude 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.

I reviewed this PR and didn't find any bugs. Since it changes user-facing route-grammar semantics (newly rejecting /:id:verb-style keys and newly accepting /v1:2/:id, /a:x/:x) and touches the public ExtractRouteParams type, a maintainer sign-off on the API decision would be worthwhile.

What was reviewed:

  • Confirmed the new segment scan in ServerRouteList.cpp and validate_route_name matches HttpRouter::add() / executeHandlers() (segment is a param iff it starts with :; whole segment captured).
  • Traced ExtractRouteParams on /:id/:test, /orgs/:orgId/repos/:repoId/settings, /files:batchGet/:id, /v1:beta/:name/x, /static/* — the /${Rest} re-prefix keeps recursion correct and the removed ${string}* branch was dead (both arms were {}).
  • Checked strings::split(&path[1..], b"/") edge cases (bare /, trailing /, anonymous : segment) against the C++ loop — both agree on identifier count.
Extended reasoning...

Overview

The PR aligns three previously divergent parsers of Bun.serve route keys onto uWS's actual grammar (split on /; a segment is a parameter only when it starts with :, and the parameter captures the whole segment):

  • src/jsc/bindings/ServerRouteList.cpp — the params-name builder now scans by segment instead of by every :, so identifier count matches req->getParameter(i) count.
  • src/runtime/server/ServerConfig.rs — validate_route_name uses the same split, so the digit/duplicate checks only see real parameter names, and adds a new throw for ":name:verb"-style segments that the router can never match as written.
  • packages/bun-types/serve.d.ts — ExtractRouteParams now anchors on /: and re-prefixes / on recursion.
  • Docs paragraph, type fixture, and runtime tests updated to match.

I read packages/bun-uws/src/HttpRouter.h and confirmed add() (line 291) strips a segment to : only when segment[0] == ':', and executeHandlers() (line 190) pushes a param only when the stored node name starts with : — so a mid-segment : is indeed literal and :id:undelete captures the whole segment. The new C++ loop, the Rust split, and the TS template-literal recursion all produce the same identifier list for the tested keys and the edge cases I traced (leading /, trailing /, bare : segment, wildcard).

Security risks

None. Pure string-scan refactors over already-ASCII-validated route keys; no allocation ownership changes, no user-input parsing beyond what already existed.

Level of scrutiny

Medium-high. The mechanics are a focused ~40-line rewrite with good tests, but the change reaches user-facing API surface in three ways: (1) a new TypeError from Bun.serve() for keys like "/n/:id:undelete" and "/range/:from-:to" that previously registered (albeit with broken req.params); (2) previously-rejected keys "/v1:2beta/:id" and "/a:x/:x" now accepted; (3) the exported Serve.ExtractRouteParams type shape changes for literal-colon keys. Each is well-argued in the PR body, but "reject vs. name-the-whole-segment" for ":a:b" and "align to uWS vs. teach uWS Express-style mid-segment params" are API design calls a maintainer should ratify.

Other factors

Test coverage is thorough (both handler and per-method route objects, both positive matches and fallthrough, both new-throw and newly-accepted keys, plus the type fixture). No prior review comments to address. No memory-safety or exception-scope concerns in the C++ change — it's a pure loop over a WTF::String with the same m_pathIdentifiers.append as before.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Agreed that the one decision here is what to do with a parameter segment that contains a second :. The options, for whoever signs off:

  1. Throw at Bun.serve() (this PR). "/n/:id:undelete" and "/range/:from-:to" never matched what they look like: uWS captures the whole segment, so the first also matched /n/42, and req.params had the value under the wrong name.
  2. Accept and name the parameter after the whole segment (req.params["id:undelete"]), which keeps the key registering but still over-matches.
  3. Accept and name it after the text before the second : (req.params.id === "42:undelete"), which silently drops the suffix the user wrote.

The rest of the PR (params object and validator using the router's segment split, the ExtractRouteParams change) does not depend on which of these is picked; option 2 is just dropping the new check in validate_route_name (the params builder already names the whole segment), and option 3 additionally trims the identifier in ServerRouteList.cpp; either way the test expectations change with it. The previously rejected keys ("/v1:2/:id", "/a:x/:x") are literal segments as far as the router is concerned, so accepting them is just the validator agreeing with the matcher.

@alii

alii commented Aug 13, 2026

Copy link
Copy Markdown
Member

What does express do? Fastify? Etc... Use that to make your decision.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Ran the three shapes through Express 4.22 (path-to-regexp 0.1), Express 5.2 and @koa/router 15 (path-to-regexp 8), Fastify 5.11 / find-my-way 9.7, and Hono 4.13 (full output in the PR description).

route Express 5, @koa/router Express 4 Fastify (find-my-way) Hono this PR
/n/:id:undelete throws at registration: Missing text before "undelete" param matches /n/42 as { id: "4", undelete: "2" } one param named "id:undelete", also matches /n/42 TrieRouter/RegExpRouter: same as Fastify; PatternRouter: id plus literal :undelete throws
/range/:from-:to two params two params two params one param named "from-:to" (PatternRouter: matched neither /range/1-2 nor /range/1) throws
/files:batchGet/:id files literal + batchGet param (/filesXYZ/1 matches); literal colon is spelled \: same same; literal colon is spelled :: TrieRouter/PatternRouter: literal segment, { id }; default RegExpRouter: batchGet param and id is dropped literal segment, { id }

So for the :a:b shape, the current Express grammar throws, which is what this PR does, and the routers that accept it either give the segment a name nobody would write on purpose (Fastify, Hono) or split it arbitrarily (Express 4). Keeping the throw also leaves :from-:to / :id:verb free to get the Express/Fastify meaning (several parameters or a literal suffix inside one segment) later as an addition; accepting them now as one whole-segment parameter would have to be broken to do that. Staying with the throw.

For a : inside a literal segment, Bun's router behaves like Fastify's :: / Express's \: spelling by default: the segment is literal and nothing needs escaping, which is what the Google-style files:batchGet keys want. Making it a parameter like Express/Fastify would need intra-segment matching in the router, and rejecting it would break static routes that match these paths correctly today, so the PR leaves it literal and only fixes the params object to agree.

…ith ':'

ServerRouteList built the req.params identifier list from every ':' in
the route key, while uWS's HttpRouter only captures a segment whose
first byte is ':'. A literal like "/files:batchGet/:id" therefore got
identifiers [batchGet, id] for a single captured value, so params.id was
empty and the value landed under params.batchGet.

Build the identifiers per segment with the router's grammar, validate
route keys with the same split, and reject a parameter segment that
contains a second ':' (":id:undelete", ":from-:to"), since the router
can only capture the whole segment. ExtractRouteParams follows the same
rule so the declared params match the runtime object.
@robobun
robobun force-pushed the farm/fb850c6c/route-param-segment-grammar branch from a628e7c to 0b359bf Compare August 16, 2026 10:55
Comment on lines +131 to +134
// Same grammar as uWS::HttpRouter::add(): the path splits on '/', and a
// segment is a parameter only when it starts with ':'. paramsObjectForRoute
// pairs these identifiers positionally with req->getParameter(i), so a ':'
// inside a literal segment must not produce one.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +644 to +646
// Same grammar as uWS::HttpRouter::add() and ServerRouteList.cpp: the path
// splits on '/', and a segment is a parameter only when it starts with ':'.
// A ':' anywhere else in a segment is literal text.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +653 to +654
// For now, we don't support params that start with a number.
// Mostly because it makes the params object more complicated to implement and it's easier to cut scope this way for now.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +662 to +663
// The router captures the whole segment as the parameter's value, so
// ":id:verb" or ":from-:to" can neither be matched nor named.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@claude claude 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.

I reviewed this and didn't find bugs — the segment-split in ServerRouteList.cpp, validate_route_name, and ExtractRouteParams all agree with uWS's HttpRouter::add() grammar, and the tests cover the shifted-params, false-rejection, and new-throw cases. Deferring to a maintainer because the :id:undelete / :from-:to throw is a user-facing API decision that alii engaged on but hasn't signed off on yet, and there are open comment-cop notes on the multi-line comments (they look like false positives to me — those comments name the cross-component invariant, not a workaround — but they're unaddressed).

What was checked:

  • The C++ segment scan handles the leading /, empty-name (/:), and trailing-segment cases; identifier count now matches the router's capture count.
  • strings::split / contains_char exist and path[1..] is safe given the caller's path[0] == '/' assert.
  • ExtractRouteParams traced for /:a/:b, /x:y/:z, /:a/b:c/:d, /static/* — matches runtime.
Extended reasoning...

Overview

Aligns three independent parsers of the same route-key grammar with the actual matcher (uWS HttpRouter): the params-object identifier list in ServerRouteList.cpp, the Rust-side validate_route_name, and the TS-level ExtractRouteParams. Adds a new registration-time throw for parameter segments containing a second :. Docs paragraph, type-fixture cases, and runtime tests included.

Security risks

None identified. Route keys are developer-supplied at Bun.serve() time, not request-derived; the change tightens validation and does not touch request parsing, TLS, or auth paths.

Level of scrutiny

Medium-high. Bun.serve routing is core user-facing API surface, and this changes what Bun.serve() accepts/rejects: /:id:verb and /:from-:to now throw where they previously registered (silently misbehaving), and /v1:2/:id / /a:x/:x now register where they previously threw. The author surveyed Express 4/5, Fastify, Hono, and koa-router at alii's request, and the chosen behavior matches Express 5 / path-to-regexp 8 for the throw case — but alii has not explicitly confirmed the choice.

Other factors

  • The four comment-cop inline notes fire on comments that state a cross-file invariant ("same grammar as HttpRouter::add()"), one of which is a pre-existing comment that merely moved. I read them as false positives, but they are technically outstanding.
  • No bugs from the multi-agent hunt; I traced the new C++ loop against the leading-/, empty-param-name (/:), and no-trailing-slash cases and it produces one identifier per router capture. The old code dropped the empty identifier for /:/, which caused the /anon/:/:id shift the PR description shows; the new code keeps it, matching the router.
  • CI green per the status comment (only unrelated darwin agent cancellations).
  • Given the API-design decision and the open maintainer thread, a human sign-off is appropriate here rather than an automated approval.

@robobun

robobun commented Aug 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:14 AM PT - Aug 16th, 2026

❌ @robobun, your commit 0b359bf has 1 failures in Build #99415 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 37983

That installs a local version of the PR into your bun-37983 executable, so you can run:

bun-37983 --bun

This branch has not been deployed

No deployments
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