带后端的插件:完整骨架
从 static 迈向 node 的那一步,最需要的是一份完整可跑的最小工程 —— 本篇就是。三个文件,抄下来改名字就能跑。
dev.notes/├── plugin.json├── package.json├── src/server.ts # 后端进程└── ui/index.html # 面板package.json
Section titled “package.json”后端是普通 node 工程,依赖自己装(在插件目录里 pnpm install / npm install):
{ "name": "dev.notes", "private": true, "type": "module", "dependencies": { "@koa/router": "^13.0.0", "@xinghan/plugin-sdk": "workspace:*", "koa": "^2.15.0", "koa-body": "^6.0.1" }}在仓库源码里开发时 SDK 用
workspace:*;独立目录开发时改成发布版本号。"type": "module"让 tsx/node 按 ESM 解析 —— 示例代码全是 ESM 写法。
plugin.json
Section titled “plugin.json”{ "id": "dev.notes", "name": "速记", "version": "0.1.0", "runtime": "node", "entry": "dist/server.js", "devEntry": "src/server.ts", "capabilities": ["storage"], "contributes": { "views": [ { "id": "panel", "title": "速记", "icon": "message", "location": "right-drawer", "entry": "ui/index.html" } ] }}src/server.ts
Section titled “src/server.ts”后端就是一个普通 HTTP 服务。宿主与它的全部约定是四个环境变量加一个请求头:
| 约定 | 含义 |
|---|---|
process.env.PORT | 监听端口(宿主分配,必须用它,不能自己挑) |
process.env.CAPABILITY_BASE_URL | capability HTTP API 的地址 |
process.env.CAPABILITY_TOKEN | 本进程的能力令牌(构造 CapabilityClient 用) |
process.env.PLUGIN_ID | 你的插件 id |
请求头 X-Actor | 宿主反代每个请求时注入的用户身份 —— 透传进 ctx,别造假 |
import Koa from 'koa'import Router from '@koa/router'import { koaBody } from 'koa-body'import { CapabilityClient } from '@xinghan/plugin-sdk/server'
const cap = new CapabilityClient({ baseUrl: process.env.CAPABILITY_BASE_URL, token: process.env.CAPABILITY_TOKEN,})
const app = new Koa()const router = new Router()
// 健康检查:**必须提供** —— 宿主 15 秒内探不到 /healthz 就按启动失败杀进程router.get('/healthz', (ctx) => { ctx.body = { ok: true, pluginId: process.env.PLUGIN_ID }})
// 业务端点:读/写一条笔记(演示 storage capability)router.get('/api/note', async (ctx) => { const actor = (ctx.headers['x-actor'] as string) ?? 'anonymous' const note = await cap.storage.get<string>('note', { actor }) ctx.body = { ok: true, data: note ?? '' }})
router.post('/api/note', async (ctx) => { const actor = (ctx.headers['x-actor'] as string) ?? 'anonymous' const { text } = ctx.request.body as { text?: string } await cap.storage.set('note', text ?? '', { actor }) ctx.body = { ok: true }})
app.use(koaBody())app.use(router.routes())
const port = Number(process.env.PORT ?? 0)app.listen(port, '127.0.0.1', () => { console.log(`[dev.notes] listening on ${port}`)})框架随意(示例用 koa,与内置插件一致);只绑 127.0.0.1 —— 流量都从宿主反代进来,不需要也不应该对外监听。
ui/index.html
Section titled “ui/index.html”UI 请求自己的后端用相对路径:iframe 的 URL 在 /plugins/dev.notes/ui/ 下,../api/… 恰好落到反代路径上,零配置。
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" /></head><body> <textarea id="note" rows="8" style="width:100%"></textarea> <button id="save">保存</button> <script type="module"> const noteEl = document.getElementById('note') const r = await fetch('../api/note') noteEl.value = (await r.json()).data
document.getElementById('save').onclick = async () => { await fetch('../api/note', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: noteEl.value }), }) } </script></body></html>放进 ~/xinghan/plugins/dev.notes/,装依赖(koa / @koa/router / koa-body / @xinghan/plugin-sdk),重启应用。
⚠️ entry 与 devEntry 的分工是硬约束:只有源码开发模式(dev)宿主才用 tsx 跑
devEntry的 TS 源码;打包发行的应用用 node 直接跑entry,.ts入口会加载失败 → healthz 15 秒超时 → 插件被杀。所以entry必须指向构建出的 JS(tsc/esbuild出dist/server.js);纯给自己本机 dev 用时,两个都指 TS 源码也行,但发给别人前必须构建。
生命周期须知
Section titled “生命周期须知”- 进程由宿主管理:加载时 fork、卸载/重载时结束。别在进程里做”退出前必须完成”的事 —— 状态要么进
cap.storage,要么进表。 - 崩溃即日志:进程挂掉宿主会记日志;
/healthz是最便宜的自检面。 - 一个插件一个进程:内存里的东西是这个插件私有的,跨插件通信走贡献点(
contributions),不要试图共享进程状态。
- 数据读写与治理 → 读写表格数据
- 把入口放进右键菜单 → 右键菜单与命令
- 全部能力面 → Capability API 总览