Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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 @@ -49,11 +49,36 @@ export class ManifestStore {

// --- Factory Methods ---

/**
* 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__;
}

/**
* Load and validate manifest from `.egg/manifest.json`.
* Returns null if manifest doesn't exist or is invalid.
*/
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;
}
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
28 changes: 28 additions & 0 deletions packages/utils/src/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,35 @@ export function setSnapshotModuleLoader(loader: SnapshotModuleLoader): void {
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;
}

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);

if (_snapshotModuleLoader) {
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);
});
});
1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ catalog:
'@types/urijs': ^1.19.25
'@types/vary': ^1.1.3
'@typescript/native-preview': 7.0.0-dev.20260117.1
'@utoo/pack': ^1.2.7
'@vitest/coverage-v8': ^4.0.15
'@vitest/ui': ^4.0.15
accepts: ^1.3.8
Expand Down
3 changes: 3 additions & 0 deletions tools/egg-bin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"exports": {
".": "./src/index.ts",
"./baseCommand": "./src/baseCommand.ts",
"./commands/bundle": "./src/commands/bundle.ts",
"./commands/cov": "./src/commands/cov.ts",
"./commands/dev": "./src/commands/dev.ts",
"./commands/manifest": "./src/commands/manifest.ts",
Expand All @@ -41,6 +42,7 @@
"exports": {
".": "./dist/index.js",
"./baseCommand": "./dist/baseCommand.js",
"./commands/bundle": "./dist/commands/bundle.js",
"./commands/cov": "./dist/commands/cov.js",
"./commands/dev": "./dist/commands/dev.js",
"./commands/manifest": "./dist/commands/manifest.js",
Expand All @@ -59,6 +61,7 @@
},
"dependencies": {
"@eggjs/core": "workspace:*",
"@eggjs/egg-bundler": "workspace:*",
"@eggjs/tegg-vitest": "workspace:*",
"@eggjs/utils": "workspace:*",
"@oclif/core": "catalog:",
Expand Down
89 changes: 89 additions & 0 deletions tools/egg-bin/src/commands/bundle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import path from 'node:path';
import { debuglog } from 'node:util';

import { Flags } from '@oclif/core';

import { BaseCommand } from '../baseCommand.ts';

const debug = debuglog('egg/bin/commands/bundle');

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

static override examples = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> --output ./dist-bundle',
'<%= config.bin %> <%= command.id %> --mode development',
'<%= config.bin %> <%= command.id %> --framework egg --output ./out',
];

static override flags = {
output: Flags.string({
char: 'o',
description: 'output directory for the bundled artifact',
default: './dist-bundle',
}),
manifest: Flags.string({
description: 'path to manifest.json (defaults to <baseDir>/.egg/manifest.json)',
}),
framework: Flags.string({
char: 'f',
description: 'framework name or absolute path',
default: 'egg',
}),
mode: Flags.string({
description: 'build mode',
options: ['production', 'development'],
default: 'production',
}),
'no-tegg': Flags.boolean({
description: 'disable tegg decoratedFile collection',
default: false,
}),
'force-external': Flags.string({
description: 'package name to always mark as external (repeatable)',
multiple: true,
}),
'inline-external': Flags.string({
description: 'package name to force-inline even if auto-detected as external (repeatable)',
multiple: true,
}),
};

public async run(): Promise<void> {
const { flags } = this;
const baseDir = flags.base;
const outputDir = path.isAbsolute(flags.output) ? flags.output : path.join(baseDir, flags.output);
const manifestPath = flags.manifest
? path.isAbsolute(flags.manifest)
? flags.manifest
: path.join(baseDir, flags.manifest)
: undefined;

debug(
'bundle: baseDir=%s, outputDir=%s, framework=%s, mode=%s, tegg=%s',
baseDir,
outputDir,
flags.framework,
flags.mode,
!flags['no-tegg'],
);

const { bundle } = await import('@eggjs/egg-bundler');
const result = await bundle({
baseDir,
outputDir,
manifestPath,
framework: flags.framework,
mode: flags.mode as 'production' | 'development',
tegg: !flags['no-tegg'],
externals: {
force: flags['force-external'],
inline: flags['inline-external'],
},
});

this.log(`bundled to ${result.outputDir} (${result.files.length} files)`);
this.log(`manifest: ${result.manifestPath}`);
}
}
3 changes: 2 additions & 1 deletion tools/egg-bin/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import Bundle from './commands/bundle.ts';
import Cov from './commands/cov.ts';
import Dev from './commands/dev.ts';
import Manifest from './commands/manifest.ts';
import Test from './commands/test.ts';

export { Test, Cov, Dev, Manifest };
export { Test, Cov, Dev, Manifest, Bundle };

export * from './baseCommand.ts';
export * from './types.ts';
Expand Down
3 changes: 3 additions & 0 deletions tools/egg-bundler/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Integration test runtime output (fixture manifest + .egg-bundle/entries)
test/fixtures/apps/*/.egg/
test/fixtures/apps/*/.egg-bundle/
79 changes: 79 additions & 0 deletions tools/egg-bundler/docs/output-structure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Bundle output structure

`@eggjs/egg-bundler` produces a self-contained, runnable CJS bundle under the
configured `outputDir`. Everything except declared externals is inlined into
the chunks.

## Layout

```
<outputDir>/
├── worker.js # main entry chunk produced from the synthetic worker.entry.ts
├── worker.js.map # sourcemap for the worker entry
├── _root-of-the-server___<hash>.js # module graph chunk (@utoo/pack)
├── _root-of-the-server___<hash>.js.map
├── _turbopack__runtime.js # @utoo/pack runtime shim
├── _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
```

Chunk filenames prefixed with `_turbopack__` or `_root-of-the-server___` come
from `@utoo/pack`'s internal chunking; exact names (and their count) can
change across @utoo/pack versions, so treat them as opaque.

## Running the bundle

```bash
cd <outputDir>
node worker.js
```

The worker entry installs `ManifestStore.setBundleStore(...)` and
`setBundleModuleLoader(...)` before calling `startEgg({ baseDir, mode: 'single' })`,
so all framework file discovery and module resolution is served from the
inlined bundle map — no `fs.readdir` scanning at runtime.

## `bundle-manifest.json`

A reference file produced by `Bundler` (not consumed at runtime). Shape:

```json
{
"version": 1,
"generatedAt": "2026-04-11T00:00:00.000Z",
"mode": "production",
"baseDir": "/abs/path/to/app",
"framework": "egg",
"entries": [
{ "name": "worker", "source": "/abs/path/to/app/.egg-bundle/entries/worker.entry.ts" }
],
"externals": ["egg", "ioredis", "mysql2", "..."],
"chunks": ["worker.js", "worker.js.map", "..."]
}
```

Use it to inspect what went into the bundle or to drive deterministic-bundle
checks (T17).

## Externals

Packages classified as external by `ExternalsResolver` (native addons,
ESM-only packages, peer dependencies, `@eggjs/*`, and the user's
`externals.force` list) are **not** inlined. They must be installed alongside
the bundle — typically by copying the app's `package.json` next to
`worker.js` and running `npm ci --production`, or by deploying the bundle
into an image that already has these dependencies on disk.

## Known limitations

- **Agent process**: the bundled app runs in `mode: 'single'`, so the agent
runs in-process with the worker. Cluster-mode bundles (separate agent
chunk) are not yet supported.
- **Native addons**: always external. If a native module is missing from the
deployment target, the bundle will fail to start at runtime with the usual
Node module resolution error.
- **Tegg**: decorated files listed in `manifest.extensions.tegg` are included
as side-effect imports in the worker entry; if `tegg` is disabled in
`BundlerConfig`, tegg collection is intended to be skipped (not yet wired).
Loading