diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index ea9821f22bd7..7ed80f41916d 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -180,6 +180,8 @@ pub struct VirtualMachine { /// `RawSlice` carries the BACKREF outlives-holder invariant โ€” read via /// `main()`. main: Option>, + /// `process.argv[1]` when the `node` shim resolved `main` from it. Node keeps the given path too. + main_for_argv: Option<&'static [u8]>, pub main_entry_point: MainEntryPoint, pub did_look_for_server: bool, pub main_resolved_path: bun_core::String, @@ -3337,6 +3339,7 @@ impl VirtualMachine { // `log` is a fresh leaked Box; outlives the VM. addr_of_mut!((*vm).log).write(NonNull::new(log)); addr_of_mut!((*vm).main).write(None); + addr_of_mut!((*vm).main_for_argv).write(None); addr_of_mut!((*vm).main_hash).write(0); addr_of_mut!((*vm).main_entry_point).write(MainEntryPoint::File); addr_of_mut!((*vm).main_resolved_path).write(bun_core::String::EMPTY); @@ -3541,6 +3544,17 @@ impl VirtualMachine { self.main_hash = path.map_or(0, bun_watcher::Watcher::get_hash); } + /// `process.argv[1]`: the path from `set_main_for_argv`, else `main()`. + #[inline] + pub fn main_for_argv(&self) -> Option<&[u8]> { + self.main_for_argv.or_else(|| self.main()) + } + + #[inline] + pub fn set_main_for_argv(&mut self, path: &'static [u8]) { + self.main_for_argv = Some(path); + } + pub fn entry_point_is_commonjs(&self) -> bool { self.entry_point_result.evaluated_as_cjs || (self.main_entry_point == MainEntryPoint::Compiled diff --git a/src/jsc/hot_reloader.rs b/src/jsc/hot_reloader.rs index a10883e9cdea..cb99dc8ed412 100644 --- a/src/jsc/hot_reloader.rs +++ b/src/jsc/hot_reloader.rs @@ -747,6 +747,16 @@ where core::sync::atomic::Ordering::Relaxed, ); + #[cfg(not(windows))] + { + // SAFETY: the watcher thread does not run yet, so nothing else reads `reloader`. + let main = unsafe { &(*reloader).main }; + if main.dir_hash != 0 { + // SAFETY: `watcher_ptr` was just installed into the ctx and is live. + unsafe { (*watcher_ptr).add_entry_point_directory(main.dir, main.dir_hash) }; + } + } + // SAFETY: `watcher_ptr` was just installed into the ctx and is live. if let Err(err) = unsafe { (*watcher_ptr).start() } { bun_core::handle_error_return_trace(&err); diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 59dc18d0c94c..fe9b0ea0ea19 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -2654,6 +2654,49 @@ impl<'a> Resolver<'a> { first_bust || second_bust } + /// Busts what a resolve of the absolute `path` put in the cache when it found nothing. The key rules of `bust_dir_cache` apply. + pub fn bust_dir_cache_of_path(&mut self, path: &[u8]) -> bool { + let mut busted = false; + let package_json = self + .dir_cache_mut() + .get(path) + .and_then(|dir_info| dir_info.package_json()); + if let Some(package_json) = package_json { + let mut buf = ::bun_paths::path_buffer_pool::get(); + for main in package_json.main_fields.values() { + // A main field is file content of any length. The resolver looks up only one that fits a path buffer. + let Some(target) = ::bun_paths::resolve_path::join_abs_string_buf_checked::< + ::bun_paths::resolve_path::platform::Auto, + >(path, &mut buf.0, &[&main[..]]) else { + continue; + }; + busted |= self.bust_dir_cache_and_missing_parents(target); + } + } + self.bust_dir_cache_and_missing_parents(path) || busted + } + + /// Busts `path`, each parent that is cached as missing, and the first parent that is cached as a directory. + fn bust_dir_cache_and_missing_parents(&mut self, path: &[u8]) -> bool { + use strings::without_trailing_slash_windows_path as key_of; + let mut directory = key_of(path); + let mut busted = self.bust_dir_cache(directory); + // A parent that is cached as missing stops the walk up from a new directory, which then has no parent. + while let Some(parent) = ::bun_paths::dirname(directory).map(key_of) { + if parent.len() >= directory.len() { + break; + } + let exists = self.dir_cache_mut().get(parent).is_some(); + let busted_parent = self.bust_dir_cache(parent); + busted |= busted_parent; + if exists || !busted_parent { + break; + } + directory = parent; + } + busted + } + /// bust both the named file and a parent directory, because `./hello` can resolve /// to `./hello.js` or `./hello/index.js` pub fn bust_dir_cache_from_specifier( diff --git a/src/runtime/cli/fuzzilli_command.rs b/src/runtime/cli/fuzzilli_command.rs index 1558ef6eced9..e63f20db4261 100644 --- a/src/runtime/cli/fuzzilli_command.rs +++ b/src/runtime/cli/fuzzilli_command.rs @@ -8,7 +8,7 @@ use bun_core::{Environment, Global}; use bun_sys::{self as sys, Fd, FdExt, O}; #[cfg(unix)] -use super::run_command::RunCommand; +use super::run_command::{EntryPath, RunCommand}; use crate::Command; pub(crate) struct FuzzilliCommand; @@ -92,7 +92,12 @@ impl FuzzilliCommand { let temp_path: &[u8] = b"/tmp/bun-fuzzilli-reprl.js"; // The `Run.boot` entry point is hosted on `RunCommand` to avoid the // higher-tier crate cycle (see run_command.rs ยง`Run`). - let result = RunCommand::boot(_ctx, temp_path.to_vec().into_boxed_slice(), None); + let result = RunCommand::boot( + _ctx, + temp_path.to_vec().into_boxed_slice(), + EntryPath::Resolved, + None, + ); // `defer fd.close()` โ€” Fd is Copy and has no Drop; close explicitly. temp_file_fd.close(); diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 136feadb3ead..9c4be96f43a1 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -94,6 +94,15 @@ pub(crate) struct ConfigureEnvOptions { pub(crate) store_root_fd: bool, } +/// What the `entry_path` of [`RunCommand::boot`] names. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum EntryPath { + /// The file to run. + Resolved, + /// A path as `node` takes it. `boot` resolves it and it stays `process.argv[1]` (Node: `resolveMainPath`). + Unresolved, +} + pub(crate) struct RunCommand; /// What `RunCommand::boot_main` starts the VM with. @@ -101,6 +110,7 @@ enum BootMain { /// The file at `path`. An `.html` file is served by `src/js/internal/html.ts`. File { path: Box<[u8]>, + kind: EntryPath, loader: Option, }, /// `bun bake`: `src/js/internal/bake.ts` serves the app in the working directory. There is no @@ -981,12 +991,14 @@ Full documentation is available at https://bun.com/docs/cli/run pub(crate) fn boot( ctx: &mut ContextData, entry_path: Box<[u8]>, + entry_kind: EntryPath, loader: Option, ) -> crate::Result<()> { Self::boot_main( ctx, BootMain::File { path: entry_path, + kind: entry_kind, loader, }, ) @@ -1027,7 +1039,7 @@ Full documentation is available at https://bun.com/docs/cli/run let watches = ctx.debug.hot_reload != cli::command::HotReload::None; if is_checked && !watches { // `bun bake` always watches, so it checks in `Run::start`. - let BootMain::File { path, loader } = &main else { + let BootMain::File { path, loader, .. } = &main else { unreachable!("bun bake always watches") }; let has_type_errors = !Self::check_main(ctx, path, *loader, &ctx.passthrough, None); @@ -1090,12 +1102,13 @@ Full documentation is available at https://bun.com/docs/cli/run bun_dns::Order::from_string_or_die(&ctx.runtime_options.dns_result_order) as u8; // `vm.main` is a BACKREF into these bytes and the runner never returns, // so the allocation is process-lifetime by construction. - let (entry, loader): (Option<&'static [u8]>, Option) = match main { - BootMain::File { path, loader } => (Some(Box::leak(path)), loader), - BootMain::Bake => (None, None), - }; + let (entry, entry_kind, loader): (Option<&'static [u8]>, EntryPath, Option) = + match main { + BootMain::File { path, kind, loader } => (Some(Box::leak(path)), kind, loader), + BootMain::Bake => (None, EntryPath::Resolved, None), + }; // What `Run::start` passes to `vm.load_entry_point`; `mut` because the - // cron-execution branch below may swap in a synthetic `cwd/[eval]` path + // cron-execution branch (a synthetic `cwd/[eval]` path) and `EntryPath::Unresolved` swap it // while `entry` stays the user's path for the loader check further down. let mut run_entry = entry; vm.set_main(entry); @@ -1185,6 +1198,21 @@ Full documentation is available at https://bun.com/docs/cli/run bun_http::async_http::load_env(unsafe { vm.log.unwrap().as_mut() }, vm.env_loader()); vm.load_extra_env_and_source_code_printer(); + if let Some(entry) = entry + && entry_kind == EntryPath::Unresolved + && vm.module_loader.eval_source.is_none() + { + match Self::resolve_entry_path(vm, entry, Self::preserve_symlinks_main(ctx)) { + Some(resolved) if resolved != entry => { + vm.set_main_for_argv(entry); + vm.set_main(Some(resolved)); + run_entry = Some(resolved); + } + Some(_) => {} + // A preload can create the entry point, so this resolve must leave nothing in the resolver cache. + None => Self::forget_entry_path(vm, entry), + } + } vm.is_main_thread = true; bun_jsc::virtual_machine::IS_MAIN_THREAD_VM.set(true); @@ -1228,6 +1256,57 @@ Full documentation is available at https://bun.com/docs/cli/run .start() } + /// The main module of `entry`, for `vm.main()`. On `None` the module loader reports the failure. + fn resolve_entry_path( + vm: &mut VirtualMachine, + entry: &'static [u8], + preserve_symlinks_main: bool, + ) -> Option<&'static [u8]> { + let top_level_dir = vm.top_level_dir(); + // Like the module loader, keep the resolver's messages out of `vm.log`. + let mut log = bun_ast::Log::default(); + let resolver = &raw mut vm.transpiler.resolver; + // SAFETY: `resolver` is a field of the live VM. `log` is declared + // before the guard, so the guard restores the log before `log` drops. + let _restore_log = unsafe { + bun_resolver::Resolver::scoped_log(resolver, ::core::ptr::NonNull::from(&mut log)) + }; + // SAFETY: `vm` is borrowed for this call, so nothing else uses its resolver. + let resolver = unsafe { &mut *resolver }; + let resolved = resolver.resolve(top_level_dir, entry, bun_ast::ImportKind::EntryPointRun); + let path = resolved.ok()?.path_const()?.text; + if preserve_symlinks_main || !resolver.opts.preserve_symlinks { + return Some(path); + } + // The resolver cache keeps the symlink rule of the imports for each directory, so this resolve does not change it. + let mut path_buf = bun_paths::path_buffer_pool::get(); + let mut real_buf = bun_paths::path_buffer_pool::get(); + let real = sys::realpath( + bun_paths::resolve_path::z(path, &mut path_buf), + &mut real_buf, + ); + Some(real.map_or(path, |real| &*Box::leak(Box::<[u8]>::from(real)))) + } + + /// After [`Self::resolve_entry_path`] found nothing: removes what that resolve put in the resolver cache. + fn forget_entry_path(vm: &mut VirtualMachine, entry: &[u8]) { + let mut buf = bun_paths::path_buffer_pool::get(); + // The resolver keys an absolute path as it is given, or without its `..` when it has one. + let collapsed = vm.fs().abs_buf_checked(&[entry], &mut buf.0); + for path in [Some(entry), collapsed].into_iter().flatten() { + let key = strings::paths::without_trailing_slash_windows_path(path); + vm.transpiler.resolver.bust_dir_cache_of_path(key); + } + } + + /// Node keeps a symlink in the path of the main module only with `--preserve-symlinks-main` (`resolveMainPath`). + fn preserve_symlinks_main(ctx: &ContextData) -> bool { + ctx.runtime_options.preserve_symlinks_main + || bun_core::env_var::NODE_PRESERVE_SYMLINKS_MAIN + .get() + .unwrap_or(false) + } + /// Allow setting a custom timezone. Without `$TZ`, JSC/ICU lazily /// auto-detects the host zone the first time a `Date` is constructed โ€” /// matching upstream Bun. `.env` files are loaded by @@ -1901,7 +1980,7 @@ impl RunCommand { // owned copy by value. let owned: Box<[u8]> = path.to_vec().into_boxed_slice(); - if let Err(err) = Self::boot(ctx, owned, loader) { + if let Err(err) = Self::boot(ctx, owned, EntryPath::Resolved, loader) { Self::boot_failed_exit(ctx, paths::basename(path), &err); } true @@ -2691,11 +2770,7 @@ impl RunCommand { let prefixed: Vec; let resolution: ::core::result::Result = { let saved_preserve = this_transpiler.resolver.opts.preserve_symlinks; - this_transpiler.resolver.opts.preserve_symlinks = - ctx.runtime_options.preserve_symlinks_main - || bun_core::env_var::NODE_PRESERVE_SYMLINKS_MAIN - .get() - .unwrap_or(false); + this_transpiler.resolver.opts.preserve_symlinks = Self::preserve_symlinks_main(ctx); // SAFETY: `Transpiler::init` always sets `fs`; resolver-cache lifetime. let top_level_dir = unsafe { (*this_transpiler.fs).top_level_dir() }; let resolved = match this_transpiler.resolver.resolve( @@ -3136,7 +3211,7 @@ impl RunCommand { // `basename(target_name)` (= "-"), not `basename(entry_path)` // (= "[stdin]"), in the error message. let owned: Box<[u8]> = entry_path.to_vec().into_boxed_slice(); - if let Err(err) = Self::boot(ctx, owned, None) { + if let Err(err) = Self::boot(ctx, owned, EntryPath::Resolved, None) { Self::boot_failed_exit(ctx, b"-", &err); } Ok(true) @@ -3186,7 +3261,7 @@ impl RunCommand { let entry: Box<[u8]> = entry_point_buf[..cwd_len + EVAL_TRIGGER.len()] .to_vec() .into_boxed_slice(); - Self::boot(ctx, entry, None) + Self::boot(ctx, entry, EntryPath::Resolved, None) } /// `node` argv0 emulation. Port of `execAsIfNode`. @@ -3221,7 +3296,7 @@ impl RunCommand { let entry: Box<[u8]> = entry_point_buf[..cwd_len + EVAL_TRIGGER.len()] .to_vec() .into_boxed_slice(); - return Self::boot(ctx, entry, None); + return Self::boot(ctx, entry, EntryPath::Resolved, None); } if ctx.positionals.is_empty() { @@ -3262,7 +3337,7 @@ impl RunCommand { // `Global::configure_allocator` and (b) uses the // `Output.err(err, "Failed to run script \"...\"")` form. let basename: Box<[u8]> = paths::basename(&normalized).to_vec().into_boxed_slice(); - if let Err(err) = Self::boot(ctx, normalized, None) { + if let Err(err) = Self::boot(ctx, normalized, EntryPath::Unresolved, None) { Self::exec_as_if_node_boot_failed(ctx, &basename, err); } Ok(()) @@ -4304,7 +4379,12 @@ impl BunXFastPath { ::core::slice::from_raw_parts_mut(raw.cast::(), bun_paths::PATH_MAX_WIDE * 2) }; let utf8 = strings::convert_utf16_to_utf8_in_buffer(out_buf, wpath); - if let Err(err) = RunCommand::boot(ctx, utf8.to_vec().into_boxed_slice(), None) { + if let Err(err) = RunCommand::boot( + ctx, + utf8.to_vec().into_boxed_slice(), + EntryPath::Resolved, + None, + ) { // SAFETY: `ctx.log` was set in `create_context_data`. let _ = unsafe { &mut *ctx.log }.print(std::ptr::from_mut(Output::error_writer())); Output::err( diff --git a/src/runtime/node/node_process.rs b/src/runtime/node/node_process.rs index 2202d4d21906..8c9fe62029de 100644 --- a/src/runtime/node/node_process.rs +++ b/src/runtime/node/node_process.rs @@ -410,7 +410,7 @@ mod _impl { } else { b"/[stdin]" }; - if let Some(main) = vm.main() + if let Some(main) = vm.main_for_argv() && !strings::ends_with(main, EVAL_SUFFIX) && !strings::ends_with(main, STDIN_SUFFIX) { diff --git a/src/runtime/shell/interpreter.rs b/src/runtime/shell/interpreter.rs index 7504de4d8fe2..9cc508828c25 100644 --- a/src/runtime/shell/interpreter.rs +++ b/src/runtime/shell/interpreter.rs @@ -1512,7 +1512,7 @@ impl Interpreter { // SAFETY: `bun_vm()` on a JS event loop returns the live // `*VirtualMachine` owning that loop. let vm = unsafe { &*vm_ptr }; - if let Some(main) = vm.main() { + if let Some(main) = vm.main_for_argv() { if int == 0 { out.extend_from_slice(main); return; diff --git a/src/watcher/Watcher.rs b/src/watcher/Watcher.rs index 1d105fe4e744..3123367c6a8c 100644 --- a/src/watcher/Watcher.rs +++ b/src/watcher/Watcher.rs @@ -793,6 +793,14 @@ impl Watcher { self.cwd } + /// Watches the directory of the entry point when `is_eligible_directory` leaves it out: a replaced entry file is found again through it. + #[cfg(not(windows))] + pub fn add_entry_point_directory(&mut self, dir: &'static [u8], hash: HashType) { + if !self.is_eligible_directory(dir) { + let _ = self.add_directory::(Fd::INVALID, dir, hash); + } + } + pub fn add_directory( &mut self, fd: Fd, diff --git a/test/cli/run/as-node.test.ts b/test/cli/run/as-node.test.ts index 4d291b398033..321b01225033 100644 --- a/test/cli/run/as-node.test.ts +++ b/test/cli/run/as-node.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; +import { mkdirSync, renameSync, rmSync, symlinkSync, writeFileSync } from "fs"; import { join } from "path"; -import { bunEnv, bunExe, fakeNodeRun, isMacOS, isWindows, tempDir } from "../../harness"; +import { bunEnv, bunExe, fakeNodeRun, isLinux, isMacOS, isWindows, tempDir } from "../../harness"; describe("fake node cli", () => { test("the node cli actually works", () => { @@ -131,4 +132,352 @@ describe("fake node cli", () => { expect(result.stderr.toString()).toContain("Missing script"); expect(result.success).toBe(false); }); + + // These tests are not concurrent. In a debug build each `bun --bun` deletes and makes again the + // directory of the `node` shim, so a concurrent one can fail with `Script not found "node"`. + + // `node ./pkg`, `node .`, `node ./entry` and `node ./bin/link` do not name the file that runs. + describe("an entry point that does not name the file that runs", () => { + const cjs = `console.log(JSON.stringify({ + argv1: process.argv[1], + filename: __filename, + requireMain: require.main === module, + mainModule: process.mainModule === module, + bunMain: Bun.main, + }));`; + const esm = `console.log(JSON.stringify({ + argv1: process.argv[1], + filename: import.meta.filename, + importMetaMain: import.meta.main, + bunMain: Bun.main, + }));`; + const entryPoints = [ + { name: "a directory", pkg: {}, file: "pkg/index.js", cwd: ".", arg: "./pkg", argv1: "pkg" }, + { + name: "`.` with a package.json main", + pkg: { main: "./lib/entry.js" }, + file: "pkg/lib/entry.js", + cwd: "pkg", + arg: ".", + argv1: "pkg", + }, + { + name: "a file without its extension", + pkg: {}, + file: "pkg/entry.js", + cwd: "pkg", + arg: "./entry", + argv1: "pkg/entry", + }, + ]; + + test.each(entryPoints)("CommonJS, $name", async ({ pkg, file, cwd, arg, argv1 }) => { + using temp = tempDir("fake-node-main", { "pkg/package.json": JSON.stringify(pkg), [file]: cjs }); + const result = await runAsNode(join(temp, cwd), [arg]); + expect({ ...result, stdout: parse(result.stdout) }).toEqual({ + stdout: { + argv1: join(temp, argv1), + filename: join(temp, file), + requireMain: true, + mainModule: true, + bunMain: join(temp, file), + }, + stderr: "", + exitCode: 0, + }); + }); + + test.each(entryPoints)("ESM, $name", async ({ pkg, file, cwd, arg, argv1 }) => { + using temp = tempDir("fake-node-main", { + "pkg/package.json": JSON.stringify({ ...pkg, type: "module" }), + [file]: esm, + }); + const result = await runAsNode(join(temp, cwd), [arg]); + expect({ ...result, stdout: parse(result.stdout) }).toEqual({ + stdout: { + argv1: join(temp, argv1), + filename: join(temp, file), + importMetaMain: true, + bunMain: join(temp, file), + }, + stderr: "", + exitCode: 0, + }); + }); + + test("CommonJS, a symlink", async () => { + using temp = tempDir("fake-node-main", { "pkg/package.json": "{}", "pkg/cli.js": cjs }); + mkdirSync(join(temp, "bin")); + symlinkSync(join("..", "pkg", "cli.js"), join(temp, "bin", "link")); + const result = await runAsNode(String(temp), ["./bin/link"]); + expect({ ...result, stdout: parse(result.stdout) }).toEqual({ + stdout: { + argv1: join(temp, "bin", "link"), + filename: join(temp, "pkg", "cli.js"), + requireMain: true, + mainModule: true, + bunMain: join(temp, "pkg", "cli.js"), + }, + stderr: "", + exitCode: 0, + }); + }); + + // Node keeps a symlink in the path of the main module only with `--preserve-symlinks-main`. + test("NODE_PRESERVE_SYMLINKS=1: the main module is the real path, and an import keeps the symlink", async () => { + using temp = tempDir("fake-node-main", { + "real/package.json": "{}", + "real/entry.js": `console.log(JSON.stringify({ + filename: __filename, + requireMain: require.main === module, + imported: require(process.cwd() + "/import.js"), + }));`, + "real/dep.js": "module.exports = __filename;", + "import.js": `module.exports = require("./link/dep.js");`, + }); + symlinkSync(join(temp, "real"), join(temp, "link"), "dir"); + const result = await runAsNode(String(temp), ["./link/entry.js"], { NODE_PRESERVE_SYMLINKS: "1" }); + expect({ ...result, stdout: parse(result.stdout) }).toEqual({ + stdout: { + filename: join(temp, "real", "entry.js"), + requireMain: true, + imported: join(temp, "link", "dep.js"), + }, + stderr: "", + exitCode: 0, + }); + }); + + test("NODE_PRESERVE_SYMLINKS=1 and --preserve-symlinks-main: the main module keeps the path through the symlink", async () => { + using temp = tempDir("fake-node-main", { + "real/package.json": "{}", + "real/entry.js": "console.log(__filename);", + }); + symlinkSync(join(temp, "real"), join(temp, "link"), "dir"); + const args = ["--preserve-symlinks-main", "./link/entry.js"]; + const result = await runAsNode(String(temp), args, { NODE_PRESERVE_SYMLINKS: "1" }); + expect(result).toEqual({ stdout: join(temp, "link", "entry.js") + "\n", stderr: "", exitCode: 0 }); + }); + + // The entry point imports a package from `node_modules` above it, which needs the parents of each new directory. + test.each([ + { name: "the index file of the directory", pkg: {}, arg: "./pkg", file: "pkg/index.js" }, + { + name: "the package.json main in new directories", + pkg: { main: "./dist/esm/main.js" }, + arg: "./pkg", + file: "pkg/dist/esm/main.js", + }, + { name: "a file in new directories", pkg: {}, arg: "./out/esm/main.js", file: "out/esm/main.js" }, + ])("a preload can create the entry point: $name", async ({ pkg, arg, file }) => { + using temp = tempDir("fake-node-main", { + "pkg/package.json": JSON.stringify(pkg), + "node_modules/dep/index.js": "module.exports = 'entry ran';", + "pre.js": ` + const { mkdirSync, writeFileSync } = require("fs"); + const { dirname, join } = require("path"); + const file = join(__dirname, ${JSON.stringify(file)}); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, "console.log(require('dep'));"); + `, + }); + const result = await runAsNode(String(temp), ["-r", "./pre.js", arg]); + expect(result).toEqual({ stdout: "entry ran\n", stderr: "", exitCode: 0 }); + }); + + test.each([ + { name: "a trailing separator", arg: (_: string) => "./missing/" }, + { name: "a doubled separator", arg: (temp: string) => temp + "//missing" }, + ])("an entry point that does not exist, with $name, fails as before", async ({ arg }) => { + using temp = tempDir("fake-node-main", {}); + const { stdout, stderr, exitCode } = await runAsNode(String(temp), [arg(String(temp))]); + expect({ stdout, error: stderr.split("\n")[0].slice(0, 24), exitCode }).toEqual({ + stdout: "", + error: "error: Module not found ", + exitCode: 1, + }); + }); + + test("a main field that is longer than a path is not found", async () => { + using temp = tempDir("fake-node-main", { + "pkg/package.json": JSON.stringify({ main: "./" + Buffer.alloc(8192, "a").toString() + ".js" }), + }); + const { stdout, stderr, exitCode } = await runAsNode(String(temp), ["./pkg"]); + expect({ stdout, error: stderr.split("\n")[0].slice(0, 24), exitCode }).toEqual({ + stdout: "", + error: "error: Module not found ", + exitCode: 1, + }); + }); + + test("a package.json that does not parse adds no output", async () => { + using temp = tempDir("fake-node-main", { + "pkg/package.json": "{", + "pkg/index.js": "console.log(require.main === module);", + }); + expect(await runAsNode(String(temp), ["./pkg"])).toEqual({ stdout: "true\n", stderr: "", exitCode: 0 }); + }); + + test("cron execution mode still calls scheduled()", async () => { + using temp = tempDir("fake-node-main", { + "pkg/package.json": JSON.stringify({ type: "module" }), + "pkg/index.js": "export default { scheduled(controller) { console.log(controller.cron); } };", + }); + const result = await runAsNode(String(temp), ["--cron-title=job", "--cron-period=* * * * *", "./pkg"]); + expect(result).toEqual({ stdout: "* * * * *\n", stderr: "", exitCode: 0 }); + }); + + test("the Bun shell `$1` is process.argv[1]", async () => { + using temp = tempDir("fake-node-main", { + "pkg/package.json": JSON.stringify({ type: "module" }), + "pkg/index.js": "console.log(JSON.stringify([process.argv[1], (await Bun.$`echo $1`.text()).trim()]));", + }); + const result = await runAsNode(String(temp), ["./pkg"]); + expect({ ...result, stdout: parse(result.stdout) }).toEqual({ + stdout: [join(temp, "pkg"), join(temp, "pkg")], + stderr: "", + exitCode: 0, + }); + }); + }); + + // Until the main module loads, Bun parses an import with an unknown extension as code. + describe("an import with an unknown extension is an asset", () => { + const files = { + "pkg/package.json": JSON.stringify({ type: "module" }), + "pkg/index.js": ` + import asset from "./asset.bin"; + import { basename } from "node:path"; + console.log(JSON.stringify({ filename: import.meta.filename, asset: basename(asset) }));`, + "pkg/asset.bin": "this is not javascript {", + }; + const loaded = (temp: string) => ({ + stdout: { filename: join(temp, "pkg", "index.js"), asset: "asset.bin" }, + stderr: "", + exitCode: 0, + }); + + test("when the entry point is a directory", async () => { + using temp = tempDir("fake-node-asset", files); + const result = await runAsNode(String(temp), ["./pkg"]); + expect({ ...result, stdout: parse(result.stdout) }).toEqual(loaded(temp)); + }); + + // On Windows the module loader names the entry point with `\`. + test("when the entry point is an absolute path with `/`", async () => { + using temp = tempDir("fake-node-asset", files); + const result = await runAsNode(String(temp), [join(temp, "pkg", "index.js").replaceAll("\\", "/")]); + expect({ ...result, stdout: parse(result.stdout) }).toEqual(loaded(temp)); + }); + + test("when the entry point is a symlink", async () => { + using temp = tempDir("fake-node-asset", files); + mkdirSync(join(temp, "bin")); + symlinkSync(join("..", "pkg", "index.js"), join(temp, "bin", "link")); + const result = await runAsNode(String(temp), ["./bin/link"]); + expect({ ...result, stdout: parse(result.stdout) }).toEqual(loaded(temp)); + }); + }); + + // The watcher finds a replaced entry point again by the path of the file that runs (`MainFile` in hot_reloader.rs). + // Not for Windows: its watcher does not read `MainFile`, and a rename over a file that is open can fail there. + describe.concurrent.skipIf(isWindows)("a watcher reloads an entry point that is replaced", () => { + const source = (n: number) => `console.log("ready ${n}"); globalThis.timer ??= setInterval(() => {}, 1000);`; + const entryPoints = [ + { name: "a directory", cwd: ".", arg: "./pkg", file: "pkg/index.js" }, + { name: "`.`", cwd: "pkg", arg: ".", file: "pkg/index.js" }, + { name: "a file without its extension", cwd: ".", arg: "./pkg/entry", file: "pkg/entry.js" }, + { name: "a symlink", cwd: ".", arg: "./bin/link", file: "pkg/cli.js" }, + // The watcher does not watch a directory outside the working directory for the files in it. + { name: "a directory outside the working directory", cwd: "work", arg: "../pkg", file: "pkg/index.js" }, + { name: "a file outside the working directory", cwd: "work", arg: "../pkg/index.js", file: "pkg/index.js" }, + ]; + type EntryPoint = (typeof entryPoints)[number]; + + function project({ file }: EntryPoint) { + const temp = tempDir("fake-node-hot", { "pkg/package.json": "{}", "work/package.json": "{}", [file]: source(1) }); + mkdirSync(join(temp, "bin")); + symlinkSync(join("..", "pkg", "cli.js"), join(temp, "bin", "link")); + return temp; + } + + function node(temp: string, flag: string, { cwd, arg }: EntryPoint) { + return Bun.spawn({ + cmd: [bunExe(), flag, arg], + argv0: "node", + cwd: join(temp, cwd), + env: { ...bunEnv, NODE_ENV: undefined }, + stdout: "pipe", + stderr: "pipe", + }); + } + + function replaceByRename(temp: string, { file }: EntryPoint) { + writeFileSync(join(temp, file + ".tmp"), source(2)); + renameSync(join(temp, file + ".tmp"), join(temp, file)); + } + + // Only inotify finds a file again that is deleted first. + test.skipIf(!isLinux).each(entryPoints)("--hot, $name, deleted and then created", async entryPoint => { + using temp = project(entryPoint); + await using proc = node(temp, "--hot", entryPoint); + const stdout = proc.stdout.getReader(); + await until(stdout, "ready 1"); + rmSync(join(temp, entryPoint.file)); + // The delete starts a reload, which fails. Without this wait that reload can find the new file. + await until(proc.stderr.getReader(), "Module not found"); + writeFileSync(join(temp, entryPoint.file), source(2)); + await until(stdout, "ready 2"); + }); + + test.each(entryPoints)("--hot, $name, replaced by a rename", async entryPoint => { + using temp = project(entryPoint); + await using proc = node(temp, "--hot", entryPoint); + const stdout = proc.stdout.getReader(); + await until(stdout, "ready 1"); + replaceByRename(temp, entryPoint); + await until(stdout, "ready 2"); + }); + + test.each(entryPoints)("--watch, $name, replaced by a rename", async entryPoint => { + using temp = project(entryPoint); + await using proc = node(temp, "--watch", entryPoint); + const stdout = proc.stdout.getReader(); + await until(stdout, "ready 1"); + replaceByRename(temp, entryPoint); + await until(stdout, "ready 2"); + }); + }); }); + +// Reads until `text` arrives. It throws when the stream ends first, so a child that exits fails the test. +async function until(reader: { read(): Promise<{ done: boolean; value?: Uint8Array }> }, text: string) { + const decoder = new TextDecoder(); + let seen = ""; + while (!seen.includes(text)) { + const { value, done } = await reader.read(); + if (done) throw new Error(`the stream ended before ${JSON.stringify(text)}, after ${JSON.stringify(seen)}`); + seen += decoder.decode(value, { stream: true }); + } +} + +async function runAsNode(cwd: string, args: string[], env?: Record) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--bun", "node", ...args], + cwd, + env: { ...bunEnv, NODE_ENV: undefined, ...env }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +// A child that fails prints no JSON. Keep what it printed for the failure message. +function parse(stdout: string) { + try { + return JSON.parse(stdout); + } catch { + return stdout; + } +}