Skip to content
Open
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
18 changes: 12 additions & 6 deletions tools/egg-bundler/docs/output-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ the chunks.
├── _turbopack__runtime.js.map
├── tsconfig.json # written by PackRunner; SWC reads decorator options from here
├── package.json # written by PackRunner; `{ "type": "commonjs" }` so node parses *.js as CJS
└── bundle-manifest.json # written by Bundler; reference / debug metadata
└── bundle-manifest.json # written by Bundler; runtime metadata plus debug metadata
```

Chunk filenames prefixed with `_turbopack__` or `_root-of-the-server___` come
Expand All @@ -32,14 +32,20 @@ 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, framework, mode: 'single' })`. The worker reads
`bundle-manifest.json` at startup to recover the original app `baseDir` and
configured `framework`, while still using its own output directory to locate the
bundle artifact. 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`

A reference file produced by `Bundler` (not consumed at runtime). Shape:
A runtime metadata file produced by `Bundler`. The worker reads `baseDir` and
`framework` from this file during startup, so deployment must keep it next to
`worker.js`; a missing, unreadable, or malformed manifest is a startup error.
The remaining fields are reference / debug metadata. Shape:

```json
{
Expand Down
1 change: 0 additions & 1 deletion tools/egg-bundler/src/lib/Bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,6 @@ export class Bundler {
const entryGen = new EntryGenerator({
baseDir: absBaseDir,
manifestLoader,
framework,
externals: new Set(Object.keys(externalsMap)),
});
const entries = await wrapStep('entry generation', () => entryGen.generate());
Expand Down
107 changes: 53 additions & 54 deletions tools/egg-bundler/src/lib/EntryGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ export interface EntryGeneratorOptions {
baseDir: string;
manifestLoader: ManifestLoader;
outputDir?: string;
framework?: string;
externals?: ReadonlySet<string>;
}

Expand Down Expand Up @@ -47,14 +46,12 @@ export class EntryGenerator {
readonly #baseDir: string;
readonly #loader: ManifestLoader;
readonly #outputDir: string;
readonly #framework: string;
readonly #externals: ReadonlySet<string>;

constructor(options: EntryGeneratorOptions) {
this.#baseDir = options.baseDir;
this.#loader = options.manifestLoader;
this.#outputDir = options.outputDir ?? path.join(options.baseDir, '.egg-bundle', 'entries');
this.#framework = options.framework ?? 'egg';
this.#externals = options.externals ?? new Set();
}
Comment thread
killagu marked this conversation as resolved.

Expand Down Expand Up @@ -194,15 +191,14 @@ export class EntryGenerator {
}

const manifestJson = JSON.stringify(manifest, null, 2);
const frameworkSpec = JSON.stringify(this.#toFrameworkImportSpecifier());

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(__appBaseDir, '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 @@ -212,17 +208,39 @@ for (const [key, spec] of __EXTERNAL_SPECS) {

return `// ⚠️ auto-generated by @eggjs/egg-bundler — do not edit
/* eslint-disable */
import fs from 'node:fs';
import path from 'node:path';

import { ManifestStore } from '@eggjs/core';
import { startEgg } from ${frameworkSpec};
import { Agent as __EggAgent, Application as __EggApplication, startEgg } from "egg";

${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 = process.argv[1] ? path.dirname(path.resolve(process.argv[1])) : process.cwd();
type __BundleManifest = { baseDir: string; framework: string };
function __readBundleManifest(): __BundleManifest {
const manifestPath = path.join(__outputDir, 'bundle-manifest.json');
let data: Record<string, unknown> | null;
try {
data = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record<string, unknown> | null;
} catch (error) {
throw new Error(\`[egg-bundler] failed to read \${manifestPath}: \${String(error)}\`);
}
if (!data || typeof data !== 'object' || typeof data.baseDir !== 'string' || typeof data.framework !== 'string') {
throw new Error(\`[egg-bundler] invalid bundle-manifest.json: \${manifestPath}\`);
}
return {
baseDir: data.baseDir,
framework: data.framework,
};
}

const __bundleManifest = __readBundleManifest();
const __appBaseDir = path.resolve(__outputDir, __bundleManifest.baseDir);
const __framework = __bundleManifest.framework;

const MANIFEST_DATA = ${manifestJson} as const;

Expand All @@ -231,23 +249,44 @@ ${mapLines.join('\n')}
};
${externalBlock}
const __BUNDLE_MAP: Record<string, unknown> = {};
function __toMapKey(filepath: string): string {
return filepath.split(path.win32.sep).join(path.posix.sep);
}

function __setBundleMapAlias(key: string | undefined, mod: unknown): void {
if (!key) return;
__BUNDLE_MAP[__toMapKey(key)] = mod;
}

if (__framework === 'egg') {
__setBundleMapAlias(__framework, { Agent: __EggAgent, Application: __EggApplication });
}

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;
__setBundleMapAlias(rel, mod);
__setBundleMapAlias(path.resolve(__appBaseDir, rel), mod);
__setBundleMapAlias(path.resolve(__outputDir, rel), mod);
}

for (const [requestRel, resolvedRel] of Object.entries(MANIFEST_DATA.resolveCache ?? {})) {
if (!resolvedRel) continue;
const mod = __BUNDLE_MAP[__toMapKey(resolvedRel)];
if (mod === undefined) continue;
__setBundleMapAlias(requestRel, mod);
__setBundleMapAlias(path.resolve(__appBaseDir, requestRel), mod);
__setBundleMapAlias(path.resolve(__outputDir, requestRel), 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, __appBaseDir));
__bundleGlobalThis.__EGG_BUNDLE_MODULE_LOADER__ = (filepath) => {
const key = filepath.split(path.sep).join('/');
const key = __toMapKey(filepath);
return __BUNDLE_MAP[key];
};

startEgg({ baseDir: __baseDir, mode: 'single' }).then((app) => {
startEgg({ baseDir: __appBaseDir, 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 +300,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
68 changes: 22 additions & 46 deletions tools/egg-bundler/test/EntryGenerator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,15 +171,22 @@ describe('EntryGenerator', () => {
const worker = await fs.readFile(result.workerEntry, 'utf8');

expect(worker).toContain("import { ManifestStore } from '@eggjs/core'");
expect(worker).toContain('import { startEgg } from "egg"');
expect(worker).toContain('import { Agent as __EggAgent, Application as __EggApplication, startEgg } from "egg"');
expect(worker).toContain(
'const __outputDir = process.argv[1] ? path.dirname(path.resolve(process.argv[1])) : process.cwd()',
);
expect(worker).toContain('const __appBaseDir = path.resolve(__outputDir, __bundleManifest.baseDir)');
expect(worker).toContain('const __framework = __bundleManifest.framework');
expect(worker).toContain('ManifestStore.setBundleStore(ManifestStore.fromBundle(MANIFEST_DATA');
expect(worker).toContain('__EGG_BUNDLE_MODULE_LOADER__');
expect(worker).toContain("startEgg({ baseDir: __baseDir, mode: 'single' })");
expect(worker).toContain('__setBundleMapAlias(__framework, { Agent: __EggAgent, Application: __EggApplication })');
expect(worker).toContain("startEgg({ baseDir: __appBaseDir, framework: __framework, mode: 'single' })");
});

it('builds a BUNDLE_MAP keyed by both the relKey form and the resolved absolute form', async () => {
it('builds a BUNDLE_MAP keyed by relKey, app absolute, output absolute, and resolveCache request aliases', async () => {
const manifest = makeManifest({
fileDiscovery: { app: ['controller.ts'] },
resolveCache: { 'app/controller': 'app/controller.ts' },
});

const gen = new EntryGenerator({ baseDir: tmpDir, manifestLoader: createFakeLoader(manifest) });
Expand All @@ -188,8 +195,13 @@ describe('EntryGenerator', () => {

expect(worker).toContain('__BUNDLE_MAP_REL');
expect(worker).toContain('["app/controller.ts"]: __m0');
expect(worker).toContain('__BUNDLE_MAP[abs] = mod');
expect(worker).toContain('__BUNDLE_MAP[rel] = mod');
expect(worker).toContain('__setBundleMapAlias(rel, mod)');
expect(worker).toContain('__setBundleMapAlias(path.resolve(__appBaseDir, rel), mod)');
expect(worker).toContain('__setBundleMapAlias(path.resolve(__outputDir, rel), mod)');
expect(worker).toContain(
'for (const [requestRel, resolvedRel] of Object.entries(MANIFEST_DATA.resolveCache ?? {}))',
);
expect(worker).toContain('__setBundleMapAlias(path.resolve(__appBaseDir, requestRel), mod)');
});

it('loads externalized package files via createRequire instead of static imports', async () => {
Expand Down Expand Up @@ -242,7 +254,7 @@ describe('EntryGenerator', () => {
const worker = await fs.readFile(result.workerEntry, 'utf8');

expect(extractImports(worker).length).toBe(0);
expect(worker).toContain("startEgg({ baseDir: __baseDir, mode: 'single' })");
expect(worker).toContain("startEgg({ baseDir: __appBaseDir, framework: __framework, mode: 'single' })");
expect(worker).toContain('__EGG_BUNDLE_MODULE_LOADER__');
expect(worker).toContain('ManifestStore.setBundleStore');
});
Expand Down Expand Up @@ -270,53 +282,17 @@ describe('EntryGenerator', () => {
expect(path.dirname(result.workerEntry)).toBe(customOut);
});

it('honors a custom framework specifier', async () => {
const gen = new EntryGenerator({
baseDir: tmpDir,
framework: '@my-org/framework',
manifestLoader: createFakeLoader(makeManifest()),
});
const result = await gen.generate();
const worker = await fs.readFile(result.workerEntry, 'utf8');

expect(worker).toContain('import { startEgg } from "@my-org/framework"');
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' }));

it('uses framework from bundle-manifest.json at runtime', async () => {
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);
expect(worker).toContain('import { Agent as __EggAgent, Application as __EggApplication, startEgg } from "egg"');
expect(worker).toContain('const __framework = __bundleManifest.framework');
expect(worker).toContain("startEgg({ baseDir: __appBaseDir, framework: __framework, mode: 'single' })");
});

it('produces byte-identical worker output across independent baseDir runs (T17 determinism baseline)', async () => {
Expand Down
Loading
Loading