diff --git a/tools/scripts/package.json b/tools/scripts/package.json index 4bb3deb287..93c625314a 100644 --- a/tools/scripts/package.json +++ b/tools/scripts/package.json @@ -31,7 +31,10 @@ "./baseCommand": "./src/baseCommand.ts", "./commands/start": "./src/commands/start.ts", "./commands/stop": "./src/commands/stop.ts", + "./esm-polyfill": "./src/esm-polyfill.ts", + "./file-url-resolver": "./src/file-url-resolver.ts", "./helper": "./src/helper.ts", + "./resolve-framework-entry": "./src/resolve-framework-entry.ts", "./types": "./src/types.ts", "./package.json": "./package.json" }, @@ -42,7 +45,10 @@ "./baseCommand": "./dist/baseCommand.js", "./commands/start": "./dist/commands/start.js", "./commands/stop": "./dist/commands/stop.js", + "./esm-polyfill": "./dist/esm-polyfill.js", + "./file-url-resolver": "./dist/file-url-resolver.js", "./helper": "./dist/helper.js", + "./resolve-framework-entry": "./dist/resolve-framework-entry.js", "./types": "./dist/types.js", "./package.json": "./package.json" } @@ -56,9 +62,11 @@ "dependencies": { "@eggjs/utils": "workspace:*", "@oclif/core": "catalog:", + "esbuild": "catalog:", "node-homedir": "catalog:", "runscript": "catalog:", "source-map-support": "catalog:", + "tsx": "catalog:", "utility": "catalog:" }, "devDependencies": { diff --git a/tools/scripts/scripts/start-single.cjs b/tools/scripts/scripts/start-single.cjs new file mode 100644 index 0000000000..5d375556fc --- /dev/null +++ b/tools/scripts/scripts/start-single.cjs @@ -0,0 +1,83 @@ +const http = require('node:http'); +const { debuglog } = require('node:util'); + +const { importModule } = require('@eggjs/utils'); + +const debug = debuglog('egg/scripts/start-single/cjs'); + +async function main() { + debug('argv: %o', process.argv); + const options = JSON.parse(process.argv[2]); + debug('start single options: %o', options); + const exports = await importModule(options.framework); + let startEgg = exports.start ?? exports.startEgg; + if (typeof startEgg !== 'function') { + startEgg = exports.default?.start ?? exports.default?.startEgg; + } + if (typeof startEgg !== 'function') { + throw new Error(`Cannot find start/startEgg function from framework: ${options.framework}`); + } + const app = await startEgg({ + baseDir: options.baseDir, + framework: options.framework, + env: options.env, + mode: 'single', + }); + + const port = options.port ?? 7001; + const server = http.createServer(app.callback()); + app.emit('server', server); + + await new Promise((resolve, reject) => { + server.listen(port, () => { + resolve(undefined); + }); + server.once('error', reject); + }); + + const address = server.address(); + const url = typeof address === 'string' ? address : `http://127.0.0.1:${address.port}`; + + debug('server started on %s', url); + + // notify parent process (daemon mode) + if (process.send) { + process.send({ + action: 'egg-ready', + data: { address: url, port: address.port ?? port }, + }); + } + + // graceful shutdown + const shutdown = async (signal) => { + debug('receive signal %s, closing server', signal); + server.close(() => { + debug('server closed'); + if (typeof app.close === 'function') { + app + .close() + .then(() => { + process.exit(0); + }) + .catch(() => { + process.exit(1); + }); + } else { + process.exit(0); + } + }); + // force exit after timeout + setTimeout(() => { + process.exit(1); + }, 10000).unref(); + }; + + process.once('SIGTERM', () => shutdown('SIGTERM')); + process.once('SIGINT', () => shutdown('SIGINT')); + process.once('SIGQUIT', () => shutdown('SIGQUIT')); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/tools/scripts/scripts/start-single.mjs b/tools/scripts/scripts/start-single.mjs new file mode 100644 index 0000000000..1fc24e9e3a --- /dev/null +++ b/tools/scripts/scripts/start-single.mjs @@ -0,0 +1,80 @@ +import http from 'node:http'; +import { debuglog } from 'node:util'; + +import { importModule } from '@eggjs/utils'; + +const debug = debuglog('egg/scripts/start-single/esm'); + +async function main() { + debug('argv: %o', process.argv); + const options = JSON.parse(process.argv[2]); + debug('start single options: %o', options); + const framework = await importModule(options.framework); + const startEgg = framework.start ?? framework.startEgg; + if (typeof startEgg !== 'function') { + throw new Error(`Cannot find start/startEgg function from framework: ${options.framework}`); + } + const app = await startEgg({ + baseDir: options.baseDir, + framework: options.framework, + env: options.env, + mode: 'single', + }); + + const port = options.port ?? 7001; + const server = http.createServer(app.callback()); + app.emit('server', server); + + await new Promise((resolve, reject) => { + server.listen(port, () => { + resolve(undefined); + }); + server.once('error', reject); + }); + + const address = server.address(); + const url = typeof address === 'string' ? address : `http://127.0.0.1:${address.port}`; + + debug('server started on %s', url); + + // notify parent process (daemon mode) + if (process.send) { + process.send({ + action: 'egg-ready', + data: { address: url, port: address.port ?? port }, + }); + } + + // graceful shutdown + const shutdown = async (signal) => { + debug('receive signal %s, closing server', signal); + server.close(() => { + debug('server closed'); + if (typeof app.close === 'function') { + app + .close() + .then(() => { + process.exit(0); + }) + .catch(() => { + process.exit(1); + }); + } else { + process.exit(0); + } + }); + // force exit after timeout + setTimeout(() => { + process.exit(1); + }, 10000).unref(); + }; + + process.once('SIGTERM', () => shutdown('SIGTERM')); + process.once('SIGINT', () => shutdown('SIGINT')); + process.once('SIGQUIT', () => shutdown('SIGQUIT')); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/tools/scripts/src/commands/start.ts b/tools/scripts/src/commands/start.ts index 2ca048a683..12f4b7fdd2 100644 --- a/tools/scripts/src/commands/start.ts +++ b/tools/scripts/src/commands/start.ts @@ -81,6 +81,10 @@ export default class Start extends BaseCommand { summary: 'whether enable sourcemap support, will load `source-map-support` etc', aliases: ['ts', 'typescript'], }), + single: Flags.boolean({ + description: 'start as single process mode (no cluster)', + default: false, + }), }; isReady = false; @@ -104,8 +108,9 @@ export default class Start extends BaseCommand { return name; } - protected async getServerBin(): Promise { - const serverBinName = this.isESM ? 'start-cluster.mjs' : 'start-cluster.cjs'; + protected async getServerBin(single?: boolean): Promise { + const prefix = single ? 'start-single' : 'start-cluster'; + const serverBinName = this.isESM ? `${prefix}.mjs` : `${prefix}.cjs`; return path.join(import.meta.dirname, '../../scripts', serverBinName); } @@ -145,8 +150,12 @@ export default class Start extends BaseCommand { // normalize env this.env.HOME = HOME; this.env.NODE_ENV = 'production'; - // disable ts file loader - this.env.EGG_TS_ENABLE = 'false'; + // Disable ts file loader in cluster mode. + // In single mode, Node.js 22.18+ native type stripping handles .ts files. + const isSingle = flags.single; + if (!isSingle) { + this.env.EGG_TS_ENABLE = 'false'; + } // it makes env big but more robust this.env.PATH = this.env.Path = [ @@ -173,6 +182,11 @@ export default class Start extends BaseCommand { // additional execArgv const execArgv: string[] = ['--no-deprecation', '--trace-warnings']; + // Single mode loads framework .ts source directly; use tsx for full TypeScript + // support including decorators (Node.js native type stripping can't handle them). + if (isSingle) { + execArgv.push('--import=tsx/esm'); + } if (this.pkgEgg.revert) { const reverts = Array.isArray(this.pkgEgg.revert) ? this.pkgEgg.revert : [this.pkgEgg.revert]; for (const revert of reverts) { @@ -242,10 +256,14 @@ export default class Start extends BaseCommand { cwd: baseDir, }; - this.log('Starting %s application at %s', frameworkName, baseDir); + this.log('Starting %s application at %s%s', frameworkName, baseDir, isSingle ? ' (single process mode)' : ''); // remove unused properties from stringify, alias had been remove by `removeAlias` - const ignoreKeys = ['env', 'daemon', 'stdout', 'stderr', 'timeout', 'ignore-stderr', 'node']; + const ignoreKeys = ['env', 'daemon', 'stdout', 'stderr', 'timeout', 'ignore-stderr', 'node', 'single']; + if (isSingle) { + // workers is not used in single mode + ignoreKeys.push('workers'); + } const clusterOptions = stringify( { ...flags, @@ -254,7 +272,7 @@ export default class Start extends BaseCommand { ignoreKeys, ); // Note: `spawn` is not like `fork`, had to pass `execArgv` yourself - const serverBin = await this.getServerBin(); + const serverBin = await this.getServerBin(isSingle); const eggArgs = [...execArgv, serverBin, clusterOptions, `--title=${flags.title}`]; const spawnScript = `${command} ${eggArgs.map((a) => `'${a}'`).join(' ')}`; this.log('Spawn %o', spawnScript); @@ -266,19 +284,9 @@ export default class Start extends BaseCommand { options.stdio = ['ignore', stdout, stderr, 'ipc']; options.detached = true; const child = (this.#child = spawn(command, eggArgs, options)); - this.isReady = false; - child.on('message', (msg: any) => { - // https://github.com/eggjs/cluster/blob/master/src/master.ts#L119 - if (msg && msg.action === 'egg-ready') { - this.isReady = true; - this.log('%s started on %s', frameworkName, msg.data.address); - child.unref(); - child.disconnect(); - } - }); - // check start status - await this.checkStatus(); + // Wait for egg-ready IPC message instead of polling with sleep + await this.waitForReady(child, frameworkName); } else { options.stdio = ['inherit', 'inherit', 'inherit', 'ipc']; const child = (this.#child = spawn(command, eggArgs, options)); @@ -299,52 +307,69 @@ export default class Start extends BaseCommand { } } - protected async checkStatus(): Promise { - let count = 0; - let hasError = false; - let isSuccess = true; - const timeout = this.flags.timeout / 1000; + protected async waitForReady(child: ChildProcess, readyLabel: string): Promise { + const timeoutMs = this.flags.timeout; const stderrFile = this.flags.stderr!; - while (!this.isReady) { + + try { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Start failed, ${timeoutMs / 1000}s timeout`)); + }, timeoutMs); + + child.on('message', (msg: any) => { + // https://github.com/eggjs/cluster/blob/master/src/master.ts#L119 + if (msg && msg.action === 'egg-ready') { + clearTimeout(timer); + this.isReady = true; + this.log('%s started on %s', readyLabel, msg.data.address); + child.unref(); + child.disconnect(); + resolve(); + } + }); + + child.on('exit', (code) => { + if (code) { + clearTimeout(timer); + reject(new Error(`Child process exited with code ${code}`)); + } + }); + }); + } catch (err: any) { + // Check stderr for error details + let hasStderrContent = false; try { const stats = await stat(stderrFile); if (stats && stats.size > 0) { - hasError = true; - break; + hasStderrContent = true; } } catch { - // nothing - } - - if (count >= timeout) { - this.logToStderr('Start failed, %ds timeout', timeout); - isSuccess = false; - break; + // stderr file may not exist } - await scheduler.wait(1000); - this.log('Wait Start: %d...', ++count); - } - - if (hasError) { - try { - const args = ['-n', '100', stderrFile]; - this.logToStderr('tail %s', args.join(' ')); - const { stdout: headStdout } = await execFile('head', args); - const { stdout: tailStdout } = await execFile('tail', args); - this.logToStderr('Got error when startup: '); - this.logToStderr(headStdout); - this.logToStderr('...'); - this.logToStderr(tailStdout); - } catch (err) { - this.logToStderr('ignore tail error: %s', err); + if (hasStderrContent) { + try { + const args = ['-n', '100', stderrFile]; + this.logToStderr('tail %s', args.join(' ')); + const { stdout: headStdout } = await execFile('head', args); + const { stdout: tailStdout } = await execFile('tail', args); + this.logToStderr('Got error when startup: '); + this.logToStderr(headStdout); + this.logToStderr('...'); + this.logToStderr(tailStdout); + } catch (tailErr) { + this.logToStderr('ignore tail error: %s', tailErr); + } + if (this.flags['ignore-stderr']) { + return; // User opted to ignore stderr errors + } + this.logToStderr('Start got error, see %o', stderrFile); + this.logToStderr('Or use `--ignore-stderr` to ignore stderr at startup.'); + } else { + this.logToStderr('%s', err.message); } - isSuccess = this.flags['ignore-stderr']; - this.logToStderr('Start got error, see %o', stderrFile); - this.logToStderr('Or use `--ignore-stderr` to ignore stderr at startup.'); - } - if (!isSuccess) { this.#child.kill('SIGTERM'); await scheduler.wait(1000); this.exit(1); diff --git a/tools/scripts/src/commands/stop.ts b/tools/scripts/src/commands/stop.ts index 4e86c66cb4..e6af8c982d 100644 --- a/tools/scripts/src/commands/stop.ts +++ b/tools/scripts/src/commands/stop.ts @@ -44,11 +44,14 @@ export default class Stop extends BaseCommand { this.log(`stopping egg application${flags.title ? ` with --title=${flags.title}` : ''}`); // node ~/eggjs/scripts/scripts/start-cluster.cjs {"title":"egg-server","workers":4,"port":7001,"baseDir":"~/eggjs/test/showcase","framework":"~/eggjs/test/showcase/node_modules/egg"} + // node ~/eggjs/scripts/scripts/start-single.mjs {"title":"egg-server","port":7001,"baseDir":"~/eggjs/test/showcase","framework":"~/eggjs/test/showcase/node_modules/egg"} let processList = await this.findNodeProcesses((item) => { const cmd = item.cmd; + const isEggProcess = + cmd.includes('start-cluster') || cmd.includes('start-single') || cmd.startsWith('egg-server'); const matched = flags.title - ? cmd.includes('start-cluster') && cmd.includes(format(osRelated.titleTemplate, flags.title)) - : cmd.includes('start-cluster'); + ? isEggProcess && cmd.includes(format(osRelated.titleTemplate, flags.title)) + : isEggProcess; if (matched) { debug('find master process: %o', item); } diff --git a/tools/scripts/src/esm-polyfill.ts b/tools/scripts/src/esm-polyfill.ts new file mode 100644 index 0000000000..d09ba14bcf --- /dev/null +++ b/tools/scripts/src/esm-polyfill.ts @@ -0,0 +1,79 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import type { Plugin as EsbuildPlugin } from 'esbuild'; + +/** + * Pure transformation for ESM polyfill. Exported for unit testing. + * + * Returns the modified source, or `null` if the file is CJS (no ESM + * markers), if the file declares `__dirname` at top-level, or if no + * substitutions were needed. Detects ESM by presence of `import.meta.` + * or a top-level `import`/`export` statement — CJS files (e.g. files + * using `module.exports` and `require`) are left untouched. + * + * Replacements: + * - `import.meta.dirname`/`url`/`filename` → literal strings baked at + * build time using the original source path + * - `__dirname` → literal absolute dirname of the original source + * + * Deliberately does NOT touch `__filename`: ESM sources often contain + * `const __filename = fileURLToPath(import.meta.url)` as a CJS-compat + * fallback (e.g. koa-onerror), and literal substitution would turn the + * declaration into `const "/path" = ...` (syntax error). Because + * `__filename` is never substituted, a local `__filename` declaration + * is already safe and does NOT need to trigger opt-out. + * + * The opt-out check is top-level only: `@cnpmjs/packument` uses + * `const __dirname = new URL('.', import.meta.url).pathname` at module + * top-level, which is what we need to skip. Restricting to top-level + * (no leading whitespace) means nested-scope declarations — e.g. + * koa-onerror's function-body ` const __filename = fileURLToPath(...)` + * — don't accidentally opt out the whole file and lose the critical + * `__dirname` substitution in their CJS-compat branch. + */ +export function applyEsmPolyfill(contents: string, absPath: string): string | null { + const isESM = contents.includes('import.meta.') || /^(?:import|export)\s/m.test(contents); + if (!isESM) return null; + + // Skip files that declare __dirname at top level — substituting the + // LHS of the declaration would produce `const "/path" = ...`. Only + // __dirname is substituted below, so only __dirname declarations + // need to opt out; __filename is left untouched unconditionally. + if (/^(?:const|let|var)\s+__dirname\b/m.test(contents)) { + return null; + } + + const dirname = path.dirname(absPath); + const url = pathToFileURL(absPath).href; + + const modified = contents + .replace(/\bimport\.meta\.dirname\b/g, JSON.stringify(dirname)) + .replace(/\bimport\.meta\.url\b/g, JSON.stringify(url)) + .replace(/\bimport\.meta\.filename\b/g, JSON.stringify(absPath)) + .replace(/\b__dirname\b/g, JSON.stringify(dirname)); + + return modified === contents ? null : modified; +} + +/** + * esbuild plugin: polyfill ESM-only constructs (`import.meta.*`, + * `__dirname`) for CJS bundle output. See {@link applyEsmPolyfill}. + */ +export function esmPolyfillPlugin(): EsbuildPlugin { + return { + name: 'esm-polyfill', + setup(build) { + build.onLoad({ filter: /\.(ts|js|mjs|cjs)$/ }, async (args) => { + const contents = await fs.readFile(args.path, 'utf8'); + const modified = applyEsmPolyfill(contents, args.path); + if (modified === null) return null; + + const ext = path.extname(args.path); + const loader = ext === '.ts' ? ('ts' as const) : ('js' as const); + return { contents: modified, loader }; + }); + }, + }; +} diff --git a/tools/scripts/src/file-url-resolver.ts b/tools/scripts/src/file-url-resolver.ts new file mode 100644 index 0000000000..358d25e5b1 --- /dev/null +++ b/tools/scripts/src/file-url-resolver.ts @@ -0,0 +1,22 @@ +import { fileURLToPath } from 'node:url'; + +import type { Plugin as EsbuildPlugin } from 'esbuild'; + +/** + * esbuild plugin: resolve `file://` URL imports to file paths. + * + * A generated bundle entry may emit `import * as mod from "file:///abs/path.ts"` + * to disambiguate absolute paths. esbuild's default resolver does not + * understand the `file://` protocol, so this plugin converts such + * specifiers to absolute filesystem paths before esbuild resolves them. + */ +export function fileUrlResolverPlugin(): EsbuildPlugin { + return { + name: 'file-url-resolver', + setup(build) { + build.onResolve({ filter: /^file:\/\// }, (args) => ({ + path: fileURLToPath(args.path), + })); + }, + }; +} diff --git a/tools/scripts/src/helper.ts b/tools/scripts/src/helper.ts index 95aef10d50..55ccad44b3 100644 --- a/tools/scripts/src/helper.ts +++ b/tools/scripts/src/helper.ts @@ -21,7 +21,7 @@ export async function findNodeProcess(filterFn?: FilterFunction): Promise((arr, line) => { - if (!!line && !line.includes('/bin/sh') && line.includes('node')) { + if (!!line && !line.includes('/bin/sh') && (line.includes('node') || line.includes('egg-server'))) { const m = line.match(REGEX); if (m) { const item: NodeProcess = isWindows ? { pid: parseInt(m[2]), cmd: m[1] } : { pid: parseInt(m[1]), cmd: m[2] }; diff --git a/tools/scripts/src/resolve-framework-entry.ts b/tools/scripts/src/resolve-framework-entry.ts new file mode 100644 index 0000000000..291bd69abb --- /dev/null +++ b/tools/scripts/src/resolve-framework-entry.ts @@ -0,0 +1,62 @@ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +/** + * Resolve the framework's main entry file from its package.json. + * + * Supports multiple layouts: + * - Worktree/monorepo: `src/index.ts` (preferred when present) + * - Installed CJS: `dist/index.js` (via `main`) + * - Installed ESM/dual: resolved via `exports['.']` (string or + * conditional, recursive pick of `import > node > default > require`) + * + * Returns the entry path plus a list of registry keys — the package + * directory, the resolved entry file, and the entry's containing + * directory — so runtime lookups by any of those paths resolve to the + * same framework module. + */ +export function resolveFrameworkEntry(frameworkPath: string): { entryPath: string; registryKeys: string[] } { + const keys: string[] = [frameworkPath]; + + // Worktree/monorepo layout: prefer .ts source directly + const srcEntry = path.join(frameworkPath, 'src/index.ts'); + if (existsSync(srcEntry)) { + keys.push(srcEntry, path.join(frameworkPath, 'src')); + return { entryPath: srcEntry, registryKeys: keys }; + } + + // Installed layout: read package.json + const pkgPath = path.join(frameworkPath, 'package.json'); + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); + + let relEntry: string | undefined; + const dotExport = pkg.exports?.['.']; + if (typeof dotExport === 'string') { + relEntry = dotExport; + } else if (dotExport && typeof dotExport === 'object') { + // conditional exports — try common conditions in priority order + const pick = (v: unknown): string | undefined => { + if (typeof v === 'string') return v; + if (v && typeof v === 'object') { + const o = v as Record; + return pick(o.import) ?? pick(o.node) ?? pick(o.default) ?? pick(o.require); + } + return undefined; + }; + relEntry = pick(dotExport); + } + relEntry ??= pkg.main ?? 'index.js'; + + const entryPath = path.resolve(frameworkPath, relEntry as string); + if (!existsSync(entryPath)) { + throw new Error( + `Framework entry not found: ${entryPath} (resolved from ${pkgPath}). ` + + 'Ensure the package is built and has a valid "main" or "exports" field.', + ); + } + keys.push(entryPath); + const entryDir = path.dirname(entryPath); + if (entryDir !== frameworkPath) keys.push(entryDir); + + return { entryPath, registryKeys: keys }; +} diff --git a/tools/scripts/test/esm-polyfill.test.ts b/tools/scripts/test/esm-polyfill.test.ts new file mode 100644 index 0000000000..4a3216aad7 --- /dev/null +++ b/tools/scripts/test/esm-polyfill.test.ts @@ -0,0 +1,183 @@ +import { strict as assert } from 'node:assert'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { describe, it } from 'vitest'; + +import { applyEsmPolyfill } from '../src/esm-polyfill.ts'; + +describe('test/esm-polyfill.test.ts', () => { + const absPath = path.resolve('/tmp/snapshot-fixtures/pkg/src/index.ts'); + const dirname = path.dirname(absPath); + const url = pathToFileURL(absPath).href; + + describe('ESM detection', () => { + it('detects top-level import', () => { + const src = "import fs from 'node:fs';\nconst x = __dirname;"; + const out = applyEsmPolyfill(src, absPath); + assert.equal(out, `import fs from 'node:fs';\nconst x = ${JSON.stringify(dirname)};`); + }); + + it('detects top-level export', () => { + const src = 'export const foo = __dirname;'; + const out = applyEsmPolyfill(src, absPath); + assert.equal(out, `export const foo = ${JSON.stringify(dirname)};`); + }); + + it('detects import.meta.*', () => { + const src = 'const u = import.meta.url;\nconst d = __dirname;'; + const out = applyEsmPolyfill(src, absPath); + assert.equal(out, `const u = ${JSON.stringify(url)};\nconst d = ${JSON.stringify(dirname)};`); + }); + + it('leaves CJS sources untouched', () => { + const src = "const fs = require('node:fs');\nmodule.exports = { dir: __dirname };"; + assert.equal(applyEsmPolyfill(src, absPath), null); + }); + + it('returns null when ESM file has no substitutable tokens', () => { + const src = "import fs from 'node:fs';\nconsole.log(fs.readFileSync);"; + assert.equal(applyEsmPolyfill(src, absPath), null); + }); + }); + + describe('__dirname patterns', () => { + it('aws-sdk: typeof __dirname !== "undefined" ternary', () => { + const src = "export const base = typeof __dirname !== 'undefined' ? __dirname : void 0;"; + const out = applyEsmPolyfill(src, absPath); + assert.equal( + out, + `export const base = typeof ${JSON.stringify(dirname)} !== 'undefined' ? ${JSON.stringify(dirname)} : void 0;`, + ); + }); + + it('plain path.join(__dirname, ...)', () => { + const src = "import path from 'node:path';\nexport const p = path.join(__dirname, 'templates');"; + const out = applyEsmPolyfill(src, absPath); + assert.equal( + out, + `import path from 'node:path';\nexport const p = path.join(${JSON.stringify(dirname)}, 'templates');`, + ); + }); + + it('does not match __dirname as substring (word boundary)', () => { + const src = 'export const my__dirname_alias = 1;\nexport const x = __dirname;'; + const out = applyEsmPolyfill(src, absPath); + assert.ok(out); + assert.ok(out.includes('my__dirname_alias')); + assert.ok(out.includes(`export const x = ${JSON.stringify(dirname)};`)); + }); + }); + + describe('local declaration opt-out (top-level __dirname only)', () => { + it('@cnpmjs/packument: top-level const __dirname = new URL(...) → opt out', () => { + const src = [ + "import { createRequire } from 'node:module';", + 'const require = createRequire(import.meta.url);', + "const __dirname = new URL('.', import.meta.url).pathname;", + "export const root = __dirname + '/assets';", + ].join('\n'); + // File declares __dirname at top level — polyfill must NOT rewrite + // anything, otherwise the LHS becomes a string literal (syntax error). + assert.equal(applyEsmPolyfill(src, absPath), null); + }); + + it('top-level let __dirname → opt out', () => { + const src = "let __dirname = '/foo';\nexport const x = __dirname;"; + assert.equal(applyEsmPolyfill(src, absPath), null); + }); + + it('koa-onerror: nested const __filename inside function body → keep substituting __dirname', () => { + const src = [ + "import path from 'node:path';", + "import { fileURLToPath } from 'node:url';", + 'function getSourceDirname() {', + " if (typeof __dirname === 'string') {", + ' return __dirname;', + ' }', + ' const __filename = fileURLToPath(import.meta.url);', + ' return path.dirname(__filename);', + '}', + ].join('\n'); + const out = applyEsmPolyfill(src, absPath); + assert.ok(out, 'expected file to be polyfilled, not opted out'); + // `__dirname` in the typeof guard AND the return are both substituted + // with the baked source dir. + assert.ok(out.includes(`typeof ${JSON.stringify(dirname)} === 'string'`)); + assert.ok(out.includes(`return ${JSON.stringify(dirname)};`)); + // Nested `const __filename = fileURLToPath(...)` declaration is intact: + // the polyfill never substitutes __filename, so the LHS is preserved. + assert.ok(out.includes('const __filename = fileURLToPath(')); + // `import.meta.url` got baked. + assert.ok(out.includes(JSON.stringify(url))); + }); + + it('nested-scope const __dirname inside function → opt out does NOT trigger (rare, top-level only)', () => { + // Indented `const __dirname = ...` inside a function body: the + // regex is anchored to line-starting const/let/var (no leading + // whitespace), so this does NOT trigger opt-out. The polyfill + // then runs and substitutes both the typeof check and the LHS + // of the nested declaration — which would be a syntax error at + // bundle time. This test documents the rare-case behavior; we + // intentionally prefer the koa-onerror-safe top-level-only rule + // because nested `const __dirname = ...` is genuinely uncommon. + const src = [ + 'function getDirname() {', + " const __dirname = '/foo';", + ' return __dirname;', + '}', + 'export { getDirname };', + ].join('\n'); + const out = applyEsmPolyfill(src, absPath); + assert.ok(out, 'expected file to be polyfilled, not opted out'); + // Both occurrences (including the declaration LHS) are substituted. + // The resulting file would be a syntax error if bundled, but that + // only matters for files with this rare nested pattern. + assert.ok(out.includes(`const ${JSON.stringify(dirname)} = '/foo';`)); + assert.ok(out.includes(`return ${JSON.stringify(dirname)};`)); + }); + + it('local `var __filename` does NOT trigger opt-out (only __dirname matters)', () => { + // __filename is never substituted, so a local __filename declaration + // is already safe and must not cause opt-out. + const src = [ + "import { fileURLToPath } from 'node:url';", + 'var __filename = fileURLToPath(import.meta.url);', + "export const x = __dirname + '/' + __filename;", + ].join('\n'); + const out = applyEsmPolyfill(src, absPath); + assert.ok(out); + // __dirname substituted, __filename preserved + assert.ok(out.includes(`${JSON.stringify(dirname)} + '/' + __filename`)); + assert.ok(out.includes('var __filename = fileURLToPath(')); + // import.meta.url also substituted + assert.ok(out.includes(JSON.stringify(url))); + }); + + it('does not opt out on comments or unrelated const declarations', () => { + const src = "// use __dirname\nconst x = 1;\nexport const y = path.join(__dirname, 'foo');"; + const out = applyEsmPolyfill(src, absPath); + assert.ok(out); + assert.ok(out.includes(`path.join(${JSON.stringify(dirname)}, 'foo')`)); + }); + }); + + describe('import.meta.* replacements', () => { + it('replaces import.meta.dirname, url, filename', () => { + const src = [ + 'export const d = import.meta.dirname;', + 'export const u = import.meta.url;', + 'export const f = import.meta.filename;', + ].join('\n'); + const out = applyEsmPolyfill(src, absPath); + assert.equal( + out, + [ + `export const d = ${JSON.stringify(dirname)};`, + `export const u = ${JSON.stringify(url)};`, + `export const f = ${JSON.stringify(absPath)};`, + ].join('\n'), + ); + }); + }); +}); diff --git a/tools/scripts/test/resolve-framework-entry.test.ts b/tools/scripts/test/resolve-framework-entry.test.ts new file mode 100644 index 0000000000..ddd153b8cd --- /dev/null +++ b/tools/scripts/test/resolve-framework-entry.test.ts @@ -0,0 +1,123 @@ +import { strict as assert } from 'node:assert'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { describe, it, beforeEach, afterEach } from 'vitest'; + +import { resolveFrameworkEntry } from '../src/resolve-framework-entry.ts'; + +describe('test/resolve-framework-entry.test.ts', () => { + let tmpRoot: string; + + beforeEach(async () => { + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'egg-resolve-fw-')); + }); + afterEach(async () => { + await fs.rm(tmpRoot, { force: true, recursive: true }); + }); + + async function writePkg(dir: string, pkg: Record) { + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, 'package.json'), JSON.stringify(pkg, null, 2)); + } + + async function touch(file: string) { + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, ''); + } + + it('prefers src/index.ts in worktree layout', async () => { + const fw = path.join(tmpRoot, 'worktree-egg'); + await writePkg(fw, { name: 'egg', main: 'dist/index.js' }); + await touch(path.join(fw, 'src/index.ts')); + await touch(path.join(fw, 'dist/index.js')); + + const r = resolveFrameworkEntry(fw); + assert.equal(r.entryPath, path.join(fw, 'src/index.ts')); + assert.ok(r.registryKeys.includes(fw)); + assert.ok(r.registryKeys.includes(path.join(fw, 'src/index.ts'))); + assert.ok(r.registryKeys.includes(path.join(fw, 'src'))); + }); + + it('resolves main field in installed CJS layout', async () => { + const fw = path.join(tmpRoot, 'installed-cjs-egg'); + await writePkg(fw, { name: 'egg', main: 'dist/index.js' }); + await touch(path.join(fw, 'dist/index.js')); + + const r = resolveFrameworkEntry(fw); + assert.equal(r.entryPath, path.join(fw, 'dist/index.js')); + assert.ok(r.registryKeys.includes(fw)); + assert.ok(r.registryKeys.includes(path.join(fw, 'dist/index.js'))); + assert.ok(r.registryKeys.includes(path.join(fw, 'dist'))); + }); + + it('resolves string exports["."]', async () => { + const fw = path.join(tmpRoot, 'exports-string-egg'); + await writePkg(fw, { + name: 'egg', + main: 'dist/index.js', + exports: { '.': './dist/esm/index.js' }, + }); + await touch(path.join(fw, 'dist/esm/index.js')); + + const r = resolveFrameworkEntry(fw); + assert.equal(r.entryPath, path.join(fw, 'dist/esm/index.js')); + }); + + it('resolves conditional exports (import > node > default > require)', async () => { + const fw = path.join(tmpRoot, 'exports-conditional-egg'); + await writePkg(fw, { + name: 'egg', + main: 'dist/index.js', + exports: { + '.': { + import: './dist/esm/index.js', + require: './dist/cjs/index.js', + default: './dist/index.js', + }, + }, + }); + await touch(path.join(fw, 'dist/esm/index.js')); + + const r = resolveFrameworkEntry(fw); + assert.equal(r.entryPath, path.join(fw, 'dist/esm/index.js')); + }); + + it('resolves nested conditional exports', async () => { + const fw = path.join(tmpRoot, 'exports-nested-egg'); + await writePkg(fw, { + name: 'egg', + exports: { + '.': { + node: { + import: './dist/esm/index.js', + require: './dist/cjs/index.js', + }, + default: './dist/index.js', + }, + }, + }); + await touch(path.join(fw, 'dist/esm/index.js')); + + const r = resolveFrameworkEntry(fw); + assert.equal(r.entryPath, path.join(fw, 'dist/esm/index.js')); + }); + + it('throws when resolved entry does not exist', async () => { + const fw = path.join(tmpRoot, 'broken-egg'); + await writePkg(fw, { name: 'egg', main: 'dist/index.js' }); + // dist/index.js intentionally absent + + assert.throws(() => resolveFrameworkEntry(fw), /Framework entry not found/); + }); + + it('falls back to index.js when no main/exports', async () => { + const fw = path.join(tmpRoot, 'no-main-egg'); + await writePkg(fw, { name: 'egg' }); + await touch(path.join(fw, 'index.js')); + + const r = resolveFrameworkEntry(fw); + assert.equal(r.entryPath, path.join(fw, 'index.js')); + }); +}); diff --git a/tools/scripts/test/utils.ts b/tools/scripts/test/utils.ts index 79ffaf6867..1bc2011e83 100644 --- a/tools/scripts/test/utils.ts +++ b/tools/scripts/test/utils.ts @@ -27,6 +27,8 @@ export async function cleanup(baseDir: string) { let type = 'unknown: ' + cmd; if (cmd.includes('start-cluster')) { type = 'master'; + } else if (cmd.includes('start-single')) { + type = 'single'; } else if (cmd.includes('app_worker.js')) { type = 'worker'; } else if (cmd.includes('agent_worker.js')) { @@ -34,7 +36,7 @@ export async function cleanup(baseDir: string) { } try { - process.kill(pid, type === 'master' ? '' : 'SIGKILL'); + process.kill(pid, type === 'master' || type === 'single' ? '' : 'SIGKILL'); console.log(`cleanup ${type} ${pid}`); } catch (err: any) { console.log(`cleanup ${type} ${pid} got error ${err.code || err.message || err}`); diff --git a/tools/scripts/tsdown.config.ts b/tools/scripts/tsdown.config.ts new file mode 100644 index 0000000000..a27dfaec91 --- /dev/null +++ b/tools/scripts/tsdown.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: 'src/**/*.ts', + unbundle: true, + external: [/^@eggjs\//, 'egg'], + unused: { + level: 'error', + // tsx is used at runtime via --import=tsx/esm in spawned processes, not directly imported + ignore: ['tsx', 'runscript'], + }, +});