Last active
November 12, 2021 09:59
-
-
Save lap00zza/6b9878df14f0f8810b09a4fc9feb92a1 to your computer and use it in GitHub Desktop.
A barebones WebSocket client for nodejs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * 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