/** * 极简「平台侧」模拟实现 —— 只为让本示例**不接真平台也能一键跑起来**。 * * ## 它模拟什么 * 反向通道只用到三个端点(真实现见平台仓 `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} 模拟平台句柄(含排队 / 取结果 / 读帧的辅助方法)。 */ export async function startMockPlatform(options = {}) { const holdCapMs = Number.isFinite(options.holdCapMs) ? options.holdCapMs : 800 const token = options.token ?? 'dev-token' /** @type {Array} 待取的工作项 */ const queue = [] /** @type {Map} 已回投的结果(按 callId) */ const results = new Map() /** @type {Array} 收到的帧 */ const frames = [] /** @type {Array} 收到的落库发言 */ 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)) }, } }