Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion tools/create-egg/src/templates/tegg/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions tools/egg-bin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
59 changes: 45 additions & 14 deletions tools/egg-bin/src/baseCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export abstract class BaseCommand<T extends typeof Command> 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)
Expand Down Expand Up @@ -214,39 +214,70 @@ export abstract class BaseCommand<T extends typeof Command> 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)
// 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));
// 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
// 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
// the package root through its main entry, then inject register.mjs as a
// single `--import` — this transpiles `.ts` for both CJS and ESM apps.
//
// 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 {
// 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';
// set current process.env.EGG_TYPESCRIPT too
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
Expand Down
4 changes: 2 additions & 2 deletions tools/egg-bin/src/commands/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,8 @@ export default class Snapshot<T extends typeof Snapshot> extends BaseCommand<T>

async #spawnNode(nodeArgs: readonly string[], extraEnv: NodeJS.ProcessEnv = {}): Promise<void> {
// 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 };
Expand Down
8 changes: 4 additions & 4 deletions tools/egg-bin/src/commands/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,10 @@ export default class Test<T extends typeof Test> extends BaseCommand<T> {
}

// 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')) {
Expand Down
9 changes: 4 additions & 5 deletions tools/egg-bin/test/coffee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,11 @@ import coffee from 'coffee';
const coffeeFork = {
fork(modulePath: string, args: string[], options: ForkOptions = {}): ReturnType<typeof coffee.fork> {
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 = {
Expand Down
37 changes: 37 additions & 0 deletions tools/egg-bin/test/commands/test-tscompiler.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
11 changes: 6 additions & 5 deletions tools/egg-bin/test/my-egg-bin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -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();
});
Expand Down
10 changes: 6 additions & 4 deletions tools/egg-bin/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 } : {}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
globals: true,
},
};
Expand Down
Loading