@guozhi-fe/shared-websocket

v0.1.1

Published

SharedWorker-first WebSocket client with React hooks and a direct fallback.

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-dom

reactreact-dom 是 peer dependencies,支持范围为 >=18 <20react-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 };
    }
  },
};

完整、可处理 stringArrayBufferBlob 的示例见 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_UNSUPPORTED
  • WORKER_CONSTRUCTION_FAILED
  • WORKER_INIT_TIMEOUT
  • WORKER_DISCONNECTED
  • PROTOCOL_VERSION_MISMATCH
  • PROTOCOL_NOT_REGISTERED

不会降级:

  • 普通 socket/network close
  • SOCKET_ERROR
  • HEARTBEAT_TIMEOUT
  • AUTH_FAILED
  • CONNECTION_CONFIG_CONFLICT
  • topic 订阅失败
  • 服务端消息解析失败

transport 返回 shared-workerdirect,可以用于诊断环境是否发生降级。

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