@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 CHANGED
@@ -1,3 +1,101 @@
1
- # Temporary Holding Version
1
+ # dsh-service-broker
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ DSH 服务中间件。服务解耦标准的执行者:**启动清单/后端/用户不直接拉服务,
4
+ 一切结构化命令投递到这里,由中间件把每个服务作为独立进程拉起、守护、登记。**
5
+
6
+ 原则对应关系:
7
+
8
+ | 原则 | 本插件的做法 |
9
+ |------|--------------|
10
+ | 服务彼此独立 | 每个服务 = 独立进程 + 独立端口,互不感知、不互相带动 |
11
+ | 能做什么局限在自己的端口里 | 引擎只按 spec 拉起/回收,不解释服务内容 |
12
+ | 跨端口调动交给中间件 | `requires` 拓扑、端口分配、复用/冲突仲裁、discovery 都在这里 |
13
+ | 不硬耦合 | 引擎零 dsh 依赖;embedded(cordis 插件壳)与 standalone(独立端口)同一份逻辑 |
14
+
15
+ ## 变更记录
16
+
17
+ - **0.2.0**(2026-11-04):新增**统一服务 spec**——`service.schema.json`(JSON Schema)
18
+ 与 `lib/spec.mjs`(代码面校验 `validateServiceSpec`/`validateServiceManifest`),
19
+ `loadManifest` 在装载期拒绝结构非法的清单(同清单重复 id 也在此报错);
20
+ 新增 exports `./spec`、`./schema`。`stack-services.json`(dsh-stack-kit 编译)与
21
+ `service-manifest.json`(dsh-suite-kit 物化)自此共用同一份 spec 契约。
22
+ - 0.1.0:初始发布。
23
+
24
+ ## 两种形态
25
+
26
+ ### standalone(桌面/外部场景的主形态)
27
+
28
+ ```bash
29
+ node server.mjs [--port 39610] [--manifest services.json] [--stop-on-exit]
30
+ ```
31
+
32
+ - 开机清单:`--manifest` 指向的 JSON(`{"services":[…spec]}` 或裸数组)幂等拉起;
33
+ - 之后外部(如桌面后端)把结构化命令投递到控制端点。
34
+
35
+ ### embedded(dsh 场景)
36
+
37
+ cordis insert 唯一一行(`cordis.patch.yml`),config:
38
+
39
+ ```yaml
40
+ - insert:
41
+ - id: service-broker
42
+ name: "dsh-service-broker"
43
+ config:
44
+ manifest: "C:/…/services.json" # 开机清单(可省略=待命)
45
+ controlPort: 0 # >0 时embedded 也开控制端点
46
+ stopOnUnload: false # true=插件卸载时全停(默认服务独立存活)
47
+ skills:
48
+ url: "http://127.0.0.1:39321" # 可选:ctx.skills 瘦客户端数据源
49
+ ```
50
+
51
+ ## 服务规格 spec(与桌面后端 works_services 契约对齐)
52
+
53
+ ```json
54
+ {
55
+ "id": "ppt-workbench",
56
+ "cmd": ["node", "…/server.mjs", "--port", "${PORT}"],
57
+ "env": { "EXAMPLE": "${SOME_VAR}" },
58
+ "cwd": "…",
59
+ "ports": { "main": 39311, "extra": "auto" },
60
+ "url": "http://127.0.0.1:${PORT}",
61
+ "healthPath": "/healthz",
62
+ "keepAlive": true,
63
+ "requires": ["dep-service"]
64
+ }
65
+ ```
66
+
67
+ - `${PORT}` / `${PORT_<NAME>}`:端口占位符(auto 由中间件现场分配;固定端口原样使用);
68
+ cmd/env/url/cwd 里都可引用;其余 `${VAR}` 从环境展开,缺失 → 空串(诚实降级)。
69
+ - `requires`:中间件按闭包拓扑排序拉起(成环报错);只在本批内解析。
70
+ - `keepAlive`:默认 true,进程死亡由守护按**原端口**自动重启(连续失败限次)。
71
+
72
+ ## 结构化命令投递(控制端点,只绑 127.0.0.1)
73
+
74
+ ```
75
+ GET /healthz 中间件自身健康
76
+ GET /control/list 服务全量列表(含 running/pid/ports/url)
77
+ POST /control/apply { commands: [{op:"up",service:spec} | {op:"stop",id}], waitMs? }
78
+ POST /control/start { id }(重放清单/上次规格)或 { service: spec }
79
+ POST /control/stop { id }(pid 身份核验后 taskkill /T /F)
80
+ GET /control/doctor?id 存活 + 健康端点体检
81
+ ```
82
+
83
+ 设置 `DSH_BROKER_TOKEN` 后,`/control/*` 需带 `x-broker-token` 头。
84
+
85
+ ## discovery 注册表
86
+
87
+ `~/.dsh/services.json`(可 `--discovery`/`config.discoveryPath` 改):
88
+
89
+ ```json
90
+ { "version": 1, "updatedAt": "…", "services": {
91
+ "ppt-workbench": { "id": "…", "state": "running", "url": "http://127.0.0.1:39311",
92
+ "healthPath": "/healthz", "pid": 1234, "ports": {"main": 39311},
93
+ "keepAlive": true, "reused": false, "startedAt": "…" } } }
94
+ ```
95
+
96
+ UI/侧栏/其他工具只读这张表做发现,**永远不直接拉起别人**。
97
+
98
+ ## 复用优先(幂等)
99
+
100
+ `up` 前先看目标端口:已有健康实例(`/healthz` 200 且自报 pid)→ 收编复用,
101
+ 不重启;被不健康进程占用 → 诚实失败,不抢端口。
@@ -0,0 +1,6 @@
1
+ # dsh-service-broker:唯一中间件插件。
2
+ # 服务解耦标准见 docs/SERVICE_STANDARD.md——业务/端口服务一律不 insert 进
3
+ # dsh 进程,由本插件按清单以独立进程拉起;跨端口组织只经过中间件。
4
+ - insert:
5
+ - id: service-broker
6
+ name: "dsh-service-broker"
package/dsh-suite.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "../dsh-suite-kit/suite.schema.json",
3
+ "name": "dsh-service-broker-suite",
4
+ "version": "0.1.0",
5
+ "title": "Service Broker",
6
+ "description": "服务中间件:按清单/结构化命令以独立进程拉起各服务,负责守护、发现与控制端点。",
7
+ "packages": {
8
+ "dsh-service-broker": {
9
+ "role": "middleware",
10
+ "version": "^0.1.0",
11
+ "path": ".",
12
+ "provides": {
13
+ "services": ["serviceBroker"],
14
+ "capabilities": { "service.broker": "1.0.0" },
15
+ "ports": ["auto"]
16
+ }
17
+ }
18
+ },
19
+ "rows": [{ "id": "service-broker", "name": "dsh-service-broker" }],
20
+ "checks": []
21
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * dsh-service-broker 控制端点:外部投递结构化命令的入口。
3
+ *
4
+ * 端点(只绑 127.0.0.1):
5
+ * GET /healthz 中间件自身健康
6
+ * GET /control/list 服务全量列表
7
+ * POST /control/apply 结构化命令批投递 { commands: [...], waitMs? }
8
+ * POST /control/start { id? | service? }(id = 重放上次规格;service = 新规格)
9
+ * POST /control/stop { id }
10
+ * GET /control/doctor?id=
11
+ *
12
+ * 鉴权:设了 DSH_BROKER_TOKEN 时,除 /healthz 外都要带 x-broker-token。
13
+ * HTTP 形状对齐 dsh-runtime-control 的控制面风格。
14
+ */
15
+ import http from 'node:http'
16
+ import { existsSync, readFileSync } from 'node:fs'
17
+
18
+ function jsonBody(req, limit = 8 * 1024 * 1024) {
19
+ return new Promise((resolve, reject) => {
20
+ let size = 0
21
+ const chunks = []
22
+ req.on('data', (chunk) => {
23
+ size += chunk.length
24
+ if (size > limit) { reject(new Error('body too large')); req.destroy(); return }
25
+ chunks.push(chunk)
26
+ })
27
+ req.on('end', () => {
28
+ const raw = Buffer.concat(chunks).toString('utf8')
29
+ if (!raw) return resolve({})
30
+ try { resolve(JSON.parse(raw)) } catch { reject(new Error('bad json')) }
31
+ })
32
+ req.on('error', reject)
33
+ })
34
+ }
35
+
36
+ function send(res, code, value) {
37
+ const text = JSON.stringify(value)
38
+ res.writeHead(code, {
39
+ 'content-type': 'application/json; charset=utf-8',
40
+ 'content-length': Buffer.byteLength(text),
41
+ 'access-control-allow-origin': '*',
42
+ })
43
+ res.end(text)
44
+ }
45
+
46
+ /**
47
+ * @param {import('./engine.mjs').ServiceEngine} engine
48
+ * @param {{ token?: string, manifestPath?: string }} options
49
+ */
50
+ export function createControlServer(engine, options = {}) {
51
+ const manifestPath = options.manifestPath
52
+ const server = http.createServer(async (req, res) => {
53
+ const url = new URL(req.url, 'http://127.0.0.1')
54
+ const route = `${req.method} ${url.pathname}`
55
+ try {
56
+ if (options.token && route !== 'GET /healthz'
57
+ && req.headers['x-broker-token'] !== options.token) {
58
+ return send(res, 401, { ok: false, error: '需要 x-broker-token' })
59
+ }
60
+
61
+ if (route === 'GET /healthz') {
62
+ return send(res, 200, {
63
+ ok: true,
64
+ service: 'dsh-service-broker',
65
+ role: 'middleware',
66
+ pid: process.pid,
67
+ uptimeSec: Math.round(process.uptime()),
68
+ })
69
+ }
70
+
71
+ if (route === 'GET /control/list') {
72
+ return send(res, 200, { ok: true, services: await engine.list() })
73
+ }
74
+
75
+ if (route === 'POST /control/apply') {
76
+ const body = await jsonBody(req)
77
+ const result = await engine.apply(body.commands || [], { waitMs: body.waitMs })
78
+ return send(res, result.ok ? 200 : 207, result)
79
+ }
80
+
81
+ if (route === 'POST /control/start') {
82
+ const body = await jsonBody(req)
83
+ let spec = body.service
84
+ if (!spec && body.id && manifestPath && existsSync(manifestPath)) {
85
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
86
+ const services = Array.isArray(manifest) ? manifest : manifest.services
87
+ spec = (services || []).find((s) => s.id === body.id)
88
+ if (spec) spec = { ...spec, id: body.id }
89
+ }
90
+ if (!spec && body.id) {
91
+ const row = (await engine.list()).find((s) => s.id === body.id)
92
+ if (row?.url) return send(res, 200, { ok: true, services: [row] })
93
+ }
94
+ if (!spec) return send(res, 400, { ok: false, error: '缺 service 规格或可重放的 id' })
95
+ const result = await engine.apply([{ op: 'up', service: spec }], { waitMs: body.waitMs })
96
+ return send(res, result.ok ? 200 : 207, result)
97
+ }
98
+
99
+ if (route === 'POST /control/stop') {
100
+ const body = await jsonBody(req)
101
+ if (!body.id) return send(res, 400, { ok: false, error: '缺 id' })
102
+ return send(res, 200, await engine.stop(body.id))
103
+ }
104
+
105
+ if (route === 'GET /control/doctor') {
106
+ const id = url.searchParams.get('id')
107
+ if (!id) return send(res, 400, { ok: false, error: '缺 id' })
108
+ return send(res, 200, await engine.doctor(id))
109
+ }
110
+
111
+ send(res, 404, { ok: false, error: `未知端点 ${route}` })
112
+ } catch (err) {
113
+ send(res, 400, { ok: false, error: err.message })
114
+ }
115
+ })
116
+ return server
117
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * discovery 注册表:中间件运行事实的唯一落点。
3
+ *
4
+ * 形状(~/.dsh/services.json):
5
+ * {
6
+ * "version": 1,
7
+ * "updatedAt": "2026-11-01T…",
8
+ * "services": {
9
+ * "<id>": { id, state, url, pid, ports, specHash, keepAlive, startedAt }
10
+ * }
11
+ * }
12
+ *
13
+ * 口径:损坏 = 空表(诚实降级,不阻塞);原子写(唯一 tmp + rename,
14
+ * tmp 名带随机段——同进程并发写不会互抢同一个 tmp)。
15
+ */
16
+ import { readFile, rename, writeFile, mkdir } from 'node:fs/promises'
17
+ import path from 'node:path'
18
+
19
+ /** 每个注册表文件一条写队列:整文件读改写串行化,杜绝并发互相覆盖。 */
20
+ const _writeQueues = new Map()
21
+
22
+ function enqueueWrite(filePath, task) {
23
+ const key = path.resolve(filePath).toLowerCase()
24
+ const prev = _writeQueues.get(key) || Promise.resolve()
25
+ const run = prev.then(task, task)
26
+ _writeQueues.set(key, run.then(() => {}, () => {}))
27
+ return run
28
+ }
29
+
30
+ export async function loadDiscovery(filePath) {
31
+ try {
32
+ return JSON.parse(await readFile(filePath, 'utf8'))
33
+ } catch {
34
+ return null
35
+ }
36
+ }
37
+
38
+ export function saveDiscovery(filePath, data) {
39
+ return enqueueWrite(filePath, async () => {
40
+ await mkdir(path.dirname(filePath), { recursive: true })
41
+ const tmp = path.join(
42
+ path.dirname(filePath),
43
+ `.${path.basename(filePath)}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`)
44
+ await writeFile(tmp, JSON.stringify(data, null, 2), 'utf8')
45
+ await rename(tmp, filePath)
46
+ })
47
+ }