From e69e210935a8a9858080670349563eb50f5d8963 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:37:18 +0000 Subject: [PATCH] Bun.serve: register HTML import outputs under their percent-encoded path The page carries the raw output name, for example /my img-24bdys0j.png. A browser percent-encodes the path before the request, so it asks for /my%20img-24bdys0j.png. The static route was keyed on the raw bytes and the request got a 404. The production build of an HTML import and the manifest of a compiled executable now register each output under the WHATWG path percent-encoded form of its path. --- src/runtime/server/HTMLBundle.rs | 5 +-- src/runtime/server/mod.rs | 29 ++++++++++++++++++ src/runtime/server/server_body.rs | 2 +- test/bundler/bundler_html_server.test.ts | 39 ++++++++++++++++++++++++ test/js/bun/http/bun-serve-html.test.ts | 30 ++++++++++++++++++ 5 files changed, 102 insertions(+), 3 deletions(-) diff --git a/src/runtime/server/HTMLBundle.rs b/src/runtime/server/HTMLBundle.rs index ec0a47591823..c2f79d50010e 100644 --- a/src/runtime/server/HTMLBundle.rs +++ b/src/runtime/server/HTMLBundle.rs @@ -617,14 +617,15 @@ impl Route { { route_path = &route_path[1..]; } + let route_path = crate::server::percent_encode_route_path(route_path); if i == html_index { - this_html_route = Some((static_route, Box::<[u8]>::from(route_path))); + this_html_route = Some((static_route, Box::<[u8]>::from(&*route_path))); continue; } bun_core::handle_oom(server.append_static_route( - route_path, + &route_path, AnyRoute::Static(RefPtr::new(static_route)), MethodOptional::Any, )); diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index d29af0bf5146..31cfc89e4f61 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -161,6 +161,35 @@ pub(crate) fn write_status(resp: *mut uws_sys::NewAppResponse std::borrow::Cow<'_, [u8]> { + fn needs_escape(byte: u8) -> bool { + byte < 0x20 + || byte >= 0x7F + || matches!( + byte, + b' ' | b'"' | b'#' | b'<' | b'>' | b'?' | b'`' | b'{' | b'}' + ) + } + if !path.iter().copied().any(needs_escape) { + return std::borrow::Cow::Borrowed(path); + } + let mut out = Vec::with_capacity(path.len() + 16); + for &byte in path { + if needs_escape(byte) { + let hex = bun_core::fmt::hex2_upper(byte); + out.extend_from_slice(&[b'%', hex[0], hex[1]]); + } else { + out.push(byte); + } + } + std::borrow::Cow::Owned(out) +} + /// The route table's ref on each route. pub enum AnyRoute { /// Serve a static file — `"/robots.txt": new Response(...)` diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index 037be482622a..cd7a35d7619e 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -627,7 +627,7 @@ impl AnyRoute { if !relative_path.starts_with(b"/") { builder.push(b'/'); } - builder.extend_from_slice(relative_path); + builder.extend_from_slice(&super::percent_encode_route_path(relative_path)); let Some(headers_js) = argument.get(init_ctx.global, b"headers")? else { return Ok(None); diff --git a/test/bundler/bundler_html_server.test.ts b/test/bundler/bundler_html_server.test.ts index 83a918b1111f..feb26aac9a9b 100644 --- a/test/bundler/bundler_html_server.test.ts +++ b/test/bundler/bundler_html_server.test.ts @@ -121,5 +121,44 @@ describe.concurrent("bundler", () => { stdout: "Home status: 200\nHome has content: true\nAbout status: 200\nAbout has content: true", }, }); + + // The page carries the raw asset name. A browser percent-encodes it + // before the request, so the route is registered under that form. + itBundled(`compile/${backend}/HTMLServerEncodedAssetRoute`, { + compile: true, + backend: backend, + files: { + "/entry.ts": /* js */ ` + import index from "./index.html"; + + using server = Bun.serve({ + port: 0, + routes: { + "/": index, + }, + }); + + const html = await (await fetch(server.url)).text(); + for (const [, src] of html.matchAll(/ + + + + + + + `, + "/my img.png": "fake png", + "/ünï.png": "fake png", + }, + run: { + stdout: "/my%20img.png 200\n/%C3%BCn%C3%AF.png 200", + }, + }); } }); diff --git a/test/js/bun/http/bun-serve-html.test.ts b/test/js/bun/http/bun-serve-html.test.ts index 45ab33d47126..5ae161adef6b 100644 --- a/test/js/bun/http/bun-serve-html.test.ts +++ b/test/js/bun/http/bun-serve-html.test.ts @@ -1606,3 +1606,33 @@ describe("production headers and import.meta.env", () => { expect(results).toEqual(cases.map(([, , expected]) => expected)); }); }); + +// The page carries the raw asset name. A browser percent-encodes it before +// the request, so the production build registers the route under that form. +test.concurrent("production build serves an asset whose name has a space or non-ASCII", async () => { + using dir = tempDir("bun-serve-html-encoded-asset-route", { + "index.html": ``, + "my img.png": "fake png", + "ünï.png": "fake png", + "app.ts": `console.log("app");`, + "serve.ts": /*ts*/ ` + import page from "./index.html"; + using server = Bun.serve({ port: 0, development: false, routes: { "/": page } }); + const html = await (await fetch(server.url)).text(); + const result = []; + for (const [, src] of html.matchAll(/