@capyclaw/ppt-workbench 0.0.0-stage → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +126 -3
- package/THIRD_PARTY_NOTICES.md +21 -0
- package/contract/ppt-contract.json +1529 -0
- package/cordis.patch.yml +25 -0
- package/core/audit.js +500 -0
- package/core/chart.js +199 -0
- package/core/color.js +80 -0
- package/core/deck-layout.js +195 -0
- package/core/formula.js +80 -0
- package/core/hfmath/hershey.js +1763 -0
- package/core/hfmath/index.js +1176 -0
- package/core/hfmath/stretch.js +41 -0
- package/core/hfmath/symbols.js +408 -0
- package/core/ops/browser.js +28 -0
- package/core/ops/chart.js +64 -0
- package/core/ops/deck.js +21 -0
- package/core/ops/dispatcher.js +2 -0
- package/core/ops/element.js +55 -0
- package/core/ops/history.js +11 -0
- package/core/ops/import.js +95 -0
- package/core/ops/index.js +39 -0
- package/core/ops/playback.js +9 -0
- package/core/ops/recolor.js +33 -0
- package/core/ops/slide.js +66 -0
- package/core/ops/table.js +85 -0
- package/core/ops/text.js +35 -0
- package/core/ops/view.js +13 -0
- package/core/ops.js +12 -0
- package/core/quick.js +52 -0
- package/core/shape-library.js +122 -0
- package/core/table.js +178 -0
- package/core/text.js +200 -0
- package/editor/app.js +5366 -0
- package/editor/heartbeat-worker.js +116 -0
- package/editor/index.html +89 -0
- package/editor/ppt-full-app.js +376 -0
- package/lib/host.mjs +98 -0
- package/package.json +51 -4
- package/server/audit.mjs +2 -0
- package/server/bridge-client.mjs +174 -0
- package/server/constants.mjs +20 -0
- package/server/contract-snapshots.mjs +62 -0
- package/server/contract.mjs +24 -0
- package/server/deck-layout.mjs +2 -0
- package/server/http-utils.mjs +2 -0
- package/server/infra/http.mjs +113 -0
- package/server/infra/runtime/context.mjs +128 -0
- package/server/infra/store/blob-store.mjs +109 -0
- package/server/infra/tuning.mjs +82 -0
- package/server/infra/util/fs-lock.mjs +132 -0
- package/server/infra/workspace/manager.mjs +657 -0
- package/server/mcp/dispatch.mjs +2 -0
- package/server/mcp/handler.mjs +2 -0
- package/server/mcp/instructions.mjs +40 -0
- package/server/mcp-http.mjs +3 -0
- package/server/ppt-shim.mjs +156 -0
- package/server/relay.mjs +245 -0
- package/server/routes/assets.mjs +2 -0
- package/server/routes/bridge.mjs +47 -0
- package/server/routes/editor.mjs +9 -0
- package/server/routes/import.mjs +258 -0
- package/server/routes/mcp.mjs +17 -0
- package/server/runtime/recovery.mjs +9 -0
- package/server/runtime/watch.mjs +17 -0
- package/server/shared/SHARED_MANIFEST.json +47 -0
- package/server/shared/asset-service.mjs +217 -0
- package/server/shared/editor-host.mjs +125 -0
- package/server/shared/kit/editor/lease.mjs +139 -0
- package/server/shared/kit/service/api.mjs +45 -0
- package/server/shared/kit/service/http.mjs +113 -0
- package/server/shared/kit/service/mcp-http.mjs +142 -0
- package/server/shared/kit/service/single-instance.mjs +72 -0
- package/server/shared/kit/tools/validate.mjs +115 -0
- package/server/shared/kit/tuning.mjs +82 -0
- package/server/shared/kit/util/fs-lock.mjs +132 -0
- package/server/shared/service-host.mjs +511 -0
- package/server/shared/state-service.mjs +249 -0
- package/server/shared/tool-host.mjs +341 -0
- package/server/shared/tuning.mjs +63 -0
- package/server/single-instance.mjs +2 -0
- package/server/store/backends/file-backend.mjs +13 -0
- package/server/store/checkpoints.mjs +28 -0
- package/server/store/doc.mjs +52 -0
- package/server/store/index.mjs +34 -0
- package/server/store/journal.mjs +9 -0
- package/server/store/lease.mjs +2 -0
- package/server/store/sanitize.mjs +32 -0
- package/server/store.mjs +2 -0
- package/server/tools/capabilities/apply.mjs +33 -0
- package/server/tools/capabilities/audit.mjs +24 -0
- package/server/tools/capabilities/chart.mjs +83 -0
- package/server/tools/capabilities/compose.mjs +97 -0
- package/server/tools/capabilities/contract.mjs +34 -0
- package/server/tools/capabilities/deck.mjs +27 -0
- package/server/tools/capabilities/element.mjs +67 -0
- package/server/tools/capabilities/export.mjs +95 -0
- package/server/tools/capabilities/formula.mjs +72 -0
- package/server/tools/capabilities/history.mjs +29 -0
- package/server/tools/capabilities/image.mjs +152 -0
- package/server/tools/capabilities/imagesearch.mjs +152 -0
- package/server/tools/capabilities/import.mjs +180 -0
- package/server/tools/capabilities/index.mjs +58 -0
- package/server/tools/capabilities/line.mjs +58 -0
- package/server/tools/capabilities/notes.mjs +33 -0
- package/server/tools/capabilities/present.mjs +34 -0
- package/server/tools/capabilities/recolor.mjs +60 -0
- package/server/tools/capabilities/screenshot.mjs +86 -0
- package/server/tools/capabilities/shape.mjs +64 -0
- package/server/tools/capabilities/slide.mjs +81 -0
- package/server/tools/capabilities/style.mjs +48 -0
- package/server/tools/capabilities/table.mjs +127 -0
- package/server/tools/capabilities/task.mjs +52 -0
- package/server/tools/capabilities/text.mjs +82 -0
- package/server/tools/domains/color.mjs +4 -0
- package/server/tools/domains/image.mjs +49 -0
- package/server/tools/domains/index.mjs +7 -0
- package/server/tools/domains/pptx-export.mjs +566 -0
- package/server/tools/domains/recolor.mjs +25 -0
- package/server/tools/domains/remote-image.mjs +291 -0
- package/server/tools/domains/slide.mjs +21 -0
- package/server/tools/domains/text-style.mjs +11 -0
- package/server/tools/domains/tooling.mjs +13 -0
- package/server/tools/index.mjs +14 -0
- package/server/tools/kernel/define.mjs +5 -0
- package/server/tools/kernel/ports.mjs +42 -0
- package/server/tools/kernel/registry.mjs +17 -0
- package/server/tools.mjs +18 -0
- package/server/tuning.mjs +86 -0
- package/server/validate.mjs +2 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-editor-host —— 通用浏览器编辑器宿主。
|
|
3
|
+
*
|
|
4
|
+
* 功能插件只提供 editor/ 静态目录,不再自己写:
|
|
5
|
+
* - 静态文件路由 / content-type / 404
|
|
6
|
+
* - “显示即连接”的租约仲裁(后打开者抢占,旧窗口静默待命)
|
|
7
|
+
* - claim / release / status / heartbeat 端点
|
|
8
|
+
* - 连接状态供 /healthz 使用
|
|
9
|
+
*
|
|
10
|
+
* 浏览器端可直接使用 dsh-plugin-kit 的 editor/lease.mjs(无 Node 依赖)。
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { createEditorLease } from './kit/editor/lease.mjs'
|
|
15
|
+
import { send, readBody } from './kit/service/http.mjs'
|
|
16
|
+
import { createServiceApi } from './kit/service/api.mjs'
|
|
17
|
+
|
|
18
|
+
const MIME = {
|
|
19
|
+
'.html': 'text/html; charset=utf-8',
|
|
20
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
21
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
22
|
+
'.css': 'text/css; charset=utf-8',
|
|
23
|
+
'.json': 'application/json; charset=utf-8',
|
|
24
|
+
'.svg': 'image/svg+xml',
|
|
25
|
+
'.png': 'image/png',
|
|
26
|
+
'.jpg': 'image/jpeg',
|
|
27
|
+
'.jpeg': 'image/jpeg',
|
|
28
|
+
'.webp': 'image/webp',
|
|
29
|
+
'.woff2': 'font/woff2',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function safeJoin(root, rel) {
|
|
33
|
+
const target = path.resolve(root, `.${path.sep}${rel}`)
|
|
34
|
+
const base = path.resolve(root)
|
|
35
|
+
return target === base || target.startsWith(base + path.sep) ? target : null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function contentType(file) {
|
|
39
|
+
return MIME[path.extname(file).toLowerCase()] || 'application/octet-stream'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {{ id: string, dir: string, basePath?: string, index?: string, files?: Record<string,string|string[]> }} options
|
|
44
|
+
*/
|
|
45
|
+
export function createEditorHost({ id, dir, basePath = '', index = 'index.html', files = null } = {}) {
|
|
46
|
+
if (!id) throw new Error('createEditorHost: id 必填')
|
|
47
|
+
const prefix = basePath === '/' ? '' : String(basePath || '').replace(/\/+$/, '')
|
|
48
|
+
const lease = createEditorLease()
|
|
49
|
+
|
|
50
|
+
const staticRoutes = async function editorStatic(req, res, u) {
|
|
51
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return false
|
|
52
|
+
if (prefix && u.pathname !== prefix && !u.pathname.startsWith(prefix + '/')) return false
|
|
53
|
+
const relRaw = prefix ? u.pathname.slice(prefix.length) : u.pathname
|
|
54
|
+
const rel = relRaw === '' || relRaw === '/' ? index : decodeURIComponent(relRaw.replace(/^\/+/, ''))
|
|
55
|
+
let file = null
|
|
56
|
+
if (files) {
|
|
57
|
+
const hit = files[relRaw === '' ? '/' : relRaw] || files[relRaw]
|
|
58
|
+
if (!hit) return false
|
|
59
|
+
const name = Array.isArray(hit) ? hit[0] : hit
|
|
60
|
+
file = safeJoin(dir, name)
|
|
61
|
+
} else {
|
|
62
|
+
file = safeJoin(dir, rel)
|
|
63
|
+
}
|
|
64
|
+
if (!file) return false
|
|
65
|
+
try {
|
|
66
|
+
const data = fs.readFileSync(file)
|
|
67
|
+
res.writeHead(200, {
|
|
68
|
+
'content-type': contentType(file),
|
|
69
|
+
'content-length': data.length,
|
|
70
|
+
'x-content-type-options': 'nosniff',
|
|
71
|
+
'referrer-policy': 'no-referrer',
|
|
72
|
+
'cache-control': 'no-cache',
|
|
73
|
+
})
|
|
74
|
+
res.end(req.method === 'HEAD' ? undefined : data)
|
|
75
|
+
return true
|
|
76
|
+
} catch {
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const leaseRoutes = async function editorLeaseRoutes(req, res, u) {
|
|
82
|
+
const claimPath = `${prefix}/claim`
|
|
83
|
+
const releasePath = `${prefix}/release`
|
|
84
|
+
const statusPath = `${prefix}/status`
|
|
85
|
+
if (u.pathname === claimPath && req.method === 'POST') {
|
|
86
|
+
const body = await readBody(req)
|
|
87
|
+
const result = lease.claim(String(body.id || ''), !!body.force)
|
|
88
|
+
send(req, res, result.ok ? 200 : 409, result)
|
|
89
|
+
return true
|
|
90
|
+
}
|
|
91
|
+
if (u.pathname === releasePath && req.method === 'POST') {
|
|
92
|
+
const body = await readBody(req)
|
|
93
|
+
send(req, res, 200, lease.release(String(body.id || '')))
|
|
94
|
+
return true
|
|
95
|
+
}
|
|
96
|
+
if (u.pathname === statusPath && req.method === 'GET') {
|
|
97
|
+
send(req, res, 200, { ok: true, connected: lease.isLive(), lease: lease.status() })
|
|
98
|
+
return true
|
|
99
|
+
}
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
id,
|
|
105
|
+
lease,
|
|
106
|
+
routes() {
|
|
107
|
+
return [staticRoutes, leaseRoutes]
|
|
108
|
+
},
|
|
109
|
+
staticRoutes,
|
|
110
|
+
leaseRoutes,
|
|
111
|
+
status() {
|
|
112
|
+
return { connected: lease.isLive(), lease: lease.status() }
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function createEditorHostService(config = {}) {
|
|
118
|
+
return {
|
|
119
|
+
api: createServiceApi('editorHost', '1.0.0', ['static', 'lease', 'heartbeat', 'status']),
|
|
120
|
+
version: '0.1.0',
|
|
121
|
+
createEditorHost,
|
|
122
|
+
createLease: createEditorLease,
|
|
123
|
+
defaultHost: config.dir ? createEditorHost({ id: config.id || 'editor', ...config }) : null,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「显示即连接」编辑器租约标准模块。
|
|
3
|
+
*
|
|
4
|
+
* 活动权(claim)只存在于内部仲裁;不要把它渲染成 UI 状态。
|
|
5
|
+
* 新打开的窗口强制抢占,旧窗口静默转入待命。
|
|
6
|
+
*/
|
|
7
|
+
import { tuning } from '../tuning.mjs'
|
|
8
|
+
|
|
9
|
+
const DEFAULT_TTL = 15 // 秒
|
|
10
|
+
const DEFAULT_HEARTBEAT = 3 // 秒(心跳间隔应小于 TTL)
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 创建编辑器租约仲裁器。
|
|
14
|
+
* @param {{ ttl?: number }} [opts]
|
|
15
|
+
*/
|
|
16
|
+
export function createEditorLease(opts = {}) {
|
|
17
|
+
const ttl = Number(opts.ttl) || DEFAULT_TTL
|
|
18
|
+
let owner = null // { id, ts }
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
claim(id, force = false) {
|
|
22
|
+
const now = Date.now() / 1000
|
|
23
|
+
if (force || !owner || owner.id === id || now - owner.ts > ttl) {
|
|
24
|
+
owner = { id, ts: now }
|
|
25
|
+
return { ok: true, owner: id }
|
|
26
|
+
}
|
|
27
|
+
return { ok: false, owner: owner.id }
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
release(id) {
|
|
31
|
+
if (owner && owner.id === id) owner = null
|
|
32
|
+
return { ok: true }
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
isLive() {
|
|
36
|
+
return !!owner && Date.now() / 1000 - owner.ts <= ttl
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
status() {
|
|
40
|
+
return owner
|
|
41
|
+
? { owner: owner.id, age: Math.round((Date.now() / 1000 - owner.ts) * 100) / 100 }
|
|
42
|
+
: null
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
async wait(o = {}) {
|
|
46
|
+
const { leaseWaitPollMs: pollMs, leaseWaitTimeoutMs: defaultTimeoutMs } = tuning()
|
|
47
|
+
const deadline = Date.now() + (o.timeoutMs || defaultTimeoutMs)
|
|
48
|
+
while (true) {
|
|
49
|
+
if (this.isLive()) return this.status()
|
|
50
|
+
if (Date.now() >= deadline) {
|
|
51
|
+
const hint = o.openHint ? `(打开 ${o.openHint})` : ''
|
|
52
|
+
const panel = o.panelHint || '编辑器面板'
|
|
53
|
+
throw new Error(`${panel}尚未连接${hint}。页面加载后会自动连接,无需其他操作。`)
|
|
54
|
+
}
|
|
55
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs))
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 编辑器端自动挂接循环。
|
|
63
|
+
* 首次 claim 强制抢占(后打开者赢),之后静默心跳。
|
|
64
|
+
* @param {{
|
|
65
|
+
* clientId: string,
|
|
66
|
+
* claim: (force: boolean) => Promise<boolean>,
|
|
67
|
+
* release?: () => void,
|
|
68
|
+
* onActive?: () => void,
|
|
69
|
+
* onStandby?: () => void,
|
|
70
|
+
* retryMs?: number,
|
|
71
|
+
* heartbeatMs?: number,
|
|
72
|
+
* setInterval?: (cb: () => void) => any,
|
|
73
|
+
* clearInterval?: (handle: any) => void
|
|
74
|
+
* }} o
|
|
75
|
+
*/
|
|
76
|
+
export function startAutoAttach(o) {
|
|
77
|
+
if (!o || !o.clientId) throw new Error('startAutoAttach: clientId 必填')
|
|
78
|
+
if (typeof o.claim !== 'function') throw new Error('startAutoAttach: claim 必须是函数')
|
|
79
|
+
|
|
80
|
+
const retryMs = o.retryMs || 1000
|
|
81
|
+
const heartbeatMs = o.heartbeatMs || DEFAULT_HEARTBEAT * 1000
|
|
82
|
+
const setI = o.setInterval || ((cb) => setInterval(cb, heartbeatMs))
|
|
83
|
+
const clearI = o.clearInterval || ((handle) => clearInterval(handle))
|
|
84
|
+
|
|
85
|
+
let active = false
|
|
86
|
+
let heartbeat = null
|
|
87
|
+
let disposed = false
|
|
88
|
+
|
|
89
|
+
async function tryClaim(force) {
|
|
90
|
+
try {
|
|
91
|
+
return await o.claim(force)
|
|
92
|
+
} catch {
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function goActive() {
|
|
98
|
+
if (active) return
|
|
99
|
+
active = true
|
|
100
|
+
try { o.onActive && o.onActive() } catch { /* 用户回调失败不阻塞挂接 */ }
|
|
101
|
+
heartbeat = setI(async () => {
|
|
102
|
+
if (disposed) return
|
|
103
|
+
const ok = await tryClaim(false)
|
|
104
|
+
if (!ok) goStandby()
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function goStandby() {
|
|
109
|
+
if (!active) return
|
|
110
|
+
active = false
|
|
111
|
+
if (heartbeat != null) {
|
|
112
|
+
clearI(heartbeat)
|
|
113
|
+
heartbeat = null
|
|
114
|
+
}
|
|
115
|
+
try { o.onStandby && o.onStandby() } catch { /* 静默 */ }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
;(async () => {
|
|
119
|
+
let first = true
|
|
120
|
+
while (!disposed) {
|
|
121
|
+
const ok = await tryClaim(first)
|
|
122
|
+
first = false
|
|
123
|
+
if (ok) {
|
|
124
|
+
goActive()
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
await new Promise((resolve) => setTimeout(resolve, retryMs))
|
|
128
|
+
}
|
|
129
|
+
})()
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
get active() { return active },
|
|
133
|
+
dispose() {
|
|
134
|
+
disposed = true
|
|
135
|
+
goStandby()
|
|
136
|
+
try { o.release && o.release() } catch { /* 释放失败不阻塞退出 */ }
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** 公共服务 API 契约:插件之间只通过这份描述判断兼容性。 */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {string} name 服务名(ctx.<name>)
|
|
5
|
+
* @param {string} version API 版本(semver;major 变化 = 不兼容)
|
|
6
|
+
* @param {string[]} capabilities 能力标签
|
|
7
|
+
*/
|
|
8
|
+
export function createServiceApi(name, version = '1.0.0', capabilities = []) {
|
|
9
|
+
if (!name) throw new Error('createServiceApi 需要 name')
|
|
10
|
+
return Object.freeze({
|
|
11
|
+
name,
|
|
12
|
+
version,
|
|
13
|
+
capabilities: Object.freeze([...capabilities]),
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 断言一个已注入的服务对象满足消费者要求。
|
|
19
|
+
* 缺失服务或 major 不一致时抛出明确错误,避免“看起来能跑”的静默不兼容。
|
|
20
|
+
* @param {object} service
|
|
21
|
+
* @param {{ name: string, major?: number|string, version?: string }} expected
|
|
22
|
+
*/
|
|
23
|
+
export function assertServiceApi(service, expected = {}) {
|
|
24
|
+
const name = expected.name
|
|
25
|
+
if (!name) throw new Error('assertServiceApi 需要 expected.name')
|
|
26
|
+
const major = String(expected.major ?? String(expected.version || '1').split('.')[0])
|
|
27
|
+
if (!service) throw new Error(`缺少公共服务: ctx.${name}`)
|
|
28
|
+
const api = service.api
|
|
29
|
+
if (!api || typeof api !== 'object') {
|
|
30
|
+
throw new Error(`服务 ctx.${name} 没有声明 api 契约;请升级提供方插件`)
|
|
31
|
+
}
|
|
32
|
+
if (api.name !== name) {
|
|
33
|
+
throw new Error(`服务 ctx.${name} 的 api.name=${api.name},与消费者期望的 ${name} 不一致`)
|
|
34
|
+
}
|
|
35
|
+
const gotMajor = String(api.version || '').split('.')[0]
|
|
36
|
+
if (gotMajor !== major) {
|
|
37
|
+
throw new Error(`服务 ctx.${name} API major=${gotMajor},消费者要求 major=${major}(提供方版本 ${api.version})`)
|
|
38
|
+
}
|
|
39
|
+
return api
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function serviceApiSummary(service) {
|
|
43
|
+
const api = service?.api
|
|
44
|
+
return api ? { name: api.name, version: api.version, capabilities: [...(api.capabilities || [])] } : null
|
|
45
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** 本机插件 HTTP 传输:安全响应头、body 上限、查询解析、loopback 监听。 */
|
|
2
|
+
import http from 'node:http'
|
|
3
|
+
import { tuning } from '../tuning.mjs'
|
|
4
|
+
|
|
5
|
+
export const SECURITY_HEADERS = {
|
|
6
|
+
'x-content-type-options': 'nosniff',
|
|
7
|
+
'referrer-policy': 'no-referrer',
|
|
8
|
+
'cache-control': 'no-store, no-cache, must-revalidate',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** 客户端已断开时放弃写入:长请求(截图/长轮询)在 handler 完成前客户端超时
|
|
12
|
+
* 断连是常态,写已销毁的 socket 会触发无人监听的 'error' 事件打崩进程。 */
|
|
13
|
+
function resDead(res) {
|
|
14
|
+
return res.destroyed || res.writableEnded
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function send(req, res, code, obj, extraHeaders = {}) {
|
|
18
|
+
if (resDead(res)) return
|
|
19
|
+
const body = obj === undefined ? '' : JSON.stringify(obj)
|
|
20
|
+
try {
|
|
21
|
+
res.writeHead(code, {
|
|
22
|
+
'content-type': 'application/json',
|
|
23
|
+
'content-length': Buffer.byteLength(body),
|
|
24
|
+
...SECURITY_HEADERS,
|
|
25
|
+
...extraHeaders,
|
|
26
|
+
})
|
|
27
|
+
res.end(req.method === 'HEAD' ? undefined : body)
|
|
28
|
+
} catch { /* socket 已死:响应无人接收,静默放弃 */ }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function sendText(req, res, code, text, contentType = 'text/plain; charset=utf-8', extraHeaders = {}) {
|
|
32
|
+
if (resDead(res)) return
|
|
33
|
+
const body = text === undefined || text === null ? '' : String(text)
|
|
34
|
+
try {
|
|
35
|
+
res.writeHead(code, {
|
|
36
|
+
'content-type': contentType,
|
|
37
|
+
'content-length': Buffer.byteLength(body),
|
|
38
|
+
...SECURITY_HEADERS,
|
|
39
|
+
...extraHeaders,
|
|
40
|
+
})
|
|
41
|
+
res.end(req.method === 'HEAD' ? undefined : body)
|
|
42
|
+
} catch { /* socket 已死:响应无人接收,静默放弃 */ }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function bodyError(code, message) {
|
|
46
|
+
const error = new Error(message)
|
|
47
|
+
error.code = code
|
|
48
|
+
return error
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function readBody(req, limit = 10 * 1024 * 1024) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
let size = 0
|
|
54
|
+
const chunks = []
|
|
55
|
+
let settled = false
|
|
56
|
+
const fail = (error) => {
|
|
57
|
+
if (settled) return
|
|
58
|
+
settled = true
|
|
59
|
+
reject(error)
|
|
60
|
+
}
|
|
61
|
+
req.on('data', (chunk) => {
|
|
62
|
+
if (settled) return
|
|
63
|
+
size += chunk.length
|
|
64
|
+
if (size > limit) {
|
|
65
|
+
/* 先让路由回 413;立即 destroy 会让客户端只看到 connection reset。 */
|
|
66
|
+
fail(bodyError('BODY_TOO_LARGE', 'body too large'))
|
|
67
|
+
req.resume()
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
chunks.push(chunk)
|
|
71
|
+
})
|
|
72
|
+
req.on('end', () => {
|
|
73
|
+
if (settled) return
|
|
74
|
+
settled = true
|
|
75
|
+
const raw = Buffer.concat(chunks).toString('utf8')
|
|
76
|
+
if (!raw) return resolve({})
|
|
77
|
+
try { resolve(JSON.parse(raw)) } catch { reject(bodyError('BAD_JSON', 'bad json')) }
|
|
78
|
+
})
|
|
79
|
+
req.on('error', (error) => fail(error))
|
|
80
|
+
/* 客户端中途断连且未触发 error/aborted 时(如 curl -m 超时),close 兜底,
|
|
81
|
+
否则 promise 永远 pending,handler 泄漏。 */
|
|
82
|
+
req.on('close', () => {
|
|
83
|
+
if (!req.readableEnded) fail(bodyError('CLIENT_ABORTED', 'client aborted'))
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function intQ(url, name) {
|
|
89
|
+
const value = parseInt(url.searchParams.get(name) || '0', 10)
|
|
90
|
+
return Number.isFinite(value) && value >= 0 ? value : 0
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function listen(port, opts = {}) {
|
|
94
|
+
const defaults = tuning()
|
|
95
|
+
const {
|
|
96
|
+
host = '127.0.0.1',
|
|
97
|
+
requestTimeoutMs = defaults.requestTimeoutMs,
|
|
98
|
+
headersTimeoutMs = defaults.headersTimeoutMs,
|
|
99
|
+
keepAliveTimeoutMs = defaults.keepAliveTimeoutMs,
|
|
100
|
+
handler,
|
|
101
|
+
} = opts
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
const server = handler ? http.createServer(handler) : http.createServer()
|
|
104
|
+
server.requestTimeout = requestTimeoutMs
|
|
105
|
+
server.headersTimeout = headersTimeoutMs
|
|
106
|
+
server.keepAliveTimeout = keepAliveTimeoutMs
|
|
107
|
+
server.on('clientError', (error, socket) => {
|
|
108
|
+
if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\n\r\n')
|
|
109
|
+
})
|
|
110
|
+
server.on('error', reject)
|
|
111
|
+
server.listen(port, host, () => resolve(server))
|
|
112
|
+
})
|
|
113
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 通用 MCP Streamable HTTP 处理器。
|
|
3
|
+
*
|
|
4
|
+
* 只实现工具面所需的最小协议:initialize / ping / tools/list / tools/call。
|
|
5
|
+
* 所有工具调用在进入业务实现前先经过 validateToolArguments。
|
|
6
|
+
*/
|
|
7
|
+
import { readBody, send } from './http.mjs'
|
|
8
|
+
import { validateToolArguments } from '../tools/validate.mjs'
|
|
9
|
+
|
|
10
|
+
const DEFAULT_PROTOCOL = '2024-11-05'
|
|
11
|
+
|
|
12
|
+
function rpcError(id, code, message, data) {
|
|
13
|
+
const error = { code, message }
|
|
14
|
+
if (data !== undefined) error.data = data
|
|
15
|
+
return { jsonrpc: '2.0', id: id ?? null, error }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function reply(id, result) {
|
|
19
|
+
return { jsonrpc: '2.0', id, result }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createMcpHandler({
|
|
23
|
+
tools = [], callTool, serverInfo, instructions,
|
|
24
|
+
maxBodyBytes = 10 * 1024 * 1024, maxBatch = 100,
|
|
25
|
+
} = {}) {
|
|
26
|
+
if (!Array.isArray(tools)) throw new Error('createMcpHandler: tools 必须是数组')
|
|
27
|
+
if (typeof callTool !== 'function') throw new Error('createMcpHandler: callTool 必须是函数')
|
|
28
|
+
|
|
29
|
+
const toolMap = new Map(tools.map((tool) => [tool.name, tool]))
|
|
30
|
+
|
|
31
|
+
return async function handle(req, res) {
|
|
32
|
+
/* 长工具调用(如截图)期间客户端可能超时断连;socket 的 'error' 事件
|
|
33
|
+
无人监听会打崩进程。挂空处理器,配合 send() 的断连防护。 */
|
|
34
|
+
res.on('error', () => {})
|
|
35
|
+
if (req.method !== 'POST') {
|
|
36
|
+
if (res.destroyed) return
|
|
37
|
+
res.writeHead(405, { allow: 'POST', 'content-type': 'text/plain; charset=utf-8' })
|
|
38
|
+
return res.end('MCP endpoint: POST only')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let msg
|
|
42
|
+
try {
|
|
43
|
+
msg = await readBody(req, maxBodyBytes)
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error?.code === 'BODY_TOO_LARGE') {
|
|
46
|
+
return send(req, res, 413, rpcError(null, -32600, 'Request body too large'))
|
|
47
|
+
}
|
|
48
|
+
return send(req, res, 400, rpcError(null, -32700, 'Parse error'))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!msg || typeof msg !== 'object') {
|
|
52
|
+
return send(req, res, 400, rpcError(null, -32600, 'Invalid Request'))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const batch = Array.isArray(msg)
|
|
56
|
+
if (batch && msg.length === 0) {
|
|
57
|
+
return send(req, res, 400, rpcError(null, -32600, 'Invalid Request'))
|
|
58
|
+
}
|
|
59
|
+
if (batch && msg.length > maxBatch) {
|
|
60
|
+
return send(req, res, 400, rpcError(null, -32600, `Batch too large (max ${maxBatch})`))
|
|
61
|
+
}
|
|
62
|
+
const messages = batch ? msg : [msg]
|
|
63
|
+
const replies = []
|
|
64
|
+
for (const message of messages) {
|
|
65
|
+
const result = await handleOne(message, req)
|
|
66
|
+
if (result !== null) replies.push(result)
|
|
67
|
+
}
|
|
68
|
+
if (!replies.length) {
|
|
69
|
+
if (res.destroyed) return
|
|
70
|
+
res.writeHead(202, { 'content-length': 0 })
|
|
71
|
+
return res.end()
|
|
72
|
+
}
|
|
73
|
+
return send(req, res, 200, batch ? replies : replies[0])
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function handleOne(message, req) {
|
|
77
|
+
if (!message || typeof message !== 'object' || Array.isArray(message)) {
|
|
78
|
+
return rpcError(null, -32600, 'Invalid Request')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const { id, method, params } = message
|
|
82
|
+
const isNotification = id === undefined || id === null
|
|
83
|
+
|
|
84
|
+
if (typeof method !== 'string' || !method) {
|
|
85
|
+
return isNotification ? null : rpcError(id, -32600, 'Invalid Request')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (method === 'initialize') {
|
|
89
|
+
const result = {
|
|
90
|
+
protocolVersion: String(params?.protocolVersion || DEFAULT_PROTOCOL),
|
|
91
|
+
capabilities: { tools: { listChanged: false } },
|
|
92
|
+
serverInfo: serverInfo || { name: 'dsh-plugin', version: '0.0.0' },
|
|
93
|
+
}
|
|
94
|
+
if (typeof instructions === 'string' && instructions) result.instructions = instructions
|
|
95
|
+
return isNotification ? null : reply(id, result)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (method === 'notifications/initialized' || method === 'initialized') return null
|
|
99
|
+
if (method === 'notifications/cancelled' || method === 'notifications/cancel') return null
|
|
100
|
+
if (method === 'ping') return isNotification ? null : reply(id, {})
|
|
101
|
+
|
|
102
|
+
if (method === 'tools/list') {
|
|
103
|
+
return isNotification ? null : reply(id, { tools })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (method === 'tools/call') {
|
|
107
|
+
const name = params?.name
|
|
108
|
+
const args = params?.arguments && typeof params.arguments === 'object' ? params.arguments : {}
|
|
109
|
+
const tool = toolMap.get(name)
|
|
110
|
+
if (!tool) {
|
|
111
|
+
if (isNotification) return null
|
|
112
|
+
return reply(id, {
|
|
113
|
+
content: [{ type: 'text', text: `未知工具: ${String(name)}` }],
|
|
114
|
+
isError: true,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const check = validateToolArguments(tool, args)
|
|
119
|
+
if (!check.ok) {
|
|
120
|
+
if (isNotification) return null
|
|
121
|
+
return reply(id, {
|
|
122
|
+
content: [{ type: 'text', text: '参数不合法:' + check.errors.join(';') }],
|
|
123
|
+
isError: true,
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const out = await callTool(name, args, { req })
|
|
129
|
+
if (out && Array.isArray(out.content)) return reply(id, out)
|
|
130
|
+
return reply(id, { content: [{ type: 'text', text: out?.text || 'ok' }] })
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (isNotification) return null
|
|
133
|
+
return reply(id, {
|
|
134
|
+
content: [{ type: 'text', text: '工具失败:' + String((error && error.message) || error) }],
|
|
135
|
+
isError: true,
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return isNotification ? null : rpcError(id, -32601, `Method not found: ${method}`)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** 单实例锁:同一时间只允许一个本机插件实例持有锁。 */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const LOCK_FILE = path.join(HERE, '.plugin-instance.lock');
|
|
8
|
+
|
|
9
|
+
export function acquireLock(lockFile = LOCK_FILE) {
|
|
10
|
+
try {
|
|
11
|
+
// 检查锁文件是否存在
|
|
12
|
+
if (fs.existsSync(lockFile)) {
|
|
13
|
+
const lockData = JSON.parse(fs.readFileSync(lockFile, 'utf8'));
|
|
14
|
+
const { pid, timestamp, port } = lockData;
|
|
15
|
+
|
|
16
|
+
// 检查进程是否还活着(Windows)
|
|
17
|
+
try {
|
|
18
|
+
process.kill(pid, 0); // 信号 0 只检测不杀
|
|
19
|
+
const age = Date.now() - timestamp;
|
|
20
|
+
if (age < 60000) {
|
|
21
|
+
// 进程活着且锁文件不到 1 分钟
|
|
22
|
+
console.error(`✗ 另一个插件实例正在运行 (PID ${pid}, 端口 ${port})`);
|
|
23
|
+
console.error(`如果确认没有运行,删除: ${lockFile}`);
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
} catch (e) {
|
|
27
|
+
// 进程已死,清理旧锁
|
|
28
|
+
console.log(`清理过期锁文件 (PID ${pid} 已不存在)`);
|
|
29
|
+
fs.unlinkSync(lockFile);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return true;
|
|
34
|
+
} catch (e) {
|
|
35
|
+
console.error('获取锁失败:', e);
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function writeLock(port, lockFile = LOCK_FILE) {
|
|
41
|
+
try {
|
|
42
|
+
// 写入锁文件
|
|
43
|
+
fs.mkdirSync(path.dirname(lockFile), { recursive: true });
|
|
44
|
+
fs.writeFileSync(lockFile, JSON.stringify({
|
|
45
|
+
pid: process.pid,
|
|
46
|
+
port,
|
|
47
|
+
timestamp: Date.now(),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
// 进程退出时清理
|
|
51
|
+
const cleanup = () => {
|
|
52
|
+
try {
|
|
53
|
+
if (fs.existsSync(lockFile)) {
|
|
54
|
+
const data = JSON.parse(fs.readFileSync(lockFile, 'utf8'));
|
|
55
|
+
if (data.pid === process.pid) {
|
|
56
|
+
fs.unlinkSync(lockFile);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} catch (e) { /* 忽略清理失败 */ }
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
process.on('exit', cleanup);
|
|
63
|
+
process.on('SIGINT', () => { cleanup(); process.exit(0); });
|
|
64
|
+
process.on('SIGTERM', () => { cleanup(); process.exit(0); });
|
|
65
|
+
process.on('uncaughtException', (e) => { console.error('未捕获异常:', e); cleanup(); process.exit(1); });
|
|
66
|
+
|
|
67
|
+
return true;
|
|
68
|
+
} catch (e) {
|
|
69
|
+
console.error('写入锁失败:', e);
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|