diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..935d77928 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +lib/parser-v3/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c4aa00d5..9f90a0993 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# [4.1.0](https://github.com/socketio/engine.io/compare/4.0.6...4.1.0) (2021-01-14) + + +### Features + +* add support for v3.x clients ([663d326](https://github.com/socketio/engine.io/commit/663d326d18de598318bd2120b2b70cd51adf8955)) + + ## [4.0.6](https://github.com/socketio/engine.io/compare/4.0.5...4.0.6) (2021-01-04) diff --git a/README.md b/README.md index 62c800e1b..90cc933e5 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,7 @@ to a single process. - `wsEngine` (`String`): what WebSocket server implementation to use. Specified module must conform to the `ws` interface (see [ws module api docs](https://github.com/websockets/ws/blob/master/doc/ws.md)). Default value is `ws`. An alternative c++ addon is also available by installing `uws` module. - `cors` (`Object`): the options that will be forwarded to the cors module. See [there](https://github.com/expressjs/cors#configuration-options) for all available options. Defaults to no CORS allowed. - `initialPacket` (`Object`): an optional packet which will be concatenated to the handshake packet emitted by Engine.IO. + - `allowEIO3` (`Boolean`): whether to support v3 Engine.IO clients (defaults to `false`) - `close` - Closes all clients - **Returns** `Server` for chaining diff --git a/lib/parser-v3/index.js b/lib/parser-v3/index.js new file mode 100644 index 000000000..bd5d56baa --- /dev/null +++ b/lib/parser-v3/index.js @@ -0,0 +1,483 @@ +// imported from https://github.com/socketio/engine.io-parser/tree/2.2.x + +/** + * Module dependencies. + */ + +var utf8 = require('./utf8'); + +/** + * Current protocol version. + */ +exports.protocol = 3; + +const hasBinary = (packets) => { + for (const packet of packets) { + if (packet.data instanceof ArrayBuffer || ArrayBuffer.isView(packet.data)) { + return true; + } + } + return false; +} + +/** + * Packet types. + */ + +var packets = exports.packets = { + open: 0 // non-ws + , close: 1 // non-ws + , ping: 2 + , pong: 3 + , message: 4 + , upgrade: 5 + , noop: 6 +}; + +var packetslist = Object.keys(packets); + +/** + * Premade error packet. + */ + +var err = { type: 'error', data: 'parser error' }; + +const EMPTY_BUFFER = Buffer.concat([]); + +/** + * Encodes a packet. + * + * [ ] + * + * Example: + * + * 5hello world + * 3 + * 4 + * + * Binary is encoded in an identical principle + * + * @api private + */ + +exports.encodePacket = function (packet, supportsBinary, utf8encode, callback) { + if (typeof supportsBinary === 'function') { + callback = supportsBinary; + supportsBinary = null; + } + + if (typeof utf8encode === 'function') { + callback = utf8encode; + utf8encode = null; + } + + if (Buffer.isBuffer(packet.data)) { + return encodeBuffer(packet, supportsBinary, callback); + } else if (packet.data && (packet.data.buffer || packet.data) instanceof ArrayBuffer) { + return encodeBuffer({ type: packet.type, data: arrayBufferToBuffer(packet.data) }, supportsBinary, callback); + } + + // Sending data as a utf-8 string + var encoded = packets[packet.type]; + + // data fragment is optional + if (undefined !== packet.data) { + encoded += utf8encode ? utf8.encode(String(packet.data), { strict: false }) : String(packet.data); + } + + return callback('' + encoded); +}; + +/** + * Encode Buffer data + */ + +function encodeBuffer(packet, supportsBinary, callback) { + if (!supportsBinary) { + return exports.encodeBase64Packet(packet, callback); + } + + var data = packet.data; + var typeBuffer = Buffer.allocUnsafe(1); + typeBuffer[0] = packets[packet.type]; + return callback(Buffer.concat([typeBuffer, data])); +} + +/** + * Encodes a packet with binary data in a base64 string + * + * @param {Object} packet, has `type` and `data` + * @return {String} base64 encoded message + */ + +exports.encodeBase64Packet = function(packet, callback){ + var data = Buffer.isBuffer(packet.data) ? packet.data : arrayBufferToBuffer(packet.data); + var message = 'b' + packets[packet.type]; + message += data.toString('base64'); + return callback(message); +}; + +/** + * Decodes a packet. Data also available as an ArrayBuffer if requested. + * + * @return {Object} with `type` and `data` (if any) + * @api private + */ + +exports.decodePacket = function (data, binaryType, utf8decode) { + if (data === undefined) { + return err; + } + + var type; + + // String data + if (typeof data === 'string') { + + type = data.charAt(0); + + if (type === 'b') { + return exports.decodeBase64Packet(data.substr(1), binaryType); + } + + if (utf8decode) { + data = tryDecode(data); + if (data === false) { + return err; + } + } + + if (Number(type) != type || !packetslist[type]) { + return err; + } + + if (data.length > 1) { + return { type: packetslist[type], data: data.substring(1) }; + } else { + return { type: packetslist[type] }; + } + } + + // Binary data + if (binaryType === 'arraybuffer') { + // wrap Buffer/ArrayBuffer data into an Uint8Array + var intArray = new Uint8Array(data); + type = intArray[0]; + return { type: packetslist[type], data: intArray.buffer.slice(1) }; + } + + if (data instanceof ArrayBuffer) { + data = arrayBufferToBuffer(data); + } + type = data[0]; + return { type: packetslist[type], data: data.slice(1) }; +}; + +function tryDecode(data) { + try { + data = utf8.decode(data, { strict: false }); + } catch (e) { + return false; + } + return data; +} + +/** + * Decodes a packet encoded in a base64 string. + * + * @param {String} base64 encoded message + * @return {Object} with `type` and `data` (if any) + */ + +exports.decodeBase64Packet = function(msg, binaryType) { + var type = packetslist[msg.charAt(0)]; + var data = Buffer.from(msg.substr(1), 'base64'); + if (binaryType === 'arraybuffer') { + var abv = new Uint8Array(data.length); + for (var i = 0; i < abv.length; i++){ + abv[i] = data[i]; + } + data = abv.buffer; + } + return { type: type, data: data }; +}; + +/** + * Encodes multiple messages (payload). + * + * :data + * + * Example: + * + * 11:hello world2:hi + * + * If any contents are binary, they will be encoded as base64 strings. Base64 + * encoded strings are marked with a b before the length specifier + * + * @param {Array} packets + * @api private + */ + +exports.encodePayload = function (packets, supportsBinary, callback) { + if (typeof supportsBinary === 'function') { + callback = supportsBinary; + supportsBinary = null; + } + + if (supportsBinary && hasBinary(packets)) { + return exports.encodePayloadAsBinary(packets, callback); + } + + if (!packets.length) { + return callback('0:'); + } + + function encodeOne(packet, doneCallback) { + exports.encodePacket(packet, supportsBinary, false, function(message) { + doneCallback(null, setLengthHeader(message)); + }); + } + + map(packets, encodeOne, function(err, results) { + return callback(results.join('')); + }); +}; + +function setLengthHeader(message) { + return message.length + ':' + message; +} + +/** + * Async array map using after + */ + +function map(ary, each, done) { + const results = new Array(ary.length); + let count = 0; + + for (let i = 0; i < ary.length; i++) { + each(ary[i], (error, msg) => { + results[i] = msg; + if (++count === ary.length) { + done(null, results); + } + }); + } +} + +/* + * Decodes data when a payload is maybe expected. Possible binary contents are + * decoded from their base64 representation + * + * @param {String} data, callback method + * @api public + */ + +exports.decodePayload = function (data, binaryType, callback) { + if (typeof data !== 'string') { + return exports.decodePayloadAsBinary(data, binaryType, callback); + } + + if (typeof binaryType === 'function') { + callback = binaryType; + binaryType = null; + } + + if (data === '') { + // parser error - ignoring payload + return callback(err, 0, 1); + } + + var length = '', n, msg, packet; + + for (var i = 0, l = data.length; i < l; i++) { + var chr = data.charAt(i); + + if (chr !== ':') { + length += chr; + continue; + } + + if (length === '' || (length != (n = Number(length)))) { + // parser error - ignoring payload + return callback(err, 0, 1); + } + + msg = data.substr(i + 1, n); + + if (length != msg.length) { + // parser error - ignoring payload + return callback(err, 0, 1); + } + + if (msg.length) { + packet = exports.decodePacket(msg, binaryType, false); + + if (err.type === packet.type && err.data === packet.data) { + // parser error in individual packet - ignoring payload + return callback(err, 0, 1); + } + + var more = callback(packet, i + n, l); + if (false === more) return; + } + + // advance cursor + i += n; + length = ''; + } + + if (length !== '') { + // parser error - ignoring payload + return callback(err, 0, 1); + } + +}; + +/** + * + * Converts a buffer to a utf8.js encoded string + * + * @api private + */ + +function bufferToString(buffer) { + var str = ''; + for (var i = 0, l = buffer.length; i < l; i++) { + str += String.fromCharCode(buffer[i]); + } + return str; +} + +/** + * + * Converts a utf8.js encoded string to a buffer + * + * @api private + */ + +function stringToBuffer(string) { + var buf = Buffer.allocUnsafe(string.length); + for (var i = 0, l = string.length; i < l; i++) { + buf.writeUInt8(string.charCodeAt(i), i); + } + return buf; +} + +/** + * + * Converts an ArrayBuffer to a Buffer + * + * @api private + */ + +function arrayBufferToBuffer(data) { + // data is either an ArrayBuffer or ArrayBufferView. + var length = data.byteLength || data.length; + var offset = data.byteOffset || 0; + + return Buffer.from(data.buffer || data, offset, length); +} + +/** + * Encodes multiple messages (payload) as binary. + * + * <1 = binary, 0 = string>[...] + * + * Example: + * 1 3 255 1 2 3, if the binary contents are interpreted as 8 bit integers + * + * @param {Array} packets + * @return {Buffer} encoded payload + * @api private + */ + +exports.encodePayloadAsBinary = function (packets, callback) { + if (!packets.length) { + return callback(EMPTY_BUFFER); + } + + map(packets, encodeOneBinaryPacket, function(err, results) { + return callback(Buffer.concat(results)); + }); +}; + +function encodeOneBinaryPacket(p, doneCallback) { + + function onBinaryPacketEncode(packet) { + + var encodingLength = '' + packet.length; + var sizeBuffer; + + if (typeof packet === 'string') { + sizeBuffer = Buffer.allocUnsafe(encodingLength.length + 2); + sizeBuffer[0] = 0; // is a string (not true binary = 0) + for (var i = 0; i < encodingLength.length; i++) { + sizeBuffer[i + 1] = parseInt(encodingLength[i], 10); + } + sizeBuffer[sizeBuffer.length - 1] = 255; + return doneCallback(null, Buffer.concat([sizeBuffer, stringToBuffer(packet)])); + } + + sizeBuffer = Buffer.allocUnsafe(encodingLength.length + 2); + sizeBuffer[0] = 1; // is binary (true binary = 1) + for (var i = 0; i < encodingLength.length; i++) { + sizeBuffer[i + 1] = parseInt(encodingLength[i], 10); + } + sizeBuffer[sizeBuffer.length - 1] = 255; + + doneCallback(null, Buffer.concat([sizeBuffer, packet])); + } + + exports.encodePacket(p, true, true, onBinaryPacketEncode); + +} + + +/* + * Decodes data when a payload is maybe expected. Strings are decoded by + * interpreting each byte as a key code for entries marked to start with 0. See + * description of encodePayloadAsBinary + + * @param {Buffer} data, callback method + * @api public + */ + +exports.decodePayloadAsBinary = function (data, binaryType, callback) { + if (typeof binaryType === 'function') { + callback = binaryType; + binaryType = null; + } + + var bufferTail = data; + var buffers = []; + var i; + + while (bufferTail.length > 0) { + var strLen = ''; + var isString = bufferTail[0] === 0; + for (i = 1; ; i++) { + if (bufferTail[i] === 255) break; + // 310 = char length of Number.MAX_VALUE + if (strLen.length > 310) { + return callback(err, 0, 1); + } + strLen += '' + bufferTail[i]; + } + bufferTail = bufferTail.slice(strLen.length + 1); + + var msgLength = parseInt(strLen, 10); + + var msg = bufferTail.slice(1, msgLength + 1); + if (isString) msg = bufferToString(msg); + buffers.push(msg); + bufferTail = bufferTail.slice(msgLength + 1); + } + + var total = buffers.length; + for (i = 0; i < total; i++) { + var buffer = buffers[i]; + callback(exports.decodePacket(buffer, binaryType, true), i, total); + } +}; diff --git a/lib/parser-v3/utf8.js b/lib/parser-v3/utf8.js new file mode 100644 index 000000000..b878740ff --- /dev/null +++ b/lib/parser-v3/utf8.js @@ -0,0 +1,210 @@ +/*! https://mths.be/utf8js v2.1.2 by @mathias */ + +var stringFromCharCode = String.fromCharCode; + +// Taken from https://mths.be/punycode +function ucs2decode(string) { + var output = []; + var counter = 0; + var length = string.length; + var value; + var extra; + while (counter < length) { + value = string.charCodeAt(counter++); + if (value >= 0xD800 && value <= 0xDBFF && counter < length) { + // high surrogate, and there is a next character + extra = string.charCodeAt(counter++); + if ((extra & 0xFC00) == 0xDC00) { // low surrogate + output.push(((value & 0x3FF) << 10) + (extra & 0x3FF) + 0x10000); + } else { + // unmatched surrogate; only append this code unit, in case the next + // code unit is the high surrogate of a surrogate pair + output.push(value); + counter--; + } + } else { + output.push(value); + } + } + return output; +} + +// Taken from https://mths.be/punycode +function ucs2encode(array) { + var length = array.length; + var index = -1; + var value; + var output = ''; + while (++index < length) { + value = array[index]; + if (value > 0xFFFF) { + value -= 0x10000; + output += stringFromCharCode(value >>> 10 & 0x3FF | 0xD800); + value = 0xDC00 | value & 0x3FF; + } + output += stringFromCharCode(value); + } + return output; +} + +function checkScalarValue(codePoint, strict) { + if (codePoint >= 0xD800 && codePoint <= 0xDFFF) { + if (strict) { + throw Error( + 'Lone surrogate U+' + codePoint.toString(16).toUpperCase() + + ' is not a scalar value' + ); + } + return false; + } + return true; +} +/*--------------------------------------------------------------------------*/ + +function createByte(codePoint, shift) { + return stringFromCharCode(((codePoint >> shift) & 0x3F) | 0x80); +} + +function encodeCodePoint(codePoint, strict) { + if ((codePoint & 0xFFFFFF80) == 0) { // 1-byte sequence + return stringFromCharCode(codePoint); + } + var symbol = ''; + if ((codePoint & 0xFFFFF800) == 0) { // 2-byte sequence + symbol = stringFromCharCode(((codePoint >> 6) & 0x1F) | 0xC0); + } + else if ((codePoint & 0xFFFF0000) == 0) { // 3-byte sequence + if (!checkScalarValue(codePoint, strict)) { + codePoint = 0xFFFD; + } + symbol = stringFromCharCode(((codePoint >> 12) & 0x0F) | 0xE0); + symbol += createByte(codePoint, 6); + } + else if ((codePoint & 0xFFE00000) == 0) { // 4-byte sequence + symbol = stringFromCharCode(((codePoint >> 18) & 0x07) | 0xF0); + symbol += createByte(codePoint, 12); + symbol += createByte(codePoint, 6); + } + symbol += stringFromCharCode((codePoint & 0x3F) | 0x80); + return symbol; +} + +function utf8encode(string, opts) { + opts = opts || {}; + var strict = false !== opts.strict; + + var codePoints = ucs2decode(string); + var length = codePoints.length; + var index = -1; + var codePoint; + var byteString = ''; + while (++index < length) { + codePoint = codePoints[index]; + byteString += encodeCodePoint(codePoint, strict); + } + return byteString; +} + +/*--------------------------------------------------------------------------*/ + +function readContinuationByte() { + if (byteIndex >= byteCount) { + throw Error('Invalid byte index'); + } + + var continuationByte = byteArray[byteIndex] & 0xFF; + byteIndex++; + + if ((continuationByte & 0xC0) == 0x80) { + return continuationByte & 0x3F; + } + + // If we end up here, it’s not a continuation byte + throw Error('Invalid continuation byte'); +} + +function decodeSymbol(strict) { + var byte1; + var byte2; + var byte3; + var byte4; + var codePoint; + + if (byteIndex > byteCount) { + throw Error('Invalid byte index'); + } + + if (byteIndex == byteCount) { + return false; + } + + // Read first byte + byte1 = byteArray[byteIndex] & 0xFF; + byteIndex++; + + // 1-byte sequence (no continuation bytes) + if ((byte1 & 0x80) == 0) { + return byte1; + } + + // 2-byte sequence + if ((byte1 & 0xE0) == 0xC0) { + byte2 = readContinuationByte(); + codePoint = ((byte1 & 0x1F) << 6) | byte2; + if (codePoint >= 0x80) { + return codePoint; + } else { + throw Error('Invalid continuation byte'); + } + } + + // 3-byte sequence (may include unpaired surrogates) + if ((byte1 & 0xF0) == 0xE0) { + byte2 = readContinuationByte(); + byte3 = readContinuationByte(); + codePoint = ((byte1 & 0x0F) << 12) | (byte2 << 6) | byte3; + if (codePoint >= 0x0800) { + return checkScalarValue(codePoint, strict) ? codePoint : 0xFFFD; + } else { + throw Error('Invalid continuation byte'); + } + } + + // 4-byte sequence + if ((byte1 & 0xF8) == 0xF0) { + byte2 = readContinuationByte(); + byte3 = readContinuationByte(); + byte4 = readContinuationByte(); + codePoint = ((byte1 & 0x07) << 0x12) | (byte2 << 0x0C) | + (byte3 << 0x06) | byte4; + if (codePoint >= 0x010000 && codePoint <= 0x10FFFF) { + return codePoint; + } + } + + throw Error('Invalid UTF-8 detected'); +} + +var byteArray; +var byteCount; +var byteIndex; +function utf8decode(byteString, opts) { + opts = opts || {}; + var strict = false !== opts.strict; + + byteArray = ucs2decode(byteString); + byteCount = byteArray.length; + byteIndex = 0; + var codePoints = []; + var tmp; + while ((tmp = decodeSymbol(strict)) !== false) { + codePoints.push(tmp); + } + return ucs2encode(codePoints); +} + +module.exports = { + version: '2.1.2', + encode: utf8encode, + decode: utf8decode +}; diff --git a/lib/server.js b/lib/server.js index 6033b0b04..a7007357c 100644 --- a/lib/server.js +++ b/lib/server.js @@ -32,7 +32,8 @@ class Server extends EventEmitter { httpCompression: { threshold: 1024 }, - cors: false + cors: false, + allowEIO3: false }, opts ); @@ -228,6 +229,17 @@ class Server extends EventEmitter { * @api private */ async handshake(transportName, req) { + const protocol = req._query.EIO === "3" ? 3 : 4; // 4th revision by default + if (protocol === 3 && !this.opts.allowEIO3) { + debug("unsupported protocol version"); + sendErrorMessage( + req, + req.res, + Server.errors.UNSUPPORTED_PROTOCOL_VERSION + ); + return; + } + let id; try { id = await this.generateId(req); @@ -258,7 +270,7 @@ class Server extends EventEmitter { sendErrorMessage(req, req.res, Server.errors.BAD_REQUEST); return; } - const socket = new Socket(id, this, transport, req); + const socket = new Socket(id, this, transport, req, protocol); const self = this; if (this.opts.cookie) { @@ -442,7 +454,8 @@ Server.errors = { UNKNOWN_SID: 1, BAD_HANDSHAKE_METHOD: 2, BAD_REQUEST: 3, - FORBIDDEN: 4 + FORBIDDEN: 4, + UNSUPPORTED_PROTOCOL_VERSION: 5 }; Server.errorMessages = { @@ -450,7 +463,8 @@ Server.errorMessages = { 1: "Session ID unknown", 2: "Bad handshake method", 3: "Bad request", - 4: "Forbidden" + 4: "Forbidden", + 5: "Unsupported protocol version" }; /** diff --git a/lib/socket.js b/lib/socket.js index f9236cdde..debf1a6a4 100644 --- a/lib/socket.js +++ b/lib/socket.js @@ -7,7 +7,7 @@ class Socket extends EventEmitter { * * @api private */ - constructor(id, server, transport, req) { + constructor(id, server, transport, req, protocol) { super(); this.id = id; this.server = server; @@ -19,6 +19,7 @@ class Socket extends EventEmitter { this.sentCallbackFn = []; this.cleanupFn = []; this.request = req; + this.protocol = protocol; // Cache IP since it might not be in the req later if (req.websocket && req.websocket._socket) { @@ -61,7 +62,16 @@ class Socket extends EventEmitter { } this.emit("open"); - this.schedulePing(); + + if (this.protocol === 3) { + // in protocol v3, the client sends a ping, and the server answers with a pong + this.resetPingTimeout( + this.server.opts.pingInterval + this.server.opts.pingTimeout + ); + } else { + // in protocol v4, the server sends a ping, and the client answers with a pong + this.schedulePing(); + } } /** @@ -83,7 +93,21 @@ class Socket extends EventEmitter { ); switch (packet.type) { + case "ping": + if (this.transport.protocol !== 3) { + this.onError("invalid heartbeat direction"); + return; + } + debug("got ping"); + this.sendPacket("pong"); + this.emit("heartbeat"); + break; + case "pong": + if (this.transport.protocol === 3) { + this.onError("invalid heartbeat direction"); + return; + } debug("got pong"); this.schedulePing(); this.emit("heartbeat"); diff --git a/lib/transport.js b/lib/transport.js index f45c5911b..7fb2603a5 100644 --- a/lib/transport.js +++ b/lib/transport.js @@ -1,5 +1,6 @@ const EventEmitter = require("events"); -const parser = require("engine.io-parser"); +const parser_v4 = require("engine.io-parser"); +const parser_v3 = require("./parser-v3/index"); const debug = require("debug")("engine:transport"); /** @@ -21,6 +22,8 @@ class Transport extends EventEmitter { super(); this.readyState = "open"; this.discarded = false; + this.protocol = req._query.EIO === "3" ? 3 : 4; // 4th revision by default + this.parser = this.protocol === 3 ? parser_v3 : parser_v4; } /** @@ -90,7 +93,7 @@ class Transport extends EventEmitter { * @api private */ onData(data) { - this.onPacket(parser.decodePacket(data)); + this.onPacket(this.parser.decodePacket(data)); } /** diff --git a/lib/transports/polling.js b/lib/transports/polling.js index c49583ab3..e84f2773a 100644 --- a/lib/transports/polling.js +++ b/lib/transports/polling.js @@ -1,5 +1,4 @@ const Transport = require("../transport"); -const parser = require("engine.io-parser"); const zlib = require("zlib"); const accepts = require("accepts"); const debug = require("debug")("engine:polling"); @@ -109,10 +108,16 @@ class Polling extends Transport { return; } + const isBinary = "application/octet-stream" === req.headers["content-type"]; + + if (isBinary && this.protocol === 4) { + return this.onError("invalid content"); + } + this.dataReq = req; this.dataRes = res; - let chunks = ""; + let chunks = isBinary ? Buffer.concat([]) : ""; const self = this; function cleanup() { @@ -129,11 +134,16 @@ class Polling extends Transport { function onData(data) { let contentLength; - chunks += data; - contentLength = Buffer.byteLength(chunks); + if (isBinary) { + chunks = Buffer.concat([chunks, data]); + contentLength = chunks.length; + } else { + chunks += data; + contentLength = Buffer.byteLength(chunks); + } if (contentLength > self.maxHttpBufferSize) { - chunks = ""; + chunks = isBinary ? Buffer.concat([]) : ""; req.connection.destroy(); } } @@ -154,7 +164,7 @@ class Polling extends Transport { } req.on("close", onClose); - req.setEncoding("utf8"); + if (!isBinary) req.setEncoding("utf8"); req.on("data", onData); req.on("end", onEnd); } @@ -178,7 +188,11 @@ class Polling extends Transport { self.onPacket(packet); }; - parser.decodePayload(data).forEach(callback); + if (this.protocol === 3) { + this.parser.decodePayload(data, callback); + } else { + this.parser.decodePayload(data).forEach(callback); + } } /** @@ -210,13 +224,18 @@ class Polling extends Transport { this.shouldClose = null; } - const self = this; - parser.encodePayload(packets, data => { - const compress = packets.some(function(packet) { + const doWrite = data => { + const compress = packets.some(packet => { return packet.options && packet.options.compress; }); - self.write(data, { compress: compress }); - }); + this.write(data, { compress }); + }; + + if (this.protocol === 3) { + this.parser.encodePayload(packets, this.supportsBinary, doWrite); + } else { + this.parser.encodePayload(packets, doWrite); + } } /** diff --git a/lib/transports/websocket.js b/lib/transports/websocket.js index 7f0f0cb0d..6cd86c546 100644 --- a/lib/transports/websocket.js +++ b/lib/transports/websocket.js @@ -1,5 +1,4 @@ const Transport = require("../transport"); -const parser = require("engine.io-parser"); const debug = require("debug")("engine:ws"); class WebSocket extends Transport { @@ -71,7 +70,7 @@ class WebSocket extends Transport { for (var i = 0; i < packets.length; i++) { var packet = packets[i]; - parser.encodePacket(packet, self.supportsBinary, send); + this.parser.encodePacket(packet, self.supportsBinary, send); } function send(data) { diff --git a/package-lock.json b/package-lock.json index fcb60db66..a1c9e6ff7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "engine.io", - "version": "4.0.6", + "version": "4.1.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -150,6 +150,12 @@ } } }, + "after": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/after/-/after-0.8.2.tgz", + "integrity": "sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=", + "dev": true + }, "ajv": { "version": "5.5.2", "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz", @@ -198,6 +204,12 @@ "sprintf-js": "~1.0.2" } }, + "arraybuffer.slice": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz", + "integrity": "sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==", + "dev": true + }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -288,6 +300,12 @@ "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==" }, + "blob": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/blob/-/blob-0.0.5.tgz", + "integrity": "sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==", + "dev": true + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -405,6 +423,12 @@ "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", "dev": true }, + "component-inherit": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/component-inherit/-/component-inherit-0.0.3.tgz", + "integrity": "sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM=", + "dev": true + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -502,9 +526,9 @@ "dev": true }, "engine.io-client": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-4.0.6.tgz", - "integrity": "sha512-5lPh8rrhxIruo5ZlgFt31KM626o5OCXrCHBweieWWuVicDtnYdz/iR93k6N9k0Xs61WrYxZKIWXzeSaJF6fpNA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-4.1.0.tgz", + "integrity": "sha512-OUmn4m71/lW3ixICv4h3DuBRuh3ri0w3cDuepjsrINSbbqbni4Xw1shTFiKhl0v58lEtNpwJTpSKJJ3fondu5Q==", "dev": true, "requires": { "base64-arraybuffer": "0.1.4", @@ -530,6 +554,55 @@ } } }, + "engine.io-client-v3": { + "version": "npm:engine.io-client@3.5.0", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-3.5.0.tgz", + "integrity": "sha512-12wPRfMrugVw/DNyJk34GQ5vIVArEcVMXWugQGGuw2XxUSztFNmJggZmv8IZlLyEdnpO1QB9LkcjeWewO2vxtA==", + "dev": true, + "requires": { + "component-emitter": "~1.3.0", + "component-inherit": "0.0.3", + "debug": "~3.1.0", + "engine.io-parser": "~2.2.0", + "has-cors": "1.1.0", + "indexof": "0.0.1", + "parseqs": "0.0.6", + "parseuri": "0.0.6", + "ws": "~7.4.2", + "xmlhttprequest-ssl": "~1.5.4", + "yeast": "0.1.2" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "engine.io-parser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-2.2.1.tgz", + "integrity": "sha512-x+dN/fBH8Ro8TFwJ+rkB2AmuVw9Yu2mockR/p3W8f8YtExwFgDvBDi0GWyb4ZLkpahtDGZgtr3zLovanJghPqg==", + "dev": true, + "requires": { + "after": "0.8.2", + "arraybuffer.slice": "~0.0.7", + "base64-arraybuffer": "0.1.4", + "blob": "0.0.5", + "has-binary2": "~1.0.2" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, "engine.io-parser": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-4.0.0.tgz", @@ -833,6 +906,23 @@ "ansi-regex": "^2.0.0" } }, + "has-binary2": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-binary2/-/has-binary2-1.0.3.tgz", + "integrity": "sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==", + "dev": true, + "requires": { + "isarray": "2.0.1" + }, + "dependencies": { + "isarray": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.1.tgz", + "integrity": "sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=", + "dev": true + } + } + }, "has-cors": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-cors/-/has-cors-1.1.0.tgz", @@ -872,6 +962,12 @@ "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", "dev": true }, + "indexof": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz", + "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=", + "dev": true + }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", diff --git a/package.json b/package.json index 4113505b7..39ce5b244 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "engine.io", - "version": "4.0.6", + "version": "4.1.0", "description": "The realtime engine behind Socket.IO. Provides the foundation of a bidirectional connection between client and server", "main": "lib/engine.io.js", "author": "Guillermo Rauch ", @@ -36,7 +36,8 @@ "devDependencies": { "babel-eslint": "^8.0.2", "eiows": "^3.3.0", - "engine.io-client": "4.0.6", + "engine.io-client": "4.1.0", + "engine.io-client-v3": "npm:engine.io-client@3.5.0", "eslint": "^4.19.1", "eslint-config-prettier": "^6.9.0", "expect.js": "^0.3.1", @@ -47,7 +48,7 @@ }, "scripts": { "lint": "eslint lib/ test/ *.js", - "test": "npm run lint && npm run format:check && mocha && EIO_WS_ENGINE=eiows mocha", + "test": "npm run lint && npm run format:check && mocha && EIO_CLIENT=3 mocha && EIO_WS_ENGINE=eiows mocha", "format:check": "prettier --check 'lib/**/*.js' 'test/**/*.js'", "format:fix": "prettier --write 'lib/**/*.js' 'test/**/*.js'" }, diff --git a/test/common.js b/test/common.js index e55222934..b8d0e91c3 100644 --- a/test/common.js +++ b/test/common.js @@ -1,4 +1,8 @@ const eio = require(".."); +const eioc = + process.env.EIO_CLIENT === "3" + ? require("engine.io-client-v3") + : require("engine.io-client"); /** * Listen shortcut that fires a callback on an ephemeral port. @@ -10,6 +14,8 @@ exports.listen = (opts, fn) => { opts = {}; } + opts.allowEIO3 = true; + const e = eio.listen(0, opts, () => { fn(e.httpServer.address().port); }); @@ -17,6 +23,8 @@ exports.listen = (opts, fn) => { return e; }; +exports.eioc = eioc; + /** * Sprintf util. */ diff --git a/test/fixtures/server-close-upgraded.js b/test/fixtures/server-close-upgraded.js index fafb32b4e..46d8983e1 100644 --- a/test/fixtures/server-close-upgraded.js +++ b/test/fixtures/server-close-upgraded.js @@ -1,4 +1,4 @@ -const eioc = require("engine.io-client"); +const eioc = require("../common").eioc; const listen = require("../common").listen; const engine = listen(port => { diff --git a/test/fixtures/server-close-upgrading.js b/test/fixtures/server-close-upgrading.js index 4fe2917fd..7938658e1 100644 --- a/test/fixtures/server-close-upgrading.js +++ b/test/fixtures/server-close-upgrading.js @@ -1,4 +1,4 @@ -const eioc = require("engine.io-client"); +const eioc = require("../common").eioc; const listen = require("../common").listen; const engine = listen(port => { diff --git a/test/fixtures/server-close.js b/test/fixtures/server-close.js index 6d8b4c0c1..532ee92c3 100644 --- a/test/fixtures/server-close.js +++ b/test/fixtures/server-close.js @@ -1,4 +1,4 @@ -const eioc = require("engine.io-client"); +const eioc = require("../common").eioc; const listen = require("../common").listen; const engine = listen(port => { diff --git a/test/jsonp.js b/test/jsonp.js index ecbb8630e..d1edf4284 100644 --- a/test/jsonp.js +++ b/test/jsonp.js @@ -1,4 +1,4 @@ -const eioc = require("engine.io-client"); +const eioc = require("./common").eioc; const listen = require("./common").listen; const expect = require("expect.js"); const request = require("superagent"); diff --git a/test/server.js b/test/server.js index a01c0564f..e58d09e83 100644 --- a/test/server.js +++ b/test/server.js @@ -7,7 +7,7 @@ const path = require("path"); const exec = require("child_process").exec; const zlib = require("zlib"); const eio = require(".."); -const eioc = require("engine.io-client"); +const eioc = require("./common").eioc; const listen = require("./common").listen; const expect = require("expect.js"); const request = require("superagent"); @@ -487,6 +487,26 @@ describe("server", () => { ); }); + it("should disallow unsupported protocol versions", done => { + const httpServer = http.createServer(); + const engine = eio({ allowEIO3: false }); + engine.attach(httpServer); + httpServer.listen(() => { + const port = httpServer.address().port; + request + .get("http://localhost:%d/engine.io/".s(port)) + .query({ transport: "polling", EIO: 3 }) + .end((err, res) => { + expect(err).to.be.an(Error); + expect(res.status).to.be(400); + expect(res.body.code).to.be(5); + expect(res.body.message).to.be("Unsupported protocol version"); + engine.close(); + done(); + }); + }); + }); + it("should send a packet along with the handshake", done => { listen({ initialPacket: "faster!" }, port => { const socket = new eioc.Socket("ws://localhost:%d".s(port)); @@ -997,43 +1017,83 @@ describe("server", () => { } ); - it( - "should trigger with connection `ping timeout` " + - "after `pingInterval + pingTimeout`", - done => { - const opts = { - allowUpgrades: false, - pingInterval: 300, - pingTimeout: 100 - }; - const engine = listen(opts, port => { - const socket = new eioc.Socket("ws://localhost:%d".s(port)); - let clientCloseReason = null; + if (process.env.EIO_CLIENT === "3") { + it( + "should trigger with connection `ping timeout` " + + "after `pingInterval + pingTimeout`", + done => { + const opts = { + allowUpgrades: false, + pingInterval: 300, + pingTimeout: 100 + }; + const engine = listen(opts, port => { + const socket = new eioc.Socket("ws://localhost:%d".s(port)); + let clientCloseReason = null; - socket.on("open", () => { - socket.on("close", reason => { - clientCloseReason = reason; + socket.on("open", () => { + socket.on("close", reason => { + clientCloseReason = reason; + }); + }); + + engine.on("connection", conn => { + conn.once("heartbeat", () => { + setTimeout(() => { + socket.onPacket = () => {}; + expect(clientCloseReason).to.be(null); + }, 150); + setTimeout(() => { + expect(clientCloseReason).to.be(null); + }, 350); + setTimeout(() => { + expect(clientCloseReason).to.be("ping timeout"); + done(); + }, 500); + }); }); }); + } + ); + } else { + it( + "should trigger with connection `ping timeout` " + + "after `pingInterval + pingTimeout`", + done => { + const opts = { + allowUpgrades: false, + pingInterval: 300, + pingTimeout: 100 + }; + const engine = listen(opts, port => { + const socket = new eioc.Socket("ws://localhost:%d".s(port)); + let clientCloseReason = null; - engine.on("connection", conn => { - conn.once("heartbeat", () => { - socket.onPacket = () => {}; - setTimeout(() => { - expect(clientCloseReason).to.be(null); - }, 150); - setTimeout(() => { - expect(clientCloseReason).to.be(null); - }, 350); - setTimeout(() => { - expect(clientCloseReason).to.be("ping timeout"); - done(); - }, 500); + socket.on("open", () => { + socket.on("close", reason => { + clientCloseReason = reason; + }); + }); + + engine.on("connection", conn => { + conn.once("heartbeat", () => { + socket.onPacket = () => {}; + setTimeout(() => { + expect(clientCloseReason).to.be(null); + }, 150); + setTimeout(() => { + expect(clientCloseReason).to.be(null); + }, 350); + setTimeout(() => { + expect(clientCloseReason).to.be("ping timeout"); + done(); + }, 500); + }); }); }); - }); - } - ); + } + ); + } it( "should abort the polling data request if it is " + "in progress", @@ -1796,7 +1856,11 @@ describe("server", () => { res.end("hello world\n"); }); - const engine = eio({ transports: ["polling"], allowUpgrades: false }); + const engine = eio({ + transports: ["polling"], + allowUpgrades: false, + allowEIO3: true + }); engine.attach(srv); srv.listen(() => { const port = srv.address().port; @@ -1834,7 +1898,11 @@ describe("server", () => { res.end("hello world\n"); }); - const engine = eio({ transports: ["polling"], allowUpgrades: false }); + const engine = eio({ + transports: ["polling"], + allowUpgrades: false, + allowEIO3: true + }); engine.attach(srv); srv.listen(() => { const port = srv.address().port; @@ -1874,7 +1942,11 @@ describe("server", () => { res.end("hello world\n"); }); - const engine = eio({ transports: ["websocket"], allowUpgrades: false }); + const engine = eio({ + transports: ["websocket"], + allowUpgrades: false, + allowEIO3: true + }); engine.attach(srv); srv.listen(() => { const port = srv.address().port; @@ -1913,7 +1985,11 @@ describe("server", () => { res.end("hello world\n"); }); - const engine = eio({ transports: ["polling"], allowUpgrades: false }); + const engine = eio({ + transports: ["polling"], + allowUpgrades: false, + allowEIO3: true + }); engine.attach(srv); srv.listen(() => { const port = srv.address().port; @@ -1952,7 +2028,11 @@ describe("server", () => { res.end("hello world\n"); }); - const engine = eio({ transports: ["websocket"], allowUpgrades: false }); + const engine = eio({ + transports: ["websocket"], + allowUpgrades: false, + allowEIO3: true + }); engine.attach(srv); srv.listen(() => { const port = srv.address().port; @@ -2409,7 +2489,11 @@ describe("server", () => { engine.on("connection", conn => { conn.on("packet", packet => { conn.close(); - expect(packet.type).to.be("pong"); + if (process.env.EIO_CLIENT === "3") { + expect(packet.type).to.be("ping"); + } else { + expect(packet.type).to.be("pong"); + } done(); }); }); @@ -2438,7 +2522,11 @@ describe("server", () => { engine.on("connection", conn => { conn.on("packetCreate", packet => { conn.close(); - expect(packet.type).to.be("ping"); + if (process.env.EIO_CLIENT === "3") { + expect(packet.type).to.be("pong"); + } else { + expect(packet.type).to.be("ping"); + } done(); }); });