@aoctech/ws-client
v1.3.0
Published
Resilient WebSocket React hook shared across CTech apps: app-level heartbeat, backoff reconnect, reconnect-on-token-refresh.
Downloads
629
Readme
@aoctech/ws-client
Resilient WebSocket React hook shared across CTech apps: app-level heartbeat, backoff reconnect, and immediate reconnect on a token refresh.
Repo name is
ctech-ws-clienton GitHub; published to npm as@aoctech/ws-client. Searching by either name should land here.
Why this exists
ctech-dfe and ctech-wallet each carried their own byte-identical copy of the same
useWebSocket hook. Neither side verified its own ping/pong: the server sent an app-level JSON
ping every 30s and never checked for a reply, and the client replied but the server never noticed
if it didn't — so a half-open connection (a server restart, a dropped TCP reset somewhere in the
proxy chain) left the UI stuck showing "connected" indefinitely. A silent background token refresh
also never reconnected the socket, so it could keep sending a now-stale JWT until the connection
happened to drop for some other reason.
This package is the single implementation. The server side of the fix (native WS ping/pong control
frames) lives in each app's own ws.go — a browser can't send those itself (see below) — but the
client-side heartbeat, backoff, and reconnect-on-token-change logic is here, once.
Install
npm install @aoctech/ws-clientUsage
import { useWebSocket } from "@aoctech/ws-client";
import { subscribeAccessToken } from "@/lib/api/client";
const { status } = useWebSocket({
url: wsUrl, // null disables the connection
onMessage: (data) => { /* handle a parsed JSON message */ },
enabled: !!wsUrl,
authToken: token,
// Reconnects immediately (no backoff) when a new token comes in — e.g. a
// silent OAuth refresh. Optional; omit if the app has no such notifier.
subscribeToken: subscribeAccessToken,
});
// status: 'disconnected' | 'connecting' | 'reconnecting' | 'connected' | 'error'How the heartbeat works
A browser's WebSocket API gives JavaScript no way to send a native ping control frame — only the browser itself answers a server-sent one, transparently, per RFC 6455. So the two directions use different mechanisms:
- Server → client: the server sends a native WS ping periodically and enforces a read deadline
via
SetPongHandler. The browser answers it automatically; this hook has no code for it at all. - Client → server: every
CLIENT_PING_INTERVAL_MS(20s) the hook sends an app-level{"type":"ping"}text frame and arms aCLIENT_PONG_TIMEOUT_MS(10s) timer. If the server's own{"type":"pong"}reply doesn't arrive in time, the hook closes the socket — the existing backoff-reconnect path takes it from there. The server must reply to this explicitly; it's not automatic like the native direction.
API
useWebSocket(options): { status, attempt, send, reconnect }— see Usage above.status: WSStatus—disconnected|connecting|reconnecting|connected|error.attempt: number— reconnect attempts since the last successful open (capped atMAX_RECONNECT_ATTEMPTS). Reset to0on open.send(value: object): boolean— sends a JSON-encoded frame if the socket is open; returnsfalse(and is a no-op) when not connected. Use it for app frames likeact,chat,ready.reconnect(): void— forces an immediate reconnect with no backoff, the same path a token refresh takes. Wire a "Reconnect now" button to it.onOpen?: () => void— option fired once after the socket opens and the auth token frame is sent. Put a post-auth follow-up frame here (e.g. a ping that makes the server run a reconnect command) instead of racing the open event.type WSStatus = 'disconnected' | 'connecting' | 'reconnecting' | 'connected' | 'error'nextBackoffDelay(attempt),isPongMessage(data)— the pure helpers behind the hook, exported standalone for testing.BASE_DELAY_MS,MAX_DELAY_MS,MAX_RECONNECT_ATTEMPTS,CLIENT_PING_INTERVAL_MS,CLIENT_PONG_TIMEOUT_MS— the tuning constants above.
Development
npm run build # tsc -> dist/
npm test # build + node's built-in test runnerThe hook itself has no test in this repo (rendering a hook needs either a DOM/RTL harness this repo
has no other use for, or react-test-renderer, which React 19 deprecates). Its behavior is tested
in the consuming apps instead, using their existing Vitest+RTL setups — see
ctech-dfe/ui/src/__tests__/lib/useRealtimeUpdates.test.tsx.
Releasing
publish.yml only fires on a published GitHub Release — a push to main alone never publishes
(it only runs ci.yml, which tests). Publishing uses npm's OIDC trusted publishing, so there's no
NPM_TOKEN secret to manage; provenance is generated automatically.
# 1. Bump "version" in package.json, then commit and push as usual
git commit -am "chore: release vX.Y.Z"
git push
# 2. Tag it and push the tag
git tag vX.Y.Z
git push --tags
# 3. Cut the release — this is what actually triggers the publish workflow
gh release create vX.Y.Z --generate-notesLicense
MIT
Implementation reference (file:line) — audited
Anchors into src/ (src/index.ts:1-2 re-exports heartbeat + useWebSocket):
useWebSocket(options)—src/useWebSocket.ts:45. OptionsUseWebSocketOptions:14(url,onMessage,enabled?,authToken?first-frame JWT,subscribeToken?token-change → immediate reconnect,onOpen?); resultUseWebSocketResult:35(statusWSStatus:12,attempt,send:174,reconnect:164).- First-frame auth: on open, sends
{"token": <jwt>}ifauthTokenset —src/useWebSocket.ts:118-124. - Heartbeat: app-level
{"type":"ping"}every 20s, 10s pong timeout → close on miss —src/useWebSocket.ts:98-104. - Heartbeat helpers/constants —
src/heartbeat.ts:nextBackoffDelay:12,isPongMessage:16,BASE_DELAY_MS:1,MAX_DELAY_MS:2,MAX_RECONNECT_ATTEMPTS:3,CLIENT_PING_INTERVAL_MS:9,CLIENT_PONG_TIMEOUT_MS:10. - No in-repo hook test (see README "Development"); behavior is covered in consuming apps.
