4
0
0

New Site Nest 和 Vue 插件平台源码与架构学习教程

2026-10-18
2026-10-18
文章摘要
|

这套方案要解决的业务问题是:一个已交付的系统,怎样在不反复修改宿主业务代码的情况下,安装订单、CRM、待办等应用,并同时获得后端能力、前端页面和可控的启停行为。

它的核心做法是:宿主提供稳定的运行协议,插件交付业务实现,管理器把经过校验的业务实现接入平台。 Nest 负责创建后端对象,Vue 负责呈现页面,安装、注册、授权和生命周期则由我们自己的插件平台负责。

面向前端开发者,建议第一次先读第 1、2、4 节和第 7 节第 2、4 章;第二次再带着断点读安装、请求和停用链。完整实战路线放在第 6、9、10 节。

0. 分析范围与可信度说明

本文分析的是本地 E:/demo/new-site,重点是宿主、插件 SDK 与已经改造为 Vue 3、Vite、Pinia 的 plugin-template。CRM 和订单插件证明平台可以接入多种业务,详细代码示例采用当前待办模板,避免在多套业务字段之间来回切换。

资料包含实际源码、package.json、模板 README、历史验收报告,以及上一版完整交互架构图。New Site 当前不是一个具有独立提交历史的 Git 仓库;架构证据使用的本地源码快照提交为 95425fee313202400957b139b5d1c0219a2ce7b1,它是文档证据快照,不代表已发布产品版本。

Halo 对照范围是本地 E:/demo/research/halo 的 main 分支,提交 f6194fbd51ef995ce065dc603c3adf2ae39ff669。本文只比较已读到的插件核心与 UI 装载机制,不推断私有应用市场的服务端实现。

本文使用以下标记:

  • 【已验证】:当前源码、配置或本轮执行结果直接支持。例如 enable() 的调用顺序、固定 HTTP 分发入口、Vue 适配器创建独立应用。
  • 【合理推断】:根据现有结构评估的影响,例如同页插件可以干扰 DOM、失控异步 disposer 可能阻塞 UI 同步。
  • 【待确认】:没有在本轮运行中复测,或属于尚未实现的生产能力,例如多发布者密钥治理、数据库事务和跨进程隔离。

本轮实际执行了文件与符号检索、源码读取,以及模板目录中的 npm run check,后端 TypeScript 与前端 vue-tsc 均通过。没有在本轮重新安装依赖、运行宿主、重新打包或执行会改写源码的浏览器验收。历史 8 项自动检查、18 项开发浏览器验收、9 项真实宿主验收属于已有记录,不能等同于本轮复测,见 VERIFICATION.md。

有三处阅读时必须校正的旧信息:旧架构图中的数据库目前是 JSON 文件;宿主路由使用 hash 和 Map,不是 Vue Router;模板仍有 NotesService、NotesRuntime、notes-* 操作和少量“便签”文案。它们目前服务于待办页面,不存在一个已经实现的 TodoController。种子数据中提到旧 src/ui 目录的文字也已过时,现在应读 src/App.vue。这些命名没有改变实际插件协议。

1. 项目全景概览

30 秒解释版本

New Site 是一个可安装业务应用的 Nest 宿主。一个插件包同时带着后端代码、Vue 页面和元信息。管理员上传后,平台先校验和保存;启用时再创建插件的独立 Nest 容器,注册业务操作,再让浏览器加载插件页面。停用时先撤销入口,再排空请求,最后清理前后端资源。

5 分钟解释版本

假设你向三个客户交付同一套系统。客户甲需要 CRM,客户乙需要订单,客户丙只需要待办。如果直接把所有业务写进宿主,每次新增业务都要改模块导入、HTTP 接口、前端路由、菜单、权限、构建与发布,客户之间的业务组合也会逐渐变成分支判断。

插件平台把变化分成两层。宿主负责长期稳定的事情:用户身份、应用安装、协议版本、资源归属、统一 API 入口、页面装载、日志与存储能力。插件负责变化频繁的事情:订单状态机、线索字段、待办表单、业务校验和展示。

开发者在独立 Vue 工程中写 App.vue 和 Pinia,在 src/server 中写 Nest 业务服务。完成后执行打包命令,把构建产物连同 Manifest 与签名封成 .plugin.json。客户无需把插件仓库复制进宿主源码,只需上传符合协议且受宿主信任的包。

这个设计适合按客户选择业务模块的私有化部署、内部受信应用广场,以及由统一团队或经过审查的合作方开发的扩展。它也适合把稳定平台与快速变化的业务交付节奏分开。

它的边界同样明确:当前后端插件共享 Node 进程,前端插件共享页面。独立 Nest 容器、Vue app 和 Pinia 解决对象与生命周期组织问题,不能阻止恶意代码访问进程或页面。对陌生开发者上传即运行、严格租户隔离、资源配额、任意版本无重启切换等需求,需要额外架构。

借鉴 Halo 的地方与实际差异

维度 Halo 已读源码 当前 New Site
管理器 PF4J DefaultPluginManager 的扩展 自建 PluginPlatform,承担 PluginManager 职责
后端加载 JAR、插件 ClassLoader CJS 与 Node require()
插件容器 带 ClassLoader、以共享容器为 parent 的 Spring Context 独立根 Nest ApplicationContext,显式注入 SDK
后端接入 注册 Controller 映射及 RouterFunction 固定 Controller 查询动态 operations Map
前端装载 发现 provider、CSS 与模块装载、向真实 Vue Router 注册路由 provider、CSS、ESM 与 hash 路由 Map;适配器创建独立 Vue app
停用 注销路由、关闭上下文,另有 PF4J unload 流程 撤销入口、排空、清理、关闭 DI 容器;不承诺卸载 Node 模块缓存

Halo 依据为固定提交中的 HaloPluginManager、Context 工厂 与 UI 注册流程。New Site 依据为 PluginPlatform.enable、固定操作入口 和 UI 注册。

因此,这是一套借鉴 Halo 组织思想的 Node 实现。当前成熟度是可实践、已有验收记录的教学 Demo,不是已具备审核、结算、许可证和强隔离能力的商业市场产品。

2. 开始学习前需要掌握的概念

必须先掌握

概念 定义与项目中的作用 对应例子与源码 前端类比及局限
宿主与插件协议 宿主是运行环境,协议规定业务怎样被接入 HostSdk、PluginEntrypoint、PluginManifest:SDK 类似组件 props 和插件接口;协议还必须处理权限、版本、资源回收
依赖注入 DI 由容器创建对象并把依赖交给构造函数 NotesRuntime 注入 SDK 与 NotesService:后端入口 类似 provide/inject;后端容器不管理 DOM,也不等于全局变量
DynamicModule 描述模块类、Provider 与导出的定义对象 createPluginModule():工厂 类似应用配置;返回定义不等于应用已经运行
ApplicationContext Nest 按模块定义创建出的 DI 对象图 NestFactory.createApplicationContext():启用 类似创建出的应用实例;它在本例不监听端口,不提供进程隔离
Runtime 与生命周期 插件供宿主调用的启动和清理对象 PluginRuntime.start/dispose:接口 类似 mount/unmount 契约;后台连接、timer 必须明确归属
Operation 由 ID、权限、输入 Schema 和 handler 组成的业务操作 notes-create:注册 类似按 action 名分发;它是在后端执行并再次鉴权的业务入口

对三个核心概念,分别回答八个问题就不会混淆:

  1. PluginManager 是什么? 自建协调器;为什么需要?Nest 不负责下载、验签、安装与运行状态;在哪里?真实类 PluginPlatform;例子?启用 todo;类比?应用生命周期管理员;局限?不是 JS 模块系统;误区?把它当 Nest 原生类;依赖?它调用工厂和 Nest,而不是被 DynamicModule 自动创建。
  2. DynamicModule 是什么? 模块元数据;为什么需要?让插件显式定义自己的 Provider;在哪里?工厂返回值;例子?HOST_SDK 的 value provider 与 PLUGIN_RUNTIME 的 class provider;类比?组件树配置;局限?不是对象实例;误区?动态模块天然支持热卸载;依赖?需要 ApplicationContext 才会形成可用对象。
  3. ApplicationContext 是什么? DI 容器实例;为什么需要?让每次激活拥有一套可关闭的插件对象;在哪里?activation.context;例子?同一个发布停用再启用产生新 Runtime;类比?新的 Vue app;局限?仍然共享进程,且不会自动继承宿主 Provider;误区?独立容器等于安全沙箱;依赖?根据模块定义创建,再通过 Token 取得 Runtime。

可以边读边学

概念 当前作用与例子 源码与常见误区
CJS、ESM、import map 后端 require CJS;浏览器 import ESM;依赖名映射到共享 Vue Vite 配置、import map。前后端不是同一个模块运行环境
Manifest、digest、activationId Manifest 描述身份和入口;digest 标识发布内容;activationId 标识某次运行 数据类型。相同版本重新启用,activationId 可以变化而 digest 不变
JSON Schema 与 Ajv 把不可信请求变成业务函数可接受的数据 prepareOperations。TypeScript 类型不会在网络输入上自动执行
HMR 替换前端模块并尽量保留开发状态 Pinia HMR。HMR 不是生产升级,也不保证任意修改都保留状态
drain 与协作取消 撤入口后等待已接收的受管工作结束,通过 AbortSignal 通知取消 drain。signal 不能强制打断忽略它的代码
数字签名 用受信公钥验证包来源与完整性 包校验。签名不是恶意行为检测

进阶阶段再学习

可以在独立完成一个插件后,再学习发布者信任与吊销、可恢复安装任务、数据库迁移、可靠事件 Outbox、跨进程 RPC、资源限额和宿主 SDK 兼容策略。它们解决生产治理问题;当前文件存储、内存事件和 DI 容器并没有隐含提供这些能力。

概念依赖顺序是:业务模块化需求 → 插件协议 → DI 与模块定义 → 容器与 Runtime → 操作注册和 UI provider → 生命周期与资源所有权 → 发布、兼容与安全治理。

3. 技术栈与工程系统

技术 用途 核心程度 配置或入口 学习优先级与掌握程度
Node 与 TypeScript 进程、构建、类型与后端执行 核心 宿主 package.json 高,理解模块缓存与异步任务
Nest 11.1.6 宿主 HTTP、插件 DI 容器 核心 host.ts 高,掌握 Provider、Token、Context
Vue 3.5.22 宿主与插件页面 核心 plugin.ts 高,掌握应用实例与卸载
Vite 7.1.12 SFC HMR 与生产 ESM 构建 核心开发工具 vite.config.ts 高,掌握库模式、external、相对资源
Pinia 3.0.3 插件页面状态与 Store HMR 插件前端核心 todos.ts 高,掌握每 app 创建与 dispose
esbuild 后端 TypeScript 打为 CJS 构建核心 build.mjs 中,理解入口和外置依赖
Ajv 操作输入运行时校验 后端核心 prepareOperations 中,会写与调试 Schema
Node crypto SHA-256 与 Ed25519 包协议核心 pack.mjs 中,理解摘要、签名和受信公钥
node:test 与 Playwright HTTP、生命周期、真实浏览器验收 质量工具 测试脚本 中,会选择有意义的验收

宿主与模板各有自己的 package.json 和锁文件。模板通过 sdk/*.tgz 相对文件依赖引用 SDK、UI 协议、Vue adapter 和开发宿主快照,因此可复制到独立目录开发,不需要相对导入宿主源码。依赖事实见 模板 package.json。当前是一个包含宿主、SDK、示例插件和独立模板的多包目录组织,不应把它描述成已经采用 npm workspaces 的发布型 Monorepo。

开发路径和生产路径分开:开发时前端来自 Vite 模块图,后端来自可重启的 API 子进程;生产时执行类型检查与打包,输出签名 .plugin.json,客户上传后由原宿主装载。宿主自己的 npm run dev 只构建再启动,模板的 npm run dev 才有这套 Vite 开发监督流程。

配置入口主要是插件 plugin.json、模板工具设置与本地签名配置。模板 Node 要求为 ^20.19.0 || >=22.12.0;宿主声明 Node >=20,两者不能混为一个最低要求。发布流程不把开发密钥、业务数据、node_modules 或私钥打进插件包。

4. 整体架构

4.1 架构风格

【已验证】这是同进程插件架构。宿主维护稳定平台内核,插件有独立 Nest DI 对象图,通过显式 SDK 接入共享能力。判断依据是 require(entry) 与 createApplicationContext() 都在同一 enable() 方法内执行,业务请求最终直接调用 JavaScript handler,没有插件 RPC 或容器网络。

它有模块化单体的运行特征,也有独立制品的交付特征。独立交付不要求每个业务都成为微服务;同进程执行也不要求把业务源码编译进宿主入口。

4.2 模块划分

模块 职责与对外接口 上游 → 下游 层级与源码
HTTP 宿主 固定管理、操作与 provider API 浏览器 → Platform 核心:host.ts
PluginPlatform 安装、启用、发布、排空、清理与持久状态 Controller → 插件/Nest/文件 核心:平台
后端 SDK Token、Runtime、Operation、Manifest 与能力契约 平台/插件 → 稳定类型协议 核心:SDK
插件后端 Provider、业务校验、操作与生命周期 SDK/容器 → 业务 JSON 核心:模板后端
活动注册表 operations Map 与 stage 门禁,属于平台内部状态 Dispatcher → handler 核心:注册校验
UI provider 只公布活动、对当前用户可见的插件描述 平台 → 浏览器装载器 核心:providers
宿主前端 页面壳、管理台、菜单、hash 路由、import 与撤销 provider/用户 → 插件 UI/API 核心:app.js
Vue adapter 将普通 Vue app 转为宿主 UI 协议,挂载与清理 宿主路由组件 → App.vue 核心:adapter
插件前端 SFC、Pinia、业务请求、样式 HostContext → ctx.invoke 业务:App.vue
开发监督器 Vite 长驻、构建成功后重启后端、API 代理 文件保存 → 开发子进程 重要:dev.mjs
构建与签名 CJS/ESM/CSS/chunks 收集、摘要与签名包 工程源码 → 插件包 重要:build、pack
JSON 本地存储 安装意图与插件命名空间业务文档 平台 → 文件系统 重要:存储实现

PackageLoader、Registry 与事件能力在图中是职责划分;当前不是三个已经独立实现和部署的服务类。

4.3 架构图

Nest 与 Vue 插件平台完整架构

图按开发、打包、上传、启用、业务访问和停用清理展开。放大原图可查看各节点及连线;下面给出便于先建立整体模型的简化版本。

flowchart TB Dev[插件开发者] --> Project[Vue App 与 Nest 业务工程] Project --> Build[Vite ESM 与后端 CJS 构建] Build --> Package[Manifest 文件摘要 签名包] Package --> Admin[上传安装与启停 API] User[业务用户] --> Shell[宿主页面与动态菜单] subgraph Host[客户部署的宿主平台] Admin --> Manager[PluginPlatform 管理生命周期] Manager --> State[(安装状态 JSON)] Manager --> Factory[require 后调用 createPluginModule] Factory --> Definition[DynamicModule 定义] Definition --> Nest[NestFactory 创建 ApplicationContext] Nest --> Runtime[插件 Runtime 与业务 Provider] Runtime --> Register[校验操作后切换 ENABLED] Register --> Gateway[固定 HTTP 入口与操作 Map] Register --> Provider[活动 UI Provider] Runtime --> SDK[显式 HostSdk] SDK --> Data[(插件业务 JSON)] Manager --> Stop[撤入口 排空 dispose close] end Provider --> Loader[浏览器加载 ESM 与 CSS] Loader --> Shell Shell --> Adapter[Vue 适配器] Adapter --> App[独立 Vue App 与 Pinia] App -->|ctx.invoke| Gateway Gateway --> Runtime Stop -->|provider 撤销后同步| Loader

完整图还包含本地 HMR、资源 API、SDK 事件、停用失败分支与源码节点依据:完整交互架构图。它是本地文件;转为对外文章时应上传图的导出文件或替换成读者可访问的地址。

4.4 核心数据流

以“新增一条待办”为例,页面先把表单交给 Pinia action,action 调用 ctx.invoke('notes-create', input)。宿主 Context 将操作名转为固定 API URL,附上会话、CSRF 与发布 digest。Controller 进入平台,平台确认这次激活可接流量、用户具备权限且输入符合 Schema,然后调用插件 handler。

handler 进入 NotesService.create(),通过 SDK 的串行更新保存文档,随后发布插件自己的事件;订阅者尝试发送通知,业务结果返回给浏览器。事件通知失败会记录日志,不把已经成功保存的业务伪装成失败。它没有可靠事件重放保证。

4.5 启动流程

真正入口 main.ts 解析根路径并调用 createHost(),读取 PORT,最后监听本地 4310。createHost() 创建 PluginPlatform 并先执行 init(),再创建包含固定 Controller 的宿主 Nest HTTP app,提供静态页面。见 createHost。

平台初始化从安装状态 JSON 恢复安装记录,对 desiredEnabled=true 的插件尝试调用 enable()。单个启用失败被记录,但安装状态文件本身非法仍可能阻止初始化。最后浏览器加载宿主壳、建立演示会话、获取 UI providers,才装载对应业务页面。恢复逻辑见 init。

4.6 关键调用链

启用链:PlatformController.enable → PluginPlatform.enable → verifyInstalledFiles → require(entry) → createPluginModule(createSdk(activation)) → NestFactory.createApplicationContext → context.get(PLUGIN_RUNTIME) → runtime.start → prepareOperations → 切换 ENABLED → 持久化。异常进入 withdraw、drain、cleanup,并根据能否确认清理选择 FAILED 或 RESTART_REQUIRED。依据:Controller、完整启用。

业务链:useTodosStore() 返回的 save() → UiContext.invoke → PlatformController.invoke → PluginPlatform.invoke → Operation.handler → NotesService.create → sdk.storage.update → JSON 文件 → 返回对象。异常由平台记录,再交给 Nest 返回 HTTP 错误。依据:Store.save、Context、分发、业务、存储。

5. 源码地图与推荐阅读顺序

顺序 文件与关键符号 阅读目的与前置概念 读完能回答什么
1 plugin.json 先看制品契约,只需 JSON 一个应用向宿主声明什么
2 SDK:HostSdk、Runtime、Manifest 理解 TypeScript 接口与 Token 插件能调用哪些宿主能力
3 插件工厂 DI、Provider、DynamicModule SDK 和 Runtime 怎样进入容器
4 enable 模块缓存与异步函数 谁创建容器,何时公开操作
5 固定 Controller、invoke HTTP 与 Schema 不改宿主怎样增加业务操作
6 plugin.ts、adapter Vue createApp、provide、Pinia 普通 SFC 怎样成为插件页面
7 synchronizeUi ESM、CSS、Map 页面和菜单为什么会出现或消失
8 disableInternal、cleanup 在途任务、资源所有权 为什么停用要分多个阶段
9 dev.mjs、build.mjs、pack.mjs Node 子进程与构建 开发体验为何不等于生产升级
10 模板测试、浏览器验收 测试隔离 如何证明扩展没有破坏生命周期

调试重点是 enable/invoke/withdraw/drain/cleanup、前端 removeUi/synchronizeUi 和业务 service。二次开发先改模板业务文件;理解核心之前暂时跳过 node_modules、dist、运行时 .data/.local、旧截图和生成的签名制品。SDK tarball 是交付物,理解协议先读对应源码。

6. 完整学习路线

第一轮 7 天建立可解释的整体模型;再用约 8 天熟悉源码与调试;若要独立负责平台演进,按一个月安排兼容、安全与持久化专题。

阶段 建议时间 阅读与源码 实践 验收
全景与概念 第 1 天 第 1—4 节,Manifest、SDK、工厂 手画宿主、后端插件、浏览器三者关系 能解释需求与三个核心对象
运行与观察 第 2 天 第 9 节,入口与 dev 工具 在独立模板副本运行预览,观察 PID/provider 说清 Vite 与 Nest 子进程各负责什么
主链路 第 3—4 天 enable、Controller、invoke、Service、storage 对一次新增操作逐步调试 定位权限、Schema、handler 与写入
Vue 接入 第 5 天 plugin.ts、adapter、synchronizeUi 修改 SFC 与 store,观察 HMR 能解释共享 runtime、独立 app 与 Pinia
生命周期 第 6—7 天 withdraw、drain、cleanup、removeUi 启停、读只读角色结果、观察资源数 撤入口、保留业务数据、无新增后台任务
故障与修改 第 8—11 天 测试、签名、操作注册、错误分支 修改已有功能,模拟操作名错误并修复 根据证据定位故障,而非盲目重启
小型扩展 第 12—15 天 第 10、11 节 独立新增一个报销插件并打包安装 宿主源码无业务性修改,完成完整启停
平台能力 第 16—30 天 第 8、12、16 节 设计一次 SDK v2/数据库/发布审核改造 说明兼容迁移、信任边界和失败恢复

7. 详细课程章节

第 1 章:把一个业务工程变成可安装制品

1. 本章定位

先理解宿主接收什么,才能判断哪些事情属于插件。一个 Vue dist 只说明怎样在浏览器执行,并没有描述后端入口、权限、宿主兼容版本和可信来源。

2. 学习目标

能辨认 Manifest、文件列表、digest 与签名;能指出安装与启用的边界;能解释为什么上传源码或任意 ZIP 不符合当前协议。

3. 前置知识

Base64 是二进制转文本的编码,不能证明安全;摘要标识内容;签名让宿主用预先信任的公钥核对“谁签过哪些内容”。这些步骤都先于执行第三方 JavaScript。

4. 对应源码

读 PluginPackage、pack、install、validatePackage。这四处分别定义契约、生产制品、消费制品与校验条件。

5. 工作原理

问题是客户系统怎样在没有插件源码工程的情况下安装业务。设计目标是制品自描述、完整、可核对、可重复安装。核心抽象是 manifest + files + signature;实现按文件记录路径、Base64 内容与 SHA-256,并对规范化的 Manifest 和排序后的路径/摘要载荷签名。

6. 执行流程

npm run pack → 类型检查 → 后端 CJS 与前端 Vite 构建 → 收集 CSS/chunks → 文件摘要 → 签名 → 输出 .plugin.json → 上传 → 宿主校验 → 暂存与重命名 → 记录 INSTALLED。这里最后一步仍没有 require 插件。

flowchart LR Source[插件源码] --> Build[类型检查与前后端构建] Build --> Sign[文件摘要与签名] Sign --> Package[可上传 JSON 包] Package --> Validate[宿主校验] Validate --> Files[不可变安装目录] Files --> Installed[INSTALLED 尚不执行]

7. 关键源码讲解

包的实际结构可以缩写为:

interface PluginPackage {
  manifest: PluginManifest;
  files: Array<{ path: string; contentBase64: string; sha256: string }>;
  signature: { keyId: string; value: string };
}

输入是构建完成的字节,输出是可上传 JSON。校验拒绝越界路径、重复文件、缺失入口、超限文件、错误摘要或无效签名。同 ID/版本不能被不同内容静默覆盖。文件上限 200、单文件 4 MiB、解码总量 20 MiB;模板还把最终 JSON 控制在 10 MiB,Base64 膨胀后可能更早遇到这个限制。当前只接受本机演示信任键 demo-local,不是多发布者市场。

8. 调试与观察方法

断点放在 validatePackage 与 install 写安装记录前;观察 Manifest、digest、文件名和 phase,不输出私钥。正常安装后应为 INSTALLED 且没有新增业务操作。安装失败先看入口、签名公钥、版本与路径;不要通过关闭验签“解决”。npm run pack 在本文本轮未执行,历史结果见第 0 节。

9. 本章实践任务

在模板副本打包后,只读取并列出包内文件与入口,确认包含后端 CJS、前端 ESM、CSS 和帮助组件 chunk,不含私钥、source map 与 node_modules。验收为 Manifest 每个入口都在 files 中。只观察不需要撤销;修改实验字段后用副本原始文件恢复。

10. 常见误区

安装不是执行;签名不是加密;相同 keyId 不代表同一把密钥;开发密钥不自动获得客户宿主信任。第三方发布应由审核后的受信流程签名,不能把宿主私钥发给所有开发者。

11. 自测题

  1. Base64、摘要、签名分别解决什么?
  2. 为什么安装阶段不能公开业务入口?
  3. 哪里定义包格式与文件限制?
  4. 摘要正确但签名失败应排查什么?
  5. 支持多个发布者需要怎样扩展信任模型?

第 2 章:PluginManager 如何使用 DynamicModule 和 ApplicationContext

1. 本章定位

这是整套机制的中心:从磁盘上的代码文件,到一组真正运行的 Nest 业务对象,中间需要加载、描述、实例化、启动和发布五个动作。

2. 学习目标

能按真实顺序解释 enable;能分别指出三个核心对象的输入、输出和所有者;能说明插件如何取得 SDK 而不是直接依赖宿主内部 Service。

3. 前置知识

Provider 是容器管理的对象定义,Token 是查找依赖的标识。useValue 注入已有对象,useClass 让容器创建类实例。一个独立 Context 对应一张依赖对象图,但这些对象仍在同一个进程和事件循环中运行。

4. 对应源码

重点是 enable、createPluginModule、Runtime.start 和 SDK Token。

5. 工作原理

问题是插件未编译进宿主 imports,宿主怎样创建它。设计目标是通过稳定工厂协议取得模块定义,并让每次激活持有自己的容器与资源。Manager 负责何时加载与何时公开,DynamicModule 负责怎样组装,ApplicationContext 负责创建并持有实例。

6. 执行流程

sequenceDiagram participant A as 管理 API participant M as PluginPlatform participant P as 插件工厂 participant N as NestFactory participant C as ApplicationContext participant R as PluginRuntime A->>M: enable(id) M->>M: 复验文件并创建 STAGING activation M->>P: require 后 createPluginModule(scopedSdk) P-->>M: DynamicModule M->>N: createApplicationContext(definition) N-->>M: 独立 Context M->>C: get(PLUGIN_RUNTIME) C-->>M: Runtime 实例 M->>R: start() R->>M: registerOperation 及受管资源 M->>M: 校验贡献并切换 ENABLED M-->>A: 安装运行状态

7. 关键源码讲解

插件真实返回的定义为:

export function createPluginModule(sdk: HostSdk): DynamicModule {
  return {
    module: NotesPluginModule,
    providers: [
      { provide: HOST_SDK, useValue: sdk },
      NotesService,
      { provide: PLUGIN_RUNTIME, useClass: NotesRuntime },
    ],
    exports: [PLUGIN_RUNTIME],
  };
}

输入是平台为本次激活创建的 SDK;输出就是 DynamicModule 配置对象。运行时 Token 使用 SDK 约定的 PLUGIN_RUNTIME 常量,没有另一个 runtimeToken 返回字段。Manager 随后调用 Nest 并通过 Token 取出 Runtime。这里没有 HTTP server,也没有父容器自动继承。SDK 是显式的 value provider,业务插件不需要知道宿主内部 Service 的路径与构造方式。

Runtime.start 注册的操作先被暂存;Manager 编译输入 Schema、检查重复 ID 与权限声明,全部通过后才将 stage 改为 ENABLED。这个可见性切换发生在单个事件循环同步步骤内,不能理解为同时回滚文件写入的数据库事务:start 已写入的初始化数据不会自动撤销。

启用失败会撤销入口并清理。若已经执行代码却没有得到可控 Context,或者回滚清理失败,会进入 RESTART_REQUIRED。abortOnError:false 允许错误返回管理流程,不代表初始化失败时一定没有残留。

8. 调试与观察方法

依次断在平台 251、253、256、257、260、263 行;观察 definition.providers、activation.context、activation.runtime、activation.operations、activation.stage。启用前没有活动实例,start 中仍为 STAGING,发布后才 ENABLED。找不到 PLUGIN_RUNTIME 应检查 Token 和 providers,而不是改宿主 HTTP 路由。

9. 本章实践任务

在调试副本给 NotesRuntime 的构造、start、dispose 各加一条日志,启用、停用、再启用同一制品。验收为两次不同 activationId、不同 Runtime 实例,业务数据保持。恢复时删除实验日志并重新打包;不要据此承诺新的 digest 可在同进程切换。

10. 常见误区

DynamicModule 的“动态”不包含应用市场;Context 不会自动共享所有宿主对象;关闭 Context 不清除 require 缓存。PluginManager 在这里是架构职责名称,源码类名是 PluginPlatform。

11. 自测题

  1. 模块定义与容器实例有什么不同?
  2. 谁决定调用 start 和发布操作?
  3. 哪里把 SDK 绑定到 HOST_SDK?
  4. start 中注册成功但页面没出现,应观察哪个状态?
  5. 为什么直接把宿主 ModuleRef 交给所有插件会增加耦合?

第 3 章:固定 HTTP 入口如何承载动态业务

1. 本章定位

理解容器之后,需要解释用户请求怎样到达插件。这个 Demo 的关键选择是保持 Nest Controller 固定,让业务操作注册表动态变化。

2. 学习目标

能从新增待办按钮走到写文件;能区分前端隐藏按钮与后端权限;能指出 Schema、release 校验、在途计数与实际 handler 的位置。

3. 前置知识

客户端 TypeScript 类型不约束伪造的 HTTP 请求。后端必须在业务函数前检查身份、权限和输入。CSRF 保护基于浏览器自动携带的会话凭据,不能由前端 UI 是否显示某按钮替代。

4. 对应源码

读 Context.invoke、Controller.invoke、平台 invoke、Service.create、SDK.storage。

5. 工作原理

问题是插件操作不断增加,宿主不能逐个 import 业务 Controller。设计目标是让通用入口统一治理流量。核心抽象是 PluginOperation:ID、title、permission、inputSchema、handler。新增业务改变注册表,不改变固定网关。

6. 执行流程

sequenceDiagram participant V as Vue 与 Pinia participant H as 宿主 HTTP participant M as PluginPlatform participant B as 插件 Service participant S as SDK 存储 V->>H: POST apps/todo/operations/notes-create H->>H: 会话 Origin CSRF H->>M: invoke(id, operation, input, principal, digest) M->>M: ENABLED 权限 Schema 及 pending 登记 M->>B: handler(input, context) B->>S: update(notes, updater) S-->>B: 保存后的文档 B->>M: 发布自身事件并尝试通知 B-->>M: 新增对象 M-->>V: JSON 响应

7. 关键源码讲解

真实注册片段的关键字段是:

this.sdk.registerOperation({
  id: 'notes-create',
  title: '创建便签',
  permission: `${this.sdk.pluginId}.note.create`,
  inputSchema: {
    type: 'object', additionalProperties: false, required: ['title'],
    properties: {
      title: { type: 'string', minLength: 1, maxLength: 100 },
      content: { type: 'string', maxLength: 2000 },
    },
  },
  handler: (input, context) => this.notes.create(input, context),
});

运行时从当前激活的 Map 查 handler,先检查 digest、ENABLED、用户权限和 Schema,再登记本次任务的 Promise。handler 的 context 提供 principal、requestId 与 signal。这样停用时可以拒绝新任务,又知道旧任务还有多少没有结束。

存储 update 把同一个插件、同一个 key 的“读取、修改、写入”放在串行队列内,避免两个并发请求都读取旧数组再互相覆盖。实际文件在 .data/business/<id>/<key>.json,以临时文件写入后 rename 替换。它适用于当前单进程示例,不具备多实例数据库事务语义。

8. 调试与观察方法

浏览器 Network 查看操作 ID、状态码与 x-plugin-release,不要复制认证头到公开日志。后端断点放在 invoke 的状态/权限/Schema 分支与业务 update 回调。旧发布可能返回 409,未启用或操作不存在可能返回 404,权限不足返回 403;具体错误正文与记录一起判断。

9. 本章实践任务

在独立副本中用只读角色尝试创建,再以管理员创建。验收是只读请求后端拒绝且数据不变,管理员成功后数据、统计和通知一致。删除自己创建的测试记录即可恢复;不要删除整个业务目录。

10. 常见误区

动态操作注册不等于热插 Nest Controller;前端校验不等于后端校验;文件 update 的串行性不代表跨文档事务;当前事件名必须位于本插件命名空间,不是任意跨插件共享业务总线。

11. 自测题

  1. 为什么网络输入仍需要 JSON Schema?
  2. Controller、Map 与 Service 分别承担什么?
  3. 在途 Promise 在哪里登记?
  4. 按钮消失仍能伪造写请求,应该在哪修复?
  5. 想支持订单与库存的跨业务事务,需要增加什么能力?

第 4 章:普通 Vue 应用如何接入宿主并保持开发体验

1. 本章定位

后端已经公开能力,浏览器还需要知道“有哪些业务页面、从哪里加载、怎样挂载和撤销”。这章把熟悉的 SFC 工程与插件运行协议连接起来。

2. 学习目标

能解释 plugin.ts、adapter、App.vue、Pinia 各自的职责;能追踪 provider 到菜单;能区分普通前端 HMR、后端重启和生产发布。

3. 前置知识

共享 Vue runtime 是共享框架实现;独立 Vue app 是不同的根应用;独立 Pinia 是不同的内存 Store 容器。三者同时存在并不矛盾。ESM external 表示运行时从宿主解析依赖,不是把依赖代码丢弃后希望浏览器自行找到它。

4. 对应源码

读 plugin.ts、adapter、synchronizeUi、Vite 配置、dev 监督器。

5. 工作原理

问题是业务工程应该像普通 Vue 项目一样写,同时又符合宿主的 UI 插件接口。适配器负责桥接:对宿主导出 id、routes、activate,并由 activate 返回清理函数;对业务创建 Vue app、注入 HostContext,并调用业务 setup 安装 Pinia。

6. 执行流程

GET UI providers → 校验 digest/activationId → 加载 CSS 与动态 import → definition.activate(ctx) → 注册合法单一路径到 Map → 生成菜单 → 宿主渲染包装组件 → 包装组件 createApp/provide → 安装 Pinia → mount App.vue → Store 调 ctx.invoke。

sequenceDiagram participant P as UI Provider API participant H as 宿主装载器 participant A as Vue 适配器 participant V as App 与 Pinia P-->>H: 活动插件资源与路径描述 H->>H: 加载 CSS 并 import ESM H->>A: activate(ctx) A-->>H: 返回清理函数 H->>H: 注册 Map 路由并显示菜单 H->>A: 渲染包装组件 A->>V: createApp provide 安装 Pinia mount V->>H: ctx.invoke 访问业务 API

7. 关键源码讲解

业务入口是真实代码:

export default defineVueAppPlugin({
  manifest,
  root: App,
  setup(app) {
    const pinia = createPinia();
    app.use(pinia);
    return () => disposePinia(pinia);
  },
});

adapter 在包装组件 mounted 时 createApp(root),app.provide(CONTEXT, props.ctx),调用 setup 后 mount。每次页面挂载创建自己的 Pinia;普通切页或停用时 unmount 和 dispose,因此持久化业务数据与页面内存草稿必须分开考虑。

生产 Vite library mode 生成 ESM、一个 CSS 文件和相对路径 chunk。external 只有 Vue 与宿主 UI SDK;Pinia 和适配器被打进插件。宿主 import map 提供共享 Vue,避免宿主渲染出的包装组件引用另一套不一致的框架身份。

开发时 Vite 长驻。SFC、CSS、Store 保存交给 HMR;Pinia 使用 acceptHMRUpdate。src/server/** 或 plugin.json 变化才触发后端开发包重建,先构建成功再停旧 API。后端使用随机端口,由 Vite 代理统一入口,前端开发者无需管理两套 API 地址。重启 API 后 activationId 改变可能重新挂载 Pinia,不能承诺未提交草稿跨后端重启保留。

8. 调试与观察方法

前端断点放在 synchronizeUi、adapter mounted 与 Store action。Network 检查 provider 描述、主 ESM、CSS、HelpPanel chunk 与操作请求。普通 SFC HMR 时观察 DOM 更新、页面 timeOrigin、后端 PID 与 activationId 不变;后端重启时 PID 与 activationId 改变,而 Vite 页面通常不整页刷新。

9. 本章实践任务

模板副本中先填写未提交草稿,再改 App.vue 的文字、CSS 与 Store action,观察 HMR;随后修改后端日志观察只重启 API。验收区分这两种行为,恢复修改后再次确认页面正常。不要在手动 dev 运行时同时执行会写同目录产物的 pack 或浏览器改写测试。

10. 常见误区

宿主当前没有 Vue Router;单个声明根路径不代表任意深链已支持;scoped CSS 不隔离所有全局样式;卸载 Vue app 不卸载浏览器 ESM;前端 HMR 不提供客户环境的生产热升级。

11. 自测题

  1. 共享 Vue 与独立 Pinia 为什么能同时成立?
  2. adapter 为什么夹在宿主路由组件与 App.vue 中间?
  3. Pinia HMR 与后端重启分别由哪些文件处理?
  4. 主页面正常但帮助组件 404 应沿哪条资源链检查?
  5. 多页面深链需求应该扩展宿主协议还是逐业务改宿主路由?

第 5 章:启停、失败与资源回收

1. 本章定位

能够“装起来”只完成了一半。业务插件还会注册任务、监听器和异步操作,平台必须能撤销入口并给正在运行的工作一个明确结局。

2. 学习目标

能解释 withdraw、drain、dispose、close 的顺序;能区分 DISABLED 与 RESTART_REQUIRED;能说明前后端撤销的时间差以及业务数据为何保留。

3. 前置知识

资源所有权意味着知道谁创建、谁持有句柄、何时释放。drain 是等待受管 Promise 完成,不是阻塞所有 JS;AbortSignal 是协作通知。超时不会自动终止 Promise 内部代码。

4. 对应源码

读 disableInternal、withdraw/drain、cleanup、removeUi 与 adapter cleanup。

5. 工作原理

问题是直接关闭容器可能使仍在运行的请求访问已经回收的资源。设计目标是先阻止新增工作,再等待已接受的工作结束,最后回收受管资源。SDK 包装的操作、事件、任务与存储提供可追踪的所有权边界。

6. 执行流程

flowchart TD A[管理员停用] --> W[withdraw 设置 DRAINING 并撤入口] W --> D[drain 等待受管在途工作] D -->|排空成功| P[runtime.dispose 与反向 SDK disposers] D -->|超时| R[RESTART_REQUIRED] P --> C[继续尝试 context.close] P -->|总清理超时| R C -->|汇总清理成功| S[DISABLED 保留业务数据] C -->|汇总异常或超时| R W --> U[provider 不再可见] U --> B[浏览器同步后撤菜单和路由] B --> V[UI disposer 卸载 App 和 Pinia 并移除 CSS]

7. 关键源码讲解

withdraw 同步把 stage 设为 DRAINING,清受管 timer、订阅并 abort,随后新 invoke、provider 与资源入口都过不了 ENABLED 门禁。drain 不断观察 pending,包含已接收工作继续创建的受管子任务。

如果 drain 超时,平台不继续关闭仍可能被请求使用的资源,直接要求重启。排空成功才进入 cleanup;某个 Runtime 或 SDK disposer 抛错不会阻止尝试其余清理和 context.close,最后聚合异常。清理总超时或失败同样进入 RESTART_REQUIRED。

浏览器不是与后端共享内存。宿主每 2.5 秒轮询 provider,也会在本页管理动作后主动刷新。removeUi 先离开当前业务页,删除 Map 路由,等待 UI disposer,再移除 CSS。adapter 随之 unmount app、运行页面卸载钩子并 dispose Pinia。ESM 模块缓存仍可能存在。

停用只关闭本次运行,保留安装文件与业务 JSON。再次启用同 digest 可以创建新 Context。当前进程如果已经加载过同 ID 的另一 digest,平台明确要求重启,不能称为任意版本热替换。

8. 调试与观察方法

观察 stage、pending.size、subscriptions、timers、context 和 phase;前端观察 uiRecords、pluginRoutes 和 CSS link。断点设在 withdraw、drain 循环、每个清理调用与 removeUi。停止新入口后,资源数仍非零应检查在途工作和未托管资源;不要只根据菜单消失判断后端已停用。

9. 本章实践任务

在隔离测试中让一个受管 handler 等待可控 Promise,发起请求后停用,再释放 Promise。验收先拒绝新请求,再完成旧请求并关闭;另测不释放触发 RESTART_REQUIRED。恢复通过结束测试专有进程完成,不在真实客户宿主上注入永不结束的任务。

10. 常见误区

停用不等于删除数据;close 不等于卸载代码;Promise 超时不等于底层工作被终止;SDK 无法追踪插件绕过它创建的所有资源。浏览器前端 disposer 当前没有超时治理,合作代码不返回时可能阻塞同步流程。

11. 自测题

  1. 为什么先撤入口再排空?
  2. 前后端撤销为何不是同一瞬间?
  3. 哪个函数决定调用 context.close?
  4. disposer 失败后是否仍尝试 close?drain 超时呢?
  5. 如何为第三方 UI disposer 增加超时与故障状态?

8. 核心模块深度分析

PluginPlatform 的状态与所有权

最重要的数据不是某个业务 Service,而是 installation 与 activation。installation 是持久的安装记录,包含 Manifest、digest、期望启用状态与 phase;activation 是当前进程中的运行实例,持有 Context、Runtime、stage、operations、待完成 Promise 和受管资源。类型入口见 PluginInstallation,内部所有者见 平台字段。

状态对象 生命周期 修改入口 容易破坏的约束
installation 跨宿主重启保留 install/enable/disable/persist 运行状态与磁盘期望状态不一定在失败时同时成功
activation 每次启用新建 createActivation/enable/cleanup 所有资源必须归属于这次 activationId
operations Map start 暂存、校验后可见 registerOperation/prepareOperations STAGING 不接流量;重复 ID、未声明权限必须拒绝
loadedReleases 当前进程保留 require 前记录 停用不能把它当作代码已卸载而随意清除
pending 每个受管异步工作期间 track/drain 必须包含子任务,完成后及时移除

同一个插件的安装与启停通过 exclusive() 串行,避免两次管理动作交错;同一个业务文档的 read/update 通过文档队列串行。两者保护不同对象,不是一个全局“锁住 Node”的机制。依据:SDK 存储、队列。

当前设计的优点是调用直接、部署简单、资源归属清楚,且宿主不必知道具体业务。代价是平台类承担较多职责,同进程异常与阻塞会影响所有插件,启动中的业务副作用不能整体事务回滚,模块缓存也限制升级方式。

可替代方案包括:在启动期把受信业务统一编译为普通 Nest 模块;维持当前独立制品,但把业务放到 worker/子进程或容器,通过 RPC 访问受控能力;或者只对前端扩展提供页面插件。选择取决于独立交付、隔离、调试和部署复杂度,不能仅看“动态加载”四个字。

SDK 的能力与资源模型

SDK 既缩小插件对宿主内部代码的依赖,也为 timer、订阅、存储和清理建立归属。当前 capability 只有 storage、events、notifications、timers;注册操作和订阅有阶段限制;事件名限制在 ${pluginId}. 命名空间;timer 有 busy 标记,避免自身重叠执行。见 createSdk。

SDK 被冻结可以防止插件改写这个接口对象,但不能阻止插件直接 import Node 内置模块。能力声明约束的是遵守 SDK 的调用,不构成操作系统权限控制。

文件存储的两层约束也需要分开:命名空间避免正常插件拿错其他业务 key;每 key 队列避免单进程丢更新。它不提供多个宿主进程之间的协调,也不提供跨 key 原子事务。通知与日志当前在内存,不能代替持久消息队列。

前端同步的一致性与代价

UI provider 把后端的有效性转换成浏览器能理解的资源描述。digest 绑定制品,activationId 绑定运行代次,前端用二者判断旧 UI 是否应撤销后再装载。refresh 串行化避免旧快照晚返回而覆盖新撤销结果,见 refresh。

当前协议简单,但约束也集中:一个根路径;Vue/SDK 版本兼容;CSS 为同页样式;前端 disposer 需要合作;provider 轮询存在可见延迟。扩展时应针对这些协议升级,而不是让业务直接写宿主内部 Map。

测试策略与性能观察

平台应测试门禁和失败恢复,业务应测试规则与写入,浏览器应测试实际资源装载、权限和撤销。比“某方法调用次数正确”更有意义的断言是:安装后操作不存在;启用校验失败不公开半套操作;停用后新调用拒绝;已接收工作被排空;业务数据保留;错误清理不假报成功。

【合理推断】性能敏感区包括同事件循环的 CPU 任务、JSON 文档全量读写、启用时批量 Schema 编译、过多后台任务和长时间运行的 handler。Schema 已在注册时编译,业务请求复用验证器;真正容量和资源限额仍需基准与生产负载确认。

9. 运行、测试和调试指南

环境与最小启动

使用模板需要符合其 Node engines 与 npm。宿主默认仅监听 127.0.0.1:4310,Vite 默认 5173,后端开发 HTTP 使用随机端口,Inspector 默认 9230。先确认同目录没有已有 dev 实例;不需要为预览再手动启动第二个 API 服务。

【已执行并验证】

本轮在 E:/demo/new-site/plugin-template 执行:

npm run check

结果:tsc -p tsconfig.server.json && vue-tsc -p tsconfig.web.json 成功,退出码 0。这只验证类型,不代表本轮重新跑过完整安装和启停。

本轮还执行了 rg --files、按范围源码读取与 Halo 本地 Git 元数据核对。未执行删除数据、覆盖已发布包、发布博客或部署变更。

【仓库声明但本轮未执行】

宿主首次准备和启动:

cd E:/demo/new-site
npm ci --ignore-scripts
npm run dev

模板开发:

cd E:/demo/new-site/plugin-template
npm ci
npm run dev

模板构建、测试与打包应在停止同目录 dev 后运行:

npm run build
npm test
npm run test:e2e
npm run pack

pack 需要已配置的受信 Ed25519 签名密钥。示例的私钥路径属于本机演示,不应变成第三方获取宿主私钥的生产流程。真实宿主验收会安装并启停测试插件,运行前了解目标环境与测试数据范围:

npm run test:host -- --host http://127.0.0.1:4310

【根据项目结构推断,需在目标环境确认】

仅运行某一组模板测试可使用 node:test 的筛选方式,例如:

node --test --test-name-pattern "你的测试名称" tests/template.test.mjs

测试名称以当前文件为准;该命令本轮未执行。后端调试使用仓库声明的 npm run dev:debug,配套 .vscode/launch.json。Inspector 和 source map 有历史记录;本轮未操作 IDE 断点。

最小排查路径

  1. 启动失败:先读 terminal,检查 Node 版本、锁文件、Vite 端口和项目锁。端口占用先找进程归属,或在副本配置不同 DEV_PORT,不盲目结束其他服务。
  2. 包安装失败:检查 Manifest 版本、入口、文件大小、摘要、公钥、签名与同版本不可覆盖约束。
  3. 启用失败:按 require → 工厂 → Context → Runtime → start → prepareOperations 顺序找第一处失败。
  4. 页面不见:检查 ENABLED、当前用户业务权限、provider、ESM 导出、根路径冲突和 CSS/chunk。
  5. API 失败:检查会话、Origin/CSRF、digest、操作 ID、权限和 Schema,再进入 Service。
  6. 停用异常:先看 pending,再看 disposer/close 错误;RESTART_REQUIRED 不能靠手工改状态字段消除。

宿主日志在平台内存记录与终端,不存在本文凭空假定的永久日志目录;测试日志与截图在对应 test-results 中。开发业务数据通常位于 .local/runtime/business,生产 Demo 在 .data/business。环境与签名设置见 模板说明。

10. 渐进式实战项目

实践一:运行并观察一条新增待办链

背景是验证自己理解的是运行机制。目标是用 Network 和断点找到前后端连接。涉及 Preview、Pinia、Context、Controller、Platform 与 Service。

实施时在独立副本执行仓库开发命令,创建一条带唯一标题的记录,逐步跟进 notes-create。记录 provider 的 digest/activationId、请求的 operation、后端 permission 与存储 key。测试读取与只读角色拒绝行为;验收是能够把一条响应解释到写入位置。注意开发代理窗口可能短暂 503。恢复时只删除自己创建的记录。进一步挑战是说明通知失败为何不应让成功写入变成失败响应。

实践二:增加待办标题长度提示

背景是前端已有表单与后端最大 100 字符的约束。目标是修改低风险 UI,不改变协议。修改 App.vue,增加输入长度提示或与 Schema 一致的 maxlength;保持 Store 操作名不变。

先阅读后端 Schema,再修改页面与样式,运行 check,开发中验证 HMR 与草稿保留;同时确认超限伪造请求仍由后端拒绝。验收是正常请求行为不变,提示与实际校验一致。撤销 UI diff 即可恢复。进一步挑战是减少前后端约束重复,但不要假定共享 TypeScript 类型能替代运行时校验。

实践三:定位一个操作名不匹配故障

背景是前端调用 notes-toggle、后端按 ID 注册处理器。目标是从 404 定位协议不匹配。在实验分支只把前端操作名改成一个不存在的值,不改宿主。

观察 Network,检查插件是否 ENABLED,再核对前端 operation、后端 Map 与 Runtime 注册,最后恢复正确操作名。运行 check 并验证切换状态后持久化正确。验收是能指出准确错配位置,不通过新增一个宿主专用 Controller 规避问题。恢复前端原始字节。进一步挑战是在测试中加入“未知操作被拒绝且数据不变”的断言。

实践四:独立实现一个报销管理插件

背景是证明平台能接入新业务。目标是在宿主源码不变的前提下实现一套新的后端操作与 Vue 页面。先复制模板到独立目录,初始化 ID 为 expense、名称为“报销管理”,配置 Manifest 权限与根路径 /expense。

数据模型采用 id/title/amount/status/createdAt,金额建议使用整数分避免浮点累加误差,状态先限定 draft/submitted。后端实现 expenses-list、expenses-create、expenses-submit 三个操作,在 storage.update 回调内检查“只有草稿可提交”,禁止重复提交。把新的权限写入 Manifest,并让 Runtime 注册对应操作。事件使用 expense. 命名空间;暂不设计跨插件财务事务。

前端用 App.vue 和 Pinia 展示列表、新建表单与提交按钮,通过 ctx.invoke 调用这三个操作;不要给宿主增加 expense import、菜单分支或专用接口。测试覆盖金额输入、状态转换、重复提交、只读角色和停用后的拒绝。

停止同目录 dev 后执行类型检查、测试与 pack。签名由受信发布流程完成,然后在测试宿主上传、安装、启用、创建并提交记录,停用、再启用,确认数据保留和前端撤销。验收同时核对宿主源码 diff 为零、操作与资源归属为 expense、另一插件继续可用。可能踩坑包括残留 notes-* 操作、Manifest 权限不一致、根路径冲突、开发密钥不受信与同版本覆盖。恢复使用测试宿主正常停用该插件,保留业务数据;不要为恢复实验删除客户数据。进一步挑战是增加撤回状态和兼容旧文档的迁移方案。

本实践是扩展示例设计,尚未在仓库实现。它使用已经存在的扩展契约,没有把建议功能写成已完成事实。

11. 二次开发指南

新增普通业务优先放在独立模板:Manifest 定义身份、入口、权限与能力;后端 service 管规则,Runtime 负责注册;前端 SFC/Store 管页面与请求。可以先保留示例操作名理解链路,真正发布新业务时应统一业务命名。

需求 插件自己能完成吗 宿主需要什么变化
新增 CRM、订单、报销 CRUD 在现有 SDK/包大小/页面约束内可以 不需要登记业务名称与菜单
新表单、列表、图表、内部组件 可以,依赖随插件构建 共享依赖需遵守当前版本契约
使用现有 storage/notifications/timers 可以,声明 capability 无需新增具体业务接口
插件内多个视图 可用组件状态或 memory router 真实 URL 深链、参数路由需要通用协议升级
真正纯前端插件 当前不支持完整协议 放宽后端入口/至少一个 operation 与独立 UI 授权规则
文件上传、数据库事务、新公共能力 当前 SDK 未提供的不能假装已有 增加版本化 SDK 能力、权限、实现和生命周期测试
陌生第三方安全执行 同进程机制无法提供强隔离 进程/容器隔离、受控 RPC 与运行治理

“后续只上传包”的成立条件是业务使用已经稳定的通用能力。平台能力不足时可以做一次通用升级;不应每来一个业务就给宿主增加特殊分支。

保持兼容的做法是版本化 Host API 与 SDK,优先新增可选字段和能力,保留旧操作约定,明确 Manifest 最低版本与共享依赖范围。不要让插件依赖 PluginPlatform 内部字段、宿主的 Map、业务文件绝对路径或未公开的 Nest Provider。

提交前检查:Manifest 与操作权限一致;Schema 覆盖不可信输入;资源走受管 API;前端清理监听;旧异步结果失效;产物路径和 chunk 正确;私钥与环境文件不入包;启停不改其他插件;升级与数据迁移有明确策略。业务规则测试补在插件测试,协议和清理门禁测试补在平台,实际资源与页面接入补浏览器验收。

12. 风险区域与设计取舍

风险 证据等级与依据 影响与取舍
同进程第三方执行 【已验证】require;影响为【合理推断】 可绕过 SDK 访问 Node 能力;CPU 阻塞、进程退出、内存泄漏会影响宿主
同页 UI 与样式 【已验证】import 与 CSS 独立 app/Pinia 不隔离 DOM、同源能力或全局 CSS
平台职责集中 【已验证】平台类 修改加载、授权和清理容易互相影响;后续可按职责拆内部模块但保留所有权
注册的隐式约束 【已验证】prepareOperations 注册阶段、操作唯一、权限声明、至少一个操作都影响可用性
模块缓存与全局状态 【已验证】loadedReleases context.close 不保证模块字节、静态引用与全部资源释放
开始执行后的回滚 【已验证】enable catch 入口可撤销,已经完成的业务写入不能自动事务回滚
持久化与并发 【已验证】串行存储与原子替换 适合当前单进程文档,不能直接扩为多实例或跨文档事务
事件与通知可靠性 【已验证】业务发布 内存订阅与通知无宕机重放,可靠交付需 Outbox 等设计
授权仍为演示 【已验证】SessionStore viewer 按 read 后缀派生权限;生产需独立 RBAC 存储,声明权限不等于用户获批
静态资源授权差异 【已验证】资源 Controller provider 元数据按用户过滤,资源接口没有同等 SessionGuard,不能误称所有 JS 按业务权限鉴权
UI disposer 长时间等待 【已验证】await disposer,阻塞影响为【合理推断】 当前没有该处超时,可使 UI 同步链停滞,需要限时和故障恢复设计
跨平台与容量 【待确认】本轮未做兼容/负载试验 不把 Windows 历史验收扩大成所有平台与任意业务规模保证

新手不宜首先修改验签规范化、入口路径检查、stage 门禁、pending 跟踪和清理顺序。这些位置决定第三方字节是否执行、请求是否越权以及平台是否误判停止成功。

13. 最终源码阅读清单

14. 掌握程度检查

初级:能够使用

独立启动模板预览;找到 Manifest、Vue 入口和后端工厂;成功使用一个已有操作;解释上传格式;能区分安装与启用。可观察产出是一张自己画的模块图和一次带 Network 记录的操作说明。

中级:能够解释

不看本文,沿着真实符号讲完一次 enable、新增记录与 disable;解释三个核心对象的输入输出、固定路由与操作 Map、共享 Vue 与独立 Pinia;根据一个 403/404/409 错误定位分支。可观察产出是带文件行号的调用链与一次故障复盘。

高级:能够修改和扩展

独立完成报销或同等复杂度插件,包含状态校验、权限、前端页面、受管资源、签名包与安装启停验收;提交宿主业务源码 diff 为零的证据,说明何时必须升级平台协议。还应能解释 RESTART_REQUIRED、业务数据兼容及陌生第三方安全执行所需的额外边界。

15. 自测题参考答案

章节 概念题 架构题 定位题 调试题 设计题
第 1 章 编码传字节、摘要识别内容、签名核对受信来源与完整性 安装未创建 Context,不能接收业务流量 SDK 包类型、pack 与 validatePackage 核对公钥是否相同、签名载荷、keyId 与包修改 发布者身份、公钥注册/吊销、审核签名、权限和审计
第 2 章 定义是元数据,Context 是对象图实例 PluginPlatform 调工厂/Nest/start 并控制发布 工厂 HOST_SDK useValue provider 看 STAGING/ENABLED、prepareOperations、provider 权限与 UI import 限制稳定 SDK 表面,避免插件耦合内部容器及越权能力
第 3 章 网络请求不受 TS 编译类型保护 Controller 接 HTTP,Map 找操作,Service 执行业务 track 与 invoke 的 pending lease 修后端权限/输入边界,前端隐藏只是展示 定义事务所有权与跨业务协议,可能需要数据库/可靠事件
第 4 章 同一框架实现可创建不同应用和 Store 容器 包装组件把宿主贡献协议转换成普通 Vue 生命周期 todos.ts 的 HMR 与 dev.mjs 的后端 watcher 检查 Vite 相对 chunk、包清单、资源 URL 与启用状态 升级通用路由协议并做兼容,不逐业务改宿主
第 5 章 阻止新工作后才可能收敛在途集合 浏览器独立执行,需通过 provider 同步 drain 成功后的 cleanup disposer 失败仍尝试 close;drain 超时不进入关闭 设置限时、故障状态与恢复路径,同时承认超时不终止原代码

评分重点是能否给出源码位置、前置条件与失败后果,而不是记住术语。

16. 未确认事项与后续调查清单

  1. 本轮运行覆盖:只重新执行了类型检查;如需要确认当前环境端到端状态,应在隔离副本重新执行 build/test/e2e/pack 和真实宿主验收,保存新报告。
  2. 第三方治理:当前没有多发布者信任库、密钥撤销、审核任务、许可证和商业结算。需要单独明确产品流程与安全责任。
  3. 强隔离执行:若要求陌生第三方上传即可安全运行,应验证子进程或容器、最小权限、配额、网络与文件访问治理及 RPC 取消语义。
  4. 数据与升级:没有通用 schema 迁移、跨文档事务和自动业务回滚;生产数据库与新旧插件兼容需要专门设计。
  5. 路由与 UI:多根路由、参数深链、纯前端插件、全局组件槽位、严格样式隔离与前端清理超时都需要平台协议演进。
  6. 性能与可观测性:没有本轮负载数据;需要测 JSON 文档规模、长操作、后台任务压力与多插件争用,并设计持久日志和监控指标。
  7. 跨平台:历史主要在 Windows 与本机 Chrome 验收;Linux/macOS、容器部署和反向代理 Origin/CSRF 行为应独立验证。
  8. Halo 的设计演进:本文只对固定提交源码做机制对照;若要解释某次设计选择的历史原因,应进一步查对应 Git 历史、Issue 与 PR,不从文件名推断作者意图。

理解整套方案后,最有价值的第一次二次开发是:在不改变宿主业务源码的条件下,完成一个带明确状态转换、权限与清理契约的新插件,并能解释它从文件变成对象、从对象变成接口、再变成页面的每一个步骤。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或者给予支持!