Инфраструктура — 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 (валидация + 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:tsuseEffect(() => 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.ts | UI без бэкенда, детерминированные тесты | Мок может разойтись с реальным API |
🧩 Проверь себя
🔗 Мостик к Модулю 4
Клиенты готовы. Управляем состоянием через Zustand + TanStack Query.