Writing your dependency free WebSocket server in Node.js



Node.js is a popular tool for building client-server applications. Used correctly, Node.js is capable of handling a large number of network requests using just one thread. Undoubtedly, network I / O is one of the strengths of this platform. It would seem that when using Node.js to write server-side application code that actively uses various network protocols, developers should know how these protocols work, but this is often not the case. This is due to another strong point of Node.js, it is its NPM package manager, in which you can find a ready-made solution for almost any task. Using ready-made packages, we simplify our life, reuse the code (and this is correct), but at the same time we hide from ourselves, behind the screen of libraries, the essence of the ongoing processes.In this article, we will try to understand the WebSocket protocol by implementing part of the specification without using external dependencies. Welcome to cat.





, , WebSocket . , , http, , . http . Http request/reply β€” , . (, http 2.0). , . , , http, . RFC6202, , . WebSocket 2008 , . , WebSocket 2011 13 RFC6455. OSI http tcp. WebSocket http. WebSocket , , , . . , WebSocket 2009 , , Google Chrome 4 . , , . WebSocket :



  1. (handshake)




, , WebSocket, http . , GET . , , , , . http , . typescript ts-node.



import * as http from 'http';
import * as stream from 'stream';

export class SocketServer {
  constructor(private port: number) {
    http
      .createServer()
      .on('request', (request: http.IncomingMessage, socket: stream.Duplex) => {
        console.log(request.headers);
      })
      .listen(this.port);
      console.log('server start on port: ', this.port);
  }
}

new SocketServer(8080);


8080. .



const socket = new WebSocket('ws://localhost:8080');


WebSocket, . readyState. :



  • 0 β€”
  • 1 β€” .
  • 2 β€”
  • 3 β€”


readyState, 0, 3. , . WebSocket API



:



{
  host: 'localhost:8080',
  connection: 'Upgrade',
  pragma: 'no-cache',
  'cache-control': 'no-cache',
  'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36',
  upgrade: 'websocket',
  origin: 'chrome-search://local-ntp',
  'sec-websocket-version': '13',
  'accept-encoding': 'gzip, deflate, br',
  'accept-language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7',
  'sec-websocket-key': 'h/k2aB+Gu3cbgq/GoSDOqQ==',
  'sec-websocket-extensions': 'permessage-deflate; client_max_window_bits'
}


, http RFC2616. http GET, upgrade , . , 101, β€” . WebSocket , :



  • sec-websocket-version . 13
  • sec-websocket-extensions , . ,
  • sec-websocket-protocol , . , , . β€” , .
  • sec-websocket-key . . .


, , 101, sec-websocket-accept, , sec-websocket-key :



  1. sec-websocket-key 258EAFA5-E914-47DA-95CA-C5AB0DC85B11
  2. sha-1
  3. base64


Upgrade: WebSocket Connection: Upgrade. , . sec-websocket-key node.js crypto. .



import * as crypto from 'crypto';


SocketServer



private HANDSHAKE_CONSTANT = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
constructor(private port: number) {
  http
    .createServer()
    .on('upgrade', (request: http.IncomingMessage, socket: stream.Duplex) => {
      const clientKey = request.headers['sec-websocket-key'];
      const handshakeKey = crypto
        .createHash('sha1')
        .update(clientKey + this.HANDSHAKE_CONSTANT)
        .digest('base64');
      const responseHeaders = [
        'HTTP/1.1 101',
        'upgrade: websocket',
        'connection: upgrade',
        `sec-webSocket-accept: ${handshakeKey}`,
        '\r\n',
      ];
      socket.write(responseHeaders.join('\r\n'));
    })
    .listen(this.port);
  console.log('server start on port: ', this.port);
}


http Node.js upgrade , . , , 1. . .





. β€” . . , , , .. . , , , ( ). , , , . .





, , . .



. 2



0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
FIN RSV1 RSV2 RSV3 OPCODE MASK


  • FIN . 1, , 0, . .
  • RSV1, RSV2, RSV3 .
  • OPCODE 4 . : . . , UTF8, . 3 ping, pong, close. .

    • 0 , β€”
    • 1
    • 2
    • 8
    • 9 Ping
    • xA Pong
  • MASK β€” . 0, , 1, . , , . , , .
  • 7 , .


. 0 12



  • <= 125, , , . ,
  • = 126 2
  • = 127 8


0, 2, 8 0, 4




, , . . β€” 4 , . , XOR. , , XOR.



, WebSocket .





, . WebSocket , . Ping. , . Ping, , . , Pong , Ping. ,



private MASK_LENGTH = 4; //  .   
private OPCODE = {
  PING: 0x89, //     Ping
  SHORT_TEXT_MESSAGE: 0x81, //     ,    125 
};
private DATA_LENGTH = {
  MIDDLE: 128, // ,         
  SHORT: 125, //    
  LONG: 126, // ,   2    
  VERY_LONG: 127, // ,   8    
};


Ping



private ping(message?: string) {
  const payload = Buffer.from(message || '');
  const meta = Buffer.alloc(2);
  meta[0] = this.OPCODE.PING;
  meta[1] = payload.length;
  return Buffer.concat([meta, payload]);
}


, , . - Ping. , . , , . .



private CONTROL_MESSAGES = {
  PING: Buffer.from([this.OPCODE.PING, 0x0]),
};
private connections: Set<stream.Duplex> = new Set();


, Ping 5 , .



setInterval(() => socket.write(this.CONTROL_MESSAGES.PING), heartbeatTimeout);
this.connections.add(socket);


. . , , . , , , , , .



private decryptMessage(message: Buffer) {
  const length = message[1] ^ this.DATA_LENGTH.MIDDLE; // 1
  if (length <= this.DATA_LENGTH.SHORT) {
    return {
      length,
      mask: message.slice(2, 6), // 2
      data: message.slice(6),
    };
  }
  if (length === this.DATA_LENGTH.LONG) {
    return {
      length: message.slice(2, 4).readInt16BE(), // 3
      mask: message.slice(4, 8),
      data: message.slice(8),
    };
  }
  if (length === this.DATA_LENGTH.VERY_LONG) {
    return {
      payloadLength: message.slice(2, 10).readBigInt64BE(), // 4
      mask: message.slice(10, 14),
      data: message.slice(14),
    };
  }
  throw new Error('Wrong message format');
}


  1. . XOR , 128 , 10000000. , , , 1.
  2. 126,
  3. 127,


. ,



private unmasked(mask: Buffer, data: Buffer) {
  return Buffer.from(data.map((byte, i) => byte ^ mask[i % this.MASK_LENGTH]));
}


XOR . 4 . .



public sendShortMessage(message: Buffer, socket: stream.Duplex) {
  const meta = Buffer.alloc(2);
  meta[0] = this.OPCODE.SHORT_TEXT_MESSAGE;
  meta[1] = message.length;
  socket.write(Buffer.concat([meta, message]));
}


. , .



socket.on('data', (data: Buffer) => {
  if (data[0] === this.OPCODE.SHORT_TEXT_MESSAGE) { //       
    const meta = this.decryptMessage(data);
    const message = this.unmasked(meta.mask, meta.data);
    this.connections.forEach(socket => {
      this.sendShortMessage(message, socket);
    });
  }
});

this.connections.forEach(socket => {
  this.sendShortMessage(
    Buffer.from(`   .    ${this.connections.size}`),
    socket,
  );
});


. .



const socket = new WebSocket('ws://localhost:8080');
socket.onmessage = ({ data }) => console.log(data);




socket.send('Hello world!');






Of course, if your application needs WebSockets, and most likely you need them, you shouldn't implement the protocol yourself unless absolutely necessary. You can always choose a suitable solution from the variety of libraries in npm. Better to reuse already written and tested code. But understanding how it works "under the hood" will always give you much more than just using someone else's code. The above example is available on github




All Articles