diff --git a/src/resolver/dir_info.rs b/src/resolver/dir_info.rs index b90cba7326e7..12bc2469921f 100644 --- a/src/resolver/dir_info.rs +++ b/src/resolver/dir_info.rs @@ -168,6 +168,16 @@ impl Default for DirInfo { } } +/// What `Resolver::real_path_of` found for an entry of a directory. +pub enum RealPath { + /// No symlink is on the path. + Same, + /// The real path: the entry is a symlink, or the resolver cached the joined path on it. + Whole(&'static [u8]), + /// Only a parent directory is a symlink: the real path is this directory plus the entry's base. + Dir(&'static [u8]), +} + /// Dereference an arena-interned `NonNull` to `&'static T`. /// /// Single deref site for the three `Option>` read accessors on diff --git a/src/resolver/fs.rs b/src/resolver/fs.rs index fc43324ca00c..782dbf081872 100644 --- a/src/resolver/fs.rs +++ b/src/resolver/fs.rs @@ -139,6 +139,7 @@ pub struct Entry { pub mutex: Mutex, pub need_stat: AtomicBool, + /// The lexical path, in the spelling of the first lookup that filled it. `Resolver::real_path_of` gives the real one. pub abs_path: Interned, } @@ -183,17 +184,6 @@ impl Entry { self.dir } - /// `Interned` is `Copy`. - #[inline] - pub fn abs_path(&self) -> Interned { - self.abs_path - } - - #[inline] - pub fn set_abs_path(&mut self, p: Interned) { - self.abs_path = p; - } - /// Stat-on-first-use. /// /// # Safety diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index ce1a731cc9a6..7814210e9500 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -36,6 +36,7 @@ pub use data_url::DataURL; /// Re-export real `DirInfo`. pub use dir_info::DirInfo; pub use dir_info::DirInfoRef; +pub use dir_info::RealPath; /// Re-export real filesystem `Path`. pub use fs::Path; /// Re-export real `PackageJSON`. diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 3fb9eb7bc952..8edbec8c5a66 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -275,6 +275,7 @@ use crate::tsconfig_json::TSConfigJSON; pub use crate::data_url::DataURL; pub use crate::dir_info as DirInfo; pub use crate::dir_info::DirInfoRef; +use crate::dir_info::RealPath; pub use ::bun_options_types::global_cache::GlobalCache; // Sibling resolver modules. They retain the same item names so cross-references @@ -1574,6 +1575,19 @@ impl<'a> Resolver<'a> { self.resolve(source_dir, import_path, kind) } + /// The real path of `entry`, which `dir` lists. The one rule, also for the route scan of `Bun.FileSystemRouter`. + pub fn real_path_of(&self, dir: &DirInfo::DirInfo, entry: &Fs::file_system::Entry) -> RealPath { + // SAFETY: `rfs_ptr` is the process-global RealFS. `symlink` locks the entry to stat it. + let symlink = unsafe { entry.symlink(self.rfs_ptr(), self.store_fd) }; + if !symlink.is_empty() { + RealPath::Whole(symlink) + } else if !dir.abs_real_path.is_empty() { + RealPath::Dir(dir.abs_real_path) + } else { + RealPath::Same + } + } + pub(crate) fn finalize_result( &mut self, result: &mut Result, @@ -1648,10 +1662,8 @@ impl<'a> Resolver<'a> { // map in place under that lock. The entry pointer stays valid after // unlock (EntryStore-owned). if let Some(query) = dir.get_entry(self.generation, name.filename) { - // SAFETY: rfs points at the process-global RealFS; the lazy-stat - // rewrite inside `symlink()` is serialized on the per-entry mutex. - let symlink_path = unsafe { query.entry().symlink(self.rfs_ptr(), self.store_fd) }; - if !symlink_path.is_empty() { + let real_path = self.real_path_of(&dir, query.entry()); + if let RealPath::Whole(symlink_path) = real_path { path.set_realpath(symlink_path); if !result.file_fd.is_valid() { result.file_fd = query.entry().cache().fd; @@ -1664,9 +1676,9 @@ impl<'a> Resolver<'a> { bstr::BStr::new(symlink_path) )); } - } else if !dir.abs_real_path.is_empty() { + } else if let RealPath::Dir(abs_real_path) = real_path { // When the directory is a symlink, we don't need to call getFdPath. - let parts = [dir.abs_real_path, query.entry().base()]; + let parts = [abs_real_path, query.entry().base()]; let mut buf = bun_paths::path_buffer_pool::get(); // NOTE: `abs_buf` returns a borrow of `buf`; capture only the @@ -6327,10 +6339,8 @@ impl<'a> Resolver<'a> { // dies (NLL) before any later `&mut` to this slot. let entry = lookup.entry(); - // SAFETY: `rfs_ptr` points at the process-global RealFS; the lazy-stat - // rewrite inside `symlink()` is serialized on `Entry.mutex`. - let mut symlink = unsafe { entry.symlink(rfs_ptr, self.store_fd) }; - if !symlink.is_empty() { + let real_path = self.real_path_of(&parent_, entry); + if let RealPath::Whole(symlink) = real_path { if let Some(logs) = self.debug_logs.as_mut() { let mut buf = Vec::new(); let _ = write!( @@ -6342,15 +6352,15 @@ impl<'a> Resolver<'a> { logs.add_note(buf); } info.abs_real_path = symlink; - } else if !parent_.abs_real_path.is_empty() { + } else if let RealPath::Dir(parent_real_path) = real_path { // this might leak a little i'm not sure - let parts = [parent_.abs_real_path, base]; + let parts = [parent_real_path, base]; // NOTE: split into two statements so the two `&mut FileSystem` // borrows from `unsafe { &mut *self.fs() }` don't overlap (Stacked Borrows). let joined = self .fs_ref() .abs_buf(&parts, bufs!(dir_info_uncached_filename)); - symlink = self + let symlink = self .fs_ref() .dirname_store .append_slice(joined) diff --git a/src/router/lib.rs b/src/router/lib.rs index 63ed477d6c53..6d50ac92bdcd 100644 --- a/src/router/lib.rs +++ b/src/router/lib.rs @@ -33,6 +33,7 @@ fn wyhash(input: &[u8]) -> u64 { // name the real `FileSystem` / `Entry` / `DirEntry` / `DirnameStore` types. use bun_resolver::DirInfo; use bun_resolver::DirInfoRef; +use bun_resolver::RealPath; use bun_resolver::fs as Fs; use bun_resolver::fs::FileSystem; @@ -579,9 +580,8 @@ impl<'a> RouteLoader<'a> { // SAFETY: no other live borrow of `*entry_ptr` here; // `resolver.fs_impl()` points at the process-global RealFS. let kind = unsafe { (&*entry_ptr).kind(resolver.fs_impl(), false) }; - // SAFETY: shared read-only borrow for the match arms; the only - // subsequent mutation is via `Route::parse` which takes the raw - // pointer and reborrows internally. + // SAFETY: EntryStore-owned, valid for process lifetime; nothing + // below takes a `&mut` to it. let entry: &Fs::Entry = unsafe { &*entry_ptr }; match kind { Fs::EntryKind::Dir => { @@ -622,18 +622,14 @@ impl<'a> RouteLoader<'a> { // SAFETY: entry.dir is at least base_dir.len()-1 bytes; verified above in debug let public_dir = &entry_dir[base_dir.len() - 1..entry_dir.len()]; - // SAFETY: `entry_ptr` is EntryStore-owned (process - // lifetime) with no other live `&mut` borrow here. - let route = unsafe { - Route::parse( - entry.base(), - extname, - entry_ptr, - self.log, - public_dir, - self.route_dirname_len, - ) - }; + let route = Route::parse( + entry, + extname, + &resolver.real_path_of(root_dir_info, entry), + self.log, + public_dir, + self.route_dirname_len, + ); if let Some(route) = route { self.append_route(route); } @@ -716,33 +712,15 @@ pub struct Route { impl Route { pub(crate) const INDEX_ROUTE_NAME: &'static [u8] = b"/"; - /// # Safety - /// `entry` must point to a live `Fs::Entry` (EntryStore-owned) with no - /// other active `&mut` borrow for the duration of the call. `base_` and - /// `extname` may borrow `(*entry).base_`; see the NOTE below. - pub(crate) unsafe fn parse( - base_: &[u8], + pub(crate) fn parse( + entry: &Fs::Entry, extname: &[u8], - entry: *mut Fs::Entry, + real_path: &RealPath, log: &mut bun_ast::Log, public_dir_: &[u8], routes_dirname_len: u16, ) -> Option { - // NOTE: `entry` is a raw `*mut Entry` - // because `base_`/`extname` may borrow `(*entry).base_` (tiny inline - // string) and a `&mut Entry` parameter would alias them. - // Reads go through `unsafe { &*entry }`; the single mutation - // (`set_abs_path`) goes through `unsafe { &mut *entry }` after - // `base_`/`extname` are no longer used. - // SAFETY: caller passes an EntryStore-owned pointer valid for the - // process lifetime; no other live `&mut` to it during this call. - let entry_abs_path = unsafe { &*entry }.abs_path().as_bytes(); - let mut abs_path_str: &[u8] = if entry_abs_path.is_empty() { - b"" - } else { - entry_abs_path - }; - + let base_ = entry.base(); let base = &base_[0..base_.len() - extname.len()]; let public_dir = strings::trim(public_dir_, SEP_STR.as_bytes()); @@ -806,11 +784,38 @@ impl Route { let mut validation_result = pattern::ValidationResult::default(); let is_index = name.is_empty(); + let dirname_store = FileSystem::instance().dirname_store(); + // The path the resolver loads this file under, so never `Entry.abs_path`. + let abs_path: &'static [u8] = if let &RealPath::Whole(path) = real_path { + #[cfg(windows)] + let path = dirname_store + .append(bun_paths::resolve_path::platform_to_posix_buf( + path, + &mut bufs.normalized_abs_path_buf, + )) + .expect("unreachable"); + path + } else { + let dir = if let &RealPath::Dir(dir) = real_path { + dir + } else { + entry.dir() + }; + #[cfg(windows)] + let dir = bun_paths::resolve_path::platform_to_posix_buf( + dir, + &mut bufs.normalized_abs_path_buf, + ); + let sep: &[u8] = if dir.ends_with(b"/") { b"" } else { b"/" }; + dirname_store + .append_parts(&[dir, sep, entry.base()]) + .expect("unreachable") + }; + 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. + // 'static. let (name, match_name): (&'static [u8], &'static [u8]) = if !name.is_empty() { validation_result = match Pattern::validate(&name[1..], log) { Some(v) => v, @@ -830,9 +835,12 @@ impl Route { // lifetime arena), so rebinding here drops the borrow on // `route_file_buf` and avoids needing lifetime transmutes // below. - let dirname_store = FileSystem::instance().dirname_store(); - let public_path: &'static [u8] = - dirname_store.append(public_path).expect("unreachable"); + // The public path is the tail of the absolute path unless a symlink is below the routes directory. + let public_path: &'static [u8] = if abs_path.ends_with(public_path) { + &abs_path[abs_path.len() - public_path.len()..] + } else { + 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 @@ -849,118 +857,12 @@ impl Route { (Route::INDEX_ROUTE_NAME, Route::INDEX_ROUTE_NAME) }; - if abs_path_str.is_empty() { - // The reads of `cache().fd` and the `set_abs_path` write below - // rewrite the cached `Entry`; serialize them on the per-entry - // mutex (the same lock every other `Entry` rewrite path takes). - // SAFETY: see fn-level NOTE — read-only reborrow. - let _entry_guard = unsafe { &*entry }.mutex.lock_guard(); - // 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(); - } - } - }); - - // SAFETY: see fn-level NOTE — read-only reborrow. - if let Some(valid) = unsafe { &*entry }.cache().fd.unwrap_valid() { - *file = Some(bun_sys::File::from_fd(valid)); - needs_close.set(false); - } else { - // SAFETY: see fn-level NOTE — read-only reborrow. - let entry_r = unsafe { &*entry }; - let parts = [entry_r.dir(), entry_r.base()]; - let abs_len = FileSystem::instance().abs_buf(&parts, route_file_buf).len(); - // Rebind so the later getFdPath error - // message prints the computed path instead of `b""`. - // SAFETY: lifetime-laundered raw view into route_file_buf - // (same pattern as `public_path` above) so the buffer can - // be reborrowed mutably for the NUL write / open below. - abs_path_str = - unsafe { core::slice::from_raw_parts(route_file_buf.as_ptr(), abs_len) }; - route_file_buf[abs_len] = 0; - // SAFETY: NUL-terminated above; `abs_len` bytes valid in route_file_buf. - let buf = bun_core::ZStr::from_buf(&route_file_buf[..], abs_len); - match bun_sys::open_file_absolute_z(buf, bun_sys::OpenFlags::READ_ONLY) { - Ok(f) => { - *file = Some(f); - } - Err(err) => { - needs_close.set(false); - log.add_error_fmt( - None, - bun_ast::Loc::EMPTY, - format_args!( - "{} opening route: {}", - bstr::BStr::new(err.name()), - bstr::BStr::new(&route_file_buf[..abs_len]) - ), - ); - return None; - } - } - 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 None; - } - }; - - abs_path_str = FileSystem::instance() - .dirname_store() - .append(_abs) - .expect("unreachable"); - - // SAFETY: sole mutation; `base_`/`extname` (which may borrow - // `(*entry).base_.remainder_buf`) are not used after this. - unsafe { &mut *entry }.set_abs_path(Interned::from_static(abs_path_str)); - } - - #[cfg(windows)] - let abs_path: AbsPath = { - // Intern into DirnameStore so the slice is genuinely `'static` - // and the `Interned` widen is sound on Windows. - let normalized = bun_paths::resolve_path::platform_to_posix_buf( - abs_path_str, - &mut bufs.normalized_abs_path_buf, - ); - let interned: &'static [u8] = FileSystem::instance() - .dirname_store() - .append(normalized) - .expect("unreachable"); - Interned::from_static(interned) - }; - #[cfg(not(windows))] - let abs_path = Interned::from_static(abs_path_str); - #[cfg(all(debug_assertions, windows))] { debug_assert!(!strings::index_of_char(name, b'\\').is_some()); debug_assert!(!strings::index_of_char(match_name, b'\\').is_some()); - debug_assert!(!strings::index_of_char(abs_path.as_bytes(), b'\\').is_some()); - // SAFETY: read-only reborrow; the `&mut` write above is dead. - debug_assert!(!strings::index_of_char(unsafe { &*entry }.base(), b'\\').is_some()); + debug_assert!(!strings::index_of_char(abs_path, b'\\').is_some()); + debug_assert!(!strings::index_of_char(entry.base(), b'\\').is_some()); } Some(Route { @@ -973,7 +875,7 @@ impl Route { }, param_count: validation_result.param_count, kind: validation_result.kind, - abs_path, + abs_path: Interned::from_static(abs_path), has_uppercase, }) }) @@ -1138,6 +1040,8 @@ pub trait ResolverLike { /// 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; + /// `Resolver::real_path_of`. + fn real_path_of(&self, dir: &DirInfo, entry: &Fs::Entry) -> RealPath; } // ────────────────────────────────────────────────────────────────────────── diff --git a/src/runtime/api/filesystem_router.rs b/src/runtime/api/filesystem_router.rs index ee3031618b0b..4baab1401f7f 100644 --- a/src/runtime/api/filesystem_router.rs +++ b/src/runtime/api/filesystem_router.rs @@ -74,6 +74,14 @@ impl<'a, 'r> Router::ResolverLike for RouterResolver<'a, 'r> { fn read_dir_info_ignore_error(&mut self, path: &[u8]) -> Option { self.0.read_dir_info_ignore_error(path) } + #[inline] + fn real_path_of( + &self, + dir: &bun_resolver::DirInfo, + entry: &Fs::Entry, + ) -> bun_resolver::RealPath { + self.0.real_path_of(dir, entry) + } } // `js.routesSetCached` codegen accessor — emitted by the `.classes.ts` diff --git a/test/js/bun/util/filesystem_router.test.ts b/test/js/bun/util/filesystem_router.test.ts index 28c6fd9f0437..31ef97c81988 100644 --- a/test/js/bun/util/filesystem_router.test.ts +++ b/test/js/bun/util/filesystem_router.test.ts @@ -1,7 +1,8 @@ import { FileSystemRouter } from "bun"; -import { expect, it } from "bun:test"; +import { describe, expect, it } from "bun:test"; import fs, { mkdirSync, rmSync } from "fs"; import { bunEnv, bunExe, isASAN, isMacOS, isWindows, normalizeBunSnapshot, tempDir, tmpdirSync } from "harness"; +import os from "os"; import path, { dirname } from "path"; function createTree(basedir: string, paths: string[]) { @@ -1134,3 +1135,336 @@ it("an absolute dir with '..' in it names routes like its normalized spelling", exitCode: 0, }); }); + +// The directory-entry cache is process-global and keyed by path. Each case +// builds its own tree, so what one case makes the resolver cache cannot reach +// another. +describe("a route's path does not depend on what the resolver cached first", () => { + const primers: Record void | Promise> = { + "nothing": () => {}, + "Bun.resolveSync": (pages, files) => { + for (const file of files) Bun.resolveSync(path.join(pages, file), pages); + }, + "Bun.build": (pages, files) => + Bun.build({ entrypoints: files.map(file => path.join(pages, file)), target: "bun", throw: false }), + }; + + it.each(Object.keys(primers))("routes dir reached through a symlink, after %s", async primer => { + using dir = tempDir("fsr-symlinked-dir", { + "real/pages/index.tsx": "export default 0;\n", + "real/pages/a.tsx": "export default 1;\n", + "real/pages/sub/d.tsx": "export default 2;\n", + "shared/e.tsx": "export default 3;\n", + "other/x.tsx": "export default 4;\n", + }); + const root = String(dir).replaceAll("\\", "/"); + fs.symlinkSync(path.join(root, "real"), path.join(root, "link"), "junction"); + fs.symlinkSync(path.join(root, "other"), path.join(root, "real", "pages", "linked"), "junction"); + fs.symlinkSync(path.join(root, "shared", "e.tsx"), path.join(root, "real", "pages", "c.tsx"), "file"); + const pages = path.join(root, "link", "pages"); + const files = ["index.tsx", "a.tsx", "c.tsx", "sub/d.tsx", "linked/x.tsx"]; + + await primers[primer](pages, files); + const router = new FileSystemRouter({ dir: pages, style: "nextjs", fileExtensions: [".tsx"] }); + + const routes = { + "/": `${root}/real/pages/index.tsx`, + "/a": `${root}/real/pages/a.tsx`, + "/c": `${root}/shared/e.tsx`, + "/sub/d": `${root}/real/pages/sub/d.tsx`, + "/linked/x": `${root}/other/x.tsx`, + }; + expect({ + routes: router.routes, + a: { filePath: router.match("/a")!.filePath, src: router.match("/a")!.src }, + d: { filePath: router.match("/sub/d")!.filePath, src: router.match("/sub/d")!.src }, + // The resolver loads each route file under the path the router reports. + resolved: files.map(file => Bun.resolveSync(path.join(pages, file), pages).replaceAll("\\", "/")), + }).toEqual({ + routes, + a: { filePath: routes["/a"], src: "a.tsx" }, + d: { filePath: routes["/sub/d"], src: "sub/d.tsx" }, + resolved: Object.values(routes), + }); + }); + + it.each(Object.keys(primers))( + "a symlinked file and a symlinked subdirectory in a plain routes dir, after %s and across reload()", + async primer => { + using dir = tempDir("fsr-links-below-root", { + "pages/a.tsx": "export default 1;\n", + "shared/e.tsx": "export default 2;\n", + "other/x.tsx": "export default 3;\n", + }); + const root = String(dir).replaceAll("\\", "/"); + const pages = path.join(root, "pages"); + fs.symlinkSync(path.join(root, "shared", "e.tsx"), path.join(pages, "c.tsx"), "file"); + fs.symlinkSync(path.join(root, "other"), path.join(pages, "linked"), "junction"); + + await primers[primer](pages, ["a.tsx", "c.tsx", "linked/x.tsx"]); + const router = new FileSystemRouter({ dir: pages, style: "nextjs", fileExtensions: [".tsx"] }); + const read = () => ({ routes: router.routes, src: router.match("/a")!.src }); + const first = read(); + router.reload(); + + const expected = { + routes: { + "/a": `${root}/pages/a.tsx`, + "/c": `${root}/shared/e.tsx`, + "/linked/x": `${root}/other/x.tsx`, + }, + src: "a.tsx", + }; + expect({ first, afterReload: read() }).toEqual({ first: expected, afterReload: expected }); + }, + ); + + it("no symlink, after an import that spells the file name in another letter case", () => { + using dir = tempDir("fsr-case-import", { + "pages/a.tsx": "export default 1;\n", + "pages/b.tsx": "export default 2;\n", + }); + const root = String(dir).replaceAll("\\", "/"); + const pages = path.join(root, "pages"); + + // The resolver finds `b.tsx` for `B` (its directory lookup ignores case) and + // caches the spelling of the import. A file system that tells `B.tsx` from + // `b.tsx` has no such file, so the resolve is allowed to fail. + try { + Bun.resolveSync(path.join(pages, "B"), pages); + } catch {} + const router = new FileSystemRouter({ dir: pages, style: "nextjs", fileExtensions: [".tsx"] }); + const first = router.routes; + router.reload(); + + const expected = { + "/a": `${root}/pages/a.tsx`, + "/b": `${root}/pages/b.tsx`, + }; + expect({ first, afterReload: router.routes }).toEqual({ first: expected, afterReload: expected }); + }); +}); + +it("a router does not change which tsconfig.json the bundler applies to a route file", async () => { + // `proj/link` points outside `proj`. The bundler takes tsconfig.json from the + // directories above the path as imported (`proj/`), not from those above the + // real path, so the JSX import source below applies to the route file. + async function jsxImportSource(routerFirst: boolean) { + using dir = tempDir("fsr-tsconfig", { + "proj/tsconfig.json": JSON.stringify({ compilerOptions: { jsx: "react-jsx", jsxImportSource: "custom-src" } }), + "elsewhere/real/pages/b.tsx": "export default
;\n", + }); + const root = String(dir); + fs.symlinkSync(path.join(root, "elsewhere", "real"), path.join(root, "proj", "link"), "junction"); + const pages = path.join(root, "proj", "link", "pages"); + + if (routerFirst) new FileSystemRouter({ dir: pages, style: "nextjs", fileExtensions: [".tsx"] }); + const build = await Bun.build({ entrypoints: [path.join(pages, "b.tsx")], target: "bun", external: ["*"] }); + const output = await build.outputs[0].text(); + return output.match(/from "([^"/]+)\/jsx/)?.[1]; + } + + expect({ + buildOnly: await jsxImportSource(false), + routerFirst: await jsxImportSource(true), + }).toEqual({ + buildOnly: "custom-src", + routerFirst: "custom-src", + }); +}); + +// The symlinked file is compared with what the resolver returns in the same +// process: whether --preserve-symlinks keeps a file's link is the resolver's rule. +it.each(["the router", "Bun.resolveSync"])( + "with --preserve-symlinks a route's path is the one the resolver returns, %s first", + async first => { + using dir = tempDir("fsr-preserve-symlinks", { + "real/pages/a.tsx": "export default 1;\n", + "shared/e.tsx": "export default 2;\n", + "fixture.ts": /* ts */ ` + import path from "path"; + const pages = path.join(import.meta.dir, "link", "pages"); + const resolve = () => + ["a.tsx", "c.tsx"].map(file => Bun.resolveSync(path.join(pages, file), pages).replaceAll("\\\\", "/")); + const before = process.argv[2] === "Bun.resolveSync" ? resolve() : undefined; + const router = new Bun.FileSystemRouter({ dir: pages, style: "nextjs", fileExtensions: [".tsx"] }); + const read = () => ({ routes: router.routes, src: router.match("/a")!.src }); + const constructed = read(); + router.reload(); + console.log(JSON.stringify({ constructed, reloaded: read(), resolved: before ?? resolve() })); + `, + }); + const root = String(dir).replaceAll("\\", "/"); + fs.symlinkSync(path.join(root, "real"), path.join(root, "link"), "junction"); + fs.symlinkSync(path.join(root, "shared", "e.tsx"), path.join(root, "real", "pages", "c.tsx"), "file"); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--preserve-symlinks", "fixture.ts", first], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const { resolved = [], ...routers } = stdout ? JSON.parse(stdout) : {}; + + const linkA = `${root}/link/pages/a.tsx`; + expect([`${root}/shared/e.tsx`, `${root}/link/pages/c.tsx`]).toContain(resolved[1]); + const expected = { routes: { "/a": linkA, "/c": resolved[1] }, src: "a.tsx" }; + expect({ ...routers, resolved, stderr, exitCode }).toEqual({ + constructed: expected, + reloaded: expected, + resolved: [linkA, resolved[1]], + stderr: "", + exitCode: 0, + }); + }, +); + +// Whether a dangling symlink resolves is the resolver's rule. The router does +// not open route files, so it lists the link exactly when the resolver finds it. +it("a dangling symlink is a route only if the resolver resolves it, and under that path", () => { + using dir = tempDir("fsr-dangling-link", { + "pages/ok.tsx": "export default 1;\n", + }); + const root = String(dir).replaceAll("\\", "/"); + const pages = path.join(root, "pages"); + fs.symlinkSync(path.join(root, "missing.tsx"), path.join(pages, "dead.tsx"), "file"); + + const router = new FileSystemRouter({ dir: pages, style: "nextjs", fileExtensions: [".tsx"] }); + const first = router.routes; + router.reload(); + + let resolved: string | undefined; + try { + resolved = Bun.resolveSync(path.join(pages, "dead.tsx"), pages).replaceAll("\\", "/"); + } catch {} + const expected = { + ...(resolved !== undefined && { "/dead": resolved }), + "/ok": `${root}/pages/ok.tsx`, + }; + expect({ first, afterReload: router.routes }).toEqual({ first: expected, afterReload: expected }); +}); + +// On Windows a backslash separates directories, so no file has one in its name. +it.skipIf(isWindows)("a file name with a backslash is a route under its own path", () => { + using dir = tempDir("fsr-backslash-name", { + "pages/ok.tsx": "export default 1;\n", + }); + const root = String(dir); + fs.writeFileSync(path.join(root, "pages", "a\\b.tsx"), "export default 2;\n"); + + const router = new FileSystemRouter({ dir: path.join(root, "pages"), style: "nextjs", fileExtensions: [".tsx"] }); + expect(router.routes).toEqual({ + "/a\\b": `${root}/pages/a\\b.tsx`, + "/ok": `${root}/pages/ok.tsx`, + }); +}); + +it("a new router reads the directory listing the resolver cached, reload() reads the disk", () => { + using dir = tempDir("fsr-cached-listing", { + "pages/keep.tsx": "export default 1;\n", + "pages/gone.tsx": "export default 2;\n", + }); + const pages = path.join(String(dir), "pages"); + + Bun.resolveSync(path.join(pages, "keep.tsx"), pages); + fs.rmSync(path.join(pages, "gone.tsx")); + fs.writeFileSync(path.join(pages, "new.tsx"), "export default 3;\n"); + + const router = new FileSystemRouter({ dir: pages, style: "nextjs", fileExtensions: [".tsx"] }); + const first = Object.keys(router.routes).sort(); + router.reload(); + expect({ first, afterReload: Object.keys(router.routes).sort() }).toEqual({ + first: ["/gone", "/keep"], + afterReload: ["/keep", "/new"], + }); +}); + +// Only a file system that ignores letter case lets `dir` name the directory in another case. +const tmp = fs.realpathSync(os.tmpdir()); +const fileSystemIgnoresCase = fs.existsSync(tmp.toUpperCase()) && fs.existsSync(tmp.toLowerCase()); +it.skipIf(!fileSystemIgnoresCase)("filePath keeps the letter case of the dir option, like the resolver", () => { + using dir = tempDir("fsr-dir-case", { + "pages/a.tsx": "export default 1;\n", + }); + const root = String(dir).replaceAll("\\", "/"); + const pages = path.join(root, "PAGES"); + + const router = new FileSystemRouter({ dir: pages, style: "nextjs", fileExtensions: [".tsx"] }); + expect({ + routes: router.routes, + resolved: Bun.resolveSync(path.join(pages, "a.tsx"), pages).replaceAll("\\", "/"), + }).toEqual({ + routes: { "/a": `${root}/PAGES/a.tsx` }, + resolved: `${root}/PAGES/a.tsx`, + }); +}); + +// A save by rename reaches the hot reloader as an event on the directory. It +// used to find the watched module through the resolver's cached path of the +// directory entry, which `reload()` clears and only refilled for route files. +// Windows cannot rename over an open file and reports these changes on the +// file, so it has no such event. +it.skipIf(isWindows)( + "--hot reloads a module saved by rename after reload() re-read its directory", + async () => { + using dir = tempDir("fsr-hot-after-reload", { + "pages/a.tsx": `export default "a1";\n`, + "pages/helper.ts": `export default "h1";\n`, + "server.ts": /* ts */ ` + import helper from "./pages/helper.ts"; + const router = new Bun.FileSystemRouter({ + dir: import.meta.dir + "/pages", + style: "nextjs", + fileExtensions: [".tsx"], + }); + const route = (await import(router.match("/a")!.filePath)).default; + router.reload(); + console.log(route, helper); + `, + }); + const root = String(dir); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--hot", "server.ts"], + env: bunEnv, + cwd: root, + stdout: "pipe", + stderr: "inherit", + stdin: "ignore", + }); + + const reader = proc.stdout.getReader(); + const decoder = new TextDecoder(); + let buffered = ""; + // The next line that differs from `previous`: one save can reload twice. + async function lineAfter(previous: string | undefined) { + while (true) { + const newline = buffered.indexOf("\n"); + if (newline !== -1) { + const line = buffered.slice(0, newline); + buffered = buffered.slice(newline + 1); + if (line !== previous) return line; + continue; + } + const { value, done } = await reader.read(); + if (done) throw new Error(`--hot exited after ${JSON.stringify(previous)}`); + buffered += decoder.decode(value, { stream: true }); + } + } + function saveByRename(file: string, text: string) { + fs.writeFileSync(path.join(root, file + ".tmp"), text); + fs.renameSync(path.join(root, file + ".tmp"), path.join(root, file)); + } + + const first = await lineAfter(undefined); + saveByRename("pages/helper.ts", `export default "h2";\n`); + const second = await lineAfter(first); + saveByRename("pages/a.tsx", `export default "a2";\n`); + const third = await lineAfter(second); + + expect([first, second, third]).toEqual(["a1 h1", "a1 h2", "a2 h2"]); + }, + 30_000, +);