Skip to content

Bun.serve: register the default handler on "/*" for every method - #45397

Open
robobun wants to merge 1 commit into
robobun/5627effc/serve-unknown-method-501from
robobun/5627effc/serve-star-default-any
Open

robobun wants to merge 1 commit into
robobun/5627effc/serve-unknown-method-501from
robobun/5627effc/serve-star-default-any

Conversation

@robobun

@robobun robobun commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Under "/*": { GET: fn }, PROPFIND /x gets the 404 of a router miss and not fetch. Under "/*": { GET: static, TRACE: fn } the TRACE handler runs.
  • set_routes (src/runtime/server/mod.rs:2677) registers the default handler per other method with App::method(), which knows 9 of the 36 methods (src/uws_sys/App.rs:227).
  • That loop also replaces the HEAD of a non-function GET route.

Fix

  • set_routes registers the default handler with any("/*"), unless an any-method "/*" route exists. uWS tries it after the routes of the request's method.
  • The set of covered methods becomes one flag. App::method() asserts in debug builds that the method has a route function.
  • Verified: 10 new tests in test/js/bun/http/bun-serve-routes.test.ts, 9 fail without the change. 7 tables run over HTTP/1.1, HTTP/2 and HTTP/3.
  • Self-reviewed: 33 concerns raised, 32 addressed (Bun.serve: answer 501 to an unknown method and 404 to a router miss, not a closed socket #45396).

Background

  • The default handler answers when no route does: fetch, or the 404.
  • HTTP/1.1 registers an any-method route under each of the 36 method names. HTTP/2 and HTTP/3 keep one any-method tree.
  • Considered 27 more route functions for App::method(). One any() does the same.

Downsides

  • Answers change for requests that had one (Notes). HEAD on "/*": { GET: <not a function> } comes from the route, not fetch. On HTTP/2 and HTTP/3 an any-method route runs before fetch.
  • Registration with such a route: 7,665 to 7,713 allocations on HTTP/1.1, 365 to 325 on HTTP/2 and HTTP/3. Release binary: 3,328 bytes smaller.
Notes

Answers, before and after. One server with tls, http2: true and http3: true. fetch answers every request. Each row gets the same answer over HTTP/1.1, HTTP/2 and HTTP/3 after this PR.

routes request before, HTTP/1.1 before, HTTP/2 and HTTP/3 after
"/*": { GET: fn } PROPFIND /x, ACL /x 404, connection closed 404 fetch
"/*": { GET: new Response("s") } HEAD /x fetch fetch the static route
"/*": { GET: new Response("s"), TRACE: fn } PROPFIND /x, ACL /x the TRACE route the TRACE route fetch
"/*": { GET: fn }, "/any": fn2 POST /any fn2 fetch fn2
same, with a static, a file or a /u/:id any-method route POST to it the route fetch the route
same PROPFIND /nope 404, connection closed 404 fetch
"/*": { GET: new Response(Bun.file(missing)) }, "/any": fn2 GET /x 404, connection closed 404 fetch
"/*": { GET: fn }, "/any": fn2, no fetch POST /any fn2 404 fn2

HEAD on a GET route on "/*" that is not a function, by kind (HTTP/1.1, before then after):

  • a Response: fetch, then the route (its Content-Length, no body).
  • a Response of a Bun.file: fetch, then the route. A file that cannot be opened: fetch both times, because the route yields.
  • { dir }: fetch, then the route. A miss is a 404 from the route and fetch does not run.
  • an HTML import: fetch, then the route.

That is what the same route does on any other path. apply_static_route registers the route's own HEAD when the method set has GET. The old loop then registered the default handler for HEAD on "/*" over it.

A file route whose file cannot be opened falls back to fetch (bun-serve-file.test.ts, "handles nonexistent files gracefully"). For a GET file route on "/*" that worked only while the table had no any-method route.

Still different on HTTP/2 and HTTP/3. A route for the request's own method runs before an any-method route, also when the any-method route has the more specific path. With "/*": { GET: fn }, "/any": fn2, GET /any runs fn2 on HTTP/1.1 and fn on HTTP/2 and HTTP/3. Only HTTP/1.1 registers an any-method route under each method name. This PR does not change that, and its tests do not assert it. No issue tracks it.

Measurements.

Not in this PR.

Suites (debug build with ASAN). On this PR: bun-serve-routes (88 tests), bun-serve-static, bun-serve-file, serve-directory-routes, bun-serve-html. On the last PR of the stack, which has this change: also serve-http2, serve-http2-protocol, serve-http3, node-http, and serve.test.ts.

@robobun

robobun commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on main:

const server = Bun.serve({
  port: 0,
  routes: { "/*": { GET: () => new Response("star") } },
  fetch: req => new Response("fetch saw " + req.method),
});
for (const method of ["GET", "POST", "PROPFIND"]) {
  const answer = await fetch(server.url, { method }).then(
    async res => `${res.status} ${await res.text()}`,
    err => err.code,
  );
  console.log(method, "->", answer);
}
server.stop(true);

Before:

GET -> 200 star
POST -> 200 fetch saw POST
PROPFIND -> ECONNRESET

With #45396 alone the last line is PROPFIND -> 404 (the 404 of a router miss). With this PR it is PROPFIND -> 200 fetch saw PROPFIND.

The stack: #44510, #45396, #45397, #45398, #45399 (draft), #45400 (draft).

CI: the one red test in the runs of this stack is test/cli/install/bun-install-windows-rename-retry.test.ts on Windows 11 aarch64. It is red on other PRs too, and this diff does not touch install code.

@robobun
robobun force-pushed the robobun/5627effc/serve-unknown-method-501 branch from c58a788 to f81c8f6 Compare October 15, 2026 20:32
@robobun
robobun force-pushed the robobun/5627effc/serve-star-default-any branch from 9cd6431 to 6a5010d Compare October 15, 2026 20:32

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked whether the new debug_assert!(false, ...) arms in App::method and the two RouteKind::from_method can trip on user input: the only caller passing a user-chosen method is the per-method user route registration in set_routes, and ServerConfig only accepts the 9 method keys that have route functions, so they are unreachable from routes objects. I also checked that the negative-route loop is unchanged apart from the negative_h1 -> default_h1 rename.

Extended reasoning...

The change collapses the per-method "/" fallback registration in src/runtime/server/mod.rs into a single any-method registration for H1 and H2/H3, and adds debug-only assertions in src/uws_sys for methods without a route function; it touches no auth, crypto, or input-parsing surface. Two confirmed behavioral regressions were posted inline (file-route-on-"/" yield path and websocket upgrade under a GET-only "/*" route), so a human should weigh those before merging.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/runtime/server/mod.rs — Users with websocket and "/*": { GET: fn } get every WebSocket upgrade routed to fetch, or a 403 when there is no fetch, and fn never sees the upgrade. Step 3 registers fn's upgrade route with app.ws at mod.rs:2428 but only an any-method route sets has_any_ws_route_for_star_path (mod.rs:2398-2400). The fallback app.ws("/") at mod.rs:2636-2644 then registers the same method, pattern and HIGH priority, and HttpRouter::add (HttpRouter.h:282) removes fn's route first. Fix: set has_any_ws_route_for_star_path for a GET "/" user route as well, so the fallback ws route is only added when no "/*" upgrade route exists.

    Why this was flagged

    Trigger: Bun.serve({ routes: { "/*": { GET: (req, server) => server.upgrade(req) ? undefined : new Response("x") } }, websocket: {...} }) without a fetch, then a client opens ws://host/anything. mod.rs:2427-2434 registers the user's upgrade route via app.ws("/", ud, 1) for the GET route, but only the RouteMethod::Any arm at mod.rs:2398-2400 sets has_any_ws_route_for_star_path. mod.rs:2636 therefore registers the fallback app.ws("/", self_ptr, 0). Both go through onHttp with upgrade=true at HIGH_PRIORITY (HttpContext.h:1160) on the same pattern, and HttpRouter::add calls remove(methods[0], pattern, priority) at HttpRouter.h:282, so the user's handler is culled and only the fallback remains. The fallback runs fetch, or answers 403 when fetch is absent, so fn's server.upgrade never runs. The base branch has the same gap; the diff rewrote this coverage tracking (renaming the flag at mod.rs:2356) and the PR text lists the double registration under 'Not in this PR', so it was examined and left. Population: every server combining websocket with a per-method "/*" route; rate is every upgrade request.

    Verification: Pre-existing: the base at 6dec63d has the identical ws logic at its lines 2358/2403/2435/2653-2655. mod.rs:2397-2400 sets has_any_ws_route_for_star_path = true only inside the RouteMethod::Any arm; the GET arm at mod.rs:2425-2434 never sets the flag, so mod.rs:2636-2644 registers a second ws route on the same pattern and HttpRouter.h:280-282 add() removes the user route's handler.

Comment thread src/runtime/server/mod.rs

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

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

@robobun
robobun force-pushed the robobun/5627effc/serve-unknown-method-501 branch from f81c8f6 to 71e415c Compare October 15, 2026 21:28
@robobun
robobun force-pushed the robobun/5627effc/serve-star-default-any branch from 6a5010d to c7df286 Compare October 15, 2026 21:45
@robobun

robobun commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator Author

On the additional finding of the review (websocket with "/*": { GET: fn }): main has the same behavior, and this PR does not change it. I left it out on purpose.

Today every upgrade request on such a server reaches fetch, because the fallback upgrade route on "/*" replaces the upgrade route of fn. A server that has "/*": { GET: page } for its pages and calls server.upgrade(req) in fetch works today. If set_routes skips the fallback, the upgrade requests of that server reach page, and the WebSocket handshake fails.

On any other path the route gets the upgrade request, so the change is consistent. But it can break a server that works today, so it needs its own PR and a maintainer decision. The PR body lists it under "Not in this PR".

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I checked the router semantics this PR leans on in packages/bun-uws/src/HttpRouter.h and Http2Context.h/Http3Context.h: any() registers at LOW_PRIORITY and a node's handlers are sorted by priority, so a per-method "/*" route (MEDIUM) runs before default_h1 and the default only sees what it yields; the remove(methods[0], ..) at the top of HttpRouter::add can only evict an earlier default_h1 from a "/*": false negative route, never a user route. I also confirmed the new debug_assert!(false, ..) arms in App.rs/h2.rs/h3.rs cannot trip from user input, since ServerConfig.rs probes only the 9 method keys that have route functions, and that http_method::Set still has callers after the deletions.

Extended reasoning...

The change replaces per-method registration of the Bun.serve "/*" default handler with a single any-method registration on H1 and the H2/H3 mux routers in src/runtime/server/mod.rs, adds debug-only assertions in src/uws_sys/App.rs, h2.rs and h3.rs, and adds protocol-matrix tests. It touches no auth, crypto or input-parsing surface, but it changes request-dispatch behavior for HTTP/1.1, HTTP/2 and HTTP/3 in ways the author documents as intentional, so it is not a mechanical change suitable for an approve.

Comment thread test/js/bun/http/bun-serve-routes.test.ts Outdated
@robobun
robobun force-pushed the robobun/5627effc/serve-star-default-any branch from c7df286 to fbde686 Compare October 15, 2026 21:57

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

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

@robobun
robobun force-pushed the robobun/5627effc/serve-unknown-method-501 branch from 71e415c to a375993 Compare October 17, 2026 09:11
@robobun
robobun force-pushed the robobun/5627effc/serve-star-default-any branch from fbde686 to 47a7b48 Compare October 17, 2026 09:17
@robobun

robobun commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:17 AM PT - Oct 17th, 2026

@robobun, your commit 47a7b48 is building: #127441

@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 the latest push and didn't find any bugs; because this changes user-visible routing precedence (HEAD on a non-function GET "/*" route now comes from the route, and any-method routes now run ahead of fetch on HTTP/2 and HTTP/3), a maintainer's look at the intended semantics is still worthwhile.

What was reviewed:

  • Read packages/bun-uws/src/HttpRouter.h and the H1/H2/H3 onHttp registrations: handlers at a node run in priority order (MEDIUM method route before the LOW any route), H1 any() fans out to all 36 method names, and H2/H3 consult the "" tree only after the method tree, so the single any("/*") default is reached after a per-method "/" route yields.
  • Checked that the new debug_assert! in App::method / RouteKind::from_method is unreachable from user input: route objects accept only the 9 methods listed at src/runtime/server/ServerConfig.rs:814.
  • Confirmed no leftover readers of the removed coverage variables, that the H1 and H2/H3 branches now use the same handlers as before, and that the earlier dynamic-import nit is addressed by the module-scope import of bun-serve-routes-page.html.
Extended reasoning...

The commit rewrites step 9 of NewServer::set_routes in src/runtime/server/mod.rs so the default handler is one any-method "/" route (H1 and H2/H3) instead of a per-method loop limited to 9 of 36 methods, collapses the per-method coverage set into one bool, and adds debug-only assertions in src/uws_sys/App.rs, h2.rs and h3.rs; it touches no auth, crypto or input-parsing surface. The uWS router semantics the change relies on were verified from HttpRouter.h, Http2Context.h and Http3Context.h rather than from the comments, and the new tests cover the HTTP/1.1, HTTP/2 and HTTP/3 matrix with toEqual on full result tables. It is deferred rather than approved because it intentionally changes answers that requests previously received (HEAD on static "/" GET routes, any-method route precedence over fetch on H2/H3), which is a product decision for a maintainer; the bug hunt exited on dry_streak with no findings and no third-party objections are outstanding.

@robobun
robobun force-pushed the robobun/5627effc/serve-unknown-method-501 branch from a375993 to b9147c1 Compare October 17, 2026 10:06
@robobun
robobun force-pushed the robobun/5627effc/serve-star-default-any branch from 47a7b48 to 142d9eb Compare October 17, 2026 10:06

@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 the latest push and found no bugs; the two earlier inline items (file-route yield on an any-method "/", dynamic HTML import) are addressed in the code. Because this changes user-visible routing of Bun.serve (HEAD on a non-function GET "/" route, any-route ordering over HTTP/2 and HTTP/3), a human look at the intended behaviour table would still be worthwhile.

What was reviewed:

  • Checked the uWS router semantics the fix relies on: onHttp("*") registers at LOW_PRIORITY under all 36 method names (HttpContext.h:1148-1156) and HttpRouter::route tries the method tree before the "" tree, so a per-method "/" route still wins and a yielding one falls through to the new any default.
  • Checked the collapsed coverage flag against the old paths: no-"/"-route, any-method "/", and DevServer-H1-only cases register the same handlers as before; only the partial-method case changes, and it is covered by the new tables.
  • Checked the new debug_assert!s are unreachable from user input: route objects and HTML/file routes only produce the 9 methods in ServerConfig.rs:814-824 plus GET/HEAD (server_body.rs:650-653).
Extended reasoning...

The PR rewrites the "/" default-handler registration in src/runtime/server/mod.rs (steps 3-9 of route setup) to register one any-method route instead of one route per uncovered method, and adds debug assertions in src/uws_sys/App.rs, h2.rs and h3.rs for methods without a route function. It touches no auth, crypto or input-parsing surface; the user-reachable change is which handler answers a given method/path. Deferring rather than approving because the change intentionally alters observable routing behaviour (HEAD on static "/" GET routes, H2/H3 any-route ordering) that maintainers should sign off on, and the diff is ~360 lines across the core server routing path; no CODEOWNERS entry covers these files and no third-party objection is outstanding.

set_routes registers the default handler (fetch, or the 404) with
any("/*") whenever no any-method "/*" route exists. Before, a server
with a "/*" route for some methods registered the default handler with
App::method() for each other method, and App::method() registers only 9
of the 36 methods.

The set of covered methods is now one flag: an any-method "/*" route is
registered or it is not.
@robobun
robobun force-pushed the robobun/5627effc/serve-unknown-method-501 branch from b9147c1 to eb10891 Compare October 17, 2026 11:03
@robobun
robobun force-pushed the robobun/5627effc/serve-star-default-any branch from 142d9eb to eaf2275 Compare October 17, 2026 11:03

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

Code review found no issues

No high-confidence issues detected in this change.

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.

1 participant