From 1544072822e09e629d4d9bc1f88712f67ff0a622 Mon Sep 17 00:00:00 2001 From: killagu Date: Sun, 28 Jun 2026 01:16:06 +0800 Subject: [PATCH 1/5] perf(egg-bin): boot TypeScript via @oxc-node/core instead of ts-node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace `ts-node/register` (CJS) + the hardcoded `ts-node/esm` `--loader` with a single `--import @oxc-node/core/register`, the Rust/oxc loader already used by the root test suite (#5965). oxc installs both a CJS require hook and an ESM `module.register()` hook in one shot, so ESM apps no longer need a separate loader. This removes the slow ts-node startup every forked egg-bin CLI paid (tens of seconds on Windows CI). - baseCommand: default `tscompiler` is now `@oxc-node/core/register`; its import-only export is injected via `--import` (resolved through the package main, since it cannot be CJS-resolved). Legacy compilers (ts-node, swc, esbuild) keep their CJS register + ts-node/esm loader path, so explicit `--tscompiler=...` overrides behave exactly as before. tsconfig-paths/register is still injected (oxc does not resolve tsconfig `paths`). - test/coffee.ts: fork the egg-bin CLI via `--import @oxc-node/core/register`. - add `@oxc-node/core` dependency; keep `ts-node` for explicit opt-in. Full egg-bin suite identical before/after (built, CI-style): 86 passed, 0 failed, 39 skipped — zero regressions. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/egg-bin/package.json | 1 + tools/egg-bin/src/baseCommand.ts | 40 +++++++++++++++++++------- tools/egg-bin/src/commands/snapshot.ts | 4 +-- tools/egg-bin/src/commands/test.ts | 8 +++--- tools/egg-bin/test/coffee.ts | 9 +++--- tools/egg-bin/test/my-egg-bin.test.ts | 11 +++---- 6 files changed, 46 insertions(+), 27 deletions(-) diff --git a/tools/egg-bin/package.json b/tools/egg-bin/package.json index b3ed58fedc..3bc1f223c9 100644 --- a/tools/egg-bin/package.json +++ b/tools/egg-bin/package.json @@ -69,6 +69,7 @@ "@eggjs/tegg-vitest": "workspace:*", "@eggjs/utils": "workspace:*", "@oclif/core": "catalog:", + "@oxc-node/core": "catalog:", "@vitest/coverage-v8": "catalog:", "ci-parallel-vars": "catalog:", "detect-port": "catalog:", diff --git a/tools/egg-bin/src/baseCommand.ts b/tools/egg-bin/src/baseCommand.ts index 487441d124..d6bae75c35 100644 --- a/tools/egg-bin/src/baseCommand.ts +++ b/tools/egg-bin/src/baseCommand.ts @@ -92,7 +92,7 @@ export abstract class BaseCommand extends Command { }), tscompiler: Flags.string({ helpGroup: 'GLOBAL', - summary: 'TypeScript compiler, like ts-node/register', + summary: 'TypeScript compiler, like @oxc-node/core/register', aliases: ['tsc'], }), // flag with no value (--typescript) @@ -225,15 +225,31 @@ export abstract class BaseCommand extends Command { throw new Error(`Cannot resolve '${specifier}' from ${findPaths.join(', ')}`); }; this.isESM = pkg.type === 'module'; + // oxc-node's register entry installs BOTH a CJS require hook (via pirates) + // and an ESM `module.register()` hook from a single `--import`, so when it is + // the active compiler an ESM app needs no separate `--loader` (see below). + let isOxcCompiler = false; if (typescript) { - flags.tscompiler = flags.tscompiler ?? 'ts-node/register'; - const tsNodeRegister = cjsResolve(flags.tscompiler); - flags.tscompiler = tsNodeRegister; - // should require tsNodeRegister on current process, let it can require *.ts files - // e.g.: dev command will execute egg loader to find configs and plugins - // await importModule(tsNodeRegister); - // let child process auto require ts-node too - this.addNodeOptions(this.formatImportModule(tsNodeRegister)); + flags.tscompiler = flags.tscompiler ?? '@oxc-node/core/register'; + isOxcCompiler = flags.tscompiler.includes('@oxc-node/core'); + if (isOxcCompiler) { + // `@oxc-node/core/register` is exported with an `import`-only condition + // (no `require`), so it cannot be CJS-resolved nor `--require`d. Resolve + // the package root through its main entry, then inject register.mjs as a + // single `--import` — this transpiles `.ts` for both CJS and ESM apps. + const oxcRegister = path.join(path.dirname(cjsResolve('@oxc-node/core')), 'register.mjs'); + flags.tscompiler = oxcRegister; + this.addNodeOptions(`--import "${pathToFileURL(oxcRegister).href}"`); + } else { + // legacy compilers (ts-node, swc, esbuild) expose a CJS register entry + const tsNodeRegister = cjsResolve(flags.tscompiler); + flags.tscompiler = tsNodeRegister; + // should require tsNodeRegister on current process, let it can require *.ts files + // e.g.: dev command will execute egg loader to find configs and plugins + // await importModule(tsNodeRegister); + // let child process auto require ts-node too + this.addNodeOptions(this.formatImportModule(tsNodeRegister)); + } // tell egg loader to load ts file // see https://github.com/eggjs/egg-core/blob/master/lib/loader/egg_loader.js#L443 this.env.EGG_TYPESCRIPT = 'true'; @@ -241,12 +257,14 @@ export abstract class BaseCommand extends Command { process.env.EGG_TYPESCRIPT = 'true'; // load files from tsconfig on startup this.env.TS_NODE_FILES = process.env.TS_NODE_FILES ?? 'true'; - // keep same logic with egg-core, test cmd load files need it + // keep same logic with egg-core, test cmd load files need it. + // oxc-node does not resolve tsconfig `paths`, so tsconfig-paths/register + // is still required alongside every compiler. // see https://github.com/eggjs/egg-core/blob/master/lib/loader/egg_loader.js#L49 const tsConfigPathsRegister = cjsResolve('tsconfig-paths/register'); this.addNodeOptions(this.formatImportModule(tsConfigPathsRegister)); } - if (this.isESM) { + if (this.isESM && !isOxcCompiler) { // use ts-node/esm loader on esm let esmLoader = cjsResolve('ts-node/esm'); // ES Module loading with absolute path fails on windows diff --git a/tools/egg-bin/src/commands/snapshot.ts b/tools/egg-bin/src/commands/snapshot.ts index d743e5b516..c8243024c4 100644 --- a/tools/egg-bin/src/commands/snapshot.ts +++ b/tools/egg-bin/src/commands/snapshot.ts @@ -146,8 +146,8 @@ export default class Snapshot extends BaseCommand async #spawnNode(nodeArgs: readonly string[], extraEnv: NodeJS.ProcessEnv = {}): Promise { // Run the self-contained bundle with a clean env: start from process.env, NOT - // this.env. BaseCommand.#afterInit injects NODE_OPTIONS=--loader ts-node/esm - // (plus ts-node/register, tsconfig-paths) into this.env for TypeScript apps; + // this.env. BaseCommand.#afterInit injects NODE_OPTIONS=--import @oxc-node/core/register + // (plus tsconfig-paths) into this.env for TypeScript apps; // applying that to `node --build-snapshot worker.js` would pull a non-bundled // loader into the snapshot build. process.env never carries that injection. const env = { ...process.env, ...extraEnv }; diff --git a/tools/egg-bin/src/commands/test.ts b/tools/egg-bin/src/commands/test.ts index eafd6b5139..91fdef1026 100644 --- a/tools/egg-bin/src/commands/test.ts +++ b/tools/egg-bin/src/commands/test.ts @@ -161,10 +161,10 @@ export default class Test extends BaseCommand { } // Propagate NODE_OPTIONS from this.env to process.env so vitest fork - // workers inherit them (e.g. ts-node/esm loader for TypeScript support). - // Also disable Node.js native type stripping when TypeScript loader is active, - // because native type stripping can't handle decorators and runs before - // custom ESM loaders like ts-node/esm. + // workers inherit them (e.g. the @oxc-node/core/register loader for + // TypeScript support). Also disable Node.js native type stripping when a + // TypeScript loader is active, because native type stripping can't handle + // decorators and runs before custom module hooks like @oxc-node/core. if (this.env.NODE_OPTIONS) { let nodeOptions = this.env.NODE_OPTIONS; if (flags.typescript && !nodeOptions.includes('--no-experimental-strip-types')) { diff --git a/tools/egg-bin/test/coffee.ts b/tools/egg-bin/test/coffee.ts index 4b2a1a8561..ba1d1932d4 100644 --- a/tools/egg-bin/test/coffee.ts +++ b/tools/egg-bin/test/coffee.ts @@ -5,12 +5,11 @@ import coffee from 'coffee'; const coffeeFork = { fork(modulePath: string, args: string[], options: ForkOptions = {}): ReturnType { options.execArgv = [ - // '--require', 'ts-node/register/transpile-only', + // oxc-node registers a CJS require hook + an ESM module.register() hook + // from one `--import`, so the forked egg-bin CLI (TypeScript) boots much + // faster than the old `ts-node/register` + `ts-node/esm` loader pair. '--import', - 'ts-node/register/transpile-only', - '--no-warnings', - '--loader', - 'ts-node/esm', + '@oxc-node/core/register', ...(options.execArgv ?? []), ]; options.env = { diff --git a/tools/egg-bin/test/my-egg-bin.test.ts b/tools/egg-bin/test/my-egg-bin.test.ts index 63b9992f79..1865a1cef1 100644 --- a/tools/egg-bin/test/my-egg-bin.test.ts +++ b/tools/egg-bin/test/my-egg-bin.test.ts @@ -19,10 +19,11 @@ describe('test/my-egg-bin.test.ts', () => { .end(); }); - // Each forked `egg-bin` spawn pays a slow ts-node/esm startup (~50s on - // Windows CI). Keep these as one fork per case so no single test sums multiple - // sequential spawns and blows the timeout — splitting was the fix for the - // flaky `Test bin (windows-latest)` job. + // Each forked `egg-bin` spawn pays a TypeScript loader startup cost (smaller + // now that the CLI boots via @oxc-node/core/register instead of ts-node/esm, + // but still non-trivial on Windows CI). Keep these as one fork per case so no + // single test sums multiple sequential spawns and blows the timeout — + // splitting was the fix for the flaky `Test bin (windows-latest)` job. it('should my-egg-bin nsp -h success', async () => { await coffee .fork(eggBin, ['nsp', '-h'], { cwd }) @@ -69,7 +70,7 @@ describe('test/my-egg-bin.test.ts', () => { // .debug() .expect('stdout', /Run the development server with my-egg-bin/) .expect('stdout', /listening port, default to 7001/) - .expect('stdout', /TypeScript compiler, like ts-node\/register/) + .expect('stdout', /TypeScript compiler, like @oxc-node\/core\/register/) .expect('code', 0) .end(); }); From a674b3f4784982bf90aa8ec2a666fefb26af413d Mon Sep 17 00:00:00 2001 From: killagu Date: Sun, 28 Jun 2026 01:30:30 +0800 Subject: [PATCH 2/5] fix(egg-bin): tighten oxc detection and cover the legacy tscompiler path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detect oxc by exact specifier / `@oxc-node/core/` prefix instead of a loose `includes()` substring, so a similarly named compiler can't be misdetected (review feedback from gemini-code-assist). - Add an in-process `test --dry-run` case exercising an explicit non-oxc `--tscompiler` (the ts-node/swc/esbuild CJS-register branch), restoring patch coverage on baseCommand.ts now that oxc — not that branch — is the default. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/egg-bin/src/baseCommand.ts | 5 ++- .../test/commands/test-tscompiler.test.ts | 37 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 tools/egg-bin/test/commands/test-tscompiler.test.ts diff --git a/tools/egg-bin/src/baseCommand.ts b/tools/egg-bin/src/baseCommand.ts index d6bae75c35..6f390bca26 100644 --- a/tools/egg-bin/src/baseCommand.ts +++ b/tools/egg-bin/src/baseCommand.ts @@ -231,7 +231,10 @@ export abstract class BaseCommand extends Command { let isOxcCompiler = false; if (typescript) { flags.tscompiler = flags.tscompiler ?? '@oxc-node/core/register'; - isOxcCompiler = flags.tscompiler.includes('@oxc-node/core'); + // Match the package specifier precisely (exact entry or a `@oxc-node/core/` + // subpath) rather than a loose substring, so a similarly named compiler + // can't be misdetected as oxc. + isOxcCompiler = flags.tscompiler === '@oxc-node/core/register' || flags.tscompiler.startsWith('@oxc-node/core/'); if (isOxcCompiler) { // `@oxc-node/core/register` is exported with an `import`-only condition // (no `require`), so it cannot be CJS-resolved nor `--require`d. Resolve diff --git a/tools/egg-bin/test/commands/test-tscompiler.test.ts b/tools/egg-bin/test/commands/test-tscompiler.test.ts new file mode 100644 index 0000000000..36102c141d --- /dev/null +++ b/tools/egg-bin/test/commands/test-tscompiler.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest'; + +import Test from '../../src/commands/test.ts'; +import { getFixtures } from '../helper.ts'; + +/** + * Cover the legacy (non-oxc) `--tscompiler` branch in `BaseCommand#afterInit`. + * + * The default compiler is now `@oxc-node/core/register`, so the ts-node / swc / + * esbuild CJS-register path only runs when a compiler is explicitly requested. + * `--dry-run` exercises that init path in-process (so it is covered) without + * actually booting a vitest run. + */ +describe('test/commands/test-tscompiler.test.ts', () => { + const baseDir = getFixtures('example-ts-test'); + + it('resolves an explicit non-oxc tscompiler via its CJS register entry', async () => { + const envSnapshot = { ...process.env }; + const logs: string[] = []; + const spy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + logs.push(args.map(String).join(' ')); + }); + try { + await Test.run(['--base', baseDir, '--typescript', '--tscompiler', '@swc-node/register', '--dry-run']); + } finally { + spy.mockRestore(); + // Test.run mutates a few NODE_ENV / EGG_* process.env keys; restore them so + // the shared (isolate:false) worker stays clean for sibling tests. + for (const key of Object.keys(process.env)) { + if (!(key in envSnapshot)) delete process.env[key]; + } + Object.assign(process.env, envSnapshot); + } + // --dry-run prints the resolved vitest config and returns before running. + expect(logs.join('\n')).toContain('vitest config'); + }); +}); From b6f12afa329670b560d34a3a4001707acff48b67 Mon Sep 17 00:00:00 2001 From: killagu Date: Sun, 28 Jun 2026 01:34:46 +0800 Subject: [PATCH 3/5] fix(egg-bin): resolve the default oxc loader from egg-bin, not the app When `--tscompiler` is not specified, resolve the bundled `@oxc-node/core` from egg-bin's own install instead of the app's `baseDir` first. Otherwise an app pinning an older `@oxc-node/core` (e.g. the tegg template's `^0.0.35`, below the `>=0.1.0` decorator-metadata floor) would shadow the bundled copy and could crash on startup. An explicit `--tscompiler=@oxc-node/core/...` keeps the normal app-first lookup. Also bump the create-egg tegg template to `@oxc-node/core@^0.1.0`. Review feedback from coderabbitai. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../create-egg/src/templates/tegg/package.json | 2 +- tools/egg-bin/src/baseCommand.ts | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/tools/create-egg/src/templates/tegg/package.json b/tools/create-egg/src/templates/tegg/package.json index 01879a7559..b98d1e05df 100644 --- a/tools/create-egg/src/templates/tegg/package.json +++ b/tools/create-egg/src/templates/tegg/package.json @@ -39,7 +39,7 @@ "@eggjs/bin": "beta", "@eggjs/mock": "beta", "@eggjs/tsconfig": "beta", - "@oxc-node/core": "^0.0.35", + "@oxc-node/core": "^0.1.0", "@types/node": "24", "@vitest/coverage-v8": "4", "cross-env": "10", diff --git a/tools/egg-bin/src/baseCommand.ts b/tools/egg-bin/src/baseCommand.ts index 6f390bca26..88b3b7f8ba 100644 --- a/tools/egg-bin/src/baseCommand.ts +++ b/tools/egg-bin/src/baseCommand.ts @@ -214,15 +214,15 @@ export abstract class BaseCommand extends Command { // - importResolve's import.meta.resolve is scoped to @eggjs/utils, not here // createRequire resolves from the caller's location with CJS semantics, // correctly handling extension resolution and flat-hoisted node_modules. - const cjsResolve = (specifier: string): string => { - for (const p of findPaths) { + const cjsResolve = (specifier: string, paths: string[] = findPaths): string => { + for (const p of paths) { try { return createRequire(path.join(p, 'package.json')).resolve(specifier); } catch { /* try next path */ } } - throw new Error(`Cannot resolve '${specifier}' from ${findPaths.join(', ')}`); + throw new Error(`Cannot resolve '${specifier}' from ${paths.join(', ')}`); }; this.isESM = pkg.type === 'module'; // oxc-node's register entry installs BOTH a CJS require hook (via pirates) @@ -230,6 +230,9 @@ export abstract class BaseCommand extends Command { // the active compiler an ESM app needs no separate `--loader` (see below). let isOxcCompiler = false; if (typescript) { + // Remember whether the compiler was explicitly chosen (flag / env / + // package.json) before we apply the oxc default below. + const tscompilerSpecified = flags.tscompiler !== undefined; flags.tscompiler = flags.tscompiler ?? '@oxc-node/core/register'; // Match the package specifier precisely (exact entry or a `@oxc-node/core/` // subpath) rather than a loose substring, so a similarly named compiler @@ -240,7 +243,14 @@ export abstract class BaseCommand extends Command { // (no `require`), so it cannot be CJS-resolved nor `--require`d. Resolve // the package root through its main entry, then inject register.mjs as a // single `--import` — this transpiles `.ts` for both CJS and ESM apps. - const oxcRegister = path.join(path.dirname(cjsResolve('@oxc-node/core')), 'register.mjs'); + // + // For the implicit default, resolve oxc from egg-bin's own install + // (rootDir) rather than app-first, so an app pinning an older + // @oxc-node/core (below the >=0.1.0 decorator floor) can't shadow the + // bundled copy and break startup. An explicit `--tscompiler=@oxc-node/...` + // keeps the normal app-first lookup. + const oxcPaths = tscompilerSpecified ? findPaths : [rootDir]; + const oxcRegister = path.join(path.dirname(cjsResolve('@oxc-node/core', oxcPaths)), 'register.mjs'); flags.tscompiler = oxcRegister; this.addNodeOptions(`--import "${pathToFileURL(oxcRegister).href}"`); } else { From 6f2a31f89ee49af66812e8fac0ffe74fe9ed569b Mon Sep 17 00:00:00 2001 From: killagu Date: Sun, 28 Jun 2026 01:40:10 +0800 Subject: [PATCH 4/5] test(egg-bin): raise Windows CI maxWorkers from 2 to 4 The 2-worker cap was set to bound vitest-in-vitest child-process contention under the slow ts-node/esm fork startup. Now that children boot via @oxc-node/core/register, the per-fork loader tax is much lower, so 4 workers fit and shorten the Windows `Test bin` job. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/egg-bin/vitest.config.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tools/egg-bin/vitest.config.ts b/tools/egg-bin/vitest.config.ts index 2211deabcb..ef6bc5f8ed 100644 --- a/tools/egg-bin/vitest.config.ts +++ b/tools/egg-bin/vitest.config.ts @@ -6,9 +6,11 @@ import type { ViteUserConfig } from 'vitest/config'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // These tests spawn child `egg-bin` processes (vitest-in-vitest), which are slow -// on Windows CI. Run fewer test files at once to cut child-process contention -// and give each case more headroom, mirroring the root config's Windows -// handling — otherwise cases routinely exceed the 60s timeout and flake. +// on Windows CI. Cap the number of test files running at once to bound +// child-process contention and give each case more headroom (the root config +// handles Windows similarly) — otherwise cases routinely exceed the 60s timeout +// and flake. Booting children via @oxc-node/core/register instead of ts-node/esm +// removed most of the per-fork loader startup tax, so 4 workers now fit. const isWindowsCI = process.env.CI && process.platform === 'win32'; const config: ViteUserConfig = { @@ -18,7 +20,7 @@ const config: ViteUserConfig = { exclude: ['**/test/fixtures/**', '**/node_modules/**', '**/dist/**'], testTimeout: isWindowsCI ? 120000 : 60000, hookTimeout: isWindowsCI ? 120000 : 60000, - ...(isWindowsCI ? { maxWorkers: 2 } : {}), + ...(isWindowsCI ? { maxWorkers: 4 } : {}), globals: true, }, }; From a5cd88de15998fae25a45b5f6661998ba9724a14 Mon Sep 17 00:00:00 2001 From: killagu Date: Sun, 28 Jun 2026 02:12:58 +0800 Subject: [PATCH 5/5] test(egg-bin): keep Windows CI maxWorkers at 2 Raising it to 4 oversaturated the 4-vCPU Windows runner: these vitest-in-vitest forks are CPU-bound (each spawns its own vitest), so 4 workers made every case slower and `test.test.ts > should success with some files` timed out at 120s (total ~29m, no speedup over the 2-worker baseline). Revert to 2; oxc already trims the per-fork loader startup, which is the part that was actually loader-bound. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/egg-bin/vitest.config.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tools/egg-bin/vitest.config.ts b/tools/egg-bin/vitest.config.ts index ef6bc5f8ed..3b2bbf89e0 100644 --- a/tools/egg-bin/vitest.config.ts +++ b/tools/egg-bin/vitest.config.ts @@ -8,9 +8,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // These tests spawn child `egg-bin` processes (vitest-in-vitest), which are slow // on Windows CI. Cap the number of test files running at once to bound // child-process contention and give each case more headroom (the root config -// handles Windows similarly) — otherwise cases routinely exceed the 60s timeout -// and flake. Booting children via @oxc-node/core/register instead of ts-node/esm -// removed most of the per-fork loader startup tax, so 4 workers now fit. +// handles Windows similarly) — otherwise cases routinely exceed the timeout and +// flake. The cap stays at 2: these forks are CPU-bound (each spawns its own +// vitest), so raising it to 4 oversaturated the 4-vCPU runner — per-case times +// ballooned and `should success with some files` timed out at 120s. oxc lowered +// the per-fork loader startup tax but not this CPU contention. const isWindowsCI = process.env.CI && process.platform === 'win32'; const config: ViteUserConfig = { @@ -20,7 +22,7 @@ const config: ViteUserConfig = { exclude: ['**/test/fixtures/**', '**/node_modules/**', '**/dist/**'], testTimeout: isWindowsCI ? 120000 : 60000, hookTimeout: isWindowsCI ? 120000 : 60000, - ...(isWindowsCI ? { maxWorkers: 4 } : {}), + ...(isWindowsCI ? { maxWorkers: 2 } : {}), globals: true, }, };