@guozhi-fe/shared-websocket
v0.1.1
Published
SharedWorker-first WebSocket client with React hooks and a direct fallback.
Maintainers
Readme
@guozhi-fe/shared-websocket
SharedWorker 优先的浏览器 WebSocket SDK。它把同源页面、Garfish 子应用和同源 iframe 对同一个 connectionKey 的订阅合并到一条物理 WebSocket,并为 React 18/19 提供统一 Hook。SharedWorker 不可用或初始化失败时,SDK 会降级到 react-use-websocket 页面直连。
特性
- SharedWorker 内使用原生
WebSocket,集中处理鉴权、应用层心跳、重连与订阅恢复。 - 以
connectionKey隔离用户、租户和服务,禁止冲突配置复用连接。 topic -> Client精确路由,同一页面内对重复 topic 做引用计数。- token 只保存在内存中;更旧的 token 版本不会覆盖新版本。
- 业务消息默认不在断线期间排队;显式排队需要幂等键。
- 离线队列最多 100 条或 1 MiB,达到任一限制即拒绝新消息。
- React Strict Mode 安全,支持 React 18 和 React 19。
- ESM、CommonJS、TypeScript declarations 和独立子路径导出。
- 模块顶层不访问浏览器全局,SSR 构建阶段可以安全导入。
安装
npm install @guozhi-fe/shared-websocket react react-domreact 与 react-dom 是 peer dependencies,支持范围为 >=18 <20。react-use-websocket 是包内 Direct Transport 的运行时依赖,无需业务项目重复声明。
本地交互 Demo
仓库内提供了一个可直接运行的 React + SharedWorker + 模拟 WebSocket 服务端 Demo:
npm run demo打开 http://127.0.0.1:4174。点击“打开第二个页面”,在两个页面分别选择不同 topic,即可观察服务端的“物理连接数”保持为 1,而“订阅并集”包含两个页面的 topic。Demo 还可验证 Token 刷新、服务端断开后的重连、心跳超时和直接连接降级;后者使用 http://127.0.0.1:4174/?direct=1。
架构
同源页面 / Garfish 子应用 / 同源 iframe
-> createSharedWebSocketClient().useWebSocket()
-> 页面级 PageClient(单 MessagePort + topic 引用计数)
-> 应用自定义 SharedWorker 入口
-> gz-shared-websocket Worker runtime
-> 每个 connectionKey 一条原生 WebSocket
SharedWorker 不支持或初始化失败
-> react-use-websocket(share: true)
-> 页面级 WebSocket普通网络中断、服务端临时不可用、心跳超时、鉴权失败和连接配置冲突不会触发 transport 降级,避免共享连接与直连同时存在。
1. 实现业务协议适配器
Worker 不能通过 MessagePort 接收函数。页面配置只向 Worker 传递可结构化克隆的 protocolId;实际 adapter 必须在应用自己的 Worker 入口中注册。
import type {
ProtocolMessage,
SocketData,
WebSocketProtocolAdapter,
} from '@guozhi-fe/shared-websocket/protocol';
type IncomingMessage =
| { type: 'auth-success' }
| { type: 'auth-failure'; message?: string }
| { type: 'pong'; timestamp: number }
| { type: 'ping'; timestamp: number }
| { type: 'topic'; topic: string; payload: unknown }
| { type: 'system'; payload: unknown };
type OutgoingMessage =
| { type: 'auth'; token: string }
| { type: 'ping'; timestamp: number }
| { type: 'pong'; timestamp: number }
| { type: 'subscribe'; topics: string[] }
| { type: 'unsubscribe'; topics: string[] };
export const omsProtocolAdapter: WebSocketProtocolAdapter<IncomingMessage, OutgoingMessage> = {
createAuthMessage: (token) => ({ type: 'auth', token }),
createPingMessage: (timestamp) => ({ type: 'ping', timestamp }),
createPongMessage: (timestamp) => ({ type: 'pong', timestamp }),
createSubscribeMessage: (topics) => ({ type: 'subscribe', topics }),
createUnsubscribeMessage: (topics) => ({ type: 'unsubscribe', topics }),
encode: (message) => JSON.stringify(message),
decode: (data: SocketData) => {
if (typeof data !== 'string') throw new TypeError('Expected JSON text');
return JSON.parse(data) as IncomingMessage;
},
classify: (message): ProtocolMessage => {
switch (message.type) {
case 'auth-success':
case 'auth-failure':
case 'pong':
return message;
case 'ping':
return { type: 'server-ping', timestamp: message.timestamp };
case 'topic':
return {
type: 'topic-message',
topic: message.topic,
payload: message.payload,
};
case 'system':
return { type: 'system-message', payload: message.payload };
}
},
};完整、可处理 string、ArrayBuffer 和 Blob 的示例见 examples/omsProtocolAdapter.ts。
2. 创建并构建应用 Worker 入口
// src/workers/gz-shared-websocket.worker.ts
import { exposeSharedWebSocketWorker } from '@guozhi-fe/shared-websocket/worker';
import { omsProtocolAdapter } from '../realtime/omsProtocolAdapter';
exposeSharedWebSocketWorker({
protocolVersion: 1,
protocols: {
'oms-v1': omsProtocolAdapter,
},
});必须把这个入口构建为单一 ESM 文件,并部署到固定、同源、无 hash 的地址,例如:
/shared-workers/gz-shared-websocket.worker.js所有需要共享连接的主应用、Garfish 子应用和同源 iframe 必须使用相同的 Worker URL、Worker name、Worker type 和协议版本。不要让各子应用分别产出不同 hash 的 Worker URL。
以 Vite 为例,可以为 Worker 维护一个独立构建配置:
// vite.worker.config.ts
import { resolve } from 'node:path';
import { defineConfig } from 'vite';
export default defineConfig({
build: {
outDir: 'dist/shared-workers',
emptyOutDir: false,
lib: {
entry: resolve(__dirname, 'src/workers/gz-shared-websocket.worker.ts'),
formats: ['es'],
fileName: () => 'gz-shared-websocket.worker.js',
},
},
});npm 包发布的是协议无关的 Worker runtime,不会发布绑定某个业务协议的可执行 Worker 文件。
3. 创建页面 Client
import { createSharedWebSocketClient } from '@guozhi-fe/shared-websocket';
import { omsProtocolAdapter } from './omsProtocolAdapter';
export const omsWebSocket = createSharedWebSocketClient({
url: '/api/realtime',
workerUrl: '/shared-workers/gz-shared-websocket.worker.js',
workerName: 'gz-shared-websocket-v1',
protocolVersion: 1,
protocolId: 'oms-v1',
directProtocol: omsProtocolAdapter,
heartbeat: {
interval: 25_000,
timeout: 10_000,
},
reconnect: {
initialDelay: 1_000,
maxDelay: 30_000,
jitter: 0.2,
},
});directProtocol 只在页面直连降级通道执行;SharedWorker 主通道根据 protocolId 使用 Worker 入口中注册的 adapter。
4. 更新鉴权
鉴权不作为每个 Hook 的参数。登录或刷新 token 后,通过页面 Client 统一更新:
omsWebSocket.updateAuth({
connectionKey: 'oms:tenant-a:user-1001',
token,
version: tokenIssuedAt,
});version 必须单调递增。SDK 会忽略相同或更旧版本,避免多个页面并发刷新时 token 回退。token 不会写入 localStorage、日志、connectionKey 或诊断字段。
退出登录或切换用户:
omsWebSocket.clearAuth('oms:tenant-a:user-1001');切换用户时必须使用新的 connectionKey。
5. React Hook
function MarketPanel() {
const {
status,
readyState,
transport,
lastMessage,
lastJsonMessage,
error,
diagnostics,
sendMessage,
sendJsonMessage,
reconnect,
} = omsWebSocket.useWebSocket<{ price: number }>({
connectionKey: 'oms:tenant-a:user-1001',
topics: ['market.SH.600000', 'order.user-1001'],
enabled: true,
onMessage(message) {
console.log(message.type, message.payload);
},
onError(currentError) {
console.error(currentError.code);
},
});
return (
<section>
<div>{status}</div>
<div>{transport}</div>
<div>{lastJsonMessage?.price}</div>
</section>
);
}topics 变化时自动计算差异,组件卸载时自动释放引用。enabled: false 不订阅 topic,也不会维持连接。
不提供 getWebSocket():SharedWorker 模式的物理 socket 不在页面主线程,业务代码不应直接关闭或修改共享连接。
6. 页面恢复(可选)
浏览器将页面切到后台、冻结,或放入 back/forward cache 后,主线程租约心跳可能暂停。需要自动恢复的业务必须显式启用:
const omsWebSocket = createSharedWebSocketClient({
// ...其他配置
pageRecovery: {
enabled: true,
probeTimeout: 1_000,
},
});启用后,页面在恢复可见或触发 pageshow 时会先探测原有 Worker 会话。会话仍存在时不会重建端口;租约已过期时,SDK 会自动重建端口,并重放内存中的最新鉴权和 topic 订阅。
恢复只保证接收恢复之后的新实时数据,不补齐页面不可用期间错过的消息。恢复探测的短暂窗口内,业务发送会以 SEND_WHILE_DISCONNECTED 拒绝。未配置 pageRecovery 时,现有生命周期行为保持不变。
7. 安全发送与离线队列
断线时默认拒绝业务发送:
sendJsonMessage(
{ type: 'place-order', order },
{
queueIfDisconnected: false,
},
);只有服务端能够按幂等键去重的消息才允许排队:
sendJsonMessage(
{ type: 'refresh-snapshot', requestId },
{
queueIfDisconnected: true,
idempotencyKey: requestId,
},
);规则:
- 最多 100 条或 1 MiB。
- 超限拒绝新消息,不静默淘汰旧消息。
- 鉴权成功后按 FIFO 发送。
- 页面断开时移除该页面拥有的排队消息。
- 退出登录、清除鉴权或销毁 Client 时清空。
react-use-websocket的内部预连接队列被显式关闭,Direct Transport 使用相同安全策略。
8. Transport 降级
允许降级到 direct 的错误:
WORKER_UNSUPPORTEDWORKER_CONSTRUCTION_FAILEDWORKER_INIT_TIMEOUTWORKER_DISCONNECTEDPROTOCOL_VERSION_MISMATCHPROTOCOL_NOT_REGISTERED
不会降级:
- 普通 socket/network close
SOCKET_ERRORHEARTBEAT_TIMEOUTAUTH_FAILEDCONNECTION_CONFIG_CONFLICT- topic 订阅失败
- 服务端消息解析失败
transport 返回 shared-worker 或 direct,可以用于诊断环境是否发生降级。
9. Garfish、iframe 和独立 WebSocket
- 同源 Garfish 子应用可以连接同一个固定 Worker。
- 同源 iframe 可以连接同一个固定 Worker。
- 跨域 iframe 无法直接共享主页面的 SharedWorker,会进入页面直连。
- 普通窗口和无痕窗口不会共享。
- 未接入本包、自己执行
new WebSocket()的子应用完全不受影响;它们只会继续占用独立连接和独立订阅。
SharedWorker 是资源共享机制,不是 topic 权限边界。服务端仍必须对 token、topic 和业务操作逐项鉴权。
10. 默认策略
| 能力 | 默认值 | | ------------------ | ------ | | 服务端 ping 间隔 | 25 秒 | | pong 超时 | 10 秒 | | 页面租约心跳 | 20 秒 | | 页面租约过期 | 60 秒 | | 无 Client 关闭宽限 | 5 秒 | | 初始重连延迟 | 1 秒 | | 最大重连基准延迟 | 30 秒 | | 重连抖动 | ±20% | | 稳定后重置重连次数 | 30 秒 | | 队列条数 | 100 | | 队列字节数 | 1 MiB | | Worker 初始化超时 | 5 秒 |
10. 子路径导出
import { createSharedWebSocketClient } from '@guozhi-fe/shared-websocket';
import { useSharedWebSocket } from '@guozhi-fe/shared-websocket/react';
import type { WebSocketProtocolAdapter } from '@guozhi-fe/shared-websocket/protocol';
import { exposeSharedWebSocketWorker } from '@guozhi-fe/shared-websocket/worker';./protocol 和 ./worker 不依赖 React。根入口和 ./react 提供 React 集成。
11. 浏览器与运行环境
本包采用能力检测,不以浏览器名称猜测:
- 存在且能够初始化
SharedWorker:使用共享通道。 - 不存在、被 CSP 阻止、跨域加载失败或握手不兼容:使用 direct。
- 浏览器必须支持 WebSocket、MessagePort、structured clone、ES2020 和 React 18/19 所需能力。
- SSR 期间可以导入包,但连接只会在浏览器 Hook effect 中建立。
真实的跨页面共享行为应在目标浏览器、CSP、Garfish 和 iframe 部署形态下做验收。
开发
npm install
npm run typecheck
npm test
npm run lint
npm run format:check
npm run build
npm run check:package
npm pack --dry-run完整设计与实施计划:
License
MIT