Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -112,5 +112,8 @@ tegg/plugin/tegg/test/fixtures/apps/**/*.js
*.tsbuildinfo
*.tgz

# bundler e2e test output
tegg/core/bundler/test/.e2e-output

ecosystem-ci/cnpmcore
ecosystem-ci/examples
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@
"version:beta": "node scripts/version.js prerelease --prerelease-tag=beta",
"version:rc": "node scripts/version.js prerelease --prerelease-tag=rc"
},
"dependencies": {
"@utoo/pack": "link:../../../../../tnpm/utoo/packages/pack"
},
Comment on lines +48 to +50
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The dependency @utoo/pack is added using a link: specifier. While this is common in monorepos for local development, it's important to ensure that the production build process correctly resolves this dependency, either by publishing @utoo/pack or by ensuring it's available in the deployment environment.

"devDependencies": {
"@eggjs/bin": "workspace:*",
"@eggjs/tsconfig": "workspace:*",
Expand Down
319 changes: 184 additions & 135 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ catalog:
'@fengmk2/ps-tree': ^2.0.1
'@oclif/core': ^4.2.0
'@oxc-node/core': ^0.0.35
typebox: ^1.0.65
'@swc-node/register': ^1.11.1
'@swc/core': ^1.15.1
'@types/accepts': ^1.3.7
Expand Down Expand Up @@ -65,6 +64,7 @@ catalog:
'@types/urijs': ^1.19.25
'@types/vary': ^1.1.3
'@typescript/native-preview': 7.0.0-dev.20260117.1
'@utoo/pack': ^1.2.7
'@vitest/coverage-v8': ^4.0.15
'@vitest/ui': ^4.0.15
accepts: ^1.3.8
Expand Down Expand Up @@ -204,6 +204,7 @@ catalog:
tsx: 4.20.6
type-fest: ^5.0.1
type-is: ^2.0.0
typebox: ^1.0.65
typescript: ^5.9.3
unplugin-unused: ^0.5.4
urijs: ^1.19.11
Expand Down Expand Up @@ -249,4 +250,5 @@ minimumReleaseAgeExclude:
- import-without-cache

overrides:
'@utoo/pack': link:../../../../../tnpm/utoo/packages/pack
vite: npm:rolldown-vite@^7.1.13
64 changes: 64 additions & 0 deletions tegg/core/bundler/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{
"name": "@eggjs/tegg-bundler",
"version": "4.0.0-beta.36",
"description": "tegg bundler for serverless - bundles individual controller methods into standalone files",
"keywords": [
"bundler",
"egg",
"serverless",
"tegg",
"typescript"
],
"homepage": "https://github.com/eggjs/egg/tree/next/tegg/core/bundler",
"bugs": {
"url": "https://github.com/eggjs/egg/issues"
},
"license": "MIT",
"author": "killagu <killa123@126.com>",
"repository": {
"type": "git",
"url": "git+https://github.com/eggjs/egg.git",
"directory": "tegg/core/bundler"
},
"files": [
"dist"
],
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./src/index.ts",
"./package.json": "./package.json"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./package.json": "./package.json"
}
},
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "vitest run"
},
"dependencies": {
"@eggjs/controller-decorator": "workspace:*",
"@eggjs/core-decorator": "workspace:*",
"@eggjs/metadata": "workspace:*",
"@eggjs/tegg-loader": "workspace:*",
"@eggjs/tegg-types": "workspace:*",
"typescript": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"@utoo/pack": "catalog:",
"esbuild": "catalog:",
"vitest": "catalog:"
},
"engines": {
"node": ">=22.18.0"
}
Comment on lines +61 to +63
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The engines.node field specifies a very high minimum version (>=22.18.0). While this might be the current development environment, it's quite restrictive. Consider if a slightly broader range (e.g., >=20.0.0) could be supported to increase compatibility, or add a comment explaining why such a specific version is required.

}
224 changes: 224 additions & 0 deletions tegg/core/bundler/src/Bundler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import fs from 'node:fs/promises';
import path from 'node:path';

import { ControllerMetadataUtil, HTTPControllerMeta } from '@eggjs/controller-decorator';
import { PrototypeUtil } from '@eggjs/core-decorator';
import { ClassProtoDescriptor, GlobalGraph } from '@eggjs/metadata';
import { LoaderFactory } from '@eggjs/tegg-loader';
import { ControllerType } from '@eggjs/tegg-types';
import type { ModuleReference } from '@eggjs/tegg-types';

import { DependencyResolver } from './DependencyResolver.ts';
import { EntryGenerator } from './EntryGenerator.ts';
import { MetaGenerator } from './MetaGenerator.ts';
import type { MethodMeta } from './MetaGenerator.ts';
import { MethodAnalyzer } from './MethodAnalyzer.ts';

export type { MethodMeta };

export interface BundlerOptions {
/** Output directory for bundles and meta files */
outputPath: string;
/** Tegg module references to scan */
moduleReferences: ModuleReference[];
/** Externals config passed to the bundler (package → global var mapping) */
externals?: Record<string, string>;
/** Build mode */
mode?: 'production' | 'development';
}

export interface MethodBundleResult {
/** Unique key for this method bundle, e.g. "UserController.getUser" */
key: string;
/** Absolute path to the bundled JS file */
bundlePath: string;
/** Absolute path to the meta JSON file */
metaPath: string;
/** Parsed meta object */
meta: MethodMeta;
}

/**
* Options for the build step.
* Users provide a `BuildFunc` that accepts these options and produces a JS bundle.
* Example implementation: use `@utoo/pack` or `esbuild`.
*/
export interface BuildOptions {
entry: Array<{ name: string; import: string }>;
target: string;
platform: 'browser' | 'node';
output: { path: string; type: string };
externals: Record<string, string>;
optimization: { treeShaking: boolean; removeUnusedExports: boolean };
mode: 'production' | 'development';
/** Project root path — used by Turbopack to resolve entry imports */
projectPath?: string;
/** Root path — filesystem boundary for module resolution */
rootPath?: string;
}

/**
* A function that takes build options and produces a JS bundle.
* Users can inject their own implementation (e.g. @utoo/pack, esbuild, rollup).
*/
export type BuildFunc = (options: BuildOptions) => Promise<void>;

/**
* Main orchestrator for the tegg serverless bundler.
*
* For each controller method:
* 1. Analyzes the method body to find accessed services
* 2. Resolves the full transitive dependency closure
* 3. Generates a minimal entry file importing only needed deps
* 4. Calls the user-provided build function to produce a standalone bundle
* 5. Generates a meta JSON file with HTTP routing + DI dependency info
*/
export class Bundler {
private readonly buildFunc: BuildFunc;

constructor(buildFunc?: BuildFunc) {
// Default build func: dynamically import @utoo/pack
this.buildFunc = buildFunc ?? defaultBuildFunc;
}

async bundle(options: BundlerOptions): Promise<MethodBundleResult[]> {
const {
outputPath,
moduleReferences,
mode = 'production',
externals = {
// @swc/helpers is injected by SWC when compiling decorators
// (experimentalDecorators). It must be external so the bundled
// output can resolve it from node_modules at runtime.
'@swc/helpers': '@swc/helpers',
},
} = options;

// Load all modules and build the global dependency graph
const moduleDescriptors = await LoaderFactory.loadApp(moduleReferences);
const globalGraph = await GlobalGraph.create(moduleDescriptors);
globalGraph.build();
globalGraph.sort();

const analyzer = new MethodAnalyzer();
const resolver = new DependencyResolver(globalGraph);
const entryGenerator = new EntryGenerator();
const metaGenerator = new MetaGenerator();

// Create temp dir for generated entry files inside outputPath
// so Turbopack can resolve them relative to the project root
const tmpDir = path.join(outputPath, '.tegg-entries');

// Write a tsconfig.json in outputPath so Turbopack/SWC enables
// experimentalDecorators when compiling TypeScript source files.
// Without this, decorator syntax (@Controller, @Inject, etc.) fails
// at the chunk code generation stage.
await fs.mkdir(outputPath, { recursive: true });
await fs.writeFile(
path.join(outputPath, 'tsconfig.json'),
JSON.stringify({
compilerOptions: {
experimentalDecorators: true,
},
}) + '\n',
);

const results: MethodBundleResult[] = [];

try {
await fs.mkdir(outputPath, { recursive: true });

for (const [, protos] of globalGraph.moduleProtoDescriptorMap) {
for (const proto of protos) {
if (!ClassProtoDescriptor.isClassProtoDescriptor(proto)) continue;

const controllerMeta = ControllerMetadataUtil.getControllerMetadata(proto.clazz);
if (!controllerMeta || controllerMeta.type !== ControllerType.HTTP) continue;

if (!(controllerMeta instanceof HTTPControllerMeta)) continue;

const filePath = PrototypeUtil.getFilePath(proto.clazz);
if (!filePath) continue;

for (const methodMeta of controllerMeta.methods) {
const key = `${controllerMeta.controllerName}.${methodMeta.name}`;

// Step 1: Analyze which injected services the method actually uses
const accessedProps = analyzer.analyze(filePath, proto.clazz.name, methodMeta.name);

// Step 2: Resolve the full dependency closure for those services
const deps = resolver.resolve(proto, accessedProps);

// Step 3: Generate entry file with only needed imports
const entryPath = await entryGenerator.generate(tmpDir, proto.clazz, methodMeta.name, deps);

// Step 4: Bundle via user-provided build function
// Each method gets its own output subdirectory to avoid filename collisions
// (@utoo/pack may merge entries sharing the same controller into one file)
const methodOutputPath = path.join(outputPath, key);
await fs.mkdir(methodOutputPath, { recursive: true });
// Turbopack platform:'node' outputs CJS bundles (require/module.exports).
// Write a package.json to ensure Node.js treats .js files as CommonJS,
// even if a parent package.json has "type": "module".
await fs.writeFile(path.join(methodOutputPath, 'package.json'), '{"type":"commonjs"}\n');
await this.buildFunc({
entry: [{ name: key, import: entryPath }],
target: 'node 22',
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The target: 'node 22' is hardcoded for the build function. It would be more flexible to make this configurable via the BundlerOptions interface, allowing users to specify their desired Node.js target environment for the generated bundles.

Suggested change
target: 'node 22',
target: options.nodeTarget ?? 'node 22',

platform: 'node',
output: { path: methodOutputPath, type: 'standalone' },
externals,
optimization: { treeShaking: true, removeUnusedExports: true },
mode,
// Use parent outputPath as projectPath so Turbopack can resolve
// entry files in .tegg-entries/; rootPath is auto-detected so
// it can resolve source files outside the output directory
projectPath: outputPath,
});
// Find the actual output JS file (entry name may be transformed by the bundler)
const outputFiles = await fs.readdir(methodOutputPath);
const jsFile = outputFiles.find((f) => f.endsWith('.js') && !f.startsWith('_turbopack'));
if (!jsFile) {
throw new Error(`No JS output file found in ${methodOutputPath} for ${key}`);
}
const bundlePath = path.join(methodOutputPath, jsFile);

// Step 5: Generate meta file
const meta = metaGenerator.generate(controllerMeta, methodMeta, deps, proto, accessedProps);
const metaPath = path.join(outputPath, `${key}.meta.json`);
await fs.writeFile(metaPath, JSON.stringify(meta, null, 2));

results.push({ key, bundlePath, metaPath, meta });
}
}
}
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}

return results;
}
}

async function defaultBuildFunc(options: BuildOptions): Promise<void> {
// Dynamically import @utoo/pack build command to avoid hard dependency.
// Uses the sub-path import to avoid loading HMR/WebSocket code from the root entry.
// Users must install @utoo/pack themselves if using the default build function.
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { build } = (await import('@utoo/pack/esm/commands/build.js' as any)) as {
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The as any cast for the dynamic import of @utoo/pack/esm/commands/build.js suppresses type safety. It would be better to define a proper type for the imported module or the build function to maintain type correctness.

    const { build } = (await import('@utoo/pack/esm/commands/build.js')) as { build: (options: { config: BuildOptions }, projectPath?: string, rootPath?: string) => Promise<void>; };

build: (options: { config: BuildOptions }, projectPath?: string, rootPath?: string) => Promise<void>;
};
const projectPath = options.projectPath ?? options.output.path;
const rootPath = options.rootPath;
await build({ config: options }, projectPath, rootPath);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ERR_MODULE_NOT_FOUND') {
throw new Error(
'Default build function requires @utoo/pack to be installed. ' +
'Either install @utoo/pack or provide a custom buildFunc to the Bundler constructor.',
{ cause: err },
);
}
throw err;
}
}
Loading