From ee7d543a3cc807a369121550fdf45b4c40dec5cb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:08:17 +0000 Subject: [PATCH 1/7] FileSystemRouter: read the route tree from disk on every scan `new Bun.FileSystemRouter()` listed what the resolver had cached for `dir`. `Routes::scan` now reads each directory itself, for the constructor and for `reload()`, and a route table owns its strings. `reload()` drops the resolver's record of each directory that the last scan or this scan read. Under a watcher it resolves the watched files of those directories again, so that the hot reloader still finds the module of a saved file. The constructor joins every `dir`, as #46286 does. Co-authored-by: Dylan Conway --- bench/snippets/filesystem-router.mjs | 49 + scripts/rust-miri.ts | 1 + src/bun_alloc/lib.rs | 48 - src/install/NetworkTask.rs | 2 +- src/jsc/hot_reloader.rs | 20 + src/resolver/lib.rs | 11 - src/router/error.rs | 25 - src/router/lib.rs | 1557 ++++++++++++-------- src/runtime/api/filesystem_router.rs | 307 ++-- test/js/bun/util/filesystem_router.test.ts | 1547 ++++++++++++++++++- 10 files changed, 2621 insertions(+), 946 deletions(-) create mode 100644 bench/snippets/filesystem-router.mjs delete mode 100644 src/router/error.rs diff --git a/bench/snippets/filesystem-router.mjs b/bench/snippets/filesystem-router.mjs new file mode 100644 index 000000000000..36d457572238 --- /dev/null +++ b/bench/snippets/filesystem-router.mjs @@ -0,0 +1,49 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { bench, run } from "../runner.mjs"; + +const root = mkdtempSync(join(tmpdir(), "bun-bench-fsr-")); +process.on("exit", () => rmSync(root, { recursive: true, force: true })); + +// 500 routes in 10 directories, one in ten of them dynamic. +const dense = join(root, "dense"); +for (let d = 0; d < 10; d++) { + const section = d === 0 ? dense : join(dense, `section-${d}`); + mkdirSync(section, { recursive: true }); + for (let i = 0; i < 50; i++) { + writeFileSync(join(section, i % 10 === 0 ? `[param${i}].tsx` : `page-${i}.tsx`), ""); + } +} + +// 50 routes in 50 directories: the shape in which a scan costs the most for each route. +const sparse = join(root, "sparse"); +// The same, with each route file a link to a file outside the tree. +const linked = join(root, "linked"); +mkdirSync(join(root, "targets")); +for (let d = 0; d < 50; d++) { + mkdirSync(join(sparse, `section-${d}`), { recursive: true }); + writeFileSync(join(sparse, `section-${d}`, "page.tsx"), ""); + mkdirSync(join(linked, `section-${d}`), { recursive: true }); + writeFileSync(join(root, "targets", `page-${d}.tsx`), ""); + symlinkSync(join(root, "targets", `page-${d}.tsx`), join(linked, `section-${d}`, "page.tsx")); +} + +// The first scan of a process is not a repeat of anything: time it once, on its own. +const start = Bun.nanoseconds(); +const router = new Bun.FileSystemRouter({ style: "nextjs", dir: dense }); +console.log(`first new Bun.FileSystemRouter(): ${((Bun.nanoseconds() - start) / 1e6).toFixed(2)} ms`); + +for (const [shape, dir] of [ + ["500 routes, 10 directories", dense], + ["50 routes, 50 directories", sparse], + ["50 linked routes, 50 directories", linked], +]) { + const reloaded = new Bun.FileSystemRouter({ style: "nextjs", dir }); + bench(`new Bun.FileSystemRouter() (${shape})`, () => new Bun.FileSystemRouter({ style: "nextjs", dir })); + bench(`router.reload() (${shape})`, () => reloaded.reload()); +} +bench("router.match() a static route", () => router.match("/section-3/page-7")); +bench("router.match() a dynamic route", () => router.match("/section-3/hello")); + +await run(); diff --git a/scripts/rust-miri.ts b/scripts/rust-miri.ts index 34d778d05991..8e9c62ab6648 100644 --- a/scripts/rust-miri.ts +++ b/scripts/rust-miri.ts @@ -51,6 +51,7 @@ const MIRI_CRATES = [ "bun_md", "bun_ptr", "bun_resolve_builtins", + "bun_router", "bun_shell_parser", "bun_threading", "bun_url", diff --git a/src/bun_alloc/lib.rs b/src/bun_alloc/lib.rs index 0cae99668a53..ae301ce6febd 100644 --- a/src/bun_alloc/lib.rs +++ b/src/bun_alloc/lib.rs @@ -310,8 +310,6 @@ pub fn trim<'a>(s: &'a [u8], chars: &[u8]) -> &'a [u8] { } // ─── ascii-lowercase helpers ────────────────────────────────────────────── -// Sunk from bun_core::strings so bun_alloc::BSSList::append_lower_case can call -// them without a dep cycle (bun_core → bun_alloc, not the reverse). // `bun_core::strings` re-exports `copy_lowercase` and `ascii_lowercase_buf`. /// ASCII-lowercase @@ -2005,52 +2003,6 @@ impl BSSStringList> }`, which (a) heap-allocs 4 KiB - /// on first use per thread and (b) pays a `RefCell` flag check per call. - /// Filenames are overwhelmingly <256 bytes, so a stack scratch suffices for - /// the hot path; longer inputs (rare — full paths) fall through to a - /// one-shot heap temp. No TLS, no Box-on-first-use, no `RefCell`. - /// - /// SAFETY: see [`append`]. - pub unsafe fn append_lower_case<'a>( - this: *mut Self, - value: &[u8], - ) -> core::result::Result<&'a [u8], AllocError> { - // SAFETY: see `append`. - let _guard = unsafe { (*this).mutex.lock() }; - - // `do_append` only reads `slice` via `BSSAppendable::copy_into` (copies - // into `self.backing_buf` / a fresh heap alloc) and returns raw parts - // pointing at that owned storage, not at `slice` — so the scratch - // buffer's borrow does not escape. - let (ptr, len) = if value.len() <= 256 { - let mut scratch = [0u8; 256]; - // SAFETY: inner mutex held ⇒ this thread has exclusive access. - unsafe { - (*this).do_append(&crate::copy_lowercase(value, &mut scratch[..value.len()]))? - } - } else { - // Slow path: input >256 bytes (rare). Use a one-shot heap temp via - // mimalloc directly (PORTING.md forbids `Vec` in hot allocators). - let p = mimalloc::mi_malloc(value.len()).cast::(); - if p.is_null() { - return Err(AllocError); - } - // SAFETY: `p` is a fresh allocation of `value.len()` bytes; sole owner. - let tmp = unsafe { core::slice::from_raw_parts_mut(p, value.len()) }; - // SAFETY: inner mutex held ⇒ this thread has exclusive access. - let r = unsafe { (*this).do_append(&crate::copy_lowercase(value, tmp)) }; - // SAFETY: `p` was allocated by `mi_malloc` above. - unsafe { mimalloc::mi_free(p.cast()) }; - r? - }; - // SAFETY: see `append`. - Ok(unsafe { core::slice::from_raw_parts(ptr, len) }) - } - /// Returns `(ptr, len)` of the freshly-appended payload (excluding the trailing NUL), /// where `ptr` carries write provenance (`out.as_mut_ptr()`). Callers reconstruct a /// `&[u8]` (`append`) or `&mut [u8]` (`append_mutable`) from it; returning raw parts diff --git a/src/install/NetworkTask.rs b/src/install/NetworkTask.rs index 4c58812b56f1..ee47ef3a70b4 100644 --- a/src/install/NetworkTask.rs +++ b/src/install/NetworkTask.rs @@ -20,7 +20,7 @@ use crate::{ExtractTarball, PackageManager, TarballStream, Task}; // Adapter so `StringOrTinyString::init_append_if_needed` can intern overflow // names into the resolver's filename arena. The bun_sys-level `FilenameStore` exposes `append` / -// `append_lower_case` but doesn't itself implement `strings::Appender` (that +// `append_slice` but doesn't itself implement `strings::Appender` (that // impl lives in `bun_resolver`, which this crate can't reach without a cycle). pub struct FilenameStoreAppender<'a>(pub(crate) &'a FilenameStore); impl strings::Appender for FilenameStoreAppender<'_> { diff --git a/src/jsc/hot_reloader.rs b/src/jsc/hot_reloader.rs index a5b258768dc6..22ffb572d247 100644 --- a/src/jsc/hot_reloader.rs +++ b/src/jsc/hot_reloader.rs @@ -54,6 +54,26 @@ impl ImportWatcher { .items::<"package_json", Option<&'static bun_watcher::PackageJSON>>()[index as usize] } + /// The paths of the watched files whose directory `in_dir` accepts, copied under the watcher's mutex. + pub fn watched_files(&self, mut in_dir: impl FnMut(&[u8]) -> bool) -> Vec> { + let w = match self { + ImportWatcher::Hot(w) | ImportWatcher::Watch(w) => w, + ImportWatcher::None => return Vec::new(), + }; + let _guard = w.mutex.lock_guard(); + let paths = w.watchlist.items_file_path(); + let kinds = w.watchlist.items_kind(); + paths + .iter() + .zip(kinds) + .filter(|(path, kind)| { + **kind == bun_watcher::Kind::File + && in_dir(bun_paths::dirname(path).unwrap_or_default()) + }) + .map(|(path, _)| Box::from(&**path)) + .collect() + } + #[inline] pub fn add_file_by_path_slow(&mut self, file_path: &[u8]) -> bool { match self { diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index a9872c36e891..ea031ed03b3c 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -156,17 +156,6 @@ pub mod fs { unsafe { bun_alloc::BSSStringList::append($backing(), &value) } .map_err(|_| bun_alloc::AllocError) } - /// Like `append`, but ASCII-lowercases `value` while copying - /// it into the store. - #[inline] - pub fn append_lower_case( - &self, - value: &[u8], - ) -> core::result::Result<&'static [u8], bun_alloc::AllocError> { - // SAFETY: see `append`. - unsafe { bun_alloc::BSSStringList::append_lower_case($backing(), value) } - .map_err(|_| bun_alloc::AllocError) - } } }; } diff --git a/src/router/error.rs b/src/router/error.rs deleted file mode 100644 index 57cddd0a59bd..000000000000 --- a/src/router/error.rs +++ /dev/null @@ -1,25 +0,0 @@ -#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] -pub enum Error { - #[error(transparent)] - Sys(#[from] bun_errno::SystemErrno), - #[error(transparent)] - Alloc(#[from] bun_alloc::AllocError), -} - -impl Error { - #[allow(clippy::trivially_copy_pass_by_ref)] - pub(crate) fn name(&self) -> &'static str { - match self { - Self::Sys(e) => <&'static str>::from(e), - Self::Alloc(_) => "OutOfMemory", - } - } -} - -impl bun_core::output::ErrName for Error { - fn name(&self) -> &[u8] { - (*self).name().as_bytes() - } -} - -pub type Result = core::result::Result; diff --git a/src/router/lib.rs b/src/router/lib.rs index ad79f499e626..451bfca7c515 100644 --- a/src/router/lib.rs +++ b/src/router/lib.rs @@ -1,8 +1,5 @@ #![warn(unused_must_use)] -pub mod error; -pub use error::{Error, Result}; - // This is a Next.js-compatible file-system router. // It uses the filesystem to infer entry points. // Despite being Next.js-compatible, it's not tied to Next.js. @@ -10,37 +7,17 @@ pub use error::{Error, Result}; // All it does is resolve URL paths to the appropriate entry point and parse URL params/query. use core::cmp::Ordering; use core::ptr::NonNull; -use std::cell::RefCell; -use bun_collections::{ArrayHashMap, StringHashMap}; -use bun_core::strings; -use bun_paths::{self, MAX_PATH_BYTES, PathBuffer, SEP, SEP_STR}; +use bun_collections::StringHashMap; +use bun_core::{ZStr, strings}; +use bun_paths::{MAX_PATH_BYTES, SEP, path_buffer_pool}; +use bun_ptr::RawSlice; +use bun_sys::{E, FileKind}; use bun_http_types::URLPath::URLPath; -// ────────────────────────────────────────────────────────────────────────── -// Cross-crate name aliases. These are pure re-exports of real lower-tier types -// (no shadow structs); kept as a private module so the aliased paths -// (`bun_ast::Log`, `Fs::Entry`) read naturally. -// ────────────────────────────────────────────────────────────────────────── -// Wyhash with seed 0. NOT Wyhash11 (different algo). -#[inline] -fn wyhash(input: &[u8]) -> u64 { - bun_wyhash::hash(input) -} - -// `bun.fs` namespace — `bun_router` depends on `bun_resolver` directly, so -// name the real `FileSystem` / `Entry` / `DirEntry` / `DirnameStore` types. -use bun_resolver::DirInfo; -use bun_resolver::DirInfoRef; -use bun_resolver::fs as Fs; -use bun_resolver::fs::FileSystem; - -type CoreError = crate::Error; - use bun_collections::index_sort; use bun_core::HashedString; -use bun_ptr::Interned; // ────────────────────────────────────────────────────────────────────────── // cross-tier decoupling @@ -60,13 +37,8 @@ pub struct RouteConfig { pub extensions: Box<[Box<[u8]>]>, } -// `hash_const` is byte-identical to the runtime `bun_wyhash::hash` (seed 0); -// `tests::hash_const_matches_runtime` in `bun_wyhash` guards drift. -const INDEX_ROUTE_HASH: u32 = - bun_wyhash::hash_const(0, b"$$/index-route$$-!(@*@#&*%-901823098123") as u32; - // Param/List are lifetime-parameterized: `name` borrows the route name -// (DirnameStore-backed) and `value` borrows the *request URL buffer*, so the +// (owned by `Routes`) and `value` borrows the *request URL buffer*, so the // correct representation is a borrowed `&'a [u8]`, not a forged `'static`. // // `bun_url::route_param::Param<'a>` is now lifetime-generic (TYPE_ONLY @@ -75,88 +47,44 @@ const INDEX_ROUTE_HASH: u32 = pub use bun_url::route_param; pub use route_param::Param; -pub struct Router { - pub routes: Routes, - pub(crate) loaded_routes: bool, - pub config: RouteConfig, -} - -impl Router { - pub fn init(config: RouteConfig) -> Result { - Ok(Router { - routes: Routes { - static_: StringHashMap::new(), - ..Routes::default() - }, - loaded_routes: false, - config, - }) - } - - pub fn get_entry_points(&self) -> &[&'static [u8]] { - self.routes.list.items_filepath() - } +const BANNED_DIRS: [&[u8]; 1] = [b"node_modules"]; - pub fn get_names(&self) -> &[&'static [u8]] { - self.routes.list.items_name() - } - - // This loads routes recursively, in depth-first order. - // it does not currently handle duplicate exact route matches. that's undefined behavior, for now. - pub fn load_routes( - &mut self, - log: &mut bun_ast::Log, - root_dir_info: &DirInfo, - resolver: &mut R, - base_dir: &[u8], - ) -> Result<(), CoreError> { - if self.loaded_routes { - return Ok(()); - } - self.routes = - RouteLoader::load_all(self.config.clone(), log, resolver, root_dir_info, base_dir); - self.loaded_routes = true; - Ok(()) - } -} - -pub const BANNED_DIRS: [&[u8]; 1] = [b"node_modules"]; - -struct RouteIndex { - route: Box, - name: &'static [u8], - match_name: &'static [u8], - filepath: &'static [u8], -} +/// Bounds a route's name: a link adds to it and not to the path the walk opens, so no path length ends a chain of links. +const MAX_PUBLIC_PATH_LEN: usize = if MAX_PATH_BYTES < pattern::RoutePathInt::MAX as usize { + MAX_PATH_BYTES +} else { + pattern::RoutePathInt::MAX as usize +}; // TODO(b2-blocked): bun_collections::MultiArrayElement derive — proc-macro not // yet landed, so MultiArrayList can't expose per-field column // accessors. Hand-rolled SoA struct until the derive exists. -#[derive(Default)] struct RouteIndexList { // The `Box` is load-bearing: `Routes::index` / `Routes::static_` hold // `NonNull` / `*const Route` into the box interiors; unboxing // would dangle them on `Vec` realloc. #[expect(clippy::vec_box)] route: Vec>, - name: Vec<&'static [u8]>, - match_name: Vec<&'static [u8]>, - filepath: Vec<&'static [u8]>, + /// Bytes of the route's own strings, as the next two columns are. + name: Vec>, + match_name: Vec>, + filepath: Vec>, } impl RouteIndexList { - pub(crate) fn set_capacity(&mut self, cap: usize) -> Result<(), CoreError> { - self.route.reserve_exact(cap); - self.name.reserve_exact(cap); - self.match_name.reserve_exact(cap); - self.filepath.reserve_exact(cap); - Ok(()) + fn with_capacity(cap: usize) -> Self { + Self { + route: Vec::with_capacity(cap), + name: Vec::with_capacity(cap), + match_name: Vec::with_capacity(cap), + filepath: Vec::with_capacity(cap), + } } - fn push(&mut self, item: RouteIndex) { - self.route.push(item.route); - self.name.push(item.name); - self.match_name.push(item.match_name); - self.filepath.push(item.filepath); + fn push(&mut self, route: Box) { + self.name.push(RawSlice::new(route.name())); + self.match_name.push(RawSlice::new(route.match_name())); + self.filepath.push(RawSlice::new(route.abs_path())); + self.route.push(route); } #[inline] pub(crate) fn len(&self) -> usize { @@ -167,19 +95,20 @@ impl RouteIndexList { &self.route } #[inline] - pub(crate) fn items_name(&self) -> &[&'static [u8]] { + pub(crate) fn items_name(&self) -> &[RawSlice] { &self.name } #[inline] - pub(crate) fn items_match_name(&self) -> &[&'static [u8]] { + pub(crate) fn items_match_name(&self) -> &[RawSlice] { &self.match_name } #[inline] - pub(crate) fn items_filepath(&self) -> &[&'static [u8]] { + pub(crate) fn items_filepath(&self) -> &[RawSlice] { &self.filepath } } +/// The routes of one scan. Nothing changes them afterwards: `reload()` scans into a new `Routes`. pub struct Routes { pub(crate) list: RouteIndexList, /// Index into `list`'s columns where dynamic routes begin (sorted after @@ -200,23 +129,102 @@ pub struct Routes { /// `list.route`). Stored as `NonNull` (not `&'a Route`) so `Routes` claims /// no borrow it doesn't actually take; matches `static_` above. pub(crate) index: Option>, + + /// The directory that every `Route::abs_path` outside a link is below, spelled like one, with a `/` at the end. + dir: Box<[u8]>, + /// Real path of the routes directory. + real_dir: Box<[u8]>, + /// For each directory the scan read: its path below the routes directory, then its real path if that is not `real_dir` and that path. A NUL ends each. + scanned: Vec, } -impl Default for Routes { - fn default() -> Self { - Self { - list: RouteIndexList::default(), - dynamic_start: None, - dynamic_len: 0, - static_: StringHashMap::new(), - index: None, +impl Routes { + /// Reads `dir` and the tree below it from disk, never from a cache. `None`: there is no directory at `dir`. + pub fn scan( + dir: &[u8], + extensions: &[Box<[u8]>], + preserve_symlinks: bool, + log: &mut bun_ast::Log, + ) -> bun_sys::Result> { + let root = match bun_sys::Dir::open(dir) { + Ok(root) => root, + Err(err) if is_absent(&err) => return Ok(None), + Err(err) => return Err(err.with_path(dir)), + }; + let real_dir: Box<[u8]> = { + let mut buf = path_buffer_pool::get(); + Box::from(&*at_path(root.get_fd_path(&mut buf), dir)?) + }; + let listing = match DirListing::read(root, dir) { + Ok(listing) => listing, + // Removed after it was opened. + Err(err) if is_absent(&err) => return Ok(None), + Err(err) => return Err(err), + }; + + // The resolver names a module below a link by the link only when it preserves links. + let (path, real_path) = if preserve_symlinks && *real_dir != *dir { + (dir.to_vec(), Some(real_dir.to_vec())) + } else { + (real_dir.to_vec(), None) + }; + let mut routes_dir = path.clone(); + bun_paths::resolve_path::platform_to_posix_in_place(&mut routes_dir[..]); + if routes_dir.last() != Some(&b'/') { + routes_dir.push(b'/'); } + + let mut loader = RouteLoader::new(extensions, preserve_symlinks, real_dir, log); + loader.load(WalkedDir { + listing, + next: 0, + path, + real_path, + public_dir_len: 0, + })?; + Ok(Some(loader.finish(routes_dir.into_boxed_slice()))) + } + + /// Every name of every directory the scan read: below `dir`, which is the routes directory as the caller names it, below its real path, and the directory's own real path. + pub fn for_each_dir_name(&self, dir: &[u8], each: &mut dyn FnMut(&[u8])) { + let roots = [dir, &*self.real_dir]; + let roots = &roots[..if *self.real_dir == *dir { 1 } else { 2 }]; + let mut name: Vec = Vec::new(); + let mut rest: &[u8] = &self.scanned; + while !rest.is_empty() { + let (below, after) = split_at_nul(rest); + let (real_path, after) = split_at_nul(after); + rest = after; + for root in roots { + name.clear(); + name.extend_from_slice(root); + // The root of a file system ends with the separator that `below` starts with. + let is_doubled = !below.is_empty() && root.ends_with(&[SEP]); + name.extend_from_slice(&below[usize::from(is_doubled)..]); + each(&name); + } + if !real_path.is_empty() { + each(real_path); + } + } + } + + #[inline] + pub fn dir(&self) -> &[u8] { + &self.dir + } + + /// The name of each route and the absolute path of its file. + pub fn iter(&self) -> impl Iterator { + self.list + .items_name() + .iter() + .zip(self.list.items_filepath()) + .map(|(name, file_path)| (name.slice(), file_path.slice())) } -} -impl Routes { pub fn match_page_with_allocator<'p>( - &self, + &'p self, url_path: &URLPath, params: &mut route_param::List<'p>, ) -> Option> { @@ -261,9 +269,9 @@ impl Routes { // SAFETY: points into a Box owned by self.list; valid for &self. let index = unsafe { index_ptr.as_ref() }; return Some(Match { - name: index.name, + name: index.name(), pathname: url_path.pathname, - file_path: index.abs_path.as_bytes(), + file_path: index.abs_path(), query_string: url_path.query_string, }); } @@ -276,9 +284,9 @@ impl Routes { // self.list, which outlives self. let route = unsafe { &*route_ptr }; return Some(Match { - name: route.name, + name: route.name(), pathname: url_path.pathname, - file_path: route.abs_path.as_bytes(), + file_path: route.abs_path(), query_string: url_path.query_string, }); } @@ -287,7 +295,7 @@ impl Routes { } fn match_dynamic<'p>( - &self, + &'p self, path: &'p [u8], params: &mut route_param::List<'p>, ) -> Option<*const Route> { @@ -313,7 +321,7 @@ impl Routes { } fn match_<'p>( - &self, + &'p self, pathname_: &'p [u8], params: &mut route_param::List<'p>, ) -> Option<*const Route> { @@ -330,58 +338,155 @@ impl Routes { } } -struct RouteLoader<'a> { - // allocator dropped — global mimalloc - fs: &'static FileSystem, - config: RouteConfig, - route_dirname_len: u16, +/// A directory the walk is inside of. +struct WalkedDir { + listing: DirListing, + /// The entry of `listing` that the walk loads next. + next: usize, + /// The path that the routes of this directory are named under. + path: Vec, + /// The real path, when `path` keeps the name of a link. + real_path: Option>, + /// How long `RouteLoader::public_dir` is for the entries of this directory. + public_dir_len: usize, +} - dedupe_dynamic: ArrayHashMap, +impl WalkedDir { + fn real_path(&self) -> &[u8] { + self.real_path.as_deref().unwrap_or(&self.path) + } +} + +/// Whether `err` says that there is nothing at the path. +fn is_absent(err: &bun_sys::Error) -> bool { + matches!( + err.get_errno(), + E::ENOENT | E::ENOTDIR | E::ELOOP | E::ENAMETOOLONG + ) +} + +/// What an NFS server says for a directory that was removed. Windows has no such error. +#[cfg(not(windows))] +const STALE: E = E::ESTALE; +#[cfg(windows)] +const STALE: E = E::ENOENT; + +/// `None` for an error that says a name is gone or may not be read, which the walk leaves out. Any other error ends the scan. +fn unless_skippable(result: bun_sys::Result) -> bun_sys::Result> { + match result { + Ok(value) => Ok(Some(value)), + Err(err) + if is_absent(&err) + || matches!(err.get_errno(), STALE | E::EACCES | E::EPERM | E::EBUSY) => + { + Ok(None) + } + Err(err) => Err(err), + } +} + +/// `result`, with an error that names `path`. +fn at_path(result: bun_sys::Result, path: &[u8]) -> bun_sys::Result { + result.map_err(|err| err.with_path(path)) +} + +/// `bytes` up to its first NUL, and what comes after that NUL. +fn split_at_nul(bytes: &[u8]) -> (&[u8], &[u8]) { + let nul = strings::index_of_char_usize(bytes, 0).unwrap_or(bytes.len()); + (&bytes[..nul], bytes.get(nul + 1..).unwrap_or_default()) +} + +/// Where the link at `path` leads. +fn link_target<'b>(path: &ZStr, buf: &'b mut bun_paths::PathBuffer) -> bun_sys::Result<&'b [u8]> { + // One `readlink`, of the descriptor. `realpath` does one for each directory of `path`. + #[cfg(any(target_os = "linux", target_os = "android"))] + { + let link = bun_sys::File::open(path, bun_sys::O::PATH | bun_sys::O::CLOEXEC, 0)?; + bun_sys::get_fd_path(link.handle(), buf).map(|target| &*target) + } + #[cfg(not(any(target_os = "linux", target_os = "android")))] + { + bun_sys::realpath(path, buf) + } +} + +struct RouteLoader<'a> { + extensions: &'a [Box<[u8]>], + preserve_symlinks: bool, + real_dir: Box<[u8]>, log: &'a mut bun_ast::Log, - // NOTE: raw NonNull (not &'a Route) because it points into self.all_routes - // (self-referential); `Routes` co-owns it with `list`. - index: Option>, + + /// The directory being read, below the routes directory: empty, or `/blog/[slug]`. + public_dir: Vec, + /// The path of the route file being read. + path: Vec, + /// What becomes `Routes::scanned`. + scanned: Vec, + + /// Match name of a dynamic route to its place in `all_routes`. + dedupe_dynamic: StringHashMap, + /// The place of the index route in `all_routes`. + index: Option, static_list: StringHashMap<*const Route>, - // `Box` is load-bearing: `index` / `static_list` above hold raw pointers + // `Box` is load-bearing: `static_list` above holds raw pointers // into the box interiors; unboxing would dangle them on `Vec` realloc. #[expect(clippy::vec_box)] all_routes: Vec>, } impl<'a> RouteLoader<'a> { - fn append_route(&mut self, route: Route) { - use bun_collections::hash_map::Entry; + fn new( + extensions: &'a [Box<[u8]>], + preserve_symlinks: bool, + real_dir: Box<[u8]>, + log: &'a mut bun_ast::Log, + ) -> Self { + RouteLoader { + extensions, + preserve_symlinks, + real_dir, + log, + public_dir: Vec::new(), + path: Vec::new(), + // The routes directory: no path below itself, and no other real path. + scanned: vec![0, 0], + dedupe_dynamic: StringHashMap::new(), + index: None, + static_list: StringHashMap::new(), + all_routes: Vec::new(), + } + } + /// Records that the directory at `public_dir`, whose real path is `real_path`, was read. + fn record(&mut self, real_path: &[u8]) { + let start = self.scanned.len(); + self.scanned.extend_from_slice(&self.public_dir); + bun_paths::resolve_path::posix_to_platform_in_place(&mut self.scanned[start..]); + let is_below_real_dir = + real_path.strip_prefix(&*self.real_dir) == Some(&self.scanned[start..]); + self.scanned.push(0); + if !is_below_real_dir { + self.scanned.extend_from_slice(real_path); + } + self.scanned.push(0); + } + + fn append_route(&mut self, route: Route) { // /index.js - if route.full_hash == INDEX_ROUTE_HASH { - // Derived from a `&mut`, not a `&*` downgrade: `NonNull::from(&T)` - // yields a frozen tag. Today `index` is only read through (`match_` - // takes `as_ref()`), so the old spelling was not live UB, but it was - // one write away from it. The static arm below needs no such change: - // `&raw const *box` forms no reference at all. Parking the `Box` - // before deriving is belt-and-braces. - self.all_routes.push(Box::new(route)); - let parked = self.all_routes.last_mut().expect("just pushed"); - // Box contents have a stable address; never removed from - // `all_routes` until consumed by `load_all`. - self.index = Some(NonNull::from(&mut **parked)); + if route.is_index() { + // Of several index files, the first of the walk is the index route. + if self.index.is_none() { + self.index = Some(self.all_routes.len()); + self.all_routes.push(Box::new(route)); + } return; } // static route if route.param_count == 0 { - if let Some(existing) = self.static_list.get(route.match_name.as_bytes()) { - let source = bun_ast::Source::init_empty_file(route.abs_path.as_bytes()); - self.log.add_error_fmt( - Some(&source), - bun_ast::Loc::EMPTY, - format_args!( - "Route \"{}\" is already defined by {}", - bstr::BStr::new(route.name), - // SAFETY: *existing aliases a Box in self.all_routes - bstr::BStr::new(unsafe { &**existing }.abs_path.as_bytes()), - ), - ); + if let Some(existing) = self.static_list.get(route.match_name()) { + // SAFETY: *existing aliases a Box in self.all_routes + Self::add_duplicate_error(self.log, &route, unsafe { &**existing }); return; } @@ -396,122 +501,63 @@ impl<'a> RouteLoader<'a> { // This hack is below the engineering quality bar I'm happy with. // It will cause unexpected behavior. if new_route.has_uppercase { - if let Some(existing) = self.static_list.get(&new_route.name[1..]) { - let source = bun_ast::Source::init_empty_file(new_route.abs_path.as_bytes()); - self.log.add_error_fmt( - Some(&source), - bun_ast::Loc::EMPTY, - format_args!( - "Route \"{}\" is already defined by {}", - bstr::BStr::new(new_route.name), - // SAFETY: *existing aliases a Box in self.all_routes - bstr::BStr::new(unsafe { &**existing }.abs_path.as_bytes()), - ), - ); - - return; - } - self.static_list - .put_assume_capacity(&new_route.name[1..], new_route_ptr); + .put_assume_capacity(&new_route.name()[1..], new_route_ptr); } self.static_list - .put_assume_capacity(new_route.match_name.as_bytes(), new_route_ptr); + .put_assume_capacity(new_route.match_name(), new_route_ptr); self.all_routes.push(new_route); return; } - { - match self.dedupe_dynamic.entry(route.full_hash) { - Entry::Occupied(e) => { - let source = bun_ast::Source::init_empty_file(route.abs_path.as_bytes()); - self.log.add_error_fmt( - Some(&source), - bun_ast::Loc::EMPTY, - format_args!( - "Route \"{}\" is already defined by {}", - bstr::BStr::new(route.name), - bstr::BStr::new(*e.get()), - ), - ); - return; - } - Entry::Vacant(v) => { - v.insert(route.abs_path.as_bytes()); - } - } - } - - { - let new_route = Box::new(route); - self.all_routes.push(new_route); + if let Some(&existing) = self.dedupe_dynamic.get(route.match_name()) { + Self::add_duplicate_error(self.log, &route, &self.all_routes[existing]); + return; } + self.dedupe_dynamic + .put_assume_capacity(route.match_name(), self.all_routes.len()); + self.all_routes.push(Box::new(route)); } - fn load_all( - config: RouteConfig, - log: &'a mut bun_ast::Log, - resolver: &mut R, - root_dir_info: &DirInfo, - base_dir: &[u8], - ) -> Routes { - let mut route_dirname_len: u16 = 0; - - // Call bun_paths directly to avoid the higher-tier bun_resolver dep. - let relative_dir = bun_paths::resolve_path::relative(base_dir, &config.dir); - if !relative_dir.starts_with(b"..") { - route_dirname_len = - (relative_dir.len() + usize::from(config.dir[config.dir.len() - 1] != SEP)) as u16; - } - - let mut this = RouteLoader { - log, - fs: resolver.fs(), - config, - static_list: StringHashMap::new(), - dedupe_dynamic: ArrayHashMap::new(), - all_routes: Vec::new(), - index: None, - route_dirname_len, - }; - this.load(resolver, root_dir_info, base_dir); - if this.all_routes.is_empty() { - return Routes { - static_: this.static_list, - ..Routes::default() - }; - } + fn add_duplicate_error(log: &mut bun_ast::Log, route: &Route, existing: &Route) { + let source = bun_ast::Source::init_empty_file(route.abs_path()); + log.add_error_fmt( + Some(&source), + bun_ast::Loc::EMPTY, + format_args!( + "Route \"{}\" is already defined by {}", + bstr::BStr::new(route.name()), + bstr::BStr::new(existing.abs_path()), + ), + ); + } - index_sort::sort_slice_unstable_by(&mut this.all_routes, |a, b| { + fn finish(mut self, dir: Box<[u8]>) -> Routes { + // The sort moves the boxes, and not what is in them. + let index = self + .index + .map(|at| NonNull::from(&mut *self.all_routes[at])); + index_sort::sort_slice_unstable_by(&mut self.all_routes, |a, b| { sorter::sort_by_name_cmp(a, b) }); - let mut route_list = RouteIndexList::default(); - route_list - .set_capacity(this.all_routes.len()) - .expect("unreachable"); + let mut route_list = RouteIndexList::with_capacity(self.all_routes.len()); let mut dynamic_start: Option = None; let mut index_id: Option = None; - for (i, route) in this.all_routes.into_iter().enumerate() { + for (i, route) in self.all_routes.into_iter().enumerate() { if (route.kind as u8) > (pattern::Tag::Static as u8) && dynamic_start.is_none() { dynamic_start = Some(i); } - if route.full_hash == INDEX_ROUTE_HASH { + if route.is_index() { index_id = Some(i); } - let (filepath, match_name) = (route.abs_path.as_bytes(), route.match_name.as_bytes()); - route_list.push(RouteIndex { - name: route.name, - filepath, - match_name, - route, - }); + route_list.push(route); } let mut dynamic_len: usize = 0; @@ -530,116 +576,144 @@ impl<'a> RouteLoader<'a> { list: route_list, dynamic_start, dynamic_len, - static_: this.static_list, + static_: self.static_list, // Points into a Box now owned by `route_list`; co-owned raw // pointer. - index: this.index, + index, + dir, + real_dir: self.real_dir, + scanned: self.scanned, } } - fn load( - &mut self, - resolver: &mut R, - root_dir_info: &DirInfo, - base_dir: &[u8], - ) { - let fs = self.fs; - - // Snapshot the cached `DirEntry`'s entry pointers under `entries_mutex`: - // another thread (e.g. the bundler's resolver) rewrites this map in place - // under that lock, so iterating it live would walk freed buckets. - let entry_ptrs: Vec<*mut Fs::Entry> = { - let _entries_lock = fs.fs.entries_mutex.lock_guard(); - match root_dir_info.get_entries_const() { - Some(entries) => entries.data.values().copied().collect(), - None => return, - } - }; - // NOTE: the guard is dropped before this loop on purpose — the - // `read_dir_info_ignore_error` recursion below re-acquires `entries_mutex`. - 'outer: for entry_ptr in entry_ptrs { - // NOTE: the snapshot yields raw `*mut Entry`. Reborrow locally for - // each access so `&` reads and the `kind()` call do not - // overlap; the lazy-stat rewrite inside `kind()` is serialized on - // the per-entry `Entry.mutex`. - // SAFETY: EntryStore-owned, valid for process lifetime. - if unsafe { &*entry_ptr }.base()[0] == b'.' { - continue 'outer; - } + /// Loads the routes of `root` and of the directories below it, depth first. + fn load(&mut self, root: WalkedDir) -> bun_sys::Result<()> { + // Not the call stack: the tree on disk decides how deep this goes. + let mut walked = vec![root]; + while let Some(top) = walked.len().checked_sub(1) { + let next = walked[top].next; + walked[top].next += 1; + let Some(entry) = walked[top].listing.get(next) else { + walked.pop(); + if let Some(parent) = walked.last() { + self.public_dir.truncate(parent.public_dir_len); + } + continue; + }; - // Thread the resolver's fs `Implementation` through — - // `Entry.kind` derefs it to lazily stat when `need_stat` is - // true, so null would be a latent crash / silent route-drop - // once the stub forwards it. - // SAFETY: no other live borrow of `*entry_ptr` here; - // `resolver.fs_impl()` points at the process-global RealFS. - let kind = unsafe { (&*entry_ptr).kind(resolver.fs_impl(), false) }; - // SAFETY: shared read-only borrow for the match arms; the only - // subsequent mutation is via `Route::parse` which takes the raw - // pointer and reborrows internally. - let entry: &Fs::Entry = unsafe { &*entry_ptr }; - match kind { - Fs::EntryKind::Dir => { - for banned_dir in BANNED_DIRS.iter() { - if entry.base() == *banned_dir { - continue 'outer; - } + let public_path_len = self.public_dir.len() + 1 + entry.name.len(); + match entry.kind { + EntryKind::Dir => { + if BANNED_DIRS.contains(&entry.name) || public_path_len >= MAX_PUBLIC_PATH_LEN { + continue; } - let abs_parts = [entry.dir(), entry.base()]; - if let Some(dir_info) = resolver.read_dir_info_ignore_error(fs.abs(&abs_parts)) - { - self.load(resolver, &dir_info, base_dir); - } + let mut path = Vec::new(); + join_path(&mut path, &walked[top].path, entry.name); + let Some(child) = unless_skippable(at_path(bun_sys::Dir::open(&path), &path))? + else { + continue; + }; + let mut real_path = walked[top].real_path.as_ref().map(|parent| { + let mut real_path = Vec::new(); + join_path(&mut real_path, parent, entry.name); + real_path + }); + self.public_dir.push(b'/'); + self.public_dir.extend_from_slice(entry.name); + let listing = 'read: { + if entry.is_link { + let mut buf = path_buffer_pool::get(); + let Some(target) = + unless_skippable(at_path(child.get_fd_path(&mut buf), &path))? + else { + break 'read None; + }; + // The resolver can have a record of the link under the real path of the directory it is in. + self.record(real_path.as_deref().unwrap_or(&path)); + // A link to a directory the walk is inside of would never end. + if walked.iter().any(|dir| dir.real_path() == &*target) { + break 'read None; + } + if self.preserve_symlinks { + real_path = Some(target.to_vec()); + } else { + path.clear(); + path.extend_from_slice(target); + } + } + unless_skippable(DirListing::read(child, &path))? + }; + let Some(listing) = listing else { + self.public_dir.truncate(walked[top].public_dir_len); + continue; + }; + self.record(real_path.as_deref().unwrap_or(&path)); + walked.push(WalkedDir { + listing, + next: 0, + path, + real_path, + public_dir_len: self.public_dir.len(), + }); } - Fs::EntryKind::File => { - let extname = bun_paths::extension(entry.base()); + EntryKind::File => { + let extname = bun_paths::extension(entry.name); // exclude "." or "" if extname.len() < 2 { continue; } + if !self + .extensions + .iter() + .any(|extension| &extname[1..] == extension.as_ref()) + { + continue; + } - for _extname in self.config.extensions.iter() { - if &extname[1..] == _extname.as_ref() { - // `entry.dir()` is `base_dir` or a subdirectory of it, cached - // with or without a trailing slash depending on which resolver - // spelled it first (`base_dir` always has one). Both spellings - // trim to the same `public_dir`. - let entry_dir = entry.dir(); - debug_assert!(entry_dir.len() + 1 >= base_dir.len()); - if entry_dir.len() >= base_dir.len() { - debug_assert!(bun_paths::resolve_path::is_sep_any( - entry_dir[base_dir.len() - 1] - )); - } + join_path(&mut self.path, &walked[top].path, entry.name); + if self.path.len() >= MAX_PATH_BYTES { + self.log.add_error_fmt( + None, + bun_ast::Loc::EMPTY, + format_args!( + "ENAMETOOLONG opening route: {}", + bstr::BStr::new(&self.path) + ), + ); + continue; + } + if public_path_len >= MAX_PUBLIC_PATH_LEN { + continue; + } + // Only a link needs the file system to say where it leads. + if entry.is_link { + self.path.push(0); + let link = ZStr::from_buf(&self.path, self.path.len() - 1); + let mut buf = path_buffer_pool::get(); + let Some(target) = unless_skippable(at_path( + link_target(link, &mut buf), + link.as_bytes(), + ))? + else { + continue; + }; + self.path.clear(); + self.path.extend_from_slice(target); + } - // SAFETY: entry.dir is at least base_dir.len()-1 bytes; verified above in debug - let public_dir = &entry_dir[base_dir.len() - 1..entry_dir.len()]; - - // SAFETY: `entry_ptr` is EntryStore-owned (process - // lifetime) with no other live `&mut` borrow here. - let route = unsafe { - Route::parse( - entry.base(), - extname, - entry_ptr, - self.log, - public_dir, - self.route_dirname_len, - ) - }; - if let Some(route) = route { - self.append_route(route); - } - break; - } + if let Some(route) = + Route::parse(entry.name, extname, &self.path, &self.public_dir, self.log) + { + self.append_route(route); } } - Fs::EntryKind::Dangling => {} + EntryKind::Other => {} } } + Ok(()) } } @@ -676,301 +750,116 @@ impl TinyPtr { } } -// On Windows we need to normalize this path to have forward slashes. -// The normalized path is interned into `DirnameStore` (process-lifetime arena), so -// `abs_path` is uniformly an `Interned` over `'static` bytes on every -// platform — keeping `RouteIndexList.filepath: &'static [u8]` sound and -// avoiding the borrow-then-move at `RouteLoader::load_all`. -pub type AbsPath = Interned; - pub struct Route { + /// `name`, then `match_name` when it is not a part of `name`, then `abs_path`. + strings: Box<[u8]>, + name_len: u32, + match_name_start: u32, + match_name_end: u32, + abs_path_start: u32, + + pub(crate) param_count: u16, + + pub(crate) kind: pattern::Tag, + + pub(crate) has_uppercase: bool, +} + +impl Route { /// Public display name for the route. /// "/", "/index" is "/" /// "/foo/index.js" becomes "/foo" /// case-sensitive, has leading slash - pub(crate) name: &'static [u8], + #[inline] + pub(crate) fn name(&self) -> &[u8] { + &self.strings[..self.name_len as usize] + } /// Name used for matching. /// - Omits leading slash /// - Lowercased /// This is [inconsistent with Next.js](https://github.com/vercel/next.js/issues/21498) - pub(crate) match_name: Interned, - - pub(crate) full_hash: u32, - pub(crate) param_count: u16, - - pub(crate) abs_path: AbsPath, - - pub(crate) kind: pattern::Tag, - - pub(crate) has_uppercase: bool, -} + #[inline] + pub(crate) fn match_name(&self) -> &[u8] { + &self.strings[self.match_name_start as usize..self.match_name_end as usize] + } -// TODO(b1): inherent assoc types unstable; module-level alias instead. + /// Every link is resolved, and `/` separates it on every platform. + #[inline] + pub(crate) fn abs_path(&self) -> &[u8] { + &self.strings[self.abs_path_start as usize..] + } -impl Route { - pub(crate) const INDEX_ROUTE_NAME: &'static [u8] = b"/"; + /// Whether this is the route of an index file in the routes directory, which is `/`. + #[inline] + fn is_index(&self) -> bool { + self.name_len == 1 + } - /// # Safety - /// `entry` must point to a live `Fs::Entry` (EntryStore-owned) with no - /// other active `&mut` borrow for the duration of the call. `base_` and - /// `extname` may borrow `(*entry).base_`; see the NOTE below. - pub(crate) unsafe fn parse( + /// `public_dir`: the directory of the file below the routes directory, empty or `/blog/[slug]`. + pub(crate) fn parse( base_: &[u8], extname: &[u8], - entry: *mut Fs::Entry, + abs_path: &[u8], + public_dir: &[u8], log: &mut bun_ast::Log, - public_dir_: &[u8], - routes_dirname_len: u16, ) -> Option { - // NOTE: `entry` is a raw `*mut Entry` because `base_`/`extname` may - // borrow `(*entry).base_` and a `&mut Entry` parameter would alias them. let base = &base_[0..base_.len() - extname.len()]; + debug_assert!(!base.is_empty()); - // SAFETY: caller passes an EntryStore-owned pointer valid for the - // process lifetime; no other live `&mut` to it during this call. - let entry_r = unsafe { &*entry }; - // The public path and the longer absolute path are both built in `route_file_buf`. - let dir = strings::without_trailing_slash(entry_r.dir()); - if dir.len() + 1 + base_.len() >= MAX_PATH_BYTES { - log.add_error_fmt( - None, - bun_ast::Loc::EMPTY, - format_args!( - "ENAMETOOLONG opening route: {}{}{}", - bstr::BStr::new(dir), - SEP_STR, - bstr::BStr::new(base_) - ), - ); - return None; + // `/foo/index.ts` has the name `/foo`, and `/foo/bar.tsx` has the name `/foo/bar`. + let is_index_file = base == b"index"; + let name_len = if is_index_file { + public_dir.len() + } else { + public_dir.len() + 1 + base.len() + }; + let is_index = name_len == 0; + let has_uppercase = !is_index + && [public_dir, base, extname] + .iter() + .any(|part| part.iter().any(u8::is_ascii_uppercase)); + + let mut strings: Vec = Vec::with_capacity( + name_len.max(1) + if has_uppercase { name_len - 1 } else { 0 } + abs_path.len(), + ); + strings.extend_from_slice(public_dir); + if !is_index_file { + strings.push(b'/'); + strings.extend_from_slice(base); } - let public_dir = strings::trim(public_dir_, SEP_STR.as_bytes()); - - // this is a path like - // "/pages/index.js" - // "/pages/foo/index.ts" - // "/pages/foo/bar.tsx" - // the name we actually store will often be this one - ROUTE_BUFS.with_borrow_mut(|bufs| { - let route_file_buf = &mut bufs.route_file_buf; - let public_path: &[u8] = 'brk: { - if base.is_empty() { - break 'brk public_dir; - } - let mut buf: &mut [u8] = &mut route_file_buf[..]; - - if !public_dir.is_empty() { - // NOTE: reshaped for borrowck — `buf` already aliases - // route_file_buf[..]; write through it instead of re-borrowing. - buf[0] = b'/'; - buf = &mut buf[1..]; - buf[..public_dir.len()].copy_from_slice(public_dir); - } - buf[public_dir.len()] = b'/'; - buf = &mut buf[public_dir.len() + 1..]; - buf[..base.len()].copy_from_slice(base); - buf = &mut buf[base.len()..]; - buf[..extname.len()].copy_from_slice(extname); - buf = &mut buf[extname.len()..]; - - let written_len = buf.as_ptr() as usize - route_file_buf.as_ptr() as usize; - - #[cfg(windows)] - { - bun_paths::resolve_path::platform_to_posix_in_place( - &mut route_file_buf[0..written_len], - ); - } - - // SAFETY: written_len computed from sub-slice pointer arithmetic above - break 'brk unsafe { - core::slice::from_raw_parts(route_file_buf.as_ptr(), written_len) - }; - }; - - let mut name = &public_path[0..public_path.len() - extname.len()]; - - while name.len() > 1 && name[name.len() - 1] == b'/' { - name = &name[0..name.len() - 1]; - } - - name = &name[routes_dirname_len as usize..]; - - if name.ends_with(b"/index") { - name = &name[0..name.len() - 6]; - } - - name = strings::trim_right(name, b"/"); - - let mut validation_result = pattern::ValidationResult::default(); - let is_index = name.is_empty(); - - let mut has_uppercase = false; - // NOTE: reshaped for borrowck — both arms intern via DirnameStore - // (process-lifetime arena → `&'static`), so the post-if bindings are - // 'static and the route_file_buf borrow is dropped before the - // abs-path block below needs it mutably. - let (name, match_name): (&'static [u8], &'static [u8]) = if !name.is_empty() { - validation_result = match Pattern::validate(&name[1..], log) { - Some(v) => v, - None => return None, - }; - - let mut name_i: usize = 0; - while !has_uppercase && name_i < public_path.len() { - has_uppercase = public_path[name_i] >= b'A' && public_path[name_i] <= b'Z'; - name_i += 1; - } - - let name_offset = name.as_ptr() as usize - public_path.as_ptr() as usize; - let name_len = name.len(); - - // NOTE: DirnameStore::append returns `&'static [u8]` (process- - // lifetime arena), so rebinding here drops the borrow on - // `route_file_buf` and avoids needing lifetime transmutes - // below. - let dirname_store = FileSystem::instance().dirname_store(); - let public_path: &'static [u8] = - dirname_store.append(public_path).expect("unreachable"); - let name: &'static [u8] = &public_path[name_offset..][0..name_len]; - let match_name: &'static [u8] = if has_uppercase { - dirname_store - .append_lower_case(&name[1..]) - .expect("unreachable") - } else { - &name[1..] - }; - - debug_assert!(match_name[0] != b'/'); - debug_assert!(name[0] == b'/'); - (name, match_name) + let mut validation_result = pattern::ValidationResult::default(); + let match_name = if is_index { + strings.push(b'/'); + 0..1 + } else { + validation_result = Pattern::validate(&strings[1..], log)?; + if has_uppercase { + strings.extend_from_within(1..); + strings[name_len..].make_ascii_lowercase(); + name_len..strings.len() } else { - (Route::INDEX_ROUTE_NAME, Route::INDEX_ROUTE_NAME) - }; - - // The closure runs under the per-entry mutex, like every other write to a cached `Entry`. - let Ok(entry_abs_path) = entry_r.abs_path_or_try_fill(|| { - let mut abs_path_str: &[u8] = b""; - // NOTE: reshaped for borrowck — `defer if (needs_close) file.close()` - // becomes a scopeguard owning the Option; `needs_close` is a - // Cell so the drop closure can read it while the body still mutates. - // The guard is inverted: when the fd belongs to the cache - // (`needs_close == false`), `into_raw()` so we do not close - // someone else's fd. - let needs_close = core::cell::Cell::new(true); - let mut file = scopeguard::guard(None::, |f| { - if !needs_close.get() { - if let Some(f) = f { - let _ = f.into_raw(); - } - } - }); - - if let Some(valid) = entry_r.cache().fd.unwrap_valid() { - *file = Some(bun_sys::File::from_fd(valid)); - needs_close.set(false); - } else { - let parts = [entry_r.dir(), entry_r.base()]; - let abs_len = FileSystem::instance().abs_buf(&parts, route_file_buf).len(); - // Rebind so the later getFdPath error - // message prints the computed path instead of `b""`. - // SAFETY: lifetime-laundered raw view into route_file_buf - // (same pattern as `public_path` above) so the buffer can - // be reborrowed mutably for the NUL write / open below. - abs_path_str = - unsafe { core::slice::from_raw_parts(route_file_buf.as_ptr(), abs_len) }; - route_file_buf[abs_len] = 0; - // SAFETY: NUL-terminated above; `abs_len` bytes valid in route_file_buf. - let buf = bun_core::ZStr::from_buf(&route_file_buf[..], abs_len); - match bun_sys::open_file_absolute_z(buf, bun_sys::OpenFlags::READ_ONLY) { - Ok(f) => { - *file = Some(f); - } - Err(err) => { - needs_close.set(false); - log.add_error_fmt( - None, - bun_ast::Loc::EMPTY, - format_args!( - "{} opening route: {}", - bstr::BStr::new(err.name()), - bstr::BStr::new(&route_file_buf[..abs_len]) - ), - ); - return Err(()); - } - } - FileSystem::set_max_fd(file.as_ref().unwrap().handle().native()); - } - - let fd = file.as_ref().unwrap().handle(); - let _abs = match bun_sys::get_fd_path(fd, route_file_buf) { - Ok(p) => &p[..], - Err(err) => { - log.add_error_fmt( - None, - bun_ast::Loc::EMPTY, - format_args!( - "{} resolving route: {}", - bstr::BStr::new(err.name()), - bstr::BStr::new(abs_path_str) - ), - ); - return Err(()); - } - }; - - Ok(Interned::from_static( - FileSystem::instance() - .dirname_store() - .append(_abs) - .expect("unreachable"), - )) - }) else { - return None; - }; - let abs_path_str = entry_abs_path.as_bytes(); - - #[cfg(windows)] - let abs_path: AbsPath = { - // Intern into DirnameStore so the slice is genuinely `'static` - // and the `Interned` widen is sound on Windows. - let normalized = bun_paths::resolve_path::platform_to_posix_buf( - abs_path_str, - &mut bufs.normalized_abs_path_buf, - ); - let interned: &'static [u8] = FileSystem::instance() - .dirname_store() - .append(normalized) - .expect("unreachable"); - Interned::from_static(interned) - }; - #[cfg(not(windows))] - let abs_path = Interned::from_static(abs_path_str); - - #[cfg(all(debug_assertions, windows))] - { - debug_assert!(!strings::index_of_char(name, b'\\').is_some()); - debug_assert!(!strings::index_of_char(match_name, b'\\').is_some()); - debug_assert!(!strings::index_of_char(abs_path.as_bytes(), b'\\').is_some()); - debug_assert!(!strings::index_of_char(entry_r.base(), b'\\').is_some()); + 1..name_len } - - Some(Route { - name, - match_name: Interned::from_static(match_name), - full_hash: if is_index { - INDEX_ROUTE_HASH - } else { - wyhash(name) as u32 - }, - param_count: validation_result.param_count, - kind: validation_result.kind, - abs_path, - has_uppercase, - }) + }; + let name_len = name_len.max(1); + debug_assert!(is_index || strings[match_name.start] != b'/'); + debug_assert!(strings[0] == b'/'); + + let abs_path_start = strings.len(); + strings.extend_from_slice(abs_path); + bun_paths::resolve_path::platform_to_posix_in_place(&mut strings[abs_path_start..]); + + Some(Route { + strings: strings.into_boxed_slice(), + name_len: name_len as u32, + match_name_start: match_name.start as u32, + match_name_end: match_name.end as u32, + abs_path_start: abs_path_start as u32, + param_count: validation_result.param_count, + kind: validation_result.kind, + has_uppercase, }) } } @@ -1008,8 +897,8 @@ pub(crate) mod sorter { } pub(crate) fn sort_by_name(a: &Route, b: &Route) -> bool { - let a_name = a.match_name.as_bytes(); - let b_name = b.match_name.as_bytes(); + let a_name = a.match_name(); + let b_name = b.match_name(); // route order determines route match order // - static routes go first because we match those first @@ -1045,49 +934,156 @@ pub(crate) mod sorter { } } -struct RouteBufs { - route_file_buf: PathBuffer, - #[cfg(windows)] - normalized_abs_path_buf: bun_sys::windows::PathBuffer, -} - -thread_local! { - // bun.ThreadlocalBuffers: heap-backed so only a Box pointer lives in TLS - // (keeps PT_TLS MemSiz small — see test/js/bun/binary/tls-segment-size). - static ROUTE_BUFS: RefCell> = RefCell::new(Box::new(RouteBufs { - route_file_buf: PathBuffer::ZEROED, - #[cfg(windows)] - normalized_abs_path_buf: bun_sys::windows::PathBuffer::ZEROED, - })); -} - pub struct Match<'a> { /// raw url path from the request pub pathname: &'a [u8], - /// absolute filesystem path to the entry point + /// absolute filesystem path to the entry point; borrows the `Routes` pub file_path: &'a [u8], - /// route name, like `"posts/[id]"` + /// route name, like `"posts/[id]"`; borrows the `Routes` pub name: &'a [u8], pub query_string: &'a [u8], } // ────────────────────────────────────────────────────────────────────────── -// Traits abstracting over the router's collaborators -// (Resolver, Server, RequestContext). -// TODO(refactor): colocate these with the canonical types in -// bun_resolver / bun_runtime. +// Directory listing // ────────────────────────────────────────────────────────────────────────── -pub trait ResolverLike { - // `bun_resolver::fs::FileSystem` is a process-global singleton, so `'static` - // here is faithful and avoids threading the resolver borrow into RouteLoader<'a>. - fn fs(&self) -> &'static FileSystem; - /// The resolver's `Implementation` field, passed to - /// `Entry.kind` for lazy stat. - fn fs_impl(&self) -> *mut Fs::Implementation; - /// Returns an arena handle (not a borrow) so the resolver's `&mut self` - /// borrow ends before the recursive `load()` re-borrows it. - fn read_dir_info_ignore_error(&mut self, path: &[u8]) -> Option; +/// What a name in a directory is, or what a link with that name leads to. +#[derive(Clone, Copy, PartialEq, Eq)] +enum EntryKind { + File, + Dir, + /// Not a route and not walked: a link to nothing, a FIFO, a name that may not be read. + Other, +} + +#[derive(Clone, Copy)] +struct DirListingEntry<'a> { + name: &'a [u8], + kind: EntryKind, + is_link: bool, +} + +#[derive(Clone, Copy)] +struct ListedName { + start: usize, + len: u32, + kind: EntryKind, + is_link: bool, +} + +/// The names in one directory, read from disk, one for each name without ASCII case, in the order of those. +struct DirListing { + names: Vec, + entries: Vec, +} + +impl DirListing { + /// Closes `dir`, so that the walk has one directory open at a time. An error names `path` or the entry. + fn read(dir: bun_sys::Dir, path: &[u8]) -> bun_sys::Result { + let mut names: Vec = Vec::new(); + let mut entries: Vec = Vec::new(); + let mut iterator = bun_sys::iterate_dir(dir.fd()); + while let Some(entry) = at_path(iterator.next(), path)? { + let name = entry.name.slice_u8(); + if name.first() == Some(&b'.') { + continue; + } + let (kind, is_link) = match entry.kind { + FileKind::File => (EntryKind::File, false), + FileKind::Directory => (EntryKind::Dir, false), + // `Unknown`: the file system does not say what the name is. + FileKind::SymLink | FileKind::Unknown => { + unless_skippable(Self::follow(&dir, &entry)) + .map_err(|err| { + let mut entry_path = Vec::new(); + join_path(&mut entry_path, path, name); + err.with_path(&entry_path) + })? + .unwrap_or((EntryKind::Other, false)) + } + // A FIFO, socket or device. + _ => (EntryKind::Other, false), + }; + entries.push(ListedName { + start: names.len(), + len: name.len() as u32, + kind, + is_link, + }); + names.extend_from_slice(name); + } + drop(dir); + + Self::sort(&names, &mut entries); + Ok(DirListing { names, entries }) + } + + /// Keeps, of the names that are one name without ASCII case, the one read last: the module resolver's listing has that one. + fn sort(names: &[u8], entries: &mut Vec) { + let name = |entry: &ListedName| &names[entry.start..][..entry.len as usize]; + let folded = |entry: &ListedName| name(entry).iter().map(u8::to_ascii_lowercase); + index_sort::sort_slice_unstable_by(entries, |a, b| { + folded(a).cmp(folded(b)).then(b.start.cmp(&a.start)) + }); + entries.dedup_by(|later, kept| name(later).eq_ignore_ascii_case(name(kept))); + } + + /// What a link or a name of no known kind is, and whether it is a link. + fn follow( + dir: &bun_sys::Dir, + entry: &bun_sys::dir_iterator::IteratorResult, + ) -> bun_sys::Result<(EntryKind, bool)> { + let mut is_link = entry.kind == FileKind::SymLink; + // On Windows a link says what it stands for. + if is_link && let Some(is_dir) = entry.link_is_directory() { + let kind = if is_dir { + EntryKind::Dir + } else { + EntryKind::File + }; + return Ok((kind, true)); + } + + // Asked with a stat and not an open: the open of a FIFO does not return. + let mut buf = path_buffer_pool::get(); + let name = bun_paths::resolve_path::z(entry.name.slice_u8(), &mut buf); + let mut kind = FileKind::SymLink; + if !is_link { + kind = + bun_sys::kind_from_mode(bun_sys::lstatat(dir.fd(), name)?.st_mode as bun_sys::Mode); + is_link = kind == FileKind::SymLink; + } + if is_link { + kind = + bun_sys::kind_from_mode(bun_sys::fstatat(dir.fd(), name)?.st_mode as bun_sys::Mode); + } + Ok(match kind { + FileKind::File => (EntryKind::File, is_link), + FileKind::Directory => (EntryKind::Dir, is_link), + _ => (EntryKind::Other, is_link), + }) + } + + fn get(&self, index: usize) -> Option> { + let entry = self.entries.get(index)?; + Some(DirListingEntry { + name: &self.names[entry.start..][..entry.len as usize], + kind: entry.kind, + is_link: entry.is_link, + }) + } +} + +/// `dir`, a separator and `name`, byte for byte: a normalizing join would rewrite a name. +fn join_path(out: &mut Vec, dir: &[u8], name: &[u8]) { + out.clear(); + out.reserve(dir.len() + 1 + name.len()); + out.extend_from_slice(dir); + if dir.last() != Some(&SEP) { + out.push(SEP); + } + out.extend_from_slice(name); } // ────────────────────────────────────────────────────────────────────────── @@ -1097,7 +1093,7 @@ pub trait ResolverLike { pub mod pattern { use super::*; - type RoutePathInt = u16; + pub(crate) type RoutePathInt = u16; #[derive(Clone, Copy)] pub struct Pattern { @@ -1517,6 +1513,31 @@ pub(crate) use pattern::Pattern; mod tests { use super::*; + // `cargo test -p bun_router` links no highway kernel, so the two these tests reach are scalar stubs (Miri calls neither). + + /// Returns `haystack_len` when `needle` does not occur, like the kernel. + #[unsafe(no_mangle)] + unsafe extern "C" fn highway_index_of_char( + haystack: *const u8, + haystack_len: usize, + needle: u8, + ) -> usize { + // SAFETY: test stub; callers pass a valid (ptr, len) pair. + let haystack = unsafe { core::slice::from_raw_parts(haystack, haystack_len) }; + haystack + .iter() + .position(|&b| b == needle) + .unwrap_or(haystack_len) + } + + /// `n` when `c` does not occur, like the kernel. + #[unsafe(no_mangle)] + unsafe extern "C" fn highway_last_index_of_char(h: *const u8, n: usize, c: u8) -> usize { + // SAFETY: test stub; callers pass a valid (ptr, len) pair. + let h = unsafe { core::slice::from_raw_parts(h, n) }; + h.iter().rposition(|&b| b == c).unwrap_or(n) + } + #[test] fn pattern_match() { type Entry = Param<'static>; @@ -1731,12 +1752,252 @@ mod tests { assert!(run(optional_catch_all) == 0); } - // The route-loader integration tests ("Github - // API Route Loader", "Sample Route Loader", "Routes Basic", "Dynamic - // Routes") are not implemented: they assert through `ctx.matched_route`, which - // `Router::match_` does not yet populate (the JavaScriptHandler enqueue - // path is still commented out), and they consume fixture route lists that - // live in the test-runner harness crate. + /// The table a scan makes of `files` (directory below the routes directory, name) in the walk's order, and its errors. + fn table(files: &[(&[u8], &[u8])], extensions: &[&[u8]]) -> (Routes, Vec>) { + let extensions: Vec> = extensions.iter().map(|e| Box::from(*e)).collect(); + let mut log = bun_ast::Log::new(); + let real_dir = Box::from(b"/pages".as_slice()); + let mut loader = RouteLoader::new(&extensions, false, real_dir, &mut log); + for (public_dir, base) in files { + let extname = bun_paths::extension(base); + let abs_path = [b"/pages", *public_dir, b"/", *base].concat(); + if let Some(route) = Route::parse(base, extname, &abs_path, public_dir, loader.log) { + loader.append_route(route); + } + } + let routes = loader.finish(Box::from(b"/pages/".as_slice())); + let errors = log.msgs.iter().map(|msg| msg.data.text.to_vec()).collect(); + (routes, errors) + } + + /// The name and file of the route that `path` matches, and the parameters. + fn match_of<'p>( + routes: &'p Routes, + path: &'p [u8], + ) -> Option<(&'p [u8], &'p [u8], Vec<(&'p [u8], &'p [u8])>)> { + let url_path = bun_http_types::URLPath::parse(path).unwrap(); + let mut params = route_param::List::default(); + let matched = routes.match_page_with_allocator(&url_path, &mut params)?; + let params = params + .iter() + .map(|param| (param.name, param.value)) + .collect(); + Some((matched.name, matched.file_path, params)) + } + + #[test] + fn route_table() { + let (routes, errors) = table( + &[ + (b"", b"About.tsx"), + (b"", b"[ID].tsx"), + (b"", b"[id].tsx"), + (b"", b"a.ts"), + (b"", b"a.tsx"), + (b"", b"about.tsx"), + (b"", b"b.tsx"), + (b"/docs", b"[...rest].tsx"), + (b"", b"e.tsx"), + (b"", b"index.ts"), + (b"", b"index.tsx"), + (b"/opt", b"[[...all]].tsx"), + (b"/posts", b"[slug].tsx"), + (b"/posts", b"index.tsx"), + (b"/posts", b"new.tsx"), + ], + &[b"tsx", b"ts"], + ); + + // The second file of a route name is an error, also when the names differ in case. + assert_eq!( + errors, + [ + b"Route \"/[id]\" is already defined by /pages/[ID].tsx".to_vec(), + b"Route \"/a\" is already defined by /pages/a.ts".to_vec(), + b"Route \"/about\" is already defined by /pages/About.tsx".to_vec(), + ] + ); + let mut listed: Vec<(&[u8], &[u8])> = routes.iter().collect(); + listed.sort_unstable(); + assert_eq!( + listed, + [ + (b"/".as_slice(), b"/pages/index.ts".as_slice()), + (b"/About", b"/pages/About.tsx"), + (b"/[ID]", b"/pages/[ID].tsx"), + (b"/a", b"/pages/a.ts"), + (b"/b", b"/pages/b.tsx"), + (b"/docs/[...rest]", b"/pages/docs/[...rest].tsx"), + (b"/e", b"/pages/e.tsx"), + (b"/opt/[[...all]]", b"/pages/opt/[[...all]].tsx"), + (b"/posts", b"/pages/posts/index.tsx"), + (b"/posts/[slug]", b"/pages/posts/[slug].tsx"), + (b"/posts/new", b"/pages/posts/new.tsx"), + ] + ); + + let name_of = |path: &'static [u8]| match_of(&routes, path).map(|(name, _, _)| name); + assert_eq!(name_of(b"/"), Some(b"/".as_slice())); + assert_eq!(name_of(b"/a"), Some(b"/a".as_slice())); + assert_eq!(name_of(b"/About"), Some(b"/About".as_slice())); + assert_eq!(name_of(b"/about"), Some(b"/About".as_slice())); + assert_eq!(name_of(b"/posts"), Some(b"/posts".as_slice())); + assert_eq!(name_of(b"/posts/new"), Some(b"/posts/new".as_slice())); + assert_eq!(name_of(b"/none/of/them"), None); + assert_eq!( + match_of(&routes, b"/posts/hello?x=1"), + Some(( + b"/posts/[slug]".as_slice(), + b"/pages/posts/[slug].tsx".as_slice(), + vec![(b"slug".as_slice(), b"hello".as_slice())] + )) + ); + assert_eq!( + match_of(&routes, b"/42"), + Some(( + b"/[ID]".as_slice(), + b"/pages/[ID].tsx".as_slice(), + vec![(b"ID".as_slice(), b"42".as_slice())] + )) + ); + assert_eq!( + match_of(&routes, b"/docs/a/b"), + Some(( + b"/docs/[...rest]".as_slice(), + b"/pages/docs/[...rest].tsx".as_slice(), + vec![(b"rest".as_slice(), b"a/b".as_slice())] + )) + ); + assert_eq!( + match_of(&routes, b"/opt/x/y"), + Some(( + b"/opt/[[...all]]".as_slice(), + b"/pages/opt/[[...all]].tsx".as_slice(), + vec![(b"all".as_slice(), b"x/y".as_slice())] + )) + ); + } + + #[test] + fn listing_has_one_entry_for_a_name_without_case() { + // As a directory returns them. A file system can return one name twice. + let read: [&[u8]; 6] = [b"b.tsx", b"A.tsx", b"sub", b"a.tsx", b"B.TSX", b"a.tsx"]; + let mut names: Vec = Vec::new(); + let mut entries: Vec = Vec::new(); + for name in read { + entries.push(ListedName { + start: names.len(), + len: name.len() as u32, + kind: EntryKind::File, + is_link: false, + }); + names.extend_from_slice(name); + } + DirListing::sort(&names, &mut entries); + let listing = DirListing { names, entries }; + let kept: Vec<(&[u8], usize)> = listing + .entries + .iter() + .enumerate() + .map(|(i, entry)| (listing.get(i).unwrap().name, entry.start)) + .collect(); + assert_eq!( + kept, + [(b"a.tsx".as_slice(), 23), (b"B.TSX", 18), (b"sub", 10)] + ); + } + + #[test] + fn errors_that_leave_a_name_out() { + let error = |errno| bun_sys::Error::from_code(errno, bun_sys::Tag::open); + for errno in [ + E::ENOENT, + E::ENOTDIR, + E::ELOOP, + E::ENAMETOOLONG, + STALE, + E::EACCES, + E::EPERM, + E::EBUSY, + ] { + assert!(matches!( + unless_skippable::<()>(Err(error(errno))), + Ok(None) + )); + } + for errno in [E::EMFILE, E::ENFILE, E::ENOMEM, E::EIO, E::ENODEV, E::EBADF] { + let result = unless_skippable::<()>(at_path(Err(error(errno)), b"/pages/sub")); + let err = result.unwrap_err(); + assert_eq!( + (err.get_errno(), &*err.path), + (errno, b"/pages/sub".as_slice()) + ); + } + assert!(matches!(unless_skippable(Ok(1)), Ok(Some(1)))); + } + + #[test] + fn names_of_the_directories_of_a_scan() { + let names = |real_dir: &[u8], dir: &[u8], read: &[(&[u8], &[u8])]| { + let mut log = bun_ast::Log::new(); + let mut loader = RouteLoader::new(&[], false, Box::from(real_dir), &mut log); + for (public_dir, real_path) in read { + loader.public_dir = public_dir.to_vec(); + loader.record(real_path); + } + let routes = loader.finish(Box::default()); + let mut names: Vec> = Vec::new(); + routes.for_each_dir_name(dir, &mut |name| names.push(name.to_vec())); + names + }; + let sep = |path: &[u8]| -> Vec { + path.iter() + .map(|byte| if *byte == b'/' { SEP } else { *byte }) + .collect() + }; + let all = + |paths: &[&[u8]]| -> Vec> { paths.iter().map(|path| sep(path)).collect() }; + + // No link: one name for each directory. + assert_eq!( + names( + &sep(b"/app/pages"), + &sep(b"/app/pages"), + &[ + (b"/blog", &sep(b"/app/pages/blog")), + (b"/blog/[slug]", &sep(b"/app/pages/blog/[slug]")) + ], + ), + all(&[b"/app/pages", b"/app/pages/blog", b"/app/pages/blog/[slug]"]) + ); + // `dir` through a link, and a link below it: each name that leads to the directory. + assert_eq!( + names( + &sep(b"/real/pages"), + &sep(b"/link/pages"), + &[ + (b"/ln", &sep(b"/elsewhere")), + (b"/ln/deep", &sep(b"/elsewhere/deep")) + ], + ), + all(&[ + b"/link/pages", + b"/real/pages", + b"/link/pages/ln", + b"/real/pages/ln", + b"/elsewhere", + b"/link/pages/ln/deep", + b"/real/pages/ln/deep", + b"/elsewhere/deep", + ]) + ); + // The root of a file system ends with a separator. + let root = sep(b"/"); + assert_eq!( + names(&root, &root, &[(b"/sub", &sep(b"/sub"))]), + all(&[b"/", b"/sub", b"/sub"]) + ); + } #[test] fn pattern() { diff --git a/src/runtime/api/filesystem_router.rs b/src/runtime/api/filesystem_router.rs index b169d4b1890c..fe2e0e30f8b9 100644 --- a/src/runtime/api/filesystem_router.rs +++ b/src/runtime/api/filesystem_router.rs @@ -1,10 +1,15 @@ //! `Bun.FileSystemRouter` / `MatchedRoute` — Next.js-style file router. use bun_paths::strings; +use std::rc::Rc; use bun_ast as Log; +#[cfg(unix)] +use bun_collections::StringSet; use bun_core::Utf8Bytes; use bun_jsc::bindgen::{self, JsHeap, JsLocal, JsPayload as _, JsScope, JsWrapper, to_js}; +#[cfg(unix)] +use bun_jsc::hot_reloader::ImportWatcher; use bun_jsc::ref_string::{RefString, StringTooLong}; use bun_jsc::virtual_machine::VirtualMachine; use bun_jsc::{JSGlobalObject, JSValue, JsCell, JsResult, LogJsc}; @@ -12,9 +17,11 @@ use bun_paths::{self as path, MAX_PATH_BYTES}; use bun_ptr::BackRef; use bun_http_types::URLPath; +#[cfg(unix)] use bun_resolver::Resolver; use bun_resolver::fs as Fs; -use bun_router::{self as Router, RouteConfig}; +use bun_router::{RouteConfig, Routes}; +use bun_sys_jsc::ErrorJsc as _; use bun_url::{CombinedScanner, QueryStringMap, URL, route_param}; use crate::api::bun_object; @@ -25,31 +32,6 @@ use crate::node::types::Valid; const DEFAULT_EXTENSIONS: &[&[u8]] = &[b"tsx", b"jsx", b"ts", b"mjs", b"cjs", b"js"]; -// ── ResolverLike bridge ─────────────────────────────────────────────────── -// `bun_router::ResolverLike` is the duck-typed seam for `Router::load_routes`; -// `bun_resolver::Resolver` is the concrete impl. The orphan-rule-compliant -// impl lives here in `bun_runtime` (the runtime sees both). - -/// Newtype so the orphan rule lets us `impl ResolverLike` for the foreign -/// `bun_resolver::Resolver`. -struct RouterResolver<'a, 'r>(&'r mut Resolver<'a>); - -impl<'a, 'r> Router::ResolverLike for RouterResolver<'a, 'r> { - #[inline] - fn fs(&self) -> &'static Fs::FileSystem { - Fs::FileSystem::instance() - } - #[inline] - fn fs_impl(&self) -> *mut Fs::Implementation { - // SAFETY: `&fs.fs` — the `Implementation` field of the singleton. - unsafe { &raw mut (*self.0.fs()).fs } - } - #[inline] - fn read_dir_info_ignore_error(&mut self, path: &[u8]) -> Option { - self.0.read_dir_info_ignore_error(path) - } -} - /// A ref on a string of the VM's interning cache. struct RefCountedString(BackRef); @@ -82,25 +64,38 @@ impl Drop for RefCountedString { pub(crate) struct FileSystemRouter { origin: Option, - base_dir: RefCountedString, - router: JsCell, asset_prefix: Option, + /// `dir` is the directory as the caller named it: every scan follows its links again. + config: RouteConfig, + /// `reload()` puts a new table here and leaves the old one as it is: a `MatchedRoute` shares it. + routes: JsCell>, } -#[derive(Clone, Copy, PartialEq, Eq)] -enum Load { - /// From the resolver's cache of the directory. A warning is an error. - Cached, - /// From the disk. Warnings pass. - FromDisk, +/// The routes below `config.dir` on disk now, or `None` if there is no such directory. +fn scan( + global: &JSGlobalObject, + config: &RouteConfig, + log: &mut Log::Log, +) -> JsResult> { + let dir = strings::paths::without_trailing_slash_windows_path(&config.dir); + let preserve_symlinks = global.bun_vm().transpiler.resolver.opts.preserve_symlinks; + match Routes::scan(dir, &config.extensions, preserve_symlinks, log) { + Ok(routes) => Ok(routes), + Err(err) => Err(global.throw_value(err.to_js(global)?)), + } } -/// The routes of `config.dir`, or `None` if there is no such directory. -fn load_router( - global: &JSGlobalObject, - config: RouteConfig, - load: Load, -) -> JsResult> { +/// The reloader of the process, which is that of the main thread, if files are watched. +#[cfg(unix)] +fn import_watcher() -> Option<&'static ImportWatcher> { + let main = VirtualMachine::get_main_thread_vm()?; + // SAFETY: the main VM is never freed. Its watcher is installed before a script runs and other threads read it under its mutex. + unsafe { (*main).bun_watcher.as_ref() } +} + +/// Resolves each of `files` as an import of it does, so that the resolver has its directory and its path again. +#[cfg(unix)] +fn resolve_again(vm: &VirtualMachine, files: &[Box<[u8]>]) { let mut log = Log::Log::new(); // SAFETY: the VM and its resolver outlive this call, and this is dropped before `log`. let _restore_log = unsafe { @@ -109,115 +104,14 @@ fn load_router( core::ptr::NonNull::from(&mut log), ) }; - if load == Load::FromDisk { - FileSystemRouter::bust_dir_cache_recursive( - strings::paths::without_trailing_slash_windows_path(&config.dir), - ); - } - - let resolver = &mut VirtualMachine::get().as_mut().transpiler.resolver; - let root_dir_info = match resolver.read_dir_info(&config.dir) { - Ok(Some(info)) => info, - Ok(None) => return Ok(None), - Err(_) => { - let error = log.to_js(global, format_args!("reading root directory"))?; - return Err(global.throw_value(error)); - } - }; - - let mut router = Router::Router::init(config).expect("unreachable"); - let config_dir = router.config.dir.clone(); - let loaded = router.load_routes( - &mut log, - &root_dir_info, - &mut RouterResolver(resolver), - &config_dir, - ); - if loaded.is_err() || (load == Load::Cached && log.errors + log.warnings > 0) { - let error = log.to_js(global, format_args!("loading routes"))?; - return Err(global.throw_value(error)); - } - Ok(Some((router, root_dir_info))) -} - -impl FileSystemRouter { - fn bust_dir_cache_recursive(input_path: &[u8]) { - // `vm.as_mut()` at each use: the recursive call makes its own, which ends one held across it. - let vm = VirtualMachine::get(); - #[cfg(not(windows))] - let path = input_path; - #[cfg(windows)] - let path; - #[cfg(windows)] - let normalized: Vec; - #[cfg(windows)] - { - // Note: `with_borrow_mut` can't hand back a slice that outlives - // the closure, so copy out (cold reload path). - normalized = WIN32_NORMALIZE_BUF - .with_borrow_mut(|buf| vm.fs().normalize_buf(&mut buf[..], input_path).to_vec()); - path = normalized.as_slice(); - } - - let root_dir_info = match vm.as_mut().transpiler.resolver.read_dir_info(path) { - Ok(v) => v, - Err(_) => return, - }; - - if let Some(dir_ref) = root_dir_info { - // Snapshot the cached `DirEntry`'s entry pointers under `entries_mutex` - // (other threads rewrite the map in place under that lock), then drop - // the guard: `bust_dir_cache` / the recursion below re-acquire it. - let entry_ptrs: Vec<*mut Fs::Entry> = { - let _entries_lock = Fs::FileSystem::instance().fs.entries_mutex.lock_guard(); - match dir_ref.get_entries_const() { - Some(entries) => entries.data.values().copied().collect(), - None => Vec::new(), - } - }; - { - 'outer: for entry_ptr in entry_ptrs { - // BACKREF: `entry_ptr` is a `*mut Entry` into the process-static - // EntryStore; the store outlives this loop. Wrap once so the - // shared-only reads below are safe `Deref`s. - let entry = BackRef::from( - core::ptr::NonNull::new(entry_ptr).expect("EntryStore entry"), - ); - if entry.base().first() == Some(&b'.') { - continue 'outer; - } - // `Transpiler::fs_mut()` is the audited safe `&mut FileSystem` - // accessor for the process-lifetime singleton; `&mut .fs` (the - // `Implementation` field) is the lazy-stat receiver. `kind` - // needs `&mut Entry` to update the cached stat; no shared - // borrow of `*entry_ptr` is live across this block. - let kind = { - let fs_impl = &mut vm.transpiler.fs_mut().fs; - // SAFETY: `entry_ptr` is a live `*mut Entry` in the process-static - // EntryStore (checked non-null above); the lazy-stat rewrite is - // serialized on `Entry.mutex`; fs_impl is the process-global RealFS. - unsafe { (&*entry_ptr).kind(fs_impl, false) } - }; - if kind == Fs::EntryKind::Dir { - for banned_dir in Router::BANNED_DIRS.iter() { - if entry.base() == *banned_dir { - continue 'outer; - } - } - let abs_parts: [&[u8]; 2] = [entry.dir, entry.base()]; - // `abs()` writes into a thread-local buffer; copy out - // before recursing (recursion overwrites it). - let full_path = vm.fs().abs(&abs_parts).to_vec(); - let _ = vm.as_mut().transpiler.resolver.bust_dir_cache( - strings::paths::without_trailing_slash_windows_path(&full_path), - ); - Self::bust_dir_cache_recursive(&full_path); - } - } - } - } - - let _ = vm.as_mut().transpiler.resolver.bust_dir_cache(path); + let top_level_dir = vm.top_level_dir(); + for file in files { + // A file that is gone has no module to reload. + let _ = + vm.as_mut() + .transpiler + .resolver + .resolve(top_level_dir, file, Log::ImportKind::Dynamic); } } @@ -243,23 +137,19 @@ impl FileSystemRouter { Valid::no_null_bytes(options.dir, "dir", "a string", global)?; let mut out_buf = [0u8; MAX_PATH_BYTES * 2]; - let mut root_dir_path = global.bun_vm().top_level_dir(); - if !(options.dir.is_empty() || options.dir == b".") { - // An absolute path is joined too: a route's name is cut from the resolver's normalized spelling. - let Some(joined) = - path::resolve_path::join_abs_string_buf_checked::( - Fs::FileSystem::instance().top_level_dir(), - &mut out_buf, - &[options.dir], - ) - else { - return Err(cx.heap.error(format_args!( - "Unable to find directory: {}", - bstr::BStr::new(options.dir) - ))); - }; - root_dir_path = joined; - } + // Every path is joined, absolute or empty: `reload()` names `dir` to the resolver in the resolver's spelling. + let Some(root_dir_path) = + path::resolve_path::join_abs_string_buf_checked::( + Fs::FileSystem::instance().top_level_dir(), + &mut out_buf, + &[options.dir], + ) + else { + return Err(cx.heap.error(format_args!( + "Unable to find directory: {}", + bstr::BStr::new(options.dir) + ))); + }; let mut extensions: Vec> = options .file_extensions @@ -277,25 +167,23 @@ impl FileSystemRouter { dir: strings::paths::clone_normalizing_separators(root_dir_path).into_boxed_slice(), extensions: extensions.into(), }; - let Some((mut router, root_dir_info)) = load_router(global, config, Load::Cached)? else { + let mut log = Log::Log::new(); + let Some(routes) = scan(global, &config, &mut log)? else { return Err(cx.heap.error(format_args!( "Unable to find directory: {}", bstr::BStr::new(root_dir_path) ))); }; - - let base_dir_str = if !root_dir_info.abs_real_path.is_empty() { - root_dir_info.abs_real_path - } else { - root_dir_info.abs_path - }; + // `reload()` leaves out a route file that has an error. A new router does not start with one. + if log.has_errors() { + let error = log.to_js(global, format_args!("loading routes"))?; + return Err(global.throw_value(error).into()); + } let vm = global.bun_vm().as_mut(); let mut ref_counted = |bytes: &[u8]| { RefCountedString::new(vm, bytes).map_err(|_| global.throw_string_too_long()) }; - let base_dir = ref_counted(base_dir_str)?; - router.config.dir = Box::from(base_dir.bytes()); let not_empty = |bytes: &&[u8]| !bytes.is_empty(); Ok(FileSystemRouter { origin: options @@ -303,13 +191,13 @@ impl FileSystemRouter { .filter(not_empty) .map(&mut ref_counted) .transpose()?, - base_dir, asset_prefix: options .asset_prefix .filter(not_empty) .map(&mut ref_counted) .transpose()?, - router: JsCell::new(router), + config, + routes: JsCell::new(Rc::new(routes)), }) } @@ -361,13 +249,11 @@ impl FileSystemRouter { ))); } }; + // SAFETY: a match borrows bytes of the route table. The `MatchedRoute` below keeps the + // match and holds the table, which an `Rc` does not move and which nothing changes. + let routes: &'static Routes = unsafe { bun_ptr::detach_lifetime_ref(&**self.routes.get()) }; let mut params_list = route_param::List::default(); - let Some(route) = self - .router - .get() - .routes - .match_page_with_allocator(&url_path, &mut params_list) - else { + let Some(route) = routes.match_page_with_allocator(&url_path, &mut params_list) else { return Ok(None); }; @@ -392,7 +278,7 @@ impl FileSystemRouter { param_map: JsCell::new(None), origin: self.origin.clone(), asset_prefix: self.asset_prefix.clone(), - base_dir: self.base_dir.clone(), + routes: Rc::clone(self.routes.get()), }; Ok(Some(matched.into_js(heap))) } @@ -406,14 +292,41 @@ impl FileSystemRouter { this_value: JsWrapper<'a, Self>, cx: &mut JsScope<'a>, ) -> bindgen::Result<'a, JsWrapper<'a, Self>> { - let config = self.router.get().config.clone(); - let Some((router, _)) = load_router(cx.global(), config, Load::FromDisk)? else { + let global = cx.global(); + let vm = global.bun_vm(); + // The messages are not read: a route file that has an error in its name is left out. + let mut log = Log::Log::new(); + let scanned = scan(global, &self.config, &mut log)?; + + // The resolver reads again each directory that the last scan or this one read, under each of its names. + let dir = strings::paths::without_trailing_slash_windows_path(&self.config.dir); + // With inotify and kqueue the reloader finds the module of a saved file through the resolver's record of its directory. + #[cfg(unix)] + let watcher = import_watcher(); + #[cfg(unix)] + let mut dropped = StringSet::new(); + let mut forget = |name: &[u8]| { + let _ = vm.as_mut().transpiler.resolver.bust_dir_cache(name); + #[cfg(unix)] + if watcher.is_some() { + bun_core::handle_oom(dropped.insert(name)); + } + }; + self.routes.get().for_each_dir_name(dir, &mut forget); + if let Some(routes) = &scanned { + routes.for_each_dir_name(dir, &mut forget); + } + #[cfg(unix)] + if let Some(watcher) = watcher { + resolve_again(vm, &watcher.watched_files(|dir| dropped.contains(dir))); + } + let Some(routes) = scanned else { return Err(cx.heap.error(format_args!( "Unable to find directory: {}", - bstr::BStr::new(&*self.router.get().config.dir) + bstr::BStr::new(&*self.config.dir) ))); }; - self.router.set(router); + self.routes.set(Rc::new(routes)); js_FileSystemRouter::routes_set_cached(this_value.to_js(), cx.global(), JSValue::ZERO); Ok(this_value) } @@ -421,8 +334,7 @@ impl FileSystemRouter { pub(crate) fn get_routes( &self, ) -> impl IntoIterator, impl AsRef<[u8]>)> { - let router = self.router.get(); - router.get_names().iter().zip(router.get_entry_points()) + self.routes.get().iter() } pub(crate) fn get_style(&self) -> RouterStyle { @@ -431,20 +343,21 @@ impl FileSystemRouter { } pub(crate) struct MatchedRoute { - /// The route's. The resolver's DirnameStore holds it for the life of the process, as it does `file_path`. + /// The route's. Bytes of `routes`, as `file_path` is. name: &'static [u8], file_path: &'static [u8], /// Decoded, with the query string. pathname: Utf8Bytes<'static>, /// Where the query string starts in `pathname`. query_start: usize, - /// Its values borrow `pathname`. + /// Its names are bytes of `routes`, and its values borrow `pathname`. params_list: route_param::List<'static>, /// Made by the first of `params` and `query` to need it. param_map: JsCell>, origin: Option, asset_prefix: Option, - base_dir: RefCountedString, + /// The table the route was matched in, which a later `reload()` does not change. + routes: Rc, } impl MatchedRoute { @@ -552,7 +465,7 @@ impl MatchedRoute { .map(|origin| URL::parse(origin.bytes())); bun_object::get_public_path_with_asset_prefix( self.file_path, - self.base_dir.bytes(), + self.routes.dir(), &origin_url.unwrap_or_default(), self.asset_prefix .as_ref() @@ -563,9 +476,3 @@ impl MatchedRoute { src } } - -#[cfg(windows)] -thread_local! { - static WIN32_NORMALIZE_BUF: core::cell::RefCell> = - core::cell::RefCell::new(bun_core::boxed_zeroed()); -} diff --git a/test/js/bun/util/filesystem_router.test.ts b/test/js/bun/util/filesystem_router.test.ts index 2e22b5eddfcd..3b0076242890 100644 --- a/test/js/bun/util/filesystem_router.test.ts +++ b/test/js/bun/util/filesystem_router.test.ts @@ -1,8 +1,24 @@ import { FileSystemRouter } from "bun"; +import { dlopen, ptr } from "bun:ffi"; import { heapStats } from "bun:jsc"; -import { expect, it } from "bun:test"; +import { beforeAll, describe, expect, it } from "bun:test"; import fs, { mkdirSync, rmSync } from "fs"; -import { bunEnv, bunExe, isASAN, isMacOS, isWindows, normalizeBunSnapshot, tempDir, tmpdirSync } from "harness"; +import { + bunEnv, + bunExe, + dtUnknownReaddir, + expectRssDeltaBelow, + isASAN, + isDebug, + isLinux, + isMacOS, + isWindows, + libcPathForDlopen, + normalizeBunSnapshot, + tempDir, + tmpdirSync, + unprivilegedSpawnOptions, +} from "harness"; import path, { dirname } from "path"; function createTree(basedir: string, paths: string[]) { @@ -499,7 +515,6 @@ it("the constructor and reload() don't create allocator heaps", () => { for (const fileExtensions of [undefined, [".tsx", ".ts"]]) { const create = () => new FileSystemRouter({ dir, style: "nextjs", fileExtensions }); - // The first router warms the resolver's directory cache. const router = create(); router.reload(); @@ -1019,10 +1034,14 @@ it("match() returns null when the path string does not start with '/'", () => { }); it("reload() while Bun.build() resolves the same directory", async () => { - // The router's route-load loop and Bun.build's entry-point resolution (which - // runs on the bundler thread) share the process-global directory-entry cache. + // Bun.build's entry-point resolution runs on the bundler thread and rewrites + // the process-global directory-entry cache in place. On the main thread, + // reload() drops the records of the same directories from that cache and + // Bun.resolveSync() reads them into it again. // Run in a subprocess so a crash is observable as a signal instead of taking // down the test runner. + // On a debug build the builds of 40 rounds alone take longer than the test may run. + const rounds = isDebug ? 10 : 40; const files: Record = { "fixture.ts": /* ts */ ` import path from "path"; @@ -1039,12 +1058,12 @@ it("reload() while Bun.build() resolves the same directory", async () => { }); // The first build completes with generation 0 and the bundle thread then // bumps its generation, so every later build's resolver re-reads the - // directory listing in place. reload() iterates the same listing on the - // main thread, and that in-place re-read is what the reload loop races. + // directory listing in place. The reload loop races that in-place re-read. await Bun.build({ entrypoints, target: "bun", throw: false }); let matches = 0; + let resolved = 0; let buildsOk = true; - for (let round = 0; round < 40; round++) { + for (let round = 0; round < ${rounds}; round++) { const builds = Array.from({ length: 4 }, () => Bun.build({ entrypoints, target: "bun", throw: false }), ); @@ -1052,11 +1071,17 @@ it("reload() while Bun.build() resolves the same directory", async () => { router.reload(); const m = router.match("/p7"); if (m && m.filePath.endsWith("p7.tsx")) matches++; + if (Bun.resolveSync("./sub/s7.tsx", pagesDir).endsWith("s7.tsx")) resolved++; + try { + Bun.resolveSync("./missing.tsx", pagesDir); + } catch { + resolved++; + } } const results = await Promise.all(builds); buildsOk &&= results.every(r => r.success); } - console.log("matches", matches, "builds-ok", buildsOk); + console.log("matches", matches, "resolved", resolved, "builds-ok", buildsOk); `, }; for (let i = 1; i <= 40; i++) { @@ -1078,13 +1103,18 @@ it("reload() while Bun.build() resolves the same directory", async () => { stderr: normalizeBunSnapshot(stderr, String(dir)), exitCode, signalCode: proc.signalCode, - }).toEqual({ stdout: "matches 2000 builds-ok true", stderr: "", exitCode: 0, signalCode: null }); + }).toEqual({ + stdout: `matches ${rounds * 50} resolved ${rounds * 100} builds-ok true`, + stderr: "", + exitCode: 0, + signalCode: null, + }); }, 60_000); it("loads routes from a directory already cached by Bun.build()", async () => { - // The resolver caches the directory name without a trailing slash while the - // router spells it with one; loading routes out of the already-populated - // entry cache must accept either spelling. Run in a subprocess so a crash is + // The resolver has the directory cached, and under a name without a trailing + // slash while the router spells it with one. The router names its routes from + // its own walk of the directory. Run in a subprocess so a crash is // observable as a nonzero exit instead of taking down the test runner. using dir = tempDir("fsr-prewarmed-entry-cache", { "fixture.ts": /* ts */ ` @@ -1312,3 +1342,1494 @@ it(".query and .params support names that look like array indices", () => { expect(params).toEqual({ 0: "hello" }); expect(params[0]).toBe("hello"); }); + +const page = "export default 1;\n"; +const routeNames = (router: FileSystemRouter) => Object.keys(router.routes).sort(); +const routerOver = (dir: string, options: { fileExtensions?: string[] } = {}) => + new FileSystemRouter({ style: "nextjs", dir, ...options }); +/** `routes` with each file path relative to `root`, in POSIX form. */ +const routesBelow = (router: FileSystemRouter, root: string) => + Object.fromEntries( + Object.entries(router.routes) + .sort(([a], [b]) => (a < b ? -1 : 1)) + .map(([name, filePath]) => [name, path.relative(root, filePath).replaceAll("\\", "/")]), + ); + +describe("a new router reads the directory from disk", () => { + // Each of these leaves a record of `pages` in the resolver's directory cache. + const recorders: Record unknown> = { + "an earlier router": pages => routerOver(pages), + "import()": pages => import(path.join(pages, "a.tsx")), + "require()": pages => require(path.join(pages, "a.tsx")), + "Bun.resolveSync()": pages => Bun.resolveSync("./a.tsx", pages), + "Bun.build()": pages => Bun.build({ entrypoints: [path.join(pages, "a.tsx")], target: "bun", throw: false }), + }; + it.each(Object.keys(recorders))("after %s read it", async recorder => { + using dir = tempDir("fsr-fresh-recorded", { "pages/a.tsx": page, "pages/old.tsx": page }); + const pages = path.join(String(dir), "pages"); + await recorders[recorder](pages); + + fs.writeFileSync(path.join(pages, "new.tsx"), page); + fs.rmSync(path.join(pages, "old.tsx")); + expect(routesBelow(routerOver(pages), pages)).toEqual({ "/a": "a.tsx", "/new": "new.tsx" }); + }); + + it("a file added to and a file removed from a subdirectory", () => { + using dir = tempDir("fsr-fresh-subdir", { "pages/a.tsx": page, "pages/sub/old.tsx": page }); + const pages = path.join(String(dir), "pages"); + expect(routeNames(routerOver(pages))).toEqual(["/a", "/sub/old"]); + + fs.writeFileSync(path.join(pages, "sub", "new.tsx"), page); + fs.rmSync(path.join(pages, "sub", "old.tsx")); + expect(routeNames(routerOver(pages))).toEqual(["/a", "/sub/new"]); + // A router over the subdirectory reads it too. + expect(routeNames(routerOver(path.join(pages, "sub")))).toEqual(["/new"]); + }); + + it("a new and a removed subdirectory", () => { + using dir = tempDir("fsr-fresh-dirs", { "pages/a.tsx": page, "pages/gone/post.tsx": page }); + const pages = path.join(String(dir), "pages"); + expect(routeNames(routerOver(pages))).toEqual(["/a", "/gone/post"]); + + fs.mkdirSync(path.join(pages, "blog")); + fs.writeFileSync(path.join(pages, "blog", "post.tsx"), page); + fs.rmSync(path.join(pages, "gone"), { recursive: true }); + expect(routeNames(routerOver(pages))).toEqual(["/a", "/blog/post"]); + }); + + it("a file replaced by a directory of the same name", () => { + using dir = tempDir("fsr-fresh-swap", { "pages/a.tsx": page, "pages/b.tsx": page }); + const pages = path.join(String(dir), "pages"); + expect(routeNames(routerOver(pages))).toEqual(["/a", "/b"]); + + fs.rmSync(path.join(pages, "b.tsx")); + fs.mkdirSync(path.join(pages, "b.tsx")); + fs.writeFileSync(path.join(pages, "b.tsx", "c.tsx"), page); + expect(routeNames(routerOver(pages))).toEqual(["/a", "/b.tsx/c"]); + }); + + it("a directory created after the constructor threw for it", () => { + using dir = tempDir("fsr-fresh-missing", {}); + const pages = path.join(String(dir), "pages"); + expect(() => routerOver(pages)).toThrow(`Unable to find directory: ${pages}`); + + fs.mkdirSync(pages); + fs.writeFileSync(path.join(pages, "a.tsx"), page); + expect(routeNames(routerOver(pages))).toEqual(["/a"]); + }); + + it("a directory removed after a router read it", () => { + using dir = tempDir("fsr-fresh-removed", { "pages/a.tsx": page }); + const pages = path.join(String(dir), "pages"); + expect(routeNames(routerOver(pages))).toEqual(["/a"]); + + fs.rmSync(pages, { recursive: true }); + expect(() => routerOver(pages)).toThrow( + expect.objectContaining({ name: "Error", message: `Unable to find directory: ${pages}` }), + ); + }); + + it("in a Worker, after a router on the main thread", async () => { + using dir = tempDir("fsr-fresh-worker", { + "pages/a.tsx": page, + "pages/old.tsx": page, + "worker.mjs": ` + self.onmessage = event => { + const router = new Bun.FileSystemRouter({ style: "nextjs", dir: event.data }); + postMessage(Object.keys(router.routes).sort()); + }; + `, + }); + const pages = path.join(String(dir), "pages"); + expect(routeNames(routerOver(pages))).toEqual(["/a", "/old"]); + fs.writeFileSync(path.join(pages, "new.tsx"), page); + fs.rmSync(path.join(pages, "old.tsx")); + + const worker = new Worker(path.join(String(dir), "worker.mjs")); + try { + const { promise, resolve, reject } = Promise.withResolvers(); + worker.onmessage = event => resolve(event.data); + worker.onerror = reject; + worker.postMessage(pages); + expect(await promise).toEqual(["/a", "/new"]); + } finally { + worker.terminate(); + } + }); +}); + +describe("a directory that the resolver looked for before it existed", () => { + // Each of these leaves a "not found" record of `pages/blog` and of `pages/blog/deep` in the resolver. + const lookups: Record unknown> = { + "Bun.resolveSync()": pages => [ + expect(() => Bun.resolveSync("./blog/post", pages)).toThrow(notFound), + expect(() => Bun.resolveSync("./blog/deep/post", pages)).toThrow(notFound), + ], + "require()": pages => [ + expect(() => require(path.join(pages, "blog", "post.tsx"))).toThrow(), + expect(() => require(path.join(pages, "blog", "deep", "post.tsx"))).toThrow(), + ], + "import()": async pages => [ + await expect(import(path.join(pages, "blog", "post.tsx"))).rejects.toThrow(), + await expect(import(path.join(pages, "blog", "deep", "post.tsx"))).rejects.toThrow(), + ], + }; + const expected = { "/a": "a.tsx", "/blog/deep/post": "blog/deep/post.tsx", "/blog/post": "blog/post.tsx" }; + const create = (pages: string) => { + fs.mkdirSync(path.join(pages, "blog", "deep"), { recursive: true }); + fs.writeFileSync(path.join(pages, "blog", "post.tsx"), page); + fs.writeFileSync(path.join(pages, "blog", "deep", "post.tsx"), page); + }; + + it.each(Object.keys(lookups))("is listed by the first reload() after %s missed it", async lookup => { + using dir = tempDir("fsr-reload-not-found", { "pages/a.tsx": page }); + const pages = path.join(String(dir), "pages"); + const router = routerOver(pages); + await lookups[lookup](pages); + create(pages); + + router.reload(); + expect(routesBelow(router, pages)).toEqual(expected); + }); + + it.each(Object.keys(lookups))("is listed by a new router after %s missed it", async lookup => { + using dir = tempDir("fsr-new-not-found", { "pages/a.tsx": page }); + const pages = path.join(String(dir), "pages"); + await lookups[lookup](pages); + create(pages); + + expect(routesBelow(routerOver(pages), pages)).toEqual(expected); + }); +}); + +describe("bun --hot", () => { + /** Runs `server.js` under `--hot`, calls `change` after its first line, and returns all it wrote when it exits. */ + async function run(root: string, firstLine: string, change: () => void) { + await using runner = Bun.spawn({ + cmd: [bunExe(), "--hot", "--no-clear-screen", "server.js"], + env: bunEnv, + cwd: root, + stdout: "pipe", + stderr: "inherit", + stdin: "ignore", + }); + const reader = runner.stdout.getReader(); + const decoder = new TextDecoder(); + let stdout = ""; + while (!stdout.includes("\n")) { + const { value, done } = await reader.read(); + if (done) break; + stdout += decoder.decode(value, { stream: true }); + } + expect(stdout).toBe(firstLine); + change(); + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + stdout += decoder.decode(value, { stream: true }); + } + return { stdout, exitCode: await runner.exited }; + } + + it("a module-level router lists the pages added since the last run", async () => { + const server = (run: number) => ` + const router = new Bun.FileSystemRouter({ style: "nextjs", dir: import.meta.dir + "/pages" }); + console.log("run ${run}: " + Object.keys(router.routes).sort().join(" ")); + ${run === 2 ? "process.exit(0);" : ""} + `; + using dir = tempDir("fsr-hot-new", { "server.js": server(1), "pages/index.tsx": page, "pages/blog/a.tsx": page }); + const root = String(dir); + const result = await run(root, "run 1: / /blog/a\n", () => { + fs.writeFileSync(path.join(root, "pages", "new.tsx"), page); + fs.writeFileSync(path.join(root, "pages", "blog", "b.tsx"), page); + fs.writeFileSync(path.join(root, "server.js"), server(2)); + }); + expect(result).toEqual({ stdout: "run 1: / /blog/a\nrun 2: / /blog/a /blog/b /new\n", exitCode: 0 }); + }); + + // With inotify the reloader finds the module of a renamed file through the resolver's record of its directory, + // which reload() drops. Each case saves `saved` by a rename after the first run. + const router = `new Bun.FileSystemRouter({ style: "nextjs", dir: import.meta.dir + "/pages", fileExtensions: [".tsx", ".js"] })`; + const cases: Record; saved: string; link?: [string, string] }> = { + "a route after reload()": { + files: { + "server.js": `const router = ${router}; + const imported = await import(router.match("/blog/post").filePath); + router.reload();`, + "pages/blog/post.tsx": "export default 1;\n", + }, + saved: "pages/blog/post.tsx", + }, + "a module that a route imports after reload()": { + files: { + "server.js": `const router = ${router}; + const imported = await import(router.match("/blog/post").filePath); + router.reload();`, + "pages/blog/post.tsx": `export { default } from "./helper.ts";\n`, + "pages/blog/helper.ts": "export default 1;\n", + }, + saved: "pages/blog/helper.ts", + }, + "a file with a route extension that is not the route after reload()": { + files: { + "server.js": `const router = ${router}; + const imported = await import("./pages/index.tsx"); + router.reload();`, + "pages/index.js": "export default 0;\n", + "pages/index.tsx": "export default 1;\n", + }, + saved: "pages/index.tsx", + }, + "a module below a linked directory after reload()": { + files: { + "server.js": `const router = ${router}; + const imported = await import("./pages/lib/config.ts"); + router.reload();`, + "pages/a.tsx": "export default 0;\n", + "shared/config.ts": "export default 1;\n", + }, + link: ["shared", "pages/lib"], + saved: "shared/config.ts", + }, + "a route after reload() in a Worker": { + files: { + "server.js": `const router = ${router}; + const imported = await import(router.match("/blog/post").filePath); + const worker = new Worker(import.meta.dir + "/worker.js"); + await new Promise(done => (worker.onmessage = done)); + worker.terminate();`, + "worker.js": `${router}.reload(); + postMessage("reloaded");`, + "pages/blog/post.tsx": "export default 1;\n", + }, + saved: "pages/blog/post.tsx", + }, + }; + it.skipIf(!isLinux).each(Object.keys(cases))("a save by a rename reloads %s", async name => { + const { files, saved, link } = cases[name]; + files["server.js"] += ` + console.log("version " + imported.default); + if (imported.default === 2) process.exit(0); + `; + using dir = tempDir("fsr-hot-reload", { ...files, "saved": "export default 2;\n" }); + const root = String(dir); + if (link) fs.symlinkSync(path.join(root, link[0]), path.join(root, link[1])); + const result = await run(root, "version 1\n", () => { + fs.renameSync(path.join(root, "saved"), path.join(root, saved)); + }); + expect(result).toEqual({ stdout: "version 1\nversion 2\n", exitCode: 0 }); + }); +}); + +it("routes and match() keep the scan they came from until reload()", () => { + using dir = tempDir("fsr-scan-snapshot", { "pages/a.tsx": page, "pages/old.tsx": page, "pages/[id]/post.tsx": page }); + const pages = path.join(String(dir), "pages"); + const read = (matched: ReturnType) => ({ + name: matched!.name, + filePath: path.relative(pages, matched!.filePath).replaceAll("\\", "/"), + src: matched!.src, + kind: matched!.kind, + params: matched!.params, + query: matched!.query, + }); + const router = routerOver(pages); + // Not read before the route table they came from is gone. + const matched = [router.match("/old"), router.match("/42/post?tab=a")]; + + fs.writeFileSync(path.join(pages, "new.tsx"), page); + fs.rmSync(path.join(pages, "old.tsx")); + fs.rmSync(path.join(pages, "[id]"), { recursive: true }); + expect({ names: routeNames(router), old: router.match("/old")?.name, new: router.match("/new") }).toEqual({ + names: ["/[id]/post", "/a", "/old"], + old: "/old", + new: null, + }); + + router.reload(); + expect({ names: routeNames(router), old: router.match("/old"), new: router.match("/new")?.name }).toEqual({ + names: ["/a", "/new"], + old: null, + new: "/new", + }); + router.reload(); + Bun.gc(true); + expect(matched.map(read)).toEqual([ + { name: "/old", filePath: "old.tsx", src: "old.tsx", kind: "exact", params: {}, query: {} }, + { + name: "/[id]/post", + filePath: "[id]/post.tsx", + src: "[id]/post.tsx", + kind: "dynamic", + params: { id: "42" }, + query: { id: "42", tab: "a" }, + }, + ]); + + // A match also outlives the router it came from. + const orphan = (() => routerOver(pages).match("/new"))(); + Bun.gc(true); + expect(read(orphan)).toEqual({ + name: "/new", + filePath: "new.tsx", + src: "new.tsx", + kind: "exact", + params: {}, + query: {}, + }); +}); + +const notFound = expect.objectContaining({ code: "ERR_MODULE_NOT_FOUND" }); +/** What the resolver answers for `specifier` from `from`: a path below `from`, or "not found". */ +const resolved = (specifier: string, from: string) => { + try { + return path.relative(from, Bun.resolveSync(specifier, from)).replaceAll("\\", "/"); + } catch { + return "not found"; + } +}; + +it("reload() finds a directory that was deleted and created again", () => { + using dir = tempDir("fsr-reload-recreated", { "pages/keep.tsx": page, "pages/a/b/one.tsx": page }); + const pages = path.join(String(dir), "pages"); + const router = routerOver(pages); + expect(routeNames(router)).toEqual(["/a/b/one", "/keep"]); + + fs.rmSync(path.join(pages, "a"), { recursive: true }); + router.reload(); + expect(routeNames(router)).toEqual(["/keep"]); + + fs.mkdirSync(path.join(pages, "a", "b"), { recursive: true }); + fs.writeFileSync(path.join(pages, "a", "b", "two.tsx"), page); + router.reload(); + expect({ routes: routesBelow(router, pages), one: router.match("/a/b/one") }).toEqual({ + routes: { "/a/b/two": "a/b/two.tsx", "/keep": "keep.tsx" }, + one: null, + }); + + // Nothing changed since the last scan. + router.reload(); + expect(routeNames(router)).toEqual(["/a/b/two", "/keep"]); +}); + +it("reload() throws while dir is gone, keeps its routes, and lists dir again when it is back", () => { + using dir = tempDir("fsr-reload-root-gone", { "pages/keep.tsx": page, "pages/a/b/one.tsx": page }); + const pages = path.join(String(dir), "pages"); + const router = routerOver(pages); + const held = router.match("/a/b/one")!; + const ask = () => [resolved("./keep.tsx", pages), resolved("./a/b/one.tsx", pages)]; + // The resolver has a record of each directory from here on. + expect(ask()).toEqual(["keep.tsx", "a/b/one.tsx"]); + + fs.rmSync(pages, { recursive: true }); + const gone = expect.objectContaining({ name: "Error", message: `Unable to find directory: ${pages}${path.sep}` }); + expect(() => router.reload()).toThrow(gone); + // A reload() that fails keeps the last route table. + expect({ names: routeNames(router), held: path.relative(pages, held.filePath).replaceAll("\\", "/") }).toEqual({ + names: ["/a/b/one", "/keep"], + held: "a/b/one.tsx", + }); + // The resolver finds no module below a directory that reload() did not find. + expect(ask()).toEqual(["not found", "not found"]); + expect(() => router.reload()).toThrow(gone); + + fs.mkdirSync(path.join(pages, "a", "b"), { recursive: true }); + fs.writeFileSync(path.join(pages, "a", "b", "two.tsx"), page); + router.reload(); + expect(routesBelow(router, pages)).toEqual({ "/a/b/two": "a/b/two.tsx" }); +}); + +it("after reload(), the resolver finds no module in a directory that was renamed or removed", () => { + using dir = tempDir("fsr-reload-removed", { + "pages/a.tsx": page, + "pages/blog/post.tsx": page, + "pages/gone/deep/b.tsx": page, + }); + const pages = path.join(String(dir), "pages"); + const router = routerOver(pages); + const ask = () => + ["./blog/post.tsx", "./posts/post.tsx", "./gone/deep/b.tsx"].map(specifier => resolved(specifier, pages)); + expect(ask()).toEqual(["blog/post.tsx", "not found", "gone/deep/b.tsx"]); + + fs.renameSync(path.join(pages, "blog"), path.join(pages, "posts")); + fs.rmSync(path.join(pages, "gone"), { recursive: true }); + router.reload(); + expect({ routes: routeNames(router), resolver: ask() }).toEqual({ + routes: ["/a", "/posts/post"], + resolver: ["not found", "posts/post.tsx", "not found"], + }); +}); + +it("reload() finds what is new in a directory that the resolver cached before the router listed it", () => { + using dir = tempDir("fsr-reload-resolver-first", { "pages/keep.tsx": page }); + const pages = path.join(String(dir), "pages"); + const router = routerOver(pages); + expect(routeNames(router)).toEqual(["/keep"]); + + // Each failed lookup leaves a "not found" record for the directory. + expect(() => Bun.resolveSync("./n/one.tsx", pages)).toThrow(notFound); + expect(() => Bun.resolveSync("./n/m/two.tsx", pages)).toThrow(notFound); + fs.mkdirSync(path.join(pages, "n", "m"), { recursive: true }); + fs.writeFileSync(path.join(pages, "n", "one.tsx"), page); + fs.writeFileSync(path.join(pages, "n", "m", "two.tsx"), page); + + // This lookup leaves a listing of `seen` that has only `one.tsx`. + fs.mkdirSync(path.join(pages, "seen")); + fs.writeFileSync(path.join(pages, "seen", "one.tsx"), page); + expect(resolved("./seen/one.tsx", pages)).toBe("seen/one.tsx"); + fs.writeFileSync(path.join(pages, "seen", "two.tsx"), page); + + router.reload(); + expect(routesBelow(router, pages)).toEqual({ + "/keep": "keep.tsx", + "/n/m/two": "n/m/two.tsx", + "/n/one": "n/one.tsx", + "/seen/one": "seen/one.tsx", + "/seen/two": "seen/two.tsx", + }); +}); + +// Replay a failure with BUN_FSR_RELOAD_SEED=. +it("reload() and a new router list what is on disk after random changes to the directories", () => { + const seed = Number(process.env.BUN_FSR_RELOAD_SEED ?? 3) >>> 0; + let state = seed; + const int = (n: number) => { + state = (state + 0x6d2b79f5) | 0; + let t = Math.imul(state ^ (state >>> 15), 1 | state); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return Math.floor((((t ^ (t >>> 14)) >>> 0) / 4294967296) * n); + }; + using tmp = tempDir("fsr-reload-model", { "keep.tsx": "" }); + const dir = String(tmp).replaceAll("\\", "/"); + + // directory ("" is the root, "/d0/d2") -> names of the route files in it + const tree = new Map>([["", new Set(["keep"])]]); + const anyDir = () => [...tree.keys()][int(tree.size)]; + const ops = [ + function touch() { + const parent = anyDir(); + const name = `f${int(3)}`; + fs.writeFileSync(`${dir}${parent}/${name}.tsx`, ""); + tree.get(parent)!.add(name); + return `touch ${parent}/${name}.tsx`; + }, + function makeDirs() { + let child = anyDir(); + for (let i = 1 + int(2); i > 0 && child.split("/").length < 4; i--) { + child += `/d${int(3)}`; + if (!tree.has(child)) tree.set(child, new Set()); + } + mkdirSync(dir + child, { recursive: true }); + return `mkdir -p ${child}`; + }, + function removeDir() { + const gone = anyDir(); + if (!gone) return "nothing"; + rmSync(dir + gone, { recursive: true, force: true }); + for (const key of [...tree.keys()]) if (key === gone || key.startsWith(gone + "/")) tree.delete(key); + return `rm -rf ${gone}`; + }, + // What a failed import leaves in the resolver's cache for a directory. + function failedResolve() { + const from = `${anyDir()}/d${int(3)}`; + expect(() => Bun.resolveSync("./missing.tsx", dir + from)).toThrow(notFound); + return `resolve in ${from}`; + }, + ]; + + const router = new FileSystemRouter({ dir, style: "nextjs" }); + const history: string[] = []; + for (let round = 0; round < 100; round++) { + const steps: string[] = []; + for (let i = 1 + int(3); i > 0; i--) steps.push(ops[int(ops.length)]()); + history.push(steps.join("; ")); + router.reload(); + const onDisk = Object.fromEntries( + [...tree].flatMap(([parent, names]) => + [...names].map(name => [`${parent}/${name}`, `${dir}${parent}/${name}.tsx`]), + ), + ); + const context = { seed, round, last: history.slice(-4) }; + expect({ ...context, routes: router.routes }).toEqual({ ...context, routes: onDisk }); + if (round % 10 === 9) { + expect({ ...context, routes: new FileSystemRouter({ dir, style: "nextjs" }).routes }).toEqual({ + ...context, + routes: onDisk, + }); + } + } +}); + +// inotify +it.skipIf(!isLinux)("reload() and a new router open each directory once", () => { + using dir = tempDir("fsr-reload-once-inotify", { "pages/index.tsx": "", "pages/sub/page.tsx": "" }); + const pages = path.join(String(dir), "pages"); + const libc = dlopen(libcPathForDlopen(), { + inotify_init1: { args: ["int"], returns: "int" }, + inotify_add_watch: { args: ["int", "ptr", "u32"], returns: "int" }, + read: { args: ["int", "ptr", "usize"], returns: "isize" }, + close: { args: ["int"], returns: "int" }, + }); + const IN_CLOSE_NOWRITE = 0x10; + const IN_OPEN = 0x20; + const IN_NONBLOCK = 0o4000; + const fd = libc.symbols.inotify_init1(IN_NONBLOCK); + try { + expect(fd).toBeGreaterThan(-1); + // The close between two opens of one directory keeps them from being merged into one queued event. + const watch = (p: string) => libc.symbols.inotify_add_watch(fd, Buffer.from(p + "\0"), IN_OPEN | IN_CLOSE_NOWRITE); + const router = routerOver(pages); + const watched = { root: watch(pages), sub: watch(path.join(pages, "sub")) }; + expect(Math.min(watched.root, watched.sub)).toBeGreaterThan(-1); + const buf = new Uint8Array(1 << 16); + const view = new DataView(buf.buffer); + const opens = () => { + const counts = { root: 0, sub: 0 }; + for (;;) { + const length = Number(libc.symbols.read(fd, ptr(buf), buf.length)); + if (length <= 0) break; + for (let offset = 0; offset < length;) { + const descriptor = view.getInt32(offset, true); + const mask = view.getUint32(offset + 4, true); + const nameLength = view.getUint32(offset + 12, true); + // No name: the event is about the watched directory itself. + if (nameLength === 0 && mask & IN_OPEN) { + if (descriptor === watched.root) counts.root++; + if (descriptor === watched.sub) counts.sub++; + } + offset += 16 + nameLength; + } + } + return counts; + }; + router.reload(); + expect(opens()).toEqual({ root: 1, sub: 1 }); + router.reload(); + expect(opens()).toEqual({ root: 1, sub: 1 }); + expect(routeNames(router)).toEqual(["/", "/sub/page"]); + // A new router reads each directory once too, whatever the resolver has cached. + routerOver(pages); + expect(opens()).toEqual({ root: 1, sub: 1 }); + } finally { + libc.symbols.close(fd); + libc.close(); + } +}); + +it("skips dot entries, node_modules and files with another extension", () => { + using dir = tempDir("fsr-skips", { + "pages/index.tsx": page, + "pages/a.tsx": page, + "pages/b.ts": page, + "pages/c.css": "", + "pages/README": "", + "pages/.hidden.tsx": page, + "pages/.hidden/x.tsx": page, + "pages/node_modules/pkg/index.tsx": page, + "pages/blog/index.tsx": page, + "pages/blog/[slug]/index.tsx": page, + "pages/blog/node_modules/x.tsx": page, + }); + const pages = path.join(String(dir), "pages"); + expect({ + default: routesBelow(routerOver(pages), pages), + tsxOnly: routeNames(routerOver(pages, { fileExtensions: [".tsx"] })), + }).toEqual({ + default: { + "/": "index.tsx", + "/a": "a.tsx", + "/b": "b.ts", + "/blog": "blog/index.tsx", + "/blog/[slug]": "blog/[slug]/index.tsx", + }, + tsxOnly: ["/", "/a", "/blog", "/blog/[slug]"], + }); +}); + +it("a route with uppercase letters keeps its case and matches a lowercase path", () => { + using dir = tempDir("fsr-uppercase", { + "pages/About.tsx": page, + "pages/Docs/Intro.tsx": page, + "pages/Users/index.tsx": page, + "pages/Users/[Id].tsx": page, + }); + const pages = path.join(String(dir), "pages"); + const router = routerOver(pages); + const match = (url: string) => { + const matched = router.match(url); + return matched && { name: matched.name, params: matched.params, src: matched.src }; + }; + expect({ + names: routeNames(router), + exact: [match("/About"), match("/Docs/Intro"), match("/Users")], + lowercase: [match("/about"), match("/docs/intro"), match("/users"), match("/users/42")], + }).toEqual({ + names: ["/About", "/Docs/Intro", "/Users", "/Users/[Id]"], + exact: [ + { name: "/About", params: {}, src: "About.tsx" }, + { name: "/Docs/Intro", params: {}, src: "Docs/Intro.tsx" }, + { name: "/Users", params: {}, src: "Users/index.tsx" }, + ], + lowercase: [ + { name: "/About", params: {}, src: "About.tsx" }, + { name: "/Docs/Intro", params: {}, src: "Docs/Intro.tsx" }, + { name: "/Users", params: {}, src: "Users/index.tsx" }, + { name: "/Users/[Id]", params: { Id: "42" }, src: "Users/[Id].tsx" }, + ], + }); +}); + +it("a dir that is a file, is below a file, is a link to itself or has a name too long is not found", () => { + using dir = tempDir("fsr-root-absent", { "file.tsx": page }); + const root = String(dir); + const absent = [path.join(root, "file.tsx"), path.join(root, "file.tsx", "below")]; + // Windows has other errors for these two, and a junction does not lead to itself. + if (!isWindows) { + fs.symlinkSync("loop", path.join(root, "loop")); + absent.push(path.join(root, "loop"), path.join(root, Buffer.alloc(300, "a").toString())); + } + const messages = absent.map(pages => { + try { + routerOver(pages); + return "no error"; + } catch (e) { + return `${(e as Error).name}: ${(e as Error).message}`; + } + }); + expect(messages).toEqual(absent.map(pages => `Error: Unable to find directory: ${pages}`)); +}); + +// The owner of a file reads it on Windows whatever its mode is. +it.skipIf(isWindows)("an unreadable dir is an error, and what is unreadable below it is left out", async () => { + using dir = tempDir("fsr-no-access", { + "locked/a.tsx": page, + "pages/a.tsx": page, + "pages/private/b.tsx": page, + "pages/secret.tsx": page, + "elsewhere/hidden.tsx": page, + "elsewhere/sealed/c.tsx": page, + }); + const root = String(dir); + const pages = path.join(root, "pages"); + fs.symlinkSync(path.join(root, "elsewhere", "hidden.tsx"), path.join(pages, "to-hidden.tsx")); + fs.symlinkSync(path.join(root, "elsewhere", "sealed", "c.tsx"), path.join(pages, "into-sealed.tsx")); + fs.symlinkSync(path.join(root, "elsewhere", "sealed"), path.join(pages, "sealed")); + const locked = [ + path.join(root, "locked"), + path.join(pages, "private"), + path.join(pages, "secret.tsx"), + path.join(root, "elsewhere", "hidden.tsx"), + path.join(root, "elsewhere", "sealed"), + ]; + // Root ignores the mode of a file, so the child does not run as root. + using unprivileged = unprivilegedSpawnOptions(root); + for (const file of locked) fs.chmodSync(file, 0o000); + try { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const path = require("path"); + const router = dir => new Bun.FileSystemRouter({ style: "nextjs", dir }); + let error; + try { + router("locked"); + } catch (e) { + error = { code: e.code, syscall: e.syscall, path: path.basename(e.path ?? "") }; + } + const pages = router("pages"); + const routes = () => + Object.fromEntries(Object.entries(pages.routes).map(([name, file]) => [name, path.relative(process.cwd(), file)])); + const listed = routes(); + // A route file that cannot be read is a route. The import of it is what fails. + const imported = await import(pages.match("/secret").filePath).then( + () => "no error", + e => /EACCES/.exec(e.message)?.[0], + ); + pages.reload(); + console.log(JSON.stringify({ error, listed, imported, reloaded: routes() })); + `, + ], + env: bunEnv, + cwd: root, + stdout: "pipe", + stderr: "pipe", + ...unprivileged, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const routes = { "/a": "pages/a.tsx", "/secret": "pages/secret.tsx", "/to-hidden": "elsewhere/hidden.tsx" }; + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: JSON.stringify({ + error: { code: "EACCES", syscall: "open", path: "locked" }, + listed: routes, + imported: "EACCES", + reloaded: routes, + }), + stderr: "", + exitCode: 0, + }); + } finally { + for (const file of locked) fs.chmodSync(file, 0o755); + } +}); + +// ulimit: the limit of a Windows process is not one a shell sets. +it.skipIf(isWindows)("a scan with no file descriptor left is an error, not a table with routes missing", async () => { + using dir = tempDir("fsr-no-descriptor", { + "pages/a.tsx": page, + "pages/sub/b.tsx": page, + "fixture.ts": /* ts */ ` + import fs from "fs"; + import path from "path"; + const options = { style: "nextjs", dir: path.join(import.meta.dir, "pages") } as const; + const router = new Bun.FileSystemRouter(options); + const held: number[] = []; + try { + for (;;) held.push(fs.openSync(import.meta.path, "r")); + } catch {} + const codes: string[] = []; + for (const scan of [() => router.reload(), () => new Bun.FileSystemRouter(options)]) { + try { + scan(); + codes.push("no error"); + } catch (e) { + codes.push(e.code); + } + } + const kept = Object.keys(router.routes).sort(); + for (const fd of held) fs.closeSync(fd); + console.log(JSON.stringify({ codes, kept, reloaded: Object.keys(router.reload().routes).sort() })); + `, + }); + await using proc = Bun.spawn({ + cmd: ["sh", "-c", 'ulimit -n 256 && exec "$@"', "sh", bunExe(), "fixture.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: JSON.stringify({ codes: ["EMFILE", "EMFILE"], kept: ["/a", "/sub/b"], reloaded: ["/a", "/sub/b"] }), + stderr: "", + exitCode: 0, + }); +}); + +it("follows a link to a directory", () => { + using dir = tempDir("fsr-link-dir", { + "pages/a.tsx": page, + "elsewhere/folder/x.tsx": page, + "elsewhere/folder/deep/y.tsx": page, + }); + const root = String(dir); + const pages = path.join(root, "pages"); + fs.symlinkSync(path.join(root, "elsewhere", "folder"), path.join(pages, "ln"), "junction"); + + // The name comes from the link. The file path and `src` are those of the file the link leads to. + const router = routerOver(pages); + expect({ + routes: routesBelow(router, root), + src: [router.match("/ln/x")!.src, router.match("/ln/deep/y")!.src], + }).toEqual({ + routes: { + "/a": "pages/a.tsx", + "/ln/deep/y": "elsewhere/folder/deep/y.tsx", + "/ln/x": "elsewhere/folder/x.tsx", + }, + src: ["../elsewhere/folder/x.tsx", "../elsewhere/folder/deep/y.tsx"], + }); +}); + +// A link to a file needs a privilege on Windows. +const canLinkToFile = (() => { + if (!isWindows) return true; + using probe = tempDir("fsr-link-probe", { "target": "" }); + try { + fs.symlinkSync(path.join(String(probe), "target"), path.join(String(probe), "link"), "file"); + return true; + } catch (err: any) { + if (err.code === "EPERM" || err.code === "EACCES") return false; + throw err; + } +})(); + +it.skipIf(!canLinkToFile)("follows a link to a file", () => { + using dir = tempDir("fsr-link-file", { "pages/a.tsx": page, "elsewhere/deep/target.tsx": page }); + const root = String(dir); + const pages = path.join(root, "pages"); + fs.symlinkSync(path.join(root, "elsewhere", "deep", "target.tsx"), path.join(pages, "s.tsx"), "file"); + // A link to a link to the file. + fs.symlinkSync("s.tsx", path.join(pages, "t.tsx"), "file"); + + const router = routerOver(pages); + expect({ routes: routesBelow(router, root), src: router.match("/s")!.src }).toEqual({ + routes: { "/a": "pages/a.tsx", "/s": "elsewhere/deep/target.tsx", "/t": "elsewhere/deep/target.tsx" }, + src: "../elsewhere/deep/target.tsx", + }); +}); + +it("skips a link to nothing", () => { + using dir = tempDir("fsr-link-nowhere", { "pages/a.tsx": page }); + const root = String(dir); + const pages = path.join(root, "pages"); + // A junction is a link to a directory on Windows. Elsewhere the type is not used. + fs.symlinkSync(path.join(root, "nowhere.tsx"), path.join(pages, "dangling.tsx"), "junction"); + fs.symlinkSync(path.join(root, "nowhere"), path.join(pages, "dangling"), "junction"); + if (canLinkToFile) fs.symlinkSync(path.join(root, "nowhere.tsx"), path.join(pages, "gone.tsx"), "file"); + if (!isWindows) { + // To itself, to below a file, and to a name that is too long. + fs.symlinkSync("loop.tsx", path.join(pages, "loop.tsx")); + fs.symlinkSync("a.tsx/below.tsx", path.join(pages, "below-file.tsx")); + fs.symlinkSync(Buffer.alloc(300, "n").toString(), path.join(pages, "long.tsx")); + } + expect(routeNames(routerOver(pages))).toEqual(["/a"]); +}); + +// mkfifo: Windows has no FIFO in a directory. +it.skipIf(isWindows)("skips a FIFO and a link to a FIFO", async () => { + using dir = tempDir("fsr-not-a-file", { "pages/a.tsx": page }); + const root = String(dir); + const pages = path.join(root, "pages"); + expect(Bun.spawnSync(["mkfifo", path.join(pages, "fifo.tsx"), path.join(root, "fifo-target")]).exitCode).toBe(0); + fs.symlinkSync(path.join(root, "fifo-target"), path.join(pages, "link-to-fifo.tsx")); + + // In a subprocess: a router that opens a FIFO to learn its path never returns. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `console.log(Object.keys(new Bun.FileSystemRouter({ style: "nextjs", dir: process.argv[1] }).routes).join(" "));`, + pages, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + timeout: 30_000, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode, signalCode: proc.signalCode }).toEqual({ + stdout: "/a\n", + stderr: "", + exitCode: 0, + signalCode: null, + }); +}); + +it("a link that leads back into the tree is not followed again", () => { + using dir = tempDir("fsr-link-cycle", { + "one/a.tsx": page, + "two/a.tsx": page, + "two/sub/b.tsx": page, + "three/a.tsx": page, + "three/sub/deeper/c.tsx": page, + }); + const [one, two, three] = ["one", "two", "three"].map(name => path.join(String(dir), name)); + // To the routes directory from it and from below it, and to a directory below it. + fs.symlinkSync(one, path.join(one, "self"), "junction"); + fs.symlinkSync(two, path.join(two, "sub", "up"), "junction"); + fs.symlinkSync(path.join(three, "sub"), path.join(three, "sub", "deeper", "back"), "junction"); + expect({ + one: routeNames(routerOver(one)), + two: routeNames(routerOver(two)), + three: routeNames(routerOver(three)), + }).toEqual({ + one: ["/a"], + two: ["/a", "/sub/b"], + three: ["/a", "/sub/deeper/c"], + }); +}); + +// On Windows the bound is what 16 bits count and not the length of a path: 1,285 junctions deep. +it.skipIf(isWindows)("a chain of links ends where a route name is as long as a path can be", async () => { + // Each link adds 51 bytes to the name of a route and leads to a directory with a short path, + // so the length of a path does not end the walk. The whole chain makes a name of 71,400 bytes. + using dir = tempDir("fsr-link-chain", { + "fixture.ts": /* ts */ ` + import fs from "fs"; + import path from "path"; + const root = import.meta.dir; + const link = Buffer.alloc(50, "l").toString(); + fs.mkdirSync(path.join(root, "pages")); + for (let i = 0; i < 1400; i++) { + fs.mkdirSync(path.join(root, "d" + i)); + fs.writeFileSync(path.join(root, "d" + i, "p.tsx"), ""); + fs.symlinkSync(path.join(root, "d" + i), path.join(root, i === 0 ? "pages" : "d" + (i - 1), link)); + } + const names = Object.keys(new Bun.FileSystemRouter({ style: "nextjs", dir: path.join(root, "pages") }).routes); + console.log(JSON.stringify({ routes: names.length, longest: Math.max(...names.map(name => name.length)) })); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // A route's directory and file name are shorter than PATH_MAX: 4096 bytes on Linux, 1024 on macOS. + const expected = isLinux ? { routes: 80, longest: 4082 } : { routes: 19, longest: 971 }; + expect({ stdout, stderr, exitCode, signalCode: proc.signalCode }).toEqual({ + stdout: JSON.stringify(expected) + "\n", + stderr: "", + exitCode: 0, + signalCode: null, + }); +}); + +it("a tsconfig.json or package.json that does not parse is not the router's error", () => { + using dir = tempDir("fsr-broken-config", { + "tsconfig.json": `{ "compilerOptions": { "paths": `, + "pages/a.tsx": page, + "pages/package.json": `{ "name": `, + "pages/sub/b.tsx": page, + "pages/sub/tsconfig.json": `{ "compilerOptions": `, + }); + expect(routeNames(routerOver(path.join(String(dir), "pages")))).toEqual(["/a", "/sub/b"]); +}); + +it("reload() and new routers in a loop keep no memory of the scans before", async () => { + const files: Record = {}; + for (let i = 0; i < 50; i++) files[`pages/folder-${i}/Route-${i}.tsx`] = ""; + using dir = tempDir("fsr-scan-memory", files); + + // Each reload() used to leave the resolver's records of 51 directories behind: about 420 KB. + const code = /* ts */ ` + const options = { style: "nextjs", dir: ${JSON.stringify(path.join(String(dir), "pages"))} }; + const router = new Bun.FileSystemRouter(options); + const scan = count => { + for (let i = 0; i < count; i++) { + router.reload(); + new Bun.FileSystemRouter(options); + } + Bun.gc(true); + }; + scan(20); + const before = process.memoryUsage.rss(); + scan(100); + console.log(JSON.stringify({ deltaMiB: (process.memoryUsage.rss() - before) / 1024 / 1024 })); + `; + await expectRssDeltaBelow(["-e", code], { release: 10, debug: 10 }); +}); + +describe("two files for one route", () => { + const message = (pages: string, root: string) => { + try { + routerOver(pages); + return "no error"; + } catch (e) { + return (e as Error).message.replaceAll("\\", "/").replaceAll(root.replaceAll("\\", "/"), ""); + } + }; + + // The walk reads the names of a directory in their order without case, and a directory before what is below the next name. + it("the first file of the walk defines a route, and the next is an error for a new router", () => { + using dir = tempDir("fsr-duplicate", { + "static/a.tsx": page, + "static/a.ts": page, + "static/b.tsx": page, + "dynamic/[id].tsx": page, + "dynamic/[id].ts": page, + "index/blog/index.tsx": page, + "index/blog/index.ts": page, + "folder/blog.tsx": page, + "folder/blog/index.tsx": page, + "case/Home.tsx": page, + "case/home/index.tsx": page, + "dynamic-case/[Id].tsx": page, + "dynamic-case/[id].ts": page, + }); + const root = String(dir); + const trees = ["static", "dynamic", "index", "folder", "case", "dynamic-case"]; + expect(trees.map(tree => message(path.join(root, tree), root))).toEqual([ + `Route "/a" is already defined by /static/a.ts`, + `Route "/[id]" is already defined by /dynamic/[id].ts`, + `Route "/blog" is already defined by /index/blog/index.ts`, + `Route "/blog" is already defined by /folder/blog/index.tsx`, + `Route "/Home" is already defined by /case/home/index.tsx`, + `Route "/[Id]" is already defined by /dynamic-case/[id].ts`, + ]); + }); + + it("reload() keeps the first file of the walk for a route", () => { + using dir = tempDir("fsr-duplicate-reload", { + "pages/a.tsx": page, + "pages/[id].tsx": page, + "pages/blog/index.tsx": page, + }); + const pages = path.join(String(dir), "pages"); + const router = routerOver(pages); + for (const file of ["a.ts", "[id].ts", "blog/index.ts", "blog.tsx"]) fs.writeFileSync(path.join(pages, file), page); + router.reload(); + expect(routesBelow(router, pages)).toEqual({ "/[id]": "[id].ts", "/a": "a.ts", "/blog": "blog/index.ts" }); + }); + + it("the first index file of the walk is the index route, with no error", () => { + using dir = tempDir("fsr-index-files", { + "pages/index.tsx": page, + "pages/index.ts": page, + "pages/index.js": page, + "pages/index.jsx": page, + }); + const pages = path.join(String(dir), "pages"); + const index = (fileExtensions?: string[]) => { + const router = routerOver(pages, { fileExtensions }); + return { routes: routesBelow(router, pages), match: path.relative(pages, router.match("/")!.filePath) }; + }; + expect([index(), index([".tsx", ".ts"])]).toEqual([ + { routes: { "/": "index.js" }, match: "index.js" }, + { routes: { "/": "index.ts" }, match: "index.ts" }, + ]); + }); + + // Names that differ only in case are one name on macOS and Windows. + it.skipIf(!isLinux)("of names that differ only in case, the route is the one an import loads", async () => { + const files = ["About.tsx", "about.tsx", "B.tsx", "b.tsx", "page.tsx", "PAGE.tsx", "Blog/x.tsx", "blog/x.tsx"]; + // A route extension in another case is not one. + files.push("c.tsx", "C.TSX", "[Id].tsx", "[id].tsx", "Gone.tsx"); + using dir = tempDir( + "fsr-case-twins", + Object.fromEntries(files.map(file => [`pages/${file}`, `export default ${JSON.stringify(file)};\n`])), + ); + const pages = path.join(String(dir), "pages"); + // The resolver has this name for `Gone.tsx` if the directory returns it last. + fs.symlinkSync("nowhere", path.join(pages, "gone.tsx")); + const router = routerOver(pages); + const routes = Object.entries(routesBelow(router, pages)); + const loaded = await Promise.all( + routes.map(async ([name, file]) => [name, (await import(path.join(pages, file))).default]), + ); + // One route for each of the first five pairs. The last two pairs have a name that is no route. + expect({ count: routes.length >= 5 && routes.length <= 7, loaded }).toEqual({ count: true, loaded: routes }); + router.reload(); + expect(Object.entries(routesBelow(router, pages))).toEqual(routes); + }); +}); + +describe("dir is reached through a link", () => { + async function run(fixture: string, files: Record, link: [target: string, name: string]) { + using dir = tempDir("fsr-linked-dir", { "fixture.ts": fixture, ...files }); + fs.symlinkSync(path.join(String(dir), link[0]), path.join(String(dir), link[1]), "junction"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { + stdout: normalizeBunSnapshot(stdout, String(dir)), + stderr: normalizeBunSnapshot(stderr, String(dir)), + exitCode, + signalCode: proc.signalCode, + }; + } + const names = `const names = router => Object.keys(router.routes).sort().join(" ");`; + + it("reload() names the routes as the constructor does", async () => { + const result = await run( + /* ts */ ` + import path from "path"; + ${names} + const dir = path.join(import.meta.dir, "link", "pages"); + const router = new Bun.FileSystemRouter({ dir, style: "nextjs", fileExtensions: [".tsx"] }); + const before = names(router); + router.reload(); + const again = new Bun.FileSystemRouter({ dir, style: "nextjs", fileExtensions: [".tsx"] }); + console.log([before, names(router), router.match("/sub/c")?.name, router.match("/")?.name, names(again)].join(" | ")); + `, + { "real/pages/index.tsx": page, "real/pages/b.tsx": page, "real/pages/sub/c.tsx": page }, + ["real", "link"], + ); + expect(result).toEqual({ + stdout: "/ /b /sub/c | / /b /sub/c | /sub/c | / | / /b /sub/c", + stderr: "", + exitCode: 0, + signalCode: null, + }); + }); + + it("reload() follows the link to where it leads now", async () => { + const result = await run( + /* ts */ ` + import fs from "fs"; + import path from "path"; + ${names} + const link = path.join(import.meta.dir, "link"); + const router = new Bun.FileSystemRouter({ dir: path.join(link, "pages"), style: "nextjs" }); + const before = names(router); + // A junction is a directory to Windows. + if (process.platform === "win32") fs.rmdirSync(link); + else fs.unlinkSync(link); + fs.symlinkSync(path.join(import.meta.dir, "second"), link, "junction"); + router.reload(); + const match = router.match("/two"); + console.log([before, names(router), path.relative(import.meta.dir, match.filePath).replaceAll("\\\\", "/"), match.src].join(" | ")); + `, + { "first/pages/one.tsx": page, "second/pages/two.tsx": page }, + ["first", "link"], + ); + expect(result).toEqual({ + stdout: "/one | /two | second/pages/two.tsx | two.tsx", + stderr: "", + exitCode: 0, + signalCode: null, + }); + }); +}); + +it("reload() returns when the resolver has dir cached without a trailing separator", async () => { + // The failed resolve and the router over `sub` leave `pages` cached without a trailing separator. + using dir = tempDir("fsr-reload-failed-resolve", { + "fixture.ts": /* ts */ ` + import path from "path"; + const pagesDir = path.join(import.meta.dir, "pages"); + try { + Bun.resolveSync("./does-not-exist.ts", pagesDir); + } catch {} + new Bun.FileSystemRouter({ dir: path.join(pagesDir, "sub"), style: "nextjs", fileExtensions: [".tsx"] }); + const router = new Bun.FileSystemRouter({ dir: pagesDir, style: "nextjs", fileExtensions: [".tsx"] }); + const before = Object.keys(router.routes).sort().join(" "); + router.reload(); + console.log(before, "|", Object.keys(router.routes).sort().join(" "), router.match("/")?.name); + `, + "pages/index.tsx": "export default 1;\n", + "pages/b.tsx": "export default 2;\n", + "pages/sub/c.tsx": "export default 3;\n", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ + stdout: normalizeBunSnapshot(stdout, String(dir)), + stderr: normalizeBunSnapshot(stderr, String(dir)), + exitCode, + signalCode: proc.signalCode, + }).toEqual({ stdout: "/ /b /sub/c | / /b /sub/c /", stderr: "", exitCode: 0, signalCode: null }); +}); + +it("after reload(), the resolver reads again each directory that the scan read", async () => { + using dir = tempDir("fsr-reload-resolver", { + "fixture.ts": /* ts */ ` + import fs from "fs"; + import path from "path"; + const root = import.meta.dir; + const pages = path.join(root, "pages"); + const elsewhere = path.join(root, "elsewhere"); + const other = path.join(root, "other"); + const [linked, real] = ["linked", "real"].map(name => path.join(root, name, "pages")); + const resolve = (specifier: string, from: string) => { + try { + return path.relative(root, Bun.resolveSync(specifier, from)).replaceAll("\\\\", "/"); + } catch { + return "not found"; + } + }; + const build = async () => + (await Bun.build({ entrypoints: [path.join(pages, "entry.tsx")], target: "bun", throw: false })).success; + // Each answer comes from a directory that the resolver has a record of after the first call. + const ask = async () => ({ + file: resolve("./x", pages), + below: resolve("./sub/y", pages), + twoBelow: resolve("./sub/deep/v", pages), + linkInside: [resolve("./ln/z", pages), resolve("./z", elsewhere)], + belowLinkInside: [resolve("./ln/deep/u", pages), resolve("./deep/u", elsewhere)], + linkToDir: [resolve("./w", linked), resolve("./w", real)], + twoBelowLinkToDir: [resolve("./sub/deep/s", linked), resolve("./sub/deep/s", real)], + linkBelowLinkToDir: [resolve("./via/m", linked), resolve("./via/m", real), resolve("./m", other)], + caseOfName: resolve("./renamed.tsx", pages), + build: await build(), + }); + + const router = new Bun.FileSystemRouter({ style: "nextjs", dir: pages }); + const throughLink = new Bun.FileSystemRouter({ style: "nextjs", dir: linked }); + const before = await ask(); + + // Each module gets another extension, one gets another case, and the entry point gets a new import. + fs.renameSync(path.join(pages, "x.tsx"), path.join(pages, "x.ts")); + fs.renameSync(path.join(pages, "sub", "y.tsx"), path.join(pages, "sub", "y.ts")); + fs.renameSync(path.join(pages, "sub", "deep", "v.tsx"), path.join(pages, "sub", "deep", "v.ts")); + fs.renameSync(path.join(elsewhere, "z.tsx"), path.join(elsewhere, "z.ts")); + fs.renameSync(path.join(elsewhere, "deep", "u.tsx"), path.join(elsewhere, "deep", "u.ts")); + fs.renameSync(path.join(real, "w.tsx"), path.join(real, "w.ts")); + fs.renameSync(path.join(real, "sub", "deep", "s.tsx"), path.join(real, "sub", "deep", "s.ts")); + fs.renameSync(path.join(other, "m.tsx"), path.join(other, "m.ts")); + fs.renameSync(path.join(pages, "Renamed.tsx"), path.join(pages, "renamed.tsx")); + fs.writeFileSync(path.join(pages, "added.ts"), "export default 1;\\n"); + fs.writeFileSync(path.join(pages, "entry.tsx"), 'export { default } from "./added";\\n'); + + router.reload(); + throughLink.reload(); + console.log(JSON.stringify({ before, after: await ask() })); + `, + "pages/entry.tsx": page, + "pages/x.tsx": page, + "pages/Renamed.tsx": page, + "pages/sub/y.tsx": page, + "pages/sub/deep/v.tsx": page, + "elsewhere/z.tsx": page, + "elsewhere/deep/u.tsx": page, + "real/pages/w.tsx": page, + "real/pages/sub/deep/s.tsx": page, + "other/m.tsx": page, + }); + const root = String(dir); + fs.symlinkSync(path.join(root, "elsewhere"), path.join(root, "pages", "ln"), "junction"); + fs.symlinkSync(path.join(root, "real"), path.join(root, "linked"), "junction"); + fs.symlinkSync(path.join(root, "other"), path.join(root, "real", "pages", "via"), "junction"); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.ts"], + env: bunEnv, + cwd: root, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const answers = (extension: string, renamed: string) => ({ + file: `pages/x.${extension}`, + below: `pages/sub/y.${extension}`, + twoBelow: `pages/sub/deep/v.${extension}`, + linkInside: [`elsewhere/z.${extension}`, `elsewhere/z.${extension}`], + belowLinkInside: [`elsewhere/deep/u.${extension}`, `elsewhere/deep/u.${extension}`], + linkToDir: [`real/pages/w.${extension}`, `real/pages/w.${extension}`], + twoBelowLinkToDir: [`real/pages/sub/deep/s.${extension}`, `real/pages/sub/deep/s.${extension}`], + linkBelowLinkToDir: [`other/m.${extension}`, `other/m.${extension}`, `other/m.${extension}`], + caseOfName: `pages/${renamed}.tsx`, + build: true, + }); + expect({ stdout: stdout.trim(), stderr, exitCode, signalCode: proc.signalCode }).toEqual({ + stdout: JSON.stringify({ before: answers("tsx", "Renamed"), after: answers("ts", "renamed") }), + stderr: "", + exitCode: 0, + signalCode: null, + }); +}); + +describe.skipIf(!dtUnknownReaddir.available)("on a file system whose readdir reports no entry type", () => { + let env: NodeJS.Dict; + // Compiles the shim, which can take longer than a hook may on a busy machine. + beforeAll(async () => { + if (dtUnknownReaddir.available) env = await dtUnknownReaddir.env(); + }, 30_000); + + it("a router lists the same routes, and a new router reads the disk", async () => { + using dir = tempDir("fsr-dt-unknown", { + "pages/index.tsx": page, + "pages/a.tsx": page, + "pages/posts/[id].tsx": page, + "pages/api.v1/z.tsx": page, + "pages/README.md": "", + "pages/noext": "", + "pages/.hidden.tsx": page, + "pages/node_modules/pkg/index.tsx": page, + "outside/linked-file.tsx": page, + "outside/linked-dir/in-linked.tsx": page, + }); + const root = String(dir); + const pages = path.join(root, "pages"); + fs.symlinkSync(path.join(root, "outside", "linked-file.tsx"), path.join(pages, "lf.tsx")); + fs.symlinkSync(path.join(root, "outside", "linked-dir"), path.join(pages, "ld")); + fs.symlinkSync(path.join(root, "outside", "nowhere.tsx"), path.join(pages, "dangling.tsx")); + expect(Bun.spawnSync(["mkfifo", path.join(pages, "fifo.tsx")]).exitCode).toBe(0); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const fs = require("fs"); + const path = require("path"); + const list = () => + Object.fromEntries( + Object.entries(new Bun.FileSystemRouter({ style: "nextjs", dir: "pages" }).routes) + .sort(([a], [b]) => (a < b ? -1 : 1)) + .map(([name, file]) => [name, path.relative(process.cwd(), file)]), + ); + const first = list(); + fs.writeFileSync("pages/posts/new.tsx", "export default 1;"); + fs.mkdirSync("pages/added"); + fs.writeFileSync("pages/added/b.tsx", "export default 1;"); + console.log(JSON.stringify({ first, second: list() })); + `, + ], + env, + cwd: root, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const first = { + "/": "pages/index.tsx", + "/a": "pages/a.tsx", + "/api.v1/z": "pages/api.v1/z.tsx", + "/ld/in-linked": "outside/linked-dir/in-linked.tsx", + "/lf": "outside/linked-file.tsx", + "/posts/[id]": "pages/posts/[id].tsx", + }; + const second = { ...first, "/added/b": "pages/added/b.tsx", "/posts/new": "pages/posts/new.tsx" }; + // The shim says that it changed a record. Without this line the test covers nothing. + expect(stderr).toContain(dtUnknownReaddir.marker); + expect({ stdout: stdout.trim(), exitCode }).toEqual({ + stdout: JSON.stringify({ + first, + second: Object.fromEntries(Object.entries(second).sort(([a], [b]) => (a < b ? -1 : 1))), + }), + exitCode: 0, + }); + }); +}); + +it("with --preserve-symlinks a route below a link is named by the link, as the resolver names it", async () => { + using dir = tempDir("fsr-preserve-symlinks", { + "fixture.ts": /* ts */ ` + import path from "path"; + const root = import.meta.dir; + const below = (file: string) => path.relative(root, file).replaceAll("\\\\", "/"); + const pages = path.join(root, "pages"); + const linked = path.join(root, "linked", "pages"); + const inside = new Bun.FileSystemRouter({ style: "nextjs", dir: pages }); + const through = new Bun.FileSystemRouter({ style: "nextjs", dir: linked }); + const report = async () => { + const [x, w] = [inside.match("/ln/x")!, through.match("/w")!]; + return { + routes: [Object.values(inside.routes).map(below).sort(), Object.values(through.routes).map(below)], + src: [x.src, w.src], + resolver: [below(Bun.resolveSync("./ln/x.tsx", pages)), below(Bun.resolveSync("./w.tsx", linked))], + oneModule: [ + (await import(x.filePath)) === (await import(path.join(pages, "ln", "x.tsx"))), + (await import(w.filePath)) === (await import(path.join(linked, "w.tsx"))), + ], + }; + }; + const first = await report(); + inside.reload(); + through.reload(); + console.log(JSON.stringify({ first, reloaded: await report() })); + `, + "pages/a.tsx": page, + "elsewhere/x.tsx": page, + "real/pages/w.tsx": page, + }); + const root = String(dir); + fs.symlinkSync(path.join(root, "elsewhere"), path.join(root, "pages", "ln"), "junction"); + fs.symlinkSync(path.join(root, "real"), path.join(root, "linked"), "junction"); + // A link back to a directory the walk is inside of, by another name than the walk has for it. + fs.symlinkSync(path.join(root, "pages"), path.join(root, "elsewhere", "back"), "junction"); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--preserve-symlinks", "fixture.ts"], + env: bunEnv, + cwd: root, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const report = { + routes: [["pages/a.tsx", "pages/ln/x.tsx"], ["linked/pages/w.tsx"]], + src: ["ln/x.tsx", "w.tsx"], + resolver: ["pages/ln/x.tsx", "linked/pages/w.tsx"], + oneModule: [true, true], + }; + expect({ stdout: stdout.trim(), stderr, exitCode, signalCode: proc.signalCode }).toEqual({ + stdout: JSON.stringify({ first: report, reloaded: report }), + stderr: "", + exitCode: 0, + signalCode: null, + }); +}); + +it.each([".", ""])("reload() of dir %j makes the resolver read the current directory again", async cwd => { + using dir = tempDir("fsr-reload-cwd", { "x.tsx": page, "sub/y.tsx": page }); + const root = String(dir); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const fs = require("fs"); + const path = require("path"); + const router = new Bun.FileSystemRouter({ style: "nextjs", dir: process.argv[1] }); + const ask = () => ["./x", "./sub/y"].map(specifier => path.basename(Bun.resolveSync(specifier, process.cwd()))); + const before = ask(); + fs.renameSync("x.tsx", "x.ts"); + fs.renameSync("sub/y.tsx", "sub/y.ts"); + router.reload(); + console.log(JSON.stringify({ before, after: ask(), routes: Object.keys(router.routes).sort() })); + `, + cwd, + ], + env: bunEnv, + // The resolver spells a Windows path with the drive letter in uppercase and the rest as it is given. + cwd: isWindows ? root.toLowerCase() : root, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: JSON.stringify({ before: ["x.tsx", "y.tsx"], after: ["x.ts", "y.ts"], routes: ["/sub/y", "/x"] }), + stderr: "", + exitCode: 0, + }); +}); + +// A name on Windows has no backslash. +it.skipIf(isWindows)("a backslash in a name is a part of the name", () => { + using dir = tempDir("fsr-backslash", { "pages/a/b.tsx": "slash" }); + const root = String(dir); + fs.mkdirSync(path.join(root, "pages", "mid\\dle")); + fs.writeFileSync(path.join(root, "pages", "a\\b.tsx"), "backslash"); + fs.writeFileSync(path.join(root, "pages", "mid\\dle", "y.tsx"), "below a backslash"); + const router = routerOver(path.join(root, "pages")); + const read = Object.fromEntries( + Object.entries(router.routes).map(([name, file]) => [ + name, + [path.relative(root, file), fs.readFileSync(file, "utf8")], + ]), + ); + expect({ read, match: path.relative(root, router.match("/a%5Cb")!.filePath) }).toEqual({ + read: { + "/a\\b": ["pages/a\\b.tsx", "backslash"], + "/a/b": ["pages/a/b.tsx", "slash"], + "/mid\\dle/y": ["pages/mid\\dle/y.tsx", "below a backslash"], + }, + match: "pages/a\\b.tsx", + }); +}); + +// The length of a path is 4,096 bytes here. Elsewhere the fixture cannot be made with these calls. +it.skipIf(!isLinux)("a directory whose real path is too long to name is an error as dir and left out below dir", () => { + using dir = tempDir("fsr-unnameable", { "pages/top.tsx": page }); + const root = String(dir); + // Two links keep each path that a call gets short. The real path of `unnameable` has 4,400 bytes. + const half = Array(11).fill(Buffer.alloc(200, "s").toString()).join("/"); + fs.mkdirSync(path.join(root, half), { recursive: true }); + fs.symlinkSync(path.join(root, half), path.join(root, "l1")); + fs.mkdirSync(path.join(root, "l1", half), { recursive: true }); + fs.symlinkSync(path.join("l1", half), path.join(root, "unnameable")); + fs.writeFileSync(path.join(root, "unnameable", "a.tsx"), page); + fs.symlinkSync("../unnameable", path.join(root, "pages", "dir")); + fs.symlinkSync("../unnameable/a.tsx", path.join(root, "pages", "file.tsx")); + + let error: { code?: string; path?: string } = {}; + try { + routerOver(path.join(root, "unnameable")); + } catch (e) { + error = e as typeof error; + } + expect({ + asDir: { code: error.code, path: error.path }, + belowDir: routeNames(routerOver(path.join(root, "pages"))), + }).toEqual({ + asDir: { code: "ENAMETOOLONG", path: path.join(root, "unnameable") }, + belowDir: ["/top"], + }); +}); From ff46e20431c9b234e939aa24738cd24f30614e6f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:26:11 +0000 Subject: [PATCH 2/7] bun test: free the list of test files before exit, and test fixes `TestCommand::exec` ends with `global_exit()`, which does not return, so the end of the function never freed the list of test files. LeakSanitizer reported the list on the ASAN lane of this branch. `exec` now drops it before the exit. Remove `Entry::dir` from the resolver: the router was its last caller. Tests: the fixture of the directory with a long real path is a chain of links with short targets, because a file system can refuse a long link target. The tests that only start a child process are concurrent. The lookups that must miss assert the error code. --- src/resolver/fs.rs | 6 --- src/runtime/cli/test_command.rs | 2 + test/js/bun/util/filesystem_router.test.ts | 49 ++++++++++++---------- 3 files changed, 30 insertions(+), 27 deletions(-) diff --git a/src/resolver/fs.rs b/src/resolver/fs.rs index e26790b6fcdf..6c60ff516bc2 100644 --- a/src/resolver/fs.rs +++ b/src/resolver/fs.rs @@ -179,12 +179,6 @@ impl Entry { self.base_lowercase_.slice() } - /// Interned in DirnameStore. - #[inline] - pub fn dir(&self) -> &'static [u8] { - self.dir - } - /// Returns `Interned::EMPTY` until a fill has been published. Lock-free. #[inline] pub fn abs_path(&self) -> Interned { diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index 7d5dd0eed8cc..65962d5c112b 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -2758,6 +2758,8 @@ impl TestCommand { jest::Jest::RUNNER.write(None); } drop(reporter); + // `global_exit()` does not return, so the end of this function never frees the list. + drop(all_test_files); { let vm_ptr: *mut VirtualMachine = vm; // SAFETY: `vm_ptr` reborrows the live `&mut VirtualMachine`; diff --git a/test/js/bun/util/filesystem_router.test.ts b/test/js/bun/util/filesystem_router.test.ts index 3b0076242890..f7ae9060b610 100644 --- a/test/js/bun/util/filesystem_router.test.ts +++ b/test/js/bun/util/filesystem_router.test.ts @@ -1354,6 +1354,9 @@ const routesBelow = (router: FileSystemRouter, root: string) => .sort(([a], [b]) => (a < b ? -1 : 1)) .map(([name, filePath]) => [name, path.relative(root, filePath).replaceAll("\\", "/")]), ); +/** What `import()` and `Bun.resolveSync()` throw for a module that is not there, and what `require()` throws. */ +const notFound = expect.objectContaining({ code: "ERR_MODULE_NOT_FOUND" }); +const requireNotFound = expect.objectContaining({ code: "MODULE_NOT_FOUND" }); describe("a new router reads the directory from disk", () => { // Each of these leaves a record of `pages` in the resolver's directory cache. @@ -1466,12 +1469,12 @@ describe("a directory that the resolver looked for before it existed", () => { expect(() => Bun.resolveSync("./blog/deep/post", pages)).toThrow(notFound), ], "require()": pages => [ - expect(() => require(path.join(pages, "blog", "post.tsx"))).toThrow(), - expect(() => require(path.join(pages, "blog", "deep", "post.tsx"))).toThrow(), + expect(() => require(path.join(pages, "blog", "post.tsx"))).toThrow(requireNotFound), + expect(() => require(path.join(pages, "blog", "deep", "post.tsx"))).toThrow(requireNotFound), ], "import()": async pages => [ - await expect(import(path.join(pages, "blog", "post.tsx"))).rejects.toThrow(), - await expect(import(path.join(pages, "blog", "deep", "post.tsx"))).rejects.toThrow(), + await expect(import(path.join(pages, "blog", "post.tsx"))).rejects.toThrow(notFound), + await expect(import(path.join(pages, "blog", "deep", "post.tsx"))).rejects.toThrow(notFound), ], }; const expected = { "/a": "a.tsx", "/blog/deep/post": "blog/deep/post.tsx", "/blog/post": "blog/post.tsx" }; @@ -1502,7 +1505,7 @@ describe("a directory that the resolver looked for before it existed", () => { }); }); -describe("bun --hot", () => { +describe.concurrent("bun --hot", () => { /** Runs `server.js` under `--hot`, calls `change` after its first line, and returns all it wrote when it exits. */ async function run(root: string, firstLine: string, change: () => void) { await using runner = Bun.spawn({ @@ -1678,7 +1681,6 @@ it("routes and match() keep the scan they came from until reload()", () => { }); }); -const notFound = expect.objectContaining({ code: "ERR_MODULE_NOT_FOUND" }); /** What the resolver answers for `specifier` from `from`: a path below `from`, or "not found". */ const resolved = (specifier: string, from: string) => { try { @@ -1999,7 +2001,7 @@ it("a dir that is a file, is below a file, is a link to itself or has a name too }); // The owner of a file reads it on Windows whatever its mode is. -it.skipIf(isWindows)("an unreadable dir is an error, and what is unreadable below it is left out", async () => { +it.concurrent.skipIf(isWindows)("an unreadable dir is an error, what is unreadable below it is left out", async () => { using dir = tempDir("fsr-no-access", { "locked/a.tsx": page, "pages/a.tsx": page, @@ -2074,7 +2076,7 @@ it.skipIf(isWindows)("an unreadable dir is an error, and what is unreadable belo }); // ulimit: the limit of a Windows process is not one a shell sets. -it.skipIf(isWindows)("a scan with no file descriptor left is an error, not a table with routes missing", async () => { +it.concurrent.skipIf(isWindows)("a scan with no file descriptor left is an error, not a partial table", async () => { using dir = tempDir("fsr-no-descriptor", { "pages/a.tsx": page, "pages/sub/b.tsx": page, @@ -2187,7 +2189,7 @@ it("skips a link to nothing", () => { }); // mkfifo: Windows has no FIFO in a directory. -it.skipIf(isWindows)("skips a FIFO and a link to a FIFO", async () => { +it.concurrent.skipIf(isWindows)("skips a FIFO and a link to a FIFO", async () => { using dir = tempDir("fsr-not-a-file", { "pages/a.tsx": page }); const root = String(dir); const pages = path.join(root, "pages"); @@ -2241,7 +2243,7 @@ it("a link that leads back into the tree is not followed again", () => { }); // On Windows the bound is what 16 bits count and not the length of a path: 1,285 junctions deep. -it.skipIf(isWindows)("a chain of links ends where a route name is as long as a path can be", async () => { +it.concurrent.skipIf(isWindows)("a chain of links ends where a route name is as long as a path can be", async () => { // Each link adds 51 bytes to the name of a route and leads to a directory with a short path, // so the length of a path does not end the walk. The whole chain makes a name of 71,400 bytes. using dir = tempDir("fsr-link-chain", { @@ -2407,7 +2409,7 @@ describe("two files for one route", () => { }); }); -describe("dir is reached through a link", () => { +describe.concurrent("dir is reached through a link", () => { async function run(fixture: string, files: Record, link: [target: string, name: string]) { using dir = tempDir("fsr-linked-dir", { "fixture.ts": fixture, ...files }); fs.symlinkSync(path.join(String(dir), link[0]), path.join(String(dir), link[1]), "junction"); @@ -2480,7 +2482,7 @@ describe("dir is reached through a link", () => { }); }); -it("reload() returns when the resolver has dir cached without a trailing separator", async () => { +it.concurrent("reload() returns when the resolver has dir cached without a trailing separator", async () => { // The failed resolve and the router over `sub` leave `pages` cached without a trailing separator. using dir = tempDir("fsr-reload-failed-resolve", { "fixture.ts": /* ts */ ` @@ -2516,7 +2518,7 @@ it("reload() returns when the resolver has dir cached without a trailing separat }).toEqual({ stdout: "/ /b /sub/c | / /b /sub/c /", stderr: "", exitCode: 0, signalCode: null }); }); -it("after reload(), the resolver reads again each directory that the scan read", async () => { +it.concurrent("after reload(), the resolver reads again each directory that the scan read", async () => { using dir = tempDir("fsr-reload-resolver", { "fixture.ts": /* ts */ ` import fs from "fs"; @@ -2688,7 +2690,7 @@ describe.skipIf(!dtUnknownReaddir.available)("on a file system whose readdir rep }); }); -it("with --preserve-symlinks a route below a link is named by the link, as the resolver names it", async () => { +it.concurrent("with --preserve-symlinks a route below a link is named by the link, as the resolver does", async () => { using dir = tempDir("fsr-preserve-symlinks", { "fixture.ts": /* ts */ ` import path from "path"; @@ -2747,7 +2749,7 @@ it("with --preserve-symlinks a route below a link is named by the link, as the r }); }); -it.each([".", ""])("reload() of dir %j makes the resolver read the current directory again", async cwd => { +it.concurrent.each([".", ""])("reload() of dir %j makes the resolver read the current directory again", async cwd => { using dir = tempDir("fsr-reload-cwd", { "x.tsx": page, "sub/y.tsx": page }); const root = String(dir); await using proc = Bun.spawn({ @@ -2809,12 +2811,17 @@ it.skipIf(isWindows)("a backslash in a name is a part of the name", () => { it.skipIf(!isLinux)("a directory whose real path is too long to name is an error as dir and left out below dir", () => { using dir = tempDir("fsr-unnameable", { "pages/top.tsx": page }); const root = String(dir); - // Two links keep each path that a call gets short. The real path of `unnameable` has 4,400 bytes. - const half = Array(11).fill(Buffer.alloc(200, "s").toString()).join("/"); - fs.mkdirSync(path.join(root, half), { recursive: true }); - fs.symlinkSync(path.join(root, half), path.join(root, "l1")); - fs.mkdirSync(path.join(root, "l1", half), { recursive: true }); - fs.symlinkSync(path.join("l1", half), path.join(root, "unnameable")); + // A chain of links keeps each path and each link target short: a file system can limit a target to 1,023 bytes. + // Each link adds a directory of 200 bytes, so the real path of `unnameable` has 4,400 bytes. + const name = Buffer.alloc(200, "s").toString(); + let deepest = ""; + for (let i = 1; i <= 22; i++) { + const target = path.join(deepest, name); + fs.mkdirSync(path.join(root, target)); + deepest = "l" + i; + fs.symlinkSync(target, path.join(root, deepest)); + } + fs.symlinkSync(deepest, path.join(root, "unnameable")); fs.writeFileSync(path.join(root, "unnameable", "a.tsx"), page); fs.symlinkSync("../unnameable", path.join(root, "pages", "dir")); fs.symlinkSync("../unnameable/a.tsx", path.join(root, "pages", "file.tsx")); From c744a3108714ee09d104eb02c7e109020be70401 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:30:24 +0000 Subject: [PATCH 3/7] test: one case for each place of a config file that does not parse The resolver logs the parse error of a tsconfig.json or package.json only when it first reads the directory of the file. On main the first `new Bun.FileSystemRouter()` then throws the resolver's BuildMessage and the next one returns the routes. The test has one case for each place of the file: above `dir`, in `dir` (tsconfig.json and package.json) and below `dir`. Each case compares the first router, a second router and the first one after `reload()`. --- test/js/bun/util/filesystem_router.test.ts | 29 ++++++++++++++-------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/test/js/bun/util/filesystem_router.test.ts b/test/js/bun/util/filesystem_router.test.ts index 7e3b54bd8732..74dd093a5559 100644 --- a/test/js/bun/util/filesystem_router.test.ts +++ b/test/js/bun/util/filesystem_router.test.ts @@ -2287,16 +2287,25 @@ it.concurrent.skipIf(isWindows)("a chain of links ends where a route name is as }); }); -it("a tsconfig.json or package.json that does not parse is not the router's error", () => { - using dir = tempDir("fsr-broken-config", { - "tsconfig.json": `{ "compilerOptions": { "paths": `, - "pages/a.tsx": page, - "pages/package.json": `{ "name": `, - "pages/sub/b.tsx": page, - "pages/sub/tsconfig.json": `{ "compilerOptions": `, - }); - expect(routeNames(routerOver(path.join(String(dir), "pages")))).toEqual(["/a", "/sub/b"]); -}); +// The resolver logs the parse error of such a file only when it first reads the directory of the file. +it.each(["tsconfig.json", "pages/tsconfig.json", "pages/package.json", "pages/sub/tsconfig.json"])( + "a %s that does not parse is not the router's error", + file => { + using dir = tempDir("fsr-broken-config", { + "pages/a.tsx": page, + "pages/sub/b.tsx": page, + [file]: `{"compilerOptions": {`, + }); + const pages = path.join(String(dir), "pages"); + const first = routerOver(pages); + const second = routerOver(pages); + first.reload(); + expect({ first: routeNames(first), second: routeNames(second) }).toEqual({ + first: ["/a", "/sub/b"], + second: ["/a", "/sub/b"], + }); + }, +); it("reload() and new routers in a loop keep no memory of the scans before", async () => { const files: Record = {}; From fa8ab08ab4273485ff2ba52b4080cd2f56db2963 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:46:41 +0000 Subject: [PATCH 4/7] test: run the save-by-rename cases on macOS too With kqueue the event of the file itself reloads a module that was saved by a rename. The cases stay off on Windows, where a rename over a file that exists can fail with EPERM. Also correct a comment in `reload()`: the inotify and kqueue reloaders read the resolver's record of a directory on a directory event. --- src/runtime/api/filesystem_router.rs | 2 +- test/js/bun/util/filesystem_router.test.ts | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/runtime/api/filesystem_router.rs b/src/runtime/api/filesystem_router.rs index 4a72d138bcf5..e0e5205c564e 100644 --- a/src/runtime/api/filesystem_router.rs +++ b/src/runtime/api/filesystem_router.rs @@ -300,7 +300,7 @@ impl FileSystemRouter { // The resolver reads again each directory that the last scan or this one read, under each of its names. let dir = strings::paths::without_trailing_slash_windows_path(&self.config.dir); - // With inotify and kqueue the reloader finds the module of a saved file through the resolver's record of its directory. + // On a directory event the inotify and kqueue reloaders find the watched modules of that directory through the resolver's record of it. #[cfg(unix)] let watcher = import_watcher(); #[cfg(unix)] diff --git a/test/js/bun/util/filesystem_router.test.ts b/test/js/bun/util/filesystem_router.test.ts index 74dd093a5559..45217b7f9134 100644 --- a/test/js/bun/util/filesystem_router.test.ts +++ b/test/js/bun/util/filesystem_router.test.ts @@ -1557,8 +1557,9 @@ describe.concurrent("bun --hot", () => { expect(result).toEqual({ stdout: "run 1: / /blog/a\nrun 2: / /blog/a /blog/b /new\n", exitCode: 0 }); }); - // With inotify the reloader finds the module of a renamed file through the resolver's record of its directory, - // which reload() drops. Each case saves `saved` by a rename after the first run. + // Each case saves `saved` by a rename after the first run. With inotify that is an event of the directory, and the + // reloader finds the module through the resolver's record of the directory, which reload() drops. With kqueue the + // event of the file itself reloads the module. const router = `new Bun.FileSystemRouter({ style: "nextjs", dir: import.meta.dir + "/pages", fileExtensions: [".tsx", ".js"] })`; const cases: Record; saved: string; link?: [string, string] }> = { "a route after reload()": { @@ -1615,7 +1616,8 @@ describe.concurrent("bun --hot", () => { saved: "pages/blog/post.tsx", }, }; - it.skipIf(!isLinux).each(Object.keys(cases))("a save by a rename reloads %s", async name => { + // A rename over a file that exists can fail with EPERM on Windows. + it.skipIf(isWindows).each(Object.keys(cases))("a save by a rename reloads %s", async name => { const { files, saved, link } = cases[name]; files["server.js"] += ` console.log("version " + imported.default); From ac2a0af80b87191d138d558f0075eb0555e6f2b0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:27:33 +0000 Subject: [PATCH 5/7] router: drop six unused dependencies, assert stderr in the --hot tests After the rewrite `src/router/lib.rs` uses none of thiserror, bun_errno, bun_alloc, scopeguard, bun_resolver and bun_wyhash. `Cargo.lock` loses the same six entries of `bun_router`. The `bun --hot` test helper pipes the child's stderr and returns it, so that a failing case shows the child's error text in the assertion. A child with another first line is killed and not waited for. --- Cargo.lock | 6 ------ src/router/Cargo.toml | 9 --------- test/js/bun/util/filesystem_router.test.ts | 21 +++++++++++++++------ 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3c54139bb8c8..d1ff8365ef20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1558,21 +1558,15 @@ name = "bun_router" version = "0.0.0" dependencies = [ "bstr", - "bun_alloc", "bun_ast", "bun_collections", "bun_core", - "bun_errno", "bun_http_types", "bun_paths", "bun_ptr", - "bun_resolver", "bun_sys", "bun_url", - "bun_wyhash", - "scopeguard", "strum", - "thiserror", ] [[package]] diff --git a/src/router/Cargo.toml b/src/router/Cargo.toml index ad54988e8aab..be71bd046539 100644 --- a/src/router/Cargo.toml +++ b/src/router/Cargo.toml @@ -10,22 +10,13 @@ path = "lib.rs" workspace = true [dependencies] -thiserror.workspace = true -bun_errno.workspace = true -bun_alloc.workspace = true strum.workspace = true bstr.workspace = true -scopeguard.workspace = true bun_core.workspace = true bun_collections.workspace = true bun_ast.workspace = true bun_http_types.workspace = true bun_paths.workspace = true bun_ptr.workspace = true -# `bun_resolver` is acyclic from this crate (resolver does not depend on -# router); router consumes the real `bun_resolver::fs::{FileSystem,DirEntry}` -# now that the `bun_sys::fs::DirEntry` opaque-seam workaround is gone. -bun_resolver.workspace = true bun_sys.workspace = true bun_url.workspace = true -bun_wyhash.workspace = true diff --git a/test/js/bun/util/filesystem_router.test.ts b/test/js/bun/util/filesystem_router.test.ts index 45217b7f9134..20ac2fe03b26 100644 --- a/test/js/bun/util/filesystem_router.test.ts +++ b/test/js/bun/util/filesystem_router.test.ts @@ -1520,9 +1520,11 @@ describe.concurrent("bun --hot", () => { env: bunEnv, cwd: root, stdout: "pipe", - stderr: "inherit", + stderr: "pipe", stdin: "ignore", }); + // Read from the start, so that a full pipe does not stop the child. + const stderr = runner.stderr.text(); const reader = runner.stdout.getReader(); const decoder = new TextDecoder(); let stdout = ""; @@ -1531,14 +1533,17 @@ describe.concurrent("bun --hot", () => { if (done) break; stdout += decoder.decode(value, { stream: true }); } - expect(stdout).toBe(firstLine); - change(); + // A child with another first line does not exit by itself. The caller's assertion then shows what it wrote. + if (stdout === firstLine) change(); + else runner.kill(); for (;;) { const { value, done } = await reader.read(); if (done) break; stdout += decoder.decode(value, { stream: true }); } - return { stdout, exitCode: await runner.exited }; + // A debug build logs each reload. + const errors = (await stderr).replaceAll("DEBUG: Reloading...\n", ""); + return { stdout, stderr: errors, exitCode: await runner.exited }; } it("a module-level router lists the pages added since the last run", async () => { @@ -1554,7 +1559,11 @@ describe.concurrent("bun --hot", () => { fs.writeFileSync(path.join(root, "pages", "blog", "b.tsx"), page); fs.writeFileSync(path.join(root, "server.js"), server(2)); }); - expect(result).toEqual({ stdout: "run 1: / /blog/a\nrun 2: / /blog/a /blog/b /new\n", exitCode: 0 }); + expect(result).toEqual({ + stdout: "run 1: / /blog/a\nrun 2: / /blog/a /blog/b /new\n", + stderr: "", + exitCode: 0, + }); }); // Each case saves `saved` by a rename after the first run. With inotify that is an event of the directory, and the @@ -1629,7 +1638,7 @@ describe.concurrent("bun --hot", () => { const result = await run(root, "version 1\n", () => { fs.renameSync(path.join(root, "saved"), path.join(root, saved)); }); - expect(result).toEqual({ stdout: "version 1\nversion 2\n", exitCode: 0 }); + expect(result).toEqual({ stdout: "version 1\nversion 2\n", stderr: "", exitCode: 0 }); }); }); From db185c2f3bf4e84000ca309382d0db1fe85bcd32 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:39:28 +0000 Subject: [PATCH 6/7] test: build the --hot fixture of each run without changing the table of cases The body of `a save by a rename reloads %s` appended its suffix to the `server.js` string of the shared case. A second run of the body in the same process, such as a retry, then wrote the suffix twice and could not pass. --- test/js/bun/util/filesystem_router.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/js/bun/util/filesystem_router.test.ts b/test/js/bun/util/filesystem_router.test.ts index 20ac2fe03b26..469a5b053fcf 100644 --- a/test/js/bun/util/filesystem_router.test.ts +++ b/test/js/bun/util/filesystem_router.test.ts @@ -1628,11 +1628,11 @@ describe.concurrent("bun --hot", () => { // A rename over a file that exists can fail with EPERM on Windows. it.skipIf(isWindows).each(Object.keys(cases))("a save by a rename reloads %s", async name => { const { files, saved, link } = cases[name]; - files["server.js"] += ` + const server = `${files["server.js"]} console.log("version " + imported.default); if (imported.default === 2) process.exit(0); `; - using dir = tempDir("fsr-hot-reload", { ...files, "saved": "export default 2;\n" }); + using dir = tempDir("fsr-hot-reload", { ...files, "server.js": server, "saved": "export default 2;\n" }); const root = String(dir); if (link) fs.symlinkSync(path.join(root, link[0]), path.join(root, link[1])); const result = await run(root, "version 1\n", () => { From 3477adc2b2f7f19088d0968c99df9df4f4640ef1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:51:08 +0000 Subject: [PATCH 7/7] router: a link loop or a long name as `dir` is a system error `Routes::scan` reported `Unable to find directory` for a `dir` whose open failed with ELOOP or ENAMETOOLONG. Only ENOENT and ENOTDIR say that no directory is there. Every other error of the open is now thrown with its code, syscall and path. Tests: - The catch blocks around the resolver and around the descriptor loop let only the expected error code pass. - A new router's route for a file that was added after the resolver read the directory is imported, for each way the resolver can read it. - With --preserve-symlinks a linked route file has its target's name, as the resolver names it. --- src/router/lib.rs | 9 ++- test/js/bun/util/filesystem_router.test.ts | 83 ++++++++++++++++++---- 2 files changed, 78 insertions(+), 14 deletions(-) diff --git a/src/router/lib.rs b/src/router/lib.rs index 451bfca7c515..6fa691c1524f 100644 --- a/src/router/lib.rs +++ b/src/router/lib.rs @@ -148,7 +148,7 @@ impl Routes { ) -> bun_sys::Result> { let root = match bun_sys::Dir::open(dir) { Ok(root) => root, - Err(err) if is_absent(&err) => return Ok(None), + Err(err) if is_no_directory(&err) => return Ok(None), Err(err) => return Err(err.with_path(dir)), }; let real_dir: Box<[u8]> = { @@ -158,7 +158,7 @@ impl Routes { let listing = match DirListing::read(root, dir) { Ok(listing) => listing, // Removed after it was opened. - Err(err) if is_absent(&err) => return Ok(None), + Err(err) if is_no_directory(&err) => return Ok(None), Err(err) => return Err(err), }; @@ -357,6 +357,11 @@ impl WalkedDir { } } +/// Whether `err` says that no directory is at the path: nothing is there, or a file is. +fn is_no_directory(err: &bun_sys::Error) -> bool { + matches!(err.get_errno(), E::ENOENT | E::ENOTDIR) +} + /// Whether `err` says that there is nothing at the path. fn is_absent(err: &bun_sys::Error) -> bool { matches!( diff --git a/test/js/bun/util/filesystem_router.test.ts b/test/js/bun/util/filesystem_router.test.ts index 469a5b053fcf..7b3bed31754a 100644 --- a/test/js/bun/util/filesystem_router.test.ts +++ b/test/js/bun/util/filesystem_router.test.ts @@ -1074,7 +1074,8 @@ it("reload() while Bun.build() resolves the same directory", async () => { if (Bun.resolveSync("./sub/s7.tsx", pagesDir).endsWith("s7.tsx")) resolved++; try { Bun.resolveSync("./missing.tsx", pagesDir); - } catch { + } catch (e) { + if (e.code !== "ERR_MODULE_NOT_FOUND") throw e; resolved++; } } @@ -1381,7 +1382,10 @@ describe("a new router reads the directory from disk", () => { fs.writeFileSync(path.join(pages, "new.tsx"), page); fs.rmSync(path.join(pages, "old.tsx")); - expect(routesBelow(routerOver(pages), pages)).toEqual({ "/a": "a.tsx", "/new": "new.tsx" }); + const router = routerOver(pages); + expect(routesBelow(router, pages)).toEqual({ "/a": "a.tsx", "/new": "new.tsx" }); + // The resolver still has its record of `pages`. It reads the directory again when it misses a file. + expect((await import(router.routes["/new"])).default).toBe(1); }); it("a file added to and a file removed from a subdirectory", () => { @@ -1703,7 +1707,8 @@ it("routes and match() keep the scan they came from until reload()", () => { const resolved = (specifier: string, from: string) => { try { return path.relative(from, Bun.resolveSync(specifier, from)).replaceAll("\\", "/"); - } catch { + } catch (e) { + if ((e as { code?: string }).code !== "ERR_MODULE_NOT_FOUND") throw e; return "not found"; } }; @@ -1998,15 +2003,10 @@ it("a route with uppercase letters keeps its case and matches a lowercase path", }); }); -it("a dir that is a file, is below a file, is a link to itself or has a name too long is not found", () => { +it("a dir that is a file or is below a file is not found", () => { using dir = tempDir("fsr-root-absent", { "file.tsx": page }); const root = String(dir); const absent = [path.join(root, "file.tsx"), path.join(root, "file.tsx", "below")]; - // Windows has other errors for these two, and a junction does not lead to itself. - if (!isWindows) { - fs.symlinkSync("loop", path.join(root, "loop")); - absent.push(path.join(root, "loop"), path.join(root, Buffer.alloc(300, "a").toString())); - } const messages = absent.map(pages => { try { routerOver(pages); @@ -2018,6 +2018,24 @@ it("a dir that is a file, is below a file, is a link to itself or has a name too expect(messages).toEqual(absent.map(pages => `Error: Unable to find directory: ${pages}`)); }); +// Windows has other errors for these two, and a junction does not lead to itself. +it.skipIf(isWindows)("a dir that is a link to itself or has a name too long is a system error", () => { + using dir = tempDir("fsr-root-error", {}); + const root = String(dir); + fs.symlinkSync("loop", path.join(root, "loop")); + const dirs = { ELOOP: path.join(root, "loop"), ENAMETOOLONG: path.join(root, Buffer.alloc(300, "a").toString()) }; + const errors = Object.values(dirs).map(pages => { + try { + routerOver(pages); + return "no error"; + } catch (e) { + const error = e as { code?: string; syscall?: string; path?: string }; + return { code: error.code, syscall: error.syscall, path: error.path }; + } + }); + expect(errors).toEqual(Object.entries(dirs).map(([code, pages]) => ({ code, syscall: "open", path: pages }))); +}); + // The owner of a file reads it on Windows whatever its mode is. it.concurrent.skipIf(isWindows)("an unreadable dir is an error, what is unreadable below it is left out", async () => { using dir = tempDir("fsr-no-access", { @@ -2106,7 +2124,9 @@ it.concurrent.skipIf(isWindows)("a scan with no file descriptor left is an error const held: number[] = []; try { for (;;) held.push(fs.openSync(import.meta.path, "r")); - } catch {} + } catch (e) { + if (e.code !== "EMFILE") throw e; + } const codes: string[] = []; for (const scan of [() => router.reload(), () => new Bun.FileSystemRouter(options)]) { try { @@ -2517,7 +2537,9 @@ it.concurrent("reload() returns when the resolver has dir cached without a trail const pagesDir = path.join(import.meta.dir, "pages"); try { Bun.resolveSync("./does-not-exist.ts", pagesDir); - } catch {} + } catch (e) { + if (e.code !== "ERR_MODULE_NOT_FOUND") throw e; + } new Bun.FileSystemRouter({ dir: path.join(pagesDir, "sub"), style: "nextjs", fileExtensions: [".tsx"] }); const router = new Bun.FileSystemRouter({ dir: pagesDir, style: "nextjs", fileExtensions: [".tsx"] }); const before = Object.keys(router.routes).sort().join(" "); @@ -2558,7 +2580,8 @@ it.concurrent("after reload(), the resolver reads again each directory that the const resolve = (specifier: string, from: string) => { try { return path.relative(root, Bun.resolveSync(specifier, from)).replaceAll("\\\\", "/"); - } catch { + } catch (e) { + if (e.code !== "ERR_MODULE_NOT_FOUND") throw e; return "not found"; } }; @@ -2810,6 +2833,42 @@ it.concurrent.each([".", ""])("reload() of dir %j makes the resolver read the cu }); }); +// The resolver names a linked file by its target with and without the flag. The flag is for directories. +it.concurrent.skipIf(!canLinkToFile)("with --preserve-symlinks a linked route file has its target's name", async () => { + using dir = tempDir("fsr-preserve-file-link", { + "fixture.ts": /* ts */ ` + import path from "path"; + const root = import.meta.dir; + const below = (file: string) => path.relative(root, file).replaceAll("\\\\", "/"); + const pages = path.join(root, "pages"); + const route = new Bun.FileSystemRouter({ style: "nextjs", dir: pages }).match("/lf")!; + console.log(JSON.stringify({ + route: below(route.filePath), + resolver: below(Bun.resolveSync("./lf.tsx", pages)), + oneModule: (await import(route.filePath)) === (await import(path.join(pages, "lf.tsx"))), + })); + `, + "pages/a.tsx": page, + "elsewhere/x.tsx": page, + }); + const root = String(dir); + fs.symlinkSync(path.join(root, "elsewhere", "x.tsx"), path.join(root, "pages", "lf.tsx"), "file"); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--preserve-symlinks", "fixture.ts"], + env: bunEnv, + cwd: root, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: JSON.stringify({ route: "elsewhere/x.tsx", resolver: "elsewhere/x.tsx", oneModule: true }), + stderr: "", + exitCode: 0, + }); +}); + // A name on Windows has no backslash. it.skipIf(isWindows)("a backslash in a name is a part of the name", () => { using dir = tempDir("fsr-backslash", { "pages/a/b.tsx": "slash" });