keepline
排队的人一直在排队。
一个类型的WebSocket客户端,用于自动重新连接,半打开检测,出站排队,
重新连接安全订阅、模式验证和可观察性——使用一级React绑定。
无依赖核心·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中执行。
入口点
| 进口 | 内容 |
|---|---|
keepline | createSocket、回退策略、关闭代码助手、错误。没有反应。 |
keepline/react | useSocket, useSocketStatus, useSocketMessage, useLastMessage, useSocketSubscription, useSocketMetrics, SocketProvider |
keepline/compat | Migration-focused useWebSocket 形状像 react-use-websocket ——看 MIGRATION.md |
keepline/testing | MockWebSocket, installMockWebSocket, mockSocketFactory |
keepline/sentry | createSentryReporter -面包屑和例外,没有 @sentry/* 依赖 |
keepline/logger | createConsoleLogger |
keepline/socket-io | socketIo, 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 描述一下变化。
许可证
麻省理工学院