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
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
2 changes: 2 additions & 0 deletions tools/egg-bundler/src/lib/Bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { load as yamlLoad } from 'js-yaml';
import type { BundlerConfig, BundleResult } from '../index.ts';
import { EntryGenerator } from './EntryGenerator.ts';
import { ExternalsResolver } from './ExternalsResolver.ts';
import { assertFrameworkPackageSpecifier } from './frameworkSpecifier.ts';
import { ManifestLoader } from './ManifestLoader.ts';
import { PackRunner } from './PackRunner.ts';

Expand Down Expand Up @@ -204,6 +205,7 @@ export class Bundler {

const absBaseDir = path.resolve(baseDir);
const absOutputDir = path.resolve(absBaseDir, rawOutputDir);
assertFrameworkPackageSpecifier(framework);
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
137 changes: 86 additions & 51 deletions tools/egg-bundler/src/lib/EntryGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { debuglog } from 'node:util';

import type { StartupManifest } from '@eggjs/core';

import { assertFrameworkPackageSpecifier } from './frameworkSpecifier.ts';
import type { ManifestLoader } from './ManifestLoader.ts';

const debug = debuglog('egg/bundler/entry-generator');
Expand Down Expand Up @@ -55,6 +56,7 @@ export class EntryGenerator {
this.#loader = options.manifestLoader;
this.#outputDir = options.outputDir ?? path.join(options.baseDir, '.egg-bundle', 'entries');
this.#framework = options.framework ?? 'egg';
assertFrameworkPackageSpecifier(this.#framework);
this.#externals = options.externals ?? new Set();
}

Expand Down Expand Up @@ -176,6 +178,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 +232,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 +260,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 +336,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
Loading
Loading