Инфраструктура — API и WebSocket клиенты

Из M2: мы заложили FSD + Clean Architecture (UseCase, guards, «глупые» компоненты). Теперь строим слой infrastructure: типобезопасный HTTP-клиент и WebSocket-клиент с автопереподключением.

Перед началом: Модули 1–2. Инструменты: fetch, WebSocket API. Время: ~5 ч.


🔥 Инцидент. Пользователь открывает чат. REST-запрос за сообщениями + WebSocket для real-time. WebSocket рвётся каждые 30 секунд (сервер перезапускает). Нет reconnect-логики, нет heartbeat. Пользователь видит «Сообщения устарели» каждые полминуты.


📖 Теория

Фокус: вынести всё, что не бизнес-логика, в один слой — чтобы не дублировать и легко мокать для тестов.

Поток данных

Поток: компонент → UseCase → адаптеры → бэкенд; UseCase и адаптеры пишут в стор

Компонент не знает про сеть — он вызывает UseCase. UseCase (валидация + guards) обращается к адаптерам (chatApi / wsClient). Адаптеры живут в infrastructure и не знают про стор и компоненты.

HTTP-клиент (apiClient)

Типобезопасная обёртка над fetch с интерцепторами:

ts
// src/infrastructure/api/client.ts
export const apiClient = {
  async request<T>(path: string, options?: RequestInit): Promise<T> {
    const headers = new Headers(options?.headers)
    headers.set("Content-Type", "application/json")

    // trace_id для корреляции фронтенд ↔ бэкенд логов
    const traceId = crypto.randomUUID()
    headers.set("X-Trace-ID", traceId)

    // JWT (если есть)
    const token = localStorage.getItem("access_token")
    if (token) headers.set("Authorization", `Bearer ${token}`)

    const res = await fetch("/api" + path, { ...options, headers })

    if (res.status === 401) {
      // токен протух → попытка refresh (см. модуль 4)
      throw new UnauthorizedError()
    }
    if (!res.ok) {
      const err = await res.json().catch(() => ({}))
      throw new ApiError(res.status, err.message ?? res.statusText)
    }
    return res.json()
  },

  get: <T>(path: string) => this.request<T>(path),
  post: <T>(path: string, body: unknown) =>
    this.request<T>(path, { method: "POST", body: JSON.stringify(body) }),
}

Типизированные API-методы (chatApi)

Обёртки над apiClient для каждого эндпоинта бэкенда (из модуля 5 бэкенд-курса):

ts
// src/infrastructure/api/chatApi.ts
export const chatApi = {
  // Auth
  register: (email: string, password: string, name: string) =>
    apiClient.post<{ id: string; email: string; name: string }>("/auth/register", { email, password, name }),

  login: (email: string, password: string) =>
    apiClient.post<{ access_token: string; refresh_token: string; user: User }>("/auth/login", { email, password }),

  // Rooms
  getRooms: () => apiClient.get<Room[]>("/rooms"),
  createRoom: (name: string, type: "p2p" | "group", participantIds: string[]) =>
    apiClient.post<Room>("/rooms", { name, type, participant_ids: participantIds }),

  // Messages
  sendMessage: (roomId: string, text: string, requestId: string) =>
    apiClient.post<{ id: string }>(`/rooms/${roomId}/messages`, { text, request_id: requestId }),

  getHistory: (roomId: string, cursor?: string) =>
    apiClient.get<{ messages: Message[]; next_cursor: string | null }>(
      `/rooms/${roomId}/messages` + (cursor ? `?cursor=${cursor}` : "")
    ),
}

Эти методы возвращают типизированные объекты — TypeScript ловит ошибки на этапе компиляции. DTO (внешний формат) не утекает в UseCase — маппинг происходит здесь.

WebSocket-клиент (wsClient) — полная реализация

Singleton с автопереподключением, heartbeat и парсингом событий:

ts
// src/infrastructure/ws/wsClient.ts
type WSEvent = { type: string; [key: string]: unknown }
type Handler = (event: WSEvent) => void

class WebSocketClient {
  private ws: WebSocket | null = null
  private url: string
  private token: string | null = null
  private handlers = new Map<string, Set<Handler>>()
  private reconnectAttempts = 0
  private heartbeatTimer: ReturnType<typeof setInterval> | null = null
  private shouldReconnect = true

  constructor(url: string) { this.url = url }

  setToken(token: string) { this.token = token }

  connect() {
    if (!this.token) return
    this.ws = new WebSocket(`${this.url}?token=${this.token}`)
    this.ws.onopen = () => {
      this.reconnectAttempts = 0
      this.startHeartbeat()
    }
    this.ws.onmessage = (e) => {
      const event: WSEvent = JSON.parse(e.data)
      this.emit(event.type, event)
    }
    this.ws.onclose = () => {
      this.stopHeartbeat()
      if (this.shouldReconnect) this.scheduleReconnect()
    }
    this.ws.onerror = () => this.ws?.close()
  }

  // Exponential backoff: 1s, 2s, 4s, 8s, ... max 30s
  private scheduleReconnect() {
    const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 30_000)
    this.reconnectAttempts++
    setTimeout(() => this.connect(), delay)
  }

  // Heartbeat: каждые 30s отправляем ping
  private startHeartbeat() {
    this.heartbeatTimer = setInterval(() => {
      this.send({ type: "ping" })
    }, 30_000)
  }

  private stopHeartbeat() {
    if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
    this.heartbeatTimer = null
  }

  send(data: unknown) {
    this.ws?.send(JSON.stringify(data))
  }

  on(type: string, handler: Handler) {
    if (!this.handlers.has(type)) this.handlers.set(type, new Set())
    this.handlers.get(type)!.add(handler)
    return () => this.off(type, handler) // для cleanup в useEffect
  }

  off(type: string, handler: Handler) {
    this.handlers.get(type)?.delete(handler)
  }

  private emit(type: string, event: WSEvent) {
    this.handlers.get(type)?.forEach((h) => h(event))
  }

  disconnect() {
    this.shouldReconnect = false
    this.stopHeartbeat()
    this.ws?.close()
  }
}

export const wsClient = new WebSocketClient(
  `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ws`
)

on() возвращает функцию cleanup — удобно в React:

ts
useEffect(() => wsClient.on("message", handler), [])

Корреляция фронтенд/бэкенд

trace_id из apiClient попадает в заголовок X-Trace-ID. На бэкенде (модуль 11 observability) он записывается в логи и трейсы. При инциденте по одному trace_id виден весь путь: фронтенд → API Gateway → Cloud Function → YDB.

In-memory моки

Для unit-тестов и быстрого старта без бэкенда — заглушки, возвращающие фикстуры:

ts
// src/infrastructure/api/chatApi.mock.ts
export const mockChatApi: typeof chatApi = {
  getRooms: async () => [{ id: "r1", name: "general", type: "group" }],
  sendMessage: async () => ({ id: "mock-msg-id" }),
  getHistory: async () => ({ messages: [], next_cursor: null }),
  // ...
}

Практика

  • Реализовать apiClient с X-Trace-ID, Bearer-токеном и обработкой 401.
  • Реализовать wsClient с экспоненциальным backoff и heartbeat (код выше — используйте как основу).
  • Создать chatApi со всеми методами из таблицы эндпоинтов бэкенд-курса.
  • Настроить in-memory моки для запуска UI без бэкенда.

💱 Компромиссы (Trade-offs)

РешениеПлюсыМинусы
apiClient на fetch + интерцепторыТипобезопасность, единый trace_id, Bearer/401 в одном местеСвоё решение vs готовые axios/ky
X-Trace-ID сквознойКорреляция логов фронт ↔ бэкНужна поддержка заголовка на бэке
WS: exponential backoffНе перегружает сервер при массовом реконнектеСложнее дебаг, задержка восстановления
Heartbeat (ping каждые 30s)Детектит «мёртвое» соединение быстрее TCP keepaliveЛишний трафик, таймеры в вкладке
In-memory моки chatApi.mock.tsUI без бэкенда, детерминированные тестыМок может разойтись с реальным API

🧩 Проверь себя

1. При WS reconnect после auth-refresh нужно...
2. Heartbeat/ping-pong в WebSocket-клиенте — зачем?
3. OpenAPI-контракт через zod/tRPC — зачем?
4. Message dedup на клиенте (по id) — зачем?
5. WebSocket-клиент (infrastructure) должен уметь...

🔗 Мостик к Модулю 4

Клиенты готовы. Управляем состоянием через Zustand + TanStack Query.