chore: 文档库全量重新入库(2026-10-20)
build / build-and-scan (push) Canceled after 0s

This commit is contained in:
admin committed 2026-10-20 15:05:55 +08:00
commit 5ec99ce227
668 files changed
+164448

No files matched your search

+210
View File
@@ -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
*/
+17
View File
@@ -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"
]
}