Skip to content

Instantly share code, notes, and snippets.

@lap00zza
Last active November 12, 2021 09:59
Show Gist options
  • Select an option

  • Save lap00zza/6b9878df14f0f8810b09a4fc9feb92a1 to your computer and use it in GitHub Desktop.

Select an option

Save lap00zza/6b9878df14f0f8810b09a4fc9feb92a1 to your computer and use it in GitHub Desktop.
A barebones WebSocket client for nodejs
/**
* nanoWS
* A very basic websocket client. Its does not fully cover rfc6455 so
* it must not used for any real world work. I wrote it to find out
* how websockets actually worked.
*
* @licence MIT
* @author Jewel Mahanta <[email protected]>
*/
const crypto = require("crypto");
const { URL } = require("url");
const https = require("https");
const http = require("http");
const { EventEmitter } = require("events");
// const zlib = require("zlib");
// const { StringDecoder } = require("string_decoder");
// for logging console outputs
let DEBUG = true;
// ERRORS
class ProtocolError extends Error {
constructor() {
super("only https, http, wss and ws are supported");
this.name = "ProtocolError";
}
}
// prettier-ignore
/**
* Decodes a websocket data frame header (byte 1 and byte 2) according
* to [rfc6455](https://tools.ietf.org/html/rfc6455#section-5.2)
* @param {Buffer} header - The first 2 bytes of frame
*/
const getFrameInfo = (header) => {
// BYTE 1
const FIN = header[0] & 0b10000000;
const RESV1 = header[0] & 0b01000000; /* no-use */
const RESV2 = header[0] & 0b00100000; /* no-use */
const RESV3 = header[0] & 0b00010000; /* no-use */
const OPCODE = header[0] & 0b00001111;
// BYTE 2
const MASK = header[1] & 0b10000000;
const LEN = header[1] & 0b01111111;
return {
FIN: !!FIN,
OPCODE,
MASK: !!MASK,
LEN
};
};
/**
* Payload masking according to
* [rfc6455](https://tools.ietf.org/html/rfc6455#section-5.3)
* @param {Buffer} payload
*/
const maskPayload = payload => {
const maskingKey = crypto.randomBytes(4);
const maskedPayload = Buffer.alloc(payload.length);
for (let i = 0; i < payload.length; i++) {
const j = i % 4;
maskedPayload[i] = payload[i] ^ maskingKey[j];
}
return {
payload: maskedPayload,
key: maskingKey
};
};
/**
* @param {String} data
* @returns {Buffer}
* @todo add support for length > 125
*/
const encodeFrame = data => {
if (typeof data !== "string") throw new TypeError("data must be string");
if (data.length > 125)
throw new Error("Not equipped to handled > 125 length");
// Buffer size reference:
// 2 for Headers (fin, opcode, mask, len)
// 4 for MASKING KEY
// rest is for payload length
const _b = Buffer.alloc(2);
_b[0] = 0b10000001;
_b[1] = 0b10000000 | data.length;
const masked = maskPayload(Buffer.from(data));
return Buffer.concat([_b, masked.key, masked.payload], 2 + 4 + data.length);
};
class WebSocket extends EventEmitter {
/**
* @param {String} url
*/
constructor(url) {
const _url = new URL(url);
if (!["https:", "http:", "wss:", "ws:"].includes(_url.protocol))
throw new ProtocolError();
if (_url.protocol === "wss:") _url.protocol = "https:";
if (_url.protocol === "ws:") _url.protocol = "http:";
super();
this.url = _url;
this._socket = undefined;
this.httpModule =
_url.protocol === "https:" || _url.protocol === "wss:"
? https
: http;
this.SOCKET_STATE = "CONNECTING";
this.getSocket();
}
static getHTTPHeaders() {
return {
headers: {
Upgrade: "websocket",
Connection: "Upgrade",
"Sec-WebSocket-Key": crypto.randomBytes(16).toString("hex"),
"Sec-WebSocket-Version": 13
}
};
}
/**
* A websocket starts off as a normal HTTP request. This is the
* handshake part. Once our connection is upgraded, the actual
* data transfer can start.
*/
getSocket() {
this.httpModule
.request(this.url, WebSocket.getHTTPHeaders())
.on("response", res => {
console.log("::response::");
res.pipe(process.stdout);
})
// This is what we are interested in. Remember
// socket (net.Socket) is a Duplex stream.
.on("upgrade", (res, socket, head) => {
console.log("::upgrade::");
if (DEBUG) console.log("\x1b[35m%s\x1b[0m", "::head::", head);
this._socket = socket;
this.attachSocketEvents();
this.SOCKET_STATE = "OPEN";
this.emit("open");
if (head) this.processChunk(head);
})
.end();
}
/**
* We finally got out socket. Cool! Lets add a few listeners to make
* the socket actually useful.
* @see handleClose
*/
attachSocketEvents() {
this._socket.on("data", chunk => {
if (DEBUG) console.log("\x1b[35m%s\x1b[0m", "::raw::", chunk);
this.processChunk(chunk);
});
this._socket.on("close", () => {
if (DEBUG) console.log("\x1b[31m%s\x1b[0m", "::closed::");
this.SOCKET_STATE = "CLOSED";
this.emit("close");
});
}
/**
* A single chunk can contain multiple frames. We extract the
* required bytes from the chunk.
* @param {Buffer} chunk
*/
processChunk(chunk) {
if (DEBUG) console.log("\x1b[32m%s\x1b[0m", "::extracting frames::");
let offset = 0;
while (offset < chunk.length) {
///////////////////////////
// Get Frame Headers
///////////////////////////
const frame = getFrameInfo(chunk.slice(offset, offset + 2));
//////////////////////////////
// Calculate Payload Length
//////////////////////////////
let payloadLength = 0;
let payloadOffset = 2;
if (frame.LEN <= 125) {
payloadLength = frame.LEN;
} else if (frame.LEN === 126) {
// read the next 16 bits (2bytes) as an unsigned int
payloadLength = (chunk[offset + 2] << 8) | chunk[offset + 3];
payloadOffset += 2;
} else {
// prettier-ignore
// read the next 64 bits (8bytes) as an unsigned int
payloadLength =
(chunk[offset + 2] << 56) | (chunk[offset + 3] << 48) |
(chunk[offset + 4] << 40) | (chunk[offset + 5] << 32) |
(chunk[offset + 6] << 24) | (chunk[offset + 7] << 16) |
(chunk[offset + 8] << 8) | chunk[offset + 9];
payloadOffset += 8;
}
///////////////////////////////////////////
// Add Payload information and dispatch
///////////////////////////////////////////
frame.PAYLOAD_LENGTH = payloadLength;
frame.PAYLOAD = chunk.slice(
offset + payloadOffset,
offset + payloadOffset + payloadLength
);
this.handleFrame(frame);
// And off we go again
offset += 2 + payloadLength;
}
}
handleFrame(frame) {
if (DEBUG) console.log("\x1b[34m%s\x1b[0m", "::FRAME::", frame);
//////////////////////
// OPCODE Reference //
//////////////////////
// 0x0 Continue
// 0x1 Text
// 0x2 Binary
//
// 0x8 Close
// 0x9 Ping
// 0xA Pong
switch (frame.OPCODE) {
// NOTE: StringDecoder will be useful in Continue
case 0x1:
this.handleTextData(frame.PAYLOAD.toString());
break;
case 0x8:
this.handleClose();
break;
}
}
handleTextData(text) {
if (DEBUG) console.log("\x1b[33m%s\x1b[0m", "::emit message::", text);
this.emit("message", text);
}
handleClose() {
// For now lets close the connection by sending
// <Buffer 0b10001000 0b00000000>
// ^^^^
if (DEBUG) console.log("\x1b[31m%s\x1b[0m", "::closing:: 0x8");
this.SOCKET_STATE = "CLOSING";
this._socket.write(Buffer.from([0b10001000, 0b00000000], 2));
}
/**
* @param {String} data
* @todo add support for binary data
*/
send(data) {
if (this.SOCKET_STATE !== "OPEN")
throw new Error("Websocket needs to be open.");
if (typeof data !== "string")
throw new TypeError("data must be string");
this._socket.write(encodeFrame(data));
}
close() {
this.handleClose();
}
}
module.exports = WebSocket;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment