Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions tools/egg-bin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
18 changes: 17 additions & 1 deletion tools/egg-bundler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,22 @@ await bundle({
`outputDir` is resolved from `baseDir` when it is relative. The default manifest
path is `<baseDir>/.egg/manifest.json`.

Applications can also provide stable bundle configuration in
`<baseDir>/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
Expand All @@ -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

Expand Down
2 changes: 2 additions & 0 deletions tools/egg-bundler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
"@eggjs/core": "workspace:*",
"@utoo/pack": "catalog:",
"execa": "catalog:",
"js-yaml": "catalog:",
"tsx": "catalog:"
},
"devDependencies": {
Expand All @@ -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:",
Expand Down
123 changes: 120 additions & 3 deletions tools/egg-bundler/src/lib/Bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, unknown>;

interface BundleManifest {
readonly version: number;
Expand All @@ -55,6 +60,114 @@ function wrapStep<T>(step: string, fn: () => Promise<T>): Promise<T> {
});
}

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<string, string> = {};
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);
}
Comment thread
killagu marked this conversation as resolved.

return Object.keys(alias).length > 0 ? { resolve: { alias } } : undefined;
}

async function loadModuleBundlePackConfig(baseDir: string): Promise<BundlerConfig['pack']> {
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 } : {}),
};
Comment thread
killagu marked this conversation as resolved.
}

export function sanitizeBundleOutputRelativePath(relativeName: string): string {
const normalized = relativeName.replace(/\\/g, '/');
const segments = normalized.split('/');
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 10 additions & 2 deletions tools/egg-bundler/src/lib/PackRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type BuildFunc = (config: { config: unknown }, projectPath: string, rootP

export interface PackRunnerResolveConfig {
readonly alias?: Readonly<Record<string, string>>;
readonly [key: string]: unknown;
}

export interface PackRunnerOptions {
Expand Down Expand Up @@ -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<readonly string[]> {
Expand Down
115 changes: 115 additions & 0 deletions tools/egg-bundler/test/Bundler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});
Loading
Loading