Compare commits

..

2 Commits

Author SHA1 Message Date
Claude Bot
3aa7964f9a fix: merge all inheritable fields in tsconfig references extends chain
The extends merge loop for referenced configs was only copying
base_url and paths. Now also merges JSX settings, emit_decorator_metadata,
and preserve_imports_not_used_as_values, matching the root extends merge.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-19 09:55:22 +00:00
Claude Bot
e99608620c fix(resolver): support tsconfig project references for path mapping (#20172)
When a root tsconfig.json uses "references" to point to sub-configs
(e.g. tsconfig.app.json, tsconfig.node.json), Bun now loads those
referenced configs and merges their compilerOptions.paths into the
root config. This is a common pattern in Vue/Vite projects.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-19 09:36:07 +00:00
6 changed files with 284 additions and 128 deletions

View File

@@ -152,18 +152,16 @@ JSC::JSValue createNodeURLBinding(Zig::GlobalObject* globalObject)
ASSERT(domainToAsciiFunction);
auto domainToUnicodeFunction = JSC::JSFunction::create(vm, globalObject, 1, "domainToUnicode"_s, jsDomainToUnicode, ImplementationVisibility::Public);
ASSERT(domainToUnicodeFunction);
binding->putDirectIndex(
binding->putByIndexInline(
globalObject,
(unsigned)0,
domainToAsciiFunction,
0,
JSC::PutDirectIndexMode::PutDirectIndexLikePutDirect);
binding->putDirectIndex(
false);
binding->putByIndexInline(
globalObject,
(unsigned)1,
domainToUnicodeFunction,
0,
JSC::PutDirectIndexMode::PutDirectIndexLikePutDirect);
false);
return binding;
}

View File

@@ -92,55 +92,43 @@ const kDeferredTimeouts = Symbol("deferredTimeouts");
const kEmptyObject = Object.freeze(Object.create(null));
// These are declared as plain objects instead of `const enum` to prevent the
// TypeScript enum reverse-mapping pattern (e.g. `Enum[Enum["x"] = 0] = "x"`)
// from triggering setters on `Object.prototype` during module initialization.
// See: https://github.com/oven-sh/bun/issues/24336
export const ClientRequestEmitState = {
socket: 1,
prefinish: 2,
finish: 3,
response: 4,
} as const;
export type ClientRequestEmitState = (typeof ClientRequestEmitState)[keyof typeof ClientRequestEmitState];
export const enum ClientRequestEmitState {
socket = 1,
prefinish = 2,
finish = 3,
response = 4,
}
export const NodeHTTPResponseAbortEvent = {
none: 0,
abort: 1,
timeout: 2,
} as const;
export type NodeHTTPResponseAbortEvent = (typeof NodeHTTPResponseAbortEvent)[keyof typeof NodeHTTPResponseAbortEvent];
export const NodeHTTPIncomingRequestType = {
FetchRequest: 0,
FetchResponse: 1,
NodeHTTPResponse: 2,
} as const;
export type NodeHTTPIncomingRequestType =
(typeof NodeHTTPIncomingRequestType)[keyof typeof NodeHTTPIncomingRequestType];
export const NodeHTTPBodyReadState = {
none: 0,
pending: 1 << 1,
done: 1 << 2,
hasBufferedDataDuringPause: 1 << 3,
} as const;
export type NodeHTTPBodyReadState = (typeof NodeHTTPBodyReadState)[keyof typeof NodeHTTPBodyReadState];
export const enum NodeHTTPResponseAbortEvent {
none = 0,
abort = 1,
timeout = 2,
}
export const enum NodeHTTPIncomingRequestType {
FetchRequest,
FetchResponse,
NodeHTTPResponse,
}
export const enum NodeHTTPBodyReadState {
none,
pending = 1 << 1,
done = 1 << 2,
hasBufferedDataDuringPause = 1 << 3,
}
// Must be kept in sync with NodeHTTPResponse.Flags
export const NodeHTTPResponseFlags = {
socket_closed: 1 << 0,
request_has_completed: 1 << 1,
closed_or_completed: (1 << 0) | (1 << 1),
} as const;
export type NodeHTTPResponseFlags = (typeof NodeHTTPResponseFlags)[keyof typeof NodeHTTPResponseFlags];
export const enum NodeHTTPResponseFlags {
socket_closed = 1 << 0,
request_has_completed = 1 << 1,
export const NodeHTTPHeaderState = {
none: 0,
assigned: 1,
sent: 2,
} as const;
export type NodeHTTPHeaderState = (typeof NodeHTTPHeaderState)[keyof typeof NodeHTTPHeaderState];
closed_or_completed = socket_closed | request_has_completed,
}
export const enum NodeHTTPHeaderState {
none,
assigned,
sent,
}
function emitErrorNextTickIfErrorListenerNT(self, err, cb) {
process.nextTick(emitErrorNextTickIfErrorListener, self, err, cb);

View File

@@ -4244,6 +4244,132 @@ pub const Resolver = struct {
// todo deinit these parent configs somehow?
}
info.tsconfig_json = merged_config;
// Handle "references" - load each referenced tsconfig and merge
// their paths into this config. This supports the common pattern
// where a root tsconfig.json uses "references" to delegate to
// sub-configs (e.g. tsconfig.app.json, tsconfig.node.json).
if (merged_config.references.len > 0) {
const ts_dir_name = Dirname.dirname(merged_config.abs_path);
for (merged_config.references) |ref_path| {
// Per the TypeScript spec, if "path" points to a directory,
// look for tsconfig.json inside it. If it points to a file,
// use it directly.
const abs_ref_path = brk2: {
if (strings.endsWithComptime(ref_path, ".json")) {
break :brk2 ResolvePath.joinAbsStringBuf(
ts_dir_name,
bufs(.tsconfig_path_abs),
&[_]string{ ts_dir_name, ref_path },
.auto,
);
} else {
break :brk2 ResolvePath.joinAbsStringBuf(
ts_dir_name,
bufs(.tsconfig_path_abs),
&[_]string{ ts_dir_name, ref_path, "tsconfig.json" },
.auto,
);
}
};
const ref_config_maybe = r.parseTSConfig(abs_ref_path, bun.invalid_fd) catch |err| brk2: {
r.log.addDebugFmt(null, logger.Loc.Empty, r.allocator, "{s} loading tsconfig.json reference {f}", .{
@errorName(err),
bun.fmt.QuotedFormatter{
.text = abs_ref_path,
},
}) catch {};
break :brk2 null;
};
if (ref_config_maybe) |ref_config| {
// Also resolve extends chains for referenced configs
var ref_parent_configs = try bun.BoundedArray(*TSConfigJSON, 64).init(0);
try ref_parent_configs.append(ref_config);
var ref_current = ref_config;
while (ref_current.extends.len > 0) {
const ref_ts_dir = Dirname.dirname(ref_current.abs_path);
const ref_extends_abs = ResolvePath.joinAbsStringBuf(ref_ts_dir, bufs(.tsconfig_path_abs), &[_]string{ ref_ts_dir, ref_current.extends }, .auto);
const ref_parent_maybe = r.parseTSConfig(ref_extends_abs, bun.invalid_fd) catch break;
if (ref_parent_maybe) |ref_parent| {
try ref_parent_configs.append(ref_parent);
ref_current = ref_parent;
} else {
break;
}
}
// Merge the referenced config's extends chain
// (same fields as the root extends merge)
var ref_merged = ref_parent_configs.pop().?;
while (ref_parent_configs.pop()) |ref_parent| {
ref_merged.emit_decorator_metadata = ref_merged.emit_decorator_metadata or ref_parent.emit_decorator_metadata;
if (ref_parent.base_url.len > 0) {
ref_merged.base_url = ref_parent.base_url;
ref_merged.base_url_for_paths = ref_parent.base_url_for_paths;
}
ref_merged.jsx = ref_parent.mergeJSX(ref_merged.jsx);
ref_merged.jsx_flags.setUnion(ref_parent.jsx_flags);
if (ref_parent.preserve_imports_not_used_as_values) |value| {
ref_merged.preserve_imports_not_used_as_values = value;
}
var ref_iter = ref_parent.paths.iterator();
while (ref_iter.next()) |c| {
ref_merged.paths.put(c.key_ptr.*, c.value_ptr.*) catch unreachable;
}
}
// Merge referenced config's paths into the root config.
// Path values need to be made absolute using the referenced
// config's base_url_for_paths, since the root config may
// have a different (or no) base URL.
const ref_base = if (ref_merged.hasBaseURL()) ref_merged.base_url else ref_merged.base_url_for_paths;
var ref_iter = ref_merged.paths.iterator();
while (ref_iter.next()) |c| {
const original_values = c.value_ptr.*;
if (ref_base.len > 0 and (merged_config.base_url_for_paths.len == 0 or
!strings.eql(ref_base, merged_config.base_url_for_paths)))
{
// Resolve each path value to absolute so it works
// regardless of the root config's baseUrl
var abs_values = bun.default_allocator.alloc(string, original_values.len) catch unreachable;
for (original_values, 0..) |orig_path, i| {
if (!std.fs.path.isAbsolute(orig_path)) {
const join_parts = [_]string{ ref_base, orig_path };
abs_values[i] = r.fs.dirname_store.append(
string,
r.fs.absBuf(&join_parts, bufs(.tsconfig_base_url)),
) catch unreachable;
} else {
abs_values[i] = orig_path;
}
}
merged_config.paths.put(c.key_ptr.*, abs_values) catch unreachable;
} else {
merged_config.paths.put(c.key_ptr.*, original_values) catch unreachable;
}
}
// If the root config has no base_url_for_paths but the referenced
// config has paths, we need to ensure base_url_for_paths is set
if (merged_config.base_url_for_paths.len == 0 and ref_merged.paths.count() > 0) {
merged_config.base_url_for_paths = ref_merged.base_url_for_paths;
}
// Merge other settings from referenced configs
merged_config.jsx = ref_merged.mergeJSX(merged_config.jsx);
merged_config.jsx_flags.setUnion(ref_merged.jsx_flags);
merged_config.emit_decorator_metadata = merged_config.emit_decorator_metadata or ref_merged.emit_decorator_metadata;
if (ref_merged.preserve_imports_not_used_as_values) |value| {
if (merged_config.preserve_imports_not_used_as_values == null) {
merged_config.preserve_imports_not_used_as_values = value;
}
}
}
}
}
}
info.enclosing_tsconfig_json = info.tsconfig_json;
}

View File

@@ -43,6 +43,12 @@ pub const TSConfigJSON = struct {
emit_decorator_metadata: bool = false,
experimental_decorators: bool = false,
// TypeScript project references. Each entry is the "path" value from the
// "references" array in tsconfig.json. These are relative paths to other
// tsconfig files (or directories containing tsconfig.json).
// See: https://www.typescriptlang.org/docs/handbook/project-references.html
references: []const string = &.{},
pub fn hasBaseURL(tsconfig: *const TSConfigJSON) bool {
return tsconfig.base_url.len > 0;
}
@@ -158,6 +164,26 @@ pub const TSConfigJSON = struct {
}
}
}
// Parse "references"
if (json.asProperty("references")) |references_value| {
if (!source.path.isNodeModule()) {
if (references_value.expr.asArray()) |ref_array_iter| {
var ref_array = ref_array_iter;
var refs = std.array_list.Managed(string).init(allocator);
while (ref_array.next()) |element| {
if (element.asProperty("path")) |path_prop| {
if (path_prop.expr.asString(allocator)) |ref_path| {
refs.append(ref_path) catch unreachable;
}
}
}
if (refs.items.len > 0) {
result.references = refs.toOwnedSlice() catch unreachable;
}
}
}
}
var has_base_url = false;
// Parse "compilerOptions"

View File

@@ -0,0 +1,95 @@
import { expect, test } from "bun:test";
import { bunRun, tempDirWithFiles } from "harness";
import { join } from "path";
test("tsconfig references resolves paths from referenced configs", () => {
const dir = tempDirWithFiles("tsconfig-refs", {
"tsconfig.json": JSON.stringify({
files: [],
references: [{ path: "./tsconfig.app.json" }, { path: "./tsconfig.node.json" }],
}),
"tsconfig.app.json": JSON.stringify({
compilerOptions: {
baseUrl: ".",
paths: {
"@/*": ["./src/*"],
},
},
include: ["src/**/*"],
}),
"tsconfig.node.json": JSON.stringify({
compilerOptions: {
baseUrl: ".",
paths: {
"@server/*": ["./server/*"],
},
},
include: ["server/**/*"],
}),
"server/index.ts": `import { foo } from '@server/lib/foo';
console.log(foo);`,
"server/lib/foo.ts": `export const foo = 123;`,
"src/main.ts": `import { bar } from '@/lib/bar';
console.log(bar);`,
"src/lib/bar.ts": `export const bar = 456;`,
});
// Test @server/* paths from tsconfig.node.json
const serverResult = bunRun(join(dir, "server/index.ts"));
expect(serverResult.stdout).toBe("123");
// Test @/* paths from tsconfig.app.json
const appResult = bunRun(join(dir, "src/main.ts"));
expect(appResult.stdout).toBe("456");
});
test("tsconfig references resolves directory references", () => {
const dir = tempDirWithFiles("tsconfig-dir-refs", {
"tsconfig.json": JSON.stringify({
files: [],
references: [{ path: "./app" }],
}),
"app/tsconfig.json": JSON.stringify({
compilerOptions: {
baseUrl: "..",
paths: {
"#utils/*": ["./src/utils/*"],
},
},
}),
"src/index.ts": `import { helper } from '#utils/helper';
console.log(helper);`,
"src/utils/helper.ts": `export const helper = "works";`,
});
const result = bunRun(join(dir, "src/index.ts"));
expect(result.stdout).toBe("works");
});
test("tsconfig references with extends in referenced config", () => {
const dir = tempDirWithFiles("tsconfig-refs-extends", {
"tsconfig.json": JSON.stringify({
files: [],
references: [{ path: "./tsconfig.app.json" }],
}),
"tsconfig.app.json": JSON.stringify({
extends: "./tsconfig.base.json",
compilerOptions: {
paths: {
"@app/*": ["./src/*"],
},
},
}),
"tsconfig.base.json": JSON.stringify({
compilerOptions: {
baseUrl: ".",
},
}),
"src/index.ts": `import { val } from '@app/lib/val';
console.log(val);`,
"src/lib/val.ts": `export const val = "extended";`,
});
const result = bunRun(join(dir, "src/index.ts"));
expect(result.stdout).toBe("extended");
});

View File

@@ -1,77 +0,0 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
// https://github.com/oven-sh/bun/issues/24336
// require('http') should not trigger Object.prototype setters during module loading.
// Node.js produces no output for both CJS and ESM, and Bun should match that behavior.
test("require('http') does not trigger Object.prototype[0] setter", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
Object.defineProperty(Object.prototype, '0', {
set() { console.log('SETTER_TRIGGERED'); }
});
require('http');
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout).toBe("");
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});
test("require('url') does not trigger Object.prototype[0] setter", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
Object.defineProperty(Object.prototype, '0', {
set() { console.log('SETTER_TRIGGERED'); }
});
require('url');
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout).toBe("");
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});
test("require('util') does not trigger Object.prototype[0] setter", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
Object.defineProperty(Object.prototype, '0', {
set() { console.log('SETTER_TRIGGERED'); }
});
require('util');
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout).toBe("");
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});