@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.
Files changed (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -3
  3. package/THIRD_PARTY_NOTICES.md +21 -0
  4. package/contract/ppt-contract.json +1529 -0
  5. package/cordis.patch.yml +25 -0
  6. package/core/audit.js +500 -0
  7. package/core/chart.js +199 -0
  8. package/core/color.js +80 -0
  9. package/core/deck-layout.js +195 -0
  10. package/core/formula.js +80 -0
  11. package/core/hfmath/hershey.js +1763 -0
  12. package/core/hfmath/index.js +1176 -0
  13. package/core/hfmath/stretch.js +41 -0
  14. package/core/hfmath/symbols.js +408 -0
  15. package/core/ops/browser.js +28 -0
  16. package/core/ops/chart.js +64 -0
  17. package/core/ops/deck.js +21 -0
  18. package/core/ops/dispatcher.js +2 -0
  19. package/core/ops/element.js +55 -0
  20. package/core/ops/history.js +11 -0
  21. package/core/ops/import.js +95 -0
  22. package/core/ops/index.js +39 -0
  23. package/core/ops/playback.js +9 -0
  24. package/core/ops/recolor.js +33 -0
  25. package/core/ops/slide.js +66 -0
  26. package/core/ops/table.js +85 -0
  27. package/core/ops/text.js +35 -0
  28. package/core/ops/view.js +13 -0
  29. package/core/ops.js +12 -0
  30. package/core/quick.js +52 -0
  31. package/core/shape-library.js +122 -0
  32. package/core/table.js +178 -0
  33. package/core/text.js +200 -0
  34. package/editor/app.js +5366 -0
  35. package/editor/heartbeat-worker.js +116 -0
  36. package/editor/index.html +89 -0
  37. package/editor/ppt-full-app.js +376 -0
  38. package/lib/host.mjs +98 -0
  39. package/package.json +51 -4
  40. package/server/audit.mjs +2 -0
  41. package/server/bridge-client.mjs +174 -0
  42. package/server/constants.mjs +20 -0
  43. package/server/contract-snapshots.mjs +62 -0
  44. package/server/contract.mjs +24 -0
  45. package/server/deck-layout.mjs +2 -0
  46. package/server/http-utils.mjs +2 -0
  47. package/server/infra/http.mjs +113 -0
  48. package/server/infra/runtime/context.mjs +128 -0
  49. package/server/infra/store/blob-store.mjs +109 -0
  50. package/server/infra/tuning.mjs +82 -0
  51. package/server/infra/util/fs-lock.mjs +132 -0
  52. package/server/infra/workspace/manager.mjs +657 -0
  53. package/server/mcp/dispatch.mjs +2 -0
  54. package/server/mcp/handler.mjs +2 -0
  55. package/server/mcp/instructions.mjs +40 -0
  56. package/server/mcp-http.mjs +3 -0
  57. package/server/ppt-shim.mjs +156 -0
  58. package/server/relay.mjs +245 -0
  59. package/server/routes/assets.mjs +2 -0
  60. package/server/routes/bridge.mjs +47 -0
  61. package/server/routes/editor.mjs +9 -0
  62. package/server/routes/import.mjs +258 -0
  63. package/server/routes/mcp.mjs +17 -0
  64. package/server/runtime/recovery.mjs +9 -0
  65. package/server/runtime/watch.mjs +17 -0
  66. package/server/shared/SHARED_MANIFEST.json +47 -0
  67. package/server/shared/asset-service.mjs +217 -0
  68. package/server/shared/editor-host.mjs +125 -0
  69. package/server/shared/kit/editor/lease.mjs +139 -0
  70. package/server/shared/kit/service/api.mjs +45 -0
  71. package/server/shared/kit/service/http.mjs +113 -0
  72. package/server/shared/kit/service/mcp-http.mjs +142 -0
  73. package/server/shared/kit/service/single-instance.mjs +72 -0
  74. package/server/shared/kit/tools/validate.mjs +115 -0
  75. package/server/shared/kit/tuning.mjs +82 -0
  76. package/server/shared/kit/util/fs-lock.mjs +132 -0
  77. package/server/shared/service-host.mjs +511 -0
  78. package/server/shared/state-service.mjs +249 -0
  79. package/server/shared/tool-host.mjs +341 -0
  80. package/server/shared/tuning.mjs +63 -0
  81. package/server/single-instance.mjs +2 -0
  82. package/server/store/backends/file-backend.mjs +13 -0
  83. package/server/store/checkpoints.mjs +28 -0
  84. package/server/store/doc.mjs +52 -0
  85. package/server/store/index.mjs +34 -0
  86. package/server/store/journal.mjs +9 -0
  87. package/server/store/lease.mjs +2 -0
  88. package/server/store/sanitize.mjs +32 -0
  89. package/server/store.mjs +2 -0
  90. package/server/tools/capabilities/apply.mjs +33 -0
  91. package/server/tools/capabilities/audit.mjs +24 -0
  92. package/server/tools/capabilities/chart.mjs +83 -0
  93. package/server/tools/capabilities/compose.mjs +97 -0
  94. package/server/tools/capabilities/contract.mjs +34 -0
  95. package/server/tools/capabilities/deck.mjs +27 -0
  96. package/server/tools/capabilities/element.mjs +67 -0
  97. package/server/tools/capabilities/export.mjs +95 -0
  98. package/server/tools/capabilities/formula.mjs +72 -0
  99. package/server/tools/capabilities/history.mjs +29 -0
  100. package/server/tools/capabilities/image.mjs +152 -0
  101. package/server/tools/capabilities/imagesearch.mjs +152 -0
  102. package/server/tools/capabilities/import.mjs +180 -0
  103. package/server/tools/capabilities/index.mjs +58 -0
  104. package/server/tools/capabilities/line.mjs +58 -0
  105. package/server/tools/capabilities/notes.mjs +33 -0
  106. package/server/tools/capabilities/present.mjs +34 -0
  107. package/server/tools/capabilities/recolor.mjs +60 -0
  108. package/server/tools/capabilities/screenshot.mjs +86 -0
  109. package/server/tools/capabilities/shape.mjs +64 -0
  110. package/server/tools/capabilities/slide.mjs +81 -0
  111. package/server/tools/capabilities/style.mjs +48 -0
  112. package/server/tools/capabilities/table.mjs +127 -0
  113. package/server/tools/capabilities/task.mjs +52 -0
  114. package/server/tools/capabilities/text.mjs +82 -0
  115. package/server/tools/domains/color.mjs +4 -0
  116. package/server/tools/domains/image.mjs +49 -0
  117. package/server/tools/domains/index.mjs +7 -0
  118. package/server/tools/domains/pptx-export.mjs +566 -0
  119. package/server/tools/domains/recolor.mjs +25 -0
  120. package/server/tools/domains/remote-image.mjs +291 -0
  121. package/server/tools/domains/slide.mjs +21 -0
  122. package/server/tools/domains/text-style.mjs +11 -0
  123. package/server/tools/domains/tooling.mjs +13 -0
  124. package/server/tools/index.mjs +14 -0
  125. package/server/tools/kernel/define.mjs +5 -0
  126. package/server/tools/kernel/ports.mjs +42 -0
  127. package/server/tools/kernel/registry.mjs +17 -0
  128. package/server/tools.mjs +18 -0
  129. package/server/tuning.mjs +86 -0
  130. 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
+ }