@twinsearth/w2m-dsh-plugin 3.1.0 → 3.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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +21 -0
- package/README.md +16 -1
- package/codex/mcp-servers.toml +1 -1
- package/cordis.patch.yml +5 -0
- package/docs/acceptance/3.2.0/RELEASE-3200.json +47 -0
- package/lib/contract.js +22 -0
- package/package.json +7 -4
- package/plugin.json +1 -1
- package/src/interop/contract/LICENSE +661 -0
- package/src/interop/contract/PROVENANCE.md +26 -0
- package/src/interop/contract/client.js +93 -0
- package/src/interop/contract/constants.js +94 -0
- package/src/interop/contract/envelope.js +83 -0
- package/src/interop/contract/index.js +20 -0
- package/src/interop/contract/negotiate.js +106 -0
- package/src/interop/contract/relay.js +229 -0
- package/src/interop/contract/server.js +77 -0
- package/src/interop/contract/transports.js +319 -0
- package/src/interop/index.mjs +270 -0
- package/src/plugin/tools.mjs +1 -1
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang/dsh-Master contributors — see LICENSE.
|
|
3
|
+
|
|
4
|
+
import { CAPABILITIES, CONTRACT_VERSION, ERROR_CODES, METHOD_VERSION, METHODS } from './constants.js'
|
|
5
|
+
import { createResponse, normalizeError, validateEnvelope } from './envelope.js'
|
|
6
|
+
import { helloPayload, parseVersion, unsupportedMethodError } from './negotiate.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 契约服务端(Fang 侧/任意一侧):
|
|
10
|
+
* * hello() 返回协商信息;
|
|
11
|
+
* * invoke(envelope) 校验信封、检查方法版本、调用 handler、返回标准响应。
|
|
12
|
+
*
|
|
13
|
+
* 兼容原则:新增方法只需加 handler;删除/改义必须同时保留 method@old 一个 MINOR 周期。
|
|
14
|
+
*/
|
|
15
|
+
export function createContractServer({
|
|
16
|
+
role = 'mother',
|
|
17
|
+
instanceId = '',
|
|
18
|
+
version = CONTRACT_VERSION,
|
|
19
|
+
capabilities = CAPABILITIES,
|
|
20
|
+
transports = [],
|
|
21
|
+
handlers = {},
|
|
22
|
+
logger = null,
|
|
23
|
+
startedAt = Date.now(),
|
|
24
|
+
} = {}) {
|
|
25
|
+
const registry = new Map(Object.entries(handlers))
|
|
26
|
+
const hello = () => helloPayload({
|
|
27
|
+
role,
|
|
28
|
+
instanceId,
|
|
29
|
+
version,
|
|
30
|
+
methods: [...new Set([METHODS.HELLO, METHODS.HEALTH, ...registry.keys()])],
|
|
31
|
+
capabilities,
|
|
32
|
+
transports,
|
|
33
|
+
startedAt,
|
|
34
|
+
})
|
|
35
|
+
const logs = { invoked: 0, failed: 0, lastError: null }
|
|
36
|
+
|
|
37
|
+
const invoke = async (envelope) => {
|
|
38
|
+
const validated = validateEnvelope(envelope)
|
|
39
|
+
if (!validated.ok) {
|
|
40
|
+
return createResponse({ id: envelope?.id, error: { code: ERROR_CODES.INVALID_ENVELOPE, message: validated.errors.join(', ') } })
|
|
41
|
+
}
|
|
42
|
+
const method = String(envelope.method)
|
|
43
|
+
try {
|
|
44
|
+
if (method === METHODS.HELLO) return createResponse({ id: envelope.id, result: hello() })
|
|
45
|
+
if (method === METHODS.HEALTH) {
|
|
46
|
+
return createResponse({ id: envelope.id, result: { ok: true, role, instanceId, version, uptimeMs: Date.now() - startedAt, methods: [...registry.keys()] } })
|
|
47
|
+
}
|
|
48
|
+
const handler = registry.get(method)
|
|
49
|
+
if (!handler) return createResponse({ id: envelope.id, error: unsupportedMethodError(method) })
|
|
50
|
+
const methodVersion = parseVersion(METHOD_VERSION[method] ?? '1.0')
|
|
51
|
+
const localVersion = parseVersion(version)
|
|
52
|
+
if (methodVersion && localVersion && methodVersion.major !== localVersion.major) {
|
|
53
|
+
return createResponse({ id: envelope.id, error: { code: ERROR_CODES.UNSUPPORTED_VERSION, message: `method ${method} requires contract major ${methodVersion.major}` } })
|
|
54
|
+
}
|
|
55
|
+
logs.invoked += 1
|
|
56
|
+
const result = await handler(envelope.params ?? null, envelope)
|
|
57
|
+
return createResponse({ id: envelope.id, result: result ?? null, meta: { role, instanceId } })
|
|
58
|
+
} catch (error) {
|
|
59
|
+
logs.failed += 1
|
|
60
|
+
logs.lastError = normalizeError(error, ERROR_CODES.HANDLER_THREW)
|
|
61
|
+
if (!error?.code) logs.lastError.code = ERROR_CODES.HANDLER_THREW
|
|
62
|
+
try { logger?.warn?.(`[contract] ${method} failed: ${logs.lastError.code} ${logs.lastError.message}`) } catch { /* ignore */ }
|
|
63
|
+
return createResponse({ id: envelope?.id, error: logs.lastError })
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
hello,
|
|
69
|
+
invoke,
|
|
70
|
+
describe() {
|
|
71
|
+
return {
|
|
72
|
+
protocol: 'fang-master', contractVersion: version, role, instanceId,
|
|
73
|
+
methods: [...registry.keys()], capabilities, transports, stats: { ...logs },
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang/dsh-Master contributors — see LICENSE.
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs/promises'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
CONTRACT_VERSION,
|
|
9
|
+
DEFAULT_TRANSPORT_ORDER,
|
|
10
|
+
ERROR_CODES,
|
|
11
|
+
LEGACY_SERVICE_KEYS,
|
|
12
|
+
METHODS,
|
|
13
|
+
PROTOCOL,
|
|
14
|
+
} from './constants.js'
|
|
15
|
+
import { ContractTransportError, createEnvelope, createResponse, normalizeError, validateEnvelope } from './envelope.js'
|
|
16
|
+
import { helloPayload, negotiate } from './negotiate.js'
|
|
17
|
+
|
|
18
|
+
function safeGet(ctx, name) {
|
|
19
|
+
try {
|
|
20
|
+
if (!ctx || typeof ctx !== 'object') return null
|
|
21
|
+
if (typeof ctx.get === 'function') return ctx.get(name) ?? null
|
|
22
|
+
return ctx[name] ?? null
|
|
23
|
+
} catch { return null }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, Math.max(1, ms))) }
|
|
27
|
+
|
|
28
|
+
function authHeaders(token) {
|
|
29
|
+
return token ? { authorization: `Bearer ${token}` } : {}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function baseOf(url) { return String(url ?? '').trim().replace(/\/+$/, '') }
|
|
33
|
+
|
|
34
|
+
/** 统一传输接口:{name, kind, available(), hello(), invoke(method, params, opts)}。 */
|
|
35
|
+
|
|
36
|
+
export function createLocalTransport({ ctx, serviceName = 'fangContract' } = {}) {
|
|
37
|
+
const service = () => safeGet(ctx, serviceName)
|
|
38
|
+
return {
|
|
39
|
+
name: 'local', kind: 'local', available: () => Boolean(service() && typeof service().invoke === 'function'),
|
|
40
|
+
async hello() {
|
|
41
|
+
const s = service()
|
|
42
|
+
if (!s) throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'local contract service is absent', { transport: 'local' })
|
|
43
|
+
return await s.hello()
|
|
44
|
+
},
|
|
45
|
+
async invoke(method, params, opts = {}) {
|
|
46
|
+
const s = service()
|
|
47
|
+
if (!s || typeof s.invoke !== 'function') throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'local contract service is absent', { transport: 'local' })
|
|
48
|
+
const response = await s.invoke(createEnvelope({ method, params, source: 'local', meta: opts.meta, timeoutMs: opts.timeoutMs }))
|
|
49
|
+
const check = validateEnvelope(response, { response: true })
|
|
50
|
+
if (!check.ok) throw new ContractTransportError(ERROR_CODES.INVALID_ENVELOPE, check.errors.join(','), { transport: 'local' })
|
|
51
|
+
return response
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createHttpTransport({ baseUrl = '', token = '', headers: extraHeaders = {}, fetchImpl = fetch, timeoutMs = 15_000, pathPrefix = '/fang/api/contract' } = {}) {
|
|
57
|
+
const base = baseOf(baseUrl)
|
|
58
|
+
const prefix = `/${String(pathPrefix).replace(/^\/+|\/+$/g, '')}`
|
|
59
|
+
const available = () => Boolean(base)
|
|
60
|
+
const call = async (endpoint, role, fn, callTimeoutMs = timeoutMs) => {
|
|
61
|
+
if (!available()) throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'http contract baseUrl is missing', { transport: 'http' })
|
|
62
|
+
const effectiveTimeout = Math.max(1, Number(callTimeoutMs) || timeoutMs)
|
|
63
|
+
const controller = new AbortController()
|
|
64
|
+
const timer = setTimeout(() => controller.abort(), effectiveTimeout)
|
|
65
|
+
timer.unref?.()
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetchImpl(`${base}${prefix}${endpoint}`, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'content-type': 'application/json', accept: 'application/json', ...authHeaders(token), ...(extraHeaders && typeof extraHeaders === 'object' ? extraHeaders : {}) },
|
|
70
|
+
body: JSON.stringify(role),
|
|
71
|
+
signal: controller.signal,
|
|
72
|
+
})
|
|
73
|
+
const text = await response.text()
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
throw new ContractTransportError(ERROR_CODES.TRANSPORT_HTTP_ERROR, `HTTP ${response.status}`, { transport: 'http', detail: text.slice(0, 500) })
|
|
76
|
+
}
|
|
77
|
+
let parsed = null
|
|
78
|
+
try { parsed = JSON.parse(text) } catch { throw new ContractTransportError(ERROR_CODES.INVALID_ENVELOPE, 'HTTP response is not JSON', { transport: 'http' }) }
|
|
79
|
+
return fn(parsed)
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error instanceof ContractTransportError) throw error
|
|
82
|
+
if (error?.name === 'AbortError') throw new ContractTransportError(ERROR_CODES.TRANSPORT_TIMEOUT, `HTTP contract timeout (>${timeoutMs}ms)`, { transport: 'http', retryable: true })
|
|
83
|
+
throw new ContractTransportError(ERROR_CODES.TRANSPORT_HTTP_ERROR, String(error?.message ?? error), { transport: 'http', retryable: true })
|
|
84
|
+
} finally { clearTimeout(timer) }
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
name: 'http', kind: 'http', available,
|
|
88
|
+
async hello() {
|
|
89
|
+
return await call('/hello', { method: METHODS.HELLO }, (parsed) => {
|
|
90
|
+
// 允许 /hello 直接返回 payload,或返回标准 response envelope。
|
|
91
|
+
if (parsed && parsed.protocol) return parsed
|
|
92
|
+
if (parsed && parsed.ok && parsed.result) return parsed.result
|
|
93
|
+
throw new ContractTransportError(ERROR_CODES.INVALID_ENVELOPE, 'invalid hello payload', { transport: 'http' })
|
|
94
|
+
})
|
|
95
|
+
},
|
|
96
|
+
async invoke(method, params, opts = {}) {
|
|
97
|
+
const envelope = createEnvelope({ method, params, source: 'http', meta: opts.meta, timeoutMs: opts.timeoutMs })
|
|
98
|
+
return await call('/invoke', envelope, (parsed) => {
|
|
99
|
+
const check = validateEnvelope(parsed, { response: true })
|
|
100
|
+
if (!check.ok) throw new ContractTransportError(ERROR_CODES.INVALID_ENVELOPE, check.errors.join(','), { transport: 'http' })
|
|
101
|
+
return parsed
|
|
102
|
+
}, opts.timeoutMs)
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function atomicWrite(file, text) {
|
|
108
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
109
|
+
return fs.writeFile(tmp, text, 'utf8').then(() => fs.rename(tmp, file))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Windows 对端仍持有句柄时 unlink 可能短暂 EPERM;成功响应后做有界重试清理。 */
|
|
113
|
+
async function removeFileWithRetry(file, attempts = 8) {
|
|
114
|
+
for (let index = 0; index < attempts; index += 1) {
|
|
115
|
+
try { await fs.unlink(file); return true } catch (error) {
|
|
116
|
+
if (error?.code === 'ENOENT') return true
|
|
117
|
+
if (index + 1 < attempts) await sleep(25 * (index + 1))
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 文件 outbox 兜底传输:同一文件系统、无网络/无 HTTP 时使用。
|
|
125
|
+
* 请求写 requests/<id>.json,对端(startFileContractServer)写 responses/<id>.json。
|
|
126
|
+
*/
|
|
127
|
+
export function createFileTransport({ dir = '', fetchImpl = null, pollMs = 100, timeoutMs = 15_000 } = {}) {
|
|
128
|
+
const root = String(dir ?? '').trim()
|
|
129
|
+
const directories = {
|
|
130
|
+
request: path.join(root, 'requests'),
|
|
131
|
+
response: path.join(root, 'responses'),
|
|
132
|
+
}
|
|
133
|
+
const ensure = async () => {
|
|
134
|
+
await fs.mkdir(directories.request, { recursive: true })
|
|
135
|
+
await fs.mkdir(directories.response, { recursive: true })
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
name: 'file', kind: 'file',
|
|
139
|
+
available: () => Boolean(root),
|
|
140
|
+
async hello() {
|
|
141
|
+
if (!root) throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'file transport dir is missing', { transport: 'file' })
|
|
142
|
+
return await this.invoke(METHODS.HELLO, null).then((response) => (response.ok ? response.result : Promise.reject(new ContractTransportError(ERROR_CODES.REMOTE_ERROR, response.error?.message ?? 'hello failed', { transport: 'file' }))))
|
|
143
|
+
},
|
|
144
|
+
async invoke(method, params, opts = {}) {
|
|
145
|
+
if (!root) throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'file transport dir is missing', { transport: 'file' })
|
|
146
|
+
await ensure()
|
|
147
|
+
const envelope = createEnvelope({ method, params, source: 'file', meta: opts.meta, timeoutMs: opts.timeoutMs })
|
|
148
|
+
const requestFile = path.join(directories.request, `${envelope.id}.json`)
|
|
149
|
+
const responseFile = path.join(directories.response, `${envelope.id}.json`)
|
|
150
|
+
await atomicWrite(requestFile, JSON.stringify(envelope))
|
|
151
|
+
const effectiveTimeout = Math.max(1, Number(opts.timeoutMs) || timeoutMs)
|
|
152
|
+
const deadline = Date.now() + effectiveTimeout
|
|
153
|
+
while (Date.now() < deadline) {
|
|
154
|
+
try {
|
|
155
|
+
const text = await fs.readFile(responseFile, 'utf8')
|
|
156
|
+
await removeFileWithRetry(responseFile)
|
|
157
|
+
const parsed = JSON.parse(text)
|
|
158
|
+
const check = validateEnvelope(parsed, { response: true })
|
|
159
|
+
if (!check.ok) throw new ContractTransportError(ERROR_CODES.INVALID_ENVELOPE, check.errors.join(','), { transport: 'file' })
|
|
160
|
+
// 请求文件由调用方负责回收:服务端实现可能只写响应而不删请求,
|
|
161
|
+
// 成功匹配响应后必须幂等清理,避免 contract-outbox 长期积累。
|
|
162
|
+
await removeFileWithRetry(requestFile)
|
|
163
|
+
return parsed
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error instanceof ContractTransportError) throw error
|
|
166
|
+
}
|
|
167
|
+
await sleep(pollMs)
|
|
168
|
+
}
|
|
169
|
+
await removeFileWithRetry(requestFile)
|
|
170
|
+
await removeFileWithRetry(responseFile)
|
|
171
|
+
throw new ContractTransportError(ERROR_CODES.TRANSPORT_TIMEOUT, `file contract timeout (>${effectiveTimeout}ms)`, { transport: 'file', retryable: true })
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 文件 fallback 的服务端轮询器:读取 requests/,调用 contractServer.invoke,写 responses/。
|
|
178
|
+
* @returns {{stop:()=>void, describe:()=>(object)}}
|
|
179
|
+
*/
|
|
180
|
+
export function startFileContractServer({ dir, server, pollMs = 100, maxAgeMs = 60_000, logger = null } = {}) {
|
|
181
|
+
const root = String(dir ?? '').trim()
|
|
182
|
+
if (!root) throw new Error('startFileContractServer requires dir')
|
|
183
|
+
const requestDir = path.join(root, 'requests')
|
|
184
|
+
const responseDir = path.join(root, 'responses')
|
|
185
|
+
let stopped = false
|
|
186
|
+
let timer = null
|
|
187
|
+
const tick = async () => {
|
|
188
|
+
if (stopped) return
|
|
189
|
+
try {
|
|
190
|
+
await fs.mkdir(requestDir, { recursive: true })
|
|
191
|
+
await fs.mkdir(responseDir, { recursive: true })
|
|
192
|
+
const entries = await fs.readdir(requestDir).catch(() => [])
|
|
193
|
+
for (const name of entries) {
|
|
194
|
+
if (!name.endsWith('.json')) continue
|
|
195
|
+
const file = path.join(requestDir, name)
|
|
196
|
+
try {
|
|
197
|
+
const stat = await fs.stat(file)
|
|
198
|
+
if (Date.now() - stat.mtimeMs > maxAgeMs) { await fs.unlink(file).catch(() => {}); continue }
|
|
199
|
+
const envelope = JSON.parse(await fs.readFile(file, 'utf8'))
|
|
200
|
+
const response = await server.invoke(envelope)
|
|
201
|
+
await atomicWrite(path.join(responseDir, `${envelope.id}.json`), JSON.stringify(response))
|
|
202
|
+
await fs.unlink(file).catch(() => {})
|
|
203
|
+
} catch (error) {
|
|
204
|
+
try { logger?.warn?.(`[contract:file] ${name}: ${String(error?.message ?? error)}`) } catch { /* ignore */ }
|
|
205
|
+
await fs.unlink(file).catch(() => {})
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} catch { /* poll must not die */ }
|
|
209
|
+
if (!stopped) timer = setTimeout(tick, pollMs)
|
|
210
|
+
}
|
|
211
|
+
timer = setTimeout(tick, pollMs)
|
|
212
|
+
return {
|
|
213
|
+
stop() { stopped = true; if (timer) clearTimeout(timer); timer = null },
|
|
214
|
+
describe() { return { transport: 'file', dir: root, pollMs, stopped } },
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** 旧版 3.1.x 服务键回退:接口包被替换/新端点不存在时仍能工作。 */
|
|
219
|
+
export function createLegacyTransport({ ctx } = {}) {
|
|
220
|
+
const forker = () => safeGet(ctx, LEGACY_SERVICE_KEYS.forker)
|
|
221
|
+
const decision = () => safeGet(ctx, LEGACY_SERVICE_KEYS.decisionCenter)
|
|
222
|
+
const sessions = () => safeGet(ctx, LEGACY_SERVICE_KEYS.sessionCatalog)
|
|
223
|
+
const orchestrator = () => safeGet(ctx, LEGACY_SERVICE_KEYS.orchestrator)
|
|
224
|
+
const available = () => Boolean(forker() || decision() || sessions() || orchestrator())
|
|
225
|
+
return {
|
|
226
|
+
name: 'legacy', kind: 'legacy', available,
|
|
227
|
+
async hello() {
|
|
228
|
+
return {
|
|
229
|
+
protocol: PROTOCOL, contractVersion: '0.9.0', role: 'mother', instanceId: 'legacy',
|
|
230
|
+
methods: [METHODS.HEALTH, METHODS.FORK_SPAWN, METHODS.DECISION_CREATE, METHODS.SESSION_LIST],
|
|
231
|
+
capabilities: ['sync'], transports: ['legacy'],
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
async invoke(method, params) {
|
|
235
|
+
if (method === METHODS.HEALTH) return createResponse({ result: { ok: true, legacy: true } })
|
|
236
|
+
if (method === METHODS.FORK_SPAWN) {
|
|
237
|
+
const service = forker()
|
|
238
|
+
if (!service || typeof service.spawn !== 'function') throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'legacy fangForker is absent', { transport: 'legacy' })
|
|
239
|
+
return createResponse({ result: await service.spawn(params ?? {}) })
|
|
240
|
+
}
|
|
241
|
+
if (method === METHODS.DECISION_CREATE) {
|
|
242
|
+
const service = decision()
|
|
243
|
+
if (!service || typeof service.create !== 'function') throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'legacy fangDecisionCenter is absent', { transport: 'legacy' })
|
|
244
|
+
return createResponse({ result: await service.create(params ?? {}) })
|
|
245
|
+
}
|
|
246
|
+
if (method === METHODS.SESSION_LIST) {
|
|
247
|
+
const service = sessions()
|
|
248
|
+
if (!service || typeof service.list !== 'function') throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'legacy fangSessionCatalog is absent', { transport: 'legacy' })
|
|
249
|
+
return createResponse({ result: await service.list(params ?? {}) })
|
|
250
|
+
}
|
|
251
|
+
throw new ContractTransportError(ERROR_CODES.UNSUPPORTED_METHOD, `legacy transport cannot serve ${method}`, { transport: 'legacy' })
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 传输路由器:按 local → http → file → legacy 顺序尝试;
|
|
258
|
+
* 传输不可用/超时/协议不兼容/未知方法时自动降级;业务错误不降级(避免重复副作用)。
|
|
259
|
+
*/
|
|
260
|
+
export function createTransportRouter({ transports = [], order = DEFAULT_TRANSPORT_ORDER, localHello = null, logger = null } = {}) {
|
|
261
|
+
const byName = new Map(transports.map((transport) => [transport.name, transport]))
|
|
262
|
+
const ordered = order.map((name) => byName.get(name)).filter((transport) => transport && (!transport.available || transport.available()))
|
|
263
|
+
const attempts = []
|
|
264
|
+
const record = (entry) => { attempts.push({ at: Date.now(), ...entry }); if (attempts.length > 100) attempts.shift() }
|
|
265
|
+
return {
|
|
266
|
+
transports: () => ordered.map((transport) => ({ name: transport.name, kind: transport.kind })),
|
|
267
|
+
attempts: () => [...attempts],
|
|
268
|
+
describe() {
|
|
269
|
+
return {
|
|
270
|
+
order,
|
|
271
|
+
available: ordered.map((transport) => transport.name),
|
|
272
|
+
configured: transports.map((transport) => ({ name: transport.name, kind: transport.kind, available: transport.available ? transport.available() : true })),
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
async hello() {
|
|
276
|
+
const local = localHello ?? helloPayload({ role: 'client', instanceId: 'contract-client' })
|
|
277
|
+
for (const transport of ordered) {
|
|
278
|
+
try {
|
|
279
|
+
const remote = await transport.hello()
|
|
280
|
+
const negotiation = negotiate(local, remote)
|
|
281
|
+
record({ transport: transport.name, action: 'hello', ok: true, compatible: negotiation.compatible, reason: negotiation.reason ?? null })
|
|
282
|
+
if (negotiation.compatible) return { ok: true, transport: transport.name, hello: remote, negotiation, fallback: null }
|
|
283
|
+
// remote-too-old/新:若还有后续传输(如 legacy),继续降级。
|
|
284
|
+
if (transport.name !== 'legacy') continue
|
|
285
|
+
return { ok: true, transport: transport.name, hello: remote, negotiation, fallback: 'legacy' }
|
|
286
|
+
} catch (error) {
|
|
287
|
+
record({ transport: transport.name, action: 'hello', ok: false, code: error?.code ?? null })
|
|
288
|
+
try { logger?.warn?.(`[contract] hello via ${transport.name} failed: ${error?.code ?? error?.message ?? error}`) } catch { /* ignore */ }
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return { ok: false, code: ERROR_CODES.ALL_TRANSPORTS_FAILED, error: 'no transport could complete hello', attempts: this.attempts() }
|
|
292
|
+
},
|
|
293
|
+
async invoke(method, params, { idempotencyKey = '', timeoutMs = 30_000 } = {}) {
|
|
294
|
+
const envelopeMeta = idempotencyKey ? { idempotencyKey } : {}
|
|
295
|
+
const failures = []
|
|
296
|
+
for (const transport of ordered) {
|
|
297
|
+
try {
|
|
298
|
+
const response = await transport.invoke(method, params, { timeoutMs, meta: envelopeMeta })
|
|
299
|
+
// 有的传输(local)返回 response envelope;统一读取。
|
|
300
|
+
const ok = response?.ok === true
|
|
301
|
+
record({ transport: transport.name, action: method, ok, code: response?.error?.code ?? null })
|
|
302
|
+
if (ok) return { ok: true, transport: transport.name, response }
|
|
303
|
+
const code = String(response?.error?.code ?? '')
|
|
304
|
+
const fallbackable = code === ERROR_CODES.UNSUPPORTED_METHOD || code === ERROR_CODES.UNSUPPORTED_VERSION || response?.error?.retryable === true
|
|
305
|
+
failures.push({ transport: transport.name, code, message: response?.error?.message ?? '' })
|
|
306
|
+
if (!fallbackable) return { ok: false, transport: transport.name, response, error: response?.error ?? normalizeError(code), attempts: this.attempts() }
|
|
307
|
+
} catch (error) {
|
|
308
|
+
const code = error?.code ?? ERROR_CODES.TRANSPORT_UNAVAILABLE
|
|
309
|
+
failures.push({ transport: transport.name, code, message: String(error?.message ?? error) })
|
|
310
|
+
record({ transport: transport.name, action: method, ok: false, code })
|
|
311
|
+
try { logger?.warn?.(`[contract] ${method} via ${transport.name} failed: ${code} ${error?.message ?? ''}`) } catch { /* ignore */ }
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return { ok: false, code: ERROR_CODES.ALL_TRANSPORTS_FAILED, error: 'all contract transports failed', failures, attempts: this.attempts() }
|
|
315
|
+
},
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export { CONTRACT_VERSION, LEGACY_SERVICE_KEYS, METHODS }
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W2M 互联契约层(contract 1.x):w2m ↔ 瓮城 dsh-Fang ↔ 房主 dsh-Master 的唯一通道。
|
|
3
|
+
*
|
|
4
|
+
* 三个项目相互独立、各自发布。这里不 import 任何对方内部代码,只使用已发布的版本化契约 SDK
|
|
5
|
+
* (逐字节副本见 `src/interop/contract/`,来源与哈希见其 PROVENANCE.md)。
|
|
6
|
+
*
|
|
7
|
+
* 三个方向:
|
|
8
|
+
* * **w2m 作为服务端**:`createContractServer` 承载 `w2m.*` 方法,经 local 服务与
|
|
9
|
+
* `/api/w2m/contract/{hello,invoke,health}` 暴露;同宿主上 fang/master 的 local 传输默认找
|
|
10
|
+
* `fangContract` 服务,所以本插件在它缺席时**也**提供该名字(存在则不覆盖,避免和 fang 抢键)。
|
|
11
|
+
* * **w2m 作为客户端**:`createW2mContractClient` 直接复用 SDK 的 local → http → file → legacy
|
|
12
|
+
* 四级降级路由,调用 fang/master 的方法(`fang.*`、`contract.*`)。
|
|
13
|
+
* * **file 兜底**:配置 `fileDir` 时挂上 SDK 的 file 契约服务端,HTTP 被挡/重启窗口仍能互通。
|
|
14
|
+
*
|
|
15
|
+
* 契约版本与插件版本解耦:`CONTRACT_VERSION`(SDK,1.1.1)管线协议;`W2M_CONTRACT_VERSION`
|
|
16
|
+
* (1.0.0)管 w2m 方法表的语义版本;插件自身版本是 third thing,各自独立演进。
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
CONTRACT_VERSION,
|
|
20
|
+
METHODS as CONTRACT_METHODS,
|
|
21
|
+
PROTOCOL,
|
|
22
|
+
PROTOCOL_VERSION,
|
|
23
|
+
createContractClient,
|
|
24
|
+
createContractServer,
|
|
25
|
+
startFileContractServer,
|
|
26
|
+
} from './contract/index.js';
|
|
27
|
+
|
|
28
|
+
/** w2m 方法表:只新增,不改既有方法语义;删除/改义必须升 W2M_CONTRACT_VERSION 的 MAJOR。 */
|
|
29
|
+
export const W2M_CONTRACT_VERSION = '1.0.0';
|
|
30
|
+
|
|
31
|
+
/** Cordis 插件面:契约层没有必需依赖,Connection 服务在 apply 里按需探测。 */
|
|
32
|
+
export const name = 'w2m-contract';
|
|
33
|
+
export const inject = [];
|
|
34
|
+
|
|
35
|
+
export const W2M_METHODS = Object.freeze({
|
|
36
|
+
STATUS: 'w2m.status',
|
|
37
|
+
METHODS: 'w2m.methods',
|
|
38
|
+
FLEET_SNAPSHOT: 'w2m.fleet.snapshot',
|
|
39
|
+
DEVICES_LIST: 'w2m.devices.list',
|
|
40
|
+
TASKS_LIST: 'w2m.tasks.list',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** 方法 → 契约版本(协商后按公共 minor 裁剪)。 */
|
|
44
|
+
export const W2M_METHOD_VERSION = Object.freeze({
|
|
45
|
+
[W2M_METHODS.STATUS]: '1.0',
|
|
46
|
+
[W2M_METHODS.METHODS]: '1.0',
|
|
47
|
+
[W2M_METHODS.FLEET_SNAPSHOT]: '1.0',
|
|
48
|
+
[W2M_METHODS.DEVICES_LIST]: '1.0',
|
|
49
|
+
[W2M_METHODS.TASKS_LIST]: '1.0',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export const W2M_CONTRACT_CAPABILITIES = Object.freeze(['sync', 'async', 'idempotent']);
|
|
53
|
+
|
|
54
|
+
export const CONTRACT_ROUTE_PREFIX = '/api/w2m/contract';
|
|
55
|
+
export const CONTRACT_LOCAL_SERVICE = 'fangContract';
|
|
56
|
+
export const W2M_CONTRACT_SERVICE = 'w2mContract';
|
|
57
|
+
|
|
58
|
+
/** 服务缺席时的明确错误码:不伪造数据。 */
|
|
59
|
+
const SERVICE_ABSENT = 'w2m-service-absent';
|
|
60
|
+
|
|
61
|
+
function errorOf(code, message) {
|
|
62
|
+
const error = new Error(message);
|
|
63
|
+
error.code = code;
|
|
64
|
+
return error;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 从 Cordis 上下文读一个可选服务(读不到就是 undefined,不猜)。 */
|
|
68
|
+
function serviceOf(ctx, name) {
|
|
69
|
+
try {
|
|
70
|
+
return typeof ctx?.get === 'function' ? (ctx.get(name) ?? undefined) : undefined;
|
|
71
|
+
} catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 组装 w2m 契约服务端。
|
|
78
|
+
*
|
|
79
|
+
* @param {{ctx?: object, instanceId?: string, providers?: object, now?: () => number}} options
|
|
80
|
+
* providers 可注入 `status` / `fleetSnapshot` / `devices` / `tasks`,缺省时对应方法如实报
|
|
81
|
+
* `w2m-service-absent`,而不是返回空数据冒充成功。
|
|
82
|
+
* @returns {{server: object, handlers: object}} server 即 SDK 契约服务端(hello/invoke/describe)。
|
|
83
|
+
*/
|
|
84
|
+
export function createW2mContractServer(options = {}) {
|
|
85
|
+
const { ctx = null, instanceId = `w2m-${process.pid}` } = options;
|
|
86
|
+
// providers 可由调用方注入,也可由宿主在 `w2mContractProviders` 服务里给出;两者都没有就如实报缺席。
|
|
87
|
+
const configured = options.providers ?? serviceOf(ctx, 'w2mContractProviders') ?? {};
|
|
88
|
+
const providers = configured !== null && typeof configured === 'object' ? configured : {};
|
|
89
|
+
const startedAt = Date.now();
|
|
90
|
+
const handler = (method, providerNames) => async (params) => {
|
|
91
|
+
for (const name of providerNames) {
|
|
92
|
+
const provider = providers[name];
|
|
93
|
+
if (typeof provider === 'function') return provider(params ?? null);
|
|
94
|
+
}
|
|
95
|
+
throw errorOf(SERVICE_ABSENT, `${method}: this w2m host exposes no ${providerNames.join('/')} provider`);
|
|
96
|
+
};
|
|
97
|
+
const handlers = {
|
|
98
|
+
[W2M_METHODS.STATUS]: async () => ({
|
|
99
|
+
ok: true,
|
|
100
|
+
plugin: 'w2m',
|
|
101
|
+
contractVersion: W2M_CONTRACT_VERSION,
|
|
102
|
+
sdkVersion: CONTRACT_VERSION,
|
|
103
|
+
protocol: PROTOCOL,
|
|
104
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
105
|
+
instanceId,
|
|
106
|
+
methods: [...Object.keys(W2M_METHODS).map((key) => W2M_METHODS[key])],
|
|
107
|
+
uptimeMs: Date.now() - startedAt,
|
|
108
|
+
}),
|
|
109
|
+
[W2M_METHODS.METHODS]: async () => ({
|
|
110
|
+
ok: true,
|
|
111
|
+
contractVersion: W2M_CONTRACT_VERSION,
|
|
112
|
+
methods: Object.keys(W2M_METHODS).map((key) => ({
|
|
113
|
+
method: W2M_METHODS[key],
|
|
114
|
+
since: W2M_METHOD_VERSION[W2M_METHODS[key]],
|
|
115
|
+
})),
|
|
116
|
+
}),
|
|
117
|
+
[W2M_METHODS.FLEET_SNAPSHOT]: handler(W2M_METHODS.FLEET_SNAPSHOT, ['fleetSnapshot']),
|
|
118
|
+
[W2M_METHODS.DEVICES_LIST]: handler(W2M_METHODS.DEVICES_LIST, ['devices', 'fleetSnapshot']),
|
|
119
|
+
[W2M_METHODS.TASKS_LIST]: handler(W2M_METHODS.TASKS_LIST, ['tasks']),
|
|
120
|
+
};
|
|
121
|
+
const server = createContractServer({
|
|
122
|
+
// 契约 SDK 的角色词表只有 mother/essence/fork(Fang 概念),未知角色会被规整成 fork。
|
|
123
|
+
// w2m 是“不是母体的对等端”,用 essence 表达;w2m 自己的身份在 instanceId 与 w2m.* 方法里。
|
|
124
|
+
role: 'essence',
|
|
125
|
+
instanceId,
|
|
126
|
+
version: W2M_CONTRACT_VERSION,
|
|
127
|
+
capabilities: W2M_CONTRACT_CAPABILITIES,
|
|
128
|
+
transports: ['local', 'http', 'file'],
|
|
129
|
+
handlers,
|
|
130
|
+
});
|
|
131
|
+
return { server, handlers };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 组装 w2m 契约客户端:复用 SDK 的四级传输降级。
|
|
136
|
+
*
|
|
137
|
+
* @param {{ctx?: object, config?: object, fetchImpl?: typeof fetch, logger?: object, instanceId?: string}} options
|
|
138
|
+
* @returns {object} SDK 客户端 + w2m 便捷方法
|
|
139
|
+
*/
|
|
140
|
+
export function createW2mContractClient(options = {}) {
|
|
141
|
+
const client = createContractClient({
|
|
142
|
+
ctx: options.ctx ?? null,
|
|
143
|
+
config: options.config ?? {},
|
|
144
|
+
fetchImpl: options.fetchImpl ?? fetch,
|
|
145
|
+
logger: options.logger ?? null,
|
|
146
|
+
instanceId: options.instanceId ?? `w2m-client-${process.pid}`,
|
|
147
|
+
});
|
|
148
|
+
return {
|
|
149
|
+
...client,
|
|
150
|
+
async status(params = {}, invokeOptions = {}) {
|
|
151
|
+
return client.invoke(W2M_METHODS.STATUS, params, invokeOptions);
|
|
152
|
+
},
|
|
153
|
+
async w2mMethods(params = {}, invokeOptions = {}) {
|
|
154
|
+
return client.invoke(W2M_METHODS.METHODS, params, invokeOptions);
|
|
155
|
+
},
|
|
156
|
+
async fleetSnapshot(params = {}, invokeOptions = {}) {
|
|
157
|
+
return client.invoke(W2M_METHODS.FLEET_SNAPSHOT, params, invokeOptions);
|
|
158
|
+
},
|
|
159
|
+
async devices(params = {}, invokeOptions = {}) {
|
|
160
|
+
return client.invoke(W2M_METHODS.DEVICES_LIST, params, invokeOptions);
|
|
161
|
+
},
|
|
162
|
+
async tasks(params = {}, invokeOptions = {}) {
|
|
163
|
+
return client.invoke(W2M_METHODS.TASKS_LIST, params, invokeOptions);
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function json(status, body) {
|
|
169
|
+
return Response.json(body, { status, headers: { 'cache-control': 'no-store' } });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 从请求体读 JSON(契约信封);读不出就是空对象,交给 SDK 报 invalid-envelope。 */
|
|
173
|
+
async function envelopeOf(request) {
|
|
174
|
+
if (request.method === 'GET') {
|
|
175
|
+
try {
|
|
176
|
+
const url = new URL(request.url, 'http://127.0.0.1');
|
|
177
|
+
const method = url.searchParams.get('method');
|
|
178
|
+
if (method === null) return null;
|
|
179
|
+
const paramsRaw = url.searchParams.get('params');
|
|
180
|
+
return { method, params: paramsRaw === null ? null : JSON.parse(paramsRaw) };
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const value = await request.json();
|
|
187
|
+
return value !== null && typeof value === 'object' ? value : null;
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 把契约端挂到宿主 Connection fetch 注册表上。
|
|
195
|
+
*
|
|
196
|
+
* 路径固定为 `/api/w2m/contract/*`:w2m 不占用 fang 的 `/fang/api/contract/*`,同宿主联通用
|
|
197
|
+
* local 服务(默认 `fangContract`),跨宿主时对端把 pathPrefix 配成这里。
|
|
198
|
+
*
|
|
199
|
+
* @param {object} ctx Cordis 上下文。
|
|
200
|
+
* @param {{server: object}} deps
|
|
201
|
+
* @returns {{mounted: boolean, reason: string|null}}
|
|
202
|
+
*/
|
|
203
|
+
export function registerContractRoutes(ctx, deps) {
|
|
204
|
+
const status = { mounted: false, reason: 'the host has no Connection service' };
|
|
205
|
+
if (typeof ctx?.inject !== 'function') return status;
|
|
206
|
+
ctx.inject(['connection'], (webCtx) => {
|
|
207
|
+
const registry = webCtx?.connection?.fetch;
|
|
208
|
+
if (registry === undefined || typeof registry.register !== 'function') {
|
|
209
|
+
status.reason = 'the connection service carries no fetch registry';
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const register = (path, methods, fetchHandler) => {
|
|
213
|
+
registry.register({ path, methods, requestBody: 'buffered', fetch: fetchHandler });
|
|
214
|
+
};
|
|
215
|
+
register(`${CONTRACT_ROUTE_PREFIX}/hello`, ['GET', 'POST'], async () => json(200, deps.server.hello()));
|
|
216
|
+
register(`${CONTRACT_ROUTE_PREFIX}/health`, ['GET'], async () =>
|
|
217
|
+
json(
|
|
218
|
+
200,
|
|
219
|
+
await deps.server.invoke({
|
|
220
|
+
protocol: PROTOCOL,
|
|
221
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
222
|
+
id: 'health',
|
|
223
|
+
method: CONTRACT_METHODS.HEALTH,
|
|
224
|
+
params: null,
|
|
225
|
+
meta: {},
|
|
226
|
+
}),
|
|
227
|
+
),
|
|
228
|
+
);
|
|
229
|
+
register(`${CONTRACT_ROUTE_PREFIX}/invoke`, ['POST', 'GET'], async (request) => {
|
|
230
|
+
const envelope = await envelopeOf(request);
|
|
231
|
+
return json(200, await deps.server.invoke(envelope));
|
|
232
|
+
});
|
|
233
|
+
status.mounted = true;
|
|
234
|
+
status.reason = null;
|
|
235
|
+
});
|
|
236
|
+
return status;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 挂载 w2m 契约层:服务端 + 服务 + HTTP 路由(+ 可选 file 兜底)。
|
|
241
|
+
*
|
|
242
|
+
* @param {object} ctx Cordis 上下文。
|
|
243
|
+
* @param {{instanceId?: string, providers?: object, fileDir?: string, localService?: string}} [config]
|
|
244
|
+
*/
|
|
245
|
+
export function apply(ctx, config = {}) {
|
|
246
|
+
const { server } = createW2mContractServer({
|
|
247
|
+
ctx,
|
|
248
|
+
instanceId:
|
|
249
|
+
typeof config?.instanceId === 'string' && config.instanceId !== '' ? config.instanceId : undefined,
|
|
250
|
+
providers: config?.providers ?? {},
|
|
251
|
+
});
|
|
252
|
+
const service = { ...server };
|
|
253
|
+
const provide = (name) => {
|
|
254
|
+
if (typeof ctx?.effect === 'function') ctx.effect(() => ctx.provide(name, service));
|
|
255
|
+
else if (typeof ctx?.provide === 'function') ctx.provide(name, service);
|
|
256
|
+
};
|
|
257
|
+
provide(W2M_CONTRACT_SERVICE);
|
|
258
|
+
const localServiceName =
|
|
259
|
+
typeof config?.localService === 'string' && config.localService !== ''
|
|
260
|
+
? config.localService
|
|
261
|
+
: CONTRACT_LOCAL_SERVICE;
|
|
262
|
+
// 同宿主对端(fang/master 的 local 传输)默认找 `fangContract`:缺席时才补位,存在就让给 fang。
|
|
263
|
+
if (serviceOf(ctx, localServiceName) === undefined) provide(localServiceName);
|
|
264
|
+
registerContractRoutes(ctx, { server });
|
|
265
|
+
if (typeof config?.fileDir === 'string' && config.fileDir !== '') {
|
|
266
|
+
const fileServer = startFileContractServer({ dir: config.fileDir, server, logger: null });
|
|
267
|
+
if (typeof ctx?.effect === 'function') ctx.effect(() => () => fileServer?.close?.());
|
|
268
|
+
}
|
|
269
|
+
return service;
|
|
270
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -296,7 +296,7 @@ const MAX_STAGES = 16;
|
|
|
296
296
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
297
297
|
* overrides it at runtime for tests and packagers.
|
|
298
298
|
*/
|
|
299
|
-
const PLUGIN_VERSION = '3.
|
|
299
|
+
const PLUGIN_VERSION = '3.2.0';
|
|
300
300
|
|
|
301
301
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
302
302
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|