refactor: reorganize package structure and decouple framework packages (#338)
* refactor: reorganize package structure and decouple framework packages ## Package Structure Reorganization - Reorganized 55 packages into categorized subdirectories: - packages/framework/ - Generic framework (Laya/Cocos compatible) - packages/engine/ - ESEngine core modules - packages/rendering/ - Rendering modules (WASM dependent) - packages/physics/ - Physics modules - packages/streaming/ - World streaming - packages/network-ext/ - Network extensions - packages/editor/ - Editor framework and plugins - packages/rust/ - Rust WASM engine - packages/tools/ - Build tools and SDK ## Framework Package Decoupling - Decoupled behavior-tree and blueprint packages from ESEngine dependencies - Created abstracted interfaces (IBTAssetManager, IBehaviorTreeAssetContent) - ESEngine-specific code moved to esengine/ subpath exports - Framework packages now usable with Cocos/Laya without ESEngine ## CI Configuration - Updated CI to only type-check and lint framework packages - Added type-check:framework and lint:framework scripts ## Breaking Changes - Package import paths changed due to directory reorganization - ESEngine integrations now use subpath imports (e.g., '@esengine/behavior-tree/esengine') * fix: update es-engine file path after directory reorganization * docs: update README to focus on framework over engine * ci: only build framework packages, remove Rust/WASM dependencies * fix: remove esengine subpath from behavior-tree and blueprint builds ESEngine integration code will only be available in full engine builds. Framework packages are now purely engine-agnostic. * fix: move network-protocols to framework, build both in CI * fix: update workflow paths from packages/core to packages/framework/core * fix: exclude esengine folder from type-check in behavior-tree and blueprint * fix: update network tsconfig references to new paths * fix: add test:ci:framework to only test framework packages in CI * fix: only build core and math npm packages in CI * fix: exclude test files from CodeQL and fix string escaping security issue
This commit is contained in:
415
packages/engine/runtime-core/src/PluginLoader.ts
Normal file
415
packages/engine/runtime-core/src/PluginLoader.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* @zh 插件加载器
|
||||
* @en Plugin Loader
|
||||
*
|
||||
* @zh 提供统一的插件加载机制,支持:
|
||||
* - 动态 ESM 导入
|
||||
* - 依赖拓扑排序
|
||||
* - 加载状态追踪
|
||||
* - 错误隔离
|
||||
*
|
||||
* @en Provides unified plugin loading with:
|
||||
* - Dynamic ESM imports
|
||||
* - Dependency topological sorting
|
||||
* - Load state tracking
|
||||
* - Error isolation
|
||||
*/
|
||||
|
||||
import { createLogger } from '@esengine/ecs-framework';
|
||||
import type { IRuntimePlugin, ModuleManifest } from './PluginManager';
|
||||
import { runtimePluginManager } from './PluginManager';
|
||||
import {
|
||||
topologicalSort,
|
||||
validateDependencies as validateDeps,
|
||||
resolveDependencyId,
|
||||
type IDependable
|
||||
} from './utils/DependencyUtils';
|
||||
|
||||
const logger = createLogger('PluginLoader');
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义 | Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @zh 插件加载状态
|
||||
* @en Plugin load state
|
||||
*/
|
||||
export type PluginLoadState =
|
||||
| 'pending' // 等待加载
|
||||
| 'loading' // 加载中
|
||||
| 'loaded' // 已加载
|
||||
| 'failed' // 加载失败
|
||||
| 'missing'; // 依赖缺失
|
||||
|
||||
/**
|
||||
* @zh 插件源类型
|
||||
* @en Plugin source type
|
||||
*/
|
||||
export type PluginSourceType = 'npm' | 'local' | 'static';
|
||||
|
||||
/**
|
||||
* @zh 插件包信息
|
||||
* @en Plugin package info
|
||||
*/
|
||||
export interface PluginPackageInfo {
|
||||
plugin: boolean;
|
||||
pluginExport: string;
|
||||
category?: string;
|
||||
isEnginePlugin?: boolean;
|
||||
dependencies?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 插件配置
|
||||
* @en Plugin configuration
|
||||
*/
|
||||
export interface PluginConfig {
|
||||
enabled: boolean;
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 项目插件配置
|
||||
* @en Project plugin configuration
|
||||
*/
|
||||
export interface ProjectPluginConfig {
|
||||
plugins: Record<string, PluginConfig>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 插件加载配置
|
||||
* @en Plugin load configuration
|
||||
*/
|
||||
export interface PluginLoadConfig {
|
||||
packageId: string;
|
||||
enabled: boolean;
|
||||
sourceType: PluginSourceType;
|
||||
exportName?: string;
|
||||
dependencies?: string[];
|
||||
options?: Record<string, unknown>;
|
||||
localPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 插件加载信息
|
||||
* @en Plugin load info
|
||||
*/
|
||||
export interface PluginLoadInfo {
|
||||
packageId: string;
|
||||
state: PluginLoadState;
|
||||
plugin?: IRuntimePlugin;
|
||||
error?: string;
|
||||
missingDeps?: string[];
|
||||
loadTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 加载器配置
|
||||
* @en Loader configuration
|
||||
*/
|
||||
export interface PluginLoaderConfig {
|
||||
plugins: PluginLoadConfig[];
|
||||
timeout?: number;
|
||||
continueOnFailure?: boolean;
|
||||
localLoader?: (path: string) => Promise<string>;
|
||||
localExecutor?: (code: string, id: string) => Promise<IRuntimePlugin | null>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 插件加载器 | Plugin Loader
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @zh 插件加载器
|
||||
* @en Plugin Loader
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const loader = new PluginLoader({
|
||||
* plugins: [
|
||||
* { packageId: '@esengine/sprite', enabled: true, sourceType: 'npm' }
|
||||
* ]
|
||||
* });
|
||||
* await loader.loadAll();
|
||||
* ```
|
||||
*/
|
||||
export class PluginLoader {
|
||||
private _config: Required<PluginLoaderConfig>;
|
||||
private _loaded = new Map<string, PluginLoadInfo>();
|
||||
private _loading = false;
|
||||
|
||||
constructor(config: PluginLoaderConfig) {
|
||||
this._config = {
|
||||
plugins: config.plugins,
|
||||
timeout: config.timeout ?? 30000,
|
||||
continueOnFailure: config.continueOnFailure ?? true,
|
||||
localLoader: config.localLoader ?? (async () => ''),
|
||||
localExecutor: config.localExecutor ?? (async () => null)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 加载所有启用的插件
|
||||
* @en Load all enabled plugins
|
||||
*/
|
||||
async loadAll(): Promise<Map<string, PluginLoadInfo>> {
|
||||
if (this._loading) {
|
||||
throw new Error('Loading already in progress');
|
||||
}
|
||||
|
||||
this._loading = true;
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const enabled = this._config.plugins.filter(p => p.enabled);
|
||||
|
||||
// 验证依赖
|
||||
const missing = this._validateDependencies(enabled);
|
||||
for (const [id, deps] of missing) {
|
||||
this._loaded.set(id, {
|
||||
packageId: id,
|
||||
state: 'missing',
|
||||
missingDeps: deps,
|
||||
error: `Missing: ${deps.join(', ')}`
|
||||
});
|
||||
}
|
||||
|
||||
// 过滤有效插件并排序
|
||||
const valid = enabled.filter(p => !missing.has(p.packageId));
|
||||
const sorted = this._sortByDependencies(valid);
|
||||
|
||||
// 串行加载
|
||||
for (const plugin of sorted) {
|
||||
await this._loadOne(plugin);
|
||||
}
|
||||
|
||||
const time = Date.now() - start;
|
||||
const loadedCount = this.getLoaded().length;
|
||||
logger.info(`Loaded ${loadedCount}/${enabled.length} plugins in ${time}ms`);
|
||||
|
||||
return this._loaded;
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取已加载插件
|
||||
* @en Get loaded plugins
|
||||
*/
|
||||
getLoaded(): PluginLoadInfo[] {
|
||||
return [...this._loaded.values()].filter(p => p.state === 'loaded');
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取失败的插件
|
||||
* @en Get failed plugins
|
||||
*/
|
||||
getFailed(): PluginLoadInfo[] {
|
||||
return [...this._loaded.values()].filter(
|
||||
p => p.state === 'failed' || p.state === 'missing'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取插件信息
|
||||
* @en Get plugin info
|
||||
*/
|
||||
get(packageId: string): PluginLoadInfo | undefined {
|
||||
return this._loaded.get(packageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 重置
|
||||
* @en Reset
|
||||
*/
|
||||
reset(): void {
|
||||
this._loaded.clear();
|
||||
}
|
||||
|
||||
// ========== 私有方法 ==========
|
||||
|
||||
private async _loadOne(config: PluginLoadConfig): Promise<void> {
|
||||
const info: PluginLoadInfo = {
|
||||
packageId: config.packageId,
|
||||
state: 'loading'
|
||||
};
|
||||
this._loaded.set(config.packageId, info);
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
let plugin: IRuntimePlugin | null = null;
|
||||
|
||||
switch (config.sourceType) {
|
||||
case 'npm':
|
||||
plugin = await this._loadNpm(config);
|
||||
break;
|
||||
case 'local':
|
||||
plugin = await this._loadLocal(config);
|
||||
break;
|
||||
case 'static':
|
||||
logger.warn(`Static plugin ${config.packageId} should be pre-registered`);
|
||||
break;
|
||||
}
|
||||
|
||||
if (plugin) {
|
||||
info.plugin = plugin;
|
||||
info.state = 'loaded';
|
||||
info.loadTime = Date.now() - start;
|
||||
runtimePluginManager.register(plugin);
|
||||
logger.debug(`Loaded: ${config.packageId} (${info.loadTime}ms)`);
|
||||
} else {
|
||||
throw new Error('Plugin export not found');
|
||||
}
|
||||
} catch (error) {
|
||||
info.state = 'failed';
|
||||
info.error = error instanceof Error ? error.message : String(error);
|
||||
info.loadTime = Date.now() - start;
|
||||
logger.error(`Failed: ${config.packageId} - ${info.error}`);
|
||||
|
||||
if (!this._config.continueOnFailure) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadNpm(config: PluginLoadConfig): Promise<IRuntimePlugin | null> {
|
||||
const module = await import(/* @vite-ignore */ config.packageId);
|
||||
const exportName = config.exportName || 'default';
|
||||
const plugin = module[exportName] as IRuntimePlugin;
|
||||
return plugin?.manifest ? plugin : null;
|
||||
}
|
||||
|
||||
private async _loadLocal(config: PluginLoadConfig): Promise<IRuntimePlugin | null> {
|
||||
if (!config.localPath) {
|
||||
throw new Error('Local path not specified');
|
||||
}
|
||||
const code = await this._config.localLoader(config.localPath);
|
||||
return this._config.localExecutor(code, config.packageId);
|
||||
}
|
||||
|
||||
private _sortByDependencies(plugins: PluginLoadConfig[]): PluginLoadConfig[] {
|
||||
const items: IDependable[] = plugins.map(p => ({
|
||||
id: p.packageId,
|
||||
dependencies: p.dependencies
|
||||
}));
|
||||
const map = new Map(plugins.map(p => [p.packageId, p]));
|
||||
|
||||
const result = topologicalSort(items, { resolveId: resolveDependencyId });
|
||||
if (result.hasCycles) {
|
||||
throw new Error(`Circular dependency: ${result.cycleIds?.join(', ')}`);
|
||||
}
|
||||
|
||||
return result.sorted.map(item => map.get(item.id)!);
|
||||
}
|
||||
|
||||
private _validateDependencies(plugins: PluginLoadConfig[]): Map<string, string[]> {
|
||||
const enabledIds = new Set(plugins.map(p => p.packageId));
|
||||
const missing = new Map<string, string[]>();
|
||||
|
||||
for (const plugin of plugins) {
|
||||
const deps = plugin.dependencies || [];
|
||||
const missingDeps = deps
|
||||
.map(d => resolveDependencyId(d))
|
||||
.filter(d => !enabledIds.has(d));
|
||||
|
||||
if (missingDeps.length > 0) {
|
||||
missing.set(plugin.packageId, missingDeps);
|
||||
}
|
||||
}
|
||||
|
||||
return missing;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 便捷函数 | Convenience Functions
|
||||
// ============================================================================
|
||||
|
||||
/** @zh 已加载插件缓存 @en Loaded plugins cache */
|
||||
const loadedCache = new Map<string, IRuntimePlugin>();
|
||||
|
||||
/**
|
||||
* @zh 加载单个插件
|
||||
* @en Load single plugin
|
||||
*/
|
||||
export async function loadPlugin(
|
||||
packageId: string,
|
||||
info: PluginPackageInfo
|
||||
): Promise<IRuntimePlugin | null> {
|
||||
if (loadedCache.has(packageId)) {
|
||||
return loadedCache.get(packageId)!;
|
||||
}
|
||||
|
||||
try {
|
||||
const module = await import(/* @vite-ignore */ packageId);
|
||||
const plugin = module[info.pluginExport || 'default'] as IRuntimePlugin;
|
||||
|
||||
if (!plugin?.manifest) {
|
||||
logger.warn(`Invalid plugin: ${packageId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
loadedCache.set(packageId, plugin);
|
||||
return plugin;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load ${packageId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 加载启用的插件(简化 API)
|
||||
* @en Load enabled plugins (simplified API)
|
||||
*/
|
||||
export async function loadEnabledPlugins(
|
||||
config: ProjectPluginConfig,
|
||||
packageInfoMap: Record<string, PluginPackageInfo>
|
||||
): Promise<void> {
|
||||
const plugins: PluginLoadConfig[] = [];
|
||||
|
||||
for (const [id, cfg] of Object.entries(config.plugins)) {
|
||||
if (!cfg.enabled) continue;
|
||||
const info = packageInfoMap[id];
|
||||
if (!info) {
|
||||
logger.warn(`No package info for ${id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
plugins.push({
|
||||
packageId: id,
|
||||
enabled: true,
|
||||
sourceType: 'npm',
|
||||
exportName: info.pluginExport,
|
||||
dependencies: info.dependencies
|
||||
});
|
||||
}
|
||||
|
||||
const loader = new PluginLoader({ plugins });
|
||||
await loader.loadAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 注册静态插件
|
||||
* @en Register static plugin
|
||||
*/
|
||||
export function registerStaticPlugin(plugin: IRuntimePlugin): void {
|
||||
runtimePluginManager.register(plugin);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取已加载插件
|
||||
* @en Get loaded plugins
|
||||
*/
|
||||
export function getLoadedPlugins(): IRuntimePlugin[] {
|
||||
return [...loadedCache.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 重置加载器
|
||||
* @en Reset loader
|
||||
*/
|
||||
export function resetPluginLoader(): void {
|
||||
loadedCache.clear();
|
||||
}
|
||||
Reference in New Issue
Block a user