@capyclaw/ppt-workbench 0.0.0-stage → 2.4.1
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 +53 -6
- 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,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 }
|