Я построил WebSocket-сервер из сырого TCP, чтобы по-настоящему его понять
Опубликовано 5 августа 2025 г. · 7 мин чтения
Зачем строить то, что уже существует?
Честный ответ: потому что я годами использовал WebSocket и всё равно не мог ответить "что такое фрейм?" или "почему существует маскирование?" Я знал API браузера. Знал, как подключить socket.io. Нулевое понимание того, что происходит между клиентом и сервером.
Поэтому я построил noServer: Node.js HTTP и WebSocket сервер без зависимостей на сырых TCP. Без express, без ws, без socket.io — только net-модуль Node и RFC.
node server.js # Слушает на :3000 # GET / → 200 # GET /ws → 101 Switching Protocols
Сырой TCP — отправная точка
net.createServer() от Node даёт сокет для каждого соединения. Этот сокет — байты на входе, байты на выходе. Никакого протокола, никакой разбивки на фреймы.
import net from 'net';
const server = net.createServer((socket) => {
socket.on('data', (chunk: Buffer) => {
// Сырые байты. Может быть HTTP. Может быть WebSocket.
});
});
server.listen(3000);Рукопожатие
RFC 6455 определяет магический GUID: 258EAFA5-E914-47DA-95CA-C5AB0DC85B11. Единственная цель: не дать обычным HTTP-серверам случайно принять upgrade-запросы.
import crypto from 'crypto';
const MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
function acceptKey(clientKey: string): string {
return crypto
.createHash('sha1')
.update(clientKey + MAGIC)
.digest('base64');
}После ответа 101 соединение обновлено. Больше нет HTTP. Дальше — протокол фреймов WebSocket.
Фреймы: настоящий протокол
Сообщение WebSocket разбивается на фреймы. У каждого фрейма двоичный заголовок, затем полезная нагрузка.
function parseFrame(buffer: Buffer) {
const fin = (buffer[0] & 0x80) !== 0;
const opcode = buffer[0] & 0x0f;
const masked = (buffer[1] & 0x80) !== 0;
let payloadLen = buffer[1] & 0x7f;
let offset = 2;
if (payloadLen === 126) {
payloadLen = buffer.readUInt16BE(offset);
offset += 2;
}
const mask = masked ? buffer.slice(offset, offset + 4) : null;
if (masked) offset += 4;
const payload = buffer.slice(offset, offset + payloadLen);
return { fin, opcode, payload, mask };
}Маскирование и почему оно существует
Фреймы клиента ДОЛЖНЫ быть маскированы. Фреймы сервера НЕ ДОЛЖНЫ быть маскированы. Это не опционально.
Маскирование — XOR с 4-байтным ключом:
function unmask(payload: Buffer, mask: Buffer): Buffer {
const result = Buffer.alloc(payload.length);
for (let i = 0; i < payload.length; i++) {
result[i] = payload[i] ^ mask[i % 4];
}
return result;
}Это не шифрование. Не безопасность. Единственная цель — не дать фреймам WebSocket выглядеть как HTTP для промежуточных прокси.
Что я теперь знаю
Библиотека ws — около 2000 строк. После построения noServer я понимаю, что делает большинство этих строк. Почему существует ping/pong, почему бит FIN, почему маскирование только на клиенте.
Я всё ещё использую ws в продакшне. Речь шла не о замене библиотек — о понимании.
Если вы используете WebSocket и никогда не читали RFC 6455, постройте игрушечную реализацию. Вы поймёте протокол за один день так, как не даст никакое количество чтения документации.