Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 54 additions & 10 deletions src/runtime/server/HTMLBundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,9 @@ pub struct Route {
pub(crate) dev_server_id: Cell<Option<route_bundle::Index>>,
/// When state == .pending, incomplete responses are stored here.
pending_responses: JsCell<Vec<PendingResponse>>,
/// The chunk routes the last successful bundle appended to the server.
/// `server.reload()` replaces that list; `adopt_assets` carries these over.
Comment thread
robobun marked this conversation as resolved.
Resolved
assets: JsCell<Vec<(Box<[u8]>, RefPtr<StaticRoute>)>>,
}

pub enum State {
Expand Down Expand Up @@ -188,6 +191,10 @@ impl Route {
cost += mem::size_of::<Route>();
cost += self.pending_responses.get().len() * mem::size_of::<PendingResponse>();
cost += self.state.get().memory_cost();
// The `StaticRoute`s themselves are counted through the server's static route list.
for (path, _) in self.assets.get().iter() {
cost += mem::size_of::<(Box<[u8]>, RefPtr<StaticRoute>)>() + path.len();
}
cost
}

Expand All @@ -200,9 +207,37 @@ impl Route {
server: Cell::new(None),
state: JsCell::new(State::Pending),
dev_server_id: Cell::new(None),
assets: JsCell::new(Vec::new()),
})
}

/// Takes the chunk routes of `old`, the route for the same `HTMLBundle`
/// in the config that `server.reload()` replaces, so the chunk urls in a
/// document a client already holds stay served until the next build.
/// Returns them for the caller to append to the new config.
Comment thread
robobun marked this conversation as resolved.
Resolved
pub(crate) fn adopt_assets(&self, old: &Route) -> Vec<(Box<[u8]>, RefPtr<StaticRoute>)> {
if !std::ptr::eq(self.bundle.as_ptr(), old.bundle.as_ptr()) || !self.assets.get().is_empty()
{
return Vec::new();
}
let assets = old.assets.get().clone();
self.assets.set(assets.clone());
assets
}

/// Registers one chunk of the bundle on the server and records it in
/// `assets`.
Comment thread
robobun marked this conversation as resolved.
Resolved
fn append_asset(&self, server: AnyServer, path: &[u8], route: StaticRoute) {
let route = RefPtr::new(route);
self.assets
.with_mut(|assets| assets.push((Box::<[u8]>::from(path), route.clone())));
bun_core::handle_oom(server.append_static_route(
path,
AnyRoute::Static(route),
MethodOptional::Any,
));
}

pub(crate) fn on_request(this: ThisPtr<Self>, req: AnyRequest, resp: AnyResponse) {
Self::on_any_request(this, req, resp, false);
}
Expand Down Expand Up @@ -551,6 +586,7 @@ impl Route {
// needs it by `&mut` before it is shared. Static routes are keyed
// by `dest_path`, so registration order is immaterial.
let mut this_html_route: Option<(StaticRoute, Box<[u8]>)> = None;
self.assets.set(Vec::new());

// Create static routes for each output file
// Index loop because the SourceMap branch reads a sibling entry.
Expand Down Expand Up @@ -623,21 +659,29 @@ impl Route {
continue;
}

bun_core::handle_oom(server.append_static_route(
route_path,
AnyRoute::Static(RefPtr::new(static_route)),
MethodOptional::Any,
));
self.append_asset(server, route_path, static_route);
}

let (mut html_route, html_route_path) =
this_html_route.expect("the loop above visited html_index");
let html_route_clone = html_route.clone(global_this);
bun_core::handle_oom(server.append_static_route(
&html_route_path,
AnyRoute::Static(RefPtr::new(html_route)),
MethodOptional::Any,
));
// The page is also served at its output path, unless an html
// route is mounted there: a static route at the same path
// would replace it in the route list, and the page would
// never bundle again. Not an asset either way, so a reload
// drops it and the new html route bundles.
Comment thread
robobun marked this conversation as resolved.
Resolved
let html_path_has_html_route =
server.config().static_routes.iter().any(|entry| {
matches!(entry.route, AnyRoute::Html(_))
&& *entry.path == *html_route_path
});
if !html_path_has_html_route {
bun_core::handle_oom(server.append_static_route(
&html_route_path,
AnyRoute::Static(RefPtr::new(html_route)),
MethodOptional::Any,
));
}
self.state.set(State::Html(html_route_clone));

if !bun_core::handle_oom(server.reload_static_routes()) {
Expand Down
20 changes: 20 additions & 0 deletions src/runtime/server/server_body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2150,6 +2150,26 @@ where
dev_server.html_router.fallback = None;
}

// Carry the chunk routes of each built html route into the new list.
let mut adopted_assets = Vec::new();
for new_entry in new_config.static_routes.iter() {
let AnyRoute::Html(new_route) = &new_entry.route else {
continue;
};
for old_entry in self.config.static_routes.iter() {
if let AnyRoute::Html(old_route) = &old_entry.route {
adopted_assets.extend(new_route.adopt_assets(old_route));
}
}
}
for (path, route) in adopted_assets {
bun_core::handle_oom(new_config.append_static_route(
&path,
AnyRoute::Static(route),
server_config::MethodOptional::Any,
));
}

// NOTE: `Vec<StaticRouteEntry>` impls `Drop`, so
// a move-assign frees the old `static_routes`.
self.config.static_routes = core::mem::take(&mut new_config.static_routes);
Expand Down
97 changes: 97 additions & 0 deletions test/js/bun/http/bun-serve-html.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1097,6 +1097,103 @@ test.concurrent("server.reload() while an html route's first bundle is still in
});
});

// server.reload() replaces the server's static route list with the one from the
// new config. The js and css chunk routes of a built html route lived only on
// that list, so after a reload the chunk urls in a document a client already
// held fell through to fetch() until some client requested the page again.
// The page itself still bundles again on the next request after a reload, so
// a reload picks up an edit on disk. The chunks of the previous build stay
// served next to the new ones until the following reload. "/index.html" is
// also the bundler's output path for the page, so that route checks that the
// previous page does not shadow the new route.
describe.each([
{ development: false, route: "/" },
{ development: false, route: "/index.html" },
{ development: { hmr: false }, route: "/" },
{ development: { hmr: false }, route: "/index.html" },
])("server.reload() keeps the chunks of a built html route (%p)", ({ development, route }) => {
const files = {
"index.html": `<!DOCTYPE html><html><head><link rel="stylesheet" href="./style.css"></head><body><script type="module" src="./app.ts"></script></body></html>`,
"style.css": `body { color: red; }`,
"app.ts": `console.log("app v1");`,
};
const serveHead = /*ts*/ `
import html from "./index.html";
import { writeFileSync } from "node:fs";

const options = () => ({
port: 0,
hostname: "127.0.0.1",
development: ${JSON.stringify(development)},
routes: { ${JSON.stringify(route)}: html },
fetch: () => new Response("fallback", { status: 404 }),
});
const server = Bun.serve(options());
const route = ${JSON.stringify(route)};
const get = async (path) => {
const res = await fetch(new URL(path, server.url));
return { status: res.status, body: await res.text() };
};
const status = async (path) => (await get(path)).status;
const chunks = (page) => [/src="([^"]+\\.js)"/.exec(page)[1], /href="([^"]+\\.css)"/.exec(page)[1]];
`;

test.concurrent("chunk urls stay served after reload until the page is requested again", async () => {
using dir = tempDir("bun-serve-html-reload-chunks", {
...files,
"serve.ts": /*ts*/ `
${serveHead}
const [js, css] = chunks((await get(route)).body);

const before = [await status(js), await status(css)];
server.reload(options());
const afterReload = [await status(js), await status(css)];
const pageAfterReload = await status(route);
const afterPage = [await status(js), await status(css)];

console.log(JSON.stringify({ before, afterReload, pageAfterReload, afterPage }));
server.stop(true);
`,
});
const { stdout, stderr, exitCode } = await runServeFixture(dir);
expect({ stdout, exitCode }, stderr).toEqual({
stdout: JSON.stringify({
before: [200, 200],
afterReload: [200, 200],
pageAfterReload: 200,
afterPage: [200, 200],
}),
exitCode: 0,
});
});

test.concurrent("the page bundles again after reload and the previous chunks stay served", async () => {
using dir = tempDir("bun-serve-html-reload-rebuild", {
...files,
"serve.ts": /*ts*/ `
${serveHead}
const [js1] = chunks((await get(route)).body);
const v1 = (await get(js1)).body.includes("app v1");

writeFileSync("app.ts", 'console.log("app v2");');
server.reload(options());

const [js2] = chunks((await get(route)).body);
const v2 = (await get(js2)).body.includes("app v2");
const oldChunkAfterRebuild = await status(js1);

console.log(JSON.stringify({ v1, v2, sameChunk: js1 === js2, oldChunkAfterRebuild }));
server.stop(true);
`,
});
const { stdout, stderr, exitCode } = await runServeFixture(dir);
expect({ stdout, exitCode }, stderr).toEqual({
stdout: JSON.stringify({ v1: true, v2: true, sameChunk: false, oldChunkAfterRebuild: 200 }),
exitCode: 0,
});
});
});

// process.chdir() leaves the cached top-level directory with a trailing slash,
// which the dev server then used as its root. Reporting a bundle failure
// relativizes the failing file against that root and hit a debug assertion
Expand Down
Loading