From f6edfe364c9a5b0f35177da0b15509934a5eb373 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Sat, 24 Oct 2026 19:54:59 +0000
Subject: [PATCH 1/3] macros: resolve macro imports with a resolver for Bun, so
the "macro" export condition applies
A macro import was resolved by the resolver of the importing build. That
resolver has the conditions of the output target, so the documented
"macro" export condition never matched. `bun build` for the browser also
ran the "browser" build of a package as the macro, and `--external` or
`--packages=external` changed which copy of the package the macro ran.
The macro context now resolves with a second resolver, made from the
importing one on the first macro call. What a target decides is set as
Bun's runtime sets it: the conditions "macro", "bun" and "node" plus the
user's conditions, Bun's main fields and extensions, no "browser" field,
and no externals. Every other option stays as the importing resolver has
it. A thread keeps the resolver for the next module of the same build.
A builtin module behind the `macro:` prefix is found before the resolver
runs, and a macro file named `bun.js` is no longer taken for the `bun`
builtin.
The docs example lists "macro" first, because the first matching key
decides the file.
---
docs/bundler/macros.mdx | 18 +-
docs/runtime/module-resolution.mdx | 3 +-
src/bundler/ThreadPool.rs | 2 +-
src/bundler/entry_points.rs | 2 +-
src/bundler/options.rs | 60 +--
src/bundler/transpiler.rs | 99 ++++-
src/js_parser_jsc/Macro.rs | 66 ++-
src/jsc/RuntimeTranspilerStore.rs | 2 +-
src/resolver/resolver.rs | 13 +
test/bundler/transpiler/macro-test.test.ts | 461 ++++++++++++++++++++-
10 files changed, 677 insertions(+), 49 deletions(-)
diff --git a/docs/bundler/macros.mdx b/docs/bundler/macros.mdx
index c4becbccb82a..df99780460ca 100644
--- a/docs/bundler/macros.mdx
+++ b/docs/bundler/macros.mdx
@@ -85,20 +85,22 @@ macro();
## Export condition "macro"
-When shipping a library containing a macro to npm or another package registry, use the `"macro"` export condition to provide a version of your package exclusively for the macro environment.
+When shipping a library containing a macro to npm or another package registry, use the `"macro"` export condition to provide a version of your package exclusively for macro imports.
```json title="package.json" icon="file-json"
{
"name": "my-package",
"exports": {
+ "macro": "./index.macro.js",
"import": "./index.js",
"require": "./index.js",
- "default": "./index.js",
- "macro": "./index.macro.js"
+ "default": "./index.js"
}
}
```
+List `"macro"` first. The first condition that matches decides the file, and a macro import also matches `"import"` and `"default"`.
+
With this configuration, users can consume your package at runtime or at bundle-time using the same import specifier:
```ts title="index.ts" icon="/icons/typescript.svg"
@@ -106,7 +108,15 @@ import pkg from "my-package"; // runtime import
import { macro } from "my-package" with { type: "macro" }; // macro import
```
-The first import resolves to `./node_modules/my-package/index.js`; Bun's bundler resolves the second to `./node_modules/my-package/index.macro.js`.
+The first import resolves to `./node_modules/my-package/index.js`; Bun resolves the second to `./node_modules/my-package/index.macro.js`.
+
+A macro runs in Bun, so a macro import resolves with the conditions `"macro"`, `"bun"`, and `"node"`, whatever the `target` of the build is. The `"macro"` condition applies to the macro import only. Imports inside the macro's own module resolve as they do in Bun's runtime, without `"macro"`.
+
+
+ The `"macro"` condition requires Bun v1.4.4 or newer. Older versions resolve a macro import like any other import. To
+ support them too, export the macro file under a subpath of its own, such as `"./macro": "./index.macro.js"`, and
+ import it as `my-package/macro`.
+
## Execution
diff --git a/docs/runtime/module-resolution.mdx b/docs/runtime/module-resolution.mdx
index 77760cb4b9ad..9d58d0fe9b53 100644
--- a/docs/runtime/module-resolution.mdx
+++ b/docs/runtime/module-resolution.mdx
@@ -185,6 +185,7 @@ Once it finds the `foo` package, Bun reads its `package.json` to determine the p
{
"name": "foo",
"exports": {
+ "macro": "./index.macro.js", // if the import is a macro import
"bun": "./index.js",
"node-addons": "./index.js", // unless --no-addons was passed
"node": "./index.js",
@@ -195,7 +196,7 @@ Once it finds the `foo` package, Bun reads its `package.json` to determine the p
}
```
-Whichever of these conditions occurs _first_ in the `package.json` determines the package's entrypoint.
+Whichever of these conditions occurs _first_ in the `package.json` determines the package's entrypoint. The `"macro"` condition only matches an import with `{ type: "macro" }`: see [macros](/bundler/macros#export-condition-macro).
Bun respects subpath [`"exports"`](https://nodejs.org/api/packages.html#subpath-exports) and [`"imports"`](https://nodejs.org/api/packages.html#imports).
diff --git a/src/bundler/ThreadPool.rs b/src/bundler/ThreadPool.rs
index b1240fb3299d..688df4c4e2f8 100644
--- a/src/bundler/ThreadPool.rs
+++ b/src/bundler/ThreadPool.rs
@@ -550,7 +550,7 @@ impl Worker {
// behind `macro_context.data` (raw `*mut`, no `Drop` glue);
// `Transpiler` has no `Drop` impl, so `worker.data = None` below
// would strand it. Free both transpilers' boxes explicitly — the
- // box only owns a `MacroMap` and a lazy `bun_alloc::Arena`, no JSC
+ // box only owns a `MacroMap`, a lazy `Resolver` and a lazy `bun_alloc::Arena`, no JSC
// handles, so the worker thread tearing it down is safe.
if let Some(data) = worker.data.as_mut() {
if let Some(ctx) = data.transpiler.macro_context.take() {
diff --git a/src/bundler/entry_points.rs b/src/bundler/entry_points.rs
index 5d342c0e4788..4dec9bc8982d 100644
--- a/src/bundler/entry_points.rs
+++ b/src/bundler/entry_points.rs
@@ -199,7 +199,7 @@ impl MacroEntryPoint {
entry.code_buffer[..label_len].copy_from_slice(macro_label_);
let code_len: usize = 'brk: {
- if import_path.base == b"bun" {
+ if import_path.dir.is_empty() && import_path.filename == b"bun" {
let mut cursor = std::io::Cursor::new(&mut entry.code_buffer[label_len..]);
write!(
&mut cursor,
diff --git a/src/bundler/options.rs b/src/bundler/options.rs
index 8b10aa315108..5dfb4f41ab59 100644
--- a/src/bundler/options.rs
+++ b/src/bundler/options.rs
@@ -97,7 +97,7 @@ const DEFAULT_WILDCARD_PATTERNS: &[(&[u8], &[u8])] = &[
// (b"/blob:", b""),
];
-fn default_wildcard_patterns() -> Vec {
+pub(crate) fn default_wildcard_patterns() -> Vec {
DEFAULT_WILDCARD_PATTERNS
.iter()
.map(|(p, s)| WildcardPattern {
@@ -718,6 +718,22 @@ pub struct ESMConditions {
}
impl ESMConditions {
+ /// The conditions of a build for `target`: the target's defaults, `node-addons`, then the user's.
+ pub(crate) fn for_target(
+ target: Target,
+ transform: &api::TransformOptions,
+ ) -> Result {
+ Self::init(
+ Target::default_conditions_map()[target],
+ transform.allow_addons.unwrap_or(true),
+ &transform
+ .conditions
+ .iter()
+ .map(|s| &**s)
+ .collect::>(),
+ )
+ }
+
pub fn init(
defaults: &[&[u8]],
allow_addons: bool,
@@ -1869,21 +1885,7 @@ impl<'a> BundleOptions<'a> {
opts.main_fields = owned_string_list(Target::default_main_fields_map()[opts.target]);
}
- {
- // conditions:
- // 1. defaults
- // 2. node-addons
- // 3. user conditions
- opts.conditions = ESMConditions::init(
- Target::default_conditions_map()[opts.target],
- transform.allow_addons.unwrap_or(true),
- &transform
- .conditions
- .iter()
- .map(|s| &**s)
- .collect::>(),
- )?;
- }
+ opts.conditions = ESMConditions::for_target(opts.target, &transform)?;
match opts.target {
Target::Node => {
@@ -1900,17 +1902,11 @@ impl<'a> BundleOptions<'a> {
opts.env.behavior = api::DotEnvBehavior::LoadAll;
if transform.extension_order.is_empty() {
- // we must also support require'ing .node files
- static EXT_WITH_NODE: &[&[u8]] = &[
- b".tsx", b".ts", b".jsx", b".cts", b".cjs", b".js", b".mjs", b".mts",
- b".json", b".node",
- ];
- static NM_EXT_WITH_NODE: &[&[u8]] = &[
- b".jsx", b".cjs", b".js", b".mjs", b".mts", b".tsx", b".ts", b".cts",
- b".json", b".node",
- ];
- opts.extension_order.default.default = owned_string_list(EXT_WITH_NODE);
- opts.extension_order.node_modules.default = owned_string_list(NM_EXT_WITH_NODE);
+ opts.extension_order.default.default =
+ owned_string_list(bundle_options_defaults::EXTENSION_ORDER_BUN);
+ opts.extension_order.node_modules.default = owned_string_list(
+ bundle_options_defaults::node_modules::EXTENSION_ORDER_BUN,
+ );
}
}
_ => {}
@@ -2008,6 +2004,11 @@ pub(crate) mod bundle_options_defaults {
b".tsx", b".ts", b".jsx", b".cts", b".cjs", b".js", b".mjs", b".mts", b".json",
];
+ /// Bun must also support require'ing .node files.
+ pub(crate) const EXTENSION_ORDER_BUN: &[&[u8]] = &[
+ b".tsx", b".ts", b".jsx", b".cts", b".cjs", b".js", b".mjs", b".mts", b".json", b".node",
+ ];
+
pub(crate) const MAIN_FIELD_EXTENSION_ORDER: &[&[u8]] =
&[b".js", b".cjs", b".cts", b".tsx", b".ts", b".jsx", b".json"];
@@ -2020,6 +2021,11 @@ pub(crate) mod bundle_options_defaults {
b".jsx", b".cjs", b".js", b".mjs", b".mts", b".tsx", b".ts", b".cts", b".json",
];
+ pub(crate) const EXTENSION_ORDER_BUN: &[&[u8]] = &[
+ b".jsx", b".cjs", b".js", b".mjs", b".mts", b".tsx", b".ts", b".cts", b".json",
+ b".node",
+ ];
+
pub(crate) const MODULE_EXTENSION_ORDER: &[&[u8]] = &[
b".mjs", b".jsx", b".mts", b".js", b".cjs", b".tsx", b".ts", b".cts", b".json",
];
diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs
index c5ef7f1b8fd3..3f36d74948ef 100644
--- a/src/bundler/transpiler.rs
+++ b/src/bundler/transpiler.rs
@@ -101,7 +101,7 @@ impl<'a> Transpiler<'a> {
// The lazily-created `Box` is
// process-lifetime by default, but
// worker VMs run `destroy()` on thread exit and would otherwise strand
- // one box per worker. The box only owns a `MacroMap` and an optional
+ // one box per worker. The box only owns a `MacroMap`, a `Resolver` and an optional
// `bun_alloc::Arena` — no JSC handles — so freeing it from either
// worker section-5 teardown or main-thread `global_exit` is safe.
if let Some(ctx) = self.macro_context.take() {
@@ -1071,7 +1071,104 @@ fn resolver_bundle_options_subset(
}
}
+/// The options of a resolver for the imports of macros, from the options of the resolver of the importing build.
+#[cold]
+#[inline(never)]
+fn macro_resolver_options(
+ from: &resolver::options::BundleOptions,
+ transform: &api::TransformOptions,
+) -> crate::Result {
+ use crate::options_impl::{Target, TargetExt, bundle_options_defaults as defaults};
+ use resolver::options as ropts;
+
+ let conditions = options::ESMConditions::for_target(Target::BunMacro, transform)?;
+ // As in `from_api`: `--main-fields` and `--extension-order` replace the lists of the target.
+ let main_fields = if transform.main_fields.is_empty() {
+ ropts::owned_string_list(Target::default_main_fields_map()[Target::BunMacro])
+ } else {
+ transform.main_fields.iter().cloned().collect()
+ };
+ let (extensions, node_modules_extensions) = if transform.extension_order.is_empty() {
+ (
+ ropts::owned_string_list(defaults::EXTENSION_ORDER_BUN),
+ ropts::owned_string_list(defaults::node_modules::EXTENSION_ORDER_BUN),
+ )
+ } else {
+ (
+ from.extension_order.default.default.clone(),
+ from.extension_order.node_modules.default.clone(),
+ )
+ };
+
+ Ok(ropts::BundleOptions {
+ // A macro runs in Bun: what the target decides is as in Bun's runtime, with "macro", without the build's externals.
+ target: Target::BunMacro,
+ conditions: ropts::Conditions {
+ import: conditions.import,
+ require: conditions.require,
+ style: conditions.style,
+ },
+ main_fields,
+ main_fields_is_default: transform.main_fields.is_empty(),
+ extension_order: ropts::ExtensionOrder {
+ default: ropts::ExtensionOrderGroup {
+ default: extensions,
+ esm: from.extension_order.default.esm.clone(),
+ },
+ node_modules: ropts::ExtensionOrderGroup {
+ default: node_modules_extensions,
+ esm: from.extension_order.node_modules.esm.clone(),
+ },
+ css: from.extension_order.css.clone(),
+ },
+ external: ropts::ExternalModules {
+ patterns: options::default_wildcard_patterns(),
+ ..Default::default()
+ },
+ packages: ropts::Packages::Bundle,
+ mark_builtins_as_external: false,
+ polyfill_node_globals: false,
+ framework: None,
+
+ // The rest is not about the target. It stays as the importing resolver has it.
+ jsx: from.jsx.clone(),
+ extra_cjs_extensions: from.extra_cjs_extensions.clone(),
+ global_cache: from.global_cache,
+ install: from.install,
+ load_package_json: from.load_package_json,
+ load_tsconfig_json: from.load_tsconfig_json,
+ main_field_extension_order: from.main_field_extension_order.clone(),
+ install_preference: from.install_preference,
+ preserve_symlinks: from.preserve_symlinks,
+ rewrite_jest_for_tests: from.rewrite_jest_for_tests,
+ tsconfig_override: from.tsconfig_override.clone(),
+ tsconfig_override_json: from.tsconfig_override_json.clone(),
+ production: from.production,
+ force_node_env: from.force_node_env,
+ output_dir: from.output_dir.clone(),
+ root_dir: from.root_dir.clone(),
+ public_path: from.public_path.clone(),
+ compile: from.compile,
+ supports_multiple_outputs: from.supports_multiple_outputs,
+ tree_shaking: from.tree_shaking,
+ allow_runtime: from.allow_runtime,
+ })
+}
+
impl<'a> Transpiler<'a> {
+ /// A resolver for the imports of macros, made from the resolver of the build that imports them.
+ pub fn macro_resolver(
+ importer: &Resolver<'a>,
+ transform: &api::TransformOptions,
+ ) -> crate::Result>> {
+ let opts = macro_resolver_options(&importer.opts, transform)?;
+ // SAFETY: the new resolver has the lifetime of `importer`, so what `importer` borrows outlives it.
+ let mut resolver = Box::new(unsafe { Resolver::for_target(importer, importer.log, opts) });
+ // Bun's runtime takes "main" before "module".
+ resolver.prefer_module_field = false;
+ Ok(resolver)
+ }
+
/// Called by [`init_runtime_state`](../runtime/jsc_hooks.rs)
/// to write `vm.transpiler`. Builds on:
/// * [`options::BundleOptions::from_api`] — `bun_bundler::options`
diff --git a/src/js_parser_jsc/Macro.rs b/src/js_parser_jsc/Macro.rs
index 9abc98c5f919..b07131d66267 100644
--- a/src/js_parser_jsc/Macro.rs
+++ b/src/js_parser_jsc/Macro.rs
@@ -42,6 +42,14 @@ fn is_macro_path(str: &[u8]) -> bool {
// MacroContext
// ══════════════════════════════════════════════════════════════════════════
+/// A macro resolver that no `MacroContext` uses now, with the options of the build that it was made for.
+type IdleMacroResolver = (Arc, Box>);
+
+thread_local! {
+ // A `MacroContext` lasts one module in a transpile job and one call of `Bun.Transpiler`: its resolver waits here for the next.
+ static IDLE_MACRO_RESOLVER: Cell> = const { Cell::new(None) };
+}
+
// All three are modelled as raw pointers because the referents live
// inside the owning `Transpiler` and are also reachable through other aliases
// (`Transpiler.resolver`, `Transpiler.env`, `Transpiler.options`); a `&'a mut`
@@ -55,6 +63,8 @@ pub(crate) struct MacroContext {
pub(crate) macros: MacroMap,
pub(crate) remap: bun_ptr::BackRef,
pub(crate) javascript_object: JSValue,
+ /// Resolves the imports of macros: `resolver` made over for the environment a macro runs in. Lazy like `bump`.
+ pub(crate) macro_resolver: Option>>,
/// The AST takes lifetime-erased `&[u8]` arena slices (property keys /
/// UTF-16 string data / `from_blob` JSON sub-parse), so we own the backing arena here
/// — `MacroContext` is stored in the long-lived `Transpiler` and outlives
@@ -95,11 +105,40 @@ impl MacroContext {
transform_options: Arc::clone(&transpiler.options.transform_options),
remap: bun_ptr::BackRef::new(&transpiler.options.macro_remap),
javascript_object: JSValue::ZERO,
+ macro_resolver: None,
// Deferred until `call()` — see field doc.
bump: None,
}
}
+ /// Resolves the import of a macro for Bun, where the macro runs, and not for the output of the importing build.
+ fn resolve(
+ &mut self,
+ source_dir: &[u8],
+ specifier: &[u8],
+ ) -> crate::Result {
+ // SAFETY: `resolver` outlives `self` (see struct comment), and nothing resolves with it during this call.
+ let importer = unsafe { &*self.resolver };
+ let resolver = match &mut self.macro_resolver {
+ Some(resolver) => resolver,
+ // The address of the options says which build made the idle resolver.
+ slot @ None => slot.insert(match IDLE_MACRO_RESOLVER.take() {
+ Some((options, idle)) if Arc::ptr_eq(&options, &self.transform_options) => idle,
+ _ => Transpiler::macro_resolver(importer, &self.transform_options)?,
+ }),
+ };
+ // What the importing resolver can get after its first macro: a log per module, the auto-installer, `require.extensions`.
+ resolver.log = importer.log;
+ resolver.package_manager = importer.package_manager;
+ if resolver.opts.extra_cjs_extensions != importer.opts.extra_cjs_extensions {
+ resolver
+ .opts
+ .extra_cjs_extensions
+ .clone_from(&importer.opts.extra_cjs_extensions);
+ }
+ Ok(resolver.resolve(source_dir, specifier, bun_ast::ImportKind::Stmt)?)
+ }
+
pub(crate) fn call(
&mut self,
import_record_path: &[u8],
@@ -130,26 +169,19 @@ impl MacroContext {
debug_assert!(!is_macro_path(import_record_path_without_macro_prefix));
- // SAFETY: `resolver` outlives `self` (see struct comment); uniquely
- // accessed for the duration of this resolve call.
- let resolver = unsafe { &mut *self.resolver };
-
let input_specifier: &[u8] = 'brk: {
if let Some(replacement) = ModuleLoader::HardcodedModule::Alias::get(
- import_record_path,
+ import_record_path_without_macro_prefix,
bun_ast::Target::Bun,
Default::default(),
) {
break 'brk replacement.path.as_bytes();
}
- let resolve_result = match resolver.resolve(
- source_dir,
- import_record_path_without_macro_prefix,
- bun_ast::ImportKind::Stmt,
- ) {
+ let resolved = self.resolve(source_dir, import_record_path_without_macro_prefix);
+ let resolve_result = match resolved {
Ok(r) => r,
- Err(bun_resolver::Error::ModuleNotFound) => {
+ Err(crate::Error::Resolver(bun_resolver::Error::ModuleNotFound)) => {
log.add_resolve_error(
Some(source),
import_range,
@@ -173,7 +205,7 @@ impl MacroContext {
bstr::BStr::new(import_record_path)
),
);
- return Err(e.into());
+ return Err(e);
}
};
// The resolver's `Result` owns its path strings via the global `DirnameStore`
@@ -258,6 +290,16 @@ impl MacroContext {
}
}
+impl Drop for MacroContext {
+ fn drop(&mut self) {
+ if let Some(resolver) = self.macro_resolver.take() {
+ let idle = (Arc::clone(&self.transform_options), resolver);
+ // The slot is gone when the thread exits. Then the resolver is dropped here.
+ let _ = IDLE_MACRO_RESOLVER.try_with(|slot| slot.set(Some(idle)));
+ }
+ }
+}
+
// ══════════════════════════════════════════════════════════════════════════
// Lower-tier bridge (`bun_ast::Macro::MacroContext` ⇆ this crate)
//
diff --git a/src/jsc/RuntimeTranspilerStore.rs b/src/jsc/RuntimeTranspilerStore.rs
index 63d4e6ea734e..5602cdfc3ef0 100644
--- a/src/jsc/RuntimeTranspilerStore.rs
+++ b/src/jsc/RuntimeTranspilerStore.rs
@@ -729,7 +729,7 @@ impl TranspilerJob {
// ~0.5 GB after 100k iterations). The owned `MimallocArena` inside is
// now lazy (`bump: Option`, init on first `.call()`), so the
// per-iteration `mi_heap_new()` is gone; this guard just reclaims the
- // small `Box`.
+ // small `Box` (and the macro resolver, when the module called a macro).
let _macro_ctx_guard =
scopeguard::guard(ptr::addr_of_mut!(transpiler.macro_context), |slot| {
// SAFETY: `slot` points into `transpiler_storage`, which is
diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs
index d1045e035a05..2ef0b5ca3c0f 100644
--- a/src/resolver/resolver.rs
+++ b/src/resolver/resolver.rs
@@ -651,6 +651,19 @@ impl<'a> Resolver<'a> {
}
}
+ /// [`Self::for_worker`] with `opts` for another target. SAFETY: the same contract.
+ pub unsafe fn for_target(
+ from: &Resolver<'_>,
+ log: NonNull,
+ opts: options::BundleOptions,
+ ) -> Resolver<'a> {
+ let care_about_browser_field = opts.target == options::Target::Browser;
+ // SAFETY: the caller upholds the contract of `for_worker`.
+ let mut resolver = unsafe { Self::for_worker(from, log, opts) };
+ resolver.care_about_browser_field = care_about_browser_field;
+ resolver
+ }
+
/// NOTE (Stacked Borrows): returns the RAW `*mut` (NOT `&'a mut`). A
/// `&'a mut` accessor would let two `fs()` calls manufacture coexisting
/// aliased unique refs to the same singleton (PORTING.md §Forbidden:
diff --git a/test/bundler/transpiler/macro-test.test.ts b/test/bundler/transpiler/macro-test.test.ts
index 16a41fa0d250..65572369ebba 100644
--- a/test/bundler/transpiler/macro-test.test.ts
+++ b/test/bundler/transpiler/macro-test.test.ts
@@ -1,7 +1,7 @@
import { escapeHTML } from "bun" with { type: "macro" };
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
-import { existsSync } from "node:fs";
+import { existsSync, symlinkSync } from "node:fs";
import path from "node:path";
import defaultMacro, {
addStrings,
@@ -811,3 +811,462 @@ describe("macro VM env loader", () => {
expect(withMacro).toEqual(withoutMacro);
});
});
+
+// https://github.com/oven-sh/bun/issues/8779
+// A macro runs in Bun while the importing file is transpiled. Its import resolves for that environment: Bun's
+// conditions plus "macro". The target, the "browser" field and the conditions of the output decide plain imports only.
+describe("a macro import resolves for the macro environment", () => {
+ const who = (label: string) =>
+ `export function who() {\n return ${JSON.stringify(label)};\n}\nexport default who;\n`;
+ const whoCjs = (label: string) => `exports.who = function who() {\n return ${JSON.stringify(label)};\n};\n`;
+
+ // The file a macro import of each specifier runs, whatever imports it: a bundle for any target, or Bun's runtime.
+ const macroCells: Record = {
+ // "macro" is the first key that matches.
+ "macro-first": "macro-first/macro.js",
+ "macro-dot": "macro-dot/macro.js",
+ "macro-nested": "macro-nested/macro.mjs",
+ "macro-subpath/sub": "macro-subpath/sub.macro.js",
+ "#m": "m.macro.ts",
+ // A tsconfig.json "paths" entry applies as it does to a plain import.
+ "@paths/who": "paths/who.ts",
+ // The first key that matches wins, like in Node. Here that is "import".
+ "macro-last": "macro-last/index.js",
+ // No "macro" key. The import still resolves for Bun and not for the output.
+ "env-conditions": "env-conditions/for-bun.js",
+ "browser-field": "browser-field/index.js",
+ "browser-map": "browser-map/index.js",
+ "module-main": "module-main/main.cjs",
+ "dev-condition": "dev-condition/index.js",
+ "custom-condition": "custom-condition/index.js",
+ };
+
+ // What imports it: a bundle for one of the three targets, or Bun's runtime with no bundle.
+ type Importer = "browser" | "bun" | "node" | "runtime";
+ // The file a plain import of the same specifier takes. That one does depend on the importer.
+ // prettier-ignore
+ const plainCells: Record> = {
+ "macro-first": { browser: "macro-first/index.js", bun: "macro-first/index.js", node: "macro-first/index.js", runtime: "macro-first/index.js" },
+ "env-conditions": { browser: "env-conditions/for-browser.js", bun: "env-conditions/for-bun.js", node: "env-conditions/for-node.js", runtime: "env-conditions/for-bun.js" },
+ "browser-field": { browser: "browser-field/browser.js", bun: "browser-field/index.js", node: "browser-field/index.js", runtime: "browser-field/index.js" },
+ "browser-map": { browser: "browser-map/browser.js", bun: "browser-map/index.js", node: "browser-map/index.js", runtime: "browser-map/index.js" },
+ "module-main": { browser: "module-main/module.mjs", bun: "module-main/module.mjs", node: "module-main/main.cjs", runtime: "module-main/main.cjs" },
+ "dev-condition": { browser: "dev-condition/dev.js", bun: "dev-condition/dev.js", node: "dev-condition/dev.js", runtime: "dev-condition/index.js" },
+ "#m": { browser: "m.browser.ts", bun: "m.ts", node: "m.ts", runtime: "m.ts" },
+ };
+
+ const expected = (importer: Importer, conditions: readonly string[] = []) => ({
+ macro: {
+ ...macroCells,
+ ...(conditions.includes("custom") && { "custom-condition": "custom-condition/custom.js" }),
+ },
+ plain: Object.fromEntries(Object.entries(plainCells).map(([specifier, files]) => [specifier, files[importer]])),
+ });
+
+ // A module that calls `who()` of every cell: `cells.macro` at transpile time, `cells.plain` when it runs.
+ const macroSpecifiers = Object.keys(macroCells);
+ const plainSpecifiers = Object.keys(plainCells);
+ const cellsSource = [
+ ...macroSpecifiers.map((s, i) => `import { who as macro${i} } from ${JSON.stringify(s)} with { type: "macro" };`),
+ ...plainSpecifiers.map((s, i) => `import { who as plain${i} } from ${JSON.stringify(s)};`),
+ `export const cells = {`,
+ ` macro: { ${macroSpecifiers.map((s, i) => `${JSON.stringify(s)}: macro${i}()`).join(", ")} },`,
+ ` plain: { ${plainSpecifiers.map((s, i) => `${JSON.stringify(s)}: plain${i}()`).join(", ")} },`,
+ `};`,
+ ``,
+ ].join("\n");
+
+ // Every file says which file it is.
+ function fixture(extra: Record = {}) {
+ const files: Record = {
+ "package.json": JSON.stringify({
+ name: "app",
+ imports: {
+ "#m": { macro: "./m.macro.ts", browser: "./m.browser.ts", default: "./m.ts" },
+ "#path": "node:path",
+ },
+ }),
+ "tsconfig.json": JSON.stringify({ compilerOptions: { paths: { "@paths/*": ["./paths/*"] } } }),
+ "paths/who.ts": who("paths/who.ts"),
+ "m.ts": who("m.ts"),
+ "m.macro.ts": who("m.macro.ts"),
+ "m.browser.ts": who("m.browser.ts"),
+ "entry.ts": cellsSource,
+ };
+ const pkg = (name: string, json: object, sources: string[]) => {
+ files[`node_modules/${name}/package.json`] = JSON.stringify({ name, ...json });
+ for (const file of sources) {
+ files[`node_modules/${name}/${file}`] = (file.endsWith(".cjs") ? whoCjs : who)(`${name}/${file}`);
+ }
+ };
+ const index = "./index.js";
+ pkg("macro-first", { exports: { macro: "./macro.js", import: index, require: index, default: index } }, [
+ "macro.js",
+ "index.js",
+ ]);
+ pkg("macro-dot", { exports: { ".": { macro: "./macro.js", default: index } } }, ["macro.js", "index.js"]);
+ pkg(
+ "macro-nested",
+ { exports: { ".": { macro: { import: "./macro.mjs", require: "./macro.cjs" }, default: index } } },
+ ["macro.mjs", "macro.cjs", "index.js"],
+ );
+ pkg("macro-subpath", { exports: { "./sub": { macro: "./sub.macro.js", default: "./sub.js" } } }, [
+ "sub.macro.js",
+ "sub.js",
+ ]);
+ pkg("macro-only", { exports: { macro: "./macro.js" } }, ["macro.js"]);
+ pkg("browser-only", { exports: { browser: "./browser.js" } }, ["browser.js"]);
+ pkg("bun-file", { exports: { bun: "./bun.js", default: index } }, ["bun.js", "index.js"]);
+ pkg("macro-last", { exports: { import: index, require: index, default: index, macro: "./macro.js" } }, [
+ "macro.js",
+ "index.js",
+ ]);
+ pkg(
+ "env-conditions",
+ { exports: { browser: "./for-browser.js", bun: "./for-bun.js", node: "./for-node.js", default: index } },
+ ["for-browser.js", "for-bun.js", "for-node.js", "index.js"],
+ );
+ pkg("browser-field", { main: index, browser: "./browser.js" }, ["index.js", "browser.js"]);
+ pkg("browser-map", { main: index, browser: { "./index.js": "./browser.js" } }, ["index.js", "browser.js"]);
+ pkg("module-main", { main: "./main.cjs", module: "./module.mjs" }, ["main.cjs", "module.mjs"]);
+ pkg("dev-condition", { exports: { development: "./dev.js", default: index } }, ["dev.js", "index.js"]);
+ pkg("custom-condition", { exports: { custom: "./custom.js", default: index } }, ["custom.js", "index.js"]);
+ return { ...files, ...extra };
+ }
+
+ // The table above, derived: a macro import takes the file that Bun's runtime resolves with the "macro" condition.
+ test.concurrent("the cells are what `bun run --conditions=macro` resolves", async () => {
+ using dir = tempDir(
+ "macro-env-oracle",
+ fixture({
+ "oracle.ts": `
+ import path from "node:path";
+ const label = file => {
+ const inPackage = path.relative(path.join(import.meta.dir, "node_modules"), file);
+ const relative = inPackage.startsWith("..") ? path.relative(import.meta.dir, file) : inPackage;
+ return relative.replaceAll(path.sep, "/");
+ };
+ const specifiers = ${JSON.stringify(macroSpecifiers)};
+ const resolved = specifiers.map(specifier => [specifier, label(Bun.resolveSync(specifier, import.meta.dir))]);
+ console.log(JSON.stringify(Object.fromEntries(resolved)));
+ `,
+ }),
+ );
+ const { result, stderr } = await run(String(dir), "run", "--conditions=macro", "oracle.ts");
+ expect({ result, stderr }).toEqual({ result: macroCells, stderr: "" });
+ });
+
+ async function run(cwd: string, ...args: string[]) {
+ await using proc = Bun.spawn({ cmd: [bunExe(), ...args], env: bunEnv, cwd, stdout: "pipe", stderr: "pipe" });
+ const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
+ // Debug builds also print "[macro] call " to stdout. The result is the last line.
+ const line = stdout.trim().split("\n").pop()!;
+ let result: unknown = line;
+ try {
+ result = JSON.parse(line);
+ } catch {}
+ return { result, stderr, exitCode };
+ }
+
+ test.concurrent.each([
+ ["bun build", [], "browser", []],
+ ["bun build --target=bun --conditions=custom", ["--target=bun", "--conditions=custom"], "bun", ["custom"]],
+ ] as const)("%s", async (_, flags, target, conditions) => {
+ using dir = tempDir("macro-env-build", fixture());
+ const build = await run(String(dir), "build", "./entry.ts", "--outdir=out", ...flags);
+ expect({ stderr: build.stderr, exitCode: build.exitCode }).toEqual({ stderr: "", exitCode: 0 });
+ const bundle = await run(
+ String(dir),
+ "-e",
+ `import { cells } from "./out/entry.js";\nconsole.log(JSON.stringify(cells));`,
+ );
+ expect(bundle.result).toEqual(expected(target, conditions));
+ });
+
+ // In its own process: the macro VM of a worker thread outlives the build that created it. Not concurrent: in a
+ // debug build these four builds take most of the default timeout when the other tests run beside them.
+ test("Bun.build(): each build's own target and conditions", async () => {
+ using dir = tempDir(
+ "macro-env-build-api",
+ fixture({
+ "late-entry.ts": `import { who } from "./late.macro.ts" with { type: "macro" };\nexport const cells = who();\n`,
+ "build.ts": `
+ const builds = {
+ bun: { target: "bun", conditions: ["custom"] },
+ browser: { target: "browser" },
+ node: { target: "node", conditions: ["custom"] },
+ };
+ const results = {};
+ for (const [name, options] of Object.entries(builds)) {
+ await Bun.build({ entrypoints: ["./entry.ts"], outdir: "out-" + name, ...options });
+ results[name] = (await import("./out-" + name + "/entry.js")).cells;
+ }
+ // A macro file that did not exist when the earlier builds listed the directory.
+ await Bun.write("./late.macro.ts", ${JSON.stringify(who("late.macro.ts"))});
+ await Bun.build({ entrypoints: ["./late-entry.ts"], outdir: "out-late" });
+ results.late = (await import("./out-late/late-entry.js")).cells;
+ console.log(JSON.stringify(results));
+ `,
+ }),
+ );
+ const { result, stderr, exitCode } = await run(String(dir), "run", "build.ts");
+ expect({ result, stderr }).toEqual({
+ result: {
+ bun: expected("bun", ["custom"]),
+ browser: expected("browser"),
+ node: expected("node", ["custom"]),
+ late: "late.macro.ts",
+ },
+ stderr: "",
+ });
+ expect(exitCode).toBe(0);
+ });
+
+ // The entry point and a require()d module are transpiled on the main thread, an imported module on another thread.
+ test.concurrent("bun run: the entry point, imported modules, a require()d module and a Worker", async () => {
+ using dir = tempDir(
+ "macro-env-run",
+ fixture({
+ "imported.ts": cellsSource + `export { cells as next } from "./imported-next.ts";\n`,
+ "imported-next.ts": cellsSource,
+ "required.ts": cellsSource,
+ "worker.ts": cellsSource + `postMessage(cells);\n`,
+ // `require.extensions` gets ".macrots" after the macros of the entry point were resolved.
+ "in-extension.macrots": who("in-extension.macrots"),
+ "uses-extension.ts": `import { who } from "./in-extension" with { type: "macro" };\nexport const extension = who();\n`,
+ "run.ts": [
+ cellsSource,
+ `import { cells as imported, next } from "./imported.ts";`,
+ `const { cells: required } = require("./required.ts");`,
+ `require.extensions[".macrots"] = require.extensions[".ts"];`,
+ `const { extension } = require("./uses-extension.ts");`,
+ `const worker = new Worker(new URL("./worker.ts", import.meta.url).href);`,
+ `const inWorker = await new Promise(resolve => (worker.onmessage = event => resolve(event.data)));`,
+ `worker.terminate();`,
+ `console.log(JSON.stringify({ entry: cells, imported, next, required, inWorker, extension }));`,
+ ``,
+ ].join("\n"),
+ }),
+ );
+ const flags = ["--conditions=custom", "--loader=.macrots:ts"];
+ const { result, stderr, exitCode } = await run(String(dir), "run", ...flags, "run.ts");
+ const cells = expected("runtime", ["custom"]);
+ expect({ result, stderr }).toEqual({
+ result: {
+ entry: cells,
+ imported: cells,
+ next: cells,
+ required: cells,
+ inWorker: cells,
+ extension: "in-extension.macrots",
+ },
+ stderr: "",
+ });
+ expect(exitCode).toBe(0);
+ });
+
+ test.concurrent("bun test", async () => {
+ using dir = tempDir(
+ "macro-env-test",
+ fixture({
+ "cells.test.ts": [
+ cellsSource,
+ `import { test } from "bun:test";`,
+ `test("cells", () => console.log(JSON.stringify(cells)));`,
+ ``,
+ ].join("\n"),
+ }),
+ );
+ const { result, exitCode } = await run(String(dir), "test", "./cells.test.ts");
+ expect(result).toEqual(expected("runtime"));
+ expect(exitCode).toBe(0);
+ });
+
+ // `Bun.Transpiler` does not bundle, so only the macro imports are resolved.
+ test.concurrent("Bun.Transpiler: transformSync() and transform() with a browser target", async () => {
+ const macrosOnly = [
+ ...macroSpecifiers.map((s, i) => `import { who as macro${i} } from ${JSON.stringify(s)} with { type: "macro" };`),
+ `export const cells = { ${macroSpecifiers.map((s, i) => `${JSON.stringify(s)}: macro${i}()`).join(", ")} };`,
+ ``,
+ ].join("\n");
+ using dir = tempDir(
+ "macro-env-transpiler",
+ fixture({
+ "macros-only.ts": macrosOnly,
+ "transform.ts": `
+ const code = await Bun.file("./macros-only.ts").text();
+ const transpiler = new Bun.Transpiler({ loader: "ts", target: "browser" });
+ await Bun.write("./sync.js", transpiler.transformSync(code));
+ // Each call is a new macro context. The second one takes the resolver that the first one left.
+ await Bun.write("./again.js", transpiler.transformSync(code));
+ await Bun.write("./async.js", await transpiler.transform(code));
+ const results = {
+ transformSync: (await import("./sync.js")).cells,
+ transformSyncAgain: (await import("./again.js")).cells,
+ transform: (await import("./async.js")).cells,
+ };
+ console.log(JSON.stringify(results));
+ `,
+ }),
+ );
+ const { result, stderr, exitCode } = await run(String(dir), "run", "transform.ts");
+ expect({ result, stderr }).toEqual({
+ result: { transformSync: macroCells, transformSyncAgain: macroCells, transform: macroCells },
+ stderr: "",
+ });
+ expect(exitCode).toBe(0);
+ });
+
+ test("every spelling of a macro import, builtin modules, and packages with one file for a macro", async () => {
+ using dir = tempDir(
+ "macro-env-spellings",
+ fixture({
+ "bunfig.toml": `[macros]\nremapped = { who = "macro-first" }\n`,
+ "spellings.ts": `
+ import { who as withAttribute } from "macro-first" with { type: "macro" };
+ import { who as assertAttribute } from "macro-first" assert { type: "macro" };
+ import { who as prefix } from "macro:macro-first";
+ import defaultImport from "macro-first" with { type: "macro" };
+ import * as namespace from "macro-first" with { type: "macro" };
+ import { who as remap } from "remapped";
+ // A builtin module is the same module under every spelling, and through the "imports" of package.json.
+ import { basename as builtin } from "node:path" with { type: "macro" };
+ import { basename as builtinPrefix } from "macro:node:path";
+ import { basename as builtinImports } from "#path" with { type: "macro" };
+ // This package has no file for a plain import.
+ import { who as macroOnly } from "macro-only" with { type: "macro" };
+ // The file for Bun is named like the "bun" builtin module.
+ import { who as bunFile } from "bun-file" with { type: "macro" };
+ // An import that no live call uses is never resolved.
+ import { who as unused } from "missing-package" with { type: "macro" };
+ import { who as dead } from "missing-package" with { type: "macro" };
+ if (false) dead();
+ const spellings = {
+ withAttribute: withAttribute(),
+ assertAttribute: assertAttribute(),
+ prefix: prefix(),
+ defaultImport: defaultImport(),
+ namespace: namespace.who(),
+ remap: remap(),
+ builtin: builtin("a/builtin.txt"),
+ builtinPrefix: builtinPrefix("a/prefix.txt"),
+ builtinImports: builtinImports("a/imports.txt"),
+ macroOnly: macroOnly(),
+ bunFile: bunFile(),
+ };
+ console.log(JSON.stringify(spellings));
+ `,
+ }),
+ );
+ const atRuntime = await run(String(dir), "run", "spellings.ts");
+ const build = await run(String(dir), "build", "./spellings.ts", "--outdir=out");
+ const bundle = await run(String(dir), "out/spellings.js");
+ const macro = "macro-first/macro.js";
+ const spellings = {
+ withAttribute: macro,
+ assertAttribute: macro,
+ prefix: macro,
+ defaultImport: macro,
+ namespace: macro,
+ remap: macro,
+ builtin: "builtin.txt",
+ builtinPrefix: "prefix.txt",
+ builtinImports: "imports.txt",
+ macroOnly: "macro-only/macro.js",
+ bunFile: "bun-file/bun.js",
+ };
+ expect({
+ run: { result: atRuntime.result, stderr: atRuntime.stderr },
+ build: { result: bundle.result, stderr: build.stderr },
+ }).toEqual({
+ run: { result: spellings, stderr: "" },
+ build: { result: spellings, stderr: "" },
+ });
+ });
+
+ // Each of these ran as a macro in a bundle for the browser before. A macro runs in Bun, so now none is found.
+ test.concurrent("a package with no file for Bun is not found, whatever the build targets", async () => {
+ using dir = tempDir(
+ "macro-env-browser-only",
+ fixture({
+ "browser-only.ts": `import { who } from "browser-only" with { type: "macro" };\nconsole.log(who());\n`,
+ }),
+ );
+ const { stderr, exitCode } = await run(String(dir), "build", "./browser-only.ts", "--target=browser");
+ expect({ notFound: stderr.includes(`Macro "browser-only" not found`), exitCode }).toEqual({
+ notFound: true,
+ exitCode: 1,
+ });
+ });
+
+ // `--external` and `--packages=external` say what the bundle leaves out, and a macro import is inlined at build
+ // time. The entries are in a folder with its own copy of the package: an import that the build marked external
+ // used to reach the macro VM unresolved, and then resolved from the working directory.
+ test("--external and --packages=external do not apply to a macro import", async () => {
+ const where = (label: string) => `export function where() {\n return ${JSON.stringify(label)};\n}\n`;
+ using dir = tempDir("macro-env-external", {
+ "sub/package.ts": `import { where } from "macros" with { type: "macro" };\nconsole.log(where());\n`,
+ "sub/relative.ts": `import { where } from "./macro.ts" with { type: "macro" };\nconsole.log(where());\n`,
+ "sub/macro.ts": where("the relative file"),
+ "sub/node_modules/macros/package.json": JSON.stringify({ name: "macros", main: "./index.js" }),
+ "sub/node_modules/macros/index.js": where("the importer's copy"),
+ "node_modules/macros/package.json": JSON.stringify({ name: "macros", main: "./index.js" }),
+ "node_modules/macros/index.js": where("the working directory's copy"),
+ "build-api.ts": `
+ const lastLines = [];
+ for (const options of [{ external: ["macros"] }, { packages: "external" }]) {
+ const build = await Bun.build({ entrypoints: ["./sub/package.ts"], target: "bun", ...options });
+ lastLines.push((await build.outputs[0].text()).trim().split("\\n").pop());
+ }
+ console.log(JSON.stringify(lastLines));
+ `,
+ });
+ const builds = [
+ ["./sub/package.ts", "--external=macros"],
+ ["./sub/package.ts", "--packages=external"],
+ ["./sub/package.ts", "--external=*"],
+ // https://github.com/oven-sh/bun/issues/40626
+ ["./sub/relative.ts", "--external=*"],
+ ];
+ // Without `--outdir` the bundle goes to stdout, and its last line is the `console.log()` of the entry.
+ const bundles = await Promise.all([
+ ...builds.map(([entry, flag]) => run(String(dir), "build", entry, "--target=bun", flag)),
+ run(String(dir), "run", "build-api.ts"),
+ ]);
+ expect(bundles.map(({ result, stderr }) => ({ result, stderr }))).toEqual([
+ { result: `console.log("the importer's copy");`, stderr: "" },
+ { result: `console.log("the importer's copy");`, stderr: "" },
+ { result: `console.log("the importer's copy");`, stderr: "" },
+ { result: `console.log("the relative file");`, stderr: "" },
+ { result: [`console.log("the importer's copy");`, `console.log("the importer's copy");`], stderr: "" },
+ ]);
+ });
+
+ // The directory cache is shared, so the macro resolver reads symlinks the way the importing resolver does.
+ test.concurrent("--preserve-symlinks also applies to a macro import", async () => {
+ using dir = tempDir("macro-env-symlinks", {
+ "linked-source/package.json": JSON.stringify({
+ name: "linked",
+ exports: { macro: "./macro.ts", default: "./index.ts" },
+ }),
+ "linked-source/index.ts": `export function where() {\n return "not the file for a macro";\n}\n`,
+ "linked-source/macro.ts": `
+ import path from "node:path";
+ export function where() {
+ return path.relative(process.cwd(), import.meta.dir).replaceAll(path.sep, "/");
+ }
+ `,
+ "node_modules/.keep": "",
+ "entry.ts": `import { where } from "linked" with { type: "macro" };\nconsole.log(JSON.stringify(where()));\n`,
+ });
+ symlinkSync(path.join(String(dir), "linked-source"), path.join(String(dir), "node_modules", "linked"), "junction");
+ const [followed, preserved] = await Promise.all([
+ run(String(dir), "run", "entry.ts"),
+ run(String(dir), "run", "--preserve-symlinks", "entry.ts"),
+ ]);
+ expect({ followed: followed.result, preserved: preserved.result }).toEqual({
+ followed: "linked-source",
+ preserved: "node_modules/linked",
+ });
+ });
+});
From 25a04e8a54c6b15edd8b479f88f9b85beca7d723 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Sat, 24 Oct 2026 21:32:05 +0000
Subject: [PATCH 2/3] macros: keep "development" for macro imports, give a
transpile job its own require.extensions list
- The macro resolver has the "development" condition when the importing
resolver has it. The user's `--conditions` and `--main-fields` apply as
before. The extension order is a copy of the importing resolver's: a macro
import reads only the `esm` lists, which no target changes. `from_api` is
as on the base again.
- A transpile job on another thread read `require.extensions` through a
bytewise copy of `vm.transpiler`. The JS thread frees that list when a
script assigns an extension, and the macro resolver reads the list on each
macro call. The job now has its own copy of the list.
- Tests: the page of an HTML import, `--conditions=browser`, `--main-fields`,
a "macro" entry that gives no file, the dev server, `require.extensions`
replaced during an import, and a Worker in a child of its own. That child
runs with leak checking off: a worker that called a macro does not free its
tables of macros when it ends. That leak is on main (#46228).
- Docs: the first version is v1.4.5, the subpath route has a full `exports`
example, and the conditions that also apply are listed.
---
docs/bundler/macros.mdx | 23 +-
docs/runtime/module-resolution.mdx | 2 +-
src/bundler/options.rs | 58 +++--
src/bundler/transpiler.rs | 50 ++---
src/js_parser_jsc/Macro.rs | 31 +--
src/jsc/RuntimeTranspilerStore.rs | 16 +-
test/bake/dev/bundle.test.ts | 59 +++++
test/bundler/transpiler/macro-test.test.ts | 247 +++++++++++++++++----
8 files changed, 355 insertions(+), 131 deletions(-)
diff --git a/docs/bundler/macros.mdx b/docs/bundler/macros.mdx
index df99780460ca..9c4eec327af8 100644
--- a/docs/bundler/macros.mdx
+++ b/docs/bundler/macros.mdx
@@ -110,14 +110,29 @@ import { macro } from "my-package" with { type: "macro" }; // macro import
The first import resolves to `./node_modules/my-package/index.js`; Bun resolves the second to `./node_modules/my-package/index.macro.js`.
-A macro runs in Bun, so a macro import resolves with the conditions `"macro"`, `"bun"`, and `"node"`, whatever the `target` of the build is. The `"macro"` condition applies to the macro import only. Imports inside the macro's own module resolve as they do in Bun's runtime, without `"macro"`.
+A macro runs in Bun, so a macro import resolves with the conditions `"macro"`, `"bun"`, and `"node"`, whatever the `target` of the build is. The `conditions` that you pass to the build also apply, and `"development"` applies when it applies to the other imports of the file. The `"macro"` condition applies to the macro import only. Imports inside the macro's own module resolve as they do in Bun's runtime, without `"macro"`.
- The `"macro"` condition requires Bun v1.4.4 or newer. Older versions resolve a macro import like any other import. To
- support them too, export the macro file under a subpath of its own, such as `"./macro": "./index.macro.js"`, and
- import it as `my-package/macro`.
+ The `"macro"` condition requires Bun v1.4.5 or newer. Older versions resolve a macro import like any other import.
+To support older versions too, export the macro file under a subpath of its own and import it as `my-package/macro`:
+
+```json title="package.json" icon="file-json"
+{
+ "name": "my-package",
+ "exports": {
+ ".": {
+ "macro": "./index.macro.js",
+ "import": "./index.js",
+ "require": "./index.js",
+ "default": "./index.js"
+ },
+ "./macro": "./index.macro.js"
+ }
+}
+```
+
## Execution
When Bun's transpiler sees a macro import, it calls the function using Bun's JavaScript runtime and converts the return value into an AST node.
diff --git a/docs/runtime/module-resolution.mdx b/docs/runtime/module-resolution.mdx
index 9d58d0fe9b53..368712a98373 100644
--- a/docs/runtime/module-resolution.mdx
+++ b/docs/runtime/module-resolution.mdx
@@ -196,7 +196,7 @@ Once it finds the `foo` package, Bun reads its `package.json` to determine the p
}
```
-Whichever of these conditions occurs _first_ in the `package.json` determines the package's entrypoint. The `"macro"` condition only matches an import with `{ type: "macro" }`: see [macros](/bundler/macros#export-condition-macro).
+Whichever of these conditions occurs _first_ in the `package.json` determines the package's entrypoint. The `"macro"` condition only matches a macro import: see [macros](/bundler/macros#export-condition-macro).
Bun respects subpath [`"exports"`](https://nodejs.org/api/packages.html#subpath-exports) and [`"imports"`](https://nodejs.org/api/packages.html#imports).
diff --git a/src/bundler/options.rs b/src/bundler/options.rs
index 5dfb4f41ab59..0fe455c43b4e 100644
--- a/src/bundler/options.rs
+++ b/src/bundler/options.rs
@@ -718,22 +718,6 @@ pub struct ESMConditions {
}
impl ESMConditions {
- /// The conditions of a build for `target`: the target's defaults, `node-addons`, then the user's.
- pub(crate) fn for_target(
- target: Target,
- transform: &api::TransformOptions,
- ) -> Result {
- Self::init(
- Target::default_conditions_map()[target],
- transform.allow_addons.unwrap_or(true),
- &transform
- .conditions
- .iter()
- .map(|s| &**s)
- .collect::>(),
- )
- }
-
pub fn init(
defaults: &[&[u8]],
allow_addons: bool,
@@ -1885,7 +1869,21 @@ impl<'a> BundleOptions<'a> {
opts.main_fields = owned_string_list(Target::default_main_fields_map()[opts.target]);
}
- opts.conditions = ESMConditions::for_target(opts.target, &transform)?;
+ {
+ // conditions:
+ // 1. defaults
+ // 2. node-addons
+ // 3. user conditions
+ opts.conditions = ESMConditions::init(
+ Target::default_conditions_map()[opts.target],
+ transform.allow_addons.unwrap_or(true),
+ &transform
+ .conditions
+ .iter()
+ .map(|s| &**s)
+ .collect::>(),
+ )?;
+ }
match opts.target {
Target::Node => {
@@ -1902,11 +1900,17 @@ impl<'a> BundleOptions<'a> {
opts.env.behavior = api::DotEnvBehavior::LoadAll;
if transform.extension_order.is_empty() {
- opts.extension_order.default.default =
- owned_string_list(bundle_options_defaults::EXTENSION_ORDER_BUN);
- opts.extension_order.node_modules.default = owned_string_list(
- bundle_options_defaults::node_modules::EXTENSION_ORDER_BUN,
- );
+ // we must also support require'ing .node files
+ static EXT_WITH_NODE: &[&[u8]] = &[
+ b".tsx", b".ts", b".jsx", b".cts", b".cjs", b".js", b".mjs", b".mts",
+ b".json", b".node",
+ ];
+ static NM_EXT_WITH_NODE: &[&[u8]] = &[
+ b".jsx", b".cjs", b".js", b".mjs", b".mts", b".tsx", b".ts", b".cts",
+ b".json", b".node",
+ ];
+ opts.extension_order.default.default = owned_string_list(EXT_WITH_NODE);
+ opts.extension_order.node_modules.default = owned_string_list(NM_EXT_WITH_NODE);
}
}
_ => {}
@@ -2004,11 +2008,6 @@ pub(crate) mod bundle_options_defaults {
b".tsx", b".ts", b".jsx", b".cts", b".cjs", b".js", b".mjs", b".mts", b".json",
];
- /// Bun must also support require'ing .node files.
- pub(crate) const EXTENSION_ORDER_BUN: &[&[u8]] = &[
- b".tsx", b".ts", b".jsx", b".cts", b".cjs", b".js", b".mjs", b".mts", b".json", b".node",
- ];
-
pub(crate) const MAIN_FIELD_EXTENSION_ORDER: &[&[u8]] =
&[b".js", b".cjs", b".cts", b".tsx", b".ts", b".jsx", b".json"];
@@ -2021,11 +2020,6 @@ pub(crate) mod bundle_options_defaults {
b".jsx", b".cjs", b".js", b".mjs", b".mts", b".tsx", b".ts", b".cts", b".json",
];
- pub(crate) const EXTENSION_ORDER_BUN: &[&[u8]] = &[
- b".jsx", b".cjs", b".js", b".mjs", b".mts", b".tsx", b".ts", b".cts", b".json",
- b".node",
- ];
-
pub(crate) const MODULE_EXTENSION_ORDER: &[&[u8]] = &[
b".mjs", b".jsx", b".mts", b".js", b".cjs", b".tsx", b".ts", b".cts", b".json",
];
diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs
index 3f36d74948ef..f0bdecc818ac 100644
--- a/src/bundler/transpiler.rs
+++ b/src/bundler/transpiler.rs
@@ -1078,27 +1078,26 @@ fn macro_resolver_options(
from: &resolver::options::BundleOptions,
transform: &api::TransformOptions,
) -> crate::Result {
- use crate::options_impl::{Target, TargetExt, bundle_options_defaults as defaults};
+ use crate::options_impl::{Target, TargetExt};
use resolver::options as ropts;
- let conditions = options::ESMConditions::for_target(Target::BunMacro, transform)?;
- // As in `from_api`: `--main-fields` and `--extension-order` replace the lists of the target.
+ // As in `from_api`: the defaults of the target, `node-addons`, then the user's `--conditions`.
+ let user_conditions: Vec<&[u8]> = transform.conditions.iter().map(|s| &**s).collect();
+ let mut conditions = options::ESMConditions::init(
+ Target::default_conditions_map()[Target::BunMacro],
+ transform.allow_addons.unwrap_or(true),
+ &user_conditions,
+ )?;
+ // "development" says how the build runs, not what it is for: a macro import has it when the importing resolver has it.
+ if from.conditions.import.contains(b"development") {
+ conditions.append_slice(&[b"development"])?;
+ }
+ // As in `from_api`: `--main-fields` replaces the list of the target.
let main_fields = if transform.main_fields.is_empty() {
ropts::owned_string_list(Target::default_main_fields_map()[Target::BunMacro])
} else {
transform.main_fields.iter().cloned().collect()
};
- let (extensions, node_modules_extensions) = if transform.extension_order.is_empty() {
- (
- ropts::owned_string_list(defaults::EXTENSION_ORDER_BUN),
- ropts::owned_string_list(defaults::node_modules::EXTENSION_ORDER_BUN),
- )
- } else {
- (
- from.extension_order.default.default.clone(),
- from.extension_order.node_modules.default.clone(),
- )
- };
Ok(ropts::BundleOptions {
// A macro runs in Bun: what the target decides is as in Bun's runtime, with "macro", without the build's externals.
@@ -1110,17 +1109,6 @@ fn macro_resolver_options(
},
main_fields,
main_fields_is_default: transform.main_fields.is_empty(),
- extension_order: ropts::ExtensionOrder {
- default: ropts::ExtensionOrderGroup {
- default: extensions,
- esm: from.extension_order.default.esm.clone(),
- },
- node_modules: ropts::ExtensionOrderGroup {
- default: node_modules_extensions,
- esm: from.extension_order.node_modules.esm.clone(),
- },
- css: from.extension_order.css.clone(),
- },
external: ropts::ExternalModules {
patterns: options::default_wildcard_patterns(),
..Default::default()
@@ -1152,6 +1140,18 @@ fn macro_resolver_options(
supports_multiple_outputs: from.supports_multiple_outputs,
tree_shaking: from.tree_shaking,
allow_runtime: from.allow_runtime,
+ // A macro import is an `import` statement: it reads the `esm` extension lists, which no target changes.
+ extension_order: ropts::ExtensionOrder {
+ default: ropts::ExtensionOrderGroup {
+ default: from.extension_order.default.default.clone(),
+ esm: from.extension_order.default.esm.clone(),
+ },
+ node_modules: ropts::ExtensionOrderGroup {
+ default: from.extension_order.node_modules.default.clone(),
+ esm: from.extension_order.node_modules.esm.clone(),
+ },
+ css: from.extension_order.css.clone(),
+ },
})
}
diff --git a/src/js_parser_jsc/Macro.rs b/src/js_parser_jsc/Macro.rs
index b07131d66267..c75558b2a70f 100644
--- a/src/js_parser_jsc/Macro.rs
+++ b/src/js_parser_jsc/Macro.rs
@@ -42,14 +42,6 @@ fn is_macro_path(str: &[u8]) -> bool {
// MacroContext
// ══════════════════════════════════════════════════════════════════════════
-/// A macro resolver that no `MacroContext` uses now, with the options of the build that it was made for.
-type IdleMacroResolver = (Arc, Box>);
-
-thread_local! {
- // A `MacroContext` lasts one module in a transpile job and one call of `Bun.Transpiler`: its resolver waits here for the next.
- static IDLE_MACRO_RESOLVER: Cell> = const { Cell::new(None) };
-}
-
// All three are modelled as raw pointers because the referents live
// inside the owning `Transpiler` and are also reachable through other aliases
// (`Transpiler.resolver`, `Transpiler.env`, `Transpiler.options`); a `&'a mut`
@@ -121,15 +113,16 @@ impl MacroContext {
let importer = unsafe { &*self.resolver };
let resolver = match &mut self.macro_resolver {
Some(resolver) => resolver,
- // The address of the options says which build made the idle resolver.
- slot @ None => slot.insert(match IDLE_MACRO_RESOLVER.take() {
- Some((options, idle)) if Arc::ptr_eq(&options, &self.transform_options) => idle,
- _ => Transpiler::macro_resolver(importer, &self.transform_options)?,
- }),
+ slot @ None => slot.insert(Transpiler::macro_resolver(
+ importer,
+ &self.transform_options,
+ )?),
};
- // What the importing resolver can get after its first macro: a log per module, the auto-installer, `require.extensions`.
+ // A context that lasts longer than one module meets a new log for each module.
resolver.log = importer.log;
+ // `PackageJSON::parse` records the `npm:` aliases of a project through this. The importer gets it at its first auto-install.
resolver.package_manager = importer.package_manager;
+ // `require.extensions` can get an entry after the first macro call.
if resolver.opts.extra_cjs_extensions != importer.opts.extra_cjs_extensions {
resolver
.opts
@@ -290,16 +283,6 @@ impl MacroContext {
}
}
-impl Drop for MacroContext {
- fn drop(&mut self) {
- if let Some(resolver) = self.macro_resolver.take() {
- let idle = (Arc::clone(&self.transform_options), resolver);
- // The slot is gone when the thread exits. Then the resolver is dropped here.
- let _ = IDLE_MACRO_RESOLVER.try_with(|slot| slot.set(Some(idle)));
- }
- }
-}
-
// ══════════════════════════════════════════════════════════════════════════
// Lower-tier bridge (`bun_ast::Macro::MacroContext` ⇆ this crate)
//
diff --git a/src/jsc/RuntimeTranspilerStore.rs b/src/jsc/RuntimeTranspilerStore.rs
index 5602cdfc3ef0..8a5f95d5f7f2 100644
--- a/src/jsc/RuntimeTranspilerStore.rs
+++ b/src/jsc/RuntimeTranspilerStore.rs
@@ -374,6 +374,10 @@ impl RuntimeTranspilerStore {
poll_ref: KeepAlive::default(),
resolved_source,
generation_number: self.generation_number.load(Ordering::SeqCst),
+ // SAFETY: this is the JS thread of `vm`, the only thread that writes the list.
+ extra_cjs_extensions: unsafe {
+ (*vm).transpiler.resolver.opts.extra_cjs_extensions.clone()
+ },
parse_error: None,
work_task: WorkPoolTask {
node: Default::default(),
@@ -431,6 +435,8 @@ pub struct TranspilerJob {
pub global_this: BackRef,
pub(crate) poll_ref: KeepAlive,
pub(crate) generation_number: u32,
+ /// `require.extensions` as of `transpile()`. The JS thread can replace the list of `vm.transpiler` while `run()` resolves.
+ pub(crate) extra_cjs_extensions: Box<[Box<[u8]>]>,
pub(crate) log: bun_ast::Log,
pub(crate) parse_error: Option,
/// Moved out by `run_from_js_thread`; dropped with the slot otherwise.
@@ -700,7 +706,7 @@ impl TranspilerJob {
// a bytewise copy. SAFETY: `vm.transpiler` is read via
// `addr_of!` (no `&VirtualMachine` formed); every internal raw pointer in the copy
// still targets memory owned by `vm.transpiler` (resolver caches, define, env) which
- // outlives this stack frame; `vm.transpiler` is not concurrently mutated.
+ // outlives this stack frame. One thing the JS thread replaces meanwhile is the `require.extensions` list: see below.
// The by-value copy must not be deinitialized; `ManuallyDrop` suppresses Drop so owned
// fields aren't double-freed against `vm.transpiler`.
let mut transpiler_storage =
@@ -715,6 +721,14 @@ impl TranspilerJob {
unsafe { &mut *(&raw mut *transpiler_storage).cast::>() };
transpiler.set_arena(&arena);
transpiler.set_log(&raw mut log);
+ // The copy has the `require.extensions` list of `vm.transpiler`, which the JS thread frees when a script
+ // assigns an extension. SAFETY: the copy is never dropped, so the list of this job keeps one owner, the job.
+ unsafe {
+ ptr::write(
+ &raw mut transpiler.resolver.opts.extra_cjs_extensions,
+ ptr::read(&raw const self.extra_cjs_extensions),
+ )
+ };
// Note: the resolver already shares opts with the parent
// Transpiler via raw pointer; set_arena/set_log keep them in sync.
transpiler.macro_context = None;
diff --git a/test/bake/dev/bundle.test.ts b/test/bake/dev/bundle.test.ts
index bb0989e6b750..acdd98470cf5 100644
--- a/test/bake/dev/bundle.test.ts
+++ b/test/bake/dev/bundle.test.ts
@@ -80,6 +80,65 @@ devTest('uses "development" condition', {
await dev.fetch("/").equals("Environment: development");
},
});
+// A macro runs in Bun while the dev server bundles the file that imports it. So its import resolves for Bun, with the
+// "macro" condition, in the graph of the server and in the graph of the client. "development" applies in both.
+{
+ const which = (file: string) => `export function which() {\n return ${JSON.stringify(file)};\n}\n`;
+ const macroPackages = {
+ "node_modules/macro-key/package.json": JSON.stringify({
+ name: "macro-key",
+ exports: { macro: "./macro.js", default: "./index.js" },
+ }),
+ "node_modules/macro-key/macro.js": which("macro.js"),
+ "node_modules/macro-key/index.js": which("index.js"),
+ "node_modules/by-environment/package.json": JSON.stringify({
+ name: "by-environment",
+ exports: { browser: "./browser.js", development: "./development.js", default: "./production.js" },
+ }),
+ "node_modules/by-environment/browser.js": which("browser.js"),
+ "node_modules/by-environment/development.js": which("development.js"),
+ "node_modules/by-environment/production.js": which("production.js"),
+ };
+ const macroImports = `
+ import { which as macroKey } from 'macro-key' with { type: "macro" };
+ import { which as byEnvironment } from 'by-environment' with { type: "macro" };
+ `;
+ devTest("a macro import in a route resolves for Bun", {
+ framework: minimalFramework,
+ files: {
+ ...macroPackages,
+ "routes/index.ts": `
+ ${macroImports}
+ export default function (req, meta) {
+ return new Response('first bundle: ' + macroKey() + ' ' + byEnvironment());
+ }
+ `,
+ },
+ async test(dev) {
+ await dev.fetch("/").equals("first bundle: macro.js development.js");
+ await dev.patch("routes/index.ts", { find: "first bundle", replace: "second bundle" });
+ await dev.fetch("/").equals("second bundle: macro.js development.js");
+ },
+ });
+ devTest("a macro import in a page resolves for Bun", {
+ files: {
+ ...macroPackages,
+ "index.html": emptyHtmlFile({ scripts: ["index.ts"] }),
+ "index.ts": `
+ ${macroImports}
+ console.log('first bundle: ' + macroKey() + ' ' + byEnvironment());
+ `,
+ },
+ async test(dev) {
+ await using c = await dev.client("/");
+ await c.expectMessage("first bundle: macro.js development.js");
+ await c.expectReload(async () => {
+ await dev.patch("index.ts", { find: "first bundle", replace: "second bundle" });
+ });
+ await c.expectMessage("second bundle: macro.js development.js");
+ },
+ });
+}
// A route is server code: it takes the "main" file of a package that also has a string "browser".
// That does not depend on which reads the package first, the server graph or a preload.
for (const [reader, bunfig] of [
diff --git a/test/bundler/transpiler/macro-test.test.ts b/test/bundler/transpiler/macro-test.test.ts
index 65572369ebba..cc318dcc0b20 100644
--- a/test/bundler/transpiler/macro-test.test.ts
+++ b/test/bundler/transpiler/macro-test.test.ts
@@ -1,7 +1,7 @@
import { escapeHTML } from "bun" with { type: "macro" };
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
-import { existsSync, symlinkSync } from "node:fs";
+import { existsSync, readdirSync, symlinkSync } from "node:fs";
import path from "node:path";
import defaultMacro, {
addStrings,
@@ -814,13 +814,15 @@ describe("macro VM env loader", () => {
// https://github.com/oven-sh/bun/issues/8779
// A macro runs in Bun while the importing file is transpiled. Its import resolves for that environment: Bun's
-// conditions plus "macro". The target, the "browser" field and the conditions of the output decide plain imports only.
+// conditions plus "macro". The target of the output, with its conditions and the "browser" field, decides plain
+// imports only. What the user sets for the whole build applies to both: `--conditions`, `--main-fields`.
describe("a macro import resolves for the macro environment", () => {
const who = (label: string) =>
`export function who() {\n return ${JSON.stringify(label)};\n}\nexport default who;\n`;
const whoCjs = (label: string) => `exports.who = function who() {\n return ${JSON.stringify(label)};\n};\n`;
- // The file a macro import of each specifier runs, whatever imports it: a bundle for any target, or Bun's runtime.
+ // The file a macro import of each specifier runs in Bun's runtime. `expected()` has the cells that depend on the
+ // conditions of the importer. No cell depends on its target.
const macroCells: Record = {
// "macro" is the first key that matches.
"macro-first": "macro-first/macro.js",
@@ -832,6 +834,8 @@ describe("a macro import resolves for the macro environment", () => {
"@paths/who": "paths/who.ts",
// The first key that matches wins, like in Node. Here that is "import".
"macro-last": "macro-last/index.js",
+ // The "macro" entry holds conditions and none of them matches, so the next key decides.
+ "macro-fallthrough": "macro-fallthrough/index.js",
// No "macro" key. The import still resolves for Bun and not for the output.
"env-conditions": "env-conditions/for-bun.js",
"browser-field": "browser-field/index.js",
@@ -858,7 +862,14 @@ describe("a macro import resolves for the macro environment", () => {
const expected = (importer: Importer, conditions: readonly string[] = []) => ({
macro: {
...macroCells,
+ // A bundle that is not for production has the "development" condition. It is not a condition of the target.
+ ...(importer !== "runtime" && { "dev-condition": "dev-condition/dev.js" }),
...(conditions.includes("custom") && { "custom-condition": "custom-condition/custom.js" }),
+ // A condition that the user names applies to a macro import too, also when it is the name of a target.
+ ...(conditions.includes("browser") && {
+ "env-conditions": "env-conditions/for-browser.js",
+ "macro-fallthrough": "macro-fallthrough/browser.js",
+ }),
},
plain: Object.fromEntries(Object.entries(plainCells).map(([specifier, files]) => [specifier, files[importer]])),
});
@@ -914,6 +925,11 @@ describe("a macro import resolves for the macro environment", () => {
"sub.macro.js",
"sub.js",
]);
+ pkg("macro-fallthrough", { exports: { macro: { browser: "./browser.js" }, default: index } }, [
+ "browser.js",
+ "index.js",
+ ]);
+ pkg("macro-null", { exports: { macro: null, default: index } }, ["index.js"]);
pkg("macro-only", { exports: { macro: "./macro.js" } }, ["macro.js"]);
pkg("browser-only", { exports: { browser: "./browser.js" } }, ["browser.js"]);
pkg("bun-file", { exports: { bun: "./bun.js", default: index } }, ["bun.js", "index.js"]);
@@ -934,7 +950,8 @@ describe("a macro import resolves for the macro environment", () => {
return { ...files, ...extra };
}
- // The table above, derived: a macro import takes the file that Bun's runtime resolves with the "macro" condition.
+ // The table above, derived: a macro import takes the file that Bun's runtime resolves with the "macro" condition,
+ // with "development" where the importer is a bundle that is not for production, and with the user's conditions.
test.concurrent("the cells are what `bun run --conditions=macro` resolves", async () => {
using dir = tempDir(
"macro-env-oracle",
@@ -952,12 +969,22 @@ describe("a macro import resolves for the macro environment", () => {
`,
}),
);
- const { result, stderr } = await run(String(dir), "run", "--conditions=macro", "oracle.ts");
- expect({ result, stderr }).toEqual({ result: macroCells, stderr: "" });
+ const development = "--conditions=development";
+ const [runtime, bundle, withBrowser] = await Promise.all([
+ run(String(dir), "run", "--conditions=macro", "oracle.ts"),
+ run(String(dir), "run", "--conditions=macro", development, "oracle.ts"),
+ run(String(dir), "run", "--conditions=macro", development, "--conditions=browser", "oracle.ts"),
+ ]);
+ expect({ runtime, bundle, withBrowser }).toEqual({
+ runtime: { result: expected("runtime").macro, stderr: "", exitCode: 0 },
+ bundle: { result: expected("bun").macro, stderr: "", exitCode: 0 },
+ withBrowser: { result: expected("browser", ["browser"]).macro, stderr: "", exitCode: 0 },
+ });
});
- async function run(cwd: string, ...args: string[]) {
- await using proc = Bun.spawn({ cmd: [bunExe(), ...args], env: bunEnv, cwd, stdout: "pipe", stderr: "pipe" });
+ const run = (cwd: string, ...args: string[]) => runWith(bunEnv, cwd, ...args);
+ async function runWith(env: typeof bunEnv, cwd: string, ...args: string[]) {
+ await using proc = Bun.spawn({ cmd: [bunExe(), ...args], env, cwd, stdout: "pipe", stderr: "pipe" });
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// Debug builds also print "[macro] call " to stdout. The result is the last line.
const line = stdout.trim().split("\n").pop()!;
@@ -971,6 +998,7 @@ describe("a macro import resolves for the macro environment", () => {
test.concurrent.each([
["bun build", [], "browser", []],
["bun build --target=bun --conditions=custom", ["--target=bun", "--conditions=custom"], "bun", ["custom"]],
+ ["bun build --conditions=browser", ["--conditions=browser"], "browser", ["browser"]],
] as const)("%s", async (_, flags, target, conditions) => {
using dir = tempDir("macro-env-build", fixture());
const build = await run(String(dir), "build", "./entry.ts", "--outdir=out", ...flags);
@@ -1023,14 +1051,13 @@ describe("a macro import resolves for the macro environment", () => {
});
// The entry point and a require()d module are transpiled on the main thread, an imported module on another thread.
- test.concurrent("bun run: the entry point, imported modules, a require()d module and a Worker", async () => {
+ test.concurrent("bun run: the entry point, imported modules and a require()d module", async () => {
using dir = tempDir(
"macro-env-run",
fixture({
"imported.ts": cellsSource + `export { cells as next } from "./imported-next.ts";\n`,
"imported-next.ts": cellsSource,
"required.ts": cellsSource,
- "worker.ts": cellsSource + `postMessage(cells);\n`,
// `require.extensions` gets ".macrots" after the macros of the entry point were resolved.
"in-extension.macrots": who("in-extension.macrots"),
"uses-extension.ts": `import { who } from "./in-extension" with { type: "macro" };\nexport const extension = who();\n`,
@@ -1040,10 +1067,7 @@ describe("a macro import resolves for the macro environment", () => {
`const { cells: required } = require("./required.ts");`,
`require.extensions[".macrots"] = require.extensions[".ts"];`,
`const { extension } = require("./uses-extension.ts");`,
- `const worker = new Worker(new URL("./worker.ts", import.meta.url).href);`,
- `const inWorker = await new Promise(resolve => (worker.onmessage = event => resolve(event.data)));`,
- `worker.terminate();`,
- `console.log(JSON.stringify({ entry: cells, imported, next, required, inWorker, extension }));`,
+ `console.log(JSON.stringify({ entry: cells, imported, next, required, extension }));`,
``,
].join("\n"),
}),
@@ -1052,19 +1076,34 @@ describe("a macro import resolves for the macro environment", () => {
const { result, stderr, exitCode } = await run(String(dir), "run", ...flags, "run.ts");
const cells = expected("runtime", ["custom"]);
expect({ result, stderr }).toEqual({
- result: {
- entry: cells,
- imported: cells,
- next: cells,
- required: cells,
- inWorker: cells,
- extension: "in-extension.macrots",
- },
+ result: { entry: cells, imported: cells, next: cells, required: cells, extension: "in-extension.macrots" },
stderr: "",
});
expect(exitCode).toBe(0);
});
+ test.concurrent("bun run: a Worker", async () => {
+ using dir = tempDir(
+ "macro-env-worker",
+ fixture({
+ "worker.ts": cellsSource + `postMessage(cells);\n`,
+ "run.ts": [
+ `const worker = new Worker(new URL("./worker.ts", import.meta.url).href);`,
+ `const inWorker = await new Promise(resolve => (worker.onmessage = event => resolve(event.data)));`,
+ `worker.terminate();`,
+ `console.log(JSON.stringify(inWorker));`,
+ ``,
+ ].join("\n"),
+ }),
+ );
+ // A worker that called a macro does not free its tables of macros when it ends, whatever the macro import resolved
+ // to (https://github.com/oven-sh/bun/pull/46228). So leak checking is off for this child.
+ const env = { ...bunEnv, ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=0"].filter(Boolean).join(":") };
+ const { result, stderr, exitCode } = await runWith(env, String(dir), "run", "--conditions=custom", "run.ts");
+ expect({ result, stderr }).toEqual({ result: expected("runtime", ["custom"]), stderr: "" });
+ expect(exitCode).toBe(0);
+ });
+
test.concurrent("bun test", async () => {
using dir = tempDir(
"macro-env-test",
@@ -1097,7 +1136,7 @@ describe("a macro import resolves for the macro environment", () => {
const code = await Bun.file("./macros-only.ts").text();
const transpiler = new Bun.Transpiler({ loader: "ts", target: "browser" });
await Bun.write("./sync.js", transpiler.transformSync(code));
- // Each call is a new macro context. The second one takes the resolver that the first one left.
+ // Each call is a new macro context, with a macro resolver of its own.
await Bun.write("./again.js", transpiler.transformSync(code));
await Bun.write("./async.js", await transpiler.transform(code));
const results = {
@@ -1117,6 +1156,8 @@ describe("a macro import resolves for the macro environment", () => {
expect(exitCode).toBe(0);
});
+ // Not concurrent: in a debug build these three processes take most of the default timeout when the other tests run
+ // beside them.
test("every spelling of a macro import, builtin modules, and packages with one file for a macro", async () => {
using dir = tempDir(
"macro-env-spellings",
@@ -1176,32 +1217,149 @@ describe("a macro import resolves for the macro environment", () => {
bunFile: "bun-file/bun.js",
};
expect({
- run: { result: atRuntime.result, stderr: atRuntime.stderr },
- build: { result: bundle.result, stderr: build.stderr },
+ run: atRuntime,
+ build: { stderr: build.stderr, exitCode: build.exitCode },
+ bundle,
}).toEqual({
- run: { result: spellings, stderr: "" },
- build: { result: spellings, stderr: "" },
+ run: { result: spellings, stderr: "", exitCode: 0 },
+ build: { stderr: "", exitCode: 0 },
+ bundle: { result: spellings, stderr: "", exitCode: 0 },
});
});
- // Each of these ran as a macro in a bundle for the browser before. A macro runs in Bun, so now none is found.
- test.concurrent("a package with no file for Bun is not found, whatever the build targets", async () => {
+ // A package with a file only for the browser ran as a macro in a bundle for the browser before. A macro runs in
+ // Bun, so now it is not found, unless the user names the condition for the whole build. A "macro" entry that gives
+ // no file is not found either: as in Node, the resolver does not go on to the next key.
+ test.concurrent("a package with no file for a macro is not found", async () => {
using dir = tempDir(
- "macro-env-browser-only",
+ "macro-env-not-found",
fixture({
"browser-only.ts": `import { who } from "browser-only" with { type: "macro" };\nconsole.log(who());\n`,
+ "macro-null.ts": `import { who } from "macro-null" with { type: "macro" };\nconsole.log(who());\n`,
}),
);
- const { stderr, exitCode } = await run(String(dir), "build", "./browser-only.ts", "--target=browser");
- expect({ notFound: stderr.includes(`Macro "browser-only" not found`), exitCode }).toEqual({
- notFound: true,
- exitCode: 1,
+ const [browserOnly, withCondition, nullInBundle, nullAtRuntime] = await Promise.all([
+ run(String(dir), "build", "./browser-only.ts", "--target=browser"),
+ run(String(dir), "build", "./browser-only.ts", "--target=browser", "--conditions=browser"),
+ run(String(dir), "build", "./macro-null.ts", "--target=browser"),
+ run(String(dir), "run", "macro-null.ts"),
+ ]);
+ const notFound = (specifier: string, { stderr, exitCode }: { stderr: string; exitCode: number }) => ({
+ notFound: stderr.includes(`error: Macro "${specifier}" not found`),
+ exitCode,
+ });
+ expect({
+ browserOnly: notFound("browser-only", browserOnly),
+ withCondition,
+ nullInBundle: notFound("macro-null", nullInBundle),
+ nullAtRuntime: notFound("macro-null", nullAtRuntime),
+ }).toEqual({
+ browserOnly: { notFound: true, exitCode: 1 },
+ withCondition: { result: `console.log("browser-only/browser.js");`, stderr: "", exitCode: 0 },
+ nullInBundle: { notFound: true, exitCode: 1 },
+ nullAtRuntime: { notFound: true, exitCode: 1 },
+ });
+ });
+
+ // An HTML import makes a second graph in the same build: the page, for the browser. The resolver of that graph has
+ // neither the user's conditions nor "development". A macro import of the page still has the user's conditions, as
+ // the macro itself has when it runs. "development" follows the graph.
+ test.concurrent("bun build: the page of an HTML import", async () => {
+ using dir = tempDir(
+ "macro-env-html",
+ fixture({
+ "index.html": ``,
+ "page.ts": cellsSource + `console.log(JSON.stringify(cells));\n`,
+ "server.ts": [
+ cellsSource,
+ `import page from "./index.html";`,
+ `if (!page) throw new Error("no page");`,
+ `console.log(JSON.stringify(cells));`,
+ ``,
+ ].join("\n"),
+ }),
+ );
+ const flags = ["--target=bun", "--conditions=custom", "--outdir=out"];
+ const build = await run(String(dir), "build", "./server.ts", ...flags);
+ expect({ stderr: build.stderr, exitCode: build.exitCode }).toEqual({ stderr: "", exitCode: 0 });
+ const chunks = readdirSync(path.join(String(dir), "out")).filter(name => name.endsWith(".js"));
+ expect(chunks.toSorted()).toEqual([expect.stringMatching(/^index-.+\.js$/), "server.js"]);
+ const [server, page] = await Promise.all(
+ ["server.js", chunks.find(name => name !== "server.js")!].map(name => run(String(dir), path.join("out", name))),
+ );
+ const ofPage = page.result as ReturnType;
+ expect({ server: server.result, page: ofPage.macro }).toEqual({
+ server: expected("bun", ["custom"]),
+ page: { ...expected("runtime", ["custom"]).macro, "dev-condition": ofPage.plain["dev-condition"] },
+ });
+ });
+
+ // `--main-fields` replaces the list of the target, for a macro import too. The macro resolves its own imports with
+ // the same list when it runs.
+ test.concurrent("--main-fields also applies to a macro import", async () => {
+ using dir = tempDir(
+ "macro-env-main-fields",
+ fixture({
+ "main-fields.ts": `
+ import { who as browserField } from "browser-field" with { type: "macro" };
+ import { who as moduleMain } from "module-main" with { type: "macro" };
+ console.log([browserField(), moduleMain()].join(" "));
+ `,
+ }),
+ );
+ const flags = ["--main-fields=browser", "--main-fields=module", "--main-fields=main"];
+ const [atRuntime, bundle] = await Promise.all([
+ run(String(dir), "run", ...flags, "main-fields.ts"),
+ run(String(dir), "build", "./main-fields.ts", "--target=bun", ...flags),
+ ]);
+ expect({ atRuntime, bundle }).toEqual({
+ atRuntime: { result: "browser-field/browser.js module-main/module.mjs", stderr: "", exitCode: 0 },
+ bundle: {
+ result: `console.log(["browser-field/browser.js", "module-main/module.mjs"].join(" "));`,
+ stderr: "",
+ exitCode: 0,
+ },
+ });
+ });
+
+ // A module that `import()` loads is transpiled on another thread, with a copy of the transpiler of the VM. The first
+ // macro waits there until the main thread has replaced the list of `require.extensions`. Then the second macro
+ // import resolves, through an extension that the list had when the import started.
+ test.concurrent("require.extensions changes while another thread resolves a macro import", async () => {
+ using dir = tempDir("macro-env-extensions-race", {
+ "gate.macrots": `
+ import { existsSync, writeFileSync } from "node:fs";
+ export function first() {
+ writeFileSync(import.meta.dir + "/started", "");
+ while (!existsSync(import.meta.dir + "/replaced")) {}
+ return "first";
+ }
+ export function second() {
+ return "second";
+ }
+ `,
+ "imported.ts": `
+ import { first, second } from "./gate" with { type: "macro" };
+ export const results = [first(), second()];
+ `,
+ "main.ts": `
+ import { existsSync, writeFileSync } from "node:fs";
+ require.extensions[".macrots"] = require.extensions[".ts"];
+ const imported = import("./imported.ts");
+ while (!existsSync(import.meta.dir + "/started")) await new Promise(resolve => setImmediate(resolve));
+ require.extensions[".other"] = require.extensions[".ts"];
+ writeFileSync(import.meta.dir + "/replaced", "");
+ console.log(JSON.stringify((await imported).results));
+ `,
});
+ const { result, stderr, exitCode } = await run(String(dir), "run", "--loader=.macrots:ts", "main.ts");
+ expect({ result, stderr, exitCode }).toEqual({ result: ["first", "second"], stderr: "", exitCode: 0 });
});
// `--external` and `--packages=external` say what the bundle leaves out, and a macro import is inlined at build
// time. The entries are in a folder with its own copy of the package: an import that the build marked external
- // used to reach the macro VM unresolved, and then resolved from the working directory.
+ // used to reach the macro VM unresolved, and then resolved from the working directory. Not concurrent: in a debug
+ // build these five processes take most of the default timeout when the other tests run beside them.
test("--external and --packages=external do not apply to a macro import", async () => {
const where = (label: string) => `export function where() {\n return ${JSON.stringify(label)};\n}\n`;
using dir = tempDir("macro-env-external", {
@@ -1233,12 +1391,13 @@ describe("a macro import resolves for the macro environment", () => {
...builds.map(([entry, flag]) => run(String(dir), "build", entry, "--target=bun", flag)),
run(String(dir), "run", "build-api.ts"),
]);
- expect(bundles.map(({ result, stderr }) => ({ result, stderr }))).toEqual([
- { result: `console.log("the importer's copy");`, stderr: "" },
- { result: `console.log("the importer's copy");`, stderr: "" },
- { result: `console.log("the importer's copy");`, stderr: "" },
- { result: `console.log("the relative file");`, stderr: "" },
- { result: [`console.log("the importer's copy");`, `console.log("the importer's copy");`], stderr: "" },
+ const importers = `console.log("the importer's copy");`;
+ expect(bundles).toEqual([
+ { result: importers, stderr: "", exitCode: 0 },
+ { result: importers, stderr: "", exitCode: 0 },
+ { result: importers, stderr: "", exitCode: 0 },
+ { result: `console.log("the relative file");`, stderr: "", exitCode: 0 },
+ { result: [importers, importers], stderr: "", exitCode: 0 },
]);
});
@@ -1264,9 +1423,9 @@ describe("a macro import resolves for the macro environment", () => {
run(String(dir), "run", "entry.ts"),
run(String(dir), "run", "--preserve-symlinks", "entry.ts"),
]);
- expect({ followed: followed.result, preserved: preserved.result }).toEqual({
- followed: "linked-source",
- preserved: "node_modules/linked",
+ expect({ followed, preserved }).toEqual({
+ followed: { result: "linked-source", stderr: "", exitCode: 0 },
+ preserved: { result: "node_modules/linked", stderr: "", exitCode: 0 },
});
});
});
From c1be659292d56413346ed8ba76a23c31cac59251 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Sun, 25 Oct 2026 05:13:43 +0000
Subject: [PATCH 3/3] test: end the require.extensions race fixture when the
import runs on the main thread
The first macro of the fixture waits for the main thread. When the import is
transpiled on the main thread (`NODE_COMPILE_CACHE`, or the async transpiler
is off), that wait cannot end. The macro now returns a message in that case,
the main script stops waiting when the import settles, and the child runs
without the two settings.
---
test/bundler/transpiler/macro-test.test.ts | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/test/bundler/transpiler/macro-test.test.ts b/test/bundler/transpiler/macro-test.test.ts
index cc318dcc0b20..92be0b38a7bd 100644
--- a/test/bundler/transpiler/macro-test.test.ts
+++ b/test/bundler/transpiler/macro-test.test.ts
@@ -1330,6 +1330,8 @@ describe("a macro import resolves for the macro environment", () => {
"gate.macrots": `
import { existsSync, writeFileSync } from "node:fs";
export function first() {
+ // The wait can only end when another thread runs main.ts.
+ if (Bun.isMainThread) return "the import was transpiled on the main thread";
writeFileSync(import.meta.dir + "/started", "");
while (!existsSync(import.meta.dir + "/replaced")) {}
return "first";
@@ -1346,13 +1348,17 @@ describe("a macro import resolves for the macro environment", () => {
import { existsSync, writeFileSync } from "node:fs";
require.extensions[".macrots"] = require.extensions[".ts"];
const imported = import("./imported.ts");
- while (!existsSync(import.meta.dir + "/started")) await new Promise(resolve => setImmediate(resolve));
+ let settled = false;
+ imported.finally(() => (settled = true)).catch(() => {});
+ while (!settled && !existsSync(import.meta.dir + "/started")) await new Promise(resolve => setImmediate(resolve));
require.extensions[".other"] = require.extensions[".ts"];
writeFileSync(import.meta.dir + "/replaced", "");
console.log(JSON.stringify((await imported).results));
`,
});
- const { result, stderr, exitCode } = await run(String(dir), "run", "--loader=.macrots:ts", "main.ts");
+ // Two settings keep the transpiler on the main thread. They are off for this child.
+ const env = { ...bunEnv, BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER: undefined, NODE_COMPILE_CACHE: undefined };
+ const { result, stderr, exitCode } = await runWith(env, String(dir), "run", "--loader=.macrots:ts", "main.ts");
expect({ result, stderr, exitCode }).toEqual({ result: ["first", "second"], stderr: "", exitCode: 0 });
});