diff --git a/packages/egg/src/index.ts b/packages/egg/src/index.ts index 876602d244..9565f32ed1 100644 --- a/packages/egg/src/index.ts +++ b/packages/egg/src/index.ts @@ -1,6 +1,7 @@ import Helper from './app/extend/helper.ts'; import { BaseContextClass } from './lib/core/base_context_class.ts'; import { startEgg, type SingleModeApplication, type SingleModeAgent } from './lib/start.ts'; +import { EggAppConfig, type EggAppConfig as EggAppConfigType } from './lib/types.ts'; // export extends export { Helper }; @@ -16,28 +17,27 @@ export * from './lib/types.ts'; export * from './lib/define.ts'; // alias EggAppConfig to Config -export type { - /** - * Egg Application Config, can be injected into Proto, e.g. SingletonProto/ContextProto/HttpController. - * - * Usage: - * ```ts - * import { Inject, Config } from 'egg'; - * - * @SingletonProto() - * class FooService { - * @Inject() - * config: Config; - * - * async bar() { - * console.log(this.config.env); - * } - * } - * ``` - * @since 4.1.0 - */ - EggAppConfig as Config, -} from './lib/types.ts'; +export const Config: typeof EggAppConfig = EggAppConfig; +/** + * Egg Application Config, can be injected into Proto, e.g. SingletonProto/ContextProto/HttpController. + * + * Usage: + * ```ts + * import { Inject, Config } from 'egg'; + * + * @SingletonProto() + * class FooService { + * @Inject() + * config: Config; + * + * async bar() { + * console.log(this.config.env); + * } + * } + * ``` + * @since 4.1.0 + */ +export type Config = EggAppConfigType; export * from './lib/start.ts'; @@ -51,7 +51,8 @@ export { Singleton, type SingletonCreateMethod, type SingletonOptions } from '@e export * from './lib/error/index.ts'; // export loggers -export type { LoggerLevel, EggLogger, EggLogger as Logger } from 'egg-logger'; +export { EggLogger as Logger } from 'egg-logger'; +export type { LoggerLevel, EggLogger } from 'egg-logger'; // export httpClients export * from './lib/core/httpclient.ts'; diff --git a/packages/egg/src/lib/types.ts b/packages/egg/src/lib/types.ts index 2b62381c54..bcde87f892 100644 --- a/packages/egg/src/lib/types.ts +++ b/packages/egg/src/lib/types.ts @@ -100,6 +100,11 @@ export interface HttpClientConfig { */ export type PowerPartial = PartialDeep; +// Some applications use this framework type name in decorated fields without +// `import type`; keep a runtime export available for decorator metadata and +// bundler static export validation while the interface below remains the type. +export const EggAppConfig: ObjectConstructor = Object; + export interface EggAppConfig extends EggCoreAppConfig { workerStartTimeout: number; baseDir: string; diff --git a/packages/egg/test/__snapshots__/index.test.ts.snap b/packages/egg/test/__snapshots__/index.test.ts.snap index c5501f38fe..d818355427 100644 --- a/packages/egg/test/__snapshots__/index.test.ts.snap +++ b/packages/egg/test/__snapshots__/index.test.ts.snap @@ -16,12 +16,14 @@ exports[`should expose properties 1`] = ` "Boot": [Function], "ClusterAgentWorkerError": [Function], "ClusterWorkerExceptionError": [Function], + "Config": [Function], "Context": [Function], "ContextHttpClient": [Function], "ContextProto": [Function], "Controller": [Function], "CookieLimitExceedError": [Function], "Cookies": [Function], + "EggAppConfig": [Function], "EggApplicationCore": [Function], "EggQualifier": [Function], "EggType": { @@ -70,6 +72,7 @@ exports[`should expose properties 1`] = ` "LifecyclePreDestroy": [Function], "LifecyclePreInject": [Function], "LifecyclePreLoad": [Function], + "Logger": [Function], "Master": [Function], "MessageUnhandledRejectionError": [Function], "MetadataUtil": [Function], diff --git a/packages/egg/test/index.test.ts b/packages/egg/test/index.test.ts index 2facc210ca..38e5ceb376 100644 --- a/packages/egg/test/index.test.ts +++ b/packages/egg/test/index.test.ts @@ -1,8 +1,13 @@ +import assert from 'node:assert/strict'; + import { test, expect } from 'vitest'; import * as egg from '../src/index.ts'; test('should expose properties', () => { expect(egg).toMatchSnapshot(); - expect(egg.Context).toBeDefined(); + assert.ok(egg.Context); + assert.strictEqual(egg.Config, Object); + assert.strictEqual(egg.EggAppConfig, Object); + assert.ok(egg.Logger); }); diff --git a/tools/egg-bundler/docs/output-structure.md b/tools/egg-bundler/docs/output-structure.md index 245630e19c..2d43b916ad 100644 --- a/tools/egg-bundler/docs/output-structure.md +++ b/tools/egg-bundler/docs/output-structure.md @@ -31,9 +31,11 @@ node worker.js ``` The worker entry installs `ManifestStore.setBundleStore(...)` and -`setBundleModuleLoader(...)` before calling `startEgg({ baseDir, mode: 'single' })`, -so all framework file discovery and module resolution is served from the -inlined bundle map — no `fs.readdir` scanning at runtime. +`globalThis.__EGG_BUNDLE_MODULE_LOADER__` before calling +`startEgg({ baseDir, mode: 'single' })`, so framework module resolution for +bundled files is served from the inlined bundle map, avoiding `fs.readdir` for +bundled framework file discovery. Application code and plugins may still use +`fs` for resources such as config, views, or assets. ## `bundle-manifest.json` diff --git a/tools/egg-bundler/package.json b/tools/egg-bundler/package.json index 47aa803129..f83050fcec 100644 --- a/tools/egg-bundler/package.json +++ b/tools/egg-bundler/package.json @@ -87,7 +87,6 @@ }, "dependencies": { "@eggjs/core": "workspace:*", - "@eggjs/utils": "workspace:*", "@utoo/pack": "catalog:", "execa": "catalog:", "tsx": "catalog:" diff --git a/tools/egg-bundler/src/lib/EntryGenerator.ts b/tools/egg-bundler/src/lib/EntryGenerator.ts index 9dfdeb419e..149c970e4e 100644 --- a/tools/egg-bundler/src/lib/EntryGenerator.ts +++ b/tools/egg-bundler/src/lib/EntryGenerator.ts @@ -194,7 +194,7 @@ export class EntryGenerator { } const manifestJson = JSON.stringify(manifest, null, 2); - const frameworkSpec = JSON.stringify(this.#framework); + const frameworkSpec = JSON.stringify(this.#toFrameworkImportSpecifier()); const externalBlock = externalSpecs.length > 0 @@ -215,7 +215,6 @@ for (const [key, spec] of __EXTERNAL_SPECS) { import path from 'node:path'; import { ManifestStore } from '@eggjs/core'; -import { setBundleModuleLoader } from '@eggjs/utils'; import { startEgg } from ${frameworkSpec}; ${importLines.join('\n')} @@ -239,11 +238,14 @@ for (const [rel, mod] of Object.entries(__BUNDLE_MAP_REL)) { __BUNDLE_MAP[rel] = mod; } +const __bundleGlobalThis = globalThis as typeof globalThis & { + __EGG_BUNDLE_MODULE_LOADER__?: (filepath: string) => unknown; +}; ManifestStore.setBundleStore(ManifestStore.fromBundle(MANIFEST_DATA as any, __baseDir)); -setBundleModuleLoader((filepath) => { +__bundleGlobalThis.__EGG_BUNDLE_MODULE_LOADER__ = (filepath) => { const key = filepath.split(path.sep).join('/'); return __BUNDLE_MAP[key]; -}); +}; startEgg({ baseDir: __baseDir, mode: 'single' }).then((app) => { const port = process.env.PORT || app.config.cluster?.listen?.port || 7001; @@ -259,6 +261,46 @@ startEgg({ baseDir: __baseDir, mode: 'single' }).then((app) => { `; } + #toFrameworkImportSpecifier(): string { + if (!path.isAbsolute(this.#framework)) return this.#framework; + const packageName = this.#packageNameFromDir(this.#framework); + if (packageName && this.#canUseFrameworkPackageName(packageName, this.#framework)) { + return packageName; + } + return this.#toImportSpecifier(this.#framework); + } + + #packageNameFromDir(dir: string): string | undefined { + try { + const req = createRequire(path.join(dir, 'package.json')); + const pkg = req(path.join(dir, 'package.json')) as { name?: unknown }; + return typeof pkg.name === 'string' && pkg.name ? pkg.name : undefined; + } catch { + return undefined; + } + } + + #canUseFrameworkPackageName(packageName: string, dir: string): boolean { + if (this.#isInsideDir(path.join(this.#baseDir, 'node_modules'), dir)) return true; + + try { + const req = createRequire(path.join(this.#baseDir, 'package.json')); + const resolvedPackageJson = req.resolve(`${packageName}/package.json`); + return this.#samePath(path.dirname(resolvedPackageJson), dir); + } catch { + return false; + } + } + + #isInsideDir(parent: string, dir: string): boolean { + const rel = path.relative(path.resolve(parent), path.resolve(dir)); + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); + } + + #samePath(left: string, right: string): boolean { + return path.resolve(left) === path.resolve(right); + } + #toImportSpecifier(absPath: string): string { // Prefer a relative specifier from the entry output dir to keep the // bundled paths portable across machines (absolute paths would leak diff --git a/tools/egg-bundler/src/lib/ExternalsResolver.ts b/tools/egg-bundler/src/lib/ExternalsResolver.ts index a21b47a87d..b4aab83460 100644 --- a/tools/egg-bundler/src/lib/ExternalsResolver.ts +++ b/tools/egg-bundler/src/lib/ExternalsResolver.ts @@ -15,6 +15,7 @@ interface PackageJson { readonly dependencies?: Record; readonly optionalDependencies?: Record; readonly peerDependencies?: Record; + readonly peerDependenciesMeta?: Record; readonly scripts?: Record; readonly exports?: unknown; } @@ -51,6 +52,7 @@ export class ExternalsResolver { for (const name of deps) { if (this.#inline.has(name) && !this.#force.has(name)) continue; + await this.#addMissingOptionalPeerExternals(name, result); if (result[name]) continue; if (await this.#shouldExternalize(name, optionalDeps, peerDeps)) { result[name] = name; @@ -65,6 +67,21 @@ export class ExternalsResolver { return result; } + async #addMissingOptionalPeerExternals(name: string, result: Record): Promise { + const pkgDir = await this.#findPackageDir(name); + if (!pkgDir) return; + const pkg = await this.#readPackageJson(pkgDir); + const peerDependencies = pkg.peerDependencies ?? {}; + const peerDependenciesMeta = pkg.peerDependenciesMeta ?? {}; + for (const peerName of Object.keys(peerDependencies)) { + if (result[peerName]) continue; + if (this.#inline.has(peerName) && !this.#force.has(peerName)) continue; + if (!peerDependenciesMeta[peerName]?.optional) continue; + if (await this.#findPackageDir(peerName, pkgDir)) continue; + result[peerName] = peerName; + } + } + async #shouldExternalize( name: string, optionalDeps: ReadonlySet, @@ -76,20 +93,32 @@ export class ExternalsResolver { const pkgDir = await this.#findPackageDir(name); if (!pkgDir) return false; const pkg = await this.#readPackageJson(pkgDir); + if (await this.#hasMissingOptionalPeerDependencies(pkgDir, pkg)) return true; if (await this.#hasNativeBinary(pkgDir, pkg)) return true; return false; } - async #findPackageDir(name: string): Promise { - const cached = this.#packageDirCache.get(name); + async #hasMissingOptionalPeerDependencies(pkgDir: string, pkg: PackageJson): Promise { + const peerDependencies = pkg.peerDependencies ?? {}; + const peerDependenciesMeta = pkg.peerDependenciesMeta ?? {}; + for (const peerName of Object.keys(peerDependencies)) { + if (!peerDependenciesMeta[peerName]?.optional) continue; + if (!(await this.#findPackageDir(peerName, pkgDir))) return true; + } + return false; + } + + async #findPackageDir(name: string, fromDir = this.#baseDir): Promise { + const cacheKey = `${fromDir}\0${name}`; + const cached = this.#packageDirCache.get(cacheKey); if (cached) return cached; - const result = this.#findPackageDirUncached(name); - this.#packageDirCache.set(name, result); + const result = this.#findPackageDirUncached(name, fromDir); + this.#packageDirCache.set(cacheKey, result); return result; } - async #findPackageDirUncached(name: string): Promise { - let dir = this.#baseDir; + async #findPackageDirUncached(name: string, fromDir: string): Promise { + let dir = fromDir; while (true) { const candidate = path.join(dir, 'node_modules', name); try { diff --git a/tools/egg-bundler/test/EntryGenerator.test.ts b/tools/egg-bundler/test/EntryGenerator.test.ts index 696e39ff03..d332d1e5e8 100644 --- a/tools/egg-bundler/test/EntryGenerator.test.ts +++ b/tools/egg-bundler/test/EntryGenerator.test.ts @@ -171,10 +171,9 @@ describe('EntryGenerator', () => { const worker = await fs.readFile(result.workerEntry, 'utf8'); expect(worker).toContain("import { ManifestStore } from '@eggjs/core'"); - expect(worker).toContain("import { setBundleModuleLoader } from '@eggjs/utils'"); expect(worker).toContain('import { startEgg } from "egg"'); expect(worker).toContain('ManifestStore.setBundleStore(ManifestStore.fromBundle(MANIFEST_DATA'); - expect(worker).toContain('setBundleModuleLoader('); + expect(worker).toContain('__EGG_BUNDLE_MODULE_LOADER__'); expect(worker).toContain("startEgg({ baseDir: __baseDir, mode: 'single' })"); }); @@ -244,7 +243,7 @@ describe('EntryGenerator', () => { expect(extractImports(worker).length).toBe(0); expect(worker).toContain("startEgg({ baseDir: __baseDir, mode: 'single' })"); - expect(worker).toContain('setBundleModuleLoader('); + expect(worker).toContain('__EGG_BUNDLE_MODULE_LOADER__'); expect(worker).toContain('ManifestStore.setBundleStore'); }); @@ -284,6 +283,42 @@ describe('EntryGenerator', () => { expect(worker).not.toContain('import { startEgg } from "egg"'); }); + it('uses the package name for an absolute framework directory with package metadata', async () => { + const frameworkDir = path.join(tmpDir, 'node_modules/custom-egg'); + await fs.mkdir(frameworkDir, { recursive: true }); + await fs.writeFile(path.join(frameworkDir, 'package.json'), JSON.stringify({ name: 'custom-egg' })); + + const gen = new EntryGenerator({ + baseDir: tmpDir, + framework: frameworkDir, + manifestLoader: createFakeLoader(makeManifest()), + }); + const result = await gen.generate(); + const worker = await fs.readFile(result.workerEntry, 'utf8'); + + expect(worker).toContain('import { startEgg } from "custom-egg"'); + expect(worker).not.toContain(frameworkDir); + }); + + it('keeps an absolute framework checkout relative when the app cannot resolve its package name', async () => { + const frameworkDir = await fs.mkdtemp(path.join(os.tmpdir(), 'egg-bundler-framework-')); + createdDirs.push(frameworkDir); + await fs.writeFile(path.join(frameworkDir, 'package.json'), JSON.stringify({ name: 'custom-egg' })); + + const gen = new EntryGenerator({ + baseDir: tmpDir, + framework: frameworkDir, + manifestLoader: createFakeLoader(makeManifest()), + }); + const result = await gen.generate(); + const worker = await fs.readFile(result.workerEntry, 'utf8'); + const relFramework = path.relative(result.entryDir, frameworkDir).replaceAll(path.sep, '/'); + + expect(worker).toContain(`import { startEgg } from "${relFramework}"`); + expect(worker).not.toContain('import { startEgg } from "custom-egg"'); + expect(worker).not.toContain(frameworkDir); + }); + it('produces byte-identical worker output across independent baseDir runs (T17 determinism baseline)', async () => { const manifest = makeManifest({ extensions: { diff --git a/tools/egg-bundler/test/ExternalsResolver.test.ts b/tools/egg-bundler/test/ExternalsResolver.test.ts index d5b9690f76..90ed52cedb 100644 --- a/tools/egg-bundler/test/ExternalsResolver.test.ts +++ b/tools/egg-bundler/test/ExternalsResolver.test.ts @@ -67,6 +67,62 @@ describe('ExternalsResolver', () => { const result = await new ExternalsResolver({ baseDir: basicApp }).resolve(); expect(result['optional-only']).toBe('optional-only'); }); + + it('externalizes missing optional peerDependencies declared by installed dependencies', async () => { + const result = await new ExternalsResolver({ baseDir: basicApp }).resolve(); + expect(result['optional-peer-host']).toBe('optional-peer-host'); + expect(result['missing-optional-peer']).toBe('missing-optional-peer'); + expect(result['required-peer']).toBeUndefined(); + expect(result['normal-js']).toBeUndefined(); + }); + + it('resolves optional peers from the dependent package directory', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'egg-bundler-externals-')); + try { + await fs.mkdir(path.join(tempDir, 'node_modules/nested-peer-host/node_modules/present-optional-peer'), { + recursive: true, + }); + await fs.writeFile( + path.join(tempDir, 'package.json'), + JSON.stringify({ + name: 'nested-peer-app', + version: '1.0.0', + private: true, + dependencies: { + 'nested-peer-host': '1.0.0', + }, + }), + ); + await fs.writeFile( + path.join(tempDir, 'node_modules/nested-peer-host/package.json'), + JSON.stringify({ + name: 'nested-peer-host', + version: '1.0.0', + peerDependencies: { + 'present-optional-peer': '^1.0.0', + }, + peerDependenciesMeta: { + 'present-optional-peer': { + optional: true, + }, + }, + }), + ); + await fs.writeFile( + path.join(tempDir, 'node_modules/nested-peer-host/node_modules/present-optional-peer/package.json'), + JSON.stringify({ + name: 'present-optional-peer', + version: '1.0.0', + }), + ); + + const result = await new ExternalsResolver({ baseDir: tempDir }).resolve(); + expect(result['nested-peer-host']).toBeUndefined(); + expect(result['present-optional-peer']).toBeUndefined(); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); }); describe('negative cases', () => { @@ -138,6 +194,15 @@ describe('ExternalsResolver', () => { expect(result['normal-js']).toBe('normal-js'); }); + it('adds missing optional peerDependencies for force-listed packages', async () => { + const result = await new ExternalsResolver({ + baseDir: basicApp, + force: ['optional-peer-host'], + }).resolve(); + expect(result['optional-peer-host']).toBe('optional-peer-host'); + expect(result['missing-optional-peer']).toBe('missing-optional-peer'); + }); + it('inline removes a peerDependency from externals', async () => { const result = await new ExternalsResolver({ baseDir: basicApp, @@ -154,6 +219,14 @@ describe('ExternalsResolver', () => { expect(result['optional-only']).toBeUndefined(); }); + it('inline removes a missing optional peerDependency from externals', async () => { + const result = await new ExternalsResolver({ + baseDir: basicApp, + inline: ['missing-optional-peer'], + }).resolve(); + expect(result['missing-optional-peer']).toBeUndefined(); + }); + it('force can still externalize framework and helper packages explicitly', async () => { const result = await new ExternalsResolver({ baseDir: basicApp, diff --git a/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap b/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap index 581d0b6525..32d77bae95 100644 --- a/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap +++ b/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap @@ -3,7 +3,6 @@ import path from 'node:path'; import { ManifestStore } from '@eggjs/core'; -import { setBundleModuleLoader } from '@eggjs/utils'; import { startEgg } from "egg"; import * as __m0 from "../../app/controller/home.ts"; @@ -67,11 +66,14 @@ for (const [rel, mod] of Object.entries(__BUNDLE_MAP_REL)) { __BUNDLE_MAP[rel] = mod; } +const __bundleGlobalThis = globalThis as typeof globalThis & { + __EGG_BUNDLE_MODULE_LOADER__?: (filepath: string) => unknown; +}; ManifestStore.setBundleStore(ManifestStore.fromBundle(MANIFEST_DATA as any, __baseDir)); -setBundleModuleLoader((filepath) => { +__bundleGlobalThis.__EGG_BUNDLE_MODULE_LOADER__ = (filepath) => { const key = filepath.split(path.sep).join('/'); return __BUNDLE_MAP[key]; -}); +}; startEgg({ baseDir: __baseDir, mode: 'single' }).then((app) => { const port = process.env.PORT || app.config.cluster?.listen?.port || 7001; diff --git a/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/optional-peer-host/package.json b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/optional-peer-host/package.json new file mode 100644 index 0000000000..32965a75dd --- /dev/null +++ b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/optional-peer-host/package.json @@ -0,0 +1,17 @@ +{ + "name": "optional-peer-host", + "version": "1.0.0", + "peerDependencies": { + "missing-optional-peer": "1.0.0", + "normal-js": "1.0.0", + "required-peer": "1.0.0" + }, + "peerDependenciesMeta": { + "missing-optional-peer": { + "optional": true + }, + "normal-js": { + "optional": true + } + } +} 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 34d6d57c30..f839a9d619 100644 --- a/tools/egg-bundler/test/fixtures/externals/basic-app/package.json +++ b/tools/egg-bundler/test/fixtures/externals/basic-app/package.json @@ -14,7 +14,8 @@ "native-dotnode": "1.0.0", "native-prebuilds": "1.0.0", "native-scripts": "1.0.0", - "normal-js": "1.0.0" + "normal-js": "1.0.0", + "optional-peer-host": "1.0.0" }, "peerDependencies": { "peer-only": "1.0.0" diff --git a/tools/egg-bundler/test/integration.test.ts b/tools/egg-bundler/test/integration.test.ts index aa4a814afe..4f75bf981f 100644 --- a/tools/egg-bundler/test/integration.test.ts +++ b/tools/egg-bundler/test/integration.test.ts @@ -383,7 +383,7 @@ globalThis.__patchedMeta = { // Spot-check: the generated entry contains the runtime hook calls const entrySource = await fs.readFile(workerSource, 'utf8'); expect(entrySource).toContain('ManifestStore.setBundleStore'); - expect(entrySource).toContain('setBundleModuleLoader'); + expect(entrySource).toContain('__EGG_BUNDLE_MODULE_LOADER__'); expect(entrySource).toContain('startEgg'); }); });