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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,21 @@ Transformer and register for Node.js projects.
node --import @oxc-node/core/register ./path/to/entry.ts
```

### `tsconfig.json` discovery

On startup, oxc-node resolves a `tsconfig.json` using this precedence:

1. `TS_NODE_PROJECT` — used as-is if set.
2. `OXC_TSCONFIG_PATH` — used as-is if set.
3. `tsconfig.json` in the current working directory.
4. If none of the above exist, walk up parent directories and use the first
`tsconfig.json` found. This makes a root-workspace `tsconfig.json` (e.g. one
with `experimentalDecorators: true`) apply to sub-projects that don't have
their own config.

To opt out of the walk-up, point `TS_NODE_PROJECT` or `OXC_TSCONFIG_PATH` at an
explicit path (a non-existent path disables discovery entirely).

## [Sponsored By](https://github.com/sponsors/Boshen)

<p align="center">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "monorepo-root-fixture",
"private": true,
"type": "module"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
let argCount = -1;

function Count(...args: unknown[]): unknown {
argCount = args.length;
return args[0];
}

@Count
class Foo {}

void Foo;

console.log(`ARG_COUNT:${argCount}`);
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"experimentalDecorators": true,
"useDefineForClassFields": false
},
"include": ["sub-project"]
}
75 changes: 75 additions & 0 deletions packages/integrate-ava/__tests__/tsconfig-discovery.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import test from "ava";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";

const SUB_PROJECT_DIR = fileURLToPath(
new URL("./fixtures/monorepo-root/sub-project/", import.meta.url),
);

const DECORATOR_SOURCE = `
function Count(...args) {
globalThis.__argCount = args.length;
}
@Count
class Foo {}
void Foo;
`;

function transformInSubprocess(
cwd: string,
env: NodeJS.ProcessEnv = {},
): { stdout: string; stderr: string; status: number | null } {
const script = `
const { OxcTransformer } = await import("@oxc-node/core");
const transformer = new OxcTransformer(process.cwd());
const result = await transformer.transformAsync(
"decorator.ts",
${JSON.stringify(DECORATOR_SOURCE)},
);
process.stdout.write(result.source());
`;
const result = spawnSync(process.execPath, ["--input-type=module", "-e", script], {
cwd,
encoding: "utf8",
env: {
...process.env,
NODE_OPTIONS: undefined,
TS_NODE_PROJECT: undefined,
OXC_TSCONFIG_PATH: undefined,
...env,
},
});
return {
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
status: result.status,
};
}

// Nested `--input-type=module -e` subprocesses that load @oxc-node/core via
// `await import()` crash on WASI (the forced-WASI worker init fails inside an
// inline ESM script). The walk-up logic is platform-agnostic, so we cover it
// on the native targets only.
const runTest = process.env.NAPI_RS_FORCE_WASI ? test.skip : test;

runTest("walks up to parent tsconfig.json when sub-project has none", (t) => {
const { stdout, stderr, status } = transformInSubprocess(SUB_PROJECT_DIR);
t.is(status, 0, `subprocess failed: ${stderr}`);
t.regex(
stdout,
/_decorate\s*\(/,
"legacy decorator helper should be emitted when experimentalDecorators is read from parent tsconfig",
);
});

runTest("explicit TS_NODE_PROJECT wins over walk-up", (t) => {
const { stdout, stderr, status } = transformInSubprocess(SUB_PROJECT_DIR, {
TS_NODE_PROJECT: "/this/path/does/not/exist.json",
});
t.is(status, 0, `subprocess failed: ${stderr}`);
t.notRegex(
stdout,
/_decorate\s*\(/,
"walk-up must not run when TS_NODE_PROJECT is explicitly set, even if the file is missing",
);
});
52 changes: 38 additions & 14 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -778,28 +778,52 @@ impl TryAsStr for Either4<String, Uint8Array, Buffer, Null> {
}
}

fn find_tsconfig_upwards(start: &Path) -> Option<PathBuf> {
for ancestor in start.ancestors() {
let candidate = ancestor.join("tsconfig.json");
if fs::exists(&candidate).unwrap_or(false) {
return Some(candidate);
}
}
None
}

fn init_resolver(
cwd: PathBuf,
conditions: Vec<String>,
) -> (Resolver, Option<Arc<TsConfig>>, Option<&'static str>) {
let tsconfig = env::var("TS_NODE_PROJECT")
let (tsconfig, used_default_name) = env::var("TS_NODE_PROJECT")
.or_else(|_| env::var("OXC_TSCONFIG_PATH"))
.map(Cow::Owned)
.unwrap_or(Cow::Borrowed("tsconfig.json"));
tracing::debug!(tsconfig = ?tsconfig);
let tsconfig_full_path = if !tsconfig.starts_with('/') {
.map(|v| (Cow::Owned(v), false))
.unwrap_or((Cow::Borrowed("tsconfig.json"), true));
tracing::debug!(tsconfig = ?tsconfig, used_default_name);
let initial_tsconfig_path = if Path::new(&*tsconfig).is_absolute() {
PathBuf::from(&*tsconfig)
} else {
cwd.join(PathBuf::from(&*tsconfig))
};
let (tsconfig_full_path, tsconfig_exists) = if fs::exists(&initial_tsconfig_path)
.unwrap_or(false)
{
(initial_tsconfig_path, true)
} else if used_default_name {
match find_tsconfig_upwards(&cwd) {
Some(found) => {
tracing::debug!(discovered_tsconfig = ?found, "found tsconfig.json by walking up parents");
(found, true)
}
None => (initial_tsconfig_path, false),
}
} else {
PathBuf::from(&*tsconfig)
(initial_tsconfig_path, false)
};
tracing::debug!(tsconfig_full_path = ?tsconfig_full_path);
let tsconfig =
fs::exists(&tsconfig_full_path)
.unwrap_or(false)
.then_some(TsconfigDiscovery::Manual(TsconfigOptions {
config_file: tsconfig_full_path.clone(),
references: TsconfigReferences::Auto,
}));
tracing::debug!(tsconfig_full_path = ?tsconfig_full_path, tsconfig_exists);
let tsconfig = tsconfig_exists.then(|| {
TsconfigDiscovery::Manual(TsconfigOptions {
config_file: tsconfig_full_path.clone(),
references: TsconfigReferences::Auto,
})
});
let resolver = Resolver::new(ResolveOptions {
tsconfig,
condition_names: conditions,
Expand Down
Loading