diff --git a/packages/core/src/loader/manifest.ts b/packages/core/src/loader/manifest.ts index 7674d7f9cd..aa7d2bcc95 100644 --- a/packages/core/src/loader/manifest.ts +++ b/packages/core/src/loader/manifest.ts @@ -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; + } if (serverEnv === 'local' && process.env.EGG_MANIFEST !== 'true') { debug('skip manifest in local env (set EGG_MANIFEST=true to enable)'); return null; @@ -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. diff --git a/packages/egg/package.json b/packages/egg/package.json index 7abfedc9ca..9dfcc98ea1 100644 --- a/packages/egg/package.json +++ b/packages/egg/package.json @@ -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", @@ -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", diff --git a/packages/utils/src/import.ts b/packages/utils/src/import.ts index 8a6e036e21..93d88b1ae2 100644 --- a/packages/utils/src/import.ts +++ b/packages/utils/src/import.ts @@ -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. * @@ -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; 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 { + 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) { diff --git a/packages/utils/test/__snapshots__/index.test.ts.snap b/packages/utils/test/__snapshots__/index.test.ts.snap index a7d626f650..40c81c80d6 100644 --- a/packages/utils/test/__snapshots__/index.test.ts.snap +++ b/packages/utils/test/__snapshots__/index.test.ts.snap @@ -19,6 +19,7 @@ exports[`test/index.test.ts > export all > should keep checking 1`] = ` "importResolve", "isESM", "isSupportTypeScript", + "setBundleModuleLoader", "setSnapshotModuleLoader", ] `; diff --git a/packages/utils/test/bundle-import.test.ts b/packages/utils/test/bundle-import.test.ts new file mode 100644 index 0000000000..88b91efdbe --- /dev/null +++ b/packages/utils/test/bundle-import.test.ts @@ -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); + }); +}); diff --git a/plugins/development/package.json b/plugins/development/package.json index ad9af8cff4..03acf312ea 100644 --- a/plugins/development/package.json +++ b/plugins/development/package.json @@ -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", @@ -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", diff --git a/plugins/development/src/app/middleware/egg_loader_trace.ts b/plugins/development/src/app/middleware/egg_loader_trace.ts index 7bacccfb95..b010c81431 100644 --- a/plugins/development/src/app/middleware/egg_loader_trace.ts +++ b/plugins/development/src/app/middleware/egg_loader_trace.ts @@ -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)); }; } diff --git a/plugins/development/src/app/middleware/loader_trace_template.ts b/plugins/development/src/app/middleware/loader_trace_template.ts new file mode 100644 index 0000000000..4101aef90f --- /dev/null +++ b/plugins/development/src/app/middleware/loader_trace_template.ts @@ -0,0 +1,51 @@ +/** Loader trace visualization template - inlined from loader_trace.html */ +export const LOADER_TRACE_TEMPLATE = ` + + + + + +
+ + + + +`; diff --git a/plugins/onerror/package.json b/plugins/onerror/package.json index 926a44d61e..e21b75a202 100644 --- a/plugins/onerror/package.json +++ b/plugins/onerror/package.json @@ -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" @@ -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" diff --git a/plugins/onerror/src/app.ts b/plugins/onerror/src/app.ts index fe3444c733..a525754d61 100644 --- a/plugins/onerror/src/app.ts +++ b/plugins/onerror/src/app.ts @@ -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 { @@ -23,7 +24,7 @@ export default class Boot implements ILifecycleBoot { async didLoad(): Promise { // 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) { diff --git a/plugins/onerror/src/config/config.default.ts b/plugins/onerror/src/config/config.default.ts index 9aa66b0016..dc7a5e5ee5 100644 --- a/plugins/onerror/src/config/config.default.ts +++ b/plugins/onerror/src/config/config.default.ts @@ -1,5 +1,3 @@ -import path from 'node:path'; - import type { Context } from 'egg'; import type { OnerrorError, OnerrorOptions } from 'koa-onerror'; @@ -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; } @@ -29,6 +29,6 @@ export default { onerror: { errorPageUrl: '', appErrorFilter: undefined, - templatePath: path.join(import.meta.dirname, '../lib/onerror_page.mustache.html'), + templatePath: '', } as OnerrorConfig, }; diff --git a/plugins/onerror/src/lib/onerror_page.ts b/plugins/onerror/src/lib/onerror_page.ts new file mode 100644 index 0000000000..b045973009 --- /dev/null +++ b/plugins/onerror/src/lib/onerror_page.ts @@ -0,0 +1,1336 @@ +/** Error page template - inlined from onerror_page.mustache.html */ +export const ONERROR_PAGE_TEMPLATE = ` + + + + + + + + + + + +
+
+

{{ status }}

+
+

{{ name }} in {{ request.url }}

+
{{ message }}
+
+ + + +
+ + +
+
+ + +
+ +
+ {{#frames}} {{index}} +
+
{{ file }}:{{ line }}:{{ column }}
+
{{ method }}
+
+ {{ context.pre }} {{ context.line }} {{ context.post }} +
+
+ {{/frames}} +
+
+
+
+ +
+

Request Details

+
+
+
URI
+
{{ request.url }}
+
+ +
+
Request Method
+
{{ request.method }}
+
+ +
+
HTTP Version
+
{{ request.httpVersion }}
+
+ +
+
Connection
+
{{ request.connection }}
+
+
+ +

Headers

+
+ {{#request.headers}} +
+
{{ key }}
+
{{ value }}
+
+ {{/request.headers}} +
+ +

Cookies

+
+ {{#request.cookies}} +
+
{{ key }}
+
{{ value }}
+
+ {{/request.cookies}} +
+

AppInfo

+
+
+
baseDir
+
{{ appInfo.baseDir }}
+
+
+
config
+
+
{{ appInfo.config }}
+
+
+
+
+ + + +
+ + +`; diff --git a/plugins/watcher/src/config/config.default.ts b/plugins/watcher/src/config/config.default.ts index 9293af67c1..e410ec6a96 100644 --- a/plugins/watcher/src/config/config.default.ts +++ b/plugins/watcher/src/config/config.default.ts @@ -1,4 +1,6 @@ -import path from 'node:path'; +import type { BaseEventSource } from '../lib/event-sources/base.ts'; +import DefaultEventSource from '../lib/event-sources/default.ts'; +import DevelopmentEventSource from '../lib/event-sources/development.ts'; export interface WatcherConfig { /** @@ -8,9 +10,9 @@ export interface WatcherConfig { type: string; /** * event sources - * key is event source type, value is event source module path + * key is event source type, value is string (module path) or event source class */ - eventSources: Record; + eventSources: Record; } export default { @@ -22,8 +24,8 @@ export default { watcher: { type: 'default', // default event source eventSources: { - default: path.join(import.meta.dirname, '../lib/event-sources/default'), - development: path.join(import.meta.dirname, '../lib/event-sources/development'), + default: DefaultEventSource, + development: DevelopmentEventSource, }, } as WatcherConfig, }; diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 691a60a336..32ff0921c1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -65,6 +65,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 diff --git a/tools/egg-bin/package.json b/tools/egg-bin/package.json index ba4642aa66..899800dcdf 100644 --- a/tools/egg-bin/package.json +++ b/tools/egg-bin/package.json @@ -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", @@ -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", @@ -59,6 +61,7 @@ }, "dependencies": { "@eggjs/core": "workspace:*", + "@eggjs/egg-bundler": "workspace:*", "@eggjs/tegg-vitest": "workspace:*", "@eggjs/utils": "workspace:*", "@oclif/core": "catalog:", diff --git a/tools/egg-bin/src/commands/bundle.ts b/tools/egg-bin/src/commands/bundle.ts new file mode 100644 index 0000000000..6c691e309b --- /dev/null +++ b/tools/egg-bin/src/commands/bundle.ts @@ -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 extends BaseCommand { + 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 /.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 { + 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}`); + } +} diff --git a/tools/egg-bin/src/index.ts b/tools/egg-bin/src/index.ts index c53db0f5b1..7ac2040641 100644 --- a/tools/egg-bin/src/index.ts +++ b/tools/egg-bin/src/index.ts @@ -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'; diff --git a/tools/egg-bundler/.gitignore b/tools/egg-bundler/.gitignore new file mode 100644 index 0000000000..1374d5fc0b --- /dev/null +++ b/tools/egg-bundler/.gitignore @@ -0,0 +1,3 @@ +# Integration test runtime output (T12 fixture manifest + .egg-bundle/entries) +test/fixtures/apps/*/.egg/ +test/fixtures/apps/*/.egg-bundle/ diff --git a/tools/egg-bundler/docs/output-structure.md b/tools/egg-bundler/docs/output-structure.md new file mode 100644 index 0000000000..264b0c45d8 --- /dev/null +++ b/tools/egg-bundler/docs/output-structure.md @@ -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 + +``` +/ +├── worker.js # main entry chunk produced from the synthetic worker.entry.ts +├── worker.js.map # sourcemap for the worker entry +├── _root-of-the-server___.js # module graph chunk (@utoo/pack) +├── _root-of-the-server___.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 +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). diff --git a/tools/egg-bundler/package.json b/tools/egg-bundler/package.json new file mode 100644 index 0000000000..730c9ea357 --- /dev/null +++ b/tools/egg-bundler/package.json @@ -0,0 +1,76 @@ +{ + "name": "@eggjs/egg-bundler", + "version": "0.0.0", + "private": true, + "description": "Bundle an Egg.js application into a deployable artifact using @utoo/pack", + "homepage": "https://github.com/eggjs/egg/tree/next/tools/egg-bundler", + "bugs": { + "url": "https://github.com/eggjs/egg/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/eggjs/egg.git", + "directory": "tools/egg-bundler" + }, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": "./src/index.ts", + "./lib/Bundler": "./src/lib/Bundler.ts", + "./lib/EntryGenerator": "./src/lib/EntryGenerator.ts", + "./lib/ExternalsResolver": "./src/lib/ExternalsResolver.ts", + "./lib/ManifestLoader": "./src/lib/ManifestLoader.ts", + "./lib/PackRunner": "./src/lib/PackRunner.ts", + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": "./dist/index.js", + "./lib/Bundler": "./dist/lib/Bundler.js", + "./lib/EntryGenerator": "./dist/lib/EntryGenerator.js", + "./lib/ExternalsResolver": "./dist/lib/ExternalsResolver.js", + "./lib/ManifestLoader": "./dist/lib/ManifestLoader.js", + "./lib/PackRunner": "./dist/lib/PackRunner.js", + "./package.json": "./package.json" + } + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsgo --noEmit", + "lint": "oxlint --type-aware", + "lint:fix": "npm run lint -- --fix", + "build": "tsdown", + "clean": "rimraf dist", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "@eggjs/core": "workspace:*", + "@utoo/pack": "catalog:", + "tsx": "catalog:" + }, + "devDependencies": { + "@eggjs/controller-plugin": "workspace:*", + "@eggjs/mock": "workspace:*", + "@eggjs/static": "workspace:*", + "@eggjs/tegg": "workspace:*", + "@eggjs/tegg-config": "workspace:*", + "@eggjs/tegg-plugin": "workspace:*", + "@types/node": "catalog:", + "rimraf": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "egg": "workspace:*" + }, + "engines": { + "node": ">=22.18.0" + } +} diff --git a/tools/egg-bundler/src/index.ts b/tools/egg-bundler/src/index.ts new file mode 100644 index 0000000000..a3cd43ab2a --- /dev/null +++ b/tools/egg-bundler/src/index.ts @@ -0,0 +1,60 @@ +export { Bundler } from './lib/Bundler.ts'; +export { EntryGenerator, type EntryGeneratorOptions, type GeneratedEntries } from './lib/EntryGenerator.ts'; +export { ExternalsResolver, type ExternalsConfig, type ExternalsResolverOptions } from './lib/ExternalsResolver.ts'; +export { ManifestLoader, type ManifestLoaderOptions } from './lib/ManifestLoader.ts'; +export { + PackRunner, + type BuildFunc, + type PackEntry, + type PackRunnerOptions, + type PackRunnerResult, +} from './lib/PackRunner.ts'; + +import { Bundler } from './lib/Bundler.ts'; +import type { BuildFunc } from './lib/PackRunner.ts'; + +export interface BundlerExternalsConfig { + /** Package names to always mark as external, in addition to auto-detected ones. */ + readonly force?: readonly string[]; + /** Package names to never mark as external (force inline), overriding auto-detection. */ + readonly inline?: readonly string[]; +} + +export interface BundlerPackConfig { + /** Injection point for tests (T11) to replace the real @utoo/pack build entry. */ + readonly buildFunc?: BuildFunc; + /** Override for the monorepo workspace root. Defaults to auto-detection. */ + readonly rootPath?: string; +} + +export interface BundlerConfig { + /** Application root directory. Required. */ + readonly baseDir: string; + /** Output directory for the bundled artifact. Required. */ + readonly outputDir: string; + /** Path to manifest.json. Defaults to `/.egg/manifest.json`. */ + readonly manifestPath?: string; + /** Framework name or absolute path. Defaults to `'egg'`. */ + readonly framework?: string; + /** Build mode. Defaults to `'production'`. */ + readonly mode?: 'production' | 'development'; + /** External package overrides. */ + readonly externals?: BundlerExternalsConfig; + /** @utoo/pack tuning. */ + readonly pack?: BundlerPackConfig; + /** Enable tegg decoratedFile collection. Defaults to `true`. */ + readonly tegg?: boolean; +} + +export interface BundleResult { + /** Absolute path to the output directory. */ + readonly outputDir: string; + /** All artifact files (absolute paths), sorted. */ + readonly files: readonly string[]; + /** Absolute path to the normalized bundled manifest. */ + readonly manifestPath: string; +} + +export async function bundle(config: BundlerConfig): Promise { + return new Bundler(config).run(); +} diff --git a/tools/egg-bundler/src/lib/Bundler.ts b/tools/egg-bundler/src/lib/Bundler.ts new file mode 100644 index 0000000000..50532e4af3 --- /dev/null +++ b/tools/egg-bundler/src/lib/Bundler.ts @@ -0,0 +1,195 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { debuglog } from 'node:util'; + +import type { BundlerConfig, BundleResult } from '../index.ts'; +import { EntryGenerator } from './EntryGenerator.ts'; +import { ExternalsResolver } from './ExternalsResolver.ts'; +import { ManifestLoader } from './ManifestLoader.ts'; +import { PackRunner } from './PackRunner.ts'; + +const debug = debuglog('egg/bundler/bundler'); + +const BUNDLE_MANIFEST_VERSION = 1; +const BUNDLE_MANIFEST_FILENAME = 'bundle-manifest.json'; + +interface BundleManifest { + readonly version: number; + readonly generatedAt: string; + readonly mode: 'production' | 'development'; + readonly baseDir: string; + readonly framework: string; + readonly entries: readonly { readonly name: string; readonly source: string }[]; + readonly externals: readonly string[]; + readonly chunks: readonly string[]; +} + +function wrapStep(step: string, fn: () => Promise): Promise { + return fn().catch((err: unknown) => { + const cause = err instanceof Error ? err : new Error(String(err)); + const wrapped = new Error(`[@eggjs/egg-bundler] ${step} failed: ${cause.message}`, { cause }); + throw wrapped; + }); +} + +export class Bundler { + readonly #config: BundlerConfig; + + constructor(config: BundlerConfig) { + this.#config = config; + } + + async run(): Promise { + const { + baseDir, + outputDir: rawOutputDir, + manifestPath, + framework = 'egg', + mode = 'production', + externals, + pack, + } = this.#config; + + const absBaseDir = path.resolve(baseDir); + const absOutputDir = path.resolve(absBaseDir, rawOutputDir); + debug('bundle start: baseDir=%s outputDir=%s framework=%s mode=%s', absBaseDir, absOutputDir, framework, mode); + + const manifestLoader = new ManifestLoader({ + baseDir: absBaseDir, + manifestPath, + framework, + }); + await wrapStep('manifest load', () => manifestLoader.load()); + + const externalsResolver = new ExternalsResolver({ + baseDir: absBaseDir, + force: externals?.force, + inline: externals?.inline, + }); + const externalsMap = await wrapStep('externals resolve', () => externalsResolver.resolve()); + debug('externals resolved: %d packages', Object.keys(externalsMap).length); + + const entryGen = new EntryGenerator({ + baseDir: absBaseDir, + manifestLoader, + framework, + externals: new Set(Object.keys(externalsMap)), + }); + const entries = await wrapStep('entry generation', () => entryGen.generate()); + debug('generated worker entry: %s', entries.workerEntry); + + const packRunner = new PackRunner({ + entries: [{ name: 'worker', filepath: entries.workerEntry }], + outputDir: absOutputDir, + externals: externalsMap, + projectPath: absBaseDir, + rootPath: pack?.rootPath, + mode, + buildFunc: pack?.buildFunc, + }); + const packResult = await wrapStep('pack build', () => packRunner.run()); + debug('pack produced %d files', packResult.files.length); + + // turbopack wraps `import.meta` in a throwing getter for bundled ESM + // chunks. Patch the output so `createRequire(import.meta.url)` and + // other `import.meta.url` usages work at runtime. + const patchCount = await wrapStep('patch import.meta.url', () => this.#patchImportMetaUrl(absOutputDir)); + debug('patched %d import.meta.url occurrences', patchCount); + + // Merge project name into output package.json so the framework's + // getAppname() finds it (it reads baseDir/package.json). + const outputPkgPath = path.join(absOutputDir, 'package.json'); + await wrapStep('patch output package.json', async () => { + const srcPkg = JSON.parse(await fs.readFile(path.join(absBaseDir, 'package.json'), 'utf8')) as { + name?: string; + }; + const outPkg = JSON.parse(await fs.readFile(outputPkgPath, 'utf8')) as Record; + if (srcPkg.name) outPkg.name = srcPkg.name; + await fs.writeFile(outputPkgPath, JSON.stringify(outPkg, null, 2)); + }); + + const manifestPathAbs = path.join(absOutputDir, BUNDLE_MANIFEST_FILENAME); + const bundleManifest: BundleManifest = { + version: BUNDLE_MANIFEST_VERSION, + generatedAt: new Date().toISOString(), + mode, + baseDir: absBaseDir, + framework, + entries: [{ name: 'worker', source: entries.workerEntry }], + externals: Object.keys(externalsMap).sort(), + chunks: [...packResult.files].sort(), + }; + await wrapStep('write bundle-manifest', () => + fs.writeFile(manifestPathAbs, JSON.stringify(bundleManifest, null, 2)), + ); + + // Re-enumerate files so bundle-manifest.json is included in the result. + const finalRelFiles = new Set(packResult.files); + finalRelFiles.add(BUNDLE_MANIFEST_FILENAME); + const files = Array.from(finalRelFiles) + .map((rel) => path.join(absOutputDir, rel)) + .sort(); + + return { + outputDir: absOutputDir, + files, + manifestPath: manifestPathAbs, + }; + } + + /** + * Turbopack replaces `import.meta` in bundled ESM chunks with an object + * that only defines a throwing `url` getter and omits `dirname`/`filename`. + * + * We post-process the output .js files in two passes: + * 1. Replace the throwing `url` IIFE with a working `file://` URL. + * 2. Inject `dirname` and `filename` getters so code like + * `path.join(import.meta.dirname, '../lib/asset.html')` works. + */ + async #patchImportMetaUrl(outputDir: string): Promise { + // Pass 1 — fix the throwing url getter. + const THROWING_IIFE = + /\(\(\)\s*=>\s*\{\s*throw\s+new\s+Error\(\s*['"]could not convert import\.meta\.url to filepath['"]\s*\)\s*;?\s*\}\)\s*\(\)/g; + // Avoid require() in the replacement — turbopack modules may declare + // `const require = createRequire(import.meta.url)` which would be in + // the TDZ when our getter runs. Use globals only. + const URL_EXPR = 'new URL("file:///" + encodeURI(process.argv[1])).href'; + + // Pass 2 — add dirname/filename after patching url. + // Match the url-only meta object that results from pass 1. + const META_URL_ONLY = + /var __TURBOPACK__import\$2e\$meta__ = \{\s*get url \(\) \{\s*return new URL\("file:\/\/\/" \+ encodeURI\(process\.argv\[1\]\)\)\.href;\s*\}\s*\};/g; + const META_FULL = `var __TURBOPACK__import$2e$meta__ = { + get url () { + return new URL("file:///" + encodeURI(process.argv[1])).href; + }, + get dirname () { + return process.argv[1].replace(/[\\\\/][^\\\\/]*$/, ""); + }, + get filename () { + return process.argv[1]; + } +};`; + + let totalPatches = 0; + const entries = await fs.readdir(outputDir); + for (const name of entries) { + if (!name.endsWith('.js')) continue; + const filepath = path.join(outputDir, name); + const content = await fs.readFile(filepath, 'utf8'); + + // Pass 1 + const urlMatches = content.match(THROWING_IIFE); + if (!urlMatches) continue; + let patched = content.replace(THROWING_IIFE, URL_EXPR); + + // Pass 2 + patched = patched.replace(META_URL_ONLY, META_FULL); + + await fs.writeFile(filepath, patched); + totalPatches += urlMatches.length; + debug('patched %d import.meta in %s', urlMatches.length, name); + } + return totalPatches; + } +} diff --git a/tools/egg-bundler/src/lib/EntryGenerator.ts b/tools/egg-bundler/src/lib/EntryGenerator.ts new file mode 100644 index 0000000000..0eed664c6d --- /dev/null +++ b/tools/egg-bundler/src/lib/EntryGenerator.ts @@ -0,0 +1,279 @@ +import fs from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { debuglog } from 'node:util'; + +import type { StartupManifest } from '@eggjs/core'; + +import type { ManifestLoader } from './ManifestLoader.ts'; + +const debug = debuglog('egg/bundler/entry-generator'); + +export interface EntryGeneratorOptions { + baseDir: string; + manifestLoader: ManifestLoader; + outputDir?: string; + framework?: string; + externals?: ReadonlySet; +} + +export interface GeneratedEntries { + workerEntry: string; + agentEntry: string; + entryDir: string; +} + +interface BundleEntry { + /** posix path relative to the runtime baseDir, e.g. "app/controller/home.ts" or "node_modules/@eggjs/static/app/middleware/static.ts" */ + relKey: string; + /** absolute path at bundle time, used in the static `import` statement so @utoo/pack can reach the module */ + absBundle: string; + /** when true, this entry belongs to an externalized package and must not be statically imported */ + external?: boolean; + /** bare package specifier with subpath for runtime require(), e.g. "@eggjs/onerror/config/config.default" */ + bareSpecifier?: string; +} + +interface TeggModuleDescriptor { + unitPath: string; + decoratedFiles?: string[]; +} + +interface TeggManifestExtension { + moduleDescriptors?: TeggModuleDescriptor[]; +} + +export class EntryGenerator { + readonly #baseDir: string; + readonly #loader: ManifestLoader; + readonly #outputDir: string; + readonly #framework: string; + readonly #externals: ReadonlySet; + + 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(); + } + + async generate(): Promise { + const manifest = await this.#loader.load(); + const entries = this.#collectBundleEntries(manifest); + debug('collected %d bundle entries', entries.length); + + await fs.mkdir(this.#outputDir, { recursive: true }); + + const workerEntry = path.join(this.#outputDir, 'worker.entry.ts'); + const agentEntry = path.join(this.#outputDir, 'agent.entry.ts'); + + await fs.writeFile(workerEntry, this.#renderWorkerEntry(entries, manifest)); + await fs.writeFile(agentEntry, this.#renderAgentEntry()); + + return { + workerEntry, + agentEntry, + entryDir: this.#outputDir, + }; + } + + #collectBundleEntries(manifest: StartupManifest): BundleEntry[] { + const map = new Map(); + + // 1. Every file discovered during loading + for (const [relDir, files] of Object.entries(manifest.fileDiscovery)) { + for (const file of files) { + this.#addEntry(map, this.#joinPosix(relDir, file)); + } + } + + // 2. Every non-null resolveCache target (extensions, plugin app.ts, middlewares…) + for (const value of Object.values(manifest.resolveCache)) { + if (value) this.#addEntry(map, value); + } + + // 3. Tegg decorated files (unitPath is either absolute or node_modules-normalized) + const tegg = manifest.extensions?.tegg as TeggManifestExtension | undefined; + if (tegg?.moduleDescriptors) { + for (const desc of tegg.moduleDescriptors) { + for (const rel of desc.decoratedFiles ?? []) { + const relKey = this.#teggRelKey(desc.unitPath, rel); + if (relKey) this.#addEntry(map, relKey); + } + } + } + + return Array.from(map.values()).sort((a, b) => a.relKey.localeCompare(b.relKey)); + } + + #addEntry(map: Map, relKey: string): void { + const normalized = relKey.replaceAll(path.sep, '/'); + if (map.has(normalized)) return; + const absBundle = this.#absFromRelKey(normalized); + const entry: BundleEntry = { relKey: normalized, absBundle }; + + const pkgInfo = this.#extractPackageInfo(normalized); + if (pkgInfo && this.#externals.has(pkgInfo.name)) { + entry.external = true; + entry.bareSpecifier = pkgInfo.subpath ? `${pkgInfo.name}/${pkgInfo.subpath}` : pkgInfo.name; + } + + map.set(normalized, entry); + } + + #extractPackageInfo(relKey: string): { name: string; subpath: string } | undefined { + if (!relKey.startsWith('node_modules/')) return undefined; + const rest = relKey.slice('node_modules/'.length); + const slashIdx = rest.startsWith('@') ? rest.indexOf('/', rest.indexOf('/') + 1) : rest.indexOf('/'); + if (slashIdx === -1) return { name: rest, subpath: '' }; + const name = rest.slice(0, slashIdx); + let subpath = rest.slice(slashIdx + 1); + // Strip dist/ prefix and file extension for bare specifier resolution + // e.g. "dist/config/config.default.js" → "config/config.default" + subpath = subpath.replace(/^dist\//, '').replace(/\.[^.]+$/, ''); + return { name, subpath }; + } + + #absFromRelKey(relKey: string): string { + if (path.isAbsolute(relKey)) return relKey; + if (relKey.startsWith('node_modules/')) { + const req = createRequire(path.join(this.#baseDir, 'package.json')); + const rest = relKey.slice('node_modules/'.length); + const slashIdx = rest.startsWith('@') ? rest.indexOf('/', rest.indexOf('/') + 1) : rest.indexOf('/'); + const pkgName = slashIdx === -1 ? rest : rest.slice(0, slashIdx); + const sub = slashIdx === -1 ? '' : rest.slice(slashIdx + 1); + try { + const pkgJson = req.resolve(`${pkgName}/package.json`); + return path.resolve(path.dirname(pkgJson), sub); + } catch { + return path.resolve(this.#baseDir, relKey); + } + } + return path.resolve(this.#baseDir, relKey); + } + + #teggRelKey(unitPath: string, rel: string): string | undefined { + if (path.isAbsolute(unitPath)) { + const abs = path.resolve(unitPath, rel); + const relToBase = path.relative(this.#baseDir, abs).replaceAll(path.sep, '/'); + if (!relToBase || relToBase.startsWith('..')) return undefined; + return relToBase; + } + return this.#joinPosix(unitPath, rel); + } + + #joinPosix(...parts: string[]): string { + return parts + .filter(Boolean) + .map((p) => p.replaceAll(path.sep, '/')) + .join('/') + .replaceAll(/\/+/g, '/'); + } + + #renderWorkerEntry(entries: BundleEntry[], manifest: StartupManifest): string { + const importLines: string[] = []; + const mapLines: string[] = []; + const externalSpecs: Array<[string, string]> = []; + + let internalIdx = 0; + for (const entry of entries) { + if (entry.external && entry.bareSpecifier) { + externalSpecs.push([entry.relKey, entry.bareSpecifier]); + } else { + const specifier = this.#toImportSpecifier(entry.absBundle); + importLines.push(`import * as __m${internalIdx} from ${JSON.stringify(specifier)};`); + mapLines.push(` [${JSON.stringify(entry.relKey)}]: __m${internalIdx},`); + internalIdx++; + } + } + + const manifestJson = JSON.stringify(manifest, null, 2); + 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 __EXTERNAL_SPECS: Array<[string, string]> = ${JSON.stringify(externalSpecs)}; +for (const [key, spec] of __EXTERNAL_SPECS) { + __BUNDLE_MAP_REL[key] = __rtReq(spec); +} +` + : ''; + + return `// ⚠️ auto-generated by @eggjs/egg-bundler — do not edit +/* eslint-disable */ +import path from 'node:path'; + +import { ManifestStore } from '@eggjs/core'; +import { setBundleModuleLoader } from '@eggjs/utils'; +import { startEgg } from ${frameworkSpec}; + +${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 MANIFEST_DATA = ${manifestJson} as const; + +const __BUNDLE_MAP_REL: Record = { +${mapLines.join('\n')} +}; +${externalBlock} +const __BUNDLE_MAP: Record = {}; +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; +} + +ManifestStore.setBundleStore(ManifestStore.fromBundle(MANIFEST_DATA as any, __baseDir)); +setBundleModuleLoader((filepath) => { + const key = filepath.split(path.sep).join('/'); + return __BUNDLE_MAP[key]; +}); + +startEgg({ baseDir: __baseDir, mode: 'single' }).then((app) => { + const port = process.env.PORT || app.config.cluster?.listen?.port || 7001; + app.listen(port, () => { + // eslint-disable-next-line no-console + console.log('[egg-bundler] server listening on port %s', port); + }); +}).catch((err) => { + // eslint-disable-next-line no-console + console.error('[egg-bundler] failed to start bundled app:', err); + process.exit(1); +}); +`; + } + + #renderAgentEntry(): string { + // Single-mode bundled apps run the agent inside the worker process + // (via startEgg). The agent entry exists to satisfy the entry-pair + // contract for T8/T11; if the bundler ever needs a standalone agent + // bundle (cluster mode), this template is the place to expand it. + return `// ⚠️ auto-generated by @eggjs/egg-bundler — do not edit +/* eslint-disable */ +// Single-mode bundled apps run the agent in-process with the worker. +// This stub exists so every bundle exposes a symmetric pair of entries. +// eslint-disable-next-line no-console +console.log('[egg-bundler] agent entry is a no-op in single-mode bundles'); +`; + } + + #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 + // the bundle-time filesystem layout into the generated source). + const rel = path.relative(this.#outputDir, absPath).replaceAll(path.sep, '/'); + if (rel.startsWith('.')) return rel; + return `./${rel}`; + } +} diff --git a/tools/egg-bundler/src/lib/ExternalsResolver.ts b/tools/egg-bundler/src/lib/ExternalsResolver.ts new file mode 100644 index 0000000000..0ff758e0bb --- /dev/null +++ b/tools/egg-bundler/src/lib/ExternalsResolver.ts @@ -0,0 +1,160 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +export interface ExternalsResolverOptions { + readonly baseDir: string; + readonly force?: readonly string[]; + readonly inline?: readonly string[]; +} + +export type ExternalsConfig = Record; + +interface PackageJson { + readonly name?: string; + readonly type?: string; + readonly dependencies?: Record; + readonly peerDependencies?: Record; + readonly scripts?: Record; + readonly exports?: unknown; +} + +const ALWAYS_EXTERNAL_NAMES: ReadonlySet = new Set(['egg', '@swc/helpers']); + +// install-time hooks using one of these tools strongly imply a native addon +const NATIVE_SCRIPT_PATTERN = /node-gyp|prebuild-install|napi-rs|node-pre-gyp|electron-rebuild/i; + +export class ExternalsResolver { + readonly #baseDir: string; + readonly #force: ReadonlySet; + readonly #inline: ReadonlySet; + + constructor(options: ExternalsResolverOptions) { + this.#baseDir = options.baseDir; + this.#force = new Set(options.force ?? []); + this.#inline = new Set(options.inline ?? []); + } + + async resolve(): Promise { + const rootPkg = await this.#readPackageJson(this.#baseDir); + const deps = Object.keys(rootPkg.dependencies ?? {}); + const peerDeps = new Set(Object.keys(rootPkg.peerDependencies ?? {})); + const result: Record = {}; + + for (const name of this.#force) { + result[name] = name; + } + + for (const name of deps) { + if (this.#inline.has(name) && !this.#force.has(name)) continue; + if (result[name]) continue; + if (await this.#shouldExternalize(name, peerDeps)) { + result[name] = name; + } + } + + for (const name of peerDeps) { + if (this.#inline.has(name) && !this.#force.has(name)) continue; + if (!result[name]) result[name] = name; + } + + return result; + } + + async #shouldExternalize(name: string, peerDeps: ReadonlySet): Promise { + if (peerDeps.has(name)) return true; + if (ALWAYS_EXTERNAL_NAMES.has(name)) return true; + if (name === 'egg') return true; + + const pkgDir = await this.#findPackageDir(name); + if (!pkgDir) return false; + if (await this.#hasNativeBinary(pkgDir)) return true; + // ESM-only packages are NOT externalized: turbopack can bundle ESM + // natively, while externalizing them would emit CJS require() which + // fails for packages without a CJS entry. + return false; + } + + async #findPackageDir(name: string): Promise { + let dir = this.#baseDir; + while (true) { + const candidate = path.join(dir, 'node_modules', name); + try { + const stat = await fs.stat(candidate); + if (stat.isDirectory()) return candidate; + } catch { + // continue walking upward + } + const parent = path.dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } + } + + async #hasNativeBinary(pkgDir: string): Promise { + const pkg = await this.#readPackageJson(pkgDir); + const scripts = pkg.scripts ?? {}; + for (const hook of ['install', 'postinstall', 'preinstall'] as const) { + const script = scripts[hook]; + if (script && NATIVE_SCRIPT_PATTERN.test(script)) return true; + } + + if (await this.#pathExists(path.join(pkgDir, 'binding.gyp'))) return true; + + try { + const prebuilds = await fs.readdir(path.join(pkgDir, 'prebuilds')); + if (prebuilds.length > 0) return true; + } catch { + // no prebuilds dir + } + + try { + const entries = await fs.readdir(pkgDir); + if (entries.some((e) => e.endsWith('.node'))) return true; + } catch { + // unreadable dir + } + + return false; + } + + async #isEsmOnly(pkgDir: string): Promise { + const pkg = await this.#readPackageJson(pkgDir); + if (pkg.type !== 'module') return false; + const exportsField = pkg.exports; + if (!exportsField || typeof exportsField !== 'object') { + return false; + } + return !this.#hasRequireCondition(exportsField); + } + + #hasRequireCondition(value: unknown): boolean { + if (!value || typeof value !== 'object') return false; + if (Array.isArray(value)) { + return value.some((v) => this.#hasRequireCondition(v)); + } + const obj = value as Record; + if ('require' in obj) return true; + for (const v of Object.values(obj)) { + if (this.#hasRequireCondition(v)) return true; + } + return false; + } + + async #readPackageJson(dir: string): Promise { + try { + const raw = await fs.readFile(path.join(dir, 'package.json'), 'utf8'); + return JSON.parse(raw) as PackageJson; + } catch { + return {}; + } + } + + async #pathExists(p: string): Promise { + try { + await fs.stat(p); + return true; + } catch { + return false; + } + } +} diff --git a/tools/egg-bundler/src/lib/ManifestLoader.ts b/tools/egg-bundler/src/lib/ManifestLoader.ts new file mode 100644 index 0000000000..da023230b2 --- /dev/null +++ b/tools/egg-bundler/src/lib/ManifestLoader.ts @@ -0,0 +1,400 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { debuglog } from 'node:util'; + +import { ManifestStore, type StartupManifest } from '@eggjs/core'; + +const debug = debuglog('egg/bundler/manifest-loader'); + +const SUPPORTED_MANIFEST_VERSION = 1; +const FRAMEWORK_DEFAULT = 'egg'; + +export interface ManifestLoaderOptions { + baseDir: string; + framework?: string; + manifestPath?: string; + autoGenerate?: boolean; + env?: string; + scope?: string; + execArgv?: string[]; +} + +interface TeggModuleDescriptor { + unitPath: string; + decoratedFiles?: string[]; +} + +interface TeggManifestExtension { + moduleDescriptors?: TeggModuleDescriptor[]; +} + +interface ModuleMapEntry { + realDir: string; + pkgName: string; +} + +export class ManifestLoader { + readonly #baseDir: string; + readonly #manifestPath: string; + readonly #autoGenerate: boolean; + readonly #env: string | undefined; + readonly #scope: string | undefined; + readonly #framework: string; + readonly #execArgv: string[] | undefined; + #manifest: StartupManifest | undefined; + #store: ManifestStore | undefined; + + constructor(options: ManifestLoaderOptions) { + this.#baseDir = options.baseDir; + this.#manifestPath = options.manifestPath ?? path.join(options.baseDir, '.egg', 'manifest.json'); + this.#autoGenerate = options.autoGenerate ?? true; + this.#env = options.env; + this.#scope = options.scope; + this.#framework = options.framework ?? FRAMEWORK_DEFAULT; + this.#execArgv = options.execArgv; + } + + async load(): Promise { + if (this.#manifest) return this.#manifest; + + let data = this.#readFromDisk(); + if (!data) { + if (!this.#autoGenerate) { + throw new Error(`[@eggjs/egg-bundler] manifest not found at ${this.#manifestPath}`); + } + await this.#generate(); + data = this.#readFromDisk(); + if (!data) { + throw new Error(`[@eggjs/egg-bundler] manifest generation did not produce ${this.#manifestPath}`); + } + } + + const normalized = this.#normalize(data); + this.#manifest = normalized; + this.#store = ManifestStore.fromBundle(normalized, this.#baseDir); + return normalized; + } + + get manifest(): StartupManifest { + if (!this.#manifest) { + throw new Error('[@eggjs/egg-bundler] ManifestLoader.load() must be awaited before accessing manifest'); + } + return this.#manifest; + } + + get store(): ManifestStore { + if (!this.#store) { + throw new Error('[@eggjs/egg-bundler] ManifestLoader.load() must be awaited before accessing store'); + } + return this.#store; + } + + getAllDiscoveredFiles(): string[] { + const m = this.manifest; + const files: string[] = []; + for (const [relDir, relFiles] of Object.entries(m.fileDiscovery)) { + const absDir = this.#resolveFromBase(relDir); + for (const relFile of relFiles) { + files.push(path.resolve(absDir, relFile)); + } + } + files.sort(); + return files; + } + + getTeggDecoratedFiles(): string[] { + const ext = this.manifest.extensions?.tegg as TeggManifestExtension | undefined; + const descriptors = ext?.moduleDescriptors; + if (!descriptors) return []; + const files: string[] = []; + for (const desc of descriptors) { + const unitAbs = path.isAbsolute(desc.unitPath) ? desc.unitPath : this.#resolveFromBase(desc.unitPath); + for (const rel of desc.decoratedFiles ?? []) { + files.push(path.resolve(unitAbs, rel)); + } + } + files.sort(); + return files; + } + + #resolveFromBase(rel: string): string { + if (path.isAbsolute(rel)) return rel; + if (rel.startsWith('node_modules/')) { + const req = createRequire(path.join(this.#baseDir, 'package.json')); + const rest = rel.slice('node_modules/'.length); + const slashIdx = rest.startsWith('@') ? rest.indexOf('/', rest.indexOf('/') + 1) : rest.indexOf('/'); + const pkgName = slashIdx === -1 ? rest : rest.slice(0, slashIdx); + const sub = slashIdx === -1 ? '' : rest.slice(slashIdx + 1); + try { + const pkgJson = req.resolve(`${pkgName}/package.json`); + return path.resolve(path.dirname(pkgJson), sub); + } catch { + return path.resolve(this.#baseDir, rel); + } + } + return path.resolve(this.#baseDir, rel); + } + + #readFromDisk(): StartupManifest | undefined { + let raw: string; + try { + raw = fs.readFileSync(this.#manifestPath, 'utf-8'); + } catch { + return undefined; + } + const parsed = JSON.parse(raw) as StartupManifest; + if (parsed.version !== SUPPORTED_MANIFEST_VERSION) { + throw new Error( + `[@eggjs/egg-bundler] manifest version mismatch at ${this.#manifestPath}: expected ${SUPPORTED_MANIFEST_VERSION}, got ${parsed.version}`, + ); + } + return parsed; + } + + async #generate(): Promise { + const scriptUrl = new URL('../scripts/generate-manifest.mjs', import.meta.url); + const scriptPath = fileURLToPath(scriptUrl); + // The child loader needs BOTH: + // 1. `framework` — the absolute package dir egg's internal + // `resolveFrameworkClasses()` uses (it calls `importResolve(framework, + // baseDir)` which walks node_modules from baseDir). + // 2. `frameworkEntry` — a concrete file URL for the subprocess's initial + // `import(framework)`. Importing the package dir directly bypasses + // `exports` (workspace dev links point at `./src/index.ts`, and bare + // directory resolution falls through to `index.js/json`). + const payload = { + baseDir: this.#baseDir, + framework: this.#resolveFrameworkPath(), + frameworkEntry: this.#resolveFrameworkEntryUrl(), + env: this.#env, + scope: this.#scope, + }; + debug('fork generate-manifest: %o', payload); + + const execArgv = this.#buildExecArgv(); + + // Use spawn (not fork) so the child has no IPC channel: tsx's ESM loader has + // resolver issues inside Node 22's IPC hooks-worker, causing workspace-linked + // packages with `exports: "./src/*.ts"` to fall back to directory resolution. + await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [...execArgv, scriptPath, JSON.stringify(payload)], { + stdio: 'inherit', + env: { + ...process.env, + EGG_MANIFEST: 'true', + }, + }); + child.on('exit', (code, signal) => { + if (code === 0) resolve(); + else + reject( + new Error(`[@eggjs/egg-bundler] manifest generate subprocess exited with code=${code} signal=${signal}`), + ); + }); + child.on('error', reject); + }); + } + + #buildExecArgv(): string[] { + const base = this.#execArgv ?? process.execArgv; + // Detect any prior tsx loader injection so recursive forks don't append duplicates. + // Accepts either bare specifier (`tsx`, `tsx/esm`) or absolute file:// URL pointing + // inside a tsx package (e.g. `.../tsx/dist/esm/index.mjs`). + const hasTsxLoader = base.some((arg) => /(^|[=\s])tsx($|\/|\s)|\/tsx(@[^/]*)?\/(dist\/)?esm\//.test(arg)); + if (hasTsxLoader) return [...base]; + // The subprocess imports 'egg' through workspace dev links into raw .ts sources + // (which contain decorators Node's strip-types mode cannot transform). Inject + // tsx's ESM loader so the child can load those sources regardless of how the + // parent Node was invoked (raw node, egg-bin, vitest, etc.). + try { + const req = createRequire(import.meta.url); + const tsxEsm = req.resolve('tsx/esm'); + const tsxUrl = pathToFileURL(tsxEsm).href; + return [...base, `--import=${tsxUrl}`]; + } catch { + debug('tsx/esm not resolvable from @eggjs/egg-bundler; falling back to inherited execArgv'); + return [...base]; + } + } + + #resolveFrameworkEntryUrl(): string { + const frameworkDir = this.#resolveFrameworkPath(); + const pkgJsonPath = path.join(frameworkDir, 'package.json'); + try { + const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')) as { + exports?: Record | string; + main?: string; + module?: string; + }; + let entryRel: string | undefined; + if (typeof pkg.exports === 'string') { + entryRel = pkg.exports; + } else if (pkg.exports && typeof pkg.exports === 'object') { + const dot = (pkg.exports as Record)['.']; + if (typeof dot === 'string') { + entryRel = dot; + } else if (dot && typeof dot === 'object') { + const cond = dot as Record; + for (const key of ['import', 'module', 'default'] as const) { + const val = cond[key]; + if (typeof val === 'string') { + entryRel = val; + break; + } + } + } + } + entryRel = entryRel ?? pkg.module ?? pkg.main; + if (!entryRel) { + throw new Error(`[@eggjs/egg-bundler] framework package ${pkgJsonPath} has no resolvable entry`); + } + return pathToFileURL(path.resolve(frameworkDir, entryRel)).href; + } catch (err) { + debug('resolve framework entry failed: %o', err); + throw err; + } + } + + #resolveFrameworkPath(): string { + if (path.isAbsolute(this.#framework)) return this.#framework; + try { + const req = createRequire(path.join(this.#baseDir, 'package.json')); + const pkgJson = req.resolve(`${this.#framework}/package.json`); + return path.dirname(pkgJson); + } catch { + return this.#framework; + } + } + + // --- Key normalization --- + + #normalize(data: StartupManifest): StartupManifest { + const moduleMap = this.#buildModuleMap(); + debug('moduleMap size: %d', moduleMap.length); + + const normalizedDiscovery: Record = {}; + for (const [key, files] of Object.entries(data.fileDiscovery)) { + const newKey = this.#normalizeRelKey(key, moduleMap); + normalizedDiscovery[newKey] = files; + } + + const normalizedResolveCache: Record = {}; + for (const [key, value] of Object.entries(data.resolveCache)) { + const newKey = this.#normalizeRelKey(key, moduleMap); + const newValue = value === null ? null : this.#normalizeRelKey(value, moduleMap); + normalizedResolveCache[newKey] = newValue; + } + + const normalizedExtensions = this.#normalizeExtensions(data.extensions, moduleMap); + + return { + ...data, + extensions: normalizedExtensions, + fileDiscovery: normalizedDiscovery, + resolveCache: normalizedResolveCache, + }; + } + + #normalizeRelKey(relKey: string, moduleMap: ModuleMapEntry[]): string { + if (!relKey) return relKey; + // Already inside baseDir and not escaping — leave as-is. + if (!relKey.startsWith('..') && !relKey.includes('node_modules/') && !relKey.includes('.pnpm/')) { + return relKey; + } + const abs = path.resolve(this.#baseDir, relKey); + let realAbs: string; + try { + realAbs = fs.realpathSync(abs); + } catch { + realAbs = abs; + } + // Longest-prefix match + let best: ModuleMapEntry | undefined; + for (const entry of moduleMap) { + if (realAbs === entry.realDir || realAbs.startsWith(entry.realDir + path.sep)) { + if (!best || entry.realDir.length > best.realDir.length) { + best = entry; + } + } + } + if (!best) { + debug('normalize: no match for %o (real=%o)', relKey, realAbs); + return relKey; + } + const rest = realAbs === best.realDir ? '' : realAbs.slice(best.realDir.length + 1); + const normalized = ['node_modules', best.pkgName, rest].filter(Boolean).join('/').replaceAll(path.sep, '/'); + return normalized; + } + + #normalizeExtensions(extensions: Record, moduleMap: ModuleMapEntry[]): Record { + const result: Record = { ...extensions }; + const tegg = extensions?.tegg as TeggManifestExtension | undefined; + if (tegg?.moduleDescriptors) { + result.tegg = { + ...tegg, + moduleDescriptors: tegg.moduleDescriptors.map((desc) => { + if (!path.isAbsolute(desc.unitPath)) return desc; + let real: string; + try { + real = fs.realpathSync(desc.unitPath); + } catch { + real = desc.unitPath; + } + let best: ModuleMapEntry | undefined; + for (const entry of moduleMap) { + if (real === entry.realDir || real.startsWith(entry.realDir + path.sep)) { + if (!best || entry.realDir.length > best.realDir.length) best = entry; + } + } + if (!best) { + // keep as relative-to-baseDir form so runtime can resolve via #resolveFromBase + const rel = path.relative(this.#baseDir, real).replaceAll(path.sep, '/'); + return { ...desc, unitPath: rel }; + } + const rest = real === best.realDir ? '' : real.slice(best.realDir.length + 1); + const unitPath = ['node_modules', best.pkgName, rest].filter(Boolean).join('/'); + return { ...desc, unitPath }; + }), + }; + } + return result; + } + + #buildModuleMap(): ModuleMapEntry[] { + const entries = new Map(); + const seenPkgs = new Set(); + + const addFromPackageJson = (packageJsonPath: string) => { + let pkg: { dependencies?: Record; devDependencies?: Record }; + try { + pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + } catch { + return; + } + const req = createRequire(packageJsonPath); + const depNames = [...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {})]; + for (const name of depNames) { + if (seenPkgs.has(name)) continue; + seenPkgs.add(name); + try { + const depPkgJson = req.resolve(`${name}/package.json`); + const realDir = fs.realpathSync(path.dirname(depPkgJson)); + if (!entries.has(realDir)) entries.set(realDir, name); + } catch { + /* dep not resolvable (optional/peer); skip */ + } + } + }; + + addFromPackageJson(path.join(this.#baseDir, 'package.json')); + const frameworkDir = this.#resolveFrameworkPath(); + addFromPackageJson(path.join(frameworkDir, 'package.json')); + + return Array.from(entries, ([realDir, pkgName]) => ({ realDir, pkgName })).sort( + (a, b) => b.realDir.length - a.realDir.length, + ); + } +} diff --git a/tools/egg-bundler/src/lib/PackRunner.ts b/tools/egg-bundler/src/lib/PackRunner.ts new file mode 100644 index 0000000000..323eea2a74 --- /dev/null +++ b/tools/egg-bundler/src/lib/PackRunner.ts @@ -0,0 +1,123 @@ +import { promises as fs } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +export interface PackEntry { + readonly name: string; + readonly filepath: string; +} + +export type BuildFunc = (config: { config: unknown }, projectPath: string, rootPath: string) => Promise; + +export interface PackRunnerOptions { + readonly entries: readonly PackEntry[]; + readonly outputDir: string; + readonly externals: Readonly>; + readonly projectPath: string; + readonly rootPath?: string; + readonly mode?: 'production' | 'development'; + readonly buildFunc?: BuildFunc; +} + +export interface PackRunnerResult { + readonly outputDir: string; + readonly files: readonly string[]; +} + +// SWC decorator compilation picks up tsconfig from the OUTPUT dir, not the +// project. Without this, tegg decorator metadata silently drops. (T0 blocker.) +const OUTPUT_TSCONFIG = { + compilerOptions: { + experimentalDecorators: true, + emitDecoratorMetadata: true, + target: 'es2022', + }, +}; + +// @utoo/pack emits CJS files; a nested `type: commonjs` package.json +// prevents the parent ESM package from forcing these into ESM parse mode. +const OUTPUT_PACKAGE_JSON = { type: 'commonjs' }; + +const require = createRequire(import.meta.url); + +// Use CJS entry explicitly: under pnpm workspace links the ESM build's +// extensionless relative imports fail to resolve. +const DEFAULT_BUILD_FUNC: BuildFunc = async (wrapped, projectPath, rootPath) => { + const mod = require('@utoo/pack/cjs/commands/build.js') as { + build: (options: unknown, projectPath?: string, rootPath?: string) => Promise; + }; + await mod.build(wrapped, projectPath, rootPath); +}; + +export class PackRunner { + readonly #options: PackRunnerOptions; + + constructor(options: PackRunnerOptions) { + this.#options = options; + } + + async run(): Promise { + const { + entries, + outputDir, + externals, + projectPath, + rootPath = projectPath, + mode = 'production', + buildFunc = DEFAULT_BUILD_FUNC, + } = this.#options; + + await fs.mkdir(outputDir, { recursive: true }); + await fs.writeFile(path.join(outputDir, 'tsconfig.json'), JSON.stringify(OUTPUT_TSCONFIG, null, 2)); + await fs.writeFile(path.join(outputDir, 'package.json'), JSON.stringify(OUTPUT_PACKAGE_JSON, null, 2)); + + // UMD-form externals ({ commonjs, root }) make @utoo/pack's standalone + // output emit a `require(name)` branch under `typeof exports === 'object'`, + // which is what node picks. Plain string externals only emit the + // `globalThis[name]` branch, unusable for direct node execution. + const umdExternals: Record = {}; + for (const [k, v] of Object.entries(externals)) { + umdExternals[k] = { commonjs: v, root: v }; + } + + const config = { + entry: entries.map((e) => ({ name: e.name, import: e.filepath })), + target: 'node 22', + platform: 'node', + mode, + output: { + path: outputDir, + type: 'standalone', + }, + externals: umdExternals, + optimization: { + treeShaking: false, + minify: false, + }, + }; + + try { + await buildFunc({ config }, projectPath, rootPath); + } catch (err) { + const names = entries.map((e) => e.name).join(', '); + throw new Error(`PackRunner failed to build ${names}: ${(err as Error).message}`, { cause: err }); + } + + const files = await this.#collectFiles(outputDir); + return { outputDir, files }; + } + + async #collectFiles(dir: string): Promise { + const entries = await fs.readdir(dir, { + recursive: true, + withFileTypes: true, + }); + return entries + .filter((d) => d.isFile()) + .map((d) => { + const parent = d.parentPath ?? dir; + return path.relative(dir, path.join(parent, d.name)); + }) + .sort(); + } +} diff --git a/tools/egg-bundler/src/scripts/generate-manifest.mjs b/tools/egg-bundler/src/scripts/generate-manifest.mjs new file mode 100644 index 0000000000..8bc681fc25 --- /dev/null +++ b/tools/egg-bundler/src/scripts/generate-manifest.mjs @@ -0,0 +1,59 @@ +import { debuglog } from 'node:util'; + +const debug = debuglog('egg/bundler/scripts/generate-manifest'); + +async function main() { + debug('argv: %o', process.argv); + const options = JSON.parse(process.argv[2]); + debug('generate manifest options: %o', options); + + if (options.env) { + process.env.EGG_SERVER_ENV = options.env; + } + if (options.scope) { + process.env.EGG_SERVER_SCOPE = options.scope; + } + process.env.EGG_MANIFEST = 'true'; + + const { ManifestStore } = await import('@eggjs/core'); + ManifestStore.clean(options.baseDir); + + // `frameworkEntry` (a file:// URL to the package's real entry file) is the + // only way to load a workspace-linked framework whose `exports` map points at + // a TypeScript source. Importing the package directory directly would bypass + // `exports` and fall through to legacy directory resolution. + let framework; + if (options.frameworkEntry) { + framework = await import(options.frameworkEntry); + } else if (options.framework) { + framework = await import(options.framework); + } else { + framework = await import('egg'); + } + + const app = await framework.start({ + baseDir: options.baseDir, + framework: options.framework, + env: options.env, + mode: 'single', + }); + + const manifest = app.loader.generateManifest(); + await ManifestStore.write(options.baseDir, manifest); + + const resolveCacheCount = Object.keys(manifest.resolveCache).length; + const fileDiscoveryCount = Object.keys(manifest.fileDiscovery).length; + const extensionCount = Object.keys(manifest.extensions).length; + console.log('[bundler-manifest] generated v%d at %s', manifest.version, manifest.generatedAt); + console.log('[bundler-manifest] resolveCache: %d', resolveCacheCount); + console.log('[bundler-manifest] fileDiscovery: %d', fileDiscoveryCount); + console.log('[bundler-manifest] extensions: %d', extensionCount); + + await app.close(); + process.exit(0); +} + +main().catch((err) => { + console.error('[bundler-manifest] generation failed:', err); + process.exit(1); +}); diff --git a/tools/egg-bundler/test/EntryGenerator.test.ts b/tools/egg-bundler/test/EntryGenerator.test.ts new file mode 100644 index 0000000000..9d7edb752d --- /dev/null +++ b/tools/egg-bundler/test/EntryGenerator.test.ts @@ -0,0 +1,297 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import type { StartupManifest } from '@eggjs/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { EntryGenerator } from '../src/lib/EntryGenerator.ts'; +import type { ManifestLoader } from '../src/lib/ManifestLoader.ts'; + +function createFakeLoader(manifest: StartupManifest): ManifestLoader { + return { load: async () => manifest } as unknown as ManifestLoader; +} + +const FROZEN_INVALIDATION = { + lockfileFingerprint: 'fake-lockfile', + configFingerprint: 'fake-config', + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, +} as const; + +function makeManifest(overrides: Partial = {}): StartupManifest { + return { + version: 1, + generatedAt: '2026-01-01T00:00:00.000Z', + invalidation: { ...FROZEN_INVALIDATION }, + extensions: {}, + resolveCache: {}, + fileDiscovery: {}, + ...overrides, + }; +} + +function extractImports(workerSource: string): { index: number; specifier: string }[] { + return [...workerSource.matchAll(/import \* as __m(\d+) from "([^"]+)";/g)].map((m) => ({ + index: Number(m[1]), + specifier: m[2]!, + })); +} + +describe('EntryGenerator', () => { + let tmpDir: string; + const createdDirs: string[] = []; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'egg-bundler-entry-gen-')); + createdDirs.push(tmpDir); + }); + + afterEach(async () => { + while (createdDirs.length) { + const dir = createdDirs.pop()!; + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + it('writes worker.entry.ts and agent.entry.ts under /.egg-bundle/entries by default', async () => { + const gen = new EntryGenerator({ + baseDir: tmpDir, + manifestLoader: createFakeLoader(makeManifest()), + }); + + const result = await gen.generate(); + + expect(result.entryDir).toBe(path.join(tmpDir, '.egg-bundle', 'entries')); + expect(result.workerEntry).toBe(path.join(result.entryDir, 'worker.entry.ts')); + expect(result.agentEntry).toBe(path.join(result.entryDir, 'agent.entry.ts')); + await expect(fs.stat(result.workerEntry)).resolves.toBeTruthy(); + await expect(fs.stat(result.agentEntry)).resolves.toBeTruthy(); + }); + + it('collects fileDiscovery + resolveCache + tegg decoratedFiles and sorts imports by relKey', async () => { + const manifest = makeManifest({ + fileDiscovery: { + 'app/controller': ['home.ts'], + 'app/service': ['user.ts'], + }, + resolveCache: { + 'app/extend/context.ts': 'app/extend/context.ts', + }, + extensions: { + tegg: { + moduleDescriptors: [ + { + unitPath: 'node_modules/@eggjs/fake-module', + decoratedFiles: ['app/service/UserService.ts'], + }, + ], + }, + }, + }); + + const gen = new EntryGenerator({ baseDir: tmpDir, manifestLoader: createFakeLoader(manifest) }); + const result = await gen.generate(); + const worker = await fs.readFile(result.workerEntry, 'utf8'); + + const imports = extractImports(worker); + expect(imports.map((i) => i.specifier)).toEqual([ + '../../app/controller/home.ts', + '../../app/extend/context.ts', + '../../app/service/user.ts', + '../../node_modules/@eggjs/fake-module/app/service/UserService.ts', + ]); + // __mN indices are contiguous starting from 0 and match sorted order + expect(imports.map((i) => i.index)).toEqual([0, 1, 2, 3]); + }); + + it('skips resolveCache entries whose value is null', async () => { + const manifest = makeManifest({ + resolveCache: { + 'has-value': 'app/real.ts', + 'is-null-entry': null, + }, + }); + + const gen = new EntryGenerator({ baseDir: tmpDir, manifestLoader: createFakeLoader(manifest) }); + const result = await gen.generate(); + const worker = await fs.readFile(result.workerEntry, 'utf8'); + + const imports = extractImports(worker); + expect(imports.length).toBe(1); + expect(imports[0]!.specifier).toBe('../../app/real.ts'); + expect(imports.some((i) => i.specifier.includes('is-null-entry'))).toBe(false); + }); + + it('deduplicates entries that appear in both fileDiscovery and resolveCache', async () => { + const manifest = makeManifest({ + fileDiscovery: { 'app/controller': ['home.ts'] }, + resolveCache: { 'app/controller/home.ts': 'app/controller/home.ts' }, + }); + + const gen = new EntryGenerator({ baseDir: tmpDir, manifestLoader: createFakeLoader(manifest) }); + const result = await gen.generate(); + const worker = await fs.readFile(result.workerEntry, 'utf8'); + + expect(extractImports(worker).length).toBe(1); + }); + + it('emits the required runtime hooks: manifest setup, bundle module loader, and startEgg', async () => { + const manifest = makeManifest({ + fileDiscovery: { 'app/controller': ['home.ts'] }, + }); + + const gen = new EntryGenerator({ baseDir: tmpDir, manifestLoader: createFakeLoader(manifest) }); + const result = await gen.generate(); + const worker = await fs.readFile(result.workerEntry, 'utf8'); + + expect(worker).toContain("import { ManifestStore } from '@eggjs/core'"); + expect(worker).toContain("import { setBundleModuleLoader } from '@eggjs/utils'"); + expect(worker).toContain('import { startEgg } from "egg"'); + expect(worker).toContain('ManifestStore.setBundleStore(ManifestStore.fromBundle(MANIFEST_DATA'); + expect(worker).toContain('setBundleModuleLoader('); + expect(worker).toContain("startEgg({ baseDir: __baseDir, mode: 'single' })"); + }); + + it('builds a BUNDLE_MAP keyed by both the relKey form and the resolved absolute form', async () => { + const manifest = makeManifest({ + fileDiscovery: { app: ['controller.ts'] }, + }); + + const gen = new EntryGenerator({ baseDir: tmpDir, manifestLoader: createFakeLoader(manifest) }); + const result = await gen.generate(); + const worker = await fs.readFile(result.workerEntry, 'utf8'); + + 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'); + }); + + it('inlines the full StartupManifest as MANIFEST_DATA so runtime never reads .egg/manifest.json', async () => { + const manifest = makeManifest({ + fileDiscovery: { app: ['a.ts'] }, + }); + + const gen = new EntryGenerator({ baseDir: tmpDir, manifestLoader: createFakeLoader(manifest) }); + const result = await gen.generate(); + const worker = await fs.readFile(result.workerEntry, 'utf8'); + + expect(worker).toContain('const MANIFEST_DATA ='); + expect(worker).toContain('"generatedAt": "2026-01-01T00:00:00.000Z"'); + expect(worker).toContain('"lockfileFingerprint": "fake-lockfile"'); + expect(worker).toContain('"configFingerprint": "fake-config"'); + }); + + it('still emits all runtime hooks and a valid file when the manifest is empty', async () => { + const gen = new EntryGenerator({ + baseDir: tmpDir, + manifestLoader: createFakeLoader(makeManifest()), + }); + const result = await gen.generate(); + 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('setBundleModuleLoader('); + expect(worker).toContain('ManifestStore.setBundleStore'); + }); + + it('generates an agent entry that is a no-op stub in single mode', async () => { + const gen = new EntryGenerator({ + baseDir: tmpDir, + manifestLoader: createFakeLoader(makeManifest()), + }); + const result = await gen.generate(); + const agent = await fs.readFile(result.agentEntry, 'utf8'); + + expect(agent).toContain('auto-generated by @eggjs/egg-bundler'); + expect(agent).toContain('Single-mode bundled apps run the agent in-process'); + expect(agent).not.toContain('startEgg'); + expect(agent).not.toContain('ManifestStore'); + }); + + it('honors a custom outputDir option', async () => { + const customOut = path.join(tmpDir, 'custom-out'); + const gen = new EntryGenerator({ + baseDir: tmpDir, + outputDir: customOut, + manifestLoader: createFakeLoader(makeManifest()), + }); + + const result = await gen.generate(); + expect(result.entryDir).toBe(customOut); + 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('produces byte-identical worker output across independent baseDir runs (T17 determinism baseline)', async () => { + const manifest = makeManifest({ + extensions: { + tegg: { + moduleDescriptors: [{ unitPath: 'node_modules/@eggjs/fake', decoratedFiles: ['b.ts', 'a.ts'] }], + }, + }, + resolveCache: { 'x.ts': 'x.ts', 'y.ts': 'y.ts', 'z.ts': null }, + fileDiscovery: { app: ['c.ts', 'a.ts', 'b.ts'] }, + }); + + const first = await new EntryGenerator({ + baseDir: tmpDir, + manifestLoader: createFakeLoader(manifest), + }).generate(); + const firstWorker = await fs.readFile(first.workerEntry, 'utf8'); + + const tmpDir2 = await fs.mkdtemp(path.join(os.tmpdir(), 'egg-bundler-entry-gen-')); + createdDirs.push(tmpDir2); + const second = await new EntryGenerator({ + baseDir: tmpDir2, + manifestLoader: createFakeLoader(manifest), + }).generate(); + const secondWorker = await fs.readFile(second.workerEntry, 'utf8'); + + expect(firstWorker).toBe(secondWorker); + }); + + it('matches the canonical file snapshot for a representative manifest', async () => { + const manifest = makeManifest({ + fileDiscovery: { + 'app/controller': ['home.ts'], + 'app/service': ['user.ts'], + }, + resolveCache: { + 'app/extend/context.ts': 'app/extend/context.ts', + 'app/middleware/timing.ts': null, + }, + extensions: { + tegg: { + moduleDescriptors: [ + { + unitPath: 'node_modules/@eggjs/fake-module', + decoratedFiles: ['app/service/UserService.ts'], + }, + ], + }, + }, + }); + + const gen = new EntryGenerator({ baseDir: tmpDir, manifestLoader: createFakeLoader(manifest) }); + const result = await gen.generate(); + const worker = await fs.readFile(result.workerEntry, 'utf8'); + + await expect(worker).toMatchFileSnapshot('./__snapshots__/EntryGenerator.worker.canonical.snap'); + }); +}); diff --git a/tools/egg-bundler/test/ExternalsResolver.test.ts b/tools/egg-bundler/test/ExternalsResolver.test.ts new file mode 100644 index 0000000000..eade209dca --- /dev/null +++ b/tools/egg-bundler/test/ExternalsResolver.test.ts @@ -0,0 +1,117 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { ExternalsResolver } from '../src/lib/ExternalsResolver.ts'; + +const fixtureBase = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures/externals'); +const basicApp = path.join(fixtureBase, 'basic-app'); + +describe('ExternalsResolver', () => { + describe('tier 1: native binary detection', () => { + it('externalizes a package whose install script invokes node-gyp', async () => { + const result = await new ExternalsResolver({ baseDir: basicApp }).resolve(); + expect(result['native-scripts']).toBe('native-scripts'); + }); + + it('externalizes a package that ships a binding.gyp', async () => { + const result = await new ExternalsResolver({ baseDir: basicApp }).resolve(); + expect(result['native-binding']).toBe('native-binding'); + }); + + it('externalizes a package with a non-empty prebuilds/ directory', async () => { + const result = await new ExternalsResolver({ baseDir: basicApp }).resolve(); + expect(result['native-prebuilds']).toBe('native-prebuilds'); + }); + + it('externalizes a package that contains a *.node file at its root', async () => { + const result = await new ExternalsResolver({ baseDir: basicApp }).resolve(); + expect(result['native-dotnode']).toBe('native-dotnode'); + }); + }); + + describe('tier 2: ESM-only detection', () => { + it('does not externalize a pure-ESM package (type=module without require condition)', async () => { + const result = await new ExternalsResolver({ baseDir: basicApp }).resolve(); + expect(result['esm-only']).toBeUndefined(); + }); + + it('does not externalize a dual-ESM package that exposes a require condition', async () => { + const result = await new ExternalsResolver({ baseDir: basicApp }).resolve(); + expect(result['esm-dual']).toBeUndefined(); + }); + }); + + describe('tier 3: hard-coded always-external', () => { + it('does not externalize @eggjs/* packages by name alone (globalThis approach)', async () => { + const result = await new ExternalsResolver({ baseDir: basicApp }).resolve(); + expect(result['@eggjs/some-plugin']).toBeUndefined(); + }); + + it('externalizes every peerDependency even if the package is not installed', async () => { + const result = await new ExternalsResolver({ baseDir: basicApp }).resolve(); + expect(result['peer-only']).toBe('peer-only'); + }); + }); + + describe('negative cases', () => { + it('leaves a plain CJS JS package out of the externals map', async () => { + const result = await new ExternalsResolver({ baseDir: basicApp }).resolve(); + expect(result['normal-js']).toBeUndefined(); + }); + + it('does not externalize a declared dep that is not installed and matches no rule', async () => { + const result = await new ExternalsResolver({ baseDir: basicApp }).resolve(); + expect(result['missing-pkg']).toBeUndefined(); + }); + + it('does not throw when the project itself has no package.json', async () => { + const resolver = new ExternalsResolver({ baseDir: path.join(fixtureBase, 'nonexistent') }); + await expect(resolver.resolve()).resolves.toEqual({}); + }); + }); + + describe('user overrides', () => { + it('force adds a normal JS package to externals even though auto-detect skips it', async () => { + const result = await new ExternalsResolver({ + baseDir: basicApp, + force: ['normal-js'], + }).resolve(); + expect(result['normal-js']).toBe('normal-js'); + }); + + it('inline removes an auto-detected native package from externals', async () => { + const result = await new ExternalsResolver({ + baseDir: basicApp, + inline: ['native-scripts'], + }).resolve(); + expect(result['native-scripts']).toBeUndefined(); + }); + + it('force wins over inline when both reference the same package', async () => { + const result = await new ExternalsResolver({ + baseDir: basicApp, + force: ['normal-js'], + inline: ['normal-js'], + }).resolve(); + expect(result['normal-js']).toBe('normal-js'); + }); + + it('inline removes a peerDependency from externals', async () => { + const result = await new ExternalsResolver({ + baseDir: basicApp, + inline: ['peer-only'], + }).resolve(); + expect(result['peer-only']).toBeUndefined(); + }); + + it('inline removes a hard-coded @eggjs/* package from externals', async () => { + const result = await new ExternalsResolver({ + baseDir: basicApp, + inline: ['@eggjs/some-plugin'], + }).resolve(); + expect(result['@eggjs/some-plugin']).toBeUndefined(); + }); + }); +}); diff --git a/tools/egg-bundler/test/PackRunner.default-build.test.ts b/tools/egg-bundler/test/PackRunner.default-build.test.ts new file mode 100644 index 0000000000..62e7defafb --- /dev/null +++ b/tools/egg-bundler/test/PackRunner.default-build.test.ts @@ -0,0 +1,67 @@ +import fs from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { PackRunner } from '../src/lib/PackRunner.ts'; + +// PackRunner's DEFAULT_BUILD_FUNC uses `createRequire(import.meta.url).require('@utoo/pack/cjs/commands/build.js')`, +// which bypasses vitest's ESM module interception. Poison the CJS require cache +// instead so the real `@utoo/pack` code never runs. +const requireFromPackRunner = createRequire(new URL('../src/lib/PackRunner.ts', import.meta.url)); +const buildModulePath = requireFromPackRunner.resolve('@utoo/pack/cjs/commands/build.js'); + +describe('PackRunner.DEFAULT_BUILD_FUNC', () => { + const originalCacheEntry = requireFromPackRunner.cache[buildModulePath]; + const mockBuild = vi.fn(async () => undefined); + + beforeEach(() => { + mockBuild.mockClear(); + requireFromPackRunner.cache[buildModulePath] = { + id: buildModulePath, + filename: buildModulePath, + loaded: true, + exports: { build: mockBuild }, + } as unknown as NodeJS.Require['cache'][string]; + }); + + afterEach(() => { + if (originalCacheEntry) { + requireFromPackRunner.cache[buildModulePath] = originalCacheEntry; + } else { + delete requireFromPackRunner.cache[buildModulePath]; + } + }); + + it('passes wrapped { config } to @utoo/pack build with projectPath and rootPath', async () => { + const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pack-runner-default-')); + try { + const runner = new PackRunner({ + entries: [{ name: 'worker', filepath: '/tmp/entry.ts' }], + outputDir, + externals: { egg: 'egg' }, + projectPath: '/tmp/proj', + rootPath: '/tmp/root', + }); + + await runner.run(); + + expect(mockBuild).toHaveBeenCalledTimes(1); + const call = mockBuild.mock.calls[0] as unknown as [ + { config: { entry: unknown[]; target: string } }, + string, + string, + ]; + const [firstArg, projectArg, rootArg] = call; + expect(firstArg).toHaveProperty('config'); + expect(firstArg.config.entry).toHaveLength(1); + expect(firstArg.config.target).toBe('node 22'); + expect(projectArg).toBe('/tmp/proj'); + expect(rootArg).toBe('/tmp/root'); + } finally { + await fs.rm(outputDir, { recursive: true, force: true }); + } + }); +}); diff --git a/tools/egg-bundler/test/PackRunner.test.ts b/tools/egg-bundler/test/PackRunner.test.ts new file mode 100644 index 0000000000..f093abc482 --- /dev/null +++ b/tools/egg-bundler/test/PackRunner.test.ts @@ -0,0 +1,185 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { PackRunner, type BuildFunc, type PackEntry } from '../src/lib/PackRunner.ts'; + +describe('PackRunner', () => { + let tmpDir: string; + const createdDirs: string[] = []; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'egg-bundler-pack-runner-')); + createdDirs.push(tmpDir); + }); + + afterEach(async () => { + while (createdDirs.length) { + const dir = createdDirs.pop()!; + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + function makeRunner( + overrides: { + entries?: readonly PackEntry[]; + outputDir?: string; + externals?: Record; + projectPath?: string; + rootPath?: string; + mode?: 'production' | 'development'; + buildFunc?: BuildFunc; + } = {}, + ): PackRunner { + const outputDir = overrides.outputDir ?? path.join(tmpDir, 'out'); + const projectPath = overrides.projectPath ?? tmpDir; + return new PackRunner({ + entries: overrides.entries ?? [{ name: 'worker', filepath: path.join(tmpDir, 'worker.entry.ts') }], + outputDir, + externals: overrides.externals ?? {}, + projectPath, + ...(overrides.rootPath !== undefined ? { rootPath: overrides.rootPath } : {}), + ...(overrides.mode !== undefined ? { mode: overrides.mode } : {}), + buildFunc: overrides.buildFunc ?? (async () => {}), + }); + } + + it('writes the output tsconfig.json with decorator metadata flags set before invoking the build', async () => { + let tsconfigAtBuildTime: string | undefined; + const buildFunc: BuildFunc = async () => { + tsconfigAtBuildTime = await fs.readFile(path.join(tmpDir, 'out', 'tsconfig.json'), 'utf8'); + }; + + await makeRunner({ buildFunc }).run(); + + expect(tsconfigAtBuildTime).toBeDefined(); + const parsed = JSON.parse(tsconfigAtBuildTime!); + expect(parsed.compilerOptions.experimentalDecorators).toBe(true); + expect(parsed.compilerOptions.emitDecoratorMetadata).toBe(true); + expect(parsed.compilerOptions.target).toBe('es2022'); + }); + + it('writes package.json { "type": "commonjs" } into the output dir so @utoo/pack CJS output parses correctly', async () => { + let pkgAtBuildTime: string | undefined; + const buildFunc: BuildFunc = async () => { + pkgAtBuildTime = await fs.readFile(path.join(tmpDir, 'out', 'package.json'), 'utf8'); + }; + + await makeRunner({ buildFunc }).run(); + + expect(pkgAtBuildTime).toBeDefined(); + expect(JSON.parse(pkgAtBuildTime!)).toEqual({ type: 'commonjs' }); + }); + + it('creates the output directory recursively even when intermediate dirs do not exist', async () => { + const deepOut = path.join(tmpDir, 'a', 'b', 'c', 'out'); + await makeRunner({ outputDir: deepOut }).run(); + await expect(fs.stat(deepOut)).resolves.toBeTruthy(); + }); + + it('passes a pack config with entry[], target node 22, platform node, standalone output, and UMD-form externals through to buildFunc', async () => { + const buildFunc = vi.fn(async () => {}); + const entries: PackEntry[] = [ + { name: 'worker', filepath: '/abs/worker.entry.ts' }, + { name: 'agent', filepath: '/abs/agent.entry.ts' }, + ]; + const externals = { '@eggjs/core': '@eggjs/core' }; + + await makeRunner({ entries, externals, buildFunc }).run(); + + expect(buildFunc).toHaveBeenCalledTimes(1); + const [wrapped, projectPath, rootPath] = buildFunc.mock.calls[0]!; + const config = (wrapped as { config: Record }).config; + expect(config.entry).toEqual([ + { name: 'worker', import: '/abs/worker.entry.ts' }, + { name: 'agent', import: '/abs/agent.entry.ts' }, + ]); + expect(config.target).toBe('node 22'); + expect(config.platform).toBe('node'); + expect(config.output).toEqual({ path: path.join(tmpDir, 'out'), type: 'standalone' }); + // Externals must be UMD-form ({ commonjs, root }) so @utoo/pack standalone + // output emits `require(name)` for CJS runtime (not globalThis[name]). + expect(config.externals).toEqual({ + '@eggjs/core': { commonjs: '@eggjs/core', root: '@eggjs/core' }, + }); + expect(projectPath).toBe(tmpDir); + expect(rootPath).toBe(tmpDir); + }); + + it('disables treeShaking and minify in the pack config (tegg runtime requires the full graph)', async () => { + const buildFunc = vi.fn(async () => {}); + await makeRunner({ buildFunc }).run(); + const config = (buildFunc.mock.calls[0]![0] as { config: Record }).config; + expect(config.optimization).toEqual({ treeShaking: false, minify: false }); + }); + + it('defaults mode to production when no mode is provided', async () => { + const buildFunc = vi.fn(async () => {}); + await makeRunner({ buildFunc }).run(); + const config = (buildFunc.mock.calls[0]![0] as { config: Record }).config; + expect(config.mode).toBe('production'); + }); + + it('forwards a custom mode (development) to the pack config when the caller sets it', async () => { + const buildFunc = vi.fn(async () => {}); + await makeRunner({ buildFunc, mode: 'development' }).run(); + const config = (buildFunc.mock.calls[0]![0] as { config: Record }).config; + expect(config.mode).toBe('development'); + }); + + it('defaults rootPath to projectPath when the caller omits rootPath', async () => { + const buildFunc = vi.fn(async () => {}); + await makeRunner({ buildFunc, projectPath: '/custom/project' }).run(); + const [, projectPath, rootPath] = buildFunc.mock.calls[0]!; + expect(projectPath).toBe('/custom/project'); + expect(rootPath).toBe('/custom/project'); + }); + + it('forwards a distinct rootPath when the caller provides it', async () => { + const buildFunc = vi.fn(async () => {}); + await makeRunner({ buildFunc, projectPath: '/proj', rootPath: '/monorepo/root' }).run(); + const [, projectPath, rootPath] = buildFunc.mock.calls[0]!; + expect(projectPath).toBe('/proj'); + expect(rootPath).toBe('/monorepo/root'); + }); + + it('returns the outputDir and a sorted list of files that @utoo/pack produced', async () => { + const outputDir = path.join(tmpDir, 'out'); + const buildFunc: BuildFunc = async () => { + await fs.mkdir(path.join(outputDir, 'nested'), { recursive: true }); + await fs.writeFile(path.join(outputDir, 'worker.js'), 'worker;'); + await fs.writeFile(path.join(outputDir, 'agent.js'), 'agent;'); + await fs.writeFile(path.join(outputDir, 'nested', 'chunk.js'), 'chunk;'); + }; + + const result = await makeRunner({ buildFunc }).run(); + + expect(result.outputDir).toBe(outputDir); + // tsconfig.json + package.json are pre-written, then buildFunc adds worker/agent/nested/chunk + const expected = ['agent.js', path.join('nested', 'chunk.js'), 'package.json', 'tsconfig.json', 'worker.js'].sort(); + expect([...result.files]).toEqual(expected); + }); + + it('wraps buildFunc failures in an Error that names every entry and preserves the cause', async () => { + const original = new Error('pack crashed'); + const buildFunc: BuildFunc = async () => { + throw original; + }; + const runner = makeRunner({ + entries: [ + { name: 'worker', filepath: '/a.ts' }, + { name: 'agent', filepath: '/b.ts' }, + ], + buildFunc, + }); + + await expect(runner.run()).rejects.toThrowError(/PackRunner failed to build worker, agent: pack crashed/); + try { + await runner.run(); + } catch (err) { + expect((err as Error).cause).toBe(original); + } + }); +}); diff --git a/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap b/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap new file mode 100644 index 0000000000..3deccda64b --- /dev/null +++ b/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap @@ -0,0 +1,86 @@ +// ⚠️ auto-generated by @eggjs/egg-bundler — do not edit +/* eslint-disable */ +import path from 'node:path'; + +import { ManifestStore } from '@eggjs/core'; +import { setBundleModuleLoader } from '@eggjs/utils'; +import { startEgg } from "egg"; + +import * as __m0 from "../../app/controller/home.ts"; +import * as __m1 from "../../app/extend/context.ts"; +import * as __m2 from "../../app/service/user.ts"; +import * as __m3 from "../../node_modules/@eggjs/fake-module/app/service/UserService.ts"; + +// 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 MANIFEST_DATA = { + "version": 1, + "generatedAt": "2026-01-01T00:00:00.000Z", + "invalidation": { + "lockfileFingerprint": "fake-lockfile", + "configFingerprint": "fake-config", + "serverEnv": "prod", + "serverScope": "", + "typescriptEnabled": true + }, + "extensions": { + "tegg": { + "moduleDescriptors": [ + { + "unitPath": "node_modules/@eggjs/fake-module", + "decoratedFiles": [ + "app/service/UserService.ts" + ] + } + ] + } + }, + "resolveCache": { + "app/extend/context.ts": "app/extend/context.ts", + "app/middleware/timing.ts": null + }, + "fileDiscovery": { + "app/controller": [ + "home.ts" + ], + "app/service": [ + "user.ts" + ] + } +} as const; + +const __BUNDLE_MAP_REL: Record = { + ["app/controller/home.ts"]: __m0, + ["app/extend/context.ts"]: __m1, + ["app/service/user.ts"]: __m2, + ["node_modules/@eggjs/fake-module/app/service/UserService.ts"]: __m3, +}; + +const __BUNDLE_MAP: Record = {}; +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; +} + +ManifestStore.setBundleStore(ManifestStore.fromBundle(MANIFEST_DATA as any, __baseDir)); +setBundleModuleLoader((filepath) => { + const key = filepath.split(path.sep).join('/'); + return __BUNDLE_MAP[key]; +}); + +startEgg({ baseDir: __baseDir, mode: 'single' }).then((app) => { + const port = process.env.PORT || app.config.cluster?.listen?.port || 7001; + app.listen(port, () => { + // eslint-disable-next-line no-console + console.log('[egg-bundler] server listening on port %s', port); + }); +}).catch((err) => { + // eslint-disable-next-line no-console + console.error('[egg-bundler] failed to start bundled app:', err); + process.exit(1); +}); diff --git a/tools/egg-bundler/test/deterministic.test.ts b/tools/egg-bundler/test/deterministic.test.ts new file mode 100644 index 0000000000..32dfa4d7ca --- /dev/null +++ b/tools/egg-bundler/test/deterministic.test.ts @@ -0,0 +1,256 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { bundle, type BuildFunc } from '../src/index.ts'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE_SOURCE = path.join(__dirname, 'fixtures/apps/minimal-app'); + +// T17 verifies that bundling the same app twice produces byte-identical +// artifacts, modulo documented exceptions: +// - bundle-manifest.json.generatedAt is always `new Date().toISOString()` +// - bundle-manifest.json.baseDir reflects the caller's baseDir input +// +// Determinism sources exercised: +// * EntryGenerator sorts fileDiscovery / resolveCache / tegg decoratedFiles +// by relKey (T9 pins this at the unit level, we re-validate here through +// the full bundle() orchestration). +// * Bundler sorts externals and chunks in the bundle-manifest. +// * Bundler writes bundle-manifest.json with JSON.stringify(_, null, 2) — +// stable key order because the object is constructed as a literal. +// * PackRunner pre-writes tsconfig.json and package.json with stable content. +// * The outputDir absolute path must NOT leak into any artifact (two tmpdirs +// with different names would cause drift if it did). +// +// Isolation note: T17 clones the fixture into its own per-test tmp dirs rather +// than writing into `fixtures/apps/minimal-app/.egg/`, because T12's +// integration.test.ts also uses that fixture and vitest runs test files in +// parallel threads. Sharing the fixture would produce flaky ENOENT on +// `.egg/manifest.json` when one file's `afterAll` races the other's `beforeAll`. +// +// Real @utoo/pack determinism is a separate concern, deferred to T16/T20. + +const FIXTURE_MANIFEST = { + version: 1, + generatedAt: '2026-01-01T00:00:00.000Z', + invalidation: { + lockfileFingerprint: 't17-fixture', + configFingerprint: 't17-fixture', + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }, + extensions: {}, + resolveCache: {}, + fileDiscovery: { + 'app/controller': ['home.ts'], + 'app/service': ['user.ts'], + 'app/middleware': ['timing.ts'], + 'app/extend': ['context.ts'], + app: ['router.ts'], + }, +}; + +async function cloneFixture(destParent: string): Promise { + const dest = path.join(destParent, 'app'); + await fs.cp(FIXTURE_SOURCE, dest, { recursive: true }); + // Remove anything the source dir may have accumulated from concurrent tests. + await fs.rm(path.join(dest, '.egg'), { recursive: true, force: true }); + await fs.rm(path.join(dest, '.egg-bundle'), { recursive: true, force: true }); + // Pre-write the fixture manifest so ManifestLoader short-circuits the + // broken `generate-manifest.mjs` subprocess path (documented under T0/T8.1). + const manifestDir = path.join(dest, '.egg'); + await fs.mkdir(manifestDir, { recursive: true }); + await fs.writeFile(path.join(manifestDir, 'manifest.json'), JSON.stringify(FIXTURE_MANIFEST, null, 2)); + return dest; +} + +// The mock build must be deterministic itself — two invocations must write +// identical content. Otherwise we'd be measuring pack variance, not bundler +// variance. Every byte is hard-coded. +function makeDeterministicMockBuild(outputDir: string): BuildFunc { + return async () => { + const artifacts: Array<[string, string]> = [ + ['worker.js', '// deterministic worker chunk\nmodule.exports = { marker: "worker" };\n'], + ['worker.js.map', '{"version":3,"sources":[],"mappings":""}'], + ['_turbopack__runtime.js', '// deterministic runtime shim\n'], + ['_turbopack__runtime.js.map', '{}'], + ['_root-of-the-server___abc123.js', '// deterministic root chunk\n'], + ['_root-of-the-server___abc123.js.map', '{}'], + ]; + for (const [name, body] of artifacts) { + await fs.writeFile(path.join(outputDir, name), body); + } + }; +} + +async function sha256(filepath: string): Promise { + return createHash('sha256') + .update(await fs.readFile(filepath)) + .digest('hex'); +} + +async function hashByBasename(files: readonly string[]): Promise> { + const hashes: Record = {}; + for (const f of files) { + hashes[path.basename(f)] = await sha256(f); + } + return hashes; +} + +describe('bundle() is deterministic (T17)', () => { + const tmpDirs: string[] = []; + + beforeEach(() => { + tmpDirs.length = 0; + }); + + afterEach(async () => { + for (const dir of tmpDirs) { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + async function mkTmp(suffix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), `egg-bundler-det-${suffix}-`)); + const real = await fs.realpath(dir); + tmpDirs.push(real); + return real; + } + + async function makeWorkspace(suffix: string): Promise<{ baseDir: string; outputDir: string }> { + const workspace = await mkTmp(suffix); + const baseDir = await cloneFixture(workspace); + const outputDir = path.join(workspace, 'out'); + await fs.mkdir(outputDir, { recursive: true }); + return { baseDir, outputDir }; + } + + it('same baseDir, two outputDirs → every produced artifact is byte-identical except bundle-manifest.generatedAt', async () => { + // Single baseDir, two output dirs. This is the primary "idempotent given + // same input" contract. worker.entry.ts is written to + // /.egg-bundle/entries/ — two runs will overwrite it with the same + // bytes (we also read it out directly to double-check). + const { baseDir, outputDir: outA } = await makeWorkspace('same-a'); + const outB = path.join(path.dirname(outA), 'out-b'); + await fs.mkdir(outB, { recursive: true }); + + const resultA = await bundle({ + baseDir, + outputDir: outA, + pack: { buildFunc: makeDeterministicMockBuild(outA) }, + }); + const resultB = await bundle({ + baseDir, + outputDir: outB, + pack: { buildFunc: makeDeterministicMockBuild(outB) }, + }); + + // File sets must match by basename. + const basenamesA = resultA.files.map((f) => path.basename(f)).sort(); + const basenamesB = resultB.files.map((f) => path.basename(f)).sort(); + expect(basenamesA).toEqual(basenamesB); + + const hashesA = await hashByBasename(resultA.files); + const hashesB = await hashByBasename(resultB.files); + + const drift: string[] = []; + for (const name of Object.keys(hashesA)) { + if (name === 'bundle-manifest.json') continue; + if (hashesA[name] !== hashesB[name]) drift.push(name); + } + // Drift diagnostic: if this fails, typical culprits (descending likelihood): + // - Map iteration order (Object.keys in EntryGenerator not sorted) + // - Date.now / new Date() snuck into an artifact besides bundle-manifest + // - outputDir absolute path leaked into a chunk (also caught by test 3) + // - tmpdir pollution (previous run's artifacts not cleaned) + // - New dependency accidentally introducing randomness + expect(drift, `non-deterministic files: ${drift.join(', ')}`).toEqual([]); + + // Bundle-manifest: filter out the known-variable field, deepEqual the rest. + type LooseManifest = Record; + const bmA = JSON.parse(await fs.readFile(resultA.manifestPath, 'utf8')) as LooseManifest; + const bmB = JSON.parse(await fs.readFile(resultB.manifestPath, 'utf8')) as LooseManifest; + expect(typeof bmA.generatedAt).toBe('string'); + expect(new Date(bmA.generatedAt as string).toString()).not.toBe('Invalid Date'); + delete bmA.generatedAt; + delete bmB.generatedAt; + expect(bmA).toEqual(bmB); + }); + + it('different baseDir clones produce byte-identical worker.entry.ts (relative specifier guarantee)', async () => { + // Two independent workspace clones, bundled to each own output. Because + // EntryGenerator emits relative specifiers (`../../app/...`) keyed on + // manifest relKeys, the two entry files must be byte-identical even + // though the absolute baseDirs differ entirely. + const { baseDir: baseDirA, outputDir: outA } = await makeWorkspace('diff-a'); + const { baseDir: baseDirB, outputDir: outB } = await makeWorkspace('diff-b'); + expect(baseDirA).not.toBe(baseDirB); + + await bundle({ + baseDir: baseDirA, + outputDir: outA, + pack: { buildFunc: makeDeterministicMockBuild(outA) }, + }); + await bundle({ + baseDir: baseDirB, + outputDir: outB, + pack: { buildFunc: makeDeterministicMockBuild(outB) }, + }); + + const entryA = await fs.readFile(path.join(baseDirA, '.egg-bundle', 'entries', 'worker.entry.ts'), 'utf8'); + const entryB = await fs.readFile(path.join(baseDirB, '.egg-bundle', 'entries', 'worker.entry.ts'), 'utf8'); + expect(entryA).toBe(entryB); + // Sanity: the entry must actually NOT contain either absolute baseDir, + // otherwise byte-equality would be accidental. + expect(entryA).not.toContain(baseDirA); + expect(entryA).not.toContain(baseDirB); + + // Same for worker.js / tsconfig.json / package.json — everything in outA + // should be byte-identical to its outB counterpart. + const drift: string[] = []; + const namesInA = (await fs.readdir(outA)).sort(); + const namesInB = (await fs.readdir(outB)).sort(); + expect(namesInA).toEqual(namesInB); + for (const name of namesInA) { + if (name === 'bundle-manifest.json') continue; // carries baseDir + generatedAt + const hashA = await sha256(path.join(outA, name)); + const hashB = await sha256(path.join(outB, name)); + if (hashA !== hashB) drift.push(name); + } + expect(drift, `non-deterministic files across clones: ${drift.join(', ')}`).toEqual([]); + }); + + it('no artifact leaks the outputDir absolute path (tmpdir difference is invisible to consumers)', async () => { + const { baseDir, outputDir: outA } = await makeWorkspace('leak-a'); + const outB = path.join(path.dirname(outA), 'out-b'); + await fs.mkdir(outB, { recursive: true }); + + const resultA = await bundle({ + baseDir, + outputDir: outA, + pack: { buildFunc: makeDeterministicMockBuild(outA) }, + }); + const resultB = await bundle({ + baseDir, + outputDir: outB, + pack: { buildFunc: makeDeterministicMockBuild(outB) }, + }); + + // bundle-manifest.json intentionally carries `baseDir` but NOT `outputDir`, + // so no emitted file should reference either tmp output path. The + // worker.entry.ts (under baseDir/.egg-bundle/entries/) uses relative + // imports — it never touches the outputDir. + const allFiles = [...resultA.files, ...resultB.files]; + for (const file of allFiles) { + const content = await fs.readFile(file, 'utf8'); + expect(content, `${path.basename(file)} leaks outA`).not.toContain(outA); + expect(content, `${path.basename(file)} leaks outB`).not.toContain(outB); + } + }); +}); diff --git a/tools/egg-bundler/test/fixtures/apps/empty-app/app/public/.gitkeep b/tools/egg-bundler/test/fixtures/apps/empty-app/app/public/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/egg-bundler/test/fixtures/apps/empty-app/app/router.ts b/tools/egg-bundler/test/fixtures/apps/empty-app/app/router.ts new file mode 100644 index 0000000000..a8005dc1b0 --- /dev/null +++ b/tools/egg-bundler/test/fixtures/apps/empty-app/app/router.ts @@ -0,0 +1,7 @@ +import type { Application } from 'egg'; + +export default (app: Application): void => { + app.get('/', async (ctx) => { + ctx.body = { ok: true }; + }); +}; diff --git a/tools/egg-bundler/test/fixtures/apps/empty-app/config/config.default.ts b/tools/egg-bundler/test/fixtures/apps/empty-app/config/config.default.ts new file mode 100644 index 0000000000..12f107aaa5 --- /dev/null +++ b/tools/egg-bundler/test/fixtures/apps/empty-app/config/config.default.ts @@ -0,0 +1,9 @@ +interface EmptyAppConfig { + keys: string; +} + +export default (): EmptyAppConfig => { + return { + keys: 'empty-app-keys', + }; +}; diff --git a/tools/egg-bundler/test/fixtures/apps/empty-app/package.json b/tools/egg-bundler/test/fixtures/apps/empty-app/package.json new file mode 100644 index 0000000000..52f5124787 --- /dev/null +++ b/tools/egg-bundler/test/fixtures/apps/empty-app/package.json @@ -0,0 +1,9 @@ +{ + "name": "empty-app", + "version": "1.0.0", + "private": true, + "type": "module", + "dependencies": { + "egg": "workspace:*" + } +} diff --git a/tools/egg-bundler/test/fixtures/apps/empty-app/tsconfig.json b/tools/egg-bundler/test/fixtures/apps/empty-app/tsconfig.json new file mode 100644 index 0000000000..8d9b97040d --- /dev/null +++ b/tools/egg-bundler/test/fixtures/apps/empty-app/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../../../../../tsconfig.json" +} diff --git a/tools/egg-bundler/test/fixtures/apps/minimal-app/app.ts b/tools/egg-bundler/test/fixtures/apps/minimal-app/app.ts new file mode 100644 index 0000000000..8cc12916f8 --- /dev/null +++ b/tools/egg-bundler/test/fixtures/apps/minimal-app/app.ts @@ -0,0 +1,18 @@ +import type { Application, ILifecycleBoot } from 'egg'; + +export default class AppBoot implements ILifecycleBoot { + private readonly app: Application; + + constructor(app: Application) { + this.app = app; + } + + async didLoad(): Promise { + (this.app as unknown as { bootStages: string[] }).bootStages ??= []; + (this.app as unknown as { bootStages: string[] }).bootStages.push('didLoad'); + } + + async willReady(): Promise { + (this.app as unknown as { bootStages: string[] }).bootStages.push('willReady'); + } +} diff --git a/tools/egg-bundler/test/fixtures/apps/minimal-app/app/controller/home.ts b/tools/egg-bundler/test/fixtures/apps/minimal-app/app/controller/home.ts new file mode 100644 index 0000000000..d26542fb18 --- /dev/null +++ b/tools/egg-bundler/test/fixtures/apps/minimal-app/app/controller/home.ts @@ -0,0 +1,12 @@ +import { Controller } from 'egg'; + +export default class HomeController extends Controller { + async index(): Promise { + this.ctx.body = { ok: true, appName: this.ctx.appName }; + } + + async user(): Promise { + const { id } = this.ctx.params as { id: string }; + this.ctx.body = await this.ctx.service.user.getUser(id); + } +} diff --git a/tools/egg-bundler/test/fixtures/apps/minimal-app/app/extend/context.ts b/tools/egg-bundler/test/fixtures/apps/minimal-app/app/extend/context.ts new file mode 100644 index 0000000000..7c3ebd5b05 --- /dev/null +++ b/tools/egg-bundler/test/fixtures/apps/minimal-app/app/extend/context.ts @@ -0,0 +1,5 @@ +export default { + get appName(): string { + return 'minimal-app'; + }, +}; diff --git a/tools/egg-bundler/test/fixtures/apps/minimal-app/app/middleware/timing.ts b/tools/egg-bundler/test/fixtures/apps/minimal-app/app/middleware/timing.ts new file mode 100644 index 0000000000..1002acc5ab --- /dev/null +++ b/tools/egg-bundler/test/fixtures/apps/minimal-app/app/middleware/timing.ts @@ -0,0 +1,9 @@ +import type { Context, Next } from 'egg'; + +export default function timing() { + return async (ctx: Context, next: Next): Promise => { + const start = Date.now(); + await next(); + ctx.set('X-Response-Time', `${Date.now() - start}ms`); + }; +} diff --git a/tools/egg-bundler/test/fixtures/apps/minimal-app/app/public/.gitkeep b/tools/egg-bundler/test/fixtures/apps/minimal-app/app/public/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/egg-bundler/test/fixtures/apps/minimal-app/app/router.ts b/tools/egg-bundler/test/fixtures/apps/minimal-app/app/router.ts new file mode 100644 index 0000000000..741acda0be --- /dev/null +++ b/tools/egg-bundler/test/fixtures/apps/minimal-app/app/router.ts @@ -0,0 +1,6 @@ +import type { Application } from 'egg'; + +export default (app: Application): void => { + app.get('/', app.controller.home.index); + app.get('/user/:id', app.controller.home.user); +}; diff --git a/tools/egg-bundler/test/fixtures/apps/minimal-app/app/service/user.ts b/tools/egg-bundler/test/fixtures/apps/minimal-app/app/service/user.ts new file mode 100644 index 0000000000..a906660a4a --- /dev/null +++ b/tools/egg-bundler/test/fixtures/apps/minimal-app/app/service/user.ts @@ -0,0 +1,7 @@ +import { Service } from 'egg'; + +export default class UserService extends Service { + async getUser(id: string): Promise<{ id: string; name: string }> { + return { id, name: `user-${id}` }; + } +} diff --git a/tools/egg-bundler/test/fixtures/apps/minimal-app/config/config.default.ts b/tools/egg-bundler/test/fixtures/apps/minimal-app/config/config.default.ts new file mode 100644 index 0000000000..e421cda959 --- /dev/null +++ b/tools/egg-bundler/test/fixtures/apps/minimal-app/config/config.default.ts @@ -0,0 +1,20 @@ +import path from 'node:path'; + +import type { EggAppInfo } from 'egg'; + +interface MinimalAppConfig { + keys: string; + middleware: string[]; + static: { prefix: string; dir: string }; +} + +export default (appInfo: EggAppInfo): MinimalAppConfig => { + return { + keys: 'minimal-app-keys', + middleware: ['timing'], + static: { + prefix: '/public/', + dir: path.join(appInfo.baseDir, 'app/public'), + }, + }; +}; diff --git a/tools/egg-bundler/test/fixtures/apps/minimal-app/package.json b/tools/egg-bundler/test/fixtures/apps/minimal-app/package.json new file mode 100644 index 0000000000..1201e4a341 --- /dev/null +++ b/tools/egg-bundler/test/fixtures/apps/minimal-app/package.json @@ -0,0 +1,10 @@ +{ + "name": "minimal-app", + "version": "1.0.0", + "private": true, + "type": "module", + "dependencies": { + "@eggjs/static": "workspace:*", + "egg": "workspace:*" + } +} diff --git a/tools/egg-bundler/test/fixtures/apps/minimal-app/tsconfig.json b/tools/egg-bundler/test/fixtures/apps/minimal-app/tsconfig.json new file mode 100644 index 0000000000..8d9b97040d --- /dev/null +++ b/tools/egg-bundler/test/fixtures/apps/minimal-app/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../../../../../tsconfig.json" +} diff --git a/tools/egg-bundler/test/fixtures/apps/tegg-app/config/config.default.ts b/tools/egg-bundler/test/fixtures/apps/tegg-app/config/config.default.ts new file mode 100644 index 0000000000..4daf0cc23d --- /dev/null +++ b/tools/egg-bundler/test/fixtures/apps/tegg-app/config/config.default.ts @@ -0,0 +1,15 @@ +interface TeggAppConfig { + keys: string; + security: { csrf: { enable: boolean } }; +} + +export default (): TeggAppConfig => { + return { + keys: 'tegg-app-keys', + security: { + csrf: { + enable: false, + }, + }, + }; +}; diff --git a/tools/egg-bundler/test/fixtures/apps/tegg-app/config/module.json b/tools/egg-bundler/test/fixtures/apps/tegg-app/config/module.json new file mode 100644 index 0000000000..ab5f866ce7 --- /dev/null +++ b/tools/egg-bundler/test/fixtures/apps/tegg-app/config/module.json @@ -0,0 +1,5 @@ +[ + { + "path": "../modules/foo" + } +] diff --git a/tools/egg-bundler/test/fixtures/apps/tegg-app/config/plugin.ts b/tools/egg-bundler/test/fixtures/apps/tegg-app/config/plugin.ts new file mode 100644 index 0000000000..054c70374b --- /dev/null +++ b/tools/egg-bundler/test/fixtures/apps/tegg-app/config/plugin.ts @@ -0,0 +1,14 @@ +export default { + teggConfig: { + package: '@eggjs/tegg-config', + enable: true, + }, + tegg: { + package: '@eggjs/tegg-plugin', + enable: true, + }, + teggController: { + package: '@eggjs/controller-plugin', + enable: true, + }, +}; diff --git a/tools/egg-bundler/test/fixtures/apps/tegg-app/modules/foo/FooController.ts b/tools/egg-bundler/test/fixtures/apps/tegg-app/modules/foo/FooController.ts new file mode 100644 index 0000000000..d25b35c6e0 --- /dev/null +++ b/tools/egg-bundler/test/fixtures/apps/tegg-app/modules/foo/FooController.ts @@ -0,0 +1,21 @@ +import { HTTPController, HTTPMethod, HTTPMethodEnum, HTTPQuery, Inject } from '@eggjs/tegg'; + +import { FooService } from './FooService.js'; + +@HTTPController({ + path: '/tegg', +}) +export class FooController { + @Inject() + fooService: FooService; + + @HTTPMethod({ + method: HTTPMethodEnum.GET, + path: '/hello', + }) + async hello(@HTTPQuery() name: string): Promise<{ message: string }> { + return { + message: this.fooService.hello(name || 'world'), + }; + } +} diff --git a/tools/egg-bundler/test/fixtures/apps/tegg-app/modules/foo/FooService.ts b/tools/egg-bundler/test/fixtures/apps/tegg-app/modules/foo/FooService.ts new file mode 100644 index 0000000000..190602567c --- /dev/null +++ b/tools/egg-bundler/test/fixtures/apps/tegg-app/modules/foo/FooService.ts @@ -0,0 +1,10 @@ +import { AccessLevel, SingletonProto } from '@eggjs/tegg'; + +@SingletonProto({ + accessLevel: AccessLevel.PUBLIC, +}) +export class FooService { + hello(name: string): string { + return `hello, ${name}`; + } +} diff --git a/tools/egg-bundler/test/fixtures/apps/tegg-app/modules/foo/package.json b/tools/egg-bundler/test/fixtures/apps/tegg-app/modules/foo/package.json new file mode 100644 index 0000000000..804ffd01f9 --- /dev/null +++ b/tools/egg-bundler/test/fixtures/apps/tegg-app/modules/foo/package.json @@ -0,0 +1,7 @@ +{ + "name": "tegg-app-foo", + "type": "module", + "eggModule": { + "name": "foo" + } +} diff --git a/tools/egg-bundler/test/fixtures/apps/tegg-app/package.json b/tools/egg-bundler/test/fixtures/apps/tegg-app/package.json new file mode 100644 index 0000000000..a413ef33f2 --- /dev/null +++ b/tools/egg-bundler/test/fixtures/apps/tegg-app/package.json @@ -0,0 +1,13 @@ +{ + "name": "tegg-app", + "version": "1.0.0", + "private": true, + "type": "module", + "dependencies": { + "@eggjs/controller-plugin": "workspace:*", + "@eggjs/tegg": "workspace:*", + "@eggjs/tegg-config": "workspace:*", + "@eggjs/tegg-plugin": "workspace:*", + "egg": "workspace:*" + } +} diff --git a/tools/egg-bundler/test/fixtures/apps/tegg-app/tsconfig.json b/tools/egg-bundler/test/fixtures/apps/tegg-app/tsconfig.json new file mode 100644 index 0000000000..8d9b97040d --- /dev/null +++ b/tools/egg-bundler/test/fixtures/apps/tegg-app/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../../../../../tsconfig.json" +} diff --git a/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/@eggjs/some-plugin/package.json b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/@eggjs/some-plugin/package.json new file mode 100644 index 0000000000..2bd1e1f2c0 --- /dev/null +++ b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/@eggjs/some-plugin/package.json @@ -0,0 +1,4 @@ +{ + "name": "@eggjs/some-plugin", + "version": "1.0.0" +} diff --git a/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/esm-dual/package.json b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/esm-dual/package.json new file mode 100644 index 0000000000..2f12c29ff7 --- /dev/null +++ b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/esm-dual/package.json @@ -0,0 +1,11 @@ +{ + "name": "esm-dual", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "require": "./cjs/index.js", + "import": "./esm/index.mjs" + } + } +} diff --git a/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/esm-only/package.json b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/esm-only/package.json new file mode 100644 index 0000000000..35d4e1dce3 --- /dev/null +++ b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/esm-only/package.json @@ -0,0 +1,8 @@ +{ + "name": "esm-only", + "version": "1.0.0", + "type": "module", + "exports": { + ".": "./index.mjs" + } +} diff --git a/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/native-binding/binding.gyp b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/native-binding/binding.gyp new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/native-binding/binding.gyp @@ -0,0 +1 @@ +{} diff --git a/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/native-binding/package.json b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/native-binding/package.json new file mode 100644 index 0000000000..6f9d9cb737 --- /dev/null +++ b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/native-binding/package.json @@ -0,0 +1,4 @@ +{ + "name": "native-binding", + "version": "1.0.0" +} diff --git a/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/native-dotnode/addon.node b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/native-dotnode/addon.node new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/native-dotnode/package.json b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/native-dotnode/package.json new file mode 100644 index 0000000000..2fc13a6d6c --- /dev/null +++ b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/native-dotnode/package.json @@ -0,0 +1,4 @@ +{ + "name": "native-dotnode", + "version": "1.0.0" +} diff --git a/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/native-prebuilds/package.json b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/native-prebuilds/package.json new file mode 100644 index 0000000000..a0e8346d21 --- /dev/null +++ b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/native-prebuilds/package.json @@ -0,0 +1,4 @@ +{ + "name": "native-prebuilds", + "version": "1.0.0" +} diff --git a/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/native-prebuilds/prebuilds/placeholder.txt b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/native-prebuilds/prebuilds/placeholder.txt new file mode 100644 index 0000000000..634ea4a524 --- /dev/null +++ b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/native-prebuilds/prebuilds/placeholder.txt @@ -0,0 +1 @@ +prebuild placeholder diff --git a/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/native-scripts/package.json b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/native-scripts/package.json new file mode 100644 index 0000000000..4d682a3263 --- /dev/null +++ b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/native-scripts/package.json @@ -0,0 +1,7 @@ +{ + "name": "native-scripts", + "version": "1.0.0", + "scripts": { + "install": "node-gyp rebuild" + } +} diff --git a/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/normal-js/package.json b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/normal-js/package.json new file mode 100644 index 0000000000..62922aa554 --- /dev/null +++ b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/normal-js/package.json @@ -0,0 +1,5 @@ +{ + "name": "normal-js", + "version": "1.0.0", + "main": "index.js" +} diff --git a/tools/egg-bundler/test/fixtures/externals/basic-app/package.json b/tools/egg-bundler/test/fixtures/externals/basic-app/package.json new file mode 100644 index 0000000000..07fab0b6e2 --- /dev/null +++ b/tools/egg-bundler/test/fixtures/externals/basic-app/package.json @@ -0,0 +1,19 @@ +{ + "name": "basic-app", + "version": "1.0.0", + "private": true, + "dependencies": { + "@eggjs/some-plugin": "1.0.0", + "esm-dual": "1.0.0", + "esm-only": "1.0.0", + "missing-pkg": "1.0.0", + "native-binding": "1.0.0", + "native-dotnode": "1.0.0", + "native-prebuilds": "1.0.0", + "native-scripts": "1.0.0", + "normal-js": "1.0.0" + }, + "peerDependencies": { + "peer-only": "1.0.0" + } +} diff --git a/tools/egg-bundler/test/index.test.ts b/tools/egg-bundler/test/index.test.ts new file mode 100644 index 0000000000..6b080e6138 --- /dev/null +++ b/tools/egg-bundler/test/index.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from 'vitest'; + +import { bundle, Bundler } from '../src/index.ts'; + +describe('@eggjs/egg-bundler', () => { + it('exposes a Bundler class and a bundle() helper', () => { + expect(typeof bundle).toBe('function'); + expect(typeof Bundler).toBe('function'); + expect(new Bundler({ baseDir: '/tmp', outputDir: '/tmp/out' })).toBeInstanceOf(Bundler); + }); + + // Full bundle() integration coverage lives in T12 (minimal-app fixture + // end-to-end via test-expert). This file just pins the public surface. +}); diff --git a/tools/egg-bundler/test/integration.test.ts b/tools/egg-bundler/test/integration.test.ts new file mode 100644 index 0000000000..08c896663e --- /dev/null +++ b/tools/egg-bundler/test/integration.test.ts @@ -0,0 +1,245 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { bundle, type BuildFunc } from '../src/index.ts'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE_BASE = path.join(__dirname, 'fixtures/apps/minimal-app'); + +// The minimal-app fixture has no checked-in manifest. `bundle()` would try +// to fork `generate-manifest.mjs`, which fails under raw node because +// `packages/egg`'s `exports['.']` points at `./src/index.ts` and Node's +// type-stripper can't parse tegg decorators reachable from that entry. The +// subprocess failure is tracked separately; for T12 we pre-write a minimal +// valid manifest and let `ManifestLoader.#readFromDisk` short-circuit. +const FIXTURE_MANIFEST = { + version: 1, + generatedAt: '2026-01-01T00:00:00.000Z', + invalidation: { + lockfileFingerprint: 't12-fixture', + configFingerprint: 't12-fixture', + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }, + extensions: {}, + resolveCache: {}, + fileDiscovery: { + 'app/controller': ['home.ts'], + 'app/service': ['user.ts'], + 'app/middleware': ['timing.ts'], + 'app/extend': ['context.ts'], + app: ['router.ts'], + }, +}; + +async function writeFixtureManifest(): Promise { + const manifestDir = path.join(FIXTURE_BASE, '.egg'); + await fs.mkdir(manifestDir, { recursive: true }); + const manifestPath = path.join(manifestDir, 'manifest.json'); + await fs.writeFile(manifestPath, JSON.stringify(FIXTURE_MANIFEST, null, 2)); + return manifestPath; +} + +async function removeFixtureManifest(): Promise { + await fs.rm(path.join(FIXTURE_BASE, '.egg'), { recursive: true, force: true }); +} + +describe('bundle() integration — minimal-app (Phase 1: mocked @utoo/pack)', () => { + let tmpOutput: string; + let writtenFiles: string[]; + + beforeAll(async () => { + await writeFixtureManifest(); + }); + + afterAll(async () => { + await removeFixtureManifest(); + }); + + beforeEach(async () => { + tmpOutput = await fs.mkdtemp(path.join(os.tmpdir(), 'egg-bundle-t12-')); + writtenFiles = []; + }); + + afterEach(async () => { + await fs.rm(tmpOutput, { recursive: true, force: true }); + }); + + // Mock build that mimics what @utoo/pack would produce, so the rest of the + // Bundler pipeline (file enumeration, bundle-manifest write-back) sees a + // realistic output shape. + function makeMockBuild(): BuildFunc { + return async (_wrapped, _project, _root) => { + const artifacts = [ + ['worker.js', '// fake worker chunk\nmodule.exports = {};\n'], + ['worker.js.map', '{"version":3,"sources":[],"mappings":""}'], + ['_turbopack__runtime.js', '// fake runtime shim\n'], + ['_turbopack__runtime.js.map', '{}'], + ['_root-of-the-server___abc123.js', '// fake root chunk\n'], + ['_root-of-the-server___abc123.js.map', '{}'], + ]; + for (const [name, body] of artifacts) { + const filePath = path.join(tmpOutput, name); + await fs.writeFile(filePath, body); + writtenFiles.push(name); + } + }; + } + + it('returns a BundleResult whose outputDir / files / manifestPath match the output directory', async () => { + const result = await bundle({ + baseDir: FIXTURE_BASE, + outputDir: tmpOutput, + pack: { buildFunc: makeMockBuild() }, + }); + + expect(result.outputDir).toBe(tmpOutput); + expect(result.manifestPath).toBe(path.join(tmpOutput, 'bundle-manifest.json')); + // files must include all mock chunks + PackRunner pre-writes + bundle-manifest + expect(result.files).toEqual( + expect.arrayContaining([ + path.join(tmpOutput, 'worker.js'), + path.join(tmpOutput, 'worker.js.map'), + path.join(tmpOutput, '_turbopack__runtime.js'), + path.join(tmpOutput, '_root-of-the-server___abc123.js'), + path.join(tmpOutput, 'tsconfig.json'), + path.join(tmpOutput, 'package.json'), + path.join(tmpOutput, 'bundle-manifest.json'), + ]), + ); + // files should be sorted + expect([...result.files]).toEqual([...result.files].sort()); + }); + + it('PackRunner pre-writes tsconfig.json (with decorator flags) and package.json (type: commonjs) BEFORE the build step runs', async () => { + let tsconfigAtBuildTime: string | undefined; + let pkgAtBuildTime: string | undefined; + const buildFunc: BuildFunc = async () => { + tsconfigAtBuildTime = await fs.readFile(path.join(tmpOutput, 'tsconfig.json'), 'utf8'); + pkgAtBuildTime = await fs.readFile(path.join(tmpOutput, 'package.json'), 'utf8'); + await fs.writeFile(path.join(tmpOutput, 'worker.js'), '// mock\n'); + }; + + await bundle({ + baseDir: FIXTURE_BASE, + outputDir: tmpOutput, + pack: { buildFunc }, + }); + + expect(tsconfigAtBuildTime).toBeDefined(); + const tsconfig = JSON.parse(tsconfigAtBuildTime!); + expect(tsconfig.compilerOptions.experimentalDecorators).toBe(true); + expect(tsconfig.compilerOptions.emitDecoratorMetadata).toBe(true); + expect(tsconfig.compilerOptions.target).toBe('es2022'); + + expect(pkgAtBuildTime).toBeDefined(); + expect(JSON.parse(pkgAtBuildTime!)).toEqual({ type: 'commonjs' }); + }); + + it('writes a bundle-manifest.json whose schema matches docs/output-structure.md', async () => { + const result = await bundle({ + baseDir: FIXTURE_BASE, + outputDir: tmpOutput, + pack: { buildFunc: makeMockBuild() }, + }); + + const bm = JSON.parse(await fs.readFile(result.manifestPath, 'utf8')); + expect(bm.version).toBe(1); + expect(typeof bm.generatedAt).toBe('string'); + expect(new Date(bm.generatedAt).toString()).not.toBe('Invalid Date'); + expect(bm.mode).toBe('production'); + expect(bm.baseDir).toBe(FIXTURE_BASE); + expect(bm.framework).toBe('egg'); + expect(bm.entries).toEqual([{ name: 'worker', source: expect.stringContaining('worker.entry.ts') }]); + expect(Array.isArray(bm.externals)).toBe(true); + // externals should be sorted and should contain at least egg (workspace dep) + expect([...bm.externals]).toEqual([...bm.externals].sort()); + expect(bm.externals).toContain('egg'); + // chunks should be sorted and contain worker.js + expect([...bm.chunks]).toEqual([...bm.chunks].sort()); + expect(bm.chunks).toContain('worker.js'); + expect(bm.chunks).toContain('tsconfig.json'); + expect(bm.chunks).toContain('package.json'); + }); + + it('honors mode: "development" in both the BundleResult and the bundle-manifest', async () => { + const result = await bundle({ + baseDir: FIXTURE_BASE, + outputDir: tmpOutput, + mode: 'development', + pack: { buildFunc: makeMockBuild() }, + }); + const bm = JSON.parse(await fs.readFile(result.manifestPath, 'utf8')); + expect(bm.mode).toBe('development'); + }); + + it('honors externals.force to inject an extra external into the bundle-manifest externals list', async () => { + const result = await bundle({ + baseDir: FIXTURE_BASE, + outputDir: tmpOutput, + pack: { buildFunc: makeMockBuild() }, + externals: { force: ['synthetic-force-ext'] }, + }); + const bm = JSON.parse(await fs.readFile(result.manifestPath, 'utf8')); + expect(bm.externals).toContain('synthetic-force-ext'); + }); + + it('wraps a buildFunc failure under the "pack build" step with an identifiable prefix and preserves cause', async () => { + const original = new Error('synthetic pack failure'); + const buildFunc: BuildFunc = async () => { + throw original; + }; + + await expect( + bundle({ + baseDir: FIXTURE_BASE, + outputDir: tmpOutput, + pack: { buildFunc }, + }), + ).rejects.toThrowError(/\[@eggjs\/egg-bundler\] pack build failed/); + + try { + await bundle({ + baseDir: FIXTURE_BASE, + outputDir: tmpOutput, + pack: { buildFunc }, + }); + } catch (err) { + // Bundler wraps with its own message; PackRunner wraps once inside. + // Walk the cause chain to find the synthetic root. + let cause: unknown = (err as Error).cause; + while (cause && (cause as Error).cause) cause = (cause as Error).cause; + expect(cause).toBe(original); + } + }); + + it('the worker.entry.ts generated by EntryGenerator lives under /.egg-bundle/entries/ and is referenced by bundle-manifest.entries[].source', async () => { + const result = await bundle({ + baseDir: FIXTURE_BASE, + outputDir: tmpOutput, + pack: { buildFunc: makeMockBuild() }, + }); + const bm = JSON.parse(await fs.readFile(result.manifestPath, 'utf8')); + const workerSource = bm.entries[0].source as string; + expect(workerSource).toBe(path.join(FIXTURE_BASE, '.egg-bundle', 'entries', 'worker.entry.ts')); + await expect(fs.stat(workerSource)).resolves.toBeTruthy(); + // Spot-check: the generated entry contains the runtime hook calls + const entrySource = await fs.readFile(workerSource, 'utf8'); + expect(entrySource).toContain('ManifestStore.setBundleStore'); + expect(entrySource).toContain('setBundleModuleLoader'); + expect(entrySource).toContain('startEgg'); + }); +}); + +describe('bundle() integration — minimal-app (Phase 2: real @utoo/pack)', () => { + it.skip('produces real @utoo/pack output — SKIPPED: depends on generate-manifest subprocess fix (see team-lead report). T16 covers this under built-egg.', async () => { + // Intentionally skipped; see the T12 report for the production bug in + // tools/egg-bundler/src/scripts/generate-manifest.mjs (raw node cannot + // parse tegg decorators reachable from egg's src entry). + }); +}); diff --git a/tools/egg-bundler/test/no-filesystem-scan.test.ts b/tools/egg-bundler/test/no-filesystem-scan.test.ts new file mode 100644 index 0000000000..ffd39ec4f5 --- /dev/null +++ b/tools/egg-bundler/test/no-filesystem-scan.test.ts @@ -0,0 +1,162 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { FileLoader, ManifestStore, type StartupManifest } from '@eggjs/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// T13 verifies the core contract that egg-bundler's deployment story rests on: +// a bundled app that registered a StartupManifest via `ManifestStore.setBundleStore` +// must NOT perform directory scans at boot. We validate this at two layers: +// +// 1. ManifestStore.load short-circuits to the registered bundle store, bypassing +// disk reads entirely (positive evidence). +// 2. ManifestStore.globFiles and FileLoader both skip their fallback globber +// whenever the directory is present in `fileDiscovery` (negative evidence). +// +// Full end-to-end verification inside a spawned bundled worker is deferred to T16 +// (cnpmcore E2E), since spawning egg with workspace dev links hits the strip-types +// blocker documented in T0. + +const FROZEN_INVALIDATION = { + lockfileFingerprint: 't13-fixture', + configFingerprint: 't13-fixture', + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, +} as const; + +function makeManifest(fileDiscovery: Record): StartupManifest { + return { + version: 1, + generatedAt: '2026-01-01T00:00:00.000Z', + invalidation: { ...FROZEN_INVALIDATION }, + extensions: {}, + resolveCache: {}, + fileDiscovery, + }; +} + +describe('no filesystem scan contract (T13)', () => { + let tmpDir: string; + let controllerDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'egg-bundler-no-scan-')); + // macOS tmpdir is a symlink (/var → /private/var). Resolve so the path + // the test uses matches the path FileLoader sees when it computes + // path.relative(baseDir, directory). + tmpDir = await fs.realpath(tmpDir); + controllerDir = path.join(tmpDir, 'app', 'controller'); + await fs.mkdir(controllerDir, { recursive: true }); + // Two files on disk: only `home.js` is in the manifest. + // If anything scans the directory we'll see `extra.js` leak through. + await fs.writeFile(path.join(controllerDir, 'home.js'), 'module.exports = class Home {};\n'); + await fs.writeFile(path.join(controllerDir, 'extra.js'), 'module.exports = class Extra {};\n'); + ManifestStore.setBundleStore(undefined); + }); + + afterEach(async () => { + ManifestStore.setBundleStore(undefined); + vi.restoreAllMocks(); + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + describe('ManifestStore.setBundleStore (positive — registered bundle store wins)', () => { + it('ManifestStore.load returns the registered bundle store without any disk read', () => { + const store = ManifestStore.fromBundle(makeManifest({ 'app/controller': ['home.js'] }), tmpDir); + ManifestStore.setBundleStore(store); + + // `tmpDir` has no `.egg/manifest.json`. Without the bundle store, load() + // would return null. With the bundle store, it returns the exact instance + // we registered — proving the load path never touched fs. + const loaded = ManifestStore.load(tmpDir, 'prod', ''); + expect(loaded).toBe(store); + expect(ManifestStore.getBundleStore()).toBe(store); + }); + + it('ManifestStore.load without a registered bundle store returns null in prod when no .egg/manifest.json exists (control)', () => { + // Baseline: fresh tmp dir, no bundle store, no disk manifest → null. + // This is what proves the previous test is actually exercising the short-circuit. + expect(ManifestStore.getBundleStore()).toBeUndefined(); + expect(ManifestStore.load(tmpDir, 'prod', '')).toBeNull(); + }); + }); + + describe('ManifestStore.globFiles (contract — fallback only runs on cache miss)', () => { + it('skips the fallback globber when fileDiscovery has the directory cached', () => { + const store = ManifestStore.fromBundle(makeManifest({ 'app/controller': ['home.js'] }), tmpDir); + const fallback = vi.fn<() => string[]>(() => { + throw new Error('fallback must not be called when manifest hits cache'); + }); + + const result = store.globFiles(controllerDir, fallback); + + expect(fallback).not.toHaveBeenCalled(); + expect(result).toEqual(['home.js']); + }); + + it('invokes the fallback globber on cache miss (control — proves the test is discriminating)', () => { + const store = ManifestStore.fromBundle(makeManifest({}), tmpDir); + const fallback = vi.fn<() => string[]>(() => ['home.js']); + + const result = store.globFiles(controllerDir, fallback); + + expect(fallback).toHaveBeenCalledTimes(1); + expect(result).toEqual(['home.js']); + }); + }); + + describe('FileLoader end-to-end (bundled manifest really replaces disk scan)', () => { + it('loads only files listed in fileDiscovery, ignoring extras on disk', async () => { + const store = ManifestStore.fromBundle(makeManifest({ 'app/controller': ['home.js'] }), tmpDir); + const target: Record = {}; + const loader = new FileLoader({ + directory: controllerDir, + target, + manifest: store, + }); + await loader.load(); + + // `home.js` is loaded because it's in fileDiscovery. + // `extra.js` exists on disk but is NOT in fileDiscovery, so if the loader + // short-circuits globby correctly, it will never appear on `target`. + expect(Object.keys(target)).toEqual(['home']); + expect(target.home).toBeTypeOf('function'); + }); + + it('without a manifest, FileLoader scans the directory and picks up both files (control)', async () => { + const target: Record = {}; + const loader = new FileLoader({ + directory: controllerDir, + target, + }); + await loader.load(); + + // No manifest → globby scan → both files loaded. This proves the previous + // test's exclusion of `extra.js` is due to the manifest, not a bug in the + // fixture. + expect(Object.keys(target).sort()).toEqual(['extra', 'home']); + }); + + it('still uses the manifest cache even when an unrelated directory on disk would also match', async () => { + // Defense-in-depth: add a second directory the fileDiscovery does NOT + // mention. The loader should quietly ignore it — the only directory it + // asks globFiles about is `controllerDir`, and that one is cached. + const orphanDir = path.join(tmpDir, 'app', 'orphan'); + await fs.mkdir(orphanDir, { recursive: true }); + await fs.writeFile(path.join(orphanDir, 'ghost.js'), 'module.exports = class Ghost {};\n'); + + const store = ManifestStore.fromBundle(makeManifest({ 'app/controller': ['home.js'] }), tmpDir); + const target: Record = {}; + const loader = new FileLoader({ + directory: controllerDir, + target, + manifest: store, + }); + await loader.load(); + + expect(Object.keys(target)).toEqual(['home']); + }); + }); +}); diff --git a/tools/egg-bundler/tsconfig.json b/tools/egg-bundler/tsconfig.json new file mode 100644 index 0000000000..4082f16a5d --- /dev/null +++ b/tools/egg-bundler/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/tools/egg-bundler/tsdown.config.ts b/tools/egg-bundler/tsdown.config.ts new file mode 100644 index 0000000000..7c0f02e8b2 --- /dev/null +++ b/tools/egg-bundler/tsdown.config.ts @@ -0,0 +1,15 @@ +import { defineConfig, type UserConfig } from 'tsdown'; + +const config: UserConfig = defineConfig({ + entry: 'src/**/*.ts', + unbundle: true, + fixedExtension: false, + external: [/^@eggjs\//, 'egg', '@utoo/pack', /\.node$/], + copy: [{ from: 'src/scripts/generate-manifest.mjs', to: 'dist/scripts/' }], + unused: { + level: 'warn', + ignore: ['@utoo/pack', 'egg'], + }, +}); + +export default config; diff --git a/tools/egg-bundler/vitest.config.ts b/tools/egg-bundler/vitest.config.ts new file mode 100644 index 0000000000..29686aa4fd --- /dev/null +++ b/tools/egg-bundler/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineProject, type UserWorkspaceConfig } from 'vitest/config'; + +const config: UserWorkspaceConfig = defineProject({ + test: { + testTimeout: 20000, + include: ['test/**/*.test.ts'], + exclude: ['**/node_modules/**', '**/dist/**'], + }, +}); + +export default config; diff --git a/tsconfig.json b/tsconfig.json index 0a167c7226..eef713b375 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -104,6 +104,9 @@ { "path": "./tools/egg-bin" }, + { + "path": "./tools/egg-bundler" + }, { "path": "./tools/scripts" }, diff --git a/tsdown.config.ts b/tsdown.config.ts index 389c514ae1..86b2131f5a 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -26,7 +26,10 @@ export default defineConfig({ // Default entry pattern - glob to include all source files entry: 'src/**/*.ts', - // should set unbundle and external together, avoid bundle @eggjs/* and egg packages + // should set unbundle and external together, avoid bundle @eggjs/* and egg packages. + // `@utoo/pack` ships prebuilt NAPI-RS binaries (`*.node`), and its transitive + // `domparser-rs` does the same — rolldown cannot analyse those binary files, + // so keep both the package and any `.node` binary fully external. unbundle: true, - external: [/^@eggjs\//, 'egg'], + external: [/^@eggjs\//, 'egg', '@utoo/pack', /\.node$/], });