Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
eac1949
feat(utils,core): add bundler runtime support APIs
killagu Apr 11, 2026
f6f0579
feat(bundler): add @eggjs/egg-bundler package
killagu Apr 11, 2026
0775c7e
feat(bundler): define BundlerConfig public API
killagu Apr 11, 2026
1fcc323
feat(core): add ManifestStore bundle store hook
killagu Apr 11, 2026
ec05987
feat(bundler): add EntryGenerator
killagu Apr 11, 2026
2bf4985
feat(egg-bin): add bundle subcommand
killagu Apr 11, 2026
eb06786
test(egg-bundler): unit tests for EntryGenerator and PackRunner (T9, …
killagu Apr 11, 2026
1c36565
feat(bundler): add Bundler orchestration
killagu Apr 11, 2026
9762836
test(bundler): add integration tests for minimal-app bundle
killagu Apr 11, 2026
55c2f40
fix(bundler): inject tsx loader into generate-manifest subprocess
killagu Apr 11, 2026
828e4b9
test(bundler): add tegg-app fixture with HTTPController + service
killagu Apr 11, 2026
27ac2a4
test(bundler): verify no-fs-scan contract and bundle determinism (T13…
killagu Apr 11, 2026
6119c44
fix(bundler): copy generate-manifest.mjs into dist
killagu Apr 11, 2026
ce53d94
fix(bundler): correct tsdown copy destination for generate-manifest.mjs
killagu Apr 11, 2026
9351cd0
fix(bundler): align tsdown config with workspace convention (unbundled)
killagu Apr 11, 2026
c15f71f
fix(bundler): pass wrapped config to @utoo/pack in DEFAULT_BUILD_FUNC
killagu Apr 11, 2026
b0d75fd
feat(bundler): resolve runtime blockers for cnpmcore E2E full pass
killagu Apr 12, 2026
d6c25b1
fix(bundler): listen HTTP port after startEgg in generated worker entry
killagu Apr 12, 2026
d3515c7
feat(core,utils,plugins,bundler): enable @eggjs/* packages to be bund…
killagu Apr 12, 2026
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
39 changes: 39 additions & 0 deletions packages/core/src/loader/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,32 @@ export class ManifestStore {
* Load and validate manifest from `.egg/manifest.json`.
* Returns null if manifest doesn't exist or is invalid.
*/
/**
* Register a pre-built manifest store for bundled egg apps. When set,
* `ManifestStore.load()` returns this store unconditionally, bypassing
* disk reads and invalidation checks. The bundler-generated entry calls
* this at startup before creating the Application.
*
* Uses globalThis so that bundled and external copies of @eggjs/core
* share the same store instance.
*/
static setBundleStore(store: ManifestStore | undefined): void {
(globalThis as any).__EGG_BUNDLE_STORE__ = store;
}

/**
* Return the registered bundle store, if any.
*/
static getBundleStore(): ManifestStore | undefined {
return (globalThis as any).__EGG_BUNDLE_STORE__;
}

static load(baseDir: string, serverEnv: string, serverScope: string): ManifestStore | null {
const bundleStore: ManifestStore | undefined = (globalThis as any).__EGG_BUNDLE_STORE__;
if (bundleStore) {
debug('load: returning registered bundle store');
return bundleStore;
}
Comment on lines 76 to +81
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Scope-check the registered bundle store before returning it.

Line 77 currently short-circuits load() for any non-empty global store. In multi-app or reused-process scenarios, this can return a manifest from a different baseDir/env/scope and bypass the normal validation path.

Proposed fix
 static load(baseDir: string, serverEnv: string, serverScope: string): ManifestStore | null {
-  const bundleStore: ManifestStore | undefined = (globalThis as any).__EGG_BUNDLE_STORE__;
-  if (bundleStore) {
+  const bundleStore = ManifestStore.getBundleStore();
+  if (
+    bundleStore &&
+    bundleStore.baseDir === baseDir &&
+    bundleStore.data.invalidation.serverEnv === serverEnv &&
+    bundleStore.data.invalidation.serverScope === serverScope
+  ) {
     debug('load: returning registered bundle store');
     return bundleStore;
   }
+  if (bundleStore) {
+    debug('load: ignore registered bundle store due context mismatch');
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/loader/manifest.ts` around lines 76 - 81, The current load
method returns any global bundle store unconditionally; change it to verify the
registered bundleStore matches the requested context before returning by
checking its identifying properties against the incoming baseDir, serverEnv and
serverScope (e.g., bundleStore.baseDir, bundleStore.serverEnv,
bundleStore.serverScope or a provided bundleStore.scope/equals method) and only
return it when all identifiers match; if they don’t match, fall through to the
normal loading/validation path and emit a debug log stating the mismatch.

if (serverEnv === 'local' && process.env.EGG_MANIFEST !== 'true') {
debug('skip manifest in local env (set EGG_MANIFEST=true to enable)');
return null;
Expand Down Expand Up @@ -84,6 +109,20 @@ export class ManifestStore {
return new ManifestStore(data, baseDir);
}

/**
* Create a ManifestStore from pre-validated bundled data.
* Skips invalidation checks — the caller (bundler) is responsible for
* guaranteeing the data matches the shipped artifact.
*/
static fromBundle(data: StartupManifest, baseDir: string): ManifestStore {
if (data.version !== MANIFEST_VERSION) {
throw new Error(
`[@eggjs/core] bundled manifest version mismatch: expected ${MANIFEST_VERSION}, got ${data.version}`,
);
}
return new ManifestStore(data, baseDir);
}

/**
* Create a collector-only ManifestStore (no cached data).
* Used during normal startup to collect data for future manifest generation.
Expand Down
2 changes: 2 additions & 0 deletions packages/egg/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"./lib/loader/AgentWorkerLoader": "./src/lib/loader/AgentWorkerLoader.ts",
"./lib/loader/AppWorkerLoader": "./src/lib/loader/AppWorkerLoader.ts",
"./lib/loader/EggApplicationLoader": "./src/lib/loader/EggApplicationLoader.ts",
"./lib/snapshot": "./src/lib/snapshot.ts",
"./lib/start": "./src/lib/start.ts",
"./lib/types": "./src/lib/types.ts",
"./lib/types.plugin": "./src/lib/types.plugin.ts",
Expand Down Expand Up @@ -125,6 +126,7 @@
"./lib/loader/AgentWorkerLoader": "./dist/lib/loader/AgentWorkerLoader.js",
"./lib/loader/AppWorkerLoader": "./dist/lib/loader/AppWorkerLoader.js",
"./lib/loader/EggApplicationLoader": "./dist/lib/loader/EggApplicationLoader.js",
"./lib/snapshot": "./dist/lib/snapshot.js",
"./lib/start": "./dist/lib/start.js",
"./lib/types": "./dist/lib/types.js",
"./lib/types.plugin": "./dist/lib/types.plugin.js",
Expand Down
35 changes: 32 additions & 3 deletions packages/utils/src/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,8 +376,6 @@ export function importResolve(filepath: string, options?: ImportResolveOptions):
*/
export type SnapshotModuleLoader = (resolvedPath: string) => any;

let _snapshotModuleLoader: SnapshotModuleLoader | undefined;

/**
* Register a snapshot module loader that intercepts `importModule()` calls.
*
Expand All @@ -388,15 +386,46 @@ let _snapshotModuleLoader: SnapshotModuleLoader | undefined;
*
* Also sets `isESM = false` because the snapshot bundle is CJS and
* esbuild's `import.meta` polyfill causes incorrect ESM detection.
*
* Uses globalThis so that bundled and external copies share the same loader.
*/
export function setSnapshotModuleLoader(loader: SnapshotModuleLoader): void {
_snapshotModuleLoader = loader;
(globalThis as any).__EGG_SNAPSHOT_MODULE_LOADER__ = loader;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

可以给 globalThis 扩展一个类型定义,这样就不需要靠 any 了

isESM = false;
}

/**
* Module loader for bundled egg apps. Called with the raw `importModule()`
* filepath (posix-normalized) before `importResolve`, so bundled apps can
* serve modules that no longer exist on disk. Return `undefined` to fall
* through to the standard import path.
*/
export type BundleModuleLoader = (filepath: string) => unknown;

/**
* Register a bundle module loader. Uses globalThis so that bundled and
* external copies of @eggjs/utils share the same loader.
*/
export function setBundleModuleLoader(loader: BundleModuleLoader | undefined): void {
(globalThis as any).__EGG_BUNDLE_MODULE_LOADER__ = loader;
if (loader) isESM = false;
Comment on lines +406 to +411
}

export async function importModule(filepath: string, options?: ImportModuleOptions): Promise<any> {
const _bundleModuleLoader: BundleModuleLoader | undefined = (globalThis as any).__EGG_BUNDLE_MODULE_LOADER__;
if (_bundleModuleLoader) {
const hit = _bundleModuleLoader(filepath.replaceAll('\\', '/'));
if (hit !== undefined) {
let obj = hit as any;
if (obj?.default?.__esModule === true && 'default' in obj.default) obj = obj.default;
if (options?.importDefaultOnly && obj && typeof obj === 'object' && 'default' in obj) obj = obj.default;
return obj;
}
}

const moduleFilePath = importResolve(filepath, options);

const _snapshotModuleLoader: SnapshotModuleLoader | undefined = (globalThis as any).__EGG_SNAPSHOT_MODULE_LOADER__;
if (_snapshotModuleLoader) {
let obj = _snapshotModuleLoader(moduleFilePath);
if (obj && typeof obj === 'object' && obj.default?.__esModule === true && obj.default && 'default' in obj.default) {
Expand Down
1 change: 1 addition & 0 deletions packages/utils/test/__snapshots__/index.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ exports[`test/index.test.ts > export all > should keep checking 1`] = `
"importResolve",
"isESM",
"isSupportTypeScript",
"setBundleModuleLoader",
"setSnapshotModuleLoader",
]
`;
63 changes: 63 additions & 0 deletions packages/utils/test/bundle-import.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { strict as assert } from 'node:assert';

import { afterEach, describe, it } from 'vitest';

import { importModule, setBundleModuleLoader } from '../src/import.ts';
import { getFilepath } from './helper.ts';

describe('test/bundle-import.test.ts', () => {
afterEach(() => {
setBundleModuleLoader(undefined);
});

it('returns the real module when no bundle loader is registered', async () => {
const result = await importModule(getFilepath('esm'));
assert.ok(result);
assert.equal(typeof result, 'object');
});

it('intercepts importModule with the registered loader', async () => {
const seen: string[] = [];
const fakeModule = { default: { hello: 'bundle' }, other: 'stuff' };
setBundleModuleLoader((p) => {
seen.push(p);
if (p.endsWith('/fixtures/esm')) return fakeModule;
});

const result = await importModule(getFilepath('esm'));
assert.deepEqual(result, fakeModule);
assert.ok(seen.some((p) => p.endsWith('/fixtures/esm')));
});

it('honors importDefaultOnly when the bundle hit has a default key', async () => {
setBundleModuleLoader(() => ({ default: { greet: 'hi' }, other: 'x' }));

const result = await importModule(getFilepath('esm'), { importDefaultOnly: true });
assert.deepEqual(result, { greet: 'hi' });
});

it('unwraps __esModule double-default shape', async () => {
setBundleModuleLoader(() => ({
default: { __esModule: true, default: { fn: 'bundled' } },
}));

const result = await importModule(getFilepath('esm'));
assert.equal(result.__esModule, true);
assert.deepEqual(result.default, { fn: 'bundled' });
});

it('falls through to normal import when loader returns undefined', async () => {
setBundleModuleLoader(() => undefined);

const result = await importModule(getFilepath('esm'));
assert.ok(result);
});

it('short-circuits importResolve so bundled paths need not exist on disk', async () => {
const fakeModule = { virtual: true };
setBundleModuleLoader((p) => (p === 'virtual/not-on-disk' ? fakeModule : undefined));

const result = await importModule('virtual/not-on-disk');
assert.deepEqual(result, fakeModule);
});
});
2 changes: 2 additions & 0 deletions plugins/development/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"./agent": "./src/agent.ts",
"./app": "./src/app.ts",
"./app/middleware/egg_loader_trace": "./src/app/middleware/egg_loader_trace.ts",
"./app/middleware/loader_trace_template": "./src/app/middleware/loader_trace_template.ts",
"./config/config.default": "./src/config/config.default.ts",
"./types": "./src/types.ts",
"./utils": "./src/utils.ts",
Expand All @@ -41,6 +42,7 @@
"./agent": "./dist/agent.js",
"./app": "./dist/app.js",
"./app/middleware/egg_loader_trace": "./dist/app/middleware/egg_loader_trace.js",
"./app/middleware/loader_trace_template": "./dist/app/middleware/loader_trace_template.js",
"./config/config.default": "./dist/config/config.default.js",
"./types": "./dist/types.js",
"./utils": "./dist/utils.js",
Expand Down
5 changes: 2 additions & 3 deletions plugins/development/src/app/middleware/egg_loader_trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,15 @@ import type { Application, MiddlewareFunc } from 'egg';
import { readJSON } from 'utility';

import { isTimingFile } from '../../utils.ts';
import { LOADER_TRACE_TEMPLATE } from './loader_trace_template.ts';

export default function createEggLoaderTraceMiddleware(_options: unknown, app: Application): MiddlewareFunc {
return async (ctx, next) => {
if (ctx.path !== '/__loader_trace__') {
return await next();
}
const templatePath = path.join(import.meta.dirname, 'loader_trace.html');
const template = await fs.readFile(templatePath, 'utf8');
const data = await loadTimingData(app);
ctx.body = template.replace('{{placeholder}}', JSON.stringify(data));
ctx.body = LOADER_TRACE_TEMPLATE.replace('{{placeholder}}', JSON.stringify(data));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, locate and read the target file
cat -n plugins/development/src/app/middleware/egg_loader_trace.ts

Repository: eggjs/egg

Length of output: 1624


🏁 Script executed:

# Read the template file
cat -n plugins/development/src/app/middleware/loader_trace_template.ts

Repository: eggjs/egg

Length of output: 1585


🏁 Script executed:

# Search for ctx.helper or escape-related functions in the codebase
rg "helper\.escape|ctx\.helper" --type ts -A 2 -B 2 | head -50

Repository: eggjs/egg

Length of output: 4625


🏁 Script executed:

# Search for escape function in security or core context
rg "escape\s*\(" --type ts plugins/security/ -B 3 -A 5 | head -100

Repository: eggjs/egg

Length of output: 681


🏁 Script executed:

# Find the escape helper test
rg "escape" plugins/security/test/app/extends/helper.test.ts -B 5 -A 10

Repository: eggjs/egg

Length of output: 1540


🏁 Script executed:

# Search for escape helper implementation in security plugin
rg "escape.*=" plugins/security/src --type ts -B 2 -A 5 | head -80

Repository: eggjs/egg

Length of output: 1151


🏁 Script executed:

# Find escape function definition
fd ".*helper.*" plugins/security/src -type f | xargs grep -l "escape"

Repository: eggjs/egg

Length of output: 225


🏁 Script executed:

# Find escape function definition
find plugins/security/src -name "*.ts" -type f | xargs grep -l "escape"

Repository: eggjs/egg

Length of output: 355


🏁 Script executed:

# Read the escape helper
cat -n plugins/security/src/lib/helper/escape.ts

Repository: eggjs/egg

Length of output: 139


🏁 Script executed:

# Check sjson helper which might be for safe JSON in scripts
cat -n plugins/security/src/lib/helper/sjson.ts

Repository: eggjs/egg

Length of output: 1082


🏁 Script executed:

# Check sjs helper which is used by sjson
cat -n plugins/security/src/lib/helper/sjs.ts

Repository: eggjs/egg

Length of output: 1729


🏁 Script executed:

# Check the helper index to see what's exported
cat -n plugins/security/src/lib/helper/index.ts

Repository: eggjs/egg

Length of output: 1011


🏁 Script executed:

# Verify that sjson is available by checking core context extensions
rg "sjson" plugins/security/src -A 3 -B 1 | head -30

Repository: eggjs/egg

Length of output: 924


Use ctx.helper.sjson() instead of JSON.stringify() for JSON embedded in script context.

JSON.stringify(data) inserted directly into a <script> block (line 11 of the template) can break script context if the data contains characters like </script> or certain unicode sequences.

The framework provides ctx.helper.sjson(), designed specifically for "escape json for output in script". This helper properly escapes all JavaScript-sensitive characters and sanitizes object keys:

-    ctx.body = LOADER_TRACE_TEMPLATE.replace('{{placeholder}}', JSON.stringify(data));
+    ctx.body = LOADER_TRACE_TEMPLATE.replace('{{placeholder}}', ctx.helper.sjson(data));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ctx.body = LOADER_TRACE_TEMPLATE.replace('{{placeholder}}', JSON.stringify(data));
ctx.body = LOADER_TRACE_TEMPLATE.replace('{{placeholder}}', ctx.helper.sjson(data));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plugins/development/src/app/middleware/egg_loader_trace.ts` at line 16, The
template injection currently uses JSON.stringify(data) when setting ctx.body
using LOADER_TRACE_TEMPLATE, which can break script contexts; replace the
JSON.stringify call with ctx.helper.sjson(data) so the JSON is escaped for safe
embedding in a <script> block, i.e. update the expression that builds ctx.body
to use ctx.helper.sjson(data) instead of JSON.stringify(data) (ensure ctx and
ctx.helper.sjson are available in the middleware).

};
}

Expand Down
51 changes: 51 additions & 0 deletions plugins/development/src/app/middleware/loader_trace_template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/** Loader trace visualization template - inlined from loader_trace.html */
export const LOADER_TRACE_TEMPLATE = `<!doctype html>
<html lang="en">
<head>
<title></title>
</head>
<body>
<div id="mountNode"></div>
<script src="https://gw.alipayobjects.com/os/antv/assets/g2/3.0.9/g2.min.js"></script>
<script>
var data = {{placeholder}};

const chart = new G2.Chart({
container: 'mountNode', // 指定图表容器 ID
height: data.length * 22, // 指定图表高度
forceFit: true,
padding: 'auto',
});

// Step 2: 载入数据源
chart.source(data, {
range: {
type: 'time',
mask: 'HH:mm:ss',
nice: true,
},
});
chart
.coord()
.transpose()
.scale(1, -1);

chart.legend({ position: 'top' });

chart.tooltip({
showTitle: false,
itemTpl: '<li>{duration}ms: {name} </li>'
});

chart
.interval()
.position('title*range')
.color('type')
.tooltip('name*duration', (name, duration) => ({ name, duration }))
.size(5);

chart.render();
</script>
</body>
</html>
`;
2 changes: 2 additions & 0 deletions plugins/onerror/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"./app": "./src/app.ts",
"./config/config.default": "./src/config/config.default.ts",
"./lib/error_view": "./src/lib/error_view.ts",
"./lib/onerror_page": "./src/lib/onerror_page.ts",
"./lib/utils": "./src/lib/utils.ts",
"./types": "./src/types.ts",
"./package.json": "./package.json"
Expand All @@ -40,6 +41,7 @@
"./app": "./dist/app.js",
"./config/config.default": "./dist/config/config.default.js",
"./lib/error_view": "./dist/lib/error_view.js",
"./lib/onerror_page": "./dist/lib/onerror_page.js",
"./lib/utils": "./dist/lib/utils.js",
"./types": "./dist/types.js",
"./package.json": "./package.json"
Expand Down
3 changes: 2 additions & 1 deletion plugins/onerror/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { onerror, type OnerrorOptions, type OnerrorError } from 'koa-onerror';

import type { OnerrorConfig } from './config/config.default.ts';
import { ErrorView } from './lib/error_view.ts';
import { ONERROR_PAGE_TEMPLATE } from './lib/onerror_page.ts';
import { isProd, detectStatus, detectErrorMessage, accepts } from './lib/utils.ts';

export interface OnerrorErrorWithCode extends OnerrorError {
Expand All @@ -23,7 +24,7 @@ export default class Boot implements ILifecycleBoot {
async didLoad(): Promise<void> {
// logging error
const config = this.app.config.onerror;
const viewTemplate = fs.readFileSync(config.templatePath, 'utf8');
const viewTemplate = config.templatePath ? fs.readFileSync(config.templatePath, 'utf8') : ONERROR_PAGE_TEMPLATE;
const app = this.app;
app.on('error', (err, ctx) => {
if (!ctx) {
Expand Down
8 changes: 4 additions & 4 deletions plugins/onerror/src/config/config.default.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import path from 'node:path';

import type { Context } from 'egg';
import type { OnerrorError, OnerrorOptions } from 'koa-onerror';

Expand All @@ -20,7 +18,9 @@ export interface OnerrorConfig extends OnerrorOptions {
*/
appErrorFilter?: (err: OnerrorError, ctx: Context) => boolean;
/**
* default template path
* Custom template path. If empty, uses the built-in error page template.
*
* Default: `''`
*/
templatePath: string;
}
Expand All @@ -29,6 +29,6 @@ export default {
onerror: {
errorPageUrl: '',
appErrorFilter: undefined,
templatePath: path.join(import.meta.dirname, '../lib/onerror_page.mustache.html'),
templatePath: '',
} as OnerrorConfig,
};
Loading
Loading