mira-app-server
Version:
Mira Server - standalone server application using mira-app-core
540 lines • 24.7 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.ServerPluginManager = void 0;
const sdk_1 = require("mira-app-core/shared/sdk");
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
class ServerPluginManager {
constructor({ server, dbService, pluginsDir }) {
this.loadedPlugins = new Map();
this.httpHooks = [];
this.fileFormatHandlers = new Map();
this.fields = [];
this.pluginsDir = path.join(pluginsDir ?? __dirname, 'plugins');
console.log({ pluginsDir: this.pluginsDir });
this.server = server;
this.dbService = dbService;
this.pluginsConfigPath = path.join(this.pluginsDir, 'plugins.json');
// 创建 MiraClient 实例用于插件
const httpPort = this.server.backend.config.httpPort || 8081;
const baseURL = `http://localhost:${httpPort}`;
this.miraClient = new sdk_1.MiraClient(baseURL);
console.log(`🔗 Created MiraClient for plugins with baseURL: ${baseURL}`);
// Ensure plugins directory exists
if (!fs.existsSync(this.pluginsDir)) {
fs.mkdirSync(this.pluginsDir, { recursive: true });
}
// Initialize plugins.json if it doesn't exist
if (!fs.existsSync(this.pluginsConfigPath)) {
fs.writeFileSync(this.pluginsConfigPath, JSON.stringify([], null, 2));
}
}
// getPluginDir
getPluginDir(pluginName) {
const pluginConfig = this.getPluginConfig(pluginName);
return path.join(this.pluginsDir, pluginConfig?.path ?? pluginName);
}
getPluginDistDir(pluginName) {
return path.join(this.getPluginDir(pluginName), 'dist');
}
getPluginWebDir(pluginName) {
return path.join(this.getPluginDir(pluginName), 'web');
}
getLoadedWebPlugins() {
const plugins = [];
for (const pluginName of this.loadedPlugins.keys()) {
const webDir = this.getPluginWebDir(pluginName);
const manifestPath = path.join(webDir, 'plugin.json');
if (!fs.existsSync(manifestPath))
continue;
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
const index = typeof manifest.index === 'string' ? manifest.index : 'index.js';
const resolvedIndex = path.resolve(webDir, index);
const relativeIndex = path.relative(path.resolve(webDir), resolvedIndex);
if (!manifest.pluginId || !manifest.pluginName || relativeIndex.startsWith('..') || path.isAbsolute(relativeIndex) || !fs.existsSync(resolvedIndex)) {
console.warn(`Skipping invalid Web plugin manifest: ${manifestPath}`);
continue;
}
// 从插件 package.json 的 mira.icon 提取图标 (emoji / material 名), 供前端展示
let pkgIcon;
try {
const pkgPath = path.join(this.getPluginDir(pluginName), 'package.json');
if (fs.existsSync(pkgPath)) {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
pkgIcon = (pkg.mira && pkg.mira.icon) || pkg.icon;
}
}
catch (e) {
console.warn(`Error reading package.json icon for ${pluginName}:`, e);
}
plugins.push({
...manifest,
index,
version: manifest.version || '1.0.0',
serverPluginName: pluginName,
icon: manifest.icon || pkgIcon,
});
}
catch (error) {
console.error(`Error reading Web plugin manifest for ${pluginName}:`, error);
}
}
return plugins;
}
getPluginConfig(pluginName) {
if (!fs.existsSync(this.pluginsConfigPath))
return undefined;
try {
const config = JSON.parse(fs.readFileSync(this.pluginsConfigPath, 'utf-8'));
return config.find(plugin => plugin.name === pluginName);
}
catch (error) {
console.error(`Error reading plugin config for ${pluginName}:`, error);
return undefined;
}
}
async loadPlugins(reload = false) {
const config = JSON.parse(fs.readFileSync(this.pluginsConfigPath, 'utf-8'));
for (const pluginConfig of config) {
if (pluginConfig.enabled) {
await this.loadPlugin(pluginConfig, reload);
}
}
}
async loadPlugin(pluginConfig, reload = false) {
try {
// 检查插件是否已经加载过
if (!reload && this.loadedPlugins.has(pluginConfig.name)) {
console.log(`Plugin ${pluginConfig.name} already loaded, skipping...`);
return;
}
const pluginPath = path.join(this.pluginsDir, pluginConfig.path);
// 如果是重新加载,清除require缓存
if (reload || this.loadedPlugins.has(pluginConfig.name)) {
delete require.cache[require.resolve(pluginPath)];
}
const pluginModule = require(pluginPath);
if (typeof pluginModule.init === 'function') {
const obj = await pluginModule.init({
pluginManager: this,
server: this.server,
dbService: this.dbService,
miraClient: this.miraClient,
});
this.loadedPlugins.set(pluginConfig.name, obj);
console.log(`${reload ? 'Reloaded' : 'Loaded'} plugin: ${pluginConfig.name}`);
}
else {
console.warn(`Plugin ${pluginConfig.name} does not have an init function, skipping...`);
}
}
catch (err) {
console.error(`Failed to load plugin ${pluginConfig.name}:`, err);
// 如果加载失败,从已加载插件中移除
this.loadedPlugins.delete(pluginConfig.name);
}
}
registerFields(fields) {
for (const field of fields) {
this.registerField(field);
}
}
registerField(field) {
let { action, type, field: fieldName } = field;
if (!fieldName || !action || !type) {
throw new Error('Field registration error: action, type, and field are required');
}
this.fields.push(field);
}
registerHttpHook(hook) {
if (!hook.path || typeof hook.handler !== 'function') {
throw new Error('HTTP hook registration error: path and handler are required');
}
this.httpHooks.push({
...hook,
method: hook.method?.toUpperCase(),
});
}
registerFileFormat(pluginName, handler) {
if (!handler.id || (!handler.extensions?.length && !handler.mimeTypes?.length)) {
throw new Error('File format registration error: id and extensions or mimeTypes are required');
}
const key = `${pluginName}:${handler.id}`;
const previous = this.fileFormatHandlers.get(key);
if (previous?.handler.thumbnail) {
this.server.backend.thumbnailService.unregisterGenerator(key);
}
if (previous?.handler.metadata) {
this.server.backend.metadataService.unregisterRule(key);
}
this.fileFormatHandlers.set(key, { pluginName, handler });
if (handler.thumbnail) {
const generator = {
name: key,
supportedExtensions: (handler.thumbnailExtensions || handler.extensions || []).map(ext => ext.replace(/^\./, '').toLowerCase()),
generate: handler.thumbnail,
};
this.server.backend.thumbnailService.registerGenerator(generator);
}
if (handler.metadata) {
this.server.backend.metadataService.registerRule({
name: key,
supportedExtensions: handler.extensions || [],
parse: handler.metadata,
});
}
return () => this.unregisterFileFormat(pluginName, handler.id);
}
unregisterFileFormat(pluginName, id) {
const key = `${pluginName}:${id}`;
const entry = this.fileFormatHandlers.get(key);
if (!entry)
return false;
if (entry.handler.thumbnail)
this.server.backend.thumbnailService.unregisterGenerator(key);
if (entry.handler.metadata)
this.server.backend.metadataService.unregisterRule(key);
return this.fileFormatHandlers.delete(key);
}
getFileFormatHandlers() {
return Array.from(this.fileFormatHandlers.values()).map(({ handler }) => ({ ...handler }));
}
async getPreviewViewers(context) {
const extension = String(context.file.extension || path.extname(context.file.name || context.filePath)).replace(/^\./, '').toLowerCase();
const mimeType = String(context.file.mimeType || context.file.mime_type || '').toLowerCase();
const manifests = new Map(this.getLoadedWebPlugins().map(manifest => [manifest.serverPluginName, manifest]));
const resolved = [];
for (const { pluginName, handler } of this.fileFormatHandlers.values()) {
const handlerMatches = handler.extensions?.some(ext => ext.replace(/^\./, '').toLowerCase() === extension) ||
handler.mimeTypes?.some(mime => mime.toLowerCase() === mimeType);
if (!handlerMatches || !handler.viewers?.length)
continue;
const manifest = manifests.get(pluginName);
if (!manifest)
continue;
for (const viewer of handler.viewers) {
const viewerMatches = (!viewer.extensions?.length && !viewer.mimeTypes?.length) ||
viewer.extensions?.some(ext => ext.replace(/^\./, '').toLowerCase() === extension) ||
viewer.mimeTypes?.some(mime => mime.toLowerCase() === mimeType);
if (!viewerMatches)
continue;
const webDir = path.resolve(this.getPluginWebDir(pluginName));
const entryPath = path.resolve(webDir, viewer.entry);
const relativeEntry = path.relative(webDir, entryPath);
if (relativeEntry.startsWith('..') || path.isAbsolute(relativeEntry) || !fs.existsSync(entryPath)) {
console.warn(`Skipping invalid preview Viewer entry: ${pluginName}/${viewer.entry}`);
continue;
}
try {
const query = await viewer.getQuery?.(context) || {};
const search = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null && value !== '')
search.set(key, String(value));
}
const encodedEntry = relativeEntry.split(path.sep).map(encodeURIComponent).join('/');
const iframePath = `/server-plugins/${encodeURIComponent(context.libraryId)}/${encodeURIComponent(pluginName)}/${encodedEntry}`;
const searchString = search.toString();
resolved.push({
viewerId: viewer.viewerId,
pluginId: manifest.pluginId,
pluginName: manifest.pluginName,
serverPluginName: pluginName,
title: viewer.title,
iframeUrl: searchString ? `${iframePath}?${searchString}` : iframePath,
priority: viewer.priority ?? (Number(manifest.priority) || 0),
icon: viewer.icon,
});
}
catch (error) {
console.warn(`Failed to resolve preview Viewer ${pluginName}:${viewer.viewerId}:`, error);
}
}
}
return resolved.sort((a, b) => b.priority - a.priority || a.title.localeCompare(b.title));
}
async processFile(filePath, context = {}) {
const extension = path.extname(filePath).toLowerCase().slice(1);
const mimeType = String(context.mimeType || '').toLowerCase();
const entry = Array.from(this.fileFormatHandlers.values()).find(({ handler }) => handler.extensions?.some(ext => ext.replace(/^\./, '').toLowerCase() === extension) ||
handler.mimeTypes?.some(mime => mime.toLowerCase() === mimeType));
return entry?.handler.process ? entry.handler.process(filePath, context) : undefined;
}
async getExtraFileList(filePath, context = {}) {
const handler = this.getFileFormatHandler(filePath);
return handler?.getExtraFileList?.(filePath, context);
}
async getExtraFile(filePath, fileName, context = {}) {
const handler = this.getFileFormatHandler(filePath);
return handler?.getExtraFile?.(filePath, fileName, context);
}
getFileFormatHandler(filePath) {
const extension = path.extname(filePath).toLowerCase().slice(1);
return Array.from(this.fileFormatHandlers.values()).find(({ handler }) => handler.extensions?.some(ext => ext.replace(/^\./, '').toLowerCase() === extension))?.handler;
}
async runHttpHooks(context) {
for (const hook of this.httpHooks) {
if (!this.matchesHttpHook(hook, context))
continue;
const result = await hook.handler(context);
if (result === false)
return false;
}
return true;
}
matchesHttpHook(hook, context) {
if (hook.method && hook.method !== context.method.toUpperCase())
return false;
if (typeof hook.path === 'string')
return hook.path === context.path;
return hook.path.test(context.path);
}
getPlugin(name) {
return this.loadedPlugins.get(name);
}
isPluginLoaded(name) {
return this.loadedPlugins.has(name);
}
unloadPlugin(name) {
if (this.loadedPlugins.has(name)) {
// 尝试调用插件的清理函数(如果存在�?
const plugin = this.loadedPlugins.get(name);
if (plugin && typeof plugin.cleanup === 'function') {
try {
plugin.cleanup();
}
catch (error) {
console.error(`Error cleaning up plugin ${name}:`, error);
}
}
for (const [key, entry] of this.fileFormatHandlers) {
if (entry.pluginName === name)
this.unregisterFileFormat(name, key.slice(name.length + 1));
}
this.loadedPlugins.delete(name);
console.log(`Unloaded plugin: ${name}`);
return true;
}
return false;
}
async reloadPlugin(name) {
const config = JSON.parse(fs.readFileSync(this.pluginsConfigPath, 'utf-8'));
const pluginConfig = config.find(p => p.name === name);
if (!pluginConfig) {
console.error(`Plugin config not found for: ${name}`);
return false;
}
if (!pluginConfig.enabled) {
console.log(`Plugin ${name} is disabled, skipping reload`);
return false;
}
// 先卸载插�?
this.unloadPlugin(name);
// 重新加载插件
await this.loadPlugin(pluginConfig, true);
return this.isPluginLoaded(name);
}
/**
* 从插件目录的 package.json + icon 文件提取展示 meta
*/
extractPluginMeta(pluginName, pluginDir) {
let packageInfo = {};
try {
const packageJsonPath = path.join(pluginDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
packageInfo = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
}
}
catch (error) {
console.error(`Error reading package.json for plugin ${pluginName}:`, error);
}
// icon 文件优先, 其次 mira.icon, 最后 package.json 顶层 icon
let icon = null;
const iconExtensions = ['.png', '.jpg', '.jpeg', '.svg', '.ico', '.gif', '.webp'];
for (const ext of iconExtensions) {
if (fs.existsSync(path.join(pluginDir, `icon${ext}`))) {
icon = `/api/plugins/${pluginName}/icon${ext}`;
break;
}
}
const miraInfo = (packageInfo.mira || {});
// author 可能是字符串或 { name, email } 对象
const authorRaw = packageInfo.author;
const author = typeof authorRaw === 'string' ? authorRaw : (authorRaw?.name || authorRaw);
return {
version: packageInfo.version,
description: packageInfo.description,
author,
main: packageInfo.main,
icon: icon || miraInfo.icon || packageInfo.icon || null,
title: miraInfo.title || packageInfo.title || pluginName,
category: miraInfo.category || packageInfo.category || 'general',
tags: miraInfo.tags || packageInfo.tags || [],
dependencies: Object.keys(packageInfo.dependencies || {}),
};
}
getPluginsList() {
const config = JSON.parse(fs.readFileSync(this.pluginsConfigPath, 'utf-8'));
return config.map(pluginConfig => {
const pluginDir = this.getPluginDir(pluginConfig.name);
const meta = this.extractPluginMeta(pluginConfig.name, pluginDir);
// 持久化字段优先, 缺失时 fallback package.json (meta)
return {
name: pluginConfig.name,
enabled: pluginConfig.enabled,
path: pluginConfig.path,
registry: pluginConfig.registry,
installedAt: pluginConfig.installedAt,
version: pluginConfig.version || meta.version || '1.0.0',
description: pluginConfig.description ?? meta.description ?? '',
author: pluginConfig.author ?? meta.author ?? 'Unknown',
main: pluginConfig.main || meta.main || 'index.js',
dependencies: meta.dependencies,
status: pluginConfig.enabled ? 'active' : 'inactive',
configurable: true,
icon: pluginConfig.icon !== undefined ? pluginConfig.icon : meta.icon,
title: pluginConfig.title || meta.title || pluginConfig.name,
category: pluginConfig.category || meta.category || 'general',
tags: pluginConfig.tags || meta.tags || [],
};
});
}
async addPlugin(config) {
const currentConfig = JSON.parse(fs.readFileSync(this.pluginsConfigPath, 'utf-8'));
// 从 package.json 自动提取展示 meta, 补全调用方未提供的字段并持久化
const pluginDir = path.join(this.pluginsDir, config.path);
const meta = this.extractPluginMeta(config.name, pluginDir);
const enriched = {
...config,
version: config.version || meta.version,
description: config.description ?? meta.description,
author: config.author ?? meta.author,
main: config.main || meta.main,
icon: config.icon !== undefined ? config.icon : meta.icon,
title: config.title || meta.title,
category: config.category || meta.category,
tags: config.tags || meta.tags,
installedAt: config.installedAt || new Date().toISOString(),
};
const existingIndex = currentConfig.findIndex(p => p.name === config.name);
if (existingIndex >= 0) {
currentConfig[existingIndex] = { ...currentConfig[existingIndex], ...enriched };
}
else {
currentConfig.push(enriched);
}
fs.writeFileSync(this.pluginsConfigPath, JSON.stringify(currentConfig, null, 2));
if (config.enabled) {
await this.loadPlugin(config, true); // 使用 reload=true 确保新插件被加载
}
}
/**
* 重新从 package.json 同步所有插件的展示 meta 到 plugins.json (供「检查更新」调用)
*/
syncPluginsMeta() {
const config = JSON.parse(fs.readFileSync(this.pluginsConfigPath, 'utf-8'));
for (let i = 0; i < config.length; i++) {
const pluginDir = this.getPluginDir(config[i].name);
const meta = this.extractPluginMeta(config[i].name, pluginDir);
config[i] = {
...config[i],
version: meta.version || config[i].version,
description: meta.description ?? config[i].description,
author: meta.author ?? config[i].author,
main: meta.main || config[i].main,
icon: meta.icon,
title: meta.title || config[i].title,
category: meta.category || config[i].category,
tags: meta.tags || config[i].tags,
};
}
fs.writeFileSync(this.pluginsConfigPath, JSON.stringify(config, null, 2));
return config.length;
}
/**
* 获取所有已加载插件的路由定�?
*/
getAllPluginRoutes() {
const allRoutes = [];
for (const [pluginName, plugin] of this.loadedPlugins) {
try {
// 检查插件是否有 getRoutes 方法
if (plugin && typeof plugin.getRoutes === 'function') {
const routes = plugin.getRoutes();
if (Array.isArray(routes)) {
// 为每个路由添加插件名称标识,但不修改路径
const routesWithPluginInfo = routes.map(route => ({
...route,
pluginName, // 添加插件名称,方便追�?
}));
allRoutes.push(...routesWithPluginInfo);
}
}
}
catch (error) {
console.error(`Error getting routes from plugin ${pluginName}:`, error);
}
}
return allRoutes;
}
/**
* 获取指定插件的路由定�?
*/
getPluginRoutes(pluginName) {
const plugin = this.loadedPlugins.get(pluginName);
if (plugin && typeof plugin.getRoutes === 'function') {
try {
const routes = plugin.getRoutes();
return Array.isArray(routes) ? routes : [];
}
catch (error) {
console.error(`Error getting routes from plugin ${pluginName}:`, error);
return [];
}
}
return [];
}
/**
* 手动注册插件实例(用于测试或特殊用途)
*/
registerPluginInstance(pluginName, pluginInstance) {
this.loadedPlugins.set(pluginName, pluginInstance);
console.log(`�?Manually registered plugin: ${pluginName}`);
}
}
exports.ServerPluginManager = ServerPluginManager;
//# sourceMappingURL=ServerPluginManager.js.map