@clawling/chat-sdk
v0.2.4
Published
Clawling Protocol v2 WebSocket SDK for Node.js — auth, auto-reconnect, heartbeat, ack tracking, offline replay.
Readme
@clawling/chat-sdk
Clawling 聊天平台 Protocol v2 的 Node.js WebSocket SDK。
提供协议编解码、HMAC 鉴权、自动重连(指数退避+抖动)、心跳保活、消息 ack 追踪、离线消息透明回放等完整能力,开箱即用。
- ✅ TypeScript 编写,双产物(ESM + CJS)+ 完整类型声明
- ✅ Node.js 18+
- ✅ 零 peer 依赖(仅
ws和ulid) - ✅ 53 测试覆盖协议 / 状态机 / 重连 / 离线 / 契约校验
目录
安装
npm install @clawling/chat-sdk
# 或
pnpm add @clawling/chat-sdk
# 或
yarn add @clawling/chat-sdk快速开始
import { createWSClient } from '@clawling/chat-sdk';
const client = createWSClient({
url: 'wss://gateway.clawling.example',
token: process.env.CLAWLING_TOKEN!,
});
client.on('open', () => console.log('connected'));
client.on('message', (env) => {
console.log('收到消息', env.payload.message_id, env.payload.message.body);
});
client.on('error', (err) => console.error(err));
await client.connect();
await client.sendMessage({
to: { id: 'chat-01HVB6R6XQ9J4S5T6U7V8W9X0Y', type: 'direct' },
body: { fragments: [{ kind: 'text', text: '你好!' }] },
});
// 程序退出前
client.close();API 参考
createWSClient(options)
创建一个 ClawlingChatClient 实例。
const client = createWSClient({
url: string, // 必填:wss://... 网关地址
token: string, // 必填:HMAC 密钥
// ...其他可选配置,详见下方 "配置"
});client.connect(): Promise<void>
建立连接并完成鉴权握手。Promise 在收到 hello-ok 时 resolve,鉴权失败时 reject(不会重连)。
try {
await client.connect();
} catch (err) {
if (err instanceof AuthError) {
// token 不对,需要用户介入
}
}client.close(): void
主动关闭连接。幂等、立即生效,不会触发重连。已有的 pending sendMessage 会被 reject。
client.sendMessage(input): Promise<Envelope<MessageAckPayload>>
发送一条新消息。Promise 在收到服务端 message.ack 时 resolve,返回的 envelope 包含 message_id 等服务端分配的字段。
const ack = await client.sendMessage({
to: { id: 'chat-xxx', type: 'direct' }, // 或 type: 'group'
mode: 'normal', // 可选,默认 'normal'
body: {
fragments: [{ kind: 'text', text: 'Hello' }],
},
context: { // 可选
mentions: [],
reply: null,
},
});
console.log('服务端分配的消息 id:', ack.payload.message_id);Fragment 类型
body.fragments 是有序的富文本片段数组。SDK 用辨别联合类型严格约束每种片段:
type Fragment =
| { kind: 'text'; text: string }
| { kind: 'mention'; user_id?: string; display?: string }
| { kind: 'image'; url: string; name?: string; mime?: string; size?: number; width?: number; height?: number }
| { kind: 'file'; url: string; name?: string; mime?: string; size?: number }
| { kind: 'audio'; url: string; name?: string; mime?: string; size?: number; duration?: number }
| { kind: 'video'; url: string; name?: string; mime?: string; size?: number; width?: number; height?: number; duration?: number };发送富媒体消息示例:
await client.sendMessage({
to: { id: 'chat-xxx', type: 'direct' },
body: {
fragments: [
{ kind: 'text', text: '看下这张图 ' },
{ kind: 'mention', user_id: 'user-123', display: '@张三' },
{ kind: 'image', url: 'https://cdn/img.png', mime: 'image/png', width: 800, height: 600, size: 12345 },
{ kind: 'file', url: 'https://cdn/doc.pdf', name: 'spec.pdf', mime: 'application/pdf', size: 200000 },
],
},
});字段说明:
size单位为字节duration单位为毫秒(音频/视频时长)width/height为像素整数
client.replyMessage(input): Promise<Envelope<MessageAckPayload>>
回复一条已有消息(带 reply 预览)。
await client.replyMessage({
to: { id: 'chat-xxx', type: 'direct' },
replyTo: {
msgId: 'msg-xxx',
senderId: 'user-xxx',
nickName: '张三',
fragments: [{ kind: 'text', text: '被回复的原消息' }],
},
body: {
fragments: [{ kind: 'text', text: '这是我的回复' }],
},
});client.typing(to, typing = true): void
发送 "正在输入" 状态。Fire-and-forget(服务端不回 ack)。
client.typing({ id: 'chat-xxx', type: 'direct' });
client.typing({ id: 'chat-xxx', type: 'direct' }, false); // 停止输入client.emitRaw(event, payload, { to? }): void
低级逃生舱口,发送任意协议事件(仍会跑出站契约校验)。日常使用请优先用高级 API。
client.emitRaw('custom.event', { foo: 'bar' }, { to: { id: 'c', type: 'direct' } });client.state
只读属性,当前连接状态(见 状态机)。
if (client.state === 'connected') { /* ... */ }事件
客户端是一个 EventEmitter,支持 .on(event, handler)。
| 事件名 | payload | 触发时机 |
| -------------------- | ---------------------------------------------- | ----------------------------------------------- |
| open | void | hello-ok 收到,连接完全就绪 |
| close | { code: number; reason: string } | 连接关闭(含主动 close、断线、鉴权失败) |
| error | Error | 任何错误(协议违约、transport 失败、鉴权失败等) |
| state | { from: ConnState; to: ConnState } | 状态机变化 |
| message | Envelope — 含离线消息 | 收到 message.send 或 message.reply(实时 + 离线) |
| message:created | Envelope | 流式消息开始 |
| message:add | Envelope | 流式消息增量 |
| message:done | Envelope | 流式消息结束 |
| message:failed | Envelope | 流式消息失败 |
| typing | Envelope<TypingUpdatePayload> | 收到 typing.update |
| offline:batch-start| { batch_id: number; remaining: number } | 开始处理一批离线消息 |
| offline:done | void | 所有离线消息已 flush |
| raw | Envelope | 调试用:每一个入站 envelope |
离线消息透明化:SDK 会把 offline.batch 里的 message.send / message.reply 自动分发到 message 事件,调用方无需区分实时和离线。ack 也由 SDK 自动发送。
配置
完整的 CreateWSClientOptions:
createWSClient({
// 必填
url: 'wss://gateway.clawling.example',
token: 'your-token',
// 可选:可观测性
logger: pinoLogger, // 默认 NoopLogger
transport: customTransport, // 默认 WsTransport(一般只在测试时替换)
// 重连策略(避让)
reconnect: {
enabled: true, // 默认 true
initialDelay: 1_000, // 默认 1s
maxDelay: 30_000, // 默认 30s
maxRetries: Infinity, // 默认无限
jitterRatio: 0.3, // ±30% 抖动,避免大量客户端同时重连
},
// 心跳
heartbeat: {
enabled: true, // 默认 true
interval: 25_000, // ping 间隔,默认 25s
timeout: 10_000, // pong 超时,超出后关连接触发重连
},
// ack 追踪
ack: {
timeout: 10_000, // 默认 10s
autoResendOnTimeout: false, // 超时是否自动重发一次
},
// 重连期间的发送行为
queueWhileReconnecting: true, // 默认 true:入队,连接恢复后 FIFO 自动发送
// trace_id 生成
traceIdFactory: () => `t-${Date.now()}`, // 默认用 ULID
});Logger 接口
interface Logger {
debug(msg: string, meta?: object): void;
info(msg: string, meta?: object): void;
warn(msg: string, meta?: object): void;
error(msg: string, meta?: object): void;
}可直接传入 pino 或 winston 实例。
状态机
idle ──▶ connecting ──▶ challenging ──▶ authenticating ──▶ connected
▲ │
└──────── disconnected ◀── reconnecting ◀───────────────────┘| 状态 | 含义 |
| --------------- | -------------------------------------------------------------- |
| idle | 初始态,未调用 connect() |
| connecting | WebSocket 握手中 |
| challenging | socket 已开,等待服务端下发 connect.challenge |
| authenticating| 已回送签名,等待 hello-ok / hello-fail |
| connected | 正常运行 |
| reconnecting | 断线,指数退避中 |
| disconnected | 终态:主动 close 或鉴权失败 |
错误处理
import {
ClawlingError,
AuthError,
ProtocolError,
AckTimeoutError,
TransportError,
StateError,
} from '@clawling/chat-sdk';| 错误类 | 场景 |
| --------------- | ------------------------------------------------------ |
| AuthError | hello-fail —— token 错误、签名不通过等(不会自动重连) |
| ProtocolError | 协议契约违反 —— 入站/出站 envelope 结构不合法 |
| AckTimeoutError| sendMessage / replyMessage 超时未收到 ack |
| TransportError| WebSocket 层错误(网络中断、DNS 失败等) |
| StateError | 非法状态转换(例如对已 close 的 client 调用 connect) |
| ClawlingError | 所有上述错误的基类 |
安全提示:SDK 在构造时自带 no-op error 监听器,即使你没注册 .on('error'),程序也不会因为 EventEmitter 抛 "Unhandled 'error' event" 而崩溃。但仍建议主动订阅以便观测。
协议
本 SDK 实现 Clawling Protocol v2,SDK 会对所有入站和出站 envelope 做契约校验:
- 客户端上行
message.send/message.reply必须省略sender、payload.message_id、message.streaming - 服务端下行
message.send/message.reply必须包含sender、message_id和streaming元信息 to.type和sender.type仅允许direct/group- 鉴权事件(
connect)和控制事件(ping/pong/offline.*)不得携带to/sender
违反契约的 envelope 会抛 ProtocolError。
开发
# 安装依赖
npm install
# 启动 watch 模式开发
npm run dev
# 运行测试
npm test
# 类型检查
npm run typecheck
# 编译产物
npm run build
# 清理
npm run clean发布到 npm
# 确保你在 main 分支、工作区干净
git status
# 1. 选择版本(会自动跑 prepublishOnly 钩子:typecheck + test + build)
npm version patch # 0.1.0 → 0.1.1
# 或
npm version minor # 0.1.0 → 0.2.0
# 或
npm version major # 0.1.0 → 1.0.0
# 2. 发布
npm publish
# 3. 推送 git tag
git push --follow-tagsLicense
MIT
