跳转到内容

带后端的插件:完整骨架

从 static 迈向 node 的那一步,最需要的是一份完整可跑的最小工程 —— 本篇就是。三个文件,抄下来改名字就能跑。

dev.notes/
├── plugin.json
├── package.json
├── src/server.ts # 后端进程
└── ui/index.html # 面板

后端是普通 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 写法。

{
"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" }
]
}
}

后端就是一个普通 HTTP 服务。宿主与它的全部约定是四个环境变量加一个请求头:

约定含义
process.env.PORT监听端口(宿主分配,必须用它,不能自己挑)
process.env.CAPABILITY_BASE_URLcapability 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 请求自己的后端用相对路径: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 源码也行,但发给别人前必须构建。

  • 进程由宿主管理:加载时 fork、卸载/重载时结束。别在进程里做”退出前必须完成”的事 —— 状态要么进 cap.storage,要么进表。
  • 崩溃即日志:进程挂掉宿主会记日志;/healthz 是最便宜的自检面。
  • 一个插件一个进程:内存里的东西是这个插件私有的,跨插件通信走贡献点(contributions),不要试图共享进程状态。