TREK WebSocket协议设计:消息类型、心跳保活与断线重连策略全解析
【免费下载链接】TREKA self-hosted travel/trip planner with real-time collaboration, interactive maps, PWA support, SSO, budgets, packing lists, and more.项目地址: https://gitcode.com/GitHub_Trending/nomad22/TREK
🧭TREK是一款可自托管的旅行行程规划工具,其实时协作能力的核心就是 WebSocket 协议:团队成员对同一行程的修改,通过一条持久化的 WebSocket 长连接在毫秒级同步到彼此屏幕上。本文带你完整拆解 TREK 的 WebSocket 协议设计,包括消息类型定义、30 秒心跳保活机制、指数退避断线重连策略,以及握手阶段的一次性令牌安全方案。
为什么旅行规划工具需要 WebSocket 实时协作
想象一个场景:你和队友正在同一份行程里分工——你在地图上加景点,队友在填预算、改待办。如果每次修改都要刷新页面,协作体验将一塌糊涂。
TREK 的解法是:每位用户登录后建立一条 WebSocket 长连接,加入所打开行程对应的"房间"(Room),服务端在该行程上发生的任何变更都会广播给房间内所有连接。相关实现分布在两个核心文件:
- 服务端网关:server/src/websocket.ts
- 客户端单例管理器:client/src/api/websocket.ts
上图:TREK 行程规划器界面,所有成员的实时变更都会通过 WebSocket 同步到这里
连接建立:一次性令牌与多层握手安全
WebSocket 握手发生在 URL 的 query 参数中,但 TREK 并不直接把你的登录 JWT 挂在 URL 上,而是多走一步安全设计(见 server/src/websocket.ts):
- 换取一次性令牌:客户端先调用
POST /api/auth/ws-token接口,用 Cookie 会话换发一个短时效的ephemeral token(临时令牌),再连接到ws(s)://host/ws?token=... - 令牌只能消费一次:服务端用
consumeEphemeralTokenWithMeta(token, 'ws')消费令牌,防止重放 - 密码版本校验:令牌绑定签发时的
password_version,一旦用户改过密码,旧令牌立即失效——这是防止会话劫持的纵深防御 - MFA 门禁:若站点启用了"强制 MFA"策略,未完成双因素认证的用户会被以 4403 关闭码拒绝
- Origin 白名单:可通过
ALLOWED_ORIGINS环境变量限制允许跨域的源
服务端同时设置了64 KB 单条消息上限(maxPayload),从协议层面杜绝超大消息攻击。
握手成功后,服务端立刻向客户端发送第一条消息:
{ "type": "welcome", "socketId": 7 }socketId是本次连接的唯一编号,后续所有广播都会用它来排除发起变更的本人(客户端对本地操作已有乐观更新,无需回声)。
消息类型一览:客户端指令与服务端事件
整条协议基于统一的 JSON 消息格式:{ "type": "...", ...payload }。方向分为两类:
客户端 → 服务端:房间指令
| 消息 | 载荷 | 作用 |
|---|---|---|
join | tripId | 加入某行程房间,服务端先做访问权限校验(canAccessTrip),无权限则回error |
leave | tripId | 离开房间,服务端自动清理房间映射 |
服务端 → 客户端:握手确认与业务广播
| 消息 | 说明 |
|---|---|
welcome | 握手完成,附带socketId |
joined/left | 加入/离开房间的确认 |
error | 权限拒绝、限流等错误提示 |
| 领域事件 | 如place:created、day:updated、budget:created、packing:updated、todo:deleted、collab:note:created、collab:message:created、reservation:updated等 |
领域事件遵循实体:动作的命名规范(例如place:created、packing:bag-updated),由 NestJS 各业务控制器在 API 写操作落库后调用 broadcast() 发出。广播时有两个精妙细节:
- 排除发送者:
excludeSid参数跳过触发变更的连接自身 - 私有数据隔离:
onlyUserId参数可让某些事件(如私有行李物品 #858)只送达属主自己的多个标签页,不泄漏给其他协作者
上图:TREK 协作标签页——聊天、共享笔记与投票全部基于同一套 WebSocket 事件流驱动
心跳保活:30 秒 ping/pong 剔除死连接
WebSocket 连接可能"假死"(NAT 超时、移动端休眠后静默断开),服务端无法感知。TREK 采用经典的应用层心跳方案(见 server/src/websocket.ts):
- 每30 秒(
HEARTBEAT_INTERVAL = 30000)服务端向所有连接发送一个ping帧 - 收到
pong回包即标记isAlive = true - 下一轮心跳时若某连接
isAlive仍为false(即上一轮 ping 石沉大海),立即terminate()强制清理,并同步摘除其所在的所有房间
这套"两拍确认"策略(给客户端一轮回复窗口再判定死亡)既不会误杀弱网用户,又能及时回收僵尸连接,配合每连接10 秒 30 条的消息速率限制(WS_MSG_LIMIT = 30/WS_MSG_WINDOW = 10s),构成了完整的连接健康治理。
断线重连:指数退避 + 状态自动恢复
服务端只是链路的一半,客户端的容错设计才是体验关键。client/src/api/websocket.ts 实现了一个单例管理器,重连策略可以概括为三步:
① 指数退避,避免风暴
连接断开后从1 秒开始等待,每次失败延迟翻倍(reconnectDelay * 2),上限30 秒(MAX_RECONNECT_DELAY)。成功重连后延迟重置为 1 秒。如果fetchWsToken返回 401(会话过期),则主动停止重连,交由登录流程处理——这是避免无效重试的关键判断。
② 自动重新加入房间
客户端用activeTrips集合记录当前打开的行程。重连成功(onopen)后,自动对每个活动行程重新发送join消息,用户无需任何手动操作即恢复实时状态。
③ 补发队列 + 全量回读,保证数据不错位
断线期间,本地修改被离线队列暂存。重连时执行一段精心排序的恢复流程:
- 先运行
preReconnectHook——把断线期间排队的变更刷到服务端(await 等待落库) - 再触发
refetchCallback对活动行程全量回读,以服务端为准刷新本地状态
这一"先写后读"的顺序保证了离线期间的编辑不会被随后的全量回读覆盖,是 TREK 离线模式(PWA)能丝滑衔接在线协作的核心保障。
页面级订阅由 useTripWebSocket Hook 管理:进入行程页自动joinTrip,离开页面自动leaveTrip,做到房间订阅与路由生命周期对齐。
实时事件的落地:Zustand 更新 + IndexedDB 写穿
收到广播后,客户端事件流最终汇入 handleRemoteEvent,它做两件事:
- Zustand 不可变更新:按
type将place:created、day:updated等领域事件映射为对应状态切片(地点、日程、预算、行李、待办、预订)的增删改 - IndexedDB 写穿:同一事件同步写入本地离线库(Dexie),fire-and-forget 不阻塞 UI——这样离线打开 App 时也能看到队友最后一次协作成果
上图:协作投票功能——投票结果通过 collab:poll:voted 事件实时推送给房间内所有成员
小结:TREK WebSocket 协议的设计要点
- 🔐握手安全:一次性临时令牌 + 密码版本绑定 + MFA 门禁 + Origin 白名单
- 🏷️消息规范:
实体:动作命名 +socketId回声排除 + 按用户投递私有事件 - 💓心跳保活:30 秒 ping/pong 两拍确认,及时剔除僵尸连接
- 🔁断线重连:1s→30s 指数退避、自动重入房间、"先刷队列后全量回读"的数据一致性恢复
- 📦离线写穿:每条远程事件同步落地 IndexedDB,无缝衔接 PWA 离线模式
这套协议没有引入任何重型实时框架,仅用 Nodews+ 浏览器原生 WebSocket API,就为自托管旅行规划工具提供了生产级的实时协作体验。如果你想动手研究,建议从 server/src/websocket.ts 与 client/src/api/websocket.ts 两个文件读起,再配合 wiki/Real-Time-Collaboration.md 官方文档对照理解。
【免费下载链接】TREKA self-hosted travel/trip planner with real-time collaboration, interactive maps, PWA support, SSO, budgets, packing lists, and more.项目地址: https://gitcode.com/GitHub_Trending/nomad22/TREK
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考