keepline

中文文档
0.7.1发布于 2026/11/04MITTypeScriptESM + CJS零依赖构建来源已验证

类型化、无依赖的WebSocket客户端,具有弹性重连接、心跳、队列、模式验证、订阅、可观察性和React 18+绑定。

中文使用文档
文档目录21 节
  1. 为什么
  2. 快速启动
  3. 反应
  4. 别的
  5. 你得到了什么
  6. 入口点
  7. 食谱
  8. 使用短期令牌进行验证
  9. 网关拒绝升级
  10. 通过套接字的请求/响应
  11. 用户可以相信的状态标识
  12. 二进制协议和自定义编解码器
  13. 套接字。IO服务器
  14. 在Node服务中
  15. 一个连接用于整个应用程序
  16. 渲染,故意
  17. 可观察性
  18. 测试
  19. 设计说明中
  20. 贡献
  21. 许可证

keepline

排队的人一直在排队。

一个类型的WebSocket客户端,用于自动重新连接,半打开检测,出站排队,
重新连接安全订阅、模式验证和可观察性——使用一级React绑定。

npm version CI status Core bundle at most 6 kB brotli MIT license

无依赖核心·ESM + CJS·React 18+·Node.js 18+

快速启动 · 特性 · 入口点 · 从react-use-websocket迁移过来

npm install keepline

为什么

一个 WebSocket 是套接字,而不是连接。所有使它在生产环境中可用的东西——注意到它已死亡、返回、在返回的过程中不冲击服务器、记住您订阅的内容、告诉您 为什么 它掉了,就交给你了。所以每个应用程序都会写它,每个应用程序都会写一个稍微有缺陷的版本。

这是为了修复的具体故障:

  • 套接字 OPEN 和死亡。 NAT条目过期,代理消失,然后 readyState 保持 1 永远,什么都没有。只有心跳会注意到。
  • readyState 不能表达“下来了还会回来”。 也不是“一劳永逸”。都读作 CLOSED,因此构建在它之上的UI无法分辨出故障和故障。
  • 无抖动地重新连接。 服务器重启会在同一时刻丢弃所有客户端;不紧张的舰队会在同一时刻反复尝试,将2秒的重启变成5分钟的中断。
  • 在封闭编码的验证失败后重新连接。 永远重新尝试被拒绝的凭据是客户端获得速率限制的方式。
  • 一个重新连接回来订阅什么。 套接字是打开的,服务器忘记了您的订阅,并且发送它们的效果不会重新运行。
  • 无声关闭代码。 关于故障的一个最有用的事实——1006网络vs 1008认证vs 4001应用程序——是大多数包装器所忽略的。
  • JSON.parse 在事件处理程序中。 WebSocket回调中的抛出不会到达错误边界,也不会 try/catch;它变成了一个未处理的窗口错误。
  • React状态下的消息历史。 在每个消费者中,每条消息重新呈现一次。在tick feed上,这是整个帧预算。

快速启动

反应

import { useSocket, useSocketSubscription } from 'keepline/react';

const Feed = ({ symbols, enabled }) => {
  const { socket, status, isReconnecting } = useSocket<ServerMessage>({
    // `null` means "not yet" — no conditional hooks, no placeholder connection.
    url: enabled ? 'wss://api.example.com/feed' : null,
    heartbeat: { message: { type: 'ping' }, isPong: (m) => m.type === 'pong' },
    onMessage: (message) => applyToChart(message)
  });

  // Re-sent automatically on every reconnect.
  useSocketSubscription(socket, symbols.length ? {
    subscribe: { type: 'add', symbols },
    unsubscribe: { type: 'remove', symbols }
  } : null);

  return <Badge status={status} reconnecting={isReconnecting} />;
};

回调是从引用中读取的,所以 内联箭头函数永远不会导致重新连接. 只有连接 身份 : key一根绳子 url, protocols, resetKeys。

别的

import { createSocket } from 'keepline';

const socket = createSocket({
  url: () => `wss://api.example.com/feed?token=${getToken()}`, // re-resolved per attempt
  schema: serverMessage,      // any Standard Schema validator
  staleAfterMs: 30_000,
  onMessage: (message) => handle(message)
});

你得到了什么

重新连接截断指数回退 默认开启抖动; linearBackoff/constantBackoff 提供;每次 shouldReconnect 否决;重试预算;硬停止传递认证和协议关闭代码;以更长的延迟响应服务器的1013“稍后再试”
活性heartbeat 带RTT测量和半开检测的乒乓球; staleAfterMs 沉默监督; connectTimeoutMs 对着黑洞宿主的握手
状态8个州,包括 reconnecting, paused 和 gave-up
出站连接时有界队列,打开时按顺序刷新; drop-oldest / drop-newest / reject 溢出的政策
订阅socket.subscription({ subscribe, unsubscribe }) -每次重新连接时重播,取消订阅时释放
请求/响应socket.request(payload, { match, timeoutMs, signal }) 返回一个承诺
验证schema 接受任何实现 标准模式 - zod≥3.24,有效值,ark型- with 不依赖于任何一个
错误解码和验证失败将成为事件,不会在套接字处理程序中抛出
浏览器意识pauseWhenHidden 停止在后台选项卡中燃烧电池和服务器容量;立即重新连接 online 而不是等待退路
Auth旋转url/protocols 每次尝试都会重新调用解析器,因此令牌在连接时是新鲜的,而不是在呈现时
可观察性一个 onEvent 包含20个输入事件的流 keepline/sentry 和 keepline/logger 在其上构建的适配器
测试keepline/testing 发布一个可脚本 MockWebSocket -真实的状态机运行,只有电线是假的
分享share: true 或 <SocketProvider>;引用计数与宽限期,以便StrictMode和路由转换不会扰乱连接
苏维埃社会主义共和国没有操作 WebSocket 全球。没有守卫,没有动态导入

核心保持在 6 kB 和React绑定 8 kB,这两种预算都在CI中执行。

入口点

进口内容
keeplinecreateSocket、回退策略、关闭代码助手、错误。没有反应。
keepline/reactuseSocket, useSocketStatus, useSocketMessage, useLastMessage, useSocketSubscription, useSocketMetrics, SocketProvider
keepline/compatMigration-focused useWebSocket 形状像 react-use-websocket ——看 MIGRATION.md
keepline/testingMockWebSocket, installMockWebSocket, mockSocketFactory
keepline/sentrycreateSentryReporter -面包屑和例外,没有 @sentry/* 依赖
keepline/loggercreateConsoleLogger
keepline/socket-iosocketIo, socketIoUrl -与Socket对话。IO v4服务器 socket.io-client

食谱

使用短期令牌进行验证

解析器运行 每一个 尝试,因此令牌在连接时是新鲜的-包括标签打开后三分钟重新连接。当浏览器发送验证关闭代码(1008,4001,4401,…)时,这是一个硬停止; shouldReconnect 可以缩小政策范围,但不能扩大。

被拒绝的WebSocket升级可能只发出 error,或交付 close 在Keepline的50ms宽限期之后。浏览器错误事件既不公开HTTP状态,也不公开关闭代码,因此该路径由 retryOnError (默认 true). 对于受保护的端点,将其设置为 false;然后,错误的传输解决了 closed,您的应用程序可以调用 socket.reconnect() 刷新凭据后。

const socket = createSocket({
  url: async () => {
    const token = await auth.getFreshToken();   // may refresh
    return `wss://api.example.com/feed?token=${token}`;
  },
  reconnect: { retryOnError: false },
  onClose: ({ category, willReconnect }) => {
    if (category === 'policy' && !willReconnect) redirectToLogin();
  }
});

在React中,当令牌驻留在 protocols 告诉钩子它是连接标识的一部分,而不是URL:

useSocket({
  url: 'wss://api.example.com/feed',
  protocols: ['app-proto', `bearer.${token}`],
  resetKeys: [token]   // new token -> new connection
});

网关拒绝升级

并不是每个拒绝都是有用的关闭代码。网关拒绝可以在浏览器中显示为 error 和 close: 1006 有效地结合在一起——没有状态,没有原因,和普通网络掉线产生的代码一样。在这种情况下,影响股市复苏的因素也就没有了 retryOnError 关闭代码表也不能识别拒绝。因此,只要标签保持打开状态,积极重试的套接字就可以同样积极地重试被拒绝的令牌。

wasOpen 报告接近事件不携带的区别:刚刚失败的尝试是否已经到达 open. 它不能识别 为什么 尝试失败。 false 涵盖所有预打开失败,包括拒绝升级、DNS或TLS失败、网络不可用、连接超时或解析URL或创建传输时的错误。

const postOpen = linearBackoff({ stepMs: 200, maxDelayMs: 5_000 });
const preOpen = exponentialBackoff({ initialDelayMs: 200, maxDelayMs: 60_000 });

const socket = createSocket({
  url: 'wss://api.example.com/feed',
  reconnect: {
    // Recover a dropped session quickly. Back off any attempt that never
    // opened — including a rejected handshake — to roughly one per minute.
    backoff: (attempt, context) =>
      context?.wasOpen ? postOpen(attempt) : preOpen(attempt)
  }
});

当目标是以更便宜的节奏不断尝试时,更喜欢这样而不是封顶 attempts:一个硬预算是由预打开的故障和长时间的中断共享的,并且可能会阻塞本来可以恢复的套接字。 attempt 每次成功打开时重置,因此健康的连接永远不会累积延迟。

通过套接字的请求/响应

const socket = createSocket<ServerMessage, ClientMessage>({
  url,
  // Default matcher: correlate by id once, instead of per call.
  matchResponse: (message, sent) => message.replyTo === sent.id
});

const reply = await socket.request(
  { id: nextId(), type: 'get-portfolio' },
  { timeoutMs: 5_000, signal: controller.signal }
);

下坠时发送?负载队列,打开时刷新,并且当应答到达或拒绝时,承诺仍然解析 RequestTimeoutError, ConnectionClosedError,或 GaveUpError,因此故障模式总是输入错误,而不是挂起。

如果它在打开之前就拒绝了,那么它排队的有效负载也会被删除——超时或中止的请求不会作为过时的副作用在以后发送。

用户可以相信的状态标识

status 区分短暂故障和中断故障,后者 readyState 不能:

const { status, isReconnecting, isFailed, reconnect } = useSocket({ url });

if (isFailed) return <button onClick={reconnect}>Connection lost — retry</button>;
if (isReconnecting) return <Spinner label="Reconnecting…" />;
return <Dot color={status === 'open' ? 'green' : 'grey'} />;

二进制协议和自定义编解码器

decode/encode 替换默认的JSON管道;扔进去一个就变成了一个 decode-error/error 事件而不是未处理的异常:

import { decode, encode } from '@msgpack/msgpack';

const socket = createSocket<ServerMessage, ClientMessage>({
  url,
  binaryType: 'arraybuffer',
  decode: (data) => decode(data as ArrayBuffer),
  encode: (payload) => encode(payload)
});

套接字。IO服务器

keepline/socket-io 让一个保持套接字与另一个套接字通信。IO v4服务器(引擎。IO v4, Socket。IO协议v5)通过一个普通的WebSocket。当服务器使用Socket时使用它。IO但是你想要keepline的重连接、状态和可观察性,或者根本不想要 socket.io-client 在包里。它是一个编解码器,而不是第二个客户端:它可以插入 createSocket 和 useSocket 通过他们的选择。

import { createSocket } from 'keepline';
import { socketIo, socketIoUrl } from 'keepline/socket-io';

interface ServerEvents { NEW: (notification: Notification) => void }
interface ClientEvents { markRead: (id: string) => void }

const io = socketIo<ServerEvents, ClientEvents>({
  auth: async () => ({ token: `Bearer ${await auth.getFreshToken()}` }), // re-run on every connect
  namespace: '/',                                                      // the default
  onConnectError: (error) => log.warn(error.reason, error.message, error.data)
});

const socket = createSocket({
  url: socketIoUrl('https://api.example.com', { language: 'en' }),
  ...io.options
});

io.on(socket, 'NEW', (notification) => show(notification));
io.emit(socket, 'markRead', id);   // queued until the server admits us

事件映射使用与的相同形状 socket.io的类型化事件,以便可以重用现有的接口。每个入站事件也是一个普通的保持消息, { event, args },所以 socket.onMessage, useSocketMessage 和 useLastMessage 照常工作; message.event === 'NEW' 缩小 args. 在React中,扩展 io.options 成 useSocket 并将结果传递给 io.emit. 创建一次编解码器(模块范围或 useMemo),而不是每次渲染:钩子保留它创建时的编解码器。使 auth 的函数 当前的 令牌,所以每次重新连接时都是新的。与 share: true,为每个名称空间赋予自己的名称空间 key,因为名称空间不是URL的一部分。

它处理每个连接的内容:

框架从处理
0{…pingInterval, pingTimeout…}服务器引擎。IO握手。用CONNECT回答: 40, 40{auth},或 40/ns,{auth} 对于名称空间。
40{"sid":…}服务器连接承认。直到现在才有记录 open 并刷新它的队列。
2服务器平,回答说 3 在一次。如果没有人到达 pingInterval + pingTimeout,连接关闭为 1006 socket.io: ping timeout 并重试。
42[name, …args], 42/ns,[…]两个事件。
44{"message":…}服务器CONNECT_ERROR:拒绝,见下文。
41, 1服务器命名空间断开/引擎。IO关闭:返回后,正常断开,关闭为 1000/1001 和重试;作为对CONNECT的回应,是拒绝。

status === 'open' 因此意味着服务器确认了CONNECT,而不仅仅是WebSocket打开了,并且保持了连接 connectTimeoutMs 覆盖整个握手过程。活跃度来自服务器自己的ping节奏,所以离开吧 heartbeat 和 staleAfterMs 未设置的。

被拒绝的连接不会重试。 服务器拒绝使用 44 CONNECT_ERROR(认证中间件调用 next(new Error(...))),通过结束会话(1 或 41),而不是确认,或者在客户端返回后立即关闭WebSocket 40,通常是裸露的 1006. 所有报告都是a 1008 关闭(category: 'policy',出口为 REFUSED_CLOSE_CODE),它永远不会重试,因此过期的令牌不会变成重新连接循环。一个把 auth() 都是一样的。套接字固定 closed,应用程序就会调用 socket.reconnect() 一旦它有了新的证书:

onClose: ({ category, willReconnect }) => {
  if (category === 'policy' && !willReconnect) refreshThenReconnect();
}

onConnectError 每次被拒绝都会调用一次,就在 1008 关闭,所以它是报告“连接被拒绝”的地方。它的 SocketIoConnectError 说明拒绝是怎么来的 error.reason:

reason发生了什么事细节
'connect-error'44 CONNECT_ERROR服务器的 message,其 data
'auth-failed'auth() 丢弃、拒绝或退回不能发送的东西;没有发送任何东西原始错误为 cause
'session-ended'服务器发送 1 或 41 而不是承认- - - - - -
'closed-before-ack'事件后WebSocket关闭 40,在后面closeCode,也在留言中
onConnectError: (error) => {
  if (error.reason === 'connect-error') showLoginError(error.message, error.data);
  else log.warn(`socket.io refused (${error.reason}): ${error.message}`);
}

密切 之前 在引擎之前发送CONNECT。IO握手,或者异步时 auth() resolves)仍然是一个普通的网络故障,并被重试,就像在CONNECT挂起时故意携带“稍后回来”代码(1001,1012,1013,1014)的关闭一样。在这段时间内的任何其他关闭都不能被区分为拒绝,所以它算作一次。

一个服务器端 socket.disconnect() 像任何其他断开连接一样重试。如果你的服务器用它来踢客户端,否决这种情况,或者客户端直接重新连接:

reconnect: {
  shouldReconnect: ({ reason }) => reason !== 'socket.io: server disconnected the namespace'
}

令牌根本不需要来自浏览器:在Socket前面有一个代理。IO服务器可以将它们注入到CONNECT中,在这种情况下,客户端发送一个bare 40 不需要 auth 选择。

传递一个自定义WebSocket构造函数(ws 在节点< 22上, mockSocketFactory 在测试中) socketIo({ socketFactory }),不是 createSocket,它将取代编解码器自己的编解码器。

超出范围,按照设计:长轮询传输(URL总是请求) transport=websocket)、二进制附件(45/46),以及致谢(43(或需要一个的事件)。这些到达时 decode-error 事件命名进入的内容,永远不会作为静默错误消息,并且包含二进制的出站有效负载在 encode 而不是作为 {}。

在Node服务中

Node 22+有一个全局 WebSocket,所以什么都没有改变。在旧的Node上,提交 ws:

import WebSocket from 'ws';
import type { WebSocketLike } from 'keepline';

const socket = createSocket({
  url: 'wss://api.example.com/feed',
  socketFactory: (url, protocols) =>
    new WebSocket(url, protocols) as unknown as WebSocketLike
});

await socket.waitForOpen();

一个连接用于整个应用程序

<SocketProvider options={{ url, heartbeat: { message: { type: 'ping' } } }}>
  <App />
</SocketProvider>

// anywhere below:
const socket = useSocketContext<ServerMessage>();
useSocketMessage(socket, (m) => store.apply(m));

或者保持组件独立,让它们通过身份共享: useSocket({ url, share: true }) 在每个钩子上对一个连接进行引用计数 key/url。

渲染,故意

三种使用消息的方式,按成本排序:

useSocketMessage(socket, (m) => ref.current?.update(m)); // no re-render (default)
const last = useLastMessage(socket, { filter });          // one re-render per match
const metrics = useSocketMetrics(socket);                 // sampled once a second

useLastMessage 是有意选择加入的。默认情况下,将每个消息存储在状态中的库使快速路径成为必须禁用的路径——这就是为什么要禁用这么多 react-use-websocket 代码有 filter: () => false。

可观察性

import * as Sentry from '@sentry/react';
import { createSentryReporter } from 'keepline/sentry';

createSocket({ url, onEvent: createSentryReporter({ sentry: Sentry }) });

面包屑用于连接、关闭代码、重试和验证失败;捕获耗尽重试的异常和每次携带实际错误的失败—除了传输的无内容之外的所有内容 error 事件。默认情况下,url将从查询字符串中剥离,并且除非您请求,否则不会记录有效负载-令牌存在于查询字符串中,而面包屑则永远存在。

当一个不相关的异常在馈送安静地中断并重试四次后十秒钟被报告时,这个线索是五分钟诊断和不可重复的票据之间的区别。

布线 onError 变成追踪器?过滤 phase, socket 是唯一倒下的人。这是浏览器的赤裸 error 事件——没有状态,没有原因,它在普通的断开连接时触发——因此捕获它只会在每个用户每个会话中产生一个异常,没有什么可处理的。每隔一个阶段都有一个真正的错误: decode 和 validation 意味着你的应用无法使用一个帧, send 说明运输公司拒绝了我的请求 encode 和 listener 是从你自己的代码中抛出的。

createSocket({
  url,
  onError: (error, phase) => {
    if (phase === 'socket') return;
    Sentry.captureException(error);
  }
});

createSentryReporter 正好适用于这个规则,所以 onEvent: createSentryReporter({ sentry: Sentry }) 不需要自己的过滤器。

测试

import { createSocket } from 'keepline';
import { MockWebSocket, mockSocketFactory } from 'keepline/testing';

const socket = createSocket({ url: 'wss://x', socketFactory: mockSocketFactory });
const ws = MockWebSocket.last();

ws.acceptConnection();
ws.serverSend({ type: 'tick' });
ws.serverClose({ code: 1006 });     // a real retry, with real backoff
expect(socket.status).toBe('reconnecting');

〇通常的方法 vi.mock('the-websocket-library') -截断传输 和 重连接逻辑,这意味着重连接逻辑是从未测试过的部分。它在这里运行。

设计说明中

为什么 useSyncExternalStore。 套接字是一个外部存储,因此React在渲染时读取其状态,而不是通过效果将其镜像到状态。这将移除组件呈现已经过时状态的窗口,并使绑定在并发呈现和StrictMode双挂载下正确。

为什么核心/绑定会分裂。 非react消费者——Node服务、图表控制器、Vue应用——是一流的受众 keepline 对他们没有反应。它还使React层足够薄以进行审计:它自己没有连接逻辑。

为什么没有 any。 用棉线强制。 unknown 加上一个缩小点,或者一个真正的类型。

贡献

bun install
bun run test      # vitest
bun run verify    # lint + typecheck + test + build

更改将随 变更集 ——运行 bun run changeset 描述一下变化。

许可证

麻省理工学院

版本与发布记录

keepline 共发布 11 个版本,首次发布于 2026/08/02;过去 12 个月发布了 11 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.7.1latest2026/11/041 天前
0.7.02026/11/031 天前
0.6.02026/09/032 个月前
0.5.12026/09/032 个月前
0.5.02026/09/032 个月前
0.4.02026/08/152 个月前

关于 keepline 的常见问题

keepline 是什么?

类型化、无依赖的WebSocket客户端,具有弹性重连接、心跳、队列、模式验证、订阅、可观察性和React 18+绑定。

如何安装 keepline?

在项目目录运行 npm install keepline,或使用 pnpm add / yarn add / bun add。

keepline 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

keepline 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

keepline 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=18。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

keepline 可以商用吗?

keepline 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

keepline 还在维护吗?

最新版本 0.7.1 发布于 2026/11/04(1 天前);至今共发布 11 个版本,其中过去 12 个月发布了 11 个;首次发布于 2026/08/02。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。