diff --git a/packages/polyfill/src/buffer.ts b/packages/polyfill/src/buffer.ts new file mode 100644 index 00000000..de971d4e --- /dev/null +++ b/packages/polyfill/src/buffer.ts @@ -0,0 +1,2134 @@ +/*! + * The buffer module from node.js, for the browser. + * + * @author Feross Aboukhadijeh + * @license MIT + */ +/* eslint-disable no-proto */ + +'use strict' + +const base64 = require('base64-js') +const ieee754 = require('ieee754') +const customInspectSymbol = + (typeof Symbol === 'function' && typeof Symbol['for'] === 'function') // eslint-disable-line dot-notation + ? Symbol['for']('nodejs.util.inspect.custom') // eslint-disable-line dot-notation + : null + +exports.Buffer = Buffer +exports.SlowBuffer = SlowBuffer +exports.INSPECT_MAX_BYTES = 50 + +const K_MAX_LENGTH = 0x7fffffff +exports.kMaxLength = K_MAX_LENGTH + +/** + * If `Buffer.TYPED_ARRAY_SUPPORT`: + * === true Use Uint8Array implementation (fastest) + * === false Print warning and recommend using `buffer` v4.x which has an Object + * implementation (most compatible, even IE6) + * + * Browsers that support typed arrays are IE 10+, Firefox 4+, Chrome 7+, Safari 5.1+, + * Opera 11.6+, iOS 4.2+. + * + * We report that the browser does not support typed arrays if the are not subclassable + * using __proto__. Firefox 4-29 lacks support for adding new properties to `Uint8Array` + * (See: https://bugzilla.mozilla.org/show_bug.cgi?id=695438). IE 10 lacks support + * for __proto__ and has a buggy typed array implementation. + */ +Buffer.TYPED_ARRAY_SUPPORT = typedArraySupport() + +if (!Buffer.TYPED_ARRAY_SUPPORT && typeof console !== 'undefined' && + typeof console.error === 'function') { + console.error( + 'This browser lacks typed array (Uint8Array) support which is required by ' + + '`buffer` v5.x. Use `buffer` v4.x if you require old browser support.' + ) +} + +function typedArraySupport() { + // Can typed array instances can be augmented? + try { + const arr = new Uint8Array(1) + const proto = { foo: function () { return 42 } } + Object.setPrototypeOf(proto, Uint8Array.prototype) + Object.setPrototypeOf(arr, proto) + // @ts-ignore + return arr.foo() === 42 + } catch (e) { + return false + } +} + +Object.defineProperty(Buffer.prototype, 'parent', { + enumerable: true, + get: function () { + if (!Buffer.isBuffer(this)) return undefined + return this.buffer + } +}) + +Object.defineProperty(Buffer.prototype, 'offset', { + enumerable: true, + get: function () { + if (!Buffer.isBuffer(this)) return undefined + return this.byteOffset + } +}) + +function createBuffer(length) { + if (length > K_MAX_LENGTH) { + throw new RangeError('The value "' + length + '" is invalid for option "size"') + } + // Return an augmented `Uint8Array` instance + const buf = new Uint8Array(length) + Object.setPrototypeOf(buf, Buffer.prototype) + return buf +} + +/** + * The Buffer constructor returns instances of `Uint8Array` that have their + * prototype changed to `Buffer.prototype`. Furthermore, `Buffer` is a subclass of + * `Uint8Array`, so the returned instances will have all the node `Buffer` methods + * and the `Uint8Array` methods. Square bracket notation works as expected -- it + * returns a single octet. + * + * The `Uint8Array` prototype remains unmodified. + */ + +function Buffer(arg, encodingOrOffset, length) { + // Common case. + if (typeof arg === 'number') { + if (typeof encodingOrOffset === 'string') { + throw new TypeError( + 'The "string" argument must be of type string. Received type number' + ) + } + return allocUnsafe(arg) + } + return from(arg, encodingOrOffset, length) +} + +Buffer.poolSize = 8192 // not used by this implementation + +function from(value, encodingOrOffset, length) { + if (typeof value === 'string') { + return fromString(value, encodingOrOffset) + } + + if (ArrayBuffer.isView(value)) { + return fromArrayView(value) + } + + if (value == null) { + throw new TypeError( + 'The first argument must be one of type string, Buffer, ArrayBuffer, Array, ' + + 'or Array-like Object. Received type ' + (typeof value) + ) + } + + if (isInstance(value, ArrayBuffer) || + (value && isInstance(value.buffer, ArrayBuffer))) { + return fromArrayBuffer(value, encodingOrOffset, length) + } + + if (typeof SharedArrayBuffer !== 'undefined' && + (isInstance(value, SharedArrayBuffer) || + (value && isInstance(value.buffer, SharedArrayBuffer)))) { + return fromArrayBuffer(value, encodingOrOffset, length) + } + + if (typeof value === 'number') { + throw new TypeError( + 'The "value" argument must not be of type number. Received type number' + ) + } + + const valueOf = value.valueOf && value.valueOf() + if (valueOf != null && valueOf !== value) { + return Buffer.from(valueOf, encodingOrOffset, length) + } + + const b = fromObject(value) + if (b) return b + + if (typeof Symbol !== 'undefined' && Symbol.toPrimitive != null && + typeof value[Symbol.toPrimitive] === 'function') { + return Buffer.from(value[Symbol.toPrimitive]('string'), encodingOrOffset, length) + } + + throw new TypeError( + 'The first argument must be one of type string, Buffer, ArrayBuffer, Array, ' + + 'or Array-like Object. Received type ' + (typeof value) + ) +} + +/** + * Functionally equivalent to Buffer(arg, encoding) but throws a TypeError + * if value is a number. + * Buffer.from(str[, encoding]) + * Buffer.from(array) + * Buffer.from(buffer) + * Buffer.from(arrayBuffer[, byteOffset[, length]]) + **/ +Buffer.from = function (value, encodingOrOffset, length) { + return from(value, encodingOrOffset, length) +} + +// Note: Change prototype *after* Buffer.from is defined to workaround Chrome bug: +// https://github.com/feross/buffer/pull/148 +Object.setPrototypeOf(Buffer.prototype, Uint8Array.prototype) +Object.setPrototypeOf(Buffer, Uint8Array) + +function assertSize(size) { + if (typeof size !== 'number') { + throw new TypeError('"size" argument must be of type number') + } else if (size < 0) { + throw new RangeError('The value "' + size + '" is invalid for option "size"') + } +} + +function alloc(size, fill, encoding) { + assertSize(size) + if (size <= 0) { + return createBuffer(size) + } + if (fill !== undefined) { + // Only pay attention to encoding if it's a string. This + // prevents accidentally sending in a number that would + // be interpreted as a start offset. + return typeof encoding === 'string' + // @ts-ignore + ? createBuffer(size).fill(fill, encoding) + : createBuffer(size).fill(fill) + } + return createBuffer(size) +} + +/** + * Creates a new filled Buffer instance. + * alloc(size[, fill[, encoding]]) + **/ +Buffer.alloc = function (size, fill, encoding) { + return alloc(size, fill, encoding) +} + +function allocUnsafe(size) { + assertSize(size) + return createBuffer(size < 0 ? 0 : checked(size) | 0) +} + +/** + * Equivalent to Buffer(num), by default creates a non-zero-filled Buffer instance. + * */ +Buffer.allocUnsafe = function (size) { + return allocUnsafe(size) +} +/** + * Equivalent to SlowBuffer(num), by default creates a non-zero-filled Buffer instance. + */ +Buffer.allocUnsafeSlow = function (size) { + return allocUnsafe(size) +} + +function fromString(string, encoding) { + if (typeof encoding !== 'string' || encoding === '') { + encoding = 'utf8' + } + + if (!Buffer.isEncoding(encoding)) { + throw new TypeError('Unknown encoding: ' + encoding) + } + + const length = byteLength(string, encoding) | 0 + let buf = createBuffer(length) + + // @ts-ignore + const actual = buf.write(string, encoding) + + if (actual !== length) { + // Writing a hex string, for example, that contains invalid characters will + // cause everything after the first invalid character to be ignored. (e.g. + // 'abxxcd' will be treated as 'ab') + buf = buf.slice(0, actual) + } + + return buf +} + +function fromArrayLike(array) { + const length = array.length < 0 ? 0 : checked(array.length) | 0 + const buf = createBuffer(length) + for (let i = 0; i < length; i += 1) { + buf[i] = array[i] & 255 + } + return buf +} + +function fromArrayView(arrayView) { + if (isInstance(arrayView, Uint8Array)) { + const copy = new Uint8Array(arrayView) + return fromArrayBuffer(copy.buffer, copy.byteOffset, copy.byteLength) + } + return fromArrayLike(arrayView) +} + +function fromArrayBuffer(array, byteOffset, length) { + if (byteOffset < 0 || array.byteLength < byteOffset) { + throw new RangeError('"offset" is outside of buffer bounds') + } + + if (array.byteLength < byteOffset + (length || 0)) { + throw new RangeError('"length" is outside of buffer bounds') + } + + let buf + if (byteOffset === undefined && length === undefined) { + buf = new Uint8Array(array) + } else if (length === undefined) { + buf = new Uint8Array(array, byteOffset) + } else { + buf = new Uint8Array(array, byteOffset, length) + } + + // Return an augmented `Uint8Array` instance + Object.setPrototypeOf(buf, Buffer.prototype) + + return buf +} + +function fromObject(obj) { + if (Buffer.isBuffer(obj)) { + const len = checked(obj.length) | 0 + const buf = createBuffer(len) + + if (buf.length === 0) { + return buf + } + + obj.copy(buf, 0, 0, len) + return buf + } + + if (obj.length !== undefined) { + if (typeof obj.length !== 'number' || numberIsNaN(obj.length)) { + return createBuffer(0) + } + return fromArrayLike(obj) + } + + if (obj.type === 'Buffer' && Array.isArray(obj.data)) { + return fromArrayLike(obj.data) + } +} + +function checked(length) { + // Note: cannot use `length < K_MAX_LENGTH` here because that fails when + // length is NaN (which is otherwise coerced to zero.) + if (length >= K_MAX_LENGTH) { + throw new RangeError('Attempt to allocate Buffer larger than maximum ' + + 'size: 0x' + K_MAX_LENGTH.toString(16) + ' bytes') + } + return length | 0 +} + +function SlowBuffer(length) { + if (+length != length) { // eslint-disable-line eqeqeq + length = 0 + } + // @ts-ignore + return Buffer.alloc(+length) +} + +Buffer.isBuffer = function isBuffer(b) { + return b != null && b._isBuffer === true && + b !== Buffer.prototype // so Buffer.isBuffer(Buffer.prototype) will be false +} + +Buffer.compare = function compare(a, b) { + if (isInstance(a, Uint8Array)) a = Buffer.from(a, a.offset, a.byteLength) + if (isInstance(b, Uint8Array)) b = Buffer.from(b, b.offset, b.byteLength) + if (!Buffer.isBuffer(a) || !Buffer.isBuffer(b)) { + throw new TypeError( + 'The "buf1", "buf2" arguments must be one of type Buffer or Uint8Array' + ) + } + + if (a === b) return 0 + + let x = a.length + let y = b.length + + for (let i = 0, len = Math.min(x, y); i < len; ++i) { + if (a[i] !== b[i]) { + x = a[i] + y = b[i] + break + } + } + + if (x < y) return -1 + if (y < x) return 1 + return 0 +} + +Buffer.isEncoding = function isEncoding(encoding) { + switch (String(encoding).toLowerCase()) { + case 'hex': + case 'utf8': + case 'utf-8': + case 'ascii': + case 'latin1': + case 'binary': + case 'base64': + case 'ucs2': + case 'ucs-2': + case 'utf16le': + case 'utf-16le': + return true + default: + return false + } +} + +Buffer.concat = function concat(list, length) { + if (!Array.isArray(list)) { + throw new TypeError('"list" argument must be an Array of Buffers') + } + + if (list.length === 0) { + // @ts-ignore + return Buffer.alloc(0) + } + + let i + if (length === undefined) { + length = 0 + for (i = 0; i < list.length; ++i) { + length += list[i].length + } + } + + const buffer = Buffer.allocUnsafe(length) + let pos = 0 + for (i = 0; i < list.length; ++i) { + let buf = list[i] + if (isInstance(buf, Uint8Array)) { + if (pos + buf.length > buffer.length) { + if (!Buffer.isBuffer(buf)) { + buf = Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength) + } + buf.copy(buffer, pos) + } else { + Uint8Array.prototype.set.call( + buffer, + buf, + pos + ) + } + } else if (!Buffer.isBuffer(buf)) { + throw new TypeError('"list" argument must be an Array of Buffers') + } else { + buf.copy(buffer, pos) + } + pos += buf.length + } + return buffer +} + +function byteLength(string, encoding) { + if (Buffer.isBuffer(string)) { + return string.length + } + if (ArrayBuffer.isView(string) || isInstance(string, ArrayBuffer)) { + return string.byteLength + } + if (typeof string !== 'string') { + throw new TypeError( + 'The "string" argument must be one of type string, Buffer, or ArrayBuffer. ' + + 'Received type ' + typeof string + ) + } + + const len = string.length + const mustMatch = (arguments.length > 2 && arguments[2] === true) + if (!mustMatch && len === 0) return 0 + + // Use a for loop to avoid recursion + let loweredCase = false + for (; ;) { + switch (encoding) { + case 'ascii': + case 'latin1': + case 'binary': + return len + case 'utf8': + case 'utf-8': + // @ts-ignore + return utf8ToBytes(string).length + case 'ucs2': + case 'ucs-2': + case 'utf16le': + case 'utf-16le': + return len * 2 + case 'hex': + return len >>> 1 + case 'base64': + return base64ToBytes(string).length + default: + if (loweredCase) { + // @ts-ignore + return mustMatch ? -1 : utf8ToBytes(string).length // assume utf8 + } + encoding = ('' + encoding).toLowerCase() + loweredCase = true + } + } +} +Buffer.byteLength = byteLength + +function slowToString(this: any, encoding, start, end) { + let loweredCase = false + + // No need to verify that "this.length <= MAX_UINT32" since it's a read-only + // property of a typed array. + + // This behaves neither like String nor Uint8Array in that we set start/end + // to their upper/lower bounds if the value passed is out of range. + // undefined is handled specially as per ECMA-262 6th Edition, + // Section 13.3.3.7 Runtime Semantics: KeyedBindingInitialization. + if (start === undefined || start < 0) { + start = 0 + } + // Return early if start > this.length. Done here to prevent potential uint32 + // coercion fail below. + // @ts-ignore + if (start > this.length) { + return '' + } + + if (end === undefined || end > this.length) { + end = this.length + } + + if (end <= 0) { + return '' + } + + // Force coercion to uint32. This will also coerce falsey/NaN values to 0. + end >>>= 0 + start >>>= 0 + + if (end <= start) { + return '' + } + + if (!encoding) encoding = 'utf8' + + while (true) { + switch (encoding) { + case 'hex': + return hexSlice(this, start, end) + + case 'utf8': + case 'utf-8': + return utf8Slice(this, start, end) + + case 'ascii': + return asciiSlice(this, start, end) + + case 'latin1': + case 'binary': + return latin1Slice(this, start, end) + + case 'base64': + return base64Slice(this, start, end) + + case 'ucs2': + case 'ucs-2': + case 'utf16le': + case 'utf-16le': + return utf16leSlice(this, start, end) + + default: + if (loweredCase) throw new TypeError('Unknown encoding: ' + encoding) + encoding = (encoding + '').toLowerCase() + loweredCase = true + } + } +} + +// This property is used by `Buffer.isBuffer` (and the `is-buffer` npm package) +// to detect a Buffer instance. It's not possible to use `instanceof Buffer` +// reliably in a browserify context because there could be multiple different +// copies of the 'buffer' package in use. This method works even for Buffer +// instances that were created from another copy of the `buffer` package. +// See: https://github.com/feross/buffer/issues/154 +Buffer.prototype._isBuffer = true + +function swap(b, n, m) { + const i = b[n] + b[n] = b[m] + b[m] = i +} + +Buffer.prototype.swap16 = function swap16() { + const len = this.length + if (len % 2 !== 0) { + throw new RangeError('Buffer size must be a multiple of 16-bits') + } + for (let i = 0; i < len; i += 2) { + swap(this, i, i + 1) + } + return this +} + +Buffer.prototype.swap32 = function swap32() { + const len = this.length + if (len % 4 !== 0) { + throw new RangeError('Buffer size must be a multiple of 32-bits') + } + for (let i = 0; i < len; i += 4) { + swap(this, i, i + 3) + swap(this, i + 1, i + 2) + } + return this +} + +Buffer.prototype.swap64 = function swap64() { + const len = this.length + if (len % 8 !== 0) { + throw new RangeError('Buffer size must be a multiple of 64-bits') + } + for (let i = 0; i < len; i += 8) { + swap(this, i, i + 7) + swap(this, i + 1, i + 6) + swap(this, i + 2, i + 5) + swap(this, i + 3, i + 4) + } + return this +} + +Buffer.prototype.toString = function toString() { + const length = this.length + if (length === 0) return '' + if (arguments.length === 0) return utf8Slice(this, 0, length) + // @ts-ignore + return slowToString.apply(this, arguments) +} + +Buffer.prototype.toLocaleString = Buffer.prototype.toString + +Buffer.prototype.equals = function equals(b) { + if (!Buffer.isBuffer(b)) throw new TypeError('Argument must be a Buffer') + if (this === b) return true + return Buffer.compare(this, b) === 0 +} + +Buffer.prototype.inspect = function inspect() { + let str = '' + const max = exports.INSPECT_MAX_BYTES + str = this.toString('hex', 0, max).replace(/(.{2})/g, '$1 ').trim() + if (this.length > max) str += ' ... ' + return '' +} +if (customInspectSymbol) { + Buffer.prototype[customInspectSymbol] = Buffer.prototype.inspect +} + +Buffer.prototype.compare = function compare(target, start, end, thisStart, thisEnd) { + if (isInstance(target, Uint8Array)) { + target = Buffer.from(target, target.offset, target.byteLength) + } + if (!Buffer.isBuffer(target)) { + throw new TypeError( + 'The "target" argument must be one of type Buffer or Uint8Array. ' + + 'Received type ' + (typeof target) + ) + } + + if (start === undefined) { + start = 0 + } + if (end === undefined) { + end = target ? target.length : 0 + } + if (thisStart === undefined) { + thisStart = 0 + } + if (thisEnd === undefined) { + thisEnd = this.length + } + + if (start < 0 || end > target.length || thisStart < 0 || thisEnd > this.length) { + throw new RangeError('out of range index') + } + + if (thisStart >= thisEnd && start >= end) { + return 0 + } + if (thisStart >= thisEnd) { + return -1 + } + if (start >= end) { + return 1 + } + + start >>>= 0 + end >>>= 0 + thisStart >>>= 0 + thisEnd >>>= 0 + + if (this === target) return 0 + + let x = thisEnd - thisStart + let y = end - start + const len = Math.min(x, y) + + const thisCopy = this.slice(thisStart, thisEnd) + const targetCopy = target.slice(start, end) + + for (let i = 0; i < len; ++i) { + if (thisCopy[i] !== targetCopy[i]) { + x = thisCopy[i] + y = targetCopy[i] + break + } + } + + if (x < y) return -1 + if (y < x) return 1 + return 0 +} + +// Finds either the first index of `val` in `buffer` at offset >= `byteOffset`, +// OR the last index of `val` in `buffer` at offset <= `byteOffset`. +// +// Arguments: +// - buffer - a Buffer to search +// - val - a string, Buffer, or number +// - byteOffset - an index into `buffer`; will be clamped to an int32 +// - encoding - an optional encoding, relevant is val is a string +// - dir - true for indexOf, false for lastIndexOf +function bidirectionalIndexOf(buffer, val, byteOffset, encoding, dir) { + // Empty buffer means no match + if (buffer.length === 0) return -1 + + // Normalize byteOffset + if (typeof byteOffset === 'string') { + encoding = byteOffset + byteOffset = 0 + } else if (byteOffset > 0x7fffffff) { + byteOffset = 0x7fffffff + } else if (byteOffset < -0x80000000) { + byteOffset = -0x80000000 + } + byteOffset = +byteOffset // Coerce to Number. + if (numberIsNaN(byteOffset)) { + // byteOffset: it it's undefined, null, NaN, "foo", etc, search whole buffer + byteOffset = dir ? 0 : (buffer.length - 1) + } + + // Normalize byteOffset: negative offsets start from the end of the buffer + if (byteOffset < 0) byteOffset = buffer.length + byteOffset + if (byteOffset >= buffer.length) { + if (dir) return -1 + else byteOffset = buffer.length - 1 + } else if (byteOffset < 0) { + if (dir) byteOffset = 0 + else return -1 + } + + // Normalize val + if (typeof val === 'string') { + // @ts-ignore + val = Buffer.from(val, encoding) + } + + // Finally, search either indexOf (if dir is true) or lastIndexOf + if (Buffer.isBuffer(val)) { + // Special case: looking for empty string/buffer always fails + if (val.length === 0) { + return -1 + } + return arrayIndexOf(buffer, val, byteOffset, encoding, dir) + } else if (typeof val === 'number') { + val = val & 0xFF // Search for a byte value [0-255] + if (typeof Uint8Array.prototype.indexOf === 'function') { + if (dir) { + return Uint8Array.prototype.indexOf.call(buffer, val, byteOffset) + } else { + return Uint8Array.prototype.lastIndexOf.call(buffer, val, byteOffset) + } + } + return arrayIndexOf(buffer, [val], byteOffset, encoding, dir) + } + + throw new TypeError('val must be string, number or Buffer') +} + +function arrayIndexOf(arr, val, byteOffset, encoding, dir) { + let indexSize = 1 + let arrLength = arr.length + let valLength = val.length + + if (encoding !== undefined) { + encoding = String(encoding).toLowerCase() + if (encoding === 'ucs2' || encoding === 'ucs-2' || + encoding === 'utf16le' || encoding === 'utf-16le') { + if (arr.length < 2 || val.length < 2) { + return -1 + } + indexSize = 2 + arrLength /= 2 + valLength /= 2 + byteOffset /= 2 + } + } + + function read(buf, i) { + if (indexSize === 1) { + return buf[i] + } else { + return buf.readUInt16BE(i * indexSize) + } + } + + let i + if (dir) { + let foundIndex = -1 + for (i = byteOffset; i < arrLength; i++) { + if (read(arr, i) === read(val, foundIndex === -1 ? 0 : i - foundIndex)) { + if (foundIndex === -1) foundIndex = i + if (i - foundIndex + 1 === valLength) return foundIndex * indexSize + } else { + if (foundIndex !== -1) i -= i - foundIndex + foundIndex = -1 + } + } + } else { + if (byteOffset + valLength > arrLength) byteOffset = arrLength - valLength + for (i = byteOffset; i >= 0; i--) { + let found = true + for (let j = 0; j < valLength; j++) { + if (read(arr, i + j) !== read(val, j)) { + found = false + break + } + } + if (found) return i + } + } + + return -1 +} + +Buffer.prototype.includes = function includes(val, byteOffset, encoding) { + return this.indexOf(val, byteOffset, encoding) !== -1 +} + +Buffer.prototype.indexOf = function indexOf(val, byteOffset, encoding) { + return bidirectionalIndexOf(this, val, byteOffset, encoding, true) +} + +Buffer.prototype.lastIndexOf = function lastIndexOf(val, byteOffset, encoding) { + return bidirectionalIndexOf(this, val, byteOffset, encoding, false) +} + +function hexWrite(buf, string, offset, length) { + offset = Number(offset) || 0 + const remaining = buf.length - offset + if (!length) { + length = remaining + } else { + length = Number(length) + if (length > remaining) { + length = remaining + } + } + + const strLen = string.length + + if (length > strLen / 2) { + length = strLen / 2 + } + let i + for (i = 0; i < length; ++i) { + const parsed = parseInt(string.substr(i * 2, 2), 16) + if (numberIsNaN(parsed)) return i + buf[offset + i] = parsed + } + return i +} + +function utf8Write(buf, string, offset, length) { + return blitBuffer(utf8ToBytes(string, buf.length - offset), buf, offset, length) +} + +function asciiWrite(buf, string, offset, length) { + return blitBuffer(asciiToBytes(string), buf, offset, length) +} + +function base64Write(buf, string, offset, length) { + return blitBuffer(base64ToBytes(string), buf, offset, length) +} + +function ucs2Write(buf, string, offset, length) { + return blitBuffer(utf16leToBytes(string, buf.length - offset), buf, offset, length) +} + +Buffer.prototype.write = function write(string, offset, length, encoding) { + // Buffer#write(string) + if (offset === undefined) { + encoding = 'utf8' + length = this.length + offset = 0 + // Buffer#write(string, encoding) + } else if (length === undefined && typeof offset === 'string') { + encoding = offset + length = this.length + offset = 0 + // Buffer#write(string, offset[, length][, encoding]) + } else if (isFinite(offset)) { + offset = offset >>> 0 + if (isFinite(length)) { + length = length >>> 0 + if (encoding === undefined) encoding = 'utf8' + } else { + encoding = length + length = undefined + } + } else { + throw new Error( + 'Buffer.write(string, encoding, offset[, length]) is no longer supported' + ) + } + + const remaining = this.length - offset + if (length === undefined || length > remaining) length = remaining + + if ((string.length > 0 && (length < 0 || offset < 0)) || offset > this.length) { + throw new RangeError('Attempt to write outside buffer bounds') + } + + if (!encoding) encoding = 'utf8' + + let loweredCase = false + for (; ;) { + switch (encoding) { + case 'hex': + return hexWrite(this, string, offset, length) + + case 'utf8': + case 'utf-8': + return utf8Write(this, string, offset, length) + + case 'ascii': + case 'latin1': + case 'binary': + return asciiWrite(this, string, offset, length) + + case 'base64': + // Warning: maxLength not taken into account in base64Write + return base64Write(this, string, offset, length) + + case 'ucs2': + case 'ucs-2': + case 'utf16le': + case 'utf-16le': + return ucs2Write(this, string, offset, length) + + default: + if (loweredCase) throw new TypeError('Unknown encoding: ' + encoding) + encoding = ('' + encoding).toLowerCase() + loweredCase = true + } + } +} + +Buffer.prototype.toJSON = function toJSON() { + return { + type: 'Buffer', + data: Array.prototype.slice.call(this._arr || this, 0) + } +} + +function base64Slice(buf, start, end) { + if (start === 0 && end === buf.length) { + return base64.fromByteArray(buf) + } else { + return base64.fromByteArray(buf.slice(start, end)) + } +} + +function utf8Slice(buf, start, end) { + end = Math.min(buf.length, end) + const res = [] + + let i = start + while (i < end) { + const firstByte = buf[i] + let codePoint = null + let bytesPerSequence = (firstByte > 0xEF) + ? 4 + : (firstByte > 0xDF) + ? 3 + : (firstByte > 0xBF) + ? 2 + : 1 + + if (i + bytesPerSequence <= end) { + let secondByte, thirdByte, fourthByte, tempCodePoint + + switch (bytesPerSequence) { + case 1: + if (firstByte < 0x80) { + codePoint = firstByte + } + break + case 2: + secondByte = buf[i + 1] + if ((secondByte & 0xC0) === 0x80) { + tempCodePoint = (firstByte & 0x1F) << 0x6 | (secondByte & 0x3F) + if (tempCodePoint > 0x7F) { + codePoint = tempCodePoint + } + } + break + case 3: + secondByte = buf[i + 1] + thirdByte = buf[i + 2] + if ((secondByte & 0xC0) === 0x80 && (thirdByte & 0xC0) === 0x80) { + tempCodePoint = (firstByte & 0xF) << 0xC | (secondByte & 0x3F) << 0x6 | (thirdByte & 0x3F) + if (tempCodePoint > 0x7FF && (tempCodePoint < 0xD800 || tempCodePoint > 0xDFFF)) { + codePoint = tempCodePoint + } + } + break + case 4: + secondByte = buf[i + 1] + thirdByte = buf[i + 2] + fourthByte = buf[i + 3] + if ((secondByte & 0xC0) === 0x80 && (thirdByte & 0xC0) === 0x80 && (fourthByte & 0xC0) === 0x80) { + tempCodePoint = (firstByte & 0xF) << 0x12 | (secondByte & 0x3F) << 0xC | (thirdByte & 0x3F) << 0x6 | (fourthByte & 0x3F) + if (tempCodePoint > 0xFFFF && tempCodePoint < 0x110000) { + codePoint = tempCodePoint + } + } + } + } + + if (codePoint === null) { + // we did not generate a valid codePoint so insert a + // replacement char (U+FFFD) and advance only 1 byte + codePoint = 0xFFFD + bytesPerSequence = 1 + } else if (codePoint > 0xFFFF) { + // encode to utf16 (surrogate pair dance) + codePoint -= 0x10000 + res.push(codePoint >>> 10 & 0x3FF | 0xD800) + codePoint = 0xDC00 | codePoint & 0x3FF + } + + res.push(codePoint) + i += bytesPerSequence + } + + return decodeCodePointsArray(res) +} + +// Based on http://stackoverflow.com/a/22747272/680742, the browser with +// the lowest limit is Chrome, with 0x10000 args. +// We go 1 magnitude less, for safety +const MAX_ARGUMENTS_LENGTH = 0x1000 + +function decodeCodePointsArray(codePoints) { + const len = codePoints.length + if (len <= MAX_ARGUMENTS_LENGTH) { + return String.fromCharCode.apply(String, codePoints) // avoid extra slice() + } + + // Decode in chunks to avoid "call stack size exceeded". + let res = '' + let i = 0 + while (i < len) { + res += String.fromCharCode.apply( + String, + codePoints.slice(i, i += MAX_ARGUMENTS_LENGTH) + ) + } + return res +} + +function asciiSlice(buf, start, end) { + let ret = '' + end = Math.min(buf.length, end) + + for (let i = start; i < end; ++i) { + ret += String.fromCharCode(buf[i] & 0x7F) + } + return ret +} + +function latin1Slice(buf, start, end) { + let ret = '' + end = Math.min(buf.length, end) + + for (let i = start; i < end; ++i) { + ret += String.fromCharCode(buf[i]) + } + return ret +} + +function hexSlice(buf, start, end) { + const len = buf.length + + if (!start || start < 0) start = 0 + if (!end || end < 0 || end > len) end = len + + let out = '' + for (let i = start; i < end; ++i) { + out += hexSliceLookupTable[buf[i]] + } + return out +} + +function utf16leSlice(buf, start, end) { + const bytes = buf.slice(start, end) + let res = '' + // If bytes.length is odd, the last 8 bits must be ignored (same as node.js) + for (let i = 0; i < bytes.length - 1; i += 2) { + res += String.fromCharCode(bytes[i] + (bytes[i + 1] * 256)) + } + return res +} + +Buffer.prototype.slice = function slice(start, end) { + const len = this.length + start = ~~start + end = end === undefined ? len : ~~end + + if (start < 0) { + start += len + if (start < 0) start = 0 + } else if (start > len) { + start = len + } + + if (end < 0) { + end += len + if (end < 0) end = 0 + } else if (end > len) { + end = len + } + + if (end < start) end = start + + const newBuf = this.subarray(start, end) + // Return an augmented `Uint8Array` instance + Object.setPrototypeOf(newBuf, Buffer.prototype) + + return newBuf +} + +/* + * Need to make sure that buffer isn't trying to write out of bounds. + */ +function checkOffset(offset, ext, length) { + if ((offset % 1) !== 0 || offset < 0) throw new RangeError('offset is not uint') + if (offset + ext > length) throw new RangeError('Trying to access beyond buffer length') +} + +Buffer.prototype.readUintLE = + Buffer.prototype.readUIntLE = function readUIntLE(offset, byteLength, noAssert) { + offset = offset >>> 0 + byteLength = byteLength >>> 0 + if (!noAssert) checkOffset(offset, byteLength, this.length) + + let val = this[offset] + let mul = 1 + let i = 0 + while (++i < byteLength && (mul *= 0x100)) { + val += this[offset + i] * mul + } + + return val + } + +Buffer.prototype.readUintBE = + Buffer.prototype.readUIntBE = function readUIntBE(offset, byteLength, noAssert) { + offset = offset >>> 0 + byteLength = byteLength >>> 0 + if (!noAssert) { + checkOffset(offset, byteLength, this.length) + } + + let val = this[offset + --byteLength] + let mul = 1 + while (byteLength > 0 && (mul *= 0x100)) { + val += this[offset + --byteLength] * mul + } + + return val + } + +Buffer.prototype.readUint8 = + Buffer.prototype.readUInt8 = function readUInt8(offset, noAssert) { + offset = offset >>> 0 + if (!noAssert) checkOffset(offset, 1, this.length) + return this[offset] + } + +Buffer.prototype.readUint16LE = + Buffer.prototype.readUInt16LE = function readUInt16LE(offset, noAssert) { + offset = offset >>> 0 + if (!noAssert) checkOffset(offset, 2, this.length) + return this[offset] | (this[offset + 1] << 8) + } + +Buffer.prototype.readUint16BE = + Buffer.prototype.readUInt16BE = function readUInt16BE(offset, noAssert) { + offset = offset >>> 0 + if (!noAssert) checkOffset(offset, 2, this.length) + return (this[offset] << 8) | this[offset + 1] + } + +Buffer.prototype.readUint32LE = + Buffer.prototype.readUInt32LE = function readUInt32LE(offset, noAssert) { + offset = offset >>> 0 + if (!noAssert) checkOffset(offset, 4, this.length) + + return ((this[offset]) | + (this[offset + 1] << 8) | + (this[offset + 2] << 16)) + + (this[offset + 3] * 0x1000000) + } + +Buffer.prototype.readUint32BE = + Buffer.prototype.readUInt32BE = function readUInt32BE(offset, noAssert) { + offset = offset >>> 0 + if (!noAssert) checkOffset(offset, 4, this.length) + + return (this[offset] * 0x1000000) + + ((this[offset + 1] << 16) | + (this[offset + 2] << 8) | + this[offset + 3]) + } + +Buffer.prototype.readBigUInt64LE = defineBigIntMethod(function readBigUInt64LE(this: any, offset) { + offset = offset >>> 0 + validateNumber(offset, 'offset') + const first = this[offset] + const last = this[offset + 7] + if (first === undefined || last === undefined) { + // @ts-ignore + boundsError(offset, this.length - 8) + } + + const lo = first + + this[++offset] * 2 ** 8 + + this[++offset] * 2 ** 16 + + this[++offset] * 2 ** 24 + + const hi = this[++offset] + + this[++offset] * 2 ** 8 + + this[++offset] * 2 ** 16 + + last * 2 ** 24 + + return BigInt(lo) + (BigInt(hi) << BigInt(32)) +}) + +Buffer.prototype.readBigUInt64BE = defineBigIntMethod(function readBigUInt64BE(this: any, offset) { + offset = offset >>> 0 + validateNumber(offset, 'offset') + const first = this[offset] + const last = this[offset + 7] + if (first === undefined || last === undefined) { + // @ts-ignore + boundsError(offset, this.length - 8) + } + + const hi = first * 2 ** 24 + + this[++offset] * 2 ** 16 + + this[++offset] * 2 ** 8 + + this[++offset] + + const lo = this[++offset] * 2 ** 24 + + this[++offset] * 2 ** 16 + + this[++offset] * 2 ** 8 + + last + + return (BigInt(hi) << BigInt(32)) + BigInt(lo) +}) + +Buffer.prototype.readIntLE = function readIntLE(offset, byteLength, noAssert) { + offset = offset >>> 0 + byteLength = byteLength >>> 0 + if (!noAssert) checkOffset(offset, byteLength, this.length) + + let val = this[offset] + let mul = 1 + let i = 0 + while (++i < byteLength && (mul *= 0x100)) { + val += this[offset + i] * mul + } + mul *= 0x80 + + if (val >= mul) val -= Math.pow(2, 8 * byteLength) + + return val +} + +Buffer.prototype.readIntBE = function readIntBE(offset, byteLength, noAssert) { + offset = offset >>> 0 + byteLength = byteLength >>> 0 + if (!noAssert) checkOffset(offset, byteLength, this.length) + + let i = byteLength + let mul = 1 + let val = this[offset + --i] + while (i > 0 && (mul *= 0x100)) { + val += this[offset + --i] * mul + } + mul *= 0x80 + + if (val >= mul) val -= Math.pow(2, 8 * byteLength) + + return val +} + +Buffer.prototype.readInt8 = function readInt8(offset, noAssert) { + offset = offset >>> 0 + if (!noAssert) checkOffset(offset, 1, this.length) + if (!(this[offset] & 0x80)) return (this[offset]) + return ((0xff - this[offset] + 1) * -1) +} + +Buffer.prototype.readInt16LE = function readInt16LE(offset, noAssert) { + offset = offset >>> 0 + if (!noAssert) checkOffset(offset, 2, this.length) + const val = this[offset] | (this[offset + 1] << 8) + return (val & 0x8000) ? val | 0xFFFF0000 : val +} + +Buffer.prototype.readInt16BE = function readInt16BE(offset, noAssert) { + offset = offset >>> 0 + if (!noAssert) checkOffset(offset, 2, this.length) + const val = this[offset + 1] | (this[offset] << 8) + return (val & 0x8000) ? val | 0xFFFF0000 : val +} + +Buffer.prototype.readInt32LE = function readInt32LE(offset, noAssert) { + offset = offset >>> 0 + if (!noAssert) checkOffset(offset, 4, this.length) + + return (this[offset]) | + (this[offset + 1] << 8) | + (this[offset + 2] << 16) | + (this[offset + 3] << 24) +} + +Buffer.prototype.readInt32BE = function readInt32BE(offset, noAssert) { + offset = offset >>> 0 + if (!noAssert) checkOffset(offset, 4, this.length) + + return (this[offset] << 24) | + (this[offset + 1] << 16) | + (this[offset + 2] << 8) | + (this[offset + 3]) +} + +Buffer.prototype.readBigInt64LE = defineBigIntMethod(function readBigInt64LE(this: any, offset) { + offset = offset >>> 0 + validateNumber(offset, 'offset') + const first = this[offset] + const last = this[offset + 7] + if (first === undefined || last === undefined) { + // @ts-ignore + boundsError(offset, this.length - 8) + } + + const val = this[offset + 4] + + this[offset + 5] * 2 ** 8 + + this[offset + 6] * 2 ** 16 + + (last << 24) // Overflow + + return (BigInt(val) << BigInt(32)) + + BigInt(first + + this[++offset] * 2 ** 8 + + this[++offset] * 2 ** 16 + + this[++offset] * 2 ** 24) +}) + +Buffer.prototype.readBigInt64BE = defineBigIntMethod(function readBigInt64BE(this: any, offset) { + offset = offset >>> 0 + validateNumber(offset, 'offset') + const first = this[offset] + const last = this[offset + 7] + if (first === undefined || last === undefined) { + // @ts-ignore + boundsError(offset, this.length - 8) + } + + const val = (first << 24) + // Overflow + this[++offset] * 2 ** 16 + + this[++offset] * 2 ** 8 + + this[++offset] + + return (BigInt(val) << BigInt(32)) + + BigInt(this[++offset] * 2 ** 24 + + this[++offset] * 2 ** 16 + + this[++offset] * 2 ** 8 + + last) +}) + +Buffer.prototype.readFloatLE = function readFloatLE(offset, noAssert) { + offset = offset >>> 0 + if (!noAssert) checkOffset(offset, 4, this.length) + return ieee754.read(this, offset, true, 23, 4) +} + +Buffer.prototype.readFloatBE = function readFloatBE(offset, noAssert) { + offset = offset >>> 0 + if (!noAssert) checkOffset(offset, 4, this.length) + return ieee754.read(this, offset, false, 23, 4) +} + +Buffer.prototype.readDoubleLE = function readDoubleLE(offset, noAssert) { + offset = offset >>> 0 + if (!noAssert) checkOffset(offset, 8, this.length) + return ieee754.read(this, offset, true, 52, 8) +} + +Buffer.prototype.readDoubleBE = function readDoubleBE(offset, noAssert) { + offset = offset >>> 0 + if (!noAssert) checkOffset(offset, 8, this.length) + return ieee754.read(this, offset, false, 52, 8) +} + +function checkInt(buf, value, offset, ext, max, min) { + if (!Buffer.isBuffer(buf)) throw new TypeError('"buffer" argument must be a Buffer instance') + if (value > max || value < min) throw new RangeError('"value" argument is out of bounds') + if (offset + ext > buf.length) throw new RangeError('Index out of range') +} + +Buffer.prototype.writeUintLE = + Buffer.prototype.writeUIntLE = function writeUIntLE(value, offset, byteLength, noAssert) { + value = +value + offset = offset >>> 0 + byteLength = byteLength >>> 0 + if (!noAssert) { + const maxBytes = Math.pow(2, 8 * byteLength) - 1 + checkInt(this, value, offset, byteLength, maxBytes, 0) + } + + let mul = 1 + let i = 0 + this[offset] = value & 0xFF + while (++i < byteLength && (mul *= 0x100)) { + this[offset + i] = (value / mul) & 0xFF + } + + return offset + byteLength + } + +Buffer.prototype.writeUintBE = + Buffer.prototype.writeUIntBE = function writeUIntBE(value, offset, byteLength, noAssert) { + value = +value + offset = offset >>> 0 + byteLength = byteLength >>> 0 + if (!noAssert) { + const maxBytes = Math.pow(2, 8 * byteLength) - 1 + checkInt(this, value, offset, byteLength, maxBytes, 0) + } + + let i = byteLength - 1 + let mul = 1 + this[offset + i] = value & 0xFF + while (--i >= 0 && (mul *= 0x100)) { + this[offset + i] = (value / mul) & 0xFF + } + + return offset + byteLength + } + +Buffer.prototype.writeUint8 = + Buffer.prototype.writeUInt8 = function writeUInt8(value, offset, noAssert) { + value = +value + offset = offset >>> 0 + if (!noAssert) checkInt(this, value, offset, 1, 0xff, 0) + this[offset] = (value & 0xff) + return offset + 1 + } + +Buffer.prototype.writeUint16LE = + Buffer.prototype.writeUInt16LE = function writeUInt16LE(value, offset, noAssert) { + value = +value + offset = offset >>> 0 + if (!noAssert) checkInt(this, value, offset, 2, 0xffff, 0) + this[offset] = (value & 0xff) + this[offset + 1] = (value >>> 8) + return offset + 2 + } + +Buffer.prototype.writeUint16BE = + Buffer.prototype.writeUInt16BE = function writeUInt16BE(value, offset, noAssert) { + value = +value + offset = offset >>> 0 + if (!noAssert) checkInt(this, value, offset, 2, 0xffff, 0) + this[offset] = (value >>> 8) + this[offset + 1] = (value & 0xff) + return offset + 2 + } + +Buffer.prototype.writeUint32LE = + Buffer.prototype.writeUInt32LE = function writeUInt32LE(value, offset, noAssert) { + value = +value + offset = offset >>> 0 + if (!noAssert) checkInt(this, value, offset, 4, 0xffffffff, 0) + this[offset + 3] = (value >>> 24) + this[offset + 2] = (value >>> 16) + this[offset + 1] = (value >>> 8) + this[offset] = (value & 0xff) + return offset + 4 + } + +Buffer.prototype.writeUint32BE = + Buffer.prototype.writeUInt32BE = function writeUInt32BE(value, offset, noAssert) { + value = +value + offset = offset >>> 0 + if (!noAssert) checkInt(this, value, offset, 4, 0xffffffff, 0) + this[offset] = (value >>> 24) + this[offset + 1] = (value >>> 16) + this[offset + 2] = (value >>> 8) + this[offset + 3] = (value & 0xff) + return offset + 4 + } + +function wrtBigUInt64LE(buf, value, offset, min, max) { + checkIntBI(value, min, max, buf, offset, 7) + + let lo = Number(value & BigInt(0xffffffff)) + buf[offset++] = lo + lo = lo >> 8 + buf[offset++] = lo + lo = lo >> 8 + buf[offset++] = lo + lo = lo >> 8 + buf[offset++] = lo + let hi = Number(value >> BigInt(32) & BigInt(0xffffffff)) + buf[offset++] = hi + hi = hi >> 8 + buf[offset++] = hi + hi = hi >> 8 + buf[offset++] = hi + hi = hi >> 8 + buf[offset++] = hi + return offset +} + +function wrtBigUInt64BE(buf, value, offset, min, max) { + checkIntBI(value, min, max, buf, offset, 7) + + let lo = Number(value & BigInt(0xffffffff)) + buf[offset + 7] = lo + lo = lo >> 8 + buf[offset + 6] = lo + lo = lo >> 8 + buf[offset + 5] = lo + lo = lo >> 8 + buf[offset + 4] = lo + let hi = Number(value >> BigInt(32) & BigInt(0xffffffff)) + buf[offset + 3] = hi + hi = hi >> 8 + buf[offset + 2] = hi + hi = hi >> 8 + buf[offset + 1] = hi + hi = hi >> 8 + buf[offset] = hi + return offset + 8 +} + +Buffer.prototype.writeBigUInt64LE = defineBigIntMethod(function writeBigUInt64LE(value, offset = 0) { + // @ts-ignore + return wrtBigUInt64LE(this, value, offset, BigInt(0), BigInt('0xffffffffffffffff')) +}) + +Buffer.prototype.writeBigUInt64BE = defineBigIntMethod(function writeBigUInt64BE(value, offset = 0) { + // @ts-ignore + return wrtBigUInt64BE(this, value, offset, BigInt(0), BigInt('0xffffffffffffffff')) +}) + +Buffer.prototype.writeIntLE = function writeIntLE(value, offset, byteLength, noAssert) { + value = +value + offset = offset >>> 0 + if (!noAssert) { + const limit = Math.pow(2, (8 * byteLength) - 1) + + checkInt(this, value, offset, byteLength, limit - 1, -limit) + } + + let i = 0 + let mul = 1 + let sub = 0 + this[offset] = value & 0xFF + while (++i < byteLength && (mul *= 0x100)) { + if (value < 0 && sub === 0 && this[offset + i - 1] !== 0) { + sub = 1 + } + this[offset + i] = ((value / mul) >> 0) - sub & 0xFF + } + + return offset + byteLength +} + +Buffer.prototype.writeIntBE = function writeIntBE(value, offset, byteLength, noAssert) { + value = +value + offset = offset >>> 0 + if (!noAssert) { + const limit = Math.pow(2, (8 * byteLength) - 1) + + checkInt(this, value, offset, byteLength, limit - 1, -limit) + } + + let i = byteLength - 1 + let mul = 1 + let sub = 0 + this[offset + i] = value & 0xFF + while (--i >= 0 && (mul *= 0x100)) { + if (value < 0 && sub === 0 && this[offset + i + 1] !== 0) { + sub = 1 + } + this[offset + i] = ((value / mul) >> 0) - sub & 0xFF + } + + return offset + byteLength +} + +Buffer.prototype.writeInt8 = function writeInt8(value, offset, noAssert) { + value = +value + offset = offset >>> 0 + if (!noAssert) checkInt(this, value, offset, 1, 0x7f, -0x80) + if (value < 0) value = 0xff + value + 1 + this[offset] = (value & 0xff) + return offset + 1 +} + +Buffer.prototype.writeInt16LE = function writeInt16LE(value, offset, noAssert) { + value = +value + offset = offset >>> 0 + if (!noAssert) checkInt(this, value, offset, 2, 0x7fff, -0x8000) + this[offset] = (value & 0xff) + this[offset + 1] = (value >>> 8) + return offset + 2 +} + +Buffer.prototype.writeInt16BE = function writeInt16BE(value, offset, noAssert) { + value = +value + offset = offset >>> 0 + if (!noAssert) checkInt(this, value, offset, 2, 0x7fff, -0x8000) + this[offset] = (value >>> 8) + this[offset + 1] = (value & 0xff) + return offset + 2 +} + +Buffer.prototype.writeInt32LE = function writeInt32LE(value, offset, noAssert) { + value = +value + offset = offset >>> 0 + if (!noAssert) checkInt(this, value, offset, 4, 0x7fffffff, -0x80000000) + this[offset] = (value & 0xff) + this[offset + 1] = (value >>> 8) + this[offset + 2] = (value >>> 16) + this[offset + 3] = (value >>> 24) + return offset + 4 +} + +Buffer.prototype.writeInt32BE = function writeInt32BE(value, offset, noAssert) { + value = +value + offset = offset >>> 0 + if (!noAssert) checkInt(this, value, offset, 4, 0x7fffffff, -0x80000000) + if (value < 0) value = 0xffffffff + value + 1 + this[offset] = (value >>> 24) + this[offset + 1] = (value >>> 16) + this[offset + 2] = (value >>> 8) + this[offset + 3] = (value & 0xff) + return offset + 4 +} + +Buffer.prototype.writeBigInt64LE = defineBigIntMethod(function writeBigInt64LE(value, offset = 0) { + // @ts-ignore + return wrtBigUInt64LE(this, value, offset, -BigInt('0x8000000000000000'), BigInt('0x7fffffffffffffff')) +}) + +Buffer.prototype.writeBigInt64BE = defineBigIntMethod(function writeBigInt64BE(value, offset = 0) { + // @ts-ignore + return wrtBigUInt64BE(this, value, offset, -BigInt('0x8000000000000000'), BigInt('0x7fffffffffffffff')) +}) + +function checkIEEE754(buf, value, offset, ext, max, min) { + if (offset + ext > buf.length) throw new RangeError('Index out of range') + if (offset < 0) throw new RangeError('Index out of range') +} + +function writeFloat(buf, value, offset, littleEndian, noAssert) { + value = +value + offset = offset >>> 0 + if (!noAssert) { + checkIEEE754(buf, value, offset, 4, 3.4028234663852886e+38, -3.4028234663852886e+38) + } + ieee754.write(buf, value, offset, littleEndian, 23, 4) + return offset + 4 +} + +Buffer.prototype.writeFloatLE = function writeFloatLE(value, offset, noAssert) { + return writeFloat(this, value, offset, true, noAssert) +} + +Buffer.prototype.writeFloatBE = function writeFloatBE(value, offset, noAssert) { + return writeFloat(this, value, offset, false, noAssert) +} + +function writeDouble(buf, value, offset, littleEndian, noAssert) { + value = +value + offset = offset >>> 0 + if (!noAssert) { + checkIEEE754(buf, value, offset, 8, 1.7976931348623157E+308, -1.7976931348623157E+308) + } + ieee754.write(buf, value, offset, littleEndian, 52, 8) + return offset + 8 +} + +Buffer.prototype.writeDoubleLE = function writeDoubleLE(value, offset, noAssert) { + return writeDouble(this, value, offset, true, noAssert) +} + +Buffer.prototype.writeDoubleBE = function writeDoubleBE(value, offset, noAssert) { + return writeDouble(this, value, offset, false, noAssert) +} + +// copy(targetBuffer, targetStart=0, sourceStart=0, sourceEnd=buffer.length) +Buffer.prototype.copy = function copy(target, targetStart, start, end) { + if (!Buffer.isBuffer(target)) throw new TypeError('argument should be a Buffer') + if (!start) start = 0 + if (!end && end !== 0) end = this.length + if (targetStart >= target.length) targetStart = target.length + if (!targetStart) targetStart = 0 + if (end > 0 && end < start) end = start + + // Copy 0 bytes; we're done + if (end === start) return 0 + if (target.length === 0 || this.length === 0) return 0 + + // Fatal error conditions + if (targetStart < 0) { + throw new RangeError('targetStart out of bounds') + } + if (start < 0 || start >= this.length) throw new RangeError('Index out of range') + if (end < 0) throw new RangeError('sourceEnd out of bounds') + + // Are we oob? + if (end > this.length) end = this.length + if (target.length - targetStart < end - start) { + end = target.length - targetStart + start + } + + const len = end - start + + if (this === target && typeof Uint8Array.prototype.copyWithin === 'function') { + // Use built-in when available, missing from IE11 + this.copyWithin(targetStart, start, end) + } else { + Uint8Array.prototype.set.call( + target, + this.subarray(start, end), + targetStart + ) + } + + return len +} + +// Usage: +// buffer.fill(number[, offset[, end]]) +// buffer.fill(buffer[, offset[, end]]) +// buffer.fill(string[, offset[, end]][, encoding]) +Buffer.prototype.fill = function fill(val, start, end, encoding) { + // Handle string cases: + if (typeof val === 'string') { + if (typeof start === 'string') { + encoding = start + start = 0 + end = this.length + } else if (typeof end === 'string') { + encoding = end + end = this.length + } + if (encoding !== undefined && typeof encoding !== 'string') { + throw new TypeError('encoding must be a string') + } + if (typeof encoding === 'string' && !Buffer.isEncoding(encoding)) { + throw new TypeError('Unknown encoding: ' + encoding) + } + if (val.length === 1) { + const code = val.charCodeAt(0) + if ((encoding === 'utf8' && code < 128) || + encoding === 'latin1') { + // Fast path: If `val` fits into a single byte, use that numeric value. + val = code + } + } + } else if (typeof val === 'number') { + val = val & 255 + } else if (typeof val === 'boolean') { + val = Number(val) + } + + // Invalid ranges are not set to a default, so can range check early. + if (start < 0 || this.length < start || this.length < end) { + throw new RangeError('Out of range index') + } + + if (end <= start) { + return this + } + + start = start >>> 0 + end = end === undefined ? this.length : end >>> 0 + + if (!val) val = 0 + + let i + if (typeof val === 'number') { + for (i = start; i < end; ++i) { + this[i] = val + } + } else { + const bytes = Buffer.isBuffer(val) + ? val + // @ts-ignore + : Buffer.from(val, encoding) + const len = bytes.length + if (len === 0) { + throw new TypeError('The value "' + val + + '" is invalid for argument "value"') + } + for (i = 0; i < end - start; ++i) { + this[i + start] = bytes[i % len] + } + } + + return this +} + +// CUSTOM ERRORS +// ============= + +// Simplified versions from Node, changed for Buffer-only usage +const errors = {} +function E(sym, getMessage, Base) { + errors[sym] = class NodeError extends Base { + constructor() { + super() + + Object.defineProperty(this, 'message', { + value: getMessage.apply(this, arguments), + writable: true, + configurable: true + }) + + // Add the error code to the name to include it in the stack trace. + this.name = `${this.name} [${sym}]` + // Access the stack to generate the error message including the error code + // from the name. + this.stack // eslint-disable-line no-unused-expressions + // Reset the name to the actual name. + delete this.name + } + + get code() { + return sym + } + + set code(value) { + Object.defineProperty(this, 'code', { + configurable: true, + enumerable: true, + value, + writable: true + }) + } + + toString() { + return `${this.name} [${sym}]: ${this.message}` + } + } +} + +E('ERR_BUFFER_OUT_OF_BOUNDS', + function (name) { + if (name) { + return `${name} is outside of buffer bounds` + } + + return 'Attempt to access memory outside buffer bounds' + }, RangeError) +E('ERR_INVALID_ARG_TYPE', + function (name, actual) { + return `The "${name}" argument must be of type number. Received type ${typeof actual}` + }, TypeError) +E('ERR_OUT_OF_RANGE', + function (str, range, input) { + let msg = `The value of "${str}" is out of range.` + let received = input + if (Number.isInteger(input) && Math.abs(input) > 2 ** 32) { + received = addNumericalSeparator(String(input)) + } else if (typeof input === 'bigint') { + received = String(input) + // @ts-ignore + if (input > BigInt(2) ** BigInt(32) || input < -(BigInt(2) ** BigInt(32))) { + received = addNumericalSeparator(received) + } + received += 'n' + } + msg += ` It must be ${range}. Received ${received}` + return msg + }, RangeError) + +function addNumericalSeparator(val) { + let res = '' + let i = val.length + const start = val[0] === '-' ? 1 : 0 + for (; i >= start + 4; i -= 3) { + res = `_${val.slice(i - 3, i)}${res}` + } + return `${val.slice(0, i)}${res}` +} + +// CHECK FUNCTIONS +// =============== + +function checkBounds(buf, offset, byteLength) { + validateNumber(offset, 'offset') + if (buf[offset] === undefined || buf[offset + byteLength] === undefined) { + // @ts-ignore + boundsError(offset, buf.length - (byteLength + 1)) + } +} + +function checkIntBI(value, min, max, buf, offset, byteLength) { + if (value > max || value < min) { + const n = typeof min === 'bigint' ? 'n' : '' + let range + if (byteLength > 3) { + if (min === 0 || min === BigInt(0)) { + range = `>= 0${n} and < 2${n} ** ${(byteLength + 1) * 8}${n}` + } else { + range = `>= -(2${n} ** ${(byteLength + 1) * 8 - 1}${n}) and < 2 ** ` + + `${(byteLength + 1) * 8 - 1}${n}` + } + } else { + range = `>= ${min}${n} and <= ${max}${n}` + } + // @ts-ignore + throw new errors.ERR_OUT_OF_RANGE('value', range, value) + } + checkBounds(buf, offset, byteLength) +} + +function validateNumber(value, name) { + if (typeof value !== 'number') { + // @ts-ignore + throw new errors.ERR_INVALID_ARG_TYPE(name, 'number', value) + } +} + +function boundsError(value, length, type) { + if (Math.floor(value) !== value) { + validateNumber(value, type) + // @ts-ignore + throw new errors.ERR_OUT_OF_RANGE(type || 'offset', 'an integer', value) + } + + if (length < 0) { + // @ts-ignore + throw new errors.ERR_BUFFER_OUT_OF_BOUNDS() + } + + // @ts-ignore + throw new errors.ERR_OUT_OF_RANGE(type || 'offset', + `>= ${type ? 1 : 0} and <= ${length}`, + value) +} + +// HELPER FUNCTIONS +// ================ + +const INVALID_BASE64_RE = /[^+/0-9A-Za-z-_]/g + +function base64clean(str) { + // Node takes equal signs as end of the Base64 encoding + str = str.split('=')[0] + // Node strips out invalid characters like \n and \t from the string, base64-js does not + str = str.trim().replace(INVALID_BASE64_RE, '') + // Node converts strings with length < 2 to '' + if (str.length < 2) return '' + // Node allows for non-padded base64 strings (missing trailing ===), base64-js does not + while (str.length % 4 !== 0) { + str = str + '=' + } + return str +} + +function utf8ToBytes(string, units) { + units = units || Infinity + let codePoint + const length = string.length + let leadSurrogate = null + const bytes = [] + + for (let i = 0; i < length; ++i) { + codePoint = string.charCodeAt(i) + + // is surrogate component + if (codePoint > 0xD7FF && codePoint < 0xE000) { + // last char was a lead + if (!leadSurrogate) { + // no lead yet + if (codePoint > 0xDBFF) { + // unexpected trail + if ((units -= 3) > -1) bytes.push(0xEF, 0xBF, 0xBD) + continue + } else if (i + 1 === length) { + // unpaired lead + if ((units -= 3) > -1) bytes.push(0xEF, 0xBF, 0xBD) + continue + } + + // valid lead + leadSurrogate = codePoint + + continue + } + + // 2 leads in a row + if (codePoint < 0xDC00) { + if ((units -= 3) > -1) bytes.push(0xEF, 0xBF, 0xBD) + leadSurrogate = codePoint + continue + } + + // valid surrogate pair + codePoint = (leadSurrogate - 0xD800 << 10 | codePoint - 0xDC00) + 0x10000 + } else if (leadSurrogate) { + // valid bmp char, but last char was a lead + if ((units -= 3) > -1) bytes.push(0xEF, 0xBF, 0xBD) + } + + leadSurrogate = null + + // encode utf8 + if (codePoint < 0x80) { + if ((units -= 1) < 0) break + bytes.push(codePoint) + } else if (codePoint < 0x800) { + if ((units -= 2) < 0) break + bytes.push( + codePoint >> 0x6 | 0xC0, + codePoint & 0x3F | 0x80 + ) + } else if (codePoint < 0x10000) { + if ((units -= 3) < 0) break + bytes.push( + codePoint >> 0xC | 0xE0, + codePoint >> 0x6 & 0x3F | 0x80, + codePoint & 0x3F | 0x80 + ) + } else if (codePoint < 0x110000) { + if ((units -= 4) < 0) break + bytes.push( + codePoint >> 0x12 | 0xF0, + codePoint >> 0xC & 0x3F | 0x80, + codePoint >> 0x6 & 0x3F | 0x80, + codePoint & 0x3F | 0x80 + ) + } else { + throw new Error('Invalid code point') + } + } + + return bytes +} + +function asciiToBytes(str) { + const byteArray = [] + for (let i = 0; i < str.length; ++i) { + // Node's code seems to be doing this and not & 0x7F.. + byteArray.push(str.charCodeAt(i) & 0xFF) + } + return byteArray +} + +function utf16leToBytes(str, units) { + let c, hi, lo + const byteArray = [] + for (let i = 0; i < str.length; ++i) { + if ((units -= 2) < 0) break + + c = str.charCodeAt(i) + hi = c >> 8 + lo = c % 256 + byteArray.push(lo) + byteArray.push(hi) + } + + return byteArray +} + +function base64ToBytes(str) { + return base64.toByteArray(base64clean(str)) +} + +function blitBuffer(src, dst, offset, length) { + let i + for (i = 0; i < length; ++i) { + if ((i + offset >= dst.length) || (i >= src.length)) break + dst[i + offset] = src[i] + } + return i +} + +// ArrayBuffer or Uint8Array objects from other contexts (i.e. iframes) do not pass +// the `instanceof` check but they should be treated as of that type. +// See: https://github.com/feross/buffer/issues/166 +function isInstance(obj, type) { + return obj instanceof type || + (obj != null && obj.constructor != null && obj.constructor.name != null && + obj.constructor.name === type.name) +} +function numberIsNaN(obj) { + // For IE11 support + return obj !== obj // eslint-disable-line no-self-compare +} + +// Create lookup table for `toString('hex')` +// See: https://github.com/feross/buffer/issues/219 +const hexSliceLookupTable = (function () { + const alphabet = '0123456789abcdef' + const table = new Array(256) + for (let i = 0; i < 16; ++i) { + const i16 = i * 16 + for (let j = 0; j < 16; ++j) { + table[i16 + j] = alphabet[i] + alphabet[j] + } + } + return table +})() + +// Return not function with Error if BigInt not supported +function defineBigIntMethod(fn) { + return typeof BigInt === 'undefined' ? BufferBigIntNotDefined : fn +} + +function BufferBigIntNotDefined() { + throw new Error('BigInt not supported') +} diff --git a/packages/polyfill/src/index.ts b/packages/polyfill/src/index.ts index 93b5b7b8..55d6ca33 100644 --- a/packages/polyfill/src/index.ts +++ b/packages/polyfill/src/index.ts @@ -12,6 +12,7 @@ import 'core-js' process.on('exit', () => require.disable()) global.setGlobal('Proxy', require('./proxy').Proxy) global.setGlobal('XMLHttpRequest', require('./xml-http-request').XMLHttpRequest) +global.setGlobal('Buffer', require('./buffer').Buffer) global.setGlobal('Blob', require('blob-polyfill').Blob) console.i18n("ms.polyfill.completed", { time: (new Date().getTime() - polyfillStartTime) / 1000 }) export default true diff --git a/packages/websocket/package.json b/packages/websocket/package.json index 8f0965e9..f4b0ab1c 100644 --- a/packages/websocket/package.json +++ b/packages/websocket/package.json @@ -19,6 +19,7 @@ "test": "echo \"Error: run tests from root\" && exit 1" }, "dependencies": { + "@socket.io/component-emitter": "^3.1.0", "backo2": "^1.0.2", "parseuri": "^0.0.6" }, diff --git a/packages/websocket/src/client/index.ts b/packages/websocket/src/client/index.ts index 8ae0bfb1..05a76c40 100644 --- a/packages/websocket/src/client/index.ts +++ b/packages/websocket/src/client/index.ts @@ -38,7 +38,7 @@ export class WebSocket extends EventEmitter { private client: Transport - constructor(url: string, subProtocol: string = '', headers: WebSocketHeader = {}) { + constructor(url: string, subProtocol: string | string[] = '', headers: WebSocketHeader = {}) { super() this.manager = manager this._url = url diff --git a/packages/websocket/src/debug.ts b/packages/websocket/src/debug.ts index c0e345d0..e58655da 100644 --- a/packages/websocket/src/debug.ts +++ b/packages/websocket/src/debug.ts @@ -1 +1,54 @@ -export = (namepsace) => (...args) => { }//console.debug(namepsace, ...args) +export = (namepsace) => (...args) => { console.trace(`[${namepsace}] ` + format(...args)) }//console.debug(namepsace, ...args) +let formatters: any = {} +formatters.s = function (v) { + return v +} +formatters.j = function (v) { + try { + return JSON.stringify(v) + } catch (error: any) { + return '[UnexpectedJSONParseError]: ' + error.message + } +} +/** +* Coerce `val`. +* +* @param {Mixed} val +* @return {Mixed} +* @api private +*/ +function coerce(val) { + if (val instanceof Error) { + return val.stack || val.message + } + return val +} +function format(...args) { + // Apply any `formatters` transformations + args[0] = coerce(args[0]) + + if (typeof args[0] !== 'string') { + // Anything else let's inspect with %O + args.unshift('%O') + } + + let index = 0 + args[0] = args[0].replace(/%([a-zA-Z%])/g, (match, format) => { + // If we encounter an escaped % then don't increase the array index + if (match === '%%') { + return '%' + } + index++ + const formatter = formatters[format] + if (typeof formatter === 'function') { + const val = args[index] + match = formatter.call(format, val) + + // Now we need to remove `args[index]` since it's inlined in the `format` + args.splice(index, 1) + index-- + } + return match + }) + return args[0] +} diff --git a/packages/websocket/src/engine.io-client/contrib/has-cors.ts b/packages/websocket/src/engine.io-client/contrib/has-cors.ts new file mode 100644 index 00000000..d993c593 --- /dev/null +++ b/packages/websocket/src/engine.io-client/contrib/has-cors.ts @@ -0,0 +1,12 @@ +// imported from https://github.com/component/has-cors +let value = false; + +try { + value = typeof XMLHttpRequest !== 'undefined' && + 'withCredentials' in new XMLHttpRequest(); +} catch (err) { + // if XMLHttp support is disabled in IE then it will throw + // when trying to create +} + +export const hasCORS = value; diff --git a/packages/websocket/src/engine.io-client/contrib/parseqs.ts b/packages/websocket/src/engine.io-client/contrib/parseqs.ts new file mode 100644 index 00000000..afc0034a --- /dev/null +++ b/packages/websocket/src/engine.io-client/contrib/parseqs.ts @@ -0,0 +1,38 @@ +// imported from https://github.com/galkn/querystring +/** + * Compiles a querystring + * Returns string representation of the object + * + * @param {Object} + * @api private + */ + +export function encode(obj) { + let str = '' + + for (let i in obj) { + if (obj.hasOwnProperty(i)) { + if (str.length) str += '&' + str += encodeURIComponent(i) + '=' + encodeURIComponent(obj[i]) + } + } + + return str +} + +/** + * Parses a simple querystring into an object + * + * @param {String} qs + * @api private + */ + +export function decode(qs) { + let qry = {} + let pairs = qs.split('&') + for (let i = 0, l = pairs.length; i < l; i++) { + let pair = pairs[i].split('=') + qry[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1]) + } + return qry +} diff --git a/packages/websocket/src/engine.io-client/contrib/parseuri.ts b/packages/websocket/src/engine.io-client/contrib/parseuri.ts new file mode 100644 index 00000000..edb47756 --- /dev/null +++ b/packages/websocket/src/engine.io-client/contrib/parseuri.ts @@ -0,0 +1,68 @@ +// imported from https://github.com/galkn/parseuri +/** + * Parses an URI + * + * @author Steven Levithan (MIT license) + * @api private + */ +const re = /^(?:(?![^:@]+:[^:@\/]*@)(http|https|ws|wss):\/\/)?((?:(([^:@]*)(?::([^:@]*))?)?@)?((?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}|[^:\/?#]*)(?::(\d*))?)(((\/(?:[^?#](?![^?#\/]*\.[^?#\/.]+(?:[?#]|$)))*\/?)?([^?#\/]*))(?:\?([^#]*))?(?:#(.*))?)/ + +const parts = [ + 'source', 'protocol', 'authority', 'userInfo', 'user', 'password', 'host', 'port', 'relative', 'path', 'directory', 'file', 'query', 'anchor' +] + +export function parse(str) { + const src = str, + b = str.indexOf('['), + e = str.indexOf(']') + + if (b != -1 && e != -1) { + str = str.substring(0, b) + str.substring(b, e).replace(/:/g, ';') + str.substring(e, str.length) + } + + let m = re.exec(str || ''), + uri = {} as any, + i = 14 + + while (i--) { + uri[parts[i]] = m[i] || '' + } + + if (b != -1 && e != -1) { + uri.source = src + uri.host = uri.host.substring(1, uri.host.length - 1).replace(/;/g, ':') + uri.authority = uri.authority.replace('[', '').replace(']', '').replace(/;/g, ':') + uri.ipv6uri = true + } + + uri.pathNames = pathNames(uri, uri['path']) + uri.queryKey = queryKey(uri, uri['query']) + + return uri +} + +function pathNames(obj, path) { + const regx = /\/{2,9}/g, + names = path.replace(regx, "/").split("/") + + if (path.slice(0, 1) == '/' || path.length === 0) { + names.splice(0, 1) + } + if (path.slice(-1) == '/') { + names.splice(names.length - 1, 1) + } + + return names +} + +function queryKey(uri, query) { + const data = {} + + query.replace(/(?:^|&)([^&=]*)=?([^&]*)/g, function ($0, $1, $2) { + if ($1) { + data[$1] = $2 + } + }) + + return data +} diff --git a/packages/websocket/src/engine.io-client/contrib/yeast.ts b/packages/websocket/src/engine.io-client/contrib/yeast.ts new file mode 100644 index 00000000..e3f45a76 --- /dev/null +++ b/packages/websocket/src/engine.io-client/contrib/yeast.ts @@ -0,0 +1,62 @@ +// imported from https://github.com/unshiftio/yeast +'use strict' + +const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_'.split('') + , length = 64 + , map = {} +let seed = 0 + , i = 0 + , prev + +/** + * Return a string representing the specified number. + * + * @param {Number} num The number to convert. + * @returns {String} The string representation of the number. + * @api public + */ +export function encode(num) { + let encoded = '' + + do { + encoded = alphabet[num % length] + encoded + num = Math.floor(num / length) + } while (num > 0) + + return encoded +} + +/** + * Return the integer value specified by the given string. + * + * @param {String} str The string to convert. + * @returns {Number} The integer value represented by the string. + * @api public + */ +export function decode(str) { + let decoded = 0 + + for (i = 0; i < str.length; i++) { + decoded = decoded * length + map[str.charAt(i)] + } + + return decoded +} + +/** + * Yeast: A tiny growing id generator. + * + * @returns {String} A unique id. + * @api public + */ +export function yeast() { + const now = encode(+new Date()) + + if (now !== prev) return seed = 0, prev = now + return now + '.' + encode(seed++) +} + +// +// Map each character to its index. +// +for (; i < length; i++) map[alphabet[i]] = i diff --git a/packages/websocket/src/engine.io-client/index.ts b/packages/websocket/src/engine.io-client/index.ts index 30fd00be..a194e9df 100644 --- a/packages/websocket/src/engine.io-client/index.ts +++ b/packages/websocket/src/engine.io-client/index.ts @@ -1,16 +1,10 @@ -import { Socket } from './socket' +import { Socket } from "./socket" -export default (uri, opts) => new Socket(uri, opts) - -/** - * Expose deps for legacy compatibility - * and standalone browser access. - */ -const protocol = Socket.protocol // this is an int -export { Socket, protocol } -// module.exports.Transport = require("./transport") -// module.exports.transports = require("./transports/index") -// module.exports.parser = require("../engine.io-parser") -export * from './transport' -export * from './transports/index' -export * from '../engine.io-parser' +export { Socket } +export { SocketOptions } from "./socket" +export const protocol = Socket.protocol +export { Transport } from "./transport" +export { transports } from "./transports/index" +export { installTimerFunctions } from "./util" +export { parse } from "./contrib/parseuri" +export { nextTick } from "./transports/websocket-constructor.js" diff --git a/packages/websocket/src/engine.io-client/socket.ts b/packages/websocket/src/engine.io-client/socket.ts index 4e047c19..209e82ae 100644 --- a/packages/websocket/src/engine.io-client/socket.ts +++ b/packages/websocket/src/engine.io-client/socket.ts @@ -1,21 +1,278 @@ -import transports from "./transports" -// const transports = require("./transports/index") -const Emitter = require("component-emitter") -const debug = (...args: any) => console.debug('engine.io-client:socket', ...args)//require("debug")("engine.io-client:socket") -import parser from "../engine.io-parser" -const parseuri = require("parseuri") -const parseqs = require("parseqs") -import { installTimerFunctions } from "./util" +// import { transports } from "./transports/index.js"; +import { transports } from "./transports" +import { installTimerFunctions, byteLength } from "./util" +import { decode } from "./contrib/parseqs" +import { parse } from "./contrib/parseuri" +// import debugModule from "debug"; // debug() +import { Emitter } from "@socket.io/component-emitter" +// import { protocol } from "engine.io-parser"; +import { protocol } from "../engine.io-parser" +import { CloseDetails } from "./transport" + +// const debug = debugModule("engine.io-client:socket"); // debug() +const debug = require('../debug')('engine.io-client:socket') + +export interface SocketOptions { + /** + * The host that we're connecting to. Set from the URI passed when connecting + */ + host: string + + /** + * The hostname for our connection. Set from the URI passed when connecting + */ + hostname: string + + /** + * If this is a secure connection. Set from the URI passed when connecting + */ + secure: boolean + + /** + * The port for our connection. Set from the URI passed when connecting + */ + port: string | number + + /** + * Any query parameters in our uri. Set from the URI passed when connecting + */ + query: { [key: string]: any } + + /** + * `http.Agent` to use, defaults to `false` (NodeJS only) + */ + agent: string | boolean + + /** + * Whether the client should try to upgrade the transport from + * long-polling to something better. + * @default true + */ + upgrade: boolean + + /** + * Forces base 64 encoding for polling transport even when XHR2 + * responseType is available and WebSocket even if the used standard + * supports binary. + */ + forceBase64: boolean + + /** + * The param name to use as our timestamp key + * @default 't' + */ + timestampParam: string + + /** + * Whether to add the timestamp with each transport request. Note: this + * is ignored if the browser is IE or Android, in which case requests + * are always stamped + * @default false + */ + timestampRequests: boolean + + /** + * A list of transports to try (in order). Engine.io always attempts to + * connect directly with the first one, provided the feature detection test + * for it passes. + * @default ['polling','websocket'] + */ + transports: string[] + + /** + * The port the policy server listens on + * @default 843 + */ + policyPost: number + + /** + * If true and if the previous websocket connection to the server succeeded, + * the connection attempt will bypass the normal upgrade process and will + * initially try websocket. A connection attempt following a transport error + * will use the normal upgrade process. It is recommended you turn this on + * only when using SSL/TLS connections, or if you know that your network does + * not block websockets. + * @default false + */ + rememberUpgrade: boolean + + /** + * Are we only interested in transports that support binary? + */ + onlyBinaryUpgrades: boolean + + /** + * Timeout for xhr-polling requests in milliseconds (0) (only for polling transport) + */ + requestTimeout: number + + /** + * Transport options for Node.js client (headers etc) + */ + transportOptions: Object + + /** + * (SSL) Certificate, Private key and CA certificates to use for SSL. + * Can be used in Node.js client environment to manually specify + * certificate information. + */ + pfx: string + + /** + * (SSL) Private key to use for SSL. Can be used in Node.js client + * environment to manually specify certificate information. + */ + key: string + + /** + * (SSL) A string or passphrase for the private key or pfx. Can be + * used in Node.js client environment to manually specify certificate + * information. + */ + passphrase: string + + /** + * (SSL) Public x509 certificate to use. Can be used in Node.js client + * environment to manually specify certificate information. + */ + cert: string + + /** + * (SSL) An authority certificate or array of authority certificates to + * check the remote host against.. Can be used in Node.js client + * environment to manually specify certificate information. + */ + ca: string | string[] + + /** + * (SSL) A string describing the ciphers to use or exclude. Consult the + * [cipher format list] + * (http://www.openssl.org/docs/apps/ciphers.html#CIPHER_LIST_FORMAT) for + * details on the format.. Can be used in Node.js client environment to + * manually specify certificate information. + */ + ciphers: string + + /** + * (SSL) If true, the server certificate is verified against the list of + * supplied CAs. An 'error' event is emitted if verification fails. + * Verification happens at the connection level, before the HTTP request + * is sent. Can be used in Node.js client environment to manually specify + * certificate information. + */ + rejectUnauthorized: boolean + + /** + * Headers that will be passed for each request to the server (via xhr-polling and via websockets). + * These values then can be used during handshake or for special proxies. + */ + extraHeaders?: { [header: string]: string } + + /** + * Whether to include credentials (cookies, authorization headers, TLS + * client certificates, etc.) with cross-origin XHR polling requests + * @default false + */ + withCredentials: boolean + + /** + * Whether to automatically close the connection whenever the beforeunload event is received. + * @default true + */ + closeOnBeforeunload: boolean + + /** + * Whether to always use the native timeouts. This allows the client to + * reconnect when the native timeout functions are overridden, such as when + * mock clocks are installed. + * @default false + */ + useNativeTimers: boolean + + /** + * weather we should unref the reconnect timer when it is + * create automatically + * @default false + */ + autoUnref: boolean + + /** + * parameters of the WebSocket permessage-deflate extension (see ws module api docs). Set to false to disable. + * @default false + */ + perMessageDeflate: { threshold: number } + + /** + * The path to get our client file from, in the case of the server + * serving it + * @default '/engine.io' + */ + path: string + + /** + * Either a single protocol string or an array of protocol strings. These strings are used to indicate sub-protocols, + * so that a single server can implement multiple WebSocket sub-protocols (for example, you might want one server to + * be able to handle different types of interactions depending on the specified protocol) + * @default [] + */ + protocols: string | string[] +} + +interface SocketReservedEvents { + open: () => void + handshake: (data) => void + packet: (packet) => void + packetCreate: (packet) => void + data: (data) => void + message: (data) => void + drain: () => void + flush: () => void + heartbeat: () => void + ping: () => void + pong: () => void + error: (err: string | Error) => void + upgrading: (transport) => void + upgrade: (transport) => void + upgradeError: (err: Error) => void + close: (reason: string, description?: CloseDetails | Error) => void +} + +export class Socket extends Emitter<{}, {}, SocketReservedEvents> { + public id: string + public transport: any + public binaryType: string + + private readyState: string + private writeBuffer + private prevBufferLen: number + private upgrades + private pingInterval: number + private pingTimeout: number + private pingTimeoutTimer: NodeJS.Timer + private setTimeoutFn: typeof setTimeout + private clearTimeoutFn: typeof clearTimeout + private readonly beforeunloadEventListener: () => void + private readonly offlineEventListener: () => void + private upgrading: boolean + private maxPayload?: number + + private readonly opts: Partial + private readonly secure: boolean + private readonly hostname: string + private readonly port: string | number + private readonly transports: string[] + + static priorWebsocketSuccess: boolean + static protocol = protocol; -export class Socket extends Emitter { /** * Socket constructor. * * @param {String|Object} uri or options - * @param {Object} options + * @param {Object} opts - options * @api public */ - constructor(uri, opts: any = {}) { + constructor(uri, opts: Partial = {}) { super() if (uri && "object" === typeof uri) { @@ -24,13 +281,13 @@ export class Socket extends Emitter { } if (uri) { - uri = parseuri(uri) + uri = parse(uri) opts.hostname = uri.host opts.secure = uri.protocol === "https" || uri.protocol === "wss" opts.port = uri.port if (uri.query) opts.query = uri.query } else if (opts.host) { - opts.hostname = parseuri(opts.host).host + opts.hostname = parse(opts.host).host } installTimerFunctions(this, opts) @@ -53,10 +310,10 @@ export class Socket extends Emitter { (typeof location !== "undefined" && location.port ? location.port : this.secure - ? 443 - : 80) + ? "443" + : "80") - this.transports = ["websocket"] + this.transports = opts.transports || ["polling", "websocket"] this.readyState = "" this.writeBuffer = [] this.prevBufferLen = 0 @@ -67,7 +324,6 @@ export class Socket extends Emitter { agent: false, withCredentials: false, upgrade: true, - jsonp: true, timestampParam: "t", rememberUpgrade: false, rejectUnauthorized: true, @@ -83,7 +339,7 @@ export class Socket extends Emitter { this.opts.path = this.opts.path.replace(/\/$/, "") + "/" if (typeof this.opts.query === "string") { - this.opts.query = parseqs.decode(this.opts.query) + this.opts.query = decode(this.opts.query) } // set on handshake @@ -100,21 +356,20 @@ export class Socket extends Emitter { // Firefox closes the connection when the "beforeunload" event is emitted but not Chrome. This event listener // ensures every browser behaves the same (no "disconnect" event at the Socket.IO level when the page is // closed/reloaded) - addEventListener( - "beforeunload", - () => { - if (this.transport) { - // silently close the transport - this.transport.removeAllListeners() - this.transport.close() - } - }, - false - ) + this.beforeunloadEventListener = () => { + if (this.transport) { + // silently close the transport + this.transport.removeAllListeners() + this.transport.close() + } + } + addEventListener("beforeunload", this.beforeunloadEventListener, false) } if (this.hostname !== "localhost") { this.offlineEventListener = () => { - this.onClose("transport close") + this.onClose("transport close", { + description: "network connection lost" + }) } addEventListener("offline", this.offlineEventListener, false) } @@ -130,15 +385,12 @@ export class Socket extends Emitter { * @return {Transport} * @api private */ - createTransport(name, opt?) { - if (name != 'websocket') { - throw new Error('Only Support WebSocket in MiaoScript!') - } + private createTransport(name) { debug('creating transport "%s"', name) - const query: any = clone(this.opts.query) + const query: any = Object.assign({}, this.opts.query) // append engine.io protocol identifier - query.EIO = parser.protocol + query.EIO = protocol // transport name query.transport = name @@ -159,8 +411,8 @@ export class Socket extends Emitter { } ) - debug("options: %j", JSON.stringify(opts)) - debug("new func", transports[name]) + debug("options: %j", opts) + return new transports[name](opts) } @@ -169,7 +421,7 @@ export class Socket extends Emitter { * * @api private */ - open() { + private open() { let transport if ( this.opts.rememberUpgrade && @@ -180,7 +432,7 @@ export class Socket extends Emitter { } else if (0 === this.transports.length) { // Emit error on next tick so it can be listened to this.setTimeoutFn(() => { - this.emit("error", "No transports available") + this.emitReserved("error", "No transports available") }, 0) return } else { @@ -191,8 +443,8 @@ export class Socket extends Emitter { // Retry with the next transport if the transport is disabled (jsonp: false) try { transport = this.createTransport(transport) - } catch (error: any) { - debug("error while creating transport: %s", error) + } catch (e) { + debug("error while creating transport: %s", e) this.transports.shift() this.open() return @@ -207,7 +459,7 @@ export class Socket extends Emitter { * * @api private */ - setTransport(transport) { + private setTransport(transport) { debug("setting transport %s", transport.name) if (this.transport) { @@ -223,9 +475,7 @@ export class Socket extends Emitter { .on("drain", this.onDrain.bind(this)) .on("packet", this.onPacket.bind(this)) .on("error", this.onError.bind(this)) - .on("close", () => { - this.onClose("transport close") - }) + .on("close", reason => this.onClose("transport close", reason)) } /** @@ -234,9 +484,9 @@ export class Socket extends Emitter { * @param {String} transport name * @api private */ - probe(name) { + private probe(name) { debug('probing transport "%s"', name) - let transport = this.createTransport(name, { probe: 1 }) + let transport = this.createTransport(name) let failed = false Socket.priorWebsocketSuccess = false @@ -251,7 +501,7 @@ export class Socket extends Emitter { if ("pong" === msg.type && "probe" === msg.data) { debug('probe transport "%s" pong', name) this.upgrading = true - this.emit("upgrading", transport) + this.emitReserved("upgrading", transport) if (!transport) return Socket.priorWebsocketSuccess = "websocket" === transport.name @@ -265,16 +515,17 @@ export class Socket extends Emitter { this.setTransport(transport) transport.send([{ type: "upgrade" }]) - this.emit("upgrade", transport) + this.emitReserved("upgrade", transport) transport = null this.upgrading = false this.flush() }) } else { debug('probe transport "%s" failed', name) - const err: any = new Error("probe error") + const err = new Error("probe error") + // @ts-ignore err.transport = transport.name - this.emit("upgradeError", err) + this.emitReserved("upgradeError", err) } }) } @@ -293,14 +544,15 @@ export class Socket extends Emitter { // Handle any error that happens while probing const onerror = err => { - const error: any = new Error("probe error: " + err) + const error = new Error("probe error: " + err) + // @ts-ignore error.transport = transport.name freezeTransport() debug('probe transport "%s" failed because of error: %s', name, err) - this.emit("upgradeError", error) + this.emitReserved("upgradeError", error) } function onTransportClose() { @@ -325,8 +577,8 @@ export class Socket extends Emitter { transport.removeListener("open", onTransportOpen) transport.removeListener("error", onerror) transport.removeListener("close", onTransportClose) - this.removeListener("close", onclose) - this.removeListener("upgrading", onupgrade) + this.off("close", onclose) + this.off("upgrading", onupgrade) } transport.once("open", onTransportOpen) @@ -342,13 +594,13 @@ export class Socket extends Emitter { /** * Called when connection is deemed open. * - * @api public + * @api private */ - onOpen() { + private onOpen() { debug("socket open") this.readyState = "open" Socket.priorWebsocketSuccess = "websocket" === this.transport.name - this.emit("open") + this.emitReserved("open") this.flush() // we check for `readyState` in case an `open` @@ -372,7 +624,7 @@ export class Socket extends Emitter { * * @api private */ - onPacket(packet) { + private onPacket(packet) { if ( "opening" === this.readyState || "open" === this.readyState || @@ -380,10 +632,10 @@ export class Socket extends Emitter { ) { debug('socket receive: type "%s", data "%s"', packet.type, packet.data) - this.emit("packet", packet) + this.emitReserved("packet", packet) // Socket is live - any packet counts - this.emit("heartbeat") + this.emitReserved("heartbeat") switch (packet.type) { case "open": @@ -393,19 +645,20 @@ export class Socket extends Emitter { case "ping": this.resetPingTimeout() this.sendPacket("pong") - this.emit("ping") - this.emit("pong") + this.emitReserved("ping") + this.emitReserved("pong") break case "error": - const err: any = new Error("server error") + const err = new Error("server error") + // @ts-ignore err.code = packet.data this.onError(err) break case "message": - this.emit("data", packet.data) - this.emit("message", packet.data) + this.emitReserved("data", packet.data) + this.emitReserved("message", packet.data) break } } else { @@ -416,16 +669,17 @@ export class Socket extends Emitter { /** * Called upon handshake completion. * - * @param {Object} handshake obj + * @param {Object} data - handshake obj * @api private */ - onHandshake(data) { - this.emit("handshake", data) + private onHandshake(data) { + this.emitReserved("handshake", data) this.id = data.sid this.transport.query.sid = data.sid this.upgrades = this.filterUpgrades(data.upgrades) this.pingInterval = data.pingInterval this.pingTimeout = data.pingTimeout + this.maxPayload = data.maxPayload this.onOpen() // In case open handler closes socket if ("closed" === this.readyState) return @@ -437,7 +691,7 @@ export class Socket extends Emitter { * * @api private */ - resetPingTimeout() { + private resetPingTimeout() { this.clearTimeoutFn(this.pingTimeoutTimer) this.pingTimeoutTimer = this.setTimeoutFn(() => { this.onClose("ping timeout") @@ -452,7 +706,7 @@ export class Socket extends Emitter { * * @api private */ - onDrain() { + private onDrain() { this.writeBuffer.splice(0, this.prevBufferLen) // setting prevBufferLen = 0 is very important @@ -461,7 +715,7 @@ export class Socket extends Emitter { this.prevBufferLen = 0 if (0 === this.writeBuffer.length) { - this.emit("drain") + this.emitReserved("drain") } else { this.flush() } @@ -472,22 +726,53 @@ export class Socket extends Emitter { * * @api private */ - flush() { + private flush() { if ( "closed" !== this.readyState && this.transport.writable && !this.upgrading && this.writeBuffer.length ) { - debug("flushing %d packets in socket", this.writeBuffer.length) - this.transport.send(this.writeBuffer) + const packets = this.getWritablePackets() + debug("flushing %d packets in socket", packets.length) + this.transport.send(packets) // keep track of current length of writeBuffer // splice writeBuffer and callbackBuffer on `drain` - this.prevBufferLen = this.writeBuffer.length - this.emit("flush") + this.prevBufferLen = packets.length + this.emitReserved("flush") } } + /** + * Ensure the encoded size of the writeBuffer is below the maxPayload value sent by the server (only for HTTP + * long-polling) + * + * @private + */ + private getWritablePackets() { + const shouldCheckPayloadSize = + this.maxPayload && + this.transport.name === "polling" && + this.writeBuffer.length > 1 + if (!shouldCheckPayloadSize) { + return this.writeBuffer + } + let payloadSize = 1 // first packet type + for (let i = 0; i < this.writeBuffer.length; i++) { + const data = this.writeBuffer[i].data + if (data) { + payloadSize += byteLength(data) + } + if (i > 0 && payloadSize > this.maxPayload) { + debug("only send %d out of %d packets", i, this.writeBuffer.length) + return this.writeBuffer.slice(0, i) + } + payloadSize += 2 // separator + packet type + } + debug("payload size is %d (max: %d)", payloadSize, this.maxPayload) + return this.writeBuffer + } + /** * Sends a message. * @@ -497,12 +782,12 @@ export class Socket extends Emitter { * @return {Socket} for chaining. * @api public */ - write(msg, options, fn) { + public write(msg, options, fn?) { this.sendPacket("message", msg, options, fn) return this } - send(msg, options, fn) { + public send(msg, options, fn?) { this.sendPacket("message", msg, options, fn) return this } @@ -516,7 +801,7 @@ export class Socket extends Emitter { * @param {Function} callback function. * @api private */ - sendPacket(type, data?, options?, fn?) { + private sendPacket(type, data?, options?, fn?) { if ("function" === typeof data) { fn = data data = undefined @@ -539,7 +824,7 @@ export class Socket extends Emitter { data: data, options: options } - this.emit("packetCreate", packet) + this.emitReserved("packetCreate", packet) this.writeBuffer.push(packet) if (fn) this.once("flush", fn) this.flush() @@ -548,9 +833,9 @@ export class Socket extends Emitter { /** * Closes the connection. * - * @api private + * @api public */ - close() { + public close() { const close = () => { this.onClose("forced close") debug("socket closing - telling transport to close") @@ -558,8 +843,8 @@ export class Socket extends Emitter { } const cleanupAndClose = () => { - this.removeListener("upgrade", cleanupAndClose) - this.removeListener("upgradeError", cleanupAndClose) + this.off("upgrade", cleanupAndClose) + this.off("upgradeError", cleanupAndClose) close() } @@ -595,10 +880,10 @@ export class Socket extends Emitter { * * @api private */ - onError(err) { + private onError(err) { debug("socket error %j", err) Socket.priorWebsocketSuccess = false - this.emit("error", err) + this.emitReserved("error", err) this.onClose("transport error", err) } @@ -607,7 +892,7 @@ export class Socket extends Emitter { * * @api private */ - onClose(reason, desc?) { + private onClose(reason: string, description?: CloseDetails | Error) { if ( "opening" === this.readyState || "open" === this.readyState || @@ -616,7 +901,6 @@ export class Socket extends Emitter { debug('socket close with reason: "%s"', reason) // clear timers - this.clearTimeoutFn(this.pingIntervalTimer) this.clearTimeoutFn(this.pingTimeoutTimer) // stop event from firing again for transport @@ -629,6 +913,11 @@ export class Socket extends Emitter { this.transport.removeAllListeners() if (typeof removeEventListener === "function") { + removeEventListener( + "beforeunload", + this.beforeunloadEventListener, + false + ) removeEventListener("offline", this.offlineEventListener, false) } @@ -639,7 +928,7 @@ export class Socket extends Emitter { this.id = null // emit close event - this.emit("close", reason, desc) + this.emitReserved("close", reason, description) // clean buffers after, so users can still // grab the buffers on `close` event @@ -655,7 +944,7 @@ export class Socket extends Emitter { * @api private * */ - filterUpgrades(upgrades) { + private filterUpgrades(upgrades) { const filteredUpgrades = [] let i = 0 const j = upgrades.length @@ -666,23 +955,3 @@ export class Socket extends Emitter { return filteredUpgrades } } - -Socket.priorWebsocketSuccess = false - -/** - * Protocol version. - * - * @api public - */ - -Socket.protocol = parser.protocol // this is an int - -function clone(obj) { - const o = {} - for (let i in obj) { - if (obj.hasOwnProperty(i)) { - o[i] = obj[i] - } - } - return o -} diff --git a/packages/websocket/src/engine.io-client/transport.ts b/packages/websocket/src/engine.io-client/transport.ts index 03561387..1507115e 100644 --- a/packages/websocket/src/engine.io-client/transport.ts +++ b/packages/websocket/src/engine.io-client/transport.ts @@ -1,15 +1,59 @@ -import parser from "../engine.io-parser" -const Emitter = require("component-emitter") +// import { decodePacket, Packet, RawData } from "engine.io-parser" +import { decodePacket, Packet, RawData } from "../engine.io-parser" +import { Emitter } from "@socket.io/component-emitter" import { installTimerFunctions } from "./util" -const debug = (...args: any) => console.debug('engine.io-client:transport', ...args)//require("debug")("engine.io-client:transport") +// import debugModule from "debug"; // debug() +import { SocketOptions } from "./socket" + +// const debug = debugModule("engine.io-client:transport"); // debug() +const debug = require('../debug')("engine.io-client:transport") // debug() + +class TransportError extends Error { + public readonly type = "TransportError"; + + constructor( + reason: string, + readonly description: any, + readonly context: any + ) { + super(reason) + } +} + +export interface CloseDetails { + description: string + context?: CloseEvent | XMLHttpRequest +} + +interface TransportReservedEvents { + open: () => void + error: (err: TransportError) => void + packet: (packet: Packet) => void + close: (details?: CloseDetails) => void + poll: () => void + pollComplete: () => void + drain: () => void +} + +export abstract class Transport extends Emitter< + {}, + {}, + TransportReservedEvents +> { + protected opts: SocketOptions + protected supportsBinary: boolean + protected query: object + protected readyState: string + protected writable: boolean = false; + protected socket: any + protected setTimeoutFn: typeof setTimeout -export class Transport extends Emitter { /** - * Transport abstract constructor. - * - * @param {Object} options. - * @api private - */ + * Transport abstract constructor. + * + * @param {Object} options. + * @api private + */ constructor(opts) { super() installTimerFunctions(this, opts) @@ -23,15 +67,17 @@ export class Transport extends Emitter { /** * Emits an error. * - * @param {String} str + * @param {String} reason + * @param description + * @param context - the error context * @return {Transport} for chaining - * @api public + * @api protected */ - onError(msg, desc) { - const err: any = new Error(msg) - err.type = "TransportError" - err.description = desc - this.emit("error", err) + protected onError(reason: string, description: any, context?: any) { + super.emitReserved( + "error", + new TransportError(reason, description, context) + ) return this } @@ -40,7 +86,7 @@ export class Transport extends Emitter { * * @api public */ - open() { + private open() { if ("closed" === this.readyState || "" === this.readyState) { this.readyState = "opening" this.doOpen() @@ -52,9 +98,9 @@ export class Transport extends Emitter { /** * Closes the transport. * - * @api private + * @api public */ - close() { + public close() { if ("opening" === this.readyState || "open" === this.readyState) { this.doClose() this.onClose() @@ -64,12 +110,12 @@ export class Transport extends Emitter { } /** - * Sends multiple packets. - * - * @param {Array} packets - * @api private - */ - send(packets) { + * Sends multiple packets. + * + * @param {Array} packets + * @api public + */ + public send(packets) { if ("open" === this.readyState) { this.write(packets) } else { @@ -81,39 +127,45 @@ export class Transport extends Emitter { /** * Called upon open * - * @api private + * @api protected */ - onOpen() { + protected onOpen() { this.readyState = "open" this.writable = true - this.emit("open") + super.emitReserved("open") } /** * Called with data. * * @param {String} data - * @api private + * @api protected */ - onData(data) { - const packet = parser.decodePacket(data, this.socket.binaryType) + protected onData(data: RawData) { + const packet = decodePacket(data, this.socket.binaryType) this.onPacket(packet) } /** * Called with a decoded packet. + * + * @api protected */ - onPacket(packet) { - this.emit("packet", packet) + protected onPacket(packet: Packet) { + super.emitReserved("packet", packet) } /** * Called upon close. * - * @api private + * @api protected */ - onClose() { + protected onClose(details?: CloseDetails) { this.readyState = "closed" - this.emit("close") + super.emitReserved("close", details) } + + protected abstract doOpen() + protected abstract doClose() + protected abstract write(packets) } diff --git a/packages/websocket/src/engine.io-client/transports/index.ts b/packages/websocket/src/engine.io-client/transports/index.ts index c279d9d8..38909ca0 100755 --- a/packages/websocket/src/engine.io-client/transports/index.ts +++ b/packages/websocket/src/engine.io-client/transports/index.ts @@ -1,4 +1,5 @@ -import { WS } from "./websocket" -export default { - 'websocket': WS +import { WS } from "./websocket.js" + +export const transports = { + websocket: WS, } diff --git a/packages/websocket/src/engine.io-client/transports/websocket-constructor.ts b/packages/websocket/src/engine.io-client/transports/websocket-constructor.ts new file mode 100644 index 00000000..2be3cb2f --- /dev/null +++ b/packages/websocket/src/engine.io-client/transports/websocket-constructor.ts @@ -0,0 +1,6 @@ +import { WebSocket as ws } from "../../client" + +export const WebSocket = ws +export const usingBrowserWebSocket = false +export const defaultBinaryType = "nodebuffer" +export const nextTick = process.nextTick diff --git a/packages/websocket/src/engine.io-client/transports/websocket.ts b/packages/websocket/src/engine.io-client/transports/websocket.ts index 78b63a3e..25880e07 100644 --- a/packages/websocket/src/engine.io-client/transports/websocket.ts +++ b/packages/websocket/src/engine.io-client/transports/websocket.ts @@ -1,21 +1,18 @@ -import { Transport } from '../transport' -// const Transport = require("../transport") -import parser from '../../engine.io-parser' -// const parser = require("../engine.io-parser") -const parseqs = require("parseqs") -const yeast = require("yeast") -import { pick } from '../util' -// const { pick } = require("../util") -import { WebSocket } from '../../client' -const usingBrowserWebSocket = true -// const { -// WebSocket, -// usingBrowserWebSocket, -// defaultBinaryType, -// nextTick -// } = require("./websocket-constructor") +import { Transport } from "../transport" +import { encode } from "../contrib/parseqs" +import { yeast } from "../contrib/yeast" +import { pick } from "../util" +import { + defaultBinaryType, + nextTick, + usingBrowserWebSocket, + WebSocket +} from "./websocket-constructor" +// import debugModule from "debug" // debug() +import { encodePacket } from "../../engine.io-parser" -const debug = (...args: any) => console.debug('engine.io-client:websocket', ...args)//require("debug")("engine.io-client:websocket") +// const debug = debugModule("engine.io-client:websocket") // debug() +const debug = (...args: any) => console.debug('engine.io-client:websocket', ...args) // detect ReactNative environment const isReactNative = @@ -24,6 +21,8 @@ const isReactNative = navigator.product.toLowerCase() === "reactnative" export class WS extends Transport { + private ws: any + /** * WebSocket transport constructor. * @@ -86,17 +85,17 @@ export class WS extends Transport { } try { - this.ws = new WebSocket(uri, protocols) - // usingBrowserWebSocket && !isReactNative - // ? protocols - // ? new WebSocket(uri, protocols) - // : new WebSocket(uri) - // : new WebSocket(uri, protocols, opts) - } catch (err) { - return this.emit("error", err) + this.ws = + usingBrowserWebSocket && !isReactNative + ? protocols + ? new WebSocket(uri, protocols) + : new WebSocket(uri) + : new WebSocket(uri, protocols, opts) + } catch (err: any) { + return this.emitReserved("error", err) } - this.ws.binaryType = this.socket.binaryType || 'arraybuffer' + this.ws.binaryType = this.socket.binaryType || defaultBinaryType this.addEventListeners() } @@ -113,7 +112,11 @@ export class WS extends Transport { } this.onOpen() } - this.ws.onclose = this.onClose.bind(this) + this.ws.onclose = closeEvent => + this.onClose({ + description: "websocket connection closed", + context: closeEvent + }) this.ws.onmessage = ev => this.onData(ev.data) this.ws.onerror = e => this.onError("websocket error", e) } @@ -133,9 +136,9 @@ export class WS extends Transport { const packet = packets[i] const lastPacket = i === packets.length - 1 - parser.encodePacket(packet, this.supportsBinary, data => { + encodePacket(packet, this.supportsBinary, data => { // always create a new object (GH-437) - const opts: any = {} + const opts: { compress?: boolean } = {} if (!usingBrowserWebSocket) { if (packet.options) { opts.compress = packet.options.compress @@ -143,6 +146,7 @@ export class WS extends Transport { if (this.opts.perMessageDeflate) { const len = + // @ts-ignore "string" === typeof data ? Buffer.byteLength(data) : data.length if (len < this.opts.perMessageDeflate.threshold) { opts.compress = false @@ -160,31 +164,22 @@ export class WS extends Transport { } else { this.ws.send(data, opts) } - } catch (error: any) { + } catch (e) { debug("websocket closed before onclose event") } if (lastPacket) { // fake drain // defer to next tick to allow Socket to clear writeBuffer - process.nextTick(() => { + nextTick(() => { this.writable = true - this.emit("drain") + this.emitReserved("drain") }, this.setTimeoutFn) } }) } } - /** - * Called upon close - * - * @api private - */ - onClose() { - Transport.prototype.onClose.call(this) - } - /** * Closes socket. * @@ -203,7 +198,7 @@ export class WS extends Transport { * @api private */ uri() { - let query = this.query || {} + let query: { b64?: number } = this.query || {} const schema = this.opts.secure ? "wss" : "ws" let port = "" @@ -226,21 +221,16 @@ export class WS extends Transport { query.b64 = 1 } - query = parseqs.encode(query) - - // prepend ? to query - if (query.length) { - query = "?" + query - } - + const encodedQuery = encode(query) const ipv6 = this.opts.hostname.indexOf(":") !== -1 + return ( schema + "://" + (ipv6 ? "[" + this.opts.hostname + "]" : this.opts.hostname) + port + this.opts.path + - query + (encodedQuery.length ? "?" + encodedQuery : "") ) } @@ -251,9 +241,6 @@ export class WS extends Transport { * @api public */ check() { - return ( - !!WebSocket && - !("__initialize" in WebSocket && this.name === WS.prototype.name) - ) + return !!WebSocket } } diff --git a/packages/websocket/src/engine.io-client/util.ts b/packages/websocket/src/engine.io-client/util.ts index 878b0c91..528ccbad 100644 --- a/packages/websocket/src/engine.io-client/util.ts +++ b/packages/websocket/src/engine.io-client/util.ts @@ -1,4 +1,6 @@ -const pick = (obj, ...attr) => { +// import { globalThisShim as globalThis } from "./globalThis.js" + +export function pick(obj, ...attr) { return attr.reduce((acc, k) => { if (obj.hasOwnProperty(k)) { acc[k] = obj[k] @@ -11,7 +13,7 @@ const pick = (obj, ...attr) => { const NATIVE_SET_TIMEOUT = setTimeout const NATIVE_CLEAR_TIMEOUT = clearTimeout -const installTimerFunctions = (obj, opts) => { +export function installTimerFunctions(obj, opts) { if (opts.useNativeTimers) { obj.setTimeoutFn = NATIVE_SET_TIMEOUT.bind(globalThis) obj.clearTimeoutFn = NATIVE_CLEAR_TIMEOUT.bind(globalThis) @@ -20,4 +22,34 @@ const installTimerFunctions = (obj, opts) => { obj.clearTimeoutFn = clearTimeout.bind(globalThis) } } -export { pick, installTimerFunctions } + +// base64 encoded buffers are about 33% bigger (https://en.wikipedia.org/wiki/Base64) +const BASE64_OVERHEAD = 1.33 + +// we could also have used `new Blob([obj]).size`, but it isn't supported in IE9 +export function byteLength(obj) { + if (typeof obj === "string") { + return utf8Length(obj) + } + // arraybuffer or blob + return Math.ceil((obj.byteLength || obj.size) * BASE64_OVERHEAD) +} + +function utf8Length(str) { + let c = 0, + length = 0 + for (let i = 0, l = str.length; i < l; i++) { + c = str.charCodeAt(i) + if (c < 0x80) { + length += 1 + } else if (c < 0x800) { + length += 2 + } else if (c < 0xd800 || c >= 0xe000) { + length += 3 + } else { + i++ + length += 4 + } + } + return length +} diff --git a/packages/websocket/src/engine.io-parser/commons.ts b/packages/websocket/src/engine.io-parser/commons.ts index 9928c92b..e4072249 100644 --- a/packages/websocket/src/engine.io-parser/commons.ts +++ b/packages/websocket/src/engine.io-parser/commons.ts @@ -12,10 +12,28 @@ Object.keys(PACKET_TYPES).forEach(key => { PACKET_TYPES_REVERSE[PACKET_TYPES[key]] = key }) -const ERROR_PACKET = { type: "error", data: "parser error" } +const ERROR_PACKET: Packet = { type: "error", data: "parser error" } -export = { - PACKET_TYPES, - PACKET_TYPES_REVERSE, - ERROR_PACKET +export { PACKET_TYPES, PACKET_TYPES_REVERSE, ERROR_PACKET } + +export type PacketType = + | "open" + | "close" + | "ping" + | "pong" + | "message" + | "upgrade" + | "noop" + | "error" + +// RawData should be "string | Buffer | ArrayBuffer | ArrayBufferView | Blob", but Blob does not exist in Node.js and +// requires to add the dom lib in tsconfig.json +export type RawData = any + +export interface Packet { + type: PacketType + options?: { compress: boolean } + data?: RawData } + +export type BinaryType = "nodebuffer" | "arraybuffer" | "blob" diff --git a/packages/websocket/src/engine.io-parser/decodePacket.ts b/packages/websocket/src/engine.io-parser/decodePacket.ts index 9387ffff..427abf6a 100644 --- a/packages/websocket/src/engine.io-parser/decodePacket.ts +++ b/packages/websocket/src/engine.io-parser/decodePacket.ts @@ -1,6 +1,15 @@ -const { PACKET_TYPES_REVERSE, ERROR_PACKET } = require("./commons") +import { + ERROR_PACKET, + PACKET_TYPES_REVERSE, + Packet, + BinaryType, + RawData +} from "./commons.js" -export const decodePacket = (encodedPacket, binaryType) => { +const decodePacket = ( + encodedPacket: RawData, + binaryType?: BinaryType +): Packet => { if (typeof encodedPacket !== "string") { return { type: "message", @@ -28,17 +37,18 @@ export const decodePacket = (encodedPacket, binaryType) => { } } -const mapBinary = (data, binaryType) => { +const mapBinary = (data: RawData, binaryType?: BinaryType) => { + const isBuffer = Buffer.isBuffer(data) switch (binaryType) { case "arraybuffer": - return Buffer.isBuffer(data) ? toArrayBuffer(data) : data + return isBuffer ? toArrayBuffer(data) : data case "nodebuffer": default: return data // assuming the data is already a Buffer } } -const toArrayBuffer = buffer => { +const toArrayBuffer = (buffer: Buffer): ArrayBuffer => { const arrayBuffer = new ArrayBuffer(buffer.length) const view = new Uint8Array(arrayBuffer) for (let i = 0; i < buffer.length; i++) { @@ -46,3 +56,5 @@ const toArrayBuffer = buffer => { } return arrayBuffer } + +export default decodePacket diff --git a/packages/websocket/src/engine.io-parser/encodePacket.ts b/packages/websocket/src/engine.io-parser/encodePacket.ts index a5c16491..1500b0dd 100644 --- a/packages/websocket/src/engine.io-parser/encodePacket.ts +++ b/packages/websocket/src/engine.io-parser/encodePacket.ts @@ -1,7 +1,10 @@ -const { PACKET_TYPES } = require("./commons") +import { PACKET_TYPES, Packet, RawData } from "./commons.js" -export const encodePacket = ({ type, data }, supportsBinary, callback) => { - console.trace('encodePacket', type, JSON.stringify(data)) +const encodePacket = ( + { type, data }: Packet, + supportsBinary: boolean, + callback: (encodedPacket: RawData) => void +) => { if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) { const buffer = toBuffer(data) return callback(encodeBuffer(buffer, supportsBinary)) @@ -21,6 +24,8 @@ const toBuffer = data => { } // only 'message' packets can contain binary, so the type prefix is not needed -const encodeBuffer = (data, supportsBinary) => { +const encodeBuffer = (data: Buffer, supportsBinary: boolean): RawData => { return supportsBinary ? data : "b" + data.toString("base64") } + +export default encodePacket diff --git a/packages/websocket/src/engine.io-parser/index.ts b/packages/websocket/src/engine.io-parser/index.ts index d68fed36..63228f7b 100644 --- a/packages/websocket/src/engine.io-parser/index.ts +++ b/packages/websocket/src/engine.io-parser/index.ts @@ -1,9 +1,13 @@ -import { encodePacket } from "./encodePacket" -import { decodePacket } from "./decodePacket" +import encodePacket from "./encodePacket.js" +import decodePacket from "./decodePacket.js" +import { Packet, PacketType, RawData, BinaryType } from "./commons.js" const SEPARATOR = String.fromCharCode(30) // see https://en.wikipedia.org/wiki/Delimiter#ASCII_delimited_text -const encodePayload = (packets, callback) => { +const encodePayload = ( + packets: Packet[], + callback: (encodedPayload: string) => void +) => { // some packets may be added to the array while encoding, so the initial length must be saved const length = packets.length const encodedPackets = new Array(length) @@ -20,7 +24,10 @@ const encodePayload = (packets, callback) => { }) } -const decodePayload = (encodedPayload, binaryType) => { +const decodePayload = ( + encodedPayload: string, + binaryType?: BinaryType +): Packet[] => { const encodedPackets = encodedPayload.split(SEPARATOR) const packets = [] for (let i = 0; i < encodedPackets.length; i++) { @@ -33,10 +40,14 @@ const decodePayload = (encodedPayload, binaryType) => { return packets } -export default { - protocol: 4, +export const protocol = 4 +export { encodePacket, encodePayload, decodePacket, - decodePayload + decodePayload, + Packet, + PacketType, + RawData, + BinaryType } diff --git a/packages/websocket/src/engine.io/index.ts b/packages/websocket/src/engine.io/index.ts index 4be68afd..888544b5 100644 --- a/packages/websocket/src/engine.io/index.ts +++ b/packages/websocket/src/engine.io/index.ts @@ -1,10 +1,45 @@ +// import { createServer } from "http" +import { Server, AttachOptions, ServerOptions } from "./server" +import transports from "./transports/index" +import * as parser from "../engine.io-parser" + +// export { Server, transports, listen, attach, parser } +export { Server, transports, attach, parser } +export { AttachOptions, ServerOptions } from "./server" +// export { uServer } from "./userver"; +export { Socket } from "./socket" +export { Transport } from "./transport" +export const protocol = parser.protocol + /** - * Module dependencies. + * Creates an http.Server exclusively used for WS upgrades. + * + * @param {Number} port + * @param {Function} callback + * @param {Object} options + * @return {Server} websocket.io server + * @api public */ -// const http = require("http") -// const Server = require("./server") -import { Server } from './server' +// function listen(port, options: AttachOptions & ServerOptions, fn) { +// if ("function" === typeof options) { +// fn = options; +// options = {}; +// } + +// const server = createServer(function(req, res) { +// res.writeHead(501); +// res.end("Not Implemented"); +// }); + +// // create engine server +// const engine = attach(server, options); +// engine.httpServer = server; + +// server.listen(port, fn); + +// return engine; +// } /** * Captures upgrade requests for a http.Server. @@ -15,12 +50,8 @@ import { Server } from './server' * @api public */ -function attach(srv, options) { +function attach(server, options: AttachOptions & ServerOptions) { const engine = new Server(options) - engine.attach(srv, options) + engine.attach(server, options) return engine } - -export = { - attach -} diff --git a/packages/websocket/src/engine.io/parser-v3/index.ts b/packages/websocket/src/engine.io/parser-v3/index.ts new file mode 100644 index 00000000..67605ed9 --- /dev/null +++ b/packages/websocket/src/engine.io/parser-v3/index.ts @@ -0,0 +1,484 @@ +// imported from https://github.com/socketio/engine.io-parser/tree/2.2.x + +/** + * Module dependencies. + */ + +var utf8 = require('./utf8') + +/** + * Current protocol version. + */ +export const 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. + */ + +export const 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 + */ + +export function encodePacket(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 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 + */ + +export function encodeBase64Packet(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 + */ + +export function decodePacket(data, binaryType, utf8decode) { + if (data === undefined) { + return err + } + + var type + + // String data + if (typeof data === 'string') { + + type = data.charAt(0) + + if (type === 'b') { + return decodeBase64Packet(data.slice(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.slice(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) + */ + +export function decodeBase64Packet(msg, binaryType) { + var type = packetslist[msg.charAt(0)] + var data = Buffer.from(msg.slice(1), 'base64') + if (binaryType === 'arraybuffer') { + var abv = new Uint8Array(data.length) + for (var i = 0; i < abv.length; i++) { + abv[i] = data[i] + } + // @ts-ignore + 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 + */ + +export function encodePayload(packets, supportsBinary, callback) { + if (typeof supportsBinary === 'function') { + callback = supportsBinary + supportsBinary = null + } + + if (supportsBinary && hasBinary(packets)) { + return encodePayloadAsBinary(packets, callback) + } + + if (!packets.length) { + return callback('0:') + } + + function encodeOne(packet, doneCallback) { + 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 + */ + +export function decodePayload(data, binaryType, callback) { + if (typeof data !== 'string') { + return 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 + } + + // @ts-ignore + if (length === '' || (length != (n = Number(length)))) { + // parser error - ignoring payload + return callback(err, 0, 1) + } + + msg = data.slice(i + 1, i + 1 + n) + + if (length != msg.length) { + // parser error - ignoring payload + return callback(err, 0, 1) + } + + if (msg.length) { + packet = 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 + */ + +export function encodePayloadAsBinary(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])) + } + + 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 + */ + +export function decodePayloadAsBinary(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(decodePacket(buffer, binaryType, true), i, total) + } +} diff --git a/packages/websocket/src/engine.io/parser-v3/utf8.ts b/packages/websocket/src/engine.io/parser-v3/utf8.ts new file mode 100644 index 00000000..65391b8b --- /dev/null +++ b/packages/websocket/src/engine.io/parser-v3/utf8.ts @@ -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/packages/websocket/src/engine.io/server.ts b/packages/websocket/src/engine.io/server.ts index ad921452..e298df96 100644 --- a/packages/websocket/src/engine.io/server.ts +++ b/packages/websocket/src/engine.io/server.ts @@ -1,52 +1,141 @@ -const qs = require("querystring") -const parse = require("url").parse +import * as qs from "querystring" +import { parse } from "url" // const base64id = require("base64id") -import transports from './transports' -import { EventEmitter } from 'events' -// const EventEmitter = require("events").EventEmitter -import { Socket } from './socket' -// const debug = require("debug")("engine") -const debug = function (...args) { } -// const cookieMod = require("cookie") - -// const DEFAULT_WS_ENGINE = require("ws").Server; -import { WebSocketServer } from '../server' -import { Transport } from './transport' -const DEFAULT_WS_ENGINE = WebSocketServer +import transports from "./transports" +import { EventEmitter } from "events" +import { Socket } from "./socket" +// import debugModule from "debug" +// import { serialize } from "cookie" +// import { Server as DEFAULT_WS_ENGINE } from "ws" +import { WebSocketServer as DEFAULT_WS_ENGINE } from "../server" +// import { IncomingMessage, Server as HttpServer } from "http" +// import { CookieSerializeOptions } from "cookie" +// import { CorsOptions, CorsOptionsDelegate } from "cors" +// const debug = debugModule("engine"); +const debug = require("../debug")("engine") import { Request } from '../server/request' import { WebSocketClient } from '../server/client' -export class Server extends EventEmitter { - public static errors = { - UNKNOWN_TRANSPORT: 0, - UNKNOWN_SID: 1, - BAD_HANDSHAKE_METHOD: 2, - BAD_REQUEST: 3, - FORBIDDEN: 4, - UNSUPPORTED_PROTOCOL_VERSION: 5 - } +type Transport = "polling" | "websocket" - public static errorMessages = { - 0: "Transport unknown", - 1: "Session ID unknown", - 2: "Bad handshake method", - 3: "Bad request", - 4: "Forbidden", - 5: "Unsupported protocol version" - } +export interface AttachOptions { + /** + * name of the path to capture + * @default "/engine.io" + */ + path?: string + /** + * destroy unhandled upgrade requests + * @default true + */ + destroyUpgrade?: boolean + /** + * milliseconds after which unhandled requests are ended + * @default 1000 + */ + destroyUpgradeTimeout?: number +} - private clients = {} - private clientsCount = 0 - public opts: any +export interface ServerOptions { + /** + * how many ms without a pong packet to consider the connection closed + * @default 20000 + */ + pingTimeout?: number + /** + * how many ms before sending a new ping packet + * @default 25000 + */ + pingInterval?: number + /** + * how many ms before an uncompleted transport upgrade is cancelled + * @default 10000 + */ + upgradeTimeout?: number + /** + * how many bytes or characters a message can be, before closing the session (to avoid DoS). + * @default 1e5 (100 KB) + */ + maxHttpBufferSize?: number + /** + * A function that receives a given handshake or upgrade request as its first parameter, + * and can decide whether to continue or not. The second argument is a function that needs + * to be called with the decided information: fn(err, success), where success is a boolean + * value where false means that the request is rejected, and err is an error code. + */ + // allowRequest?: ( + // req: IncomingMessage, + // fn: (err: string | null | undefined, success: boolean) => void + // ) => void + /** + * the low-level transports that are enabled + * @default ["polling", "websocket"] + */ + transports?: Transport[] + /** + * whether to allow transport upgrades + * @default true + */ + allowUpgrades?: boolean + /** + * parameters of the WebSocket permessage-deflate extension (see ws module api docs). Set to false to disable. + * @default false + */ + perMessageDeflate?: boolean | object + /** + * parameters of the http compression for the polling transports (see zlib api docs). Set to false to disable. + * @default true + */ + httpCompression?: boolean | object + /** + * what WebSocket server implementation to use. Specified module must + * conform to the ws interface (see ws module api docs). + * An alternative c++ addon is also available by installing eiows module. + * + * @default `require("ws").Server` + */ + wsEngine?: any + /** + * an optional packet which will be concatenated to the handshake packet emitted by Engine.IO. + */ + initialPacket?: any + /** + * configuration of the cookie that contains the client sid to send as part of handshake response headers. This cookie + * might be used for sticky-session. Defaults to not sending any cookie. + * @default false + */ + cookie?: (/*CookieSerializeOptions & */{ name: string }) | boolean + /** + * the options that will be forwarded to the cors module + */ + // cors?: CorsOptions | CorsOptionsDelegate + /** + * whether to enable compatibility with Socket.IO v2 clients + * @default false + */ + allowEIO3?: boolean +} - private corsMiddleware: any +export abstract class BaseServer extends EventEmitter { + public opts: ServerOptions - private ws: any - private perMessageDeflate: any + protected clients: any + private clientsCount: number + protected corsMiddleware: Function - constructor(opts: any = {}) { + /** + * Server constructor. + * + * @param {Object} opts - options + * @api public + */ + constructor(opts: ServerOptions = {}) { super() + + this.clients = {} + this.clientsCount = 0 + this.opts = Object.assign( { wsEngine: DEFAULT_WS_ENGINE, @@ -70,6 +159,7 @@ export class Server extends EventEmitter { // { // name: "io", // path: "/", + // // @ts-ignore // httpOnly: opts.cookie.path !== false, // sameSite: "lax" // }, @@ -93,42 +183,7 @@ export class Server extends EventEmitter { // this.init() } - // /** - // * Initialize websocket server - // * - // * @api private - // */ - // init() { - // if (!~this.opts.transports.indexOf("websocket")) return - - // if (this.ws) this.ws.close() - - // this.ws = new this.opts.wsEngine({ - // noServer: true, - // clientTracking: false, - // perMessageDeflate: this.opts.perMessageDeflate, - // maxPayload: this.opts.maxHttpBufferSize - // }) - - // if (typeof this.ws.on === "function") { - // this.ws.on("headers", (headersArray, req) => { - // // note: 'ws' uses an array of headers, while Engine.IO uses an object (response.writeHead() accepts both formats) - // // we could also try to parse the array and then sync the values, but that will be error-prone - // const additionalHeaders = {} - - // const isInitialRequest = !req._query.sid - // if (isInitialRequest) { - // this.emit("initial_headers", additionalHeaders, req) - // } - - // this.emit("headers", additionalHeaders, req) - - // Object.keys(additionalHeaders).forEach(key => { - // headersArray.push(`${key}: ${additionalHeaders[key]}`) - // }) - // }) - // } - // } + protected abstract init() /** * Returns a list of available transports for upgrade given a certain transport. @@ -136,7 +191,7 @@ export class Server extends EventEmitter { * @return {Array} * @api public */ - upgrades(transport): Array { + public upgrades(transport) { if (!this.opts.allowUpgrades) return [] return transports[transport].upgradesTo || [] } @@ -148,7 +203,7 @@ export class Server extends EventEmitter { // * @return {Boolean} whether the request is valid // * @api private // */ - // verify(req, upgrade, fn) { + // protected verify(req, upgrade, fn) { // // transport check // const transport = req._query.transport // if (!~this.opts.transports.indexOf(transport)) { @@ -194,6 +249,13 @@ export class Server extends EventEmitter { // }) // } + // if (transport === "websocket" && !upgrade) { + // debug("invalid transport upgrade") + // return fn(Server.errors.BAD_REQUEST, { + // name: "TRANSPORT_HANDSHAKE_ERROR" + // }) + // } + // if (!this.opts.allowRequest) return fn() // return this.opts.allowRequest(req, (message, success) => { @@ -209,36 +271,234 @@ export class Server extends EventEmitter { // fn() // } - /** - * Prepares a request by processing the query string. - * - * @api private - */ - prepare(req) { - // try to leverage pre-existing `req._query` (e.g: from connect) - if (!req._query) { - req._query = ~req.url.indexOf("?") ? qs.parse(parse(req.url).query) : {} - } - } - /** * Closes all clients. * * @api public */ - close() { + public close() { debug("closing all open clients") for (let i in this.clients) { if (this.clients.hasOwnProperty(i)) { this.clients[i].close(true) } } + this.cleanup() + return this + } + + protected abstract cleanup() + + /** + * generate a socket id. + * Overwrite this method to generate your custom socket id + * + * @param {Object} request object + * @api public + */ + public generateId(req) { + // return base64id.generateId() + return req.id + } + + /** + * Handshakes a new client. + * + * @param {String} transport name + * @param {Object} request object + * @param {Function} closeConnection + * + * @api protected + */ + // protected async handshake(transportName, req, closeConnection) { + // @java-patch sync handshake + protected handshake(transportName, req, closeConnection) { + const protocol = req._query.EIO === "4" ? 4 : 3 // 3rd revision by default + if (protocol === 3 && !this.opts.allowEIO3) { + debug("unsupported protocol version") + this.emit("connection_error", { + req, + code: Server.errors.UNSUPPORTED_PROTOCOL_VERSION, + message: + Server.errorMessages[Server.errors.UNSUPPORTED_PROTOCOL_VERSION], + context: { + protocol + } + }) + closeConnection(Server.errors.UNSUPPORTED_PROTOCOL_VERSION) + return + } + + let id + try { + id = this.generateId(req) + } catch (e) { + debug("error while generating an id") + this.emit("connection_error", { + req, + code: Server.errors.BAD_REQUEST, + message: Server.errorMessages[Server.errors.BAD_REQUEST], + context: { + name: "ID_GENERATION_ERROR", + error: e + } + }) + closeConnection(Server.errors.BAD_REQUEST) + return + } + + debug('handshaking client "%s"', id) + + try { + var transport = this.createTransport(transportName, req) + if ("websocket" !== transportName) { + throw new Error('Unsupport polling at MiaoScript!') + } + // if ("polling" === transportName) { + // transport.maxHttpBufferSize = this.opts.maxHttpBufferSize + // transport.httpCompression = this.opts.httpCompression + // } else if ("websocket" === transportName) { + transport.perMessageDeflate = this.opts.perMessageDeflate + // } + + if (req._query && req._query.b64) { + transport.supportsBinary = false + } else { + transport.supportsBinary = true + } + } catch (e) { + debug('error handshaking to transport "%s"', transportName) + this.emit("connection_error", { + req, + code: Server.errors.BAD_REQUEST, + message: Server.errorMessages[Server.errors.BAD_REQUEST], + context: { + name: "TRANSPORT_HANDSHAKE_ERROR", + error: e + } + }) + closeConnection(Server.errors.BAD_REQUEST) + return + } + const socket = new Socket(id, this, transport, req, protocol) + + transport.on("headers", (headers, req) => { + const isInitialRequest = !req._query.sid + + if (isInitialRequest) { + if (this.opts.cookie) { + headers["Set-Cookie"] = [ + // serialize(this.opts.cookie.name, id, this.opts.cookie) + ] + } + this.emit("initial_headers", headers, req) + } + this.emit("headers", headers, req) + }) + + transport.onRequest(req) + + this.clients[id] = socket + this.clientsCount++ + + socket.once("close", () => { + delete this.clients[id] + this.clientsCount-- + }) + + this.emit("connection", socket) + + return transport + } + + protected abstract createTransport(transportName, req) + + /** + * Protocol errors mappings. + */ + + static errors = { + UNKNOWN_TRANSPORT: 0, + UNKNOWN_SID: 1, + BAD_HANDSHAKE_METHOD: 2, + BAD_REQUEST: 3, + FORBIDDEN: 4, + UNSUPPORTED_PROTOCOL_VERSION: 5 + }; + + static errorMessages = { + 0: "Transport unknown", + 1: "Session ID unknown", + 2: "Bad handshake method", + 3: "Bad request", + 4: "Forbidden", + 5: "Unsupported protocol version" + }; +} + +export class Server extends BaseServer { + // public httpServer?: HttpServer + private ws: any + + /** + * Initialize websocket server + * + * @api protected + */ + protected init() { + if (!~this.opts.transports.indexOf("websocket")) return + + if (this.ws) this.ws.close() + + this.ws = new this.opts.wsEngine({ + noServer: true, + clientTracking: false, + perMessageDeflate: this.opts.perMessageDeflate, + maxPayload: this.opts.maxHttpBufferSize + }) + + if (typeof this.ws.on === "function") { + this.ws.on("headers", (headersArray, req) => { + // note: 'ws' uses an array of headers, while Engine.IO uses an object (response.writeHead() accepts both formats) + // we could also try to parse the array and then sync the values, but that will be error-prone + const additionalHeaders = {} + + const isInitialRequest = !req._query.sid + if (isInitialRequest) { + this.emit("initial_headers", additionalHeaders, req) + } + + this.emit("headers", additionalHeaders, req) + + Object.keys(additionalHeaders).forEach(key => { + headersArray.push(`${key}: ${additionalHeaders[key]}`) + }) + }) + } + } + + protected cleanup() { if (this.ws) { debug("closing webSocketServer") this.ws.close() // don't delete this.ws because it can be used again if the http server starts listening again } - return this + } + + /** + * Prepares a request by processing the query string. + * + * @api private + */ + private prepare(req) { + // try to leverage pre-existing `req._query` (e.g: from connect) + if (!req._query) { + req._query = ~req.url.indexOf("?") ? qs.parse(parse(req.url).query) : {} + } + } + + protected createTransport(transportName, req) { + return new transports[transportName](req) } // /** @@ -248,7 +508,7 @@ export class Server extends EventEmitter { // * @param {http.ServerResponse|http.OutgoingMessage} response // * @api public // */ - // handleRequest(req, res) { + // public handleRequest(req, res) { // debug('handling "%s" http request "%s"', req.method, req.url) // this.prepare(req) // req.res = res @@ -284,131 +544,12 @@ export class Server extends EventEmitter { // } // } - /** - * generate a socket id. - * Overwrite this method to generate your custom socket id - * - * @param {Object} request object - * @api public - */ - generateId(req) { - return req.id - } - - /** - * Handshakes a new client. - * - * @param {String} transport name - * @param {Object} request object - * @param {Function} closeConnection - * - * @api private - */ - // @java-patch sync handshake - handshake(transportName, req, closeConnection: (code: number) => void) { - console.debug('engine.io server handshake transport', transportName, 'from', req.url) - const protocol = req._query.EIO === "4" ? 4 : 3 // 3rd revision by default - if (protocol === 3 && !this.opts.allowEIO3) { - debug("unsupported protocol version") - this.emit("connection_error", { - req, - code: Server.errors.UNSUPPORTED_PROTOCOL_VERSION, - message: - Server.errorMessages[Server.errors.UNSUPPORTED_PROTOCOL_VERSION], - context: { - protocol - } - }) - closeConnection(Server.errors.UNSUPPORTED_PROTOCOL_VERSION) - return - } - - let id - try { - id = this.generateId(req) - } catch (error: any) { - console.debug("error while generating an id") - this.emit("connection_error", { - req, - code: Server.errors.BAD_REQUEST, - message: Server.errorMessages[Server.errors.BAD_REQUEST], - context: { - name: "ID_GENERATION_ERROR", - error - } - }) - closeConnection(Server.errors.BAD_REQUEST) - return - } - - console.debug('engine.io server handshaking client "' + id + '"') - - try { - var transport: Transport = new transports[transportName](req) - if ("websocket" !== transportName) { - throw new Error('Unsupport polling at MiaoScript!') - } - // if ("polling" === transportName) { - // transport.maxHttpBufferSize = this.opts.maxHttpBufferSize - // transport.httpCompression = this.opts.httpCompression - // } else if ("websocket" === transportName) { - transport.perMessageDeflate = this.opts.perMessageDeflate - // } - - if (req._query && req._query.b64) { - transport.supportsBinary = false - } else { - transport.supportsBinary = true - } - } catch (e: any) { - console.ex(e) - this.emit("connection_error", { - req, - code: Server.errors.BAD_REQUEST, - message: Server.errorMessages[Server.errors.BAD_REQUEST], - context: { - name: "TRANSPORT_HANDSHAKE_ERROR", - error: e - } - }) - closeConnection(Server.errors.BAD_REQUEST) - return - } - console.debug(`engine.io server create socket ${id} from transport ${transport.name} protocol ${protocol}`) - const socket = new Socket(id, this, transport, req, protocol) - - transport.on("headers", (headers, req) => { - const isInitialRequest = !req._query.sid - - if (isInitialRequest) { - if (this.opts.cookie) { - headers["Set-Cookie"] = [ - // cookieMod.serialize(this.opts.cookie.name, id, this.opts.cookie) - ] - } - this.emit("initial_headers", headers, req) - } - this.emit("headers", headers, req) - }) - - transport.onRequest(req) - - this.clients[id] = socket - this.clientsCount++ - - socket.once("close", () => { - delete this.clients[id] - this.clientsCount-- - }) - this.emit("connection", socket) - } - // /** // * Handles an Engine.IO HTTP Upgrade. // * // * @api public // */ - // handleUpgrade(req, socket, upgradeHead) { + // public handleUpgrade(req, socket, upgradeHead) { // this.prepare(req) // this.verify(req, true, (errorCode, errorContext) => { @@ -423,7 +564,7 @@ export class Server extends EventEmitter { // return // } - // const head = Buffer.from(upgradeHead) // eslint-disable-line node/no-deprecated-api + // const head = Buffer.from(upgradeHead) // upgradeHead = null // // delegate to ws @@ -439,14 +580,14 @@ export class Server extends EventEmitter { * @param {ws.Socket} websocket * @api private */ - onWebSocket(req: Request, socket, websocket: WebSocketClient) { + private onWebSocket(req: Request, socket, websocket: WebSocketClient) { websocket.on("error", onUpgradeError) if ( transports[req._query.transport] !== undefined && !transports[req._query.transport].prototype.handlesUpgrades ) { - console.debug("transport doesnt handle upgraded requests") + debug("transport doesnt handle upgraded requests") websocket.close() return } @@ -460,40 +601,37 @@ export class Server extends EventEmitter { if (id) { const client = this.clients[id] if (!client) { - console.debug("upgrade attempt for closed client") + debug("upgrade attempt for closed client") websocket.close() } else if (client.upgrading) { - console.debug("transport has already been trying to upgrade") + debug("transport has already been trying to upgrade") websocket.close() } else if (client.upgraded) { - console.debug("transport had already been upgraded") + debug("transport had already been upgraded") websocket.close() } else { - console.debug("upgrading existing transport") + debug("upgrading existing transport") // transport error handling takes over websocket.removeListener("error", onUpgradeError) - const transport = new transports[req._query.transport](req) + const transport = this.createTransport(req._query.transport, req) if (req._query && req._query.b64) { transport.supportsBinary = false } else { transport.supportsBinary = true } - transport.perMessageDeflate = this.perMessageDeflate + transport.perMessageDeflate = this.opts.perMessageDeflate client.maybeUpgrade(transport) } } else { - // transport error handling takes over - websocket.removeListener("error", onUpgradeError) - // const closeConnection = (errorCode, errorContext) => // abortUpgrade(socket, errorCode, errorContext) this.handshake(req._query.transport, req, () => { }) } - function onUpgradeError() { - console.debug("websocket error before upgrade") + function onUpgradeError(...args) { + debug("websocket error before upgrade %s", ...args) // websocket.close() not needed } } @@ -505,7 +643,9 @@ export class Server extends EventEmitter { * @param {Object} options * @api public */ - attach(server, options: any = {}) { + // public attach(server: HttpServer, options: AttachOptions = {}) { + // @java-patch + public attach(server, options: AttachOptions = {}) { // let path = (options.path || "/engine.io").replace(/\/$/, "") // const destroyUpgradeTimeout = options.destroyUpgradeTimeout || 1000 @@ -514,7 +654,7 @@ export class Server extends EventEmitter { // path += "/" // function check(req) { - // return path === req.url.substr(0, path.length) + // return path === req.url.slice(0, path.length) // } // cache and clean up listeners @@ -555,7 +695,11 @@ export class Server extends EventEmitter { // // and if no eio thing handles the upgrade // // then the socket needs to die! // setTimeout(function () { + // // @ts-ignore // if (socket.writable && socket.bytesWritten <= 0) { + // socket.on("error", e => { + // debug("error while destroying upgrade: %s", e.message) + // }) // return socket.end() // } // }, destroyUpgradeTimeout) @@ -601,7 +745,11 @@ export class Server extends EventEmitter { // * @api private // */ -// function abortUpgrade(socket, errorCode, errorContext: any = {}) { +// function abortUpgrade( +// socket, +// errorCode, +// errorContext: { message?: string } = {} +// ) { // socket.on("error", () => { // debug("ignoring error from closed connection") // }) @@ -622,8 +770,6 @@ export class Server extends EventEmitter { // socket.destroy() // } -// module.exports = Server - /* eslint-disable */ // /** diff --git a/packages/websocket/src/engine.io/socket.ts b/packages/websocket/src/engine.io/socket.ts index 413e75c8..a4aa06eb 100644 --- a/packages/websocket/src/engine.io/socket.ts +++ b/packages/websocket/src/engine.io/socket.ts @@ -1,38 +1,63 @@ import { EventEmitter } from "events" -import { Server } from "./server" +// import debugModule from "debug" +// import { IncomingMessage } from "http" import { Transport } from "./transport" -import type { Request } from "../server/request" -// const debug = require("debug")("engine:socket") +import { Server } from "./server" +// import { setTimeout, clearTimeout } from "timers" +// import { Packet, PacketType, RawData } from "engine.io-parser" +import { Packet, PacketType, RawData } from "../engine.io-parser" + +// const debug = debugModule("engine:socket") +const debug = require('../debug')("engine:socket") export class Socket extends EventEmitter { - public id: string - private server: Server - private upgrading = false - private upgraded = false - public readyState = "opening" - private writeBuffer = [] - private packetsFn = [] - private sentCallbackFn = [] - private cleanupFn = [] - public request: Request - public protocol: number - public remoteAddress: any + public readonly protocol: number + // public readonly request: IncomingMessage + public readonly request: any + public readonly remoteAddress: string + + public _readyState: string public transport: Transport - private checkIntervalTimer: NodeJS.Timeout - private upgradeTimeoutTimer: NodeJS.Timeout - private pingTimeoutTimer: NodeJS.Timeout - private pingIntervalTimer: NodeJS.Timeout + private server: Server + private upgrading: boolean + private upgraded: boolean + private writeBuffer: Packet[] + private packetsFn: any[] + private sentCallbackFn: any[] + private cleanupFn: any[] + private checkIntervalTimer + private upgradeTimeoutTimer + private pingTimeoutTimer + private pingIntervalTimer + + private readonly id: string + + get readyState() { + return this._readyState + } + + set readyState(state) { + debug("readyState updated from %s to %s", this._readyState, state) + this._readyState = state + } /** * Client class (abstract). * * @api private */ - constructor(id: string, server: Server, transport: Transport, req: Request, protocol: number) { + constructor(id, server, transport, req, protocol) { super() this.id = id this.server = server + this.upgrading = false + this.upgraded = false + this.readyState = "opening" + this.writeBuffer = [] + this.packetsFn = [] + this.sentCallbackFn = [] + this.cleanupFn = [] this.request = req this.protocol = protocol @@ -57,7 +82,7 @@ export class Socket extends EventEmitter { * * @api private */ - onOpen() { + private onOpen() { this.readyState = "open" // sends an `open` packet @@ -68,7 +93,8 @@ export class Socket extends EventEmitter { sid: this.id, upgrades: this.getAvailableUpgrades(), pingInterval: this.server.opts.pingInterval, - pingTimeout: this.server.opts.pingTimeout + pingTimeout: this.server.opts.pingTimeout, + maxPayload: this.server.opts.maxHttpBufferSize }) ) @@ -95,13 +121,12 @@ export class Socket extends EventEmitter { * @param {Object} packet * @api private */ - onPacket(packet: { type: any; data: any }) { + private onPacket(packet: Packet) { if ("open" !== this.readyState) { - console.debug("packet received with closed socket") - return + return debug("packet received with closed socket") } // export packet event - // debug(`received packet ${packet.type}`) + debug(`received packet ${packet.type}`) this.emit("packet", packet) // Reset ping timeout on any packet, incoming data is a good sign of @@ -116,7 +141,7 @@ export class Socket extends EventEmitter { this.onError("invalid heartbeat direction") return } - // debug("got ping") + debug("got ping") this.sendPacket("pong") this.emit("heartbeat") break @@ -126,7 +151,8 @@ export class Socket extends EventEmitter { this.onError("invalid heartbeat direction") return } - // debug("got pong") + debug("got pong") + // this.pingIntervalTimer.refresh() this.schedulePing() this.emit("heartbeat") break @@ -148,8 +174,8 @@ export class Socket extends EventEmitter { * @param {Error} error object * @api private */ - onError(err: string) { - // debug("transport error") + private onError(err) { + debug("transport error") this.onClose("transport error", err) } @@ -159,13 +185,12 @@ export class Socket extends EventEmitter { * * @api private */ - schedulePing() { - clearTimeout(this.pingIntervalTimer) + private schedulePing() { this.pingIntervalTimer = setTimeout(() => { - // debug( - // "writing ping packet - expecting pong within %sms", - // this.server.opts.pingTimeout - // ) + debug( + "writing ping packet - expecting pong within %sms", + this.server.opts.pingTimeout + ) this.sendPacket("ping") this.resetPingTimeout(this.server.opts.pingTimeout) }, this.server.opts.pingInterval) @@ -176,7 +201,7 @@ export class Socket extends EventEmitter { * * @api private */ - resetPingTimeout(timeout: number) { + private resetPingTimeout(timeout) { clearTimeout(this.pingTimeoutTimer) this.pingTimeoutTimer = setTimeout(() => { if (this.readyState === "closed") return @@ -190,8 +215,7 @@ export class Socket extends EventEmitter { * @param {Transport} transport * @api private */ - setTransport(transport: Transport) { - console.debug(`engine.io socket ${this.id} set transport ${transport.name}`) + private setTransport(transport) { const onError = this.onError.bind(this) const onPacket = this.onPacket.bind(this) const flush = this.flush.bind(this) @@ -219,30 +243,33 @@ export class Socket extends EventEmitter { * @param {Transport} transport * @api private */ - maybeUpgrade(transport: Transport) { - console.debug( - 'might upgrade socket transport from "', this.transport.name, '" to "', transport.name, '"' + private maybeUpgrade(transport) { + debug( + 'might upgrade socket transport from "%s" to "%s"', + this.transport.name, + transport.name ) this.upgrading = true // set transport upgrade timer this.upgradeTimeoutTimer = setTimeout(() => { - console.debug("client did not complete upgrade - closing transport") + debug("client did not complete upgrade - closing transport") cleanup() if ("open" === transport.readyState) { transport.close() } }, this.server.opts.upgradeTimeout) - const onPacket = (packet: { type: string; data: string }) => { + const onPacket = packet => { if ("ping" === packet.type && "probe" === packet.data) { + debug("got probe ping packet, sending pong") transport.send([{ type: "pong", data: "probe" }]) this.emit("upgrading", transport) clearInterval(this.checkIntervalTimer) this.checkIntervalTimer = setInterval(check, 100) } else if ("upgrade" === packet.type && this.readyState !== "closed") { - // debug("got upgrade packet - upgrading") + debug("got upgrade packet - upgrading") cleanup() this.transport.discard() this.upgraded = true @@ -264,7 +291,7 @@ export class Socket extends EventEmitter { // we force a polling cycle to ensure a fast upgrade const check = () => { if ("polling" === this.transport.name && this.transport.writable) { - // debug("writing a noop packet to polling for fast upgrade") + debug("writing a noop packet to polling for fast upgrade") this.transport.send([{ type: "noop" }]) } } @@ -284,8 +311,8 @@ export class Socket extends EventEmitter { this.removeListener("close", onClose) } - const onError = (err: string) => { - // debug("client did not complete upgrade - %s", err) + const onError = err => { + debug("client did not complete upgrade - %s", err) cleanup() transport.close() transport = null @@ -311,8 +338,8 @@ export class Socket extends EventEmitter { * * @api private */ - clearTransport() { - let cleanup: () => void + private clearTransport() { + let cleanup const toCleanUp = this.cleanupFn.length @@ -323,7 +350,7 @@ export class Socket extends EventEmitter { // silence further transport errors and prevent uncaught exceptions this.transport.on("error", function () { - // debug("error triggered by discarded transport") + debug("error triggered by discarded transport") }) // ensure transport won't stay open @@ -337,7 +364,7 @@ export class Socket extends EventEmitter { * Possible reasons: `ping timeout`, `client error`, `parse error`, * `transport error`, `server close`, `transport close` */ - onClose(reason: string, description?: string) { + private onClose(reason: string, description?) { if ("closed" !== this.readyState) { this.readyState = "closed" @@ -365,16 +392,16 @@ export class Socket extends EventEmitter { * * @api private */ - setupSendCallback() { + private setupSendCallback() { // the message was sent successfully, execute the callback const onDrain = () => { if (this.sentCallbackFn.length > 0) { const seqFn = this.sentCallbackFn.splice(0, 1)[0] if ("function" === typeof seqFn) { - // debug("executing send callback") + debug("executing send callback") seqFn(this.transport) } else if (Array.isArray(seqFn)) { - // debug("executing batch send callback") + debug("executing batch send callback") const l = seqFn.length let i = 0 for (; i < l; i++) { @@ -396,18 +423,18 @@ export class Socket extends EventEmitter { /** * Sends a message packet. * - * @param {String} message + * @param {Object} data * @param {Object} options * @param {Function} callback * @return {Socket} for chaining * @api public */ - send(data: any, options: any, callback: any) { + public send(data, options, callback?) { this.sendPacket("message", data, options, callback) return this } - write(data: any, options: any, callback?: any) { + public write(data, options, callback?) { this.sendPacket("message", data, options, callback) return this } @@ -415,12 +442,14 @@ export class Socket extends EventEmitter { /** * Sends a packet. * - * @param {String} packet type - * @param {String} optional, data + * @param {String} type - packet type + * @param {String} data * @param {Object} options + * @param {Function} callback + * * @api private */ - sendPacket(type: string, data?: string, options?: { compress?: any }, callback?: undefined) { + private sendPacket(type: PacketType, data?: RawData, options?, callback?) { if ("function" === typeof options) { callback = options options = null @@ -430,12 +459,13 @@ export class Socket extends EventEmitter { options.compress = false !== options.compress if ("closing" !== this.readyState && "closed" !== this.readyState) { - // console.debug('sending packet "%s" (%s)', type, data) + debug('sending packet "%s" (%s)', type, data) - const packet: any = { - type: type, - options: options + const packet: Packet = { + type, + options } + if (data) packet.data = data // exports packetCreate event @@ -455,13 +485,13 @@ export class Socket extends EventEmitter { * * @api private */ - flush() { + private flush() { if ( "closed" !== this.readyState && this.transport.writable && this.writeBuffer.length ) { - console.trace("flushing buffer to transport") + debug("flushing buffer to transport") this.emit("flush", this.writeBuffer) this.server.emit("flush", this, this.writeBuffer) const wbuf = this.writeBuffer @@ -483,7 +513,7 @@ export class Socket extends EventEmitter { * * @api private */ - getAvailableUpgrades() { + private getAvailableUpgrades() { const availableUpgrades = [] const allUpgrades = this.server.upgrades(this.transport.name) let i = 0 @@ -500,11 +530,11 @@ export class Socket extends EventEmitter { /** * Closes the socket and underlying transport. * - * @param {Boolean} optional, discard + * @param {Boolean} discard - optional, discard the transport * @return {Socket} for chaining * @api public */ - close(discard?: any) { + public close(discard?: boolean) { if ("open" !== this.readyState) return this.readyState = "closing" @@ -523,7 +553,7 @@ export class Socket extends EventEmitter { * @param {Boolean} discard * @api private */ - closeTransport(discard: any) { + private closeTransport(discard) { if (discard) this.transport.discard() this.transport.close(this.onClose.bind(this, "forced close")) } diff --git a/packages/websocket/src/engine.io/transport.ts b/packages/websocket/src/engine.io/transport.ts index d6367b90..f927df4d 100644 --- a/packages/websocket/src/engine.io/transport.ts +++ b/packages/websocket/src/engine.io/transport.ts @@ -1,6 +1,13 @@ -import { EventEmitter } from 'events' -import parser_v4 from "../engine.io-parser" -import type { WebSocketClient } from '../server/client' +import { EventEmitter } from "events" +import * as parser_v4 from "../engine.io-parser" +import * as parser_v3 from "./parser-v3" +// import debugModule from "debug" +import { IncomingMessage } from "http" +import { Packet } from "../engine.io-parser" + +// const debug = debugModule("engine:transport") +const debug = require('../debug')("engine:transport") + /** * Noop function. * @@ -11,15 +18,28 @@ function noop() { } export abstract class Transport extends EventEmitter { public sid: string - public req /**http.IncomingMessage */ - public socket: WebSocketClient public writable: boolean - public readyState: string - public discarded: boolean - public protocol: Number - public parser: any - public perMessageDeflate: any - public supportsBinary: boolean = false + public protocol: number + + protected _readyState: string + protected discarded: boolean + protected parser: any + protected req: IncomingMessage & { cleanup: Function } + protected supportsBinary: boolean + + get readyState() { + return this._readyState + } + + set readyState(state) { + debug( + "readyState updated from %s to %s (%s)", + this._readyState, + state, + this.name + ) + this._readyState = state + } /** * Transport constructor. @@ -32,7 +52,7 @@ export abstract class Transport extends EventEmitter { this.readyState = "open" this.discarded = false this.protocol = req._query.EIO === "4" ? 4 : 3 // 3rd revision by default - this.parser = parser_v4//= this.protocol === 4 ? parser_v4 : parser_v3 + this.parser = this.protocol === 4 ? parser_v4 : parser_v3 } /** @@ -48,10 +68,10 @@ export abstract class Transport extends EventEmitter { * Called with an incoming HTTP request. * * @param {http.IncomingMessage} request - * @api private + * @api protected */ - onRequest(req) { - console.debug(`engine.io transport ${this.socket.id} setting request`, JSON.stringify(req)) + protected onRequest(req) { + debug("setting request") this.req = req } @@ -72,16 +92,18 @@ export abstract class Transport extends EventEmitter { * * @param {String} message error * @param {Object} error description - * @api private + * @api protected */ - onError(msg: string, desc?: string) { + protected onError(msg: string, desc?) { if (this.listeners("error").length) { - const err: any = new Error(msg) + const err = new Error(msg) + // @ts-ignore err.type = "TransportError" + // @ts-ignore err.description = desc this.emit("error", err) } else { - console.debug(`ignored transport error ${msg} (${desc})`) + debug("ignored transport error %s (%s)", msg, desc) } } @@ -89,9 +111,9 @@ export abstract class Transport extends EventEmitter { * Called with parsed out a packets from the data stream. * * @param {Object} packet - * @api private + * @api protected */ - onPacket(packet) { + protected onPacket(packet: Packet) { this.emit("packet", packet) } @@ -99,23 +121,24 @@ export abstract class Transport extends EventEmitter { * Called with the encoded packet data. * * @param {String} data - * @api private + * @api protected */ - onData(data) { + protected onData(data) { this.onPacket(this.parser.decodePacket(data)) } /** * Called upon transport close. * - * @api private + * @api protected */ - onClose() { + protected onClose() { this.readyState = "closed" this.emit("close") } + abstract get supportsFraming() abstract get name() - abstract send(...args: any[]) - abstract doClose(d: Function) + abstract send(packets) + abstract doClose(fn?) } diff --git a/packages/websocket/src/engine.io/transports/index.ts b/packages/websocket/src/engine.io/transports/index.ts index 1834ad2a..6bda051a 100644 --- a/packages/websocket/src/engine.io/transports/index.ts +++ b/packages/websocket/src/engine.io/transports/index.ts @@ -1,3 +1,24 @@ +// import { Polling as XHR } from "./polling" +// import { JSONP } from "./polling-jsonp" +import { WebSocket } from "./websocket" + export default { - websocket: require("./websocket").WebSocket + // polling: polling, + websocket: WebSocket } + +// /** +// * Polling polymorphic constructor. +// * +// * @api private +// */ + +// function polling(req) { +// if ("string" === typeof req._query.j) { +// return new JSONP(req) +// } else { +// return new XHR(req) +// } +// } + +// polling.upgradesTo = ["websocket"] diff --git a/packages/websocket/src/engine.io/transports/websocket.ts b/packages/websocket/src/engine.io/transports/websocket.ts index 933ed79e..baf859be 100644 --- a/packages/websocket/src/engine.io/transports/websocket.ts +++ b/packages/websocket/src/engine.io/transports/websocket.ts @@ -1,8 +1,11 @@ -import { Transport } from '../transport' -// const debug = require("debug")("engine:ws") +import { Transport } from "../transport" +// import debugModule from "debug"; + +const debug = require('../../debug')("engine:ws") export class WebSocket extends Transport { - public perMessageDeflate: any + protected perMessageDeflate: any + private socket: any /** * WebSocket transport @@ -13,7 +16,11 @@ export class WebSocket extends Transport { constructor(req) { super(req) this.socket = req.websocket - this.socket.on("message", this.onData.bind(this)) + this.socket.on("message", (data, isBinary) => { + const message = isBinary ? data : data.toString() + debug('received "%s"', message) + super.onData(message) + }) this.socket.once("close", this.onClose.bind(this)) this.socket.on("error", this.onError.bind(this)) this.writable = true @@ -21,10 +28,10 @@ export class WebSocket extends Transport { } /** - * Transport name - * - * @api public - */ + * Transport name + * + * @api public + */ get name() { return "websocket" } @@ -47,17 +54,6 @@ export class WebSocket extends Transport { return true } - /** - * Processes the incoming data. - * - * @param {String} encoded packet - * @api private - */ - onData(data) { - // debug('received "%s"', data) - super.onData(data) - } - /** * Writes a packet payload. * @@ -65,7 +61,6 @@ export class WebSocket extends Transport { * @api private */ send(packets) { - // console.log('WebSocket send packets', JSON.stringify(packets)) const packet = packets.shift() if (typeof packet === "undefined") { this.writable = true @@ -74,7 +69,7 @@ export class WebSocket extends Transport { } // always creates a new object since ws modifies it - const opts: any = {} + const opts: { compress?: boolean } = {} if (packet.options) { opts.compress = packet.options.compress } @@ -87,7 +82,7 @@ export class WebSocket extends Transport { opts.compress = false } } - console.trace('writing', data) + debug('writing "%s"', data) this.writable = false this.socket.send(data, opts, err => { @@ -109,7 +104,7 @@ export class WebSocket extends Transport { * @api private */ doClose(fn) { - // debug("closing") + debug("closing") this.socket.close() fn && fn() } diff --git a/packages/websocket/src/socket.io-adapter/index.ts b/packages/websocket/src/socket.io-adapter/index.ts index fdee138c..4b16bcc9 100644 --- a/packages/websocket/src/socket.io-adapter/index.ts +++ b/packages/websocket/src/socket.io-adapter/index.ts @@ -1,6 +1,8 @@ import { EventEmitter } from "events" export type SocketId = string +// we could extend the Room type to "string | number", but that would be a breaking change +// related: https://github.com/socketio/socket.io-redis-adapter/issues/418 export type Room = string export interface BroadcastFlags { @@ -9,11 +11,12 @@ export interface BroadcastFlags { local?: boolean broadcast?: boolean binary?: boolean + timeout?: number } export interface BroadcastOptions { rooms: Set - except?: Set + except?: Set flags?: BroadcastFlags } @@ -42,6 +45,15 @@ export class Adapter extends EventEmitter { */ public close(): Promise | void { } + /** + * Returns the number of Socket.IO servers in the cluster + * + * @public + */ + public serverCount(): Promise { + return Promise.resolve(1) + } + /** * Adds a socket to a list of room. * @@ -82,14 +94,14 @@ export class Adapter extends EventEmitter { this._del(room, id) } - private _del(room, id) { - if (this.rooms.has(room)) { - const deleted = this.rooms.get(room).delete(id) + private _del(room: Room, id: SocketId) { + const _room = this.rooms.get(room) + if (_room != null) { + const deleted = _room.delete(id) if (deleted) { this.emit("leave-room", room, id) } - if (this.rooms.get(room).size === 0) { - this.rooms.delete(room) + if (_room.size === 0 && this.rooms.delete(room)) { this.emit("delete-room", room) } } @@ -126,7 +138,7 @@ export class Adapter extends EventEmitter { */ public broadcast(packet: any, opts: BroadcastOptions): void { const flags = opts.flags || {} - const basePacketOpts = { + const packetOpts = { preEncoded: true, volatile: flags.volatile, compress: flags.compress @@ -135,22 +147,65 @@ export class Adapter extends EventEmitter { packet.nsp = this.nsp.name const encodedPackets = this.encoder.encode(packet) - const packetOpts = encodedPackets.map(encodedPacket => { - if (typeof encodedPacket === "string") { - return { - ...basePacketOpts, - wsPreEncoded: "4" + encodedPacket // "4" being the "message" packet type in Engine.IO - } - } else { - return basePacketOpts + this.apply(opts, socket => { + if (typeof socket.notifyOutgoingListeners === "function") { + socket.notifyOutgoingListeners(packet) } + + socket.client.writeToEngine(encodedPackets, packetOpts) }) + } + + /** + * Broadcasts a packet and expects multiple acknowledgements. + * + * Options: + * - `flags` {Object} flags for this packet + * - `except` {Array} sids that should be excluded + * - `rooms` {Array} list of rooms to broadcast to + * + * @param {Object} packet the packet object + * @param {Object} opts the options + * @param clientCountCallback - the number of clients that received the packet + * @param ack - the callback that will be called for each client response + * + * @public + */ + public broadcastWithAck( + packet: any, + opts: BroadcastOptions, + clientCountCallback: (clientCount: number) => void, + ack: (...args: any[]) => void + ) { + const flags = opts.flags || {} + const packetOpts = { + preEncoded: true, + volatile: flags.volatile, + compress: flags.compress + } + + packet.nsp = this.nsp.name + // we can use the same id for each packet, since the _ids counter is common (no duplicate) + packet.id = this.nsp._ids++ + + const encodedPackets = this.encoder.encode(packet) + + let clientCount = 0 this.apply(opts, socket => { - for (let i = 0; i < encodedPackets.length; i++) { - socket.client.writeToEngine(encodedPackets[i], packetOpts[i]) + // track the total number of acknowledgements that are expected + clientCount++ + // call the ack callback for each client response + socket.acks.set(packet.id, ack) + + if (typeof socket.notifyOutgoingListeners === "function") { + socket.notifyOutgoingListeners(packet) } + + socket.client.writeToEngine(encodedPackets, packetOpts) }) + + clientCountCallback(clientCount) } /** @@ -272,7 +327,7 @@ export class Adapter extends EventEmitter { * @param packet - an array of arguments, which may include an acknowledgement callback at the end */ public serverSideEmit(packet: any[]): void { - throw new Error( + console.warn( "this adapter does not support the serverSideEmit() functionality" ) } diff --git a/packages/websocket/src/socket.io-client/contrib/backo2.ts b/packages/websocket/src/socket.io-client/contrib/backo2.ts new file mode 100644 index 00000000..48cec181 --- /dev/null +++ b/packages/websocket/src/socket.io-client/contrib/backo2.ts @@ -0,0 +1,77 @@ +/** + * Initialize backoff timer with `opts`. + * + * - `min` initial timeout in milliseconds [100] + * - `max` max timeout [10000] + * - `jitter` [0] + * - `factor` [2] + * + * @param {Object} opts + * @api public + */ + +export function Backoff(this: any, opts) { + opts = opts || {} + this.ms = opts.min || 100 + this.max = opts.max || 10000 + this.factor = opts.factor || 2 + this.jitter = opts.jitter > 0 && opts.jitter <= 1 ? opts.jitter : 0 + this.attempts = 0 +} + +/** + * Return the backoff duration. + * + * @return {Number} + * @api public + */ + +Backoff.prototype.duration = function () { + var ms = this.ms * Math.pow(this.factor, this.attempts++) + if (this.jitter) { + var rand = Math.random() + var deviation = Math.floor(rand * this.jitter * ms) + ms = (Math.floor(rand * 10) & 1) == 0 ? ms - deviation : ms + deviation + } + return Math.min(ms, this.max) | 0 +} + +/** + * Reset the number of attempts. + * + * @api public + */ + +Backoff.prototype.reset = function () { + this.attempts = 0 +} + +/** + * Set the minimum duration + * + * @api public + */ + +Backoff.prototype.setMin = function (min) { + this.ms = min +} + +/** + * Set the maximum duration + * + * @api public + */ + +Backoff.prototype.setMax = function (max) { + this.max = max +} + +/** + * Set the jitter + * + * @api public + */ + +Backoff.prototype.setJitter = function (jitter) { + this.jitter = jitter +} diff --git a/packages/websocket/src/socket.io-client/index.ts b/packages/websocket/src/socket.io-client/index.ts index 28fc606c..5456666d 100644 --- a/packages/websocket/src/socket.io-client/index.ts +++ b/packages/websocket/src/socket.io-client/index.ts @@ -4,16 +4,10 @@ import { Socket, SocketOptions } from "./socket" const debug = require("../debug")("socket.io-client") -/** - * Module exports. - */ - -module.exports = exports = lookup - /** * Managers cache. */ -const cache: Record = (exports.managers = {}) +const cache: Record = {} /** * Looks up an existing `Manager` for multiplexing. @@ -76,6 +70,15 @@ function lookup( return io.socket(parsed.path, opts) } +// so that "lookup" can be used both as a function (e.g. `io(...)`) and as a +// namespace (e.g. `io.connect(...)`), for backward compatibility +Object.assign(lookup, { + Manager, + Socket, + io: lookup, + connect: lookup, +}) + /** * Protocol version. * @@ -84,22 +87,18 @@ function lookup( export { protocol } from "../socket.io-parser" -/** - * `connect`. - * - * @param {String} uri - * @public - */ - -exports.connect = lookup - /** * Expose constructors for standalone build. * * @public */ -export { Manager, ManagerOptions } from "./manager" -export { Socket } from "./socket" -export { lookup as io, SocketOptions } -export default lookup +export { + Manager, + ManagerOptions, + Socket, + SocketOptions, + lookup as io, + lookup as connect, + lookup as default, +} diff --git a/packages/websocket/src/socket.io-client/manager.ts b/packages/websocket/src/socket.io-client/manager.ts index 8e5dfe74..3e8e7596 100644 --- a/packages/websocket/src/socket.io-client/manager.ts +++ b/packages/websocket/src/socket.io-client/manager.ts @@ -1,210 +1,26 @@ -import eio from "../engine.io-client" -import { Socket, SocketOptions } from "./socket" +import { + Socket as Engine, + SocketOptions as EngineOptions, + installTimerFunctions, + nextTick, +} from "../engine.io-client" +import { Socket, SocketOptions, DisconnectDescription } from "./socket.js" +// import * as parser from "socket.io-parser" import * as parser from "../socket.io-parser" +// import { Decoder, Encoder, Packet } from "socket.io-parser" import { Decoder, Encoder, Packet } from "../socket.io-parser" -import { on } from "./on" -import * as Backoff from "backo2" +import { on } from "./on.js" +import { Backoff } from "./contrib/backo2" import { DefaultEventsMap, EventsMap, - StrictEventEmitter, -} from "./typed-events" + Emitter, +} from "@socket.io/component-emitter" +// import debugModule from "debug" // debug() +// const debug = debugModule("socket.io-client:manager") // debug() const debug = require("../debug")("socket.io-client") -interface EngineOptions { - /** - * The host that we're connecting to. Set from the URI passed when connecting - */ - host: string - - /** - * The hostname for our connection. Set from the URI passed when connecting - */ - hostname: string - - /** - * If this is a secure connection. Set from the URI passed when connecting - */ - secure: boolean - - /** - * The port for our connection. Set from the URI passed when connecting - */ - port: string - - /** - * Any query parameters in our uri. Set from the URI passed when connecting - */ - query: { [key: string]: string } - - /** - * `http.Agent` to use, defaults to `false` (NodeJS only) - */ - agent: string | boolean - - /** - * Whether the client should try to upgrade the transport from - * long-polling to something better. - * @default true - */ - upgrade: boolean - - /** - * Forces JSONP for polling transport. - */ - forceJSONP: boolean - - /** - * Determines whether to use JSONP when necessary for polling. If - * disabled (by settings to false) an error will be emitted (saying - * "No transports available") if no other transports are available. - * If another transport is available for opening a connection (e.g. - * WebSocket) that transport will be used instead. - * @default true - */ - jsonp: boolean - - /** - * Forces base 64 encoding for polling transport even when XHR2 - * responseType is available and WebSocket even if the used standard - * supports binary. - */ - forceBase64: boolean - - /** - * Enables XDomainRequest for IE8 to avoid loading bar flashing with - * click sound. default to `false` because XDomainRequest has a flaw - * of not sending cookie. - * @default false - */ - enablesXDR: boolean - - /** - * The param name to use as our timestamp key - * @default 't' - */ - timestampParam: string - - /** - * Whether to add the timestamp with each transport request. Note: this - * is ignored if the browser is IE or Android, in which case requests - * are always stamped - * @default false - */ - timestampRequests: boolean - - /** - * A list of transports to try (in order). Engine.io always attempts to - * connect directly with the first one, provided the feature detection test - * for it passes. - * @default ['polling','websocket'] - */ - transports: string[] - - /** - * The port the policy server listens on - * @default 843 - */ - policyPost: number - - /** - * If true and if the previous websocket connection to the server succeeded, - * the connection attempt will bypass the normal upgrade process and will - * initially try websocket. A connection attempt following a transport error - * will use the normal upgrade process. It is recommended you turn this on - * only when using SSL/TLS connections, or if you know that your network does - * not block websockets. - * @default false - */ - rememberUpgrade: boolean - - /** - * Are we only interested in transports that support binary? - */ - onlyBinaryUpgrades: boolean - - /** - * Timeout for xhr-polling requests in milliseconds (0) (only for polling transport) - */ - requestTimeout: number - - /** - * Transport options for Node.js client (headers etc) - */ - transportOptions: Object - - /** - * (SSL) Certificate, Private key and CA certificates to use for SSL. - * Can be used in Node.js client environment to manually specify - * certificate information. - */ - pfx: string - - /** - * (SSL) Private key to use for SSL. Can be used in Node.js client - * environment to manually specify certificate information. - */ - key: string - - /** - * (SSL) A string or passphrase for the private key or pfx. Can be - * used in Node.js client environment to manually specify certificate - * information. - */ - passphrase: string - - /** - * (SSL) Public x509 certificate to use. Can be used in Node.js client - * environment to manually specify certificate information. - */ - cert: string - - /** - * (SSL) An authority certificate or array of authority certificates to - * check the remote host against.. Can be used in Node.js client - * environment to manually specify certificate information. - */ - ca: string | string[] - - /** - * (SSL) A string describing the ciphers to use or exclude. Consult the - * [cipher format list] - * (http://www.openssl.org/docs/apps/ciphers.html#CIPHER_LIST_FORMAT) for - * details on the format.. Can be used in Node.js client environment to - * manually specify certificate information. - */ - ciphers: string - - /** - * (SSL) If true, the server certificate is verified against the list of - * supplied CAs. An 'error' event is emitted if verification fails. - * Verification happens at the connection level, before the HTTP request - * is sent. Can be used in Node.js client environment to manually specify - * certificate information. - */ - rejectUnauthorized: boolean - - /** - * Headers that will be passed for each request to the server (via xhr-polling and via websockets). - * These values then can be used during handshake or for special proxies. - */ - extraHeaders?: { [header: string]: string } - - /** - * Whether to include credentials (cookies, authorization headers, TLS - * client certificates, etc.) with cross-origin XHR polling requests - * @default false - */ - withCredentials: boolean - - /** - * Whether to automatically close the connection whenever the beforeunload event is received. - * @default true - */ - closeOnBeforeunload: boolean -} - export interface ManagerOptions extends EngineOptions { /** * Should we force a new Manager for this connection? @@ -267,13 +83,6 @@ export interface ManagerOptions extends EngineOptions { */ autoConnect: boolean - /** - * weather we should unref the reconnect timer when it is - * create automatically - * @default false - */ - autoUnref: boolean - /** * the parser to use. Defaults to an instance of the Parser that ships with socket.io. */ @@ -285,7 +94,7 @@ interface ManagerReservedEvents { error: (err: Error) => void ping: () => void packet: (packet: Packet) => void - close: (reason: string) => void + close: (reason: string, description?: DisconnectDescription) => void reconnect_failed: () => void reconnect_attempt: (attempt: number) => void reconnect_error: (err: Error) => void @@ -295,13 +104,13 @@ interface ManagerReservedEvents { export class Manager< ListenEvents extends EventsMap = DefaultEventsMap, EmitEvents extends EventsMap = ListenEvents - > extends StrictEventEmitter<{}, {}, ManagerReservedEvents> { +> extends Emitter<{}, {}, ManagerReservedEvents> { /** * The Engine.IO client instance * * @public */ - public engine: any + public engine: Engine /** * @private */ @@ -320,7 +129,9 @@ export class Manager< private nsps: Record = {}; private subs: Array> = []; + // @ts-ignore private backoff: Backoff + private setTimeoutFn: typeof setTimeout private _reconnection: boolean private _reconnectionAttempts: number private _reconnectionDelay: number @@ -358,6 +169,7 @@ export class Manager< opts.path = opts.path || "/socket.io" this.opts = opts + installTimerFunctions(this, opts) this.reconnection(opts.reconnection !== false) this.reconnectionAttempts(opts.reconnectionAttempts || Infinity) this.reconnectionDelay(opts.reconnectionDelay || 1000) @@ -507,8 +319,7 @@ export class Manager< if (~this._readyState.indexOf("open")) return this debug("opening %s", this.uri) - // @ts-ignore - this.engine = eio(this.uri, this.opts) + this.engine = new Engine(this.uri, this.opts) const socket = this.engine const self = this this._readyState = "opening" @@ -543,10 +354,11 @@ export class Manager< } // set timer - const timer = setTimeout(() => { + const timer = this.setTimeoutFn(() => { debug("connect attempt timed out after %d", timeout) openSubDestroy() socket.close() + // @ts-ignore socket.emit("error", new Error("timeout")) }, timeout) @@ -616,7 +428,11 @@ export class Manager< * @private */ private ondata(data): void { - this.decoder.add(data) + try { + this.decoder.add(data) + } catch (e) { + this.onclose("parse error", e as Error) + } } /** @@ -625,7 +441,10 @@ export class Manager< * @private */ private ondecoded(packet): void { - this.emitReserved("packet", packet) + // the nextTick call prevents an exception in a user-provided event listener from triggering a disconnection due to a "parse error" + nextTick(() => { + this.emitReserved("packet", packet) + }, this.setTimeoutFn) } /** @@ -713,13 +532,7 @@ export class Manager< debug("disconnect") this.skipReconnect = true this._reconnecting = false - if ("opening" === this._readyState) { - // `onclose` will not fire because - // an open event never happened - this.cleanup() - } - this.backoff.reset() - this._readyState = "closed" + this.onclose("forced close") if (this.engine) this.engine.close() } @@ -737,13 +550,13 @@ export class Manager< * * @private */ - private onclose(reason: string): void { - debug("onclose") + private onclose(reason: string, description?: DisconnectDescription): void { + debug("closed due to %s", reason) this.cleanup() this.backoff.reset() this._readyState = "closed" - this.emitReserved("close", reason) + this.emitReserved("close", reason, description) if (this._reconnection && !this.skipReconnect) { this.reconnect() @@ -770,7 +583,7 @@ export class Manager< debug("will wait %dms before reconnect attempt", delay) this._reconnecting = true - const timer = setTimeout(() => { + const timer = this.setTimeoutFn(() => { if (self.skipReconnect) return debug("attempting reconnect") diff --git a/packages/websocket/src/socket.io-client/on.ts b/packages/websocket/src/socket.io-client/on.ts index 7f27647b..ee9e3922 100644 --- a/packages/websocket/src/socket.io-client/on.ts +++ b/packages/websocket/src/socket.io-client/on.ts @@ -1,9 +1,7 @@ -// import type * as Emitter from "component-emitter"; -import { EventEmitter } from "events" -import { StrictEventEmitter } from "./typed-events" +import { Emitter } from "@socket.io/component-emitter" export function on( - obj: EventEmitter | StrictEventEmitter, + obj: Emitter, ev: string, fn: (err?: any) => any ): VoidFunction { diff --git a/packages/websocket/src/socket.io-client/socket.ts b/packages/websocket/src/socket.io-client/socket.ts index 67ea5b9c..7ef8d027 100644 --- a/packages/websocket/src/socket.io-client/socket.ts +++ b/packages/websocket/src/socket.io-client/socket.ts @@ -1,14 +1,17 @@ +// import { Packet, PacketType } from "socket.io-parser" import { Packet, PacketType } from "../socket.io-parser" -import { on } from "./on" -import { Manager } from "./manager" +import { on } from "./on.js" +import { Manager } from "./manager.js" import { DefaultEventsMap, EventNames, EventParams, EventsMap, - StrictEventEmitter, -} from "./typed-events" + Emitter, +} from "@socket.io/component-emitter" +// import debugModule from "debug" // debug() +// const debug = debugModule("socket.io-client:socket") // debug() const debug = require("../debug")("socket.io-client") export interface SocketOptions { @@ -35,26 +38,110 @@ const RESERVED_EVENTS = Object.freeze({ interface Flags { compress?: boolean volatile?: boolean + timeout?: number } +export type DisconnectDescription = + | Error + | { + description: string + context?: CloseEvent | XMLHttpRequest + } + interface SocketReservedEvents { connect: () => void connect_error: (err: Error) => void - disconnect: (reason: Socket.DisconnectReason) => void + disconnect: ( + reason: Socket.DisconnectReason, + description?: DisconnectDescription + ) => void } +/** + * A Socket is the fundamental class for interacting with the server. + * + * A Socket belongs to a certain Namespace (by default /) and uses an underlying {@link Manager} to communicate. + * + * @example + * const socket = io(); + * + * socket.on("connect", () => { + * console.log("connected"); + * }); + * + * // send an event to the server + * socket.emit("foo", "bar"); + * + * socket.on("foobar", () => { + * // an event was received from the server + * }); + * + * // upon disconnection + * socket.on("disconnect", (reason) => { + * console.log(`disconnected due to ${reason}`); + * }); + */ export class Socket< ListenEvents extends EventsMap = DefaultEventsMap, EmitEvents extends EventsMap = ListenEvents - > extends StrictEventEmitter { +> extends Emitter { public readonly io: Manager + /** + * A unique identifier for the session. + * + * @example + * const socket = io(); + * + * console.log(socket.id); // undefined + * + * socket.on("connect", () => { + * console.log(socket.id); // "G5p5..." + * }); + */ public id: string - public connected: boolean - public disconnected: boolean + /** + * Whether the socket is currently connected to the server. + * + * @example + * const socket = io(); + * + * socket.on("connect", () => { + * console.log(socket.connected); // true + * }); + * + * socket.on("disconnect", () => { + * console.log(socket.connected); // false + * }); + */ + public connected: boolean = false; + + /** + * Credentials that are sent when accessing a namespace. + * + * @example + * const socket = io({ + * auth: { + * token: "abcd" + * } + * }); + * + * // or with a function + * const socket = io({ + * auth: (cb) => { + * cb({ token: localStorage.token }) + * } + * }); + */ public auth: { [key: string]: any } | ((cb: (data: object) => void) => void) + /** + * Buffer for packets received before the CONNECT packet + */ public receiveBuffer: Array> = []; + /** + * Buffer for packets that will be sent once the socket is connected + */ public sendBuffer: Array = []; private readonly nsp: string @@ -64,29 +151,39 @@ export class Socket< private flags: Flags = {}; private subs?: Array private _anyListeners: Array<(...args: any[]) => void> + private _anyOutgoingListeners: Array<(...args: any[]) => void> /** * `Socket` constructor. - * - * @public */ constructor(io: Manager, nsp: string, opts?: Partial) { super() this.io = io this.nsp = nsp - this.ids = 0 - this.acks = {} - this.receiveBuffer = [] - this.sendBuffer = [] - this.connected = false - this.disconnected = true - this.flags = {} if (opts && opts.auth) { this.auth = opts.auth } if (this.io._autoConnect) this.open() } + /** + * Whether the socket is currently disconnected + * + * @example + * const socket = io(); + * + * socket.on("connect", () => { + * console.log(socket.disconnected); // false + * }); + * + * socket.on("disconnect", () => { + * console.log(socket.disconnected); // true + * }); + */ + public get disconnected(): boolean { + return !this.connected + } + /** * Subscribe to open, close and packet events * @@ -105,7 +202,21 @@ export class Socket< } /** - * Whether the Socket will try to reconnect when its Manager connects or reconnects + * Whether the Socket will try to reconnect when its Manager connects or reconnects. + * + * @example + * const socket = io(); + * + * console.log(socket.active); // true + * + * socket.on("disconnect", (reason) => { + * if (reason === "io server disconnect") { + * // the disconnection was initiated by the server, you need to manually reconnect + * console.log(socket.active); // false + * } + * // else the socket will automatically try to reconnect + * console.log(socket.active); // true + * }); */ public get active(): boolean { return !!this.subs @@ -114,7 +225,12 @@ export class Socket< /** * "Opens" the socket. * - * @public + * @example + * const socket = io({ + * autoConnect: false + * }); + * + * socket.connect(); */ public connect(): this { if (this.connected) return this @@ -126,7 +242,7 @@ export class Socket< } /** - * Alias for connect() + * Alias for {@link connect()}. */ public open(): this { return this.connect() @@ -135,8 +251,17 @@ export class Socket< /** * Sends a `message` event. * + * This method mimics the WebSocket.send() method. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/send + * + * @example + * socket.send("hello"); + * + * // this is equivalent to + * socket.emit("message", "hello"); + * * @return self - * @public */ public send(...args: any[]): this { args.unshift("message") @@ -149,15 +274,25 @@ export class Socket< * Override `emit`. * If the event is in `events`, it's emitted normally. * + * @example + * socket.emit("hello", "world"); + * + * // all serializable datastructures are supported (no need to call JSON.stringify) + * socket.emit("hello", 1, "2", { 3: ["4"], 5: Uint8Array.from([6]) }); + * + * // with an acknowledgement from the server + * socket.emit("hello", "world", (val) => { + * // ... + * }); + * * @return self - * @public */ public emit>( ev: Ev, ...args: EventParams ): this { if (RESERVED_EVENTS.hasOwnProperty(ev)) { - throw new Error('"' + ev + '" is a reserved event name') + throw new Error('"' + ev.toString() + '" is a reserved event name') } args.unshift(ev) @@ -171,9 +306,12 @@ export class Socket< // event ack callback if ("function" === typeof args[args.length - 1]) { - debug("emitting packet with ack id %d", this.ids) - this.acks[this.ids] = args.pop() - packet.id = this.ids++ + const id = this.ids++ + debug("emitting packet with ack id %d", id) + + const ack = args.pop() as Function + this._registerAckCallback(id, ack) + packet.id = id } const isTransportWritable = @@ -186,6 +324,7 @@ export class Socket< if (discardPacket) { debug("discard packet as the transport is not currently writable") } else if (this.connected) { + this.notifyOutgoingListeners(packet) this.packet(packet) } else { this.sendBuffer.push(packet) @@ -196,6 +335,36 @@ export class Socket< return this } + /** + * @private + */ + private _registerAckCallback(id: number, ack: Function) { + const timeout = this.flags.timeout + if (timeout === undefined) { + this.acks[id] = ack + return + } + + // @ts-ignore + const timer = this.io.setTimeoutFn(() => { + delete this.acks[id] + for (let i = 0; i < this.sendBuffer.length; i++) { + if (this.sendBuffer[i].id === id) { + debug("removing packet with ack id %d from the buffer", id) + this.sendBuffer.splice(i, 1) + } + } + debug("event with ack id %d has timed out after %d ms", id, timeout) + ack.call(this, new Error("operation has timed out")) + }, timeout) + + this.acks[id] = (...args) => { + // @ts-ignore + this.io.clearTimeoutFn(timer) + ack.apply(this, [null, ...args]) + } + } + /** * Sends a packet. * @@ -239,14 +408,17 @@ export class Socket< * Called upon engine `close`. * * @param reason + * @param description * @private */ - private onclose(reason: Socket.DisconnectReason): void { + private onclose( + reason: Socket.DisconnectReason, + description?: DisconnectDescription + ): void { debug("close (%s)", reason) this.connected = false - this.disconnected = true delete this.id - this.emitReserved("disconnect", reason) + this.emitReserved("disconnect", reason, description) } /** @@ -276,17 +448,11 @@ export class Socket< break case PacketType.EVENT: - this.onevent(packet) - break - case PacketType.BINARY_EVENT: this.onevent(packet) break case PacketType.ACK: - this.onack(packet) - break - case PacketType.BINARY_ACK: this.onack(packet) break @@ -296,6 +462,7 @@ export class Socket< break case PacketType.CONNECT_ERROR: + this.destroy() const err = new Error(packet.data.message) // @ts-ignore err.data = packet.data.data @@ -386,7 +553,6 @@ export class Socket< debug("socket connected with id %s", id) this.id = id this.connected = true - this.disconnected = false this.emitBuffered() this.emitReserved("connect") } @@ -400,7 +566,10 @@ export class Socket< this.receiveBuffer.forEach((args) => this.emitEvent(args)) this.receiveBuffer = [] - this.sendBuffer.forEach((packet) => this.packet(packet)) + this.sendBuffer.forEach((packet) => { + this.notifyOutgoingListeners(packet) + this.packet(packet) + }) this.sendBuffer = [] } @@ -432,10 +601,20 @@ export class Socket< } /** - * Disconnects the socket manually. + * Disconnects the socket manually. In that case, the socket will not try to reconnect. + * + * If this is the last active Socket instance of the {@link Manager}, the low-level connection will be closed. + * + * @example + * const socket = io(); + * + * socket.on("disconnect", (reason) => { + * // console.log(reason); prints "io client disconnect" + * }); + * + * socket.disconnect(); * * @return self - * @public */ public disconnect(): this { if (this.connected) { @@ -454,10 +633,9 @@ export class Socket< } /** - * Alias for disconnect() + * Alias for {@link disconnect()}. * * @return self - * @public */ public close(): this { return this.disconnect() @@ -466,9 +644,11 @@ export class Socket< /** * Sets the compress flag. * + * @example + * socket.compress(false).emit("hello"); + * * @param compress - if `true`, compresses the sending data * @return self - * @public */ public compress(compress: boolean): this { this.flags.compress = compress @@ -479,20 +659,44 @@ export class Socket< * Sets a modifier for a subsequent event emission that the event message will be dropped when this socket is not * ready to send messages. * + * @example + * socket.volatile.emit("hello"); // the server may or may not receive it + * * @returns self - * @public */ public get volatile(): this { this.flags.volatile = true return this } + /** + * Sets a modifier for a subsequent event emission that the callback will be called with an error when the + * given number of milliseconds have elapsed without an acknowledgement from the server: + * + * @example + * socket.timeout(5000).emit("my-event", (err) => { + * if (err) { + * // the server did not acknowledge the event in the given delay + * } + * }); + * + * @returns self + */ + public timeout(timeout: number): this { + this.flags.timeout = timeout + return this + } + /** * Adds a listener that will be fired when any event is emitted. The event name is passed as the first argument to the * callback. * + * @example + * socket.onAny((event, ...args) => { + * console.log(`got ${event}`); + * }); + * * @param listener - * @public */ public onAny(listener: (...args: any[]) => void): this { this._anyListeners = this._anyListeners || [] @@ -504,8 +708,12 @@ export class Socket< * Adds a listener that will be fired when any event is emitted. The event name is passed as the first argument to the * callback. The listener is added to the beginning of the listeners array. * + * @example + * socket.prependAny((event, ...args) => { + * console.log(`got event ${event}`); + * }); + * * @param listener - * @public */ public prependAny(listener: (...args: any[]) => void): this { this._anyListeners = this._anyListeners || [] @@ -516,8 +724,20 @@ export class Socket< /** * Removes the listener that will be fired when any event is emitted. * + * @example + * const catchAllListener = (event, ...args) => { + * console.log(`got event ${event}`); + * } + * + * socket.onAny(catchAllListener); + * + * // remove a specific listener + * socket.offAny(catchAllListener); + * + * // or remove all listeners + * socket.offAny(); + * * @param listener - * @public */ public offAny(listener?: (...args: any[]) => void): this { if (!this._anyListeners) { @@ -540,12 +760,108 @@ export class Socket< /** * Returns an array of listeners that are listening for any event that is specified. This array can be manipulated, * e.g. to remove listeners. - * - * @public */ public listenersAny() { return this._anyListeners || [] } + + /** + * Adds a listener that will be fired when any event is emitted. The event name is passed as the first argument to the + * callback. + * + * Note: acknowledgements sent to the server are not included. + * + * @example + * socket.onAnyOutgoing((event, ...args) => { + * console.log(`sent event ${event}`); + * }); + * + * @param listener + */ + public onAnyOutgoing(listener: (...args: any[]) => void): this { + this._anyOutgoingListeners = this._anyOutgoingListeners || [] + this._anyOutgoingListeners.push(listener) + return this + } + + /** + * Adds a listener that will be fired when any event is emitted. The event name is passed as the first argument to the + * callback. The listener is added to the beginning of the listeners array. + * + * Note: acknowledgements sent to the server are not included. + * + * @example + * socket.prependAnyOutgoing((event, ...args) => { + * console.log(`sent event ${event}`); + * }); + * + * @param listener + */ + public prependAnyOutgoing(listener: (...args: any[]) => void): this { + this._anyOutgoingListeners = this._anyOutgoingListeners || [] + this._anyOutgoingListeners.unshift(listener) + return this + } + + /** + * Removes the listener that will be fired when any event is emitted. + * + * @example + * const catchAllListener = (event, ...args) => { + * console.log(`sent event ${event}`); + * } + * + * socket.onAnyOutgoing(catchAllListener); + * + * // remove a specific listener + * socket.offAnyOutgoing(catchAllListener); + * + * // or remove all listeners + * socket.offAnyOutgoing(); + * + * @param [listener] - the catch-all listener (optional) + */ + public offAnyOutgoing(listener?: (...args: any[]) => void): this { + if (!this._anyOutgoingListeners) { + return this + } + if (listener) { + const listeners = this._anyOutgoingListeners + for (let i = 0; i < listeners.length; i++) { + if (listener === listeners[i]) { + listeners.splice(i, 1) + return this + } + } + } else { + this._anyOutgoingListeners = [] + } + return this + } + + /** + * Returns an array of listeners that are listening for any event that is specified. This array can be manipulated, + * e.g. to remove listeners. + */ + public listenersAnyOutgoing() { + return this._anyOutgoingListeners || [] + } + + /** + * Notify the listeners for each packet sent + * + * @param packet + * + * @private + */ + private notifyOutgoingListeners(packet: Packet) { + if (this._anyOutgoingListeners && this._anyOutgoingListeners.length) { + const listeners = this._anyOutgoingListeners.slice() + for (const listener of listeners) { + listener.apply(this, packet.data) + } + } + } } export namespace Socket { @@ -555,4 +871,5 @@ export namespace Socket { | "ping timeout" | "transport close" | "transport error" + | "parse error" } diff --git a/packages/websocket/src/socket.io-client/typed-events.ts b/packages/websocket/src/socket.io-client/typed-events.ts deleted file mode 100644 index 997415ce..00000000 --- a/packages/websocket/src/socket.io-client/typed-events.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { EventEmitter } from "events" - -/** - * An events map is an interface that maps event names to their value, which - * represents the type of the `on` listener. - */ -export interface EventsMap { - [event: string]: any -} - -/** - * The default events map, used if no EventsMap is given. Using this EventsMap - * is equivalent to accepting all event names, and any data. - */ -export interface DefaultEventsMap { - [event: string]: (...args: any[]) => void -} - -/** - * Returns a union type containing all the keys of an event map. - */ -export type EventNames = keyof Map & (string | symbol) - -/** The tuple type representing the parameters of an event listener */ -export type EventParams< - Map extends EventsMap, - Ev extends EventNames - > = Parameters - -/** - * The event names that are either in ReservedEvents or in UserEvents - */ -export type ReservedOrUserEventNames< - ReservedEventsMap extends EventsMap, - UserEvents extends EventsMap - > = EventNames | EventNames - -/** - * Type of a listener of a user event or a reserved event. If `Ev` is in - * `ReservedEvents`, the reserved event listener is returned. - */ -export type ReservedOrUserListener< - ReservedEvents extends EventsMap, - UserEvents extends EventsMap, - Ev extends ReservedOrUserEventNames - > = FallbackToUntypedListener< - Ev extends EventNames - ? ReservedEvents[Ev] - : Ev extends EventNames - ? UserEvents[Ev] - : never - > - -/** - * Returns an untyped listener type if `T` is `never`; otherwise, returns `T`. - * - * This is a hack to mitigate https://github.com/socketio/socket.io/issues/3833. - * Needed because of https://github.com/microsoft/TypeScript/issues/41778 - */ -type FallbackToUntypedListener = [T] extends [never] - ? (...args: any[]) => void - : T - -/** - * Strictly typed version of an `EventEmitter`. A `TypedEventEmitter` takes type - * parameters for mappings of event names to event data types, and strictly - * types method calls to the `EventEmitter` according to these event maps. - * - * @typeParam ListenEvents - `EventsMap` of user-defined events that can be - * listened to with `on` or `once` - * @typeParam EmitEvents - `EventsMap` of user-defined events that can be - * emitted with `emit` - * @typeParam ReservedEvents - `EventsMap` of reserved events, that can be - * emitted by socket.io with `emitReserved`, and can be listened to with - * `listen`. - */ -export abstract class StrictEventEmitter< - ListenEvents extends EventsMap, - EmitEvents extends EventsMap, - ReservedEvents extends EventsMap = {} - > extends EventEmitter { - /** - * Adds the `listener` function as an event listener for `ev`. - * - * @param ev Name of the event - * @param listener Callback function - */ - on>( - ev: Ev, - listener: ReservedOrUserListener - ): this { - super.on(ev as string, listener) - return this - } - - /** - * Adds a one-time `listener` function as an event listener for `ev`. - * - * @param ev Name of the event - * @param listener Callback function - */ - once>( - ev: Ev, - listener: ReservedOrUserListener - ): this { - super.once(ev as string, listener) - return this - } - - /** - * Emits an event. - * - * @param ev Name of the event - * @param args Values to send to listeners of this event - */ - // @ts-ignore - emit>( - ev: Ev, - ...args: EventParams - ): this { - super.emit(ev as string, ...args) - return this - } - - /** - * Emits a reserved event. - * - * This method is `protected`, so that only a class extending - * `StrictEventEmitter` can emit its own reserved events. - * - * @param ev Reserved event name - * @param args Arguments to emit along with the event - */ - protected emitReserved>( - ev: Ev, - ...args: EventParams - ): this { - super.emit(ev as string, ...args) - return this - } - - /** - * Returns the listeners listening to an event. - * - * @param event Event name - * @returns Array of listeners subscribed to `event` - */ - listeners>( - event: Ev - ): ReservedOrUserListener[] { - return super.listeners(event as string) as ReservedOrUserListener< - ReservedEvents, - ListenEvents, - Ev - >[] - } -} diff --git a/packages/websocket/src/socket.io-client/url.ts b/packages/websocket/src/socket.io-client/url.ts index 00729fc4..b47dd59b 100644 --- a/packages/websocket/src/socket.io-client/url.ts +++ b/packages/websocket/src/socket.io-client/url.ts @@ -1,5 +1,8 @@ -import * as parseuri from "parseuri" +// import { parse } from "engine.io-client" +import { parse } from "../engine.io-client" +// import debugModule from "debug" // debug() +// const debug = debugModule("socket.io-client:url"); // debug() const debug = require("../debug")("socket.io-client") type ParsedUrl = { @@ -67,7 +70,7 @@ export function url( // parse debug("parse %s", uri) - obj = parseuri(uri) as ParsedUrl + obj = parse(uri) as ParsedUrl } // make sure we treat `localhost:80` and `localhost` equally diff --git a/packages/websocket/src/socket.io-parser/binary.ts b/packages/websocket/src/socket.io-parser/binary.ts index dd02b685..43618715 100644 --- a/packages/websocket/src/socket.io-parser/binary.ts +++ b/packages/websocket/src/socket.io-parser/binary.ts @@ -1,4 +1,4 @@ -import { isBinary } from "./is-binary" +import { isBinary } from "./is-binary.js" /** * Replaces every Buffer | ArrayBuffer | Blob | File in packet with a numbered placeholder. @@ -33,7 +33,7 @@ function _deconstructPacket(data, buffers) { } else if (typeof data === "object" && !(data instanceof Date)) { const newData = {} for (const key in data) { - if (data.hasOwnProperty(key)) { + if (Object.prototype.hasOwnProperty.call(data, key)) { newData[key] = _deconstructPacket(data[key], buffers) } } @@ -60,15 +60,23 @@ export function reconstructPacket(packet, buffers) { function _reconstructPacket(data, buffers) { if (!data) return data - if (data && data._placeholder) { - return buffers[data.num] // appropriate buffer (should be natural order anyway) + if (data && data._placeholder === true) { + const isIndexValid = + typeof data.num === "number" && + data.num >= 0 && + data.num < buffers.length + if (isIndexValid) { + return buffers[data.num] // appropriate buffer (should be natural order anyway) + } else { + throw new Error("illegal attachments") + } } else if (Array.isArray(data)) { for (let i = 0; i < data.length; i++) { data[i] = _reconstructPacket(data[i], buffers) } } else if (typeof data === "object") { for (const key in data) { - if (data.hasOwnProperty(key)) { + if (Object.prototype.hasOwnProperty.call(data, key)) { data[key] = _reconstructPacket(data[key], buffers) } } diff --git a/packages/websocket/src/socket.io-parser/index.ts b/packages/websocket/src/socket.io-parser/index.ts index 5db1d8f3..77e724c1 100644 --- a/packages/websocket/src/socket.io-parser/index.ts +++ b/packages/websocket/src/socket.io-parser/index.ts @@ -1,8 +1,10 @@ -import EventEmitter = require("events") -import { deconstructPacket, reconstructPacket } from "./binary" -import { isBinary, hasBinary } from "./is-binary" +import { Emitter } from "@socket.io/component-emitter" +import { deconstructPacket, reconstructPacket } from "./binary.js" +import { isBinary, hasBinary } from "./is-binary.js" +// import debugModule from "debug" // debug() -// const debug = require("debug")("socket.io-parser") +// const debug = debugModule("socket.io-parser") // debug() +const debug = require("../debug")("socket.io-client") /** * Protocol version. @@ -35,6 +37,12 @@ export interface Packet { */ export class Encoder { + /** + * Encoder constructor + * + * @param {function} replacer - custom replacer to pass down to JSON.parse + */ + constructor(private replacer?: (this: any, key: string, value: any) => any) { } /** * Encode a packet as a single string if non-binary, or as a * buffer sequence, depending on packet type. @@ -42,7 +50,7 @@ export class Encoder { * @param {Object} obj - packet object */ public encode(obj: Packet) { - console.trace("encoding packet", JSON.stringify(obj)) + debug("encoding packet %j", obj) if (obj.type === PacketType.EVENT || obj.type === PacketType.ACK) { if (hasBinary(obj)) { @@ -85,10 +93,10 @@ export class Encoder { // json data if (null != obj.data) { - str += JSON.stringify(obj.data) + str += JSON.stringify(obj.data, this.replacer) } - console.trace("encoded", JSON.stringify(obj), "as", str) + debug("encoded %j as %s", obj, str) return str } @@ -108,15 +116,24 @@ export class Encoder { } } +interface DecoderReservedEvents { + decoded: (packet: Packet) => void +} + /** * A socket.io Decoder instance * * @return {Object} decoder */ -export class Decoder extends EventEmitter { +export class Decoder extends Emitter<{}, {}, DecoderReservedEvents> { private reconstructor: BinaryReconstructor - constructor() { + /** + * Decoder constructor + * + * @param {function} reviver - custom reviver to pass down to JSON.stringify + */ + constructor(private reviver?: (this: any, key: string, value: any) => any) { super() } @@ -129,6 +146,9 @@ export class Decoder extends EventEmitter { public add(obj: any) { let packet if (typeof obj === "string") { + if (this.reconstructor) { + throw new Error("got plaintext data when reconstructing a packet") + } packet = this.decodeString(obj) if ( packet.type === PacketType.BINARY_EVENT || @@ -139,11 +159,11 @@ export class Decoder extends EventEmitter { // no attachments, labeled binary but no binary data to follow if (packet.attachments === 0) { - super.emit("decoded", packet) + super.emitReserved("decoded", packet) } } else { // non-binary full packet - super.emit("decoded", packet) + super.emitReserved("decoded", packet) } } else if (isBinary(obj) || obj.base64) { // raw binary data @@ -154,7 +174,7 @@ export class Decoder extends EventEmitter { if (packet) { // received final buffer this.reconstructor = null - super.emit("decoded", packet) + super.emitReserved("decoded", packet) } } } else { @@ -223,7 +243,7 @@ export class Decoder extends EventEmitter { // look up json data if (str.charAt(++i)) { - const payload = tryParse(str.substr(i)) + const payload = this.tryParse(str.substr(i)) if (Decoder.isPayloadValid(p.type, payload)) { p.data = payload } else { @@ -231,10 +251,18 @@ export class Decoder extends EventEmitter { } } - console.trace("decoded", str, "as", p) + debug("decoded %s as %j", str, p) return p } + private tryParse(str) { + try { + return JSON.parse(str, this.reviver) + } catch (e) { + return false + } + } + private static isPayloadValid(type: PacketType, payload: any): boolean { switch (type) { case PacketType.CONNECT: @@ -262,14 +290,6 @@ export class Decoder extends EventEmitter { } } -function tryParse(str) { - try { - return JSON.parse(str) - } catch (error: any) { - return false - } -} - /** * A manager of a binary event's 'buffer sequence'. Should * be constructed whenever a packet of type BINARY_EVENT is diff --git a/packages/websocket/src/socket.io-parser/is-binary.ts b/packages/websocket/src/socket.io-parser/is-binary.ts index 505173dd..ace158d9 100644 --- a/packages/websocket/src/socket.io-parser/is-binary.ts +++ b/packages/websocket/src/socket.io-parser/is-binary.ts @@ -7,14 +7,14 @@ const isView = (obj: any) => { } const toString = Object.prototype.toString -const withNativeBlob = false -// typeof Blob === "function" || -// (typeof Blob !== "undefined" && -// toString.call(Blob) === "[object BlobConstructor]") -const withNativeFile = false -// typeof File === "function" || -// (typeof File !== "undefined" && -// toString.call(File) === "[object FileConstructor]") +const withNativeBlob = + typeof Blob === "function" || + (typeof Blob !== "undefined" && + toString.call(Blob) === "[object BlobConstructor]") +const withNativeFile = + typeof File === "function" || + (typeof File !== "undefined" && + toString.call(File) === "[object FileConstructor]") /** * Returns true if obj is a Buffer, an ArrayBuffer, a Blob or a File. @@ -24,8 +24,9 @@ const withNativeFile = false export function isBinary(obj: any) { return ( - (withNativeArrayBuffer && (obj instanceof ArrayBuffer || isView(obj))) - // || (withNativeBlob && obj instanceof Blob) || (withNativeFile && obj instanceof File) + (withNativeArrayBuffer && (obj instanceof ArrayBuffer || isView(obj))) || + (withNativeBlob && obj instanceof Blob) || + (withNativeFile && obj instanceof File) ) } diff --git a/packages/websocket/src/socket.io/broadcast-operator.ts b/packages/websocket/src/socket.io/broadcast-operator.ts index 0a005275..55e8ee36 100644 --- a/packages/websocket/src/socket.io/broadcast-operator.ts +++ b/packages/websocket/src/socket.io/broadcast-operator.ts @@ -12,7 +12,7 @@ import type { TypedEventBroadcaster, } from "./typed-events" -export class BroadcastOperator +export class BroadcastOperator implements TypedEventBroadcaster { constructor( @@ -25,18 +25,27 @@ export class BroadcastOperator /** * Targets a room when emitting. * - * @param room - * @return a new BroadcastOperator instance - * @public + * @example + * // the “foo” event will be broadcast to all connected clients in the “room-101” room + * io.to("room-101").emit("foo", "bar"); + * + * // with an array of rooms (a client will be notified at most once) + * io.to(["room-101", "room-102"]).emit("foo", "bar"); + * + * // with multiple chained calls + * io.to("room-101").to("room-102").emit("foo", "bar"); + * + * @param room - a room, or an array of rooms + * @return a new {@link BroadcastOperator} instance for chaining */ - public to(room: Room | Room[]): BroadcastOperator { + public to(room: Room | Room[]) { const rooms = new Set(this.rooms) if (Array.isArray(room)) { room.forEach((r) => rooms.add(r)) } else { rooms.add(room) } - return new BroadcastOperator( + return new BroadcastOperator( this.adapter, rooms, this.exceptRooms, @@ -45,31 +54,43 @@ export class BroadcastOperator } /** - * Targets a room when emitting. + * Targets a room when emitting. Similar to `to()`, but might feel clearer in some cases: * - * @param room - * @return a new BroadcastOperator instance - * @public + * @example + * // disconnect all clients in the "room-101" room + * io.in("room-101").disconnectSockets(); + * + * @param room - a room, or an array of rooms + * @return a new {@link BroadcastOperator} instance for chaining */ - public in(room: Room | Room[]): BroadcastOperator { + public in(room: Room | Room[]) { return this.to(room) } /** * Excludes a room when emitting. * - * @param room - * @return a new BroadcastOperator instance - * @public + * @example + * // the "foo" event will be broadcast to all connected clients, except the ones that are in the "room-101" room + * io.except("room-101").emit("foo", "bar"); + * + * // with an array of rooms + * io.except(["room-101", "room-102"]).emit("foo", "bar"); + * + * // with multiple chained calls + * io.except("room-101").except("room-102").emit("foo", "bar"); + * + * @param room - a room, or an array of rooms + * @return a new {@link BroadcastOperator} instance for chaining */ - public except(room: Room | Room[]): BroadcastOperator { + public except(room: Room | Room[]) { const exceptRooms = new Set(this.exceptRooms) if (Array.isArray(room)) { room.forEach((r) => exceptRooms.add(r)) } else { exceptRooms.add(room) } - return new BroadcastOperator( + return new BroadcastOperator( this.adapter, this.rooms, exceptRooms, @@ -80,13 +101,15 @@ export class BroadcastOperator /** * Sets the compress flag. * + * @example + * io.compress(false).emit("hello"); + * * @param compress - if `true`, compresses the sending data * @return a new BroadcastOperator instance - * @public */ - public compress(compress: boolean): BroadcastOperator { + public compress(compress: boolean) { const flags = Object.assign({}, this.flags, { compress }) - return new BroadcastOperator( + return new BroadcastOperator( this.adapter, this.rooms, this.exceptRooms, @@ -99,12 +122,14 @@ export class BroadcastOperator * receive messages (because of network slowness or other issues, or because they’re connected through long polling * and is in the middle of a request-response cycle). * + * @example + * io.volatile.emit("hello"); // the clients may or may not receive it + * * @return a new BroadcastOperator instance - * @public */ - public get volatile(): BroadcastOperator { + public get volatile() { const flags = Object.assign({}, this.flags, { volatile: true }) - return new BroadcastOperator( + return new BroadcastOperator( this.adapter, this.rooms, this.exceptRooms, @@ -115,12 +140,39 @@ export class BroadcastOperator /** * Sets a modifier for a subsequent event emission that the event data will only be broadcast to the current node. * - * @return a new BroadcastOperator instance - * @public + * @example + * // the “foo” event will be broadcast to all connected clients on this node + * io.local.emit("foo", "bar"); + * + * @return a new {@link BroadcastOperator} instance for chaining */ - public get local(): BroadcastOperator { + public get local() { const flags = Object.assign({}, this.flags, { local: true }) - return new BroadcastOperator( + return new BroadcastOperator( + this.adapter, + this.rooms, + this.exceptRooms, + flags + ) + } + + /** + * Adds a timeout in milliseconds for the next operation + * + * @example + * io.timeout(1000).emit("some-event", (err, responses) => { + * if (err) { + * // some clients did not acknowledge the event in the given delay + * } else { + * console.log(responses); // one response per client + * } + * }); + * + * @param timeout + */ + public timeout(timeout: number) { + const flags = Object.assign({}, this.flags, { timeout }) + return new BroadcastOperator( this.adapter, this.rooms, this.exceptRooms, @@ -131,15 +183,30 @@ export class BroadcastOperator /** * Emits to all clients. * + * @example + * // the “foo” event will be broadcast to all connected clients + * io.emit("foo", "bar"); + * + * // the “foo” event will be broadcast to all connected clients in the “room-101” room + * io.to("room-101").emit("foo", "bar"); + * + * // with an acknowledgement expected from all connected clients + * io.timeout(1000).emit("some-event", (err, responses) => { + * if (err) { + * // some clients did not acknowledge the event in the given delay + * } else { + * console.log(responses); // one response per client + * } + * }); + * * @return Always true - * @public */ public emit>( ev: Ev, ...args: EventParams ): boolean { if (RESERVED_EVENTS.has(ev)) { - throw new Error(`"${ev}" is a reserved event name`) + throw new Error(`"${String(ev)}" is a reserved event name`) } // set up packet object const data = [ev, ...args] @@ -148,14 +215,65 @@ export class BroadcastOperator data: data, } - if ("function" == typeof data[data.length - 1]) { - throw new Error("Callbacks are not supported when broadcasting") + const withAck = typeof data[data.length - 1] === "function" + + if (!withAck) { + this.adapter.broadcast(packet, { + rooms: this.rooms, + except: this.exceptRooms, + flags: this.flags, + }) + + return true } - this.adapter.broadcast(packet, { - rooms: this.rooms, - except: this.exceptRooms, - flags: this.flags, + const ack = data.pop() as (...args: any[]) => void + let timedOut = false + let responses: any[] = [] + + const timer = setTimeout(() => { + timedOut = true + ack.apply(this, [new Error("operation has timed out"), responses]) + }, this.flags.timeout) + + let expectedServerCount = -1 + let actualServerCount = 0 + let expectedClientCount = 0 + + const checkCompleteness = () => { + if ( + !timedOut && + expectedServerCount === actualServerCount && + responses.length === expectedClientCount + ) { + clearTimeout(timer) + ack.apply(this, [null, responses]) + } + } + + this.adapter.broadcastWithAck( + packet, + { + rooms: this.rooms, + except: this.exceptRooms, + flags: this.flags, + }, + (clientCount) => { + // each Socket.IO server in the cluster sends the number of clients that were notified + expectedClientCount += clientCount + actualServerCount++ + checkCompleteness() + }, + (clientResponse) => { + // each client sends an acknowledgement + responses.push(clientResponse) + checkCompleteness() + } + ) + + this.adapter.serverCount().then((serverCount) => { + expectedServerCount = serverCount + checkCompleteness() }) return true @@ -164,7 +282,8 @@ export class BroadcastOperator /** * Gets a list of clients. * - * @public + * @deprecated this method will be removed in the next major release, please use {@link Server#serverSideEmit} or + * {@link fetchSockets} instead. */ public allSockets(): Promise> { if (!this.adapter) { @@ -176,71 +295,122 @@ export class BroadcastOperator } /** - * Returns the matching socket instances + * Returns the matching socket instances. This method works across a cluster of several Socket.IO servers. * - * @public + * Note: this method also works within a cluster of multiple Socket.IO servers, with a compatible {@link Adapter}. + * + * @example + * // return all Socket instances + * const sockets = await io.fetchSockets(); + * + * // return all Socket instances in the "room1" room + * const sockets = await io.in("room1").fetchSockets(); + * + * for (const socket of sockets) { + * console.log(socket.id); + * console.log(socket.handshake); + * console.log(socket.rooms); + * console.log(socket.data); + * + * socket.emit("hello"); + * socket.join("room1"); + * socket.leave("room2"); + * socket.disconnect(); + * } */ - public fetchSockets(): Promise[]> { + public fetchSockets(): Promise[]> { return this.adapter .fetchSockets({ rooms: this.rooms, except: this.exceptRooms, + flags: this.flags, }) .then((sockets) => { return sockets.map((socket) => { if (socket instanceof Socket) { // FIXME the TypeScript compiler complains about missing private properties - return socket as unknown as RemoteSocket + return socket as unknown as RemoteSocket } else { - return new RemoteSocket(this.adapter, socket as SocketDetails) + return new RemoteSocket( + this.adapter, + socket as SocketDetails + ) } }) }) } /** - * Makes the matching socket instances join the specified rooms + * Makes the matching socket instances join the specified rooms. * - * @param room - * @public + * Note: this method also works within a cluster of multiple Socket.IO servers, with a compatible {@link Adapter}. + * + * @example + * + * // make all socket instances join the "room1" room + * io.socketsJoin("room1"); + * + * // make all socket instances in the "room1" room join the "room2" and "room3" rooms + * io.in("room1").socketsJoin(["room2", "room3"]); + * + * @param room - a room, or an array of rooms */ public socketsJoin(room: Room | Room[]): void { this.adapter.addSockets( { rooms: this.rooms, except: this.exceptRooms, + flags: this.flags, }, Array.isArray(room) ? room : [room] ) } /** - * Makes the matching socket instances leave the specified rooms + * Makes the matching socket instances leave the specified rooms. * - * @param room - * @public + * Note: this method also works within a cluster of multiple Socket.IO servers, with a compatible {@link Adapter}. + * + * @example + * // make all socket instances leave the "room1" room + * io.socketsLeave("room1"); + * + * // make all socket instances in the "room1" room leave the "room2" and "room3" rooms + * io.in("room1").socketsLeave(["room2", "room3"]); + * + * @param room - a room, or an array of rooms */ public socketsLeave(room: Room | Room[]): void { this.adapter.delSockets( { rooms: this.rooms, except: this.exceptRooms, + flags: this.flags, }, Array.isArray(room) ? room : [room] ) } /** - * Makes the matching socket instances disconnect + * Makes the matching socket instances disconnect. + * + * Note: this method also works within a cluster of multiple Socket.IO servers, with a compatible {@link Adapter}. + * + * @example + * // make all socket instances disconnect (the connections might be kept alive for other namespaces) + * io.disconnectSockets(); + * + * // make all socket instances in the "room1" room disconnect and close the underlying connections + * io.in("room1").disconnectSockets(true); * * @param close - whether to close the underlying connection - * @public */ public disconnectSockets(close: boolean = false): void { this.adapter.disconnectSockets( { rooms: this.rooms, except: this.exceptRooms, + flags: this.flags, }, close ) @@ -250,32 +420,35 @@ export class BroadcastOperator /** * Format of the data when the Socket instance exists on another Socket.IO server */ -interface SocketDetails { +interface SocketDetails { id: SocketId handshake: Handshake rooms: Room[] - data: any + data: SocketData } /** * Expose of subset of the attributes and methods of the Socket class */ -export class RemoteSocket +export class RemoteSocket implements TypedEventBroadcaster { public readonly id: SocketId public readonly handshake: Handshake public readonly rooms: Set - public readonly data: any + public readonly data: SocketData - private readonly operator: BroadcastOperator + private readonly operator: BroadcastOperator - constructor(adapter: Adapter, details: SocketDetails) { + constructor(adapter: Adapter, details: SocketDetails) { this.id = details.id this.handshake = details.handshake this.rooms = new Set(details.rooms) this.data = details.data - this.operator = new BroadcastOperator(adapter, new Set([this.id])) + this.operator = new BroadcastOperator( + adapter, + new Set([this.id]) + ) } public emit>( @@ -289,7 +462,6 @@ export class RemoteSocket * Joins a room. * * @param {String|Array} room - room or array of rooms - * @public */ public join(room: Room | Room[]): void { return this.operator.socketsJoin(room) @@ -299,7 +471,6 @@ export class RemoteSocket * Leaves a room. * * @param {String} room - * @public */ public leave(room: Room): void { return this.operator.socketsLeave(room) @@ -310,8 +481,6 @@ export class RemoteSocket * * @param {Boolean} close - if `true`, closes the underlying connection * @return {Socket} self - * - * @public */ public disconnect(close = false): this { this.operator.disconnectSockets(close) diff --git a/packages/websocket/src/socket.io/client.ts b/packages/websocket/src/socket.io/client.ts index 11c7aa33..932dc998 100644 --- a/packages/websocket/src/socket.io/client.ts +++ b/packages/websocket/src/socket.io/client.ts @@ -9,9 +9,10 @@ import type { EventsMap } from "./typed-events" import type { Socket } from "./socket" // import type { SocketId } from "socket.io-adapter" import type { SocketId } from "../socket.io-adapter" -import type { Socket as EngineIOSocket } from '../engine.io/socket' +import type { Socket as RawSocket } from '../engine.io/socket' // const debug = debugModule("socket.io:client"); +const debug = require('../debug')("socket.io:client") interface WriteOptions { compress?: boolean @@ -20,28 +21,39 @@ interface WriteOptions { wsPreEncoded?: string } +type CloseReason = + | "transport error" + | "transport close" + | "forced close" + | "ping timeout" + | "parse error" + export class Client< ListenEvents extends EventsMap, EmitEvents extends EventsMap, - ServerSideEvents extends EventsMap - > { - public readonly conn: EngineIOSocket - /** - * @private - */ - readonly id: string - private readonly server: Server + ServerSideEvents extends EventsMap, + SocketData = any +> { + public readonly conn: RawSocket + + private readonly id: string + private readonly server: Server< + ListenEvents, + EmitEvents, + ServerSideEvents, + SocketData + > private readonly encoder: Encoder - private readonly decoder: any + private readonly decoder: Decoder private sockets: Map< SocketId, - Socket - > = new Map() + Socket + > = new Map(); private nsps: Map< string, - Socket - > = new Map() - private connectTimeout: NodeJS.Timeout + Socket + > = new Map(); + private connectTimeout?: NodeJS.Timeout /** * Client constructor. @@ -51,8 +63,8 @@ export class Client< * @package */ constructor( - server: Server, - conn: EngineIOSocket + server: Server, + conn: any ) { this.server = server this.conn = conn @@ -67,7 +79,8 @@ export class Client< * * @public */ - public get request(): any/** IncomingMessage */ { + // public get request(): IncomingMessage { + public get request(): any { return this.conn.request } @@ -77,7 +90,6 @@ export class Client< * @private */ private setup() { - console.debug(`socket.io client setup conn ${this.conn.id}`) this.onclose = this.onclose.bind(this) this.ondata = this.ondata.bind(this) this.onerror = this.onerror.bind(this) @@ -91,10 +103,10 @@ export class Client< this.connectTimeout = setTimeout(() => { if (this.nsps.size === 0) { - console.debug(`no namespace joined yet, close the client ${this.id}`) + debug("no namespace joined yet, close the client") this.close() } else { - console.debug(`the client ${this.id} has already joined a namespace, nothing to do`) + debug("the client has already joined a namespace, nothing to do") } }, this.server._connectTimeout) } @@ -108,7 +120,7 @@ export class Client< */ private connect(name: string, auth: object = {}): void { if (this.server._nsps.has(name)) { - console.debug(`socket.io client ${this.id} connecting to namespace ${name}`) + debug("connecting to namespace %s", name) return this.doConnect(name, auth) } @@ -117,14 +129,13 @@ export class Client< auth, ( dynamicNspName: - | Namespace + | Namespace | false ) => { if (dynamicNspName) { - console.debug(`dynamic namespace ${dynamicNspName} was created`) this.doConnect(name, auth) } else { - console.debug(`creation of namespace ${name} was denied`) + debug("creation of namespace %s was denied", name) this._packet({ type: PacketType.CONNECT_ERROR, nsp: name, @@ -178,20 +189,16 @@ export class Client< * * @private */ - _remove(socket: Socket): void { + _remove( + socket: Socket + ): void { if (this.sockets.has(socket.id)) { const nsp = this.sockets.get(socket.id)!.nsp.name this.sockets.delete(socket.id) this.nsps.delete(nsp) } else { - console.debug("ignoring remove for", socket.id) + debug("ignoring remove for %s", socket.id) } - // @java-patch disconnect client when no live socket - process.nextTick(() => { - if (this.sockets.size == 0) { - this.onclose('no live socket') - } - }) } /** @@ -200,43 +207,47 @@ export class Client< * @private */ private close(): void { - console.debug(`client ${this.id} close - reason: forcing transport close`) if ("open" === this.conn.readyState) { - console.debug("forcing transport close") + debug("forcing transport close") this.conn.close() this.onclose("forced server close") } } /** - * Writes a packet to the transport. - * - * @param {Object} packet object - * @param {Object} opts - * @private - */ + * Writes a packet to the transport. + * + * @param {Object} packet object + * @param {Object} opts + * @private + */ _packet(packet: Packet | any[], opts: WriteOptions = {}): void { if (this.conn.readyState !== "open") { - console.debug(`client ${this.id} ignoring packet write ${JSON.stringify(packet)}`) + debug("ignoring packet write %j", packet) return } const encodedPackets = opts.preEncoded ? (packet as any[]) // previous versions of the adapter incorrectly used socket.packet() instead of writeToEngine() : this.encoder.encode(packet as Packet) - for (const encodedPacket of encodedPackets) { - this.writeToEngine(encodedPacket, opts) - } + this.writeToEngine(encodedPackets, opts) } private writeToEngine( - encodedPacket: String | Buffer, + encodedPackets: Array, opts: WriteOptions ): void { if (opts.volatile && !this.conn.transport.writable) { - console.debug(`client ${this.id} volatile packet is discarded since the transport is not currently writable`) + debug( + "volatile packet is discarded since the transport is not currently writable" + ) return } - this.conn.write(encodedPacket, opts) + const packets = Array.isArray(encodedPackets) + ? encodedPackets + : [encodedPackets] + for (const encodedPacket of packets) { + this.conn.write(encodedPacket, opts) + } } /** @@ -248,8 +259,9 @@ export class Client< // try/catch is needed for protocol violations (GH-1880) try { this.decoder.add(data) - } catch (error: any) { - this.onerror(error) + } catch (e) { + debug("invalid packet format") + this.onerror(e) } } @@ -259,22 +271,31 @@ export class Client< * @private */ private ondecoded(packet: Packet): void { - if (PacketType.CONNECT === packet.type) { - if (this.conn.protocol === 3) { - const parsed = url.parse(packet.nsp, true) - this.connect(parsed.pathname!, parsed.query) - } else { - this.connect(packet.nsp, packet.data) - } + let namespace: string + let authPayload + if (this.conn.protocol === 3) { + const parsed = url.parse(packet.nsp, true) + namespace = parsed.pathname! + authPayload = parsed.query } else { - const socket = this.nsps.get(packet.nsp) - if (socket) { - process.nextTick(function () { - socket._onpacket(packet) - }) - } else { - console.debug(`client ${this.id} no socket for namespace ${packet.nsp}.`) - } + namespace = packet.nsp + authPayload = packet.data + } + const socket = this.nsps.get(namespace) + + if (!socket && packet.type === PacketType.CONNECT) { + this.connect(namespace, authPayload) + } else if ( + socket && + packet.type !== PacketType.CONNECT && + packet.type !== PacketType.CONNECT_ERROR + ) { + process.nextTick(function () { + socket._onpacket(packet) + }) + } else { + debug("invalid state (packet type: %s)", packet.type) + this.close() } } @@ -297,8 +318,8 @@ export class Client< * @param reason * @private */ - private onclose(reason: string): void { - console.debug(`client ${this.id} close with reason ${reason}`) + private onclose(reason: CloseReason | "forced server close"): void { + debug("client close with reason %s", reason) // ignore a potential subsequent `close` event this.destroy() diff --git a/packages/websocket/src/socket.io/index.ts b/packages/websocket/src/socket.io/index.ts index 002f1caf..5a7deede 100644 --- a/packages/websocket/src/socket.io/index.ts +++ b/packages/websocket/src/socket.io/index.ts @@ -1,24 +1,30 @@ // import http = require("http"); +// import type { Server as HTTPSServer } from "https"; +// import type { Http2SecureServer } from "http2"; // import { createReadStream } from "fs"; // import { createDeflate, createGzip, createBrotliCompress } from "zlib"; // import accepts = require("accepts"); // import { pipeline } from "stream"; // import path = require("path"); -import engine = require("../engine.io") -import { Client } from './client' -import { EventEmitter } from 'events' +import { + attach, + Server as Engine, + ServerOptions as EngineOptions, + AttachOptions, + // uServer, +} from "../engine.io" +import { Client } from "./client" +import { EventEmitter } from "events" import { ExtendedError, Namespace, ServerReservedEventsMap } from "./namespace" -import { ParentNamespace } from './parent-namespace' +import { ParentNamespace } from "./parent-namespace" // import { Adapter, Room, SocketId } from "socket.io-adapter" import { Adapter, Room, SocketId } from "../socket.io-adapter" -// import * as parser from "socket.io-parser"; +// import * as parser from "socket.io-parser" import * as parser from "../socket.io-parser" -// import type { Encoder } from "socket.io-parser"; +// import type { Encoder } from "socket.io-parser" import type { Encoder } from "../socket.io-parser" -// import debugModule from "debug"; -import { Socket } from './socket' -// import type { CookieSerializeOptions } from "cookie"; -// import type { CorsOptions } from "cors"; +// import debugModule from "debug" +import { Socket } from "./socket" import type { BroadcastOperator, RemoteSocket } from "./broadcast-operator" import { EventsMap, @@ -27,13 +33,14 @@ import { StrictEventEmitter, EventNames, } from "./typed-events" +// import { patchAdapter, restoreAdapter, serveFile } from "./uws" -import type { Socket as EngineIOSocket } from '../engine.io/socket' +// const debug = debugModule("socket.io:server") +const debug = require('../debug')("socket.io:server") // const clientVersion = require("../package.json").version // const dotMapRegex = /\.map/ -// type Transport = "polling" | "websocket"; type ParentNspNameMatchFn = ( name: string, auth: { [key: string]: any }, @@ -42,105 +49,7 @@ type ParentNspNameMatchFn = ( type AdapterConstructor = typeof Adapter | ((nsp: Namespace) => Adapter) -interface EngineOptions { - /** - * how many ms without a pong packet to consider the connection closed - * @default 5000 - */ - pingTimeout: number - /** - * how many ms before sending a new ping packet - * @default 25000 - */ - pingInterval: number - /** - * how many ms before an uncompleted transport upgrade is cancelled - * @default 10000 - */ - upgradeTimeout: number - /** - * how many bytes or characters a message can be, before closing the session (to avoid DoS). - * @default 1e5 (100 KB) - */ - maxHttpBufferSize: number - /** - * A function that receives a given handshake or upgrade request as its first parameter, - * and can decide whether to continue or not. The second argument is a function that needs - * to be called with the decided information: fn(err, success), where success is a boolean - * value where false means that the request is rejected, and err is an error code. - */ - // allowRequest: ( - // req: http.IncomingMessage, - // fn: (err: string | null | undefined, success: boolean) => void - // ) => void - /** - * the low-level transports that are enabled - * @default ["polling", "websocket"] - */ - // transports: Transport[] - /** - * whether to allow transport upgrades - * @default true - */ - allowUpgrades: boolean - /** - * parameters of the WebSocket permessage-deflate extension (see ws module api docs). Set to false to disable. - * @default false - */ - perMessageDeflate: boolean | object - /** - * parameters of the http compression for the polling transports (see zlib api docs). Set to false to disable. - * @default true - */ - httpCompression: boolean | object - /** - * what WebSocket server implementation to use. Specified module must - * conform to the ws interface (see ws module api docs). Default value is ws. - * An alternative c++ addon is also available by installing uws module. - */ - wsEngine: string - /** - * an optional packet which will be concatenated to the handshake packet emitted by Engine.IO. - */ - initialPacket: any - /** - * configuration of the cookie that contains the client sid to send as part of handshake response headers. This cookie - * might be used for sticky-session. Defaults to not sending any cookie. - * @default false - */ - // cookie: CookieSerializeOptions | boolean - /** - * the options that will be forwarded to the cors module - */ - // cors: CorsOptions - /** - * whether to enable compatibility with Socket.IO v2 clients - * @default false - */ - allowEIO3: boolean -} - -interface AttachOptions { - /** - * name of the path to capture - * @default "/engine.io" - */ - path: string - /** - * destroy unhandled upgrade requests - * @default true - */ - destroyUpgrade: boolean - /** - * milliseconds after which unhandled requests are ended - * @default 1000 - */ - destroyUpgradeTimeout: number -} - -interface EngineAttachOptions extends EngineOptions, AttachOptions { } - -interface ServerOptions extends EngineAttachOptions { +interface ServerOptions extends EngineOptions, AttachOptions { /** * name of the path to capture * @default "/socket.io" @@ -155,6 +64,7 @@ interface ServerOptions extends EngineAttachOptions { * the adapter to use * @default the in-memory adapter (https://github.com/socketio/socket.io-adapter) */ + // adapter: AdapterConstructor adapter: any /** * the parser to use @@ -168,31 +78,62 @@ interface ServerOptions extends EngineAttachOptions { connectTimeout: number } +/** + * Represents a Socket.IO server. + * + * @example + * import { Server } from "socket.io"; + * + * const io = new Server(); + * + * io.on("connection", (socket) => { + * console.log(`socket ${socket.id} connected`); + * + * // send an event to the client + * socket.emit("foo", "bar"); + * + * socket.on("foobar", () => { + * // an event was received from the client + * }); + * + * // upon disconnection + * socket.on("disconnect", (reason) => { + * console.log(`socket ${socket.id} disconnected due to ${reason}`); + * }); + * }); + * + * io.listen(3000); + */ export class Server< ListenEvents extends EventsMap = DefaultEventsMap, EmitEvents extends EventsMap = ListenEvents, - ServerSideEvents extends EventsMap = DefaultEventsMap - > extends StrictEventEmitter< + ServerSideEvents extends EventsMap = DefaultEventsMap, + SocketData = any +> extends StrictEventEmitter< ServerSideEvents, EmitEvents, - ServerReservedEventsMap - > { + ServerReservedEventsMap< + ListenEvents, + EmitEvents, + ServerSideEvents, + SocketData + > +> { public readonly sockets: Namespace< ListenEvents, EmitEvents, - ServerSideEvents + ServerSideEvents, + SocketData > /** * A reference to the underlying Engine.IO server. * - * Example: - * - * - * const clientsCount = io.engine.clientsCount; - * + * @example + * const clientsCount = io.engine.clientsCount; * */ public engine: any + /** @private */ readonly _parser: typeof parser /** @private */ @@ -201,28 +142,62 @@ export class Server< /** * @private */ - _nsps: Map> = - new Map(); + _nsps: Map< + string, + Namespace + > = new Map(); private parentNsps: Map< ParentNspNameMatchFn, - ParentNamespace + ParentNamespace > = new Map(); private _adapter?: AdapterConstructor private _serveClient: boolean private opts: Partial - private eio + private eio: Engine private _path: string private clientPathRegex: RegExp + /** * @private */ _connectTimeout: number + // private httpServer: http.Server | HTTPSServer | Http2SecureServer - // private httpServer: http.Server - - constructor(srv: any, opts: Partial = {}) { + /** + * Server constructor. + * + * @param srv http server, port, or options + * @param [opts] + */ + constructor(opts?: Partial) + constructor( + // srv?: http.Server | HTTPSServer | Http2SecureServer | number, + srv?: any, + opts?: Partial + ) + constructor( + srv: + // | undefined + // | Partial + // | http.Server + // | HTTPSServer + // | Http2SecureServer + // | number, + any, + opts?: Partial + ) + constructor( + srv: + // | undefined + // | Partial + // | http.Server + // | HTTPSServer + // | Http2SecureServer + // | number, + any, + opts: Partial = {} + ) { super() - if (!srv) { throw new Error('srv can\'t be undefiend!') } // if ( // "object" === typeof srv && // srv instanceof Object && @@ -237,8 +212,12 @@ export class Server< this._parser = opts.parser || parser this.encoder = new this._parser.Encoder() this.adapter(opts.adapter || Adapter) - this.sockets = this.of('/') - // if (srv) this.attach(srv as http.Server); + this.sockets = this.of("/") + this.opts = opts + // if (srv || typeof srv == "number") + // this.attach( + // srv as http.Server | HTTPSServer | Http2SecureServer | number + // ) this.attach(srv, this.opts) } @@ -247,7 +226,6 @@ export class Server< * * @param v - whether to serve client code * @return self when setting or value when getting - * @public */ public serveClient(v: boolean): this public serveClient(): boolean @@ -271,7 +249,9 @@ export class Server< name: string, auth: { [key: string]: any }, fn: ( - nsp: Namespace | false + nsp: + | Namespace + | false ) => void ): void { if (this.parentNsps.size === 0) return fn(false) @@ -285,15 +265,18 @@ export class Server< } nextFn.value(name, auth, (err, allow) => { if (err || !allow) { - run() - } else { - const namespace = this.parentNsps - .get(nextFn.value)! - .createChild(name) - // @ts-ignore - this.sockets.emitReserved("new_namespace", namespace) - fn(namespace) + return run() } + if (this._nsps.has(name)) { + // the namespace was created in the meantime + debug("dynamic namespace %s already exists", name) + return fn(this._nsps.get(name) as Namespace) + } + const namespace = this.parentNsps.get(nextFn.value)!.createChild(name) + debug("dynamic namespace %s was created", name) + // @ts-ignore + this.sockets.emitReserved("new_namespace", namespace) + fn(namespace) }) } @@ -305,7 +288,6 @@ export class Server< * * @param {String} v pathname * @return {Server|String} self when setting or value when getting - * @public */ public path(v: string): this public path(): string @@ -319,7 +301,7 @@ export class Server< this.clientPathRegex = new RegExp( "^" + escapedPath + - "/socket\\.io(\\.min|\\.msgpack\\.min)?\\.js(\\.map)?$" + "/socket\\.io(\\.msgpack|\\.esm)?(\\.min)?\\.js(\\.map)?(?:\\?|$)" ) return this } @@ -327,7 +309,6 @@ export class Server< /** * Set the delay after which a client without namespace is closed * @param v - * @public */ public connectTimeout(v: number): this public connectTimeout(): number @@ -343,7 +324,6 @@ export class Server< * * @param v pathname * @return self when setting or value when getting - * @public */ public adapter(): AdapterConstructor | undefined public adapter(v: AdapterConstructor): this @@ -364,14 +344,14 @@ export class Server< * @param srv - server or port * @param opts - options passed to engine.io * @return self - * @public */ public listen( - srv: any,//http.Server | number, + // srv: http.Server | HTTPSServer | Http2SecureServer | number, + srv: any, opts: Partial = {} ): this { throw Error('Unsupport listen at MiaoScript Engine!') - //return this.attach(srv, opts) + // return this.attach(srv, opts) } /** @@ -380,10 +360,10 @@ export class Server< * @param srv - server or port * @param opts - options passed to engine.io * @return self - * @public */ public attach( - srv: any,//http.Server | number, + // srv: http.Server | HTTPSServer | Http2SecureServer | number, + srv: any, opts: Partial = {} ): this { // if ("function" == typeof srv) { @@ -418,6 +398,69 @@ export class Server< return this } + // public attachApp(app /*: TemplatedApp */, opts: Partial = {}) { + // // merge the options passed to the Socket.IO server + // Object.assign(opts, this.opts) + // // set engine.io path to `/socket.io` + // opts.path = opts.path || this._path + + // // initialize engine + // debug("creating uWebSockets.js-based engine with opts %j", opts) + // const engine = new uServer(opts) + + // engine.attach(app, opts) + + // // bind to engine events + // this.bind(engine) + + // if (this._serveClient) { + // // attach static file serving + // app.get(`${this._path}/*`, (res, req) => { + // if (!this.clientPathRegex.test(req.getUrl())) { + // req.setYield(true) + // return + // } + + // const filename = req + // .getUrl() + // .replace(this._path, "") + // .replace(/\?.*$/, "") + // .replace(/^\//, "") + // const isMap = dotMapRegex.test(filename) + // const type = isMap ? "map" : "source" + + // // Per the standard, ETags must be quoted: + // // https://tools.ietf.org/html/rfc7232#section-2.3 + // const expectedEtag = '"' + clientVersion + '"' + // const weakEtag = "W/" + expectedEtag + + // const etag = req.getHeader("if-none-match") + // if (etag) { + // if (expectedEtag === etag || weakEtag === etag) { + // debug("serve client %s 304", type) + // res.writeStatus("304 Not Modified") + // res.end() + // return + // } + // } + + // debug("serve client %s", type) + + // res.writeHeader("cache-control", "public, max-age=0") + // res.writeHeader( + // "content-type", + // "application/" + (isMap ? "json" : "javascript") + // ) + // res.writeHeader("etag", expectedEtag) + + // const filepath = path.join(__dirname, "../client-dist/", filename) + // serveFile(res, filepath) + // }) + // } + + // patchAdapter(app) + // } + /** * Initialize engine * @@ -425,10 +468,14 @@ export class Server< * @param opts - options passed to engine.io * @private */ - private initEngine(srv: any, opts: Partial) { - // // initialize engine - console.debug("creating engine.io instance with opts", JSON.stringify(opts)) - this.eio = engine.attach(srv, opts) + private initEngine( + // srv: http.Server | HTTPSServer | Http2SecureServer, + srv: any, + opts: EngineOptions & AttachOptions + ): void { + // initialize engine + debug("creating engine.io instance with opts %j", opts) + this.eio = attach(srv, opts) // // attach static file serving // if (this._serveClient) this.attachServe(srv) @@ -446,13 +493,15 @@ export class Server< // * @param srv http server // * @private // */ - // private attachServe(srv: http.Server): void { + // private attachServe( + // srv: http.Server | HTTPSServer | Http2SecureServer + // ): void { // debug("attaching client serving req handler") // const evs = srv.listeners("request").slice(0) // srv.removeAllListeners("request") // srv.on("request", (req, res) => { - // if (this.clientPathRegex.test(req.url)) { + // if (this.clientPathRegex.test(req.url!)) { // this.serve(req, res) // } else { // for (let i = 0; i < evs.length; i++) { @@ -470,7 +519,7 @@ export class Server< // * @private // */ // private serve(req: http.IncomingMessage, res: http.ServerResponse): void { - // const filename = req.url!.replace(this._path, "") + // const filename = req.url!.replace(this._path, "").replace(/\?.*$/, "") // const isMap = dotMapRegex.test(filename) // const type = isMap ? "map" : "source" @@ -547,11 +596,9 @@ export class Server< * Binds socket.io to an engine.io instance. * * @param {engine.Server} engine engine.io (or compatible) server - * @return {Server} self - * @public + * @return self */ - public bind(engine): Server { - console.debug('engine.io', engine.constructor.name, 'bind to socket.io') + public bind(engine): this { this.engine = engine this.engine.on("connection", this.onconnection.bind(this)) return this @@ -561,12 +608,12 @@ export class Server< * Called with each incoming transport connection. * * @param {engine.Socket} conn - * @return {Server} self + * @return self * @private */ - private onconnection(conn: EngineIOSocket): Server { - console.debug(`socket.io index incoming connection with id ${conn.id}`) - let client = new Client(this, conn) + private onconnection(conn): this { + debug("incoming connection with id %s", conn.id) + const client = new Client(this, conn) if (conn.protocol === 3) { // @ts-ignore client.connect("/") @@ -577,17 +624,30 @@ export class Server< /** * Looks up a namespace. * - * @param {String|RegExp|Function} name nsp name + * @example + * // with a simple string + * const myNamespace = io.of("/my-namespace"); + * + * // with a regex + * const dynamicNsp = io.of(/^\/dynamic-\d+$/).on("connection", (socket) => { + * const namespace = socket.nsp; // newNamespace.name === "/dynamic-101" + * + * // broadcast to all clients in the given sub-namespace + * namespace.emit("hello"); + * }); + * + * @param name - nsp name * @param fn optional, nsp `connection` ev handler - * @public */ public of( name: string | RegExp | ParentNspNameMatchFn, - fn?: (socket: Socket) => void - ): Namespace { + fn?: ( + socket: Socket + ) => void + ): Namespace { if (typeof name === "function" || name instanceof RegExp) { const parentNsp = new ParentNamespace(this) - console.debug(`initializing parent namespace ${parentNsp.name}`) + debug("initializing parent namespace %s", parentNsp.name) if (typeof name === "function") { this.parentNsps.set(name, parentNsp) } else { @@ -607,7 +667,7 @@ export class Server< let nsp = this._nsps.get(name) if (!nsp) { - console.debug("initializing namespace", name) + debug("initializing namespace %s", name) nsp = new Namespace(this, name) this._nsps.set(name, nsp) if (name !== "/") { @@ -623,7 +683,6 @@ export class Server< * Closes server connection * * @param [fn] optional, called as `fn([err])` on error OR all conns closed - * @public */ public close(fn?: (err?: Error) => void): void { for (const socket of this.sockets.sockets.values()) { @@ -632,6 +691,9 @@ export class Server< this.engine.close() + // // restore the Adapter prototype + // restoreAdapter() + // if (this.httpServer) { // this.httpServer.close(fn) // } else { @@ -640,14 +702,19 @@ export class Server< } /** - * Sets up namespace middleware. + * Registers a middleware, which is a function that gets executed for every incoming {@link Socket}. * - * @return self - * @public + * @example + * io.use((socket, next) => { + * // ... + * next(); + * }); + * + * @param fn - the middleware function */ public use( fn: ( - socket: Socket, + socket: Socket, next: (err?: ExtendedError) => void ) => void ): this { @@ -658,41 +725,71 @@ export class Server< /** * Targets a room when emitting. * - * @param room - * @return self - * @public + * @example + * // the “foo” event will be broadcast to all connected clients in the “room-101” room + * io.to("room-101").emit("foo", "bar"); + * + * // with an array of rooms (a client will be notified at most once) + * io.to(["room-101", "room-102"]).emit("foo", "bar"); + * + * // with multiple chained calls + * io.to("room-101").to("room-102").emit("foo", "bar"); + * + * @param room - a room, or an array of rooms + * @return a new {@link BroadcastOperator} instance for chaining */ - public to(room: Room | Room[]): BroadcastOperator { + public to(room: Room | Room[]) { return this.sockets.to(room) } /** - * Targets a room when emitting. + * Targets a room when emitting. Similar to `to()`, but might feel clearer in some cases: * - * @param room - * @return self - * @public + * @example + * // disconnect all clients in the "room-101" room + * io.in("room-101").disconnectSockets(); + * + * @param room - a room, or an array of rooms + * @return a new {@link BroadcastOperator} instance for chaining */ - public in(room: Room | Room[]): BroadcastOperator { + public in(room: Room | Room[]) { return this.sockets.in(room) } /** * Excludes a room when emitting. * - * @param name - * @return self - * @public + * @example + * // the "foo" event will be broadcast to all connected clients, except the ones that are in the "room-101" room + * io.except("room-101").emit("foo", "bar"); + * + * // with an array of rooms + * io.except(["room-101", "room-102"]).emit("foo", "bar"); + * + * // with multiple chained calls + * io.except("room-101").except("room-102").emit("foo", "bar"); + * + * @param room - a room, or an array of rooms + * @return a new {@link BroadcastOperator} instance for chaining */ - public except(name: Room | Room[]): BroadcastOperator { - return this.sockets.except(name) + public except(room: Room | Room[]) { + return this.sockets.except(room) } /** * Sends a `message` event to all clients. * + * This method mimics the WebSocket.send() method. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/send + * + * @example + * io.send("hello"); + * + * // this is equivalent to + * io.emit("message", "hello"); + * * @return self - * @public */ public send(...args: EventParams): this { this.sockets.emit("message", ...args) @@ -700,10 +797,9 @@ export class Server< } /** - * Sends a `message` event to all clients. + * Sends a `message` event to all clients. Alias of {@link send}. * * @return self - * @public */ public write(...args: EventParams): this { this.sockets.emit("message", ...args) @@ -711,11 +807,30 @@ export class Server< } /** - * Emit a packet to other Socket.IO servers + * Sends a message to the other Socket.IO servers of the cluster. + * + * @example + * io.serverSideEmit("hello", "world"); + * + * io.on("hello", (arg1) => { + * console.log(arg1); // prints "world" + * }); + * + * // acknowledgements (without binary content) are supported too: + * io.serverSideEmit("ping", (err, responses) => { + * if (err) { + * // some clients did not acknowledge the event in the given delay + * } else { + * console.log(responses); // one response per client + * } + * }); + * + * io.on("ping", (cb) => { + * cb("pong"); + * }); * * @param ev - the event name * @param args - an array of arguments, which may include an acknowledgement callback at the end - * @public */ public serverSideEmit>( ev: Ev, @@ -727,7 +842,8 @@ export class Server< /** * Gets a list of socket ids. * - * @public + * @deprecated this method will be removed in the next major release, please use {@link Server#serverSideEmit} or + * {@link Server#fetchSockets} instead. */ public allSockets(): Promise> { return this.sockets.allSockets() @@ -736,11 +852,13 @@ export class Server< /** * Sets the compress flag. * + * @example + * io.compress(false).emit("hello"); + * * @param compress - if `true`, compresses the sending data - * @return self - * @public + * @return a new {@link BroadcastOperator} instance for chaining */ - public compress(compress: boolean): BroadcastOperator { + public compress(compress: boolean) { return this.sockets.compress(compress) } @@ -749,59 +867,126 @@ export class Server< * receive messages (because of network slowness or other issues, or because they’re connected through long polling * and is in the middle of a request-response cycle). * - * @return self - * @public + * @example + * io.volatile.emit("hello"); // the clients may or may not receive it + * + * @return a new {@link BroadcastOperator} instance for chaining */ - public get volatile(): BroadcastOperator { + public get volatile() { return this.sockets.volatile } /** * Sets a modifier for a subsequent event emission that the event data will only be broadcast to the current node. * - * @return self - * @public + * @example + * // the “foo” event will be broadcast to all connected clients on this node + * io.local.emit("foo", "bar"); + * + * @return a new {@link BroadcastOperator} instance for chaining */ - public get local(): BroadcastOperator { + public get local() { return this.sockets.local } /** - * Returns the matching socket instances + * Adds a timeout in milliseconds for the next operation. * - * @public + * @example + * io.timeout(1000).emit("some-event", (err, responses) => { + * if (err) { + * // some clients did not acknowledge the event in the given delay + * } else { + * console.log(responses); // one response per client + * } + * }); + * + * @param timeout */ - public fetchSockets(): Promise[]> { + public timeout(timeout: number) { + return this.sockets.timeout(timeout) + } + + /** + * Returns the matching socket instances. + * + * Note: this method also works within a cluster of multiple Socket.IO servers, with a compatible {@link Adapter}. + * + * @example + * // return all Socket instances + * const sockets = await io.fetchSockets(); + * + * // return all Socket instances in the "room1" room + * const sockets = await io.in("room1").fetchSockets(); + * + * for (const socket of sockets) { + * console.log(socket.id); + * console.log(socket.handshake); + * console.log(socket.rooms); + * console.log(socket.data); + * + * socket.emit("hello"); + * socket.join("room1"); + * socket.leave("room2"); + * socket.disconnect(); + * } + */ + public fetchSockets(): Promise[]> { return this.sockets.fetchSockets() } /** - * Makes the matching socket instances join the specified rooms + * Makes the matching socket instances join the specified rooms. * - * @param room - * @public + * Note: this method also works within a cluster of multiple Socket.IO servers, with a compatible {@link Adapter}. + * + * @example + * + * // make all socket instances join the "room1" room + * io.socketsJoin("room1"); + * + * // make all socket instances in the "room1" room join the "room2" and "room3" rooms + * io.in("room1").socketsJoin(["room2", "room3"]); + * + * @param room - a room, or an array of rooms */ - public socketsJoin(room: Room | Room[]): void { + public socketsJoin(room: Room | Room[]) { return this.sockets.socketsJoin(room) } /** - * Makes the matching socket instances leave the specified rooms + * Makes the matching socket instances leave the specified rooms. * - * @param room - * @public + * Note: this method also works within a cluster of multiple Socket.IO servers, with a compatible {@link Adapter}. + * + * @example + * // make all socket instances leave the "room1" room + * io.socketsLeave("room1"); + * + * // make all socket instances in the "room1" room leave the "room2" and "room3" rooms + * io.in("room1").socketsLeave(["room2", "room3"]); + * + * @param room - a room, or an array of rooms */ - public socketsLeave(room: Room | Room[]): void { + public socketsLeave(room: Room | Room[]) { return this.sockets.socketsLeave(room) } /** - * Makes the matching socket instances disconnect + * Makes the matching socket instances disconnect. + * + * Note: this method also works within a cluster of multiple Socket.IO servers, with a compatible {@link Adapter}. + * + * @example + * // make all socket instances disconnect (the connections might be kept alive for other namespaces) + * io.disconnectSockets(); + * + * // make all socket instances in the "room1" room disconnect and close the underlying connections + * io.in("room1").disconnectSockets(true); * * @param close - whether to close the underlying connection - * @public */ - public disconnectSockets(close: boolean = false): void { + public disconnectSockets(close: boolean = false) { return this.sockets.disconnectSockets(close) } } @@ -822,4 +1007,10 @@ emitterMethods.forEach(function (fn) { } }) +module.exports = (srv?, opts?) => new Server(srv, opts) +module.exports.Server = Server +module.exports.Namespace = Namespace +module.exports.Socket = Socket + export { Socket, ServerOptions, Namespace, BroadcastOperator, RemoteSocket } +export { Event } from "./socket" diff --git a/packages/websocket/src/socket.io/namespace.ts b/packages/websocket/src/socket.io/namespace.ts index a0242e88..8035f407 100644 --- a/packages/websocket/src/socket.io/namespace.ts +++ b/packages/websocket/src/socket.io/namespace.ts @@ -1,4 +1,3 @@ - import { Socket } from "./socket" import type { Server } from "./index" import { @@ -14,7 +13,8 @@ import type { Client } from "./client" import type { Adapter, Room, SocketId } from "../socket.io-adapter" import { BroadcastOperator, RemoteSocket } from "./broadcast-operator" -// const debug = debugModule("socket.io:namespace"); +// const debug = debugModule("socket.io:namespace") +const debug = require('../debug')("socket.io:namespace") export interface ExtendedError extends Error { data?: any @@ -23,56 +23,125 @@ export interface ExtendedError extends Error { export interface NamespaceReservedEventsMap< ListenEvents extends EventsMap, EmitEvents extends EventsMap, - ServerSideEvents extends EventsMap - > { - connect: (socket: Socket) => void + ServerSideEvents extends EventsMap, + SocketData +> { + connect: ( + socket: Socket + ) => void connection: ( - socket: Socket + socket: Socket ) => void } export interface ServerReservedEventsMap< + ListenEvents extends EventsMap, + EmitEvents extends EventsMap, + ServerSideEvents extends EventsMap, + SocketData +> extends NamespaceReservedEventsMap< ListenEvents, EmitEvents, - ServerSideEvents - > extends NamespaceReservedEventsMap< - ListenEvents, - EmitEvents, - ServerSideEvents - > { + ServerSideEvents, + SocketData +> { new_namespace: ( - namespace: Namespace + namespace: Namespace ) => void } export const RESERVED_EVENTS: ReadonlySet = new Set< - keyof ServerReservedEventsMap + keyof ServerReservedEventsMap >(["connect", "connection", "new_namespace"]) +/** + * A Namespace is a communication channel that allows you to split the logic of your application over a single shared + * connection. + * + * Each namespace has its own: + * + * - event handlers + * + * ``` + * io.of("/orders").on("connection", (socket) => { + * socket.on("order:list", () => {}); + * socket.on("order:create", () => {}); + * }); + * + * io.of("/users").on("connection", (socket) => { + * socket.on("user:list", () => {}); + * }); + * ``` + * + * - rooms + * + * ``` + * const orderNamespace = io.of("/orders"); + * + * orderNamespace.on("connection", (socket) => { + * socket.join("room1"); + * orderNamespace.to("room1").emit("hello"); + * }); + * + * const userNamespace = io.of("/users"); + * + * userNamespace.on("connection", (socket) => { + * socket.join("room1"); // distinct from the room in the "orders" namespace + * userNamespace.to("room1").emit("holà"); + * }); + * ``` + * + * - middlewares + * + * ``` + * const orderNamespace = io.of("/orders"); + * + * orderNamespace.use((socket, next) => { + * // ensure the socket has access to the "orders" namespace + * }); + * + * const userNamespace = io.of("/users"); + * + * userNamespace.use((socket, next) => { + * // ensure the socket has access to the "users" namespace + * }); + * ``` + */ export class Namespace< ListenEvents extends EventsMap = DefaultEventsMap, EmitEvents extends EventsMap = ListenEvents, - ServerSideEvents extends EventsMap = DefaultEventsMap - > extends StrictEventEmitter< + ServerSideEvents extends EventsMap = DefaultEventsMap, + SocketData = any +> extends StrictEventEmitter< ServerSideEvents, EmitEvents, - NamespaceReservedEventsMap - > { + NamespaceReservedEventsMap< + ListenEvents, + EmitEvents, + ServerSideEvents, + SocketData + > +> { public readonly name: string public readonly sockets: Map< SocketId, - Socket + Socket > = new Map(); public adapter: Adapter /** @private */ - readonly server: Server + readonly server: Server< + ListenEvents, + EmitEvents, + ServerSideEvents, + SocketData + > /** @private */ _fns: Array< ( - socket: Socket, + socket: Socket, next: (err?: ExtendedError) => void ) => void > = []; @@ -87,7 +156,7 @@ export class Namespace< * @param name */ constructor( - server: Server, + server: Server, name: string ) { super() @@ -103,20 +172,27 @@ export class Namespace< * * @private */ - _initAdapter() { + _initAdapter(): void { // @ts-ignore this.adapter = new (this.server.adapter()!)(this) } /** - * Sets up namespace middleware. + * Registers a middleware, which is a function that gets executed for every incoming {@link Socket}. * - * @return self - * @public + * @example + * const myNamespace = io.of("/my-namespace"); + * + * myNamespace.use((socket, next) => { + * // ... + * next(); + * }); + * + * @param fn - the middleware function */ public use( fn: ( - socket: Socket, + socket: Socket, next: (err?: ExtendedError) => void ) => void ): this { @@ -132,7 +208,7 @@ export class Namespace< * @private */ private run( - socket: Socket, + socket: Socket, fn: (err: ExtendedError | null) => void ) { const fns = this._fns.slice(0) @@ -157,34 +233,63 @@ export class Namespace< /** * Targets a room when emitting. * - * @param room - * @return self - * @public + * @example + * const myNamespace = io.of("/my-namespace"); + * + * // the “foo” event will be broadcast to all connected clients in the “room-101” room + * myNamespace.to("room-101").emit("foo", "bar"); + * + * // with an array of rooms (a client will be notified at most once) + * myNamespace.to(["room-101", "room-102"]).emit("foo", "bar"); + * + * // with multiple chained calls + * myNamespace.to("room-101").to("room-102").emit("foo", "bar"); + * + * @param room - a room, or an array of rooms + * @return a new {@link BroadcastOperator} instance for chaining */ - public to(room: Room | Room[]): BroadcastOperator { - return new BroadcastOperator(this.adapter).to(room) + public to(room: Room | Room[]) { + return new BroadcastOperator(this.adapter).to(room) } /** - * Targets a room when emitting. + * Targets a room when emitting. Similar to `to()`, but might feel clearer in some cases: * - * @param room - * @return self - * @public + * @example + * const myNamespace = io.of("/my-namespace"); + * + * // disconnect all clients in the "room-101" room + * myNamespace.in("room-101").disconnectSockets(); + * + * @param room - a room, or an array of rooms + * @return a new {@link BroadcastOperator} instance for chaining */ - public in(room: Room | Room[]): BroadcastOperator { - return new BroadcastOperator(this.adapter).in(room) + public in(room: Room | Room[]) { + return new BroadcastOperator(this.adapter).in(room) } /** * Excludes a room when emitting. * - * @param room - * @return self - * @public + * @example + * const myNamespace = io.of("/my-namespace"); + * + * // the "foo" event will be broadcast to all connected clients, except the ones that are in the "room-101" room + * myNamespace.except("room-101").emit("foo", "bar"); + * + * // with an array of rooms + * myNamespace.except(["room-101", "room-102"]).emit("foo", "bar"); + * + * // with multiple chained calls + * myNamespace.except("room-101").except("room-102").emit("foo", "bar"); + * + * @param room - a room, or an array of rooms + * @return a new {@link BroadcastOperator} instance for chaining */ - public except(room: Room | Room[]): BroadcastOperator { - return new BroadcastOperator(this.adapter).except(room) + public except(room: Room | Room[]) { + return new BroadcastOperator(this.adapter).except( + room + ) } /** @@ -197,41 +302,45 @@ export class Namespace< client: Client, query, fn?: (socket: Socket) => void - ): Socket { - const socket = new Socket(this, client, query || {}) - console.debug(`socket.io namespace client ${client.id} adding socket ${socket.id} to nsp ${this.name}`) - this.run(socket, err => { + ): Socket { + debug("adding socket to nsp %s", this.name) + const socket = new Socket(this, client, query) + this.run(socket, (err) => { process.nextTick(() => { - if ("open" == client.conn.readyState) { - if (err) { - if (client.conn.protocol === 3) { - return socket._error(err.data || err.message) - } else { - return socket._error({ - message: err.message, - data: err.data, - }) - } - } - - // track socket - this.sockets.set(socket.id, socket) - console.debug(`socket.io namespace ${this.name} track client ${client.id} socket ${socket.id}`) - - // it's paramount that the internal `onconnect` logic - // fires before user-set events to prevent state order - // violations (such as a disconnection before the connection - // logic is complete) - socket._onconnect() - // @java-patch multi thread need direct callback socket - if (fn) fn(socket) - - // fire user-set events - this.emitReserved("connect", socket) - this.emitReserved("connection", socket) - } else { - console.debug(`next called after client ${client.id} was closed - ignoring socket`) + if ("open" !== client.conn.readyState) { + debug("next called after client was closed - ignoring socket") + socket._cleanup() + return } + + if (err) { + debug("middleware error, sending CONNECT_ERROR packet to the client") + socket._cleanup() + if (client.conn.protocol === 3) { + return socket._error(err.data || err.message) + } else { + return socket._error({ + message: err.message, + data: err.data, + }) + } + } + + // track socket + this.sockets.set(socket.id, socket) + + // it's paramount that the internal `onconnect` logic + // fires before user-set events to prevent state order + // violations (such as a disconnection before the connection + // logic is complete) + socket._onconnect() + // if (fn) fn() + // @java-patch multi thread need direct callback socket + if (fn) fn(socket) + + // fire user-set events + this.emitReserved("connect", socket) + this.emitReserved("connection", socket) }) }) return socket @@ -242,33 +351,64 @@ export class Namespace< * * @private */ - _remove(socket: Socket): void { + _remove( + socket: Socket + ): void { if (this.sockets.has(socket.id)) { - console.debug(`namespace ${this.name} remove socket ${socket.id}`) this.sockets.delete(socket.id) } else { - console.debug(`namespace ${this.name} ignoring remove for ${socket.id}`) + debug("ignoring remove for %s", socket.id) } } /** - * Emits to all clients. + * Emits to all connected clients. + * + * @example + * const myNamespace = io.of("/my-namespace"); + * + * myNamespace.emit("hello", "world"); + * + * // all serializable datastructures are supported (no need to call JSON.stringify) + * myNamespace.emit("hello", 1, "2", { 3: ["4"], 5: Uint8Array.from([6]) }); + * + * // with an acknowledgement from the clients + * myNamespace.timeout(1000).emit("some-event", (err, responses) => { + * if (err) { + * // some clients did not acknowledge the event in the given delay + * } else { + * console.log(responses); // one response per client + * } + * }); * * @return Always true - * @public */ public emit>( ev: Ev, ...args: EventParams ): boolean { - return new BroadcastOperator(this.adapter).emit(ev, ...args) + return new BroadcastOperator(this.adapter).emit( + ev, + ...args + ) } /** * Sends a `message` event to all clients. * + * This method mimics the WebSocket.send() method. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/send + * + * @example + * const myNamespace = io.of("/my-namespace"); + * + * myNamespace.send("hello"); + * + * // this is equivalent to + * myNamespace.emit("message", "hello"); + * * @return self - * @public */ public send(...args: EventParams): this { this.emit("message", ...args) @@ -276,10 +416,9 @@ export class Namespace< } /** - * Sends a `message` event to all clients. + * Sends a `message` event to all clients. Sends a `message` event. Alias of {@link send}. * * @return self - * @public */ public write(...args: EventParams): this { this.emit("message", ...args) @@ -287,18 +426,39 @@ export class Namespace< } /** - * Emit a packet to other Socket.IO servers + * Sends a message to the other Socket.IO servers of the cluster. + * + * @example + * const myNamespace = io.of("/my-namespace"); + * + * myNamespace.serverSideEmit("hello", "world"); + * + * myNamespace.on("hello", (arg1) => { + * console.log(arg1); // prints "world" + * }); + * + * // acknowledgements (without binary content) are supported too: + * myNamespace.serverSideEmit("ping", (err, responses) => { + * if (err) { + * // some clients did not acknowledge the event in the given delay + * } else { + * console.log(responses); // one response per client + * } + * }); + * + * myNamespace.on("ping", (cb) => { + * cb("pong"); + * }); * * @param ev - the event name * @param args - an array of arguments, which may include an acknowledgement callback at the end - * @public */ public serverSideEmit>( ev: Ev, ...args: EventParams ): boolean { if (RESERVED_EVENTS.has(ev)) { - throw new Error(`"${ev}" is a reserved event name`) + throw new Error(`"${String(ev)}" is a reserved event name`) } args.unshift(ev) this.adapter.serverSideEmit(args) @@ -319,22 +479,30 @@ export class Namespace< /** * Gets a list of clients. * - * @return self - * @public + * @deprecated this method will be removed in the next major release, please use {@link Namespace#serverSideEmit} or + * {@link Namespace#fetchSockets} instead. */ public allSockets(): Promise> { - return new BroadcastOperator(this.adapter).allSockets() + return new BroadcastOperator( + this.adapter + ).allSockets() } /** - * Sets the compress flag. - * - * @param compress - if `true`, compresses the sending data - * @return self - * @public - */ - public compress(compress: boolean): BroadcastOperator { - return new BroadcastOperator(this.adapter).compress(compress) + * Sets the compress flag. + * + * @example + * const myNamespace = io.of("/my-namespace"); + * + * myNamespace.compress(false).emit("hello"); + * + * @param compress - if `true`, compresses the sending data + * @return self + */ + public compress(compress: boolean) { + return new BroadcastOperator(this.adapter).compress( + compress + ) } /** @@ -342,62 +510,153 @@ export class Namespace< * receive messages (because of network slowness or other issues, or because they’re connected through long polling * and is in the middle of a request-response cycle). * + * @example + * const myNamespace = io.of("/my-namespace"); + * + * myNamespace.volatile.emit("hello"); // the clients may or may not receive it + * * @return self - * @public */ - public get volatile(): BroadcastOperator { - return new BroadcastOperator(this.adapter).volatile + public get volatile() { + return new BroadcastOperator(this.adapter).volatile } /** * Sets a modifier for a subsequent event emission that the event data will only be broadcast to the current node. * - * @return self - * @public - */ - public get local(): BroadcastOperator { - return new BroadcastOperator(this.adapter).local - } - - /** - * Returns the matching socket instances + * @example + * const myNamespace = io.of("/my-namespace"); * - * @public - */ - public fetchSockets(): Promise[]> { - return new BroadcastOperator(this.adapter).fetchSockets() - } - - /** - * Makes the matching socket instances join the specified rooms + * // the “foo” event will be broadcast to all connected clients on this node + * myNamespace.local.emit("foo", "bar"); * - * @param room - * @public + * @return a new {@link BroadcastOperator} instance for chaining */ - public socketsJoin(room: Room | Room[]): void { - return new BroadcastOperator(this.adapter).socketsJoin(room) + public get local() { + return new BroadcastOperator(this.adapter).local } /** - * Makes the matching socket instances leave the specified rooms + * Adds a timeout in milliseconds for the next operation. * - * @param room - * @public + * @example + * const myNamespace = io.of("/my-namespace"); + * + * myNamespace.timeout(1000).emit("some-event", (err, responses) => { + * if (err) { + * // some clients did not acknowledge the event in the given delay + * } else { + * console.log(responses); // one response per client + * } + * }); + * + * @param timeout */ - public socketsLeave(room: Room | Room[]): void { - return new BroadcastOperator(this.adapter).socketsLeave(room) + public timeout(timeout: number) { + return new BroadcastOperator(this.adapter).timeout( + timeout + ) } /** - * Makes the matching socket instances disconnect + * Returns the matching socket instances. + * + * Note: this method also works within a cluster of multiple Socket.IO servers, with a compatible {@link Adapter}. + * + * @example + * const myNamespace = io.of("/my-namespace"); + * + * // return all Socket instances + * const sockets = await myNamespace.fetchSockets(); + * + * // return all Socket instances in the "room1" room + * const sockets = await myNamespace.in("room1").fetchSockets(); + * + * for (const socket of sockets) { + * console.log(socket.id); + * console.log(socket.handshake); + * console.log(socket.rooms); + * console.log(socket.data); + * + * socket.emit("hello"); + * socket.join("room1"); + * socket.leave("room2"); + * socket.disconnect(); + * } + */ + public fetchSockets() { + return new BroadcastOperator( + this.adapter + ).fetchSockets() + } + + /** + * Makes the matching socket instances join the specified rooms. + * + * Note: this method also works within a cluster of multiple Socket.IO servers, with a compatible {@link Adapter}. + * + * @example + * const myNamespace = io.of("/my-namespace"); + * + * // make all socket instances join the "room1" room + * myNamespace.socketsJoin("room1"); + * + * // make all socket instances in the "room1" room join the "room2" and "room3" rooms + * myNamespace.in("room1").socketsJoin(["room2", "room3"]); + * + * @param room - a room, or an array of rooms + */ + public socketsJoin(room: Room | Room[]) { + return new BroadcastOperator( + this.adapter + ).socketsJoin(room) + } + + /** + * Makes the matching socket instances leave the specified rooms. + * + * Note: this method also works within a cluster of multiple Socket.IO servers, with a compatible {@link Adapter}. + * + * @example + * const myNamespace = io.of("/my-namespace"); + * + * // make all socket instances leave the "room1" room + * myNamespace.socketsLeave("room1"); + * + * // make all socket instances in the "room1" room leave the "room2" and "room3" rooms + * myNamespace.in("room1").socketsLeave(["room2", "room3"]); + * + * @param room - a room, or an array of rooms + */ + public socketsLeave(room: Room | Room[]) { + return new BroadcastOperator( + this.adapter + ).socketsLeave(room) + } + + /** + * Makes the matching socket instances disconnect. + * + * Note: this method also works within a cluster of multiple Socket.IO servers, with a compatible {@link Adapter}. + * + * @example + * const myNamespace = io.of("/my-namespace"); + * + * // make all socket instances disconnect (the connections might be kept alive for other namespaces) + * myNamespace.disconnectSockets(); + * + * // make all socket instances in the "room1" room disconnect and close the underlying connections + * myNamespace.in("room1").disconnectSockets(true); * * @param close - whether to close the underlying connection - * @public */ - public disconnectSockets(close: boolean = false): void { - return new BroadcastOperator(this.adapter).disconnectSockets(close) + public disconnectSockets(close: boolean = false) { + return new BroadcastOperator( + this.adapter + ).disconnectSockets(close) } + // @java-patch public close() { RESERVED_EVENTS.forEach(event => this.removeAllListeners(event as any)) this.server._nsps.delete(this.name) diff --git a/packages/websocket/src/socket.io/parent-namespace.ts b/packages/websocket/src/socket.io/parent-namespace.ts index 9a5c01ab..7ebee1e7 100644 --- a/packages/websocket/src/socket.io/parent-namespace.ts +++ b/packages/websocket/src/socket.io/parent-namespace.ts @@ -1,5 +1,5 @@ import { Namespace } from "./namespace" -import type { Server } from "./index" +import type { Server, RemoteSocket } from "./index" import type { EventParams, EventNames, @@ -12,16 +12,24 @@ import type { BroadcastOptions } from "../socket.io-adapter" export class ParentNamespace< ListenEvents extends EventsMap = DefaultEventsMap, EmitEvents extends EventsMap = ListenEvents, - ServerSideEvents extends EventsMap = DefaultEventsMap - > extends Namespace { + ServerSideEvents extends EventsMap = DefaultEventsMap, + SocketData = any +> extends Namespace { private static count: number = 0; - private children: Set> = new Set(); + private children: Set< + Namespace + > = new Set(); - constructor(server: Server) { + constructor( + server: Server + ) { super(server, "/_" + ParentNamespace.count++) } - _initAdapter() { + /** + * @private + */ + _initAdapter(): void { const broadcast = (packet: any, opts: BroadcastOptions) => { this.children.forEach((nsp) => { nsp.adapter.broadcast(packet, opts) @@ -42,21 +50,9 @@ export class ParentNamespace< return true } - // public emit(...args: any[]): boolean { - // this.children.forEach(nsp => { - // nsp._rooms = this._rooms - // nsp._flags = this._flags - // nsp.emit.apply(nsp, args as any) - // }) - // this._rooms.clear() - // this._flags = {} - - // return true - // } - createChild( name: string - ): Namespace { + ): Namespace { const namespace = new Namespace(this.server, name) namespace._fns = this._fns.slice(0) this.listeners("connect").forEach((listener) => @@ -69,4 +65,13 @@ export class ParentNamespace< this.server._nsps.set(name, namespace) return namespace } + + fetchSockets(): Promise[]> { + // note: we could make the fetchSockets() method work for dynamic namespaces created with a regex (by sending the + // regex to the other Socket.IO servers, and returning the sockets of each matching namespace for example), but + // the behavior for namespaces created with a function is less clear + // note²: we cannot loop over each children namespace, because with multiple Socket.IO servers, a given namespace + // may exist on one node but not exist on another (since it is created upon client connection) + throw new Error("fetchSockets() is not supported on parent namespaces") + } } diff --git a/packages/websocket/src/socket.io/socket.ts b/packages/websocket/src/socket.io/socket.ts index a9804a9a..8b58de91 100644 --- a/packages/websocket/src/socket.io/socket.ts +++ b/packages/websocket/src/socket.io/socket.ts @@ -1,7 +1,6 @@ - +// import { Packet, PacketType } from "socket.io-parser" import { Packet, PacketType } from "../socket.io-parser" -import url = require("url") -// import debugModule from "debug" +// import debugModule from "debug"; import type { Server } from "./index" import { EventParams, @@ -12,24 +11,41 @@ import { } from "./typed-events" import type { Client } from "./client" import type { Namespace, NamespaceReservedEventsMap } from "./namespace" -// import type { IncomingMessage, IncomingHttpHeaders } from "http" +// import type { IncomingMessage, IncomingHttpHeaders } from "http"; import type { Adapter, BroadcastFlags, Room, SocketId, -} from "socket.io-adapter" -// import base64id from "base64id" +} from "../socket.io-adapter" +// import base64id from "base64id"; import type { ParsedUrlQuery } from "querystring" import { BroadcastOperator } from "./broadcast-operator" +import * as url from "url" // const debug = debugModule("socket.io:socket"); +const debug = require('../debug')("socket.io:socket") type ClientReservedEvents = "connect_error" +// TODO for next major release: cleanup disconnect reasons +export type DisconnectReason = + // Engine.IO close reasons + | "transport error" + | "transport close" + | "forced close" + | "ping timeout" + | "parse error" + // Socket.IO disconnect reasons + | "server shutting down" + | "forced server close" + | "client namespace disconnect" + | "server namespace disconnect" + | any + export interface SocketReservedEventsMap { - disconnect: (reason: string) => void - disconnecting: (reason: string) => void + disconnect: (reason: DisconnectReason) => void + disconnecting: (reason: DisconnectReason) => void error: (err: Error) => void } @@ -47,7 +63,7 @@ export interface EventEmitterReservedEventsMap { export const RESERVED_EVENTS: ReadonlySet = new Set< | ClientReservedEvents - | keyof NamespaceReservedEventsMap + | keyof NamespaceReservedEventsMap | keyof SocketReservedEventsMap | keyof EventEmitterReservedEventsMap >([ @@ -66,7 +82,8 @@ export interface Handshake { /** * The headers sent as part of the handshake */ - headers: any//IncomingHttpHeaders + // headers: IncomingHttpHeaders; + headers: any /** * The date of creation (as string) @@ -109,33 +126,94 @@ export interface Handshake { auth: { [key: string]: any } } +/** + * `[eventName, ...args]` + */ +export type Event = [string, ...any[]] + +function noop() { } + +/** + * This is the main object for interacting with a client. + * + * A Socket belongs to a given {@link Namespace} and uses an underlying {@link Client} to communicate. + * + * Within each {@link Namespace}, you can also define arbitrary channels (called "rooms") that the {@link Socket} can + * join and leave. That provides a convenient way to broadcast to a group of socket instances. + * + * @example + * io.on("connection", (socket) => { + * console.log(`socket ${socket.id} connected`); + * + * // send an event to the client + * socket.emit("foo", "bar"); + * + * socket.on("foobar", () => { + * // an event was received from the client + * }); + * + * // join the room named "room1" + * socket.join("room1"); + * + * // broadcast to everyone in the room named "room1" + * io.to("room1").emit("hello"); + * + * // upon disconnection + * socket.on("disconnect", (reason) => { + * console.log(`socket ${socket.id} disconnected due to ${reason}`); + * }); + * }); + */ export class Socket< ListenEvents extends EventsMap = DefaultEventsMap, EmitEvents extends EventsMap = ListenEvents, - ServerSideEvents extends EventsMap = DefaultEventsMap - > extends StrictEventEmitter< + ServerSideEvents extends EventsMap = DefaultEventsMap, + SocketData = any +> extends StrictEventEmitter< ListenEvents, EmitEvents, SocketReservedEventsMap - > { - public readonly id: SocketId - public readonly handshake: Handshake - +> { /** - * Additional information that can be attached to the Socket instance and which will be used in the fetchSockets method + * An unique identifier for the session. */ - public data: any = {}; + public readonly id: SocketId + /** + * The handshake details. + */ + public readonly handshake: Handshake + /** + * Additional information that can be attached to the Socket instance and which will be used in the + * {@link Server.fetchSockets()} method. + */ + public data: Partial = {}; + /** + * Whether the socket is currently connected or not. + * + * @example + * io.use((socket, next) => { + * console.log(socket.connected); // false + * next(); + * }); + * + * io.on("connection", (socket) => { + * console.log(socket.connected); // true + * }); + */ + public connected: boolean = false; - public connected: boolean - public disconnected: boolean - - private readonly server: Server + private readonly server: Server< + ListenEvents, + EmitEvents, + ServerSideEvents, + SocketData + > private readonly adapter: Adapter private acks: Map void> = new Map(); - private fns: Array<(event: Array, next: (err?: Error) => void) => void> = - []; + private fns: Array<(event: Event, next: (err?: Error) => void) => void> = []; private flags: BroadcastFlags = {}; private _anyListeners?: Array<(...args: any[]) => void> + private _anyOutgoingListeners?: Array<(...args: any[]) => void> /** * Interface to a `Client` for a given `Namespace`. @@ -151,23 +229,23 @@ export class Socket< auth: object ) { super() - this.nsp = nsp this.server = nsp.server this.adapter = this.nsp.adapter // if (client.conn.protocol === 3) { - // // @ts-ignore + // @ts-ignore this.id = nsp.name !== "/" ? nsp.name + "#" + client.id : client.id // } else { - // this.id = base64id.generateId() // don't reuse the Engine.IO id because it's sensitive information + // this.id = base64id.generateId() // don't reuse the Engine.IO id because it's sensitive information // } - this.client = client - this.acks = new Map() - this.connected = true - this.disconnected = false this.handshake = this.buildHandshake(auth) } - buildHandshake(auth): Handshake { + /** + * Builds the `handshake` BC object + * + * @private + */ + private buildHandshake(auth: object): Handshake { return { headers: this.request.headers, time: new Date() + "", @@ -177,6 +255,7 @@ export class Socket< secure: !!this.request.connection.encrypted, issued: +new Date(), url: this.request.url!, + // @ts-ignore query: url.parse(this.request.url!, true).query, auth, } @@ -185,15 +264,27 @@ export class Socket< /** * Emits to this client. * + * @example + * io.on("connection", (socket) => { + * socket.emit("hello", "world"); + * + * // all serializable datastructures are supported (no need to call JSON.stringify) + * socket.emit("hello", 1, "2", { 3: ["4"], 5: Buffer.from([6]) }); + * + * // with an acknowledgement from the client + * socket.emit("hello", "world", (val) => { + * // ... + * }); + * }); + * * @return Always returns `true`. - * @public */ public emit>( ev: Ev, ...args: EventParams ): boolean { if (RESERVED_EVENTS.has(ev)) { - throw new Error(`"${ev}" is a reserved event name`) + throw new Error(`"${String(ev)}" is a reserved event name`) } const data: any[] = [ev, ...args] const packet: any = { @@ -203,57 +294,124 @@ export class Socket< // access last argument to see if it's an ACK callback if (typeof data[data.length - 1] === "function") { - console.trace("emitting packet with ack id %d", this.nsp._ids) - this.acks.set(this.nsp._ids, data.pop()) - packet.id = this.nsp._ids++ + const id = this.nsp._ids++ + debug("emitting packet with ack id %d", id) + + this.registerAckCallback(id, data.pop()) + packet.id = id } const flags = Object.assign({}, this.flags) this.flags = {} + this.notifyOutgoingListeners(packet) this.packet(packet, flags) return true } /** - * Targets a room when broadcasting. - * - * @param room - * @return self - * @public + * @private */ - public to(room: Room | Room[]): BroadcastOperator { - return this.newBroadcastOperator().to(room) + private registerAckCallback(id: number, ack: (...args: any[]) => void): void { + const timeout = this.flags.timeout + if (timeout === undefined) { + this.acks.set(id, ack) + return + } + + const timer = setTimeout(() => { + debug("event with ack id %d has timed out after %d ms", id, timeout) + this.acks.delete(id) + ack.call(this, new Error("operation has timed out")) + }, timeout) + + this.acks.set(id, (...args) => { + clearTimeout(timer) + ack.apply(this, [null, ...args]) + }) } /** * Targets a room when broadcasting. * - * @param room - * @return self - * @public + * @example + * io.on("connection", (socket) => { + * // the “foo” event will be broadcast to all connected clients in the “room-101” room, except this socket + * socket.to("room-101").emit("foo", "bar"); + * + * // the code above is equivalent to: + * io.to("room-101").except(socket.id).emit("foo", "bar"); + * + * // with an array of rooms (a client will be notified at most once) + * socket.to(["room-101", "room-102"]).emit("foo", "bar"); + * + * // with multiple chained calls + * socket.to("room-101").to("room-102").emit("foo", "bar"); + * }); + * + * @param room - a room, or an array of rooms + * @return a new {@link BroadcastOperator} instance for chaining */ - public in(room: Room | Room[]): BroadcastOperator { + public to(room: Room | Room[]) { + return this.newBroadcastOperator().to(room) + } + + /** + * Targets a room when broadcasting. Similar to `to()`, but might feel clearer in some cases: + * + * @example + * io.on("connection", (socket) => { + * // disconnect all clients in the "room-101" room, except this socket + * socket.in("room-101").disconnectSockets(); + * }); + * + * @param room - a room, or an array of rooms + * @return a new {@link BroadcastOperator} instance for chaining + */ + public in(room: Room | Room[]) { return this.newBroadcastOperator().in(room) } /** * Excludes a room when broadcasting. * - * @param room - * @return self - * @public + * @example + * io.on("connection", (socket) => { + * // the "foo" event will be broadcast to all connected clients, except the ones that are in the "room-101" room + * // and this socket + * socket.except("room-101").emit("foo", "bar"); + * + * // with an array of rooms + * socket.except(["room-101", "room-102"]).emit("foo", "bar"); + * + * // with multiple chained calls + * socket.except("room-101").except("room-102").emit("foo", "bar"); + * }); + * + * @param room - a room, or an array of rooms + * @return a new {@link BroadcastOperator} instance for chaining */ - public except(room: Room | Room[]): BroadcastOperator { + public except(room: Room | Room[]) { return this.newBroadcastOperator().except(room) } /** * Sends a `message` event. * + * This method mimics the WebSocket.send() method. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/send + * + * @example + * io.on("connection", (socket) => { + * socket.send("hello"); + * + * // this is equivalent to + * socket.emit("message", "hello"); + * }); + * * @return self - * @public */ public send(...args: EventParams): this { this.emit("message", ...args) @@ -261,10 +419,9 @@ export class Socket< } /** - * Sends a `message` event. + * Sends a `message` event. Alias of {@link send}. * * @return self - * @public */ public write(...args: EventParams): this { this.emit("message", ...args) @@ -290,12 +447,20 @@ export class Socket< /** * Joins a room. * + * @example + * io.on("connection", (socket) => { + * // join a single room + * socket.join("room1"); + * + * // join multiple rooms + * socket.join(["room1", "room2"]); + * }); + * * @param {String|Array} rooms - room or array of rooms * @return a Promise or nothing, depending on the adapter - * @public */ public join(rooms: Room | Array): Promise | void { - console.debug(`join room ${rooms}`) + debug("join room %s", rooms) return this.adapter.addAll( this.id, @@ -306,12 +471,20 @@ export class Socket< /** * Leaves a room. * + * @example + * io.on("connection", (socket) => { + * // leave a single room + * socket.leave("room1"); + * + * // leave multiple rooms + * socket.leave("room1").leave("room2"); + * }); + * * @param {String} room * @return a Promise or nothing, depending on the adapter - * @public */ public leave(room: string): Promise | void { - console.debug(`leave room ${room}`) + debug("leave room %s", room) return this.adapter.del(this.id, room) } @@ -334,7 +507,8 @@ export class Socket< * @private */ _onconnect(): void { - console.debug(`socket ${this.id} connected - writing packet`) + debug("socket connected - writing packet") + this.connected = true this.join(this.id) if (this.conn.protocol === 3) { this.packet({ type: PacketType.CONNECT }) @@ -350,7 +524,7 @@ export class Socket< * @private */ _onpacket(packet: Packet): void { - console.trace("got packet", JSON.stringify(packet)) + debug("got packet %j", packet) switch (packet.type) { case PacketType.EVENT: this.onevent(packet) @@ -371,9 +545,6 @@ export class Socket< case PacketType.DISCONNECT: this.ondisconnect() break - - case PacketType.CONNECT_ERROR: - this._onerror(new Error(packet.data)) } } @@ -385,10 +556,10 @@ export class Socket< */ private onevent(packet: Packet): void { const args = packet.data || [] - console.trace("emitting event", JSON.stringify(args)) + debug("emitting event %j", args) if (null != packet.id) { - console.trace("attaching ack callback to event") + debug("attaching ack callback to event") args.push(this.ack(packet.id)) } @@ -414,7 +585,7 @@ export class Socket< // prevent double callbacks if (sent) return const args = Array.prototype.slice.call(arguments) - console.trace("sending ack", JSON.stringify(args)) + debug("sending ack %j", args) self.packet({ id: id, @@ -434,11 +605,11 @@ export class Socket< private onack(packet: Packet): void { const ack = this.acks.get(packet.id!) if ("function" == typeof ack) { - console.trace(`socket ${this.id} calling ack ${packet.id} with ${packet.data}`) + debug("calling ack %s with %j", packet.id, packet.data) ack.apply(this, packet.data) this.acks.delete(packet.id!) } else { - console.debug(`socket ${this.id} bad ack`, packet.id) + debug("bad ack %s", packet.id) } } @@ -448,7 +619,7 @@ export class Socket< * @private */ private ondisconnect(): void { - console.debug(`socket ${this.id} got disconnect packet`) + debug("got disconnect packet") this._onclose("client namespace disconnect") } @@ -474,19 +645,28 @@ export class Socket< * * @private */ - _onclose(reason: string): this | undefined { + _onclose(reason: DisconnectReason): this | undefined { if (!this.connected) return this - console.debug(`closing socket ${this.id} - reason: ${reason}`) + debug("closing socket - reason %s", reason) this.emitReserved("disconnecting", reason) - this.leaveAll() + this._cleanup() this.nsp._remove(this) this.client._remove(this) this.connected = false - this.disconnected = true this.emitReserved("disconnect", reason) return } + /** + * Makes the socket leave all the rooms it was part of and prevents it from joining any other room + * + * @private + */ + _cleanup() { + this.leaveAll() + this.join = noop + } + /** * Produces an `error` packet. * @@ -501,10 +681,17 @@ export class Socket< /** * Disconnects this client. * - * @param {Boolean} close - if `true`, closes the underlying connection - * @return {Socket} self + * @example + * io.on("connection", (socket) => { + * // disconnect this socket (the connection might be kept alive for other namespaces) + * socket.disconnect(); * - * @public + * // disconnect this socket and close the underlying connection + * socket.disconnect(true); + * }) + * + * @param {Boolean} close - if `true`, closes the underlying connection + * @return self */ public disconnect(close = false): this { if (!this.connected) return this @@ -520,9 +707,13 @@ export class Socket< /** * Sets the compress flag. * + * @example + * io.on("connection", (socket) => { + * socket.compress(false).emit("hello"); + * }); + * * @param {Boolean} compress - if `true`, compresses the sending data * @return {Socket} self - * @public */ public compress(compress: boolean): this { this.flags.compress = compress @@ -530,13 +721,17 @@ export class Socket< } /** - * Sets a modifier for a subsequent event emission that the event data may be lost if the client is not ready to - * receive messages (because of network slowness or other issues, or because they’re connected through long polling - * and is in the middle of a request-response cycle). - * - * @return {Socket} self - * @public - */ + * Sets a modifier for a subsequent event emission that the event data may be lost if the client is not ready to + * receive messages (because of network slowness or other issues, or because they’re connected through long polling + * and is in the middle of a request-response cycle). + * + * @example + * io.on("connection", (socket) => { + * socket.volatile.emit("hello"); // the client may or may not receive it + * }); + * + * @return {Socket} self + */ public get volatile(): this { this.flags.volatile = true return this @@ -546,31 +741,61 @@ export class Socket< * Sets a modifier for a subsequent event emission that the event data will only be broadcast to every sockets but the * sender. * - * @return {Socket} self - * @public + * @example + * io.on("connection", (socket) => { + * // the “foo” event will be broadcast to all connected clients, except this socket + * socket.broadcast.emit("foo", "bar"); + * }); + * + * @return a new {@link BroadcastOperator} instance for chaining */ - public get broadcast(): BroadcastOperator { + public get broadcast() { return this.newBroadcastOperator() } /** * Sets a modifier for a subsequent event emission that the event data will only be broadcast to the current node. * - * @return {Socket} self - * @public + * @example + * io.on("connection", (socket) => { + * // the “foo” event will be broadcast to all connected clients on this node, except this socket + * socket.local.emit("foo", "bar"); + * }); + * + * @return a new {@link BroadcastOperator} instance for chaining */ - public get local(): BroadcastOperator { + public get local() { return this.newBroadcastOperator().local } + /** + * Sets a modifier for a subsequent event emission that the callback will be called with an error when the + * given number of milliseconds have elapsed without an acknowledgement from the client: + * + * @example + * io.on("connection", (socket) => { + * socket.timeout(5000).emit("my-event", (err) => { + * if (err) { + * // the client did not acknowledge the event in the given delay + * } + * }); + * }); + * + * @returns self + */ + public timeout(timeout: number): this { + this.flags.timeout = timeout + return this + } + /** * Dispatch incoming event to socket listeners. * * @param {Array} event - event that will get emitted * @private */ - private dispatch(event: [eventName: string, ...args: any[]]): void { - console.trace("dispatching an event", JSON.stringify(event)) + private dispatch(event: Event): void { + debug("dispatching an event %j", event) this.run(event, (err) => { process.nextTick(() => { if (err) { @@ -579,7 +804,7 @@ export class Socket< if (this.connected) { super.emitUntyped.apply(this, event) } else { - console.debug("ignore packet received after disconnection") + debug("ignore packet received after disconnection") } }) }) @@ -588,13 +813,27 @@ export class Socket< /** * Sets up socket middleware. * + * @example + * io.on("connection", (socket) => { + * socket.use(([event, ...args], next) => { + * if (isUnauthorized(event)) { + * return next(new Error("unauthorized event")); + * } + * // do not forget to call next + * next(); + * }); + * + * socket.on("error", (err) => { + * if (err && err.message === "unauthorized event") { + * socket.disconnect(); + * } + * }); + * }); + * * @param {Function} fn - middleware function (event, next) * @return {Socket} self - * @public */ - public use( - fn: (event: Array, next: (err?: Error) => void) => void - ): this { + public use(fn: (event: Event, next: (err?: Error) => void) => void): this { this.fns.push(fn) return this } @@ -606,10 +845,7 @@ export class Socket< * @param {Function} fn - last fn call in the middleware * @private */ - private run( - event: [eventName: string, ...args: any[]], - fn: (err: Error | null) => void - ): void { + private run(event: Event, fn: (err: Error | null) => void): void { const fns = this.fns.slice(0) if (!fns.length) return fn(null) @@ -629,10 +865,15 @@ export class Socket< run(0) } + /** + * Whether the socket is currently disconnected + */ + public get disconnected() { + return !this.connected + } + /** * A reference to the request that originated the underlying Engine.IO Socket. - * - * @public */ public get request(): any /** IncomingMessage */ { return this.client.request @@ -641,25 +882,47 @@ export class Socket< /** * A reference to the underlying Client transport connection (Engine.IO Socket object). * - * @public + * @example + * io.on("connection", (socket) => { + * console.log(socket.conn.transport.name); // prints "polling" or "websocket" + * + * socket.conn.once("upgrade", () => { + * console.log(socket.conn.transport.name); // prints "websocket" + * }); + * }); */ public get conn() { return this.client.conn } /** - * @public + * Returns the rooms the socket is currently in. + * + * @example + * io.on("connection", (socket) => { + * console.log(socket.rooms); // Set { } + * + * socket.join("room1"); + * + * console.log(socket.rooms); // Set { , "room1" } + * }); */ public get rooms(): Set { return this.adapter.socketRooms(this.id) || new Set() } /** - * Adds a listener that will be fired when any event is emitted. The event name is passed as the first argument to the - * callback. + * Adds a listener that will be fired when any event is received. The event name is passed as the first argument to + * the callback. + * + * @example + * io.on("connection", (socket) => { + * socket.onAny((event, ...args) => { + * console.log(`got event ${event}`); + * }); + * }); * * @param listener - * @public */ public onAny(listener: (...args: any[]) => void): this { this._anyListeners = this._anyListeners || [] @@ -668,11 +931,10 @@ export class Socket< } /** - * Adds a listener that will be fired when any event is emitted. The event name is passed as the first argument to the - * callback. The listener is added to the beginning of the listeners array. + * Adds a listener that will be fired when any event is received. The event name is passed as the first argument to + * the callback. The listener is added to the beginning of the listeners array. * * @param listener - * @public */ public prependAny(listener: (...args: any[]) => void): this { this._anyListeners = this._anyListeners || [] @@ -681,10 +943,24 @@ export class Socket< } /** - * Removes the listener that will be fired when any event is emitted. + * Removes the listener that will be fired when any event is received. + * + * @example + * io.on("connection", (socket) => { + * const catchAllListener = (event, ...args) => { + * console.log(`got event ${event}`); + * } + * + * socket.onAny(catchAllListener); + * + * // remove a specific listener + * socket.offAny(catchAllListener); + * + * // or remove all listeners + * socket.offAny(); + * }); * * @param listener - * @public */ public offAny(listener?: (...args: any[]) => void): this { if (!this._anyListeners) { @@ -707,17 +983,117 @@ export class Socket< /** * Returns an array of listeners that are listening for any event that is specified. This array can be manipulated, * e.g. to remove listeners. - * - * @public */ public listenersAny() { return this._anyListeners || [] } - private newBroadcastOperator(): BroadcastOperator { + /** + * Adds a listener that will be fired when any event is sent. The event name is passed as the first argument to + * the callback. + * + * Note: acknowledgements sent to the client are not included. + * + * @example + * io.on("connection", (socket) => { + * socket.onAnyOutgoing((event, ...args) => { + * console.log(`sent event ${event}`); + * }); + * }); + * + * @param listener + */ + public onAnyOutgoing(listener: (...args: any[]) => void): this { + this._anyOutgoingListeners = this._anyOutgoingListeners || [] + this._anyOutgoingListeners.push(listener) + return this + } + + /** + * Adds a listener that will be fired when any event is emitted. The event name is passed as the first argument to the + * callback. The listener is added to the beginning of the listeners array. + * + * @example + * io.on("connection", (socket) => { + * socket.prependAnyOutgoing((event, ...args) => { + * console.log(`sent event ${event}`); + * }); + * }); + * + * @param listener + */ + public prependAnyOutgoing(listener: (...args: any[]) => void): this { + this._anyOutgoingListeners = this._anyOutgoingListeners || [] + this._anyOutgoingListeners.unshift(listener) + return this + } + + /** + * Removes the listener that will be fired when any event is sent. + * + * @example + * io.on("connection", (socket) => { + * const catchAllListener = (event, ...args) => { + * console.log(`sent event ${event}`); + * } + * + * socket.onAnyOutgoing(catchAllListener); + * + * // remove a specific listener + * socket.offAnyOutgoing(catchAllListener); + * + * // or remove all listeners + * socket.offAnyOutgoing(); + * }); + * + * @param listener - the catch-all listener + */ + public offAnyOutgoing(listener?: (...args: any[]) => void): this { + if (!this._anyOutgoingListeners) { + return this + } + if (listener) { + const listeners = this._anyOutgoingListeners + for (let i = 0; i < listeners.length; i++) { + if (listener === listeners[i]) { + listeners.splice(i, 1) + return this + } + } + } else { + this._anyOutgoingListeners = [] + } + return this + } + + /** + * Returns an array of listeners that are listening for any event that is specified. This array can be manipulated, + * e.g. to remove listeners. + */ + public listenersAnyOutgoing() { + return this._anyOutgoingListeners || [] + } + + /** + * Notify the listeners for each packet sent (emit or broadcast) + * + * @param packet + * + * @private + */ + private notifyOutgoingListeners(packet: Packet) { + if (this._anyOutgoingListeners && this._anyOutgoingListeners.length) { + const listeners = this._anyOutgoingListeners.slice() + for (const listener of listeners) { + listener.apply(this, packet.data) + } + } + } + + private newBroadcastOperator() { const flags = Object.assign({}, this.flags) this.flags = {} - return new BroadcastOperator( + return new BroadcastOperator( this.adapter, new Set(), new Set([this.id]),