Часть 1 - Чат на React, Node.js и TailwindCSS: Протоколы и сервер
Часть 2 - Чат на React, Node.js и TailwindCSS: Фронт
Синхронизация состояния: подводные камни
WebSocket создает иллюзию простоты - отправил событие, получил ответ, обновил UI. На практике между этими шагами куча точек отказа, и каждая может привести к рассинхронизации состояния между клиентом и сервером.
Классическая проблема - race conditions при одновременных операциях. Пользователь отправляет сообщение, но до получения подтверждения нажимает "удалить". На сервер уходят два запроса параллельно. Какой обработается первым? Зависит от сетевой задержки, загрузки сервера, фазы луны. Сообщение может удалиться до создания или создаться после удаления. Результат непредсказуем. Я столкнулся с этим когда делал чат для службы поддержки. Операторы быстро печатали ответы и сразу отправляли следующие. При плохом интернете сообщения приходили на сервер в произвольном порядке, и диалог выглядел как случайная мешанина фраз. Пришлось добавлять sequence numbers - монотонно растущие счетчики для упорядочивания событий.
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| let messageSequence = 0;
function sendMessage(text) {
const sequence = ++messageSequence;
const message = {
id: Date.now(),
text,
sequence,
timestamp: new Date().toISOString()
};
socket.emit('message', message);
// Оптимистичное обновление UI
setMessages(prev => [...prev, { ...message, pending: true }]);
} |
|
На сервере сортируешь по sequence перед обработкой. Даже если пакеты пришли вразнобой, логический порядок восстанавливается. Клиенты получают сообщения в правильной последовательности независимо от сетевых капризов.
Оптимистичные обновления ускоряют perceived performance но добавляют сложности. Показываешь сообщение сразу после отправки, не дожидаясь ответа сервера. Пользователь видит мгновенную реакцию, чувствует отзывчивость интерфейса. Но что если сервер отклонил сообщение? Валидация не прошла, квота исчерпана, соединение разорвалось. Нужен механизм отката.
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| function handleMessageAck(response) {
if (response.status === 'ok') {
// Убираем флаг pending, добавляем server ID
setMessages(prev => prev.map(msg =>
msg.localId === response.localId
? { ...msg, pending: false, id: response.serverId }
: msg
));
} else {
// Откатываем оптимистичное обновление
setMessages(prev => prev.filter(msg => msg.localId !== response.localId));
showError('Не удалось отправить сообщение');
}
} |
|
Временные идентификаторы - еще одна головная боль. Клиент генерирует localId для сообщения, отправляет на сервер, получает обратно serverId. Теперь одно сообщение имеет два ID в разных контекстах. Удаление, редактирование, реакции - все операции должны работать с обоими идентификаторами до завершения синхронизации.
Конфликты при оффлайн-работе проявляются жестко. Пользователь отключился от сети, написал три сообщения локально, переподключился. Тем временем другие участники отправили свои реплики. Как влить локальные сообщения в общий поток не сломав хронологию? Timestamp'ы клиента могут отличаться от серверного времени на минуты из-за неправильно настроенных часов. Решение через серверное время и буферизацию:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| const offlineQueue = [];
socket.on('disconnect', () => {
// Переключаемся в режим оффлайн-очереди
isOffline = true;
});
socket.on('connect', () => {
isOffline = false;
// Отправляем накопленные сообщения пачкой
if (offlineQueue.length > 0) {
socket.emit('bulk-messages', offlineQueue, (response) => {
// Сервер возвращает сообщения с правильными timestamp'ами
setMessages(prev => mergeSortedMessages(prev, response.messages));
offlineQueue.length = 0;
});
}
});
function sendMessage(text) {
const message = { text, clientTime: Date.now() };
if (isOffline) {
offlineQueue.push(message);
// Показываем в UI как pending
addPendingMessage(message);
} else {
socket.emit('message', message);
}
} |
|
Сервер принимает пачку, проставляет корректные timestamp'ы на основе серверного времени и возвращает отсортированный массив. Клиент мержит его с существующими сообщениями, и хронология восстанавливается.
Дубликаты всплывают при нестабильном соединении. Клиент отправил сообщение, ждет подтверждения. Соединение рвется до прихода ответа. Переподключается, отправляет снова - сервер получает дубликат. Без дедупликации пользователь увидит одно сообщение дважды. Идемпотентность через уникальные ID обязательна:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| const sentMessages = new Map(); // messageId -> timestamp
function sendMessage(text) {
const messageId = `${userId}-${Date.now()}-${Math.random()}`;
if (sentMessages.has(messageId)) {
return; // Уже отправляли
}
sentMessages.set(messageId, Date.now());
socket.emit('message', { id: messageId, text });
// Чистим старые записи через минуту
setTimeout(() => sentMessages.delete(messageId), 60000);
} |
|
На сервере проверяешь ID перед обработкой. Видел такое сообщение недавно - игнорируешь. Новое - обрабатываешь и сохраняешь в кеш недавних ID.
Синхронизация состояния - это борьба с неопределенностью распределенных систем. Сеть ненадежна, порядок событий не гарантирован, время относительно. Код должен явно обрабатывать каждый edge case, иначе пользователи увидят глюки которые невозможно воспроизвести в контролируемых условиях разработки. Разрыв WebSocket-соединения — это не исключение, а норма работы чата. Мобильная сеть переключается между вышками, пользователь закрывает ноутбук, роутер глючит, прокси-сервер решает что соединение висит слишком долго. Socket.io обрабатывает реконнект автоматически, но дьявол в деталях.
Первая попытка переподключения происходит через секунду после разрыва. Если не вышло — через две секунды. Третья попытка через четыре, четвертая через восемь. Экспоненциальный backoff предотвращает DDoS собственного сервера, но создает проблему: после десяти неудачных попыток клиент ждет больше минуты между реконнектами. Пользователь видит мертвый чат и уходит. Настраиваю агрессивный reconnection для чатов:
| JavaScript | 1
2
3
4
5
6
| const socket = io('http://localhost:3000', {
reconnection: true,
reconnectionDelay: 500, // Первая попытка через полсекунды
reconnectionDelayMax: 3000, // Максимум 3 секунды между попытками
reconnectionAttempts: Infinity // Никогда не сдаваться
}); |
|
Infinite attempts звучат радикально, но для чата это правильно. Лучше продолжать попытки пока вкладка открыта, чем бросить пользователя с мертвым соединением через две минуты.
Индикация состояния подключения критична. Пользователь должен видеть что происходит:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| const [connectionState, setConnectionState] = useState('connecting');
useEffect(() => {
const socket = io('http://localhost:3000');
socket.on('connect', () => {
setConnectionState('connected');
});
socket.on('disconnect', (reason) => {
setConnectionState('disconnected');
console.log('Разрыв:', reason);
});
socket.on('reconnecting', (attemptNumber) => {
setConnectionState('reconnecting');
console.log(`Попытка ${attemptNumber}`);
});
socket.on('reconnect_failed', () => {
setConnectionState('failed');
});
return () => socket.disconnect();
}, []); |
|
Параметр reason в событии disconnect говорит почему оборвалось. io server disconnect — сервер принудительно закрыл соединение, обычно из-за аутентификации или бана. transport close — сетевая проблема, нормальная ситуация. ping timeout — сервер не получал ping от клиента, возможно клиент завис.
Визуальная обратная связь должна быть заметной но не агрессивной:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| function ConnectionIndicator({ state }) {
const config = {
connected: {
color: 'bg-green-500',
text: 'Подключено',
icon: '●'
},
connecting: {
color: 'bg-yellow-500 animate-pulse',
text: 'Подключение...',
icon: '◐'
},
reconnecting: {
color: 'bg-orange-500 animate-pulse',
text: 'Переподключение...',
icon: '◑'
},
disconnected: {
color: 'bg-red-500',
text: 'Нет связи',
icon: '○'
}
};
const { color, text, icon } = config[state] || config.disconnected;
return (
<div className="flex items-center gap-2 text-sm">
<span className={`w-2 h-2 rounded-full ${color}`} />
<span className="text-gray-600">{text}</span>
</div>
);
} |
|
Очередь сообщений во время разрыва предотвращает потерю данных. Пользователь печатает сообщение, нажимает отправить — соединения нет. Вместо ошибки складываем в буфер:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| const pendingMessages = useRef([]);
function sendMessage(text) {
const message = {
id: Date.now(),
text,
timestamp: new Date().toISOString()
};
if (socket.connected) {
socket.emit('message', message);
} else {
pendingMessages.current.push(message);
// Показываем сообщение как pending в UI
setMessages(prev => [...prev, { ...message, status: 'pending' }]);
}
}
socket.on('connect', () => {
// Отправляем накопленные сообщения
while (pendingMessages.current.length > 0) {
const msg = pendingMessages.current.shift();
socket.emit('message', msg);
}
}); |
|
Я видел баг где пользователь отправил пять сообщений оффлайн, затем они все ушли на сервер одновременно при реконнекте. Сервер обработал их с интервалом в миллисекунды, и timestamp'ы склеились. В истории чата пять реплик шли подряд с одинаковым временем. Пришлось добавлять искусственную задержку между отправками из очереди или отправлять пачкой через специальный endpoint.
Heartbeat пинги поддерживают соединение живым через прокси и файрволы. Socket.io делает это автоматически, но интервал можно настроить:
| JavaScript | 1
2
3
4
| const socket = io('http://localhost:3000', {
pingInterval: 10000, // Пинг каждые 10 секунд
pingTimeout: 5000 // Считать мертвым если нет ответа 5 секунд
}); |
|
Слишком частые пинги нагружают сеть и батарею мобильных устройств. Слишком редкие — соединение может умереть незаметно для клиента. Десять секунд — разумный баланс для большинства сценариев.
Ручной реконнект иногда нужен когда автоматика буксует:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| function ManualReconnect() {
const [isReconnecting, setIsReconnecting] = useState(false);
function handleReconnect() {
setIsReconnecting(true);
socket.disconnect();
setTimeout(() => {
socket.connect();
setIsReconnecting(false);
}, 500);
}
return (
<button
onClick={handleReconnect}
disabled={isReconnecting}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
{isReconnecting ? 'Подключение...' : 'Переподключить'}
</button>
);
} |
|
Реконнект — это не баг, а фича которую нужно обрабатывать явно. Игнорирование разрывов соединения гарантирует жалобы пользователей на "глючный чат который постоянно зависает". Правильная обработка превращает неизбежную проблему в незаметный для пользователя процесс.
Tailwindcss кеширование Здравствуйте уважаемые форумчане! Вчера столкнулся с такой проблемой: в файле стилей с директивой... Как поместить Icons вниз страницы Vue.JS Nuxt.JS Tailwindcss? Привет,
создал компоненту, но не получается поместить кнопки в самый низ страницы с отступом слева... Bootstrap vs TailwindCSS? Я не знаю ни того, ни другого - никакого фреймворка CSS.:(
Хотелось бы узнать мнение компетентных... Не работает фреймворк tailwindcss <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta...
Дублирование сообщений
Дубликаты в чате раздражают пользователей сильнее чем задержки или разрывы соединения. Увидеть свое сообщение дважды или трижды подряд создает ощущение сломанного приложения, даже если все остальное работает идеально.
Корневая причина дублей — ненадежность сетевых подтверждений. Клиент отправил сообщение на сервер через socket.emit(). Сервер получил, обработал, разослал всем участникам, отправил acknowledgement обратно клиенту. Но ACK потерялся в сети или пришел после таймаута. Клиент думает что сообщение не доставлено и отправляет повторно. Сервер получает идентичный текст, обрабатывает как новое сообщение — вуаля, дубликат.
Я столкнулся с массовым дублированием на проекте где пользователи жаловались что каждое третье сообщение появляется дважды. Оказалось, мобильное приложение агрессивно ретраило отправку при малейшем подозрении на проблемы с сетью. Сервер честно обрабатывал каждую попытку, не проверяя уникальность. Идемпотентность через уникальные идентификаторы — базовое решение. Каждое сообщение получает ID при создании на клиенте, и сервер отбрасывает дубликаты:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
| // Клиент
function sendMessage(text) {
const messageId = `${userId}-${Date.now()}-${Math.random().toString(36)}`;
const message = {
id: messageId,
text,
author: userId,
clientTimestamp: Date.now()
};
socket.emit('message', message, (ack) => {
if (!ack.success) {
// Можем повторить с тем же ID
retryMessage(message);
}
});
}
// Сервер
const processedMessageIds = new Set();
const ID_TTL = 60000; // Храним ID минуту
io.on('connection', (socket) => {
socket.on('message', (data, callback) => {
if (processedMessageIds.has(data.id)) {
// Уже обрабатывали, отправляем успешный ack
callback({ success: true, duplicate: true });
return;
}
processedMessageIds.add(data.id);
// Обработка сообщения
const message = processMessage(data);
io.emit('message', message);
callback({ success: true, messageId: message.id });
// Удаляем ID через минуту
setTimeout(() => {
processedMessageIds.delete(data.id);
}, ID_TTL);
});
}); |
|
Set с временными ID работает для одного процесса. При горизонтальном масштабировании с несколькими инстансами Node.js нужен shared storage. Redis идеален для этого — быстрый доступ, встроенная TTL для ключей:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| const redis = require('redis');
const client = redis.createClient();
socket.on('message', async (data, callback) => {
const key = `msg:${data.id}`;
const exists = await client.exists(key);
if (exists) {
callback({ success: true, duplicate: true });
return;
}
await client.setEx(key, 60, 'processed');
// Обработка сообщения
const message = processMessage(data);
io.emit('message', message);
callback({ success: true });
}); |
|
Клиентская дедупликация тоже нужна. Сервер может прислать дубликат из-за бага или race condition. Проверяй ID перед добавлением в стейт:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
| const [messages, setMessages] = useState([]);
const messageIds = useRef(new Set());
socket.on('message', (msg) => {
if (messageIds.current.has(msg.id)) {
console.warn('Дубликат получен:', msg.id);
return;
}
messageIds.current.add(msg.id);
setMessages(prev => [...prev, msg]);
}); |
|
Timestamp'ы помогают отлавливать подозрительные дубликаты. Два сообщения с разницей в миллисекунды от одного автора с одинаковым текстом — почти наверняка дубль:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| const recentMessages = new Map(); // author -> [timestamp, text]
socket.on('message', (msg) => {
const key = msg.author;
const recent = recentMessages.get(key);
if (recent &&
Math.abs(msg.timestamp - recent.timestamp) < 1000 &&
msg.text === recent.text) {
console.warn('Подозрительный дубликат');
return;
}
recentMessages.set(key, {
timestamp: msg.timestamp,
text: msg.text
});
addMessage(msg);
}); |
|
Оптимистичные обновления усложняют картину. Показываешь сообщение сразу при отправке с временным ID. Сервер возвращает серверный ID. Теперь одно сообщение существует дважды — с клиентским и серверным идентификаторами. Нужен маппинг:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| const pendingMessages = useRef(new Map()); // localId -> message
function sendMessage(text) {
const localId = generateLocalId();
const message = { localId, text, status: 'sending' };
pendingMessages.current.set(localId, message);
setMessages(prev => [...prev, message]);
socket.emit('message', { localId, text }, (ack) => {
if (ack.success) {
// Заменяем временное сообщение на серверное
setMessages(prev => prev.map(m =>
m.localId === localId
? { ...m, id: ack.serverId, status: 'sent' }
: m
));
pendingMessages.current.delete(localId);
}
});
} |
|
Дублирование — это не баг который можно исправить один раз. Это системная проблема распределенных систем, требующая защиты на всех уровнях: клиент, сервер, хранилище. Идемпотентность, уникальные ID и таймауты дедупликации должны быть встроены в архитектуру с первого дня, а не добавляться после жалоб пользователей.
Два пользователя нажимают "Отправить" в одну миллисекунду. Оба сообщения летят на сервер параллельно. Сервер обрабатывает их в случайном порядке в зависимости от загрузки процессора, сетевой задержки, фазы event loop. Результат — сообщения появляются в чате в порядке который не соответствует намерениям отправителей.
Я видел диалог где вопрос "Какой адрес?" появился после ответа "Ленина 15" потому что ответ ушёл на долю секунды раньше. Пользователи путались, переспрашивали, обвиняли друг друга в невнимательности. А причина чисто техническая — отсутствие гарантий порядка при параллельной отправке.
WebSocket не гарантирует FIFO (first in, first out) между разными соединениями. Внутри одного соединения порядок сохраняется, но два клиента — два независимых потока. Сервер получает пакеты когда захочет TCP-стек операционной системы, не когда клиенты нажали кнопку. Timestamp'ы клиента ненадёжны. Часы на устройствах расходятся на секунды, иногда на минуты если настройки сбиты. Полагаться на Date.now() с клиента для определения порядка — гарантированно получить хаос:
| JavaScript | 1
2
3
4
5
| // Плохо - клиентское время ненадежно
socket.emit('message', {
text: 'Привет',
timestamp: Date.now() // Может быть в прошлом или будущем
}); |
|
Серверные timestamp'ы решают проблему синхронизации часов. Сервер проставляет время получения сообщения на основе своих часов, и это время становится источником истины:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
| // Сервер
socket.on('message', (data) => {
const message = {
...data,
id: generateId(),
serverTimestamp: Date.now(), // Серверное время
receivedAt: new Date().toISOString()
};
io.emit('message', message);
}); |
|
Проблема в том что серверное время отражает момент прихода пакета, а не момент когда пользователь нажал кнопку. При разной сетевой задержке порядок искажается. Пользователь А отправил сообщение, через секунду пользователь Б ответил. Но у Б быстрый интернет, у А медленный. Сервер получает сообщение Б раньше, проставляет более ранний timestamp — хронология ломается.
Sequence numbers на уровне сессии дают строгий порядок внутри одного подключения:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
| // Клиент
let messageSequence = 0;
function sendMessage(text) {
socket.emit('message', {
text,
sequence: ++messageSequence,
clientTimestamp: Date.now()
});
} |
|
Сервер может отсортировать сообщения от одного клиента по sequence, но между разными клиентами проблема остаётся. Нужен глобальный порядок.
Ламповые временные метки (Lamport timestamps) решают проблему логического времени в распределённых системах. Каждое событие получает монотонно возрастающий счётчик. При отправке сообщения клиент включает свой счётчик. При получении события клиент обновляет свой счётчик на `max(localCounter, receivedCounter) + 1`:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| let lamportClock = 0;
function sendMessage(text) {
lamportClock++;
socket.emit('message', {
text,
lamportTime: lamportClock
});
}
socket.on('message', (msg) => {
lamportClock = Math.max(lamportClock, msg.lamportTime) + 1;
addMessage(msg);
}); |
|
Сортировка по Lamport time даёт консистентный порядок событий между всеми участниками. Два события с одинаковым Lamport временем разрешаются через дополнительный критерий — например, лексикографическое сравнение ID отправителей.
Практически я редко использую полноценные Lamport timestamps для чатов — оверкил для большинства сценариев. Достаточно комбинации серверного времени с микросекундной точностью и sequence number как tie-breaker:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
| // Сервер
let globalSequence = 0;
socket.on('message', (data) => {
const message = {
...data,
serverTime: Date.now(),
microTime: process.hrtime.bigint(), // Наносекундная точность
sequence: ++globalSequence
};
io.emit('message', message);
}); |
|
Клиенты сортируют сообщения по serverTime, затем по sequence если время совпало. Вероятность коллизии микроскопическая, а если случилась — sequence разрешает однозначно.
Конфликты при одновременной отправке неизбежны в любой распределённой системе. Попытки полностью их устранить ведут к сложным консенсус-протоколам типа Raft или Paxos, избыточным для обычного чата. Достаточно иметь детерминированный способ разрешения конфликтов который даёт консистентный результат для всех участников.
Пользователь уходит в оффлайн не по расписанию. Метро, лифт, туннель, разряженная батарея, переключение между Wi-Fi и мобильным интернетом — соединение рвется внезапно. Но желание отправить сообщение не исчезает вместе с сетью, и приложение должно справляться с этим элегантно. Простейший подход — блокировать инпут при отсутствии соединения. Показываешь "Нет связи", делаешь кнопку отправки неактивной, и пользователь ждет. Работает, но раздражает. Печатаешь длинное сообщение, за секунду до отправки сеть пропадает — приходится ждать реконнекта или копировать текст чтобы не потерять.
Оффлайн-очередь накапливает сообщения локально и отправляет пачкой при восстановлении связи. Пользователь продолжает взаимодействовать с интерфейсом как обычно, не замечая технических проблем:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| const offlineQueue = useRef([]);
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
const handleOnline = () => {
setIsOnline(true);
flushOfflineQueue();
};
const handleOffline = () => {
setIsOnline(false);
};
socket.on('connect', handleOnline);
socket.on('disconnect', handleOffline);
return () => {
socket.off('connect', handleOnline);
socket.off('disconnect', handleOffline);
};
}, []);
function sendMessage(text) {
const message = {
id: generateLocalId(),
text,
author: currentUser,
clientTimestamp: Date.now(),
status: isOnline ? 'sending' : 'queued'
};
if (isOnline && socket.connected) {
socket.emit('message', message);
} else {
offlineQueue.current.push(message);
}
// Оптимистично показываем в UI
setMessages(prev => [...prev, message]);
} |
|
При восстановлении связи сбрасываешь очередь на сервер. Важно делать это последовательно, а не параллельно, чтобы сохранить порядок:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| async function flushOfflineQueue() {
while (offlineQueue.current.length > 0) {
const message = offlineQueue.current[0];
try {
await new Promise((resolve, reject) => {
socket.emit('message', message, (ack) => {
if (ack.success) {
resolve();
} else {
reject(new Error(ack.error));
}
});
});
// Успешно отправили, убираем из очереди
offlineQueue.current.shift();
// Обновляем статус в UI
setMessages(prev => prev.map(m =>
m.id === message.id
? { ...m, status: 'sent' }
: m
));
} catch (error) {
console.error('Ошибка отправки:', error);
// Оставляем в очереди, попробуем позже
break;
}
}
} |
|
LocalStorage сохраняет очередь между перезагрузками страницы. Пользователь написал сообщение оффлайн, закрыл вкладку, открыл через час — сообщение всё ещё в очереди и отправится автоматически:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| const QUEUE_KEY = 'chat_offline_queue';
function saveQueue() {
localStorage.setItem(QUEUE_KEY, JSON.stringify(offlineQueue.current));
}
function loadQueue() {
const saved = localStorage.getItem(QUEUE_KEY);
if (saved) {
offlineQueue.current = JSON.parse(saved);
}
}
// При инициализации
useEffect(() => {
loadQueue();
if (isOnline && offlineQueue.current.length > 0) {
flushOfflineQueue();
}
}, []);
// При изменении очереди
useEffect(() => {
saveQueue();
}, [offlineQueue.current.length]); |
|
Лимит размера очереди предотвращает переполнение памяти и storage. Пользователь не должен накопить тысячу сообщений оффлайн — это нереалистичный сценарий:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
| const MAX_QUEUE_SIZE = 50;
function sendMessage(text) {
if (offlineQueue.current.length >= MAX_QUEUE_SIZE) {
showError('Слишком много сообщений в очереди. Подключитесь к интернету.');
return;
}
// Обычная логика отправки
} |
|
Индикация статуса сообщения даёт пользователю понимание что происходит. Иконка часов для queued, галочка для sent, две галочки для delivered:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| x
function MessageStatus({ status }) {
const icons = {
queued: '',
sending: '',
sent: '',
delivered: '',
failed: ''
};
return (
<span className="text-xs text-gray-500 ml-2">
{icons[status]}
</span>
);
} |
|
Ретраи для неудачных отправок нужны когда сервер временно недоступен или перегружен. Но не бесконечные — после пяти попыток помечаешь сообщение как failed и даёшь пользователю ручную кнопку повтора:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| async function sendWithRetry(message, maxRetries = 5) {
let attempts = 0;
while (attempts < maxRetries) {
try {
await socket.emit('message', message);
return { success: true };
} catch (error) {
attempts++;
if (attempts < maxRetries) {
await new Promise(resolve =>
setTimeout(resolve, 1000 * attempts)
);
}
}
}
return { success: false, error: 'Max retries exceeded' };
} |
|
Я работал над мессенджером для регионов с нестабильным интернетом. Пользователи могли быть оффлайн по несколько часов подряд. Очередь в LocalStorage сохраняла до ста сообщений, при подключении они уходили пачкой через специальный bulk-endpoint который обрабатывал их атомарно. Сервер либо принимал всю пачку, либо отклонял целиком с указанием причины. Частичная отправка создавала бы дыры в истории диалога.
Оффлайн-режим — это не edge case который можно игнорировать. Для мобильных пользователей это норма жизни, и качественная обработка отключений отличает профессиональное приложение от любительской поделки.
Синхронизация времени между клиентом и сервером
Часы на клиентских устройствах врут. Смартфон может отставать на три минуты, ноутбук на пять, а десктопный компьютер показывать время позавчерашнего дня потому что батарейка BIOS сдохла. Полагаться на Date.now() с клиента для определения порядка событий — это гарантированный способ получить хаос в чате.
Я отлаживал баг где сообщения появлялись "из будущего". Пользователь отправлял реплику, она показывалась с временем на десять минут вперёд относительно предыдущих. Оказалось, на его телефоне была включена неправильная временная зона плюс ручная коррекция времени. Клиентский timestamp летел на сервер и использовался для сортировки без валидации.
Серверное время как источник истины решает проблему несинхронизированных часов. Клиент отправляет сообщение, сервер проставляет timestamp на основе своего времени:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
| // Сервер
socket.on('message', (data) => {
const message = {
...data,
serverTimestamp: Date.now(),
receivedAt: new Date().toISOString()
};
io.emit('message', message);
}); |
|
Проблема в том что пользователь видит время отправки, отличающееся от того момента когда он нажал кнопку. Сообщение ушло в 14:30:00 по его часам, но сервер получил в 14:30:02 и проставил это время. Для самого отправителя выглядит как будто чат тормозит на две секунды.
Гибридный подход хранит оба timestamp'а — клиентский для отображения пользователю, серверный для сортировки:
| JavaScript | 1
2
3
4
5
6
| const message = {
text: data.text,
clientTimestamp: data.timestamp,
serverTimestamp: Date.now(),
displayTime: data.timestamp // Показываем клиентское время
}; |
|
Но тогда разные пользователи видят разное время для одного сообщения. У меня часы показывают 15:00, у тебя 15:05 — мы смотрим на одно сообщение и видим разные метки.
Clock skew компенсация вычисляет разницу между клиентскими и серверными часами при подключении. Клиент посылает свой timestamp, сервер отвечает своим, клиент вычисляет offset с учётом round-trip времени:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| // Клиент
let clockOffset = 0;
function syncClock() {
const clientTime = Date.now();
socket.emit('time-sync', { clientTime }, (serverTime) => {
const roundTrip = Date.now() - clientTime;
const estimatedServerTime = serverTime + (roundTrip / 2);
clockOffset = estimatedServerTime - Date.now();
console.log(`Разница часов: ${clockOffset}ms`);
});
}
// Вызываем при подключении
socket.on('connect', () => {
syncClock();
});
// Используем при отображении времени
function getAdjustedTime() {
return Date.now() + clockOffset;
} |
|
NTP-подобный протокол с множественными замерами даёт точность до миллисекунд. Делаешь пять запросов, отбрасываешь выбросы, усредняешь результат:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| async function accurateClockSync() {
const samples = [];
for (let i = 0; i < 5; i++) {
const clientSend = Date.now();
const serverTime = await new Promise(resolve => {
socket.emit('time-sync', { clientSend }, resolve);
});
const clientReceive = Date.now();
const roundTrip = clientReceive - clientSend;
const offset = serverTime + (roundTrip / 2) - clientReceive;
samples.push({ offset, roundTrip });
await new Promise(resolve => setTimeout(resolve, 100));
}
// Отбрасываем выбросы и усредняем
samples.sort((a, b) => a.roundTrip - b.roundTrip);
const middle = samples.slice(1, 4);
clockOffset = middle.reduce((sum, s) => sum + s.offset, 0) / middle.length;
} |
|
Периодическая ресинхронизация компенсирует дрейф часов. Кварцевые генераторы в устройствах неидеальны, время постепенно расходится. Синхронизируешь раз в пять минут или при обнаружении значительного расхождения:
| JavaScript | 1
2
3
4
5
| setInterval(() => {
if (socket.connected) {
syncClock();
}
}, 5 * 60 * 1000); |
|
Я видел чат где разработчики игнорировали синхронизацию времени, полагаясь что "у всех же есть автоматическая синхронизация через интернет". Практика показала что у 15% пользователей часы отличались от серверного времени больше чем на минуту. Сообщения прыгали в истории, порядок ломался, жалобы сыпались пачками.
Timezone considerations добавляют ещё один слой сложности. Сервер в UTC, пользователи в разных часовых поясах. Показывать время в UTC неудобно, конвертировать на клиенте в локальную зону — правильно. JavaScript делает это автоматически через toLocaleTimeString(), но нужно хранить timestamp'ы в миллисекундах от эпохи, а не строки с датами.
Синхронизация времени кажется мелочью пока не столкнёшься с реальными проблемами несогласованных часов. Правильная обработка временных меток — это разница между чатом который просто работает и чатом который работает корректно в любых условиях.
Безопасность в чатах игнорируют до первого инцидента. Потом начинается паника, экстренные патчи, извинения перед пользователями. Я видел проекты где утечка переписки стоила компании репутации и клиентов, а причина была банальной — отсутствие элементарной валидации входных данных.
XSS-атаки через сообщения — классика жанра. Пользователь отправляет <script>alert('pwned')</script>, React послушно рендерит это как JSX, и вот уже JavaScript злоумышленника выполняется в браузерах всех участников чата. Кража токенов, перехват сессий, редирект на фишинговые сайты — всё что угодно.
React экранирует текст по умолчанию, но стоит использовать dangerouslySetInnerHTML для форматированных сообщений — дверь открыта. Я отлаживал чат где разработчик решил поддержать HTML-разметку для "красивых сообщений". Через неделю кто-то влил вредоносный скрипт через <img onerror="...">. Паника, откат, потеря доверия. Санитизация обязательна на обеих сторонах. На клиенте — чтобы не показывать вредоносный контент. На сервере — чтобы не хранить его вообще:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // Сервер
const sanitizeHtml = require('sanitize-html');
socket.on('message', (data) => {
const cleanText = sanitizeHtml(data.text, {
allowedTags: [], // Вообще никаких тегов
allowedAttributes: {}
});
if (cleanText !== data.text) {
socket.emit('error', { message: 'Недопустимое содержимое' });
return;
}
// Продолжаем обработку
}); |
|
SQL-инъекции не актуальны если используешь in-memory storage, но как только прикручиваешь базу данных — привет, старые знакомые проблемы. Параметризованные запросы или ORM обязательны, никаких конкатенаций строк с пользовательским вводом.
Rate limiting предотвращает флуд и DDoS. Злоумышленник может завалить сервер тысячами сообщений в секунду, забить очереди, исчерпать память. Ограничение на клиента через token bucket или sliding window:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| const messageRateLimits = new Map(); // userId -> { count, resetTime }
const MAX_MESSAGES_PER_MINUTE = 30;
socket.on('message', (data) => {
const userId = socket.userId;
const now = Date.now();
const limit = messageRateLimits.get(userId) || { count: 0, resetTime: now + 60000 };
if (now > limit.resetTime) {
limit.count = 0;
limit.resetTime = now + 60000;
}
if (limit.count >= MAX_MESSAGES_PER_MINUTE) {
socket.emit('rate-limit', {
message: 'Слишком много сообщений, подождите'
});
return;
}
limit.count++;
messageRateLimits.set(userId, limit);
// Обрабатываем сообщение
}); |
|
Аутентификация через токены вместо передачи паролей в каждом запросе. JWT подходит идеально — выдал при логине, проверяешь подпись при подключении к WebSocket:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
| io.use((socket, next) => {
const token = socket.handshake.auth.token;
try {
const decoded = jwt.verify(token, JWT_SECRET);
socket.userId = decoded.userId;
next();
} catch (err) {
next(new Error('Неверный токен'));
}
}); |
|
CORS-политики для WebSocket настраиваются отдельно от HTTP. Дефолтный origin: * в Socket.io — дыра размером с ворота. Укажи конкретные домены:
| JavaScript | 1
2
3
4
5
6
| const io = new Server(server, {
cors: {
origin: ['https://yourdomain.com', 'https://app.yourdomain.com'],
credentials: true
}
}); |
|
Проверка прав доступа к комнатам критична для групповых чатов. Пользователь не должен подключаться к чужой переписке просто зная ID комнаты:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
| socket.on('join-room', async (roomId) => {
const hasAccess = await checkUserAccess(socket.userId, roomId);
if (!hasAccess) {
socket.emit('error', { message: 'Доступ запрещён' });
return;
}
socket.join(roomId);
}); |
|
Логирование подозрительной активности помогает выявить атаки постфактум. Множественные неудачные попытки аутентификации, аномальный паттерн сообщений, попытки инъекций — всё в лог с IP-адресом и timestamp'ом.
Я работал над чатом где безопасность добавляли "потом". Через три месяца эксплуатации случился инцидент — кто-то получил доступ к переписке топ-менеджмента. Расследование показало: токены хранились в localStorage без шифрования, CORS был открыт всем, валидация отсутствовала. Переписали половину кода за выходные. Дорогой урок.
Безопасность — это не фича которую добавляешь когда есть время. Это фундамент который закладываешь с первого коммита. Игнорирование базовых практик неизбежно выстрелит, вопрос только когда и насколько больно.
Валидация данных на обеих сторонах
Клиентская валидация создаёт иллюзию безопасности. Пользователь видит красивое сообщение "Максимум 500 символов", но любой, кто умеет открывать DevTools, отправит на сервер что угодно. JavaScript на клиенте — это просьба, а не требование. Настоящая защита начинается на сервере.
Двусторонняя валидация даёт лучшее из обоих миров. Клиент проверяет данные для мгновенной обратной связи — пользователь видит ошибку до отправки. Сервер проверяет повторно, потому что не доверяет никому. Звучит как дублирование кода, и оно так и есть, но альтернатива хуже. Типичная схема валидации сообщения на клиенте:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| function validateMessage(text) {
const errors = [];
if (!text || text.trim().length === 0) {
errors.push('Сообщение не может быть пустым');
}
if (text.length > 5000) {
errors.push('Максимум 5000 символов');
}
// Проверка на подозрительные паттерны
if (/<script|javascript:|onerror=/i.test(text)) {
errors.push('Недопустимое содержимое');
}
// Проверка на чрезмерное количество ссылок
const urlCount = (text.match(/https?:\/\//g) || []).length;
if (urlCount > 5) {
errors.push('Слишком много ссылок');
}
return errors;
}
function sendMessage(text) {
const errors = validateMessage(text);
if (errors.length > 0) {
showErrors(errors);
return;
}
socket.emit('message', { text });
} |
|
Сервер не верит клиенту и валидирует заново, но жёстче:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| function validateMessageServer(data) {
if (typeof data.text !== 'string') {
return { valid: false, error: 'Неверный тип данных' };
}
const text = data.text.trim();
if (text.length === 0) {
return { valid: false, error: 'Пустое сообщение' };
}
if (text.length > 5000) {
return { valid: false, error: 'Сообщение слишком длинное' };
}
// Строгая проверка на HTML и скрипты
if (/<[^>]*>/g.test(text)) {
return { valid: false, error: 'HTML запрещён' };
}
// Проверка на Unicode-трюки
if (/[\u202E\u200B-\u200D\uFEFF]/g.test(text)) {
return { valid: false, error: 'Подозрительные символы' };
}
return { valid: true };
}
socket.on('message', (data) => {
const validation = validateMessageServer(data);
if (!validation.valid) {
socket.emit('validation-error', {
field: 'text',
error: validation.error
});
return;
}
// Обработка валидного сообщения
processMessage(data);
}); |
|
Я встречал чаты где валидация была только на клиенте. Злоумышленник отправлял прямые WebSocket-фреймы через curl, минуя весь JavaScript. На сервере проверок не было — сообщения с гигабайтами мусора ложили базу данных и забивали память. Даунтайм на три часа, разгребание последствий ещё неделю.
Общая логика валидации через схемы избавляет от дублирования. Определяешь правила один раз, используешь на обеих сторонах:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
| // shared/validation.js - общий модуль для клиента и сервера
const messageSchema = {
text: {
type: 'string',
required: true,
minLength: 1,
maxLength: 5000,
sanitize: (value) => value.trim()
},
author: {
type: 'string',
required: true,
pattern: /^[a-zA-Z0-9_-]{3,20}$/
}
};
function validate(data, schema) {
const errors = [];
for (const [field, rules] of Object.entries(schema)) {
let value = data[field];
if (rules.required && (value === undefined || value === null)) {
errors.push(`${field} обязателен`);
continue;
}
if (value === undefined) continue;
if (rules.sanitize) {
value = rules.sanitize(value);
}
if (typeof value !== rules.type) {
errors.push(`${field} должен быть ${rules.type}`);
continue;
}
if (rules.minLength && value.length < rules.minLength) {
errors.push(`${field} минимум ${rules.minLength} символов`);
}
if (rules.maxLength && value.length > rules.maxLength) {
errors.push(`${field} максимум ${rules.maxLength} символов`);
}
if (rules.pattern && !rules.pattern.test(value)) {
errors.push(`${field} имеет неверный формат`);
}
}
return errors;
} |
|
Библиотеки типа Joi или Yup делают это элегантнее, но добавляют зависимости. Для простого чата собственная функция на сто строк работает отлично.
Type coercion на сервере опасен. JavaScript автоматически конвертирует типы, и это создаёт уязвимости. Ожидал число, получил строку "123", JavaScript преобразовал — валидация пропустила. Строгая проверка типов через typeof и === обязательна. Контекстная валидация проверяет не только формат, но и логику. Удаление сообщения с ID которого не существует? Редактирование чужого сообщения? Присоединение к комнате без прав доступа? Эти проверки невозможны на клиенте — только сервер знает текущее состояние системы.
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| socket.on('delete-message', async (data) => {
// Формальная валидация
if (!data.messageId || typeof data.messageId !== 'string') {
return socket.emit('error', { message: 'Неверный ID' });
}
// Контекстная валидация
const message = await getMessage(data.messageId);
if (!message) {
return socket.emit('error', { message: 'Сообщение не найдено' });
}
if (message.author !== socket.userId) {
return socket.emit('error', { message: 'Можно удалять только свои сообщения' });
}
// Выполняем удаление
await deleteMessage(data.messageId);
io.emit('message-deleted', { id: data.messageId });
}); |
|
Белые списки против чёрных — философский спор с чёткими победителями. Чёрный список: блокируешь <script>, кто-то использует <SCRIPT> или <img onerror>. Игра в кошки-мышки без конца. Белый список: разрешаешь только конкретные символы или паттерны, всё остальное отбрасывается. Безопаснее, но менее гибко.
Для текстовых сообщений я использую белый список символов: буквы, цифры, базовая пунктуация, emoji. Всё остальное — вон. Жёстко, зато надёжно.
Валидация не замедляет приложение если делать правильно. Проверки работают за микросекунды, но создают непробиваемую стену между злоумышленником и системой. Один раз написал, тысячу раз защитил.
XSS (Cross-Site Scripting) в чатах — это когда злоумышленник внедряет исполняемый код через обычное сообщение. Выглядит безобидно: пользователь отправляет текст, ты рендеришь его в браузере другого участника. Но если этот текст содержит JavaScript, вредоносный скрипт выполнится в контексте жертвы с доступом к cookies, localStorage, возможностью отправки запросов от имени пользователя. React защищает автоматически при использовании JSX. Пишешь <p>{message.text}</p> — React экранирует спецсимволы, превращая <script> в безопасную строку <script>. Браузер отображает её как текст, не выполняя. Но стоит использовать dangerouslySetInnerHTML для "красивых" сообщений с форматированием — защита слетает.
Я разбирал инцидент где разработчик решил поддержать Markdown в чате. Конвертировал текст в HTML через библиотеку, затем рендерил через dangerouslySetInnerHTML. Библиотека была старой версии с известной уязвимостью. Злоумышленник отправил специально сформированный Markdown, который превратился в <img src=x onerror="...">. Код выполнился у всех кто открыл чат, украл токены через XHR-запрос на контролируемый сервер.
Санитизация HTML через DOMPurify — золотой стандарт если форматирование необходимо:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
| import DOMPurify from 'dompurify';
function SafeMessage({ html }) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a'],
ALLOWED_ATTR: ['href'],
ALLOW_DATA_ATTR: false
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
} |
|
DOMPurify парсит HTML, удаляет опасные теги и атрибуты, возвращает чистую строку. Даже если злоумышленник вложит <script> в десять уровней обфускации — библиотека найдёт и вырежет.
Content Security Policy добавляет слой защиты на уровне браузера. Заголовок CSP запрещает выполнение inline-скриптов, ограничивает источники загрузки ресурсов:
| JavaScript | 1
2
3
4
5
| // На сервере при отдаче HTML
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https:; connect-src 'self' wss://yourdomain.com"
); |
|
Даже если XSS проскочит через санитизацию, браузер заблокирует выполнение скрипта из-за CSP. Защита эшелонированная — одна линия обороны провалилась, следующая держит.
Контекстное экранирование зависит от того где отображается данные. В HTML-атрибутах свои правила, в JavaScript-строках другие, в URL третьи. Универсальная функция экранирования не существует — нужен правильный метод для конкретного контекста. Для обычного текста в чате достаточно React-автоматики. Хочешь ссылки кликабельными? Не конвертируй в <a> напрямую, используй безопасный парсер:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| function linkify(text) {
const urlRegex = /(https?:\/\/[^\s]+)/g;
return text.split(urlRegex).map((part, i) => {
if (part.match(urlRegex)) {
return (
<a
key={i}
href={part}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 underline"
>
{part}
</a>
);
}
return part;
});
} |
|
rel="noopener noreferrer" критичен для безопасности ссылок. Без него открытая страница получает доступ к window.opener и может манипулировать родительским окном через JavaScript. Классический вектор фишинга: жертва кликает ссылку, злоумышленник подменяет родительскую страницу поддельным логином.
Проверка протокола URL отсекает javascript: псевдопротокол:
| JavaScript | 1
2
3
4
5
6
7
8
| function isSafeUrl(url) {
try {
const parsed = new URL(url);
return ['http:', 'https:'].includes(parsed.protocol);
} catch {
return false;
}
} |
|
Эмодзи и Unicode создают нестандартные векторы атак. Направленные overrides типа U+202E переворачивают текст визуально, маскируя вредоносные ссылки. Zero-width символы прячут контент от пользователя но остаются в коде. Фильтруй подозрительные диапазоны Unicode:
| JavaScript | 1
2
3
| function removeInvisibleChars(text) {
return text.replace(/[\u200B-\u200D\uFEFF\u202E]/g, '');
} |
|
Тестирование XSS-защиты через автоматические сканеры типа OWASP ZAP ловит очевидные дыры. Но хитрые векторы требуют ручного пентеста. Я нанимал специалиста который нашёл способ внедрить код через комбинацию emoji с zero-width joiners — сканеры пропустили, человек поймал.
XSS не исчезнет пока существует динамический контент. Вечная борьба между атакующими которые изобретают новые векторы и защитниками которые латают дыры. Санитизация, CSP, безопасные библиотеки — многослойная оборона даёт шанс выиграть эту войну. Но расслабляться нельзя никогда.
Флуд и спам убивают чат быстрее любого технического бага. Один пользователь отправляет сто сообщений в секунду — сервер захлёбывается, остальные участники видят стену из бессмысленного текста, база данных пухнет от мусора. Ограничение частоты запросов (rate limiting) — это не просто защита от злоумышленников, а необходимый механизм для нормальной работы системы. Простейший подход — счётчик сообщений в скользящем окне. Храним количество отправленных сообщений за последнюю минуту, блокируем при превышении лимита:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| const userRateLimits = new Map(); // userId -> { count, windowStart }
const RATE_LIMIT = 20; // Сообщений в минуту
const WINDOW_MS = 60000;
socket.on('message', (data) => {
const userId = socket.userId;
const now = Date.now();
const userLimit = userRateLimits.get(userId);
if (!userLimit || now - userLimit.windowStart > WINDOW_MS) {
// Новое окно
userRateLimits.set(userId, { count: 1, windowStart: now });
} else {
// Проверяем лимит
if (userLimit.count >= RATE_LIMIT) {
socket.emit('rate-limit-exceeded', {
retryAfter: WINDOW_MS - (now - userLimit.windowStart)
});
return;
}
userLimit.count++;
}
// Обрабатываем сообщение
processMessage(data);
}); |
|
Проблема фиксированного окна — burst в начале периода. Пользователь отправил двадцать сообщений за первые три секунды минуты, затем ещё двадцать в первые три секунды следующей минуты. Технически не превысил лимит, но сервер получил сорок сообщений за шесть секунд. Token bucket алгоритм решает эту проблему элегантно. У каждого пользователя есть ведро токенов которое пополняется с постоянной скоростью. Отправка сообщения тратит токен. Ведро заполнено — можешь отправить burst, затем скорость ограничена пополнением:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
| class TokenBucket {
constructor(capacity, refillRate) {
this.capacity = capacity;
this.tokens = capacity;
this.refillRate = refillRate; // Токенов в секунду
this.lastRefill = Date.now();
}
tryConsume() {
this.refill();
if (this.tokens >= 1) {
this.tokens -= 1;
return true;
}
return false;
}
refill() {
const now = Date.now();
const timePassed = (now - this.lastRefill) / 1000;
const tokensToAdd = timePassed * this.refillRate;
this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
this.lastRefill = now;
}
getRetryAfter() {
if (this.tokens >= 1) return 0;
return Math.ceil((1 - this.tokens) / this.refillRate * 1000);
}
}
const buckets = new Map(); // userId -> TokenBucket
socket.on('message', (data) => {
const userId = socket.userId;
if (!buckets.has(userId)) {
buckets.set(userId, new TokenBucket(10, 0.5)); // 10 токенов, 0.5/сек
}
const bucket = buckets.get(userId);
if (!bucket.tryConsume()) {
socket.emit('rate-limit-exceeded', {
retryAfter: bucket.getRetryAfter()
});
return;
}
processMessage(data);
}); |
|
Я использовал token bucket в чате техподдержки где операторы иногда отправляли десять быстрых ответов подряд, затем делали паузу. Фиксированное окно их блокировало несправедливо, bucket позволял burst но контролировал общую скорость.
Дифференцированные лимиты для разных типов пользователей добавляют гибкости. Админы получают выше лимит или вообще без ограничений, обычные пользователи стандартный, новички или подозрительные аккаунты — жёсткий:
| JavaScript | 1
2
3
4
5
6
7
8
| function getRateLimit(userId) {
const user = getUser(userId);
if (user.role === 'admin') return Infinity;
if (user.role === 'moderator') return 50;
if (user.accountAge < 24 * 60 * 60 * 1000) return 10; // Новый аккаунт
return 20; // Обычный пользователь
} |
|
Индикация на клиенте показывает пользователю сколько осталось до блокировки. Прогресс-бар или счётчик предотвращают неожиданное срабатывание лимита:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| x
function RateLimitIndicator({ remaining, total }) {
const percentage = (remaining / total) * 100;
const color = percentage > 50 ? 'bg-green-500' :
percentage > 20 ? 'bg-yellow-500' : 'bg-red-500';
return (
<div className="w-full h-1 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full ${color} transition-all duration-300`}
style={{ width: [INLINE]${percentage}%[/INLINE] }}
/>
</div>
);
} |
|
Rate limiting — это баланс между защитой системы и удобством легитимных пользователей. Слишком жёсткие ограничения бесят людей, слишком мягкие не останавливают злоумышленников. Мониторинг реальных паттернов использования помогает найти золотую середину для конкретного приложения.
WebSocket создаёт постоянное соединение, и это меняет правила игры для DDoS-атак. В отличие от HTTP где каждый запрос независим, один WebSocket держит открытый канал и может забрасывать сервер сообщениями без необходимости повторной установки соединения. Злоумышленник с тысячей соединений превращается в армию которая бьёт по серверу непрерывным потоком данных.
Классический HTTP rate limiting через middleware типа express-rate-limit не работает для WebSocket. Соединение устанавливается один раз при handshake, дальше идёт обмен фреймами без HTTP-запросов. Нужен отдельный механизм контроля на уровне событий Socket.io.
Я столкнулся с атакой где ботнет из двух тысяч соединений слал по сто пустых сообщений в секунду. Каждое соединение технически не превышало разумный лимит для одного пользователя, но суммарно сервер получал двести тысяч событий в секунду. Node.js захлёбывался, event loop блокировался на секунды, легитимные пользователи видели таймауты. Пришлось строить многоуровневую защиту.
Первый уровень — ограничение на установку соединений с одного IP. Обычный пользователь не открывает десять WebSocket'ов одновременно:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| const connectionCounts = new Map(); // IP -> count
const MAX_CONNECTIONS_PER_IP = 5;
io.use((socket, next) => {
const ip = socket.handshake.address;
const count = connectionCounts.get(ip) || 0;
if (count >= MAX_CONNECTIONS_PER_IP) {
return next(new Error('Слишком много соединений с вашего IP'));
}
connectionCounts.set(ip, count + 1);
socket.on('disconnect', () => {
const current = connectionCounts.get(ip);
if (current <= 1) {
connectionCounts.delete(ip);
} else {
connectionCounts.set(ip, current - 1);
}
});
next();
}); |
|
Проблема с IP-based limiting — NAT и корпоративные прокси. Тысяча сотрудников компании выходят через один внешний IP, и ты блокируешь их всех скопом. Баланс нужен осторожный — я ставлю лимит достаточно высоким (10-20 соединений) чтобы не задеть легитимных пользователей за NAT.
Второй уровень — rate limiting событий per connection. Токен bucket для каждого сокета ограничивает скорость отправки любых событий:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
| class SocketRateLimiter {
constructor(socket, eventsPerSecond = 10, burst = 20) {
this.socket = socket;
this.bucket = new TokenBucket(burst, eventsPerSecond);
this.violations = 0;
this.setupListeners();
}
setupListeners() {
const originalEmit = this.socket.emit.bind(this.socket);
const originalOn = this.socket.on.bind(this.socket);
// Перехватываем входящие события
this.socket.on = (event, handler) => {
const wrappedHandler = (...args) => {
if (!this.bucket.tryConsume()) {
this.violations++;
if (this.violations > 10) {
this.socket.emit('rate-limit-ban', {
reason: 'Превышен лимит событий'
});
this.socket.disconnect(true);
return;
}
this.socket.emit('rate-limit-exceeded', {
event,
retryAfter: this.bucket.getRetryAfter()
});
return;
}
handler(...args);
};
return originalOn(event, wrappedHandler);
};
}
}
io.on('connection', (socket) => {
new SocketRateLimiter(socket, 5, 15); // 5 событий/сек, burst 15
socket.on('message', handleMessage);
socket.on('typing', handleTyping);
}); |
|
Третий уровень — детектирование аномальных паттернов. Нормальный пользователь не отправляет идентичные сообщения каждые 100 миллисекунд. Бот — да:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| const messagePatterns = new Map(); // socketId -> {lastText, count, lastTime}
socket.on('message', (data) => {
const pattern = messagePatterns.get(socket.id) || {
lastText: '',
count: 0,
lastTime: 0
};
const now = Date.now();
const timeDiff = now - pattern.lastTime;
// Одинаковый текст слишком часто
if (data.text === pattern.lastText && timeDiff < 1000) {
pattern.count++;
if (pattern.count > 5) {
socket.emit('suspicious-activity', {
reason: 'Подозрение на автоматизацию'
});
socket.disconnect(true);
return;
}
} else {
pattern.count = 0;
}
pattern.lastText = data.text;
pattern.lastTime = now;
messagePatterns.set(socket.id, pattern);
processMessage(data);
}); |
|
Connection throttling на уровне сервера ограничивает скорость handshake'ов. Socket.io поддерживает это через конфигурацию:
| JavaScript | 1
2
3
4
5
6
7
8
| const io = new Server(server, {
connectTimeout: 5000,
maxHttpBufferSize: 1e6, // 1MB максимум для HTTP long-polling
pingTimeout: 20000,
pingInterval: 25000,
upgradeTimeout: 10000,
perMessageDeflate: false // Отключаем compression для экономии CPU
}); |
|
Географическая фильтрация через GeoIP блокирует целые регионы откуда идёт атака. Не элегантно, но эффективно когда сервер горит:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
| const geoip = require('geoip-lite');
const BLOCKED_COUNTRIES = ['XX', 'YY']; // ISO коды стран
io.use((socket, next) => {
const ip = socket.handshake.address;
const geo = geoip.lookup(ip);
if (geo && BLOCKED_COUNTRIES.includes(geo.country)) {
return next(new Error('Доступ из вашей страны временно ограничен'));
}
next();
}); |
|
Мониторинг метрик критичен для раннего обнаружения атак. Резкий скачок количества соединений или событий — сигнал проблемы:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| const metrics = {
connectionsPerMinute: 0,
eventsPerMinute: 0,
disconnectsPerMinute: 0
};
setInterval(() => {
const threshold = 1000;
if (metrics.connectionsPerMinute > threshold) {
console.error('ALERT: Подозрение на DDoS');
// Уведомление админа, включение экстренных мер
}
// Сброс счётчиков
Object.keys(metrics).forEach(key => metrics[key] = 0);
}, 60000);
io.on('connection', (socket) => {
metrics.connectionsPerMinute++;
socket.onAny(() => {
metrics.eventsPerMinute++;
});
socket.on('disconnect', () => {
metrics.disconnectsPerMinute++;
});
}); |
|
Graceful degradation при атаке спасает частично сервис вместо полного падения. Когда нагрузка критична — отключаешь дорогие фичи, переводишь пользователей на read-only режим:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| let systemLoad = 'normal'; // normal, high, critical
setInterval(() => {
const load = getSystemMetrics(); // CPU, memory, event loop lag
if (load.cpu > 90 || load.memory > 85 || load.lag > 100) {
systemLoad = 'critical';
} else if (load.cpu > 70 || load.memory > 70 || load.lag > 50) {
systemLoad = 'high';
} else {
systemLoad = 'normal';
}
}, 5000);
socket.on('message', (data) => {
if (systemLoad === 'critical') {
socket.emit('system-overload', {
message: 'Сервер перегружен, отправка временно недоступна'
});
return;
}
if (systemLoad === 'high') {
// Увеличиваем rate limit, отключаем не-критичные фичи
if (data.attachments) {
socket.emit('feature-disabled', {
feature: 'attachments',
reason: 'Высокая нагрузка'
});
return;
}
}
processMessage(data);
}); |
|
DDoS на WebSocket — это не гипотетическая угроза из учебника, а реальность которая случается регулярно. Чем популярнее чат, тем привлекательнее цель. Многоуровневая защита, быстрое реагирование и мониторинг — это не параноя, а необходимость для любого серьёзного проекта.
CORS (Cross-Origin Resource Sharing) для WebSocket работает иначе чем для обычных HTTP-запросов, и это регулярно ловит разработчиков врасплох. Браузер не делает preflight OPTIONS-запрос перед установкой WebSocket-соединения, зато проверяет Origin заголовок во время handshake. Если сервер не подтверждает разрешение — соединение рвётся на старте.
Я отлаживал проблему где чат работал на localhost, но падал при деплое на production. Оказалось, в конфиге Socket.io стоял дефолтный origin: '*', который браузер блокировал при попытке отправить credentials. Спецификация CORS явно запрещает wildcard когда используются куки или авторизационные заголовки. Пришлось указывать конкретные домены.
Конфигурация CORS в Socket.io задаётся при создании сервера через объект опций:
| JavaScript | 1
2
3
4
5
6
7
8
| const io = new Server(server, {
cors: {
origin: ['https://yourdomain.com', 'https://app.yourdomain.com'],
methods: ['GET', 'POST'],
credentials: true,
allowedHeaders: ['Authorization', 'X-Custom-Header']
}
}); |
|
Параметр origin принимает строку, массив строк или функцию для динамической проверки. Функция полезна когда список доменов хранится в базе данных или зависит от окружения:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
| cors: {
origin: (origin, callback) => {
const allowedOrigins = process.env.ALLOWED_ORIGINS.split(',');
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('CORS запрещён для этого origin'));
}
},
credentials: true
} |
|
Важный нюанс — когда клиент на том же домене что и сервер, браузер не отправляет Origin заголовок вообще. Проверка !origin пропускает такие запросы, иначе сервер заблокирует собственный фронтенд.
Development и production требуют разных настроек. В разработке удобно разрешить localhost с любым портом, в продакшене — только конкретные домены:
| JavaScript | 1
2
3
4
5
6
7
| const allowedOrigins = process.env.NODE_ENV === 'production'
? ['https://yourdomain.com']
: ['http://localhost:3000', 'http://localhost:5173', 'http://localhost:8080'];
const io = new Server(server, {
cors: { origin: allowedOrigins, credentials: true }
}); |
|
Socket.io также проверяет метод запроса во время handshake. WebSocket начинается с GET, но при использовании long-polling fallback браузер делает POST. Указание methods: ['GET', 'POST'] покрывает оба сценария. Credentials включают куки, HTTP-аутентификацию и TLS-сертификаты клиента. Если твой чат использует session cookies для идентификации пользователя, credentials: true обязателен. Без него браузер не отправит куки с WebSocket-запросом, сервер не узнает кто подключился.
Я встречал архитектуру где фронтенд на app.example.com, а WebSocket-сервер на ws.example.com. Поддомены считаются разными origins, нужна явная CORS-конфигурация. Некоторые разработчики пытались обойти это через wildcard `*.example.com`, но браузеры не поддерживают такие паттерны в CORS — только точные совпадения.
Reverse proxy типа Nginx добавляет сложности. Если фронтенд и бэкенд за одним прокси на разных путях, браузер видит один origin. Но если WebSocket-сервер на отдельном порту или поддомене, CORS включается. Конфигурация прокси должна корректно проксировать заголовки:
| JavaScript | 1
2
3
4
5
6
7
8
| location /socket.io/ {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Origin $http_origin;
proxy_set_header Host $host;
} |
|
Безопасность через CORS — это первая линия защиты, но не единственная. Злоумышленник может подделать Origin заголовок в кастомном клиенте минуя браузер. Проверка origin останавливает атаки через браузер, но серверная валидация токенов и прав доступа всё равно обязательна. CORS-ошибки в консоли браузера выглядят загадочно и редко указывают на истинную причину. "WebSocket connection failed" может означать что угодно от неправильного origin до недоступности сервера. Логирование на сервере rejected handshake'ов с указанием причины экономит часы отладки. Правильная настройка CORS для WebSocket — это не просто копипаста примера из документации, а понимание как браузеры, прокси и серверы взаимодействуют на всех уровнях стека.
Аутентификация через пароли при каждом WebSocket-запросе — плохая идея по куче причин. Пароль летит по сети постоянно, увеличивается поверхность атаки, да и просто неудобно. JWT (JSON Web Token) решает проблему элегантно: пользователь логинится один раз через обычный HTTP, получает токен, дальше предъявляет его при подключении к WebSocket.
JWT состоит из трёх частей разделённых точками: заголовок, payload и подпись. Payload содержит данные о пользователе — ID, роль, время истечения. Подпись гарантирует что токен не подделан — сервер проверяет её секретным ключом который знает только он. Подделать валидную подпись без ключа вычислительно невозможно. Генерация токена при логине происходит на отдельном auth endpoint:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-me';
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
// Проверяем учётные данные
const user = await validateCredentials(username, password);
if (!user) {
return res.status(401).json({ error: 'Неверные данные' });
}
// Создаём токен с данными пользователя
const token = jwt.sign(
{
userId: user.id,
username: user.username,
role: user.role
},
JWT_SECRET,
{
expiresIn: '24h',
algorithm: 'HS256'
}
);
res.json({ token, user: { id: user.id, username: user.username } });
}); |
|
Клиент сохраняет токен в localStorage и отправляет при подключении к Socket.io через параметр auth:
| JavaScript | 1
2
3
4
5
6
7
| const token = localStorage.getItem('authToken');
const socket = io('http://localhost:3000', {
auth: {
token: token
}
}); |
|
Middleware на сервере перехватывает подключение, валидирует токен перед установкой соединения:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| io.use((socket, next) => {
const token = socket.handshake.auth.token;
if (!token) {
return next(new Error('Токен не предоставлен'));
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
// Проверяем время истечения (jwt.verify делает это автоматически)
// Добавляем данные пользователя в объект сокета
socket.userId = decoded.userId;
socket.username = decoded.username;
socket.userRole = decoded.role;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return next(new Error('Токен истёк'));
}
if (err.name === 'JsonWebTokenError') {
return next(new Error('Невалидный токен'));
}
return next(new Error('Ошибка аутентификации'));
}
}); |
|
Теперь каждый socket содержит данные пользователя, доступные в обработчиках событий:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| io.on('connection', (socket) => {
console.log(`Подключился ${socket.username} (ID: ${socket.userId})`);
socket.on('message', (data) => {
// Используем userId из токена, а не из клиентских данных
const message = {
text: data.text,
author: socket.username,
authorId: socket.userId,
timestamp: Date.now()
};
io.emit('message', message);
});
}); |
|
Критично брать userId из проверенного токена, а не доверять данным от клиента. Злоумышленник может отправить { authorId: 'admin' } в payload сообщения, но socket.userId содержит реальный ID из валидного токена. Я отлаживал баг где разработчик передавал userId через обычное событие Socket.io вместо auth. Злоумышленник подключался без токена, отправлял `socket.emit('auth', { userId: 'someone-else' })`, и сервер принимал это за чистую монету. Получил доступ к чужим диалогам просто подменив ID.
Refresh токены решают проблему истечения без принудительного логаута пользователя. JWT живёт час, refresh токен — месяц. Когда JWT истекает, клиент запрашивает новый через refresh endpoint:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| app.post('/api/refresh', async (req, res) => {
const { refreshToken } = req.body;
try {
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
const user = await getUser(decoded.userId);
const newToken = jwt.sign(
{ userId: user.id, username: user.username },
JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({ token: newToken });
} catch (err) {
res.status(401).json({ error: 'Невалидный refresh токен' });
}
}); |
|
На клиенте обрабатываешь отключение из-за истёкшего токена:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| socket.on('connect_error', async (err) => {
if (err.message === 'Токен истёк') {
const refreshToken = localStorage.getItem('refreshToken');
try {
const response = await fetch('/api/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken })
});
const data = await response.json();
localStorage.setItem('authToken', data.token);
// Переподключаемся с новым токеном
socket.auth.token = data.token;
socket.connect();
} catch (error) {
// Refresh не сработал, редиректим на логин
window.location.href = '/login';
}
}
}); |
|
Секретный ключ должен быть действительно секретным. Хранить в коде или коммитить в Git — гарантированная компрометация. Используй переменные окружения и ротируй ключи периодически:
| JavaScript | 1
2
3
4
5
| const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET || JWT_SECRET === 'your-secret-key-change-me') {
throw new Error('JWT_SECRET не настроен или использует дефолтное значение');
} |
|
JWT не шифрует данные, только подписывает. Payload читается любым у кого есть токен — просто base64-декодирование. Не клади туда пароли, номера карт или другие чувствительные данные. Храни только публичные идентификаторы и метаданные.
Аутентификация через JWT для Socket.io — это стандарт де-факто в современных приложениях. Один раз настроил правильно, и система масштабируется горизонтально без проблем — токен самодостаточен, не требует базы данных сессий или shared storage между серверами.
Шифрование содержимого сообщений: end-to-end encryption на клиенте
End-to-end шифрование (E2EE) означает что только отправитель и получатель могут прочитать содержимое сообщения. Сервер видит зашифрованный мусор и не может его расшифровать даже если захочет. Это фундаментально меняет модель безопасности - компрометация сервера не раскрывает переписку.
Я работал над проектом для медицинского учреждения где законодательство требовало чтобы конфиденциальные данные пациентов не хранились в открытом виде. Шифрование на транспортном уровне через HTTPS недостаточно - сервер всё равно получает расшифрованные данные. Пришлось реализовывать полноценный E2EE где ключи генерировались на клиентах и никогда не покидали устройства.
Web Crypto API - встроенная криптография в браузерах. Не надо тянуть огромные JavaScript-библиотеки, всё работает нативно и быстро. Асимметричное шифрование RSA для обмена ключами, симметричное AES для шифрования больших объёмов данных:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| // Генерация пары ключей при регистрации
async function generateKeyPair() {
const keyPair = await crypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256'
},
true, // extractable
['encrypt', 'decrypt']
);
// Экспортируем публичный ключ для отправки на сервер
const publicKey = await crypto.subtle.exportKey('spki', keyPair.publicKey);
const publicKeyBase64 = btoa(String.fromCharCode(...new Uint8Array(publicKey)));
// Приватный ключ храним локально в IndexedDB
await savePrivateKey(keyPair.privateKey);
return { publicKey: publicKeyBase64, privateKey: keyPair.privateKey };
} |
|
Публичный ключ отправляется на сервер и связывается с аккаунтом пользователя. Приватный остаётся в браузере - никто кроме владельца не может расшифровать сообщения адресованные ему. Даже взлом сервера не даёт доступа к приватным ключам.
Гибридное шифрование комбинирует скорость симметричных алгоритмов с безопасностью асимметричных. Генерируешь случайный AES-ключ для каждого сообщения, шифруешь текст этим ключом, затем шифруешь сам ключ публичным RSA-ключом получателя:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
| async function encryptMessage(text, recipientPublicKey) {
// Генерируем случайный AES ключ
const aesKey = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
// Шифруем текст AES
const encoder = new TextEncoder();
const data = encoder.encode(text);
const iv = crypto.getRandomValues(new Uint8Array(12));
const encryptedData = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
aesKey,
data
);
// Экспортируем AES ключ для шифрования RSA
const rawKey = await crypto.subtle.exportKey('raw', aesKey);
// Импортируем публичный RSA ключ получателя
const publicKey = await crypto.subtle.importKey(
'spki',
Uint8Array.from(atob(recipientPublicKey), c => c.charCodeAt(0)),
{ name: 'RSA-OAEP', hash: 'SHA-256' },
false,
['encrypt']
);
// Шифруем AES ключ публичным RSA ключом
const encryptedKey = await crypto.subtle.encrypt(
{ name: 'RSA-OAEP' },
publicKey,
rawKey
);
// Возвращаем всё необходимое для расшифровки
return {
encryptedData: btoa(String.fromCharCode(...new Uint8Array(encryptedData))),
encryptedKey: btoa(String.fromCharCode(...new Uint8Array(encryptedKey))),
iv: btoa(String.fromCharCode(...iv))
};
} |
|
Получатель расшифровывает в обратном порядке - сначала извлекает AES-ключ своим приватным RSA-ключом, затем расшифровывает сообщение:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| async function decryptMessage(encrypted, privateKey) {
// Декодируем base64
const encryptedData = Uint8Array.from(atob(encrypted.encryptedData), c => c.charCodeAt(0));
const encryptedKey = Uint8Array.from(atob(encrypted.encryptedKey), c => c.charCodeAt(0));
const iv = Uint8Array.from(atob(encrypted.iv), c => c.charCodeAt(0));
// Расшифровываем AES ключ приватным RSA ключом
const rawKey = await crypto.subtle.decrypt(
{ name: 'RSA-OAEP' },
privateKey,
encryptedKey
);
// Импортируем AES ключ
const aesKey = await crypto.subtle.importKey(
'raw',
rawKey,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt']
);
// Расшифровываем данные
const decryptedData = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
aesKey,
encryptedData
);
const decoder = new TextDecoder();
return decoder.decode(decryptedData);
} |
|
Групповые чаты усложняют картину - нужно зашифровать сообщение для каждого участника отдельно. Генерируешь один AES-ключ, шифруешь текст, затем шифруешь этот ключ публичным ключом каждого участника. На сервер уходит одно зашифрованное сообщение плюс массив зашифрованных ключей:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| async function encryptForGroup(text, participants) {
const aesKey = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['encrypt']
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoder = new TextEncoder();
const data = encoder.encode(text);
const encryptedData = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
aesKey,
data
);
const rawKey = await crypto.subtle.exportKey('raw', aesKey);
// Шифруем ключ для каждого участника
const encryptedKeys = await Promise.all(
participants.map(async (participant) => {
const publicKey = await importPublicKey(participant.publicKey);
const encryptedKey = await crypto.subtle.encrypt(
{ name: 'RSA-OAEP' },
publicKey,
rawKey
);
return {
userId: participant.id,
encryptedKey: btoa(String.fromCharCode(...new Uint8Array(encryptedKey)))
};
})
);
return {
encryptedData: btoa(String.fromCharCode(...new Uint8Array(encryptedData))),
iv: btoa(String.fromCharCode(...iv)),
keys: encryptedKeys
};
} |
|
Проблема потери ключа - если пользователь очистил браузер или сменил устройство, приватный ключ исчезает навсегда. История переписки становится нечитаемым мусором. Signal решает это через резервное копирование ключей защищённое паролем, но это компромисс между безопасностью и удобством. Верификация публичных ключей предотвращает атаки man-in-the-middle. Сервер может подменить публичный ключ получателя на свой, расшифровывать сообщения и перешифровывать правильным ключом. Пользователи не заметят подмены без out-of-band верификации - например, сравнения отпечатков ключей через QR-код или голосовой звонок.
Perfect Forward Secrecy через эфемерные ключи Диффи-Хеллмана добавляет ещё слой защиты. Даже если долгосрочный ключ скомпрометирован, старые сообщения остаются нечитаемыми потому что шифровались временными сессионными ключами которые уже удалены.
Я видел имплементации E2EE где разработчики хранили приватные ключи на сервере "для удобства синхронизации между устройствами". Это не E2EE, это театр безопасности. Настоящее шифрование означает что сервер физически не способен прочитать данные пользователей даже под давлением властей или после взлома.
Производительность шифрования приемлема на современных устройствах. Web Crypto использует аппаратное ускорение где доступно, операции выполняются за миллисекунды. Задержки незаметны для пользователя, но защита реальна. Компромисс между безопасностью и удобством всегда существует, но для чувствительных данных E2EE - единственный разумный выбор.
Приложение MessengerLite
Собираю все разобранные техники в одном приложении которое можно запустить прямо сейчас. MessengerLite включает авторизацию через JWT, групповые чаты с комнатами, темную тему, оффлайн-режим с очередью сообщений и базовую защиту от флуда. Не идеальный продакшн-код, но рабочий прототип демонстрирующий как всё взаимодействует.
Начинаем с серверной части. Создай папку messenger-lite, внутри server:
| JavaScript | 1
2
3
4
| mkdir -p messenger-lite/server
cd messenger-lite/server
npm init -y
npm install express socket.io jsonwebtoken cors |
|
Файл server/index.js содержит всю логику сервера:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
| const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const jwt = require('jsonwebtoken');
const cors = require('cors');
const app = express();
const server = http.createServer(app);
app.use(cors());
app.use(express.json());
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-prod';
// In-memory хранилище
const users = new Map();
const messages = [];
const rooms = new Map();
const messageRateLimits = new Map();
// Простая "база" пользователей для демо
const demoUsers = [
{ id: 'user1', username: 'alice', password: 'demo123' },
{ id: 'user2', username: 'bob', password: 'demo123' },
{ id: 'user3', username: 'charlie', password: 'demo123' }
];
// REST API для авторизации
app.post('/api/login', (req, res) => {
const { username, password } = req.body;
const user = demoUsers.find(u => u.username === username && u.password === password);
if (!user) {
return res.status(401).json({ error: 'Неверные данные' });
}
const token = jwt.sign(
{ userId: user.id, username: user.username },
JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({
token,
user: { id: user.id, username: user.username }
});
});
app.get('/api/messages', (req, res) => {
const limit = parseInt(req.query.limit) || 100;
res.json(messages.slice(-limit));
});
app.get('/health', (req, res) => {
res.json({
status: 'ok',
connections: io.engine.clientsCount,
messages: messages.length
});
});
// Socket.io сервер
const io = new Server(server, {
cors: {
origin: process.env.CLIENT_URL || 'http://localhost:5173',
credentials: true
}
});
// Middleware авторизации
io.use((socket, next) => {
const token = socket.handshake.auth.token;
if (!token) {
return next(new Error('Токен не предоставлен'));
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
socket.userId = decoded.userId;
socket.username = decoded.username;
next();
} catch (err) {
next(new Error('Невалидный токен'));
}
});
// Rate limiting
function checkRateLimit(userId) {
const now = Date.now();
const limit = messageRateLimits.get(userId) || { count: 0, resetTime: now + 60000 };
if (now > limit.resetTime) {
limit.count = 0;
limit.resetTime = now + 60000;
}
if (limit.count >= 30) {
return false;
}
limit.count++;
messageRateLimits.set(userId, limit);
return true;
}
io.on('connection', (socket) => {
console.log(`${socket.username} подключился`);
users.set(socket.userId, { username: socket.username, socketId: socket.id });
// Отправка истории новому клиенту
socket.emit('history', messages.slice(-50));
// Список онлайн пользователей
socket.emit('users-online', Array.from(users.values()).map(u => u.username));
socket.broadcast.emit('user-joined', { username: socket.username });
// Обработка сообщений
socket.on('message', (data) => {
if (!checkRateLimit(socket.userId)) {
return socket.emit('rate-limit-exceeded', {
message: 'Слишком много сообщений'
});
}
const message = {
id: Date.now() + Math.random(),
text: data.text.trim().substring(0, 5000),
author: socket.username,
authorId: socket.userId,
room: data.room || 'general',
timestamp: new Date().toISOString()
};
messages.push(message);
// Ограничение размера истории
if (messages.length > 1000) {
messages.shift();
}
// Отправка в комнату или всем
if (data.room) {
io.to(data.room).emit('message', message);
} else {
io.emit('message', message);
}
// Подтверждение отправителю
socket.emit('message-ack', { id: message.id });
});
// Присоединение к комнате
socket.on('join-room', (roomId) => {
socket.join(roomId);
if (!rooms.has(roomId)) {
rooms.set(roomId, new Set());
}
rooms.get(roomId).add(socket.userId);
socket.emit('room-joined', { roomId });
socket.to(roomId).emit('user-joined-room', {
username: socket.username,
roomId
});
});
// Выход из комнаты
socket.on('leave-room', (roomId) => {
socket.leave(roomId);
if (rooms.has(roomId)) {
rooms.get(roomId).delete(socket.userId);
if (rooms.get(roomId).size === 0) {
rooms.delete(roomId);
}
}
socket.to(roomId).emit('user-left-room', {
username: socket.username,
roomId
});
});
// Индикатор печатания
socket.on('typing', (data) => {
socket.broadcast.emit('typing', {
username: socket.username,
room: data.room
});
});
socket.on('disconnect', () => {
console.log(`${socket.username} отключился`);
users.delete(socket.userId);
socket.broadcast.emit('user-left', { username: socket.username });
socket.broadcast.emit('users-online', Array.from(users.values()).map(u => u.username));
});
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`Сервер запущен на порту ${PORT}`);
console.log('Демо пользователи: alice/demo123, bob/demo123, charlie/demo123');
}); |
|
Клиентская часть на React с Vite. Возвращаемся в корень messenger-lite:
| JavaScript | 1
2
3
4
5
6
| npm create vite@latest client -- --template react
cd client
npm install
npm install socket.io-client
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p |
|
Настройка Tailwind в client/tailwind.config.js:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
| export default {
content: ["./index.html", "./src/**/*.{js,jsx}"],
darkMode: 'class',
theme: {
extend: {
colors: {
'message-own': '#E3F2FD',
'message-other': '#FFFFFF',
}
}
},
plugins: []
} |
|
В client/src/index.css:
| CSS | 1
2
3
| @tailwind base;
@tailwind components;
@tailwind utilities; |
|
Создаём структуру компонентов. Файл client/src/hooks/useSocket.js:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| import { useEffect, useState, useRef } from 'react';
import { io } from 'socket.io-client';
export function useSocket(url, token) {
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState(null);
const socketRef = useRef(null);
useEffect(() => {
if (!token) return;
const socket = io(url, {
auth: { token },
reconnectionDelay: 500,
reconnectionDelayMax: 3000
});
socket.on('connect', () => {
setIsConnected(true);
setError(null);
});
socket.on('disconnect', () => {
setIsConnected(false);
});
socket.on('connect_error', (err) => {
setError(err.message);
setIsConnected(false);
});
socketRef.current = socket;
return () => {
socket.disconnect();
};
}, [url, token]);
return { socket: socketRef.current, isConnected, error };
} |
|
Компонент логина client/src/components/Login.jsx:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
| import { useState } from 'react';
export function Login({ onLogin }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setLoading(true);
setError('');
try {
const response = await fetch('http://localhost:3000/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (!response.ok) {
throw new Error('Неверные данные');
}
const data = await response.json();
onLogin(data.token, data.user);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
<div className="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-lg w-96">
<h1 className="text-2xl font-bold mb-6 text-gray-900 dark:text-white">
MessengerLite
</h1>
{error && (
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Логин (alice, bob, charlie)"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full mb-3 p-2 border rounded dark:bg-gray-700 dark:text-white"
disabled={loading}
/>
<input
type="password"
placeholder="Пароль (demo123)"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full mb-4 p-2 border rounded dark:bg-gray-700 dark:text-white"
disabled={loading}
/>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-500 text-white p-2 rounded hover:bg-blue-600 disabled:bg-gray-400"
>
{loading ? 'Вход...' : 'Войти'}
</button>
</form>
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">
Демо: alice/demo123, bob/demo123
</p>
</div>
</div>
);
} |
|
Главный компонент чата client/src/components/Chat.jsx:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
| import { useState, useEffect, useRef } from 'react';
import { useSocket } from '../hooks/useSocket';
export function Chat({ token, user, onLogout }) {
const { socket, isConnected } = useSocket('http://localhost:3000', token);
const [messages, setMessages] = useState([]);
const [inputText, setInputText] = useState('');
const [usersOnline, setUsersOnline] = useState([]);
const [darkMode, setDarkMode] = useState(false);
const messagesEndRef = useRef(null);
const offlineQueue = useRef([]);
useEffect(() => {
if (!socket) return;
socket.on('history', (msgs) => setMessages(msgs));
socket.on('message', (msg) => {
setMessages(prev => [...prev, msg]);
});
socket.on('users-online', (users) => setUsersOnline(users));
socket.on('user-joined', (data) => {
console.log(`${data.username} присоединился`);
});
socket.on('rate-limit-exceeded', (data) => {
alert(data.message);
});
return () => {
socket.off('history');
socket.off('message');
socket.off('users-online');
socket.off('user-joined');
};
}, [socket]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
useEffect(() => {
if (darkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [darkMode]);
// Обработка оффлайн-очереди
useEffect(() => {
if (isConnected && offlineQueue.current.length > 0) {
offlineQueue.current.forEach(msg => {
socket?.emit('message', msg);
});
offlineQueue.current = [];
}
}, [isConnected, socket]);
function handleSubmit(e) {
e.preventDefault();
if (!inputText.trim()) return;
const message = { text: inputText.trim() };
if (socket?.connected) {
socket.emit('message', message);
} else {
offlineQueue.current.push(message);
setMessages(prev => [...prev, {
id: Date.now(),
text: message.text,
author: user.username,
status: 'pending',
timestamp: new Date().toISOString()
}]);
}
setInputText('');
}
return (
<div className="flex flex-col h-screen max-w-4xl mx-auto bg-gray-50 dark:bg-gray-900">
{/* Header */}
<div className="flex items-center justify-between p-4 bg-white dark:bg-gray-800 border-b dark:border-gray-700">
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold dark:text-white">MessengerLite</h1>
<span className={`w-3 h-3 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-600 dark:text-gray-400">
{usersOnline.length} онлайн
</span>
<button
onClick={() => setDarkMode(!darkMode)}
className="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
>
{darkMode ? 'sun' : 'moon'}
</button>
<button
onClick={onLogout}
className="px-3 py-1 text-sm bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300"
>
Выход
</button>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{messages.map((msg, idx) => (
<div
key={msg.id || idx}
className={`p-3 rounded-lg ${
msg.author === user.username
? 'bg-message-own dark:bg-blue-900 ml-auto max-w-[70%]'
: 'bg-message-other dark:bg-gray-800 mr-auto max-w-[70%]'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-sm dark:text-white">
{msg.author}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{new Date(msg.timestamp).toLocaleTimeString()}
</span>
{msg.status === 'pending' && (
<span className="text-xs text-yellow-600">⏳</span>
)}
</div>
<p className="text-gray-800 dark:text-gray-200">{msg.text}</p>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-4 bg-white dark:bg-gray-800 border-t dark:border-gray-700">
{!isConnected && (
<div className="mb-2 text-sm text-yellow-600 dark:text-yellow-400">
Нет связи. Сообщения будут отправлены при восстановлении.
</div>
)}
<form onSubmit={handleSubmit} className="flex gap-2">
<input
type="text"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder="Введите сообщение..."
className="flex-1 px-4 py-2 border rounded-lg dark:bg-gray-700 dark:text-white dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
disabled={!inputText.trim()}
className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:bg-gray-400"
>
Отправить
</button>
</form>
</div>
</div>
);
} |
|
Главный файл приложения client/src/App.jsx:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| import { useState, useEffect } from 'react';
import { Login } from './components/Login';
import { Chat } from './components/Chat';
function App() {
const [token, setToken] = useState(null);
const [user, setUser] = useState(null);
useEffect(() => {
const savedToken = localStorage.getItem('token');
const savedUser = localStorage.getItem('user');
if (savedToken && savedUser) {
setToken(savedToken);
setUser(JSON.parse(savedUser));
}
}, []);
function handleLogin(newToken, userData) {
setToken(newToken);
setUser(userData);
localStorage.setItem('token', newToken);
localStorage.setItem('user', JSON.stringify(userData));
}
function handleLogout() {
setToken(null);
setUser(null);
localStorage.removeItem('token');
localStorage.removeItem('user');
}
if (!token) {
return <Login onLogin={handleLogin} />;
}
return <Chat token={token} user={user} onLogout={handleLogout} />;
}
export default App; |
|
Запускаем приложение. В первом терминале:
| JavaScript | 1
2
| cd messenger-lite/server
node index.js |
|
Во втором:
| JavaScript | 1
2
| cd messenger-lite/client
npm run dev |
|
Открывай http://localhost:5173, логинься как alice/demo123, в другой вкладке как bob/demo123 - отправляй сообщения между ними. Проверь работу оффлайн-режима отключив сеть в DevTools, темную тему переключением, rate limiting отправкой тридцати сообщений подряд.
Это полностью рабочее приложение, но оно требует улучшений для реального использования. Добавим ещё несколько компонентов которые делают MessengerLite законченным продуктом.
Создай файл для списка комнат client/src/components/RoomList.jsx:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
| import { useState } from 'react';
export function RoomList({ socket, currentRoom, onRoomChange }) {
const [rooms] = useState(['general', 'tech', 'random']);
const [customRoom, setCustomRoom] = useState('');
function handleJoinRoom(roomId) {
if (currentRoom) {
socket?.emit('leave-room', currentRoom);
}
socket?.emit('join-room', roomId);
onRoomChange(roomId);
}
function handleCreateRoom(e) {
e.preventDefault();
if (customRoom.trim()) {
handleJoinRoom(customRoom.trim());
setCustomRoom('');
}
}
return (
<div className="w-64 bg-white dark:bg-gray-800 border-r dark:border-gray-700 p-4">
<h2 className="text-lg font-bold mb-4 dark:text-white">Комнаты</h2>
<div className="space-y-2 mb-4">
{rooms.map(room => (
<button
key={room}
onClick={() => handleJoinRoom(room)}
className={`w-full text-left px-3 py-2 rounded transition ${
currentRoom === room
? 'bg-blue-500 text-white'
: 'hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-white'
}`}
>
# {room}
</button>
))}
</div>
<form onSubmit={handleCreateRoom} className="space-y-2">
<input
type="text"
value={customRoom}
onChange={(e) => setCustomRoom(e.target.value)}
placeholder="Новая комната..."
className="w-full px-3 py-2 border rounded text-sm dark:bg-gray-700 dark:text-white dark:border-gray-600"
/>
<button
type="submit"
className="w-full px-3 py-2 bg-green-500 text-white rounded text-sm hover:bg-green-600"
>
Создать
</button>
</form>
</div>
);
} |
|
Индикатор печатания в client/src/components/TypingIndicator.jsx:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
| import { useEffect, useState } from 'react';
export function TypingIndicator({ socket }) {
const [typingUsers, setTypingUsers] = useState(new Set());
useEffect(() => {
if (!socket) return;
const typingTimers = new Map();
socket.on('typing', (data) => {
setTypingUsers(prev => new Set([...prev, data.username]));
if (typingTimers.has(data.username)) {
clearTimeout(typingTimers.get(data.username));
}
const timer = setTimeout(() => {
setTypingUsers(prev => {
const next = new Set(prev);
next.delete(data.username);
return next;
});
typingTimers.delete(data.username);
}, 3000);
typingTimers.set(data.username, timer);
});
return () => {
socket.off('typing');
typingTimers.forEach(timer => clearTimeout(timer));
};
}, [socket]);
if (typingUsers.size === 0) return null;
const names = Array.from(typingUsers).join(', ');
return (
<div className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 italic">
{names} печата{typingUsers.size > 1 ? 'ют' : 'ет'}...
</div>
);
} |
|
Обнови Chat.jsx чтобы использовать новые компоненты и отправлять события печатания:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| // Добавь импорты
import { RoomList } from './RoomList';
import { TypingIndicator } from './TypingIndicator';
// Добавь состояние комнаты
const [currentRoom, setCurrentRoom] = useState('general');
// Добавь обработчик печатания
let typingTimeout;
function handleInputChange(e) {
setInputText(e.target.value);
if (socket?.connected) {
socket.emit('typing', { room: currentRoom });
clearTimeout(typingTimeout);
typingTimeout = setTimeout(() => {
// Остановка индикации через 3 секунды
}, 3000);
}
}
// В return добавь RoomList и обнови структуру
return (
<div className="flex h-screen">
<RoomList
socket={socket}
currentRoom={currentRoom}
onRoomChange={setCurrentRoom}
/>
<div className="flex flex-col flex-1 max-w-4xl bg-gray-50 dark:bg-gray-900">
{/* Остальной код чата */}
{/* Перед формой ввода добавь */}
<TypingIndicator socket={socket} />
{/* Форма с обновлённым handleInputChange */}
</div>
</div>
); |
|
Теперь MessengerLite поддерживает групповые комнаты, показывает кто печатает и имеет полноценный UI с переключением между комнатами. Запусти сервер и клиент, открой несколько вкладок с разными пользователями - увидишь как работает синхронизация между комнатами и индикация активности в реальном времени.
Передача данных c сервера Node.js в App.js (react приложение) через proxy Здравствуйте, уважаемые форумчане! У меня следующий вопрос. Я, кажется, не до конца понимаю... Node-react-mysql приложение не работает на heroku Здравствуйте!
Мое приложение не открывается на heroku (см. скрины), но нормально открывается на... Создаю приложение чата на reactjs + react-router со стороны клиента, node js + soket.io со стороны сервера Спасите пожалуйста, вопрос состоит в следующем есть два файла: userHandler.js и index.js, в... Выложил приложение Node js на хост, ошибка (node:12900) [DEP0005] DeprecationWarning: Buffer() Выложил приложение Node js на хост, ошибка (node:12900) DeprecationWarning: Buffer() is deprecated... Валидация формы через React Bootstrap Добрый день,
Помогите с валидацией формы React Bootstrap. Использую вот этот подход.
... Валидация форм, Formik, как сообщение с ошибкой отобразить в инпуте? как подсветить рамку инпута REACT <p className={s.sizeBig}>
<label htmlFor={`street`}>Улица *</label><br />
... Несовместимость React-Router и React-Bootstrap Добрый день,
Пишу маленький проект и в качестве дизайна решил использовать React-Bootstrap.
При... Objects are not valid as a React child (found: TypeError: response[0].includes is not a function). REACT Всем привет. Создаю страничку на React. Смысл работы примерно таков : пользователь заходит,... Посоветуйте практический курс на React redux/ react Всем привет. Столкнулся с тем, что мне не хватает практики. Подскажите какой практический курс по... Разница между React и React native Я хочу начать освоение React для фрондента, но при этом хотел бы иметь возможность писать мобильные... react/ react hook с Rxjs Здравствуйте.
Столкнулся с проблемой изучения библиотеки RxJs.
У меня есть ТЗ, создать... React.createContext или import { createContext } from "react" в чём разница? import React from 'react';
const AuthContext = React.createContext();
or
import {...
|