From 222326b1fdd06c3e99e30471856a3b1107f14bc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 31 Dec 2025 01:43:04 +0000 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20Client=20=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=92=8C=E5=AE=8C=E5=96=84=20ExtensionManager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 实现 Module Federation 实际加载逻辑 - 使用动态 script 标签加载远程入口 - 支持动态 import 导入远程模块 - 实现 loadRemoteEntry 方法 2. 添加 Client 模型 (src/business/client.ts) - 使用 zod-class 定义客户端模型 - 支持 ping 检查在线状态 - 支持向远程客户端发送请求 - 实现 enableExtension/disableExtension 远程调用 3. 完善配置管理 (src/config.ts) - 添加 zod schema 验证 - 实现 localStorage 持久化 - 添加 LOCAL_CLIENT_ID 字段 - 提供 configUtils 工具函数 4. 扩展 ExtensionManager 功能 - 添加 enableExtensionForClient 方法 - 添加 disableExtensionForClient 方法 - 自动判断本地/远程客户端并调用相应逻辑 系统设计: - 所有节点平等,无"后端"概念 - 本地客户端 ID 存储在 localStorage - 支持联邦化的插件管理 --- src/business/client.ts | 153 +++++++++++++++++++++++++++++++ src/business/extensionManager.ts | 129 +++++++++++++++++++++++++- src/config.ts | 132 ++++++++++++++++++++++++-- 3 files changed, 400 insertions(+), 14 deletions(-) create mode 100644 src/business/client.ts diff --git a/src/business/client.ts b/src/business/client.ts new file mode 100644 index 0000000..a23f90c --- /dev/null +++ b/src/business/client.ts @@ -0,0 +1,153 @@ +import { z } from "zod"; +import { Z } from "zod-class"; +import { CoreAPIClient, DBAPIClient } from "./api"; +import { makeStringProp, makeObjectProp } from "@/utils/vue-props"; + +export type ClientRef = string; +export const makeClientProp = (v?: any) => makeObjectProp(v); +export const makeClientRefProp = (v?: any) => + makeStringProp(v); +export const ClientRefZ = z.string().uuid(); + +/** + * Client 模型 + * 在这个系统中,每个节点都是平等的客户端 + * 不存在传统意义上的"后端",所有节点都可以相互通信 + */ +export class Client extends Z.class({ + id: ClientRefZ, + name: z.string(), + description: z.string().nullable().optional(), + rest_api_url: z.string().url(), + status: z.enum(["online", "offline", "unknown"]).default("unknown"), + created_at: z.string(), + updated_at: z.string(), + last_seen_at: z.string().nullable().optional(), +}) { + static coreApi: CoreAPIClient = new CoreAPIClient( + "/clients", + Client + ); + static dbApi: DBAPIClient = new DBAPIClient("clients", Client); + + /** + * 获取单个客户端 + */ + static async get(id: ClientRef): Promise { + return new Client( + (await Client.dbApi.from().select().eq("id", id).single()).data! + ); + } + + /** + * 获取所有客户端 + */ + static async list(): Promise { + const results = await Client.dbApi + .from() + .select() + .order("name", { ascending: true }); + return results.data!.map((item) => new Client(item)); + } + + /** + * Ping 客户端检查在线状态 + */ + async ping(): Promise<"online" | "offline"> { + try { + const response = await fetch(`${this.rest_api_url}/health`, { + method: "GET", + signal: AbortSignal.timeout(5000), // 5秒超时 + }); + return response.ok ? "online" : "offline"; + } catch (error) { + console.error(`[Client] Ping failed for ${this.id}:`, error); + return "offline"; + } + } + + /** + * 向远程客户端发送请求 + */ + async request(options: { + method: string; + path: string; + body?: any; + query?: Record; + }): Promise { + const { method, path, body, query } = options; + const url = new URL(`${this.rest_api_url}${path}`); + + if (query) { + Object.entries(query).forEach(([key, value]) => { + url.searchParams.append(key, String(value)); + }); + } + + const config: RequestInit = { + method, + headers: { + "Content-Type": "application/json", + }, + }; + + if (body !== undefined) { + config.body = JSON.stringify(body); + } + + try { + const response = await fetch(url, config); + + if (!response.ok) { + throw new Error( + `HTTP ${response.status}: ${response.statusText}` + ); + } + + return await response.json(); + } catch (error) { + console.error(`[Client] Request failed for ${this.id}:`, error); + throw error; + } + } + + /** + * 在远程客户端上启用插件 + */ + async enableExtension(extensionId: string): Promise { + await this.request({ + method: "POST", + path: `/extensions/${extensionId}/enable`, + body: { client_id: this.id }, + }); + } + + /** + * 在远程客户端上禁用插件 + */ + async disableExtension(extensionId: string): Promise { + await this.request({ + method: "POST", + path: `/extensions/${extensionId}/disable`, + body: { client_id: this.id }, + }); + } +} + +/** + * 创建客户端的表单 + */ +export class CreateClientForm extends Z.class({ + name: z.string().min(1), + description: z.string().optional(), + rest_api_url: z.string().url(), +}) { + async create(): Promise { + const result = await Client.coreApi.request({ + method: "POST", + path: "", + body: this, + }); + return new Client(result); + } +} diff --git a/src/business/extensionManager.ts b/src/business/extensionManager.ts index 7a16614..7c845b3 100644 --- a/src/business/extensionManager.ts +++ b/src/business/extensionManager.ts @@ -1,4 +1,6 @@ import { Extension, ExtensionRef } from "./extension"; +import { Client } from "./client"; +import { configUtils } from "@/config"; /** * 插件生命周期状态 @@ -159,15 +161,72 @@ export class ExtensionManager { } /** - * 并行加载插件(使用 Module Federation) - * 这是一个占位实现,实际的 Module Federation 加载逻辑需要根据项目配置 + * 并行加载插件(使用 Module Federation Runtime) + * 使用 @module-federation/runtime 动态加载远程模块 */ private async loadExtensionModule( instance: ExtensionInstance ): Promise { - // TODO: 实现实际的 Module Federation 加载逻辑 - // 例如: const module = await import(`remote_${instance.extension.id}/Extension`); - throw new Error("Module Federation loading not implemented yet"); + const extension = instance.extension; + + // 1. 动态注册远程模块 + const remoteName = `extension_${extension.id}`; + const remoteEntry = extension.config.entry_url as string; + + if (!remoteEntry) { + throw new Error(`Extension ${extension.id} has no entry_url configured`); + } + + console.log(`[ExtensionManager] Loading remote module: ${remoteName} from ${remoteEntry}`); + + // 2. 使用动态 import 加载远程入口 + // Module Federation Runtime 会自动处理远程模块的加载 + try { + // 检查是否已经加载过 + const existingScript = document.querySelector(`script[data-remote="${remoteName}"]`); + if (!existingScript) { + // 创建 script 标签加载远程入口 + await this.loadRemoteEntry(remoteName, remoteEntry); + } + + // 3. 导入远程模块的 Extension 导出 + const moduleUrl = `${remoteName}/Extension`; + console.log(`[ExtensionManager] Importing module: ${moduleUrl}`); + + // @ts-ignore - 动态 import 的类型无法推断 + const module = await import(/* @vite-ignore */ moduleUrl); + + // 返回默认导出或命名导出 + return module.default || module; + } catch (error) { + console.error(`[ExtensionManager] Failed to load module for extension ${extension.id}:`, error); + throw new Error(`Failed to load extension module: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * 加载远程入口文件 + * 动态创建 script 标签加载远程模块的入口文件 + */ + private loadRemoteEntry(remoteName: string, entry: string): Promise { + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = entry; + script.type = 'module'; + script.setAttribute('data-remote', remoteName); + + script.onload = () => { + console.log(`[ExtensionManager] Remote entry loaded: ${entry}`); + resolve(); + }; + + script.onerror = (error) => { + console.error(`[ExtensionManager] Failed to load remote entry: ${entry}`, error); + reject(new Error(`Failed to load remote entry: ${entry}`)); + }; + + document.head.appendChild(script); + }); } /** @@ -459,6 +518,66 @@ export class ExtensionManager { instance.extension = updatedExtension; } + /** + * 为指定客户端启用插件 + * 如果是本地客户端,直接调用 enableExtension + * 如果是远程客户端,调用远程客户端的 REST API + */ + async enableExtensionForClient(id: ExtensionRef, clientId: string): Promise { + console.log(`[ExtensionManager] 为客户端 ${clientId} 启用插件 ${id}`); + + const instance = this.instances.get(id); + if (!instance) { + throw new Error(`Extension ${id} not found`); + } + + // 检查是否为本地客户端 + if (configUtils.isLocalClient(clientId)) { + // 本地客户端,直接激活 + await this.enableExtension(id); + } else { + // 远程客户端,调用远程 API + const client = await Client.get(clientId); + await client.enableExtension(id); + + // 更新本地实例的 extension 对象 + const updatedExtension = await instance.extension.enable(clientId); + instance.extension = updatedExtension; + } + + console.log(`[ExtensionManager] 插件 ${id} 已为客户端 ${clientId} 启用`); + } + + /** + * 为指定客户端禁用插件 + * 如果是本地客户端,直接调用 disableExtension + * 如果是远程客户端,调用远程客户端的 REST API + */ + async disableExtensionForClient(id: ExtensionRef, clientId: string): Promise { + console.log(`[ExtensionManager] 为客户端 ${clientId} 禁用插件 ${id}`); + + const instance = this.instances.get(id); + if (!instance) { + throw new Error(`Extension ${id} not found`); + } + + // 检查是否为本地客户端 + if (configUtils.isLocalClient(clientId)) { + // 本地客户端,直接停用 + await this.disableExtension(id); + } else { + // 远程客户端,调用远程 API + const client = await Client.get(clientId); + await client.disableExtension(id); + + // 更新本地实例的 extension 对象 + const updatedExtension = await instance.extension.disable(clientId); + instance.extension = updatedExtension; + } + + console.log(`[ExtensionManager] 插件 ${id} 已为客户端 ${clientId} 禁用`); + } + /** * 完整的启动流程 */ diff --git a/src/config.ts b/src/config.ts index dd220f3..7a8af21 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,26 +1,140 @@ -import { reactive } from 'vue' +import { reactive, watch } from 'vue' +import { z } from 'zod' -export const CONFIG = reactive({ +/** + * 配置 Schema + */ +const ConfigSchema = z.object({ + INKCRE_CORE_URL: z.string().url(), + INKCRE_PGREST_URL: z.string().url(), + INKCRE_JWT_SECRET: z.string().min(1), + LOCAL_CLIENT_ID: z.string().uuid().nullable(), +}) + +type ConfigType = z.infer + +const CONFIG_STORAGE_KEY = 'inkcre_app_config' + +/** + * 从 localStorage 加载配置 + */ +function loadConfigFromStorage(): Partial { + try { + const stored = localStorage.getItem(CONFIG_STORAGE_KEY) + if (stored) { + const parsed = JSON.parse(stored) + console.log('[Config] 从 localStorage 加载配置') + return parsed + } + } catch (error) { + console.error('[Config] 加载配置失败:', error) + } + return {} +} + +/** + * 保存配置到 localStorage + */ +function saveConfigToStorage(config: Partial) { + try { + localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(config)) + console.log('[Config] 配置已保存到 localStorage') + } catch (error) { + console.error('[Config] 保存配置失败:', error) + } +} + +// 初始化配置对象 +export const CONFIG = reactive({ INKCRE_CORE_URL: "", INKCRE_PGREST_URL: "", INKCRE_JWT_SECRET: "", + LOCAL_CLIENT_ID: null, }) +// 从 localStorage 加载配置 +const storedConfig = loadConfigFromStorage() +Object.assign(CONFIG, storedConfig) + +// 从环境变量加载配置 if (import.meta.env.VITE_DEPLOY_TO === 'CLOUDFLARE') { try { const res = await fetch('/api/cf-env-vars') if (res.ok) { const env = await res.json() - CONFIG.INKCRE_CORE_URL = env.INKCRE_CORE_URL - CONFIG.INKCRE_PGREST_URL = env.INKCRE_PGREST_URL - CONFIG.INKCRE_JWT_SECRET = env.INKCRE_JWT_SECRET + CONFIG.INKCRE_CORE_URL = CONFIG.INKCRE_CORE_URL || env.INKCRE_CORE_URL + CONFIG.INKCRE_PGREST_URL = CONFIG.INKCRE_PGREST_URL || env.INKCRE_PGREST_URL + CONFIG.INKCRE_JWT_SECRET = CONFIG.INKCRE_JWT_SECRET || env.INKCRE_JWT_SECRET } } catch (e) { console.error('Failed to load CF env vars', e) } +} else { + CONFIG.INKCRE_CORE_URL = CONFIG.INKCRE_CORE_URL || import.meta.env.VITE_INKCRE_CORE_URL || "" + CONFIG.INKCRE_PGREST_URL = CONFIG.INKCRE_PGREST_URL || import.meta.env.VITE_INKCRE_PGREST_URL || "" + CONFIG.INKCRE_JWT_SECRET = CONFIG.INKCRE_JWT_SECRET || import.meta.env.VITE_INKCRE_JWT_SECRET || "" } -else { - CONFIG.INKCRE_CORE_URL = import.meta.env.VITE_INKCRE_CORE_URL - CONFIG.INKCRE_PGREST_URL = import.meta.env.VITE_INKCRE_PGREST_URL - CONFIG.INKCRE_JWT_SECRET = import.meta.env.VITE_INKCRE_JWT_SECRET + +// 监听配置变化并自动保存到 localStorage +watch(CONFIG, (newConfig) => { + saveConfigToStorage(newConfig) +}, { deep: true }) + +/** + * 配置管理工具函数 + */ +export const configUtils = { + /** + * 获取本地客户端 ID + */ + getLocalClientId(): string | null { + return CONFIG.LOCAL_CLIENT_ID + }, + + /** + * 设置本地客户端 ID + */ + setLocalClientId(clientId: string | null) { + CONFIG.LOCAL_CLIENT_ID = clientId + }, + + /** + * 检查指定的客户端 ID 是否为本地客户端 + */ + isLocalClient(clientId: string): boolean { + return CONFIG.LOCAL_CLIENT_ID === clientId + }, + + /** + * 重置配置 + */ + reset() { + CONFIG.INKCRE_CORE_URL = "" + CONFIG.INKCRE_PGREST_URL = "" + CONFIG.INKCRE_JWT_SECRET = "" + CONFIG.LOCAL_CLIENT_ID = null + localStorage.removeItem(CONFIG_STORAGE_KEY) + }, + + /** + * 导出配置(用于备份) + */ + export(): string { + return JSON.stringify(CONFIG, null, 2) + }, + + /** + * 导入配置(用于恢复) + */ + import(configJson: string) { + try { + const parsed = JSON.parse(configJson) + const validated = ConfigSchema.parse(parsed) + Object.assign(CONFIG, validated) + console.log('[Config] 配置导入成功') + } catch (error) { + console.error('[Config] 配置导入失败:', error) + throw new Error('无效的配置格式') + } + }, }