Spaces:
Runtime error
Runtime error
; | |
const { Writable } = require('stream'); | |
const PerMessageDeflate = require('./permessage-deflate'); | |
const { | |
BINARY_TYPES, | |
EMPTY_BUFFER, | |
kStatusCode, | |
kWebSocket | |
} = require('./constants'); | |
const { concat, toArrayBuffer, unmask } = require('./buffer-util'); | |
const { isValidStatusCode, isValidUTF8 } = require('./validation'); | |
const FastBuffer = Buffer[Symbol.species]; | |
const promise = Promise.resolve(); | |
// | |
// `queueMicrotask()` is not available in Node.js < 11. | |
// | |
const queueTask = | |
typeof queueMicrotask === 'function' ? queueMicrotask : queueMicrotaskShim; | |
const GET_INFO = 0; | |
const GET_PAYLOAD_LENGTH_16 = 1; | |
const GET_PAYLOAD_LENGTH_64 = 2; | |
const GET_MASK = 3; | |
const GET_DATA = 4; | |
const INFLATING = 5; | |
const WAIT_MICROTASK = 6; | |
/** | |
* HyBi Receiver implementation. | |
* | |
* @extends Writable | |
*/ | |
class Receiver extends Writable { | |
/** | |
* Creates a Receiver instance. | |
* | |
* @param {Object} [options] Options object | |
* @param {String} [options.binaryType=nodebuffer] The type for binary data | |
* @param {Object} [options.extensions] An object containing the negotiated | |
* extensions | |
* @param {Boolean} [options.isServer=false] Specifies whether to operate in | |
* client or server mode | |
* @param {Number} [options.maxPayload=0] The maximum allowed message length | |
* @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or | |
* not to skip UTF-8 validation for text and close messages | |
*/ | |
constructor(options = {}) { | |
super(); | |
this._binaryType = options.binaryType || BINARY_TYPES[0]; | |
this._extensions = options.extensions || {}; | |
this._isServer = !!options.isServer; | |
this._maxPayload = options.maxPayload | 0; | |
this._skipUTF8Validation = !!options.skipUTF8Validation; | |
this[kWebSocket] = undefined; | |
this._bufferedBytes = 0; | |
this._buffers = []; | |
this._compressed = false; | |
this._payloadLength = 0; | |
this._mask = undefined; | |
this._fragmented = 0; | |
this._masked = false; | |
this._fin = false; | |
this._opcode = 0; | |
this._totalPayloadLength = 0; | |
this._messageLength = 0; | |
this._fragments = []; | |
this._state = GET_INFO; | |
this._loop = false; | |
} | |
/** | |
* Implements `Writable.prototype._write()`. | |
* | |
* @param {Buffer} chunk The chunk of data to write | |
* @param {String} encoding The character encoding of `chunk` | |
* @param {Function} cb Callback | |
* @private | |
*/ | |
_write(chunk, encoding, cb) { | |
if (this._opcode === 0x08 && this._state == GET_INFO) return cb(); | |
this._bufferedBytes += chunk.length; | |
this._buffers.push(chunk); | |
this.startLoop(cb); | |
} | |
/** | |
* Consumes `n` bytes from the buffered data. | |
* | |
* @param {Number} n The number of bytes to consume | |
* @return {Buffer} The consumed bytes | |
* @private | |
*/ | |
consume(n) { | |
this._bufferedBytes -= n; | |
if (n === this._buffers[0].length) return this._buffers.shift(); | |
if (n < this._buffers[0].length) { | |
const buf = this._buffers[0]; | |
this._buffers[0] = new FastBuffer( | |
buf.buffer, | |
buf.byteOffset + n, | |
buf.length - n | |
); | |
return new FastBuffer(buf.buffer, buf.byteOffset, n); | |
} | |
const dst = Buffer.allocUnsafe(n); | |
do { | |
const buf = this._buffers[0]; | |
const offset = dst.length - n; | |
if (n >= buf.length) { | |
dst.set(this._buffers.shift(), offset); | |
} else { | |
dst.set(new Uint8Array(buf.buffer, buf.byteOffset, n), offset); | |
this._buffers[0] = new FastBuffer( | |
buf.buffer, | |
buf.byteOffset + n, | |
buf.length - n | |
); | |
} | |
n -= buf.length; | |
} while (n > 0); | |
return dst; | |
} | |
/** | |
* Starts the parsing loop. | |
* | |
* @param {Function} cb Callback | |
* @private | |
*/ | |
startLoop(cb) { | |
let err; | |
this._loop = true; | |
do { | |
switch (this._state) { | |
case GET_INFO: | |
err = this.getInfo(); | |
break; | |
case GET_PAYLOAD_LENGTH_16: | |
err = this.getPayloadLength16(); | |
break; | |
case GET_PAYLOAD_LENGTH_64: | |
err = this.getPayloadLength64(); | |
break; | |
case GET_MASK: | |
this.getMask(); | |
break; | |
case GET_DATA: | |
err = this.getData(cb); | |
break; | |
case INFLATING: | |
this._loop = false; | |
return; | |
default: | |
// | |
// `WAIT_MICROTASK`. | |
// | |
this._loop = false; | |
queueTask(() => { | |
this._state = GET_INFO; | |
this.startLoop(cb); | |
}); | |
return; | |
} | |
} while (this._loop); | |
cb(err); | |
} | |
/** | |
* Reads the first two bytes of a frame. | |
* | |
* @return {(RangeError|undefined)} A possible error | |
* @private | |
*/ | |
getInfo() { | |
if (this._bufferedBytes < 2) { | |
this._loop = false; | |
return; | |
} | |
const buf = this.consume(2); | |
if ((buf[0] & 0x30) !== 0x00) { | |
this._loop = false; | |
return error( | |
RangeError, | |
'RSV2 and RSV3 must be clear', | |
true, | |
1002, | |
'WS_ERR_UNEXPECTED_RSV_2_3' | |
); | |
} | |
const compressed = (buf[0] & 0x40) === 0x40; | |
if (compressed && !this._extensions[PerMessageDeflate.extensionName]) { | |
this._loop = false; | |
return error( | |
RangeError, | |
'RSV1 must be clear', | |
true, | |
1002, | |
'WS_ERR_UNEXPECTED_RSV_1' | |
); | |
} | |
this._fin = (buf[0] & 0x80) === 0x80; | |
this._opcode = buf[0] & 0x0f; | |
this._payloadLength = buf[1] & 0x7f; | |
if (this._opcode === 0x00) { | |
if (compressed) { | |
this._loop = false; | |
return error( | |
RangeError, | |
'RSV1 must be clear', | |
true, | |
1002, | |
'WS_ERR_UNEXPECTED_RSV_1' | |
); | |
} | |
if (!this._fragmented) { | |
this._loop = false; | |
return error( | |
RangeError, | |
'invalid opcode 0', | |
true, | |
1002, | |
'WS_ERR_INVALID_OPCODE' | |
); | |
} | |
this._opcode = this._fragmented; | |
} else if (this._opcode === 0x01 || this._opcode === 0x02) { | |
if (this._fragmented) { | |
this._loop = false; | |
return error( | |
RangeError, | |
`invalid opcode ${this._opcode}`, | |
true, | |
1002, | |
'WS_ERR_INVALID_OPCODE' | |
); | |
} | |
this._compressed = compressed; | |
} else if (this._opcode > 0x07 && this._opcode < 0x0b) { | |
if (!this._fin) { | |
this._loop = false; | |
return error( | |
RangeError, | |
'FIN must be set', | |
true, | |
1002, | |
'WS_ERR_EXPECTED_FIN' | |
); | |
} | |
if (compressed) { | |
this._loop = false; | |
return error( | |
RangeError, | |
'RSV1 must be clear', | |
true, | |
1002, | |
'WS_ERR_UNEXPECTED_RSV_1' | |
); | |
} | |
if ( | |
this._payloadLength > 0x7d || | |
(this._opcode === 0x08 && this._payloadLength === 1) | |
) { | |
this._loop = false; | |
return error( | |
RangeError, | |
`invalid payload length ${this._payloadLength}`, | |
true, | |
1002, | |
'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH' | |
); | |
} | |
} else { | |
this._loop = false; | |
return error( | |
RangeError, | |
`invalid opcode ${this._opcode}`, | |
true, | |
1002, | |
'WS_ERR_INVALID_OPCODE' | |
); | |
} | |
if (!this._fin && !this._fragmented) this._fragmented = this._opcode; | |
this._masked = (buf[1] & 0x80) === 0x80; | |
if (this._isServer) { | |
if (!this._masked) { | |
this._loop = false; | |
return error( | |
RangeError, | |
'MASK must be set', | |
true, | |
1002, | |
'WS_ERR_EXPECTED_MASK' | |
); | |
} | |
} else if (this._masked) { | |
this._loop = false; | |
return error( | |
RangeError, | |
'MASK must be clear', | |
true, | |
1002, | |
'WS_ERR_UNEXPECTED_MASK' | |
); | |
} | |
if (this._payloadLength === 126) this._state = GET_PAYLOAD_LENGTH_16; | |
else if (this._payloadLength === 127) this._state = GET_PAYLOAD_LENGTH_64; | |
else return this.haveLength(); | |
} | |
/** | |
* Gets extended payload length (7+16). | |
* | |
* @return {(RangeError|undefined)} A possible error | |
* @private | |
*/ | |
getPayloadLength16() { | |
if (this._bufferedBytes < 2) { | |
this._loop = false; | |
return; | |
} | |
this._payloadLength = this.consume(2).readUInt16BE(0); | |
return this.haveLength(); | |
} | |
/** | |
* Gets extended payload length (7+64). | |
* | |
* @return {(RangeError|undefined)} A possible error | |
* @private | |
*/ | |
getPayloadLength64() { | |
if (this._bufferedBytes < 8) { | |
this._loop = false; | |
return; | |
} | |
const buf = this.consume(8); | |
const num = buf.readUInt32BE(0); | |
// | |
// The maximum safe integer in JavaScript is 2^53 - 1. An error is returned | |
// if payload length is greater than this number. | |
// | |
if (num > Math.pow(2, 53 - 32) - 1) { | |
this._loop = false; | |
return error( | |
RangeError, | |
'Unsupported WebSocket frame: payload length > 2^53 - 1', | |
false, | |
1009, | |
'WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH' | |
); | |
} | |
this._payloadLength = num * Math.pow(2, 32) + buf.readUInt32BE(4); | |
return this.haveLength(); | |
} | |
/** | |
* Payload length has been read. | |
* | |
* @return {(RangeError|undefined)} A possible error | |
* @private | |
*/ | |
haveLength() { | |
if (this._payloadLength && this._opcode < 0x08) { | |
this._totalPayloadLength += this._payloadLength; | |
if (this._totalPayloadLength > this._maxPayload && this._maxPayload > 0) { | |
this._loop = false; | |
return error( | |
RangeError, | |
'Max payload size exceeded', | |
false, | |
1009, | |
'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH' | |
); | |
} | |
} | |
if (this._masked) this._state = GET_MASK; | |
else this._state = GET_DATA; | |
} | |
/** | |
* Reads mask bytes. | |
* | |
* @private | |
*/ | |
getMask() { | |
if (this._bufferedBytes < 4) { | |
this._loop = false; | |
return; | |
} | |
this._mask = this.consume(4); | |
this._state = GET_DATA; | |
} | |
/** | |
* Reads data bytes. | |
* | |
* @param {Function} cb Callback | |
* @return {(Error|RangeError|undefined)} A possible error | |
* @private | |
*/ | |
getData(cb) { | |
let data = EMPTY_BUFFER; | |
if (this._payloadLength) { | |
if (this._bufferedBytes < this._payloadLength) { | |
this._loop = false; | |
return; | |
} | |
data = this.consume(this._payloadLength); | |
if ( | |
this._masked && | |
(this._mask[0] | this._mask[1] | this._mask[2] | this._mask[3]) !== 0 | |
) { | |
unmask(data, this._mask); | |
} | |
} | |
if (this._opcode > 0x07) return this.controlMessage(data); | |
if (this._compressed) { | |
this._state = INFLATING; | |
this.decompress(data, cb); | |
return; | |
} | |
if (data.length) { | |
// | |
// This message is not compressed so its length is the sum of the payload | |
// length of all fragments. | |
// | |
this._messageLength = this._totalPayloadLength; | |
this._fragments.push(data); | |
} | |
return this.dataMessage(); | |
} | |
/** | |
* Decompresses data. | |
* | |
* @param {Buffer} data Compressed data | |
* @param {Function} cb Callback | |
* @private | |
*/ | |
decompress(data, cb) { | |
const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; | |
perMessageDeflate.decompress(data, this._fin, (err, buf) => { | |
if (err) return cb(err); | |
if (buf.length) { | |
this._messageLength += buf.length; | |
if (this._messageLength > this._maxPayload && this._maxPayload > 0) { | |
return cb( | |
error( | |
RangeError, | |
'Max payload size exceeded', | |
false, | |
1009, | |
'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH' | |
) | |
); | |
} | |
this._fragments.push(buf); | |
} | |
const er = this.dataMessage(); | |
if (er) return cb(er); | |
this.startLoop(cb); | |
}); | |
} | |
/** | |
* Handles a data message. | |
* | |
* @return {(Error|undefined)} A possible error | |
* @private | |
*/ | |
dataMessage() { | |
if (this._fin) { | |
const messageLength = this._messageLength; | |
const fragments = this._fragments; | |
this._totalPayloadLength = 0; | |
this._messageLength = 0; | |
this._fragmented = 0; | |
this._fragments = []; | |
if (this._opcode === 2) { | |
let data; | |
if (this._binaryType === 'nodebuffer') { | |
data = concat(fragments, messageLength); | |
} else if (this._binaryType === 'arraybuffer') { | |
data = toArrayBuffer(concat(fragments, messageLength)); | |
} else { | |
data = fragments; | |
} | |
this.emit('message', data, true); | |
} else { | |
const buf = concat(fragments, messageLength); | |
if (!this._skipUTF8Validation && !isValidUTF8(buf)) { | |
this._loop = false; | |
return error( | |
Error, | |
'invalid UTF-8 sequence', | |
true, | |
1007, | |
'WS_ERR_INVALID_UTF8' | |
); | |
} | |
this.emit('message', buf, false); | |
} | |
} | |
this._state = WAIT_MICROTASK; | |
} | |
/** | |
* Handles a control message. | |
* | |
* @param {Buffer} data Data to handle | |
* @return {(Error|RangeError|undefined)} A possible error | |
* @private | |
*/ | |
controlMessage(data) { | |
if (this._opcode === 0x08) { | |
this._loop = false; | |
if (data.length === 0) { | |
this.emit('conclude', 1005, EMPTY_BUFFER); | |
this.end(); | |
this._state = GET_INFO; | |
} else { | |
const code = data.readUInt16BE(0); | |
if (!isValidStatusCode(code)) { | |
return error( | |
RangeError, | |
`invalid status code ${code}`, | |
true, | |
1002, | |
'WS_ERR_INVALID_CLOSE_CODE' | |
); | |
} | |
const buf = new FastBuffer( | |
data.buffer, | |
data.byteOffset + 2, | |
data.length - 2 | |
); | |
if (!this._skipUTF8Validation && !isValidUTF8(buf)) { | |
return error( | |
Error, | |
'invalid UTF-8 sequence', | |
true, | |
1007, | |
'WS_ERR_INVALID_UTF8' | |
); | |
} | |
this.emit('conclude', code, buf); | |
this.end(); | |
this._state = GET_INFO; | |
} | |
} else if (this._opcode === 0x09) { | |
this.emit('ping', data); | |
this._state = WAIT_MICROTASK; | |
} else { | |
this.emit('pong', data); | |
this._state = WAIT_MICROTASK; | |
} | |
} | |
} | |
module.exports = Receiver; | |
/** | |
* Builds an error object. | |
* | |
* @param {function(new:Error|RangeError)} ErrorCtor The error constructor | |
* @param {String} message The error message | |
* @param {Boolean} prefix Specifies whether or not to add a default prefix to | |
* `message` | |
* @param {Number} statusCode The status code | |
* @param {String} errorCode The exposed error code | |
* @return {(Error|RangeError)} The error | |
* @private | |
*/ | |
function error(ErrorCtor, message, prefix, statusCode, errorCode) { | |
const err = new ErrorCtor( | |
prefix ? `Invalid WebSocket frame: ${message}` : message | |
); | |
Error.captureStackTrace(err, error); | |
err.code = errorCode; | |
err[kStatusCode] = statusCode; | |
return err; | |
} | |
/** | |
* A shim for `queueMicrotask()`. | |
* | |
* @param {Function} cb Callback | |
*/ | |
function queueMicrotaskShim(cb) { | |
promise.then(cb).catch(throwErrorNextTick); | |
} | |
/** | |
* Throws an error. | |
* | |
* @param {Error} err The error to throw | |
* @private | |
*/ | |
function throwError(err) { | |
throw err; | |
} | |
/** | |
* Throws an error in the next tick. | |
* | |
* @param {Error} err The error to throw | |
* @private | |
*/ | |
function throwErrorNextTick(err) { | |
process.nextTick(throwError, err); | |
} | |