UNPKG

presidium-websocket

Version:

Presidium WebSocket client and server for Node.js

502 lines (444 loc) 12.7 kB
/** * presidium-websocket v0.4.1 * https://github.com/richytong/presidium-websocket * (c) 2025 Richard Tong * presidium-websocket may be freely distributed under the MIT license. */ const net = require('net') const tls = require('tls') const events = require('events') const crypto = require('crypto') const zlib = require('zlib') const encodeWebSocketFrame = require('./_internal/encodeWebSocketFrame') const decodeWebSocketFrame = require('./_internal/decodeWebSocketFrame') const decodeWebSocketHandshakeResponse = require('./_internal/decodeWebSocketHandshakeResponse') const LinkedList = require('./_internal/LinkedList') const __ = require('./_internal/placeholder') const curry3 = require('./_internal/curry3') const append = require('./_internal/append') const call = require('./_internal/call') const thunkify1 = require('./_internal/thunkify1') const thunkify3 = require('./_internal/thunkify3') const functionConcatSync = require('./_internal/functionConcatSync') /** * @name WebSocket * * @docs * Creates a Presidium WebSocket client. * * ```coffeescript [specscript] * new WebSocket(url string) -> websocket WebSocket * * new WebSocket(url string, options { * rejectUnauthorized: boolean, * autoConnect: boolean, * maxMessageLength: number, * offerPerMessageDeflate: boolean * }) -> websocket WebSocket * * websocket.on('open', ()=>()) -> () * websocket.on('message', (message Buffer)=>()) -> () * websocket.on('ping', ()=>()) -> () * websocket.on('pong', ()=>()) -> () * websocket.on('error', (error Error)=>()) -> () * websocket.on('close', ()=>()) -> () * ``` */ class WebSocket extends events.EventEmitter { constructor(url, options = {}) { super() const parsedUrl = new URL(url) if (parsedUrl.protocol != 'ws:' && parsedUrl.protocol != 'wss:') { throw new TypeError('URL protocol must be "ws" or "wss"') } this.url = { hostname: parsedUrl.hostname, protocol: parsedUrl.protocol, pathname: parsedUrl.pathname, search: parsedUrl.search, hash: parsedUrl.hash } if (parsedUrl.port.length > 0) { this.url.port = Number(parsedUrl.port) } else if (parsedUrl.protocol == 'wss:') { this.url.port = 443 } else { this.url.port = 80 } this._connectOptions = { rejectUnauthorized: options.rejectUnauthorized ?? true, servername: net.isIP(this.url.hostname) ? '' : this.url.hostname } this.on('error', () => { this.destroy() }) this._maxMessageLength = options.maxMessageLength ?? 4 * 1024 this._socketBufferLength = options.socketBufferLength ?? 100 * 1024 this._offerPerMessageDeflate = options.offerPerMessageDeflate ?? true this.readyState = 3 // CLOSED this._autoConnect = options.autoConnect ?? true if (this._autoConnect) { this.connect() } this._continuationPayloads = [] } /** * @name connect * * @docs * ```coffeescript [specscript] * websocket.connect() -> () * ``` */ connect() { if (this._socket) { // dispose existing this._socket.destroy() } if (this.url.protocol == 'wss:') { this._socket = tls.connect( { port: this.url.port, host: this.url.hostname, rejectUnauthorized: this._connectOptions.rejectUnauthorized, servername: this._connectOptions.servername, onread: { buffer: Buffer.alloc(3 * 1024 * 1024), callback: this._onread.bind(this) } }, this._requestUpgrade.bind(this) ) } else { this._socket = net.connect( { port: this.url.port, host: this.url.hostname, onread: { buffer: Buffer.alloc(this._socketBufferLength), callback: this._onread.bind(this) } }, this._requestUpgrade.bind(this) ) } this.readyState = 0 // CONNECTING this._socket.on('error', error => { this.emit('error', error) }) this._handleDataFrames() } /** * @name _onread * * @docs * ```coffeescript [specscript] * websocket._onread(nread number, buffer Buffer) -> () * ``` */ _onread(nread, buffer) { this._socket.emit('data', Buffer.from(buffer.slice(0, nread))) } /** * @name _requestUpgrade * * @docs * ```coffeescript [specscript] * _requestUpgrade() -> () * ``` */ _requestUpgrade() { const key = crypto.randomBytes(16).toString('base64') this._socket.write( `GET ${this.url.pathname}${this.url.search}${this.url.hash} HTTP/1.1\r\nHost: ${this.url.hostname}:${this.url.port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n${this._offerPerMessageDeflate ? 'Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits\r\n' : ''}\r\n` ) } /** * @name _handleDataFrames * * @docs * ```coffeescript [specscript] * websocket._handleDataFrames() -> () * ``` */ async _handleDataFrames() { const chunks = new LinkedList() this._socket.on('data', functionConcatSync( curry3(append, chunks, __, 'WebSocket'), thunkify1( process.nextTick, thunkify3(call, this._processChunk, this, chunks) ) )) } /** * @name _processChunk * * @docs * ```coffeescript [specscript] * websocket._processChunk(chunks Array<Buffer>) -> () * ``` */ async _processChunk(chunks) { if (this.readyState === 0) { // process handshake let chunk = chunks.shift() let decodeResult = decodeWebSocketHandshakeResponse(chunk) while (decodeResult == null && chunks.length > 0) { chunk = Buffer.concat([chunk, chunks.shift()]) decodeResult = decodeWebSocketHandshakeResponse(chunk) } if (decodeResult == null) { chunks.prepend(chunk) return undefined } const { handshakeSucceeded, perMessageDeflate, message, remaining } = decodeResult if (!handshakeSucceeded) { this.destroy() this.emit('error', new Error(message)) return undefined } if (perMessageDeflate) { this._perMessageDeflate = true this._socket._perMessageDeflate = true } if (remaining.length > 0) { chunks.prepend(remaining) } this.readyState = 1 // OPEN this.sendPing() this.emit('open') return undefined } // process data frames while (chunks.length > 0) { let chunk = chunks.shift() let decodeResult = await decodeWebSocketFrame.call(this, chunk, this._perMessageDeflate) while (decodeResult == null && chunks.length > 0) { chunk = Buffer.concat([chunk, chunks.shift()]) decodeResult = await decodeWebSocketFrame.call(this, chunk, this._perMessageDeflate) } if (decodeResult == null) { chunks.prepend(chunk) return undefined } const { fin, opcode, payload, remaining, masked } = decodeResult // The client must close the connection upon receiving a frame that is masked if (masked) { this.sendClose('masked frame') this.destroy() break } if (remaining.length > 0) { chunks.prepend(remaining) } this._handleDataFrame(payload, opcode, fin) } return undefined } /** * @name _handleDataFrame * * @docs * ```coffeescript [specscript] * websocket._handleDataFrame(payload Buffer, opcode number, fin boolean) -> () * ``` */ _handleDataFrame(payload, opcode, fin) { if (opcode === 0x0) { // continuation frame this._continuationPayloads.push(payload) if (fin) { // last continuation frame this.emit('message', Buffer.concat(this._continuationPayloads)) this._continuationPayloads = [] } } else if (fin) { // unfragmented message switch (opcode) { case 0x1: // text frame case 0x2: // binary frame this.emit('message', payload) break case 0x8: // close frame this.readyState = 2 // CLOSING if (this.sentClose) { this.destroy(payload) } else { this.sendClose() this.destroy(payload) } break case 0x9: // ping frame this.emit('ping', payload) this.sendPong(payload) break case 0xA: // pong frame this.emit('pong', payload) break } } else { // fragmented message, wait for continuation frames this._continuationPayloads.push(payload) } } /** * @name send * * @docs * ```coffeescript [specscript] * websocket.send(payload Buffer|string) -> () * ``` */ async send(payload) { let buffer = null let opcode = null if (Buffer.isBuffer(payload)) { buffer = payload opcode = 0x2 } else if (ArrayBuffer.isView(payload)) { buffer = Buffer.from(payload.buffer) opcode = 0x2 } else if (typeof payload == 'string') { buffer = Buffer.from(payload, 'utf8') opcode = 0x1 } else { this.emit('error', new TypeError('send can only process binary or text frames')) return undefined } let compressed = false if (this._perMessageDeflate && buffer.length > 0) { try { const compressedPayload = zlib.deflateRawSync(buffer) if ( compressedPayload.length >= 4 && compressedPayload.slice(-4).equals(Buffer.from([0x00, 0x00, 0xff, 0xff])) ) { buffer = compressedPayload.slice(0, -4) } else { buffer = compressedPayload } compressed = true } catch (error) { this.emit('error', error) return undefined } } if (buffer.length <= this._maxMessageLength) { // unfragmented this._socket.write(encodeWebSocketFrame.call( this, buffer, opcode, true, true, compressed )) } else { // fragmented let index = 0 let fragment = buffer.slice(0, this._maxMessageLength) this._socket.write(encodeWebSocketFrame.call( this, fragment, opcode, true, false, compressed )) // continuation frames index += this._maxMessageLength while (index < payload.length) { const fin = index + this._maxMessageLength >= payload.length fragment = buffer.slice(index, index + this._maxMessageLength) this._socket.write(encodeWebSocketFrame.call( this, fragment, 0x0, true, fin, compressed )) index += this._maxMessageLength } } return undefined } /** * @name sendClose * * @docs * Sends close frame to the server * * ```coffeescript [specscript] * websocket.sendClose(payload Buffer|string) -> () * ``` */ sendClose(payload = Buffer.from([])) { if (!Buffer.isBuffer(payload)) { payload = Buffer.from(payload) } this._socket.write(encodeWebSocketFrame.call(this, payload, 0x8, true)) // close frame this.sentClose = true } /** * @name sendPing * * @docs * Sends a ping frame to the server * * ```coffeescript [specscript] * websocket.sendPing(payload Buffer|string) -> () * ``` */ sendPing(payload = Buffer.from([])) { if (!Buffer.isBuffer(payload)) { payload = Buffer.from(payload) } this._socket.write(encodeWebSocketFrame.call(this, payload, 0x9, true)) // ping frame } /** * @name sendPong * * @docs * Sends "pong" back to client * * ```coffeescript [specscript] * websocket.sendPong(payload Buffer|string) -> () * ``` */ sendPong(payload = Buffer.from([])) { if (!Buffer.isBuffer(payload)) { payload = Buffer.from(payload) } this._socket.write(encodeWebSocketFrame.call(this, payload, 0xA, true)) // pong frame } /** * @name close * * @docs * Closes the websocket * * ```coffeescript [specscript] * websocket.close(payload Buffer|string) -> () * ``` */ close(payload = Buffer.from([])) { this.readyState = 2 // CLOSING this.sendClose(payload) } /** * @name destroy * * @docs * Closes the websocket * * ```coffeescript [specscript] * websocket.destroy(Buffer|string) -> () * ``` */ destroy(payload) { this._socket.destroy() this.closed = true this.readyState = 3 // CLOSED this.emit('close', payload) } } module.exports = WebSocket