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/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 c53fad77675d..712724f47d4f 100644 --- a/scripts/rust-miri.ts +++ b/scripts/rust-miri.ts @@ -52,6 +52,7 @@ const MIRI_CRATES = [ "bun_pod", "bun_ptr", "bun_resolve_builtins", + "bun_router", "bun_semver", "bun_shell_parser", "bun_threading", diff --git a/src/bun_alloc/lib.rs b/src/bun_alloc/lib.rs index 3814a283573a..b8d9a40f6abc 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 @@ -2033,52 +2031,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 e29491b3abe7..209172cbb9b0 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 a10883e9cdea..407bafca2ba0 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/fs.rs b/src/resolver/fs.rs index a976fb4403d2..dfaaf66caf8d 100644 --- a/src/resolver/fs.rs +++ b/src/resolver/fs.rs @@ -180,12 +180,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/resolver/lib.rs b/src/resolver/lib.rs index 661bd096c9a7..08d89376455c 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/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/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 a6bc7d0a7c15..6fa691c1524f 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_no_directory(&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_no_directory(&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,160 @@ 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 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!( + 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 +506,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,98 +581,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 { - // SAFETY: EntryStore-owned, valid for process lifetime. - let entry: &Fs::Entry = unsafe { &*entry_ptr }; - if entry.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; + }; - match entry.kind(&fs.fs, false) { - 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()]; - - let route = Route::parse( - entry.base(), - extname, - entry, - 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(()) } } @@ -658,292 +755,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 + } + /// `public_dir`: the directory of the file below the routes directory, empty or `/blog/[slug]`. pub(crate) fn parse( base_: &[u8], extname: &[u8], - entry: &Fs::Entry, + abs_path: &[u8], + public_dir: &[u8], log: &mut bun_ast::Log, - public_dir_: &[u8], - routes_dirname_len: u16, ) -> Option { let base = &base_[0..base_.len() - extname.len()]; + debug_assert!(!base.is_empty()); - // The public path and the longer absolute path are both built in `route_file_buf`. - let dir = strings::without_trailing_slash(entry.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::get().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.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.cache().fd.unwrap_valid() { - *file = Some(bun_sys::File::from_fd(valid)); - needs_close.set(false); - } else { - let parts = [entry.dir(), entry.base()]; - let abs_len = FileSystem::get().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::get() - .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::get() - .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.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, }) } } @@ -981,8 +902,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 @@ -1018,46 +939,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; - /// 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); } // ────────────────────────────────────────────────────────────────────────── @@ -1067,7 +1098,7 @@ pub trait ResolverLike { pub mod pattern { use super::*; - type RoutePathInt = u16; + pub(crate) type RoutePathInt = u16; #[derive(Clone, Copy)] pub struct Pattern { @@ -1487,6 +1518,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>; @@ -1701,12 +1757,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 4789d81c50cf..e0e5205c564e 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,26 +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::get() - } - #[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); @@ -77,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 { @@ -104,103 +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::get().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; - } - if entry.kind(&vm.fs().fs, false) == 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); } } @@ -226,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::get().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::get().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 @@ -260,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 @@ -286,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)), }) } @@ -344,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); }; @@ -375,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))) } @@ -389,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); + // 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)] + 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) } @@ -404,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 { @@ -414,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 { @@ -535,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() @@ -546,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 8886fb09657b..7b3bed31754a 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,18 @@ 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 (e) { + if (e.code !== "ERR_MODULE_NOT_FOUND") throw e; + 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 +1104,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 */ ` @@ -1319,3 +1350,1579 @@ 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("\\", "/")]), + ); +/** 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. + 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")); + 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", () => { + 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(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(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" }; + 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.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({ + cmd: [bunExe(), "--hot", "--no-clear-screen", "server.js"], + env: bunEnv, + cwd: root, + stdout: "pipe", + 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 = ""; + while (!stdout.includes("\n")) { + const { value, done } = await reader.read(); + if (done) break; + stdout += decoder.decode(value, { stream: true }); + } + // 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 }); + } + // 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 () => { + 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", + 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 + // 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()": { + 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", + }, + }; + // 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]; + const server = `${files["server.js"]} + console.log("version " + imported.default); + if (imported.default === 2) process.exit(0); + `; + 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", () => { + fs.renameSync(path.join(root, "saved"), path.join(root, saved)); + }); + expect(result).toEqual({ stdout: "version 1\nversion 2\n", stderr: "", 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: {}, + }); +}); + +/** 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 (e) { + if ((e as { code?: string }).code !== "ERR_MODULE_NOT_FOUND") throw e; + 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 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")]; + 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}`)); +}); + +// 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", { + "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.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, + "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 (e) { + if (e.code !== "EMFILE") throw e; + } + 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.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"); + 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.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", { + "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, + }); +}); + +// 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 = {}; + 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.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"); + 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.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 */ ` + import path from "path"; + const pagesDir = path.join(import.meta.dir, "pages"); + try { + Bun.resolveSync("./does-not-exist.ts", pagesDir); + } 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(" "); + 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.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"; + 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 (e) { + if (e.code !== "ERR_MODULE_NOT_FOUND") throw e; + 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.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"; + 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.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({ + 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, + }); +}); + +// 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" }); + 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); + // 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")); + + 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"], + }); +});