'use strict' const { Writable } = require('node:stream') const { parserStates, opcodes, states, emptyBuffer, sentCloseFrameState } = require('./constants') const { kReadyState, kSentClose, kResponse, kReceivedClose } = require('./symbols') const { channels } = require('../../core/diagnostics') const { isValidStatusCode, failWebsocketConnection, websocketMessageReceived, utf8Decode } = require('./util') const { WebsocketFrameSend } = require('./frame') // This code was influenced by ws released under the MIT license. // Copyright (c) 2011 Einar Otto Stangvik // Copyright (c) 2013 Arnout Kazemier and contributors // Copyright (c) 2016 Luigi Pinca and contributors class ByteParser extends Writable { #buffers = [] #byteOffset = 0 #state = parserStates.INFO #info = {} #fragments = [] constructor (ws) { super() this.ws = ws } /** * @param {Buffer} chunk * @param {() => void} callback */ _write (chunk, _, callback) { this.#buffers.push(chunk) this.#byteOffset += chunk.length this.run(callback) } /** * Runs whenever a new chunk is received. * Callback is called whenever there are no more chunks buffering, * or not enough bytes are buffered to parse. */ run (callback) { while (true) { if (this.#state === parserStates.INFO) { // If there aren't enough bytes to parse the payload length, etc. if (this.#byteOffset < 2) { return callback() } const buffer = this.consume(2) this.#info.fin = (buffer[0] & 0x80) !== 0 this.#info.opcode = buffer[0] & 0x0F // If we receive a fragmented message, we use the type of the first // frame to parse the full message as binary/text, when it's terminated this.#info.originalOpcode ??= this.#info.opcode this.#info.fragmented = !this.#info.fin && this.#info.opcode !== opcodes.CONTINUATION if (this.#info.fragmented && this.#info.opcode !== opcodes.BINARY && this.#info.opcode !== opcodes.TEXT) { // Only text and binary frames can be fragmented failWebsocketConnection(this.ws, 'Invalid frame type was fragmented.') return } const payloadLength = buffer[1] & 0x7F if (payloadLength <= 125) { this.#info.payloadLength = payloadLength this.#state = parserStates.READ_DATA } else if (payloadLength === 126) { this.#state = parserStates.PAYLOADLENGTH_16 } else if (payloadLength === 127) { this.#state = parserStates.PAYLOADLENGTH_64 } if (this.#info.fragmented && payloadLength > 125) { // A fragmented frame can't be fragmented itself failWebsocketConnection(this.ws, 'Fragmented frame exceeded 125 bytes.') return } else if ( (this.#info.opcode === opcodes.PING || this.#info.opcode === opcodes.PONG || this.#info.opcode === opcodes.CLOSE) && payloadLength > 125 ) { // Control frames can have a payload length of 125 bytes MAX failWebsocketConnection(this.ws, 'Payload length for control frame exceeded 125 bytes.') return } else if (this.#info.opcode === opcodes.CLOSE) { if (payloadLength === 1) { failWebsocketConnection(this.ws, 'Received close frame with a 1-byte body.') return } const body = this.consume(payloadLength) this.#info.closeInfo = this.parseCloseBody(body) if (this.ws[kSentClose] !== sentCloseFrameState.SENT) { // If an endpoint receives a Close frame and did not previously send a // Close frame, the endpoint MUST send a Close frame in response. (When // sending a Close frame in response, the endpoint typically echos the // status code it received.) let body = emptyBuffer if (this.#info.closeInfo.code) { body = Buffer.allocUnsafe(2) body.writeUInt16BE(this.#info.closeInfo.code, 0) } const closeFrame = new WebsocketFrameSend(body) this.ws[kResponse].socket.write( closeFrame.createFrame(opcodes.CLOSE), (err) => { if (!err) { this.ws[kSentClose] = sentCloseFrameState.SENT } } ) } // Upon either sending or receiving a Close control frame, it is said // that _The WebSocket Closing Handshake is Started_ and that the // WebSocket connection is in the CLOSING state. this.ws[kReadyState] = states.CLOSING this.ws[kReceivedClose] = true this.end() return } else if (this.#info.opcode === opcodes.PING) { // Upon receipt of a Ping frame, an endpoint MUST send a Pong frame in // response, unless it already received a Close frame. // A Pong frame sent in response to a Ping frame must have identical // "Application data" const body = this.consume(payloadLength) if (!this.ws[kReceivedClose]) { const frame = new WebsocketFrameSend(body) this.ws[kResponse].socket.write(frame.createFrame(opcodes.PONG)) if (channels.ping.hasSubscribers) { channels.ping.publish({ payload: body }) } } this.#state = parserStates.INFO if (this.#byteOffset > 0) { continue } else { callback() return } } else if (this.#info.opcode === opcodes.PONG) { // A Pong frame MAY be sent unsolicited. This serves as a // unidirectional heartbeat. A response to an unsolicited Pong frame is // not expected. const body = this.consume(payloadLength) if (channels.pong.hasSubscribers) { channels.pong.publish({ payload: body }) } if (this.#byteOffset > 0) { continue } else { callback() return } } } else if (this.#state === parserStates.PAYLOADLENGTH_16) { if (this.#byteOffset < 2) { return callback() } const buffer = this.consume(2) this.#info.payloadLength = buffer.readUInt16BE(0) this.#state = parserStates.READ_DATA } else if (this.#state === parserStates.PAYLOADLENGTH_64) { if (this.#byteOffset < 8) { return callback() } const buffer = this.consume(8) const upper = buffer.readUInt32BE(0) // 2^31 is the maxinimum bytes an arraybuffer can contain // on 32-bit systems. Although, on 64-bit systems, this is // 2^53-1 bytes. // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Invalid_array_length // https://source.chromium.org/chromium/chromium/src/+/main:v8/src/common/globals.h;drc=1946212ac0100668f14eb9e2843bdd846e510a1e;bpv=1;bpt=1;l=1275 // https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/js-array-buffer.h;l=34;drc=1946212ac0100668f14eb9e2843bdd846e510a1e if (upper > 2 ** 31 - 1) { failWebsocketConnection(this.ws, 'Received payload length > 2^31 bytes.') return } const lower = buffer.readUInt32BE(4) this.#info.payloadLength = (upper << 8) + lower this.#state = parserStates.READ_DATA } else if (this.#state === parserStates.READ_DATA) { if (this.#byteOffset < this.#info.payloadLength) { // If there is still more data in this chunk that needs to be read return callback() } else if (this.#byteOffset >= this.#info.payloadLength) { // If the server sent multiple frames in a single chunk const body = this.consume(this.#info.payloadLength) this.#fragments.push(body) // If the frame is unfragmented, or a fragmented frame was terminated, // a message was received if (!this.#info.fragmented || (this.#info.fin && this.#info.opcode === opcodes.CONTINUATION)) { const fullMessage = Buffer.concat(this.#fragments) websocketMessageReceived(this.ws, this.#info.originalOpcode, fullMessage) this.#info = {} this.#fragments.length = 0 } this.#state = parserStates.INFO } } if (this.#byteOffset === 0) { callback() break } } } /** * Take n bytes from the buffered Buffers * @param {number} n * @returns {Buffer|null} */ consume (n) { if (n > this.#byteOffset) { return null } else if (n === 0) { return emptyBuffer } if (this.#buffers[0].length === n) { this.#byteOffset -= this.#buffers[0].length return this.#buffers.shift() } const buffer = Buffer.allocUnsafe(n) let offset = 0 while (offset !== n) { const next = this.#buffers[0] const { length } = next if (length + offset === n) { buffer.set(this.#buffers.shift(), offset) break } else if (length + offset > n) { buffer.set(next.subarray(0, n - offset), offset) this.#buffers[0] = next.subarray(n - offset) break } else { buffer.set(this.#buffers.shift(), offset) offset += next.length } } this.#byteOffset -= n return buffer } parseCloseBody (data) { // https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.5 /** @type {number|undefined} */ let code if (data.length >= 2) { // _The WebSocket Connection Close Code_ is // defined as the status code (Section 7.4) contained in the first Close // control frame received by the application code = data.readUInt16BE(0) } // https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.6 /** @type {Buffer} */ let reason = data.subarray(2) // Remove BOM if (reason[0] === 0xEF && reason[1] === 0xBB && reason[2] === 0xBF) { reason = reason.subarray(3) } if (code !== undefined && !isValidStatusCode(code)) { return null } try { reason = utf8Decode(reason) } catch { return null } return { code, reason } } get closingInfo () { return this.#info.closeInfo } } module.exports = { ByteParser }