diff --git a/tools/egg-bin/README.md b/tools/egg-bin/README.md index 5d310ac649..497341cb93 100644 --- a/tools/egg-bin/README.md +++ b/tools/egg-bin/README.md @@ -218,6 +218,20 @@ node worker.js supports multiple. Dot-relative targets such as `./target.js` and `../target.js` are resolved from the application base directory +Bundle aliases can also be committed in the application `module.yml`: + +```yaml +bundle: + pack: + resolve: + alias: + some-package: ./node_modules/some-package/index.js +``` + +`module.yml` aliases use the same target resolution rules as `--pack-alias`. +If the same alias is configured in both places, the explicit CLI +`--pack-alias` value wins. + See [`@eggjs/egg-bundler`](../egg-bundler/README.md) for the programmatic API and output structure. diff --git a/tools/egg-bundler/README.md b/tools/egg-bundler/README.md index de323bd563..1643fc300c 100644 --- a/tools/egg-bundler/README.md +++ b/tools/egg-bundler/README.md @@ -28,6 +28,22 @@ await bundle({ `outputDir` is resolved from `baseDir` when it is relative. The default manifest path is `/.egg/manifest.json`. +Applications can also provide stable bundle configuration in +`/module.yml`. The supported schema is: + +```yaml +bundle: + pack: + resolve: + alias: + some-package: ./node_modules/some-package/index.js +``` + +Dot-relative alias targets are resolved from `baseDir`; package-style and +absolute targets are passed through. Aliases supplied directly through the +programmatic `pack.resolve.alias` option override aliases with the same key from +`module.yml`. + If the startup manifest is missing, the bundler generates it by starting the app with `metadataOnly: true`. In that mode Egg skips the agent and normal boot lifecycle, runs `loadMetadata()` hooks, and the manifest generation child @@ -48,7 +64,7 @@ not run. | `externals.inline` | Package names to force inline even if auto-detected as external. | | `pack.buildFunc` | Test hook for replacing the real `@utoo/pack` build entry. | | `pack.rootPath` | Override the monorepo workspace root used by `@utoo/pack`. | -| `pack.resolve.alias` | Application-supplied `@utoo/pack` resolve aliases. | +| `pack.resolve.alias` | Application-supplied `@utoo/pack` resolve aliases; overrides `module.yml`. | ## Result diff --git a/tools/egg-bundler/package.json b/tools/egg-bundler/package.json index f83050fcec..91e30e1df1 100644 --- a/tools/egg-bundler/package.json +++ b/tools/egg-bundler/package.json @@ -89,6 +89,7 @@ "@eggjs/core": "workspace:*", "@utoo/pack": "catalog:", "execa": "catalog:", + "js-yaml": "catalog:", "tsx": "catalog:" }, "devDependencies": { @@ -98,6 +99,7 @@ "@eggjs/tegg": "workspace:*", "@eggjs/tegg-config": "workspace:*", "@eggjs/tegg-plugin": "workspace:*", + "@types/js-yaml": "catalog:", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", "rimraf": "catalog:", diff --git a/tools/egg-bundler/src/lib/Bundler.ts b/tools/egg-bundler/src/lib/Bundler.ts index 0f65222ae0..f288472501 100644 --- a/tools/egg-bundler/src/lib/Bundler.ts +++ b/tools/egg-bundler/src/lib/Bundler.ts @@ -2,6 +2,8 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import { debuglog } from 'node:util'; +import { load as yamlLoad } from 'js-yaml'; + import type { BundlerConfig, BundleResult } from '../index.ts'; import { EntryGenerator } from './EntryGenerator.ts'; import { ExternalsResolver } from './ExternalsResolver.ts'; @@ -35,6 +37,9 @@ const TURBOPACK_IMPORT_META_OBJECT = /\b(var|let|const)\s+([A-Za-z_$][\w$]*import\$2e\$meta__[A-Za-z0-9_$]*)\s*=\s*\{\s*get\s+url\s*\(\)\s*\{[\s\S]*?\}\s*\};?/g; const LINE_SOURCE_MAP_URL = /(?:\r?\n)?\/\/# sourceMappingURL=([^\r\n]*)\s*$/; const BLOCK_SOURCE_MAP_URL = /(?:\r?\n)?\/\*# sourceMappingURL=([\s\S]*?)\*\/\s*$/; +const UNSAFE_ALIAS_SPECIFIERS = new Set(['__proto__', 'constructor', 'prototype']); + +type JsonRecord = Record; interface BundleManifest { readonly version: number; @@ -55,6 +60,114 @@ function wrapStep(step: string, fn: () => Promise): Promise { }); } +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function getErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function normalizePackAliasTarget(baseDir: string, target: string): string { + return target.startsWith('.') ? path.resolve(baseDir, target) : target; +} + +function validateModulePackAliasSpecifier(filepath: string, specifier: string): void { + if (!specifier) { + throw new Error(`Invalid bundle config in ${filepath}: bundle.pack.resolve.alias contains an empty specifier.`); + } + if (UNSAFE_ALIAS_SPECIFIERS.has(specifier)) { + throw new Error(`Invalid bundle config in ${filepath}: bundle.pack.resolve.alias.${specifier} is not allowed.`); + } +} + +function parseModuleBundlePackConfig(filepath: string, baseDir: string, rawConfig: unknown): BundlerConfig['pack'] { + if (rawConfig == null) return undefined; + if (!isRecord(rawConfig)) { + throw new Error(`Invalid bundle config in ${filepath}: module.yml must contain an object.`); + } + + const bundleConfig = rawConfig.bundle; + if (bundleConfig == null) return undefined; + if (!isRecord(bundleConfig)) { + throw new Error(`Invalid bundle config in ${filepath}: bundle must be an object.`); + } + + const packConfig = bundleConfig.pack; + if (packConfig == null) return undefined; + if (!isRecord(packConfig)) { + throw new Error(`Invalid bundle config in ${filepath}: bundle.pack must be an object.`); + } + + const resolveConfig = packConfig.resolve; + if (resolveConfig == null) return undefined; + if (!isRecord(resolveConfig)) { + throw new Error(`Invalid bundle config in ${filepath}: bundle.pack.resolve must be an object.`); + } + + const aliasConfig = resolveConfig.alias; + if (aliasConfig == null) return undefined; + if (!isRecord(aliasConfig)) { + throw new Error(`Invalid bundle config in ${filepath}: bundle.pack.resolve.alias must be an object.`); + } + + const alias: Record = {}; + for (const [specifier, target] of Object.entries(aliasConfig)) { + validateModulePackAliasSpecifier(filepath, specifier); + if (typeof target !== 'string' || target.length === 0) { + throw new Error( + `Invalid bundle config in ${filepath}: bundle.pack.resolve.alias.${specifier} must be a non-empty string.`, + ); + } + alias[specifier] = normalizePackAliasTarget(baseDir, target); + } + + return Object.keys(alias).length > 0 ? { resolve: { alias } } : undefined; +} + +async function loadModuleBundlePackConfig(baseDir: string): Promise { + const filepath = path.join(baseDir, 'module.yml'); + let content: string; + try { + content = await fs.readFile(filepath, 'utf8'); + } catch (err) { + if (isRecord(err) && err.code === 'ENOENT') return undefined; + throw new Error(`Unable to read ${filepath}: ${getErrorMessage(err)}`, { cause: err }); + } + + if (content.trim().length === 0) return undefined; + + let rawConfig: unknown; + try { + rawConfig = yamlLoad(content); + } catch (err) { + throw new Error(`Unable to parse ${filepath}: ${getErrorMessage(err)}`, { cause: err }); + } + + return parseModuleBundlePackConfig(filepath, baseDir, rawConfig); +} + +function mergePackConfig( + modulePack: BundlerConfig['pack'], + explicitPack: BundlerConfig['pack'], +): BundlerConfig['pack'] { + const alias = { + ...modulePack?.resolve?.alias, + ...explicitPack?.resolve?.alias, + }; + const hasAlias = Object.keys(alias).length > 0; + if (!explicitPack && !hasAlias) return undefined; + const resolve = { + ...explicitPack?.resolve, + ...(hasAlias ? { alias } : {}), + }; + + return { + ...explicitPack, + ...(Object.keys(resolve).length > 0 ? { resolve } : {}), + }; +} + export function sanitizeBundleOutputRelativePath(relativeName: string): string { const normalized = relativeName.replace(/\\/g, '/'); const segments = normalized.split('/'); @@ -92,6 +205,10 @@ export class Bundler { const absBaseDir = path.resolve(baseDir); const absOutputDir = path.resolve(absBaseDir, rawOutputDir); debug('bundle start: baseDir=%s outputDir=%s framework=%s mode=%s', absBaseDir, absOutputDir, framework, mode); + const mergedPack = mergePackConfig( + await wrapStep('module.yml bundle config load', () => loadModuleBundlePackConfig(absBaseDir)), + pack, + ); const manifestLoader = new ManifestLoader({ baseDir: absBaseDir, @@ -123,10 +240,10 @@ export class Bundler { outputDir: absOutputDir, externals: externalsMap, projectPath: absBaseDir, - rootPath: pack?.rootPath, + rootPath: mergedPack?.rootPath, mode, - buildFunc: pack?.buildFunc, - resolve: pack?.resolve, + buildFunc: mergedPack?.buildFunc, + resolve: mergedPack?.resolve, }); const packResult = await wrapStep('pack build', () => packRunner.run()); debug('pack produced %d files', packResult.files.length); diff --git a/tools/egg-bundler/src/lib/PackRunner.ts b/tools/egg-bundler/src/lib/PackRunner.ts index ffeb848147..4537ab89f1 100644 --- a/tools/egg-bundler/src/lib/PackRunner.ts +++ b/tools/egg-bundler/src/lib/PackRunner.ts @@ -11,6 +11,7 @@ export type BuildFunc = (config: { config: unknown }, projectPath: string, rootP export interface PackRunnerResolveConfig { readonly alias?: Readonly>; + readonly [key: string]: unknown; } export interface PackRunnerOptions { @@ -118,8 +119,15 @@ export class PackRunner { } #buildResolveConfig(resolve: PackRunnerResolveConfig | undefined): PackRunnerResolveConfig | undefined { - if (!resolve?.alias || Object.keys(resolve.alias).length === 0) return undefined; - return { alias: { ...resolve.alias } }; + if (!resolve) return undefined; + + const { alias, ...rest } = resolve; + const resolveConfig = { + ...rest, + ...(alias && Object.keys(alias).length > 0 ? { alias: { ...alias } } : {}), + }; + + return Object.keys(resolveConfig).length > 0 ? resolveConfig : undefined; } async #collectFiles(dir: string): Promise { diff --git a/tools/egg-bundler/test/Bundler.test.ts b/tools/egg-bundler/test/Bundler.test.ts index 40b82844ad..54599a4e4f 100644 --- a/tools/egg-bundler/test/Bundler.test.ts +++ b/tools/egg-bundler/test/Bundler.test.ts @@ -118,4 +118,119 @@ describe('Bundler', () => { expect(packResolve).toEqual({ alias }); }); + + it('loads pack resolve aliases from module.yml and resolves dot-relative targets from baseDir', async () => { + let packResolve: unknown; + await fs.writeFile( + path.join(tmpApp, 'module.yml'), + [ + 'bundle:', + ' pack:', + ' resolve:', + ' alias:', + ' module-file: ./node_modules/module-file/index.js', + ' package-style: package-style', + ].join('\n'), + ); + + await bundle({ + baseDir: tmpApp, + outputDir: tmpOutput, + pack: { + buildFunc: async (wrapped) => { + packResolve = (wrapped.config as { resolve?: unknown }).resolve; + await fs.writeFile(path.join(tmpOutput, 'worker.js'), '// worker\n'); + }, + }, + }); + + expect(packResolve).toEqual({ + alias: { + 'module-file': path.join(tmpApp, 'node_modules/module-file/index.js'), + 'package-style': 'package-style', + }, + }); + }); + + it('lets explicit pack resolve aliases override module.yml aliases', async () => { + let packResolve: unknown; + await fs.writeFile( + path.join(tmpApp, 'module.yml'), + [ + 'bundle:', + ' pack:', + ' resolve:', + ' alias:', + ' shared: ./from-module.js', + ' module-only: ./module-only.js', + ].join('\n'), + ); + + await bundle({ + baseDir: tmpApp, + outputDir: tmpOutput, + pack: { + resolve: { + conditionNames: ['node'], + alias: { + shared: path.join(tmpApp, 'from-cli.js'), + 'cli-only': 'cli-only', + }, + }, + buildFunc: async (wrapped) => { + packResolve = (wrapped.config as { resolve?: unknown }).resolve; + await fs.writeFile(path.join(tmpOutput, 'worker.js'), '// worker\n'); + }, + }, + }); + + expect(packResolve).toEqual({ + conditionNames: ['node'], + alias: { + shared: path.join(tmpApp, 'from-cli.js'), + 'module-only': path.join(tmpApp, 'module-only.js'), + 'cli-only': 'cli-only', + }, + }); + }); + + it('throws a clear error when module.yml bundle alias config is invalid', async () => { + await fs.writeFile( + path.join(tmpApp, 'module.yml'), + ['bundle:', ' pack:', ' resolve:', ' alias:', ' invalid-target:', ' nested: true'].join( + '\n', + ), + ); + + await expect( + bundle({ + baseDir: tmpApp, + outputDir: tmpOutput, + pack: { + buildFunc: async () => { + await fs.writeFile(path.join(tmpOutput, 'worker.js'), '// worker\n'); + }, + }, + }), + ).rejects.toThrow(/module\.yml bundle config load failed: .*bundle\.pack\.resolve\.alias\.invalid-target/); + }); + + it('rejects prototype-polluting module.yml bundle alias specifiers', async () => { + await fs.writeFile( + path.join(tmpApp, 'module.yml'), + ['bundle:', ' pack:', ' resolve:', ' alias:', ' constructor: ./polluted.js'].join('\n'), + ); + + await expect( + bundle({ + baseDir: tmpApp, + outputDir: tmpOutput, + pack: { + buildFunc: async () => { + await fs.writeFile(path.join(tmpOutput, 'worker.js'), '// worker\n'); + }, + }, + }), + ).rejects.toThrow(/module\.yml bundle config load failed: .*bundle\.pack\.resolve\.alias\.constructor/); + }); }); diff --git a/tools/egg-bundler/test/PackRunner.test.ts b/tools/egg-bundler/test/PackRunner.test.ts index 0e1944bda2..ad03bd03c6 100644 --- a/tools/egg-bundler/test/PackRunner.test.ts +++ b/tools/egg-bundler/test/PackRunner.test.ts @@ -33,6 +33,7 @@ describe('PackRunner', () => { buildFunc?: BuildFunc; resolve?: { alias?: Record; + [key: string]: unknown; }; } = {}, ): PackRunner { @@ -125,6 +126,22 @@ describe('PackRunner', () => { expect(config.resolve).toEqual({ alias }); }); + it('preserves non-alias resolve options while cloning aliases', async () => { + const buildFunc = vi.fn(async () => {}); + const alias = { + 'some-package': path.join(tmpDir, 'node_modules', 'some-package', 'index.js'), + }; + + await makeRunner({ buildFunc, resolve: { conditionNames: ['node'], alias } }).run(); + + const config = (buildFunc.mock.calls[0]![0] as { config: Record }).config; + expect(config.resolve).toEqual({ + conditionNames: ['node'], + alias, + }); + expect((config.resolve as { alias: Record }).alias).not.toBe(alias); + }); + it('disables treeShaking and minify in the pack config (tegg runtime requires the full graph)', async () => { const buildFunc = vi.fn(async () => {}); await makeRunner({ buildFunc }).run();