Skip to content

html dev server: route pages from the deepest directory that contains them all - #47305

Open
robobun wants to merge 1 commit into
robobun/c2f2d98b/html-entry-route-keysfrom
robobun/c2f2d98b/html-entry-common-directory
Open

robobun wants to merge 1 commit into
robobun/c2f2d98b/html-entry-route-keysfrom
robobun/c2f2d98b/html-entry-common-directory

Conversation

@robobun

@robobun robobun commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #47279.

@Jarred-Sumner: this moves routes that answer on 1.4.3, with no flag (as #38732, which #46021 took out). Notes has the choices.

Problem

  • bun ./index.html ./info.html serves / and /fo.
  • The bun create React template runs bun './**/*.html': dashboard.html and the built dist/dashboard.html get /ashboard and /ist/dashboard.
  • Cause: the base path (longestCommonPath, src/js/internal/html.ts:121) is the longest common prefix of the page paths in characters, so it can end inside a name.

Fix

  • The base path is the deepest directory that contains every page.
  • Removed as unreachable or unused: the path.relative branch, the empty-remainder branch, and the process.cwd() push and pop.
  • Verified: test/js/bun/http/bun-serve-html-entry.test.ts, 6 of 8 new tests that run on Linux fail on the base branch. Self-reviewed: 32 concerns raised, 29 addressed, 3 rejected (Notes).

Background

  • A route is the page's path below the base path, without .html and a last index.
  • Considered a trim of the prefix at its last /: it keeps the wrong loop. The native bun_paths helper has the defect too: bun build ./pub/a.html ./pubx/b.html writes _.._/pubx/b.html.

Downsides

  • Breaking, with no message: page-home.html + page-about.html served /home and /about, and now serve /page-home and /page-about. No user reported the cut.
  • Windows, pages on two drives: each page keeps its whole path (tested on Windows). Main can give one drive short routes (simulated).
  • :1.html + :2.html served /1 and /2. Now /:1 and /:2 are one parameter route for Bun.serve().
Notes

The decision

  • This changes routes without a flag. landing-prs.md calls a changed default a breaking change, and Hardening: fix crashes across the runtime, bundler, dev server, shell and CLI #46021 kept what 1.4.3 does ("What worked on 1.4.3 still works"). The choices: land it as it is, land it with a release that announces it, or print a note: at start for each route that differs from the one 1.4.3 gives.
  • The note: is not in this PR. To name the old route, each start must compute the old prefix again, which is the loop over every page that this PR removes.
  • Only page sets that main cuts move. A set is cut when every page path shares characters after the last / that they share. In a replay of 300,000 random page sets, 0 of the 285,781 sets that main names right change a route.
  • The cut routes are not stable on main. page-home.html + page-about.html serve /home and /about. Add index.html, and main serves /page-home and /page-about.
  • Found by reading the code. I found no report of a cut route in the tracker.
  • html dev server: percent-encode the route of a page #47279 (route keys) moves no route name and does not wait for this decision. This PR needs it first: with whole names, é1.html + é2.html need the percent-encoded key.

Behaviour changes (Linux x64, main is bun 1.4.3-canary.1+367d939d9, whose route block is the one of main)

Command main this PR
bun ./index.html ./info.html /, /fo /, /info
bun ./home.html ./help.html /ome, /elp /home, /help
bun ./pub/a.html ./pubx/b.html /a, /x/b /pub/a, /pubx/b
bun ./pub.html ./pub/a.html /, /a /pub, /pub/a
bun ./x/a.html ./xa.html /a for both, x/a.html has no URL /x/a, /xa
bun './**/*.html' with dashboard.html, dist/dashboard.html /ashboard, /ist/dashboard /dashboard, /dist/dashboard
bun ./page-home.html ./page-about.html /home, /about /page-home, /page-about
bun ./😀.html ./😁.html exits, TypeError: Invalid route "/�" /😀, /😁
bun ./:1.html ./:2.html /1, /2 /:1, /:2, one parameter route
bun ./site/a.html ./site/b.html /a, /b the same
bun ./index.html /* the same

The dashboard row is the tree that bun create ./dashboard.tsx and its build script leave (src/runtime/cli/create/projects/react-spa/package.json). I made the two files by hand and did not run bun create.

No directory in common (Windows). The test bun with pages on two drives (substDrive, three pages) runs on Windows only. It passes on the two Windows lanes of CI (2019 x64, 11 aarch64). The cases below are simulated with path.win32 on Linux.

  • C:\a\x.html, D:\b\y.html, D:\b\z.html: main gives /C:/a/x, /D:/b/y and / (for z.html). Now /C:/a/x, /D:/b/y, /D:/b/z.
  • C:\proj\index.html, about.html, pricing.html and \\nas\shared\legal.html: main gives /, /about, /pricing, //nas/shared/legal. Now /C:/proj, /C:/proj/about, /C:/proj/pricing, //nas/shared/legal.
  • Main's result depends on the sort order of the pages: its loop starts again from the next page when the prefix becomes empty (if (!acc) return curr).
  • In 200,000 simulated sets, 37,941 of the 49,560 sets on two roots change a route. The sets on one root behave as on POSIX.
  • When the url: line names a UNC page, it is wrong: //nas/... is read as a host. Main prints /nas/... there, which is not a route either.

Not fixed here

  • bun build ./pub/a.html ./pubx/b.html --outdir out writes out/a.html and out/_.._/pubx/b.html, on 1.4.3 and on this branch. That is the native helper, which this PR does not use. Bun.build, bun build: use the shared directory of 9 or more entry points as the default root #38732 changes its case of 9 or more entry points.
  • docs.html beside docs/index.html: both get /docs, and one replaces the other with no message.
  • A page name that starts with : or * is a parameter or a wildcard route, on main too when the name is not cut. Related: Bun.serve: register the files of an HTML import as literal routes #46009.
  • In development mode /about.html, /blog/ and /blog/index.html answer 404. The routes are /about and /blog.
  • The template: after bun run build, its dev glob also finds the built page. Then there are two pages, and / answers 404.
  • A page argument without ./ is resolved as a module specifier first (html.ts:87). The new tests write ./.

Measurements (Linux x64, this branch against its base #47279)

  • Method: the route block and the banner path of html.ts, built from the source text with new Function, page imports as plain objects, on bun 1.4.3.
  • GC cells of one cold run (bun:jsc heapStats().objectTypeCounts, 3 runs, identical): 1 page 36 to 31. 2 pages 47 to 45. 100 pages 736 to 633.
  • Calls (String, Array and node:path builtins wrapped, pages in one directory): 1 page 18 host and 4 closure calls to 13 and 3. 2 pages 23 and 9 to 22 and 8. 100 pages 905 and 499 to 806 and 400. 1,000 pages 9,005 and 4,999 to 8,006 and 4,000.
  • User-space instructions of one cold run (a perf_event_open counter, 5 runs, perf is not installed): 1 page 868,663..869,295 to 669,229..669,684. 2 pages 1,070,290..1,070,744 to 862,097..863,855. 100 pages 5,722,221..9,549,736 to 2,196,848..2,587,960.
  • getcwd calls until the banner (an LD_PRELOAD counter, strace is not installed): 2 with one page and 2 with two pages, before and after. process.cwd() is cached.
  • Built-in text, release flavour: internal/html.js 5,152 to 4,724 bytes. InternalModuleRegistryConstants.bin 2,752,734 to 2,752,306.

Replay against a whole-segment oracle (segment arrays, its own percent-encoding)

  • POSIX, 300,000 random sets of 2 to 11 pages: the routes and the keys of this branch equal the oracle in every set. 14,219 sets are cut on main.
  • Windows, simulated, 200,000 sets: equal to the oracle in every set.

Verification

  • bun bd test test/js/bun/http/bun-serve-html-entry.test.ts: 17 pass, 1 skipped (the Windows test). With src/ of the base branch: 6 fail (./pub/a.html ./pubx/b.html, nine pages in pub/x/ and pubx/, ./pub.html ./pub/a.html, ./index.html ./info.html, the dashboard tree, ./😀.html ./😁.html).
  • bun test --debug-aot on the test file: 17 pass.
  • Windows: the two Windows lanes of CI pass the file, 18 of 18. Not run here: Windows and macOS.

Self-review
The review read #47279 and this change as one diff and asked for two PRs. Rejected:

  • A test and a warning for page names that start with :: the route is a parameter route on main too when the name is not cut. The case is in Downsides.
  • A warning when two pages get one route: new output of the command, not part of this change.
  • Tracker issues for the items under "Not fixed here": none filed.

no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/http/bun-serve-html-entry.test.ts

@robobun

robobun commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

@robobun

robobun commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on bun 1.4.3-canary.1+367d939d9 (Linux x64):

$ mkdir -p pub pubx && echo '<h1>a</h1>' > pub/a.html && echo '<h1>b</h1>' > pubx/b.html
$ NO_COLOR=1 bun ./pub/a.html ./pubx/b.html
Routes:
  ├── /a → pub/a.html
  └── /x/b → pubx/b.html

$ echo '<h1>i</h1>' > index.html && echo '<h1>info</h1>' > info.html
$ NO_COLOR=1 bun ./index.html ./info.html
Routes:
  ├── / → index.html
  └── /fo → info.html

PR: #47305, stacked on #47279

CI

Build 134902 passed. test/js/bun/http/bun-serve-html-entry.test.ts passes on every lane: Linux x64 and aarch64 (glibc and musl), Linux x64 with ASAN, macOS x64 and aarch64, Windows 2019 x64, and Windows 11 aarch64. The two Windows lanes also run the test bun with pages on two drives, which is skipped elsewhere.

The test-aot-drawn lanes of that build fail in tests under test/aot/. They are soft failures, and they also fail on builds of main (134732, 134812).

The next step is a decision by a maintainer on the route change (the PR description has the choices), then a review.

@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 the new base-path loop in src/js/internal/html.ts for termination and the single-entry path: base shrinks one directory per iteration and startsWith("") is always true, so it cannot spin, and on POSIX every resolved page starts with / so base never drops below /; a single entry still gets servePaths[0] = "*" afterwards. The docs/bundler/html-static.mdx example output still matches the new behaviour, there is no ahead-of-time/ copy of internal/html.ts to keep in sync, and the diff only adds tests without altering existing ones.

Extended reasoning...

The change replaces the character-wise longest-common-prefix base path in the bun ./a.html ./b.html dev server with a directory-boundary base, plus a docs sentence and new route tests; no security-sensitive surface is touched. Inline findings already cover the unannounced route changes for prefix-sharing page names, the untested Windows two-root cases, and the serial test spawns, so a human decision on the breaking-change policy is still needed before merge.

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

  • 🟡 src/js/internal/html.ts — Users whose page names all begin with : get one page silently unreachable after merging, where the base branch served both. With the base trimmed at a directory boundary at src/js/internal/html.ts:117-122, ./:1.html ./:2.html become routes /:1 and /:2 at html.ts:170, and Bun.serve treats both as a one-segment parameter route, so whichever registers first answers every single-segment request. On the base branch the shared : was cut and the routes were /1 and /2. Fix: percent-encode or otherwise escape route-syntax characters (: at segment start, *) in routeKey or before html.ts:170, or reject such names with a message, so a filename can never become a parameter or wildcard route.

    Why this was flagged

    Trigger: any page set where every basename starts with : (or whose common directory makes a :-leading segment the first segment), e.g. bun ./:1.html ./:2.html on POSIX. Entry: start() in src/js/internal/html.ts. html.ts:130-146 yields servePath :1 and :2; html.ts:170 registers routes["/:1"] and routes["/:2"], both HTMLBundle values. ServerConfig.rs:756 and :789 only run validate_route_name for callable routes, so the HTML bundles bypass the parameter check and are handed to the router as two parameter routes at the same depth; one shadows the other and that page answers 404 or the wrong page everywhere. The base branch's character prefix cut the shared : so it served /1 and /2 (the PR table confirms this row). The dismissing finder called it too_minor because : names are invalid on Windows; on POSIX and macOS they are legal and the regression is a page the user passed on the command line disappearing without any message. Remedy: encode : and * at segment start in routeKey (serve_cli.ts:137) so routes are literal, covering the UNC and * siblings too.

    Verification: Trigger: a page set whose basenames all begin with :. src/js/internal/html.ts:117-122 sets base to the directory, so line 170 registers /:1 and /:2; routeKey (src/js/internal/serve_cli.ts:134-138) does not encode :. On the base branch routes were /1 and /2, both reachable. uWS treats a :-leading segment as a parameter, so one page is unreachable with no error.

  • 🟣 src/js/internal/html.ts — Windows users with a UNC page beside a drive-letter page get a route whose key begins with //, which Bun.serve registers but browsers and the banner read as a host, so the page is unreachable from the printed link. When no directory contains every page, html.ts:116-122 leaves base empty, so //nas/shared/legal.html keeps its whole path at html.ts:130. The strip at html.ts:144-145 removes exactly one leading /, and html.ts:170 prepends one again, giving //nas/shared/legal. Fix: when base is empty, strip every leading / (and the drive or UNC root) from each page before building the route, or fall back to a per-root base so UNC and drive pages get rooted routes.

    Why this was flagged

    Trigger: on win32, pages whose resolved paths share no directory, e.g. C:\proj\index.html and \\nas\shared\legal.html, through the HTML entry CLI. html.ts:113 normalizes to //nas/shared/legal.html; the loop at html.ts:118-122 reduces base to the empty string; html.ts:130 leaves the whole path; html.ts:136-138 strips .html; html.ts:144-145 strips only one of the two leading slashes; html.ts:170 calls routeKey("/" + "/nas/shared/legal") so the route key is //nas/shared/legal. A relative resolution of that key against the server URL in the banner yields host nas, and a browser link //nas/shared/legal goes to another host, so the user cannot reach the page from what Bun prints. The base branch's result depended on sort order and for some orderings gave the drive-letter pages short usable routes, which the new code never does. Windows-only, but the new code path at html.ts:116 explicitly designs for the empty-base case and leaves this consequence in place.

    Verification: pre-existing. Trigger: win32, pages whose resolved paths share no root (a UNC page such as \\nas\shared\legal.html beside C:\proj\index.html). In /home/claude/bun/src/js/internal/html.ts the loop at 118-122 pops base down to ""; 144-145 removes exactly one slash; line 170 prepends one, so the key is //nas/shared/legal. The base branch produces the identical key by the identical route.

Comment thread src/js/internal/html.ts Resolved
Comment thread src/js/internal/html.ts Resolved
… them all

The base path was the longest common prefix of the page paths in
characters, so it could end in the middle of a name: pub/a.html and
pubx/b.html were served as /a and /x/b. The base path is now the
deepest directory that contains every page.
@robobun
robobun force-pushed the robobun/c2f2d98b/html-entry-common-directory branch from 88d560f to bcd5df2 Compare October 29, 2026 04:18

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

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

@robobun

robobun commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator Author

Replies to the two findings of the review that are outside the diff lines.

Page names that all start with :. This is the third Downsides bullet. Bun.serve() reads /:1 as a parameter route on main too, for each page set where the name is not cut: bun ./:1.html ./b.html registers /:1 there. A key such as /%3A1 makes the route literal, but a browser does not encode : in a path, so the page then answers only at the encoded spelling. bun bake rejects such names at start. Each of the two is a change of its own, so this PR leaves the key as it is written.

A UNC page beside a page on a drive. The key //nas/shared/legal is the same on main. Notes has the case under "No directory in common (Windows)".

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