Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
4 changes: 3 additions & 1 deletion tools/egg-bin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,9 @@ node worker.js

- `--output` / `-o` output directory, default to `./dist-bundle`
- `--manifest` path to `manifest.json`, default to `<baseDir>/.egg/manifest.json`
- `--framework` / `-f` framework name or absolute path
- `--framework` / `-f` framework package specifier, defaulting to
`package.json#egg.framework` or `egg`. Absolute framework paths are not
supported by the bundled runtime.
- `--mode` build mode, `production` or `development`, default to `production`
- `--no-tegg` accepted by the CLI, but not applied by the current bundler
implementation yet
Expand Down
18 changes: 15 additions & 3 deletions tools/egg-bin/src/commands/bundle.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { debuglog } from 'node:util';

import { getFrameworkPath } from '@eggjs/utils';
import { Flags } from '@oclif/core';

import { BaseCommand } from '../baseCommand.ts';
Expand Down Expand Up @@ -35,6 +35,18 @@ function parsePackAliases(values: readonly string[], baseDir: string): Record<st
return alias;
}

async function getBundleFrameworkSpecifier(baseDir: string, framework?: string): Promise<string> {
if (framework) return framework;

const pkgPath = path.join(baseDir, 'package.json');
const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8')) as {
egg?: {
framework?: unknown;
};
};
return typeof pkg.egg?.framework === 'string' && pkg.egg.framework ? pkg.egg.framework : 'egg';
}

export default class Bundle extends BaseCommand<typeof Bundle> {
static override description = 'Bundle an egg app into a deployable artifact using @eggjs/egg-bundler';

Expand All @@ -57,7 +69,7 @@ export default class Bundle extends BaseCommand<typeof Bundle> {
}),
framework: Flags.string({
char: 'f',
description: 'framework name or absolute path',
description: 'framework package specifier',
}),
mode: Flags.string({
description: 'build mode',
Expand Down Expand Up @@ -110,7 +122,7 @@ export default class Bundle extends BaseCommand<typeof Bundle> {
baseDir,
outputDir,
manifestPath,
framework: getFrameworkPath({ framework: flags.framework, baseDir }),
framework: await getBundleFrameworkSpecifier(baseDir, flags.framework),
mode: getBundleMode(flags.mode),
tegg: !flags['no-tegg'],
externals: {
Expand Down
25 changes: 21 additions & 4 deletions tools/egg-bin/test/commands/bundle.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import path from 'node:path';

import { getFrameworkPath } from '@eggjs/utils';
import { describe, expect, it, vi, beforeEach } from 'vitest';

import Bundle from '../../src/commands/bundle.ts';
Expand Down Expand Up @@ -32,7 +31,7 @@ describe('test/commands/bundle.test.ts', () => {
baseDir,
outputDir: path.join(baseDir, 'dist-bundle'),
manifestPath: undefined,
framework: getFrameworkPath({ baseDir }),
framework: 'aliyun-egg',
mode: 'production',
tegg: true,
externals: {
Expand Down Expand Up @@ -66,7 +65,7 @@ describe('test/commands/bundle.test.ts', () => {
baseDir,
outputDir: path.join(baseDir, 'bundle-output'),
manifestPath: path.join(baseDir, '.egg/custom-manifest.json'),
framework: getFrameworkPath({ baseDir }),
framework: 'aliyun-egg',
mode: 'development',
tegg: false,
externals: {
Expand All @@ -91,7 +90,7 @@ describe('test/commands/bundle.test.ts', () => {
baseDir,
outputDir: path.join(baseDir, 'dist-bundle'),
manifestPath: undefined,
framework: getFrameworkPath({ baseDir }),
framework: 'aliyun-egg',
mode: 'production',
tegg: true,
externals: {
Expand All @@ -108,4 +107,22 @@ describe('test/commands/bundle.test.ts', () => {
},
});
});

it('should pass framework package specifier without resolving it to an absolute path', async () => {
await Bundle.run(['--base', baseDir, '--framework', '@my-org/framework']);

expect(bundleMock).toHaveBeenCalledTimes(1);
expect(bundleMock).toHaveBeenCalledWith({
baseDir,
outputDir: path.join(baseDir, 'dist-bundle'),
manifestPath: undefined,
framework: '@my-org/framework',
mode: 'production',
tegg: true,
externals: {
force: [],
inline: [],
},
});
});
});
2 changes: 1 addition & 1 deletion tools/egg-bundler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ not run.
| `baseDir` | Application root directory. Required. |
| `outputDir` | Output directory for the bundled artifact. Required. |
| `manifestPath` | Path to `manifest.json`. Defaults to `<baseDir>/.egg/manifest.json`. |
| `framework` | Framework name or absolute path. Defaults to `egg`. |
| `framework` | Framework package specifier. Defaults to `egg`; absolute paths are unsupported. |
| `mode` | Build mode, `production` or `development`. Defaults to `production`. |
| `tegg` | Accepted by `BundlerConfig`, but not applied by the current implementation yet. |
| `externals.force` | Package names to always keep external. |
Expand Down
11 changes: 7 additions & 4 deletions tools/egg-bundler/docs/output-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,13 @@ node worker.js

The worker entry installs `ManifestStore.setBundleStore(...)` and
`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.
`startEgg({ baseDir: outputDir, framework, mode: 'single' })`, so framework
specifier lookup is served by the already imported bundled framework module,
without adding framework path aliases. Runtime lookup keeps
the deploy output directory separate from the original app paths: the bundle map
is keyed by relKey, output-dir absolute paths, precomputed original app absolute
paths, and manifest `resolveCache` request aliases. Application code and plugins
may still use `fs` for resources such as config, views, or assets.

## `bundle-manifest.json`

Expand Down
2 changes: 1 addition & 1 deletion tools/egg-bundler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export interface BundlerConfig {
readonly outputDir: string;
/** Path to manifest.json. Defaults to `<baseDir>/.egg/manifest.json`. */
readonly manifestPath?: string;
/** Framework name or absolute path. Defaults to `'egg'`. */
/** Framework package specifier. Defaults to `'egg'`; absolute framework paths are not supported by bundle runtime. */
readonly framework?: string;
/** Build mode. Defaults to `'production'`. */
readonly mode?: 'production' | 'development';
Expand Down
5 changes: 5 additions & 0 deletions tools/egg-bundler/src/lib/Bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,11 @@ export class Bundler {

const absBaseDir = path.resolve(baseDir);
const absOutputDir = path.resolve(absBaseDir, rawOutputDir);
if (path.isAbsolute(framework)) {
throw new Error(
`[@eggjs/egg-bundler] framework must be a package specifier for bundled runtime, got absolute path: ${framework}`,
);
Comment thread
killagu marked this conversation as resolved.
Outdated
}
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)),
Expand Down
140 changes: 89 additions & 51 deletions tools/egg-bundler/src/lib/EntryGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ export class EntryGenerator {
this.#loader = options.manifestLoader;
this.#outputDir = options.outputDir ?? path.join(options.baseDir, '.egg-bundle', 'entries');
this.#framework = options.framework ?? 'egg';
if (path.isAbsolute(this.#framework)) {
throw new Error(
`[@eggjs/egg-bundler] framework must be a package specifier for bundled runtime, got absolute path: ${this.#framework}`,
);
}
Comment thread
killagu marked this conversation as resolved.
Outdated
this.#externals = options.externals ?? new Set();
}

Expand Down Expand Up @@ -176,6 +181,42 @@ export class EntryGenerator {
.replaceAll(/\/+/g, '/');
}

#collectResolveCacheAliases(manifest: StartupManifest): Array<[string, string]> {
const aliases: Array<[string, string]> = [];
for (const [requestRel, targetRel] of Object.entries(manifest.resolveCache)) {
if (typeof targetRel !== 'string') continue;
for (const requestAbs of this.#absoluteAliasKeys(requestRel)) {
aliases.push([requestAbs, targetRel]);
}
}
return this.#uniqueAliasPairs(aliases).sort(([left], [right]) => left.localeCompare(right));
}

#normalizeKey(filepath: string): string {
return filepath.replaceAll(path.sep, '/');
}

#absoluteAliasKeys(relKey: string): string[] {
const keys = new Set<string>();
keys.add(this.#normalizeKey(this.#absFromRelKey(relKey)));
if (!path.isAbsolute(relKey)) {
keys.add(this.#normalizeKey(path.resolve(this.#baseDir, relKey)));
}
return [...keys];
}

#uniqueAliasPairs(pairs: Array<[string, string]>): Array<[string, string]> {
const seen = new Set<string>();
const unique: Array<[string, string]> = [];
for (const pair of pairs) {
const key = JSON.stringify(pair);
if (seen.has(key)) continue;
seen.add(key);
unique.push(pair);
}
return unique;
}

#renderWorkerEntry(entries: BundleEntry[], manifest: StartupManifest): string {
const importLines: string[] = [];
const mapLines: string[] = [];
Expand All @@ -194,15 +235,21 @@ export class EntryGenerator {
}

const manifestJson = JSON.stringify(manifest, null, 2);
const frameworkSpec = JSON.stringify(this.#toFrameworkImportSpecifier());
const appAbsoluteAliases = JSON.stringify(
this.#uniqueAliasPairs(
entries.flatMap((entry) => this.#absoluteAliasKeys(entry.relKey).map((abs) => [abs, entry.relKey])),
),
);
const appResolveCacheAliases = JSON.stringify(this.#collectResolveCacheAliases(manifest));
Comment thread
killagu marked this conversation as resolved.
const frameworkSpec = JSON.stringify(this.#framework);

const externalBlock =
externalSpecs.length > 0
? `
// External-package files: loaded at runtime via require(), not bundled.
// Uses createRequire + dynamic specifiers so @utoo/pack cannot trace them.
import { createRequire as __createRequire } from 'node:module';
const __rtReq = __createRequire(path.join(__baseDir, 'package.json'));
const __rtReq = __createRequire(path.join(__outputDir, 'package.json'));
const __EXTERNAL_SPECS: Array<[string, string]> = ${JSON.stringify(externalSpecs)};
for (const [key, spec] of __EXTERNAL_SPECS) {
__BUNDLE_MAP_REL[key] = __rtReq(spec);
Expand All @@ -216,38 +263,69 @@ import path from 'node:path';

import { ManifestStore } from '@eggjs/core';
import { startEgg } from ${frameworkSpec};
import * as __frameworkModule from ${frameworkSpec};
Comment thread
killagu marked this conversation as resolved.

${importLines.join('\n')}

// Derive the runtime output directory from the entry file being executed.
// Cannot use __dirname because turbopack replaces it with the compile-time
// path of the INPUT file, not the OUTPUT directory.
const __baseDir = path.dirname(path.resolve(process.argv[1] || '.'));
const __outputDir = path.dirname(path.resolve(process.argv[1] || '.'));
const __framework = ${frameworkSpec};

const MANIFEST_DATA = ${manifestJson} as const;
const __APP_ABSOLUTE_ALIASES: Array<[string, string]> = ${appAbsoluteAliases};
const __APP_RESOLVE_CACHE_ALIASES: Array<[string, string]> = ${appResolveCacheAliases};

const __BUNDLE_MAP_REL: Record<string, unknown> = {
${mapLines.join('\n')}
};
${externalBlock}
const __BUNDLE_MAP: Record<string, unknown> = {};
const __normalizeBundleKey = (filepath: string) => filepath.split(path.sep).join('/');
const __setBundleMap = (filepath: string, mod: unknown) => {
__BUNDLE_MAP[__normalizeBundleKey(filepath)] = mod;
};
const __getBundleMap = (filepath: string) => __BUNDLE_MAP[__normalizeBundleKey(filepath)];
const __setBundleAliases = (rel: string, mod: unknown) => {
__setBundleMap(rel, mod);
if (!path.isAbsolute(rel)) {
__setBundleMap(path.resolve(__outputDir, rel), mod);
}
};
Comment thread
killagu marked this conversation as resolved.
__setBundleMap(__framework, __frameworkModule);
for (const [rel, mod] of Object.entries(__BUNDLE_MAP_REL)) {
const abs = path.resolve(__baseDir, rel).split(path.sep).join('/');
__BUNDLE_MAP[abs] = mod;
// Also key by posix join so callers that already hand us posix paths hit.
__BUNDLE_MAP[rel] = mod;
__setBundleAliases(rel, mod);
}
for (const [appAbs, targetRel] of __APP_ABSOLUTE_ALIASES) {
const mod = __getBundleMap(targetRel);
if (mod !== undefined) {
__setBundleMap(appAbs, mod);
}
}
for (const [requestRel, targetRel] of Object.entries(MANIFEST_DATA.resolveCache)) {
if (!targetRel) continue;
const mod = __getBundleMap(targetRel) ?? __getBundleMap(path.resolve(__outputDir, targetRel));
if (mod !== undefined) {
__setBundleAliases(requestRel, mod);
}
}
for (const [appAbsRequest, targetRel] of __APP_RESOLVE_CACHE_ALIASES) {
const mod = __getBundleMap(targetRel);
if (mod !== undefined) {
__setBundleMap(appAbsRequest, mod);
}
}

const __bundleGlobalThis = globalThis as typeof globalThis & {
__EGG_BUNDLE_MODULE_LOADER__?: (filepath: string) => unknown;
};
ManifestStore.setBundleStore(ManifestStore.fromBundle(MANIFEST_DATA as any, __baseDir));
ManifestStore.setBundleStore(ManifestStore.fromBundle(MANIFEST_DATA as any, __outputDir));
__bundleGlobalThis.__EGG_BUNDLE_MODULE_LOADER__ = (filepath) => {
const key = filepath.split(path.sep).join('/');
return __BUNDLE_MAP[key];
return __getBundleMap(filepath);
};

Comment thread
killagu marked this conversation as resolved.
startEgg({ baseDir: __baseDir, mode: 'single' }).then((app) => {
startEgg({ baseDir: __outputDir, framework: __framework, mode: 'single' }).then((app) => {
Comment thread
killagu marked this conversation as resolved.
const port = process.env.PORT || app.config.cluster?.listen?.port || 7001;
app.listen(port, () => {
// eslint-disable-next-line no-console
Expand All @@ -261,46 +339,6 @@ 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
Expand Down
20 changes: 20 additions & 0 deletions tools/egg-bundler/test/Bundler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,26 @@ describe('Bundler', () => {
});
});

it('rejects absolute framework paths before generating bundle entries', async () => {
const frameworkDir = path.join(tmpApp, 'node_modules/custom-egg');

await expect(
bundle({
baseDir: tmpApp,
outputDir: tmpOutput,
framework: frameworkDir,
pack: {
buildFunc: async () => {
await fs.writeFile(path.join(tmpOutput, 'worker.js'), '// worker\n');
},
},
}),
).rejects.toThrow('framework must be a package specifier for bundled runtime');

expect(mocks.manifestLoaderOptions).toHaveLength(0);
expect(mocks.entryGenerate).not.toHaveBeenCalled();
});

it('passes application supplied pack resolve aliases into the pack build config', async () => {
let packResolve: unknown;
const alias = {
Expand Down
Loading
Loading