Skip to content

Refactor WebSocket topic subscription helper into reusable client utilities #135

Description

@sourcery-ai

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

  1. Introduce websocketClient.ts with:
    • buildWebSocketUrl(path: string): string
    • createReconnectingWebSocket(url: string, onMessage: MessageHandler)
  2. 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).
  3. Update imports and references wherever createTopicSubscription is used to pull from the new modules as needed (the function signature should remain the same).
  4. 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.
  5. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions