@capyclaw/service-broker 0.1.0 → 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
@@ -12,6 +12,15 @@ DSH 服务中间件。服务解耦标准的执行者:**启动清单/后端/用
12
12
  | 跨端口调动交给中间件 | `requires` 拓扑、端口分配、复用/冲突仲裁、discovery 都在这里 |
13
13
  | 不硬耦合 | 引擎零 dsh 依赖;embedded(cordis 插件壳)与 standalone(独立端口)同一份逻辑 |
14
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
+
15
24
  ## 两种形态
16
25
 
17
26
  ### standalone(桌面/外部场景的主形态)
package/lib/manifest.mjs CHANGED
@@ -9,6 +9,7 @@
9
9
  import { existsSync, readFileSync } from 'node:fs'
10
10
  import path from 'node:path'
11
11
  import { fileURLToPath } from 'node:url'
12
+ import { validateServiceManifest } from './spec.mjs'
12
13
 
13
14
  export const PKG_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
14
15
 
@@ -23,6 +24,8 @@ export function loadManifest(manifestPath) {
23
24
  const raw = JSON.parse(readFileSync(file, 'utf8'))
24
25
  const services = Array.isArray(raw) ? raw : raw.services
25
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- ')}`)
26
29
  const base = path.dirname(file)
27
30
  return services.map((service) => expandPackageDir(service, base))
28
31
  }
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,25 +1,42 @@
1
1
  {
2
2
  "name": "@capyclaw/service-broker",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "DSH 服务中间件(唯一进程内插件):按清单/结构化命令把各服务作为独立进程拉起,负责守护、发现(discovery)、控制端点;服务与服务之间不互相带动,跨端口组织一律经过这里。",
5
5
  "type": "module",
6
6
  "main": "./lib/host.mjs",
7
7
  "exports": {
8
8
  ".": "./lib/host.mjs",
9
9
  "./engine": "./lib/engine.mjs",
10
+ "./manifest": "./lib/manifest.mjs",
11
+ "./spec": "./lib/spec.mjs",
12
+ "./schema": "./service.schema.json",
10
13
  "./package.json": "./package.json"
11
14
  },
15
+ "engines": {
16
+ "node": ">=18",
17
+ "dsh": "^0.2.0-rc.2"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
12
22
  "dsh": {
13
23
  "bundle": {
14
24
  "patch": "./cordis.patch.yml"
15
25
  },
16
26
  "suite": "./dsh-suite.json",
17
- "apiVersion": "1.0.0"
27
+ "apiVersion": "1.0.0",
28
+ "category": "service",
29
+ "platform": "both"
30
+ },
31
+ "meta": {
32
+ "title": "服务中间件",
33
+ "description": "按清单把各服务作为独立进程拉起,守护、发现与控制端点;服务之间不互相带动"
18
34
  },
19
35
  "files": [
20
36
  "lib/",
21
37
  "server.mjs",
22
38
  "services.json",
39
+ "service.schema.json",
23
40
  "cordis.patch.yml",
24
41
  "dsh-suite.json",
25
42
  "README.md"
@@ -27,5 +44,8 @@
27
44
  "license": "MIT",
28
45
  "scripts": {
29
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"
30
50
  }
31
51
  }
@@ -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
+ }