Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
215e090
feat(core): compile ES module syntax to CommonJS where tsc would (#905)
Nov 6, 2026
f4a08d9
fix(core): keep compiled CommonJS importing what the ES module imported
Nov 9, 2026
ae3b767
Merge branch 'main' into feat/commonjs-lowering-tsc-parity
Brooooooklyn Nov 10, 2026
19c19cd
fix(core): match tsc's CommonJS output in import cycles
Brooooooklyn Nov 10, 2026
c135b3d
fix(core): export each declarator in turn, and call tags without a re…
Brooooooklyn Nov 10, 2026
4406e93
fix(core): resolve mapped, extensionless and symlinked requires as th…
Brooooooklyn Nov 10, 2026
a98adf2
fix(core): keep active conditions and watch reports in the require() …
Brooooooklyn Nov 10, 2026
f1709b2
fix(core): export destructured bindings as they run, and find sources…
Brooooooklyn Nov 10, 2026
6d218b8
fix(core): keep files that declare Object or Symbol ES modules, and t…
Brooooooklyn Nov 10, 2026
cd3b19f
fix(core): resolve directory indexes and extensionless requests in th…
Brooooooklyn Nov 10, 2026
60c0f79
fix(core): keep a directory's main when it names the index file
Brooooooklyn Nov 10, 2026
1e2ad66
fix(core): order a directory's extensionless main, and keep `#` in re…
Brooooooklyn Nov 10, 2026
0219f21
fix(core): keep `./foo/` on the directory, and order a directory-valu…
Brooooooklyn Nov 10, 2026
9ac9bbd
fix(core): publish a redeclared var where it is set, and use the proj…
Brooooooklyn Nov 10, 2026
03b9f03
fix(core): re-probe a symlinked directory's index under its real path
Brooooooklyn Nov 10, 2026
0b9367a
fix(core): keep a var a nested declaration sets live, and order a dot…
Brooooooklyn Nov 10, 2026
0878869
fix(core): keep a directory main that shares the request's name, and …
Brooooooklyn Nov 10, 2026
36ea0e0
fix(core): rewrite a destructured export only when every binding can …
Brooooooklyn Nov 10, 2026
3f0888a
test(core): let the require.resolve paths test pass with runtime warn…
Brooooooklyn Nov 10, 2026
fe3a3c0
fix(core): keep a module's own __vite_ssr_exports__ binding, and find…
Brooooooklyn Nov 10, 2026
6a1da33
fix(core): anchor a redeclared var's getter at its first declaration,…
Brooooooklyn Nov 10, 2026
4d48275
fix(core): keep a file naming `exports` anywhere an ES module, and tw…
Brooooooklyn Nov 10, 2026
121732f
fix(core): keep a file naming a value `__vite_ssr_…` an ES module, an…
Brooooooklyn Nov 10, 2026
868725a
fix(core): find the source an explicit `./dep.js` names before any co…
Brooooooklyn Nov 10, 2026
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
103 changes: 99 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ type checker, and it does not write build output to disk.
- ESM and CommonJS, including `.mts`, `.cts`, `.mjs`, and `.cjs`
- JSX and selected TypeScript compiler options
- `tsconfig.json` path aliases and TypeScript-aware import resolution
- Imports such as `./file.js` resolving to `./file.ts` or `./file.tsx`. From
JavaScript files and the entry point, `./file.js` itself runs when it exists
- Imports and `require()` calls such as `./file.js` resolving to `./file.ts` or
`./file.tsx`. From JavaScript files and the entry point, `./file.js` itself runs
when it exists
- Source-mapped stack traces
- Importing TypeScript from a tool, without hooks (`@oxc-node/core/importer`)
- Node features and flags such as watch mode, the test runner, and the
Expand Down Expand Up @@ -359,6 +360,100 @@ transformation. These include path aliases, module and JSX settings, legacy
decorators and decorator metadata, class-field semantics, and relative import
extension rewriting.

### ES module syntax in a CommonJS package

A `.ts`, `.tsx`, `.js` or `.jsx` file with `import`/`export` syntax in a
`"type": "commonjs"` package is compiled to CommonJS when its tsconfig's `module`
is `commonjs`, `node16`, `node18`, `node20` or `nodenext`, as `tsc` compiles it.
The output takes `tsc`'s shape. An exported local is a plain, writable property of
`exports`, so `mock.method(namespace, "name")` replaces it for every CommonJS
importer, as it does under `tsc`. A local that is never reassigned is assigned once
it is declared; a reassigned one is `exports.name` itself, so it stays live, and
so is each binding of an `export const [a, b] = …` pattern. A re-export, and a
reassigned local exported under two names, declared by a pattern with a binding
that is not exported, or set to an anonymous function, stays live through a
getter instead, which a test cannot replace. In an import cycle, an export that has not
been set yet reads as `undefined`, as under `tsc`; function declarations are
exported before the file runs. A file's imports run before the rest of it, as in
an ES module.

The package must say `"type": "commonjs"`: `tsc` also emits CommonJS for a package
without a `"type"`, but there such a file runs as an ES module, as before, so a
project relying on Node.js' syntax detection keeps working. A file also stays an
ES module when it uses `import.meta` or top-level await, which have no CommonJS
form, or declares a value named like one the CommonJS wrapper or the compiled
output uses (`exports`, `require`, `module`, `__filename`, `__dirname`,
`__importDefault`, `__importStar`, `__exportStar`, `Object`, `Symbol`) at its top
level, or `exports` or a name starting with `__vite_ssr_` anywhere, a parameter
included; a type of that name is fine. Removing `"type": "commonjs"` turns the
compilation off.
Outside that combination — no `"type"`, `"type": "module"`, or an ES `module`
setting — a file with module syntax runs as an ES module, as before. A `.cts`
file is not compiled this way; see
[Programmatic transformation](#programmatic-transformation).

With `node16`, `node18`, `node20` or `nodenext`, the package's `"type"` decides the
format of a `.ts` or `.tsx` file without module syntax, as it does for `tsc`: in a
`"type": "commonjs"` package, or one without a `"type"`, such a file is CommonJS,
also when an ES module imports it. Earlier releases ran it as an ES module. Only an
ES `module` setting, such as `esnext`, makes every `.ts` and `.tsx` file an ES
module.

An ES module that imports the compiled file gets what Node.js gives it for any
CommonJS module:

- Its named imports are the names Node.js' `cjs-module-lexer` finds: the file's
own exports, but not those it re-exports with `export *` from another TypeScript
file, because Node.js only follows re-exports into `.js` and `.cjs` files.
- Its default import is `module.exports`, whose `default` property is the file's
default export: after `import lib from "./lib.js"`, the class `lib.ts` exports
as default is `lib.default`. TypeScript types the import the same way under
`nodenext`. Earlier releases ran the file as an ES module, whose default import
was its default export.
- Its imports are a snapshot taken when the file finishes loading, as for any
CommonJS module: a later write to an exported `let` does not reach them.
Earlier releases kept them live.

The compiled file's imports are `require()` calls. They resolve as its imports
did, with two differences:

- A dependency resolves with the `require` condition, as under `tsc`, so a package
whose `exports` offers only `import` fails with `ERR_PACKAGE_PATH_NOT_EXPORTED`.
An ES `module` setting or `"type": "module"` keeps such a file an ES module.
- Node.js caches a relative `require()` per directory and request. When a
JavaScript file `require()`s `./lib.js` before a TypeScript file in the same
directory does, both get `lib.js`, even next to `lib.ts`; when the TypeScript
file asks first, both get `lib.ts`.

Otherwise TypeScript comes first, by the file's own name or by its output's:
`./lib.js` and `./lib` find `lib.ts`, also when a compiled `lib.js` sits next to
it, `./lib.mjs` finds `lib.mts`, and `./config` finds `config.ts` before
`config.json`. When no `lib.js` exists, `./lib.js` finds `lib.jsx` too. tsconfig
`paths` and `baseUrl` are tried before `node_modules`, and a package `imports`
entry or a self-reference that names output finds its source the same way. The
source is looked for beside the name a request reaches, before a symlink there is
followed. The
`require()` calls of every TypeScript file outside `node_modules` resolve this
way, and so do the extensionless ones of a `.js` file compiled from an ES module;
a file a package ships keeps its compiled `.js`. `require.resolve()` with a
`paths` option returns what Node.js finds in those directories.

Stack traces report the source's lines; for a file Node.js loads through the
module hooks — the entry point, or one an ES module imports — a column can be a
character off. Error messages and `Function#toString` show the compiled names of
imports, such as `__vite_ssr_import_0__`.

Importing an ES module — an `.mts` or `.mjs` file, or a `"type": "module"`
package — from the compiled file is `require()` of an ES module. Node.js loads the
entry point, test files and files an ES module imports through the module hooks,
together with the compiled files they `require()`, and `require()` of an ES module
from a file loaded that way needs
[nodejs/node#59679](https://github.com/nodejs/node/pull/59679), which the 22.x line
has from 22.22.3. Earlier releases fail with `TypeError: Cannot read properties of
undefined (reading 'exports')`.

### Dependencies

The loader does not transform files in `node_modules` by default. Set
`OXC_TRANSFORM_ALL=typescript` to transform only the TypeScript dependencies —
`.ts`, `.mts`, `.cts` and `.tsx`; JavaScript dependencies are still left to
Expand Down Expand Up @@ -411,8 +506,8 @@ The standalone transformer emits CommonJS. The registered loader preserves the
module format selected for ESM files.

A `.cts` file is CommonJS, so it cannot use ES module syntax (`import`, `export`,
or `import.meta`): oxc does not compile it to CommonJS the way `tsc` and `tsx` do,
and the loader reports an error instead. Rename such a file to `.mts`, or write it
or `import.meta`): oxc-node does not compile a `.cts` file's module syntax to
CommonJS the way `tsc` and `tsx` do, and the loader reports an error instead. Rename such a file to `.mts`, or write it
with `import x = require()` and `export =`. Type-only imports and exports are fine.

## Importing TypeScript from a tool
Expand Down
3 changes: 3 additions & 0 deletions packages/core/esm.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ if (!isMainThread) {
// `oxnode` adds another), so each copy tags the module name it passes to the
// transformer and registers a resolver for that tag: a tagged specifier only ever
// resolves against the copy that emitted it.
//
// `register.mjs` computes this module's tag too: a file compiled to CommonJS here
// `require()`s its helpers on the main thread, which has to resolve them.
const HELPER_SPECIFIER_PREFIX = "@oxc-node/core/helpers/";
const HELPER_TAGGED_PREFIX = "@oxc-node/core@";
const HELPER_TAG =
Expand Down
17 changes: 17 additions & 0 deletions packages/core/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,20 @@ export declare function transformModuleRunner(path: string, source: string | Uin
* it uses the frozen policy.
*/
export declare function transformsDep(path: string): boolean

/**
* The files a tsconfig `paths` alias or `baseUrl` maps a bare `require()` request to,
* in the order to try them. Not checked on disk: `register.mjs` resolves each one with
* Node.js' CommonJS resolution, ahead of the request itself.
*
* An import resolves through [`create_resolve`], which applies the importer's tsconfig
* itself, but `require()` stays on Node.js' own CommonJS resolution, which knows
* nothing of tsconfig. Without these, an ES module compiled to CommonJS loses the
* aliases it resolved as an ES module (#905).
*
* The config is the one [`create_resolve`] applies to `parent`
* ([`TsconfigSource::for_importer`]), and as there, nothing maps a request that is not
* bare ([`is_bare_specifier`]) or a request from inside `node_modules`. `cwd` is only
* read when this is the loader context's first resolution.
*/
export declare function tsconfigPathCandidates(cwd: string, parent: string, specifier: string): Array<string>
1 change: 1 addition & 0 deletions packages/core/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -796,3 +796,4 @@ module.exports.transform = nativeBinding.transform
module.exports.transformAsync = nativeBinding.transformAsync
module.exports.transformModuleRunner = nativeBinding.transformModuleRunner
module.exports.transformsDep = nativeBinding.transformsDep
module.exports.tsconfigPathCandidates = nativeBinding.tsconfigPathCandidates
Loading
Loading