@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,511 @@
1
+ /**
2
+ * dsh-service-host —— 通用服务宿主。
3
+ *
4
+ * 功能插件只声明“我有哪些路由、健康字段、指标”,不再自己写:
5
+ * - http server / listen / 端口扫描 / strictPort
6
+ * - 单实例锁 / 复用探测 / discovery 文件
7
+ * - /healthz /metrics 标准骨架
8
+ * - 静态资源、路径穿越防护、优雅关闭
9
+ *
10
+ * 两种模式:
11
+ * standalone(默认):自建 loopback server,行为与旧 relay 一致;
12
+ * embedded:传入 DSH 的 ctx.webServer,把同一套路由挂到 `<basePath>` 前缀。
13
+ */
14
+ import http from 'node:http'
15
+ import fs from 'node:fs'
16
+ import os from 'node:os'
17
+ import path from 'node:path'
18
+ import crypto from 'node:crypto'
19
+ import { listen, send, sendText, readBody } from './kit/service/http.mjs'
20
+ import { createServiceApi } from './kit/service/api.mjs'
21
+ import { tuning } from './tuning.mjs'
22
+
23
+ const MIME = {
24
+ '.html': 'text/html; charset=utf-8',
25
+ '.js': 'text/javascript; charset=utf-8',
26
+ '.mjs': 'text/javascript; charset=utf-8',
27
+ '.css': 'text/css; charset=utf-8',
28
+ '.json': 'application/json; charset=utf-8',
29
+ '.svg': 'image/svg+xml',
30
+ '.png': 'image/png',
31
+ '.jpg': 'image/jpeg',
32
+ '.jpeg': 'image/jpeg',
33
+ '.webp': 'image/webp',
34
+ '.woff2': 'font/woff2',
35
+ '.map': 'application/json; charset=utf-8',
36
+ }
37
+
38
+ function slugOf(id) {
39
+ return String(id || 'service').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'service'
40
+ }
41
+
42
+ function pidAlive(pid) {
43
+ if (!pid) return false
44
+ try { process.kill(pid, 0); return true } catch (error) { return error.code === 'EPERM' }
45
+ }
46
+
47
+ /** 浏览器跨源写请求防护:只允许本机 loopback 页面 POST。 */
48
+ function isLoopbackOrigin(origin) {
49
+ if (!origin) return true
50
+ try {
51
+ const parsed = new URL(origin)
52
+ return parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost' || parsed.hostname === '::1' || parsed.hostname === '[::1]'
53
+ } catch {
54
+ return false
55
+ }
56
+ }
57
+
58
+ function acquireLock(lockFile) {
59
+ fs.mkdirSync(path.dirname(lockFile), { recursive: true })
60
+ const token = crypto.randomBytes(12).toString('hex')
61
+ const { lockFreshGraceMs } = tuning()
62
+ for (let attempt = 0; attempt < 2; attempt++) {
63
+ try {
64
+ /* 原子创建:两个进程同时启动时只有一个能拿到 'wx'。 */
65
+ const fd = fs.openSync(lockFile, 'wx')
66
+ try {
67
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, token, port: 0, timestamp: Date.now() }))
68
+ try { fs.fsyncSync(fd) } catch { /* 部分文件系统不支持 */ }
69
+ } finally {
70
+ fs.closeSync(fd)
71
+ }
72
+ return { file: lockFile, token }
73
+ } catch (error) {
74
+ if (error.code !== 'EEXIST') throw error
75
+ let info = null
76
+ try { info = JSON.parse(fs.readFileSync(lockFile, 'utf8')) } catch { /* 可能正在写入 */ }
77
+ let age = 0
78
+ try { age = Date.now() - fs.statSync(lockFile).mtimeMs } catch { /* 已被移除 */ }
79
+ /* 有效且 pid 存活:绝不抢占;空/坏锁只有在存在超过宽限期后才视为残留。 */
80
+ if (info && pidAlive(info.pid)) return null
81
+ if (!info && age < lockFreshGraceMs) return null
82
+ try { fs.rmSync(lockFile, { force: true }) } catch { /* 忽略 */ }
83
+ }
84
+ }
85
+ return null
86
+ }
87
+
88
+ function writeLock(lock, port) {
89
+ let current = null
90
+ try { current = JSON.parse(fs.readFileSync(lock.file, 'utf8')) } catch { /* 忽略 */ }
91
+ if (!current || current.token !== lock.token) {
92
+ throw new Error(`锁已被其它实例接管: ${lock.file}`)
93
+ }
94
+ fs.writeFileSync(lock.file, JSON.stringify({
95
+ pid: process.pid, token: lock.token, port, timestamp: Date.now(),
96
+ }))
97
+ }
98
+
99
+ function releaseLock(lock) {
100
+ if (!lock) return
101
+ try {
102
+ const data = JSON.parse(fs.readFileSync(lock.file, 'utf8'))
103
+ if (data.token === lock.token) fs.rmSync(lock.file, { force: true })
104
+ } catch { /* 不是自己的锁或已被删除 */ }
105
+ }
106
+
107
+ async function probeExisting(origin, healthPath, id) {
108
+ try {
109
+ const res = await fetch(`${origin}${healthPath}`, { signal: AbortSignal.timeout(tuning().reuseProbeTimeoutMs) })
110
+ if (!res.ok) return null
111
+ const info = await res.json()
112
+ if (info?.service === id || info?.name === id || info?.bridge === id) return info
113
+ } catch { /* 端口没有本服务 */ }
114
+ return null
115
+ }
116
+
117
+ function safeJoin(root, rel) {
118
+ const target = path.resolve(root, `.${path.sep}${rel}`)
119
+ const base = path.resolve(root)
120
+ return target === base || target.startsWith(base + path.sep) ? target : null
121
+ }
122
+
123
+ /**
124
+ * @param {{
125
+ * id: string,
126
+ * title?: string,
127
+ * version?: string,
128
+ * contractVersion?: string,
129
+ * publicBasePath?: string,
130
+ * health?: (() => object)|object,
131
+ * metrics?: (() => Array<[string, number]>|string)|Array<[string, number]>|string,
132
+ * }} options
133
+ */
134
+ export function createServiceApp(options = {}) {
135
+ if (!options.id) throw new Error('createServiceApp: id 必填')
136
+ const id = String(options.id)
137
+ const title = options.title || id
138
+ const version = options.version || '0.0.0'
139
+ const contractVersion = options.contractVersion || '1.0.0'
140
+ const slug = slugOf(options.slug || id)
141
+ const startedAt = Date.now()
142
+ const routes = []
143
+ let healthExtra = options.health || null
144
+ let metricsExtra = options.metrics || null
145
+ let server = null
146
+ let embeddedDispose = null
147
+ let basePath = options.publicBasePath || ''
148
+ let listeningPort = 0
149
+ let url = ''
150
+ let closed = false
151
+
152
+ const app = {
153
+ id,
154
+ title,
155
+ version,
156
+ contractVersion,
157
+ slug,
158
+ get port() { return listeningPort },
159
+ get url() { return url },
160
+ get running() { return !!server || !!embeddedDispose },
161
+
162
+ /** 注册一个中间件式路由:返回 true 表示已处理。 */
163
+ use(handler) {
164
+ if (typeof handler !== 'function') throw new Error('service-host: route handler 必须是函数')
165
+ routes.push(handler)
166
+ return () => {
167
+ const index = routes.indexOf(handler)
168
+ if (index >= 0) routes.splice(index, 1)
169
+ }
170
+ },
171
+
172
+ get(pathname, handler) {
173
+ return app.use((req, res, u) => {
174
+ if (req.method !== 'GET' && req.method !== 'HEAD') return false
175
+ if (u.pathname !== pathname) return false
176
+ return Promise.resolve(handler(req, res, u)).then(() => true)
177
+ })
178
+ },
179
+
180
+ post(pathname, handler) {
181
+ return app.use((req, res, u) => {
182
+ if (req.method !== 'POST' || u.pathname !== pathname) return false
183
+ return Promise.resolve(handler(req, res, u)).then(() => true)
184
+ })
185
+ },
186
+
187
+ /** 静态目录挂载。files 可给显式映射:{ '/': ['index.html','text/html'] }。 */
188
+ static(prefix, dir, { files = null, index = 'index.html' } = {}) {
189
+ const cleanPrefix = prefix === '/' ? '' : prefix.replace(/\/+$/, '')
190
+ return app.use((req, res, u) => {
191
+ if (req.method !== 'GET' && req.method !== 'HEAD') return false
192
+ if (cleanPrefix && u.pathname !== cleanPrefix && !u.pathname.startsWith(cleanPrefix + '/')) return false
193
+ const relRaw = cleanPrefix ? u.pathname.slice(cleanPrefix.length) : u.pathname
194
+ const rel = relRaw === '' || relRaw === '/' ? index : decodeURIComponent(relRaw.replace(/^\/+/, ''))
195
+ let file = null
196
+ let type = MIME[path.extname(rel).toLowerCase()] || 'application/octet-stream'
197
+ if (files) {
198
+ const hit = files[relRaw === '' ? '/' : relRaw] || files[relRaw]
199
+ if (!hit) return false
200
+ const [name, explicitType] = Array.isArray(hit) ? hit : [hit]
201
+ file = safeJoin(dir, name)
202
+ type = explicitType || type
203
+ } else {
204
+ file = safeJoin(dir, rel)
205
+ }
206
+ if (!file) return false
207
+ try {
208
+ const data = fs.readFileSync(file)
209
+ res.writeHead(200, {
210
+ 'content-type': type,
211
+ 'content-length': data.length,
212
+ 'x-content-type-options': 'nosniff',
213
+ 'referrer-policy': 'no-referrer',
214
+ 'cache-control': 'no-cache',
215
+ })
216
+ res.end(req.method === 'HEAD' ? undefined : data)
217
+ return true
218
+ } catch {
219
+ return false
220
+ }
221
+ })
222
+ },
223
+
224
+ setHealth(fn) { healthExtra = fn; return app },
225
+ setMetrics(fn) { metricsExtra = fn; return app },
226
+
227
+ async health() {
228
+ const extra = typeof healthExtra === 'function' ? await healthExtra() : (healthExtra || {})
229
+ return {
230
+ ...extra,
231
+ ok: true,
232
+ service: id,
233
+ name: id,
234
+ title,
235
+ version,
236
+ contractVersion,
237
+ pid: process.pid,
238
+ port: listeningPort,
239
+ uptimeSec: Math.max(0, Math.round((Date.now() - startedAt) / 1000)),
240
+ }
241
+ },
242
+
243
+ async metricsText() {
244
+ const uptime = Math.max(0, Math.round((Date.now() - startedAt) / 1000))
245
+ const lines = [
246
+ `# HELP ${slug}_up 服务是否在线`,
247
+ `# TYPE ${slug}_up gauge`,
248
+ `${slug}_up 1`,
249
+ `# HELP ${slug}_uptime_seconds 服务运行秒数`,
250
+ `# TYPE ${slug}_uptime_seconds gauge`,
251
+ `${slug}_uptime_seconds ${uptime}`,
252
+ `# HELP ${slug}_port 实际监听端口`,
253
+ `# TYPE ${slug}_port gauge`,
254
+ `${slug}_port ${listeningPort}`,
255
+ ]
256
+ const extra = typeof metricsExtra === 'function' ? await metricsExtra() : metricsExtra
257
+ if (typeof extra === 'string' && extra) lines.push(extra.replace(/\s+$/, ''))
258
+ else if (Array.isArray(extra)) {
259
+ for (const row of extra) {
260
+ if (!Array.isArray(row)) continue
261
+ const [name, value] = row
262
+ if (name) lines.push(`${name} ${Number(value) || 0}`)
263
+ }
264
+ }
265
+ return lines.join('\n') + '\n'
266
+ },
267
+
268
+ async dispatch(req, res, stripPrefix = '') {
269
+ try {
270
+ /* 畸形请求行(如 absolute-form URL)不能让整个宿主进程崩溃。 */
271
+ const u = new URL(req.url || '/', 'http://127.0.0.1')
272
+ if (req.method !== 'GET' && req.method !== 'HEAD' && !isLoopbackOrigin(req.headers.origin)) {
273
+ return send(req, res, 403, { ok: false, error: 'cross-origin write blocked' })
274
+ }
275
+ if (stripPrefix && (u.pathname === stripPrefix || u.pathname.startsWith(stripPrefix + '/'))) {
276
+ u.pathname = u.pathname.slice(stripPrefix.length) || '/'
277
+ }
278
+ if (u.pathname === '/healthz') {
279
+ return send(req, res, 200, await app.health())
280
+ }
281
+ if (u.pathname === '/metrics') {
282
+ return sendText(req, res, 200, await app.metricsText(), 'text/plain; version=0.0.4; charset=utf-8')
283
+ }
284
+ for (const handler of routes) {
285
+ if (await handler(req, res, u)) return
286
+ }
287
+ return send(req, res, 404, { ok: false, error: 'not found', path: u.pathname })
288
+ } catch (error) {
289
+ const status = error?.code === 'BODY_TOO_LARGE'
290
+ ? 413
291
+ : error?.code === 'BAD_JSON' || error?.code === 'ERR_INVALID_URL'
292
+ ? 400
293
+ : 500
294
+ if (!res.headersSent) send(req, res, status, { ok: false, error: String((error && error.message) || error) })
295
+ else res.destroy()
296
+ }
297
+ },
298
+
299
+ /**
300
+ * @param {{
301
+ * port?: number, host?: string, strictPort?: boolean, allowReuse?: boolean,
302
+ * scanLimit?: number, lockFile?: string, hostDir?: string,
303
+ * discoveryFile?: string, discovery?: object, publicBasePath?: string,
304
+ * webServer?: object, basePath?: string,
305
+ * }} listenOptions
306
+ */
307
+ async listen(listenOptions = {}) {
308
+ if (app.running) throw new Error(`service-host ${id}: 已经在运行`)
309
+ const host = listenOptions.host || '127.0.0.1'
310
+ const port = Number(listenOptions.port ?? 0)
311
+ const strictPort = !!listenOptions.strictPort
312
+ const allowReuse = listenOptions.allowReuse !== false
313
+ const scanLimit = Number(listenOptions.scanLimit ?? tuning().portScanLimit)
314
+ basePath = listenOptions.publicBasePath ?? listenOptions.basePath ?? basePath ?? ''
315
+ if (basePath === '/') basePath = ''
316
+
317
+ const webServer = listenOptions.webServer
318
+ if (webServer && typeof webServer.register === 'function') {
319
+ const prefix = basePath || `/plugins/${id}`
320
+ embeddedDispose = webServer.register({
321
+ kind: 'prefix',
322
+ path: prefix,
323
+ handler: (req, res) => app.dispatch(req, res, prefix),
324
+ })
325
+ listeningPort = Number(webServer.port) || 0
326
+ url = `http://127.0.0.1:${listeningPort}${prefix}`
327
+ return { url, port: listeningPort, reused: false, mode: 'embedded', stop: app.close }
328
+ }
329
+
330
+ const hostDir = listenOptions.hostDir || path.join(os.tmpdir(), `dsh-${id}`)
331
+ const lockFile = listenOptions.lockFile || path.join(hostDir, 'service.lock')
332
+ const publicBasePath = basePath
333
+ const healthPath = `${publicBasePath}/healthz` || '/healthz'
334
+
335
+ if (allowReuse && port > 0) {
336
+ const existing = await probeExisting(`http://127.0.0.1:${port}`, healthPath, id)
337
+ /* 只复用同契约版本的实例:旧代码/半初始化实例不能被当成本实例。 */
338
+ if (existing && existing.contractVersion && existing.contractVersion === contractVersion) {
339
+ listeningPort = port
340
+ url = `http://127.0.0.1:${port}${publicBasePath}`
341
+ return { url, port, reused: true, mode: 'standalone', stop: async () => {} }
342
+ }
343
+ }
344
+
345
+ const lock = acquireLock(lockFile)
346
+ if (!lock) {
347
+ throw new Error(`${id} 已有另一个实例在运行;如确认没有,请删除锁文件: ${lockFile}`)
348
+ }
349
+
350
+ let actualPort = port
351
+ let lastError = null
352
+ const requestTimeoutMs = Number(listenOptions.requestTimeoutMs) || 30000
353
+ const headersTimeoutMs = Number(listenOptions.headersTimeoutMs) || 10000
354
+ const keepAliveTimeoutMs = Number(listenOptions.keepAliveTimeoutMs) || 5000
355
+ for (let attempt = 0; attempt <= (strictPort ? 0 : scanLimit); attempt++) {
356
+ const tryPort = port === 0 ? 0 : port + attempt
357
+ try {
358
+ server = await listen(tryPort, { host, requestTimeoutMs, headersTimeoutMs, keepAliveTimeoutMs })
359
+ actualPort = server.address().port
360
+ break
361
+ } catch (error) {
362
+ lastError = error
363
+ if (port === 0 || strictPort || attempt === scanLimit) {
364
+ releaseLock(lock)
365
+ throw new Error(`端口 ${tryPort} 不可用${strictPort ? '(strictPort)' : ''}: ${(error && error.message) || error}`)
366
+ }
367
+ }
368
+ }
369
+ if (!server) {
370
+ releaseLock(lock)
371
+ throw lastError || new Error('无法监听端口')
372
+ }
373
+
374
+ try {
375
+ writeLock(lock, actualPort)
376
+ } catch (error) {
377
+ try { server.close() } catch { /* 忽略 */ }
378
+ server = null
379
+ releaseLock(lock)
380
+ throw error
381
+ }
382
+ listeningPort = actualPort
383
+ url = `http://127.0.0.1:${actualPort}${publicBasePath}`
384
+
385
+ server.on('request', (req, res) => {
386
+ Promise.resolve(app.dispatch(req, res, publicBasePath)).catch((error) => {
387
+ try {
388
+ if (!res.headersSent) send(req, res, 500, { ok: false, error: String((error && error.message) || error) })
389
+ else res.destroy()
390
+ } catch { /* 连接已断开 */ }
391
+ })
392
+ })
393
+ const discoveryFile = listenOptions.discoveryFile
394
+ || (listenOptions.hostDir ? path.join(hostDir, 'discovery.json') : '')
395
+ if (discoveryFile) {
396
+ try {
397
+ fs.mkdirSync(path.dirname(discoveryFile), { recursive: true })
398
+ fs.writeFileSync(discoveryFile, JSON.stringify({
399
+ service: id,
400
+ ...(listenOptions.discovery || {}),
401
+ url, port: actualPort, pid: process.pid, updated: new Date().toISOString(),
402
+ }, null, 2))
403
+ } catch { /* discovery 写失败不影响服务 */ }
404
+ }
405
+
406
+ app._lock = lock
407
+ app._discoveryFile = discoveryFile
408
+ return { url, port: actualPort, reused: false, mode: 'standalone', stop: app.close }
409
+ },
410
+
411
+ async close() {
412
+ if (closed) return
413
+ closed = true
414
+ if (embeddedDispose) {
415
+ try { embeddedDispose() } catch { /* 忽略 */ }
416
+ embeddedDispose = null
417
+ return
418
+ }
419
+ if (!server) return
420
+ const srv = server
421
+ server = null
422
+ await new Promise((resolve) => {
423
+ const { closeGraceMs, closeForceMs } = tuning()
424
+ let done = false
425
+ const finish = () => { if (done) return; done = true; resolve() }
426
+ srv.close(finish)
427
+ setTimeout(() => srv.closeAllConnections?.(), closeGraceMs).unref?.()
428
+ setTimeout(finish, closeForceMs).unref?.()
429
+ })
430
+ if (app._discoveryFile) {
431
+ try {
432
+ const data = JSON.parse(fs.readFileSync(app._discoveryFile, 'utf8'))
433
+ if (data.pid === process.pid) fs.rmSync(app._discoveryFile, { force: true })
434
+ } catch { /* 忽略 */ }
435
+ }
436
+ if (app._lock) releaseLock(app._lock)
437
+ },
438
+ }
439
+
440
+ return app
441
+ }
442
+
443
+ export function createServiceHost(config = {}) {
444
+ return {
445
+ api: createServiceApi('serviceHost', '1.0.0', ['http', 'static', 'health', 'metrics', 'lock', 'reuse', 'discovery', 'shared-host']),
446
+ version: '0.1.0',
447
+ createApp(options = {}) {
448
+ return createServiceApp({ ...config, ...options })
449
+ },
450
+ createSharedHost(options = {}) {
451
+ return createSharedHost({ ...config, ...options })
452
+ },
453
+ readBody,
454
+ }
455
+ }
456
+
457
+ /**
458
+ * 共享宿主:多个功能插件挂到同一个 HTTP server / 同一个端口。
459
+ *
460
+ * const host = ctx.serviceHost.createSharedHost({ id: 'dsh-app' })
461
+ * host.mount('/plugins/ppt', pptApp)
462
+ * host.mount('/plugins/canvas', canvasApp)
463
+ * await host.listen({ port: 3080 })
464
+ *
465
+ * 每个子 app 仍由 createServiceApp 创建;共享宿主只负责前缀分发和统一生命周期。
466
+ * @param {{ id?: string, title?: string, version?: string, contractVersion?: string, health?: Function|object }} options
467
+ */
468
+ export function createSharedHost(options = {}) {
469
+ const mounts = new Map()
470
+ const id = options.id || 'dsh-shared-host'
471
+ const app = createServiceApp({
472
+ ...options,
473
+ id,
474
+ title: options.title || 'DSH Shared Host',
475
+ version: options.version || '0.1.0',
476
+ contractVersion: options.contractVersion || '1.0.0',
477
+ health: options.health || (() => ({ children: [...mounts.keys()] })),
478
+ })
479
+
480
+ const mount = (prefix, child) => {
481
+ const clean = String(prefix || '').replace(/\/+$/, '') || '/'
482
+ if (clean === '/') throw new Error('shared-host: 子插件必须使用非根前缀')
483
+ if (mounts.has(clean)) throw new Error(`shared-host: 前缀已挂载: ${clean}`)
484
+ if (!child || typeof child.dispatch !== 'function') throw new Error(`shared-host: ${clean} 的子 app 必须提供 dispatch()`)
485
+ const dispose = app.use((req, res, u) => {
486
+ if (u.pathname !== clean && !u.pathname.startsWith(clean + '/')) return false
487
+ return child.dispatch(req, res, clean).then(() => true)
488
+ })
489
+ mounts.set(clean, { child, dispose })
490
+ return () => {
491
+ dispose()
492
+ mounts.delete(clean)
493
+ }
494
+ }
495
+
496
+ return {
497
+ id,
498
+ app,
499
+ api: createServiceApi('serviceHost', '1.0.0', ['shared-host']),
500
+ mount,
501
+ children() { return [...mounts.keys()] },
502
+ listen(listenOptions = {}) {
503
+ return app.listen(listenOptions)
504
+ },
505
+ close() {
506
+ return app.close()
507
+ },
508
+ }
509
+ }
510
+
511
+ export { send, sendText, readBody }