Переключение HTTP на WebSocket
HTTP-запрос заканчивается ответом, а переписка требует получать события после первоначального открытия страницы. WebSocket создаёт длительный двусторонний канал поверх соединения. Браузер начинает его специальным HTTP-запросом, сервер проверяет условия и переключает соединение на другой протокол. Этот переход называют upgrade. Он не заменяет все маршруты приложения: страница, создание сессии и список комнат продолжают работать через HTTP.
В «Линии» переключение разрешено только на пути /chat и только после создания учебной сессии. Используем Node.js 24 LTS, Express 5.2.1 и пакет ws 8.18.3. В браузере применяется встроенный WebSocket, а серверный пакет устанавливается только в Node.js. Полное третье состояние лежит в stages/03; пример not_run, сетевой обмен и показанные ответы при подготовке не выполнялись.
Сначала проверка, затем переключение
Создадим WebSocketServer с параметром noServer: true. Такой объект не открывает отдельный порт и не принимает решение за HTTP-приложение. Мы самостоятельно обрабатываем событие upgrade существующего сервера, проверяем путь, Origin и сессию, после чего вызываем handleUpgrade. Преимущество этого порядка в том, что чужое соединение отклоняется до создания контекста чата и до подписки на события приложения.
Не будем вручную формировать успешный ответ WebSocket. В нём есть специальные поля handshake, и ошибки легко делают соединение несовместимым с браузером. Библиотека ws выполняет протокольную часть, а наш код принимает прикладное решение о допуске. Транспорт знает, правильно ли оформлен WebSocket-запрос; приложение знает, имеет ли этот клиент допустимую учебную сессию.
Полный файл socket.js третьего снимка показывает обе границы. Проверка сообщения пока минимальна; полноценный договор ввода добавим в пятом уроке.
import { WebSocket, WebSocketServer } from 'ws';
import { randomUUID } from 'node:crypto';
import { origin, readSession, canEnter } from './identity.js';
function send(client, event) {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(event));
}
}
export function attachSockets(server) {
const wss = new WebSocketServer({ noServer: true, maxPayload: 8192 });
server.on('upgrade', (request, socket, head) => {
socket.on('error', () => socket.destroy());
const path = new URL(request.url, origin).pathname;
const session = readSession(request);
if (path !== '/chat' || request.headers.origin !== origin || !session) {
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, client => {
client.actor = session.actor;
client.expiresAt = session.expiresAt;
wss.emit('connection', client);
});
});
wss.on('connection', client => {
client.on('error', () => client.terminate());
send(client, { version: 1, type: 'ready', actorId: client.actor.id });
client.on('message', (data, isBinary) => {
try {
if (Date.now() >= client.expiresAt) {
client.close(1008, 'session_expired');
return;
}
if (isBinary) throw new Error('binary_not_allowed');
const command = JSON.parse(data.toString('utf8'));
if (command.type !== 'send') return;
command.roomId = 'ticket-17';
const message = {
version: 1,
type: 'message',
roomId: command.roomId,
messageId: randomUUID(),
clientMessageId: command.clientMessageId,
actorId: client.actor.id,
text: command.text,
createdAt: new Date().toISOString()
};
for (const peer of wss.clients) {
send(peer, message);
}
} catch (error) {
send(client, { version: 1, type: 'error', code: error.message });
}
});
});
return { wss, close() {
for (const client of wss.clients) client.close(1001, 'server_stopping');
wss.close();
} };
}
Параметр head содержит уже прочитанные байты после HTTP-заголовков. Их необходимо передать handleUpgrade вместе с запросом и сокетом. Потеря этих байтов может проявиться только при определённом разбиении сетевых пакетов, поэтому самодельная замена параметра пустым буфером создаёт трудноуловимую ошибку. Рассматривайте три аргумента события upgrade как часть единого протокольного обмена.
При отказе мы отправляем короткий HTTP-ответ, указываем закрытие и уничтожаем соединение. Простого return было бы недостаточно: сырой сокет остался бы открытым, а браузер ожидал бы завершения handshake. На этом этапе ещё нет WebSocket, поэтому нельзя вызывать client.close() и отправлять прикладное событие об ошибке. Отказ HTTP и закрытие уже открытого WebSocket относятся к разным стадиям.
Origin и cookie решают разные задачи
Заголовок Origin сообщает, с какой страницы браузер пытается открыть канал. Ожидаем точное значение http://127.0.0.1:4310. Такая проверка нужна, потому что браузер может приложить cookie к WebSocket-запросу автоматически. Однако клиент вне браузера способен сам написать заголовок Origin; следовательно, он не является доказательством личности. В нашем примере сессия тоже учебная: персонажа выбирают без пароля.
readSession() читает идентификатор из cookie и ищет серверную запись, созданную HTTP-маршрутом. Если записи нет или срок истёк, upgrade не выполняется. После допуска сохраняем actor и expiresAt непосредственно на объекте клиента. Входящие команды не содержат доверенного имени автора. Даже если кто-то добавит actorId: "agent" в JSON, событие получит автора из сохранённого сервером контекста.
Строка URL содержит только путь /chat. Иногда в учебных примерах передают ?user=alice и воспринимают это как вход. Такой адрес обозначает лишь утверждение самого клиента. Передавать там секрет ещё хуже: адрес может попасть в логи и историю. Браузерный конструктор WebSocket также не позволяет произвольно задать обычный заголовок Authorization, поэтому механизм входа необходимо выбирать с учётом доступного API, а не копировать приём из HTTP-клиента Node.js.
Первое событие открытого канала
После успешного переключения сервер посылает событие ready. Оно означает, что прикладной обработчик создан, а контекст участника закреплён за соединением. Его нельзя понимать как подтверждение конкретного сообщения, наличие прав на все комнаты или гарантию сохранности будущей переписки. В текущем снимке успешное подключение должно получить такой объект; идентификатор отражает выбранного учебного участника.
{"version":1,"type":"ready","actorId":"alice"}
Затем обработчик принимает текстовое JSON-сообщение типа send и рассылает созданное событие всем открытым клиентам. Пока все они участвуют в одной условной комнате ticket-17; настоящую маршрутизацию введём шестым уроком. Это временная стадия локального проекта. Состояние показывает транспорт и отправку события, а не готовую многопользовательскую систему поддержки с изоляцией обращений.
У send() есть проверка readyState. Клиент может закрыться между созданием события и обходом набора подключений. Отправка только открытым сокетам предотвращает обычную ошибку работы с уже закрытым соединением. Однако эта проверка не говорит о скорости получателя и количестве накопленных байтов; отдельное ограничение буфера появится позже. Мы постепенно разделяем доступность транспорта и способность клиента успевать читать.
Параметр maxPayload: 8192 ограничивает размер входящего сообщения в байтах. Он не равен разрешённой длине пользовательского текста, измеряемой JavaScript в кодовых единицах строки. JSON включает имена полей, идентификаторы и служебные символы, а русские буквы в UTF-8 занимают несколько байтов. Поэтому транспортный предел ставим шире будущего ограничения текста, сохраняя смысл обоих барьеров.
Ошибки сырого сокета, ошибки WebSocket и ошибки команды обрабатываются на соответствующих уровнях. Временная ошибка разбора команды возвращается событием error, а неисправный транспорт закрывается. Не отправляйте пользователю внутренний стек исключения: он не помогает исправить фразу и может раскрыть устройство приложения. В следующих состояниях коды ошибок станут частью проверенного договора, а пока важен сам порядок обработки.
Соединение, однажды переключённое на WebSocket, уже не обрабатывает обычные Express-маршруты для каждого сообщения. Поэтому HTTP-middleware JSON и его limit не проверяют содержимое кадра чата. Такое ошибочное предположение встречается, когда оба транспорта используют один порт. Общий HTTP-сервер означает общий входной адрес, но после upgrade поток получает отдельный обработчик и отдельные ограничения. Напротив, запрос страницы продолжает идти обычным HTTP-путём и не становится WebSocket только из-за существующего сокета в другой вкладке. Чтобы понять границу, проследите конкретное соединение: handshake проходит HTTP-проверки, дальше библиотека разбирает кадры, после чего приложение получает data и решает смысл команды.
Реализация upgrade соответствует README ws и RFC 6455. Библиотека отвечает за handshake и кадры; наш сервер отвечает за путь, сессию и прикладные события. После третьего урока канал существует, но на странице ещё нет полноценного клиента. Следующим шагом свяжем его жизненный цикл с формой и состоянием интерфейса.