Skip to content

Commit f2b9334

Browse files
authored
feat(bundler): add EntryGenerator with sorted static imports (#5883)
Generates `worker.entry.ts` + `agent.entry.ts` stub. Worker entry: deterministic sorted static imports, internal/external split (`createRequire` for externals), inlined `MANIFEST_DATA`, dual-keyed `BUNDLE_MAP` via `setBundleModuleLoader`, `process.argv[1]` baseDir, `startEgg({mode:'single'})` + `app.listen()`. 13 unit tests + canonical snapshot. Part of #5863 split. Tracking: #5871. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents fa8c1b3 + ebb103d commit f2b9334

4 files changed

Lines changed: 683 additions & 0 deletions

File tree

tools/egg-bundler/package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
"types": "./src/index.ts",
2525
"import": "./src/index.ts"
2626
},
27+
"./lib/EntryGenerator": {
28+
"types": "./src/lib/EntryGenerator.ts",
29+
"import": "./src/lib/EntryGenerator.ts"
30+
},
2731
"./package.json": "./package.json"
2832
},
2933
"publishConfig": {
@@ -33,6 +37,10 @@
3337
"types": "./dist/index.d.ts",
3438
"import": "./dist/index.js"
3539
},
40+
"./lib/EntryGenerator": {
41+
"types": "./dist/lib/EntryGenerator.d.ts",
42+
"import": "./dist/lib/EntryGenerator.js"
43+
},
3644
"./package.json": "./package.json"
3745
}
3846
},
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
import fs from 'node:fs/promises';
2+
import { createRequire } from 'node:module';
3+
import path from 'node:path';
4+
import { pathToFileURL } from 'node:url';
5+
import { debuglog } from 'node:util';
6+
7+
import type { StartupManifest } from '@eggjs/core';
8+
9+
import type { ManifestLoader } from './ManifestLoader.ts';
10+
11+
const debug = debuglog('egg/bundler/entry-generator');
12+
13+
export interface EntryGeneratorOptions {
14+
baseDir: string;
15+
manifestLoader: ManifestLoader;
16+
outputDir?: string;
17+
framework?: string;
18+
externals?: ReadonlySet<string>;
19+
}
20+
21+
export interface GeneratedEntries {
22+
workerEntry: string;
23+
entryDir: string;
24+
}
25+
26+
interface BundleEntry {
27+
/** posix path relative to the runtime baseDir, e.g. "app/controller/home.ts" or "node_modules/@eggjs/static/app/middleware/static.ts" */
28+
relKey: string;
29+
/** absolute path at bundle time, used in the static `import` statement so @utoo/pack can reach the module */
30+
absBundle: string;
31+
/** when true, this entry belongs to an externalized package and must not be statically imported */
32+
external?: boolean;
33+
/** bare package specifier with subpath for runtime require(), e.g. "@eggjs/onerror/config/config.default" */
34+
bareSpecifier?: string;
35+
}
36+
37+
interface TeggModuleDescriptor {
38+
unitPath: string;
39+
decoratedFiles?: string[];
40+
}
41+
42+
interface TeggManifestExtension {
43+
moduleDescriptors?: TeggModuleDescriptor[];
44+
}
45+
46+
export class EntryGenerator {
47+
readonly #baseDir: string;
48+
readonly #loader: ManifestLoader;
49+
readonly #outputDir: string;
50+
readonly #framework: string;
51+
readonly #externals: ReadonlySet<string>;
52+
53+
constructor(options: EntryGeneratorOptions) {
54+
this.#baseDir = options.baseDir;
55+
this.#loader = options.manifestLoader;
56+
this.#outputDir = options.outputDir ?? path.join(options.baseDir, '.egg-bundle', 'entries');
57+
this.#framework = options.framework ?? 'egg';
58+
this.#externals = options.externals ?? new Set();
59+
}
60+
61+
async generate(): Promise<GeneratedEntries> {
62+
const manifest = await this.#loader.load();
63+
const entries = this.#collectBundleEntries(manifest);
64+
debug('collected %d bundle entries', entries.length);
65+
66+
await fs.mkdir(this.#outputDir, { recursive: true });
67+
68+
const workerEntry = path.join(this.#outputDir, 'worker.entry.ts');
69+
70+
await fs.writeFile(workerEntry, this.#renderWorkerEntry(entries, manifest));
71+
72+
return {
73+
workerEntry,
74+
entryDir: this.#outputDir,
75+
};
76+
}
77+
78+
#collectBundleEntries(manifest: StartupManifest): BundleEntry[] {
79+
const map = new Map<string, BundleEntry>();
80+
81+
// 1. Every file discovered during loading
82+
for (const [relDir, files] of Object.entries(manifest.fileDiscovery)) {
83+
for (const file of files) {
84+
this.#addEntry(map, this.#joinPosix(relDir, file));
85+
}
86+
}
87+
88+
// 2. Every non-null resolveCache target (extensions, plugin app.ts, middlewares…)
89+
for (const value of Object.values(manifest.resolveCache)) {
90+
if (value) this.#addEntry(map, value);
91+
}
92+
93+
// 3. Tegg decorated files (unitPath is either absolute or node_modules-normalized)
94+
const tegg = manifest.extensions?.tegg as TeggManifestExtension | undefined;
95+
if (tegg?.moduleDescriptors) {
96+
for (const desc of tegg.moduleDescriptors) {
97+
for (const rel of desc.decoratedFiles ?? []) {
98+
const relKey = this.#teggRelKey(desc.unitPath, rel);
99+
if (relKey) this.#addEntry(map, relKey);
100+
}
101+
}
102+
}
103+
104+
return Array.from(map.values()).sort((a, b) => {
105+
if (a.relKey < b.relKey) return -1;
106+
if (a.relKey > b.relKey) return 1;
107+
return 0;
108+
});
109+
}
110+
111+
#addEntry(map: Map<string, BundleEntry>, relKey: string): void {
112+
const normalized = relKey.replaceAll(path.sep, '/');
113+
if (map.has(normalized)) return;
114+
const absBundle = this.#absFromRelKey(normalized);
115+
const entry: BundleEntry = { relKey: normalized, absBundle };
116+
117+
const pkgInfo = this.#extractPackageInfo(normalized);
118+
if (pkgInfo && this.#externals.has(pkgInfo.name)) {
119+
entry.external = true;
120+
entry.bareSpecifier = pkgInfo.subpath ? `${pkgInfo.name}/${pkgInfo.subpath}` : pkgInfo.name;
121+
}
122+
123+
map.set(normalized, entry);
124+
}
125+
126+
#extractPackageInfo(relKey: string): { name: string; subpath: string } | undefined {
127+
if (!relKey.startsWith('node_modules/')) return undefined;
128+
const rest = relKey.slice('node_modules/'.length);
129+
const slashIdx = rest.startsWith('@') ? rest.indexOf('/', rest.indexOf('/') + 1) : rest.indexOf('/');
130+
if (slashIdx === -1) return { name: rest, subpath: '' };
131+
const name = rest.slice(0, slashIdx);
132+
let subpath = rest.slice(slashIdx + 1);
133+
// Strip dist/ prefix and only known-safe runtime extensions for bare specifier resolution.
134+
// Preserve significant extensions such as .cjs/.mjs and multi-part names like .d.ts.
135+
// e.g. "dist/config/config.default.js" → "config/config.default"
136+
subpath = subpath.replace(/^dist\//, '');
137+
if (subpath.endsWith('.js')) {
138+
subpath = subpath.slice(0, -'.js'.length);
139+
}
140+
return { name, subpath };
141+
}
142+
143+
#absFromRelKey(relKey: string): string {
144+
if (path.isAbsolute(relKey)) return relKey;
145+
if (relKey.startsWith('node_modules/')) {
146+
const req = createRequire(path.join(this.#baseDir, 'package.json'));
147+
const rest = relKey.slice('node_modules/'.length);
148+
const slashIdx = rest.startsWith('@') ? rest.indexOf('/', rest.indexOf('/') + 1) : rest.indexOf('/');
149+
const pkgName = slashIdx === -1 ? rest : rest.slice(0, slashIdx);
150+
const sub = slashIdx === -1 ? '' : rest.slice(slashIdx + 1);
151+
try {
152+
const pkgJson = req.resolve(`${pkgName}/package.json`);
153+
return path.resolve(path.dirname(pkgJson), sub);
154+
} catch {
155+
return path.resolve(this.#baseDir, relKey);
156+
}
157+
}
158+
return path.resolve(this.#baseDir, relKey);
159+
}
160+
161+
#teggRelKey(unitPath: string, rel: string): string | undefined {
162+
if (path.isAbsolute(unitPath)) {
163+
const abs = path.resolve(unitPath, rel);
164+
const relToBase = path.relative(this.#baseDir, abs).replaceAll(path.sep, '/');
165+
if (!relToBase || relToBase.startsWith('..')) return undefined;
166+
return relToBase;
167+
}
168+
return this.#joinPosix(unitPath, rel);
169+
}
170+
171+
#joinPosix(...parts: string[]): string {
172+
return parts
173+
.filter(Boolean)
174+
.map((p) => p.replaceAll(path.sep, '/'))
175+
.join('/')
176+
.replaceAll(/\/+/g, '/');
177+
}
178+
179+
#renderWorkerEntry(entries: BundleEntry[], manifest: StartupManifest): string {
180+
const importLines: string[] = [];
181+
const mapLines: string[] = [];
182+
const externalSpecs: Array<[string, string]> = [];
183+
184+
let internalIdx = 0;
185+
for (const entry of entries) {
186+
if (entry.external && entry.bareSpecifier) {
187+
externalSpecs.push([entry.relKey, entry.bareSpecifier]);
188+
} else {
189+
const specifier = this.#toImportSpecifier(entry.absBundle);
190+
importLines.push(`import * as __m${internalIdx} from ${JSON.stringify(specifier)};`);
191+
mapLines.push(` [${JSON.stringify(entry.relKey)}]: __m${internalIdx},`);
192+
internalIdx++;
193+
}
194+
}
195+
196+
const manifestJson = JSON.stringify(manifest, null, 2);
197+
const frameworkSpec = JSON.stringify(this.#framework);
198+
199+
const externalBlock =
200+
externalSpecs.length > 0
201+
? `
202+
// External-package files: loaded at runtime via require(), not bundled.
203+
// Uses createRequire + dynamic specifiers so @utoo/pack cannot trace them.
204+
import { createRequire as __createRequire } from 'node:module';
205+
const __rtReq = __createRequire(path.join(__baseDir, 'package.json'));
206+
const __EXTERNAL_SPECS: Array<[string, string]> = ${JSON.stringify(externalSpecs)};
207+
for (const [key, spec] of __EXTERNAL_SPECS) {
208+
__BUNDLE_MAP_REL[key] = __rtReq(spec);
209+
}
210+
`
211+
: '';
212+
213+
return `// ⚠️ auto-generated by @eggjs/egg-bundler — do not edit
214+
/* eslint-disable */
215+
import path from 'node:path';
216+
217+
import { ManifestStore } from '@eggjs/core';
218+
import { setBundleModuleLoader } from '@eggjs/utils';
219+
import { startEgg } from ${frameworkSpec};
220+
221+
${importLines.join('\n')}
222+
223+
// Derive the runtime output directory from the entry file being executed.
224+
// Cannot use __dirname because turbopack replaces it with the compile-time
225+
// path of the INPUT file, not the OUTPUT directory.
226+
const __baseDir = path.dirname(path.resolve(process.argv[1] || '.'));
227+
228+
const MANIFEST_DATA = ${manifestJson} as const;
229+
230+
const __BUNDLE_MAP_REL: Record<string, unknown> = {
231+
${mapLines.join('\n')}
232+
};
233+
${externalBlock}
234+
const __BUNDLE_MAP: Record<string, unknown> = {};
235+
for (const [rel, mod] of Object.entries(__BUNDLE_MAP_REL)) {
236+
const abs = path.resolve(__baseDir, rel).split(path.sep).join('/');
237+
__BUNDLE_MAP[abs] = mod;
238+
// Also key by posix join so callers that already hand us posix paths hit.
239+
__BUNDLE_MAP[rel] = mod;
240+
}
241+
242+
ManifestStore.setBundleStore(ManifestStore.fromBundle(MANIFEST_DATA as any, __baseDir));
243+
setBundleModuleLoader((filepath) => {
244+
const key = filepath.split(path.sep).join('/');
245+
return __BUNDLE_MAP[key];
246+
});
247+
248+
startEgg({ baseDir: __baseDir, mode: 'single' }).then((app) => {
249+
const port = process.env.PORT || app.config.cluster?.listen?.port || 7001;
250+
app.listen(port, () => {
251+
// eslint-disable-next-line no-console
252+
console.log('[egg-bundler] server listening on port %s', port);
253+
});
254+
}).catch((err) => {
255+
// eslint-disable-next-line no-console
256+
console.error('[egg-bundler] failed to start bundled app:', err);
257+
process.exit(1);
258+
});
259+
`;
260+
}
261+
262+
#toImportSpecifier(absPath: string): string {
263+
// Prefer a relative specifier from the entry output dir to keep the
264+
// bundled paths portable across machines (absolute paths would leak
265+
// the bundle-time filesystem layout into the generated source).
266+
const rel = path.relative(this.#outputDir, absPath).replaceAll(path.sep, '/');
267+
if (path.isAbsolute(rel)) return pathToFileURL(absPath).href;
268+
if (rel.startsWith('.')) return rel;
269+
return `./${rel}`;
270+
}
271+
}

0 commit comments

Comments
 (0)