Skip to content

Commit fcd685e

Browse files
killaguclaude
andauthored
perf(egg-bin): boot TypeScript via @oxc-node/core instead of ts-node (#6007)
## Motivation Every forked `egg-bin` CLI (and every `egg-bin dev/test/cov` child) booted TypeScript through `ts-node/register` + a hardcoded `ts-node/esm` `--loader`. ts-node is a JS-based TS compiler and the deprecated `--loader` hook is slow to spin up — on Windows CI this loader startup dominates the `Test bin` job (tens of seconds **per fork**, ~109 forks). The rest of the repo already moved its runtime `.ts` transpilation to `@oxc-node/core/register` (Rust/oxc) in #5965. This PR brings egg-bin in line. ## What changed - **`baseCommand.ts`** — default `--tscompiler` is now `@oxc-node/core/register`. oxc installs **both** a CJS require hook (via pirates) and an ESM `module.register()` hook from a single `--import`, so: - it is injected as one `--import` for CJS *and* ESM apps (its export is `import`-only, so it is resolved through the package main rather than CJS-resolved / `--require`d); - ESM apps no longer get a separate `ts-node/esm` `--loader`. - `tsconfig-paths/register` is still injected (oxc does not resolve tsconfig `paths`). - **Backward compatible**: legacy compilers keep their exact old path. `--tscompiler=ts-node/register`, `@swc-node/register`, `esbuild-register`, `pkg.egg.tscompiler` and `TS_COMPILER` all still resolve a CJS register + the `ts-node/esm` ESM loader, unchanged. `ts-node` stays a dependency for explicit opt-in. - **`test/coffee.ts`** — the test harness forks the egg-bin CLI itself via `--import @oxc-node/core/register` (this is the part that paid the Windows tax). - Help summary + the one assertion that pinned `ts-node/register` updated; stale `ts-node/esm` comments refreshed. - Add `@oxc-node/core` (`catalog:` → `^0.1.0`, decorator-metadata-correct). ## Test evidence Ran the full egg-bin suite **built, CI-style** (`build → vitest run`) on both the pristine `next` (ts-node) and this branch (oxc), then diffed the per-test pass/fail sets: | | total | passed | failed | skipped | |---|---|---|---|---| | `next` (ts-node) | 125 | 86 | 0 | 39 | | this PR (oxc) | 125 | 86 | 0 | 39 | **Regressions: none. Test set: identical. Failures: none.** Decorator-heavy fixtures, ESM/CJS apps, and the explicit `--tscompiler` override tests all still pass. `tsgo` typecheck and `oxlint --type-aware` clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Updated TypeScript startup to use import-based runtime registration via `@oxc-node/core/register`, and refreshed `--tscompiler` help text. * Aligned forked TypeScript CLI startup to match the new runtime registration approach. * **Bug Fixes** * Improved consistency across command, snapshot, and test flows for ESM vs CommonJS loader behavior, with clearer snapshot messaging. * **Tests** * Added Vitest coverage for the legacy (non-oxc) `--tscompiler` initialization path with environment isolation. * Updated related help-text/comment expectations. * **Chores** * Updated tooling/template dependencies and refreshed Windows CI concurrency guidance (no behavior change). <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5134d9c commit fcd685e

9 files changed

Lines changed: 107 additions & 34 deletions

File tree

tools/create-egg/src/templates/tegg/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
"@eggjs/bin": "beta",
4040
"@eggjs/mock": "beta",
4141
"@eggjs/tsconfig": "beta",
42-
"@oxc-node/core": "^0.0.35",
42+
"@oxc-node/core": "^0.1.0",
4343
"@types/node": "24",
4444
"@vitest/coverage-v8": "4",
4545
"cross-env": "10",

tools/egg-bin/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
"@eggjs/tegg-vitest": "workspace:*",
7070
"@eggjs/utils": "workspace:*",
7171
"@oclif/core": "catalog:",
72+
"@oxc-node/core": "catalog:",
7273
"@vitest/coverage-v8": "catalog:",
7374
"ci-parallel-vars": "catalog:",
7475
"detect-port": "catalog:",

tools/egg-bin/src/baseCommand.ts

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export abstract class BaseCommand<T extends typeof Command> extends Command {
9292
}),
9393
tscompiler: Flags.string({
9494
helpGroup: 'GLOBAL',
95-
summary: 'TypeScript compiler, like ts-node/register',
95+
summary: 'TypeScript compiler, like @oxc-node/core/register',
9696
aliases: ['tsc'],
9797
}),
9898
// flag with no value (--typescript)
@@ -214,39 +214,70 @@ export abstract class BaseCommand<T extends typeof Command> extends Command {
214214
// - importResolve's import.meta.resolve is scoped to @eggjs/utils, not here
215215
// createRequire resolves from the caller's location with CJS semantics,
216216
// correctly handling extension resolution and flat-hoisted node_modules.
217-
const cjsResolve = (specifier: string): string => {
218-
for (const p of findPaths) {
217+
const cjsResolve = (specifier: string, paths: string[] = findPaths): string => {
218+
for (const p of paths) {
219219
try {
220220
return createRequire(path.join(p, 'package.json')).resolve(specifier);
221221
} catch {
222222
/* try next path */
223223
}
224224
}
225-
throw new Error(`Cannot resolve '${specifier}' from ${findPaths.join(', ')}`);
225+
throw new Error(`Cannot resolve '${specifier}' from ${paths.join(', ')}`);
226226
};
227227
this.isESM = pkg.type === 'module';
228+
// oxc-node's register entry installs BOTH a CJS require hook (via pirates)
229+
// and an ESM `module.register()` hook from a single `--import`, so when it is
230+
// the active compiler an ESM app needs no separate `--loader` (see below).
231+
let isOxcCompiler = false;
228232
if (typescript) {
229-
flags.tscompiler = flags.tscompiler ?? 'ts-node/register';
230-
const tsNodeRegister = cjsResolve(flags.tscompiler);
231-
flags.tscompiler = tsNodeRegister;
232-
// should require tsNodeRegister on current process, let it can require *.ts files
233-
// e.g.: dev command will execute egg loader to find configs and plugins
234-
// await importModule(tsNodeRegister);
235-
// let child process auto require ts-node too
236-
this.addNodeOptions(this.formatImportModule(tsNodeRegister));
233+
// Remember whether the compiler was explicitly chosen (flag / env /
234+
// package.json) before we apply the oxc default below.
235+
const tscompilerSpecified = flags.tscompiler !== undefined;
236+
flags.tscompiler = flags.tscompiler ?? '@oxc-node/core/register';
237+
// Match the package specifier precisely (exact entry or a `@oxc-node/core/`
238+
// subpath) rather than a loose substring, so a similarly named compiler
239+
// can't be misdetected as oxc.
240+
isOxcCompiler = flags.tscompiler === '@oxc-node/core/register' || flags.tscompiler.startsWith('@oxc-node/core/');
241+
if (isOxcCompiler) {
242+
// `@oxc-node/core/register` is exported with an `import`-only condition
243+
// (no `require`), so it cannot be CJS-resolved nor `--require`d. Resolve
244+
// the package root through its main entry, then inject register.mjs as a
245+
// single `--import` — this transpiles `.ts` for both CJS and ESM apps.
246+
//
247+
// For the implicit default, resolve oxc from egg-bin's own install
248+
// (rootDir) rather than app-first, so an app pinning an older
249+
// @oxc-node/core (below the >=0.1.0 decorator floor) can't shadow the
250+
// bundled copy and break startup. An explicit `--tscompiler=@oxc-node/...`
251+
// keeps the normal app-first lookup.
252+
const oxcPaths = tscompilerSpecified ? findPaths : [rootDir];
253+
const oxcRegister = path.join(path.dirname(cjsResolve('@oxc-node/core', oxcPaths)), 'register.mjs');
254+
flags.tscompiler = oxcRegister;
255+
this.addNodeOptions(`--import "${pathToFileURL(oxcRegister).href}"`);
256+
} else {
257+
// legacy compilers (ts-node, swc, esbuild) expose a CJS register entry
258+
const tsNodeRegister = cjsResolve(flags.tscompiler);
259+
flags.tscompiler = tsNodeRegister;
260+
// should require tsNodeRegister on current process, let it can require *.ts files
261+
// e.g.: dev command will execute egg loader to find configs and plugins
262+
// await importModule(tsNodeRegister);
263+
// let child process auto require ts-node too
264+
this.addNodeOptions(this.formatImportModule(tsNodeRegister));
265+
}
237266
// tell egg loader to load ts file
238267
// see https://github.com/eggjs/egg-core/blob/master/lib/loader/egg_loader.js#L443
239268
this.env.EGG_TYPESCRIPT = 'true';
240269
// set current process.env.EGG_TYPESCRIPT too
241270
process.env.EGG_TYPESCRIPT = 'true';
242271
// load files from tsconfig on startup
243272
this.env.TS_NODE_FILES = process.env.TS_NODE_FILES ?? 'true';
244-
// keep same logic with egg-core, test cmd load files need it
273+
// keep same logic with egg-core, test cmd load files need it.
274+
// oxc-node does not resolve tsconfig `paths`, so tsconfig-paths/register
275+
// is still required alongside every compiler.
245276
// see https://github.com/eggjs/egg-core/blob/master/lib/loader/egg_loader.js#L49
246277
const tsConfigPathsRegister = cjsResolve('tsconfig-paths/register');
247278
this.addNodeOptions(this.formatImportModule(tsConfigPathsRegister));
248279
}
249-
if (this.isESM) {
280+
if (this.isESM && !isOxcCompiler) {
250281
// use ts-node/esm loader on esm
251282
let esmLoader = cjsResolve('ts-node/esm');
252283
// ES Module loading with absolute path fails on windows

tools/egg-bin/src/commands/snapshot.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,8 @@ export default class Snapshot<T extends typeof Snapshot> extends BaseCommand<T>
146146

147147
async #spawnNode(nodeArgs: readonly string[], extraEnv: NodeJS.ProcessEnv = {}): Promise<void> {
148148
// Run the self-contained bundle with a clean env: start from process.env, NOT
149-
// this.env. BaseCommand.#afterInit injects NODE_OPTIONS=--loader ts-node/esm
150-
// (plus ts-node/register, tsconfig-paths) into this.env for TypeScript apps;
149+
// this.env. BaseCommand.#afterInit injects NODE_OPTIONS=--import @oxc-node/core/register
150+
// (plus tsconfig-paths) into this.env for TypeScript apps;
151151
// applying that to `node --build-snapshot worker.js` would pull a non-bundled
152152
// loader into the snapshot build. process.env never carries that injection.
153153
const env = { ...process.env, ...extraEnv };

tools/egg-bin/src/commands/test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,10 +161,10 @@ export default class Test<T extends typeof Test> extends BaseCommand<T> {
161161
}
162162

163163
// Propagate NODE_OPTIONS from this.env to process.env so vitest fork
164-
// workers inherit them (e.g. ts-node/esm loader for TypeScript support).
165-
// Also disable Node.js native type stripping when TypeScript loader is active,
166-
// because native type stripping can't handle decorators and runs before
167-
// custom ESM loaders like ts-node/esm.
164+
// workers inherit them (e.g. the @oxc-node/core/register loader for
165+
// TypeScript support). Also disable Node.js native type stripping when a
166+
// TypeScript loader is active, because native type stripping can't handle
167+
// decorators and runs before custom module hooks like @oxc-node/core.
168168
if (this.env.NODE_OPTIONS) {
169169
let nodeOptions = this.env.NODE_OPTIONS;
170170
if (flags.typescript && !nodeOptions.includes('--no-experimental-strip-types')) {

tools/egg-bin/test/coffee.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,11 @@ import coffee from 'coffee';
55
const coffeeFork = {
66
fork(modulePath: string, args: string[], options: ForkOptions = {}): ReturnType<typeof coffee.fork> {
77
options.execArgv = [
8-
// '--require', 'ts-node/register/transpile-only',
8+
// oxc-node registers a CJS require hook + an ESM module.register() hook
9+
// from one `--import`, so the forked egg-bin CLI (TypeScript) boots much
10+
// faster than the old `ts-node/register` + `ts-node/esm` loader pair.
911
'--import',
10-
'ts-node/register/transpile-only',
11-
'--no-warnings',
12-
'--loader',
13-
'ts-node/esm',
12+
'@oxc-node/core/register',
1413
...(options.execArgv ?? []),
1514
];
1615
options.env = {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
import Test from '../../src/commands/test.ts';
4+
import { getFixtures } from '../helper.ts';
5+
6+
/**
7+
* Cover the legacy (non-oxc) `--tscompiler` branch in `BaseCommand#afterInit`.
8+
*
9+
* The default compiler is now `@oxc-node/core/register`, so the ts-node / swc /
10+
* esbuild CJS-register path only runs when a compiler is explicitly requested.
11+
* `--dry-run` exercises that init path in-process (so it is covered) without
12+
* actually booting a vitest run.
13+
*/
14+
describe('test/commands/test-tscompiler.test.ts', () => {
15+
const baseDir = getFixtures('example-ts-test');
16+
17+
it('resolves an explicit non-oxc tscompiler via its CJS register entry', async () => {
18+
const envSnapshot = { ...process.env };
19+
const logs: string[] = [];
20+
const spy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
21+
logs.push(args.map(String).join(' '));
22+
});
23+
try {
24+
await Test.run(['--base', baseDir, '--typescript', '--tscompiler', '@swc-node/register', '--dry-run']);
25+
} finally {
26+
spy.mockRestore();
27+
// Test.run mutates a few NODE_ENV / EGG_* process.env keys; restore them so
28+
// the shared (isolate:false) worker stays clean for sibling tests.
29+
for (const key of Object.keys(process.env)) {
30+
if (!(key in envSnapshot)) delete process.env[key];
31+
}
32+
Object.assign(process.env, envSnapshot);
33+
}
34+
// --dry-run prints the resolved vitest config and returns before running.
35+
expect(logs.join('\n')).toContain('vitest config');
36+
});
37+
});

tools/egg-bin/test/my-egg-bin.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@ describe('test/my-egg-bin.test.ts', () => {
1919
.end();
2020
});
2121

22-
// Each forked `egg-bin` spawn pays a slow ts-node/esm startup (~50s on
23-
// Windows CI). Keep these as one fork per case so no single test sums multiple
24-
// sequential spawns and blows the timeout — splitting was the fix for the
25-
// flaky `Test bin (windows-latest)` job.
22+
// Each forked `egg-bin` spawn pays a TypeScript loader startup cost (smaller
23+
// now that the CLI boots via @oxc-node/core/register instead of ts-node/esm,
24+
// but still non-trivial on Windows CI). Keep these as one fork per case so no
25+
// single test sums multiple sequential spawns and blows the timeout —
26+
// splitting was the fix for the flaky `Test bin (windows-latest)` job.
2627
it('should my-egg-bin nsp -h success', async () => {
2728
await coffee
2829
.fork(eggBin, ['nsp', '-h'], { cwd })
@@ -69,7 +70,7 @@ describe('test/my-egg-bin.test.ts', () => {
6970
// .debug()
7071
.expect('stdout', /Run the development server with my-egg-bin/)
7172
.expect('stdout', /listening port, default to 7001/)
72-
.expect('stdout', /TypeScript compiler, like ts-node\/register/)
73+
.expect('stdout', /TypeScript compiler, like @oxc-node\/core\/register/)
7374
.expect('code', 0)
7475
.end();
7576
});

tools/egg-bin/vitest.config.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@ import type { ViteUserConfig } from 'vitest/config';
66
const __dirname = path.dirname(fileURLToPath(import.meta.url));
77

88
// These tests spawn child `egg-bin` processes (vitest-in-vitest), which are slow
9-
// on Windows CI. Run fewer test files at once to cut child-process contention
10-
// and give each case more headroom, mirroring the root config's Windows
11-
// handling — otherwise cases routinely exceed the 60s timeout and flake.
9+
// on Windows CI. Cap the number of test files running at once to bound
10+
// child-process contention and give each case more headroom (the root config
11+
// handles Windows similarly) — otherwise cases routinely exceed the timeout and
12+
// flake. The cap stays at 2: these forks are CPU-bound (each spawns its own
13+
// vitest), so raising it to 4 oversaturated the 4-vCPU runner — per-case times
14+
// ballooned and `should success with some files` timed out at 120s. oxc lowered
15+
// the per-fork loader startup tax but not this CPU contention.
1216
const isWindowsCI = process.env.CI && process.platform === 'win32';
1317

1418
const config: ViteUserConfig = {

0 commit comments

Comments
 (0)