keepline

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

Typed, dependency-free WebSocket client with resilient reconnection, heartbeats, queues, schema validation, subscriptions, observability, and React 18+ bindings.

原始使用文档
文档目录21 节
  1. Why
  2. Quick start
  3. React
  4. Anything else
  5. What you get
  6. Entry points
  7. Recipes
  8. Auth with short-lived tokens
  9. A gateway that refuses the upgrade
  10. Request/response over the socket
  11. A status badge users can believe
  12. Binary protocols and custom codecs
  13. Socket.IO servers
  14. In a Node service
  15. One connection for the whole app
  16. Rendering, deliberately
  17. Observability
  18. Testing
  19. Design notes
  20. Contributing
  21. License

keepline

The line stays up.

A typed WebSocket client for automatic reconnection, half-open detection, outbound queuing,
reconnect-safe subscriptions, schema validation, and observability — with first-class React bindings.

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

Dependency-free core · ESM + CJS · React 18+ · Node.js 18+

Quick start · Features · Entry points · Migrate from react-use-websocket

npm install keepline

Why

A WebSocket is a socket, not a connection. Everything that makes it usable in production — noticing it died, coming back, not hammering the server on the way back, remembering what you were subscribed to, telling you why it dropped — is left to you. So every app writes it, and every app writes a slightly broken version.

The specific failures this exists to fix:

  • A socket that is OPEN and dead. A NAT entry expires, a proxy goes away, and readyState stays 1 forever while nothing arrives. Only a heartbeat notices.
  • readyState cannot express "down but coming back." Nor "down for good." Both read as CLOSED, so a UI built on it cannot tell a blip from an outage.
  • Reconnecting without jitter. A server restart drops every client at the same instant; an un-jittered fleet then retries at the same instant, repeatedly, turning a 2-second restart into a 5-minute outage.
  • Reconnecting after a close-coded auth failure. Retrying rejected credentials forever is how a client earns a rate limit.
  • A reconnect that comes back subscribed to nothing. The socket is open, the server has forgotten your subscriptions, and the effect that sent them does not re-run.
  • Silent close codes. The single most useful fact about a failure — 1006 network vs 1008 auth vs 4001 application — is the one thing most wrappers throw away.
  • JSON.parse in an event handler. A throw inside a WebSocket callback reaches no error boundary and no try/catch; it becomes an unhandled window error.
  • Message history in React state. One re-render per message, in every consumer. On a tick feed that is the entire frame budget.

Quick start

React

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} />;
};

Callbacks are read from a ref, so inline arrow functions never cause a reconnect. Only the connection identity does: key, a string url, protocols, and resetKeys.

Anything else

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)
});

What you get

ReconnectionTruncated exponential backoff with jitter on by default; linearBackoff/constantBackoff provided; per-attempt shouldReconnect veto; retry budget; hard-stops delivered auth and protocol close codes; honours a server's 1013 "try again later" with a longer delay
Livenessheartbeat ping/pong with RTT measurement and half-open detection; staleAfterMs silence watchdog; connectTimeoutMs for handshakes that hang against a black-holed host
Status8 real states, including reconnecting, paused and gave-up
OutboundBounded queue while connecting, flushed in order on open; drop-oldest / drop-newest / reject overflow policies
Subscriptionssocket.subscription({ subscribe, unsubscribe }) — replayed on every reconnect, released on unsubscribe
Request/responsesocket.request(payload, { match, timeoutMs, signal }) returns a promise
Validationschema accepts anything implementing Standard Schema — zod ≥ 3.24, valibot, arktype — with no dependency on any of them
ErrorsDecode and validation failures become events, never throws inside a socket handler
Browser awarenesspauseWhenHidden to stop burning battery and server capacity in a background tab; instant reconnect on online instead of waiting out the backoff
Auth rotationurl/protocols resolvers are re-invoked per attempt, so tokens are fresh at connect time — not at render time
ObservabilityOne onEvent stream of 20 typed events, with keepline/sentry and keepline/logger adapters built on it
Testingkeepline/testing ships a scriptable MockWebSocket — the real state machine runs, only the wire is fake
Sharingshare: true or <SocketProvider>; reference-counted with a grace period so StrictMode and route transitions don't churn the connection
SSRNo-ops without a WebSocket global. No guards, no dynamic imports

Core stays under 6 kB brotli and React bindings under 8 kB, with both budgets enforced in CI.

Entry points

ImportContents
keeplinecreateSocket, backoff strategies, close-code helpers, errors. No React.
keepline/reactuseSocket, useSocketStatus, useSocketMessage, useLastMessage, useSocketSubscription, useSocketMetrics, SocketProvider
keepline/compatMigration-focused useWebSocket shaped like react-use-websocket — see MIGRATION.md
keepline/testingMockWebSocket, installMockWebSocket, mockSocketFactory
keepline/sentrycreateSentryReporter — breadcrumbs and exceptions, no @sentry/* dependency
keepline/loggercreateConsoleLogger
keepline/socket-iosocketIo, socketIoUrl — talk to a Socket.IO v4 server without socket.io-client

Recipes

Auth with short-lived tokens

A resolver runs on every attempt, so the token is fresh at connect time — including the reconnect three minutes after the tab was opened. When the browser delivers an auth close code (1008, 4001, 4401, ...), it is a hard stop; shouldReconnect can narrow that policy but cannot widen it.

A rejected WebSocket upgrade may instead emit only error, or deliver close after Keepline's 50ms grace period. Browser error events expose neither the HTTP status nor a close code, so that path is governed by retryOnError (default true). For a protected endpoint, set it to false; the errored transport then settles closed, and your application can call socket.reconnect() after refreshing credentials.

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();
  }
});

In React, when the token lives in protocols instead of the URL, tell the hook it is part of the connection's identity:

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

A gateway that refuses the upgrade

Not every rejection arrives as a useful close code. A gateway refusal can surface in the browser as error and close: 1006 effectively together — no status, no reason, and the same code an ordinary network drop produces. In that event shape the close owns recovery, so neither retryOnError nor the close-code table can identify the rejection. A socket that retries drops aggressively can therefore retry a rejected token just as aggressively for as long as the tab stays open.

wasOpen reports the distinction the close event does not carry: whether the attempt that just failed had reached open. It does not identify why an attempt failed. false covers every pre-open failure, including a rejected upgrade, DNS or TLS failure, unavailable network, connection timeout, or an error while resolving the URL or creating the transport.

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)
  }
});

When the goal is to keep retrying at a cheaper cadence, prefer this to capping attempts: one hard budget is shared by pre-open failures and long outages, and can strand a socket that would otherwise have recovered. attempt resets on every successful open, so a healthy connection never accumulates delay.

Request/response over the socket

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 }
);

Sent while down? The payload queues, flushes on open, and the promise still resolves when the reply arrives — or rejects with RequestTimeoutError, ConnectionClosedError, or GaveUpError, so the failure mode is always a typed error, never a hang.

If it rejects before opening, its queued payload is removed too — a timed-out or aborted request is never sent later as a stale side effect.

A status badge users can believe

status distinguishes a blip from an outage, which readyState cannot:

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'} />;

Binary protocols and custom codecs

decode/encode replace the default JSON pipeline; a throw in either becomes a decode-error/error event instead of an unhandled exception:

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

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

Socket.IO servers

keepline/socket-io lets a keepline socket talk to a Socket.IO v4 server (Engine.IO v4, Socket.IO protocol v5) over a plain WebSocket. Use it when the server speaks Socket.IO but you want keepline's reconnection, status and observability, or simply don't want socket.io-client in the bundle. It is a codec, not a second client: it plugs into createSocket and useSocket through their options.

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

The event maps use the same shape as socket.io's typed events, so existing interfaces can be reused. Every inbound event is also an ordinary keepline message, { event, args }, so socket.onMessage, useSocketMessage and useLastMessage work as usual; message.event === 'NEW' narrows args. In React, spread io.options into useSocket and pass its result to io.emit. Create the codec once (module scope or useMemo), not per render: the hook keeps the codec it was created with. Make auth a function that reads the current token, so it is fresh on every reconnect. With share: true, give each namespace its own key, because the namespace is not part of the URL.

What it handles, per connection:

FrameFromHandling
0{…pingInterval, pingTimeout…}serverEngine.IO handshake. Answered with CONNECT: 40, 40{auth}, or 40/ns,{auth} for a namespace.
40{"sid":…}serverCONNECT acknowledged. Only now does keepline report open and flush its queue.
2serverPing, answered with 3 at once. If none arrives within pingInterval + pingTimeout, the connection is closed as 1006 socket.io: ping timeout and retried.
42[name, …args], 42/ns,[…]bothEvents.
44{"message":…}serverCONNECT_ERROR: a refusal, see below.
41, 1serverNamespace disconnect / Engine.IO close: after the ack, an ordinary disconnect, closed as 1000/1001 and retried; in answer to the CONNECT, a refusal.

status === 'open' therefore means the server acknowledged the CONNECT, not merely that the WebSocket opened, and keepline's connectTimeoutMs covers the whole handshake. Liveness comes from the server's own ping cadence, so leave heartbeat and staleAfterMs unset.

Refused connections are not retried. A server refuses with a 44 CONNECT_ERROR (an auth middleware calling next(new Error(...))), by ending the session (1 or 41) instead of acknowledging, or by closing the WebSocket right after the client's 40, typically with a bare 1006. All are reported as a 1008 close (category: 'policy', exported as REFUSED_CLOSE_CODE), which keepline never retries, so an expired token does not turn into a reconnect loop. A throwing auth() is treated the same way. The socket settles closed, and the app calls socket.reconnect() once it has fresh credentials:

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

onConnectError is called exactly once for every one of these refusals, just before the 1008 close, so it is the one place to report "connection refused". Its SocketIoConnectError says how the refusal arrived in error.reason:

reasonWhat happenedDetail
'connect-error'44 CONNECT_ERRORThe server's message, and its data
'auth-failed'auth() threw, rejected or returned something unsendable; nothing was sentThe original error as cause
'session-ended'The server sent 1 or 41 instead of acknowledging—
'closed-before-ack'The WebSocket closed after the 40, before the ackcloseCode, also in the message
onConnectError: (error) => {
  if (error.reason === 'connect-error') showLoginError(error.message, error.data);
  else log.warn(`socket.io refused (${error.reason}): ${error.message}`);
}

A close before the CONNECT is sent (before the Engine.IO handshake, or while an async auth() resolves) is still an ordinary network failure and is retried, as is a close carrying a deliberate "come back later" code (1001, 1012, 1013, 1014) even while the CONNECT is pending. Any other close in that window can't be told apart from a refusal, so it counts as one.

A server-side socket.disconnect() is retried like any other disconnect. If your server uses it to kick clients, veto that one case, or the client reconnects straight back:

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

Tokens don't have to come from the browser at all: a proxy in front of the Socket.IO server can inject them into the CONNECT, in which case the client sends a bare 40 and needs no auth option.

Pass a custom WebSocket constructor (ws on Node < 22, mockSocketFactory in tests) as socketIo({ socketFactory }), not to createSocket, where it would replace the codec's own.

Out of scope, by design: the long-polling transport (the URL always asks for transport=websocket), binary attachments (45/46), and acknowledgements (43, or an event that asks for one). These arrive as decode-error events naming what came in, never as silently wrong messages, and an outbound payload containing binary is refused at encode rather than sent as {}.

In a Node service

Node 22+ has a global WebSocket, so nothing changes. On older Node, hand in 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();

One connection for the whole app

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

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

Or keep components independent and let them share by identity: useSocket({ url, share: true }) reference-counts one connection across every hook with the same key/url.

Rendering, deliberately

Three ways to consume messages, in order of cost:

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 is opt-in on purpose. A library that stores every message in state by default makes the fast path the one you have to disable — which is why so much react-use-websocket code carries filter: () => false.

Observability

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

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

Breadcrumbs for connects, close codes, retries and validation failures; captured exceptions for exhausted retries and every failure that carries a real error — everything except the transport's contentless error event. URLs are stripped of query strings by default, and payloads are never recorded unless you ask — tokens live in query strings and breadcrumbs are forever.

When an unrelated exception is reported ten seconds after the feed quietly dropped and retried four times, that trail is the difference between a five-minute diagnosis and an unreproducible ticket.

Wiring onError into a tracker yourself? Filter on phase, and socket is the only one to drop. It is the browser's bare error event — no status, no reason, and it fires on ordinary disconnects — so capturing it yields one exception per user per session and nothing to act on. Every other phase carries a real error: decode and validation mean a frame arrived your app could not use, send means the transport refused a write, and encode and listener are a throw from your own code.

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

createSentryReporter applies exactly this rule, so onEvent: createSentryReporter({ sentry: Sentry }) needs no filter of its own.

Testing

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');

The usual approach — vi.mock('the-websocket-library') — stubs out the transport and the reconnection logic, which means the reconnection logic is the one part never tested. Here it runs.

Design notes

Why useSyncExternalStore. The socket is an external store, so React reads its status at render time instead of mirroring it into state via an effect. That removes the window in which a component renders a status that is already stale, and makes the bindings correct under concurrent rendering and StrictMode double-mounting.

Why a core/bindings split. Non-React consumers — a Node service, a chart controller, a Vue app — are a first-class audience, and keepline pulls in no React for them. It also keeps the React layer thin enough to audit: it holds no connection logic of its own.

Why no any. Enforced by lint. unknown plus a narrowing point, or a real type.

Contributing

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

Changes are released with changesets — run bun run changeset and describe the change.

License

MIT

版本与发布记录

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 是什么?

Typed, dependency-free WebSocket client with resilient reconnection, heartbeats, queues, schema validation, subscriptions, observability, and React 18+ bindings.

如何安装 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 和提交记录判断。