207 lines
6.9 KiB
JavaScript
207 lines
6.9 KiB
JavaScript
/**
|
||
* 极简「平台侧」模拟实现 —— 只为让本示例**不接真平台也能一键跑起来**。
|
||
*
|
||
* ## 它模拟什么
|
||
* 反向通道只用到三个端点(真实现见平台仓 `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))
|
||
},
|
||
}
|
||
}
|