Files
dsh_shenxian/poc/business-plugins-im/minimal-callbacks/mock-platform.mjs
T
admin 5ec99ce227
build / build-and-scan (push) Canceled after 0s
chore: 文档库全量重新入库(2026-10-20)
2026-10-20 15:05:55 +08:00

207 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 极简「平台侧」模拟实现 —— 只为让本示例**不接真平台也能一键跑起来**。
*
* ## 它模拟什么
* 反向通道只用到三个端点(真实现见平台仓 `src/web/routes/im.ts` 的「纯插入」段):
* | 端点 | 方向 | 作用 |
* |---|---|---|
* | `POST /api/im/plugins/register` | 插件 → 平台 | 交声明面 |
* | `POST /api/im/plugins/bridge/pull` | 插件 → 平台(长轮询) | 取工作项;队列空 ⇒ 挂起到 `waitMs` 后回空 |
* | `POST /api/im/plugins/bridge/result` | 插件 → 平台 | 回投结果(⛔ 幂等:过期 `callId` ⇒ `unknown-call-id`) |
* 外加两个出向端点(`out/frame` / `out/message`),用来断言事件回调**真的跑到了**。
*
* ⚠️ **这不是平台的替代品**:它不校验房间成员、不落库、无鉴权语义,只做"协议形状对齐"。
* 端到端真实验收仍要连真平台(见本目录 README「接真平台」)。
*
* @module mock-platform
*/
import { createServer } from 'node:http'
/** 服务端长轮询硬上限(与平台 `ImBridgeCallbackBus.maxPollHoldMs = 20 s` 同一口径)。 */
export const MAX_POLL_HOLD_MS = 20_000
/**
* 起一个模拟平台。
*
* @param {object} [options]
* @param {number} [options.holdCapMs] - **本模拟器**单次挂起上限(缺省 800)。⚠️ 只为让自检快跑;
* 真平台是 20 s。它调小了 ⇒ 客户端会更快重拨,属预期(不会因此误判功能)。
* @param {string} [options.token] - 期望的实例凭据(缺省 `dev-token`)。
* @returns {Promise<object>} 模拟平台句柄(含排队 / 取结果 / 读帧的辅助方法)。
*/
export async function startMockPlatform(options = {}) {
const holdCapMs = Number.isFinite(options.holdCapMs) ? options.holdCapMs : 800
const token = options.token ?? 'dev-token'
/** @type {Array<object>} 待取的工作项 */
const queue = []
/** @type {Map<string, object>} 已回投的结果(按 callId) */
const results = new Map()
/** @type {Array<object>} 收到的帧 */
const frames = []
/** @type {Array<object>} 收到的落库发言 */
const outMessages = []
/** @type {Array<() => void>} 在挂起的长轮询(有货就唤醒) */
let waiters = []
let registered = null
let pullCount = 0
let lastToken = null
function wake() {
const pending = waiters
waiters = []
for (const fn of pending) fn()
}
function json(res, status, body) {
const text = JSON.stringify(body)
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(text) })
res.end(text)
}
async function readBody(req) {
const chunks = []
for await (const c of req) chunks.push(c)
const text = Buffer.concat(chunks).toString('utf8')
if (text === '') return {}
try {
return JSON.parse(text)
} catch {
return { __badJson: text }
}
}
const server = createServer((req, res) => {
void (async () => {
const path = (req.url ?? '').split('?')[0]
const body = req.method === 'POST' ? await readBody(req) : {}
lastToken = req.headers['x-dsh-im-instance-token'] ?? null
if (path === '/api/im/plugins/register') {
registered = { pluginId: req.headers['x-dsh-im-plugin-id'] ?? null, manifest: body }
json(res, 200, { ok: true, pluginId: registered.pluginId })
return
}
if (path === '/api/im/plugins/bridge/pull') {
pullCount += 1
const want = Number.isFinite(body?.waitMs) ? body.waitMs : 0
const deadline = Date.now() + Math.max(0, Math.min(want, holdCapMs))
while (queue.length === 0 && Date.now() < deadline) {
await new Promise((resolve) => {
const t = setTimeout(resolve, Math.max(1, deadline - Date.now()))
waiters.push(() => {
clearTimeout(t)
resolve()
})
})
}
const max = Number.isFinite(body?.max) ? body.max : 32
const items = queue.splice(0, Math.max(1, max))
json(res, 200, { ok: true, items })
return
}
if (path === '/api/im/plugins/bridge/result') {
const callId = typeof body?.callId === 'string' ? body.callId : ''
if (callId === '') {
json(res, 400, { ok: false, error: 'call-id-required' })
return
}
if (results.has(callId)) {
// 真口径:幂等,过期 / 重复回投 ⇒ 具名拒绝,⛔ 不重复 resolve。
json(res, 200, { ok: false, reason: 'unknown-call-id' })
return
}
results.set(callId, { ok: body.ok === true, reason: body.reason, detail: body.detail })
json(res, 200, { ok: true, accepted: true })
return
}
if (path === '/api/im/plugins/out/frame') {
frames.push(body)
json(res, 200, { ok: true })
return
}
if (path === '/api/im/plugins/out/message') {
outMessages.push(body)
json(res, 200, { ok: true })
return
}
if (path === '/api/im/plugins/unregister') {
registered = null
json(res, 200, { ok: true })
return
}
if (path === '/api/im/rooms') {
json(res, 200, { ok: true, rooms: [] })
return
}
json(res, 404, { ok: false, error: 'not-found', detail: path })
})().catch((err) => {
try {
json(res, 500, { ok: false, error: 'mock-failure', detail: String(err) })
} catch {
/* 已断开 */
}
})
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const port = server.address().port
return {
url: `http://127.0.0.1:${port}`,
token,
/** 平台收到的登记面(含 manifest 原文)—— 用来断言"函数没被送出去"。 */
get registered() {
return registered
},
get frames() {
return frames
},
get outMessages() {
return outMessages
},
get pullCount() {
return pullCount
},
get lastToken() {
return lastToken
},
/** 造一条待判定的发言(模拟"用户在写")。 */
queueSpeakCheck({ callId, input, timeoutMs = 50 }) {
queue.push({ kind: 'speak.check', callId, pluginId: registered?.pluginId ?? '', timeoutMs, input })
wake()
},
/** 造一条事件(模拟"有人发言了")。 */
queueEvent({ callId, event, timeoutMs = 50 }) {
queue.push({ kind: 'event.deliver', callId, pluginId: registered?.pluginId ?? '', timeoutMs, event })
wake()
},
/** 等某个 `callId` 的回投结果(超时 ⇒ 回 `null`,由调用方断言失败)。 */
async waitResult(callId, timeoutMs = 3000) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (results.has(callId)) return results.get(callId)
await new Promise((r) => setTimeout(r, 10))
}
return null
},
async close() {
wake()
await new Promise((resolve) => server.close(resolve))
},
}
}