Context
The current WebSocket topic subscription helper (createTopicSubscription) is handling multiple concerns at once:
- URL construction, including protocol conversion (http → ws / https → wss)
- Authentication token extraction and query parameter creation
- Connection lifecycle management (connect, close)
- Reconnection logic / state machine
- Topic-specific subscribe/unsubscribe and message filtering
This makes the helper more complex and limits reuse of the WebSocket connection logic and auth handling in other parts of the codebase.
A bot review (sourcery-ai[bot]) suggested refactoring to separate these concerns into reusable utilities while keeping the public API of createTopicSubscription identical.
Problem
createTopicSubscription currently:
- Duplicates URL/auth handling logic that could be centralized.
- Manages flags and reconnect logic for each subscription instance.
- Mixes generic WebSocket responsibilities with topic-specific behavior (subscribe/unsubscribe, filtering messages by topic).
This increases complexity and makes it harder to:
- Share WebSocket connection logic between multiple consumers.
- Evolve reconnection behavior, logging, or auth without touching all call sites.
- Add more advanced features like multi-topic subscriptions or shared connections.
Proposed Refactor
1. Centralize URL & auth handling
Create a small utility (e.g. websocketClient.ts) that knows how to build an authenticated WebSocket URL based on a path, reusing existing auth utilities:
// websocketClient.ts
import { getAuthHeaders } from "../api";
export function buildWebSocketUrl(path: string): string {
const baseUrl = import.meta.env.DOCKSTAT_API_PORT || "http://localhost:3030";
const wsBase = baseUrl
.replace("http://", "ws://")
.replace("https://", "wss://");
const headers = getAuthHeaders(); // already knows how to read token
const token = headers.Authorization?.replace("Bearer ", "");
const query = token ? `?token=${encodeURIComponent(token)}` : "";
return `${wsBase}${path}${query}`;
}
All WebSocket consumers then call buildWebSocketUrl("/ws") (or other paths) instead of duplicating URL + token logic.
2. Extract reconnection/state machine into a WebSocket client
Move the connection lifecycle and reconnection logic into a generic createReconnectingWebSocket utility. This client handles connect, reconnect, close, and message dispatch, while remaining agnostic of topics:
// websocketClient.ts
type MessageHandler = (event: MessageEvent) => void;
export function createReconnectingWebSocket(
url: string,
onMessage: MessageHandler
) {
let ws: WebSocket | null = null;
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
let closed = false;
const connect = () => {
ws = new WebSocket(url);
ws.onopen = () => {
// connection established; topic logic lives elsewhere
};
ws.onmessage = onMessage;
ws.onclose = () => {
if (!closed) {
reconnectTimeout = setTimeout(connect, 3000);
}
};
ws.onerror = console.error;
};
connect();
return {
send(data: unknown) {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data));
}
},
close() {
closed = true;
if (reconnectTimeout) clearTimeout(reconnectTimeout);
ws?.close();
},
};
}
3. Keep createTopicSubscription as a thin wrapper
Refactor createTopicSubscription so it focuses only on:
- Subscribing/unsubscribing to a given topic
- Parsing incoming messages
- Filtering by topic and forwarding data to
onData
Example:
// topicSubscription.ts
import {
buildWebSocketUrl,
createReconnectingWebSocket,
} from "./websocketClient";
export function createTopicSubscription<T>(
topic: string,
onData: (data: T) => void
): () => void {
const client = createReconnectingWebSocket(
buildWebSocketUrl("/ws"),
(event) => {
try {
const message: ServerMessage = JSON.parse(event.data);
if (message.topic === topic) onData(message.data as T);
} catch (err) {
console.error(
`Failed to parse WebSocket message for topic "${topic}":`,
err,
);
}
},
);
client.send({ type: "subscribe", topic } satisfies ClientMessage);
return () => {
client.send({ type: "unsubscribe", topic } satisfies ClientMessage);
client.close();
};
}
This keeps the existing public API (returns a cleanup function) while simplifying internals.
Benefits
- Reduced complexity:
createTopicSubscription becomes easy to read and reason about.
- Reusability: Other modules can use
buildWebSocketUrl and createReconnectingWebSocket for different WebSocket endpoints without duplicating logic.
- Centralized auth & URL logic: Changes to auth/token handling or base URL configuration happen in one place.
- Improved extensibility: Future multi-topic subscriptions or shared-connection behavior can be implemented inside
websocketClient without touching each consumer.
Suggested Action Items
- Introduce
websocketClient.ts with:
buildWebSocketUrl(path: string): string
createReconnectingWebSocket(url: string, onMessage: MessageHandler)
- Refactor existing
createTopicSubscription implementation to:
- Use
buildWebSocketUrl("/ws") for URL/auth
- Use
createReconnectingWebSocket for connection lifecycle
- Limit itself to topic-specific logic (subscribe, unsubscribe, message filtering).
- Update imports and references wherever
createTopicSubscription is used to pull from the new modules as needed (the function signature should remain the same).
- Add tests (or adjust existing ones) for:
- URL construction with/without auth token
- Reconnection behavior when the server closes the connection
- Topic subscription and message filtering behavior.
- Document the new utilities in the codebase (short JSDoc or README section) so future contributors know to reuse them instead of re-implementing WebSocket logic.
Notes
- The refactor is intended to be backwards compatible with the public API of
createTopicSubscription.
- Pay attention to environment-specific base URL construction and ensure it matches current behavior (e.g.,
DOCKSTAT_API_PORT and local development defaults).
I created this issue for @Its4Nik from #132 (comment).
Tips and commands
Getting Help
Context
The current WebSocket topic subscription helper (
createTopicSubscription) is handling multiple concerns at once:This makes the helper more complex and limits reuse of the WebSocket connection logic and auth handling in other parts of the codebase.
A bot review (
sourcery-ai[bot]) suggested refactoring to separate these concerns into reusable utilities while keeping the public API ofcreateTopicSubscriptionidentical.Problem
createTopicSubscriptioncurrently:This increases complexity and makes it harder to:
Proposed Refactor
1. Centralize URL & auth handling
Create a small utility (e.g.
websocketClient.ts) that knows how to build an authenticated WebSocket URL based on a path, reusing existing auth utilities:All WebSocket consumers then call
buildWebSocketUrl("/ws")(or other paths) instead of duplicating URL + token logic.2. Extract reconnection/state machine into a WebSocket client
Move the connection lifecycle and reconnection logic into a generic
createReconnectingWebSocketutility. This client handles connect, reconnect, close, and message dispatch, while remaining agnostic of topics:3. Keep
createTopicSubscriptionas a thin wrapperRefactor
createTopicSubscriptionso it focuses only on:onDataExample:
This keeps the existing public API (returns a cleanup function) while simplifying internals.
Benefits
createTopicSubscriptionbecomes easy to read and reason about.buildWebSocketUrlandcreateReconnectingWebSocketfor different WebSocket endpoints without duplicating logic.websocketClientwithout touching each consumer.Suggested Action Items
websocketClient.tswith:buildWebSocketUrl(path: string): stringcreateReconnectingWebSocket(url: string, onMessage: MessageHandler)createTopicSubscriptionimplementation to:buildWebSocketUrl("/ws")for URL/authcreateReconnectingWebSocketfor connection lifecyclecreateTopicSubscriptionis used to pull from the new modules as needed (the function signature should remain the same).Notes
createTopicSubscription.DOCKSTAT_API_PORTand local development defaults).I created this issue for @Its4Nik from #132 (comment).
Tips and commands
Getting Help