211 lines
9.3 KiB
JavaScript
211 lines
9.3 KiB
JavaScript
/**
|
||
* IM 平台 · **插件/客户端接入 SDK(v1 · REST 面)**
|
||
*
|
||
* ## 为什么要有这个文件
|
||
*
|
||
* 插件与端侧都活在**没有平台代码**的地方(插件 = 用户实例里的一个 node 进程;端侧 = 桌面壳 /
|
||
* 浏览器)⇒ 它们只能走 HTTP,而在此之前**每个插件都在自己重写**同一套样板:
|
||
* 拼 URL、塞 `x-dsh-im-instance-token`、判 `{ok:false,reason}`、把状态码当语义……
|
||
* 实测代价见 `poc/im-agent-bridge/lib/index.js`(498 行里大半是这些)。
|
||
*
|
||
* ## 三处形态判据(⛔ 别按"发布一个平台实现包"去做)
|
||
*
|
||
* 1. **实现恒随平台走**:SDK 里 ⛔ **不含任何平台实现**(不 import 平台模块、不内嵌库名 / 连接串),
|
||
* 它只是一层**协议客户端** ⇒ 版本漂移面为零。
|
||
* 2. **零依赖、可 vendoring**:单文件 ESM、只用全局 `fetch`(Node ≥ 18)⇒
|
||
* 插件作者可以直接把这个文件拷进自己的包,或按 `package.json` 的 `name` 依赖。
|
||
* 3. **⛔ 不装载凭据**:实例凭据从 env 读(`DSH_IM_INSTANCE_TOKEN`,平台在起实例时注入),
|
||
* ⛔ SDK 不落盘、不缓存、不写日志(`toString()` 里也⛔ 不含 token)。
|
||
*
|
||
* ## ⚠️ 本版**不含** WS 传输层(有意)
|
||
*
|
||
* 长连接的帧协议 / 心跳 / 重连口径随 E 单客户端(`poc/im-conversation-tabs`)定稿,
|
||
* 现在抽一份会立刻变成"两套实现对不上"。⇒ 传输层 SDK 排在客户端定稿之后;
|
||
* **本文件只覆盖 REST 面**(房间 / 消息 / 数据 / 就绪判读),够插件把业务跑通。
|
||
*
|
||
* ## 用法(插件侧)
|
||
*
|
||
* ```js
|
||
* import { createImClient } from './im-plugin-client/index.mjs'
|
||
*
|
||
* export function apply(ctx) {
|
||
* const im = createImClient({ pluginId: 'dsh-plugin-carbon' }) // 包名,用于数据面身份
|
||
* ctx.on?.('some-event', async () => {
|
||
* const t = im.data.table('carbon_entries')
|
||
* const r = await t.find({ eq: { kind: 'note' }, limit: 20 })
|
||
* if (!r.ok) return ctx.log?.(`数据面不可用:${r.reason} ${r.detail}`)
|
||
* await t.insert({ entry_id: 'e1', payload: { … } })
|
||
* })
|
||
* }
|
||
* ```
|
||
*
|
||
* ⛔ **插件侧不得出现任何 SQL 字符串、不得自行连库、不得猜库名**(`DB-03 §二` 红线)。
|
||
*
|
||
* @module dshs/sdk/im-plugin-client
|
||
*/
|
||
|
||
/** 平台基址 env(平台在起实例时注入;⛔ 不硬编码域名)。 */
|
||
const ENV_URL = 'DSH_PLATFORM_IM_URL'
|
||
/** 实例凭据 env(短期、重启即换;⛔ 不落盘)。 */
|
||
const ENV_TOKEN = 'DSH_IM_INSTANCE_TOKEN'
|
||
|
||
/** 版本号(随 SDK 口径变更递增;插件报障时请带上它)。 */
|
||
export const SDK_VERSION = '1.0.0'
|
||
|
||
/**
|
||
* 建一个接入客户端。
|
||
*
|
||
* @param {object} [options]
|
||
* @param {string} [options.baseUrl] 平台基址;缺省读 `DSH_PLATFORM_IM_URL`。
|
||
* @param {string} [options.token] 实例凭据;缺省读 `DSH_IM_INSTANCE_TOKEN`。
|
||
* @param {string} [options.pluginId] **插件包名**(如 `@dsh-local/trpg-kit`)—— 数据面身份,
|
||
* 以 `x-dsh-im-plugin-id` 头送出。⚠️ 只有数据面接口需要它;不带 ⇒ 数据面接口返回 400。
|
||
* @param {number} [options.timeoutMs] 单次请求超时(默认 15000)。
|
||
* @returns {ImClient}
|
||
*/
|
||
export function createImClient(options = {}) {
|
||
const baseUrl = trimSlash(options.baseUrl ?? process.env[ENV_URL] ?? '')
|
||
const token = options.token ?? process.env[ENV_TOKEN] ?? ''
|
||
const pluginId = options.pluginId ?? ''
|
||
const timeoutMs = options.timeoutMs ?? 15_000
|
||
|
||
/** 配置是否齐备(缺 ⇒ 全部调用立刻回 `not-registered`,⛔ 不发请求、⛔ 不抛)。 */
|
||
const configured = baseUrl !== '' && token !== ''
|
||
|
||
/** 一次底层请求:统一注入凭据头、统一超时、统一把失败折成 `{ok:false,reason,detail}`。 */
|
||
async function request(method, path, body, extraHeaders = {}) {
|
||
if (!configured) {
|
||
return {
|
||
ok: false,
|
||
reason: 'not-registered',
|
||
detail: `未配置 ${ENV_URL} / ${ENV_TOKEN}(平台未注入或本进程不在实例内)`,
|
||
}
|
||
}
|
||
const headers = {
|
||
'x-dsh-im-instance-token': token,
|
||
...(pluginId === '' ? {} : { 'x-dsh-im-plugin-id': pluginId }),
|
||
...extraHeaders,
|
||
}
|
||
if (body !== undefined) headers['content-type'] = 'application/json'
|
||
const ac = new AbortController()
|
||
const timer = setTimeout(() => ac.abort(), timeoutMs)
|
||
try {
|
||
const res = await fetch(`${baseUrl}${path}`, {
|
||
method,
|
||
headers,
|
||
body: body === undefined ? undefined : JSON.stringify(body),
|
||
signal: ac.signal,
|
||
})
|
||
const text = await res.text()
|
||
let parsed
|
||
try {
|
||
parsed = text === '' ? {} : JSON.parse(text)
|
||
} catch {
|
||
return { ok: false, reason: 'bad-args', detail: `响应不是 JSON(HTTP ${res.status})` }
|
||
}
|
||
// 平台的数据面契约:**失败也回 200 + `{ok:false,reason,detail}`** ⇒ 这里原样透传。
|
||
if (typeof parsed === 'object' && parsed !== null && 'ok' in parsed) {
|
||
return { ...parsed, httpStatus: res.status }
|
||
}
|
||
// 身份 / 就绪类问题走非 200(401 / 409 / 403…)—— 折成同一个 `ok:false` 形状,
|
||
// 让插件侧**只需判 `ok`**,⛔ 不必自己维护一张状态码表。
|
||
// ⚠️ `detail` 优先于 `error`:平台的失败体是 `{error:<码>, reason, detail:<人话>}`,
|
||
// 给插件看**人话**(原话口径:不写"未达预期"了事)。
|
||
return {
|
||
ok: false,
|
||
reason: res.status === 401 ? 'not-registered' : res.status === 409 ? 'disabled' : 'bad-args',
|
||
detail: parsed.detail ?? parsed.message ?? parsed.error ?? `HTTP ${res.status}`,
|
||
httpStatus: res.status,
|
||
}
|
||
} catch (err) {
|
||
const aborted = err?.name === 'AbortError'
|
||
return {
|
||
ok: false,
|
||
reason: aborted ? 'timeout' : 'disabled',
|
||
detail: aborted ? `请求超过 ${timeoutMs} ms` : `网络失败:${String(err?.message ?? err)}`,
|
||
}
|
||
} finally {
|
||
clearTimeout(timer)
|
||
}
|
||
}
|
||
|
||
const query = (params) => {
|
||
const usp = new URLSearchParams()
|
||
for (const [k, v] of Object.entries(params ?? {})) {
|
||
if (v === undefined || v === null) continue
|
||
usp.set(k, String(v))
|
||
}
|
||
const s = usp.toString()
|
||
return s === '' ? '' : `?${s}`
|
||
}
|
||
|
||
/** 数据面 facade —— 形状与 `DB-03 §五` 的 `im.data.table(...)` 一致。 */
|
||
const data = {
|
||
/**
|
||
* 就绪判读(**建议插件启动时先问一句**,不必靠"调了才知道报错")。
|
||
* 回 `{ok, available, reason, detail, dbName, tables}`;`available:false` 时 ⛔ 别硬试写。
|
||
*/
|
||
status: () => request('GET', '/api/im/plugin-data/status'),
|
||
|
||
/** 取一张表的句柄(表名 = 声明里的 `name`,⛔ 不带 `p_<pluginId>_` 前缀)。 */
|
||
table(name) {
|
||
const path = `/api/im/data/${encodeURIComponent(name)}`
|
||
return {
|
||
insert: (row) => request('POST', path, row),
|
||
update: (id, patch) => request('PATCH', `${path}/${encodeURIComponent(id)}`, patch),
|
||
remove: (id) => request('DELETE', `${path}/${encodeURIComponent(id)}`),
|
||
count: (filter = {}) => request('GET', `${path}/count${query(filterToQuery(filter))}`),
|
||
find: (filter = {}) => request('GET', `${path}${query(filterToQuery(filter))}`),
|
||
}
|
||
},
|
||
}
|
||
|
||
/** 房间 / 消息面(与网页端**同一套接口、同一套鉴权**,`DB-03 §八·补`)。 */
|
||
const rooms = {
|
||
list: (filter = {}) => request('GET', `/api/im/rooms${query(filter)}`),
|
||
create: (input) => request('POST', '/api/im/rooms', input),
|
||
join: (roomId) => request('POST', `/api/im/rooms/${encodeURIComponent(roomId)}/join`),
|
||
members: (roomId) => request('GET', `/api/im/rooms/${encodeURIComponent(roomId)}/members`),
|
||
messages: (roomId, opts = {}) =>
|
||
request('GET', `/api/im/rooms/${encodeURIComponent(roomId)}/messages${query(opts)}`),
|
||
send: (roomId, input) => request('POST', `/api/im/rooms/${encodeURIComponent(roomId)}/messages`, input),
|
||
}
|
||
|
||
return {
|
||
version: SDK_VERSION,
|
||
/** 配置齐备?(`false` ⇒ 所有调用都会回 `not-registered`,⛔ 不会抛) */
|
||
get configured() {
|
||
return configured
|
||
},
|
||
data,
|
||
rooms,
|
||
request,
|
||
/** 诊断串:**⛔ 不含 token**(只回"有没有配、配到哪台")。 */
|
||
toString: () => `ImClient(v${SDK_VERSION}, baseUrl=${baseUrl || '<未配置>'}, token=${token === '' ? '无' : '已注入'}, pluginId=${pluginId || '<无>'})`,
|
||
}
|
||
}
|
||
|
||
/** `DataFilter` → 查询串(`eq.<列>=` + 四个保留键;与平台 `filterOf` 同口径)。 */
|
||
function filterToQuery(filter) {
|
||
const out = {}
|
||
for (const [k, v] of Object.entries(filter.eq ?? {})) out[`eq.${k}`] = v
|
||
if (filter.afterCursor !== undefined) out.after = filter.afterCursor
|
||
if (filter.order !== undefined) out.order = filter.order
|
||
if (filter.limit !== undefined) out.limit = filter.limit
|
||
return out
|
||
}
|
||
|
||
/** 去掉尾斜杠(⛔ 不在这里做 URL 合法性校验 —— 那会让"配错"变成"起不来")。 */
|
||
function trimSlash(url) {
|
||
return String(url).replace(/\/+$/, '')
|
||
}
|
||
|
||
/**
|
||
* @typedef {object} ImClient
|
||
* @property {string} version
|
||
* @property {boolean} configured
|
||
* @property {object} data
|
||
* @property {object} rooms
|
||
* @property {(method: string, path: string, body?: unknown, headers?: Record<string,string>) => Promise<{ok: boolean, reason?: string, detail?: string}>} request
|
||
*/
|