From 7bcb3975dcf2da781accd9ec886416ba803fd773 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:59:53 +0000 Subject: [PATCH 1/4] bundler: lower import.meta to a per-file object in cjs and iife output import.meta is a syntax error outside of an ES module, but the bundler only inlined a handful of its properties for --format=cjs and printed every other reference verbatim, so the output was rejected by node and by bun's @bun-cjs wrapper, and --bytecode could not compile it. iife output had the same gap for every property. When the output format is cjs, or iife for a target other than bun, the visit pass now rewrites import.meta to a generated import_meta symbol. Property accesses with a bundle-time value (dir, dirname, file, path, filename, url, main, hot) are inlined off that symbol as before; any reference that survives makes the file declare `var import_meta = {}` in a tree-shakable part and reports a warning at the site. Assignment and delete targets are no longer inlined, since that printed `"file:///..." = x`. --- src/bundler/ParseTask.rs | 14 ++ src/bundler/transpiler.rs | 1 + src/js_parser/fold.rs | 156 ++++++++++------ src/js_parser/p.rs | 63 +++++++ src/js_parser/parse/parse_entry.rs | 77 ++++++++ src/js_parser/parser.rs | 10 + src/js_parser/visit/visit_expr.rs | 7 + test/bundler/bundler_edgecase.test.ts | 254 ++++++++++++++++++++++++++ 8 files changed, 523 insertions(+), 59 deletions(-) diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index 2248a6632596..266cdd63b924 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -2604,6 +2604,20 @@ pub mod parse_worker { opts.lower_import_meta_main_for_node_js = true; } + // `import.meta` is a syntax error outside of an ES module. cjs output is + // never a module (for bun it is evaluated through the `@bun-cjs` function + // wrapper, which is also what `--bytecode` compiles), and iife output is a + // script everywhere except for bun, which loads its `// @bun` output as a + // module. The runtime source is excluded: its only `import.meta` uses are + // the `__require` definitions (RUNTIME_REQUIRE_*), which an empty object + // could not satisfy. + opts.lower_import_meta = !task.source_index.is_runtime() + && match output_format { + options::Format::Cjs => true, + options::Format::Iife => !target.is_bun(), + options::Format::Esm | options::Format::InternalBakeDev => false, + }; + opts.tree_shaking = if task.source_index.is_runtime() { true } else { diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index d9a670faaaf4..529403c62f8f 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -1585,6 +1585,7 @@ impl<'a> Transpiler<'a> { transform_only: self.options.transform_only, import_meta_main_value: None, lower_import_meta_main_for_node_js: false, + lower_import_meta: false, framework: None, repl_mode: self.options.repl_mode, lower_toml_datetimes: false, diff --git a/src/js_parser/fold.rs b/src/js_parser/fold.rs index 74125b583e17..e77e3e7ffc8f 100644 --- a/src/js_parser/fold.rs +++ b/src/js_parser/fold.rs @@ -3,7 +3,7 @@ use bun_collections::VecExt; use bun_core::feature_flags as FeatureFlags; use crate::p::P; -use crate::parser::{self as js_parser, IdentifierOpts, RelocateVars, RelocateVarsMode}; +use crate::parser::{IdentifierOpts, RelocateVars, RelocateVarsMode}; use bun_ast::ast_result::CommonJSNamedExport; use bun_ast::{self as js_ast, Binding, E, Expr, Flags, G, LocRef, S}; @@ -130,6 +130,26 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut sw_data = target.data; 'sw: loop { match sw_data { + js_ast::ExprData::EImportMeta(_) => { + return p.maybe_rewrite_import_meta_property( + target, + name, + name_loc, + loc, + identifier_opts, + ); + } + // `options.lower_import_meta` already replaced `import.meta` with + // the `import_meta` stand-in when the target was visited. + js_ast::ExprData::EIdentifier(id) if p.is_import_meta_stand_in(id.ref_) => { + return p.maybe_rewrite_import_meta_property( + target, + name, + name_loc, + loc, + identifier_opts, + ); + } js_ast::ExprData::EIdentifier(id) => { // Rewrite property accesses on explicit namespace imports as an identifier. // This lets us replace them easily in the printer to rebind them to @@ -492,64 +512,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } } - js_ast::ExprData::EImportMeta(_) => { - if name == b"main" { - return Some(p.value_for_import_meta_main(false, target.loc)); - } - - if name == b"hot" { - return Some(Expr { - data: js_ast::ExprData::ESpecial( - if p.options.features.hot_module_reloading { - E::Special::HotEnabled - } else { - E::Special::HotDisabled - }, - ), - loc, - }); - } - - // Inline import.meta properties for Bake - if p.options.framework.is_some() - || (p.options.bundle - && p.options.output_format == js_parser::options::Format::Cjs) - { - if name == b"dir" || name == b"dirname" { - // Inline import.meta.dir - return Some( - p.new_expr(e_string_init(p.source.path.name().dir), name_loc), - ); - } else if name == b"file" { - // Inline import.meta.file (filename only) - return Some( - p.new_expr(e_string_init(p.source.path.name().filename), name_loc), - ); - } else if name == b"path" { - // Inline import.meta.path (full path) - return Some(p.new_expr(e_string_init(p.source.path.text), name_loc)); - } else if name == b"url" { - // Inline import.meta.url as file:// URL - let bunstr = bun_core::String::from_bytes(p.source.path.text); - let url = p.arena.alloc_slice_copy( - format!("{}", bun_url::file_url_from_string(&bunstr)).as_bytes(), - ); - return Some(p.new_expr(e_string_init(url), name_loc)); - } - } - - // Make all property accesses on `import.meta.url` side effect free. - return Some(p.new_expr( - E::Dot { - target, - name: name_static, - name_loc, - can_be_removed_if_unused: true, - ..Default::default() - }, - target.loc, - )); - } js_ast::ExprData::ERequireCallTarget => { if name == b"main" { return Some(Expr { @@ -747,6 +709,82 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O None } + /// `target` is `import.meta`, or the `import_meta` stand-in it was + /// rewritten to under `options.lower_import_meta`. + fn maybe_rewrite_import_meta_property( + &mut self, + target: Expr, + name: &'a [u8], + name_loc: bun_ast::Loc, + loc: bun_ast::Loc, + identifier_opts: IdentifierOpts, + ) -> Option { + let p = self; + + // `import.meta.url = x` / `delete import.meta.url` must stay a property + // access: inlining the value would print `"file:///..." = x`. + if identifier_opts.assign_target() != js_ast::AssignTarget::None + || identifier_opts.is_delete_target() + { + return None; + } + + let inlined: Option = if name == b"main" { + Some(p.value_for_import_meta_main(false, target.loc)) + } else if name == b"hot" { + Some(Expr { + data: js_ast::ExprData::ESpecial(if p.options.features.hot_module_reloading { + E::Special::HotEnabled + } else { + E::Special::HotDisabled + }), + loc, + }) + } else if p.options.framework.is_some() || p.options.lower_import_meta { + // Bake serves its own `import.meta` object, and non-module output has + // none at all, so the properties whose value is known at bundle time + // are inlined. + match name { + b"dir" | b"dirname" => { + Some(p.new_expr(e_string_init(p.source.path.name().dir), name_loc)) + } + b"file" => Some(p.new_expr(e_string_init(p.source.path.name().filename), name_loc)), + b"path" | b"filename" => { + Some(p.new_expr(e_string_init(p.source.path.text), name_loc)) + } + b"url" => { + let bunstr = bun_core::String::from_bytes(p.source.path.text); + let url = p.arena.alloc_slice_copy( + format!("{}", bun_url::file_url_from_string(&bunstr)).as_bytes(), + ); + Some(p.new_expr(e_string_init(url), name_loc)) + } + _ => None, + } + } else { + None + }; + + if let Some(inlined) = inlined { + if matches!(target.data, js_ast::ExprData::EIdentifier(_)) { + p.ignore_usage_of_import_meta(&target); + } + return Some(inlined); + } + + // Make all property accesses on `import.meta` side effect free. + Some(p.new_expr( + E::Dot { + target, + name: E::Str::new(name), + name_loc, + can_be_removed_if_unused: true, + ..Default::default() + }, + target.loc, + )) + } + fn maybe_rewrite_property_access_for_namespace( &mut self, name: &'a [u8], diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 285111d1dc80..0677b4c2f3e9 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -237,7 +237,19 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { pub(crate) module_ref: Ref, pub(crate) filename_ref: Ref, pub(crate) dirname_ref: Ref, + /// Identifier standing in for `import.meta` when the output is not an ES + /// module. Runtime CommonJS modules bind it as the `$Bun_import_meta` + /// parameter of the module wrapper (see `to_ast`, the printer substitutes + /// it); bundles with `options.lower_import_meta` declare it as a per-file + /// `var import_meta = {}` and the visit pass rewrites every remaining + /// `import.meta` to it (see `value_for_import_meta`). pub(crate) import_meta_ref: Ref, + /// `lower_import_meta` only: the `import.meta` expressions rewritten to + /// `import_meta_ref` whose value is therefore the empty object. A property + /// access that gets inlined removes its entry again + /// (`ignore_usage_of_import_meta`); whatever is left is warned about once + /// the visit pass is done. + pub(crate) empty_import_meta_locs: List<'a, bun_ast::Loc>, pub(crate) hmr_api_ref: Ref, /// If bake is enabled and this is a server-side file, we want to use @@ -5254,6 +5266,56 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } + /// `options.lower_import_meta`: the replacement for an `import.meta` + /// expression, a reference to this file's `var import_meta = {}`. The + /// declaration itself is added after the visit pass, and only if a + /// reference survives `maybe_rewrite_import_meta_property`. + pub(crate) fn value_for_import_meta(&mut self, loc: bun_ast::Loc) -> Expr { + debug_assert!(self.options.lower_import_meta); + if self.import_meta_ref.is_empty() { + self.import_meta_ref = + self.declare_generated_symbol(js_ast::symbol::Kind::Other, b"import_meta"); + } + let ref_ = self.import_meta_ref; + self.record_usage(ref_); + // Like unresolvable dynamic imports, an `import.meta` inside of a `try` + // is taken as the code already handling its absence. Dependencies are + // not warned about either: the author of the build cannot change them, + // and the warning is per output format, not per site. + if !self.is_control_flow_dead + && self.fn_or_arrow_data_visit.try_body_count == 0 + && !self.source.path.is_node_module() + { + self.empty_import_meta_locs.push(loc); + } + self.new_expr(E::Identifier::init(ref_), loc) + } + + /// Whether an identifier is the `import_meta` stand-in produced by + /// [`Self::value_for_import_meta`]. + #[inline] + pub(crate) fn is_import_meta_stand_in(&self, ref_: Ref) -> bool { + !self.import_meta_ref.is_empty() && ref_.eql(self.import_meta_ref) + } + + /// A property access on `target` (the result of + /// [`Self::value_for_import_meta`]) was inlined, so the stand-in object is + /// not referenced from this expression after all. + pub(crate) fn ignore_usage_of_import_meta(&mut self, target: &Expr) { + debug_assert!( + matches!(target.data, js_ast::ExprData::EIdentifier(id) if self.is_import_meta_stand_in(id.ref_)) + ); + self.ignore_usage(self.import_meta_ref); + // Searched from the end: the entry was pushed when `target` was + // visited, which normally happened right before this call. The + // remaining entries keep their source order for the warnings. + let locs = self.empty_import_meta_locs.as_mut_slice(); + if let Some(i) = locs.iter().rposition(|loc| *loc == target.loc) { + locs[i..].rotate_left(1); + self.empty_import_meta_locs.pop(); + } + } + pub(crate) fn keep_expr_symbol_name(&mut self, _value: Expr, _name: &[u8]) -> Expr { _value } @@ -8657,6 +8719,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O filename_ref: Ref::NONE, dirname_ref: Ref::NONE, import_meta_ref: Ref::NONE, + empty_import_meta_locs: BumpVec::new_in(arena), hmr_api_ref: Ref::NONE, response_ref: Ref::NONE, bun_app_namespace_ref: Ref::NONE, diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index 776c25011ac7..57419ab2f6a4 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -94,6 +94,13 @@ pub struct Options<'a> { pub import_meta_main_value: Option, pub lower_import_meta_main_for_node_js: bool, + /// The output format is not an ES module (`cjs`, or `iife` loaded as a + /// script), so `import.meta` must not survive into the output: the + /// properties with a known value are inlined and every other reference is + /// rewritten to a per-file `var import_meta = {}` (see + /// `P::value_for_import_meta`). Set by the bundler only. + pub lower_import_meta: bool, + /// When using react fast refresh or server components, the framework is /// able to customize what import sources are used. pub framework: Option<&'a options::Framework>, // TYPE_ONLY: was bun_runtime::bake::Framework @@ -136,6 +143,7 @@ impl<'a> Default for Options<'a> { transform_only: false, import_meta_main_value: None, lower_import_meta_main_for_node_js: false, + lower_import_meta: false, framework: None, repl_mode: false, lower_toml_datetimes: false, @@ -220,6 +228,7 @@ impl<'a> Options<'a> { transform_only: self.transform_only, import_meta_main_value: self.import_meta_main_value, lower_import_meta_main_for_node_js: self.lower_import_meta_main_for_node_js, + lower_import_meta: self.lower_import_meta, framework: self.framework, repl_mode: self.repl_mode, lower_toml_datetimes: self.lower_toml_datetimes, @@ -292,6 +301,7 @@ impl<'a> Options<'a> { transform_only: false, import_meta_main_value: None, lower_import_meta_main_for_node_js: false, + lower_import_meta: false, framework: None, repl_mode: false, lower_toml_datetimes: loader == options::Loader::Toml, @@ -1205,6 +1215,73 @@ impl<'a> Parser<'a> { } } + // `options.lower_import_meta`: declare the object that the `import.meta` + // references which were not inlined now point at (`value_for_import_meta`): + // + // var import_meta = {}; + // + // As a removable part it disappears again when none of those references + // survive tree shaking. + if p.options.lower_import_meta + && !p.import_meta_ref.is_empty() + && p.symbols.as_slice()[p.import_meta_ref.inner_index() as usize].use_count_estimate > 0 + { + let import_meta_ref = p.import_meta_ref; + let binding = p.b( + B::Identifier { + r#ref: import_meta_ref, + }, + bun_ast::Loc::EMPTY, + ); + let empty_object = p.new_expr(E::Object::default(), bun_ast::Loc::EMPTY); + let part_stmts = p.arena.alloc_slice_fill_with(1, |_| { + p.s( + S::Local { + kind: js_ast::LocalKind::KVar, + decls: G::DeclList::init_one(G::Decl { + binding, + value: Some(empty_object), + }), + ..Default::default() + }, + bun_ast::Loc::EMPTY, + ) + }); + let mut declared_symbols = + bun_ast::DeclaredSymbolList::init_capacity(1).expect("unreachable"); + declared_symbols.append_assume_capacity(DeclaredSymbol { + ref_: import_meta_ref, + is_top_level: true, + }); + before.push(js_ast::Part { + stmts: part_stmts.into(), + declared_symbols, + can_be_removed_if_unused: true, + ..Default::default() + }); + + let format_name = p.options.output_format.name(); + for i in 0..p.empty_import_meta_locs.len() { + let loc = p.empty_import_meta_locs[i]; + // `import.meta` spans two tokens; cover both unless the source + // spells it unusually, in which case the `import` keyword will do. + let range = match p.source.contents.get(loc.to_usize()..) { + Some(rest) if rest.starts_with(b"import.meta") => bun_ast::Range { + loc, + len: "import.meta".len() as i32, + }, + _ => js_lexer::range_of_identifier(p.source, loc), + }; + p.log().add_range_warning_fmt( + Some(p.source), + range, + format_args!( + "\"import.meta\" is not available with the \"{format_name}\" output format and will be empty" + ), + ); + } + } + // This is a workaround for broken module environment checks in packages like lodash-es // https://github.com/lodash/lodash/issues/5660 let mut force_esm = false; diff --git a/src/js_parser/parser.rs b/src/js_parser/parser.rs index 20db6ddf9d31..1dc1b20b340d 100644 --- a/src/js_parser/parser.rs +++ b/src/js_parser/parser.rs @@ -45,6 +45,16 @@ pub mod options { pub(crate) const fn is_esm(self) -> bool { matches!(self, Format::Esm) } + + /// The `--format` spelling, for diagnostics. + pub(crate) const fn name(self) -> &'static str { + match self { + Format::Esm => "esm", + Format::Iife => "iife", + Format::Cjs => "cjs", + Format::InternalBakeDev => "internal_bake_dev", + } + } } /// Canonical home is here (the parser is the consumer /// — `P::should_allow_unresolved_dynamic_specifier`). `bun_bundler::options` diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index c3aa10f99ae6..2dfc839d5d16 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -158,6 +158,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O return; } } + + // The property accesses with a known value (`import.meta.url`, ...) are + // inlined by `maybe_rewrite_import_meta_property` once the parent + // EDot/EIndex sees this replacement as its target. + if p.options.lower_import_meta { + *e = p.value_for_import_meta(expr.loc); + } } fn e_identifier(p: &mut Self, e: &mut Expr, in_: ExprIn) { diff --git a/test/bundler/bundler_edgecase.test.ts b/test/bundler/bundler_edgecase.test.ts index c9e970fa08be..a8beadf2b6c3 100644 --- a/test/bundler/bundler_edgecase.test.ts +++ b/test/bundler/bundler_edgecase.test.ts @@ -3223,6 +3223,260 @@ describe("bundler", () => { api.expectFile("/out.js").toContain("var arguments = 1;"); }, }); + + // `import.meta` is a syntax error outside of an ES module, which is what cjs + // output is (node rejects it; bun evaluates the `@bun-cjs` wrapper as a + // script, and `--bytecode` compiles that wrapper). The properties with a + // bundle-time value are inlined, everything else is rewritten to a per-file + // `var import_meta = {}`. The outputs run as `.cjs` under node on purpose: + // node would re-parse a `.js` file as ESM and bun accepts `import.meta` + // anywhere, so only that combination observes the syntax error. + const importMetaCjsFiles = { + "/entry.js": /* js */ ` + import { depMeta, depUrl } from "./dep.js"; + import cjsDep from "./cjs-dep.cjs"; + const meta = import.meta; + console.log(typeof meta, JSON.stringify(meta), meta === depMeta, depMeta === cjsDep.meta); + console.log(import.meta.env?.MODE, typeof import.meta.resolve, typeof import.meta.require); + console.log( + import.meta.url.startsWith("file:///"), + import.meta.url.endsWith("/entry.js"), + depUrl.endsWith("/dep.js"), + cjsDep.url.endsWith("/cjs-dep.cjs"), + ); + console.log(import.meta.file, import.meta.path.endsWith("entry.js"), import.meta.filename === import.meta.path); + console.log(import.meta.dirname === import.meta.dir, import.meta.path.startsWith(import.meta.dir)); + `, + "/dep.js": /* js */ ` + export const depMeta = import.meta; + export const depUrl = import.meta.url; + `, + "/cjs-dep.cjs": /* js */ ` + module.exports = { meta: import.meta, url: import.meta.url }; + `, + }; + const importMetaCjsStdout = ` + object {} false false + undefined undefined undefined + true true true true + entry.js true true + true true + `; + itBundled("edgecase/ImportMetaCjsBecomesEmptyObject", { + files: importMetaCjsFiles, + format: "cjs", + target: "node", + outfile: "out.cjs", + onAfterBundle(api) { + const code = api.readFile("out.cjs"); + expect(code).not.toContain("import.meta"); + // One object per file that still references it. + expect(code.match(/var import_meta\w* = \{\}/g)).toHaveLength(3); + }, + run: [{ runtime: "node", stdout: importMetaCjsStdout }, { stdout: importMetaCjsStdout }], + }); + itBundled("edgecase/ImportMetaCjsBecomesEmptyObjectMinified", { + files: importMetaCjsFiles, + format: "cjs", + target: "node", + outfile: "out.cjs", + minifyIdentifiers: true, + minifySyntax: true, + minifyWhitespace: true, + onAfterBundle(api) { + api.expectFile("out.cjs").not.toContain("import.meta"); + }, + run: { runtime: "node", stdout: importMetaCjsStdout }, + }); + // `--bytecode` defaults to cjs output and compiles the `@bun-cjs` wrapper, so + // a left over `import.meta` made JSC refuse to generate the bytecode + // ("Failed to generate bytecode") and then fail to load the output. + itBundled("edgecase/ImportMetaCjsBytecode", { + files: importMetaCjsFiles, + format: "cjs", + target: "bun", + bytecode: true, + outdir: "/out", + onAfterBundle(api) { + api.expectFile("/out/entry.js").toContain("// @bun @bytecode @bun-cjs"); + api.expectFile("/out/entry.js").not.toContain("import.meta"); + api.assertFileExists("/out/entry.js.jsc"); + }, + run: { stdout: importMetaCjsStdout }, + }); + itBundled("edgecase/ImportMetaCjsExpressionShapes", { + files: { + "/entry.js": /* js */ ` + var import_meta = "declared by the user"; + console.log(import_meta, typeof import.meta, import.meta?.env, import.meta["file"], typeof import.meta.hot); + console.log(delete import.meta, [import.meta].length, (0, import.meta).nothing); + // Assignment targets are not inlined: the writes land on the object. + const meta = import.meta; + import.meta.url = "assigned"; + import.meta.file++; + console.log(meta.url, meta.file, import.meta.url.startsWith("file:///"), import.meta.file); + `, + }, + format: "cjs", + target: "node", + outfile: "out.cjs", + onAfterBundle(api) { + const code = api.readFile("out.cjs"); + expect(code).not.toContain("import.meta"); + // The user's variable and the generated one get distinct names. + expect(code).toMatch(/var import_meta\w* = "declared by the user"/); + expect(code).toMatch(/var import_meta\w* = \{\}/); + expect(code).not.toContain(".hot"); + expect(code).toContain('.url = "assigned"'); + }, + run: { + runtime: "node", + stdout: ` + declared by the user object undefined entry.js undefined + true 1 undefined + assigned NaN true entry.js + `, + }, + }); + itBundled("edgecase/ImportMetaCjsInlinedAccessesNeedNoObject", { + files: { + "/entry.js": /* js */ ` + import { used } from "./dep.js"; + console.log(import.meta.url.startsWith("file:///"), import.meta.file, used); + `, + "/dep.js": /* js */ ` + export const used = import.meta.dir === import.meta.dirname; + export function unused() { + return import.meta.env; + } + export function alsoUnused() { + return import.meta; + } + `, + }, + format: "cjs", + target: "node", + outfile: "out.cjs", + onAfterBundle(api) { + const code = api.readFile("out.cjs"); + expect(code).not.toContain("import.meta"); + expect(code).not.toContain("unused"); + // Every access that made it into the output was inlined, and the + // references inside the tree shaken functions do not keep dep.js's + // object alive, so no object is declared in either file. + expect(code).not.toContain("import_meta"); + }, + run: { runtime: "node", stdout: "true entry.js true" }, + }); + itBundled("edgecase/ImportMetaCjsWarnsPerEmptiedReference", { + files: { + "/entry.js": /* js */ ` + import "dep"; + console.log(import.meta.url.startsWith("file:///"), import.meta.dir === import.meta.dirname); + console.log(typeof import.meta, import.meta.env); + try { + console.log(import.meta.resolve("./x")); + } catch { + console.log("caught"); + } + `, + "/node_modules/dep/index.js": /* js */ ` + console.log(JSON.stringify(import.meta), import.meta.env); + `, + }, + format: "cjs", + target: "node", + outfile: "out.cjs", + bundleWarnings: { + "/entry.js": [ + '"import.meta" is not available with the "cjs" output format and will be empty', + '"import.meta" is not available with the "cjs" output format and will be empty', + ], + }, + onAfterBundle(api) { + const [first, second] = api.warnings["/entry.js"]; + // Inlined accesses, the `try` body and node_modules are not reported. + expect([first.line, second.line]).toEqual(["3", "3"]); + }, + run: { + runtime: "node", + stdout: ` + {} undefined + true true + object undefined + caught + `, + }, + }); + // iife output is a classic script for every target but bun, which loads its + // `// @bun` output as a module, where the real `import.meta` keeps working. + itBundled("edgecase/ImportMetaIifeBecomesEmptyObject", { + files: { + "/entry.js": /* js */ ` + console.log(JSON.stringify(import.meta), import.meta.env, import.meta.url.startsWith("file:///"), import.meta.file); + `, + }, + format: "iife", + target: "browser", + outfile: "out.cjs", + bundleWarnings: { + "/entry.js": [ + '"import.meta" is not available with the "iife" output format and will be empty', + '"import.meta" is not available with the "iife" output format and will be empty', + ], + }, + onAfterBundle(api) { + api.expectFile("out.cjs").not.toContain("import.meta"); + }, + run: { runtime: "node", stdout: "{} undefined true entry.js" }, + }); + itBundled("edgecase/ImportMetaIifeBecomesEmptyObjectTargetNode", { + files: { + "/entry.js": /* js */ ` + console.log(JSON.stringify(import.meta), import.meta.env, import.meta.url.startsWith("file:///"), import.meta.file); + `, + }, + format: "iife", + target: "node", + outfile: "out.cjs", + onAfterBundle(api) { + api.expectFile("out.cjs").not.toContain("import.meta"); + }, + run: { runtime: "node", stdout: "{} undefined true entry.js" }, + }); + itBundled("edgecase/ImportMetaIifeTargetBunKeepsImportMeta", { + files: { + "/entry.js": /* js */ ` + console.log(typeof import.meta.resolve, typeof import.meta.require, import.meta.url.startsWith("file:///")); + `, + }, + format: "iife", + target: "bun", + onAfterBundle(api) { + const code = api.readFile("out.js"); + expect(code).toContain("import.meta.resolve"); + expect(code).toContain("import.meta.url"); + expect(code).not.toContain("import_meta"); + }, + run: { stdout: "function function true" }, + }); + itBundled("edgecase/ImportMetaEsmKeepsImportMeta", { + files: { + "/entry.js": /* js */ ` + const meta = import.meta; + import.meta.custom = "assigned"; + console.log(meta.custom, typeof meta.url, import.meta.url === meta.url); + `, + }, + format: "esm", + target: "bun", + onAfterBundle(api) { + const code = api.readFile("out.js"); + expect(code).toContain('import.meta.custom = "assigned"'); + expect(code).not.toContain("import_meta"); + }, + run: { stdout: "assigned string true" }, + }); }); for (const backend of ["api", "cli"] as const) { From dfa73f6426a8a894e94afc52145131f2d2937213 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:24:52 +0000 Subject: [PATCH 2/4] bundler: pin the per-file import.meta path values in cjs and iife output Carried over from #38132: an entry plus a file in a subdirectory, with the exact inlined value of every path property checked in cjs output and in iife output for the browser and node targets (import.meta.filename included), and the bun-target iife control now checks that import.meta.url is the bundle's own URL at run time. --- test/bundler/bundler_edgecase.test.ts | 75 +++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 4 deletions(-) diff --git a/test/bundler/bundler_edgecase.test.ts b/test/bundler/bundler_edgecase.test.ts index a8beadf2b6c3..ce71a0885e3e 100644 --- a/test/bundler/bundler_edgecase.test.ts +++ b/test/bundler/bundler_edgecase.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isBroken, isWindows, tempDir } from "harness"; import { readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; import { decodeSourceMappingsLine, itBundled } from "./expectBundled"; // A public path composes with the referenced file's path relative to the output @@ -3304,6 +3305,49 @@ describe("bundler", () => { }, run: { stdout: importMetaCjsStdout }, }); + // The inlined values are those of the source file being parsed, like + // `__dirname`: a file in a subdirectory gets its own directory, and `url` is + // the source file's URL rather than the bundle's. + const importMetaPathsFiles = { + "/entry.js": /* js */ ` + import { dep } from "./lib/dep.js"; + console.log( + JSON.stringify([ + import.meta.dir, + import.meta.dirname, + import.meta.file, + import.meta.path, + import.meta.filename, + import.meta.url, + dep, + ]), + ); + `, + "/lib/dep.js": /* js */ ` + export const dep = [import.meta.dir, import.meta.file, import.meta.path, import.meta.url]; + `, + }; + const importMetaPathsStdout = (root: string) => + JSON.stringify([ + root, + root, + "entry.js", + join(root, "entry.js"), + join(root, "entry.js"), + pathToFileURL(join(root, "entry.js")).href, + [join(root, "lib"), "dep.js", join(root, "lib", "dep.js"), pathToFileURL(join(root, "lib", "dep.js")).href], + ]); + itBundled("edgecase/ImportMetaCjsPathsAreInlinedPerFile", ({ root }) => ({ + files: importMetaPathsFiles, + format: "cjs", + target: "node", + outfile: "out.cjs", + onAfterBundle(api) { + api.expectFile("out.cjs").not.toContain("import.meta"); + api.expectFile("out.cjs").not.toContain("import_meta"); + }, + run: { runtime: "node", stdout: importMetaPathsStdout(root) }, + })); itBundled("edgecase/ImportMetaCjsExpressionShapes", { files: { "/entry.js": /* js */ ` @@ -3444,10 +3488,32 @@ describe("bundler", () => { }, run: { runtime: "node", stdout: "{} undefined true entry.js" }, }); - itBundled("edgecase/ImportMetaIifeTargetBunKeepsImportMeta", { + itBundled("edgecase/ImportMetaIifePathsAreInlinedPerFile", ({ root }) => ({ + files: importMetaPathsFiles, + format: "iife", + target: "browser", + outfile: "out.cjs", + onAfterBundle(api) { + api.expectFile("out.cjs").not.toContain("import.meta"); + api.expectFile("out.cjs").not.toContain("import_meta"); + }, + run: { runtime: "node", stdout: importMetaPathsStdout(root) }, + })); + itBundled("edgecase/ImportMetaIifePathsAreInlinedPerFileTargetNode", ({ root }) => ({ + files: importMetaPathsFiles, + format: "iife", + target: "node", + outfile: "out.cjs", + onAfterBundle(api) { + api.expectFile("out.cjs").not.toContain("import.meta"); + api.expectFile("out.cjs").not.toContain("import_meta"); + }, + run: { runtime: "node", stdout: importMetaPathsStdout(root) }, + })); + itBundled("edgecase/ImportMetaIifeTargetBunKeepsImportMeta", ({ root }) => ({ files: { "/entry.js": /* js */ ` - console.log(typeof import.meta.resolve, typeof import.meta.require, import.meta.url.startsWith("file:///")); + console.log(typeof import.meta.resolve, typeof import.meta.require, import.meta.url); `, }, format: "iife", @@ -3458,8 +3524,9 @@ describe("bundler", () => { expect(code).toContain("import.meta.url"); expect(code).not.toContain("import_meta"); }, - run: { stdout: "function function true" }, - }); + // The bundle's own `import.meta`, not entry.js's inlined one. + run: { stdout: `function function ${pathToFileURL(join(root, "out.js")).href}` }, + })); itBundled("edgecase/ImportMetaEsmKeepsImportMeta", { files: { "/entry.js": /* js */ ` From 8d5214cca9f6b67a7bb6be4f89b5af6e48d6cd57 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:28:45 +0000 Subject: [PATCH 3/4] shorten the import.meta lowering comments --- src/bundler/ParseTask.rs | 11 +++----- src/js_parser/fold.rs | 5 ++-- src/js_parser/p.rs | 41 +++++++++++------------------- src/js_parser/parse/parse_entry.rs | 21 +++++---------- src/js_parser/visit/visit_expr.rs | 5 ++-- 5 files changed, 30 insertions(+), 53 deletions(-) diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index 266cdd63b924..65832093e683 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -2604,13 +2604,10 @@ pub mod parse_worker { opts.lower_import_meta_main_for_node_js = true; } - // `import.meta` is a syntax error outside of an ES module. cjs output is - // never a module (for bun it is evaluated through the `@bun-cjs` function - // wrapper, which is also what `--bytecode` compiles), and iife output is a - // script everywhere except for bun, which loads its `// @bun` output as a - // module. The runtime source is excluded: its only `import.meta` uses are - // the `__require` definitions (RUNTIME_REQUIRE_*), which an empty object - // could not satisfy. + // `import.meta` is a syntax error outside of an ES module: cjs output + // never is one, and iife output is a script except on bun, which loads + // `// @bun` output as a module. The runtime's `__require` definitions + // need the real `import.meta`, so the runtime is excluded. opts.lower_import_meta = !task.source_index.is_runtime() && match output_format { options::Format::Cjs => true, diff --git a/src/js_parser/fold.rs b/src/js_parser/fold.rs index e77e3e7ffc8f..acace95d2c3b 100644 --- a/src/js_parser/fold.rs +++ b/src/js_parser/fold.rs @@ -741,9 +741,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O loc, }) } else if p.options.framework.is_some() || p.options.lower_import_meta { - // Bake serves its own `import.meta` object, and non-module output has - // none at all, so the properties whose value is known at bundle time - // are inlined. + // Bake serves its own `import.meta` and non-module output has none, + // so properties with a bundle-time-known value are inlined. match name { b"dir" | b"dirname" => { Some(p.new_expr(e_string_init(p.source.path.name().dir), name_loc)) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 0677b4c2f3e9..e0b44f45799e 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -237,18 +237,13 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { pub(crate) module_ref: Ref, pub(crate) filename_ref: Ref, pub(crate) dirname_ref: Ref, - /// Identifier standing in for `import.meta` when the output is not an ES - /// module. Runtime CommonJS modules bind it as the `$Bun_import_meta` - /// parameter of the module wrapper (see `to_ast`, the printer substitutes - /// it); bundles with `options.lower_import_meta` declare it as a per-file - /// `var import_meta = {}` and the visit pass rewrites every remaining - /// `import.meta` to it (see `value_for_import_meta`). + /// Stand-in for `import.meta` in non-module output: the `$Bun_import_meta` + /// wrapper parameter for runtime CommonJS modules, or a per-file + /// `var import_meta = {}` under `options.lower_import_meta`. pub(crate) import_meta_ref: Ref, - /// `lower_import_meta` only: the `import.meta` expressions rewritten to - /// `import_meta_ref` whose value is therefore the empty object. A property - /// access that gets inlined removes its entry again - /// (`ignore_usage_of_import_meta`); whatever is left is warned about once - /// the visit pass is done. + /// `lower_import_meta` only: locations rewritten to the empty-object + /// stand-in, warned about after the visit pass. Inlined property accesses + /// remove their entry again (`ignore_usage_of_import_meta`). pub(crate) empty_import_meta_locs: List<'a, bun_ast::Loc>, pub(crate) hmr_api_ref: Ref, @@ -5266,9 +5261,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - /// `options.lower_import_meta`: the replacement for an `import.meta` - /// expression, a reference to this file's `var import_meta = {}`. The - /// declaration itself is added after the visit pass, and only if a + /// `options.lower_import_meta`: replaces `import.meta` with a reference to + /// this file's `var import_meta = {}`, declared after the visit pass if a /// reference survives `maybe_rewrite_import_meta_property`. pub(crate) fn value_for_import_meta(&mut self, loc: bun_ast::Loc) -> Expr { debug_assert!(self.options.lower_import_meta); @@ -5278,10 +5272,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } let ref_ = self.import_meta_ref; self.record_usage(ref_); - // Like unresolvable dynamic imports, an `import.meta` inside of a `try` - // is taken as the code already handling its absence. Dependencies are - // not warned about either: the author of the build cannot change them, - // and the warning is per output format, not per site. + // As with unresolvable dynamic imports, `import.meta` inside a `try` or + // in node_modules is not worth a warning the user cannot act on. if !self.is_control_flow_dead && self.fn_or_arrow_data_visit.try_body_count == 0 && !self.source.path.is_node_module() @@ -5291,24 +5283,21 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.new_expr(E::Identifier::init(ref_), loc) } - /// Whether an identifier is the `import_meta` stand-in produced by - /// [`Self::value_for_import_meta`]. + /// Whether `ref_` is the stand-in from [`Self::value_for_import_meta`]. #[inline] pub(crate) fn is_import_meta_stand_in(&self, ref_: Ref) -> bool { !self.import_meta_ref.is_empty() && ref_.eql(self.import_meta_ref) } - /// A property access on `target` (the result of - /// [`Self::value_for_import_meta`]) was inlined, so the stand-in object is - /// not referenced from this expression after all. + /// Undoes [`Self::value_for_import_meta`] for `target` after its property + /// access was inlined: drops the usage and the pending warning. pub(crate) fn ignore_usage_of_import_meta(&mut self, target: &Expr) { debug_assert!( matches!(target.data, js_ast::ExprData::EIdentifier(id) if self.is_import_meta_stand_in(id.ref_)) ); self.ignore_usage(self.import_meta_ref); - // Searched from the end: the entry was pushed when `target` was - // visited, which normally happened right before this call. The - // remaining entries keep their source order for the warnings. + // rposition: the entry was usually pushed right before this call; the + // rotate keeps the rest in source order for the warnings. let locs = self.empty_import_meta_locs.as_mut_slice(); if let Some(i) = locs.iter().rposition(|loc| *loc == target.loc) { locs[i..].rotate_left(1); diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index 57419ab2f6a4..8cdc0693712d 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -94,11 +94,9 @@ pub struct Options<'a> { pub import_meta_main_value: Option, pub lower_import_meta_main_for_node_js: bool, - /// The output format is not an ES module (`cjs`, or `iife` loaded as a - /// script), so `import.meta` must not survive into the output: the - /// properties with a known value are inlined and every other reference is - /// rewritten to a per-file `var import_meta = {}` (see - /// `P::value_for_import_meta`). Set by the bundler only. + /// The output format is not an ES module, so `import.meta` is inlined + /// where its value is known and otherwise rewritten to a per-file + /// `var import_meta = {}` (`P::value_for_import_meta`). Bundler only. pub lower_import_meta: bool, /// When using react fast refresh or server components, the framework is @@ -1215,13 +1213,9 @@ impl<'a> Parser<'a> { } } - // `options.lower_import_meta`: declare the object that the `import.meta` - // references which were not inlined now point at (`value_for_import_meta`): - // - // var import_meta = {}; - // - // As a removable part it disappears again when none of those references - // survive tree shaking. + // `options.lower_import_meta`: declare the `var import_meta = {}` that + // non-inlined `import.meta` references point at, as a part that tree + // shaking can remove. if p.options.lower_import_meta && !p.import_meta_ref.is_empty() && p.symbols.as_slice()[p.import_meta_ref.inner_index() as usize].use_count_estimate > 0 @@ -1263,8 +1257,7 @@ impl<'a> Parser<'a> { let format_name = p.options.output_format.name(); for i in 0..p.empty_import_meta_locs.len() { let loc = p.empty_import_meta_locs[i]; - // `import.meta` spans two tokens; cover both unless the source - // spells it unusually, in which case the `import` keyword will do. + // Cover both tokens of `import.meta` when spelled normally. let range = match p.source.contents.get(loc.to_usize()..) { Some(rest) if rest.starts_with(b"import.meta") => bun_ast::Range { loc, diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 2dfc839d5d16..a7862196e391 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -159,9 +159,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - // The property accesses with a known value (`import.meta.url`, ...) are - // inlined by `maybe_rewrite_import_meta_property` once the parent - // EDot/EIndex sees this replacement as its target. + // The parent EDot/EIndex inlines known properties of this replacement + // via `maybe_rewrite_import_meta_property`. if p.options.lower_import_meta { *e = p.value_for_import_meta(expr.loc); } From 74722989158df6cd72dd7343ca60ed977b2ceb28 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 21 Aug 2026 04:05:54 +0000 Subject: [PATCH 4/4] ci: retrigger