Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/bundler/bundle_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3272,6 +3272,7 @@ pub mod bv2_impl {
transpiler.options.target.is_bun() || transpiler.options.target == Target::Node;
transpiler.resolver.opts.mark_builtins_as_external =
transpiler.options.target.is_bun() || transpiler.options.target == Target::Node;
transpiler.resolver.settings_from_real_path = true;

// SAFETY: `ssr_transpiler` intentionally aliases `transpiler` via a
// raw `*mut` until bake installs a separate SSR transpiler; all
Expand Down
1 change: 1 addition & 0 deletions src/bundler/transpiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2627,6 +2627,7 @@ impl<'a> Transpiler<'a> {
log: *mut bun_ast::Log,
_opts: api::TransformOptions,
) -> crate::Result<options::TransformResult> {
self.resolver.settings_from_real_path = true;
let _ = self.enqueue_entry_points::<true>();

// `log` is the same `*mut Log` stored on `self.log`; caller
Expand Down
99 changes: 76 additions & 23 deletions src/resolver/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,9 @@ pub struct Resolver<'a> {
/// For Bun's runtime, we don't.
pub(crate) care_about_browser_field: bool,

/// Set by the bundler: a linked file takes tsconfig.json settings and "type" at its real path.
pub settings_from_real_path: bool,

pub debug_logs: Option<DebugLogs>,
pub elapsed: u64, // tracing

Expand Down Expand Up @@ -622,6 +625,7 @@ impl<'a> Resolver<'a> {
care_about_bin_folder: from.care_about_bin_folder,
care_about_scripts: from.care_about_scripts,
care_about_browser_field: from.care_about_browser_field,
settings_from_real_path: from.settings_from_real_path,
// `DebugLogs` owns Vecs — per-worker fresh.
debug_logs: None,
elapsed: 0,
Expand Down Expand Up @@ -957,6 +961,7 @@ impl<'a> Resolver<'a> {
log,
extension_order: options::ExtOrder::DefaultDefault,
care_about_browser_field,
settings_from_real_path: false,
care_about_bin_folder: false,
care_about_scripts: false,
debug_logs: None,
Expand Down Expand Up @@ -1583,17 +1588,11 @@ impl<'a> Resolver<'a> {
while let Some(path) = iter.next() {
let name = path.name();
let primary = core::mem::take(&mut is_primary);
let Ok(Some(dir)) = self.read_dir_info(name.dir) else {
let Ok(Some(mut dir)) = self.read_dir_info(name.dir) else {
continue;
};

// Node reads "type" from the nearest package.json, named or not.
if primary && !kind.is_from_css() && module_type == options::ModuleType::Unknown {
if let Some(pkg) = dir.package_json_for_module_type {
module_type = pkg.module_type;
}
}

// "sideEffects" is matched against the path as spelled, so this runs before the rewrite.
let mut needs_side_effects = true;
if let Some(existing) = Result::deref_package_json(result.package_json) {
// if we don't have it here, they might put it in a sideEfffects
Expand Down Expand Up @@ -1624,19 +1623,7 @@ impl<'a> Resolver<'a> {
}
}

if let Some(tsconfig) = self.enclosing_tsconfig_json(&dir) {
result.jsx = tsconfig.merge_jsx(core::mem::take(&mut result.jsx));
result.flags.set_emit_decorator_metadata(
result.flags.emit_decorator_metadata() || tsconfig.emit_decorator_metadata,
);
result.flags.set_experimental_decorators(
result.flags.experimental_decorators() || tsconfig.experimental_decorators,
);
if let Some(v) = tsconfig.use_define_for_class_fields {
result.flags.set_use_define_for_class_fields(v);
}
}

let mut rewritten = false;
// Probe the listing in one `entries_mutex` critical section: a
// concurrent resolver at a newer generation rewrites this `DirEntry`'s
// map in place under that lock. The entry pointer stays valid after
Expand All @@ -1647,6 +1634,7 @@ impl<'a> Resolver<'a> {
let symlink_path = unsafe { query.entry().symlink(self.rfs_ptr(), self.store_fd) };
if !symlink_path.is_empty() {
path.set_realpath(symlink_path);
rewritten = true;
if !result.file_fd.is_valid() {
result.file_fd = query.entry().cache().fd;
}
Expand Down Expand Up @@ -1732,6 +1720,44 @@ impl<'a> Resolver<'a> {
}

path.set_realpath(symlink);
rewritten = true;
}
}

let mut at_real_path = false;
if primary
&& rewritten
&& self.settings_from_real_path
&& let Some(real_dir) = self.settings_dir_of_real_path(path.text(), dir)
{
dir = real_dir;
at_real_path = true;
}

if primary && !kind.is_from_css() {
if at_real_path {
// A lookup through "exports" or "imports" read "type" at the path as spelled.
module_type = options::ModuleType::Unknown;
}
// Node reads "type" from the nearest package.json, named or not.
if module_type == options::ModuleType::Unknown
&& let Some(pkg) = dir.package_json_for_module_type
{
module_type = pkg.module_type;
}
}

// Only the path that `Result::path()` returns takes the tsconfig.json settings.
if primary && let Some(tsconfig) = self.enclosing_tsconfig_json(&dir) {
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
result.jsx = tsconfig.merge_jsx(core::mem::take(&mut result.jsx));
result.flags.set_emit_decorator_metadata(
result.flags.emit_decorator_metadata() || tsconfig.emit_decorator_metadata,
);
result.flags.set_experimental_decorators(
result.flags.experimental_decorators() || tsconfig.experimental_decorators,
);
if let Some(v) = tsconfig.use_define_for_class_fields {
result.flags.set_use_define_for_class_fields(v);
}
}
}
Expand All @@ -1747,6 +1773,30 @@ impl<'a> Resolver<'a> {
Ok(())
}

/// `None` when the file or its link is in node_modules: it keeps the settings of the path as spelled.
#[cold]
#[inline(never)]
fn settings_dir_of_real_path(
&mut self,
real: &[u8],
found_in: DirInfoRef,
) -> Option<DirInfoRef> {
// The flags of a directory that a link leads to describe the link, not the directory.
let entry_dir = found_in.abs_real_path;
let entry_in_node_modules = if entry_dir.is_empty() {
found_in.is_node_modules() || found_in.is_inside_node_modules()
} else {
strings::path_contains_node_modules_folder(entry_dir)
|| bun_paths::basename(entry_dir) == b"node_modules"
};
Comment thread
robobun marked this conversation as resolved.
Resolved
if entry_in_node_modules || strings::path_contains_node_modules_folder(real) {
return None;
}
self.dir_info_cached(Fs::PathName::init(real).dir)
.ok()
.flatten()
}

pub(crate) fn resolve_without_symlinks(
&mut self,
source_dir: &[u8],
Expand Down Expand Up @@ -6586,7 +6636,10 @@ impl<'a> Resolver<'a> {
}
};
if let Some(tsconfig_json) = parsed_tsconfig {
let mut parent_configs: BoundedArray<*mut TSConfigJSON, 64> = BoundedArray::default();
// A chain that fills the array does not end. It stops there.
const MAX_CHAIN: usize = 64;
let mut parent_configs: BoundedArray<*mut TSConfigJSON, MAX_CHAIN> =
BoundedArray::default();
parent_configs.append(tsconfig_json)?;
// `current`/`parent_config_ptr`/`merged_config` are heap TSConfigJSON
// allocations from `parse_tsconfig` (heap::alloc); uniquely owned by
Expand All @@ -6595,7 +6648,7 @@ impl<'a> Resolver<'a> {
// `Deref` instead of three open-coded raw-ptr derefs.
let mut current =
bun_ptr::BackRef::from(core::ptr::NonNull::new(tsconfig_json).expect("heap alloc"));
while !current.extends.is_empty() {
while !current.extends.is_empty() && parent_configs.len() < MAX_CHAIN {
let ts_dir_name = Dirname::dirname(&current.abs_path);
// `extends` comes from the config file and can be any length.
let parsed = match ResolvePath::join_abs_string_buf_checked(
Expand Down
2 changes: 2 additions & 0 deletions src/runtime/bake/config/transpiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ impl Framework {
// `BundleOptions`, so re-project via the dedicated helper rather than
// `Clone`.
out.sync_resolver_opts();
// `BundleV2::init` sets this for the server transpiler only.
out.resolver.settings_from_real_path = true;

out.configure_linker();
out.configure_defines()?;
Expand Down
51 changes: 51 additions & 0 deletions test/bundler/bundler_cjs.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe } from "bun:test";
import { isWindows } from "harness";
import { itBundled } from "./expectBundled";

// Tests for CommonJS <> ESM interop, specifically the __toESM helper behavior.
Expand Down Expand Up @@ -1235,4 +1236,54 @@ describe("bundler", () => {
stdout: "function/undefined",
},
});

// Test 63: a package.json above a symbolic link does not give its "type" to a
// file that the link only leads to. The real path of the file decides.
itBundled("cjs/__toESM_type_module_above_symlink_does_not_apply", {
files: {
"/app/entry.js": `import "./link/importer.js";`,
"/app/package.json": `{ "name": "app", "type": "module" }`,
"/shared/importer.js": logDefaultAndNamed,
"/shared/dep.cjs": esModuleDep,
},
symlinks: { "/app/link": "/shared" },
run: {
stdout: "function 1",
},
});

// Test 64: the package.json above the real path gives its "type" to a file
// that an import reaches through a symbolic link.
itBundled("cjs/__toESM_type_module_above_real_path_of_symlinked_importer", {
files: {
"/app/entry.js": `import "./link/importer.js";`,
"/shared/package.json": `{ "name": "shared", "type": "module" }`,
"/shared/lib/importer.js": logDefaultAndNamed,
"/shared/lib/dep.cjs": esModuleDep,
},
symlinks: { "/app/link": "/shared/lib" },
run: {
stdout: "object 1",
},
});

// Test 65: an "imports" target that is a link to a file. The lookup reads
// "type" beside the link. The real path of the file decides, on this route
// as on a relative import. A link to a file needs a privilege on Windows.
(isWindows ? itBundled.skip : itBundled)("cjs/__toESM_type_of_real_path_for_imports_target_behind_file_link", {
files: {
"/pkg/entry.js": /* js */ `
import "#importer";
import "./importer.js";
`,
"/pkg/package.json": `{ "name": "pkg", "type": "module", "imports": { "#importer": "./importer.js" } }`,
"/real/package.json": `{ "name": "real" }`,
"/real/importer.js": logDefaultAndNamed,
"/real/dep.cjs": esModuleDep,
},
symlinks: { "/pkg/importer.js": "/real/importer.js" },
run: {
stdout: "function 1",
},
});
});
24 changes: 24 additions & 0 deletions test/bundler/bundler_plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,30 @@ describe("bundler", () => {
},
};
});
// An import that every onResolve callback declines goes to the resolver. The file gets the
// package.json "type" above its real path, not the one above the link that leads to it.
itBundled("plugin/ResolveDeclinedThroughSymlinkTakesTypeOfRealPath", {
files: {
"/app/entry.js": `import "./link/importer.js";`,
"/app/package.json": `{ "name": "app", "type": "module" }`,
"/shared/importer.js": /* js */ `
import d, { named } from "./dep.cjs";
console.log(typeof d, named);
`,
"/shared/dep.cjs": /* js */ `
Object.defineProperty(exports, "__esModule", { value: true });
exports.named = 1;
exports.default = function theDefault() {};
`,
},
symlinks: { "/app/link": "/shared" },
plugins(builder) {
builder.onResolve({ filter: /.*/ }, () => undefined);
},
run: {
stdout: "function 1",
},
});
// Like esbuild, an external import is printed with the path that onResolve returned.
itBundled("plugin/ResolveExternalRewritesPath", {
files: {
Expand Down
Loading
Loading