оптимизация веб сокет

This commit is contained in:
lizerk 2026-03-12 00:42:10 +03:00
parent 0217bc1a38
commit 9462f08825
8 changed files with 458 additions and 452 deletions

View File

@ -19,6 +19,7 @@ class NotificationVerbs:
MENTION = 'chat_mention'
REPLY = 'chat_reply'
ADDED_TO_CHAT = 'chat_added_to_chat'
COMMENTED = 'commented' # Для комментариев к объектам
class MessageTemplates:

View File

@ -82,6 +82,10 @@ def notify_new_message(message):
2. Ответы на сообщения - средний приоритет
3. Обычные сообщения в чате - низкий приоритет
Специальная обработка для чатов комментариев (type='comments'):
- Уведомляет автора объекта (поста/документа/заявки)
- Уведомляет других участников дискуссии
Args:
message: Экземпляр модели Message
"""
@ -89,6 +93,11 @@ def notify_new_message(message):
author = message.author
content = message.content
# Специальная обработка для чатов комментариев
if chat.type == 'comments':
_notify_comment(message)
return
# Получаем всех участников чата кроме автора
if chat.type in ['announcement', 'channel', 'global']:
participants = chat.get_participants().exclude(id=author.id)
@ -202,6 +211,92 @@ def notify_new_message(message):
)
def _notify_comment(message):
"""
Отправляет уведомления о новом комментарии к объекту.
Уведомляет:
1. Автора объекта (Post/Document/Request)
2. Других участников обсуждения (кто оставлял комментарии)
Args:
message: Экземпляр модели Message (в чате type='comments')
"""
chat = message.chat
author = message.author
content = message.content
if not chat.context_object:
# Нет привязанного объекта - обычная обработка
return
recipients_set = set()
context_obj = chat.context_object
# Определяем автора объекта в зависимости от типа
object_author = None
object_url = None
object_type = "объекта"
# Проверяем тип объекта через модель
model_name = context_obj.__class__.__name__
if model_name == 'Post':
object_author = getattr(context_obj, 'author', None)
object_url = f"/feed/#{context_obj.id}"
object_type = "публикации"
elif model_name == 'Document':
object_author = getattr(context_obj, 'uploaded_by', None)
object_url = f"/documents/{context_obj.id}/"
object_type = "документа"
elif hasattr(context_obj, 'employee'): # Request
object_author = getattr(context_obj, 'employee', None)
object_url = f"/requests/{context_obj.id}/"
object_type = "заявки"
# 1. Уведомляем автора объекта
if object_author and object_author.id != author.id:
recipients_set.add(object_author)
# 2. Уведомляем всех, кто участвовал в обсуждении
from ..models import Message
previous_commenters = Message.objects.filter(
chat=chat,
is_deleted=False
).exclude(
author_id=author.id
).values_list('author_id', flat=True).distinct()
if previous_commenters:
from django.contrib.auth import get_user_model
User = get_user_model()
recipients_set.update(
User.objects.filter(id__in=previous_commenters, is_active=True)
)
# Отправляем уведомления
author_name = author.get_full_name() or author.username
for recipient in recipients_set:
_send_notification(
sender=author,
recipient=recipient,
verb='commented',
action_object=message,
target=context_obj,
description=truncate_message(content, 100),
action_url=object_url or f"/chat/{chat.id}/",
data={
'title': f'{author_name} оставил комментарий к {object_type}',
'chat_id': chat.id,
'message_id': message.id,
'author_id': author.id,
'object_type': model_name,
'object_id': context_obj.id,
}
)
def notify_chat_added(chat, new_users, added_by=None):
"""
Отправляет уведомления пользователям о добавлении в чат.

View File

@ -511,12 +511,13 @@ CELERY_TASK_TRACK_STARTED = True
CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 минут максимум на задачу
CELERY_TASK_SOFT_TIME_LIMIT = 25 * 60 # Мягкий лимит - 25 минут
# Приоритеты очередей
CELERY_TASK_ROUTES = {
'notifications.*': {'queue': 'notifications'},
'documents.tasks.*': {'queue': 'default'},
'employees.tasks.*': {'queue': 'default'},
}
# Приоритеты очередей (закомментировано для упрощения в development)
# В production можно раскомментировать для масштабирования
# CELERY_TASK_ROUTES = {
# 'notifications.*': {'queue': 'notifications'},
# 'documents.tasks.*': {'queue': 'default'},
# 'employees.tasks.*': {'queue': 'default'},
# }
# Настройки для периодических задач (Celery Beat)
CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'

View File

@ -2,16 +2,19 @@
import { UserProvider } from "@/contexts/UserContext";
import { CalendarProvider } from "@/contexts/CalendarContext";
import { NotificationsProvider } from "@/contexts/NotificationsContext";
import { ReactNode } from "react";
import { Toaster } from "sonner";
export function Providers({ children }: { children: ReactNode }) {
return (
<UserProvider>
<CalendarProvider>
<Toaster position="top-right" richColors />
{children}
</CalendarProvider>
<NotificationsProvider>
<CalendarProvider>
<Toaster position="top-right" richColors />
{children}
</CalendarProvider>
</NotificationsProvider>
</UserProvider>
);
}

View File

@ -0,0 +1,195 @@
"use client";
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
import apiClient from '@/lib/api';
import wsManager from '@/lib/websocketManager';
interface NotificationsContextType {
notifications: any[];
unreadCount: number;
loading: boolean;
error: Error | null;
markAsRead: (id: number) => Promise<void>;
markAllAsRead: () => Promise<void>;
markCategoryAsRead: (category: string) => Promise<void>;
deleteNotification: (id: number) => Promise<void>;
deleteAllRead: () => Promise<number>;
}
const NotificationsContext = createContext<NotificationsContextType | undefined>(undefined);
export function NotificationsProvider({ children }: { children: React.ReactNode }) {
const [notifications, setNotifications] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [unreadCount, setUnreadCount] = useState(0);
// Загрузка начальных данных
useEffect(() => {
async function fetchNotifications() {
try {
const data = await apiClient.getNotifications({
page_size: 50,
unread_only: true
});
const notifs = data.notifications || data.results || data;
const notificationsArray = Array.isArray(notifs) ? notifs : [];
setNotifications(notificationsArray);
setUnreadCount(data.unread_count ?? notificationsArray.length);
} catch (err) {
setError(err as Error);
setNotifications([]);
} finally {
setLoading(false);
}
}
fetchNotifications();
}, []);
// ЕДИНСТВЕННЫЙ WebSocket через singleton manager
useEffect(() => {
if (typeof window === 'undefined' || !localStorage.getItem('access_token')) {
console.log('[NotificationsContext] WebSocket disabled: not in browser or not authenticated');
return;
}
let unsubscribe: (() => void) | null = null;
const setupWebSocket = () => {
// Подписываемся на сообщения через singleton
unsubscribe = wsManager.subscribe((data) => {
// Игнорируем служебные события
if (['ping', 'list_update', 'new_message', 'marked_read', 'message_edited', 'reaction_added', 'reaction_removed', 'poll_update'].includes(data.type)) {
return;
}
// Новое уведомление
if (data.type === 'notification' && data.notification) {
console.log('[NotificationsContext] New notification:', data.notification);
setNotifications(prev => {
if (prev.some(n => n.id === data.notification.id)) {
return prev;
}
return [data.notification, ...prev];
});
if (!data.notification.is_read) {
setUnreadCount(prev => prev + 1);
}
}
// Обновление счетчика
if (data.type === 'unread_count' && typeof data.count === 'number') {
console.log('[NotificationsContext] Unread count update:', data.count);
setUnreadCount(data.count);
}
});
};
setupWebSocket();
return () => {
if (unsubscribe) {
unsubscribe();
}
};
}, []);
// Синхронизация между вкладками через CustomEvent
useEffect(() => {
if (typeof window === 'undefined') return;
const handleMarkedRead = (e: Event) => {
const customEvent = e as CustomEvent;
const { id } = customEvent.detail;
setNotifications(prev =>
prev.map(n => (n.id === id ? { ...n, is_read: true } : n))
);
setUnreadCount(prev => Math.max(0, prev - 1));
};
const handleAllMarkedRead = () => {
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
setUnreadCount(0);
};
window.addEventListener('notification-marked-read', handleMarkedRead);
window.addEventListener('all-notifications-marked-read', handleAllMarkedRead);
return () => {
window.removeEventListener('notification-marked-read', handleMarkedRead);
window.removeEventListener('all-notifications-marked-read', handleAllMarkedRead);
};
}, []);
const markAsRead = useCallback(async (id: number) => {
await apiClient.markNotificationAsRead(id);
setNotifications(prev =>
prev.map(n => (n.id === id ? { ...n, is_read: true } : n))
);
setUnreadCount(prev => Math.max(0, prev - 1));
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('notification-marked-read', { detail: { id } }));
}
}, []);
const markAllAsRead = useCallback(async () => {
await apiClient.markAllNotificationsAsRead();
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
setUnreadCount(0);
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('all-notifications-marked-read'));
}
}, []);
const markCategoryAsRead = useCallback(async (category: string) => {
await apiClient.markCategoryAsRead(category);
const updatedNotifications = notifications.map(n =>
n.category === category ? { ...n, is_read: true } : n
);
setNotifications(updatedNotifications);
const newUnreadCount = updatedNotifications.filter(n => !n.is_read).length;
setUnreadCount(newUnreadCount);
}, [notifications]);
const deleteNotification = useCallback(async (id: number) => {
await apiClient.deleteNotification(id);
setNotifications(prev => prev.filter(n => n.id !== id));
}, []);
const deleteAllRead = useCallback(async (): Promise<number> => {
const result = await apiClient.deleteAllReadNotifications();
setNotifications(prev => prev.filter(n => !n.is_read));
return result.count;
}, []);
const value = {
notifications,
unreadCount,
loading,
error,
markAsRead,
markAllAsRead,
markCategoryAsRead,
deleteNotification,
deleteAllRead,
};
return (
<NotificationsContext.Provider value={value}>
{children}
</NotificationsContext.Provider>
);
}
export function useNotifications() {
const context = useContext(NotificationsContext);
if (context === undefined) {
throw new Error('useNotifications must be used within NotificationsProvider');
}
return context;
}

View File

@ -169,323 +169,5 @@ export function useChats() {
return { chats, loading, error };
}
export function useNotifications() {
const [notifications, setNotifications] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [unreadCount, setUnreadCount] = useState(0);
// Загрузка начальных данных
useEffect(() => {
async function fetchNotifications() {
try {
// Для бейджей загружаем только непрочитанные (первые 50)
const data = await apiClient.getNotifications({
page_size: 50,
unread_only: true
});
// Бэкенд возвращает { notifications: [...], unread_count: N }
const notifs = data.notifications || data.results || data;
// Убеждаемся что notifs это массив
const notificationsArray = Array.isArray(notifs) ? notifs : [];
setNotifications(notificationsArray);
// Используем unread_count из API или считаем сами
setUnreadCount(data.unread_count ?? notificationsArray.length);
} catch (err) {
setError(err as Error);
setNotifications([]); // В случае ошибки устанавливаем пустой массив
} finally {
setLoading(false);
}
}
fetchNotifications();
}, []);
// WebSocket для realtime обновлений
useEffect(() => {
// Проверяем что мы в браузере и пользователь авторизован
if (typeof window === 'undefined' || !localStorage.getItem('access_token')) {
console.log('[Notifications] WebSocket disabled: not in browser or not authenticated');
return;
}
let ws: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null;
let reconnectAttempts = 0;
const maxReconnectAttempts = 5;
const connect = () => {
try {
// Используем функцию getWebSocketUrl для определения URL
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:9000';
const protocol = backendUrl.startsWith('https') ? 'wss:' : 'ws:';
const host = backendUrl.replace(/^https?:\/\//, '').replace(/\/$/, '');
let wsUrl = `${protocol}//${host}/ws/`;
// Добавляем токен
const token = localStorage.getItem('access_token');
if (token) {
wsUrl += `?token=${token}`;
}
console.log('[Notifications] WebSocket connecting to:', wsUrl);
console.log('[Notifications] Backend URL:', backendUrl);
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('[Notifications] WebSocket connected');
reconnectAttempts = 0;
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// Игнорируем служебные события
if (['ping', 'list_update', 'new_message', 'marked_read', 'message_edited', 'reaction_added', 'reaction_removed', 'poll_update'].includes(data.type)) {
return;
}
// Новое уведомление
if (data.type === 'notification' && data.notification) {
console.log('[Notifications] New notification:', data.notification);
// Добавляем в начало списка если это новое
setNotifications(prev => {
// Проверяем не дубликат ли
if (prev.some(n => n.id === data.notification.id)) {
return prev;
}
return [data.notification, ...prev];
});
// Обновляем счетчик если непрочитанное
if (!data.notification.is_read) {
setUnreadCount(prev => prev + 1);
}
}
// Обновление счетчика
if (data.type === 'unread_count' && typeof data.count === 'number') {
console.log('[Notifications] Unread count update:', data.count);
setUnreadCount(data.count);
}
} catch (error) {
console.error('[Notifications] Parse error:', error);
}
};
ws.onerror = (error) => {
console.error('[Notifications] WebSocket error:', error);
console.error('[Notifications] WebSocket URL was:', wsUrl);
console.error('[Notifications] WebSocket state:', ws?.readyState);
};
ws.onclose = () => {
console.log('[Notifications] WebSocket disconnected');
ws = null;
// Автоматический reconnect
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
console.log(`[Notifications] Reconnecting in ${delay}ms... (${reconnectAttempts}/${maxReconnectAttempts})`);
reconnectTimeout = setTimeout(connect, delay);
}
};
} catch (error) {
console.error('[Notifications] Connection failed:', error);
}
};
connect();
// Cleanup
return () => {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
}
if (ws) {
ws.close();
}
};
}, []);
// Синхронизация между экземплярами хука через CustomEvent
useEffect(() => {
if (typeof window === 'undefined') return;
const handleMarkedRead = (e: Event) => {
const customEvent = e as CustomEvent;
const { id } = customEvent.detail;
setNotifications(prev =>
prev.map(n => (n.id === id ? { ...n, is_read: true } : n))
);
setUnreadCount(prev => Math.max(0, prev - 1));
};
const handleAllMarkedRead = () => {
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
setUnreadCount(0);
};
const handleCategoryMarkedRead = async (e: Event) => {
const customEvent = e as CustomEvent;
const { category, verbs } = customEvent.detail;
// Используем переданные verb'ы для проверки
setNotifications(prev => {
const updated = prev.map(n => {
const isInCategory = verbs.includes(n.verb);
return isInCategory ? { ...n, is_read: true } : n;
});
// Пересчитываем счетчик
const newUnreadCount = updated.filter(n => !n.is_read).length;
setUnreadCount(newUnreadCount);
return updated;
});
};
const handleDeleted = (e: Event) => {
const customEvent = e as CustomEvent;
const { id, wasUnread } = customEvent.detail;
setNotifications(prev => prev.filter(n => n.id !== id));
if (wasUnread) {
setUnreadCount(prev => Math.max(0, prev - 1));
}
};
window.addEventListener('notification-marked-read', handleMarkedRead);
window.addEventListener('notifications-all-marked-read', handleAllMarkedRead);
window.addEventListener('category-marked-read', handleCategoryMarkedRead);
window.addEventListener('notification-deleted', handleDeleted);
return () => {
window.removeEventListener('notification-marked-read', handleMarkedRead);
window.removeEventListener('notifications-all-marked-read', handleAllMarkedRead);
window.removeEventListener('category-marked-read', handleCategoryMarkedRead);
window.removeEventListener('notification-deleted', handleDeleted);
};
}, []);
const markAsRead = async (id: number) => {
try {
await apiClient.markNotificationAsRead(id);
setNotifications(prev =>
prev.map(n => (n.id === id ? { ...n, is_read: true } : n))
);
setUnreadCount(prev => Math.max(0, prev - 1));
// Синхронизация между экземплярами хука через CustomEvent
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('notification-marked-read', {
detail: { id }
}));
}
} catch (err) {
console.error('Failed to mark notification as read:', err);
}
};
const markAllAsRead = async () => {
try {
await apiClient.markAllNotificationsAsRead();
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
setUnreadCount(0);
// Синхронизация между экземплярами хука через CustomEvent
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('notifications-all-marked-read'));
}
} catch (err) {
console.error('Failed to mark all notifications as read:', err);
}
};
const deleteNotification = async (id: number) => {
try {
await apiClient.deleteNotification(id);
// Проверяем было ли непрочитанным ДО удаления
const deletedNotif = notifications.find(n => n.id === id);
const wasUnread = deletedNotif && !deletedNotif.is_read;
setNotifications(prev => prev.filter(n => n.id !== id));
if (wasUnread) {
setUnreadCount(prev => Math.max(0, prev - 1));
}
// Синхронизация между экземплярами хука через CustomEvent
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('notification-deleted', {
detail: { id, wasUnread }
}));
}
} catch (err) {
console.error('Failed to delete notification:', err);
}
};
const deleteAllRead = async () => {
try {
const result = await apiClient.deleteAllReadNotifications();
setNotifications(prev => prev.filter(n => !n.is_read));
return result.count;
} catch (err) {
console.error('Failed to delete all read notifications:', err);
return 0;
}
};
const markCategoryAsRead = async (category: string) => {
try {
const result = await apiClient.markCategoryAsRead(category);
// Импортируем getVerbCategory, getVerbsByCategory для обработки
const { getVerbCategory, getVerbsByCategory } = await import('@/lib/verbTranslations');
const categoryVerbs = getVerbsByCategory(category);
// Помечаем уведомления этой категории как прочитанные
setNotifications(prev =>
prev.map(n => {
const isInCategory = categoryVerbs.includes(n.verb);
return isInCategory ? { ...n, is_read: true } : n;
})
);
// Пересчитываем счетчик непрочитанных
setNotifications(prev => {
const newUnreadCount = prev.filter(n => !n.is_read).length;
setUnreadCount(newUnreadCount);
return prev;
});
// Синхронизация между экземплярами хука через CustomEvent
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('category-marked-read', {
detail: { category, verbs: categoryVerbs }
}));
}
return result.count;
} catch (err) {
console.error('Failed to mark category as read:', err);
return 0;
}
};
return {
notifications,
loading,
error,
unreadCount,
markAsRead,
markAllAsRead,
markCategoryAsRead,
deleteNotification,
deleteAllRead
};
}
// Re-export useNotifications from context for backwards compatibility
export { useNotifications } from '@/contexts/NotificationsContext';

View File

@ -1,122 +0,0 @@
/**
* WebSocket хук для realtime уведомлений
* Подключается к UserConsumer на /ws/ и слушает события уведомлений
*/
import { useEffect, useRef, useCallback } from 'react';
import { getWebSocketUrl } from '@/lib/url';
interface NotificationMessage {
type: 'notification' | 'unread_count' | 'ping' | 'list_update' | 'new_message' | 'marked_read';
notification?: any;
count?: number;
}
interface UseNotificationWebSocketOptions {
onNotification: (notification: any) => void;
onUnreadCountUpdate: (count: number) => void;
enabled?: boolean;
}
export function useNotificationWebSocket({
onNotification,
onUnreadCountUpdate,
enabled = true
}: UseNotificationWebSocketOptions) {
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttemptsRef = useRef(0);
const maxReconnectAttempts = 5;
const reconnectDelay = 3000;
const connect = useCallback(() => {
if (!enabled || wsRef.current?.readyState === WebSocket.OPEN) {
return;
}
try {
let wsUrl = getWebSocketUrl();
// Добавляем токен
const token = localStorage.getItem('access_token');
if (token) {
wsUrl += `${wsUrl.includes('?') ? '&' : '?'}token=${token}`;
}
console.log('[NotificationWS] Connecting to:', wsUrl);
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
console.log('[NotificationWS] Connected');
reconnectAttemptsRef.current = 0;
};
ws.onmessage = (event) => {
try {
const data: NotificationMessage = JSON.parse(event.data);
// Игнорируем ping и другие события
if (data.type === 'ping' ||
data.type === 'list_update' ||
data.type === 'new_message' ||
data.type === 'marked_read') {
return;
}
console.log('[NotificationWS] Message:', data);
// Обрабатываем уведомления
if (data.type === 'notification' && data.notification) {
onNotification(data.notification);
}
// Обрабатываем счетчик
if (data.type === 'unread_count' && typeof data.count === 'number') {
onUnreadCountUpdate(data.count);
}
} catch (error) {
console.error('[NotificationWS] Parse error:', error);
}
};
ws.onerror = (error) => {
console.error('[NotificationWS] Error:', error);
};
ws.onclose = () => {
console.log('[NotificationWS] Disconnected');
wsRef.current = null;
// Автоматический reconnect
if (enabled && reconnectAttemptsRef.current < maxReconnectAttempts) {
reconnectAttemptsRef.current++;
console.log(`[NotificationWS] Reconnecting... (${reconnectAttemptsRef.current}/${maxReconnectAttempts})`);
reconnectTimeoutRef.current = setTimeout(connect, reconnectDelay);
}
};
} catch (error) {
console.error('[NotificationWS] Connection failed:', error);
}
}, [enabled, onNotification, onUnreadCountUpdate]);
useEffect(() => {
if (enabled) {
connect();
}
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
};
}, [enabled, connect]);
return {
isConnected: wsRef.current?.readyState === WebSocket.OPEN,
reconnect: connect,
};
}

View File

@ -0,0 +1,151 @@
/**
* Singleton WebSocket Manager
* Управляет единственным WebSocket соединением для всего приложения
*/
type MessageHandler = (data: any) => void;
class WebSocketManager {
private static instance: WebSocketManager | null = null;
private ws: WebSocket | null = null;
private reconnectTimeout: NodeJS.Timeout | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private messageHandlers: Set<MessageHandler> = new Set();
private isConnecting = false;
private constructor() {}
static getInstance(): WebSocketManager {
if (!WebSocketManager.instance) {
WebSocketManager.instance = new WebSocketManager();
}
return WebSocketManager.instance;
}
connect() {
// Если уже подключены или подключаемся, не создаем новое соединение
if (this.ws?.readyState === WebSocket.OPEN || this.isConnecting) {
console.log('[WS Manager] Already connected or connecting');
return;
}
// Проверяем что мы в браузере и пользователь авторизован
if (typeof window === 'undefined' || !localStorage.getItem('access_token')) {
console.log('[WS Manager] Not in browser or not authenticated');
return;
}
this.isConnecting = true;
try {
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:9000';
const protocol = backendUrl.startsWith('https') ? 'wss:' : 'ws:';
const host = backendUrl.replace(/^https?:\/\//, '').replace(/\/$/, '');
let wsUrl = `${protocol}//${host}/ws/`;
const token = localStorage.getItem('access_token');
if (token) {
wsUrl += `?token=${token}`;
}
console.log('[WS Manager] Backend URL:', backendUrl);
console.log('[WS Manager] Protocol:', protocol);
console.log('[WS Manager] Host:', host);
console.log('[WS Manager] Full WS URL:', wsUrl);
console.log('[WS Manager] Has token:', !!token);
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('[WS Manager] Connected');
this.isConnecting = false;
this.reconnectAttempts = 0;
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// Отправляем сообщение всем подписчикам
this.messageHandlers.forEach(handler => {
try {
handler(data);
} catch (err) {
console.error('[WS Manager] Handler error:', err);
}
});
} catch (error) {
console.error('[WS Manager] Parse error:', error);
}
};
this.ws.onerror = (error) => {
console.error('[WS Manager] Error event:', error);
console.error('[WS Manager] WebSocket state:', this.ws?.readyState);
console.error('[WS Manager] WebSocket URL was:', this.ws?.url);
this.isConnecting = false;
};
this.ws.onclose = (event) => {
console.log('[WS Manager] Disconnected');
console.log('[WS Manager] Close event code:', event.code);
console.log('[WS Manager] Close event reason:', event.reason);
console.log('[WS Manager] Close event wasClean:', event.wasClean);
this.isConnecting = false;
this.ws = null;
// Автоматический reconnect
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
console.log(`[WS Manager] Reconnecting in ${delay}ms... (${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
this.reconnectTimeout = setTimeout(() => this.connect(), delay);
}
};
} catch (error) {
console.error('[WS Manager] Connection failed:', error);
this.isConnecting = false;
}
}
disconnect() {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.isConnecting = false;
}
subscribe(handler: MessageHandler) {
this.messageHandlers.add(handler);
// Если есть подписчик и нет соединения, подключаемся
if (this.messageHandlers.size > 0 && !this.ws && !this.isConnecting) {
this.connect();
}
// Возвращаем функцию отписки
return () => {
this.messageHandlers.delete(handler);
// Если больше нет подписчиков, отключаемся
if (this.messageHandlers.size === 0) {
console.log('[WS Manager] No more subscribers, disconnecting');
this.disconnect();
}
};
}
getState(): number {
return this.ws?.readyState ?? WebSocket.CLOSED;
}
}
export default WebSocketManager.getInstance();