@capyclaw/service-broker 0.0.0-stage → 0.2.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/README.md +100 -2
- package/cordis.patch.yml +6 -0
- package/dsh-suite.json +21 -0
- package/lib/control.mjs +117 -0
- package/lib/discovery.mjs +47 -0
- package/lib/engine.mjs +676 -0
- package/lib/host.mjs +93 -0
- package/lib/manifest.mjs +40 -0
- package/lib/recovery.mjs +206 -0
- package/lib/skills-client.mjs +28 -0
- package/lib/spec.mjs +78 -0
- package/package.json +49 -4
- package/server.mjs +73 -0
- package/service.schema.json +42 -0
- package/services.json +3 -0
package/lib/host.mjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-service-broker:中间件插件(cordis 入口)。
|
|
3
|
+
*
|
|
4
|
+
* 这是 dsh 进程内**唯一**允许存在的插件角色——它不承载任何业务,
|
|
5
|
+
* 只做三件事:
|
|
6
|
+
* 1. 按清单(config.manifest 指向 services.json)把各服务作为独立进程拉起;
|
|
7
|
+
* 2. 向同进程提供 ctx.serviceBroker(list/start/stop/apply 控制面);
|
|
8
|
+
* 3. 可选:config.skills.url 指向技能服务时,提供 ctx.skills 瘦客户端
|
|
9
|
+
* (数据来自 HTTP,技能内容不再进程内注册)。
|
|
10
|
+
*
|
|
11
|
+
* 生命周期:keepAlive 服务(默认)不随插件卸载停止——服务独立于 dsh;
|
|
12
|
+
* config.stopOnUnload === true 时才随卸载全停。
|
|
13
|
+
*/
|
|
14
|
+
import { ServiceEngine } from './engine.mjs'
|
|
15
|
+
import { createControlServer } from './control.mjs'
|
|
16
|
+
import { createSkillsClient } from './skills-client.mjs'
|
|
17
|
+
import { loadManifest } from './manifest.mjs'
|
|
18
|
+
|
|
19
|
+
export const name = 'service-broker'
|
|
20
|
+
|
|
21
|
+
export function apply(ctx, config = {}) {
|
|
22
|
+
const engine = new ServiceEngine({
|
|
23
|
+
discoveryPath: config.discoveryPath,
|
|
24
|
+
logDir: config.logDir,
|
|
25
|
+
log: (msg) => console.log(`[service-broker] ${msg}`),
|
|
26
|
+
defaultWaitMs: Number(config.waitMs) || 20000,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const api = {
|
|
30
|
+
api: { name: 'serviceBroker', version: '1.0.0', capabilities: ['apply', 'start', 'stop', 'list', 'doctor'] },
|
|
31
|
+
engine,
|
|
32
|
+
discoveryPath: engine.discoveryPath,
|
|
33
|
+
list: () => engine.list(),
|
|
34
|
+
start: (spec, options) => engine.apply([{ op: 'up', service: spec }], options),
|
|
35
|
+
stop: (id) => engine.stop(id),
|
|
36
|
+
apply: (commands, options) => engine.apply(commands, options),
|
|
37
|
+
doctor: (id) => engine.doctor(id),
|
|
38
|
+
}
|
|
39
|
+
const disposeService = ctx.provide('serviceBroker', api)
|
|
40
|
+
|
|
41
|
+
// 技能瘦客户端:ctx.skills 形状与原进程内注册表一致(list/get),
|
|
42
|
+
// 数据源改为 HTTP 技能服务——技能内容不再进 dsh 进程。
|
|
43
|
+
let disposeSkills = null
|
|
44
|
+
if (config.skills?.url) {
|
|
45
|
+
const client = createSkillsClient(config.skills.url)
|
|
46
|
+
disposeSkills = ctx.provide('skills', client)
|
|
47
|
+
console.log(`[service-broker] skills ← ${config.skills.url}`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 开机清单:有清单就按 up 命令投递给自己(幂等,复用优先)
|
|
51
|
+
void (async () => {
|
|
52
|
+
let services = []
|
|
53
|
+
try { services = loadManifest(config.manifest) } catch (err) {
|
|
54
|
+
console.warn(`[service-broker] 清单加载失败: ${err.message}`)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
if (!services.length) {
|
|
58
|
+
console.log('[service-broker] 无开机清单,待命(服务可经控制端点/ctx.serviceBroker 投递)')
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const result = await engine.apply(services.map((service) => ({ op: 'up', service })))
|
|
63
|
+
for (const err of result.errors) console.warn(`[service-broker] ✗ ${err.id}: ${err.error}`)
|
|
64
|
+
console.log(`[service-broker] 开机清单完成:${result.services.length} 个服务就绪`)
|
|
65
|
+
} catch (err) {
|
|
66
|
+
console.warn(`[service-broker] 开机清单投递失败: ${err.message}`)
|
|
67
|
+
}
|
|
68
|
+
})()
|
|
69
|
+
|
|
70
|
+
// 可选控制端点(embedded 一般不需要;外部投递走 standalone 形态)
|
|
71
|
+
let controlServer = null
|
|
72
|
+
if (Number(config.controlPort) > 0) {
|
|
73
|
+
controlServer = createControlServer(engine, {
|
|
74
|
+
token: process.env.DSH_BROKER_TOKEN,
|
|
75
|
+
manifestPath: config.manifest,
|
|
76
|
+
})
|
|
77
|
+
controlServer.listen(Number(config.controlPort), '127.0.0.1', () => {
|
|
78
|
+
console.log(`[service-broker] 控制端点 http://127.0.0.1:${config.controlPort}/control/*`)
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
engine.startGuard({
|
|
83
|
+
intervalMs: Number(config.guardIntervalMs) || 5000,
|
|
84
|
+
maxConsecutive: Number(config.guardMaxConsecutive) || 3,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
return async () => {
|
|
88
|
+
if (controlServer) await new Promise((resolve) => controlServer.close(resolve))
|
|
89
|
+
if (typeof disposeService === 'function') await disposeService()
|
|
90
|
+
if (typeof disposeSkills === 'function') await disposeSkills()
|
|
91
|
+
await engine.shutdown({ stopAll: config.stopOnUnload === true })
|
|
92
|
+
}
|
|
93
|
+
}
|
package/lib/manifest.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 清单加载(host.mjs 与 server.mjs 共用)。
|
|
3
|
+
*
|
|
4
|
+
* - manifest 路径为相对路径时,相对 **broker 包目录**解析;
|
|
5
|
+
* - 清单里的 `${PACKAGE_DIR}` 展开为 **清单文件所在目录**——套件捆绑布局
|
|
6
|
+
* 下即套件根(services.json 与 node_modules/* 同层),同一份清单在
|
|
7
|
+
* dev 树与安装态都能解析出正确绝对路径。
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
import { validateServiceManifest } from './spec.mjs'
|
|
13
|
+
|
|
14
|
+
export const PKG_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
15
|
+
|
|
16
|
+
export function resolveManifestPath(manifestPath) {
|
|
17
|
+
if (!manifestPath) return path.join(PKG_DIR, 'services.json')
|
|
18
|
+
return path.isAbsolute(manifestPath) ? manifestPath : path.resolve(PKG_DIR, manifestPath)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function loadManifest(manifestPath) {
|
|
22
|
+
const file = resolveManifestPath(manifestPath)
|
|
23
|
+
if (!existsSync(file)) return []
|
|
24
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'))
|
|
25
|
+
const services = Array.isArray(raw) ? raw : raw.services
|
|
26
|
+
if (!Array.isArray(services)) throw new Error(`manifest 格式非法(缺 services 数组): ${file}`)
|
|
27
|
+
const specErrors = validateServiceManifest(services, file)
|
|
28
|
+
if (specErrors.length) throw new Error(`manifest 服务 spec 非法:\n- ${specErrors.join('\n- ')}`)
|
|
29
|
+
const base = path.dirname(file)
|
|
30
|
+
return services.map((service) => expandPackageDir(service, base))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function expandPackageDir(service, base) {
|
|
34
|
+
const out = { ...service, env: { ...service.env } }
|
|
35
|
+
if (Array.isArray(out.cmd)) out.cmd = out.cmd.map((a) => String(a).replaceAll('${PACKAGE_DIR}', base))
|
|
36
|
+
for (const key of ['url', 'cwd', 'logFile']) {
|
|
37
|
+
if (typeof out[key] === 'string') out[key] = out[key].replaceAll('${PACKAGE_DIR}', base)
|
|
38
|
+
}
|
|
39
|
+
return out
|
|
40
|
+
}
|
package/lib/recovery.mjs
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Durable spawn receipts are separate from discovery, which is only observational.
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import net from 'node:net'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
6
|
+
|
|
7
|
+
// Retry only unavailable queries, never substitute a PID or a different marker.
|
|
8
|
+
// A successful but different identity is returned immediately to fail comparisons.
|
|
9
|
+
export function queryWindowsIdentity(script, validate, run = spawnSync) {
|
|
10
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
11
|
+
try {
|
|
12
|
+
const r = run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8', windowsHide: true, timeout: 5000 })
|
|
13
|
+
const value = typeof r.stdout === 'string' ? r.stdout.trim() : ''
|
|
14
|
+
if (r.status === 0 && validate(value)) return value
|
|
15
|
+
} catch { /* transient launch failure is not proof */ }
|
|
16
|
+
}
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function marker(pid) {
|
|
21
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return null
|
|
22
|
+
try {
|
|
23
|
+
if (process.platform === 'win32') {
|
|
24
|
+
const v = queryWindowsIdentity(`$p = Get-Process -Id ${pid} -ErrorAction Stop; $p.StartTime.ToUniversalTime().Ticks.ToString()`, value => /^\d+$/.test(value))
|
|
25
|
+
return v ? `win:${v}` : null
|
|
26
|
+
}
|
|
27
|
+
if (process.platform === 'linux') {
|
|
28
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8')
|
|
29
|
+
const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ')
|
|
30
|
+
const boot = fs.readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim()
|
|
31
|
+
return fields[19] && boot ? `linux:${boot}:${fields[19]}` : null
|
|
32
|
+
}
|
|
33
|
+
} catch {}
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 异步版 marker:Windows 上用异步 spawn 调 PowerShell,避免 spawnSync 阻塞事件循环。
|
|
38
|
+
* 返回格式与 marker() 完全一致,可与同步结果互相比对。
|
|
39
|
+
*
|
|
40
|
+
* 动机(2026-11-04 实测):marker() 在 Windows 单次约 900ms,且 spawnSync 阻塞
|
|
41
|
+
* 事件循环,导致 broker 并发拉起多个服务时完全串行化。异步版让多个身份查询并行。
|
|
42
|
+
*/
|
|
43
|
+
export async function markerAsync(pid) {
|
|
44
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return null
|
|
45
|
+
if (process.platform !== 'win32') return marker(pid)
|
|
46
|
+
const v = await queryWindowsIdentityAsync(
|
|
47
|
+
`$p = Get-Process -Id ${pid} -ErrorAction Stop; $p.StartTime.ToUniversalTime().Ticks.ToString()`,
|
|
48
|
+
(value) => /^\d+$/.test(value),
|
|
49
|
+
)
|
|
50
|
+
return v ? `win:${v}` : null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 与 queryWindowsIdentity 同语义(最多 3 次重试),但用异步 spawn。 */
|
|
54
|
+
export async function queryWindowsIdentityAsync(script, validate, attemptLimit = 3) {
|
|
55
|
+
const once = () => new Promise((resolve) => {
|
|
56
|
+
let child
|
|
57
|
+
try {
|
|
58
|
+
child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { windowsHide: true })
|
|
59
|
+
} catch {
|
|
60
|
+
resolve(undefined)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
let out = ''
|
|
64
|
+
let settled = false
|
|
65
|
+
const finish = (value) => {
|
|
66
|
+
if (settled) return
|
|
67
|
+
settled = true
|
|
68
|
+
clearTimeout(timer)
|
|
69
|
+
resolve(value)
|
|
70
|
+
}
|
|
71
|
+
const timer = setTimeout(() => {
|
|
72
|
+
try { child.kill() } catch { /* 已退出 */ }
|
|
73
|
+
finish(undefined)
|
|
74
|
+
}, 5000)
|
|
75
|
+
child.stdout.setEncoding('utf8')
|
|
76
|
+
child.stdout.on('data', (d) => { out += d })
|
|
77
|
+
child.on('error', () => finish(undefined))
|
|
78
|
+
child.on('close', (code) => {
|
|
79
|
+
const value = out.trim()
|
|
80
|
+
finish(code === 0 && validate(value) ? value : undefined)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
for (let attempt = 0; attempt < attemptLimit; attempt++) {
|
|
85
|
+
const v = await once()
|
|
86
|
+
if (v !== undefined) return v
|
|
87
|
+
}
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function absent(pid) {
|
|
92
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false
|
|
93
|
+
try { process.kill(pid, 0); return false } catch (e) { return e.code === 'ESRCH' }
|
|
94
|
+
}
|
|
95
|
+
export function receiptDir(discovery) { return `${discovery}.ownership` }
|
|
96
|
+
export function receiptPath(discovery, id) {
|
|
97
|
+
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/i.test(id)) throw new Error('invalid service id')
|
|
98
|
+
return path.join(receiptDir(discovery), `${id}.json`)
|
|
99
|
+
}
|
|
100
|
+
export function writeReceipt(discovery, id, value) {
|
|
101
|
+
const file = receiptPath(discovery, id)
|
|
102
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
103
|
+
const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`
|
|
104
|
+
fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600, flag: 'wx' })
|
|
105
|
+
fs.renameSync(tmp, file)
|
|
106
|
+
}
|
|
107
|
+
export function removeReceipt(discovery, id) {
|
|
108
|
+
try { fs.unlinkSync(receiptPath(discovery, id)) } catch (e) { if (e.code !== 'ENOENT') throw e }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function remainingReceipts(discovery) {
|
|
112
|
+
try { return fs.readdirSync(receiptDir(discovery)).map(name => `ownership:${name}:unresolved`) }
|
|
113
|
+
catch (e) { return e.code === 'ENOENT' ? ['ownership:missing'] : ['ownership:unreadable'] }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function canConnect(port, timeoutMs = 800) {
|
|
117
|
+
return new Promise((resolve) => {
|
|
118
|
+
const socket = net.connect({ port, host: '127.0.0.1' })
|
|
119
|
+
const done = (ok) => { try { socket.destroy() } catch { /* 已销毁 */ } resolve(ok) }
|
|
120
|
+
socket.setTimeout(timeoutMs)
|
|
121
|
+
socket.once('connect', () => done(true))
|
|
122
|
+
socket.once('timeout', () => done(false))
|
|
123
|
+
socket.once('error', () => done(false))
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function portList(value) {
|
|
128
|
+
return Object.values(value || {}).filter((port) => Number.isSafeInteger(port) && port > 0)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A dead root alone cannot prove its descendants are gone, so a receipt is
|
|
133
|
+
* normally kept as evidence. The exception we can verify: the root is absent
|
|
134
|
+
* and none of the service's declared ports is listening. An orphan that no
|
|
135
|
+
* longer serves the declared port cannot be the service we were asked to run.
|
|
136
|
+
* Any listener on those ports keeps the receipt (fail closed).
|
|
137
|
+
*/
|
|
138
|
+
export async function receiptResolvable(receipt, ports) {
|
|
139
|
+
if (!receipt || !Number.isSafeInteger(receipt.pid) || receipt.pid <= 0) return false
|
|
140
|
+
if (!absent(receipt.pid)) return false
|
|
141
|
+
const list = portList(ports ?? receipt.ports)
|
|
142
|
+
if (!list.length) return false
|
|
143
|
+
for (const port of list) if (await canConnect(port)) return false
|
|
144
|
+
return true
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Socket teardown can lag slightly behind process exit; give the OS a short,
|
|
148
|
+
// bounded window before declaring the port still owned by an unknown process.
|
|
149
|
+
export async function waitReceiptResolvable(receipt, ports, timeoutMs = 3000) {
|
|
150
|
+
const deadline = Date.now() + timeoutMs
|
|
151
|
+
for (;;) {
|
|
152
|
+
if (await receiptResolvable(receipt, ports)) return true
|
|
153
|
+
if (Date.now() >= deadline) return false
|
|
154
|
+
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Caller must serialize against starting a replacement broker and supply the
|
|
159
|
+
// identity captured when it spawned this broker. An absent PID alone is not a receipt.
|
|
160
|
+
export async function recoverServices(discovery, broker) {
|
|
161
|
+
const unresolved = [], stopped = []
|
|
162
|
+
if (!broker?.pid || !broker?.marker || !broker?.recoveryMarker || !absent(broker.pid)) return { stopped, unresolved: ['broker:not-confirmed-absent'] }
|
|
163
|
+
let discovered = {}
|
|
164
|
+
try { discovered = JSON.parse(fs.readFileSync(discovery, 'utf8'))?.services || {} } catch { /* discovery 缺失时只能依赖收据自身 */ }
|
|
165
|
+
let entries
|
|
166
|
+
try { entries = fs.readdirSync(receiptDir(discovery)) } catch (e) {
|
|
167
|
+
return { stopped, unresolved: [e.code === 'ENOENT' ? 'ownership:missing' : 'ownership:unreadable'] }
|
|
168
|
+
}
|
|
169
|
+
for (const name of entries) {
|
|
170
|
+
if (!name.endsWith('.json')) { unresolved.push(`ownership:${name}:unrecognized`); continue }
|
|
171
|
+
let r
|
|
172
|
+
try { r = JSON.parse(fs.readFileSync(path.join(receiptDir(discovery), name), 'utf8')) } catch { unresolved.push(`ownership:${name}:unreadable`); continue }
|
|
173
|
+
if (!r || `${r.id}.json` !== name || !/^[a-z0-9][a-z0-9._-]*$/i.test(r.id) || r.brokerPid !== broker.pid || r.brokerMarker !== broker.recoveryMarker || r.state !== 'spawned' || !Number.isSafeInteger(r.pid) || r.pid <= 0 || !r.identity) {
|
|
174
|
+
unresolved.push(`ownership:${name}:unverified`); continue
|
|
175
|
+
}
|
|
176
|
+
if (!absent(broker.pid)) { unresolved.push('broker:not-confirmed-absent'); break }
|
|
177
|
+
if (absent(r.pid)) {
|
|
178
|
+
// Dead root: only a receipt whose declared ports are all free can be
|
|
179
|
+
// resolved; any listener may be an unverified descendant.
|
|
180
|
+
if (await waitReceiptResolvable(r, discovered[r.id]?.ports)) {
|
|
181
|
+
removeReceipt(discovery, r.id)
|
|
182
|
+
stopped.push(r.id)
|
|
183
|
+
} else unresolved.push(`service:${r.id}:already-exited-descendants-unverified`)
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
if (!absent(r.pid)) {
|
|
187
|
+
if (marker(r.pid) !== r.identity) { unresolved.push(`service:${r.id}:identity-unverified`); continue }
|
|
188
|
+
if (process.platform === 'win32') {
|
|
189
|
+
const result = spawnSync('taskkill', ['/PID', String(r.pid), '/F'], { stdio: 'ignore', timeout: 10000, windowsHide: true })
|
|
190
|
+
if (result.status !== 0 && !absent(r.pid)) { unresolved.push(`service:${r.id}:stop-failed`); continue }
|
|
191
|
+
} else {
|
|
192
|
+
try { process.kill(r.pid, 'SIGTERM') } catch (e) { if (e.code !== 'ESRCH') { unresolved.push(`service:${r.id}:stop-failed`); continue } }
|
|
193
|
+
}
|
|
194
|
+
for (let i = 0; i < 50 && !absent(r.pid); i++) await new Promise(resolve => setTimeout(resolve, 100))
|
|
195
|
+
if (!absent(r.pid)) { unresolved.push(`service:${r.id}:still-running`); continue }
|
|
196
|
+
}
|
|
197
|
+
// Root exit alone does not prove its descendants exited. It is safe to
|
|
198
|
+
// resolve once none of the declared ports is still listening; otherwise
|
|
199
|
+
// keep the receipt as evidence (fail closed).
|
|
200
|
+
if (await waitReceiptResolvable(r, discovered[r.id]?.ports)) {
|
|
201
|
+
removeReceipt(discovery, r.id)
|
|
202
|
+
stopped.push(r.id)
|
|
203
|
+
} else unresolved.push(`service:${r.id}:descendants-unverified`)
|
|
204
|
+
}
|
|
205
|
+
return { stopped, unresolved }
|
|
206
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 技能瘦客户端:把「技能服务」的 HTTP 面适配成原 ctx.skills 的进程内形状。
|
|
3
|
+
*
|
|
4
|
+
* 技能内容(SKILL.md 正文)不再通过 ctx.skills.registerProvider 进程内注册,
|
|
5
|
+
* 而是从独立技能服务拉取——dsh 进程里只剩这一个瘦适配层。
|
|
6
|
+
* 服务端形状见 bridges/ppt-bridge/dsh-skills-service(GET /skills、GET /skills/:name)。
|
|
7
|
+
*/
|
|
8
|
+
export function createSkillsClient(baseUrl) {
|
|
9
|
+
const root = String(baseUrl || '').replace(/\/$/, '')
|
|
10
|
+
return {
|
|
11
|
+
api: { name: 'skills', version: '1.0.0', kind: 'thin-client' },
|
|
12
|
+
async list(signal) {
|
|
13
|
+
const res = await fetch(`${root}/skills`, { signal: signal ?? AbortSignal.timeout(5000) })
|
|
14
|
+
if (!res.ok) throw new Error(`skills list HTTP ${res.status}`)
|
|
15
|
+
const body = await res.json()
|
|
16
|
+
return Array.isArray(body?.skills) ? body.skills : []
|
|
17
|
+
},
|
|
18
|
+
async get(candidate, options = {}) {
|
|
19
|
+
const name = typeof candidate === 'string' ? candidate : candidate?.name
|
|
20
|
+
if (!name) throw new Error('skills get: 缺 name')
|
|
21
|
+
const res = await fetch(`${root}/skills/${encodeURIComponent(name)}`, {
|
|
22
|
+
signal: options.signal ?? AbortSignal.timeout(10000),
|
|
23
|
+
})
|
|
24
|
+
if (!res.ok) throw new Error(`未知 skill ${name}(HTTP ${res.status})`)
|
|
25
|
+
return res.json()
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
}
|
package/lib/spec.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 服务 spec 校验:service.schema.json 的代码面。
|
|
3
|
+
*
|
|
4
|
+
* stack-services.json(dsh-stack-kit 编译)与 service-manifest.json
|
|
5
|
+
* (dsh-suite-kit 物化)共用这一份 spec;本模块是装载期与跨包测试
|
|
6
|
+
* 共同调用的唯一校验器(结构级——语义由 engine/discovery 在运行期裁决)。
|
|
7
|
+
*/
|
|
8
|
+
const ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/
|
|
9
|
+
|
|
10
|
+
function checkPortValue(value, label, errors) {
|
|
11
|
+
if (value === undefined) return
|
|
12
|
+
if (value === 'auto') return
|
|
13
|
+
if (!Number.isInteger(value) || value <= 0 || value > 65535) {
|
|
14
|
+
errors.push(`${label} 必须是 1-65535 整数或 "auto"`)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 校验单个服务 spec;返回错误数组(空 = 通过)。 */
|
|
19
|
+
export function validateServiceSpec(service, at = 'service') {
|
|
20
|
+
const errors = []
|
|
21
|
+
if (!service || typeof service !== 'object' || Array.isArray(service)) {
|
|
22
|
+
return [`${at} 必须是对象`]
|
|
23
|
+
}
|
|
24
|
+
if (typeof service.id !== 'string' || !ID_RE.test(service.id)) {
|
|
25
|
+
errors.push(`${at}.id 必须是非空的服务标识(字母数字开头,可含 . _ -)`)
|
|
26
|
+
}
|
|
27
|
+
if (!Array.isArray(service.cmd) || service.cmd.length === 0 || service.cmd.some((item) => typeof item !== 'string')) {
|
|
28
|
+
errors.push(`${at}.cmd 必须是非空 string[](完整命令行)`)
|
|
29
|
+
}
|
|
30
|
+
if (service.ports !== undefined) {
|
|
31
|
+
if (typeof service.ports !== 'object' || service.ports === null || Array.isArray(service.ports)) {
|
|
32
|
+
errors.push(`${at}.ports 必须是对象(main 或具名端口)`)
|
|
33
|
+
} else {
|
|
34
|
+
for (const [name, value] of Object.entries(service.ports)) {
|
|
35
|
+
checkPortValue(value, `${at}.ports.${name}`, errors)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (service.env !== undefined && (typeof service.env !== 'object' || service.env === null
|
|
40
|
+
|| Array.isArray(service.env) || Object.values(service.env).some((value) => typeof value !== 'string'))) {
|
|
41
|
+
errors.push(`${at}.env 必须是 string -> string 对象`)
|
|
42
|
+
}
|
|
43
|
+
if (service.keepAlive !== undefined && typeof service.keepAlive !== 'boolean') {
|
|
44
|
+
errors.push(`${at}.keepAlive 必须是 boolean`)
|
|
45
|
+
}
|
|
46
|
+
if (service.healthPath !== undefined && typeof service.healthPath !== 'string') {
|
|
47
|
+
errors.push(`${at}.healthPath 必须是 string`)
|
|
48
|
+
}
|
|
49
|
+
if (service.url !== undefined && typeof service.url !== 'string') errors.push(`${at}.url 必须是 string`)
|
|
50
|
+
if (service.cwd !== undefined && typeof service.cwd !== 'string') errors.push(`${at}.cwd 必须是 string`)
|
|
51
|
+
if (service.logFile !== undefined && typeof service.logFile !== 'string') errors.push(`${at}.logFile 必须是 string`)
|
|
52
|
+
if (service.requires !== undefined && (!Array.isArray(service.requires)
|
|
53
|
+
|| service.requires.some((item) => typeof item !== 'string'))) {
|
|
54
|
+
errors.push(`${at}.requires 必须是 string[]`)
|
|
55
|
+
}
|
|
56
|
+
if (service.portFrom !== undefined && !['content', 'urlPort'].includes(service.portFrom)) {
|
|
57
|
+
errors.push(`${at}.portFrom 只允许 content | urlPort`)
|
|
58
|
+
}
|
|
59
|
+
if (service.dirFrom !== undefined && service.dirFrom !== 'contentState') {
|
|
60
|
+
errors.push(`${at}.dirFrom 只允许 contentState`)
|
|
61
|
+
}
|
|
62
|
+
return errors
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 校验整份清单(services 数组 + 同清单 id 唯一);返回错误数组(空 = 通过)。 */
|
|
66
|
+
export function validateServiceManifest(services, label = 'manifest') {
|
|
67
|
+
if (!Array.isArray(services)) return [`${label}: services 必须是数组`]
|
|
68
|
+
const errors = []
|
|
69
|
+
const seen = new Set()
|
|
70
|
+
services.forEach((service, index) => {
|
|
71
|
+
errors.push(...validateServiceSpec(service, `${label}[${index}]`))
|
|
72
|
+
if (service && typeof service === 'object' && typeof service.id === 'string') {
|
|
73
|
+
if (seen.has(service.id)) errors.push(`${label}: 重复的服务 id ${service.id}`)
|
|
74
|
+
seen.add(service.id)
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
return errors
|
|
78
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capyclaw/service-broker",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "DSH 服务中间件(唯一进程内插件):按清单/结构化命令把各服务作为独立进程拉起,负责守护、发现(discovery)、控制端点;服务与服务之间不互相带动,跨端口组织一律经过这里。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./lib/host.mjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/host.mjs",
|
|
9
|
+
"./engine": "./lib/engine.mjs",
|
|
10
|
+
"./manifest": "./lib/manifest.mjs",
|
|
11
|
+
"./spec": "./lib/spec.mjs",
|
|
12
|
+
"./schema": "./service.schema.json",
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18",
|
|
17
|
+
"dsh": "^0.2.0-rc.2"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"dsh": {
|
|
23
|
+
"bundle": {
|
|
24
|
+
"patch": "./cordis.patch.yml"
|
|
25
|
+
},
|
|
26
|
+
"suite": "./dsh-suite.json",
|
|
27
|
+
"apiVersion": "1.0.0",
|
|
28
|
+
"category": "service",
|
|
29
|
+
"platform": "both"
|
|
30
|
+
},
|
|
31
|
+
"meta": {
|
|
32
|
+
"title": "服务中间件",
|
|
33
|
+
"description": "按清单把各服务作为独立进程拉起,守护、发现与控制端点;服务之间不互相带动"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"lib/",
|
|
37
|
+
"server.mjs",
|
|
38
|
+
"services.json",
|
|
39
|
+
"service.schema.json",
|
|
40
|
+
"cordis.patch.yml",
|
|
41
|
+
"dsh-suite.json",
|
|
42
|
+
"README.md"
|
|
43
|
+
],
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"scripts": {
|
|
46
|
+
"test": "node --test test/unit.test.mjs test/e2e.test.mjs test/stability.test.mjs test/adopt-detached.test.mjs"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@capyclaw/plugin-kit": "^0.2.0"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/server.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dsh-service-broker standalone 入口:中间件以独立端口服务运行。
|
|
4
|
+
*
|
|
5
|
+
* 这是桌面/外部场景的主形态:启动清单(作品/后端)不直接拉服务,
|
|
6
|
+
* 而是把结构化命令 POST 到这里的 /control/apply,由中间件统一执行。
|
|
7
|
+
*
|
|
8
|
+
* 用法:
|
|
9
|
+
* node server.mjs [--port 39610] [--manifest services.json]
|
|
10
|
+
* [--discovery ~/.dsh/services.json] [--stop-on-exit]
|
|
11
|
+
*
|
|
12
|
+
* 环境变量:DSH_BROKER_TOKEN(设置后 /control/* 需带 x-broker-token)。
|
|
13
|
+
*/
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import { mkdirSync } from 'node:fs'
|
|
16
|
+
import { receiptDir } from './lib/recovery.mjs'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
import { ServiceEngine } from './lib/engine.mjs'
|
|
19
|
+
import { createControlServer } from './lib/control.mjs'
|
|
20
|
+
import { loadManifest } from './lib/manifest.mjs'
|
|
21
|
+
|
|
22
|
+
function arg(name, dflt) {
|
|
23
|
+
const i = process.argv.indexOf(name)
|
|
24
|
+
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : dflt
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
28
|
+
const port = Number(arg('--port', process.env.DSH_BROKER_PORT || 39610))
|
|
29
|
+
const manifestPath = arg('--manifest', undefined)
|
|
30
|
+
const discoveryPath = arg('--discovery', undefined)
|
|
31
|
+
const stopOnExit = process.argv.includes('--stop-on-exit')
|
|
32
|
+
|
|
33
|
+
const engine = new ServiceEngine({
|
|
34
|
+
discoveryPath,
|
|
35
|
+
log: (msg) => console.log(`${new Date().toISOString().slice(11, 19)} ${msg}`),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// Existence distinguishes a broker with receipt support from a legacy broker.
|
|
39
|
+
mkdirSync(receiptDir(engine.discoveryPath), { recursive: true })
|
|
40
|
+
|
|
41
|
+
// 开机清单:幂等(复用优先),失败不阻断中间件自身启动
|
|
42
|
+
try {
|
|
43
|
+
const services = loadManifest(manifestPath)
|
|
44
|
+
if (services.length) {
|
|
45
|
+
const result = await engine.apply(services.map((service) => ({ op: 'up', service })))
|
|
46
|
+
for (const err of result.errors) console.warn(`✗ ${err.id}: ${err.error}`)
|
|
47
|
+
console.log(`开机清单完成:${result.services.length} 个服务就绪`)
|
|
48
|
+
} else {
|
|
49
|
+
console.log('开机清单为空,待命(等待 /control/apply 投递)')
|
|
50
|
+
}
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.warn(`开机清单失败: ${err.message}`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
engine.startGuard()
|
|
56
|
+
const server = createControlServer(engine, { token: process.env.DSH_BROKER_TOKEN, manifestPath })
|
|
57
|
+
server.listen(port, '127.0.0.1', () => {
|
|
58
|
+
console.log(`dsh-service-broker 就绪: http://127.0.0.1:${port}/control/*`)
|
|
59
|
+
console.log(`discovery: ${engine.discoveryPath}`)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
let exiting = false
|
|
63
|
+
async function shutdown() {
|
|
64
|
+
if (exiting) return
|
|
65
|
+
exiting = true
|
|
66
|
+
console.log('中间件停机…')
|
|
67
|
+
await new Promise((resolve) => server.close(resolve))
|
|
68
|
+
await engine.shutdown({ stopAll: stopOnExit })
|
|
69
|
+
process.exit(0)
|
|
70
|
+
}
|
|
71
|
+
process.on('SIGINT', shutdown)
|
|
72
|
+
process.on('SIGTERM', shutdown)
|
|
73
|
+
process.on('break', shutdown)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://dsh.local/schemas/service.schema.json",
|
|
4
|
+
"title": "DSH Broker 服务清单(唯一服务 spec)",
|
|
5
|
+
"description": "stack-services.json(dsh-stack-kit 编译)与 service-manifest.json(dsh-suite-kit 物化)是同一 spec 的两个 runtime 工件;本 schema 是二者共同的契约。${PACKAGE_DIR}/${PORT}/${PORT_<NAME>} 占位符由装载/仲裁层展开。",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["services"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"services": { "type": "array", "items": { "$ref": "#/$defs/service" } }
|
|
10
|
+
},
|
|
11
|
+
"$defs": {
|
|
12
|
+
"port": {
|
|
13
|
+
"anyOf": [
|
|
14
|
+
{ "type": "integer", "minimum": 1, "maximum": 65535 },
|
|
15
|
+
{ "type": "string", "enum": ["auto"] }
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"service": {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"required": ["id", "cmd"],
|
|
21
|
+
"additionalProperties": true,
|
|
22
|
+
"properties": {
|
|
23
|
+
"id": { "type": "string", "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$", "description": "服务 id(同清单内唯一)" },
|
|
24
|
+
"cmd": { "type": "array", "items": { "type": "string" }, "minItems": 1, "description": "完整命令行(占位符原样保留,broker 展开分配)" },
|
|
25
|
+
"ports": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"additionalProperties": { "$ref": "#/$defs/port" },
|
|
28
|
+
"description": "main 为主端口;具名端口映射 PORT_<NAME>"
|
|
29
|
+
},
|
|
30
|
+
"url": { "type": "string", "description": "服务入口 URL 模板" },
|
|
31
|
+
"healthPath": { "type": "string", "description": "健康探测路径(默认 /healthz)" },
|
|
32
|
+
"keepAlive": { "type": "boolean", "description": "进程死亡后由 broker 原端口复活" },
|
|
33
|
+
"env": { "type": "object", "additionalProperties": { "type": "string" } },
|
|
34
|
+
"cwd": { "type": "string" },
|
|
35
|
+
"requires": { "type": "array", "items": { "type": "string" }, "description": "同清单内依赖的服务 id" },
|
|
36
|
+
"logFile": { "type": "string" },
|
|
37
|
+
"portFrom": { "type": "string", "enum": ["content", "urlPort"], "description": "suite 物化期提示:主端口取 runtime content/urlPort(物化后删除)" },
|
|
38
|
+
"dirFrom": { "type": "string", "enum": ["contentState"], "description": "suite 物化期提示:--dir 参数指向 runtime contentState(物化后删除)" }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
package/services.json
ADDED