diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index bc47073ee17d..9ecd6184b0f4 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -362,7 +362,7 @@ pub mod EntryPoint { pub(crate) use crate::entry_point::Kind; } use crate::bundled_ast::Flags as AstFlags; -use crate::generic_path_with_pretty_initialized; +use crate::{generic_path_with_pretty_initialized, pretty_path_base_dir}; type DeclaredSymbolList = bun_ast::DeclaredSymbolList; impl<'a> LinkerContext<'a> { @@ -382,8 +382,12 @@ impl<'a> LinkerContext<'a> { path: &bun_paths::fs::Path<'static>, arena: &Bump, ) -> Result, BunError> { - let top_level_dir = bun_resolver::fs::FileSystem::get().top_level_dir; - generic_path_with_pretty_initialized(path, self.options.target, top_level_dir, arena) + generic_path_with_pretty_initialized( + path, + self.options.target, + pretty_path_base_dir(self.dev_server.as_ref()), + arena, + ) } pub(crate) fn should_include_part(&self, source_index: crate::IndexInt, part: &Part) -> bool { diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 24999fd82b68..f1525f6c98eb 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -18,7 +18,7 @@ pub use bv2_impl::dispatch; pub use bv2_impl::{ CompileResult, CompileResultForSourceMap, CompileResultForSourceMapColumns, ContentHasher, EventLoop, ImportTracker, PartRange, StableRef, WrapKind, generic_path_with_pretty_initialized, - target_from_hashbang, + pretty_path_base_dir, target_from_hashbang, }; pub use bv2_impl::{DevServerInput, DevServerOutput, ImportTrackerIterator, ImportTrackerStatus}; // Flatten the impl-body module into this file's namespace so external callers @@ -2489,12 +2489,11 @@ pub mod bv2_impl { if path.pretty.as_ptr() == path.text.as_ptr() { // TODO: outbase + let base_dir = pretty_path_base_dir(self.dev_server.as_ref()); let rel = bun_paths::resolve_path::relative_platform::< bun_paths::resolve_path::platform::Loose, false, - >( - bun_resolver::fs::FileSystem::get().top_level_dir, path.text - ); + >(base_dir, path.text); // SAFETY: arena outlives the bundle pass; raw-pointer detour erases the // `&self` lifetime so the resulting `&'static [u8]` doesn't pin `self`. path.pretty = @@ -5761,7 +5760,7 @@ pub mod bv2_impl { let out = generic_path_with_pretty_initialized( path, target, - self.transpiler.fs().top_level_dir, + pretty_path_base_dir(self.dev_server.as_ref()), bump, )?; Ok(out) @@ -6455,12 +6454,6 @@ pub mod bv2_impl { import_record.source_index = Index::INVALID; if let Some(entry) = dev_server.is_file_cached(path.text, bake_graph) { - let rel = bun_paths::resolve_path::relative_platform::< - bun_paths::resolve_path::platform::Loose, - false, - >( - self.transpiler.fs().top_level_dir, path.text - ); if loader == Loader::Html && entry.kind == bake_types::CacheKind::Asset { // Overload `path.text` to point to the final URL @@ -6486,8 +6479,6 @@ pub mod bv2_impl { }; import_record.path.is_disabled = false; } else { - import_record.path.text = path.text; - import_record.path.pretty = rel; import_record.path = path_as_static( &self .path_with_pretty_initialized(path, target) @@ -7565,10 +7556,28 @@ pub mod bv2_impl { None } + /// The directory `Path.pretty` is computed relative to. + /// + /// In a dev server bundle the pretty path is the module id the HMR runtimes + /// load modules by, and the dev server derives the ids it asks them to load + /// from its own root (`DevServer::relative_path`), which `app.root` can + /// point somewhere other than the cwd. Both sides have to agree on the + /// directory, so dev server bundles relativize against that root; every + /// other build relativizes against the cwd. + pub fn pretty_path_base_dir(dev_server: Option<&dispatch::DevServerHandle>) -> &[u8] { + match dev_server { + // SAFETY: points at `DevServer.root`, which is written once and + // outlives every bundle the dev server runs (see the interface + // definition in lib.rs). + Some(dev_server) => unsafe { &*dev_server.root() }, + None => bun_resolver::fs::FileSystem::get().top_level_dir, + } + } + pub fn generic_path_with_pretty_initialized( path: &bun_paths::fs::Path<'static>, target: options::Target, - top_level_dir: &[u8], + base_dir: &[u8], bump: &bun_alloc::Arena, ) -> crate::Result> { use crate::bun_fs::PathResolverExt as _; @@ -7590,7 +7599,7 @@ pub mod bv2_impl { let rel = bun_paths::resolve_path::relative_platform_buf::< bun_paths::resolve_path::platform::Loose, false, - >(&mut **buf2, top_level_dir, path.text); + >(&mut **buf2, base_dir, path.text); let mut path_clone: crate::bun_fs::Path<'_> = *path; if target == options::Target::ServerComponentsSsr { let mut fbs = bun_io::FixedBufferStream::new_mut(&mut buf.0[..]); diff --git a/src/bundler/lib.rs b/src/bundler/lib.rs index 20338efed2b8..3fa9ae4d72c8 100644 --- a/src/bundler/lib.rs +++ b/src/bundler/lib.rs @@ -47,7 +47,8 @@ pub use HTMLImportManifest::html_import_manifest; pub use bun_core::cheap_prefix_normalizer; pub use bundle_v2::{ CompileResult, CompileResultForSourceMap, ContentHasher, EventLoop, ImportTracker, PartRange, - StableRef, WrapKind, generic_path_with_pretty_initialized, target_from_hashbang, + StableRef, WrapKind, generic_path_with_pretty_initialized, pretty_path_base_dir, + target_from_hashbang, }; pub use chunk::{ CrossChunkImport, CrossChunkImportItem, CrossChunkImportItemList, bun_renamer, @@ -332,6 +333,12 @@ bun_dispatch::link_interface! { fn current_bundle_start_data() -> *mut (); fn register_barrel_with_deferrals(path: &[u8]) -> Result<(), crate::Error>; fn register_barrel_export(barrel_path: &[u8], alias: &[u8]); + // `DevServer.root`: the directory the dev server's module ids are + // relative to. Written once when the dev server is created and never + // again (so chunk workers may read it too), and alive for as long as + // the dev server is, which is longer than any bundle it runs. Read + // through `pretty_path_base_dir`. + fn root() -> *const [u8]; } } // SAFETY: the handle is `{ kind, owner: *mut () }`; the raw pointer is what diff --git a/src/bundler/linker_context/generateCodeForFileInChunkJS.rs b/src/bundler/linker_context/generateCodeForFileInChunkJS.rs index e41a1cb53ba8..f58ab7345e12 100644 --- a/src/bundler/linker_context/generateCodeForFileInChunkJS.rs +++ b/src/bundler/linker_context/generateCodeForFileInChunkJS.rs @@ -8,7 +8,6 @@ use bun_js_printer::renamer; use bun_js_printer::{self as js_printer, PrintResult, PrintResultSuccess}; use crate::analyze_transpiled_module::ModuleInfo; -use crate::generic_path_with_pretty_initialized; use crate::linker_context_mod::{StmtList, StmtListWhich}; use crate::options::Format as OutputFormat; use crate::{Chunk, Index, LinkerContext, Part, PartRange, WrapKind}; @@ -189,13 +188,8 @@ pub fn generate_code_for_file_in_chunk_js<'r, 'src>( source_ref.path.text.as_ptr(), source_ref.path.pretty.as_ptr(), ) { - let top_level_dir = bun_resolver::fs::FileSystem::get().top_level_dir; - let new_path = bun_core::handle_oom(generic_path_with_pretty_initialized( - &source_ref.path, - c.options.target, - top_level_dir, - arena, - )); + let new_path = + bun_core::handle_oom(c.path_with_pretty_initialized(&source_ref.path, arena)); source_storage = bun_ast::Source { path: new_path, // SAFETY: `source_ref` is `&'static Source`, so re-borrowing its diff --git a/src/runtime/bake/DevServer.rs b/src/runtime/bake/DevServer.rs index e8f6e34786c5..101ff873672f 100644 --- a/src/runtime/bake/DevServer.rs +++ b/src/runtime/bake/DevServer.rs @@ -286,8 +286,11 @@ pub struct DevServer { /// To validate the DevServer has not been collected, this can be checked. /// When freed, this is set to `undefined`. UAF here also trips ASAN. pub(crate) magic: Magic, - /// Absolute path to project root directory. For the HMR - /// runtime, its module IDs are strings relative to this. + /// Absolute path to project root directory (`app.root`, the cwd unless + /// configured otherwise). The HMR runtimes' module IDs are paths relative + /// to this, both the ones the bundler prints into the bundles + /// (`bun_bundler::pretty_path_base_dir`) and the ones this server asks the + /// runtimes to load (`relative_path`). pub(crate) root: Box<[u8]>, /// Unique identifier for this DevServer instance. Used to identify it /// when using the debugger protocol. @@ -610,13 +613,9 @@ pub(crate) fn init(options: Options) -> JsResult> { let global = options.vm.global(); let generic_action = "while initializing development server"; - // FileSystem is a process-lifetime singleton; `init` interns the path into - // the `DirnameStore` (process-lifetime arena) so no caller-side leak is - // needed for the `'static` it stores. - let _fs = match bun_resolver::fs::FileSystem::init(Some(options.root.as_bytes())) { - Ok(fs) => fs, - Err(err) => return Err(global.throw_error(err, generic_action)), - }; + // The process-wide `FileSystem` was initialized with the cwd when the VM + // started, so `top_level_dir` is the cwd, not `options.root`. Everything + // that needs to be relative to the project root goes through `dev.root`. let top_level_dir: &'static [u8] = bun_resolver::fs::FileSystem::get().top_level_dir; // `.bun_watcher = undefined` → `Watcher.init(DevServer, dev, fs, ...)` diff --git a/src/runtime/bake/dev_server/mod.rs b/src/runtime/bake/dev_server/mod.rs index d5d5373b2803..691f7b990609 100644 --- a/src/runtime/bake/dev_server/mod.rs +++ b/src/runtime/bake/dev_server/mod.rs @@ -1208,6 +1208,7 @@ bun_bundler::link_impl_DevServerHandle! { }; let _ = gop.value_ptr.get_or_put(alias); }, + root() => core::ptr::from_ref::<[u8]>(&(*this).root), } } diff --git a/test/bake/dev/bundle.test.ts b/test/bake/dev/bundle.test.ts index 6c6d3657029c..6d5d3a810779 100644 --- a/test/bake/dev/bundle.test.ts +++ b/test/bake/dev/bundle.test.ts @@ -865,3 +865,60 @@ devTest("barrel optimization: namespace re-export cycle through a star-exported await c.expectMessage("result: object Y KEEP DEEP OTHER"); }, }); +// The dev server addresses modules by their path relative to `app.root`, so the +// bundler has to compute the module ids it prints relative to that same +// directory rather than to the cwd. Routes are served from a directory below the +// cwd here, so the two differ. +devTest("app.root that is not the cwd", { + files: { + "bun.app.ts": ` + import path from "node:path"; + export default { + app: { + root: path.join(process.cwd(), "app"), + framework: { + fileSystemRouterTypes: [ + { + root: "app/routes", + style: "nextjs-pages", + serverEntryPoint: "./app/server.ts", + clientEntryPoint: "./app/client.ts", + }, + ], + }, + }, + }; + `, + "app/server.ts": ` + export function render(req, meta) { + const scripts = meta.modules.map(src => '').join(""); + return new Response("

" + meta.pageModule.default() + "

" + scripts + "", { + headers: { "Content-Type": "text/html" }, + }); + } + `, + "app/client.ts": ` + console.log("client loaded"); + `, + "app/message.ts": ` + export const message = "Hello"; + `, + "app/routes/index.ts": ` + import { message } from "../message"; + export default () => message; + `, + }, + async test(dev) { + await dev.fetch("/").expect.toInclude("

Hello

"); + + await using c = await dev.client("/"); + await c.expectMessage("client loaded"); + + // Server-side changes make connected clients reload the page. + await c.expectReload(async () => { + await dev.write("app/message.ts", `export const message = "Updated";`); + }); + await c.expectMessage("client loaded"); + await dev.fetch("/").expect.toInclude("

Updated

"); + }, +});