This commit is contained in:
commit
5ec99ce227
668 files changed
+164448
No files matched your search
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@dshs/im-plugin-client",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "IM 平台插件/客户端接入 SDK(v1 · REST 面)—— 零依赖、可 vendoring,⛔ 不含平台实现与凭据",
|
||||
"type": "module",
|
||||
"main": "index.mjs",
|
||||
"exports": {
|
||||
".": "./index.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"files": [
|
||||
"index.mjs"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user