@kookapp/im-socket
v0.0.3
Published
Browser-first, resilient IM WebSocket state machine
Readme
@kookapp/im-socket
面向浏览器真实用户会话的 IM WebSocket 状态机。只负责连接、心跳、resume、严格 SN 顺序和生命周期,不包含 Bot、REST、鉴权请求或 KOOK 业务事件模型。
当前版本:0.1.0。已通过 PC 兼容门面接入 apps/webapp 的 Web Adapter;真实网关互通、浏览器矩阵、灰度和生产观测仍是上线门槛。
核心契约(先读这一段)
内核永不因为「失败次数够多」而放弃传输。
只有三种情况会停下来:
| 终态 | 触发 | 恢复方式 |
| ---------- | --------------------------------------------------------------------------------------------- | ------------------------ |
| stopped | 宿主 stop() | 宿主 start() |
| fatal | 服务端明确判定身份无效(provider 抛 fatal: true 的错误,或 classifyClose 返回 'fatal') | 宿主重新登录后 start() |
| disposed | 宿主 dispose() | 不可恢复 |
默认 recoveryStrategy: 'resume' 下,网络失败、网关失败、握手失败、心跳假死一律无限重试(带抖动的有上限指数退避)。Webapp Adapter 显式使用该策略:断连优先续会话,s:5、Resume 降级或无法修复的 SN 缺口会清理旧会话并自动重建,不会主动刷新页面。recoveryStrategy: 'reload' 仅作为宿主明确选择的可选能力保留。
会话语义失效(s:5 / resume 降级 / SN 缺口不可修复)走 hard_reset,它是迁移态不是终点:内核清掉 session 后自行重建,同时发 hardReset 事件让宿主决定业务侧要不要清 store / 重拉。需要先做完清理再放行的场景用 hardResetPolicy: 'manual'。
运行时边界
- 只依赖浏览器标准能力:
WebSocket、AbortController、URL、TextDecoder、performance.now、Promise 和 timer。 - 不 import Node 内置模块,不依赖
ws,不读环境变量或文件系统。 - 唯一运行时 npm 依赖是
pako(DEFLATE 解压)。 IMSocket内核的 gateway 获取由宿主通过gatewayProvider注入;PC 兼容门面根据原生start(config)的get_gateway_api_url/http_headers/token提供浏览器 fetch 适配。- 产物 target 为
ES2020。若宿主 browserslist 覆盖更老的浏览器,需要把本包纳入转译范围。
快速使用
import IMSocket from '@kookapp/im-socket'
const socket = new IMSocket({
compression: true,
gatewayProvider: async ({ signal, compression, resume, sessionId, lastSn }) => {
const response = await getGatewayFromHost({ signal, compression, resume, sessionId, lastSn })
return response.url
},
queryProvider: () => ({
'x-client-machine-id': getMachineId(),
'x-client-utm': getClientUtm(),
}),
// 只有服务端明确拒绝身份时才停止重试
classifyClose: (event) => (event.code === 4001 ? 'fatal' : 'retry'),
})
socket.on('message', (frame) => {
// 完整协议帧:{ s, sn, d },已按 SN 严格连续排序
consumeOrderedFrame(frame)
})
socket.start() // 同步返回 void,不会抛网络错误,也不产生 unhandled rejection
await socket.waitUntilConnected(20_000) // 只表示「本次等待」超时,内核仍在后台重试start() 返回 void。「开始运行」和「何时就绪」是两件事:后者用 waitUntilConnected() 或 connected 事件观测。这样宿主写 socket.start() 不可能产生 unhandled rejection —— 旧设计里 s:5 这条正常协议路径会 reject 一个没人 catch 的 promise。
start() / stop() / dispose() 幂等。
PC SDK 兼容门面
import { ImSessionObject, kookImInitSdk } from '@kookapp/im-socket'
kookImInitSdk({})
const session = new ImSessionObject((eventName, eventData) => {
// on_im_data_event / on_im_state_event / on_im_statistics_event /
// on_im_resume_event / on_im_heartbeat_event
})
session.start({
token,
get_gateway_api_url: `${baseUrl}/api/v3/gateway/index`,
http_headers: JSON.stringify(httpHeaders),
ws_headers: JSON.stringify(wsHeaders),
compress_type: 0,
heartbeat_interval: 30,
})
session.setIp({ ip: '' }) // Web no-op,保留原生方法形态
session.stop()门面状态与原生 KOOK_IM_STATE 一致:Init(0) / Started(1) / GetGatewayOK(2) / GatewayConnected(3) / Connected(4) / Timeout(5) / StopPending(6) / Stopped(7) / Failed(8) / Ended(9)。多个内部细状态映射到同一原生状态时不会重复回调。
宿主集成要求
1. 接 nudge()
window.addEventListener('online', () => socket.nudge('online'))
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') socket.nudge('visible')
})nudge() 的语义是「时间可能已经流逝了,请按当前时刻重新评估 deadline」,并且有一条硬不变量:
只能让 deadline 提前,永远不能推迟、延长或新建义务。
探活路径天然自限流(发完即进入 awaiting_pong,此后调用无效);抢跑重连路径额外受 nudgeMinIntervalMs(默认 1 秒)限流 —— 抢跑本身就是在绕过退避,没有下限时,接在抖动事件上的 nudge() 可以形成不受退避约束的重连风暴。网络恢复后的第一次 nudge() 立即生效。
在这两条约束下它可以安全地接到任意高频事件上。它不修改 session 或 SN。
2. 用 outageStarted / outageEnded 驱动 UI,不要自己映射状态
socket.on('outageStarted', () => showReconnectOverlay())
socket.on('outageEnded', () => hideReconnectOverlay())宿主真正需要的是「要不要动 UI」,不是原始状态迁移。让每个接入方自己去推导「哪些状态该清消息时间线」,早晚有人推错,而错的代价是每次网络抖动都清空全部消息 + 全量重拉。
outageStarted 只在两种情况下触发:断连持续超过 outageThresholdMs(默认 3s),或需要刷新网关(快路径已失败)。首连期不算 outage —— 那是「连接中」,宿主自有首连看门狗。这个粒度对齐现网:WS 层带 resume 静默重连两次都失败、回退去重取 gateway 时才切 UI。
3. 其他
- 给
gatewayProvider自己的请求设置并透传鉴权;尊重传入的AbortSignal。 - 身份失效(401 / 封禁 / 异地登录)时,让
gatewayProvider抛new IMSocketError(code, msg, { fatal: true }),或用classifyClose把对应 close code 判成'fatal'。内核不会靠失败次数推断身份失效。 - 监听
hardReset做业务侧清理。默认策略下内核会自己重建连接,宿主不需要调用任何东西。 - 不在
reconnecting时清历史数据 —— 软 resume 只补lastSn之后的增量。 - 上报
error事件前用error.toSafeJSON()。originalError可能含 gateway URL / token。
事件
| 事件 | 语义 |
| ------------------------------- | ------------------------------------------------------------- |
| connected | 首次 Hello 成功 |
| recovered | ResumeAck 成功并恢复有序交付 |
| reconnecting | 已排程一次重试,附 { reason, attempt, delayMs, willResume } |
| hardReset | session 不可继续,附 { reason, code, autoRecovering } |
| fatal | 身份被判定无效,内核已停止一切尝试 |
| outageStarted / outageEnded | 面向宿主 UI 的派生断服信号 |
| message | 已按 SN 严格连续排序的 s:0 完整帧 { s, sn, d } |
| receive | 所有已解码帧,排序前发出(与 message 语义不同) |
| unresponsive | 心跳尝试耗尽,即将进入恢复 |
| error | 带稳定错误码的 IMSocketError |
| statechange | 状态字段已更新后的通知 |
| listenerError | 消费者监听器抛错或异步 reject |
| unknownFrame | 结构合法但当前不处理的 signal(含入站 s:2 / s:4) |
消费者监听器的同步异常和异步 reject 均被隔离,不会中断 FSM。
同步字段与发送
sessionId:Hello 处理完成后同步可读;软重连全程保持(宿主的x-client-sessionid不会中断),hard reset / stop 后清空。lastSn:最后一个已连续交付的业务 SN。sendData()/sendFrame():成功入 WebSocket 返回true,否则false。- 包不提供业务发送队列。KOOK 的这条 WS 事实上只收不发(除心跳)。
稳定性策略
- 单调时钟:所有 deadline 走
performance.now()。用墙钟会在系统时钟回拨(NTP 修正 / 用户改时间 / 虚拟机恢复)时让心跳、探活、缺口检测全部停摆,而状态仍显示connected。自定义Clock时now()必须单调。 - operation epoch:所有 provider continuation 和 socket 回调都要匹配当前代际;旧 Promise / 旧 socket / 旧 timer 不能推进新状态。close/error 双回调只触发一次恢复。
- 退避重置需要健康证据:连接稳定存活
stabilityWindowMs(默认 30s)才重置退避与网关复用计数。握手成功不算 —— 否则「接受连接后立刻断开」的坏节点会让每个客户端以固定频率重连。 - URL 保真:服务端下发的 gateway URL 逐字节透传,不做
URL/URLSearchParams往返(会把%20→+、~→%7E等重编码)。 - SN:
sn <= lastSn与重复直接丢弃;缺口不跨越交付;队列有硬上限;缺口超时通过 resume 重放。连续maxGapRecoveryAttempts轮 resume 仍未推进lastSn则升级为 hard reset,避免服务端真缺号时形成无限循环。队列溢出走同一条升级阶梯 —— 它和缺口是同一类问题(resume 没能把序列接上)。 - 队列保留策略:只有队列溢出触发的恢复才清空队列;其他恢复保留缓存帧(服务端重放完整时它们会被去重丢弃,零成本;重放不完整时它们是唯一补救来源)。
- 重入安全:每一处
transition()/emit()之后还要继续改状态的地方都会重新校验状态。宿主在事件回调里同步stop()/start()/nudge()/dispose()不会留下孤儿 timer 或跨会话交付。 - timer:每类关注点最多一个命名 timer,数量与连接时长无关。
- 解码:binary/text 帧按实际内容自适应尝试压缩与非压缩解析;Blob-like 异步解码严格串行,不会反转到达顺序。
完整状态、时序和设计取舍见 architecture.md。
开发验证
pnpm test # 137 个确定性测试,含兼容门面、reload、属性测试与七天心跳耐久
pnpm typecheck # src 与 tests 均纳入 strict 检查
pnpm build测试分层:
tests/lifecycle.test.ts/protocol.test.ts/heartbeat-stability.test.ts—— 行为契约tests/regression.test.ts—— 每条锁死一个曾被证实的缺陷(相对现网的倒退,或独立评审发现的问题)tests/reentrancy.test.ts—— 宿主在事件回调里同步调用start/stop/nudge/hardReset/dispose的各条路径tests/invariants.test.ts—— 确定性 PRNG 驱动的随机操作序列 + 混沌时钟,断言路径无关的不变量(活跃 socket ≤ 1、timer 有上界、SN 严格递增不重复、不存在「running 但既没连上也没有在途工作」的静默掉线)
