diff --git a/packages/websocket/.prettierrc b/packages/websocket/.prettierrc new file mode 100644 index 00000000..ede19165 --- /dev/null +++ b/packages/websocket/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 2, + "semi": true +} diff --git a/packages/websocket/package.json b/packages/websocket/package.json index 4c8b3b96..293b28ac 100644 --- a/packages/websocket/package.json +++ b/packages/websocket/package.json @@ -19,7 +19,7 @@ "test": "echo \"Error: run tests from root\" && exit 1" }, "dependencies": { - "@socket.io/component-emitter": "^4.0.0", + "@socket.io/component-emitter": "3.1.0", "backo2": "^1.0.2", "parseuri": "^0.0.6" }, diff --git a/packages/websocket/src/debug.ts b/packages/websocket/src/debug.ts index e58655da..f171f51e 100644 --- a/packages/websocket/src/debug.ts +++ b/packages/websocket/src/debug.ts @@ -1,4 +1,7 @@ -export = (namepsace) => (...args) => { console.trace(`[${namepsace}] ` + format(...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 @@ -7,16 +10,16 @@ formatters.j = function (v) { try { return JSON.stringify(v) } catch (error: any) { - return '[UnexpectedJSONParseError]: ' + error.message + return "[UnexpectedJSONParseError]: " + error.message } } /** -* Coerce `val`. -* -* @param {Mixed} val -* @return {Mixed} -* @api private -*/ + * Coerce `val`. + * + * @param {Mixed} val + * @return {Mixed} + * @api private + */ function coerce(val) { if (val instanceof Error) { return val.stack || val.message @@ -27,20 +30,20 @@ function format(...args) { // Apply any `formatters` transformations args[0] = coerce(args[0]) - if (typeof args[0] !== 'string') { + if (typeof args[0] !== "string") { // Anything else let's inspect with %O - args.unshift('%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 '%' + if (match === "%%") { + return "%" } index++ const formatter = formatters[format] - if (typeof formatter === 'function') { + if (typeof formatter === "function") { const val = args[index] match = formatter.call(format, val) diff --git a/packages/websocket/src/engine.io-client/index.ts b/packages/websocket/src/engine.io-client/index.ts index a194e9df..94524f81 100644 --- a/packages/websocket/src/engine.io-client/index.ts +++ b/packages/websocket/src/engine.io-client/index.ts @@ -7,4 +7,4 @@ 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" +export { nextTick } from "./transports/websocket-constructor" diff --git a/packages/websocket/src/engine.io-client/socket.ts b/packages/websocket/src/engine.io-client/socket.ts index 209e82ae..f2cc5684 100644 --- a/packages/websocket/src/engine.io-client/socket.ts +++ b/packages/websocket/src/engine.io-client/socket.ts @@ -7,7 +7,8 @@ import { parse } from "./contrib/parseuri" import { Emitter } from "@socket.io/component-emitter" // import { protocol } from "engine.io-parser"; import { protocol } from "../engine.io-parser" -import { CloseDetails } from "./transport" +import type { Packet, BinaryType, PacketType, RawData } from "../engine.io-parser" +import { CloseDetails, Transport } from "./transport" // const debug = debugModule("engine.io-client:socket"); // debug() const debug = require('../debug')('engine.io-client:socket') @@ -209,6 +210,12 @@ export interface SocketOptions { */ path: string + /** + * Whether we should add a trailing slash to the request path. + * @default true + */ + addTrailingSlash: boolean + /** * 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 @@ -218,11 +225,19 @@ export interface SocketOptions { protocols: string | string[] } +interface HandshakeData { + sid: string + upgrades: string[] + pingInterval: number + pingTimeout: number + maxPayload: number +} + interface SocketReservedEvents { open: () => void - handshake: (data) => void - packet: (packet) => void - packetCreate: (packet) => void + handshake: (data: HandshakeData) => void + packet: (packet: Packet) => void + packetCreate: (packet: Packet) => void data: (data) => void message: (data) => void drain: () => void @@ -237,13 +252,15 @@ interface SocketReservedEvents { close: (reason: string, description?: CloseDetails | Error) => void } +type SocketState = "opening" | "open" | "closing" | "closed" + export class Socket extends Emitter<{}, {}, SocketReservedEvents> { public id: string - public transport: any - public binaryType: string + public transport: Transport + public binaryType: BinaryType + public readyState: SocketState + public writeBuffer: Packet[] = []; - private readyState: string - private writeBuffer private prevBufferLen: number private upgrades private pingInterval: number @@ -314,7 +331,6 @@ export class Socket extends Emitter<{}, {}, SocketReservedEvents> { : "80") this.transports = opts.transports || ["polling", "websocket"] - this.readyState = "" this.writeBuffer = [] this.prevBufferLen = 0 @@ -326,6 +342,7 @@ export class Socket extends Emitter<{}, {}, SocketReservedEvents> { upgrade: true, timestampParam: "t", rememberUpgrade: false, + addTrailingSlash: true, rejectUnauthorized: true, perMessageDeflate: { threshold: 1024 @@ -336,7 +353,9 @@ export class Socket extends Emitter<{}, {}, SocketReservedEvents> { opts ) - this.opts.path = this.opts.path.replace(/\/$/, "") + "/" + this.opts.path = + this.opts.path.replace(/\/$/, "") + + (this.opts.addTrailingSlash ? "/" : "") if (typeof this.opts.query === "string") { this.opts.query = decode(this.opts.query) @@ -368,7 +387,7 @@ export class Socket extends Emitter<{}, {}, SocketReservedEvents> { if (this.hostname !== "localhost") { this.offlineEventListener = () => { this.onClose("transport close", { - description: "network connection lost" + description: "network connection lost", }) } addEventListener("offline", this.offlineEventListener, false) @@ -381,9 +400,9 @@ export class Socket extends Emitter<{}, {}, SocketReservedEvents> { /** * Creates transport of the given type. * - * @param {String} transport name + * @param {String} name - transport name * @return {Transport} - * @api private + * @private */ private createTransport(name) { debug('creating transport "%s"', name) @@ -407,7 +426,7 @@ export class Socket extends Emitter<{}, {}, SocketReservedEvents> { socket: this, hostname: this.hostname, secure: this.secure, - port: this.port + port: this.port, } ) @@ -417,10 +436,10 @@ export class Socket extends Emitter<{}, {}, SocketReservedEvents> { } /** - * Initializes transport to use and starts probe. - * - * @api private - */ + * Initializes transport to use and starts probe. + * + * @private + */ private open() { let transport if ( @@ -457,7 +476,7 @@ export class Socket extends Emitter<{}, {}, SocketReservedEvents> { /** * Sets the current transport. Disables the existing one (if any). * - * @api private + * @private */ private setTransport(transport) { debug("setting transport %s", transport.name) @@ -475,7 +494,7 @@ export class Socket extends Emitter<{}, {}, SocketReservedEvents> { .on("drain", this.onDrain.bind(this)) .on("packet", this.onPacket.bind(this)) .on("error", this.onError.bind(this)) - .on("close", reason => this.onClose("transport close", reason)) + .on("close", (reason) => this.onClose("transport close", reason)) } /** diff --git a/packages/websocket/src/engine.io-client/transport.ts b/packages/websocket/src/engine.io-client/transport.ts index 1507115e..6063c219 100644 --- a/packages/websocket/src/engine.io-client/transport.ts +++ b/packages/websocket/src/engine.io-client/transport.ts @@ -22,7 +22,7 @@ class TransportError extends Error { export interface CloseDetails { description: string - context?: CloseEvent | XMLHttpRequest + context?: unknown // context should be typed as CloseEvent | XMLHttpRequest, but these types are not available on non-browser platforms } interface TransportReservedEvents { @@ -35,32 +35,34 @@ interface TransportReservedEvents { drain: () => void } +type TransportState = "opening" | "open" | "closed" | "pausing" | "paused" + export abstract class Transport extends Emitter< - {}, - {}, + Record, + Record, TransportReservedEvents > { + public query: Record + public writable: boolean = false; + protected opts: SocketOptions protected supportsBinary: boolean - protected query: object - protected readyState: string - protected writable: boolean = false; + protected readyState: TransportState protected socket: any protected setTimeoutFn: typeof setTimeout /** - * Transport abstract constructor. - * - * @param {Object} options. - * @api private - */ + * Transport abstract constructor. + * + * @param {Object} opts - options + * @protected + */ constructor(opts) { super() installTimerFunctions(this, opts) this.opts = opts this.query = opts.query - this.readyState = "" this.socket = opts.socket } @@ -71,7 +73,7 @@ export abstract class Transport extends Emitter< * @param description * @param context - the error context * @return {Transport} for chaining - * @api protected + * @protected */ protected onError(reason: string, description: any, context?: any) { super.emitReserved( @@ -83,25 +85,19 @@ export abstract class Transport extends Emitter< /** * Opens the transport. - * - * @api public */ - private open() { - if ("closed" === this.readyState || "" === this.readyState) { - this.readyState = "opening" - this.doOpen() - } + public open() { + this.readyState = "opening" + this.doOpen() return this } /** * Closes the transport. - * - * @api public */ public close() { - if ("opening" === this.readyState || "open" === this.readyState) { + if (this.readyState === "opening" || this.readyState === "open") { this.doClose() this.onClose() } @@ -110,13 +106,12 @@ export abstract class Transport extends Emitter< } /** - * Sends multiple packets. - * - * @param {Array} packets - * @api public - */ + * Sends multiple packets. + * + * @param {Array} packets + */ public send(packets) { - if ("open" === this.readyState) { + if (this.readyState === "open") { this.write(packets) } else { // this might happen if the transport was silently closed in the beforeunload event handler @@ -127,7 +122,7 @@ export abstract class Transport extends Emitter< /** * Called upon open * - * @api protected + * @protected */ protected onOpen() { this.readyState = "open" @@ -139,17 +134,18 @@ export abstract class Transport extends Emitter< * Called with data. * * @param {String} data - * @api protected + * @protected */ protected onData(data: RawData) { const packet = decodePacket(data, this.socket.binaryType) this.onPacket(packet) } + /** * Called with a decoded packet. * - * @api protected + * @protected */ protected onPacket(packet: Packet) { super.emitReserved("packet", packet) @@ -158,14 +154,26 @@ export abstract class Transport extends Emitter< /** * Called upon close. * - * @api protected + * @protected */ protected onClose(details?: CloseDetails) { this.readyState = "closed" super.emitReserved("close", details) } + /** + * The name of the transport + */ + public abstract get name(): string + + /** + * Pauses the transport, in order not to lose packets during an upgrade. + * + * @param onPause + */ + public pause(onPause: () => void) { } + protected abstract doOpen() protected abstract doClose() - protected abstract write(packets) + protected abstract write(packets: Packet[]) } diff --git a/packages/websocket/src/engine.io-client/transports/websocket.ts b/packages/websocket/src/engine.io-client/transports/websocket.ts index 25880e07..919a6476 100644 --- a/packages/websocket/src/engine.io-client/transports/websocket.ts +++ b/packages/websocket/src/engine.io-client/transports/websocket.ts @@ -26,8 +26,8 @@ export class WS extends Transport { /** * WebSocket transport constructor. * - * @api {Object} connection options - * @api public + * @param {Object} opts - connection options + * @protected */ constructor(opts) { super(opts) @@ -35,21 +35,11 @@ export class WS extends Transport { this.supportsBinary = !opts.forceBase64 } - /** - * Transport name. - * - * @api public - */ - get name() { + override get name() { return "websocket" } - /** - * Opens socket. - * - * @api private - */ - doOpen() { + override doOpen() { if (!this.check()) { // let probe timeout return @@ -103,31 +93,25 @@ export class WS extends Transport { /** * Adds event listeners to the socket * - * @api private + * @private */ - addEventListeners() { + private addEventListeners() { this.ws.onopen = () => { if (this.opts.autoUnref) { this.ws._socket.unref() } this.onOpen() } - this.ws.onclose = closeEvent => + this.ws.onclose = (closeEvent) => this.onClose({ description: "websocket connection closed", - context: closeEvent + context: closeEvent, }) - this.ws.onmessage = ev => this.onData(ev.data) - this.ws.onerror = e => this.onError("websocket error", e) + this.ws.onmessage = (ev) => this.onData(ev.data) + this.ws.onerror = (e) => this.onError("websocket error", e) } - /** - * Writes data to socket. - * - * @param {Array} array of packets. - * @api private - */ - write(packets) { + override write(packets) { this.writable = false // encodePacket efficient as it uses WS framing @@ -136,7 +120,7 @@ export class WS extends Transport { const packet = packets[i] const lastPacket = i === packets.length - 1 - encodePacket(packet, this.supportsBinary, data => { + encodePacket(packet, this.supportsBinary, (data) => { // always create a new object (GH-437) const opts: { compress?: boolean } = {} if (!usingBrowserWebSocket) { @@ -180,12 +164,7 @@ export class WS extends Transport { } } - /** - * Closes socket. - * - * @api private - */ - doClose() { + override doClose() { if (typeof this.ws !== "undefined") { this.ws.close() this.ws = null @@ -195,7 +174,7 @@ export class WS extends Transport { /** * Generates uri for connection. * - * @api private + * @private */ uri() { let query: { b64?: number } = this.query || {} @@ -238,9 +217,9 @@ export class WS extends Transport { * Feature detection for WebSocket. * * @return {Boolean} whether this transport is available. - * @api public + * @private */ - check() { + private check() { return !!WebSocket } } diff --git a/packages/websocket/src/engine.io-client/util.ts b/packages/websocket/src/engine.io-client/util.ts index 528ccbad..50f83526 100644 --- a/packages/websocket/src/engine.io-client/util.ts +++ b/packages/websocket/src/engine.io-client/util.ts @@ -10,16 +10,16 @@ export function pick(obj, ...attr) { } // Keep a reference to the real timeout functions so they can be used when overridden -const NATIVE_SET_TIMEOUT = setTimeout -const NATIVE_CLEAR_TIMEOUT = clearTimeout +const NATIVE_SET_TIMEOUT = globalThis.setTimeout +const NATIVE_CLEAR_TIMEOUT = globalThis.clearTimeout export function installTimerFunctions(obj, opts) { if (opts.useNativeTimers) { obj.setTimeoutFn = NATIVE_SET_TIMEOUT.bind(globalThis) obj.clearTimeoutFn = NATIVE_CLEAR_TIMEOUT.bind(globalThis) } else { - obj.setTimeoutFn = setTimeout.bind(globalThis) - obj.clearTimeoutFn = clearTimeout.bind(globalThis) + obj.setTimeoutFn = globalThis.setTimeout.bind(globalThis) + obj.clearTimeoutFn = globalThis.clearTimeout.bind(globalThis) } } diff --git a/packages/websocket/src/engine.io-parser/commons.ts b/packages/websocket/src/engine.io-parser/commons.ts index e4072249..9156e367 100644 --- a/packages/websocket/src/engine.io-parser/commons.ts +++ b/packages/websocket/src/engine.io-parser/commons.ts @@ -1,39 +1,39 @@ -const PACKET_TYPES = Object.create(null) // no Map = no polyfill -PACKET_TYPES["open"] = "0" -PACKET_TYPES["close"] = "1" -PACKET_TYPES["ping"] = "2" -PACKET_TYPES["pong"] = "3" -PACKET_TYPES["message"] = "4" -PACKET_TYPES["upgrade"] = "5" -PACKET_TYPES["noop"] = "6" +const PACKET_TYPES = Object.create(null); // no Map = no polyfill +PACKET_TYPES["open"] = "0"; +PACKET_TYPES["close"] = "1"; +PACKET_TYPES["ping"] = "2"; +PACKET_TYPES["pong"] = "3"; +PACKET_TYPES["message"] = "4"; +PACKET_TYPES["upgrade"] = "5"; +PACKET_TYPES["noop"] = "6"; -const PACKET_TYPES_REVERSE = Object.create(null) +const PACKET_TYPES_REVERSE = Object.create(null); Object.keys(PACKET_TYPES).forEach(key => { - PACKET_TYPES_REVERSE[PACKET_TYPES[key]] = key -}) + PACKET_TYPES_REVERSE[PACKET_TYPES[key]] = key; +}); -const ERROR_PACKET: 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" + | "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 type RawData = any; export interface Packet { - type: PacketType - options?: { compress: boolean } - data?: RawData + type: PacketType; + options?: { compress: boolean }; + data?: RawData; } -export type BinaryType = "nodebuffer" | "arraybuffer" | "blob" +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 427abf6a..e04a781a 100644 --- a/packages/websocket/src/engine.io-parser/decodePacket.ts +++ b/packages/websocket/src/engine.io-parser/decodePacket.ts @@ -1,60 +1,60 @@ import { - ERROR_PACKET, - PACKET_TYPES_REVERSE, - Packet, - BinaryType, - RawData -} from "./commons.js" + ERROR_PACKET, + PACKET_TYPES_REVERSE, + Packet, + BinaryType, + RawData +} from "./commons.js"; const decodePacket = ( - encodedPacket: RawData, - binaryType?: BinaryType + encodedPacket: RawData, + binaryType?: BinaryType ): Packet => { - if (typeof encodedPacket !== "string") { - return { - type: "message", - data: mapBinary(encodedPacket, binaryType) - } - } - const type = encodedPacket.charAt(0) - if (type === "b") { - const buffer = Buffer.from(encodedPacket.substring(1), "base64") - return { - type: "message", - data: mapBinary(buffer, binaryType) - } - } - if (!PACKET_TYPES_REVERSE[type]) { - return ERROR_PACKET - } - return encodedPacket.length > 1 - ? { - type: PACKET_TYPES_REVERSE[type], - data: encodedPacket.substring(1) - } - : { - type: PACKET_TYPES_REVERSE[type] - } -} + if (typeof encodedPacket !== "string") { + return { + type: "message", + data: mapBinary(encodedPacket, binaryType) + }; + } + const type = encodedPacket.charAt(0); + if (type === "b") { + const buffer = Buffer.from(encodedPacket.substring(1), "base64"); + return { + type: "message", + data: mapBinary(buffer, binaryType) + }; + } + if (!PACKET_TYPES_REVERSE[type]) { + return ERROR_PACKET; + } + return encodedPacket.length > 1 + ? { + type: PACKET_TYPES_REVERSE[type], + data: encodedPacket.substring(1) + } + : { + type: PACKET_TYPES_REVERSE[type] + }; +}; const mapBinary = (data: RawData, binaryType?: BinaryType) => { - const isBuffer = Buffer.isBuffer(data) - switch (binaryType) { - case "arraybuffer": - return isBuffer ? toArrayBuffer(data) : data - case "nodebuffer": - default: - return data // assuming the data is already a Buffer - } -} + const isBuffer = Buffer.isBuffer(data); + switch (binaryType) { + case "arraybuffer": + return isBuffer ? toArrayBuffer(data) : data; + case "nodebuffer": + default: + return data; // assuming the data is already a 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++) { - view[i] = buffer[i] - } - return arrayBuffer -} + const arrayBuffer = new ArrayBuffer(buffer.length); + const view = new Uint8Array(arrayBuffer); + for (let i = 0; i < buffer.length; i++) { + view[i] = buffer[i]; + } + return arrayBuffer; +}; -export default decodePacket +export default decodePacket; diff --git a/packages/websocket/src/engine.io-parser/encodePacket.ts b/packages/websocket/src/engine.io-parser/encodePacket.ts index 1500b0dd..0557532c 100644 --- a/packages/websocket/src/engine.io-parser/encodePacket.ts +++ b/packages/websocket/src/engine.io-parser/encodePacket.ts @@ -1,31 +1,31 @@ -import { PACKET_TYPES, Packet, RawData } from "./commons.js" +import { PACKET_TYPES, Packet, RawData } from "./commons.js"; const encodePacket = ( - { type, data }: Packet, - supportsBinary: boolean, - callback: (encodedPacket: RawData) => void + { 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)) - } - // plain string - return callback(PACKET_TYPES[type] + (data || "")) -} + if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) { + const buffer = toBuffer(data); + return callback(encodeBuffer(buffer, supportsBinary)); + } + // plain string + return callback(PACKET_TYPES[type] + (data || "")); +}; const toBuffer = data => { - if (Buffer.isBuffer(data)) { - return data - } else if (data instanceof ArrayBuffer) { - return Buffer.from(data) - } else { - return Buffer.from(data.buffer, data.byteOffset, data.byteLength) - } -} + if (Buffer.isBuffer(data)) { + return data; + } else if (data instanceof ArrayBuffer) { + return Buffer.from(data); + } else { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength); + } +}; // only 'message' packets can contain binary, so the type prefix is not needed const encodeBuffer = (data: Buffer, supportsBinary: boolean): RawData => { - return supportsBinary ? data : "b" + data.toString("base64") -} + return supportsBinary ? data : "b" + data.toString("base64"); +}; -export default encodePacket +export default encodePacket; diff --git a/packages/websocket/src/engine.io-parser/index.ts b/packages/websocket/src/engine.io-parser/index.ts index 63228f7b..4acacde3 100644 --- a/packages/websocket/src/engine.io-parser/index.ts +++ b/packages/websocket/src/engine.io-parser/index.ts @@ -1,53 +1,53 @@ -import encodePacket from "./encodePacket.js" -import decodePacket from "./decodePacket.js" -import { Packet, PacketType, RawData, BinaryType } from "./commons.js" +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 SEPARATOR = String.fromCharCode(30); // see https://en.wikipedia.org/wiki/Delimiter#ASCII_delimited_text const encodePayload = ( - packets: Packet[], - callback: (encodedPayload: string) => void + 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) - let count = 0 + // 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); + let count = 0; - packets.forEach((packet, i) => { - // force base64 encoding for binary packets - encodePacket(packet, false, encodedPacket => { - encodedPackets[i] = encodedPacket - if (++count === length) { - callback(encodedPackets.join(SEPARATOR)) - } - }) - }) -} + packets.forEach((packet, i) => { + // force base64 encoding for binary packets + encodePacket(packet, false, encodedPacket => { + encodedPackets[i] = encodedPacket; + if (++count === length) { + callback(encodedPackets.join(SEPARATOR)); + } + }); + }); +}; const decodePayload = ( - encodedPayload: string, - binaryType?: BinaryType + encodedPayload: string, + binaryType?: BinaryType ): Packet[] => { - const encodedPackets = encodedPayload.split(SEPARATOR) - const packets = [] - for (let i = 0; i < encodedPackets.length; i++) { - const decodedPacket = decodePacket(encodedPackets[i], binaryType) - packets.push(decodedPacket) - if (decodedPacket.type === "error") { - break - } + const encodedPackets = encodedPayload.split(SEPARATOR); + const packets = []; + for (let i = 0; i < encodedPackets.length; i++) { + const decodedPacket = decodePacket(encodedPackets[i], binaryType); + packets.push(decodedPacket); + if (decodedPacket.type === "error") { + break; } - return packets -} + } + return packets; +}; -export const protocol = 4 +export const protocol = 4; export { - encodePacket, - encodePayload, - decodePacket, - decodePayload, - Packet, - PacketType, - RawData, - BinaryType -} + encodePacket, + encodePayload, + decodePacket, + decodePayload, + Packet, + PacketType, + RawData, + BinaryType +}; diff --git a/packages/websocket/src/engine.io/index.ts b/packages/websocket/src/engine.io/index.ts index 888544b5..0576c8f2 100644 --- a/packages/websocket/src/engine.io/index.ts +++ b/packages/websocket/src/engine.io/index.ts @@ -1,26 +1,26 @@ // import { createServer } from "http" -import { Server, AttachOptions, ServerOptions } from "./server" -import transports from "./transports/index" -import * as parser from "../engine.io-parser" +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 { 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 - -/** - * 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 - */ +export { Socket } from "./socket"; +export { Transport } from "./transport"; +export const protocol = parser.protocol; +// /** +// * 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 +// */ +// // function listen(port, options: AttachOptions & ServerOptions, fn) { // if ("function" === typeof options) { // fn = options; @@ -51,7 +51,7 @@ export const protocol = parser.protocol */ function attach(server, options: AttachOptions & ServerOptions) { - const engine = new Server(options) - engine.attach(server, options) - return engine + const engine = new Server(options); + engine.attach(server, options); + return engine; } diff --git a/packages/websocket/src/engine.io/parser-v3/index.ts b/packages/websocket/src/engine.io/parser-v3/index.ts index 67605ed9..a8055a1b 100644 --- a/packages/websocket/src/engine.io/parser-v3/index.ts +++ b/packages/websocket/src/engine.io/parser-v3/index.ts @@ -4,481 +4,482 @@ * 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) - } -} + 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.substr(1), binaryType); + } + + if (utf8decode) { + data = tryDecode(data); + if (data === false) { + return err; + } + } + + if (Number(type) != type || !packetslist[type]) { + return err; + } + + if (data.length > 1) { + return { type: packetslist[type], data: data.substring(1) }; + } else { + return { type: packetslist[type] }; + } + } + + // Binary data + if (binaryType === 'arraybuffer') { + // wrap Buffer/ArrayBuffer data into an Uint8Array + var intArray = new Uint8Array(data); + type = intArray[0]; + return { type: packetslist[type], data: intArray.buffer.slice(1) }; + } + + if (data instanceof ArrayBuffer) { + data = arrayBufferToBuffer(data); + } + type = data[0]; + return { type: packetslist[type], data: data.slice(1) }; + }; + + function tryDecode(data) { + try { + data = utf8.decode(data, { strict: false }); + } catch (e) { + return false; + } + return data; + } + + /** + * Decodes a packet encoded in a base64 string. + * + * @param {String} base64 encoded message + * @return {Object} with `type` and `data` (if any) + */ + + export function decodeBase64Packet (msg, binaryType) { + var type = packetslist[msg.charAt(0)]; + var data = Buffer.from(msg.substr(1), 'base64'); + if (binaryType === 'arraybuffer') { + var abv = new Uint8Array(data.length); + for (var i = 0; i < abv.length; i++){ + abv[i] = data[i]; + } + // @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.substr(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 index 65391b8b..b878740f 100644 --- a/packages/websocket/src/engine.io/parser-v3/utf8.ts +++ b/packages/websocket/src/engine.io/parser-v3/utf8.ts @@ -1,210 +1,210 @@ /*! https://mths.be/utf8js v2.1.2 by @mathias */ -var stringFromCharCode = String.fromCharCode +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 + 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 + 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 + 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) + 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 + 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 + 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 + 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') - } + if (byteIndex >= byteCount) { + throw Error('Invalid byte index'); + } - var continuationByte = byteArray[byteIndex] & 0xFF - byteIndex++ + var continuationByte = byteArray[byteIndex] & 0xFF; + byteIndex++; - if ((continuationByte & 0xC0) == 0x80) { - return continuationByte & 0x3F - } + if ((continuationByte & 0xC0) == 0x80) { + return continuationByte & 0x3F; + } - // If we end up here, it’s not a continuation byte - throw Error('Invalid continuation byte') + // 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 + var byte1; + var byte2; + var byte3; + var byte4; + var codePoint; - if (byteIndex > byteCount) { - throw Error('Invalid byte index') - } + if (byteIndex > byteCount) { + throw Error('Invalid byte index'); + } - if (byteIndex == byteCount) { - return false - } + if (byteIndex == byteCount) { + return false; + } - // Read first byte - byte1 = byteArray[byteIndex] & 0xFF - byteIndex++ + // Read first byte + byte1 = byteArray[byteIndex] & 0xFF; + byteIndex++; - // 1-byte sequence (no continuation bytes) - if ((byte1 & 0x80) == 0) { - return byte1 - } + // 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') - } - } + // 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') - } - } + // 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 - } - } + // 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') + throw Error('Invalid UTF-8 detected'); } -var byteArray -var byteCount -var byteIndex +var byteArray; +var byteCount; +var byteIndex; function utf8decode(byteString, opts) { - opts = opts || {} - var strict = false !== opts.strict + 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) + 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 -} + 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 e298df96..dd3469a6 100644 --- a/packages/websocket/src/engine.io/server.ts +++ b/packages/websocket/src/engine.io/server.ts @@ -1,712 +1,842 @@ -import * as qs from "querystring" -import { parse } from "url" +import * as qs from "querystring"; +import { parse } from "url"; // const base64id = require("base64id") -import transports from "./transports" -import { EventEmitter } from "events" -import { Socket } from "./socket" +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 { 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' +const debug = require("../debug")("engine"); -type Transport = "polling" | "websocket" +const kResponseHeaders = Symbol("responseHeaders"); + +import { Request } from "../server/request"; +import { WebSocketClient } from "../server/client"; + +type Transport = "polling" | "websocket"; 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 + /** + * 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; + + /** + * Whether we should add a trailing slash to the request path. + * @default true + */ + addTrailingSlash?: boolean; } 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 + /** + * 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; } +/** + * An Express-compatible middleware. + * + * Middleware functions are functions that have access to the request object (req), the response object (res), and the + * next middleware function in the application’s request-response cycle. + * + * @see https://expressjs.com/en/guide/using-middleware.html + */ +type Middleware = ( + req: any /*IncomingMessage*/, + res: any /*ServerResponse*/, + next: () => void +) => void; + export abstract class BaseServer extends EventEmitter { - public opts: ServerOptions + public opts: ServerOptions; - protected clients: any - private clientsCount: number - protected corsMiddleware: Function + protected clients: any; + private clientsCount: number; + protected middlewares: Middleware[] = []; - /** - * Server constructor. - * - * @param {Object} opts - options - * @api public - */ - constructor(opts: ServerOptions = {}) { - super() + /** + * Server constructor. + * + * @param {Object} opts - options + * @api public + */ + constructor(opts: ServerOptions = {}) { + super(); - this.clients = {} - this.clientsCount = 0 + this.clients = {}; + this.clientsCount = 0; - this.opts = Object.assign( - { - wsEngine: DEFAULT_WS_ENGINE, - pingTimeout: 20000, - pingInterval: 25000, - upgradeTimeout: 10000, - maxHttpBufferSize: 1e6, - transports: Object.keys(transports), - allowUpgrades: true, - httpCompression: { - threshold: 1024 - }, - cors: false, - allowEIO3: false - }, - opts - ) + this.opts = Object.assign( + { + wsEngine: DEFAULT_WS_ENGINE, + pingTimeout: 20000, + pingInterval: 25000, + upgradeTimeout: 10000, + maxHttpBufferSize: 1e6, + transports: Object.keys(transports), + allowUpgrades: true, + httpCompression: { + threshold: 1024, + }, + cors: false, + allowEIO3: false, + }, + opts + ); - // if (opts.cookie) { - // this.opts.cookie = Object.assign( - // { - // name: "io", - // path: "/", - // // @ts-ignore - // httpOnly: opts.cookie.path !== false, - // sameSite: "lax" - // }, - // opts.cookie - // ) - // } - - // if (this.opts.cors) { - // this.corsMiddleware = require("cors")(this.opts.cors) - // } - - // if (opts.perMessageDeflate) { - // this.opts.perMessageDeflate = Object.assign( - // { - // threshold: 1024 - // }, - // opts.perMessageDeflate - // ) - // } - - // this.init() - } - - protected abstract init() - - /** - * Returns a list of available transports for upgrade given a certain transport. - * - * @return {Array} - * @api public - */ - public upgrades(transport) { - if (!this.opts.allowUpgrades) return [] - return transports[transport].upgradesTo || [] - } - - // /** - // * Verifies a request. - // * - // * @param {http.IncomingMessage} - // * @return {Boolean} whether the request is valid - // * @api private - // */ - // protected verify(req, upgrade, fn) { - // // transport check - // const transport = req._query.transport - // if (!~this.opts.transports.indexOf(transport)) { - // debug('unknown transport "%s"', transport) - // return fn(Server.errors.UNKNOWN_TRANSPORT, { transport }) - // } - - // // 'Origin' header check - // const isOriginInvalid = checkInvalidHeaderChar(req.headers.origin) - // if (isOriginInvalid) { - // const origin = req.headers.origin - // req.headers.origin = null - // debug("origin header invalid") - // return fn(Server.errors.BAD_REQUEST, { - // name: "INVALID_ORIGIN", - // origin - // }) - // } - - // // sid check - // const sid = req._query.sid - // if (sid) { - // if (!this.clients.hasOwnProperty(sid)) { - // debug('unknown sid "%s"', sid) - // return fn(Server.errors.UNKNOWN_SID, { - // sid - // }) - // } - // const previousTransport = this.clients[sid].transport.name - // if (!upgrade && previousTransport !== transport) { - // debug("bad request: unexpected transport without upgrade") - // return fn(Server.errors.BAD_REQUEST, { - // name: "TRANSPORT_MISMATCH", - // transport, - // previousTransport - // }) - // } - // } else { - // // handshake is GET only - // if ("GET" !== req.method) { - // return fn(Server.errors.BAD_HANDSHAKE_METHOD, { - // method: req.method - // }) - // } - - // 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) => { - // if (!success) { - // return fn(Server.errors.FORBIDDEN, { - // message - // }) - // } - // fn() - // }) - // } - - // fn() + // if (opts.cookie) { + // this.opts.cookie = Object.assign( + // { + // name: "io", + // path: "/", + // // @ts-ignore + // httpOnly: opts.cookie.path !== false, + // sameSite: "lax", + // }, + // opts.cookie + // ); // } - /** - * Closes all clients. - * - * @api public - */ - 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 + // if (this.opts.cors) { + // this.use(require("cors")(this.opts.cors)); + // } + + // if (opts.perMessageDeflate) { + // this.opts.perMessageDeflate = Object.assign( + // { + // threshold: 1024, + // }, + // opts.perMessageDeflate + // ); + // } + + // this.init(); + } + + protected abstract init(); + + /** + * Compute the pathname of the requests that are handled by the server + * @param options + * @protected + */ + protected _computePath(options: AttachOptions) { + let path = (options.path || "/engine.io").replace(/\/$/, ""); + + if (options.addTrailingSlash !== false) { + // normalize path + path += "/"; } - protected abstract cleanup() + return path; + } - /** - * 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 + /** + * Returns a list of available transports for upgrade given a certain transport. + * + * @return {Array} + * @api public + */ + public upgrades(transport) { + if (!this.opts.allowUpgrades) return []; + return transports[transport].upgradesTo || []; + } + + // /** + // * Verifies a request. + // * + // * @param {http.IncomingMessage} + // * @return {Boolean} whether the request is valid + // * @api private + // */ + // protected verify(req, upgrade, fn) { + // // transport check + // const transport = req._query.transport; + // if (!~this.opts.transports.indexOf(transport)) { + // debug('unknown transport "%s"', transport); + // return fn(Server.errors.UNKNOWN_TRANSPORT, { transport }); + // } + + // // 'Origin' header check + // const isOriginInvalid = checkInvalidHeaderChar(req.headers.origin); + // if (isOriginInvalid) { + // const origin = req.headers.origin; + // req.headers.origin = null; + // debug("origin header invalid"); + // return fn(Server.errors.BAD_REQUEST, { + // name: "INVALID_ORIGIN", + // origin, + // }); + // } + + // // sid check + // const sid = req._query.sid; + // if (sid) { + // if (!this.clients.hasOwnProperty(sid)) { + // debug('unknown sid "%s"', sid); + // return fn(Server.errors.UNKNOWN_SID, { + // sid, + // }); + // } + // const previousTransport = this.clients[sid].transport.name; + // if (!upgrade && previousTransport !== transport) { + // debug("bad request: unexpected transport without upgrade"); + // return fn(Server.errors.BAD_REQUEST, { + // name: "TRANSPORT_MISMATCH", + // transport, + // previousTransport, + // }); + // } + // } else { + // // handshake is GET only + // if ("GET" !== req.method) { + // return fn(Server.errors.BAD_HANDSHAKE_METHOD, { + // method: req.method, + // }); + // } + + // 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) => { + // if (!success) { + // return fn(Server.errors.FORBIDDEN, { + // message, + // }); + // } + // fn(); + // }); + // } + + // fn(); + // } + + /** + * Adds a new middleware. + * + * @example + * import helmet from "helmet"; + * + * engine.use(helmet()); + * + * @param fn + */ + public use(fn: Middleware) { + this.middlewares.push(fn); + } + + /** + * Apply the middlewares to the request. + * + * @param req + * @param res + * @param callback + * @protected + */ + protected _applyMiddlewares( + req: any /*IncomingMessage*/, + res: any /*ServerResponse*/, + callback: () => void + ) { + if (this.middlewares.length === 0) { + debug("no middleware to apply, skipping"); + return callback(); } - /** - * 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 + const apply = (i) => { + debug("applying middleware n°%d", i + 1); + this.middlewares[i](req, res, () => { + if (i + 1 < this.middlewares.length) { + apply(i + 1); + } else { + callback(); } - - 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" - }; + apply(0); + } + + /** + * Closes all clients. + * + * @api public + */ + 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 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 { + // @java-patch reject not websocket + if ("websocket" !== transportName) { + throw new Error("Unsupport polling at MiaoScript!"); + } + var transport = this.createTransport(transportName, req); + // 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"] = [ + // @ts-ignore + // 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", + }; +} + +/** + * Exposes a subset of the http.ServerResponse interface, in order to be able to apply the middlewares to an upgrade + * request. + * + * @see https://nodejs.org/api/http.html#class-httpserverresponse + */ +class WebSocketResponse { + constructor(readonly req, readonly socket: any /*Duplex */) { + // temporarily store the response headers on the req object (see the "headers" event) + req[kResponseHeaders] = {}; + } + + public setHeader(name: string, value: any) { + this.req[kResponseHeaders][name] = value; + } + + public getHeader(name: string) { + return this.req[kResponseHeaders][name]; + } + + public removeHeader(name: string) { + delete this.req[kResponseHeaders][name]; + } + + public write() {} + + public writeHead() {} + + public end() { + // we could return a proper error code, but the WebSocket client will emit an "error" event anyway. + this.socket.destroy(); + } } export class Server extends BaseServer { - // public httpServer?: HttpServer - private ws: any + // public httpServer?: HttpServer + private ws: any; - /** - * Initialize websocket server - * - * @api protected - */ - protected init() { - if (!~this.opts.transports.indexOf("websocket")) return + /** + * Initialize websocket server + * + * @api protected + */ + protected init() { + if (!~this.opts.transports.indexOf("websocket")) return; - if (this.ws) this.ws.close() + if (this.ws) this.ws.close(); - this.ws = new this.opts.wsEngine({ - noServer: true, - clientTracking: false, - perMessageDeflate: this.opts.perMessageDeflate, - maxPayload: this.opts.maxHttpBufferSize - }) + 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 = {} + 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 = req[kResponseHeaders] || {}; + delete req[kResponseHeaders]; - 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 - } - } - - /** - * 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) - } - - // /** - // * Handles an Engine.IO HTTP request. - // * - // * @param {http.IncomingMessage} request - // * @param {http.ServerResponse|http.OutgoingMessage} response - // * @api public - // */ - // public handleRequest(req, res) { - // debug('handling "%s" http request "%s"', req.method, req.url) - // this.prepare(req) - // req.res = res - - // const callback = (errorCode, errorContext) => { - // if (errorCode !== undefined) { - // this.emit("connection_error", { - // req, - // code: errorCode, - // message: Server.errorMessages[errorCode], - // context: errorContext - // }) - // abortRequest(res, errorCode, errorContext) - // return - // } - - // if (req._query.sid) { - // debug("setting new request for existing client") - // this.clients[req._query.sid].transport.onRequest(req) - // } else { - // const closeConnection = (errorCode, errorContext) => - // abortRequest(res, errorCode, errorContext) - // this.handshake(req._query.transport, req, closeConnection) - // } - // } - - // if (this.corsMiddleware) { - // this.corsMiddleware.call(null, req, res, () => { - // this.verify(req, false, callback) - // }) - // } else { - // this.verify(req, false, callback) - // } - // } - - // /** - // * Handles an Engine.IO HTTP Upgrade. - // * - // * @api public - // */ - // public handleUpgrade(req, socket, upgradeHead) { - // this.prepare(req) - - // this.verify(req, true, (errorCode, errorContext) => { - // if (errorCode) { - // this.emit("connection_error", { - // req, - // code: errorCode, - // message: Server.errorMessages[errorCode], - // context: errorContext - // }) - // abortUpgrade(socket, errorCode, errorContext) - // return - // } - - // const head = Buffer.from(upgradeHead) - // upgradeHead = null - - // // delegate to ws - // this.ws.handleUpgrade(req, socket, head, websocket => { - // this.onWebSocket(req, socket, websocket) - // }) - // }) - // } - - /** - * Called upon a ws.io connection. - * - * @param {ws.Socket} websocket - * @api private - */ - private onWebSocket(req: Request, socket, websocket: WebSocketClient) { - websocket.on("error", onUpgradeError) - - if ( - transports[req._query.transport] !== undefined && - !transports[req._query.transport].prototype.handlesUpgrades - ) { - debug("transport doesnt handle upgraded requests") - websocket.close() - return + const isInitialRequest = !req._query.sid; + if (isInitialRequest) { + this.emit("initial_headers", additionalHeaders, req); } - // get client id - const id = req._query.sid + this.emit("headers", additionalHeaders, req); - // keep a reference to the ws.Socket - req.websocket = websocket + debug("writing headers: %j", additionalHeaders); + Object.keys(additionalHeaders).forEach((key) => { + headersArray.push(`${key}: ${additionalHeaders[key]}`); + }); + }); + } + } - if (id) { - const client = this.clients[id] - if (!client) { - debug("upgrade attempt for closed client") - websocket.close() - } else if (client.upgrading) { - debug("transport has already been trying to upgrade") - websocket.close() - } else if (client.upgraded) { - debug("transport had already been upgraded") - websocket.close() - } else { - debug("upgrading existing transport") + 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 + } + } - // transport error handling takes over - websocket.removeListener("error", onUpgradeError) + /** + * 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) : {}; + } + } - const transport = this.createTransport(req._query.transport, req) - if (req._query && req._query.b64) { - transport.supportsBinary = false - } else { - transport.supportsBinary = true - } - transport.perMessageDeflate = this.opts.perMessageDeflate - client.maybeUpgrade(transport) - } + protected createTransport(transportName, req) { + return new transports[transportName](req); + } + + // /** + // * Handles an Engine.IO HTTP request. + // * + // * @param {IncomingMessage} req + // * @param {ServerResponse} res + // * @api public + // */ + // public handleRequest(req: IncomingMessage, res: ServerResponse) { + // debug('handling "%s" http request "%s"', req.method, req.url); + // this.prepare(req); + // // @ts-ignore + // req.res = res; + + // const callback = (errorCode, errorContext) => { + // if (errorCode !== undefined) { + // this.emit("connection_error", { + // req, + // code: errorCode, + // message: Server.errorMessages[errorCode], + // context: errorContext, + // }); + // abortRequest(res, errorCode, errorContext); + // return; + // } + + // // @ts-ignore + // if (req._query.sid) { + // debug("setting new request for existing client"); + // // @ts-ignore + // this.clients[req._query.sid].transport.onRequest(req); + // } else { + // const closeConnection = (errorCode, errorContext) => + // abortRequest(res, errorCode, errorContext); + // // @ts-ignore + // this.handshake(req._query.transport, req, closeConnection); + // } + // }; + + // this._applyMiddlewares(req, res, () => { + // this.verify(req, false, callback); + // }); + // } + + // /** + // * Handles an Engine.IO HTTP Upgrade. + // * + // * @api public + // */ + // public handleUpgrade( + // req: IncomingMessage, + // socket: Duplex, + // upgradeHead: Buffer + // ) { + // this.prepare(req); + + // const res = new WebSocketResponse(req, socket); + + // this._applyMiddlewares(req, res as unknown as ServerResponse, () => { + // this.verify(req, true, (errorCode, errorContext) => { + // if (errorCode) { + // this.emit("connection_error", { + // req, + // code: errorCode, + // message: Server.errorMessages[errorCode], + // context: errorContext, + // }); + // abortUpgrade(socket, errorCode, errorContext); + // return; + // } + + // const head = Buffer.from(upgradeHead); + // upgradeHead = null; + + // // some middlewares (like express-session) wait for the writeHead() call to flush their headers + // // see https://github.com/expressjs/session/blob/1010fadc2f071ddf2add94235d72224cf65159c6/index.js#L220-L244 + // res.writeHead(); + + // // delegate to ws + // this.ws.handleUpgrade(req, socket, head, (websocket) => { + // this.onWebSocket(req, socket, websocket); + // }); + // }); + // }); + // } + + /** + * Called upon a ws.io connection. + * + * @param {ws.Socket} websocket + * @api private + */ + private onWebSocket(req: Request, socket, websocket: WebSocketClient) { + websocket.on("error", onUpgradeError); + + if ( + transports[req._query.transport] !== undefined && + !transports[req._query.transport].prototype.handlesUpgrades + ) { + debug("transport doesnt handle upgraded requests"); + websocket.close(); + return; + } + + // get client id + const id = req._query.sid; + + // keep a reference to the ws.Socket + req.websocket = websocket; + + if (id) { + const client = this.clients[id]; + if (!client) { + debug("upgrade attempt for closed client"); + websocket.close(); + } else if (client.upgrading) { + debug("transport has already been trying to upgrade"); + websocket.close(); + } else if (client.upgraded) { + debug("transport had already been upgraded"); + websocket.close(); + } else { + debug("upgrading existing transport"); + + // transport error handling takes over + websocket.removeListener("error", onUpgradeError); + + const transport = this.createTransport(req._query.transport, req); + if (req._query && req._query.b64) { + transport.supportsBinary = false; } else { - // const closeConnection = (errorCode, errorContext) => - // abortUpgrade(socket, errorCode, errorContext) - this.handshake(req._query.transport, req, () => { }) - } - - function onUpgradeError(...args) { - debug("websocket error before upgrade %s", ...args) - // websocket.close() not needed + transport.supportsBinary = true; } + transport.perMessageDeflate = this.opts.perMessageDeflate; + client.maybeUpgrade(transport); + } + } else { + // const closeConnection = (errorCode, errorContext) => + // abortUpgrade(socket, errorCode, errorContext); + this.handshake(req._query.transport, req, () => {}); } - /** - * Captures upgrade requests for a http.Server. - * - * @param {http.Server} server - * @param {Object} options - * @api public - */ - // 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 - - // // normalize path - // path += "/" - - // function check(req) { - // return path === req.url.slice(0, path.length) - // } - - // cache and clean up listeners - // const listeners = server.listeners("request").slice(0) - // server.removeAllListeners("request") - server.on("close", this.close.bind(this)) - // server.on("listening", this.init.bind(this)) - // @java-patch transfer to Netty Server - server.on("connect", (request: Request, websocket: WebSocketClient) => { - console.debug('Engine.IO connect client from', request.url) - this.prepare(request) - this.onWebSocket(request, undefined, websocket) - }) - // set server as ws server - this.ws = server - - // // add request handler - // server.on("request", (req, res) => { - // if (check(req)) { - // debug('intercepting request for path "%s"', path) - // this.handleRequest(req, res) - // } else { - // let i = 0 - // const l = listeners.length - // for (; i < l; i++) { - // listeners[i].call(server, req, res) - // } - // } - // }) - - // if (~this.opts.transports.indexOf("websocket")) { - // server.on("upgrade", (req, socket, head) => { - // if (check(req)) { - // this.handleUpgrade(req, socket, head) - // } else if (false !== options.destroyUpgrade) { - // // default node behavior is to disconnect when no handlers - // // but by adding a handler, we prevent that - // // 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) - // } - // }) - // } + function onUpgradeError(...args) { + debug("websocket error before upgrade %s", ...args); + // websocket.close() not needed } + } + + /** + * Captures upgrade requests for a http.Server. + * + * @param {http.Server} server + * @param {Object} options + * @api public + */ + // public attach(server: HttpServer, options: AttachOptions = {}) { + // @java-patch + public attach(server, options: AttachOptions = {}) { + // const path = this._computePath(options); + // const destroyUpgradeTimeout = options.destroyUpgradeTimeout || 1000; + + // function check(req) { + // // TODO use `path === new URL(...).pathname` in the next major release (ref: https://nodejs.org/api/url.html) + // return path === req.url.slice(0, path.length); + // } + + // // cache and clean up listeners + // const listeners = server.listeners("request").slice(0); + // server.removeAllListeners("request"); + server.on("close", this.close.bind(this)); + // server.on("listening", this.init.bind(this)) + // @java-patch transfer to Netty Server + server.on("connect", (request: Request, websocket: WebSocketClient) => { + console.debug("Engine.IO connect client from", request.url); + this.prepare(request); + this.onWebSocket(request, undefined, websocket); + }); + // set server as ws server + this.ws = server; + + // // add request handler + // server.on("request", (req, res) => { + // if (check(req)) { + // debug('intercepting request for path "%s"', path); + // this.handleRequest(req, res); + // } else { + // let i = 0; + // const l = listeners.length; + // for (; i < l; i++) { + // listeners[i].call(server, req, res); + // } + // } + // }); + + // if (~this.opts.transports.indexOf("websocket")) { + // server.on("upgrade", (req, socket, head) => { + // if (check(req)) { + // this.handleUpgrade(req, socket, head); + // } else if (false !== options.destroyUpgrade) { + // // default node behavior is to disconnect when no handlers + // // but by adding a handler, we prevent that + // // 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); + // } + // }); + // } + } } // /** @@ -720,19 +850,19 @@ export class Server extends BaseServer { // */ // function abortRequest(res, errorCode, errorContext) { -// const statusCode = errorCode === Server.errors.FORBIDDEN ? 403 : 400 -// const message = -// errorContext && errorContext.message -// ? errorContext.message -// : Server.errorMessages[errorCode] +// const statusCode = errorCode === Server.errors.FORBIDDEN ? 403 : 400; +// const message = +// errorContext && errorContext.message +// ? errorContext.message +// : Server.errorMessages[errorCode]; -// res.writeHead(statusCode, { "Content-Type": "application/json" }) -// res.end( -// JSON.stringify({ -// code: errorCode, -// message -// }) -// ) +// res.writeHead(statusCode, { "Content-Type": "application/json" }); +// res.end( +// JSON.stringify({ +// code: errorCode, +// message, +// }) +// ); // } // /** @@ -746,28 +876,28 @@ export class Server extends BaseServer { // */ // function abortUpgrade( -// socket, -// errorCode, -// errorContext: { message?: string } = {} +// socket, +// errorCode, +// errorContext: { message?: string } = {} // ) { -// socket.on("error", () => { -// debug("ignoring error from closed connection") -// }) -// if (socket.writable) { -// const message = errorContext.message || Server.errorMessages[errorCode] -// const length = Buffer.byteLength(message) -// socket.write( -// "HTTP/1.1 400 Bad Request\r\n" + -// "Connection: close\r\n" + -// "Content-type: text/html\r\n" + -// "Content-Length: " + -// length + -// "\r\n" + -// "\r\n" + -// message -// ) -// } -// socket.destroy() +// socket.on("error", () => { +// debug("ignoring error from closed connection"); +// }); +// if (socket.writable) { +// const message = errorContext.message || Server.errorMessages[errorCode]; +// const length = Buffer.byteLength(message); +// socket.write( +// "HTTP/1.1 400 Bad Request\r\n" + +// "Connection: close\r\n" + +// "Content-type: text/html\r\n" + +// "Content-Length: " + +// length + +// "\r\n" + +// "\r\n" + +// message +// ); +// } +// socket.destroy(); // } /* eslint-disable */ @@ -805,32 +935,32 @@ export class Server extends BaseServer { // ] // function checkInvalidHeaderChar(val) { -// val += "" -// if (val.length < 1) return false -// if (!validHdrChars[val.charCodeAt(0)]) { -// debug('invalid header, index 0, char "%s"', val.charCodeAt(0)) -// return true +// val += ""; +// if (val.length < 1) return false; +// if (!validHdrChars[val.charCodeAt(0)]) { +// debug('invalid header, index 0, char "%s"', val.charCodeAt(0)); +// return true; +// } +// if (val.length < 2) return false; +// if (!validHdrChars[val.charCodeAt(1)]) { +// debug('invalid header, index 1, char "%s"', val.charCodeAt(1)); +// return true; +// } +// if (val.length < 3) return false; +// if (!validHdrChars[val.charCodeAt(2)]) { +// debug('invalid header, index 2, char "%s"', val.charCodeAt(2)); +// return true; +// } +// if (val.length < 4) return false; +// if (!validHdrChars[val.charCodeAt(3)]) { +// debug('invalid header, index 3, char "%s"', val.charCodeAt(3)); +// return true; +// } +// for (let i = 4; i < val.length; ++i) { +// if (!validHdrChars[val.charCodeAt(i)]) { +// debug('invalid header, index "%i", char "%s"', i, val.charCodeAt(i)); +// return true; // } -// if (val.length < 2) return false -// if (!validHdrChars[val.charCodeAt(1)]) { -// debug('invalid header, index 1, char "%s"', val.charCodeAt(1)) -// return true -// } -// if (val.length < 3) return false -// if (!validHdrChars[val.charCodeAt(2)]) { -// debug('invalid header, index 2, char "%s"', val.charCodeAt(2)) -// return true -// } -// if (val.length < 4) return false -// if (!validHdrChars[val.charCodeAt(3)]) { -// debug('invalid header, index 3, char "%s"', val.charCodeAt(3)) -// return true -// } -// for (let i = 4; i < val.length; ++i) { -// if (!validHdrChars[val.charCodeAt(i)]) { -// debug('invalid header, index "%i", char "%s"', i, val.charCodeAt(i)) -// return true -// } -// } -// return false +// } +// return false; // } diff --git a/packages/websocket/src/engine.io/socket.ts b/packages/websocket/src/engine.io/socket.ts index a4aa06eb..fef2b2d7 100644 --- a/packages/websocket/src/engine.io/socket.ts +++ b/packages/websocket/src/engine.io/socket.ts @@ -1,560 +1,560 @@ -import { EventEmitter } from "events" +import { EventEmitter } from "events"; // import debugModule from "debug" // import { IncomingMessage } from "http" -import { Transport } from "./transport" -import { Server } from "./server" +import { Transport } from "./transport"; +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" +import { Packet, PacketType, RawData } from "../engine.io-parser"; // const debug = debugModule("engine:socket") -const debug = require('../debug')("engine:socket") +const debug = require("../debug")("engine:socket"); export class Socket extends EventEmitter { - public readonly protocol: number - // public readonly request: IncomingMessage - public readonly request: any - public readonly remoteAddress: string + public readonly protocol: number; + // public readonly request: IncomingMessage + public readonly request: any; + public readonly remoteAddress: string; - public _readyState: string - public transport: Transport + public _readyState: string; + public transport: Transport; - 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 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 + private readonly id: string; - get readyState() { - return this._readyState + 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, 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; + + // Cache IP since it might not be in the req later + if (req.websocket && req.websocket._socket) { + this.remoteAddress = req.websocket._socket.remoteAddress; + } else { + this.remoteAddress = req.connection.remoteAddress; } - set readyState(state) { - debug("readyState updated from %s to %s", this._readyState, state) - this._readyState = state + this.checkIntervalTimer = null; + this.upgradeTimeoutTimer = null; + this.pingTimeoutTimer = null; + this.pingIntervalTimer = null; + + this.setTransport(transport); + this.onOpen(); + } + + /** + * Called upon transport considered open. + * + * @api private + */ + private onOpen() { + this.readyState = "open"; + + // sends an `open` packet + this.transport.sid = this.id; + this.sendPacket( + "open", + JSON.stringify({ + sid: this.id, + upgrades: this.getAvailableUpgrades(), + pingInterval: this.server.opts.pingInterval, + pingTimeout: this.server.opts.pingTimeout, + maxPayload: this.server.opts.maxHttpBufferSize, + }) + ); + + if (this.server.opts.initialPacket) { + this.sendPacket("message", this.server.opts.initialPacket); } - /** - * Client class (abstract). - * - * @api private - */ - 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 + this.emit("open"); - // Cache IP since it might not be in the req later - if (req.websocket && req.websocket._socket) { - this.remoteAddress = req.websocket._socket.remoteAddress - } else { - this.remoteAddress = req.connection.remoteAddress + if (this.protocol === 3) { + // in protocol v3, the client sends a ping, and the server answers with a pong + this.resetPingTimeout( + this.server.opts.pingInterval + this.server.opts.pingTimeout + ); + } else { + // in protocol v4, the server sends a ping, and the client answers with a pong + this.schedulePing(); + } + } + + /** + * Called upon transport packet. + * + * @param {Object} packet + * @api private + */ + private onPacket(packet: Packet) { + if ("open" !== this.readyState) { + return debug("packet received with closed socket"); + } + // export packet event + debug(`received packet ${packet.type}`); + this.emit("packet", packet); + + // Reset ping timeout on any packet, incoming data is a good sign of + // other side's liveness + this.resetPingTimeout( + this.server.opts.pingInterval + this.server.opts.pingTimeout + ); + + switch (packet.type) { + case "ping": + if (this.transport.protocol !== 3) { + this.onError("invalid heartbeat direction"); + return; } + debug("got ping"); + this.sendPacket("pong"); + this.emit("heartbeat"); + break; - this.checkIntervalTimer = null - this.upgradeTimeoutTimer = null - this.pingTimeoutTimer = null - this.pingIntervalTimer = null - - this.setTransport(transport) - this.onOpen() - } - - /** - * Called upon transport considered open. - * - * @api private - */ - private onOpen() { - this.readyState = "open" - - // sends an `open` packet - this.transport.sid = this.id - this.sendPacket( - "open", - JSON.stringify({ - sid: this.id, - upgrades: this.getAvailableUpgrades(), - pingInterval: this.server.opts.pingInterval, - pingTimeout: this.server.opts.pingTimeout, - maxPayload: this.server.opts.maxHttpBufferSize - }) - ) - - if (this.server.opts.initialPacket) { - this.sendPacket("message", this.server.opts.initialPacket) + case "pong": + if (this.transport.protocol === 3) { + this.onError("invalid heartbeat direction"); + return; } + debug("got pong"); + // this.pingIntervalTimer.refresh() + this.schedulePing(); + this.emit("heartbeat"); + break; - this.emit("open") + case "error": + this.onClose("parse error"); + break; - if (this.protocol === 3) { - // in protocol v3, the client sends a ping, and the server answers with a pong - this.resetPingTimeout( - this.server.opts.pingInterval + this.server.opts.pingTimeout - ) - } else { - // in protocol v4, the server sends a ping, and the client answers with a pong - this.schedulePing() + case "message": + this.emit("data", packet.data); + this.emit("message", packet.data); + break; + } + } + + /** + * Called upon transport error. + * + * @param {Error} error object + * @api private + */ + private onError(err) { + debug("transport error"); + this.onClose("transport error", err); + } + + /** + * Pings client every `this.pingInterval` and expects response + * within `this.pingTimeout` or closes connection. + * + * @api private + */ + private schedulePing() { + this.pingIntervalTimer = setTimeout(() => { + 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); + } + + /** + * Resets ping timeout. + * + * @api private + */ + private resetPingTimeout(timeout) { + clearTimeout(this.pingTimeoutTimer); + this.pingTimeoutTimer = setTimeout(() => { + if (this.readyState === "closed") return; + this.onClose("ping timeout"); + }, timeout); + } + + /** + * Attaches handlers for the given transport. + * + * @param {Transport} transport + * @api private + */ + private setTransport(transport) { + const onError = this.onError.bind(this); + const onPacket = this.onPacket.bind(this); + const flush = this.flush.bind(this); + const onClose = this.onClose.bind(this, "transport close"); + + this.transport = transport; + this.transport.once("error", onError); + this.transport.on("packet", onPacket); + this.transport.on("drain", flush); + this.transport.once("close", onClose); + // this function will manage packet events (also message callbacks) + this.setupSendCallback(); + + this.cleanupFn.push(function () { + transport.removeListener("error", onError); + transport.removeListener("packet", onPacket); + transport.removeListener("drain", flush); + transport.removeListener("close", onClose); + }); + } + + /** + * Upgrades socket to the given transport + * + * @param {Transport} transport + * @api private + */ + 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(() => { + debug("client did not complete upgrade - closing transport"); + cleanup(); + if ("open" === transport.readyState) { + transport.close(); + } + }, this.server.opts.upgradeTimeout); + + 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"); + cleanup(); + this.transport.discard(); + this.upgraded = true; + this.clearTransport(); + this.setTransport(transport); + this.emit("upgrade", transport); + this.flush(); + if (this.readyState === "closing") { + transport.close(() => { + this.onClose("forced close"); + }); } + } else { + cleanup(); + transport.close(); + } + }; + + // 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"); + this.transport.send([{ type: "noop" }]); + } + }; + + const cleanup = () => { + this.upgrading = false; + + clearInterval(this.checkIntervalTimer); + this.checkIntervalTimer = null; + + clearTimeout(this.upgradeTimeoutTimer); + this.upgradeTimeoutTimer = null; + + transport.removeListener("packet", onPacket); + transport.removeListener("close", onTransportClose); + transport.removeListener("error", onError); + this.removeListener("close", onClose); + }; + + const onError = (err) => { + debug("client did not complete upgrade - %s", err); + cleanup(); + transport.close(); + transport = null; + }; + + const onTransportClose = () => { + onError("transport closed"); + }; + + const onClose = () => { + onError("socket closed"); + }; + + transport.on("packet", onPacket); + transport.once("close", onTransportClose); + transport.once("error", onError); + + this.once("close", onClose); + } + + /** + * Clears listeners and timers associated with current transport. + * + * @api private + */ + private clearTransport() { + let cleanup; + + const toCleanUp = this.cleanupFn.length; + + for (let i = 0; i < toCleanUp; i++) { + cleanup = this.cleanupFn.shift(); + cleanup(); } - /** - * Called upon transport packet. - * - * @param {Object} packet - * @api private - */ - private onPacket(packet: Packet) { - if ("open" !== this.readyState) { - return debug("packet received with closed socket") - } - // export packet event - debug(`received packet ${packet.type}`) - this.emit("packet", packet) + // silence further transport errors and prevent uncaught exceptions + this.transport.on("error", function () { + debug("error triggered by discarded transport"); + }); - // Reset ping timeout on any packet, incoming data is a good sign of - // other side's liveness - this.resetPingTimeout( - this.server.opts.pingInterval + this.server.opts.pingTimeout - ) + // ensure transport won't stay open + this.transport.close(); - switch (packet.type) { - case "ping": - if (this.transport.protocol !== 3) { - this.onError("invalid heartbeat direction") - return - } - debug("got ping") - this.sendPacket("pong") - this.emit("heartbeat") - break + clearTimeout(this.pingTimeoutTimer); + } - case "pong": - if (this.transport.protocol === 3) { - this.onError("invalid heartbeat direction") - return - } - debug("got pong") - // this.pingIntervalTimer.refresh() - this.schedulePing() - this.emit("heartbeat") - break + /** + * Called upon transport considered closed. + * Possible reasons: `ping timeout`, `client error`, `parse error`, + * `transport error`, `server close`, `transport close` + */ + private onClose(reason: string, description?) { + if ("closed" !== this.readyState) { + this.readyState = "closed"; - case "error": - this.onClose("parse error") - break + // clear timers + clearTimeout(this.pingIntervalTimer); + clearTimeout(this.pingTimeoutTimer); - case "message": - this.emit("data", packet.data) - this.emit("message", packet.data) - break - } + clearInterval(this.checkIntervalTimer); + this.checkIntervalTimer = null; + clearTimeout(this.upgradeTimeoutTimer); + // clean writeBuffer in next tick, so developers can still + // grab the writeBuffer on 'close' event + process.nextTick(() => { + this.writeBuffer = []; + }); + this.packetsFn = []; + this.sentCallbackFn = []; + this.clearTransport(); + this.emit("close", reason, description); } + } - /** - * Called upon transport error. - * - * @param {Error} error object - * @api private - */ - private onError(err) { - debug("transport error") - this.onClose("transport error", err) - } - - /** - * Pings client every `this.pingInterval` and expects response - * within `this.pingTimeout` or closes connection. - * - * @api private - */ - private schedulePing() { - this.pingIntervalTimer = setTimeout(() => { - 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) - } - - /** - * Resets ping timeout. - * - * @api private - */ - private resetPingTimeout(timeout) { - clearTimeout(this.pingTimeoutTimer) - this.pingTimeoutTimer = setTimeout(() => { - if (this.readyState === "closed") return - this.onClose("ping timeout") - }, timeout) - } - - /** - * Attaches handlers for the given transport. - * - * @param {Transport} transport - * @api private - */ - private setTransport(transport) { - const onError = this.onError.bind(this) - const onPacket = this.onPacket.bind(this) - const flush = this.flush.bind(this) - const onClose = this.onClose.bind(this, "transport close") - - this.transport = transport - this.transport.once("error", onError) - this.transport.on("packet", onPacket) - this.transport.on("drain", flush) - this.transport.once("close", onClose) - // this function will manage packet events (also message callbacks) - this.setupSendCallback() - - this.cleanupFn.push(function () { - transport.removeListener("error", onError) - transport.removeListener("packet", onPacket) - transport.removeListener("drain", flush) - transport.removeListener("close", onClose) - }) - } - - /** - * Upgrades socket to the given transport - * - * @param {Transport} transport - * @api private - */ - 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(() => { - debug("client did not complete upgrade - closing transport") - cleanup() - if ("open" === transport.readyState) { - transport.close() - } - }, this.server.opts.upgradeTimeout) - - 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") - cleanup() - this.transport.discard() - this.upgraded = true - this.clearTransport() - this.setTransport(transport) - this.emit("upgrade", transport) - this.flush() - if (this.readyState === "closing") { - transport.close(() => { - this.onClose("forced close") - }) - } - } else { - cleanup() - transport.close() + /** + * Setup and manage send callback + * + * @api private + */ + 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"); + seqFn(this.transport); + } else if (Array.isArray(seqFn)) { + debug("executing batch send callback"); + const l = seqFn.length; + let i = 0; + for (; i < l; i++) { + if ("function" === typeof seqFn[i]) { + seqFn[i](this.transport); } + } } + } + }; - // 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") - this.transport.send([{ type: "noop" }]) - } - } + this.transport.on("drain", onDrain); - const cleanup = () => { - this.upgrading = false + this.cleanupFn.push(() => { + this.transport.removeListener("drain", onDrain); + }); + } - clearInterval(this.checkIntervalTimer) - this.checkIntervalTimer = null + /** + * Sends a message packet. + * + * @param {Object} data + * @param {Object} options + * @param {Function} callback + * @return {Socket} for chaining + * @api public + */ + public send(data, options, callback?) { + this.sendPacket("message", data, options, callback); + return this; + } - clearTimeout(this.upgradeTimeoutTimer) - this.upgradeTimeoutTimer = null + public write(data, options, callback?) { + this.sendPacket("message", data, options, callback); + return this; + } - transport.removeListener("packet", onPacket) - transport.removeListener("close", onTransportClose) - transport.removeListener("error", onError) - this.removeListener("close", onClose) - } - - const onError = err => { - debug("client did not complete upgrade - %s", err) - cleanup() - transport.close() - transport = null - } - - const onTransportClose = () => { - onError("transport closed") - } - - const onClose = () => { - onError("socket closed") - } - - transport.on("packet", onPacket) - transport.once("close", onTransportClose) - transport.once("error", onError) - - this.once("close", onClose) + /** + * Sends a packet. + * + * @param {String} type - packet type + * @param {String} data + * @param {Object} options + * @param {Function} callback + * + * @api private + */ + private sendPacket(type: PacketType, data?: RawData, options?, callback?) { + if ("function" === typeof options) { + callback = options; + options = null; } - /** - * Clears listeners and timers associated with current transport. - * - * @api private - */ - private clearTransport() { - let cleanup + options = options || {}; + options.compress = false !== options.compress; - const toCleanUp = this.cleanupFn.length + if ("closing" !== this.readyState && "closed" !== this.readyState) { + debug('sending packet "%s" (%s)', type, data); - for (let i = 0; i < toCleanUp; i++) { - cleanup = this.cleanupFn.shift() - cleanup() - } + const packet: Packet = { + type, + options, + }; - // silence further transport errors and prevent uncaught exceptions - this.transport.on("error", function () { - debug("error triggered by discarded transport") - }) + if (data) packet.data = data; - // ensure transport won't stay open - this.transport.close() + // exports packetCreate event + this.emit("packetCreate", packet); - clearTimeout(this.pingTimeoutTimer) + this.writeBuffer.push(packet); + + // add send callback to object, if defined + if (callback) this.packetsFn.push(callback); + + this.flush(); + } + } + + /** + * Attempts to flush the packets buffer. + * + * @api private + */ + private flush() { + if ( + "closed" !== this.readyState && + this.transport.writable && + this.writeBuffer.length + ) { + debug("flushing buffer to transport"); + this.emit("flush", this.writeBuffer); + this.server.emit("flush", this, this.writeBuffer); + const wbuf = this.writeBuffer; + this.writeBuffer = []; + if (!this.transport.supportsFraming) { + this.sentCallbackFn.push(this.packetsFn); + } else { + this.sentCallbackFn.push.apply(this.sentCallbackFn, this.packetsFn); + } + this.packetsFn = []; + this.transport.send(wbuf); + this.emit("drain"); + this.server.emit("drain", this); + } + } + + /** + * Get available upgrades for this socket. + * + * @api private + */ + private getAvailableUpgrades() { + const availableUpgrades = []; + const allUpgrades = this.server.upgrades(this.transport.name); + let i = 0; + const l = allUpgrades.length; + for (; i < l; ++i) { + const upg = allUpgrades[i]; + if (this.server.opts.transports.indexOf(upg) !== -1) { + availableUpgrades.push(upg); + } + } + return availableUpgrades; + } + + /** + * Closes the socket and underlying transport. + * + * @param {Boolean} discard - optional, discard the transport + * @return {Socket} for chaining + * @api public + */ + public close(discard?: boolean) { + if ("open" !== this.readyState) return; + + this.readyState = "closing"; + + if (this.writeBuffer.length) { + this.once("drain", this.closeTransport.bind(this, discard)); + return; } - /** - * Called upon transport considered closed. - * Possible reasons: `ping timeout`, `client error`, `parse error`, - * `transport error`, `server close`, `transport close` - */ - private onClose(reason: string, description?) { - if ("closed" !== this.readyState) { - this.readyState = "closed" + this.closeTransport(discard); + } - // clear timers - clearTimeout(this.pingIntervalTimer) - clearTimeout(this.pingTimeoutTimer) - - clearInterval(this.checkIntervalTimer) - this.checkIntervalTimer = null - clearTimeout(this.upgradeTimeoutTimer) - // clean writeBuffer in next tick, so developers can still - // grab the writeBuffer on 'close' event - process.nextTick(() => { - this.writeBuffer = [] - }) - this.packetsFn = [] - this.sentCallbackFn = [] - this.clearTransport() - this.emit("close", reason, description) - } - } - - /** - * Setup and manage send callback - * - * @api private - */ - 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") - seqFn(this.transport) - } else if (Array.isArray(seqFn)) { - debug("executing batch send callback") - const l = seqFn.length - let i = 0 - for (; i < l; i++) { - if ("function" === typeof seqFn[i]) { - seqFn[i](this.transport) - } - } - } - } - } - - this.transport.on("drain", onDrain) - - this.cleanupFn.push(() => { - this.transport.removeListener("drain", onDrain) - }) - } - - /** - * Sends a message packet. - * - * @param {Object} data - * @param {Object} options - * @param {Function} callback - * @return {Socket} for chaining - * @api public - */ - public send(data, options, callback?) { - this.sendPacket("message", data, options, callback) - return this - } - - public write(data, options, callback?) { - this.sendPacket("message", data, options, callback) - return this - } - - /** - * Sends a packet. - * - * @param {String} type - packet type - * @param {String} data - * @param {Object} options - * @param {Function} callback - * - * @api private - */ - private sendPacket(type: PacketType, data?: RawData, options?, callback?) { - if ("function" === typeof options) { - callback = options - options = null - } - - options = options || {} - options.compress = false !== options.compress - - if ("closing" !== this.readyState && "closed" !== this.readyState) { - debug('sending packet "%s" (%s)', type, data) - - const packet: Packet = { - type, - options - } - - if (data) packet.data = data - - // exports packetCreate event - this.emit("packetCreate", packet) - - this.writeBuffer.push(packet) - - // add send callback to object, if defined - if (callback) this.packetsFn.push(callback) - - this.flush() - } - } - - /** - * Attempts to flush the packets buffer. - * - * @api private - */ - private flush() { - if ( - "closed" !== this.readyState && - this.transport.writable && - this.writeBuffer.length - ) { - debug("flushing buffer to transport") - this.emit("flush", this.writeBuffer) - this.server.emit("flush", this, this.writeBuffer) - const wbuf = this.writeBuffer - this.writeBuffer = [] - if (!this.transport.supportsFraming) { - this.sentCallbackFn.push(this.packetsFn) - } else { - this.sentCallbackFn.push.apply(this.sentCallbackFn, this.packetsFn) - } - this.packetsFn = [] - this.transport.send(wbuf) - this.emit("drain") - this.server.emit("drain", this) - } - } - - /** - * Get available upgrades for this socket. - * - * @api private - */ - private getAvailableUpgrades() { - const availableUpgrades = [] - const allUpgrades = this.server.upgrades(this.transport.name) - let i = 0 - const l = allUpgrades.length - for (; i < l; ++i) { - const upg = allUpgrades[i] - if (this.server.opts.transports.indexOf(upg) !== -1) { - availableUpgrades.push(upg) - } - } - return availableUpgrades - } - - /** - * Closes the socket and underlying transport. - * - * @param {Boolean} discard - optional, discard the transport - * @return {Socket} for chaining - * @api public - */ - public close(discard?: boolean) { - if ("open" !== this.readyState) return - - this.readyState = "closing" - - if (this.writeBuffer.length) { - this.once("drain", this.closeTransport.bind(this, discard)) - return - } - - this.closeTransport(discard) - } - - /** - * Closes the underlying transport. - * - * @param {Boolean} discard - * @api private - */ - private closeTransport(discard) { - if (discard) this.transport.discard() - this.transport.close(this.onClose.bind(this, "forced close")) - } + /** + * Closes the underlying transport. + * + * @param {Boolean} discard + * @api private + */ + 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 92bae220..6e39f6f6 100644 --- a/packages/websocket/src/engine.io/transport.ts +++ b/packages/websocket/src/engine.io/transport.ts @@ -1,12 +1,12 @@ -import { EventEmitter } from "events" -import * as parser_v4 from "../engine.io-parser" -import * as parser_v3 from "./parser-v3" +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" +import { Packet } from "../engine.io-parser"; // const debug = debugModule("engine:transport") -const debug = require('../debug')("engine:transport") +const debug = require("../debug")("engine:transport"); /** * Noop function. @@ -14,132 +14,132 @@ const debug = require('../debug')("engine:transport") * @api private */ -function noop() { } +function noop() {} export abstract class Transport extends EventEmitter { - public sid: string - public writable: boolean - public protocol: number + public sid: string; + public writable: boolean; + public protocol: number; - protected _readyState: string - protected discarded: boolean - protected parser: any - // protected req: IncomingMessage & { cleanup: Function } - protected req: { cleanup: Function } - protected supportsBinary: boolean + protected _readyState: string; + protected discarded: boolean; + protected parser: any; + // protected req: IncomingMessage & { cleanup: Function } + protected req: { cleanup: Function }; + protected supportsBinary: boolean; - get readyState() { - return this._readyState + 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. + * + * @param {http.IncomingMessage} request + * @api public + */ + constructor(req) { + super(); + this.readyState = "open"; + this.discarded = false; + this.protocol = req._query.EIO === "4" ? 4 : 3; // 3rd revision by default + this.parser = this.protocol === 4 ? parser_v4 : parser_v3; + } + + /** + * Flags the transport as discarded. + * + * @api private + */ + discard() { + this.discarded = true; + } + + /** + * Called with an incoming HTTP request. + * + * @param {http.IncomingMessage} request + * @api protected + */ + protected onRequest(req) { + debug("setting request"); + this.req = req; + } + + /** + * Closes the transport. + * + * @api private + */ + close(fn?) { + if ("closed" === this.readyState || "closing" === this.readyState) return; + + this.readyState = "closing"; + this.doClose(fn || noop); + } + + /** + * Called with a transport error. + * + * @param {String} message error + * @param {Object} error description + * @api protected + */ + protected onError(msg: string, desc?) { + if (this.listeners("error").length) { + const err = new Error(msg); + // @ts-ignore + err.type = "TransportError"; + // @ts-ignore + err.description = desc; + this.emit("error", err); + } else { + debug("ignored transport error %s (%s)", msg, desc); } + } - set readyState(state) { - debug( - "readyState updated from %s to %s (%s)", - this._readyState, - state, - this.name - ) - this._readyState = state - } + /** + * Called with parsed out a packets from the data stream. + * + * @param {Object} packet + * @api protected + */ + protected onPacket(packet: Packet) { + this.emit("packet", packet); + } - /** - * Transport constructor. - * - * @param {http.IncomingMessage} request - * @api public - */ - constructor(req) { - super() - this.readyState = "open" - this.discarded = false - this.protocol = req._query.EIO === "4" ? 4 : 3 // 3rd revision by default - this.parser = this.protocol === 4 ? parser_v4 : parser_v3 - } + /** + * Called with the encoded packet data. + * + * @param {String} data + * @api protected + */ + protected onData(data) { + this.onPacket(this.parser.decodePacket(data)); + } - /** - * Flags the transport as discarded. - * - * @api private - */ - discard() { - this.discarded = true - } + /** + * Called upon transport close. + * + * @api protected + */ + protected onClose() { + this.readyState = "closed"; + this.emit("close"); + } - /** - * Called with an incoming HTTP request. - * - * @param {http.IncomingMessage} request - * @api protected - */ - protected onRequest(req) { - debug("setting request") - this.req = req - } - - /** - * Closes the transport. - * - * @api private - */ - close(fn?) { - if ("closed" === this.readyState || "closing" === this.readyState) return - - this.readyState = "closing" - this.doClose(fn || noop) - } - - /** - * Called with a transport error. - * - * @param {String} message error - * @param {Object} error description - * @api protected - */ - protected onError(msg: string, desc?) { - if (this.listeners("error").length) { - const err = new Error(msg) - // @ts-ignore - err.type = "TransportError" - // @ts-ignore - err.description = desc - this.emit("error", err) - } else { - debug("ignored transport error %s (%s)", msg, desc) - } - } - - /** - * Called with parsed out a packets from the data stream. - * - * @param {Object} packet - * @api protected - */ - protected onPacket(packet: Packet) { - this.emit("packet", packet) - } - - /** - * Called with the encoded packet data. - * - * @param {String} data - * @api protected - */ - protected onData(data) { - this.onPacket(this.parser.decodePacket(data)) - } - - /** - * Called upon transport close. - * - * @api protected - */ - protected onClose() { - this.readyState = "closed" - this.emit("close") - } - - abstract get supportsFraming() - abstract get name() - abstract send(packets) - abstract doClose(fn?) + abstract get supportsFraming(); + abstract get name(); + 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 6bda051a..33320302 100644 --- a/packages/websocket/src/engine.io/transports/index.ts +++ b/packages/websocket/src/engine.io/transports/index.ts @@ -1,11 +1,11 @@ // import { Polling as XHR } from "./polling" // import { JSONP } from "./polling-jsonp" -import { WebSocket } from "./websocket" +import { WebSocket } from "./websocket"; export default { - // polling: polling, - websocket: WebSocket -} + // polling: polling, + websocket: WebSocket, +}; // /** // * Polling polymorphic constructor. diff --git a/packages/websocket/src/engine.io/transports/websocket.ts b/packages/websocket/src/engine.io/transports/websocket.ts index baf859be..125840ee 100644 --- a/packages/websocket/src/engine.io/transports/websocket.ts +++ b/packages/websocket/src/engine.io/transports/websocket.ts @@ -1,111 +1,111 @@ -import { Transport } from "../transport" +import { Transport } from "../transport"; // import debugModule from "debug"; -const debug = require('../../debug')("engine:ws") +const debug = require("../../debug")("engine:ws"); export class WebSocket extends Transport { - protected perMessageDeflate: any - private socket: any + protected perMessageDeflate: any; + private socket: any; - /** - * WebSocket transport - * - * @param {http.IncomingMessage} - * @api public - */ - constructor(req) { - super(req) - this.socket = req.websocket - 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 - this.perMessageDeflate = null + /** + * WebSocket transport + * + * @param {http.IncomingMessage} + * @api public + */ + constructor(req) { + super(req); + this.socket = req.websocket; + 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; + this.perMessageDeflate = null; + } + + /** + * Transport name + * + * @api public + */ + get name() { + return "websocket"; + } + + /** + * Advertise upgrade support. + * + * @api public + */ + get handlesUpgrades() { + return true; + } + + /** + * Advertise framing support. + * + * @api public + */ + get supportsFraming() { + return true; + } + + /** + * Writes a packet payload. + * + * @param {Array} packets + * @api private + */ + send(packets) { + const packet = packets.shift(); + if (typeof packet === "undefined") { + this.writable = true; + this.emit("drain"); + return; } - /** - * Transport name - * - * @api public - */ - get name() { - return "websocket" + // always creates a new object since ws modifies it + const opts: { compress?: boolean } = {}; + if (packet.options) { + opts.compress = packet.options.compress; } - /** - * Advertise upgrade support. - * - * @api public - */ - get handlesUpgrades() { - return true - } - - /** - * Advertise framing support. - * - * @api public - */ - get supportsFraming() { - return true - } - - /** - * Writes a packet payload. - * - * @param {Array} packets - * @api private - */ - send(packets) { - const packet = packets.shift() - if (typeof packet === "undefined") { - this.writable = true - this.emit("drain") - return + const send = data => { + if (this.perMessageDeflate) { + const len = + "string" === typeof data ? Buffer.byteLength(data) : data.length; + if (len < this.perMessageDeflate.threshold) { + opts.compress = false; } + } + debug('writing "%s"', data); + this.writable = false; - // always creates a new object since ws modifies it - const opts: { compress?: boolean } = {} - if (packet.options) { - opts.compress = packet.options.compress - } + this.socket.send(data, opts, err => { + if (err) return this.onError("write error", err.stack); + this.send(packets); + }); + }; - const send = data => { - if (this.perMessageDeflate) { - const len = - "string" === typeof data ? Buffer.byteLength(data) : data.length - if (len < this.perMessageDeflate.threshold) { - opts.compress = false - } - } - debug('writing "%s"', data) - this.writable = false - - this.socket.send(data, opts, err => { - if (err) return this.onError("write error", err.stack) - this.send(packets) - }) - } - - if (packet.options && typeof packet.options.wsPreEncoded === "string") { - send(packet.options.wsPreEncoded) - } else { - this.parser.encodePacket(packet, this.supportsBinary, send) - } + if (packet.options && typeof packet.options.wsPreEncoded === "string") { + send(packet.options.wsPreEncoded); + } else { + this.parser.encodePacket(packet, this.supportsBinary, send); } + } - /** - * Closes the transport. - * - * @api private - */ - doClose(fn) { - debug("closing") - this.socket.close() - fn && fn() - } + /** + * Closes the transport. + * + * @api private + */ + doClose(fn) { + debug("closing"); + this.socket.close(); + fn && fn(); + } } diff --git a/packages/websocket/src/socket.io-adapter/contrib/yeast.ts b/packages/websocket/src/socket.io-adapter/contrib/yeast.ts new file mode 100644 index 00000000..3fc0f905 --- /dev/null +++ b/packages/websocket/src/socket.io-adapter/contrib/yeast.ts @@ -0,0 +1,65 @@ +// 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/socket.io-adapter/index.ts b/packages/websocket/src/socket.io-adapter/index.ts index 4b16bcc9..4ae3d47e 100644 --- a/packages/websocket/src/socket.io-adapter/index.ts +++ b/packages/websocket/src/socket.io-adapter/index.ts @@ -1,334 +1,507 @@ -import { EventEmitter } from "events" +import { EventEmitter } from "events"; +import { yeast } from "./contrib/yeast"; +// import WebSocket = require("ws"); + +// const canPreComputeFrame = typeof WebSocket?.Sender?.frame === "function"; + +/** + * A public ID, sent by the server at the beginning of the Socket.IO session and which can be used for private messaging + */ +export type SocketId = string; +/** + * A private ID, sent by the server at the beginning of the Socket.IO session and used for connection state recovery + * upon reconnection + */ +export type PrivateSessionId = string; -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 type Room = string; export interface BroadcastFlags { - volatile?: boolean - compress?: boolean - local?: boolean - broadcast?: boolean - binary?: boolean - timeout?: number + volatile?: boolean; + compress?: boolean; + local?: boolean; + broadcast?: boolean; + binary?: boolean; + timeout?: number; } export interface BroadcastOptions { - rooms: Set - except?: Set - flags?: BroadcastFlags + rooms: Set; + except?: Set; + flags?: BroadcastFlags; } +interface SessionToPersist { + sid: SocketId; + pid: PrivateSessionId; + rooms: Room[]; + data: unknown; +} + +export type Session = SessionToPersist & { missedPackets: unknown[][] }; + export class Adapter extends EventEmitter { - public rooms: Map> = new Map(); - public sids: Map> = new Map(); - private readonly encoder + public rooms: Map> = new Map(); + public sids: Map> = new Map(); + private readonly encoder; - /** - * In-memory adapter constructor. - * - * @param {Namespace} nsp - */ - constructor(readonly nsp: any) { - super() - this.encoder = nsp.server.encoder + /** + * In-memory adapter constructor. + * + * @param {Namespace} nsp + */ + constructor(readonly nsp: any) { + super(); + this.encoder = nsp.server.encoder; + } + + /** + * To be overridden + */ + public init(): Promise | void {} + + /** + * To be overridden + */ + 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. + * + * @param {SocketId} id the socket id + * @param {Set} rooms a set of rooms + * @public + */ + public addAll(id: SocketId, rooms: Set): Promise | void { + if (!this.sids.has(id)) { + this.sids.set(id, new Set()); } - /** - * To be overridden - */ - public init(): Promise | void { } + for (const room of rooms) { + this.sids.get(id).add(room); - /** - * To be overridden - */ - public close(): Promise | void { } + if (!this.rooms.has(room)) { + this.rooms.set(room, new Set()); + this.emit("create-room", room); + } + if (!this.rooms.get(room).has(id)) { + this.rooms.get(room).add(id); + this.emit("join-room", room, id); + } + } + } - /** - * Returns the number of Socket.IO servers in the cluster - * - * @public - */ - public serverCount(): Promise { - return Promise.resolve(1) + /** + * Removes a socket from a room. + * + * @param {SocketId} id the socket id + * @param {Room} room the room name + */ + public del(id: SocketId, room: Room): Promise | void { + if (this.sids.has(id)) { + this.sids.get(id).delete(room); } - /** - * Adds a socket to a list of room. - * - * @param {SocketId} id the socket id - * @param {Set} rooms a set of rooms - * @public - */ - public addAll(id: SocketId, rooms: Set): Promise | void { - if (!this.sids.has(id)) { - this.sids.set(id, new Set()) + this._del(room, 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 (_room.size === 0 && this.rooms.delete(room)) { + this.emit("delete-room", room); + } + } + } + + /** + * Removes a socket from all rooms it's joined. + * + * @param {SocketId} id the socket id + */ + public delAll(id: SocketId): void { + if (!this.sids.has(id)) { + return; + } + + for (const room of this.sids.get(id)) { + this._del(room, id); + } + + this.sids.delete(id); + } + + /** + * Broadcasts a packet. + * + * 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 + * @public + */ + public broadcast(packet: any, opts: BroadcastOptions): void { + const flags = opts.flags || {}; + const packetOpts = { + preEncoded: true, + volatile: flags.volatile, + compress: flags.compress, + }; + + packet.nsp = this.nsp.name; + const encodedPackets = this._encode(packet, packetOpts); + + 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._encode(packet, packetOpts); + + let clientCount = 0; + + this.apply(opts, (socket) => { + // 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); + } + + private _encode(packet: unknown, packetOpts: Record) { + const encodedPackets = this.encoder.encode(packet); + + // if ( + // canPreComputeFrame && + // encodedPackets.length === 1 && + // typeof encodedPackets[0] === "string" + // ) { + // // "4" being the "message" packet type in the Engine.IO protocol + // const data = Buffer.from("4" + encodedPackets[0]); + // // see https://github.com/websockets/ws/issues/617#issuecomment-283002469 + // packetOpts.wsPreEncodedFrame = WebSocket.Sender.frame(data, { + // readOnly: false, + // mask: false, + // rsv1: false, + // opcode: 1, + // fin: true, + // }); + // } + + return encodedPackets; + } + + /** + * Gets a list of sockets by sid. + * + * @param {Set} rooms the explicit set of rooms to check. + */ + public sockets(rooms: Set): Promise> { + const sids = new Set(); + + this.apply({ rooms }, (socket) => { + sids.add(socket.id); + }); + + return Promise.resolve(sids); + } + + /** + * Gets the list of rooms a given socket has joined. + * + * @param {SocketId} id the socket id + */ + public socketRooms(id: SocketId): Set | undefined { + return this.sids.get(id); + } + + /** + * Returns the matching socket instances + * + * @param opts - the filters to apply + */ + public fetchSockets(opts: BroadcastOptions): Promise { + const sockets = []; + + this.apply(opts, (socket) => { + sockets.push(socket); + }); + + return Promise.resolve(sockets); + } + + /** + * Makes the matching socket instances join the specified rooms + * + * @param opts - the filters to apply + * @param rooms - the rooms to join + */ + public addSockets(opts: BroadcastOptions, rooms: Room[]): void { + this.apply(opts, (socket) => { + socket.join(rooms); + }); + } + + /** + * Makes the matching socket instances leave the specified rooms + * + * @param opts - the filters to apply + * @param rooms - the rooms to leave + */ + public delSockets(opts: BroadcastOptions, rooms: Room[]): void { + this.apply(opts, (socket) => { + rooms.forEach((room) => socket.leave(room)); + }); + } + + /** + * Makes the matching socket instances disconnect + * + * @param opts - the filters to apply + * @param close - whether to close the underlying connection + */ + public disconnectSockets(opts: BroadcastOptions, close: boolean): void { + this.apply(opts, (socket) => { + socket.disconnect(close); + }); + } + + private apply(opts: BroadcastOptions, callback: (socket) => void): void { + const rooms = opts.rooms; + const except = this.computeExceptSids(opts.except); + + if (rooms.size) { + const ids = new Set(); + for (const room of rooms) { + if (!this.rooms.has(room)) continue; + + for (const id of this.rooms.get(room)) { + if (ids.has(id) || except.has(id)) continue; + const socket = this.nsp.sockets.get(id); + if (socket) { + callback(socket); + ids.add(id); + } } + } + } else { + for (const [id] of this.sids) { + if (except.has(id)) continue; + const socket = this.nsp.sockets.get(id); + if (socket) callback(socket); + } + } + } - for (const room of rooms) { - this.sids.get(id).add(room) - - if (!this.rooms.has(room)) { - this.rooms.set(room, new Set()) - this.emit("create-room", room) - } - if (!this.rooms.get(room).has(id)) { - this.rooms.get(room).add(id) - this.emit("join-room", room, id) - } + private computeExceptSids(exceptRooms?: Set) { + const exceptSids = new Set(); + if (exceptRooms && exceptRooms.size > 0) { + for (const room of exceptRooms) { + if (this.rooms.has(room)) { + this.rooms.get(room).forEach((sid) => exceptSids.add(sid)); } + } } + return exceptSids; + } - /** - * Removes a socket from a room. - * - * @param {SocketId} id the socket id - * @param {Room} room the room name - */ - public del(id: SocketId, room: Room): Promise | void { - if (this.sids.has(id)) { - this.sids.get(id).delete(room) - } + /** + * Send a packet to the other Socket.IO servers in the cluster + * @param packet - an array of arguments, which may include an acknowledgement callback at the end + */ + public serverSideEmit(packet: any[]): void { + console.warn( + "this adapter does not support the serverSideEmit() functionality" + ); + } - this._del(room, id) - } + /** + * Save the client session in order to restore it upon reconnection. + */ + public persistSession(session: SessionToPersist) {} - 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 (_room.size === 0 && this.rooms.delete(room)) { - this.emit("delete-room", room) - } - } - } - - /** - * Removes a socket from all rooms it's joined. - * - * @param {SocketId} id the socket id - */ - public delAll(id: SocketId): void { - if (!this.sids.has(id)) { - return - } - - for (const room of this.sids.get(id)) { - this._del(room, id) - } - - this.sids.delete(id) - } - - /** - * Broadcasts a packet. - * - * 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 - * @public - */ - public broadcast(packet: any, opts: BroadcastOptions): void { - const flags = opts.flags || {} - const packetOpts = { - preEncoded: true, - volatile: flags.volatile, - compress: flags.compress - } - - packet.nsp = this.nsp.name - const encodedPackets = this.encoder.encode(packet) - - 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 => { - // 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) - } - - /** - * Gets a list of sockets by sid. - * - * @param {Set} rooms the explicit set of rooms to check. - */ - public sockets(rooms: Set): Promise> { - const sids = new Set() - - this.apply({ rooms }, socket => { - sids.add(socket.id) - }) - - return Promise.resolve(sids) - } - - /** - * Gets the list of rooms a given socket has joined. - * - * @param {SocketId} id the socket id - */ - public socketRooms(id: SocketId): Set | undefined { - return this.sids.get(id) - } - - /** - * Returns the matching socket instances - * - * @param opts - the filters to apply - */ - public fetchSockets(opts: BroadcastOptions): Promise { - const sockets = [] - - this.apply(opts, socket => { - sockets.push(socket) - }) - - return Promise.resolve(sockets) - } - - /** - * Makes the matching socket instances join the specified rooms - * - * @param opts - the filters to apply - * @param rooms - the rooms to join - */ - public addSockets(opts: BroadcastOptions, rooms: Room[]): void { - this.apply(opts, socket => { - socket.join(rooms) - }) - } - - /** - * Makes the matching socket instances leave the specified rooms - * - * @param opts - the filters to apply - * @param rooms - the rooms to leave - */ - public delSockets(opts: BroadcastOptions, rooms: Room[]): void { - this.apply(opts, socket => { - rooms.forEach(room => socket.leave(room)) - }) - } - - /** - * Makes the matching socket instances disconnect - * - * @param opts - the filters to apply - * @param close - whether to close the underlying connection - */ - public disconnectSockets(opts: BroadcastOptions, close: boolean): void { - this.apply(opts, socket => { - socket.disconnect(close) - }) - } - - private apply(opts: BroadcastOptions, callback: (socket) => void): void { - const rooms = opts.rooms - const except = this.computeExceptSids(opts.except) - - if (rooms.size) { - const ids = new Set() - for (const room of rooms) { - if (!this.rooms.has(room)) continue - - for (const id of this.rooms.get(room)) { - if (ids.has(id) || except.has(id)) continue - const socket = this.nsp.sockets.get(id) - if (socket) { - callback(socket) - ids.add(id) - } - } - } - } else { - for (const [id] of this.sids) { - if (except.has(id)) continue - const socket = this.nsp.sockets.get(id) - if (socket) callback(socket) - } - } - } - - private computeExceptSids(exceptRooms?: Set) { - const exceptSids = new Set() - if (exceptRooms && exceptRooms.size > 0) { - for (const room of exceptRooms) { - if (this.rooms.has(room)) { - this.rooms.get(room).forEach(sid => exceptSids.add(sid)) - } - } - } - return exceptSids - } - - /** - * Send a packet to the other Socket.IO servers in the cluster - * @param packet - an array of arguments, which may include an acknowledgement callback at the end - */ - public serverSideEmit(packet: any[]): void { - console.warn( - "this adapter does not support the serverSideEmit() functionality" - ) - } + /** + * Restore the session and find the packets that were missed by the client. + * @param pid + * @param offset + */ + public restoreSession( + pid: PrivateSessionId, + offset: string + ): Session { + return null; + } +} + +interface PersistedPacket { + id: string; + emittedAt: number; + data: unknown[]; + opts: BroadcastOptions; +} + +type SessionWithTimestamp = SessionToPersist & { disconnectedAt: number }; + +export class SessionAwareAdapter extends Adapter { + private readonly maxDisconnectionDuration: number; + + private sessions: Map = new Map(); + private packets: PersistedPacket[] = []; + + constructor(readonly nsp: any) { + super(nsp); + this.maxDisconnectionDuration = + nsp.server.opts.connectionStateRecovery.maxDisconnectionDuration; + + const timer = setInterval(() => { + const threshold = Date.now() - this.maxDisconnectionDuration; + this.sessions.forEach((session, sessionId) => { + const hasExpired = session.disconnectedAt < threshold; + if (hasExpired) { + this.sessions.delete(sessionId); + } + }); + for (let i = this.packets.length - 1; i >= 0; i--) { + const hasExpired = this.packets[i].emittedAt < threshold; + if (hasExpired) { + this.packets.splice(0, i + 1); + break; + } + } + }, 60 * 1000); + // prevents the timer from keeping the process alive + timer.unref(); + } + + override persistSession(session: SessionToPersist) { + (session as SessionWithTimestamp).disconnectedAt = Date.now(); + this.sessions.set(session.pid, session as SessionWithTimestamp); + } + + override restoreSession( + pid: PrivateSessionId, + offset: string + ): Session { + const session = this.sessions.get(pid); + if (!session) { + // the session may have expired + return null; + } + const hasExpired = + session.disconnectedAt + this.maxDisconnectionDuration < Date.now(); + if (hasExpired) { + // the session has expired + this.sessions.delete(pid); + return null; + } + const index = this.packets.findIndex((packet) => packet.id === offset); + if (index === -1) { + // the offset may be too old + return null; + } + const missedPackets = []; + for (let i = index + 1; i < this.packets.length; i++) { + const packet = this.packets[i]; + if (shouldIncludePacket(session.rooms, packet.opts)) { + missedPackets.push(packet.data); + } + } + return { + ...session, + missedPackets, + }; + } + + override broadcast(packet: any, opts: BroadcastOptions) { + const isEventPacket = packet.type === 2; + // packets with acknowledgement are not stored because the acknowledgement function cannot be serialized and + // restored on another server upon reconnection + const withoutAcknowledgement = packet.id === undefined; + const notVolatile = opts.flags?.volatile === undefined; + if (isEventPacket && withoutAcknowledgement && notVolatile) { + const id = yeast(); + // the offset is stored at the end of the data array, so the client knows the ID of the last packet it has + // processed (and the format is backward-compatible) + packet.data.push(id); + this.packets.push({ + id, + opts, + data: packet.data, + emittedAt: Date.now(), + }); + } + super.broadcast(packet, opts); + } +} + +function shouldIncludePacket( + sessionRooms: Room[], + opts: BroadcastOptions +): boolean { + const included = + opts.rooms.size === 0 || sessionRooms.some((room) => opts.rooms.has(room)); + const notExcluded = sessionRooms.every((room) => !opts.except.has(room)); + return included && notExcluded; } diff --git a/packages/websocket/src/socket.io-client/index.ts b/packages/websocket/src/socket.io-client/index.ts index 5456666d..c5c3ea3a 100644 --- a/packages/websocket/src/socket.io-client/index.ts +++ b/packages/websocket/src/socket.io-client/index.ts @@ -1,6 +1,7 @@ import { url } from "./url" import { Manager, ManagerOptions } from "./manager" import { Socket, SocketOptions } from "./socket" +// import debugModule from "debug"; // debug() const debug = require("../debug")("socket.io-client") diff --git a/packages/websocket/src/socket.io-client/manager.ts b/packages/websocket/src/socket.io-client/manager.ts index 3e8e7596..1919cfab 100644 --- a/packages/websocket/src/socket.io-client/manager.ts +++ b/packages/websocket/src/socket.io-client/manager.ts @@ -10,7 +10,7 @@ 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.js" -import { Backoff } from "./contrib/backo2" +import { Backoff } from "./contrib/backo2.js" import { DefaultEventsMap, EventsMap, @@ -470,6 +470,10 @@ export class Manager< this.nsps[nsp] = socket } + if (this._autoConnect) { + socket.connect() + } + return socket } diff --git a/packages/websocket/src/socket.io-client/socket.ts b/packages/websocket/src/socket.io-client/socket.ts index 7ef8d027..c316b2d5 100644 --- a/packages/websocket/src/socket.io-client/socket.ts +++ b/packages/websocket/src/socket.io-client/socket.ts @@ -14,11 +14,69 @@ import { // const debug = debugModule("socket.io-client:socket") // debug() const debug = require("../debug")("socket.io-client") +type PrependTimeoutError = { + [K in keyof T]: T[K] extends (...args: infer Params) => infer Result + ? (err: Error, ...args: Params) => Result + : T[K] +} + +/** + * Utility type to decorate the acknowledgement callbacks with a timeout error. + * + * This is needed because the timeout() flag breaks the symmetry between the sender and the receiver: + * + * @example + * interface Events { + * "my-event": (val: string) => void; + * } + * + * socket.on("my-event", (cb) => { + * cb("123"); // one single argument here + * }); + * + * socket.timeout(1000).emit("my-event", (err, val) => { + * // two arguments there (the "err" argument is not properly typed) + * }); + * + */ +export type DecorateAcknowledgements = { + [K in keyof E]: E[K] extends (...args: infer Params) => infer Result + ? (...args: PrependTimeoutError) => Result + : E[K] +} + +export type Last = T extends [...infer H, infer L] ? L : any +export type AllButLast = T extends [...infer H, infer L] + ? H + : any[] +export type FirstArg = T extends (arg: infer Param) => infer Result + ? Param + : any + export interface SocketOptions { /** * the authentication payload sent when connecting to the Namespace */ - auth: { [key: string]: any } | ((cb: (data: object) => void) => void) + auth?: { [key: string]: any } | ((cb: (data: object) => void) => void) + /** + * The maximum number of retries. Above the limit, the packet will be discarded. + * + * Using `Infinity` means the delivery guarantee is "at-least-once" (instead of "at-most-once" by default), but a + * smaller value like 10 should be sufficient in practice. + */ + retries?: number + /** + * The default timeout in milliseconds used when waiting for an acknowledgement. + */ + ackTimeout?: number +} + +type QueuedPacket = { + id: number + args: unknown[] + flags: Flags + pending: boolean + tryCount: number } /** @@ -39,13 +97,14 @@ interface Flags { compress?: boolean volatile?: boolean timeout?: number + fromQueue?: boolean } export type DisconnectDescription = | Error | { description: string - context?: CloseEvent | XMLHttpRequest + context?: unknown // context should be typed as CloseEvent | XMLHttpRequest, but these types are not available on non-browser platforms } interface SocketReservedEvents { @@ -88,19 +147,33 @@ export class Socket< 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..." - * }); - */ + * 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 + /** + * The session ID used for connection state recovery, which must not be shared (unlike {@link id}). + * + * @private + */ + private _pid: string + + /** + * The offset of the last received packet, which will be sent upon reconnection to allow for the recovery of the connection state. + * + * @private + */ + private _lastOffset: string + /** * Whether the socket is currently connected to the server. * @@ -116,7 +189,11 @@ export class Socket< * }); */ public connected: boolean = false; - + /** + * Whether the connection state was recovered after a temporary disconnection. In that case, any missed packets will + * be transmitted by the server. + */ + public recovered: boolean = false; /** * Credentials that are sent when accessing a namespace. * @@ -143,8 +220,16 @@ export class Socket< * Buffer for packets that will be sent once the socket is connected */ public sendBuffer: Array = []; + /** + * The queue of packets to be sent with retry in case of failure. + * + * Packets are sent one by one, each waiting for the server acknowledgement, in order to guarantee the delivery order. + * @private + */ + private _queue: Array = []; private readonly nsp: string + private readonly _opts: SocketOptions private ids: number = 0; private acks: object = {}; @@ -163,6 +248,7 @@ export class Socket< if (opts && opts.auth) { this.auth = opts.auth } + this._opts = Object.assign({}, opts) if (this.io._autoConnect) this.open() } @@ -296,6 +382,12 @@ export class Socket< } args.unshift(ev) + + if (this._opts.retries && !this.flags.fromQueue && !this.flags.volatile) { + this._addToQueue(args) + return this + } + const packet: any = { type: PacketType.EVENT, data: args, @@ -339,7 +431,7 @@ export class Socket< * @private */ private _registerAckCallback(id: number, ack: Function) { - const timeout = this.flags.timeout + const timeout = this.flags.timeout ?? this._opts.ackTimeout if (timeout === undefined) { this.acks[id] = ack return @@ -365,6 +457,122 @@ export class Socket< } } + /** + * Emits an event and waits for an acknowledgement + * + * @example + * // without timeout + * const response = await socket.emitWithAck("hello", "world"); + * + * // with a specific timeout + * try { + * const response = await socket.timeout(1000).emitWithAck("hello", "world"); + * } catch (err) { + * // the server did not acknowledge the event in the given delay + * } + * + * @return a Promise that will be fulfilled when the server acknowledges the event + */ + public emitWithAck>( + ev: Ev, + ...args: AllButLast> + ): Promise>>> { + // the timeout flag is optional + const withErr = + this.flags.timeout !== undefined || this._opts.ackTimeout !== undefined + return new Promise((resolve, reject) => { + args.push((arg1, arg2) => { + if (withErr) { + return arg1 ? reject(arg1) : resolve(arg2) + } else { + return resolve(arg1) + } + }) + this.emit(ev, ...(args as any[] as EventParams)) + }) + } + + /** + * Add the packet to the queue. + * @param args + * @private + */ + private _addToQueue(args: unknown[]) { + let ack + if (typeof args[args.length - 1] === "function") { + ack = args.pop() + } + + const packet = { + id: this.ids++, + tryCount: 0, + pending: false, + args, + flags: Object.assign({ fromQueue: true }, this.flags), + } + + args.push((err, ...responseArgs) => { + if (packet !== this._queue[0]) { + // the packet has already been acknowledged + return + } + const hasError = err !== null + if (hasError) { + if (packet.tryCount > this._opts.retries) { + debug( + "packet [%d] is discarded after %d tries", + packet.id, + packet.tryCount + ) + this._queue.shift() + if (ack) { + ack(err) + } + } + } else { + debug("packet [%d] was successfully sent", packet.id) + this._queue.shift() + if (ack) { + ack(null, ...responseArgs) + } + } + packet.pending = false + return this._drainQueue() + }) + + this._queue.push(packet) + this._drainQueue() + } + + /** + * Send the first packet of the queue, and wait for an acknowledgement from the server. + * @private + */ + private _drainQueue() { + debug("draining queue") + if (this._queue.length === 0) { + return + } + const packet = this._queue[0] + if (packet.pending) { + debug( + "packet [%d] has already been sent and is waiting for an ack", + packet.id + ) + return + } + packet.pending = true + packet.tryCount++ + debug("sending packet [%d] (try n°%d)", packet.id, packet.tryCount) + const currentId = this.ids + this.ids = packet.id // the same id is reused for consecutive retries, in order to allow deduplication on the server side + this.flags = packet.flags + + // @ts-ignore + this.emit.apply(this, packet.args) + this.ids = currentId // restore offset + } + /** * Sends a packet. * @@ -385,13 +593,28 @@ export class Socket< debug("transport is open - connecting") if (typeof this.auth == "function") { this.auth((data) => { - this.packet({ type: PacketType.CONNECT, data }) + this._sendConnectPacket(data as Record) }) } else { - this.packet({ type: PacketType.CONNECT, data: this.auth }) + this._sendConnectPacket(this.auth) } } + /** + * Sends a CONNECT packet to initiate the Socket.IO session. + * + * @param data + * @private + */ + private _sendConnectPacket(data: Record) { + this.packet({ + type: PacketType.CONNECT, + data: this._pid + ? Object.assign({ pid: this._pid, offset: this._lastOffset }, data) + : data, + }) + } + /** * Called upon engine or manager `error`. * @@ -435,8 +658,7 @@ export class Socket< switch (packet.type) { case PacketType.CONNECT: if (packet.data && packet.data.sid) { - const id = packet.data.sid - this.onconnect(id) + this.onconnect(packet.data.sid, packet.data.pid) } else { this.emitReserved( "connect_error", @@ -503,6 +725,9 @@ export class Socket< } // @ts-ignore super.emit.apply(this, args) + if (this._pid && args.length && typeof args[args.length - 1] === "string") { + this._lastOffset = args[args.length - 1] + } } /** @@ -549,9 +774,11 @@ export class Socket< * * @private */ - private onconnect(id: string): void { + private onconnect(id: string, pid: string) { debug("socket connected with id %s", id) this.id = id + this.recovered = pid && this._pid === pid + this._pid = pid // defined only if connection state recovery is enabled this.connected = true this.emitBuffered() this.emitReserved("connect") @@ -682,7 +909,9 @@ export class Socket< * * @returns self */ - public timeout(timeout: number): this { + public timeout( + timeout: number + ): Socket> { this.flags.timeout = timeout return this } diff --git a/packages/websocket/src/socket.io-parser/binary.ts b/packages/websocket/src/socket.io-parser/binary.ts index 43618715..078bf4db 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.js" +import { isBinary } from "./is-binary.js"; /** * Replaces every Buffer | ArrayBuffer | Blob | File in packet with a numbered placeholder. @@ -9,37 +9,37 @@ import { isBinary } from "./is-binary.js" */ export function deconstructPacket(packet) { - const buffers = [] - const packetData = packet.data - const pack = packet - pack.data = _deconstructPacket(packetData, buffers) - pack.attachments = buffers.length // number of binary 'attachments' - return { packet: pack, buffers: buffers } + const buffers = []; + const packetData = packet.data; + const pack = packet; + pack.data = _deconstructPacket(packetData, buffers); + pack.attachments = buffers.length; // number of binary 'attachments' + return { packet: pack, buffers: buffers }; } function _deconstructPacket(data, buffers) { - if (!data) return data + if (!data) return data; - if (isBinary(data)) { - const placeholder = { _placeholder: true, num: buffers.length } - buffers.push(data) - return placeholder - } else if (Array.isArray(data)) { - const newData = new Array(data.length) - for (let i = 0; i < data.length; i++) { - newData[i] = _deconstructPacket(data[i], buffers) - } - return newData - } else if (typeof data === "object" && !(data instanceof Date)) { - const newData = {} - for (const key in data) { - if (Object.prototype.hasOwnProperty.call(data, key)) { - newData[key] = _deconstructPacket(data[key], buffers) - } - } - return newData + if (isBinary(data)) { + const placeholder = { _placeholder: true, num: buffers.length }; + buffers.push(data); + return placeholder; + } else if (Array.isArray(data)) { + const newData = new Array(data.length); + for (let i = 0; i < data.length; i++) { + newData[i] = _deconstructPacket(data[i], buffers); } - return data + return newData; + } else if (typeof data === "object" && !(data instanceof Date)) { + const newData = {}; + for (const key in data) { + if (Object.prototype.hasOwnProperty.call(data, key)) { + newData[key] = _deconstructPacket(data[key], buffers); + } + } + return newData; + } + return data; } /** @@ -52,35 +52,35 @@ function _deconstructPacket(data, buffers) { */ export function reconstructPacket(packet, buffers) { - packet.data = _reconstructPacket(packet.data, buffers) - packet.attachments = undefined // no longer useful - return packet + packet.data = _reconstructPacket(packet.data, buffers); + delete packet.attachments; // no longer useful + return packet; } function _reconstructPacket(data, buffers) { - if (!data) return data + if (!data) return data; - 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 (Object.prototype.hasOwnProperty.call(data, key)) { - data[key] = _reconstructPacket(data[key], buffers) - } - } + 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 (Object.prototype.hasOwnProperty.call(data, key)) { + data[key] = _reconstructPacket(data[key], buffers); + } + } + } - return data + return data; } diff --git a/packages/websocket/src/socket.io-parser/index.ts b/packages/websocket/src/socket.io-parser/index.ts index 77e724c1..7af09358 100644 --- a/packages/websocket/src/socket.io-parser/index.ts +++ b/packages/websocket/src/socket.io-parser/index.ts @@ -1,10 +1,9 @@ -import { Emitter } from "@socket.io/component-emitter" -import { deconstructPacket, reconstructPacket } from "./binary.js" -import { isBinary, hasBinary } from "./is-binary.js" +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 = debugModule("socket.io-parser") // debug() -const debug = require("../debug")("socket.io-client") +const debug = require("../debug")("socket.io-parser"); /** * Protocol version. @@ -12,24 +11,24 @@ const debug = require("../debug")("socket.io-client") * @public */ -export const protocol: number = 5 +export const protocol: number = 5; export enum PacketType { - CONNECT, - DISCONNECT, - EVENT, - ACK, - CONNECT_ERROR, - BINARY_EVENT, - BINARY_ACK, + CONNECT, + DISCONNECT, + EVENT, + ACK, + CONNECT_ERROR, + BINARY_EVENT, + BINARY_ACK, } export interface Packet { - type: PacketType - nsp: string - data?: any - id?: number - attachments?: number + type: PacketType; + nsp: string; + data?: any; + id?: number; + attachments?: number; } /** @@ -37,87 +36,91 @@ 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. - * - * @param {Object} obj - packet object - */ - public encode(obj: Packet) { - debug("encoding packet %j", obj) + /** + * 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. + * + * @param {Object} obj - packet object + */ + public encode(obj: Packet) { + debug("encoding packet %j", obj); - if (obj.type === PacketType.EVENT || obj.type === PacketType.ACK) { - if (hasBinary(obj)) { - obj.type = - obj.type === PacketType.EVENT - ? PacketType.BINARY_EVENT - : PacketType.BINARY_ACK - return this.encodeAsBinary(obj) - } - } - return [this.encodeAsString(obj)] + if (obj.type === PacketType.EVENT || obj.type === PacketType.ACK) { + if (hasBinary(obj)) { + return this.encodeAsBinary({ + type: + obj.type === PacketType.EVENT + ? PacketType.BINARY_EVENT + : PacketType.BINARY_ACK, + nsp: obj.nsp, + data: obj.data, + id: obj.id, + }); + } + } + return [this.encodeAsString(obj)]; + } + + /** + * Encode packet as string. + */ + + private encodeAsString(obj: Packet) { + // first is type + let str = "" + obj.type; + + // attachments if we have them + if ( + obj.type === PacketType.BINARY_EVENT || + obj.type === PacketType.BINARY_ACK + ) { + str += obj.attachments + "-"; } - /** - * Encode packet as string. - */ - - private encodeAsString(obj: Packet) { - // first is type - let str = "" + obj.type - - // attachments if we have them - if ( - obj.type === PacketType.BINARY_EVENT || - obj.type === PacketType.BINARY_ACK - ) { - str += obj.attachments + "-" - } - - // if we have a namespace other than `/` - // we append it followed by a comma `,` - if (obj.nsp && "/" !== obj.nsp) { - str += obj.nsp + "," - } - - // immediately followed by the id - if (null != obj.id) { - str += obj.id - } - - // json data - if (null != obj.data) { - str += JSON.stringify(obj.data, this.replacer) - } - - debug("encoded %j as %s", obj, str) - return str + // if we have a namespace other than `/` + // we append it followed by a comma `,` + if (obj.nsp && "/" !== obj.nsp) { + str += obj.nsp + ","; } - /** - * Encode packet as 'buffer sequence' by removing blobs, and - * deconstructing packet into object with placeholders and - * a list of buffers. - */ - - private encodeAsBinary(obj: Packet) { - const deconstruction = deconstructPacket(obj) - const pack = this.encodeAsString(deconstruction.packet) - const buffers = deconstruction.buffers - - buffers.unshift(pack) // add packet info to beginning of data list - return buffers // write all the buffers + // immediately followed by the id + if (null != obj.id) { + str += obj.id; } + + // json data + if (null != obj.data) { + str += JSON.stringify(obj.data, this.replacer); + } + + debug("encoded %j as %s", obj, str); + return str; + } + + /** + * Encode packet as 'buffer sequence' by removing blobs, and + * deconstructing packet into object with placeholders and + * a list of buffers. + */ + + private encodeAsBinary(obj: Packet) { + const deconstruction = deconstructPacket(obj); + const pack = this.encodeAsString(deconstruction.packet); + const buffers = deconstruction.buffers; + + buffers.unshift(pack); // add packet info to beginning of data list + return buffers; // write all the buffers + } } interface DecoderReservedEvents { - decoded: (packet: Packet) => void + decoded: (packet: Packet) => void; } /** @@ -126,168 +129,168 @@ interface DecoderReservedEvents { * @return {Object} decoder */ export class Decoder extends Emitter<{}, {}, DecoderReservedEvents> { - private reconstructor: BinaryReconstructor + private reconstructor: BinaryReconstructor; - /** - * Decoder constructor - * - * @param {function} reviver - custom reviver to pass down to JSON.stringify - */ - constructor(private reviver?: (this: any, key: string, value: any) => any) { - super() + /** + * Decoder constructor + * + * @param {function} reviver - custom reviver to pass down to JSON.stringify + */ + constructor(private reviver?: (this: any, key: string, value: any) => any) { + super(); + } + + /** + * Decodes an encoded packet string into packet JSON. + * + * @param {String} obj - encoded packet + */ + + 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); + const isBinaryEvent = packet.type === PacketType.BINARY_EVENT; + if (isBinaryEvent || packet.type === PacketType.BINARY_ACK) { + packet.type = isBinaryEvent ? PacketType.EVENT : PacketType.ACK; + // binary packet's json + this.reconstructor = new BinaryReconstructor(packet); + + // no attachments, labeled binary but no binary data to follow + if (packet.attachments === 0) { + super.emitReserved("decoded", packet); + } + } else { + // non-binary full packet + super.emitReserved("decoded", packet); + } + } else if (isBinary(obj) || obj.base64) { + // raw binary data + if (!this.reconstructor) { + throw new Error("got binary data when not reconstructing a packet"); + } else { + packet = this.reconstructor.takeBinaryData(obj); + if (packet) { + // received final buffer + this.reconstructor = null; + super.emitReserved("decoded", packet); + } + } + } else { + throw new Error("Unknown type: " + obj); + } + } + + /** + * Decode a packet String (JSON data) + * + * @param {String} str + * @return {Object} packet + */ + private decodeString(str): Packet { + let i = 0; + // look up type + const p: any = { + type: Number(str.charAt(0)), + }; + + if (PacketType[p.type] === undefined) { + throw new Error("unknown packet type " + p.type); } - /** - * Decodes an encoded packet string into packet JSON. - * - * @param {String} obj - encoded packet - */ - - 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 || - packet.type === PacketType.BINARY_ACK - ) { - // binary packet's json - this.reconstructor = new BinaryReconstructor(packet) - - // no attachments, labeled binary but no binary data to follow - if (packet.attachments === 0) { - super.emitReserved("decoded", packet) - } - } else { - // non-binary full packet - super.emitReserved("decoded", packet) - } - } else if (isBinary(obj) || obj.base64) { - // raw binary data - if (!this.reconstructor) { - throw new Error("got binary data when not reconstructing a packet") - } else { - packet = this.reconstructor.takeBinaryData(obj) - if (packet) { - // received final buffer - this.reconstructor = null - super.emitReserved("decoded", packet) - } - } - } else { - throw new Error("Unknown type: " + obj) - } + // look up attachments if type binary + if ( + p.type === PacketType.BINARY_EVENT || + p.type === PacketType.BINARY_ACK + ) { + const start = i + 1; + while (str.charAt(++i) !== "-" && i != str.length) {} + const buf = str.substring(start, i); + if (buf != Number(buf) || str.charAt(i) !== "-") { + throw new Error("Illegal attachments"); + } + p.attachments = Number(buf); } - /** - * Decode a packet String (JSON data) - * - * @param {String} str - * @return {Object} packet - */ - private decodeString(str): Packet { - let i = 0 - // look up type - const p: any = { - type: Number(str.charAt(0)), - } - - if (PacketType[p.type] === undefined) { - throw new Error("unknown packet type " + p.type) - } - - // look up attachments if type binary - if ( - p.type === PacketType.BINARY_EVENT || - p.type === PacketType.BINARY_ACK - ) { - const start = i + 1 - while (str.charAt(++i) !== "-" && i != str.length) { } - const buf = str.substring(start, i) - if (buf != Number(buf) || str.charAt(i) !== "-") { - throw new Error("Illegal attachments") - } - p.attachments = Number(buf) - } - - // look up namespace (if any) - if ("/" === str.charAt(i + 1)) { - const start = i + 1 - while (++i) { - const c = str.charAt(i) - if ("," === c) break - if (i === str.length) break - } - p.nsp = str.substring(start, i) - } else { - p.nsp = "/" - } - - // look up id - const next = str.charAt(i + 1) - if ("" !== next && Number(next) == next) { - const start = i + 1 - while (++i) { - const c = str.charAt(i) - if (null == c || Number(c) != c) { - --i - break - } - if (i === str.length) break - } - p.id = Number(str.substring(start, i + 1)) - } - - // look up json data - if (str.charAt(++i)) { - const payload = this.tryParse(str.substr(i)) - if (Decoder.isPayloadValid(p.type, payload)) { - p.data = payload - } else { - throw new Error("invalid payload") - } - } - - debug("decoded %s as %j", str, p) - return p + // look up namespace (if any) + if ("/" === str.charAt(i + 1)) { + const start = i + 1; + while (++i) { + const c = str.charAt(i); + if ("," === c) break; + if (i === str.length) break; + } + p.nsp = str.substring(start, i); + } else { + p.nsp = "/"; } - private tryParse(str) { - try { - return JSON.parse(str, this.reviver) - } catch (e) { - return false + // look up id + const next = str.charAt(i + 1); + if ("" !== next && Number(next) == next) { + const start = i + 1; + while (++i) { + const c = str.charAt(i); + if (null == c || Number(c) != c) { + --i; + break; } + if (i === str.length) break; + } + p.id = Number(str.substring(start, i + 1)); } - private static isPayloadValid(type: PacketType, payload: any): boolean { - switch (type) { - case PacketType.CONNECT: - return typeof payload === "object" - case PacketType.DISCONNECT: - return payload === undefined - case PacketType.CONNECT_ERROR: - return typeof payload === "string" || typeof payload === "object" - case PacketType.EVENT: - case PacketType.BINARY_EVENT: - return Array.isArray(payload) && payload.length > 0 - case PacketType.ACK: - case PacketType.BINARY_ACK: - return Array.isArray(payload) - } + // look up json data + if (str.charAt(++i)) { + const payload = this.tryParse(str.substr(i)); + if (Decoder.isPayloadValid(p.type, payload)) { + p.data = payload; + } else { + throw new Error("invalid payload"); + } } - /** - * Deallocates a parser's resources - */ - public destroy() { - if (this.reconstructor) { - this.reconstructor.finishedReconstruction() - } + 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: + return typeof payload === "object"; + case PacketType.DISCONNECT: + return payload === undefined; + case PacketType.CONNECT_ERROR: + return typeof payload === "string" || typeof payload === "object"; + case PacketType.EVENT: + case PacketType.BINARY_EVENT: + return Array.isArray(payload) && payload.length > 0; + case PacketType.ACK: + case PacketType.BINARY_ACK: + return Array.isArray(payload); + } + } + + /** + * Deallocates a parser's resources + */ + public destroy() { + if (this.reconstructor) { + this.reconstructor.finishedReconstruction(); + this.reconstructor = null; + } + } } /** @@ -300,37 +303,37 @@ export class Decoder extends Emitter<{}, {}, DecoderReservedEvents> { */ class BinaryReconstructor { - private reconPack - private buffers: Array = []; + private reconPack; + private buffers: Array = []; - constructor(readonly packet: Packet) { - this.reconPack = packet - } + constructor(readonly packet: Packet) { + this.reconPack = packet; + } - /** - * Method to be called when binary data received from connection - * after a BINARY_EVENT packet. - * - * @param {Buffer | ArrayBuffer} binData - the raw binary data received - * @return {null | Object} returns null if more binary data is expected or - * a reconstructed packet object if all buffers have been received. - */ - public takeBinaryData(binData) { - this.buffers.push(binData) - if (this.buffers.length === this.reconPack.attachments) { - // done with buffer list - const packet = reconstructPacket(this.reconPack, this.buffers) - this.finishedReconstruction() - return packet - } - return null + /** + * Method to be called when binary data received from connection + * after a BINARY_EVENT packet. + * + * @param {Buffer | ArrayBuffer} binData - the raw binary data received + * @return {null | Object} returns null if more binary data is expected or + * a reconstructed packet object if all buffers have been received. + */ + public takeBinaryData(binData) { + this.buffers.push(binData); + if (this.buffers.length === this.reconPack.attachments) { + // done with buffer list + const packet = reconstructPacket(this.reconPack, this.buffers); + this.finishedReconstruction(); + return packet; } + return null; + } - /** - * Cleans up binary packet reconstruction variables. - */ - public finishedReconstruction() { - this.reconPack = null - this.buffers = [] - } + /** + * Cleans up binary packet reconstruction variables. + */ + public finishedReconstruction() { + this.reconPack = null; + this.buffers = []; + } } diff --git a/packages/websocket/src/socket.io-parser/is-binary.ts b/packages/websocket/src/socket.io-parser/is-binary.ts index ace158d9..0ec64176 100644 --- a/packages/websocket/src/socket.io-parser/is-binary.ts +++ b/packages/websocket/src/socket.io-parser/is-binary.ts @@ -1,20 +1,20 @@ -const withNativeArrayBuffer: boolean = typeof ArrayBuffer === "function" +const withNativeArrayBuffer: boolean = typeof ArrayBuffer === "function"; const isView = (obj: any) => { - return typeof ArrayBuffer.isView === "function" - ? ArrayBuffer.isView(obj) - : obj.buffer instanceof ArrayBuffer -} + return typeof ArrayBuffer.isView === "function" + ? ArrayBuffer.isView(obj) + : obj.buffer instanceof ArrayBuffer; +}; -const toString = Object.prototype.toString +const toString = Object.prototype.toString; const withNativeBlob = - typeof Blob === "function" || - (typeof Blob !== "undefined" && - toString.call(Blob) === "[object BlobConstructor]") + typeof Blob === "function" || + (typeof Blob !== "undefined" && + toString.call(Blob) === "[object BlobConstructor]"); const withNativeFile = - typeof File === "function" || - (typeof File !== "undefined" && - toString.call(File) === "[object FileConstructor]") + 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. @@ -23,44 +23,44 @@ const withNativeFile = */ export function isBinary(obj: any) { - return ( - (withNativeArrayBuffer && (obj instanceof ArrayBuffer || isView(obj))) || - (withNativeBlob && obj instanceof Blob) || - (withNativeFile && obj instanceof File) - ) + return ( + (withNativeArrayBuffer && (obj instanceof ArrayBuffer || isView(obj))) || + (withNativeBlob && obj instanceof Blob) || + (withNativeFile && obj instanceof File) + ); } export function hasBinary(obj: any, toJSON?: boolean) { - if (!obj || typeof obj !== "object") { - return false - } + if (!obj || typeof obj !== "object") { + return false; + } - if (Array.isArray(obj)) { - for (let i = 0, l = obj.length; i < l; i++) { - if (hasBinary(obj[i])) { - return true - } - } - return false + if (Array.isArray(obj)) { + for (let i = 0, l = obj.length; i < l; i++) { + if (hasBinary(obj[i])) { + return true; + } } + return false; + } - if (isBinary(obj)) { - return true + if (isBinary(obj)) { + return true; + } + + if ( + obj.toJSON && + typeof obj.toJSON === "function" && + arguments.length === 1 + ) { + return hasBinary(obj.toJSON(), true); + } + + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key) && hasBinary(obj[key])) { + return true; } + } - if ( - obj.toJSON && - typeof obj.toJSON === "function" && - arguments.length === 1 - ) { - return hasBinary(obj.toJSON(), true) - } - - for (const key in obj) { - if (Object.prototype.hasOwnProperty.call(obj, key) && hasBinary(obj[key])) { - return true - } - } - - return false + return false; } diff --git a/packages/websocket/src/socket.io/broadcast-operator.ts b/packages/websocket/src/socket.io/broadcast-operator.ts index 55e8ee36..0991b596 100644 --- a/packages/websocket/src/socket.io/broadcast-operator.ts +++ b/packages/websocket/src/socket.io/broadcast-operator.ts @@ -1,489 +1,561 @@ -// import type { BroadcastFlags, Room, SocketId } from "socket.io-adapter" -import type { BroadcastFlags, Room, SocketId } from "../socket.io-adapter" -import { Handshake, RESERVED_EVENTS, Socket } from "./socket" -// import { PacketType } from "socket.io-parser" -import { PacketType } from "../socket.io-parser" -// import type { Adapter } from "socket.io-adapter" -import type { Adapter } from "../socket.io-adapter" +import type { BroadcastFlags, Room, SocketId } from "../socket.io-adapter"; +import { Handshake, RESERVED_EVENTS, Socket } from "./socket"; +import { PacketType } from "../socket.io-parser"; +import type { Adapter } from "../socket.io-adapter"; import type { - EventParams, - EventNames, - EventsMap, - TypedEventBroadcaster, -} from "./typed-events" + EventParams, + EventNames, + EventsMap, + TypedEventBroadcaster, + DecorateAcknowledgements, + DecorateAcknowledgementsWithTimeoutAndMultipleResponses, + AllButLast, + Last, + SecondArg, +} from "./typed-events"; export class BroadcastOperator - implements TypedEventBroadcaster + implements TypedEventBroadcaster { - constructor( - private readonly adapter: Adapter, - private readonly rooms: Set = new Set(), - private readonly exceptRooms: Set = new Set(), - private readonly flags: BroadcastFlags = {} - ) { } + constructor( + private readonly adapter: Adapter, + private readonly rooms: Set = new Set(), + private readonly exceptRooms: Set = new Set(), + private readonly flags: BroadcastFlags & { + expectSingleResponse?: boolean; + } = {} + ) {} - /** - * Targets a room when emitting. - * - * @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[]) { - const rooms = new Set(this.rooms) - if (Array.isArray(room)) { - room.forEach((r) => rooms.add(r)) + /** + * Targets a room when emitting. + * + * @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[]) { + const rooms = new Set(this.rooms); + if (Array.isArray(room)) { + room.forEach((r) => rooms.add(r)); + } else { + rooms.add(room); + } + return new BroadcastOperator( + this.adapter, + rooms, + this.exceptRooms, + this.flags + ); + } + + /** + * Targets a room when emitting. Similar to `to()`, but might feel clearer in some cases: + * + * @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[]) { + return this.to(room); + } + + /** + * Excludes a room when emitting. + * + * @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[]) { + const exceptRooms = new Set(this.exceptRooms); + if (Array.isArray(room)) { + room.forEach((r) => exceptRooms.add(r)); + } else { + exceptRooms.add(room); + } + return new BroadcastOperator( + this.adapter, + this.rooms, + exceptRooms, + this.flags + ); + } + + /** + * Sets the compress flag. + * + * @example + * io.compress(false).emit("hello"); + * + * @param compress - if `true`, compresses the sending data + * @return a new BroadcastOperator instance + */ + public compress(compress: boolean) { + const flags = Object.assign({}, this.flags, { compress }); + return new BroadcastOperator( + this.adapter, + this.rooms, + this.exceptRooms, + flags + ); + } + + /** + * 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.volatile.emit("hello"); // the clients may or may not receive it + * + * @return a new BroadcastOperator instance + */ + public get volatile() { + const flags = Object.assign({}, this.flags, { volatile: true }); + return new BroadcastOperator( + this.adapter, + this.rooms, + this.exceptRooms, + flags + ); + } + + /** + * Sets a modifier for a subsequent event emission that the event data will only be broadcast to the current node. + * + * @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() { + const flags = Object.assign({}, this.flags, { local: true }); + 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< + DecorateAcknowledgementsWithTimeoutAndMultipleResponses, + SocketData + >(this.adapter, this.rooms, this.exceptRooms, flags); + } + + /** + * 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 emit>( + ev: Ev, + ...args: EventParams + ): boolean { + if (RESERVED_EVENTS.has(ev)) { + throw new Error(`"${String(ev)}" is a reserved event name`); + } + // set up packet object + const data = [ev, ...args]; + const packet = { + type: PacketType.EVENT, + data: data, + }; + + 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; + } + + 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"), + this.flags.expectSingleResponse ? null : 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, + this.flags.expectSingleResponse ? 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; + } + + /** + * Emits an event and waits for an acknowledgement from all clients. + * + * @example + * try { + * const responses = await io.timeout(1000).emitWithAck("some-event"); + * console.log(responses); // one response per client + * } catch (e) { + * // some clients did not acknowledge the event in the given delay + * } + * + * @return a Promise that will be fulfilled when all clients have acknowledged the event + */ + public emitWithAck>( + ev: Ev, + ...args: AllButLast> + ): Promise>>> { + return new Promise((resolve, reject) => { + args.push((err, responses) => { + if (err) { + err.responses = responses; + return reject(err); } else { - rooms.add(room) + return resolve(responses); } - return new BroadcastOperator( - this.adapter, - rooms, - this.exceptRooms, - this.flags - ) + }); + this.emit(ev, ...(args as any[] as EventParams)); + }); + } + + /** + * Gets a list of clients. + * + * @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) { + throw new Error( + "No adapter for this namespace, are you trying to get the list of clients of a dynamic namespace?" + ); } + return this.adapter.sockets(this.rooms); + } - /** - * Targets a room when emitting. Similar to `to()`, but might feel clearer in some cases: - * - * @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[]) { - return this.to(room) - } + /** + * Returns the matching socket instances. This method works across a cluster of several Socket.IO servers. + * + * 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.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; + } else { + return new RemoteSocket( + this.adapter, + socket as SocketDetails + ); + } + }); + }); + } - /** - * Excludes a room when emitting. - * - * @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[]) { - const exceptRooms = new Set(this.exceptRooms) - if (Array.isArray(room)) { - room.forEach((r) => exceptRooms.add(r)) - } else { - exceptRooms.add(room) - } - return new BroadcastOperator( - this.adapter, - this.rooms, - exceptRooms, - this.flags - ) - } + /** + * 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 + * + * // 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] + ); + } - /** - * Sets the compress flag. - * - * @example - * io.compress(false).emit("hello"); - * - * @param compress - if `true`, compresses the sending data - * @return a new BroadcastOperator instance - */ - public compress(compress: boolean) { - const flags = Object.assign({}, this.flags, { compress }) - return new BroadcastOperator( - this.adapter, - this.rooms, - this.exceptRooms, - flags - ) - } + /** + * 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 + * // 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] + ); + } - /** - * 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.volatile.emit("hello"); // the clients may or may not receive it - * - * @return a new BroadcastOperator instance - */ - public get volatile() { - const flags = Object.assign({}, this.flags, { volatile: true }) - return new BroadcastOperator( - this.adapter, - this.rooms, - this.exceptRooms, - flags - ) - } - - /** - * Sets a modifier for a subsequent event emission that the event data will only be broadcast to the current node. - * - * @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() { - const flags = Object.assign({}, this.flags, { local: true }) - 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, - flags - ) - } - - /** - * 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 emit>( - ev: Ev, - ...args: EventParams - ): boolean { - if (RESERVED_EVENTS.has(ev)) { - throw new Error(`"${String(ev)}" is a reserved event name`) - } - // set up packet object - const data = [ev, ...args] - const packet = { - type: PacketType.EVENT, - data: data, - } - - 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 - } - - 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 - } - - /** - * Gets a list of clients. - * - * @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) { - throw new Error( - "No adapter for this namespace, are you trying to get the list of clients of a dynamic namespace?" - ) - } - return this.adapter.sockets(this.rooms) - } - - /** - * Returns the matching socket instances. This method works across a cluster of several Socket.IO servers. - * - * 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.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 - } else { - return new RemoteSocket( - this.adapter, - socket as SocketDetails - ) - } - }) - }) - } - - /** - * 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 - * - * // 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. - * - * 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. - * - * 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 disconnectSockets(close: boolean = false): void { - this.adapter.disconnectSockets( - { - rooms: this.rooms, - except: this.exceptRooms, - flags: this.flags, - }, - close - ) - } + /** + * 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 disconnectSockets(close: boolean = false): void { + this.adapter.disconnectSockets( + { + rooms: this.rooms, + except: this.exceptRooms, + flags: this.flags, + }, + close + ); + } } /** * Format of the data when the Socket instance exists on another Socket.IO server */ interface SocketDetails { - id: SocketId - handshake: Handshake - rooms: Room[] - data: SocketData + id: SocketId; + handshake: Handshake; + rooms: Room[]; + data: SocketData; } /** * Expose of subset of the attributes and methods of the Socket class */ export class RemoteSocket - implements TypedEventBroadcaster + implements TypedEventBroadcaster { - public readonly id: SocketId - public readonly handshake: Handshake - public readonly rooms: Set - public readonly data: SocketData + public readonly id: SocketId; + public readonly handshake: Handshake; + public readonly rooms: Set; + public readonly data: SocketData; - private readonly operator: BroadcastOperator + private readonly operator: BroadcastOperator; - 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]) - ) - } + 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]), + new Set(), + { + expectSingleResponse: true, // so that remoteSocket.emit() with acknowledgement behaves like socket.emit() + } + ); + } - public emit>( - ev: Ev, - ...args: EventParams - ): boolean { - return this.operator.emit(ev, ...args) - } + /** + * Adds a timeout in milliseconds for the next operation. + * + * @example + * const sockets = await io.fetchSockets(); + * + * for (const socket of sockets) { + * if (someCondition) { + * socket.timeout(1000).emit("some-event", (err) => { + * if (err) { + * // the client did not acknowledge the event in the given delay + * } + * }); + * } + * } + * + * // note: if possible, using a room instead of looping over all sockets is preferable + * io.timeout(1000).to(someConditionRoom).emit("some-event", (err, responses) => { + * // ... + * }); + * + * @param timeout + */ + public timeout(timeout: number) { + return this.operator.timeout(timeout) as BroadcastOperator< + DecorateAcknowledgements, + SocketData + >; + } - /** - * Joins a room. - * - * @param {String|Array} room - room or array of rooms - */ - public join(room: Room | Room[]): void { - return this.operator.socketsJoin(room) - } + public emit>( + ev: Ev, + ...args: EventParams + ): boolean { + return this.operator.emit(ev, ...args); + } - /** - * Leaves a room. - * - * @param {String} room - */ - public leave(room: Room): void { - return this.operator.socketsLeave(room) - } + /** + * Joins a room. + * + * @param {String|Array} room - room or array of rooms + */ + public join(room: Room | Room[]): void { + return this.operator.socketsJoin(room); + } - /** - * Disconnects this client. - * - * @param {Boolean} close - if `true`, closes the underlying connection - * @return {Socket} self - */ - public disconnect(close = false): this { - this.operator.disconnectSockets(close) - return this - } + /** + * Leaves a room. + * + * @param {String} room + */ + public leave(room: Room): void { + return this.operator.socketsLeave(room); + } + + /** + * Disconnects this client. + * + * @param {Boolean} close - if `true`, closes the underlying connection + * @return {Socket} self + */ + public disconnect(close = false): this { + this.operator.disconnectSockets(close); + return this; + } } diff --git a/packages/websocket/src/socket.io/client.ts b/packages/websocket/src/socket.io/client.ts index 932dc998..68a30991 100644 --- a/packages/websocket/src/socket.io/client.ts +++ b/packages/websocket/src/socket.io/client.ts @@ -1,352 +1,353 @@ -// import { Decoder, Encoder, Packet, PacketType } from "socket.io-parser" -import { Decoder, Encoder, Packet, PacketType } from "../socket.io-parser" +import { Decoder, Encoder, Packet, PacketType } from "../socket.io-parser"; // import debugModule = require("debug") -import url = require("url") +import url = require("url"); // import type { IncomingMessage } from "http" -import type { Server } from "./index" -import type { Namespace } from "./namespace" -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 RawSocket } from '../engine.io/socket' +import type { Server } from "./index"; +import type { Namespace } from "./namespace"; +import type { EventsMap } from "./typed-events"; +import type { Socket } from "./socket"; +import type { SocketId } from "../socket.io-adapter"; +import type { Socket as RawSocket } from "../engine.io/socket"; -// const debug = debugModule("socket.io:client"); -const debug = require('../debug')("socket.io:client") +const debug = require("../debug")("socket.io:client"); interface WriteOptions { - compress?: boolean - volatile?: boolean - preEncoded?: boolean - wsPreEncoded?: string + compress?: boolean; + volatile?: boolean; + preEncoded?: boolean; + wsPreEncoded?: string; } type CloseReason = - | "transport error" - | "transport close" - | "forced close" - | "ping timeout" - | "parse error" + | "transport error" + | "transport close" + | "forced close" + | "ping timeout" + | "parse error"; export class Client< - ListenEvents extends EventsMap, - EmitEvents extends EventsMap, - ServerSideEvents extends EventsMap, - SocketData = any + ListenEvents extends EventsMap, + EmitEvents extends EventsMap, + ServerSideEvents extends EventsMap, + SocketData = any > { - public readonly conn: RawSocket + public readonly conn: RawSocket; - private readonly id: string - private readonly server: Server< - ListenEvents, - EmitEvents, - ServerSideEvents, - SocketData - > - private readonly encoder: Encoder - private readonly decoder: Decoder - private sockets: Map< - SocketId, - Socket - > = new Map(); - private nsps: Map< - string, - Socket - > = new Map(); - private connectTimeout?: NodeJS.Timeout + private readonly id: string; + private readonly server: Server< + ListenEvents, + EmitEvents, + ServerSideEvents, + SocketData + >; + private readonly encoder: Encoder; + private readonly decoder: Decoder; + private sockets: Map< + SocketId, + Socket + > = new Map(); + private nsps: Map< + string, + Socket + > = new Map(); + private connectTimeout?: NodeJS.Timeout; - /** - * Client constructor. - * - * @param server instance - * @param conn - * @package - */ - constructor( - server: Server, - conn: any + /** + * Client constructor. + * + * @param server instance + * @param conn + * @package + */ + constructor( + server: Server, + conn: any + ) { + this.server = server; + this.conn = conn; + this.encoder = server.encoder; + this.decoder = new server._parser.Decoder(); + this.id = conn.id; + this.setup(); + } + + /** + * @return the reference to the request that originated the Engine.IO connection + * + * @public + */ + // public get request(): IncomingMessage { + public get request(): any { + return this.conn.request; + } + + /** + * Sets up event listeners. + * + * @private + */ + private setup() { + this.onclose = this.onclose.bind(this); + this.ondata = this.ondata.bind(this); + this.onerror = this.onerror.bind(this); + this.ondecoded = this.ondecoded.bind(this); + + // @ts-ignore + this.decoder.on("decoded", this.ondecoded); + this.conn.on("data", this.ondata); + this.conn.on("error", this.onerror); + this.conn.on("close", this.onclose); + + this.connectTimeout = setTimeout(() => { + if (this.nsps.size === 0) { + debug("no namespace joined yet, close the client"); + this.close(); + } else { + debug("the client has already joined a namespace, nothing to do"); + } + }, this.server._connectTimeout); + } + + /** + * Connects a client to a namespace. + * + * @param {String} name - the namespace + * @param {Object} auth - the auth parameters + * @private + */ + private connect(name: string, auth: Record = {}): void { + if (this.server._nsps.has(name)) { + debug("connecting to namespace %s", name); + return this.doConnect(name, auth); + } + + this.server._checkNamespace( + name, + auth, + ( + dynamicNspName: + | Namespace + | false + ) => { + if (dynamicNspName) { + this.doConnect(name, auth); + } else { + debug("creation of namespace %s was denied", name); + this._packet({ + type: PacketType.CONNECT_ERROR, + nsp: name, + data: { + message: "Invalid namespace", + }, + }); + } + } + ); + } + + /** + * Connects a client to a namespace. + * + * @param name - the namespace + * @param {Object} auth - the auth parameters + * + * @private + */ + private doConnect(name: string, auth: Record): void { + const nsp = this.server.of(name); + + // @java-patch multi thread need direct callback socket + const socket = nsp._add(this, auth, (socket) => { + this.sockets.set(socket.id, socket); + this.nsps.set(nsp.name, socket); + + if (this.connectTimeout) { + clearTimeout(this.connectTimeout); + this.connectTimeout = undefined; + } + }); + } + + /** + * Disconnects from all namespaces and closes transport. + * + * @private + */ + _disconnect(): void { + for (const socket of this.sockets.values()) { + socket.disconnect(); + } + this.sockets.clear(); + this.close(); + } + + /** + * Removes a socket. Called by each `Socket`. + * + * @private + */ + _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 { + debug("ignoring remove for %s", socket.id); + } + } + + /** + * Closes the underlying connection. + * + * @private + */ + private close(): void { + if ("open" === this.conn.readyState) { + 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 + */ + _packet(packet: Packet | any[], opts: WriteOptions = {}): void { + if (this.conn.readyState !== "open") { + 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); + this.writeToEngine(encodedPackets, opts); + } + + private writeToEngine( + encodedPackets: Array, + opts: WriteOptions + ): void { + if (opts.volatile && !this.conn.transport.writable) { + debug( + "volatile packet is discarded since the transport is not currently writable" + ); + return; + } + const packets = Array.isArray(encodedPackets) + ? encodedPackets + : [encodedPackets]; + for (const encodedPacket of packets) { + this.conn.write(encodedPacket, opts); + } + } + + /** + * Called with incoming transport data. + * + * @private + */ + private ondata(data): void { + // try/catch is needed for protocol violations (GH-1880) + try { + this.decoder.add(data); + } catch (e) { + debug("invalid packet format"); + this.onerror(e); + } + } + + /** + * Called when parser fully decodes a packet. + * + * @private + */ + private ondecoded(packet: Packet): void { + let namespace: string; + let authPayload: Record; + if (this.conn.protocol === 3) { + const parsed = url.parse(packet.nsp, true); + namespace = parsed.pathname!; + authPayload = parsed.query; + } else { + 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 ) { - this.server = server - this.conn = conn - this.encoder = server.encoder - this.decoder = new server._parser.Decoder() - this.id = conn.id - this.setup() + process.nextTick(function () { + socket._onpacket(packet); + }); + } else { + debug("invalid state (packet type: %s)", packet.type); + this.close(); } + } - /** - * @return the reference to the request that originated the Engine.IO connection - * - * @public - */ - // public get request(): IncomingMessage { - public get request(): any { - return this.conn.request + /** + * Handles an error. + * + * @param {Object} err object + * @private + */ + private onerror(err): void { + for (const socket of this.sockets.values()) { + socket._onerror(err); } + this.conn.close(); + } - /** - * Sets up event listeners. - * - * @private - */ - private setup() { - this.onclose = this.onclose.bind(this) - this.ondata = this.ondata.bind(this) - this.onerror = this.onerror.bind(this) - this.ondecoded = this.ondecoded.bind(this) + /** + * Called upon transport close. + * + * @param reason + * @param description + * @private + */ + private onclose( + reason: CloseReason | "forced server close", + description?: any + ): void { + debug("client close with reason %s", reason); - // @ts-ignore - this.decoder.on("decoded", this.ondecoded) - this.conn.on("data", this.ondata) - this.conn.on("error", this.onerror) - this.conn.on("close", this.onclose) + // ignore a potential subsequent `close` event + this.destroy(); - this.connectTimeout = setTimeout(() => { - if (this.nsps.size === 0) { - debug("no namespace joined yet, close the client") - this.close() - } else { - debug("the client has already joined a namespace, nothing to do") - } - }, this.server._connectTimeout) + // `nsps` and `sockets` are cleaned up seamlessly + for (const socket of this.sockets.values()) { + socket._onclose(reason, description); } + this.sockets.clear(); - /** - * Connects a client to a namespace. - * - * @param {String} name - the namespace - * @param {Object} auth - the auth parameters - * @private - */ - private connect(name: string, auth: object = {}): void { - if (this.server._nsps.has(name)) { - debug("connecting to namespace %s", name) - return this.doConnect(name, auth) - } + this.decoder.destroy(); // clean up decoder + } - this.server._checkNamespace( - name, - auth, - ( - dynamicNspName: - | Namespace - | false - ) => { - if (dynamicNspName) { - this.doConnect(name, auth) - } else { - debug("creation of namespace %s was denied", name) - this._packet({ - type: PacketType.CONNECT_ERROR, - nsp: name, - data: { - message: "Invalid namespace", - }, - }) - } - } - ) - } - - /** - * Connects a client to a namespace. - * - * @param name - the namespace - * @param {Object} auth - the auth parameters - * - * @private - */ - private doConnect(name: string, auth: object): void { - const nsp = this.server.of(name) - - // @java-patch multi thread need direct callback socket - const socket = nsp._add(this, auth, (socket) => { - this.sockets.set(socket.id, socket) - this.nsps.set(nsp.name, socket) - - if (this.connectTimeout) { - clearTimeout(this.connectTimeout) - this.connectTimeout = undefined - } - }) - } - - /** - * Disconnects from all namespaces and closes transport. - * - * @private - */ - _disconnect(): void { - for (const socket of this.sockets.values()) { - socket.disconnect() - } - this.sockets.clear() - this.close() - } - - /** - * Removes a socket. Called by each `Socket`. - * - * @private - */ - _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 { - debug("ignoring remove for %s", socket.id) - } - } - - /** - * Closes the underlying connection. - * - * @private - */ - private close(): void { - if ("open" === this.conn.readyState) { - 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 - */ - _packet(packet: Packet | any[], opts: WriteOptions = {}): void { - if (this.conn.readyState !== "open") { - 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) - this.writeToEngine(encodedPackets, opts) - } - - private writeToEngine( - encodedPackets: Array, - opts: WriteOptions - ): void { - if (opts.volatile && !this.conn.transport.writable) { - debug( - "volatile packet is discarded since the transport is not currently writable" - ) - return - } - const packets = Array.isArray(encodedPackets) - ? encodedPackets - : [encodedPackets] - for (const encodedPacket of packets) { - this.conn.write(encodedPacket, opts) - } - } - - /** - * Called with incoming transport data. - * - * @private - */ - private ondata(data): void { - // try/catch is needed for protocol violations (GH-1880) - try { - this.decoder.add(data) - } catch (e) { - debug("invalid packet format") - this.onerror(e) - } - } - - /** - * Called when parser fully decodes a packet. - * - * @private - */ - private ondecoded(packet: Packet): void { - let namespace: string - let authPayload - if (this.conn.protocol === 3) { - const parsed = url.parse(packet.nsp, true) - namespace = parsed.pathname! - authPayload = parsed.query - } else { - 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() - } - } - - /** - * Handles an error. - * - * @param {Object} err object - * @private - */ - private onerror(err): void { - for (const socket of this.sockets.values()) { - socket._onerror(err) - } - this.conn.close() - } - - /** - * Called upon transport close. - * - * @param reason - * @private - */ - private onclose(reason: CloseReason | "forced server close"): void { - debug("client close with reason %s", reason) - - // ignore a potential subsequent `close` event - this.destroy() - - // `nsps` and `sockets` are cleaned up seamlessly - for (const socket of this.sockets.values()) { - socket._onclose(reason) - } - this.sockets.clear() - - this.decoder.destroy() // clean up decoder - } - - /** - * Cleans up event listeners. - * @private - */ - private destroy(): void { - this.conn.removeListener("data", this.ondata) - this.conn.removeListener("error", this.onerror) - this.conn.removeListener("close", this.onclose) - // @ts-ignore - this.decoder.removeListener("decoded", this.ondecoded) - - if (this.connectTimeout) { - clearTimeout(this.connectTimeout) - this.connectTimeout = undefined - } + /** + * Cleans up event listeners. + * @private + */ + private destroy(): void { + this.conn.removeListener("data", this.ondata); + this.conn.removeListener("error", this.onerror); + this.conn.removeListener("close", this.onclose); + // @ts-ignore + this.decoder.removeListener("decoded", this.ondecoded); + + if (this.connectTimeout) { + clearTimeout(this.connectTimeout); + this.connectTimeout = undefined; } + } } diff --git a/packages/websocket/src/socket.io/index.ts b/packages/websocket/src/socket.io/index.ts index 5a7deede..2a2791cd 100644 --- a/packages/websocket/src/socket.io/index.ts +++ b/packages/websocket/src/socket.io/index.ts @@ -7,75 +7,106 @@ // import { pipeline } from "stream"; // import path = require("path"); 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 { 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 type { Encoder } from "socket.io-parser" -import type { Encoder } from "../socket.io-parser" -// import debugModule from "debug" -import { Socket } from "./socket" -import type { BroadcastOperator, RemoteSocket } from "./broadcast-operator" + 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 { - EventsMap, - DefaultEventsMap, - EventParams, - StrictEventEmitter, - EventNames, -} from "./typed-events" + Adapter, + SessionAwareAdapter, + Room, + SocketId, +} from "../socket.io-adapter"; +import * as parser from "../socket.io-parser"; +import type { Encoder } from "../socket.io-parser"; +// import debugModule from "debug" +import { Socket, DisconnectReason } from "./socket"; +import type { BroadcastOperator, RemoteSocket } from "./broadcast-operator"; +import { + EventsMap, + DefaultEventsMap, + EventParams, + StrictEventEmitter, + EventNames, + DecorateAcknowledgementsWithTimeoutAndMultipleResponses, + AllButLast, + Last, + FirstArg, + SecondArg, +} from "./typed-events"; // import { patchAdapter, restoreAdapter, serveFile } from "./uws" +import type { BaseServer } from "../engine.io/server"; -// const debug = debugModule("socket.io:server") -const debug = require('../debug')("socket.io:server") +const debug = require("../debug")("socket.io:server"); // const clientVersion = require("../package.json").version // const dotMapRegex = /\.map/ type ParentNspNameMatchFn = ( - name: string, - auth: { [key: string]: any }, - fn: (err: Error | null, success: boolean) => void -) => void + name: string, + auth: { [key: string]: any }, + fn: (err: Error | null, success: boolean) => void +) => void; -type AdapterConstructor = typeof Adapter | ((nsp: Namespace) => Adapter) +type AdapterConstructor = typeof Adapter | ((nsp: Namespace) => Adapter); interface ServerOptions extends EngineOptions, AttachOptions { + /** + * name of the path to capture + * @default "/socket.io" + */ + path: string; + /** + * whether to serve the client files + * @default true + */ + serveClient: boolean; + /** + * the adapter to use + * @default the in-memory adapter (https://github.com/socketio/socket.io-adapter) + */ + // adapter: AdapterConstructor + adapter: any; + /** + * the parser to use + * @default the default parser (https://github.com/socketio/socket.io-parser) + */ + parser: any; + /** + * how many ms before a client without namespace is closed + * @default 45000 + */ + connectTimeout: number; + /** + * Whether to enable the recovery of connection state when a client temporarily disconnects. + * + * The connection state includes the missed packets, the rooms the socket was in and the `data` attribute. + */ + connectionStateRecovery: { /** - * name of the path to capture - * @default "/socket.io" + * The backup duration of the sessions and the packets. + * + * @default 120000 (2 minutes) */ - path: string + maxDisconnectionDuration?: number; /** - * whether to serve the client files + * Whether to skip middlewares upon successful connection state recovery. + * * @default true */ - serveClient: boolean - /** - * the adapter to use - * @default the in-memory adapter (https://github.com/socketio/socket.io-adapter) - */ - // adapter: AdapterConstructor - adapter: any - /** - * the parser to use - * @default the default parser (https://github.com/socketio/socket.io-parser) - */ - parser: any - /** - * how many ms before a client without namespace is closed - * @default 45000 - */ - connectTimeout: number + skipMiddlewares?: boolean; + }; + /** + * Whether to remove child namespaces that have no sockets connected to them + * @default false + */ + cleanupEmptyChildNamespaces: boolean; } /** @@ -105,890 +136,945 @@ interface ServerOptions extends EngineOptions, AttachOptions { * io.listen(3000); */ export class Server< - ListenEvents extends EventsMap = DefaultEventsMap, - EmitEvents extends EventsMap = ListenEvents, - ServerSideEvents extends EventsMap = DefaultEventsMap, - SocketData = any + ListenEvents extends EventsMap = DefaultEventsMap, + EmitEvents extends EventsMap = ListenEvents, + ServerSideEvents extends EventsMap = DefaultEventsMap, + SocketData = any > extends StrictEventEmitter< - ServerSideEvents, + ServerSideEvents, + EmitEvents, + ServerReservedEventsMap< + ListenEvents, EmitEvents, - ServerReservedEventsMap< - ListenEvents, - EmitEvents, - ServerSideEvents, - SocketData - > + ServerSideEvents, + SocketData + > > { - public readonly sockets: Namespace< - ListenEvents, - EmitEvents, - ServerSideEvents, - SocketData + public readonly sockets: Namespace< + ListenEvents, + EmitEvents, + ServerSideEvents, + SocketData + >; + /** + * A reference to the underlying Engine.IO server. + * + * @example + * const clientsCount = io.engine.clientsCount; + * + */ + public engine: BaseServer; + + /** @private */ + readonly _parser: typeof parser; + /** @private */ + readonly encoder: Encoder; + + /** + * @private + */ + _nsps: Map< + string, + Namespace + > = new Map(); + private parentNsps: Map< + ParentNspNameMatchFn, + ParentNamespace + > = new Map(); + private _adapter?: AdapterConstructor; + private _serveClient: boolean; + private readonly opts: Partial; + private eio: Engine; + private _path: string; + private clientPathRegex: RegExp; + + /** + * @private + */ + _connectTimeout: number; + // private httpServer: http.Server | HTTPSServer | Http2SecureServer + + /** + * Server constructor. + * + * @param srv http server, port, or options + * @param [opts] + */ + constructor(opts?: Partial); + constructor( + srv?: any /* http.Server | HTTPSServer | Http2SecureServer | number */, + 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 ( + // "object" === typeof srv && + // srv instanceof Object && + // !(srv as Partial).listen + // ) { + // opts = srv as Partial + // srv = undefined + // } + this.path(opts.path || "/socket.io"); + this.connectTimeout(opts.connectTimeout || 45000); + this.serveClient(false !== opts.serveClient); + this._parser = opts.parser || parser; + this.encoder = new this._parser.Encoder(); + this.opts = opts; + if (opts.connectionStateRecovery) { + opts.connectionStateRecovery = Object.assign( + { + maxDisconnectionDuration: 2 * 60 * 1000, + skipMiddlewares: true, + }, + opts.connectionStateRecovery + ); + this.adapter(opts.adapter || SessionAwareAdapter); + } else { + this.adapter(opts.adapter || Adapter); + } + opts.cleanupEmptyChildNamespaces = !!opts.cleanupEmptyChildNamespaces; + this.sockets = this.of("/"); + // if (srv || typeof srv == "number") + this.attach( + srv /**as http.Server | HTTPSServer | Http2SecureServer | number */, + this.opts + ); + } + + get _opts() { + return this.opts; + } + + /** + * Sets/gets whether client code is being served. + * + * @param v - whether to serve client code + * @return self when setting or value when getting + */ + public serveClient(v: boolean): this; + public serveClient(): boolean; + public serveClient(v?: boolean): this | boolean; + public serveClient(v?: boolean): this | boolean { + if (!arguments.length) return this._serveClient; + this._serveClient = v!; + return this; + } + + /** + * Executes the middleware for an incoming namespace not already created on the server. + * + * @param name - name of incoming namespace + * @param auth - the auth parameters + * @param fn - callback + * + * @private + */ + _checkNamespace( + name: string, + auth: { [key: string]: any }, + fn: ( + nsp: + | Namespace + | false + ) => void + ): void { + if (this.parentNsps.size === 0) return fn(false); + + const keysIterator = this.parentNsps.keys(); + + const run = () => { + const nextFn = keysIterator.next(); + if (nextFn.done) { + return fn(false); + } + nextFn.value(name, auth, (err, allow) => { + if (err || !allow) { + 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); + }); + }; + + run(); + } + + /** + * Sets the client serving path. + * + * @param {String} v pathname + * @return {Server|String} self when setting or value when getting + */ + public path(v: string): this; + public path(): string; + public path(v?: string): this | string; + public path(v?: string): this | string { + if (!arguments.length) return this._path; + + this._path = v!.replace(/\/$/, ""); + + const escapedPath = this._path.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&"); + this.clientPathRegex = new RegExp( + "^" + + escapedPath + + "/socket\\.io(\\.msgpack|\\.esm)?(\\.min)?\\.js(\\.map)?(?:\\?|$)" + ); + return this; + } + + /** + * Set the delay after which a client without namespace is closed + * @param v + */ + public connectTimeout(v: number): this; + public connectTimeout(): number; + public connectTimeout(v?: number): this | number; + public connectTimeout(v?: number): this | number { + if (v === undefined) return this._connectTimeout; + this._connectTimeout = v; + return this; + } + + /** + * Sets the adapter for rooms. + * + * @param v pathname + * @return self when setting or value when getting + */ + public adapter(): AdapterConstructor | undefined; + public adapter(v: AdapterConstructor): this; + public adapter( + v?: AdapterConstructor + ): AdapterConstructor | undefined | this { + if (!arguments.length) return this._adapter; + this._adapter = v; + for (const nsp of this._nsps.values()) { + nsp._initAdapter(); + } + return this; + } + + /** + * Attaches socket.io to a server or port. + * + * @param srv - server or port + * @param opts - options passed to engine.io + * @return self + */ + public listen( + srv: any /*http.Server | HTTPSServer | Http2SecureServer | number*/, + opts: Partial = {} + ): this { + throw Error("Unsupport listen at MiaoScript Engine!"); + } + + /** + * Attaches socket.io to a server or port. + * + * @param srv - server or port + * @param opts - options passed to engine.io + * @return self + */ + public attach( + srv: any /*http.Server | HTTPSServer | Http2SecureServer | number*/, + opts: Partial = {} + ): this { + // if ("function" == typeof srv) { + // const msg = + // "You are trying to attach socket.io to an express " + + // "request handler function. Please pass a http.Server instance."; + // throw new Error(msg); + // } + + // // handle a port as a string + // if (Number(srv) == srv) { + // srv = Number(srv); + // } + + // if ("number" == typeof srv) { + // debug("creating http server and binding to %d", srv); + // const port = srv; + // srv = http.createServer((req, res) => { + // res.writeHead(404); + // res.end(); + // }); + // srv.listen(port); + // } + + // 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; + + this.initEngine(srv, opts); + + 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 + * + * @param srv - the server to attach to + * @param opts - options passed to engine.io + * @private + */ + private initEngine( + srv: any /*http.Server | HTTPSServer | Http2SecureServer | number*/, + 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); + + // // Export http server + // this.httpServer = srv; + + // bind to engine events + this.bind(this.eio); + } + + // /** + // * Attaches the static file serving. + // * + // * @param srv http server + // * @private + // */ + // 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!)) { + // this.serve(req, res); + // } else { + // for (let i = 0; i < evs.length; i++) { + // evs[i].call(srv, req, res); + // } + // } + // }); + // } + + // /** + // * Handles a request serving of client source and map + // * + // * @param req + // * @param res + // * @private + // */ + // private serve(req: http.IncomingMessage, res: http.ServerResponse): void { + // const filename = req.url!.replace(this._path, "").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.headers["if-none-match"]; + // if (etag) { + // if (expectedEtag === etag || weakEtag === etag) { + // debug("serve client %s 304", type); + // res.writeHead(304); + // res.end(); + // return; + // } + // } + + // debug("serve client %s", type); + + // res.setHeader("Cache-Control", "public, max-age=0"); + // res.setHeader( + // "Content-Type", + // "application/" + (isMap ? "json" : "javascript") + // ); + // res.setHeader("ETag", expectedEtag); + + // Server.sendFile(filename, req, res); + // } + + // /** + // * @param filename + // * @param req + // * @param res + // * @private + // */ + // private static sendFile( + // filename: string, + // req: http.IncomingMessage, + // res: http.ServerResponse + // ): void { + // const readStream = createReadStream( + // path.join(__dirname, "../client-dist/", filename) + // ); + // const encoding = accepts(req).encodings(["br", "gzip", "deflate"]); + + // const onError = (err: NodeJS.ErrnoException | null) => { + // if (err) { + // res.end(); + // } + // }; + + // switch (encoding) { + // case "br": + // res.writeHead(200, { "content-encoding": "br" }); + // readStream.pipe(createBrotliCompress()).pipe(res); + // pipeline(readStream, createBrotliCompress(), res, onError); + // break; + // case "gzip": + // res.writeHead(200, { "content-encoding": "gzip" }); + // pipeline(readStream, createGzip(), res, onError); + // break; + // case "deflate": + // res.writeHead(200, { "content-encoding": "deflate" }); + // pipeline(readStream, createDeflate(), res, onError); + // break; + // default: + // res.writeHead(200); + // pipeline(readStream, res, onError); + // } + // } + + /** + * Binds socket.io to an engine.io instance. + * + * @param engine engine.io (or compatible) server + * @return self + */ + public bind(engine): this { + this.engine = engine; + this.engine.on("connection", this.onconnection.bind(this)); + return this; + } + + /** + * Called with each incoming transport connection. + * + * @param {engine.Socket} conn + * @return self + * @private + */ + 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("/"); + } + return this; + } + + /** + * Looks up a namespace. + * + * @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 of( + name: string | RegExp | ParentNspNameMatchFn, + fn?: ( + socket: Socket + ) => void + ): Namespace { + if (typeof name === "function" || name instanceof RegExp) { + const parentNsp = new ParentNamespace(this); + debug("initializing parent namespace %s", parentNsp.name); + if (typeof name === "function") { + this.parentNsps.set(name, parentNsp); + } else { + this.parentNsps.set( + (nsp, conn, next) => next(null, (name as RegExp).test(nsp)), + parentNsp + ); + } + if (fn) { + // @ts-ignore + parentNsp.on("connect", fn); + } + return parentNsp; + } + + if (String(name)[0] !== "/") name = "/" + name; + + let nsp = this._nsps.get(name); + if (!nsp) { + debug("initializing namespace %s", name); + nsp = new Namespace(this, name); + this._nsps.set(name, nsp); + if (name !== "/") { + // @ts-ignore + this.sockets.emitReserved("new_namespace", nsp); + } + } + if (fn) nsp.on("connect", fn); + return nsp; + } + + /** + * Closes server connection + * + * @param [fn] optional, called as `fn([err])` on error OR all conns closed + */ + public close(fn?: (err?: Error) => void): void { + for (const socket of this.sockets.sockets.values()) { + socket._onclose("server shutting down"); + } + + this.engine.close(); + + // // restore the Adapter prototype + // restoreAdapter(); + + // if (this.httpServer) { + // this.httpServer.close(fn); + // } else { + fn && fn(); + // } + } + + /** + * Registers a middleware, which is a function that gets executed for every incoming {@link Socket}. + * + * @example + * io.use((socket, next) => { + * // ... + * next(); + * }); + * + * @param fn - the middleware function + */ + public use( + fn: ( + socket: Socket, + next: (err?: ExtendedError) => void + ) => void + ): this { + this.sockets.use(fn); + return this; + } + + /** + * Targets a room when emitting. + * + * @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[]) { + return this.sockets.to(room); + } + + /** + * Targets a room when emitting. Similar to `to()`, but might feel clearer in some cases: + * + * @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[]) { + return this.sockets.in(room); + } + + /** + * Excludes a room when emitting. + * + * @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[]) { + return this.sockets.except(room); + } + + /** + * Emits an event and waits for an acknowledgement from all clients. + * + * @example + * try { + * const responses = await io.timeout(1000).emitWithAck("some-event"); + * console.log(responses); // one response per client + * } catch (e) { + * // some clients did not acknowledge the event in the given delay + * } + * + * @return a Promise that will be fulfilled when all clients have acknowledged the event + */ + public emitWithAck>( + ev: Ev, + ...args: AllButLast> + ): Promise>>> { + return this.sockets.emitWithAck(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 + * io.send("hello"); + * + * // this is equivalent to + * io.emit("message", "hello"); + * + * @return self + */ + public send(...args: EventParams): this { + this.sockets.emit("message", ...args); + return this; + } + + /** + * Sends a `message` event to all clients. Alias of {@link send}. + * + * @return self + */ + public write(...args: EventParams): this { + this.sockets.emit("message", ...args); + return this; + } + + /** + * 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 servers did not acknowledge the event in the given delay + * } else { + * console.log(responses); // one response per server (except the current one) + * } + * }); + * + * 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 serverSideEmit>( + ev: Ev, + ...args: EventParams< + DecorateAcknowledgementsWithTimeoutAndMultipleResponses, + Ev > - /** - * A reference to the underlying Engine.IO server. - * - * @example - * const clientsCount = io.engine.clientsCount; - * - */ - public engine: any - - /** @private */ - readonly _parser: typeof parser - /** @private */ - readonly encoder: Encoder - - /** - * @private - */ - _nsps: Map< - string, - Namespace - > = new Map(); - private parentNsps: Map< - ParentNspNameMatchFn, - ParentNamespace - > = new Map(); - private _adapter?: AdapterConstructor - private _serveClient: boolean - private opts: Partial - private eio: Engine - private _path: string - private clientPathRegex: RegExp - - /** - * @private - */ - _connectTimeout: number - // private httpServer: http.Server | HTTPSServer | Http2SecureServer - - /** - * 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 ( - // "object" === typeof srv && - // srv instanceof Object && - // !(srv as Partial).listen - // ) { - // opts = srv as Partial - // srv = undefined - // } - this.path(opts.path || "/socket.io") - this.connectTimeout(opts.connectTimeout || 45000) - this.serveClient(false !== opts.serveClient) - this._parser = opts.parser || parser - this.encoder = new this._parser.Encoder() - this.adapter(opts.adapter || Adapter) - 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) - } - - /** - * Sets/gets whether client code is being served. - * - * @param v - whether to serve client code - * @return self when setting or value when getting - */ - public serveClient(v: boolean): this - public serveClient(): boolean - public serveClient(v?: boolean): this | boolean - public serveClient(v?: boolean): this | boolean { - if (!arguments.length) return this._serveClient - this._serveClient = v! - return this - } - - /** - * Executes the middleware for an incoming namespace not already created on the server. - * - * @param name - name of incoming namespace - * @param auth - the auth parameters - * @param fn - callback - * - * @private - */ - _checkNamespace( - name: string, - auth: { [key: string]: any }, - fn: ( - nsp: - | Namespace - | false - ) => void - ): void { - if (this.parentNsps.size === 0) return fn(false) - - const keysIterator = this.parentNsps.keys() - - const run = () => { - const nextFn = keysIterator.next() - if (nextFn.done) { - return fn(false) - } - nextFn.value(name, auth, (err, allow) => { - if (err || !allow) { - 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) - }) - } - - run() - } - - /** - * Sets the client serving path. - * - * @param {String} v pathname - * @return {Server|String} self when setting or value when getting - */ - public path(v: string): this - public path(): string - public path(v?: string): this | string - public path(v?: string): this | string { - if (!arguments.length) return this._path - - this._path = v!.replace(/\/$/, "") - - const escapedPath = this._path.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&") - this.clientPathRegex = new RegExp( - "^" + - escapedPath + - "/socket\\.io(\\.msgpack|\\.esm)?(\\.min)?\\.js(\\.map)?(?:\\?|$)" - ) - return this - } - - /** - * Set the delay after which a client without namespace is closed - * @param v - */ - public connectTimeout(v: number): this - public connectTimeout(): number - public connectTimeout(v?: number): this | number - public connectTimeout(v?: number): this | number { - if (v === undefined) return this._connectTimeout - this._connectTimeout = v - return this - } - - /** - * Sets the adapter for rooms. - * - * @param v pathname - * @return self when setting or value when getting - */ - public adapter(): AdapterConstructor | undefined - public adapter(v: AdapterConstructor): this - public adapter( - v?: AdapterConstructor - ): AdapterConstructor | undefined | this { - if (!arguments.length) return this._adapter - this._adapter = v - for (const nsp of this._nsps.values()) { - nsp._initAdapter() - } - return this - } - - /** - * Attaches socket.io to a server or port. - * - * @param srv - server or port - * @param opts - options passed to engine.io - * @return self - */ - public listen( - // srv: http.Server | HTTPSServer | Http2SecureServer | number, - srv: any, - opts: Partial = {} - ): this { - throw Error('Unsupport listen at MiaoScript Engine!') - // return this.attach(srv, opts) - } - - /** - * Attaches socket.io to a server or port. - * - * @param srv - server or port - * @param opts - options passed to engine.io - * @return self - */ - public attach( - // srv: http.Server | HTTPSServer | Http2SecureServer | number, - srv: any, - opts: Partial = {} - ): this { - // if ("function" == typeof srv) { - // const msg = - // "You are trying to attach socket.io to an express " + - // "request handler function. Please pass a http.Server instance." - // throw new Error(msg) - // } - - // // handle a port as a string - // if (Number(srv) == srv) { - // srv = Number(srv) - // } - - // if ("number" == typeof srv) { - // debug("creating http server and binding to %d", srv) - // const port = srv - // srv = http.createServer((req, res) => { - // res.writeHead(404) - // res.end() - // }) - // srv.listen(port) - // } - - // 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 - - this.initEngine(srv, opts) - - 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 - * - * @param srv - the server to attach to - * @param opts - options passed to engine.io - * @private - */ - 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) - - // // Export http server - // this.httpServer = srv - - // bind to engine events - this.bind(this.eio) - } - - // /** - // * Attaches the static file serving. - // * - // * @param srv http server - // * @private - // */ - // 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!)) { - // this.serve(req, res) - // } else { - // for (let i = 0; i < evs.length; i++) { - // evs[i].call(srv, req, res) - // } - // } - // }) - // } - - // /** - // * Handles a request serving of client source and map - // * - // * @param req - // * @param res - // * @private - // */ - // private serve(req: http.IncomingMessage, res: http.ServerResponse): void { - // const filename = req.url!.replace(this._path, "").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.headers["if-none-match"] - // if (etag) { - // if (expectedEtag === etag || weakEtag === etag) { - // debug("serve client %s 304", type) - // res.writeHead(304) - // res.end() - // return - // } - // } - - // debug("serve client %s", type) - - // res.setHeader("Cache-Control", "public, max-age=0") - // res.setHeader( - // "Content-Type", - // "application/" + (isMap ? "json" : "javascript") - // ) - // res.setHeader("ETag", expectedEtag) - - // Server.sendFile(filename, req, res) - // } - - // /** - // * @param filename - // * @param req - // * @param res - // * @private - // */ - // private static sendFile( - // filename: string, - // req: http.IncomingMessage, - // res: http.ServerResponse - // ): void { - // const readStream = createReadStream( - // path.join(__dirname, "../client-dist/", filename) - // ) - // const encoding = accepts(req).encodings(["br", "gzip", "deflate"]) - - // const onError = (err: NodeJS.ErrnoException | null) => { - // if (err) { - // res.end() - // } - // } - - // switch (encoding) { - // case "br": - // res.writeHead(200, { "content-encoding": "br" }) - // readStream.pipe(createBrotliCompress()).pipe(res) - // pipeline(readStream, createBrotliCompress(), res, onError) - // break - // case "gzip": - // res.writeHead(200, { "content-encoding": "gzip" }) - // pipeline(readStream, createGzip(), res, onError) - // break - // case "deflate": - // res.writeHead(200, { "content-encoding": "deflate" }) - // pipeline(readStream, createDeflate(), res, onError) - // break - // default: - // res.writeHead(200) - // pipeline(readStream, res, onError) - // } - // } - - /** - * Binds socket.io to an engine.io instance. - * - * @param {engine.Server} engine engine.io (or compatible) server - * @return self - */ - public bind(engine): this { - this.engine = engine - this.engine.on("connection", this.onconnection.bind(this)) - return this - } - - /** - * Called with each incoming transport connection. - * - * @param {engine.Socket} conn - * @return self - * @private - */ - 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("/") - } - return this - } - - /** - * Looks up a namespace. - * - * @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 of( - name: string | RegExp | ParentNspNameMatchFn, - fn?: ( - socket: Socket - ) => void - ): Namespace { - if (typeof name === "function" || name instanceof RegExp) { - const parentNsp = new ParentNamespace(this) - debug("initializing parent namespace %s", parentNsp.name) - if (typeof name === "function") { - this.parentNsps.set(name, parentNsp) - } else { - this.parentNsps.set( - (nsp, conn, next) => next(null, (name as RegExp).test(nsp)), - parentNsp - ) - } - if (fn) { - // @ts-ignore - parentNsp.on("connect", fn) - } - return parentNsp - } - - if (String(name)[0] !== "/") name = "/" + name - - let nsp = this._nsps.get(name) - if (!nsp) { - debug("initializing namespace %s", name) - nsp = new Namespace(this, name) - this._nsps.set(name, nsp) - if (name !== "/") { - // @ts-ignore - this.sockets.emitReserved("new_namespace", nsp) - } - } - if (fn) nsp.on("connect", fn) - return nsp - } - - /** - * Closes server connection - * - * @param [fn] optional, called as `fn([err])` on error OR all conns closed - */ - public close(fn?: (err?: Error) => void): void { - for (const socket of this.sockets.sockets.values()) { - socket._onclose("server shutting down") - } - - this.engine.close() - - // // restore the Adapter prototype - // restoreAdapter() - - // if (this.httpServer) { - // this.httpServer.close(fn) - // } else { - fn && fn() - // } - } - - /** - * Registers a middleware, which is a function that gets executed for every incoming {@link Socket}. - * - * @example - * io.use((socket, next) => { - * // ... - * next(); - * }); - * - * @param fn - the middleware function - */ - public use( - fn: ( - socket: Socket, - next: (err?: ExtendedError) => void - ) => void - ): this { - this.sockets.use(fn) - return this - } - - /** - * Targets a room when emitting. - * - * @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[]) { - return this.sockets.to(room) - } - - /** - * Targets a room when emitting. Similar to `to()`, but might feel clearer in some cases: - * - * @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[]) { - return this.sockets.in(room) - } - - /** - * Excludes a room when emitting. - * - * @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[]) { - 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 send(...args: EventParams): this { - this.sockets.emit("message", ...args) - return this - } - - /** - * Sends a `message` event to all clients. Alias of {@link send}. - * - * @return self - */ - public write(...args: EventParams): this { - this.sockets.emit("message", ...args) - return this - } - - /** - * 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 serverSideEmit>( - ev: Ev, - ...args: EventParams - ): boolean { - return this.sockets.serverSideEmit(ev, ...args) - } - - /** - * Gets a list of socket ids. - * - * @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() - } - - /** - * Sets the compress flag. - * - * @example - * io.compress(false).emit("hello"); - * - * @param compress - if `true`, compresses the sending data - * @return a new {@link BroadcastOperator} instance for chaining - */ - public compress(compress: boolean) { - return this.sockets.compress(compress) - } - - /** - * 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.volatile.emit("hello"); // the clients may or may not receive it - * - * @return a new {@link BroadcastOperator} instance for chaining - */ - 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. - * - * @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() { - return this.sockets.local - } - - /** - * 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) { - 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. - * - * 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[]) { - return this.sockets.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 - * // 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[]) { - return this.sockets.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 - * // 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 disconnectSockets(close: boolean = false) { - return this.sockets.disconnectSockets(close) - } + ): boolean { + return this.sockets.serverSideEmit(ev, ...args); + } + + /** + * Sends a message and expect an acknowledgement from the other Socket.IO servers of the cluster. + * + * @example + * try { + * const responses = await io.serverSideEmitWithAck("ping"); + * console.log(responses); // one response per server (except the current one) + * } catch (e) { + * // some servers did not acknowledge the event in the given delay + * } + * + * @param ev - the event name + * @param args - an array of arguments + * + * @return a Promise that will be fulfilled when all servers have acknowledged the event + */ + public serverSideEmitWithAck>( + ev: Ev, + ...args: AllButLast> + ): Promise>>[]> { + return this.sockets.serverSideEmitWithAck(ev, ...args); + } + + /** + * Gets a list of socket ids. + * + * @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(); + } + + /** + * Sets the compress flag. + * + * @example + * io.compress(false).emit("hello"); + * + * @param compress - if `true`, compresses the sending data + * @return a new {@link BroadcastOperator} instance for chaining + */ + public compress(compress: boolean) { + return this.sockets.compress(compress); + } + + /** + * 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.volatile.emit("hello"); // the clients may or may not receive it + * + * @return a new {@link BroadcastOperator} instance for chaining + */ + 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. + * + * @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() { + return this.sockets.local; + } + + /** + * 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) { + 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. + * + * 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[]) { + return this.sockets.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 + * // 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[]) { + return this.sockets.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 + * // 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 disconnectSockets(close: boolean = false) { + return this.sockets.disconnectSockets(close); + } } /** @@ -996,21 +1082,28 @@ export class Server< */ const emitterMethods = Object.keys(EventEmitter.prototype).filter(function ( - key + key ) { - return typeof EventEmitter.prototype[key] === "function" -}) + return typeof EventEmitter.prototype[key] === "function"; +}); emitterMethods.forEach(function (fn) { - Server.prototype[fn] = function () { - return this.sockets[fn].apply(this.sockets, arguments) - } -}) + Server.prototype[fn] = function () { + return this.sockets[fn].apply(this.sockets, arguments); + }; +}); -module.exports = (srv?, opts?) => new Server(srv, opts) -module.exports.Server = Server -module.exports.Namespace = Namespace -module.exports.Socket = Socket +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" +export { + Socket, + DisconnectReason, + 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 8035f407..d545b99e 100644 --- a/packages/websocket/src/socket.io/namespace.ts +++ b/packages/websocket/src/socket.io/namespace.ts @@ -1,58 +1,61 @@ -import { Socket } from "./socket" -import type { Server } from "./index" +import { Socket } from "./socket"; +import type { Server } from "./index"; import { - EventParams, - EventNames, - EventsMap, - StrictEventEmitter, - DefaultEventsMap, -} from "./typed-events" -import type { Client } from "./client" + EventParams, + EventNames, + EventsMap, + StrictEventEmitter, + DefaultEventsMap, + DecorateAcknowledgementsWithTimeoutAndMultipleResponses, + AllButLast, + Last, + FirstArg, + SecondArg, +} from "./typed-events"; +import type { Client } from "./client"; // import debugModule from "debug" -// import type { Adapter, Room, SocketId } from "socket.io-adapter" -import type { Adapter, Room, SocketId } from "../socket.io-adapter" -import { BroadcastOperator, RemoteSocket } from "./broadcast-operator" +import type { Adapter, Room, SocketId } from "../socket.io-adapter"; +import { BroadcastOperator } from "./broadcast-operator"; -// const debug = debugModule("socket.io:namespace") -const debug = require('../debug')("socket.io:namespace") +const debug = require("../debug")("socket.io:namespace"); export interface ExtendedError extends Error { - data?: any + data?: any; } export interface NamespaceReservedEventsMap< - ListenEvents extends EventsMap, - EmitEvents extends EventsMap, - ServerSideEvents extends EventsMap, - SocketData + ListenEvents extends EventsMap, + EmitEvents extends EventsMap, + ServerSideEvents extends EventsMap, + SocketData > { - connect: ( - socket: Socket - ) => void - connection: ( - socket: Socket - ) => void + connect: ( + socket: Socket + ) => void; + connection: ( + socket: Socket + ) => void; } export interface ServerReservedEventsMap< - ListenEvents extends EventsMap, - EmitEvents extends EventsMap, - ServerSideEvents extends EventsMap, - SocketData + ListenEvents extends EventsMap, + EmitEvents extends EventsMap, + ServerSideEvents extends EventsMap, + SocketData > extends NamespaceReservedEventsMap< ListenEvents, EmitEvents, ServerSideEvents, SocketData -> { - new_namespace: ( - namespace: Namespace - ) => void + > { + new_namespace: ( + namespace: Namespace + ) => void; } export const RESERVED_EVENTS: ReadonlySet = new Set< - keyof ServerReservedEventsMap ->(["connect", "connection", "new_namespace"]) + 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 @@ -108,559 +111,669 @@ export const RESERVED_EVENTS: ReadonlySet = new Set< * ``` */ export class Namespace< - ListenEvents extends EventsMap = DefaultEventsMap, - EmitEvents extends EventsMap = ListenEvents, - ServerSideEvents extends EventsMap = DefaultEventsMap, - SocketData = any + ListenEvents extends EventsMap = DefaultEventsMap, + EmitEvents extends EventsMap = ListenEvents, + ServerSideEvents extends EventsMap = DefaultEventsMap, + SocketData = any > extends StrictEventEmitter< - ServerSideEvents, + ServerSideEvents, + EmitEvents, + NamespaceReservedEventsMap< + ListenEvents, EmitEvents, - NamespaceReservedEventsMap< - ListenEvents, - EmitEvents, - ServerSideEvents, - SocketData - > + ServerSideEvents, + SocketData + > > { - public readonly name: string - public readonly sockets: Map< - SocketId, - Socket - > = new Map(); + public readonly name: string; + public readonly sockets: Map< + SocketId, + Socket + > = new Map(); - public adapter: Adapter + public adapter: Adapter; - /** @private */ - readonly server: Server< - ListenEvents, - EmitEvents, - ServerSideEvents, - SocketData + /** @private */ + readonly server: Server< + ListenEvents, + EmitEvents, + ServerSideEvents, + SocketData + >; + + /** @private */ + _fns: Array< + ( + socket: Socket, + next: (err?: ExtendedError) => void + ) => void + > = []; + + /** @private */ + _ids: number = 0; + + /** + * Namespace constructor. + * + * @param server instance + * @param name + */ + constructor( + server: Server, + name: string + ) { + super(); + this.server = server; + this.name = name; + this._initAdapter(); + } + + /** + * Initializes the `Adapter` for this nsp. + * Run upon changing adapter by `Server#adapter` + * in addition to the constructor. + * + * @private + */ + _initAdapter(): void { + // @ts-ignore + this.adapter = new (this.server.adapter()!)(this); + } + + /** + * Registers a middleware, which is a function that gets executed for every incoming {@link Socket}. + * + * @example + * const myNamespace = io.of("/my-namespace"); + * + * myNamespace.use((socket, next) => { + * // ... + * next(); + * }); + * + * @param fn - the middleware function + */ + public use( + fn: ( + socket: Socket, + next: (err?: ExtendedError) => void + ) => void + ): this { + this._fns.push(fn); + return this; + } + + /** + * Executes the middleware for an incoming client. + * + * @param socket - the socket that will get added + * @param fn - last fn call in the middleware + * @private + */ + private run( + socket: Socket, + fn: (err: ExtendedError | null) => void + ) { + const fns = this._fns.slice(0); + if (!fns.length) return fn(null); + + function run(i: number) { + fns[i](socket, function (err) { + // upon error, short-circuit + if (err) return fn(err); + + // if no middleware left, summon callback + if (!fns[i + 1]) return fn(null); + + // go on to next + run(i + 1); + }); + } + + run(0); + } + + /** + * Targets a room when emitting. + * + * @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[]) { + return new BroadcastOperator(this.adapter).to(room); + } + + /** + * Targets a room when emitting. Similar to `to()`, but might feel clearer in some cases: + * + * @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[]) { + return new BroadcastOperator(this.adapter).in(room); + } + + /** + * Excludes a room when emitting. + * + * @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[]) { + return new BroadcastOperator(this.adapter).except( + room + ); + } + + /** + * Adds a new client. + * + * @return {Socket} + * @private + */ + // @java-patch sync + _add( + client: Client, + auth: Record, + fn: ( + socket: Socket + ) => void + ) { + debug("adding socket to nsp %s", this.name); + const socket = this._createSocket(client, auth); + + if ( + // @ts-ignore + this.server.opts.connectionStateRecovery?.skipMiddlewares && + socket.recovered && + client.conn.readyState === "open" + ) { + return this._doConnect(socket, fn); + } + + this.run(socket, (err) => { + process.nextTick(() => { + 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, + }); + } + } + + this._doConnect(socket, fn); + }); + }); + } + + // @java-patch sync + private _createSocket( + client: Client, + auth: Record + ) { + const sessionId = auth.pid; + const offset = auth.offset; + if ( + // @ts-ignore + this.server.opts.connectionStateRecovery && + typeof sessionId === "string" && + typeof offset === "string" + ) { + const session = this.adapter.restoreSession(sessionId, offset); + if (session) { + debug("connection state recovered for sid %s", session.sid); + return new Socket(this, client, auth, session); + } else { + debug("unable to restore session state"); + } + } + return new Socket(this, client, auth); + } + + private _doConnect( + socket: Socket, + fn: ( + socket: Socket + ) => void + ) { + // 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(socket); + + // fire user-set events + this.emitReserved("connect", socket); + this.emitReserved("connection", socket); + } + + /** + * Removes a client. Called by each `Socket`. + * + * @private + */ + _remove( + socket: Socket + ): void { + if (this.sockets.has(socket.id)) { + this.sockets.delete(socket.id); + } else { + debug("ignoring remove for %s", socket.id); + } + } + + /** + * 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 emit>( + ev: Ev, + ...args: EventParams + ): boolean { + return new BroadcastOperator(this.adapter).emit( + ev, + ...args + ); + } + + /** + * Emits an event and waits for an acknowledgement from all clients. + * + * @example + * const myNamespace = io.of("/my-namespace"); + * + * try { + * const responses = await myNamespace.timeout(1000).emitWithAck("some-event"); + * console.log(responses); // one response per client + * } catch (e) { + * // some clients did not acknowledge the event in the given delay + * } + * + * @return a Promise that will be fulfilled when all clients have acknowledged the event + */ + public emitWithAck>( + ev: Ev, + ...args: AllButLast> + ): Promise>>> { + return new BroadcastOperator( + this.adapter + ).emitWithAck(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 send(...args: EventParams): this { + this.emit("message", ...args); + return this; + } + + /** + * Sends a `message` event to all clients. Sends a `message` event. Alias of {@link send}. + * + * @return self + */ + public write(...args: EventParams): this { + this.emit("message", ...args); + return this; + } + + /** + * 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 servers did not acknowledge the event in the given delay + * } else { + * console.log(responses); // one response per server (except the current one) + * } + * }); + * + * 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 serverSideEmit>( + ev: Ev, + ...args: EventParams< + DecorateAcknowledgementsWithTimeoutAndMultipleResponses, + Ev > - - /** @private */ - _fns: Array< - ( - socket: Socket, - next: (err?: ExtendedError) => void - ) => void - > = []; - - /** @private */ - _ids: number = 0; - - /** - * Namespace constructor. - * - * @param server instance - * @param name - */ - constructor( - server: Server, - name: string - ) { - super() - this.server = server - this.name = name - this._initAdapter() + ): boolean { + if (RESERVED_EVENTS.has(ev)) { + throw new Error(`"${String(ev)}" is a reserved event name`); } + args.unshift(ev); + this.adapter.serverSideEmit(args); + return true; + } - /** - * Initializes the `Adapter` for this nsp. - * Run upon changing adapter by `Server#adapter` - * in addition to the constructor. - * - * @private - */ - _initAdapter(): void { - // @ts-ignore - this.adapter = new (this.server.adapter()!)(this) - } - - /** - * Registers a middleware, which is a function that gets executed for every incoming {@link Socket}. - * - * @example - * const myNamespace = io.of("/my-namespace"); - * - * myNamespace.use((socket, next) => { - * // ... - * next(); - * }); - * - * @param fn - the middleware function - */ - public use( - fn: ( - socket: Socket, - next: (err?: ExtendedError) => void - ) => void - ): this { - this._fns.push(fn) - return this - } - - /** - * Executes the middleware for an incoming client. - * - * @param socket - the socket that will get added - * @param fn - last fn call in the middleware - * @private - */ - private run( - socket: Socket, - fn: (err: ExtendedError | null) => void - ) { - const fns = this._fns.slice(0) - if (!fns.length) return fn(null) - - function run(i: number) { - fns[i](socket, function (err) { - // upon error, short-circuit - if (err) return fn(err) - - // if no middleware left, summon callback - if (!fns[i + 1]) return fn(null) - - // go on to next - run(i + 1) - }) - } - - run(0) - } - - /** - * Targets a room when emitting. - * - * @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[]) { - return new BroadcastOperator(this.adapter).to(room) - } - - /** - * Targets a room when emitting. Similar to `to()`, but might feel clearer in some cases: - * - * @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[]) { - return new BroadcastOperator(this.adapter).in(room) - } - - /** - * Excludes a room when emitting. - * - * @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[]) { - return new BroadcastOperator(this.adapter).except( - room - ) - } - - /** - * Adds a new client. - * - * @return {Socket} - * @private - */ - _add( - client: Client, - query, - fn?: (socket: Socket) => void - ): 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) { - 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 - } - - /** - * Removes a client. Called by each `Socket`. - * - * @private - */ - _remove( - socket: Socket - ): void { - if (this.sockets.has(socket.id)) { - this.sockets.delete(socket.id) + /** + * Sends a message and expect an acknowledgement from the other Socket.IO servers of the cluster. + * + * @example + * const myNamespace = io.of("/my-namespace"); + * + * try { + * const responses = await myNamespace.serverSideEmitWithAck("ping"); + * console.log(responses); // one response per server (except the current one) + * } catch (e) { + * // some servers did not acknowledge the event in the given delay + * } + * + * @param ev - the event name + * @param args - an array of arguments + * + * @return a Promise that will be fulfilled when all servers have acknowledged the event + */ + public serverSideEmitWithAck>( + ev: Ev, + ...args: AllButLast> + ): Promise>>[]> { + return new Promise((resolve, reject) => { + args.push((err, responses) => { + if (err) { + err.responses = responses; + return reject(err); } else { - debug("ignoring remove for %s", socket.id) + return resolve(responses); } - } + }); + this.serverSideEmit( + ev, + ...(args as any[] as EventParams) + ); + }); + } - /** - * 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 emit>( - ev: Ev, - ...args: EventParams - ): boolean { - return new BroadcastOperator(this.adapter).emit( - ev, - ...args - ) - } + /** + * Called when a packet is received from another Socket.IO server + * + * @param args - an array of arguments, which may include an acknowledgement callback at the end + * + * @private + */ + _onServerSideEmit(args: [string, ...any[]]) { + super.emitUntyped.apply(this, 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 send(...args: EventParams): this { - this.emit("message", ...args) - return this - } + /** + * Gets a list of clients. + * + * @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(); + } - /** - * Sends a `message` event to all clients. Sends a `message` event. Alias of {@link send}. - * - * @return self - */ - public write(...args: EventParams): this { - this.emit("message", ...args) - return this - } + /** + * 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 + ); + } - /** - * 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 serverSideEmit>( - ev: Ev, - ...args: EventParams - ): boolean { - if (RESERVED_EVENTS.has(ev)) { - throw new Error(`"${String(ev)}" is a reserved event name`) - } - args.unshift(ev) - this.adapter.serverSideEmit(args) - return true - } + /** + * 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 + * const myNamespace = io.of("/my-namespace"); + * + * myNamespace.volatile.emit("hello"); // the clients may or may not receive it + * + * @return self + */ + public get volatile() { + return new BroadcastOperator(this.adapter).volatile; + } - /** - * Called when a packet is received from another Socket.IO server - * - * @param args - an array of arguments, which may include an acknowledgement callback at the end - * - * @private - */ - _onServerSideEmit(args: [string, ...any[]]) { - super.emitUntyped.apply(this, args) - } + /** + * Sets a modifier for a subsequent event emission that the event data will only be broadcast to the current node. + * + * @example + * const myNamespace = io.of("/my-namespace"); + * + * // the “foo” event will be broadcast to all connected clients on this node + * myNamespace.local.emit("foo", "bar"); + * + * @return a new {@link BroadcastOperator} instance for chaining + */ + public get local() { + return new BroadcastOperator(this.adapter).local; + } - /** - * Gets a list of clients. - * - * @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() - } + /** + * Adds a timeout in milliseconds for the next operation. + * + * @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 timeout(timeout: number) { + return new BroadcastOperator(this.adapter).timeout( + timeout + ); + } - /** - * 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 - ) - } + /** + * 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(); + } - /** - * 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 - * const myNamespace = io.of("/my-namespace"); - * - * myNamespace.volatile.emit("hello"); // the clients may or may not receive it - * - * @return self - */ - public get volatile() { - return new BroadcastOperator(this.adapter).volatile - } + /** + * 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); + } - /** - * Sets a modifier for a subsequent event emission that the event data will only be broadcast to the current node. - * - * @example - * const myNamespace = io.of("/my-namespace"); - * - * // the “foo” event will be broadcast to all connected clients on this node - * myNamespace.local.emit("foo", "bar"); - * - * @return a new {@link BroadcastOperator} instance for chaining - */ - public get local() { - return new BroadcastOperator(this.adapter).local - } + /** + * 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); + } - /** - * Adds a timeout in milliseconds for the next operation. - * - * @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 timeout(timeout: number) { - return new BroadcastOperator(this.adapter).timeout( - timeout - ) - } + /** + * 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 disconnectSockets(close: boolean = false) { + return new BroadcastOperator( + this.adapter + ).disconnectSockets(close); + } - /** - * 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 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) - // @java-patch close all socket when namespace close - this.sockets.forEach(socket => socket._onclose(`namepsace ${this.name} close`)) - } + // @java-patch + public close() { + RESERVED_EVENTS.forEach((event) => this.removeAllListeners(event as any)); + this.server._nsps.delete(this.name); + // @java-patch close all socket when namespace close + this.sockets.forEach((socket) => + socket._onclose(`namepsace ${this.name} close` as any) + ); + } } diff --git a/packages/websocket/src/socket.io/parent-namespace.ts b/packages/websocket/src/socket.io/parent-namespace.ts index 7ebee1e7..12a11185 100644 --- a/packages/websocket/src/socket.io/parent-namespace.ts +++ b/packages/websocket/src/socket.io/parent-namespace.ts @@ -1,77 +1,95 @@ -import { Namespace } from "./namespace" -import type { Server, RemoteSocket } from "./index" +import { Namespace } from "./namespace"; +import type { Server, RemoteSocket } from "./index"; import type { - EventParams, - EventNames, - EventsMap, - DefaultEventsMap, -} from "./typed-events" -// import type { BroadcastOptions } from "socket.io-adapter" -import type { BroadcastOptions } from "../socket.io-adapter" + EventParams, + EventNames, + EventsMap, + DefaultEventsMap, +} from "./typed-events"; +import type { BroadcastOptions } from "../socket.io-adapter"; +// import debugModule from "debug"; + +const debug = require("../debug")("socket.io:parent-namespace"); export class ParentNamespace< - ListenEvents extends EventsMap = DefaultEventsMap, - EmitEvents extends EventsMap = ListenEvents, - ServerSideEvents extends EventsMap = DefaultEventsMap, - SocketData = any + ListenEvents extends EventsMap = DefaultEventsMap, + EmitEvents extends EventsMap = ListenEvents, + ServerSideEvents extends EventsMap = DefaultEventsMap, + SocketData = any > extends Namespace { - private static count: number = 0; - private children: Set< - Namespace - > = new Set(); + private static count: number = 0; + private children: Set< + Namespace + > = new Set(); - constructor( - server: Server - ) { - super(server, "/_" + ParentNamespace.count++) - } + constructor( + server: Server + ) { + super(server, "/_" + ParentNamespace.count++); + } - /** - * @private - */ - _initAdapter(): void { - const broadcast = (packet: any, opts: BroadcastOptions) => { - this.children.forEach((nsp) => { - nsp.adapter.broadcast(packet, opts) - }) + /** + * @private + */ + _initAdapter(): void { + const broadcast = (packet: any, opts: BroadcastOptions) => { + this.children.forEach((nsp) => { + nsp.adapter.broadcast(packet, opts); + }); + }; + // @ts-ignore FIXME is there a way to declare an inner class in TypeScript? + this.adapter = { broadcast }; + } + + public emit>( + ev: Ev, + ...args: EventParams + ): boolean { + this.children.forEach((nsp) => { + nsp.emit(ev, ...args); + }); + + return true; + } + + createChild( + name: string + ): Namespace { + debug("creating child namespace %s", name); + const namespace = new Namespace(this.server, name); + namespace._fns = this._fns.slice(0); + this.listeners("connect").forEach((listener) => + namespace.on("connect", listener) + ); + this.listeners("connection").forEach((listener) => + namespace.on("connection", listener) + ); + this.children.add(namespace); + + if (this.server._opts.cleanupEmptyChildNamespaces) { + const remove = namespace._remove; + + namespace._remove = (socket) => { + remove.call(namespace, socket); + if (namespace.sockets.size === 0) { + debug("closing child namespace %s", name); + namespace.adapter.close(); + this.server._nsps.delete(namespace.name); + this.children.delete(namespace); } - // @ts-ignore FIXME is there a way to declare an inner class in TypeScript? - this.adapter = { broadcast } + }; } - public emit>( - ev: Ev, - ...args: EventParams - ): boolean { - this.children.forEach((nsp) => { - nsp.emit(ev, ...args) - }) + this.server._nsps.set(name, namespace); + return namespace; + } - return true - } - - createChild( - name: string - ): Namespace { - const namespace = new Namespace(this.server, name) - namespace._fns = this._fns.slice(0) - this.listeners("connect").forEach((listener) => - namespace.on("connect", listener) - ) - this.listeners("connection").forEach((listener) => - namespace.on("connection", listener) - ) - this.children.add(namespace) - 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") - } + 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 8b58de91..bdcb1a3e 100644 --- a/packages/websocket/src/socket.io/socket.ts +++ b/packages/websocket/src/socket.io/socket.ts @@ -1,137 +1,151 @@ -// import { Packet, PacketType } from "socket.io-parser" -import { Packet, PacketType } from "../socket.io-parser" +import { Packet, PacketType } from "../socket.io-parser"; // import debugModule from "debug"; -import type { Server } from "./index" +import type { Server } from "./index"; import { - EventParams, - EventNames, - EventsMap, - StrictEventEmitter, - DefaultEventsMap, -} from "./typed-events" -import type { Client } from "./client" -import type { Namespace, NamespaceReservedEventsMap } from "./namespace" + AllButLast, + DecorateAcknowledgements, + DecorateAcknowledgementsWithMultipleResponses, + DefaultEventsMap, + EventNames, + EventParams, + EventsMap, + FirstArg, + Last, + StrictEventEmitter, +} from "./typed-events"; +import type { Client } from "./client"; +import type { Namespace, NamespaceReservedEventsMap } from "./namespace"; // import type { IncomingMessage, IncomingHttpHeaders } from "http"; import type { - Adapter, - BroadcastFlags, - Room, - SocketId, -} from "../socket.io-adapter" + Adapter, + BroadcastFlags, + PrivateSessionId, + Room, + Session, + SocketId, +} from "../socket.io-adapter"; // import base64id from "base64id"; -import type { ParsedUrlQuery } from "querystring" -import { BroadcastOperator } from "./broadcast-operator" -import * as url from "url" +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") +const debug = require("../debug")("socket.io:socket"); -type ClientReservedEvents = "connect_error" +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 + // 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"; + +const RECOVERABLE_DISCONNECT_REASONS: ReadonlySet = new Set([ + "transport error", + "transport close", + "forced close", + "ping timeout", + "server shutting down", + "forced server close", +]); export interface SocketReservedEventsMap { - disconnect: (reason: DisconnectReason) => void - disconnecting: (reason: DisconnectReason) => void - error: (err: Error) => void + disconnect: (reason: DisconnectReason, description?: any) => void; + disconnecting: (reason: DisconnectReason, description?: any) => void; + error: (err: Error) => void; } // EventEmitter reserved events: https://nodejs.org/api/events.html#events_event_newlistener export interface EventEmitterReservedEventsMap { - newListener: ( - eventName: string | Symbol, - listener: (...args: any[]) => void - ) => void - removeListener: ( - eventName: string | Symbol, - listener: (...args: any[]) => void - ) => void + newListener: ( + eventName: string | Symbol, + listener: (...args: any[]) => void + ) => void; + removeListener: ( + eventName: string | Symbol, + listener: (...args: any[]) => void + ) => void; } export const RESERVED_EVENTS: ReadonlySet = new Set< - | ClientReservedEvents - | keyof NamespaceReservedEventsMap - | keyof SocketReservedEventsMap - | keyof EventEmitterReservedEventsMap + | ClientReservedEvents + | keyof NamespaceReservedEventsMap + | keyof SocketReservedEventsMap + | keyof EventEmitterReservedEventsMap >([ - "connect", - "connect_error", - "disconnect", - "disconnecting", - "newListener", - "removeListener", -]) + "connect", + "connect_error", + "disconnect", + "disconnecting", + "newListener", + "removeListener", +]); /** * The handshake details */ export interface Handshake { - /** - * The headers sent as part of the handshake - */ - // headers: IncomingHttpHeaders; - headers: any + /** + * The headers sent as part of the handshake + */ + // headers: IncomingHttpHeaders; + headers: any; - /** - * The date of creation (as string) - */ - time: string + /** + * The date of creation (as string) + */ + time: string; - /** - * The ip of the client - */ - address: string + /** + * The ip of the client + */ + address: string; - /** - * Whether the connection is cross-domain - */ - xdomain: boolean + /** + * Whether the connection is cross-domain + */ + xdomain: boolean; - /** - * Whether the connection is secure - */ - secure: boolean + /** + * Whether the connection is secure + */ + secure: boolean; - /** - * The date of creation (as unix timestamp) - */ - issued: number + /** + * The date of creation (as unix timestamp) + */ + issued: number; - /** - * The request URL string - */ - url: string + /** + * The request URL string + */ + url: string; - /** - * The query object - */ - query: ParsedUrlQuery + /** + * The query object + */ + query: ParsedUrlQuery; - /** - * The auth object - */ - auth: { [key: string]: any } + /** + * The auth object + */ + auth: { [key: string]: any }; } /** * `[eventName, ...args]` */ -export type Event = [string, ...any[]] +export type Event = [string, ...any[]]; -function noop() { } +function noop() {} /** * This is the main object for interacting with a client. @@ -165,939 +179,1039 @@ function noop() { } * }); */ export class Socket< - ListenEvents extends EventsMap = DefaultEventsMap, - EmitEvents extends EventsMap = ListenEvents, - ServerSideEvents extends EventsMap = DefaultEventsMap, - SocketData = any + ListenEvents extends EventsMap = DefaultEventsMap, + EmitEvents extends EventsMap = ListenEvents, + ServerSideEvents extends EventsMap = DefaultEventsMap, + SocketData = any > extends StrictEventEmitter< + ListenEvents, + EmitEvents, + SocketReservedEventsMap +> { + /** + * An unique identifier for the session. + */ + public readonly id: SocketId; + /** + * Whether the connection state was recovered after a temporary disconnection. In that case, any missed packets will + * be transmitted to the client, the data attribute and the rooms will be restored. + */ + public readonly recovered: boolean = false; + /** + * 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; + + /** + * The session ID, which must not be shared (unlike {@link id}). + * + * @private + */ + private readonly pid: PrivateSessionId; + + // TODO: remove this unused reference + private readonly server: Server< ListenEvents, EmitEvents, - SocketReservedEventsMap -> { - /** - * An unique identifier for the session. - */ - 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; + ServerSideEvents, + SocketData + >; + private readonly adapter: Adapter; + private acks: Map void> = new Map(); + 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>; - private readonly server: Server< - ListenEvents, - EmitEvents, - ServerSideEvents, - SocketData - > - private readonly adapter: Adapter - private acks: Map void> = new Map(); - 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`. + * + * @param {Namespace} nsp + * @param {Client} client + * @param {Object} auth + * @package + */ + constructor( + readonly nsp: Namespace, + readonly client: Client, + auth: Record, + previousSession?: Session + ) { + super(); + this.server = nsp.server; + this.adapter = this.nsp.adapter; + if (previousSession) { + this.id = previousSession.sid; + this.pid = previousSession.pid; + previousSession.rooms.forEach((room) => this.join(room)); + this.data = previousSession.data as Partial; + previousSession.missedPackets.forEach((packet) => { + this.packet({ + type: PacketType.EVENT, + data: packet, + }); + }); + this.recovered = true; + } else { + // if (client.conn.protocol === 3) { + // @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 + // } + // if (this.server._opts.connectionStateRecovery) { + // this.pid = base64id.generateId(); + // } + } + this.handshake = this.buildHandshake(auth); + } - /** - * Interface to a `Client` for a given `Namespace`. - * - * @param {Namespace} nsp - * @param {Client} client - * @param {Object} auth - * @package - */ - constructor( - readonly nsp: Namespace, - readonly client: Client, - auth: object - ) { - super() - this.server = nsp.server - this.adapter = this.nsp.adapter - // if (client.conn.protocol === 3) { - // @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.handshake = this.buildHandshake(auth) + /** + * Builds the `handshake` BC object + * + * @private + */ + private buildHandshake(auth: object): Handshake { + return { + headers: this.request.headers, + time: new Date() + "", + address: this.conn.remoteAddress, + xdomain: !!this.request.headers.origin, + // @ts-ignore + secure: !!this.request.connection.encrypted, + issued: +new Date(), + url: this.request.url!, + // @ts-ignore + query: url.parse(this.request.url!, true).query, + auth, + }; + } + + /** + * 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 emit>( + ev: Ev, + ...args: EventParams + ): boolean { + if (RESERVED_EVENTS.has(ev)) { + throw new Error(`"${String(ev)}" is a reserved event name`); + } + const data: any[] = [ev, ...args]; + const packet: any = { + type: PacketType.EVENT, + data: data, + }; + + // access last argument to see if it's an ACK callback + if (typeof data[data.length - 1] === "function") { + const id = this.nsp._ids++; + debug("emitting packet with ack id %d", id); + + this.registerAckCallback(id, data.pop()); + packet.id = id; } - /** - * Builds the `handshake` BC object - * - * @private - */ - private buildHandshake(auth: object): Handshake { - return { - headers: this.request.headers, - time: new Date() + "", - address: this.conn.remoteAddress, - xdomain: !!this.request.headers.origin, - // @ts-ignore - secure: !!this.request.connection.encrypted, - issued: +new Date(), - url: this.request.url!, - // @ts-ignore - query: url.parse(this.request.url!, true).query, - auth, - } + const flags = Object.assign({}, this.flags); + this.flags = {}; + + // @ts-ignore + if (this.nsp.server.opts.connectionStateRecovery) { + // this ensures the packet is stored and can be transmitted upon reconnection + this.adapter.broadcast(packet, { + rooms: new Set([this.id]), + except: new Set(), + flags, + }); + } else { + this.notifyOutgoingListeners(packet); + this.packet(packet, flags); } - /** - * 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 emit>( - ev: Ev, - ...args: EventParams - ): boolean { - if (RESERVED_EVENTS.has(ev)) { - throw new Error(`"${String(ev)}" is a reserved event name`) - } - const data: any[] = [ev, ...args] - const packet: any = { - type: PacketType.EVENT, - data: data, - } + return true; + } - // access last argument to see if it's an ACK callback - if (typeof data[data.length - 1] === "function") { - 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 - } - - /** - * @private - */ - 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. - * - * @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 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. - * - * @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[]) { - 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 send(...args: EventParams): this { - this.emit("message", ...args) - return this - } - - /** - * Sends a `message` event. Alias of {@link send}. - * - * @return self - */ - public write(...args: EventParams): this { - this.emit("message", ...args) - return this - } - - /** - * Writes a packet. - * - * @param {Object} packet - packet object - * @param {Object} opts - options - * @private - */ - private packet( - packet: Omit & Partial>, - opts: any = {} - ): void { - packet.nsp = this.nsp.name - opts.compress = false !== opts.compress - this.client._packet(packet as Packet, opts) - } - - /** - * 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 join(rooms: Room | Array): Promise | void { - debug("join room %s", rooms) - - return this.adapter.addAll( - this.id, - new Set(Array.isArray(rooms) ? rooms : [rooms]) - ) - } - - /** - * 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 leave(room: string): Promise | void { - debug("leave room %s", room) - - return this.adapter.del(this.id, room) - } - - /** - * Leave all rooms. - * - * @private - */ - private leaveAll(): void { - this.adapter.delAll(this.id) - } - - /** - * Called by `Namespace` upon successful - * middleware execution (ie: authorization). - * Socket is added to namespace array before - * call to join, so adapters can access it. - * - * @private - */ - _onconnect(): void { - debug("socket connected - writing packet") - this.connected = true - this.join(this.id) - if (this.conn.protocol === 3) { - this.packet({ type: PacketType.CONNECT }) + /** + * Emits an event and waits for an acknowledgement + * + * @example + * io.on("connection", async (socket) => { + * // without timeout + * const response = await socket.emitWithAck("hello", "world"); + * + * // with a specific timeout + * try { + * const response = await socket.timeout(1000).emitWithAck("hello", "world"); + * } catch (err) { + * // the client did not acknowledge the event in the given delay + * } + * }); + * + * @return a Promise that will be fulfilled when the client acknowledges the event + */ + public emitWithAck>( + ev: Ev, + ...args: AllButLast> + ): Promise>>> { + // the timeout flag is optional + const withErr = this.flags.timeout !== undefined; + return new Promise((resolve, reject) => { + args.push((arg1, arg2) => { + if (withErr) { + return arg1 ? reject(arg1) : resolve(arg2); } else { - this.packet({ type: PacketType.CONNECT, data: { sid: this.id } }) + return resolve(arg1); } + }); + this.emit(ev, ...(args as any[] as EventParams)); + }); + } + + /** + * @private + */ + private registerAckCallback(id: number, ack: (...args: any[]) => void): void { + const timeout = this.flags.timeout; + if (timeout === undefined) { + this.acks.set(id, ack); + return; } - /** - * Called with each packet. Called by `Client`. - * - * @param {Object} packet - * @private - */ - _onpacket(packet: Packet): void { - debug("got packet %j", packet) - switch (packet.type) { - case PacketType.EVENT: - this.onevent(packet) - break + 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); - case PacketType.BINARY_EVENT: - this.onevent(packet) - break + this.acks.set(id, (...args) => { + clearTimeout(timer); + ack.apply(this, [null, ...args]); + }); + } - case PacketType.ACK: - this.onack(packet) - break + /** + * Targets a room when broadcasting. + * + * @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 to(room: Room | Room[]) { + return this.newBroadcastOperator().to(room); + } - case PacketType.BINARY_ACK: - this.onack(packet) - break + /** + * 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); + } - case PacketType.DISCONNECT: - this.ondisconnect() - break - } + /** + * Excludes a room when broadcasting. + * + * @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[]) { + 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 send(...args: EventParams): this { + this.emit("message", ...args); + return this; + } + + /** + * Sends a `message` event. Alias of {@link send}. + * + * @return self + */ + public write(...args: EventParams): this { + this.emit("message", ...args); + return this; + } + + /** + * Writes a packet. + * + * @param {Object} packet - packet object + * @param {Object} opts - options + * @private + */ + private packet( + packet: Omit & Partial>, + opts: any = {} + ): void { + packet.nsp = this.nsp.name; + opts.compress = false !== opts.compress; + this.client._packet(packet as Packet, opts); + } + + /** + * 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 join(rooms: Room | Array): Promise | void { + debug("join room %s", rooms); + + return this.adapter.addAll( + this.id, + new Set(Array.isArray(rooms) ? rooms : [rooms]) + ); + } + + /** + * 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 leave(room: string): Promise | void { + debug("leave room %s", room); + + return this.adapter.del(this.id, room); + } + + /** + * Leave all rooms. + * + * @private + */ + private leaveAll(): void { + this.adapter.delAll(this.id); + } + + /** + * Called by `Namespace` upon successful + * middleware execution (ie: authorization). + * Socket is added to namespace array before + * call to join, so adapters can access it. + * + * @private + */ + _onconnect(): void { + debug("socket connected - writing packet"); + this.connected = true; + this.join(this.id); + if (this.conn.protocol === 3) { + this.packet({ type: PacketType.CONNECT }); + } else { + this.packet({ + type: PacketType.CONNECT, + data: { sid: this.id, pid: this.pid }, + }); + } + } + + /** + * Called with each packet. Called by `Client`. + * + * @param {Object} packet + * @private + */ + _onpacket(packet: Packet): void { + debug("got packet %j", packet); + switch (packet.type) { + 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; + + case PacketType.DISCONNECT: + this.ondisconnect(); + break; + } + } + + /** + * Called upon event packet. + * + * @param {Packet} packet - packet object + * @private + */ + private onevent(packet: Packet): void { + const args = packet.data || []; + debug("emitting event %j", args); + + if (null != packet.id) { + debug("attaching ack callback to event"); + args.push(this.ack(packet.id)); } - /** - * Called upon event packet. - * - * @param {Packet} packet - packet object - * @private - */ - private onevent(packet: Packet): void { - const args = packet.data || [] - debug("emitting event %j", args) - - if (null != packet.id) { - debug("attaching ack callback to event") - args.push(this.ack(packet.id)) - } - - if (this._anyListeners && this._anyListeners.length) { - const listeners = this._anyListeners.slice() - for (const listener of listeners) { - listener.apply(this, args) - } - } - this.dispatch(args) + if (this._anyListeners && this._anyListeners.length) { + const listeners = this._anyListeners.slice(); + for (const listener of listeners) { + listener.apply(this, args); + } } + this.dispatch(args); + } - /** - * Produces an ack callback to emit with an event. - * - * @param {Number} id - packet id - * @private - */ - private ack(id: number): () => void { - const self = this - let sent = false - return function () { - // prevent double callbacks - if (sent) return - const args = Array.prototype.slice.call(arguments) - debug("sending ack %j", args) + /** + * Produces an ack callback to emit with an event. + * + * @param {Number} id - packet id + * @private + */ + private ack(id: number): () => void { + const self = this; + let sent = false; + return function () { + // prevent double callbacks + if (sent) return; + const args = Array.prototype.slice.call(arguments); + debug("sending ack %j", args); - self.packet({ - id: id, - type: PacketType.ACK, - data: args, - }) + self.packet({ + id: id, + type: PacketType.ACK, + data: args, + }); - sent = true - } + sent = true; + }; + } + + /** + * Called upon ack packet. + * + * @private + */ + private onack(packet: Packet): void { + const ack = this.acks.get(packet.id!); + if ("function" == typeof ack) { + debug("calling ack %s with %j", packet.id, packet.data); + ack.apply(this, packet.data); + this.acks.delete(packet.id!); + } else { + debug("bad ack %s", packet.id); } + } - /** - * Called upon ack packet. - * - * @private - */ - private onack(packet: Packet): void { - const ack = this.acks.get(packet.id!) - if ("function" == typeof ack) { - debug("calling ack %s with %j", packet.id, packet.data) - ack.apply(this, packet.data) - this.acks.delete(packet.id!) + /** + * Called upon client disconnect packet. + * + * @private + */ + private ondisconnect(): void { + debug("got disconnect packet"); + this._onclose("client namespace disconnect"); + } + + /** + * Handles a client error. + * + * @private + */ + _onerror(err: Error): void { + if (this.listeners("error").length) { + this.emitReserved("error", err); + } else { + console.error("Missing error handler on `socket`."); + console.error(err.stack); + } + } + + /** + * Called upon closing. Called by `Client`. + * + * @param {String} reason + * @param description + * @throw {Error} optional error object + * + * @private + */ + _onclose(reason: DisconnectReason, description?: any): this | undefined { + if (!this.connected) return this; + debug("closing socket - reason %s", reason); + this.emitReserved("disconnecting", reason, description); + + // if ( + // this.server._opts.connectionStateRecovery && + // RECOVERABLE_DISCONNECT_REASONS.has(reason) + // ) { + // debug("connection state recovery is enabled for sid %s", this.id); + // this.adapter.persistSession({ + // sid: this.id, + // pid: this.pid, + // rooms: [...this.rooms], + // data: this.data, + // }); + // } + + this._cleanup(); + this.nsp._remove(this); + this.client._remove(this); + this.connected = false; + this.emitReserved("disconnect", reason, description); + 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. + * + * @param {Object} err - error object + * + * @private + */ + _error(err): void { + this.packet({ type: PacketType.CONNECT_ERROR, data: err }); + } + + /** + * Disconnects this client. + * + * @example + * io.on("connection", (socket) => { + * // disconnect this socket (the connection might be kept alive for other namespaces) + * socket.disconnect(); + * + * // 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; + if (close) { + this.client._disconnect(); + } else { + this.packet({ type: PacketType.DISCONNECT }); + this._onclose("server namespace disconnect"); + } + return this; + } + + /** + * 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 compress(compress: boolean): this { + this.flags.compress = compress; + return this; + } + + /** + * 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; + } + + /** + * Sets a modifier for a subsequent event emission that the event data will only be broadcast to every sockets but the + * sender. + * + * @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() { + return this.newBroadcastOperator(); + } + + /** + * Sets a modifier for a subsequent event emission that the event data will only be broadcast to the current node. + * + * @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() { + 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 + ): Socket< + ListenEvents, + DecorateAcknowledgements, + ServerSideEvents, + SocketData + > { + this.flags.timeout = timeout; + return this; + } + + /** + * Dispatch incoming event to socket listeners. + * + * @param {Array} event - event that will get emitted + * @private + */ + private dispatch(event: Event): void { + debug("dispatching an event %j", event); + this.run(event, (err) => { + process.nextTick(() => { + if (err) { + return this._onerror(err); + } + if (this.connected) { + super.emitUntyped.apply(this, event); } else { - debug("bad ack %s", packet.id) + debug("ignore packet received after disconnection"); } + }); + }); + } + + /** + * 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 use(fn: (event: Event, next: (err?: Error) => void) => void): this { + this.fns.push(fn); + return this; + } + + /** + * Executes the middleware for an incoming event. + * + * @param {Array} event - event that will get emitted + * @param {Function} fn - last fn call in the middleware + * @private + */ + private run(event: Event, fn: (err: Error | null) => void): void { + const fns = this.fns.slice(0); + if (!fns.length) return fn(null); + + function run(i: number) { + fns[i](event, function (err) { + // upon error, short-circuit + if (err) return fn(err); + + // if no middleware left, summon callback + if (!fns[i + 1]) return fn(null); + + // go on to next + run(i + 1); + }); } - /** - * Called upon client disconnect packet. - * - * @private - */ - private ondisconnect(): void { - debug("got disconnect packet") - this._onclose("client namespace disconnect") - } + run(0); + } - /** - * Handles a client error. - * - * @private - */ - _onerror(err: Error): void { - if (this.listeners("error").length) { - this.emitReserved("error", err) - } else { - console.error("Missing error handler on `socket`.") - console.error(err.stack) + /** + * 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 get request(): any /** IncomingMessage */ { + return this.client.request; + } + + /** + * A reference to the underlying Client transport connection (Engine.IO Socket object). + * + * @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; + } + + /** + * 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 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 onAny(listener: (...args: any[]) => void): this { + this._anyListeners = this._anyListeners || []; + this._anyListeners.push(listener); + return this; + } + + /** + * 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 prependAny(listener: (...args: any[]) => void): this { + this._anyListeners = this._anyListeners || []; + this._anyListeners.unshift(listener); + return this; + } + + /** + * 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 offAny(listener?: (...args: any[]) => void): this { + if (!this._anyListeners) { + return this; + } + if (listener) { + const listeners = this._anyListeners; + for (let i = 0; i < listeners.length; i++) { + if (listener === listeners[i]) { + listeners.splice(i, 1); + return this; } + } + } else { + this._anyListeners = []; } + return this; + } - /** - * Called upon closing. Called by `Client`. - * - * @param {String} reason - * @throw {Error} optional error object - * - * @private - */ - _onclose(reason: DisconnectReason): this | undefined { - if (!this.connected) return this - debug("closing socket - reason %s", reason) - this.emitReserved("disconnecting", reason) - this._cleanup() - this.nsp._remove(this) - this.client._remove(this) - this.connected = false - this.emitReserved("disconnect", reason) - return + /** + * 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 listenersAny() { + return this._anyListeners || []; + } + + /** + * 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; } - - /** - * 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. - * - * @param {Object} err - error object - * - * @private - */ - _error(err): void { - this.packet({ type: PacketType.CONNECT_ERROR, data: err }) - } - - /** - * Disconnects this client. - * - * @example - * io.on("connection", (socket) => { - * // disconnect this socket (the connection might be kept alive for other namespaces) - * socket.disconnect(); - * - * // 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 - if (close) { - this.client._disconnect() - } else { - this.packet({ type: PacketType.DISCONNECT }) - this._onclose("server namespace disconnect") + if (listener) { + const listeners = this._anyOutgoingListeners; + for (let i = 0; i < listeners.length; i++) { + if (listener === listeners[i]) { + listeners.splice(i, 1); + return this; } - return this + } + } else { + this._anyOutgoingListeners = []; } + return this; + } - /** - * 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 compress(compress: boolean): this { - this.flags.compress = compress - 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); + } } + } - /** - * 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 - } - - /** - * Sets a modifier for a subsequent event emission that the event data will only be broadcast to every sockets but the - * sender. - * - * @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() { - return this.newBroadcastOperator() - } - - /** - * Sets a modifier for a subsequent event emission that the event data will only be broadcast to the current node. - * - * @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() { - 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: Event): void { - debug("dispatching an event %j", event) - this.run(event, (err) => { - process.nextTick(() => { - if (err) { - return this._onerror(err) - } - if (this.connected) { - super.emitUntyped.apply(this, event) - } else { - debug("ignore packet received after disconnection") - } - }) - }) - } - - /** - * 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 use(fn: (event: Event, next: (err?: Error) => void) => void): this { - this.fns.push(fn) - return this - } - - /** - * Executes the middleware for an incoming event. - * - * @param {Array} event - event that will get emitted - * @param {Function} fn - last fn call in the middleware - * @private - */ - private run(event: Event, fn: (err: Error | null) => void): void { - const fns = this.fns.slice(0) - if (!fns.length) return fn(null) - - function run(i: number) { - fns[i](event, function (err) { - // upon error, short-circuit - if (err) return fn(err) - - // if no middleware left, summon callback - if (!fns[i + 1]) return fn(null) - - // go on to next - run(i + 1) - }) - } - - 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 get request(): any /** IncomingMessage */ { - return this.client.request - } - - /** - * A reference to the underlying Client transport connection (Engine.IO Socket object). - * - * @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 - } - - /** - * 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 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 onAny(listener: (...args: any[]) => void): this { - this._anyListeners = this._anyListeners || [] - this._anyListeners.push(listener) - return this - } - - /** - * 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 prependAny(listener: (...args: any[]) => void): this { - this._anyListeners = this._anyListeners || [] - this._anyListeners.unshift(listener) - return this - } - - /** - * 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 offAny(listener?: (...args: any[]) => void): this { - if (!this._anyListeners) { - return this - } - if (listener) { - const listeners = this._anyListeners - for (let i = 0; i < listeners.length; i++) { - if (listener === listeners[i]) { - listeners.splice(i, 1) - return this - } - } - } else { - this._anyListeners = [] - } - 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 listenersAny() { - return this._anyListeners || [] - } - - /** - * 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( - this.adapter, - new Set(), - new Set([this.id]), - flags - ) - } + private newBroadcastOperator() { + const flags = Object.assign({}, this.flags); + this.flags = {}; + return new BroadcastOperator< + DecorateAcknowledgementsWithMultipleResponses, + SocketData + >(this.adapter, new Set(), new Set([this.id]), flags); + } } diff --git a/packages/websocket/src/socket.io/typed-events.ts b/packages/websocket/src/socket.io/typed-events.ts index c1d48c10..153dd916 100644 --- a/packages/websocket/src/socket.io/typed-events.ts +++ b/packages/websocket/src/socket.io/typed-events.ts @@ -1,11 +1,11 @@ -import { EventEmitter } from "events" +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 + [event: string]: any; } /** @@ -13,43 +13,43 @@ export interface EventsMap { * is equivalent to accepting all event names, and any data. */ export interface DefaultEventsMap { - [event: string]: (...args: any[]) => void + [event: string]: (...args: any[]) => void; } /** * Returns a union type containing all the keys of an event map. */ -export type EventNames = keyof Map & (string | symbol) +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 + 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 + 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 - > + 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`. @@ -58,18 +58,18 @@ export type ReservedOrUserListener< * Needed because of https://github.com/microsoft/TypeScript/issues/41778 */ type FallbackToUntypedListener = [T] extends [never] - ? (...args: any[]) => void - : T + ? (...args: any[]) => void + : T; /** * Interface for classes that aren't `EventEmitter`s, but still expose a * strictly typed `emit` method. */ export interface TypedEventBroadcaster { - emit>( - ev: Ev, - ...args: EventParams - ): boolean + emit>( + ev: Ev, + ...args: EventParams + ): boolean; } /** @@ -89,92 +89,155 @@ export abstract class StrictEventEmitter< ListenEvents extends EventsMap, EmitEvents extends EventsMap, ReservedEvents extends EventsMap = {} - > - extends EventEmitter - implements TypedEventBroadcaster + > + extends EventEmitter + implements TypedEventBroadcaster { - /** - * 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 { - return super.on(ev, listener) - } + /** + * 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 { + return super.on(ev, listener); + } - /** - * 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 { - return super.once(ev, listener) - } + /** + * 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 { + return super.once(ev, listener); + } - /** - * Emits an event. - * - * @param ev Name of the event - * @param args Values to send to listeners of this event - */ - emit>( - ev: Ev, - ...args: EventParams - ): boolean { - return super.emit(ev, ...args) - } + /** + * Emits an event. + * + * @param ev Name of the event + * @param args Values to send to listeners of this event + */ + emit>( + ev: Ev, + ...args: EventParams + ): boolean { + return super.emit(ev, ...args); + } - /** - * 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 - ): boolean { - return super.emit(ev, ...args) - } + /** + * 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 + ): boolean { + return super.emit(ev, ...args); + } - /** - * Emits an event. - * - * This method is `protected`, so that only a class extending - * `StrictEventEmitter` can get around the strict typing. This is useful for - * calling `emit.apply`, which can be called as `emitUntyped.apply`. - * - * @param ev Event name - * @param args Arguments to emit along with the event - */ - protected emitUntyped(ev: string, ...args: any[]): boolean { - return super.emit(ev, ...args) - } + /** + * Emits an event. + * + * This method is `protected`, so that only a class extending + * `StrictEventEmitter` can get around the strict typing. This is useful for + * calling `emit.apply`, which can be called as `emitUntyped.apply`. + * + * @param ev Event name + * @param args Arguments to emit along with the event + */ + protected emitUntyped(ev: string, ...args: any[]): boolean { + return super.emit(ev, ...args); + } - /** - * 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 ReservedOrUserListener< - ReservedEvents, - ListenEvents, - Ev - >[] - } + /** + * 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 ReservedOrUserListener< + ReservedEvents, + ListenEvents, + Ev + >[]; + } } + +export type Last = T extends [...infer H, infer L] ? L : any; +export type AllButLast = T extends [...infer H, infer L] + ? H + : any[]; +export type FirstArg = T extends (arg: infer Param) => infer Result + ? Param + : any; +export type SecondArg = T extends ( + err: Error, + arg: infer Param +) => infer Result + ? Param + : any; + +type PrependTimeoutError = { + [K in keyof T]: T[K] extends (...args: infer Params) => infer Result + ? (err: Error, ...args: Params) => Result + : T[K]; +}; + +type ExpectMultipleResponses = { + [K in keyof T]: T[K] extends (err: Error, arg: infer Param) => infer Result + ? (err: Error, arg: Param[]) => Result + : T[K]; +}; + +/** + * Utility type to decorate the acknowledgement callbacks with a timeout error. + * + * This is needed because the timeout() flag breaks the symmetry between the sender and the receiver: + * + * @example + * interface Events { + * "my-event": (val: string) => void; + * } + * + * socket.on("my-event", (cb) => { + * cb("123"); // one single argument here + * }); + * + * socket.timeout(1000).emit("my-event", (err, val) => { + * // two arguments there (the "err" argument is not properly typed) + * }); + * + */ +export type DecorateAcknowledgements = { + [K in keyof E]: E[K] extends (...args: infer Params) => infer Result + ? (...args: PrependTimeoutError) => Result + : E[K]; +}; + +export type DecorateAcknowledgementsWithTimeoutAndMultipleResponses = { + [K in keyof E]: E[K] extends (...args: infer Params) => infer Result + ? (...args: ExpectMultipleResponses>) => Result + : E[K]; +}; + +export type DecorateAcknowledgementsWithMultipleResponses = { + [K in keyof E]: E[K] extends (...args: infer Params) => infer Result + ? (...args: ExpectMultipleResponses) => Result + : E[K]; +};