Singleton для клиента

Работа с STOMP поверх WebSocket почти всегда приводит к необходимости централизованного управления соединением. Причина проста: WebSocket-подключение — это дорогостоящий ресурс, а STOMP-клиент поверх него содержит состояние подписок, очередь отправки сообщений и логику переподключения. Дублирование таких клиентов в рамках одного приложения приводит к рассинхронизации состояния, множественным подпискам на одни и те же топики и трудноотлавливаемым багам.

Singleton в контексте STOMP.js решает задачу обеспечения единственного экземпляра клиента, который используется во всём приложении.

Зачем STOMP-клиенту единственный экземпляр

STOMP-клиент в браузере обычно инкапсулирует:

  • WebSocket или SockJS соединение
  • набор подписок (subscriptions)
  • очередь сообщений до установления соединения
  • состояние подключения (connected / disconnected / reconnecting)
  • обработчики событий

При создании нескольких экземпляров возникают проблемы:

  • дублирование подписок на одни и те же destination
  • конкурентные подключения к брокеру
  • потеря консистентности состояния соединения
  • избыточная нагрузка на сервер (несколько handshake вместо одного)

Singleton гарантирует:

  • одно соединение на приложение
  • единый registry подписок
  • централизованную точку переподключения
  • предсказуемое управление жизненным циклом

Базовая реализация Singleton через модуль

Наиболее простой и часто используемый вариант — использование особенностей ES Modules. Модуль в JavaScript кэшируется, и все импорты получают одну и ту же ссылку на объект.

// stompClient.js
import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';

let client = null;

export function getStompClient() {
  if (client) {
    return client;
  }

  client = new Client({
    webSocketFactory: () => new SockJS('/ws'),
    reconnectDelay: 5000,
    heartbeatIncoming: 10000,
    heartbeatOutgoing: 10000,
  });

  client.activate();

  return client;
}

export function getRawClient() {
  return client;
}

Ключевая идея — ленивое создание экземпляра. Клиент создаётся только при первом обращении, после чего возвращается один и тот же объект.

Singleton через класс

Когда требуется более формализованная структура (например, в крупных архитектурах или при использовании TypeScript), применяется классический Singleton через статическое поле.

import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';

export class StompService {
  static instance = null;

  constructor() {
    if (StompService.instance) {
      return StompService.instance;
    }

    this.client = new Client({
      webSocketFactory: () => new SockJS('/ws'),
      reconnectDelay: 5000,
    });

    this.subscriptions = new Map();

    StompService.instance = this;
  }

  connect() {
    if (!this.client.active) {
      this.client.activate();
    }
  }

  disconnect() {
    this.client.deactivate();
  }

  subscribe(destination, callback) {
    const sub = this.client.subscribe(destination, callback);
    this.subscriptions.set(destination, sub);
    return sub;
  }

  unsubscribe(destination) {
    const sub = this.subscriptions.get(destination);
    if (sub) {
      sub.unsubscribe();
      this.subscriptions.delete(destination);
    }
  }
}

Такой подход удобен, когда необходимо расширять функциональность: добавлять middleware для сообщений, логирование или управление подписками.

Lazy initialization и контроль жизненного цикла

В STOMP.js важно учитывать момент подключения. Клиент может быть создан, но ещё не активирован. Singleton должен различать:

  • создание экземпляра
  • установку соединения
  • готовность к отправке сообщений

Распространённый подход — хранить Promise готовности:

let client = null;
let connectPromise = null;

export function initClient() {
  if (client) return client;

  client = new Client({
    webSocketFactory: () => new SockJS('/ws'),
    reconnectDelay: 5000,
  });

  connectPromise = new Promise((resolve) => {
    client.onConn ect = () => resolve(client);
  });

  client.activate();

  return client;
}

export function whenConnected() {
  return connectPromise;
}

Это позволяет избежать ситуации, когда подписка выполняется до фактического подключения.

Управление подписками внутри Singleton

Одной из ключевых задач становится контроль подписок. При множественных вызовах компонентов важно не создавать дубликаты.

Решение — централизованный реестр:

class SubscriptionRegistry {
  constructor(client) {
    this.client = client;
    this.subscriptions = new Map();
  }

  subscribe(destination, handler) {
    if (this.subscriptions.has(destination)) {
      return this.subscriptions.get(destination);
    }

    const sub = this.client.subscribe(destination, handler);
    this.subscriptions.set(destination, sub);

    return sub;
  }

  unsubscribe(destination) {
    const sub = this.subscriptions.get(destination);
    if (sub) {
      sub.unsubscribe();
      this.subscriptions.delete(destination);
    }
  }

  clear() {
    this.subscriptions.forEach((sub) => sub.unsubscribe());
    this.subscriptions.clear();
  }
}

Такой слой особенно полезен в SPA, где компоненты монтируются и размонтируются часто.

Переподключение и восстановление состояния

STOMP.js поддерживает автоматическое переподключение, но Singleton должен учитывать восстановление подписок после reconnect.

Типичная проблема: после переподключения сервер считает соединение новым, и все подписки теряются.

Решение — хранение декларативного списка подписок:

const registry = new Map();

function subscribe(destination, callback) {
  registry.set(destination, callback);

  return client.subscribe(destination, callback);
}

client.onConn ect = () => {
  registry.forEach((callback, destination) => {
    client.subscribe(destination, callback);
  });
};

Таким образом Singleton превращается в источник истины по состоянию подписок.

Проблемы конкурентных экземпляров

Даже при использовании Singleton в коде могут появляться скрытые дубликаты:

  • повторный импорт разных копий модуля (monorepo, microfrontend)
  • hot module replacement в dev-режиме
  • SSR, где каждый запрос создаёт новый контекст
  • тестовые среды с пересозданием модулей

Для защиты используется дополнительный глобальный реестр:

const GLOBAL_KEY = '__STOMP_SINGLETON__';

export function getClient() {
  if (window[GLOBAL_KEY]) {
    return window[GLOBAL_KEY];
  }

  const client = new Client({...});
  window[GLOBAL_KEY] = client;

  return client;
}

Это особенно важно в микрофронтендах.

Singleton и архитектура состояния

STOMP-клиент часто становится частью более широкой системы управления состоянием:

  • Redux middleware
  • Pinia/Vuex plugin
  • Svelte store
  • React context provider

В таких случаях Singleton используется как низкоуровневый транспорт, а не как точка бизнес-логики.

Пример интеграции с store:

import { readable } from 'svelte/store';

export const messages = readable([], (set) => {
  const client = getStompClient();

  const sub = client.subscribe('/topic/messages', (msg) => {
    set(JSON.parse(msg.body));
  });

  return () => sub.unsubscribe();
});

Singleton обеспечивает стабильность транспорта, store — реактивность.

Тестирование Singleton-клиента

Singleton усложняет изоляцию тестов, так как состояние сохраняется между кейсами.

Типовые решения:

  • явный reset singleton
  • dependency injection вместо прямого импорта
  • фабрика клиента

Пример reset:

export function resetClient() {
  if (client) {
    client.deactivate();
  }
  client = null;
}

В тестах это позволяет начинать каждый сценарий с чистого состояния.

Альтернатива Singleton: Dependency Injection

В крупных приложениях Singleton часто заменяется DI-контейнером:

  • клиент создаётся один раз на уровне приложения
  • передаётся через контекст
  • легко мокается
export function createStompClient() {
  return new Client({
    webSocketFactory: () => new SockJS('/ws'),
  });
}

И уже приложение решает, будет ли это Singleton или нет.

Такой подход снижает связанность и улучшает тестируемость, но увеличивает сложность архитектуры.

Поведение при SSR и серверных окружениях

В Node.js среде Singleton ведёт себя иначе:

  • нет window
  • нет долгоживущего процесса (в serverless)
  • каждый запрос может создавать новый экземпляр

Поэтому логика Singleton должна быть адаптирована:

const isBrowser = typeof window !== 'undefined';

export const getClient = () => {
  if (!isBrowser) {
    return createNewClient();
  }

  if (!window.__client) {
    window.__client = createNewClient();
  }

  return window.__client;
};

Это предотвращает утечки соединений между запросами.

Управление состоянием подключения внутри Singleton

Критически важный аспект — явное состояние клиента:

  • disconnected
  • connecting
  • connected
  • error

Без этого Singleton превращается в «чёрный ящик».

Пример расширенного контроля:

class StompSingleton {
  state = 'disconnected';

  connect() {
    this.state = 'connecting';

    this.client.onConn ect = () => {
      this.state = 'connected';
    };

    this.client.onStompEr ror = () => {
      this.state = 'error';
    };
  }
}

Это позволяет внешнему коду принимать решения без прямого доступа к внутренностям STOMP.js.

Итоговая модель поведения Singleton для STOMP.js

В зрелой реализации Singleton-клиент выполняет сразу несколько ролей:

  • гарантирует единственное WebSocket-соединение
  • управляет жизненным циклом STOMP-клиента
  • хранит реестр подписок
  • восстанавливает состояние после reconnection
  • предоставляет централизованный API для отправки сообщений
  • изолирует бизнес-логику от транспорта