From 748c552190fb3ad5669f9f8eaacfc79003edc2a2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:52:20 +0000 Subject: [PATCH 01/15] bundler: bundle template-literal require()/import() via glob When bundling, require(`./dir/${x}`) and import(`./dir/${x}`) now glob ./dir/**/* at build time and emit a lookup map of bundled modules, matching esbuild's behaviour. Previously these calls were left as unbundled runtime requires, so in a --compile'd binary they resolved relative to /$bunfs/root and failed with 'Cannot find module'. Fixes #13672 --- Cargo.lock | 1 + src/ast/runtime.rs | 46 ++--- src/js_parser/Cargo.toml | 1 + src/js_parser/p.rs | 276 ++++++++++++++++++++++++++++++ src/js_parser/visit/visit_expr.rs | 8 +- src/runtime.js | 7 + test/bundler/bundler_glob.test.ts | 230 +++++++++++++++++++++++++ 7 files changed, 548 insertions(+), 21 deletions(-) create mode 100644 test/bundler/bundler_glob.test.ts diff --git a/Cargo.lock b/Cargo.lock index eafb2b489d30..d2ad419fd695 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1061,6 +1061,7 @@ dependencies = [ "bun_core", "bun_crash_handler", "bun_dispatch", + "bun_glob", "bun_highway", "bun_io", "bun_options_types", diff --git a/src/ast/runtime.rs b/src/ast/runtime.rs index 80fec8848aa6..bb2f3ab00fc2 100644 --- a/src/ast/runtime.rs +++ b/src/ast/runtime.rs @@ -175,10 +175,11 @@ pub struct Imports { pub __promiseAll: Ref, pub __MEMO_CACHE_SENTINEL: Ref, pub __EARLY_RETURN_SENTINEL: Ref, + pub __glob: Ref, } impl Imports { - pub const ALL: [&'static [u8]; 27] = [ + pub const ALL: [&'static [u8]; 28] = [ b"__name", b"__require", b"__export", @@ -206,12 +207,13 @@ impl Imports { b"__promiseAll", b"__MEMO_CACHE_SENTINEL", b"__EARLY_RETURN_SENTINEL", + b"__glob", ]; /// Rust stable cannot sort in `const`; precomputed here and verified by /// the test in `tests` below. #[cfg_attr(not(test), allow(dead_code))] - const ALL_SORTED: [&'static [u8]; 27] = [ + const ALL_SORTED: [&'static [u8]; 28] = [ b"$$typeof", b"__EARLY_RETURN_SENTINEL", b"__MEMO_CACHE_SENTINEL", @@ -222,6 +224,7 @@ impl Imports { b"__export", b"__exportDefault", b"__exportValue", + b"__glob", b"__jsonParse", b"__legacyDecorateClassTS", b"__legacyDecorateParamTS", @@ -243,34 +246,35 @@ impl Imports { /// When generating the list of runtime imports, we sort it for determinism. /// This is a lookup table so we don't need to resort the strings each time - pub const ALL_SORTED_INDEX: [usize; 27] = [ - 15, // __name - 24, // __require + pub const ALL_SORTED_INDEX: [usize; 28] = [ + 16, // __name + 25, // __require 7, // __export - 23, // __reExport + 24, // __reExport 9, // __exportValue 8, // __exportDefault - 14, // __merge - 11, // __legacyDecorateClassTS - 12, // __legacyDecorateParamTS - 13, // __legacyMetadataTS - 22, // __publicField - 18, // __privateIn - 17, // __privateGet - 16, // __privateAdd - 20, // __privateSet - 19, // __privateMethod + 15, // __merge + 12, // __legacyDecorateClassTS + 13, // __legacyDecorateParamTS + 14, // __legacyMetadataTS + 23, // __publicField + 19, // __privateIn + 18, // __privateGet + 17, // __privateAdd + 21, // __privateSet + 20, // __privateMethod 6, // __decoratorStart 5, // __decoratorMetadata - 25, // __runInitializers + 26, // __runInitializers 4, // __decorateElement 0, // $$typeof - 26, // __using + 27, // __using 3, // __callDispose - 10, // __jsonParse - 21, // __promiseAll + 11, // __jsonParse + 22, // __promiseAll 2, // __MEMO_CACHE_SENTINEL 1, // __EARLY_RETURN_SENTINEL + 10, // __glob ]; pub const NAME: &'static [u8] = b"bun:wrap"; @@ -306,6 +310,7 @@ impl Imports { 24 => self.__promiseAll, 25 => self.__MEMO_CACHE_SENTINEL, 26 => self.__EARLY_RETURN_SENTINEL, + 27 => self.__glob, _ => return None, }; r.to_nullable() @@ -341,6 +346,7 @@ impl Imports { 24 => Some(&mut self.__promiseAll), 25 => Some(&mut self.__MEMO_CACHE_SENTINEL), 26 => Some(&mut self.__EARLY_RETURN_SENTINEL), + 27 => Some(&mut self.__glob), _ => None, } } diff --git a/src/js_parser/Cargo.toml b/src/js_parser/Cargo.toml index 627b5502d251..60b070aa745e 100644 --- a/src/js_parser/Cargo.toml +++ b/src/js_parser/Cargo.toml @@ -35,6 +35,7 @@ bun_ast.workspace = true bun_react_compiler = { path = "../react_compiler" } bun_options_types.workspace = true bun_paths.workspace = true +bun_glob.workspace = true bun_url.workspace = true bun_wyhash.workspace = true diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 2a4761ab6141..8f107e6e3412 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -1003,6 +1003,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ); } + if let Some(glob) = self.handle_glob_pattern(arg, false) { + return glob; + } + if self.options.warn_about_unbundled_modules { // Use a debug log so people can see this if they want to let r = js_lexer::range_of_identifier(self.source, state.loc); @@ -1237,6 +1241,251 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } + /// Try to recognise `require("./dir/" + x)` / `import("./dir/" + x)` (or the + /// template-literal equivalent) and lower it to a bundled lookup map so the + /// matching files are included in the output. Matches esbuild's behaviour: + /// see https://esbuild.github.io/api/#glob. Returns `None` when the pattern + /// does not qualify (no `./`/`../` prefix, fully opaque arg, etc.), in which + /// case the caller falls through to the unbundled runtime `require`. + pub fn handle_glob_pattern(&mut self, arg: Expr, is_require: bool) -> Option { + if !self.options.bundle || self.is_control_flow_dead { + return None; + } + // Only the `file` namespace has a source directory to glob from. + if !self.source.path.is_file() { + return None; + } + + // (text, is_wildcard) pairs flattened from the expression. + let mut raw_parts: smallvec::SmallVec<[(&'a [u8], bool); 8]> = smallvec::SmallVec::new(); + if !self.glob_parts_from_expr(arg, &mut raw_parts) { + return None; + } + + // Build the glob pattern. A wildcard immediately after `/` expands to + // `**/*` (may recurse into subdirectories); otherwise it is `*` (stays + // in the current directory). This matches esbuild and keeps + // `"./file-" + x + ".js"` from recursing. + let mut pattern = Vec::::new(); + let mut prefix_is_relative = false; + let mut had_wildcard = false; + let mut ends_in_slash = true; + for (text, is_wildcard) in &raw_parts { + if *is_wildcard { + had_wildcard = true; + if ends_in_slash { + pattern.extend_from_slice(b"**/*"); + } else { + pattern.push(b'*'); + } + ends_in_slash = false; + continue; + } + if pattern.is_empty() + && (strings::has_prefix_comptime(text, b"./") + || strings::has_prefix_comptime(text, b"../")) + { + prefix_is_relative = true; + } + // `*` / `?` / `[` / `{` in the literal text would change glob + // semantics relative to a substring match. esbuild escapes them; we + // don't have a glob-escape helper, so bail to the runtime path. + if bun_glob::detect_glob_syntax(text) { + return None; + } + pattern.extend_from_slice(text); + if !text.is_empty() { + ends_in_slash = *text.last().unwrap() == b'/'; + } + } + // Fully-static strings go through the normal `require("...")` path. + if !had_wildcard || !prefix_is_relative { + return None; + } + + let source_dir = self.source.path.source_dir(); + if source_dir.is_empty() { + return None; + } + + // Walk the filesystem for matches. Any I/O error (including the source + // directory being virtual / not on disk) falls through to the unbundled + // path rather than failing the build. + let mut matches: Vec> = Vec::new(); + if !walk_glob_for_bundle(source_dir, &pattern, &mut matches) { + return None; + } + + // Nothing on disk matched: fall through so `check_dynamic_specifier` + // (and the `allowUnresolved` gate) still see this call, and runtime + // resolution can still pick up files that only exist at run time. + if matches.is_empty() { + return None; + } + matches.sort(); + + let loc = arg.loc; + let arena = self.arena; + let mut properties = BumpVec::::with_capacity_in(matches.len(), arena); + let handles_import_errors = self.fn_or_arrow_data_visit.try_body_count != 0; + + for abs in &matches { + // Key in the map is the same relative form the caller will pass at + // runtime (starts with `./` or `../`). Force POSIX separators so the + // keys match what the caller builds at runtime on every platform. + let rel = bun_paths::resolve_path::relative_platform::< + bun_paths::resolve_path::platform::Posix, + false, + >(source_dir, abs); + let rel_with_dot: &'a [u8] = if strings::has_prefix_comptime(rel, b"../") + || strings::has_prefix_comptime(rel, b"./") + { + arena.alloc_slice_copy(rel) + } else { + let mut buf = BumpVec::::with_capacity_in(rel.len() + 2, arena); + buf.extend_from_slice(b"./"); + buf.extend_from_slice(rel); + buf.into_bump_slice() + }; + + let abs_path: &'a [u8] = arena.alloc_slice_copy(abs); + let import_record_index = self.add_import_record( + if is_require { + ImportKind::Require + } else { + ImportKind::Dynamic + }, + loc, + abs_path, + ); + self.import_records.items_mut()[import_record_index as usize] + .flags + .set( + bun_ast::ImportRecordFlags::HANDLES_IMPORT_ERRORS, + handles_import_errors, + ); + self.import_records_for_current_part + .push(import_record_index); + + let value_expr = if is_require { + self.new_expr( + E::RequireString { + import_record_index, + ..Default::default() + }, + loc, + ) + } else { + let path_expr = self.new_expr(E::String::init(rel_with_dot), loc); + self.new_expr( + E::Import { + expr: path_expr, + import_record_index, + options: Expr::EMPTY, + }, + loc, + ) + }; + + let body = bun_core::handle_oom(G::FnBody::init_return_expr(arena, value_expr)); + let arrow = self.new_expr( + E::Arrow { + body, + prefer_expr: true, + ..Default::default() + }, + loc, + ); + + properties.push(G::Property { + key: Some(self.new_expr(E::String::init(rel_with_dot), loc)), + value: Some(arrow), + ..Default::default() + }); + } + + let map = self.new_expr( + E::Object { + properties: G::PropertyList::from_bump_vec(properties), + ..Default::default() + }, + loc, + ); + let glob_call = self.call_runtime(loc, b"__glob", ExprNodeList::init_one(map)); + Some(self.new_expr( + E::Call { + target: glob_call, + args: ExprNodeList::init_one(arg), + ..Default::default() + }, + loc, + )) + } + + /// Flattens `expr` into alternating literal / wildcard segments. Returns + /// `false` for shapes that cannot be analysed (tagged template, non-string + /// left operand of `+`, etc.). + fn glob_parts_from_expr( + &mut self, + expr: Expr, + out: &mut smallvec::SmallVec<[(&'a [u8], bool); 8]>, + ) -> bool { + match expr.data { + js_ast::ExprData::EString(mut s) => { + s.resolve_rope_if_needed(self.arena); + let bytes = match s.string(self.arena) { + Ok(b) => b, + Err(_) => return false, + }; + out.push((bytes, false)); + true + } + js_ast::ExprData::ETemplate(tmpl) => { + if tmpl.tag.is_some() { + return false; + } + match &tmpl.head { + js_ast::e::TemplateContents::Cooked(head) => { + let bytes = match head.string(self.arena) { + Ok(b) => b, + Err(_) => return false, + }; + out.push((bytes, false)); + } + js_ast::e::TemplateContents::Raw(_) => return false, + } + for part in tmpl.parts().iter() { + if !self.glob_parts_from_expr(part.value, out) { + out.push((b"", true)); + } + match &part.tail { + js_ast::e::TemplateContents::Cooked(tail) => { + let bytes = match tail.string(self.arena) { + Ok(b) => b, + Err(_) => return false, + }; + out.push((bytes, false)); + } + js_ast::e::TemplateContents::Raw(_) => return false, + } + } + true + } + js_ast::ExprData::EBinary(bin) if bin.op == js_ast::op::Code::BinAdd => { + // The left side must itself be analysable so we have a literal + // prefix to anchor the glob. + if !self.glob_parts_from_expr(bin.left, out) { + return false; + } + if !self.glob_parts_from_expr(bin.right, out) { + out.push((b"", true)); + } + true + } + _ => false, + } + } + #[inline] pub fn should_unwrap_common_js_to_esm(&self) -> bool { self.options.features.unwrap_commonjs_to_esm @@ -9244,6 +9493,33 @@ pub trait GenerateImportSymbols { fn alias_name(&self, key: &Self::Key) -> &'static [u8]; } +/// Walk `source_dir` for `pattern` (relative, POSIX, may contain `**`/`*`), +/// pushing the absolute path of every file that matches into `out`. Returns +/// `false` on any I/O failure so the caller can fall back to the unbundled +/// runtime require. +fn walk_glob_for_bundle(source_dir: &[u8], pattern: &[u8], out: &mut Vec>) -> bool { + let mut walker = match bun_glob::BunGlobWalker::init_with_cwd( + pattern, source_dir, /*dot*/ false, /*absolute*/ true, + /*follow_symlinks*/ false, /*error_on_broken_symlinks*/ false, + /*only_files*/ true, None, + ) { + Ok(Ok(w)) => w, + _ => return false, + }; + let mut iter = bun_glob::walk::Iterator::new(&mut walker); + match iter.init() { + Ok(Ok(())) => {} + _ => return false, + } + loop { + match iter.next() { + Ok(Ok(Some(path))) => out.push(path), + Ok(Ok(None)) => return true, + _ => return false, + } + } +} + // ─── Module-level constructor fns ─── // These are fns rather than statics since `js_ast::ExprData` has interior // pointers and isn't `const`. diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index fa3efcb7c061..5a6e7f394c52 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -2033,7 +2033,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O *e = p.transpose_known_to_be_if_require(first, &state); return; } - _ => {} + _ => { + // require(`./dir/${x}`) => __glob({"./dir/a": () => require_a(), ...})(`./dir/${x}`) + if let Some(glob) = p.handle_glob_pattern(first, true) { + *e = glob; + return; + } + } } } diff --git a/src/runtime.js b/src/runtime.js index 7f0501db97c7..fcd2c8b498a5 100644 --- a/src/runtime.js +++ b/src/runtime.js @@ -109,6 +109,13 @@ var __moduleCache; // When you do know the module is CJS export var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); +// Map of bundled modules for `require("./dir/" + x)` / `import("./dir/" + x)` +export var __glob = map => path => { + var fn = map[path]; + if (fn) return fn(); + throw new Error("Module not found in bundle: " + path); +}; + export var __name = (target, name) => { Object.defineProperty(target, "name", { value: name, diff --git a/test/bundler/bundler_glob.test.ts b/test/bundler/bundler_glob.test.ts new file mode 100644 index 000000000000..9cf01e59863e --- /dev/null +++ b/test/bundler/bundler_glob.test.ts @@ -0,0 +1,230 @@ +import { describe } from "bun:test"; +import { itBundled } from "./expectBundled"; + +// Coverage for bundling `require("./dir/" + x)` / `import("./dir/" + x)` by +// glob-matching the pattern at build time, matching esbuild. +// https://github.com/oven-sh/bun/issues/13672 + +describe("bundler", () => { + itBundled("glob/RequireTemplateLiteral", { + target: "bun", + files: { + "/entry.js": /* js */ ` + const names = ["boa", "v8"]; + const out = []; + for (const n of names) { + const e = require(\`./engines/\${n}.js\`); + out.push(e.id); + } + console.log(out.join(",")); + `, + "/engines/boa.js": `module.exports = { id: "boa" };`, + "/engines/v8.js": `module.exports = { id: "v8" };`, + }, + run: { stdout: "boa,v8" }, + onAfterBundle(api) { + const out = api.readFile("/out.js"); + api.expectFile("/out.js").toContain("__glob"); + // Both matches bundled under the caller-visible relative key. + api.expectFile("/out.js").toContain(`"./engines/boa.js"`); + api.expectFile("/out.js").toContain(`"./engines/v8.js"`); + // Runtime require is not needed: everything is bundled. + if (out.includes("import.meta.require")) { + throw new Error("expected the glob map to replace the runtime require"); + } + }, + }); + + itBundled("glob/RequireStringConcat", { + target: "bun", + files: { + "/entry.js": /* js */ ` + function load(name) { + return require("./engines/" + name + ".js").id; + } + console.log(load("a") + "," + load("b")); + `, + "/engines/a.js": `module.exports = { id: "a" };`, + "/engines/b.js": `module.exports = { id: "b" };`, + }, + run: { stdout: "a,b" }, + onAfterBundle(api) { + api.expectFile("/out.js").toContain("__glob"); + }, + }); + + itBundled("glob/ImportTemplateLiteral", { + target: "bun", + files: { + "/entry.js": /* js */ ` + const name = "foo"; + const m = await import(\`./mods/\${name}.js\`); + console.log(m.default); + `, + "/mods/foo.js": `export default "FOO";`, + "/mods/bar.js": `export default "BAR";`, + }, + run: { stdout: "FOO" }, + onAfterBundle(api) { + api.expectFile("/out.js").toContain("__glob"); + api.expectFile("/out.js").toContain(`"./mods/foo.js"`); + api.expectFile("/out.js").toContain(`"./mods/bar.js"`); + }, + }); + + itBundled("glob/RequireKeyNotInMap", { + target: "bun", + files: { + "/entry.js": /* js */ ` + const name = globalThis.__missing ?? "missing"; + try { + require(\`./engines/\${name}.js\`); + console.log("unreachable"); + } catch (e) { + console.log(e.message.startsWith("Module not found in bundle:") ? "caught" : e.message); + } + `, + "/engines/a.js": `module.exports = 1;`, + }, + run: { stdout: "caught" }, + }); + + // No files match the pattern: leave the call as a runtime `require` so + // `allowUnresolved` still applies and runtime-created files can be loaded. + itBundled("glob/NoMatchesFallThrough", { + target: "bun", + files: { + "/entry.js": /* js */ ` + const name = globalThis.__x ?? "a"; + try { require(\`./nope/\${name}.js\`); } catch {} + console.log("ok"); + `, + }, + run: { stdout: "ok" }, + onAfterBundle(api) { + const out = api.readFile("/out.js"); + if (out.includes("__glob")) { + throw new Error("zero-match glob should fall through to runtime require"); + } + }, + }); + + itBundled("glob/IgnoresBarePackage", { + target: "bun", + files: { + "/entry.js": /* js */ ` + const name = "fs"; + const fs = require(\`node:\${name}\`); + console.log(typeof fs.readFileSync); + `, + }, + run: { stdout: "function" }, + onAfterBundle(api) { + const out = api.readFile("/out.js"); + if (out.includes("__glob")) { + throw new Error("bare package template should not be glob-bundled"); + } + }, + }); + + // `./file-${x}.js` has its wildcard mid-segment, so it should not recurse + // into subdirectories (`*`, not `**/*`). + itBundled("glob/WildcardNotAfterSlash", { + target: "bun", + files: { + "/entry.js": /* js */ ` + const n = "a"; + console.log(require(\`./file-\${n}.js\`)); + `, + "/file-a.js": `module.exports = "A";`, + "/file-b.js": `module.exports = "B";`, + "/sub/file-c.js": `module.exports = "C";`, + }, + run: { stdout: "A" }, + onAfterBundle(api) { + api.expectFile("/out.js").toContain(`"./file-a.js"`); + api.expectFile("/out.js").toContain(`"./file-b.js"`); + const out = api.readFile("/out.js"); + if (out.includes("file-c")) { + throw new Error("mid-segment wildcard should not recurse into subdirectories"); + } + }, + }); + + // `./engines/${f}` has its wildcard right after `/`, so it recurses. + itBundled("glob/WildcardAfterSlashRecurses", { + target: "bun", + files: { + "/entry.js": /* js */ ` + const p = "deep/inner"; + console.log(require(\`./engines/\${p}.js\`)); + `, + "/engines/top.js": `module.exports = "TOP";`, + "/engines/deep/inner.js": `module.exports = "INNER";`, + }, + run: { stdout: "INNER" }, + onAfterBundle(api) { + api.expectFile("/out.js").toContain(`"./engines/top.js"`); + api.expectFile("/out.js").toContain(`"./engines/deep/inner.js"`); + }, + }); + + itBundled("glob/ParentDirectory", { + target: "bun", + files: { + "/nested/entry.js": /* js */ ` + const n = "x"; + console.log(require(\`../other/\${n}.js\`)); + `, + "/other/x.js": `module.exports = "X";`, + }, + entryPoints: ["/nested/entry.js"], + run: { stdout: "X" }, + onAfterBundle(api) { + api.expectFile("/out.js").toContain(`"../other/x.js"`); + }, + }); + + // Literal `*` in a template segment would change glob semantics; bail to + // the runtime require rather than mis-globbing. + itBundled("glob/LiteralGlobCharsFallThrough", { + target: "bun", + files: { + "/entry.js": /* js */ ` + const name = "a"; + try { require(\`./dir*/\${name}.js\`); } catch {} + console.log("ok"); + `, + }, + run: { stdout: "ok" }, + onAfterBundle(api) { + const out = api.readFile("/out.js"); + if (out.includes("__glob")) { + throw new Error("literal * in template should fall through to runtime require"); + } + }, + }); + + // The original #13672 scenario: readdir + require(`./engines/${f}`), run + // as a compiled standalone executable from an unrelated cwd. + itBundled("compile/GlobRequireReaddir#13672", { + compile: true, + backend: "cli", + files: { + "/entry.js": /* js */ ` + const fs = require("fs"); + const path = require("path"); + const engines = {}; + for (const f of fs.readdirSync(path.join(__dirname, "engines"))) { + if (!f.endsWith(".js")) continue; + const e = require(\`./engines/\${f}\`); + engines[e.id] = e; + } + console.log(Object.keys(engines).sort().join(",")); + `, + "/engines/boa.js": `module.exports = { id: "boa" };`, + "/engines/v8.js": `module.exports = { id: "v8" };`, + }, + run: { stdout: "boa,v8", setCwd: false }, + }); +}); From b0c01030d04d7e80504a2861475074ffde8fcf2c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:52:45 +0000 Subject: [PATCH 02/15] glob-bundle: fall back to runtime require/import on map miss - __glob(map, fallback) tries fallback(path) on miss, so extensionless or directory-index specifiers ("./engines/" + "boa") still resolve the way they did before the matching files were bundled. - Thread TransposeState into the import() path so {with:{type}} and the .then().catch() / awaited-in-try error-handling heuristic are applied to the bundled matches and the fallback. - Record matched files by their relative specifier, not the globbed absolute path, so --external patterns and onResolve plugins see them. --- entry.js | 3 + src/js_parser/p.rs | 98 +++++++++++++++++++++++++++---- src/js_parser/visit/visit_expr.rs | 2 +- src/runtime.js | 7 ++- test/bundler/bundler_glob.test.ts | 66 ++++++++++++++++----- 5 files changed, 146 insertions(+), 30 deletions(-) create mode 100644 entry.js diff --git a/entry.js b/entry.js new file mode 100644 index 000000000000..5f4dc7d419df --- /dev/null +++ b/entry.js @@ -0,0 +1,3 @@ +export const id = "entry"; +const v = await import("./entry.js"); +console.log("loaded", v.id); diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 8f107e6e3412..9d819ece8856 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -1003,7 +1003,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ); } - if let Some(glob) = self.handle_glob_pattern(arg, false) { + if let Some(glob) = self.handle_glob_pattern(arg, Some(state)) { return glob; } @@ -1247,7 +1247,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O /// see https://esbuild.github.io/api/#glob. Returns `None` when the pattern /// does not qualify (no `./`/`../` prefix, fully opaque arg, etc.), in which /// case the caller falls through to the unbundled runtime `require`. - pub fn handle_glob_pattern(&mut self, arg: Expr, is_require: bool) -> Option { + /// + /// `import_state` is the surrounding `import()` visit state (`None` for + /// `require()`); it carries the second-argument options, the derived + /// loader override, and the `.then().catch()`/`await` error-handling info. + pub fn handle_glob_pattern( + &mut self, + arg: Expr, + import_state: Option<&TransposeState>, + ) -> Option { if !self.options.bundle || self.is_control_flow_dead { return None; } @@ -1327,7 +1335,16 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let loc = arg.loc; let arena = self.arena; let mut properties = BumpVec::::with_capacity_in(matches.len(), arena); - let handles_import_errors = self.fn_or_arrow_data_visit.try_body_count != 0; + let in_try = self.fn_or_arrow_data_visit.try_body_count != 0; + let handles_import_errors = match import_state { + None => in_try, + Some(s) => (s.is_await_target && in_try) || s.is_then_catch_target, + }; + let import_options = import_state + .map(|s| s.import_options) + .unwrap_or(Expr::EMPTY); + let import_loader = import_state.and_then(|s| s.import_loader); + let import_tag = import_state.and_then(|s| s.import_record_tag); for abs in &matches { // Key in the map is the same relative form the caller will pass at @@ -1348,26 +1365,35 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O buf.into_bump_slice() }; - let abs_path: &'a [u8] = arena.alloc_slice_copy(abs); + // Record the relative specifier (not the absolute path we globbed) + // so `--external` patterns and `onResolve` plugins match it the same + // as if the user had written `require("./engines/boa.js")`. let import_record_index = self.add_import_record( - if is_require { + if import_state.is_none() { ImportKind::Require } else { ImportKind::Dynamic }, loc, - abs_path, + rel_with_dot, ); - self.import_records.items_mut()[import_record_index as usize] - .flags - .set( + { + let rec = &mut self.import_records.items_mut()[import_record_index as usize]; + rec.flags.set( bun_ast::ImportRecordFlags::HANDLES_IMPORT_ERRORS, handles_import_errors, ); + if let Some(loader) = import_loader { + rec.loader = Some(loader); + } + if let Some(tag) = import_tag { + rec.tag = tag; + } + } self.import_records_for_current_part .push(import_record_index); - let value_expr = if is_require { + let value_expr = if import_state.is_none() { self.new_expr( E::RequireString { import_record_index, @@ -1381,7 +1407,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O E::Import { expr: path_expr, import_record_index, - options: Expr::EMPTY, + options: import_options, }, loc, ) @@ -1411,7 +1437,55 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O }, loc, ); - let glob_call = self.call_runtime(loc, b"__glob", ExprNodeList::init_one(map)); + + // `__glob` falls back to the runtime `require` / `import()` when the + // key is not in the map so extensionless or index-style specifiers + // (`"./engines/" + "boa"`) still resolve the way they did before the + // matching files were bundled. + let fallback = if !self.options.features.allow_runtime { + None + } else if import_state.is_none() { + self.record_usage_of_runtime_require(); + Some(self.value_for_require(loc)) + } else { + let param_ref = self.new_symbol(js_ast::symbol::Kind::Other, b"p"); + let param_expr = self.new_expr( + E::Identifier { + ref_: param_ref, + ..Default::default() + }, + loc, + ); + let import_expr = self.new_expr( + E::Import { + expr: param_expr, + import_record_index: u32::MAX, + options: import_options, + }, + loc, + ); + let body = bun_core::handle_oom(G::FnBody::init_return_expr(arena, import_expr)); + let param_binding = self.b(B::Identifier { r#ref: param_ref }, loc); + let args: &mut [G::Arg] = arena.alloc_slice_fill_with(1, |_| G::Arg { + binding: param_binding, + ..Default::default() + }); + Some(self.new_expr( + E::Arrow { + args: bun_ast::StoreSlice::new_mut(args), + body, + prefer_expr: true, + ..Default::default() + }, + loc, + )) + }; + + let glob_args = match fallback { + Some(f) => ExprNodeList::from_slice(&[map, f]), + None => ExprNodeList::init_one(map), + }; + let glob_call = self.call_runtime(loc, b"__glob", glob_args); Some(self.new_expr( E::Call { target: glob_call, diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 5a6e7f394c52..e11aafd46a67 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -2035,7 +2035,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } _ => { // require(`./dir/${x}`) => __glob({"./dir/a": () => require_a(), ...})(`./dir/${x}`) - if let Some(glob) = p.handle_glob_pattern(first, true) { + if let Some(glob) = p.handle_glob_pattern(first, None) { *e = glob; return; } diff --git a/src/runtime.js b/src/runtime.js index fcd2c8b498a5..d5cf2f4a8e86 100644 --- a/src/runtime.js +++ b/src/runtime.js @@ -109,10 +109,13 @@ var __moduleCache; // When you do know the module is CJS export var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); -// Map of bundled modules for `require("./dir/" + x)` / `import("./dir/" + x)` -export var __glob = map => path => { +// Map of bundled modules for `require("./dir/" + x)` / `import("./dir/" + x)`. +// `fallback` is the unbundled `require` / `import()` so an extensionless key or +// a file created at run time still resolves like it would have without the map. +export var __glob = (map, fallback) => path => { var fn = map[path]; if (fn) return fn(); + if (fallback) return fallback(path); throw new Error("Module not found in bundle: " + path); }; diff --git a/test/bundler/bundler_glob.test.ts b/test/bundler/bundler_glob.test.ts index 9cf01e59863e..9bbd4756da49 100644 --- a/test/bundler/bundler_glob.test.ts +++ b/test/bundler/bundler_glob.test.ts @@ -23,15 +23,10 @@ describe("bundler", () => { }, run: { stdout: "boa,v8" }, onAfterBundle(api) { - const out = api.readFile("/out.js"); api.expectFile("/out.js").toContain("__glob"); // Both matches bundled under the caller-visible relative key. api.expectFile("/out.js").toContain(`"./engines/boa.js"`); api.expectFile("/out.js").toContain(`"./engines/v8.js"`); - // Runtime require is not needed: everything is bundled. - if (out.includes("import.meta.require")) { - throw new Error("expected the glob map to replace the runtime require"); - } }, }); @@ -72,21 +67,62 @@ describe("bundler", () => { }, }); - itBundled("glob/RequireKeyNotInMap", { + // An extensionless key (`"./engines/" + "boa"`) is not in the bundled map; + // fall back to the runtime `require` so it resolves the same as before the + // files were bundled. Running from the source directory so the fallback + // can actually find the file on disk. + itBundled("glob/ExtensionlessFallbackRequire", { target: "bun", files: { "/entry.js": /* js */ ` - const name = globalThis.__missing ?? "missing"; - try { - require(\`./engines/\${name}.js\`); - console.log("unreachable"); - } catch (e) { - console.log(e.message.startsWith("Module not found in bundle:") ? "caught" : e.message); - } + const name = globalThis.__which ?? "boa"; + const e = require("./engines/" + name); + console.log(e.id); + `, + "/engines/boa.js": `module.exports = { id: "boa" };`, + "/engines/v8.js": `module.exports = { id: "v8" };`, + }, + outfile: "/out.js", + run: { stdout: "boa", setCwd: true }, + onAfterBundle(api) { + api.expectFile("/out.js").toContain("__glob"); + api.expectFile("/out.js").toContain(`"./engines/boa.js"`); + }, + }); + + itBundled("glob/ExtensionlessFallbackImport", { + target: "bun", + files: { + "/entry.js": /* js */ ` + const name = globalThis.__which ?? "boa"; + const e = await import("./engines/" + name); + console.log(e.default.id); + `, + "/engines/boa.js": `export default { id: "boa" };`, + }, + outfile: "/out.js", + run: { stdout: "boa", setCwd: true }, + onAfterBundle(api) { + api.expectFile("/out.js").toContain("(p) => import(p)"); + }, + }); + + // A second argument to `import()` carrying `with: { type }` must reach both + // the bundled matches and the runtime fallback. + itBundled("glob/ImportWithTypeOption", { + target: "bun", + files: { + "/entry.js": /* js */ ` + const name = globalThis.__which ?? "a"; + const m = await import(\`./data/\${name}.txt\`, { with: { type: "text" } }); + console.log(m.default); `, - "/engines/a.js": `module.exports = 1;`, + "/data/a.txt": "hello-from-text", + }, + run: { stdout: "hello-from-text" }, + onAfterBundle(api) { + api.expectFile("/out.js").toContain(`{ with: { type: "text" } }`); }, - run: { stdout: "caught" }, }); // No files match the pattern: leave the call as a runtime `require` so From a6b12b358cbc0d3bb37bff6a6fa6472cec9ee24c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:59:52 +0000 Subject: [PATCH 03/15] remove stray test file --- entry.js | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 entry.js diff --git a/entry.js b/entry.js deleted file mode 100644 index 5f4dc7d419df..000000000000 --- a/entry.js +++ /dev/null @@ -1,3 +0,0 @@ -export const id = "entry"; -const v = await import("./entry.js"); -console.log("loaded", v.id); From 190aff4f3d8246b1e47f4d6a99d28ff11f5d1e43 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:35:08 +0000 Subject: [PATCH 04/15] address review: Loose platform for Windows keys, flatten template ropes, glob ternary require branches, register fallback param symbol, trim comments --- src/js_parser/p.rs | 73 +++++++++++++------------------ src/runtime.js | 4 +- test/bundler/bundler_glob.test.ts | 68 ++++++++++++++++++++++++++-- 3 files changed, 95 insertions(+), 50 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 9d819ece8856..817f81193328 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -1227,6 +1227,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ) } _ => { + // Covers each branch of a ternary require() split apart by + // `maybe_transpose_if_require`. + if let Some(glob) = self.handle_glob_pattern(arg, None) { + return glob; + } let _ = self.check_dynamic_specifier(arg, arg.loc, "require()"); self.record_usage_of_runtime_require(); self.new_expr( @@ -1241,16 +1246,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - /// Try to recognise `require("./dir/" + x)` / `import("./dir/" + x)` (or the - /// template-literal equivalent) and lower it to a bundled lookup map so the - /// matching files are included in the output. Matches esbuild's behaviour: - /// see https://esbuild.github.io/api/#glob. Returns `None` when the pattern - /// does not qualify (no `./`/`../` prefix, fully opaque arg, etc.), in which - /// case the caller falls through to the unbundled runtime `require`. - /// - /// `import_state` is the surrounding `import()` visit state (`None` for - /// `require()`); it carries the second-argument options, the derived - /// loader override, and the `.then().catch()`/`await` error-handling info. + /// Lower `require("./dir/" + x)` / `import("./dir/" + x)` to a bundled + /// lookup map, matching esbuild (https://esbuild.github.io/api/#glob). + /// `None` means the pattern does not qualify and the caller falls through + /// to the unbundled runtime path. `import_state` is `None` for `require()`. pub fn handle_glob_pattern( &mut self, arg: Expr, @@ -1270,10 +1269,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O return None; } - // Build the glob pattern. A wildcard immediately after `/` expands to - // `**/*` (may recurse into subdirectories); otherwise it is `*` (stays - // in the current directory). This matches esbuild and keeps - // `"./file-" + x + ".js"` from recursing. + // Like esbuild: a wildcard after `/` becomes `**/*` (recursive), + // mid-segment it becomes `*` so `"./file-" + x + ".js"` stays flat. let mut pattern = Vec::::new(); let mut prefix_is_relative = false; let mut had_wildcard = false; @@ -1295,9 +1292,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O { prefix_is_relative = true; } - // `*` / `?` / `[` / `{` in the literal text would change glob - // semantics relative to a substring match. esbuild escapes them; we - // don't have a glob-escape helper, so bail to the runtime path. + // Glob metacharacters in literal text would change the match; bail. if bun_glob::detect_glob_syntax(text) { return None; } @@ -1316,17 +1311,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O return None; } - // Walk the filesystem for matches. Any I/O error (including the source - // directory being virtual / not on disk) falls through to the unbundled - // path rather than failing the build. let mut matches: Vec> = Vec::new(); if !walk_glob_for_bundle(source_dir, &pattern, &mut matches) { return None; } - // Nothing on disk matched: fall through so `check_dynamic_specifier` - // (and the `allowUnresolved` gate) still see this call, and runtime - // resolution can still pick up files that only exist at run time. + // Zero matches falls through so `check_dynamic_specifier` still runs. if matches.is_empty() { return None; } @@ -1347,11 +1337,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let import_tag = import_state.and_then(|s| s.import_record_tag); for abs in &matches { - // Key in the map is the same relative form the caller will pass at - // runtime (starts with `./` or `../`). Force POSIX separators so the - // keys match what the caller builds at runtime on every platform. + // `Loose` normalizes with host rules but emits POSIX separators, + // so keys match what callers build at runtime on every platform. let rel = bun_paths::resolve_path::relative_platform::< - bun_paths::resolve_path::platform::Posix, + bun_paths::resolve_path::platform::Loose, false, >(source_dir, abs); let rel_with_dot: &'a [u8] = if strings::has_prefix_comptime(rel, b"../") @@ -1365,9 +1354,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O buf.into_bump_slice() }; - // Record the relative specifier (not the absolute path we globbed) - // so `--external` patterns and `onResolve` plugins match it the same - // as if the user had written `require("./engines/boa.js")`. + // Relative specifier so `--external` and `onResolve` match it. let import_record_index = self.add_import_record( if import_state.is_none() { ImportKind::Require @@ -1438,10 +1425,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O loc, ); - // `__glob` falls back to the runtime `require` / `import()` when the - // key is not in the map so extensionless or index-style specifiers - // (`"./engines/" + "boa"`) still resolve the way they did before the - // matching files were bundled. + // On a map miss `__glob` falls back to the runtime `require`/`import()` + // so extensionless specifiers (`"./engines/" + "boa"`) still resolve. let fallback = if !self.options.features.allow_runtime { None } else if import_state.is_none() { @@ -1449,6 +1434,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O Some(self.value_for_require(loc)) } else { let param_ref = self.new_symbol(js_ast::symbol::Kind::Other, b"p"); + VecExt::append(&mut self.current_scope_mut().generated, param_ref); let param_expr = self.new_expr( E::Identifier { ref_: param_ref, @@ -1496,9 +1482,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O )) } - /// Flattens `expr` into alternating literal / wildcard segments. Returns - /// `false` for shapes that cannot be analysed (tagged template, non-string - /// left operand of `+`, etc.). + /// Flattens `expr` into alternating literal / wildcard segments. + /// `false` means the shape cannot be analysed (tagged template, etc). fn glob_parts_from_expr( &mut self, expr: Expr, @@ -1520,6 +1505,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } match &tmpl.head { js_ast::e::TemplateContents::Cooked(head) => { + // Copy so folding leftovers (ropes) can be flattened. + let mut head = *head; + head.resolve_rope_if_needed(self.arena); let bytes = match head.string(self.arena) { Ok(b) => b, Err(_) => return false, @@ -1534,6 +1522,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } match &part.tail { js_ast::e::TemplateContents::Cooked(tail) => { + let mut tail = *tail; + tail.resolve_rope_if_needed(self.arena); let bytes = match tail.string(self.arena) { Ok(b) => b, Err(_) => return false, @@ -1546,8 +1536,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O true } js_ast::ExprData::EBinary(bin) if bin.op == js_ast::op::Code::BinAdd => { - // The left side must itself be analysable so we have a literal - // prefix to anchor the glob. + // The left side must yield a literal prefix to anchor the glob. if !self.glob_parts_from_expr(bin.left, out) { return false; } @@ -9567,10 +9556,8 @@ pub trait GenerateImportSymbols { fn alias_name(&self, key: &Self::Key) -> &'static [u8]; } -/// Walk `source_dir` for `pattern` (relative, POSIX, may contain `**`/`*`), -/// pushing the absolute path of every file that matches into `out`. Returns -/// `false` on any I/O failure so the caller can fall back to the unbundled -/// runtime require. +/// Pushes the absolute path of every file under `source_dir` matching +/// `pattern` into `out`; `false` on any I/O failure. fn walk_glob_for_bundle(source_dir: &[u8], pattern: &[u8], out: &mut Vec>) -> bool { let mut walker = match bun_glob::BunGlobWalker::init_with_cwd( pattern, source_dir, /*dot*/ false, /*absolute*/ true, diff --git a/src/runtime.js b/src/runtime.js index d5cf2f4a8e86..793f20fedc70 100644 --- a/src/runtime.js +++ b/src/runtime.js @@ -109,9 +109,7 @@ var __moduleCache; // When you do know the module is CJS export var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); -// Map of bundled modules for `require("./dir/" + x)` / `import("./dir/" + x)`. -// `fallback` is the unbundled `require` / `import()` so an extensionless key or -// a file created at run time still resolves like it would have without the map. +// Lookup map for `require("./dir/" + x)`; `fallback` is the unbundled require/import. export var __glob = (map, fallback) => path => { var fn = map[path]; if (fn) return fn(); diff --git a/test/bundler/bundler_glob.test.ts b/test/bundler/bundler_glob.test.ts index 9bbd4756da49..84dba691d265 100644 --- a/test/bundler/bundler_glob.test.ts +++ b/test/bundler/bundler_glob.test.ts @@ -108,23 +108,83 @@ describe("bundler", () => { }); // A second argument to `import()` carrying `with: { type }` must reach both - // the bundled matches and the runtime fallback. + // the bundled matches and the runtime fallback. `.html` defaults to a + // non-text loader, so the raw-text output proves the attribute was applied. itBundled("glob/ImportWithTypeOption", { target: "bun", files: { "/entry.js": /* js */ ` const name = globalThis.__which ?? "a"; - const m = await import(\`./data/\${name}.txt\`, { with: { type: "text" } }); + const m = await import(\`./data/\${name}.html\`, { with: { type: "text" } }); console.log(m.default); `, - "/data/a.txt": "hello-from-text", + "/data/a.html": "hello-from-text", }, - run: { stdout: "hello-from-text" }, + run: { stdout: "hello-from-text" }, onAfterBundle(api) { api.expectFile("/out.js").toContain(`{ with: { type: "text" } }`); }, }); + // Both branches of a ternary argument get glob-bundled. + itBundled("glob/RequireTernaryArg", { + target: "bun", + files: { + "/entry.js": /* js */ ` + const n = "a"; + const which = globalThis.__y ? "y" : "x"; + const m = require(which === "y" ? \`./y/\${n}.js\` : \`./x/\${n}.js\`); + console.log(m); + `, + "/x/a.js": `module.exports = "X";`, + "/y/a.js": `module.exports = "Y";`, + }, + run: { stdout: "X" }, + onAfterBundle(api) { + api.expectFile("/out.js").toContain(`"./x/a.js"`); + api.expectFile("/out.js").toContain(`"./y/a.js"`); + }, + }); + + // A constant template part is folded into the head as a rope; the rope must + // be flattened so the pattern is "./v2/**/*.js", not "./v*.js". + itBundled("glob/TemplateFoldedConstantPart", { + target: "bun", + files: { + "/entry.js": /* js */ ` + const name = "a"; + console.log(require(\`./v\${2}/\${name}.js\`)); + `, + "/v2/a.js": `module.exports = "V2A";`, + }, + run: { stdout: "V2A" }, + onAfterBundle(api) { + api.expectFile("/out.js").toContain(`"./v2/a.js"`); + }, + }); + + // The generated fallback parameter must not shadow a user binding that is + // passed as the import options. + itBundled("glob/FallbackParamNoShadow", { + target: "bun", + files: { + "/entry.js": /* js */ ` + const p = { with: { type: "text" } }; + const name = globalThis.__which ?? "a"; + const m = await import(\`./data/\${name}.txt\`, p); + console.log(m.default); + `, + "/data/a.txt": "hello", + }, + run: { stdout: "hello" }, + onAfterBundle(api) { + const out = api.readFile("/out.js"); + if (out.includes("import(p, p)")) { + throw new Error("fallback parameter shadows user binding 'p'"); + } + }, + }); + // No files match the pattern: leave the call as a runtime `require` so // `allowUnresolved` still applies and runtime-created files can be loaded. itBundled("glob/NoMatchesFallThrough", { From d61806b1e926e4db27bdba9c2027e21fde9ac0a6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:06:51 +0000 Subject: [PATCH 05/15] glob-bundle: require a real path component in the prefix; update bundle-output snapshots for the __glob runtime helper --- src/js_parser/p.rs | 33 ++++++++++--------- .../__snapshots__/bun-build-api.test.ts.snap | 6 ++-- test/bundler/bundler_glob.test.ts | 21 ++++++++++++ .../bundler_promiseall_deadcode.test.ts | 4 +-- .../cyclic-imports-async-bundler.test.js | 2 +- 5 files changed, 45 insertions(+), 21 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 817f81193328..dfd9efc4a165 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -1269,10 +1269,20 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O return None; } + // Like esbuild, the literal prefix must be `./`/`../` plus at least one + // more character, so a bare "./" + x does not glob-bundle everything + // next to the importer. + match raw_parts.first() { + Some((text, false)) + if text.len() >= 3 + && (strings::has_prefix_comptime(text, b"./") + || strings::has_prefix_comptime(text, b"../")) => {} + _ => return None, + } + // Like esbuild: a wildcard after `/` becomes `**/*` (recursive), // mid-segment it becomes `*` so `"./file-" + x + ".js"` stays flat. let mut pattern = Vec::::new(); - let mut prefix_is_relative = false; let mut had_wildcard = false; let mut ends_in_slash = true; for (text, is_wildcard) in &raw_parts { @@ -1286,12 +1296,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ends_in_slash = false; continue; } - if pattern.is_empty() - && (strings::has_prefix_comptime(text, b"./") - || strings::has_prefix_comptime(text, b"../")) - { - prefix_is_relative = true; - } // Glob metacharacters in literal text would change the match; bail. if bun_glob::detect_glob_syntax(text) { return None; @@ -1302,7 +1306,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } // Fully-static strings go through the normal `require("...")` path. - if !had_wildcard || !prefix_is_relative { + if !had_wildcard { return None; } @@ -1435,6 +1439,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } else { let param_ref = self.new_symbol(js_ast::symbol::Kind::Other, b"p"); VecExt::append(&mut self.current_scope_mut().generated, param_ref); + self.record_declared_symbol(param_ref); let param_expr = self.new_expr( E::Identifier { ref_: param_ref, @@ -1499,14 +1504,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O out.push((bytes, false)); true } - js_ast::ExprData::ETemplate(tmpl) => { + js_ast::ExprData::ETemplate(mut tmpl) => { if tmpl.tag.is_some() { return false; } - match &tmpl.head { + match &mut tmpl.head { js_ast::e::TemplateContents::Cooked(head) => { - // Copy so folding leftovers (ropes) can be flattened. - let mut head = *head; + // Constant folding can leave the head as a rope. head.resolve_rope_if_needed(self.arena); let bytes = match head.string(self.arena) { Ok(b) => b, @@ -1516,13 +1520,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } js_ast::e::TemplateContents::Raw(_) => return false, } - for part in tmpl.parts().iter() { + for part in tmpl.parts_mut().iter_mut() { if !self.glob_parts_from_expr(part.value, out) { out.push((b"", true)); } - match &part.tail { + match &mut part.tail { js_ast::e::TemplateContents::Cooked(tail) => { - let mut tail = *tail; tail.resolve_rope_if_needed(self.arena); let bytes = match tail.string(self.arena) { Ok(b) => b, diff --git a/test/bundler/__snapshots__/bun-build-api.test.ts.snap b/test/bundler/__snapshots__/bun-build-api.test.ts.snap index a3e5110281b9..7003254d70d4 100644 --- a/test/bundler/__snapshots__/bun-build-api.test.ts.snap +++ b/test/bundler/__snapshots__/bun-build-api.test.ts.snap @@ -66,11 +66,11 @@ NS.then(({ fn: fn2 }) => { " `; -exports[`Bun.build BuildArtifact properties: hash 1`] = `"est79qzq"`; +exports[`Bun.build BuildArtifact properties: hash 1`] = `"bgsev3fg"`; -exports[`Bun.build BuildArtifact properties + entry.naming: hash 1`] = `"7gfnt0h6"`; +exports[`Bun.build BuildArtifact properties + entry.naming: hash 1`] = `"1vbevqqk"`; -exports[`Bun.build BuildArtifact properties sourcemap: hash index.js 1`] = `"est79qzq"`; +exports[`Bun.build BuildArtifact properties sourcemap: hash index.js 1`] = `"bgsev3fg"`; exports[`Bun.build BuildArtifact properties sourcemap: hash index.js.map 1`] = `"00000000"`; diff --git a/test/bundler/bundler_glob.test.ts b/test/bundler/bundler_glob.test.ts index 84dba691d265..52c2109c9f3b 100644 --- a/test/bundler/bundler_glob.test.ts +++ b/test/bundler/bundler_glob.test.ts @@ -185,6 +185,27 @@ describe("bundler", () => { }, }); + // A bare "./" prefix has no path component to anchor the glob; bundling + // every sibling of the importer would be far too greedy (esbuild bails too). + itBundled("glob/BareDotSlashFallsThrough", { + target: "bun", + files: { + "/entry.js": /* js */ ` + const name = globalThis.__x ?? "a"; + try { require(\`./\${name}\`); } catch {} + console.log("ok"); + `, + "/a.js": `module.exports = "A";`, + }, + run: { stdout: "ok" }, + onAfterBundle(api) { + const out = api.readFile("/out.js"); + if (out.includes("__glob")) { + throw new Error("bare ./ template should fall through to runtime require"); + } + }, + }); + // No files match the pattern: leave the call as a runtime `require` so // `allowUnresolved` still applies and runtime-created files can be loaded. itBundled("glob/NoMatchesFallThrough", { diff --git a/test/bundler/bundler_promiseall_deadcode.test.ts b/test/bundler/bundler_promiseall_deadcode.test.ts index 46c1ac38b7da..1f907533bd8f 100644 --- a/test/bundler/bundler_promiseall_deadcode.test.ts +++ b/test/bundler/bundler_promiseall_deadcode.test.ts @@ -164,7 +164,7 @@ describe("bundler", () => { var { AsyncEntryPoint: AsyncEntryPoint2 } = await Promise.resolve().then(() => exports_AsyncEntryPoint); AsyncEntryPoint2(); - //# debugId=42062903F19477CF64756E2164756E21 + //# debugId=7141FE02CB0F22E264756E2164756E21 //# sourceMappingURL=out.js.map " `); @@ -410,7 +410,7 @@ describe("bundler", () => { var { AsyncEntryPoint: AsyncEntryPoint2 } = await Promise.resolve().then(() => exports_AsyncEntryPoint); AsyncEntryPoint2(); - //# debugId=BF876FBF618133C264756E2164756E21 + //# debugId=02B60B4FF92A818164756E2164756E21 //# sourceMappingURL=out.js.map " `); diff --git a/test/regression/issue/cyclic-imports-async-bundler.test.js b/test/regression/issue/cyclic-imports-async-bundler.test.js index baf7cfd6ca87..906b5a14f799 100644 --- a/test/regression/issue/cyclic-imports-async-bundler.test.js +++ b/test/regression/issue/cyclic-imports-async-bundler.test.js @@ -180,7 +180,7 @@ test("cyclic imports with async dependencies should generate async wrappers", as var { AsyncEntryPoint: AsyncEntryPoint2 } = await Promise.resolve().then(() => exports_AsyncEntryPoint); AsyncEntryPoint2(); - //# debugId=2020261114B67BB564756E2164756E21 + //# debugId=37EEE4862DD2283364756E2164756E21 //# sourceMappingURL=entryBuild.js.map " `); From e18635cf4615e4fa872f1c36f9ab9bc893cc97c2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:37:21 +0000 Subject: [PATCH 06/15] glob-bundle: prefix must name a real path component so bare ../ does not glob the parent tree --- src/js_parser/p.rs | 12 ++++++------ test/bundler/bundler_glob.test.ts | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index dfd9efc4a165..44ecaae5a96a 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -1269,14 +1269,14 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O return None; } - // Like esbuild, the literal prefix must be `./`/`../` plus at least one - // more character, so a bare "./" + x does not glob-bundle everything - // next to the importer. + // The literal prefix must be `./`/`../` and name a real path component + // (not just dots and slashes), so a bare "./" + x or "../" + x does + // not glob-bundle an entire directory tree. match raw_parts.first() { Some((text, false)) - if text.len() >= 3 - && (strings::has_prefix_comptime(text, b"./") - || strings::has_prefix_comptime(text, b"../")) => {} + if (strings::has_prefix_comptime(text, b"./") + || strings::has_prefix_comptime(text, b"../")) + && text.iter().any(|&c| c != b'.' && c != b'/') => {} _ => return None, } diff --git a/test/bundler/bundler_glob.test.ts b/test/bundler/bundler_glob.test.ts index 52c2109c9f3b..6c5d35abcc55 100644 --- a/test/bundler/bundler_glob.test.ts +++ b/test/bundler/bundler_glob.test.ts @@ -206,6 +206,27 @@ describe("bundler", () => { }, }); + // Same for a bare "../" prefix: it would glob-bundle the entire parent tree. + itBundled("glob/BareDotDotSlashFallsThrough", { + target: "bun", + files: { + "/nested/entry.js": /* js */ ` + const name = globalThis.__x ?? "a"; + try { require(\`../\${name}\`); } catch {} + console.log("ok"); + `, + "/a.js": `module.exports = "A";`, + }, + entryPoints: ["/nested/entry.js"], + run: { stdout: "ok" }, + onAfterBundle(api) { + const out = api.readFile("/out.js"); + if (out.includes("__glob")) { + throw new Error("bare ../ template should fall through to runtime require"); + } + }, + }); + // No files match the pattern: leave the call as a runtime `require` so // `allowUnresolved` still applies and runtime-created files can be loaded. itBundled("glob/NoMatchesFallThrough", { From ae9d4df7c27fe490992d07f303235508d4713bbd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:52:25 +0000 Subject: [PATCH 07/15] glob-bundle: stack-overflow guard in glob_parts_from_expr --- src/js_parser/p.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 44ecaae5a96a..e584f20e4f03 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -1494,6 +1494,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O expr: Expr, out: &mut smallvec::SmallVec<[(&'a [u8], bool); 8]>, ) -> bool { + if !self.stack_check.is_safe_to_recurse() || self.reported_stack_overflow.get() { + self.report_stack_overflow(expr.loc); + return false; + } match expr.data { js_ast::ExprData::EString(mut s) => { s.resolve_rope_if_needed(self.arena); From bafd3c8d635909fa5ee3c590e041797d902fd882 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:14:43 +0000 Subject: [PATCH 08/15] update html-import manifest hashes for the __glob runtime helper --- test/bundler/html-import-manifest.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/bundler/html-import-manifest.test.ts b/test/bundler/html-import-manifest.test.ts index fbcf26e91598..74215705e8b4 100644 --- a/test/bundler/html-import-manifest.test.ts +++ b/test/bundler/html-import-manifest.test.ts @@ -106,11 +106,11 @@ console.log(favicon); "files": [ { "input": "client.html", - "path": "./client-sjg7egv9.js", + "path": "./client-m110bjd9.js", "loader": "js", "isEntry": true, "headers": { - "etag": "efKwB-6QGwk", + "etag": "VOGhIOsSzQk", "content-type": "text/javascript;charset=utf-8" } }, @@ -120,7 +120,7 @@ console.log(favicon); "loader": "html", "isEntry": true, "headers": { - "etag": "sJJm55rxM4I", + "etag": "-sM_LDk0GTo", "content-type": "text/html;charset=utf-8" } }, From fe0cbd402cca4fa696be1a83ce067f128f61c2ec Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:43:06 +0000 Subject: [PATCH 09/15] use not.toContain for negative bundle-output assertions --- test/bundler/bundler_glob.test.ts | 35 +++++++------------------------ 1 file changed, 7 insertions(+), 28 deletions(-) diff --git a/test/bundler/bundler_glob.test.ts b/test/bundler/bundler_glob.test.ts index 6c5d35abcc55..46a51b2e185e 100644 --- a/test/bundler/bundler_glob.test.ts +++ b/test/bundler/bundler_glob.test.ts @@ -178,10 +178,7 @@ describe("bundler", () => { }, run: { stdout: "hello" }, onAfterBundle(api) { - const out = api.readFile("/out.js"); - if (out.includes("import(p, p)")) { - throw new Error("fallback parameter shadows user binding 'p'"); - } + api.expectFile("/out.js").not.toContain("import(p, p)"); }, }); @@ -199,10 +196,7 @@ describe("bundler", () => { }, run: { stdout: "ok" }, onAfterBundle(api) { - const out = api.readFile("/out.js"); - if (out.includes("__glob")) { - throw new Error("bare ./ template should fall through to runtime require"); - } + api.expectFile("/out.js").not.toContain("__glob"); }, }); @@ -220,10 +214,7 @@ describe("bundler", () => { entryPoints: ["/nested/entry.js"], run: { stdout: "ok" }, onAfterBundle(api) { - const out = api.readFile("/out.js"); - if (out.includes("__glob")) { - throw new Error("bare ../ template should fall through to runtime require"); - } + api.expectFile("/out.js").not.toContain("__glob"); }, }); @@ -240,10 +231,7 @@ describe("bundler", () => { }, run: { stdout: "ok" }, onAfterBundle(api) { - const out = api.readFile("/out.js"); - if (out.includes("__glob")) { - throw new Error("zero-match glob should fall through to runtime require"); - } + api.expectFile("/out.js").not.toContain("__glob"); }, }); @@ -258,10 +246,7 @@ describe("bundler", () => { }, run: { stdout: "function" }, onAfterBundle(api) { - const out = api.readFile("/out.js"); - if (out.includes("__glob")) { - throw new Error("bare package template should not be glob-bundled"); - } + api.expectFile("/out.js").not.toContain("__glob"); }, }); @@ -282,10 +267,7 @@ describe("bundler", () => { onAfterBundle(api) { api.expectFile("/out.js").toContain(`"./file-a.js"`); api.expectFile("/out.js").toContain(`"./file-b.js"`); - const out = api.readFile("/out.js"); - if (out.includes("file-c")) { - throw new Error("mid-segment wildcard should not recurse into subdirectories"); - } + api.expectFile("/out.js").not.toContain("file-c"); }, }); @@ -336,10 +318,7 @@ describe("bundler", () => { }, run: { stdout: "ok" }, onAfterBundle(api) { - const out = api.readFile("/out.js"); - if (out.includes("__glob")) { - throw new Error("literal * in template should fall through to runtime require"); - } + api.expectFile("/out.js").not.toContain("__glob"); }, }); From 3fb524bcfb4182aec5d7a3bd6e8b5303be76930a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:20:37 +0000 Subject: [PATCH 10/15] glob-bundle: follow symlinks when walking matches --- src/js_parser/p.rs | 2 +- test/bundler/bundler_glob.test.ts | 40 ++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index e584f20e4f03..f7ec170a2708 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -9568,7 +9568,7 @@ pub trait GenerateImportSymbols { fn walk_glob_for_bundle(source_dir: &[u8], pattern: &[u8], out: &mut Vec>) -> bool { let mut walker = match bun_glob::BunGlobWalker::init_with_cwd( pattern, source_dir, /*dot*/ false, /*absolute*/ true, - /*follow_symlinks*/ false, /*error_on_broken_symlinks*/ false, + /*follow_symlinks*/ true, /*error_on_broken_symlinks*/ false, /*only_files*/ true, None, ) { Ok(Ok(w)) => w, diff --git a/test/bundler/bundler_glob.test.ts b/test/bundler/bundler_glob.test.ts index 46a51b2e185e..8b1727ebef32 100644 --- a/test/bundler/bundler_glob.test.ts +++ b/test/bundler/bundler_glob.test.ts @@ -1,4 +1,7 @@ -import { describe } from "bun:test"; +import { describe, expect, test } from "bun:test"; +import { symlinkSync } from "node:fs"; +import { join } from "node:path"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import { itBundled } from "./expectBundled"; // Coverage for bundling `require("./dir/" + x)` / `import("./dir/" + x)` by @@ -345,3 +348,38 @@ describe("bundler", () => { run: { stdout: "boa,v8", setCwd: false }, }); }); + +// A symlinked file under the wildcard is followed and bundled like the +// resolver would. itBundled cannot create symlinks, so this one is manual. +test.skipIf(isWindows)("glob/SymlinkedFileIsBundled", async () => { + using dir = tempDir("bundler-glob-symlink", { + "entry.js": `const n = "a";\nconsole.log(require(\`./engines/\${n}.js\`));`, + "engines/b.js": `module.exports = "B";`, + "real/a.js": `module.exports = "REAL-A";`, + }); + symlinkSync(join(String(dir), "real", "a.js"), join(String(dir), "engines", "a.js")); + + await using build = Bun.spawn({ + cmd: [bunExe(), "build", "--target=bun", "./entry.js", "--outfile=out.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [buildStderr, buildExitCode] = await Promise.all([build.stderr.text(), build.exited]); + expect(buildStderr).toBe(""); + expect(buildExitCode).toBe(0); + + const out = await Bun.file(join(String(dir), "out.js")).text(); + expect(out).toContain(`"./engines/a.js"`); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "./out.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toBe("REAL-A\n"); + expect(exitCode).toBe(0); +}); From 15f5be99e5fe8e485a174968513c27204ea3ecc1 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:22:47 +0000 Subject: [PATCH 11/15] [autofix.ci] apply automated fixes --- test/bundler/bundler_glob.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/bundler/bundler_glob.test.ts b/test/bundler/bundler_glob.test.ts index 8b1727ebef32..0512997aae63 100644 --- a/test/bundler/bundler_glob.test.ts +++ b/test/bundler/bundler_glob.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import { symlinkSync } from "node:fs"; import { join } from "node:path"; -import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import { itBundled } from "./expectBundled"; // Coverage for bundling `require("./dir/" + x)` / `import("./dir/" + x)` by From c8baf2320b6f6ee4bb3c0d5c58c5ab6e217c9b24 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:05:00 +0000 Subject: [PATCH 12/15] drain build stdout in symlink test --- test/bundler/bundler_glob.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/bundler/bundler_glob.test.ts b/test/bundler/bundler_glob.test.ts index 0512997aae63..94a55827c2ef 100644 --- a/test/bundler/bundler_glob.test.ts +++ b/test/bundler/bundler_glob.test.ts @@ -365,7 +365,11 @@ test.skipIf(isWindows)("glob/SymlinkedFileIsBundled", async () => { cwd: String(dir), stderr: "pipe", }); - const [buildStderr, buildExitCode] = await Promise.all([build.stderr.text(), build.exited]); + const [, buildStderr, buildExitCode] = await Promise.all([ + build.stdout.text(), + build.stderr.text(), + build.exited, + ]); expect(buildStderr).toBe(""); expect(buildExitCode).toBe(0); From d57f82e6156a6b7790ae3c847cb69c3adf89cbda Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:06:45 +0000 Subject: [PATCH 13/15] [autofix.ci] apply automated fixes --- test/bundler/bundler_glob.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/test/bundler/bundler_glob.test.ts b/test/bundler/bundler_glob.test.ts index 94a55827c2ef..440c652c7e79 100644 --- a/test/bundler/bundler_glob.test.ts +++ b/test/bundler/bundler_glob.test.ts @@ -365,11 +365,7 @@ test.skipIf(isWindows)("glob/SymlinkedFileIsBundled", async () => { cwd: String(dir), stderr: "pipe", }); - const [, buildStderr, buildExitCode] = await Promise.all([ - build.stdout.text(), - build.stderr.text(), - build.exited, - ]); + const [, buildStderr, buildExitCode] = await Promise.all([build.stdout.text(), build.stderr.text(), build.exited]); expect(buildStderr).toBe(""); expect(buildExitCode).toBe(0); From 4daed74ba2a9673273deb5c4b5e466c6a6c4a0b4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:14:33 +0000 Subject: [PATCH 14/15] test glob records against onResolve plugins and external patterns --- test/bundler/bundler_glob.test.ts | 42 ++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/test/bundler/bundler_glob.test.ts b/test/bundler/bundler_glob.test.ts index 440c652c7e79..cd0fee5af4fd 100644 --- a/test/bundler/bundler_glob.test.ts +++ b/test/bundler/bundler_glob.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import { symlinkSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { itBundled } from "./expectBundled"; // Coverage for bundling `require("./dir/" + x)` / `import("./dir/" + x)` by @@ -325,6 +325,46 @@ describe("bundler", () => { }, }); + // Glob-generated records carry the relative specifier, so onResolve + // plugins can redirect them like a hand-written require. + itBundled("glob/OnResolvePluginApplies", { + target: "bun", + files: { + "/entry.js": /* js */ ` + const n = "a"; + console.log(require(\`./engines/\${n}.js\`)); + `, + "/engines/a.js": `module.exports = "ORIGINAL";`, + "/redirected.js": `module.exports = "REDIRECTED";`, + }, + plugins(builder) { + builder.onResolve({ filter: /engines\/a\.js$/ }, args => { + return { path: resolve(dirname(args.importer), "redirected.js") }; + }); + }, + run: { stdout: "REDIRECTED" }, + }); + + // `--external` patterns match the relative specifier too, leaving the + // matched file unbundled. + itBundled("glob/ExternalPatternApplies", { + target: "bun", + files: { + "/entry.js": /* js */ ` + const n = "a"; + console.log(require(\`./engines/\${n}.js\`)); + `, + "/engines/a.js": `module.exports = "FROM-DISK";`, + }, + external: ["./engines/*"], + outfile: "/out.js", + run: { stdout: "FROM-DISK", setCwd: true }, + onAfterBundle(api) { + api.expectFile("/out.js").toContain(`require("./engines/a.js")`); + api.expectFile("/out.js").not.toContain("FROM-DISK"); + }, + }); + // The original #13672 scenario: readdir + require(`./engines/${f}`), run // as a compiled standalone executable from an unrelated cwd. itBundled("compile/GlobRequireReaddir#13672", { From ea58737fb8c6f75bfc6c24e928e6190ea65c40b4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 06:29:39 +0000 Subject: [PATCH 15/15] ci: retrigger