743 lines
17 KiB
JavaScript
743 lines
17 KiB
JavaScript
'use strict';
|
|
|
|
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 DEFER_EVENT = 6;
|
|
|
|
/**
|
|
* HyBi Receiver implementation.
|
|
*
|
|
* @extends Writable
|
|
*/
|
|
class Receiver extends Writable {
|
|
/**
|
|
* Creates a Receiver instance.
|
|
*
|
|
* @param {Object} [options] Options object
|
|
* @param {Boolean} [options.allowSynchronousEvents=false] Specifies whether
|
|
* any of the `'message'`, `'ping'`, and `'pong'` events can be emitted
|
|
* multiple times in the same tick
|
|
* @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._allowSynchronousEvents = !!options.allowSynchronousEvents;
|
|
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._errored = false;
|
|
this._loop = false;
|
|
this._state = GET_INFO;
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
this._loop = true;
|
|
|
|
do {
|
|
switch (this._state) {
|
|
case GET_INFO:
|
|
this.getInfo(cb);
|
|
break;
|
|
case GET_PAYLOAD_LENGTH_16:
|
|
this.getPayloadLength16(cb);
|
|
break;
|
|
case GET_PAYLOAD_LENGTH_64:
|
|
this.getPayloadLength64(cb);
|
|
break;
|
|
case GET_MASK:
|
|
this.getMask();
|
|
break;
|
|
case GET_DATA:
|
|
this.getData(cb);
|
|
break;
|
|
case INFLATING:
|
|
case DEFER_EVENT:
|
|
this._loop = false;
|
|
return;
|
|
}
|
|
} while (this._loop);
|
|
|
|
if (!this._errored) cb();
|
|
}
|
|
|
|
/**
|
|
* Reads the first two bytes of a frame.
|
|
*
|
|
* @param {Function} cb Callback
|
|
* @private
|
|
*/
|
|
getInfo(cb) {
|
|
if (this._bufferedBytes < 2) {
|
|
this._loop = false;
|
|
return;
|
|
}
|
|
|
|
const buf = this.consume(2);
|
|
|
|
if ((buf[0] & 0x30) !== 0x00) {
|
|
const error = this.createError(
|
|
RangeError,
|
|
'RSV2 and RSV3 must be clear',
|
|
true,
|
|
1002,
|
|
'WS_ERR_UNEXPECTED_RSV_2_3'
|
|
);
|
|
|
|
cb(error);
|
|
return;
|
|
}
|
|
|
|
const compressed = (buf[0] & 0x40) === 0x40;
|
|
|
|
if (compressed && !this._extensions[PerMessageDeflate.extensionName]) {
|
|
const error = this.createError(
|
|
RangeError,
|
|
'RSV1 must be clear',
|
|
true,
|
|
1002,
|
|
'WS_ERR_UNEXPECTED_RSV_1'
|
|
);
|
|
|
|
cb(error);
|
|
return;
|
|
}
|
|
|
|
this._fin = (buf[0] & 0x80) === 0x80;
|
|
this._opcode = buf[0] & 0x0f;
|
|
this._payloadLength = buf[1] & 0x7f;
|
|
|
|
if (this._opcode === 0x00) {
|
|
if (compressed) {
|
|
const error = this.createError(
|
|
RangeError,
|
|
'RSV1 must be clear',
|
|
true,
|
|
1002,
|
|
'WS_ERR_UNEXPECTED_RSV_1'
|
|
);
|
|
|
|
cb(error);
|
|
return;
|
|
}
|
|
|
|
if (!this._fragmented) {
|
|
const error = this.createError(
|
|
RangeError,
|
|
'invalid opcode 0',
|
|
true,
|
|
1002,
|
|
'WS_ERR_INVALID_OPCODE'
|
|
);
|
|
|
|
cb(error);
|
|
return;
|
|
}
|
|
|
|
this._opcode = this._fragmented;
|
|
} else if (this._opcode === 0x01 || this._opcode === 0x02) {
|
|
if (this._fragmented) {
|
|
const error = this.createError(
|
|
RangeError,
|
|
`invalid opcode ${this._opcode}`,
|
|
true,
|
|
1002,
|
|
'WS_ERR_INVALID_OPCODE'
|
|
);
|
|
|
|
cb(error);
|
|
return;
|
|
}
|
|
|
|
this._compressed = compressed;
|
|
} else if (this._opcode > 0x07 && this._opcode < 0x0b) {
|
|
if (!this._fin) {
|
|
const error = this.createError(
|
|
RangeError,
|
|
'FIN must be set',
|
|
true,
|
|
1002,
|
|
'WS_ERR_EXPECTED_FIN'
|
|
);
|
|
|
|
cb(error);
|
|
return;
|
|
}
|
|
|
|
if (compressed) {
|
|
const error = this.createError(
|
|
RangeError,
|
|
'RSV1 must be clear',
|
|
true,
|
|
1002,
|
|
'WS_ERR_UNEXPECTED_RSV_1'
|
|
);
|
|
|
|
cb(error);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
this._payloadLength > 0x7d ||
|
|
(this._opcode === 0x08 && this._payloadLength === 1)
|
|
) {
|
|
const error = this.createError(
|
|
RangeError,
|
|
`invalid payload length ${this._payloadLength}`,
|
|
true,
|
|
1002,
|
|
'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH'
|
|
);
|
|
|
|
cb(error);
|
|
return;
|
|
}
|
|
} else {
|
|
const error = this.createError(
|
|
RangeError,
|
|
`invalid opcode ${this._opcode}`,
|
|
true,
|
|
1002,
|
|
'WS_ERR_INVALID_OPCODE'
|
|
);
|
|
|
|
cb(error);
|
|
return;
|
|
}
|
|
|
|
if (!this._fin && !this._fragmented) this._fragmented = this._opcode;
|
|
this._masked = (buf[1] & 0x80) === 0x80;
|
|
|
|
if (this._isServer) {
|
|
if (!this._masked) {
|
|
const error = this.createError(
|
|
RangeError,
|
|
'MASK must be set',
|
|
true,
|
|
1002,
|
|
'WS_ERR_EXPECTED_MASK'
|
|
);
|
|
|
|
cb(error);
|
|
return;
|
|
}
|
|
} else if (this._masked) {
|
|
const error = this.createError(
|
|
RangeError,
|
|
'MASK must be clear',
|
|
true,
|
|
1002,
|
|
'WS_ERR_UNEXPECTED_MASK'
|
|
);
|
|
|
|
cb(error);
|
|
return;
|
|
}
|
|
|
|
if (this._payloadLength === 126) this._state = GET_PAYLOAD_LENGTH_16;
|
|
else if (this._payloadLength === 127) this._state = GET_PAYLOAD_LENGTH_64;
|
|
else this.haveLength(cb);
|
|
}
|
|
|
|
/**
|
|
* Gets extended payload length (7+16).
|
|
*
|
|
* @param {Function} cb Callback
|
|
* @private
|
|
*/
|
|
getPayloadLength16(cb) {
|
|
if (this._bufferedBytes < 2) {
|
|
this._loop = false;
|
|
return;
|
|
}
|
|
|
|
this._payloadLength = this.consume(2).readUInt16BE(0);
|
|
this.haveLength(cb);
|
|
}
|
|
|
|
/**
|
|
* Gets extended payload length (7+64).
|
|
*
|
|
* @param {Function} cb Callback
|
|
* @private
|
|
*/
|
|
getPayloadLength64(cb) {
|
|
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) {
|
|
const error = this.createError(
|
|
RangeError,
|
|
'Unsupported WebSocket frame: payload length > 2^53 - 1',
|
|
false,
|
|
1009,
|
|
'WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH'
|
|
);
|
|
|
|
cb(error);
|
|
return;
|
|
}
|
|
|
|
this._payloadLength = num * Math.pow(2, 32) + buf.readUInt32BE(4);
|
|
this.haveLength(cb);
|
|
}
|
|
|
|
/**
|
|
* Payload length has been read.
|
|
*
|
|
* @param {Function} cb Callback
|
|
* @private
|
|
*/
|
|
haveLength(cb) {
|
|
if (this._payloadLength && this._opcode < 0x08) {
|
|
this._totalPayloadLength += this._payloadLength;
|
|
if (this._totalPayloadLength > this._maxPayload && this._maxPayload > 0) {
|
|
const error = this.createError(
|
|
RangeError,
|
|
'Max payload size exceeded',
|
|
false,
|
|
1009,
|
|
'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'
|
|
);
|
|
|
|
cb(error);
|
|
return;
|
|
}
|
|
}
|
|
|
|
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
|
|
* @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) {
|
|
this.controlMessage(data, cb);
|
|
return;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
this.dataMessage(cb);
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
const error = this.createError(
|
|
RangeError,
|
|
'Max payload size exceeded',
|
|
false,
|
|
1009,
|
|
'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'
|
|
);
|
|
|
|
cb(error);
|
|
return;
|
|
}
|
|
|
|
this._fragments.push(buf);
|
|
}
|
|
|
|
this.dataMessage(cb);
|
|
if (this._state === GET_INFO) this.startLoop(cb);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handles a data message.
|
|
*
|
|
* @param {Function} cb Callback
|
|
* @private
|
|
*/
|
|
dataMessage(cb) {
|
|
if (!this._fin) {
|
|
this._state = GET_INFO;
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
//
|
|
// If the state is `INFLATING`, it means that the frame data was
|
|
// decompressed asynchronously, so there is no need to defer the event
|
|
// as it will be emitted asynchronously anyway.
|
|
//
|
|
if (this._state === INFLATING || this._allowSynchronousEvents) {
|
|
this.emit('message', data, true);
|
|
this._state = GET_INFO;
|
|
} else {
|
|
this._state = DEFER_EVENT;
|
|
queueTask(() => {
|
|
this.emit('message', data, true);
|
|
this._state = GET_INFO;
|
|
this.startLoop(cb);
|
|
});
|
|
}
|
|
} else {
|
|
const buf = concat(fragments, messageLength);
|
|
|
|
if (!this._skipUTF8Validation && !isValidUTF8(buf)) {
|
|
const error = this.createError(
|
|
Error,
|
|
'invalid UTF-8 sequence',
|
|
true,
|
|
1007,
|
|
'WS_ERR_INVALID_UTF8'
|
|
);
|
|
|
|
cb(error);
|
|
return;
|
|
}
|
|
|
|
if (this._state === INFLATING || this._allowSynchronousEvents) {
|
|
this.emit('message', buf, false);
|
|
this._state = GET_INFO;
|
|
} else {
|
|
this._state = DEFER_EVENT;
|
|
queueTask(() => {
|
|
this.emit('message', buf, false);
|
|
this._state = GET_INFO;
|
|
this.startLoop(cb);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles a control message.
|
|
*
|
|
* @param {Buffer} data Data to handle
|
|
* @return {(Error|RangeError|undefined)} A possible error
|
|
* @private
|
|
*/
|
|
controlMessage(data, cb) {
|
|
if (this._opcode === 0x08) {
|
|
if (data.length === 0) {
|
|
this._loop = false;
|
|
this.emit('conclude', 1005, EMPTY_BUFFER);
|
|
this.end();
|
|
} else {
|
|
const code = data.readUInt16BE(0);
|
|
|
|
if (!isValidStatusCode(code)) {
|
|
const error = this.createError(
|
|
RangeError,
|
|
`invalid status code ${code}`,
|
|
true,
|
|
1002,
|
|
'WS_ERR_INVALID_CLOSE_CODE'
|
|
);
|
|
|
|
cb(error);
|
|
return;
|
|
}
|
|
|
|
const buf = new FastBuffer(
|
|
data.buffer,
|
|
data.byteOffset + 2,
|
|
data.length - 2
|
|
);
|
|
|
|
if (!this._skipUTF8Validation && !isValidUTF8(buf)) {
|
|
const error = this.createError(
|
|
Error,
|
|
'invalid UTF-8 sequence',
|
|
true,
|
|
1007,
|
|
'WS_ERR_INVALID_UTF8'
|
|
);
|
|
|
|
cb(error);
|
|
return;
|
|
}
|
|
|
|
this._loop = false;
|
|
this.emit('conclude', code, buf);
|
|
this.end();
|
|
}
|
|
|
|
this._state = GET_INFO;
|
|
return;
|
|
}
|
|
|
|
if (this._allowSynchronousEvents) {
|
|
this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data);
|
|
this._state = GET_INFO;
|
|
} else {
|
|
this._state = DEFER_EVENT;
|
|
queueTask(() => {
|
|
this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data);
|
|
this._state = GET_INFO;
|
|
this.startLoop(cb);
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
createError(ErrorCtor, message, prefix, statusCode, errorCode) {
|
|
this._loop = false;
|
|
this._errored = true;
|
|
|
|
const err = new ErrorCtor(
|
|
prefix ? `Invalid WebSocket frame: ${message}` : message
|
|
);
|
|
|
|
Error.captureStackTrace(err, this.createError);
|
|
err.code = errorCode;
|
|
err[kStatusCode] = statusCode;
|
|
return err;
|
|
}
|
|
}
|
|
|
|
module.exports = Receiver;
|
|
|
|
/**
|
|
* 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);
|
|
}
|