From 0ff1833786ba63fb7a0b40ebbf0226263ca2d855 Mon Sep 17 00:00:00 2001 From: killa Date: Mon, 4 May 2026 03:17:42 +0800 Subject: [PATCH 01/11] fix(bundler): externalize native optional platform packages --- tools/egg-bundler/docs/output-structure.md | 16 +- .../egg-bundler/src/lib/ExternalsResolver.ts | 143 +++++++++++++++--- .../test/ExternalsResolver.test.ts | 131 ++++++++++++++++ .../@cnpmjs/packument/package.json | 18 +++ .../package.json | 10 ++ .../fixtures/externals/basic-app/package.json | 2 + wiki/packages/egg-bundler.md | 14 +- 7 files changed, 306 insertions(+), 28 deletions(-) create mode 100644 tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/@cnpmjs/packument/package.json create mode 100644 tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/missing-native-optional-wrapper/package.json diff --git a/tools/egg-bundler/docs/output-structure.md b/tools/egg-bundler/docs/output-structure.md index 9670d81f1c..60a6a76aa2 100644 --- a/tools/egg-bundler/docs/output-structure.md +++ b/tools/egg-bundler/docs/output-structure.md @@ -67,17 +67,21 @@ This includes the user's `externals.force` list plus auto-detected entries from root `peerDependencies`, root `optionalDependencies`, root dependency packages with native addons/native binaries, root dependency packages whose optional peer dependencies cannot be resolved, and the names of those missing optional peer -packages. The native addon and missing optional peer checks run only while -resolving the app's root dependencies/optionalDependencies; `ExternalsResolver` -does not recursively scan every transitive dependency. `externals.inline` +packages as `extraExternals`, plus native optional platform packages declared by +root dependencies. +The native addon and missing optional peer checks run only while resolving the +app's root dependencies/optionalDependencies; `ExternalsResolver` does not +recursively scan every transitive dependency. `externals.inline` removes an auto-detected external unless the same name is also present in `externals.force`. External packages must be installed alongside the bundle — typically by copying the app's `package.json` next to `worker.js` and running `npm ci --omit=dev`, or by deploying into an environment where these dependencies are already installed. ESM-only packages, `egg`, `@swc/helpers`, and `@eggjs/*` -packages are bundled by default unless `ExternalsResolver` externalizes them -through `externals.force`, dependency metadata, native addon detection, or -missing optional peer detection. +packages are bundled by default unless `externals.force` or dependency metadata +explicitly marks them external. For wrappers around native optional platform +packages that cannot be loaded through `createRequire`, the wrapper stays +bundled so the CommonJS standalone output does not emit a plain +`require(wrapper)`, while the optional native platform packages remain external. ## Known limitations diff --git a/tools/egg-bundler/src/lib/ExternalsResolver.ts b/tools/egg-bundler/src/lib/ExternalsResolver.ts index 3a71fb61ee..74efa8e242 100644 --- a/tools/egg-bundler/src/lib/ExternalsResolver.ts +++ b/tools/egg-bundler/src/lib/ExternalsResolver.ts @@ -12,16 +12,46 @@ export type ExternalsConfig = Record; interface PackageJson { readonly name?: string; readonly type?: string; + readonly main?: string; readonly dependencies?: Record; readonly optionalDependencies?: Record; readonly peerDependencies?: Record; readonly peerDependenciesMeta?: Record; readonly scripts?: Record; readonly exports?: unknown; + readonly napi?: unknown; } // install-time hooks using one of these tools strongly imply a native addon const NATIVE_SCRIPT_PATTERN = /node-gyp|prebuild-install|napi-rs|node-pre-gyp|electron-rebuild/i; +const NATIVE_OPTIONAL_PLATFORM_TOKENS = new Set([ + 'android', + 'darwin', + 'freebsd', + 'gnu', + 'linux', + 'msvc', + 'musl', + 'openharmony', + 'win32', +]); +const NATIVE_OPTIONAL_ARCH_TOKENS = new Set([ + 'aarch64', + 'arm', + 'arm64', + 'ia32', + 'loong64', + 'ppc64', + 'riscv64', + 's390x', + 'wasm32', + 'x64', +]); + +interface ExternalizeDecision { + readonly externalizePackage: boolean; + readonly extraExternals: readonly string[]; +} export class ExternalsResolver { readonly #baseDir: string; @@ -51,10 +81,13 @@ export class ExternalsResolver { } for (const name of deps) { - if (this.#inline.has(name) && !this.#force.has(name)) continue; + const inlinePackage = this.#inline.has(name) && !this.#force.has(name); await this.#addMissingOptionalPeerExternals(name, result); - if (result[name]) continue; - if (await this.#shouldExternalize(name, optionalDeps, peerDeps)) { + const decision = await this.#getExternalizeDecision(name, optionalDeps, peerDeps); + for (const extraName of decision.extraExternals) { + if (!result[extraName]) result[extraName] = extraName; + } + if (!inlinePackage && decision.externalizePackage) { result[name] = name; } } @@ -82,21 +115,28 @@ export class ExternalsResolver { } } - async #shouldExternalize( + async #getExternalizeDecision( name: string, optionalDeps: ReadonlySet, peerDeps: ReadonlySet, - ): Promise { - if (optionalDeps.has(name)) return true; - if (peerDeps.has(name)) return true; + ): Promise { + let externalizePackage = optionalDeps.has(name) || peerDeps.has(name); const pkgDir = await this.#findPackageDir(name); - if (!pkgDir) return false; + if (!pkgDir) return { externalizePackage, extraExternals: [] }; const pkg = await this.#readPackageJson(pkgDir); - if (await this.#hasMissingOptionalPeerDependencies(pkgDir, pkg)) return true; - if (await this.#hasNativeBinary(pkgDir, pkg)) return true; - if (await this.#hasNativeOptionalDependency(pkgDir, pkg)) return true; - return false; + if (await this.#hasMissingOptionalPeerDependencies(pkgDir, pkg)) externalizePackage = true; + if (await this.#hasNativeBinary(pkgDir, pkg)) externalizePackage = true; + + const nativeOptionalDeps = await this.#getNativeOptionalDependencies(pkgDir, pkg); + const extraExternals = nativeOptionalDeps.filter( + (depName) => !this.#inline.has(depName) || this.#force.has(depName), + ); + if (nativeOptionalDeps.length > 0 && this.#canLoadPackageThroughCreateRequire(pkg)) { + externalizePackage = true; + } + + return { externalizePackage, extraExternals }; } async #hasMissingOptionalPeerDependencies(pkgDir: string, pkg: PackageJson): Promise { @@ -109,15 +149,23 @@ export class ExternalsResolver { return false; } - async #hasNativeOptionalDependency(pkgDir: string, pkg: PackageJson): Promise { + async #getNativeOptionalDependencies(pkgDir: string, pkg: PackageJson): Promise { const optionalDependencies = pkg.optionalDependencies ?? {}; + const result: string[] = []; for (const depName of Object.keys(optionalDependencies)) { const depDir = await this.#findPackageDir(depName, pkgDir); - if (!depDir) continue; - const depPkg = await this.#readPackageJson(depDir); - if (await this.#hasNativeBinary(depDir, depPkg)) return true; + if (depDir) { + const depPkg = await this.#readPackageJson(depDir); + if (await this.#hasNativeBinary(depDir, depPkg)) { + result.push(depName); + continue; + } + } + if (this.#isLikelyNativeOptionalDependency(pkg, depName)) { + result.push(depName); + } } - return false; + return result; } async #findPackageDir(name: string, fromDir = this.#baseDir): Promise { @@ -171,6 +219,67 @@ export class ExternalsResolver { return false; } + #canLoadPackageThroughCreateRequire(pkg: PackageJson): boolean { + const exports = pkg.exports; + if (exports !== undefined) { + return this.#exportsTargetCanBeRequired(exports, pkg); + } + + const main = pkg.main; + if (typeof main === 'string') { + return this.#packageTargetCanBeRequired(main, pkg); + } + + return pkg.type !== 'module'; + } + + #exportsTargetCanBeRequired(target: unknown, pkg: PackageJson): boolean { + if (typeof target === 'string') return this.#packageTargetCanBeRequired(target, pkg); + if (Array.isArray(target)) return target.some((item) => this.#exportsTargetCanBeRequired(item, pkg)); + if (!this.#isRecord(target)) return false; + + const keys = Object.keys(target); + if (keys.some((key) => key.startsWith('.'))) { + if (!Object.hasOwn(target, '.')) return false; + return this.#exportsTargetCanBeRequired(target['.'], pkg); + } + if (Object.hasOwn(target, 'require')) { + if (this.#exportsTargetCanBeRequired(target.require, pkg)) return true; + } + + for (const condition of ['node', 'node-addons', 'default'] as const) { + if (this.#exportsTargetCanBeRequired(target[condition], pkg)) return true; + } + + return false; + } + + #packageTargetCanBeRequired(target: string, pkg: PackageJson): boolean { + if (/\.(?:cjs|json|node)$/i.test(target)) return true; + if (/\.mjs$/i.test(target)) return false; + return pkg.type !== 'module'; + } + + #isLikelyNativeOptionalDependency(pkg: PackageJson, depName: string): boolean { + const depBaseName = this.#getPackageBaseName(depName); + const parentBaseName = pkg.name ? this.#getPackageBaseName(pkg.name) : undefined; + const tokens = depBaseName.toLowerCase().split(/[-_]/g); + const hasPlatformToken = tokens.some((token) => NATIVE_OPTIONAL_PLATFORM_TOKENS.has(token)); + const hasArchToken = tokens.some((token) => NATIVE_OPTIONAL_ARCH_TOKENS.has(token)); + if (!hasPlatformToken || !hasArchToken) return false; + if (pkg.napi !== undefined) return true; + return parentBaseName ? depBaseName === parentBaseName || depBaseName.startsWith(`${parentBaseName}-`) : false; + } + + #getPackageBaseName(name: string): string { + const slashIndex = name.lastIndexOf('/'); + return slashIndex === -1 ? name : name.slice(slashIndex + 1); + } + + #isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); + } + async #readPackageJson(dir: string): Promise { const cached = this.#packageJsonCache.get(dir); if (cached) return cached; diff --git a/tools/egg-bundler/test/ExternalsResolver.test.ts b/tools/egg-bundler/test/ExternalsResolver.test.ts index 2b2ddb5cc6..aafb87100b 100644 --- a/tools/egg-bundler/test/ExternalsResolver.test.ts +++ b/tools/egg-bundler/test/ExternalsResolver.test.ts @@ -10,6 +10,11 @@ import { ExternalsResolver } from '../src/lib/ExternalsResolver.ts'; const fixtureBase = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures/externals'); const basicApp = path.join(fixtureBase, 'basic-app'); +async function writePackageJson(dir: string, pkg: Record): Promise { + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, 'package.json'), JSON.stringify(pkg)); +} + describe('ExternalsResolver', () => { describe('tier 1: native binary detection', () => { it('externalizes a package whose install script invokes node-gyp', async () => { @@ -35,6 +40,45 @@ describe('ExternalsResolver', () => { it('externalizes a wrapper package whose installed optional dependency is native', async () => { const result = await new ExternalsResolver({ baseDir: basicApp }).resolve(); expect(result['native-optional-wrapper']).toBe('native-optional-wrapper'); + expect(result['native-optional-platform']).toBe('native-optional-platform'); + }); + + it('externalizes a CJS wrapper and its missing optional native platform package', async () => { + const result = await new ExternalsResolver({ baseDir: basicApp }).resolve(); + expect(result['missing-native-optional-wrapper']).toBe('missing-native-optional-wrapper'); + expect(result['missing-native-optional-wrapper-linux-x64-gnu']).toBe( + 'missing-native-optional-wrapper-linux-x64-gnu', + ); + }); + + it('recognizes native optional package names with ia32 and aarch64 arch tokens', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'egg-bundler-externals-')); + try { + await writePackageJson(tempDir, { + name: 'native-arch-app', + version: '1.0.0', + private: true, + dependencies: { + 'cross-native-wrapper': '1.0.0', + }, + }); + await writePackageJson(path.join(tempDir, 'node_modules/cross-native-wrapper'), { + name: 'cross-native-wrapper', + version: '1.0.0', + main: './index.cjs', + optionalDependencies: { + 'cross-native-wrapper-linux-aarch64-gnu': '1.0.0', + 'cross-native-wrapper-win32-ia32-msvc': '1.0.0', + }, + }); + + const result = await new ExternalsResolver({ baseDir: tempDir }).resolve(); + expect(result['cross-native-wrapper']).toBe('cross-native-wrapper'); + expect(result['cross-native-wrapper-linux-aarch64-gnu']).toBe('cross-native-wrapper-linux-aarch64-gnu'); + expect(result['cross-native-wrapper-win32-ia32-msvc']).toBe('cross-native-wrapper-win32-ia32-msvc'); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } }); }); @@ -53,6 +97,83 @@ describe('ExternalsResolver', () => { const result = await new ExternalsResolver({ baseDir: basicApp }).resolve(); expect(result['esm-dual']).toBeUndefined(); }); + + it('keeps an import-only native optional wrapper bundled but externalizes its platform packages', async () => { + const result = await new ExternalsResolver({ baseDir: basicApp }).resolve(); + expect(result['@cnpmjs/packument']).toBeUndefined(); + expect(result['@cnpmjs/packument-linux-x64-gnu']).toBe('@cnpmjs/packument-linux-x64-gnu'); + expect(result['@cnpmjs/packument-darwin-x64']).toBe('@cnpmjs/packument-darwin-x64'); + }); + + it('keeps a native optional wrapper bundled when its require export target is not require-able', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'egg-bundler-externals-')); + try { + await writePackageJson(tempDir, { + name: 'esm-require-target-app', + version: '1.0.0', + private: true, + dependencies: { + 'esm-require-target-wrapper': '1.0.0', + }, + }); + await writePackageJson(path.join(tempDir, 'node_modules/esm-require-target-wrapper'), { + name: 'esm-require-target-wrapper', + version: '1.0.0', + type: 'module', + exports: { + '.': { + import: './index.js', + require: './index.js', + }, + }, + optionalDependencies: { + 'esm-require-target-wrapper-linux-x64-gnu': '1.0.0', + }, + }); + + const result = await new ExternalsResolver({ baseDir: tempDir }).resolve(); + expect(result['esm-require-target-wrapper']).toBeUndefined(); + expect(result['esm-require-target-wrapper-linux-x64-gnu']).toBe('esm-require-target-wrapper-linux-x64-gnu'); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it('externalizes a native optional wrapper with a nested require export target', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'egg-bundler-externals-')); + try { + await writePackageJson(tempDir, { + name: 'nested-require-app', + version: '1.0.0', + private: true, + dependencies: { + 'nested-require-wrapper': '1.0.0', + }, + }); + await writePackageJson(path.join(tempDir, 'node_modules/nested-require-wrapper'), { + name: 'nested-require-wrapper', + version: '1.0.0', + type: 'module', + exports: { + '.': { + node: { + import: './index.js', + require: './index.cjs', + }, + }, + }, + optionalDependencies: { + 'nested-require-wrapper-linux-x64-gnu': '1.0.0', + }, + }); + + const result = await new ExternalsResolver({ baseDir: tempDir }).resolve(); + expect(result['nested-require-wrapper']).toBe('nested-require-wrapper'); + expect(result['nested-require-wrapper-linux-x64-gnu']).toBe('nested-require-wrapper-linux-x64-gnu'); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); }); describe('tier 3: dependency metadata', () => { @@ -195,6 +316,16 @@ describe('ExternalsResolver', () => { expect(result['native-scripts']).toBeUndefined(); }); + it('inline keeps a native optional wrapper bundled without inlining its platform packages', async () => { + const result = await new ExternalsResolver({ + baseDir: basicApp, + inline: ['@cnpmjs/packument'], + }).resolve(); + expect(result['@cnpmjs/packument']).toBeUndefined(); + expect(result['@cnpmjs/packument-linux-x64-gnu']).toBe('@cnpmjs/packument-linux-x64-gnu'); + expect(result['@cnpmjs/packument-darwin-x64']).toBe('@cnpmjs/packument-darwin-x64'); + }); + it('force wins over inline when both reference the same package', async () => { const result = await new ExternalsResolver({ baseDir: basicApp, diff --git a/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/@cnpmjs/packument/package.json b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/@cnpmjs/packument/package.json new file mode 100644 index 0000000000..b10dd6f938 --- /dev/null +++ b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/@cnpmjs/packument/package.json @@ -0,0 +1,18 @@ +{ + "name": "@cnpmjs/packument", + "version": "1.7.0", + "type": "module", + "exports": { + ".": { + "import": "./js/index.js", + "types": "./js/index.d.ts" + } + }, + "napi": { + "binaryName": "packument" + }, + "optionalDependencies": { + "@cnpmjs/packument-darwin-x64": "1.7.0", + "@cnpmjs/packument-linux-x64-gnu": "1.7.0" + } +} diff --git a/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/missing-native-optional-wrapper/package.json b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/missing-native-optional-wrapper/package.json new file mode 100644 index 0000000000..16f3afb150 --- /dev/null +++ b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/missing-native-optional-wrapper/package.json @@ -0,0 +1,10 @@ +{ + "name": "missing-native-optional-wrapper", + "version": "1.0.0", + "napi": { + "binaryName": "missing-native-optional-wrapper" + }, + "optionalDependencies": { + "missing-native-optional-wrapper-linux-x64-gnu": "1.0.0" + } +} diff --git a/tools/egg-bundler/test/fixtures/externals/basic-app/package.json b/tools/egg-bundler/test/fixtures/externals/basic-app/package.json index 88ffffa893..697100e99f 100644 --- a/tools/egg-bundler/test/fixtures/externals/basic-app/package.json +++ b/tools/egg-bundler/test/fixtures/externals/basic-app/package.json @@ -3,12 +3,14 @@ "version": "1.0.0", "private": true, "dependencies": { + "@cnpmjs/packument": "1.7.0", "@eggjs/some-plugin": "1.0.0", "@swc/helpers": "1.0.0", "egg": "1.0.0", "esm-dual": "1.0.0", "esm-only": "1.0.0", "esm-string-export": "1.0.0", + "missing-native-optional-wrapper": "1.0.0", "missing-pkg": "1.0.0", "native-binding": "1.0.0", "native-dotnode": "1.0.0", diff --git a/wiki/packages/egg-bundler.md b/wiki/packages/egg-bundler.md index 1ecb8ccd97..9c7f05c20d 100644 --- a/wiki/packages/egg-bundler.md +++ b/wiki/packages/egg-bundler.md @@ -53,11 +53,15 @@ CommonJS artifact from an Egg application. - Explicit `externals.force` entries are external, and `ExternalsResolver` auto-detects root `peerDependencies`, root `optionalDependencies`, root dependency packages with native addons, root dependency packages whose optional - peer dependencies cannot be resolved, and the names of those missing optional - peer packages as external. + peer dependencies cannot be resolved, the missing optional peer package names + themselves as `extraExternals`, and native optional platform packages as + external. - `externals.inline` removes an auto-detected external unless the same package name is also listed in `externals.force`. - ESM-only packages, `egg`, `@swc/helpers`, and `@eggjs/*` packages are bundled - by default unless force-external or dependency/native-addon rules apply. -- `BundlerConfig.tegg` is accepted but not applied by the current implementation - yet. + by default unless force-external or dependency metadata applies. If a wrapper + around native optional platform packages cannot be loaded through + `createRequire`, the wrapper stays bundled and the platform packages are kept + external. +- `BundlerConfig.tegg` is accepted but intentionally not wired into the current + implementation yet. From b115e75f4e873b36a176d85940f1e9a472fb3e70 Mon Sep 17 00:00:00 2001 From: killa Date: Mon, 4 May 2026 04:40:03 +0800 Subject: [PATCH 02/11] fix(bundler): honor conditional export order --- .../egg-bundler/src/lib/ExternalsResolver.ts | 9 +++-- .../test/ExternalsResolver.test.ts | 34 +++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/tools/egg-bundler/src/lib/ExternalsResolver.ts b/tools/egg-bundler/src/lib/ExternalsResolver.ts index 74efa8e242..1f4163cb5e 100644 --- a/tools/egg-bundler/src/lib/ExternalsResolver.ts +++ b/tools/egg-bundler/src/lib/ExternalsResolver.ts @@ -47,6 +47,7 @@ const NATIVE_OPTIONAL_ARCH_TOKENS = new Set([ 'wasm32', 'x64', ]); +const CREATE_REQUIRE_EXPORT_CONDITIONS = new Set(['node-addons', 'node', 'require', 'default']); interface ExternalizeDecision { readonly externalizePackage: boolean; @@ -243,12 +244,10 @@ export class ExternalsResolver { if (!Object.hasOwn(target, '.')) return false; return this.#exportsTargetCanBeRequired(target['.'], pkg); } - if (Object.hasOwn(target, 'require')) { - if (this.#exportsTargetCanBeRequired(target.require, pkg)) return true; - } - for (const condition of ['node', 'node-addons', 'default'] as const) { - if (this.#exportsTargetCanBeRequired(target[condition], pkg)) return true; + for (const condition of keys) { + if (!CREATE_REQUIRE_EXPORT_CONDITIONS.has(condition)) continue; + return this.#exportsTargetCanBeRequired(target[condition], pkg); } return false; diff --git a/tools/egg-bundler/test/ExternalsResolver.test.ts b/tools/egg-bundler/test/ExternalsResolver.test.ts index aafb87100b..59fcccc4ff 100644 --- a/tools/egg-bundler/test/ExternalsResolver.test.ts +++ b/tools/egg-bundler/test/ExternalsResolver.test.ts @@ -174,6 +174,40 @@ describe('ExternalsResolver', () => { await fs.rm(tempDir, { recursive: true, force: true }); } }); + + it('keeps a native optional wrapper bundled when an earlier node condition is not require-able', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'egg-bundler-externals-')); + try { + await writePackageJson(tempDir, { + name: 'ordered-condition-app', + version: '1.0.0', + private: true, + dependencies: { + 'ordered-condition-wrapper': '1.0.0', + }, + }); + await writePackageJson(path.join(tempDir, 'node_modules/ordered-condition-wrapper'), { + name: 'ordered-condition-wrapper', + version: '1.0.0', + type: 'module', + exports: { + '.': { + node: './index.js', + require: './index.cjs', + }, + }, + optionalDependencies: { + 'ordered-condition-wrapper-linux-x64-gnu': '1.0.0', + }, + }); + + const result = await new ExternalsResolver({ baseDir: tempDir }).resolve(); + expect(result['ordered-condition-wrapper']).toBeUndefined(); + expect(result['ordered-condition-wrapper-linux-x64-gnu']).toBe('ordered-condition-wrapper-linux-x64-gnu'); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); }); describe('tier 3: dependency metadata', () => { From 333582cbef4f0e067ba5f7161f7aacf59e1d3514 Mon Sep 17 00:00:00 2001 From: killa Date: Mon, 4 May 2026 04:55:01 +0800 Subject: [PATCH 03/11] docs(bundler): clarify external rules --- tools/egg-bundler/docs/output-structure.md | 12 +++++++----- wiki/packages/egg-bundler.md | 4 ++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/tools/egg-bundler/docs/output-structure.md b/tools/egg-bundler/docs/output-structure.md index 60a6a76aa2..371e7685bc 100644 --- a/tools/egg-bundler/docs/output-structure.md +++ b/tools/egg-bundler/docs/output-structure.md @@ -77,11 +77,13 @@ removes an auto-detected external unless the same name is also present in typically by copying the app's `package.json` next to `worker.js` and running `npm ci --omit=dev`, or by deploying into an environment where these dependencies are already installed. ESM-only packages, `egg`, `@swc/helpers`, and `@eggjs/*` -packages are bundled by default unless `externals.force` or dependency metadata -explicitly marks them external. For wrappers around native optional platform -packages that cannot be loaded through `createRequire`, the wrapper stays -bundled so the CommonJS standalone output does not emit a plain -`require(wrapper)`, while the optional native platform packages remain external. +packages are bundled by default unless an explicit or auto-detected external +rule applies, such as `externals.force`, peer/optional dependency metadata, +native addon detection, or missing optional peer detection. For wrappers around +native optional platform packages that cannot be loaded through `createRequire`, +the wrapper stays bundled so the CommonJS standalone output does not emit a +plain `require(wrapper)`, while the optional native platform packages remain +external. ## Known limitations diff --git a/wiki/packages/egg-bundler.md b/wiki/packages/egg-bundler.md index 9c7f05c20d..55a148867a 100644 --- a/wiki/packages/egg-bundler.md +++ b/wiki/packages/egg-bundler.md @@ -59,8 +59,8 @@ CommonJS artifact from an Egg application. - `externals.inline` removes an auto-detected external unless the same package name is also listed in `externals.force`. - ESM-only packages, `egg`, `@swc/helpers`, and `@eggjs/*` packages are bundled - by default unless force-external or dependency metadata applies. If a wrapper - around native optional platform packages cannot be loaded through + by default unless `externals.force` or dependency metadata applies. If a + wrapper around native optional platform packages cannot be loaded through `createRequire`, the wrapper stays bundled and the platform packages are kept external. - `BundlerConfig.tegg` is accepted but intentionally not wired into the current From c85c0357c7b3da2a9898abb15e286dfa5fa13b92 Mon Sep 17 00:00:00 2001 From: killa Date: Mon, 4 May 2026 05:37:27 +0800 Subject: [PATCH 04/11] fix(bundler): honor exports array order --- .../egg-bundler/src/lib/ExternalsResolver.ts | 33 +++++++++++++++---- .../test/ExternalsResolver.test.ts | 31 +++++++++++++++++ 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/tools/egg-bundler/src/lib/ExternalsResolver.ts b/tools/egg-bundler/src/lib/ExternalsResolver.ts index 1f4163cb5e..1c86f5364b 100644 --- a/tools/egg-bundler/src/lib/ExternalsResolver.ts +++ b/tools/egg-bundler/src/lib/ExternalsResolver.ts @@ -54,6 +54,11 @@ interface ExternalizeDecision { readonly extraExternals: readonly string[]; } +interface ExportsTargetResolution { + readonly matched: boolean; + readonly canRequire: boolean; +} + export class ExternalsResolver { readonly #baseDir: string; readonly #force: ReadonlySet; @@ -235,22 +240,36 @@ export class ExternalsResolver { } #exportsTargetCanBeRequired(target: unknown, pkg: PackageJson): boolean { - if (typeof target === 'string') return this.#packageTargetCanBeRequired(target, pkg); - if (Array.isArray(target)) return target.some((item) => this.#exportsTargetCanBeRequired(item, pkg)); - if (!this.#isRecord(target)) return false; + return this.#resolveExportsTargetForCreateRequire(target, pkg).canRequire; + } + + #resolveExportsTargetForCreateRequire(target: unknown, pkg: PackageJson): ExportsTargetResolution { + if (typeof target === 'string') { + return { matched: true, canRequire: this.#packageTargetCanBeRequired(target, pkg) }; + } + + if (Array.isArray(target)) { + for (const item of target) { + const result = this.#resolveExportsTargetForCreateRequire(item, pkg); + if (result.matched) return result; + } + return { matched: false, canRequire: false }; + } + + if (!this.#isRecord(target)) return { matched: false, canRequire: false }; const keys = Object.keys(target); if (keys.some((key) => key.startsWith('.'))) { - if (!Object.hasOwn(target, '.')) return false; - return this.#exportsTargetCanBeRequired(target['.'], pkg); + if (!Object.hasOwn(target, '.')) return { matched: false, canRequire: false }; + return this.#resolveExportsTargetForCreateRequire(target['.'], pkg); } for (const condition of keys) { if (!CREATE_REQUIRE_EXPORT_CONDITIONS.has(condition)) continue; - return this.#exportsTargetCanBeRequired(target[condition], pkg); + return this.#resolveExportsTargetForCreateRequire(target[condition], pkg); } - return false; + return { matched: false, canRequire: false }; } #packageTargetCanBeRequired(target: string, pkg: PackageJson): boolean { diff --git a/tools/egg-bundler/test/ExternalsResolver.test.ts b/tools/egg-bundler/test/ExternalsResolver.test.ts index 59fcccc4ff..af239b2bdf 100644 --- a/tools/egg-bundler/test/ExternalsResolver.test.ts +++ b/tools/egg-bundler/test/ExternalsResolver.test.ts @@ -208,6 +208,37 @@ describe('ExternalsResolver', () => { await fs.rm(tempDir, { recursive: true, force: true }); } }); + + it('keeps a native optional wrapper bundled when the first array export fallback is not require-able', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'egg-bundler-externals-')); + try { + await writePackageJson(tempDir, { + name: 'array-export-app', + version: '1.0.0', + private: true, + dependencies: { + 'array-export-wrapper': '1.0.0', + }, + }); + await writePackageJson(path.join(tempDir, 'node_modules/array-export-wrapper'), { + name: 'array-export-wrapper', + version: '1.0.0', + type: 'module', + exports: { + '.': ['./index.js', './index.cjs'], + }, + optionalDependencies: { + 'array-export-wrapper-linux-x64-gnu': '1.0.0', + }, + }); + + const result = await new ExternalsResolver({ baseDir: tempDir }).resolve(); + expect(result['array-export-wrapper']).toBeUndefined(); + expect(result['array-export-wrapper-linux-x64-gnu']).toBe('array-export-wrapper-linux-x64-gnu'); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); }); describe('tier 3: dependency metadata', () => { From b076ed0fb5eebfb3d5f4c6428d48cb681107710b Mon Sep 17 00:00:00 2001 From: killa Date: Mon, 4 May 2026 06:10:38 +0800 Subject: [PATCH 05/11] fix(bundler): gate optional externals by requireability --- .../egg-bundler/src/lib/ExternalsResolver.ts | 6 ++-- .../test/ExternalsResolver.test.ts | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/tools/egg-bundler/src/lib/ExternalsResolver.ts b/tools/egg-bundler/src/lib/ExternalsResolver.ts index 1c86f5364b..74a2d63517 100644 --- a/tools/egg-bundler/src/lib/ExternalsResolver.ts +++ b/tools/egg-bundler/src/lib/ExternalsResolver.ts @@ -126,11 +126,13 @@ export class ExternalsResolver { optionalDeps: ReadonlySet, peerDeps: ReadonlySet, ): Promise { - let externalizePackage = optionalDeps.has(name) || peerDeps.has(name); + const isRootOptionalDep = optionalDeps.has(name); + let externalizePackage = peerDeps.has(name); const pkgDir = await this.#findPackageDir(name); - if (!pkgDir) return { externalizePackage, extraExternals: [] }; + if (!pkgDir) return { externalizePackage: externalizePackage || isRootOptionalDep, extraExternals: [] }; const pkg = await this.#readPackageJson(pkgDir); + if (isRootOptionalDep && this.#canLoadPackageThroughCreateRequire(pkg)) externalizePackage = true; if (await this.#hasMissingOptionalPeerDependencies(pkgDir, pkg)) externalizePackage = true; if (await this.#hasNativeBinary(pkgDir, pkg)) externalizePackage = true; diff --git a/tools/egg-bundler/test/ExternalsResolver.test.ts b/tools/egg-bundler/test/ExternalsResolver.test.ts index af239b2bdf..3881104561 100644 --- a/tools/egg-bundler/test/ExternalsResolver.test.ts +++ b/tools/egg-bundler/test/ExternalsResolver.test.ts @@ -105,6 +105,37 @@ describe('ExternalsResolver', () => { expect(result['@cnpmjs/packument-darwin-x64']).toBe('@cnpmjs/packument-darwin-x64'); }); + it('keeps a root optional import-only native wrapper bundled but externalizes its platform packages', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'egg-bundler-externals-')); + try { + await writePackageJson(tempDir, { + name: 'root-optional-native-wrapper-app', + version: '1.0.0', + private: true, + optionalDependencies: { + '@cnpmjs/packument': '1.7.0', + }, + }); + await writePackageJson(path.join(tempDir, 'node_modules/@cnpmjs/packument'), { + name: '@cnpmjs/packument', + version: '1.7.0', + type: 'module', + exports: { + './package.json': './package.json', + }, + optionalDependencies: { + '@cnpmjs/packument-linux-x64-gnu': '1.7.0', + }, + }); + + const result = await new ExternalsResolver({ baseDir: tempDir }).resolve(); + expect(result['@cnpmjs/packument']).toBeUndefined(); + expect(result['@cnpmjs/packument-linux-x64-gnu']).toBe('@cnpmjs/packument-linux-x64-gnu'); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it('keeps a native optional wrapper bundled when its require export target is not require-able', async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'egg-bundler-externals-')); try { From 5b87324785923628d20696517c5dd9872fd32161 Mon Sep 17 00:00:00 2001 From: killa Date: Mon, 4 May 2026 22:57:44 +0800 Subject: [PATCH 06/11] fix(core): expose collected manifest extensions --- packages/core/src/loader/manifest.ts | 3 +++ packages/core/test/loader/manifest_query.test.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/packages/core/src/loader/manifest.ts b/packages/core/src/loader/manifest.ts index 833be1ec4e..efb620fced 100644 --- a/packages/core/src/loader/manifest.ts +++ b/packages/core/src/loader/manifest.ts @@ -243,6 +243,9 @@ export class ManifestStore { * Look up a plugin extension by name. */ getExtension(name: string): unknown { + if (Object.hasOwn(this.#extensionCollector, name)) { + return this.#extensionCollector[name]; + } return this.data.extensions?.[name]; } diff --git a/packages/core/test/loader/manifest_query.test.ts b/packages/core/test/loader/manifest_query.test.ts index 3b949e88c2..db61c73f38 100644 --- a/packages/core/test/loader/manifest_query.test.ts +++ b/packages/core/test/loader/manifest_query.test.ts @@ -30,6 +30,18 @@ describe('ManifestStore query APIs', () => { } }); + it('should return extension data collected during startup', () => { + const baseDir = setupBaseDir(); + const extension = { moduleReferences: [{ name: 'runtimeModule', path: '/tmp/runtimeModule' }] }; + try { + const store = ManifestStore.createCollector(baseDir); + store.setExtension('tegg', extension); + assert.deepStrictEqual(store.getExtension('tegg'), extension); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + it('should return undefined for unknown extension', async () => { const baseDir = setupBaseDir(); try { From 36f4c0e57c2eb610c95493e1543f9a473b79fe08 Mon Sep 17 00:00:00 2001 From: killa Date: Mon, 4 May 2026 23:23:47 +0800 Subject: [PATCH 07/11] test(egg): relax watcher cluster startup timeout --- packages/egg/test/lib/plugins/watcher.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/egg/test/lib/plugins/watcher.test.ts b/packages/egg/test/lib/plugins/watcher.test.ts index cac6c5e40f..f99ff62c9f 100644 --- a/packages/egg/test/lib/plugins/watcher.test.ts +++ b/packages/egg/test/lib/plugins/watcher.test.ts @@ -81,7 +81,7 @@ describe('test/lib/plugins/watcher.test.ts', () => { app = cluster('apps/watcher-type-default'); app.coverage(false); return app.ready(); - }); + }, 60000); afterAll(() => app.close()); From 96df842672c8fd05e6aaad4d68cce233bf1e792c Mon Sep 17 00:00:00 2001 From: killa Date: Mon, 4 May 2026 23:44:49 +0800 Subject: [PATCH 08/11] fix(tegg): wait lifecycle hooks before rethrow --- tegg/core/lifecycle/src/LifycycleUtil.ts | 22 ++++++++++++++++------ tegg/core/lifecycle/test/index.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/tegg/core/lifecycle/src/LifycycleUtil.ts b/tegg/core/lifecycle/src/LifycycleUtil.ts index 5ec1ca607b..c8aed98304 100644 --- a/tegg/core/lifecycle/src/LifycycleUtil.ts +++ b/tegg/core/lifecycle/src/LifycycleUtil.ts @@ -49,22 +49,32 @@ export class LifecycleUtil { const globalLifecycleList = this.getLifecycleList(); const objLifecycleList = this.getObjectLifecycleList(obj); - await Promise.all(globalLifecycleList.map((lifecycle) => LifecycleUtil.callPreCreate(lifecycle, ctx, obj))); - await Promise.all(objLifecycleList.map((lifecycle) => LifecycleUtil.callPreCreate(lifecycle, ctx, obj))); + await LifecycleUtil.waitAll( + globalLifecycleList.map((lifecycle) => LifecycleUtil.callPreCreate(lifecycle, ctx, obj)), + ); + await LifecycleUtil.waitAll(objLifecycleList.map((lifecycle) => LifecycleUtil.callPreCreate(lifecycle, ctx, obj))); } async objectPostCreate(ctx: T, obj: R): Promise { const lifecycleList = this.getLifecycleList(); const objLifecycleList = this.getObjectLifecycleList(obj); - await Promise.all(lifecycleList.map((lifecycle) => LifecycleUtil.callPostCreate(lifecycle, ctx, obj))); - await Promise.all(objLifecycleList.map((lifecycle) => LifecycleUtil.callPostCreate(lifecycle, ctx, obj))); + await LifecycleUtil.waitAll(lifecycleList.map((lifecycle) => LifecycleUtil.callPostCreate(lifecycle, ctx, obj))); + await LifecycleUtil.waitAll(objLifecycleList.map((lifecycle) => LifecycleUtil.callPostCreate(lifecycle, ctx, obj))); } async objectPreDestroy(ctx: T, obj: R): Promise { const lifecycleList = this.getLifecycleList(); const objLifecycleList = this.getObjectLifecycleList(obj); - await Promise.all(lifecycleList.map((lifecycle) => LifecycleUtil.callPreDestroy(lifecycle, ctx, obj))); - await Promise.all(objLifecycleList.map((lifecycle) => LifecycleUtil.callPreDestroy(lifecycle, ctx, obj))); + await LifecycleUtil.waitAll(lifecycleList.map((lifecycle) => LifecycleUtil.callPreDestroy(lifecycle, ctx, obj))); + await LifecycleUtil.waitAll(objLifecycleList.map((lifecycle) => LifecycleUtil.callPreDestroy(lifecycle, ctx, obj))); + } + + private static async waitAll(promises: Promise[]): Promise { + const results = await Promise.allSettled(promises); + const rejected = results.find((result) => result.status === 'rejected'); + if (rejected) { + throw rejected.reason; + } } static async callPreCreate>( diff --git a/tegg/core/lifecycle/test/index.test.ts b/tegg/core/lifecycle/test/index.test.ts index ddf7f1633a..4923a18cc5 100644 --- a/tegg/core/lifecycle/test/index.test.ts +++ b/tegg/core/lifecycle/test/index.test.ts @@ -5,3 +5,25 @@ import * as exports from '../src/index.ts'; it('should export stable', async () => { expect(exports).toMatchSnapshot(); }); + +it('should wait all lifecycle hooks before throwing', async () => { + const util = new exports.LifecycleUtil(); + const error = new Error('mock error'); + const calls: string[] = []; + + util.registerLifecycle({ + async postCreate() { + calls.push('reject'); + throw error; + }, + }); + util.registerLifecycle({ + async postCreate() { + await new Promise((resolve) => setTimeout(resolve, 10)); + calls.push('settled'); + }, + }); + + await expect(util.objectPostCreate({}, { id: 'mock' })).rejects.toBe(error); + expect(calls).toEqual(['reject', 'settled']); +}); From 29054763d879b9f78f538d232794c985b61575c7 Mon Sep 17 00:00:00 2001 From: killa Date: Tue, 5 May 2026 00:09:06 +0800 Subject: [PATCH 09/11] test: cap vitest workers on windows ci --- vitest.config.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/vitest.config.ts b/vitest.config.ts index f7768129fa..d957f1538d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,9 +1,16 @@ import { defineConfig, type UserWorkspaceConfig } from 'vitest/config'; +const isWindowsCI = process.env.CI && process.platform === 'win32'; + const config: UserWorkspaceConfig = defineConfig({ test: { pool: 'threads', isolate: false, + ...(isWindowsCI + ? { + maxWorkers: 2, + } + : {}), projects: [ 'packages/*', 'plugins/*', From eb404a154679dd321cea6fa9f7b68ad95473d89e Mon Sep 17 00:00:00 2001 From: killa Date: Tue, 5 May 2026 00:24:06 +0800 Subject: [PATCH 10/11] fix(bin): align vitest hook timeout --- tools/egg-bin/src/commands/test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/egg-bin/src/commands/test.ts b/tools/egg-bin/src/commands/test.ts index 9d2a2a8e41..eafd6b5139 100644 --- a/tools/egg-bin/src/commands/test.ts +++ b/tools/egg-bin/src/commands/test.ts @@ -259,6 +259,7 @@ export default class Test extends BaseCommand { include: files, exclude: ['**/test/fixtures/**', '**/test/node_modules/**', '**/node_modules/**'], testTimeout: flags.timeout, + hookTimeout: flags.timeout, testNamePattern: flags.grep, bail: flags.bail ? 1 : 0, setupFiles, From 48d009acc1392f4d2c6696f36059b3d92ce1fc7d Mon Sep 17 00:00:00 2001 From: killa Date: Tue, 5 May 2026 00:37:33 +0800 Subject: [PATCH 11/11] test(session): wait for httponly warning log --- plugins/session/test/app/middleware/session.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/session/test/app/middleware/session.test.ts b/plugins/session/test/app/middleware/session.test.ts index 1a6e548b15..43d28c4832 100644 --- a/plugins/session/test/app/middleware/session.test.ts +++ b/plugins/session/test/app/middleware/session.test.ts @@ -65,6 +65,7 @@ describe('test/app/middlewares/session.test.js', () => { it('should warn when httponly false', async () => { app = mm.app({ baseDir: getFixtures('httponly-false-session') }); await app.ready(); + await scheduler.wait(1000); app.expectLog( '[@eggjs/session]: please set `config.session.httpOnly` to true. It is very dangerous if session can read by client JavaScript.', 'coreLogger',