From 586b6acbbc38ce7302c27dc6e40d1439a8515e69 Mon Sep 17 00:00:00 2001 From: MiaoWoo Date: Tue, 3 Aug 2021 16:59:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0WebSocket=20=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: MiaoWoo --- packages/websocket/src/client/index.ts | 8 +- .../websocket/src/engine.io-parser/commons.ts | 21 + .../src/engine.io-parser/decodePacket.ts | 48 + .../src/engine.io-parser/encodePacket.ts | 26 + .../websocket/src/engine.io-parser/index.ts | 42 + packages/websocket/src/engine.io/index.ts | 27 + packages/websocket/src/engine.io/server.ts | 690 +++++++++++++++ packages/websocket/src/engine.io/socket.ts | 530 +++++++++++ packages/websocket/src/engine.io/transport.ts | 121 +++ .../src/engine.io/transports/index.ts | 3 + .../src/engine.io/transports/websocket.ts | 116 +++ packages/websocket/src/index.ts | 6 +- packages/websocket/src/netty/client.ts | 26 +- packages/websocket/src/netty/httprequest.ts | 5 +- packages/websocket/src/netty/index.ts | 93 +- .../src/netty/text_websocket_frame.ts | 6 +- .../websocket/src/netty/websocket_detect.ts | 3 +- .../websocket/src/netty/websocket_handler.ts | 7 +- packages/websocket/src/server/client.ts | 7 + packages/websocket/src/server/index.ts | 116 ++- packages/websocket/src/server/request.ts | 23 + packages/websocket/src/socket-io/adapter.ts | 164 ---- packages/websocket/src/socket-io/client.ts | 360 -------- packages/websocket/src/socket-io/constants.ts | 9 - packages/websocket/src/socket-io/index.ts | 677 -------------- packages/websocket/src/socket-io/namespace.ts | 242 ----- packages/websocket/src/socket-io/packet.ts | 11 - .../src/socket-io/parent-namespace.ts | 40 - packages/websocket/src/socket-io/parser.ts | 164 ---- packages/websocket/src/socket-io/socket.ts | 491 ----------- packages/websocket/src/socket-io/types.ts | 18 - .../websocket/src/socket.io-adapter/index.ts | 279 ++++++ .../websocket/src/socket.io-parser/binary.ts | 78 ++ .../websocket/src/socket.io-parser/index.ts | 316 +++++++ .../src/socket.io-parser/is-binary.ts | 65 ++ .../src/socket.io/broadcast-operator.ts | 320 +++++++ packages/websocket/src/socket.io/client.ts | 331 +++++++ packages/websocket/src/socket.io/index.ts | 825 ++++++++++++++++++ packages/websocket/src/socket.io/namespace.ts | 407 +++++++++ .../src/socket.io/parent-namespace.ts | 72 ++ packages/websocket/src/socket.io/socket.ts | 727 +++++++++++++++ .../websocket/src/socket.io/typed-events.ts | 180 ++++ packages/websocket/src/tomcat/client.ts | 28 +- packages/websocket/src/tomcat/index.ts | 78 +- packages/websocket/src/transport.ts | 35 - 45 files changed, 5465 insertions(+), 2376 deletions(-) create mode 100644 packages/websocket/src/engine.io-parser/commons.ts create mode 100644 packages/websocket/src/engine.io-parser/decodePacket.ts create mode 100644 packages/websocket/src/engine.io-parser/encodePacket.ts create mode 100644 packages/websocket/src/engine.io-parser/index.ts create mode 100644 packages/websocket/src/engine.io/index.ts create mode 100644 packages/websocket/src/engine.io/server.ts create mode 100644 packages/websocket/src/engine.io/socket.ts create mode 100644 packages/websocket/src/engine.io/transport.ts create mode 100644 packages/websocket/src/engine.io/transports/index.ts create mode 100644 packages/websocket/src/engine.io/transports/websocket.ts create mode 100644 packages/websocket/src/server/client.ts create mode 100644 packages/websocket/src/server/request.ts delete mode 100644 packages/websocket/src/socket-io/adapter.ts delete mode 100644 packages/websocket/src/socket-io/client.ts delete mode 100644 packages/websocket/src/socket-io/constants.ts delete mode 100644 packages/websocket/src/socket-io/index.ts delete mode 100644 packages/websocket/src/socket-io/namespace.ts delete mode 100644 packages/websocket/src/socket-io/packet.ts delete mode 100644 packages/websocket/src/socket-io/parent-namespace.ts delete mode 100644 packages/websocket/src/socket-io/parser.ts delete mode 100644 packages/websocket/src/socket-io/socket.ts delete mode 100644 packages/websocket/src/socket-io/types.ts create mode 100644 packages/websocket/src/socket.io-adapter/index.ts create mode 100644 packages/websocket/src/socket.io-parser/binary.ts create mode 100644 packages/websocket/src/socket.io-parser/index.ts create mode 100644 packages/websocket/src/socket.io-parser/is-binary.ts create mode 100644 packages/websocket/src/socket.io/broadcast-operator.ts create mode 100644 packages/websocket/src/socket.io/client.ts create mode 100644 packages/websocket/src/socket.io/index.ts create mode 100644 packages/websocket/src/socket.io/namespace.ts create mode 100644 packages/websocket/src/socket.io/parent-namespace.ts create mode 100644 packages/websocket/src/socket.io/socket.ts create mode 100644 packages/websocket/src/socket.io/typed-events.ts delete mode 100644 packages/websocket/src/transport.ts diff --git a/packages/websocket/src/client/index.ts b/packages/websocket/src/client/index.ts index 19e82c03..7cfbb9d9 100644 --- a/packages/websocket/src/client/index.ts +++ b/packages/websocket/src/client/index.ts @@ -23,7 +23,7 @@ export class WebSocketManager { } } -export const managers = new WebSocketManager() +export const manager = new WebSocketManager() export class WebSocket extends EventEmitter { public static CONNECTING = 0 @@ -31,6 +31,7 @@ export class WebSocket extends EventEmitter { public static CLOSING = 2 public static CLOSED = 3 public binaryType: 'blob' | 'arraybuffer' + protected manager: WebSocketManager protected _url: string protected _headers: WebSocketHeader = {} @@ -39,6 +40,7 @@ export class WebSocket extends EventEmitter { constructor(url: string, subProtocol: string = '', headers: WebSocketHeader = {}) { super() + this.manager = manager this._url = url this._headers = headers try { @@ -51,12 +53,12 @@ export class WebSocket extends EventEmitter { } this.client.on('open', (event) => { this.onopen?.(event) - managers.add(this) + manager.add(this) }) this.client.on('message', (event) => this.onmessage?.(event)) this.client.on('close', (event) => { this.onclose?.(event) - managers.del(this) + manager.del(this) }) this.client.on('error', (event) => this.onerror?.(event)) setTimeout(() => this.client.connect(), 20) diff --git a/packages/websocket/src/engine.io-parser/commons.ts b/packages/websocket/src/engine.io-parser/commons.ts new file mode 100644 index 00000000..9928c92b --- /dev/null +++ b/packages/websocket/src/engine.io-parser/commons.ts @@ -0,0 +1,21 @@ +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) +Object.keys(PACKET_TYPES).forEach(key => { + PACKET_TYPES_REVERSE[PACKET_TYPES[key]] = key +}) + +const ERROR_PACKET = { type: "error", data: "parser error" } + +export = { + PACKET_TYPES, + PACKET_TYPES_REVERSE, + ERROR_PACKET +} diff --git a/packages/websocket/src/engine.io-parser/decodePacket.ts b/packages/websocket/src/engine.io-parser/decodePacket.ts new file mode 100644 index 00000000..9387ffff --- /dev/null +++ b/packages/websocket/src/engine.io-parser/decodePacket.ts @@ -0,0 +1,48 @@ +const { PACKET_TYPES_REVERSE, ERROR_PACKET } = require("./commons") + +export const decodePacket = (encodedPacket, binaryType) => { + 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, binaryType) => { + switch (binaryType) { + case "arraybuffer": + return Buffer.isBuffer(data) ? toArrayBuffer(data) : data + case "nodebuffer": + default: + return data // assuming the data is already a Buffer + } +} + +const toArrayBuffer = buffer => { + 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 +} diff --git a/packages/websocket/src/engine.io-parser/encodePacket.ts b/packages/websocket/src/engine.io-parser/encodePacket.ts new file mode 100644 index 00000000..a5c16491 --- /dev/null +++ b/packages/websocket/src/engine.io-parser/encodePacket.ts @@ -0,0 +1,26 @@ +const { PACKET_TYPES } = require("./commons") + +export const encodePacket = ({ type, data }, supportsBinary, callback) => { + console.trace('encodePacket', type, JSON.stringify(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) + } +} + +// only 'message' packets can contain binary, so the type prefix is not needed +const encodeBuffer = (data, supportsBinary) => { + return supportsBinary ? data : "b" + data.toString("base64") +} diff --git a/packages/websocket/src/engine.io-parser/index.ts b/packages/websocket/src/engine.io-parser/index.ts new file mode 100644 index 00000000..d68fed36 --- /dev/null +++ b/packages/websocket/src/engine.io-parser/index.ts @@ -0,0 +1,42 @@ +import { encodePacket } from "./encodePacket" +import { decodePacket } from "./decodePacket" + +const SEPARATOR = String.fromCharCode(30) // see https://en.wikipedia.org/wiki/Delimiter#ASCII_delimited_text + +const encodePayload = (packets, callback) => { + // 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)) + } + }) + }) +} + +const decodePayload = (encodedPayload, binaryType) => { + 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 +} + +export default { + protocol: 4, + encodePacket, + encodePayload, + decodePacket, + decodePayload +} diff --git a/packages/websocket/src/engine.io/index.ts b/packages/websocket/src/engine.io/index.ts new file mode 100644 index 00000000..7cffba16 --- /dev/null +++ b/packages/websocket/src/engine.io/index.ts @@ -0,0 +1,27 @@ +/** + * Module dependencies. + */ + +import * as server from "../server" +// const http = require("http") +// const Server = require("./server") +import { Server } from './server' + +/** + * Captures upgrade requests for a http.Server. + * + * @param {http.Server} server + * @param {Object} options + * @return {Server} engine server + * @api public + */ + +function attach(srv, options) { + const engine = new Server(options) + engine.attach(server.attach(srv, options), options) + return engine +} + +export = { + attach +} diff --git a/packages/websocket/src/engine.io/server.ts b/packages/websocket/src/engine.io/server.ts new file mode 100644 index 00000000..d0dff54a --- /dev/null +++ b/packages/websocket/src/engine.io/server.ts @@ -0,0 +1,690 @@ +const qs = require("querystring") +const parse = require("url").parse +// const base64id = require("base64id") +import transports from './transports' +import { EventEmitter } from 'events' +// const EventEmitter = require("events").EventEmitter +import { Socket } from './socket' +// const debug = require("debug")("engine") +const debug = function (...args) { } +// const cookieMod = require("cookie") + +// const DEFAULT_WS_ENGINE = require("ws").Server; +import { WebSocketServer } from '../server' +import { Transport } from './transport' +const DEFAULT_WS_ENGINE = WebSocketServer + +import { Request } from '../server/request' +import { WebSocketClient } from '../server/client' + +export class Server extends EventEmitter { + public static errors = { + UNKNOWN_TRANSPORT: 0, + UNKNOWN_SID: 1, + BAD_HANDSHAKE_METHOD: 2, + BAD_REQUEST: 3, + FORBIDDEN: 4, + UNSUPPORTED_PROTOCOL_VERSION: 5 + } + + public static errorMessages = { + 0: "Transport unknown", + 1: "Session ID unknown", + 2: "Bad handshake method", + 3: "Bad request", + 4: "Forbidden", + 5: "Unsupported protocol version" + } + + private clients = {} + private clientsCount = 0 + public opts: any + + private corsMiddleware: any + + private ws: any + private perMessageDeflate: any + + constructor(opts: any = {}) { + super() + 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: "/", + // 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() + } + + // /** + // * Initialize websocket server + // * + // * @api private + // */ + // init() { + // if (!~this.opts.transports.indexOf("websocket")) return + + // if (this.ws) this.ws.close() + + // this.ws = new this.opts.wsEngine({ + // noServer: true, + // clientTracking: false, + // perMessageDeflate: this.opts.perMessageDeflate, + // maxPayload: this.opts.maxHttpBufferSize + // }) + + // if (typeof this.ws.on === "function") { + // this.ws.on("headers", (headersArray, req) => { + // // note: 'ws' uses an array of headers, while Engine.IO uses an object (response.writeHead() accepts both formats) + // // we could also try to parse the array and then sync the values, but that will be error-prone + // const additionalHeaders = {} + + // const isInitialRequest = !req._query.sid + // if (isInitialRequest) { + // this.emit("initial_headers", additionalHeaders, req) + // } + + // this.emit("headers", additionalHeaders, req) + + // Object.keys(additionalHeaders).forEach(key => { + // headersArray.push(`${key}: ${additionalHeaders[key]}`) + // }) + // }) + // } + // } + + /** + * Returns a list of available transports for upgrade given a certain transport. + * + * @return {Array} + * @api public + */ + upgrades(transport): Array { + if (!this.opts.allowUpgrades) return [] + return transports[transport].upgradesTo || [] + } + + // /** + // * Verifies a request. + // * + // * @param {http.IncomingMessage} + // * @return {Boolean} whether the request is valid + // * @api private + // */ + // 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 (!this.opts.allowRequest) return fn() + + // return this.opts.allowRequest(req, (message, success) => { + // if (!success) { + // return fn(Server.errors.FORBIDDEN, { + // message + // }) + // } + // fn() + // }) + // } + + // fn() + // } + + /** + * Prepares a request by processing the query string. + * + * @api private + */ + prepare(req) { + // try to leverage pre-existing `req._query` (e.g: from connect) + if (!req._query) { + req._query = ~req.url.indexOf("?") ? qs.parse(parse(req.url).query) : {} + } + } + + /** + * Closes all clients. + * + * @api public + */ + close() { + debug("closing all open clients") + for (let i in this.clients) { + if (this.clients.hasOwnProperty(i)) { + this.clients[i].close(true) + } + } + if (this.ws) { + debug("closing webSocketServer") + this.ws.close() + // don't delete this.ws because it can be used again if the http server starts listening again + } + return this + } + + // /** + // * Handles an Engine.IO HTTP request. + // * + // * @param {http.IncomingMessage} request + // * @param {http.ServerResponse|http.OutgoingMessage} response + // * @api 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) + // } + // } + + /** + * generate a socket id. + * Overwrite this method to generate your custom socket id + * + * @param {Object} request object + * @api public + */ + generateId(req) { + return req.id + } + + /** + * Handshakes a new client. + * + * @param {String} transport name + * @param {Object} request object + * @param {Function} closeConnection + * + * @api private + */ + // @java-patch sync handshake + handshake(transportName, req, closeConnection: (code: number) => void) { + console.debug('engine.io server handshake transport', transportName, 'from', req.url) + const protocol = req._query.EIO === "4" ? 4 : 3 // 3rd revision by default + if (protocol === 3 && !this.opts.allowEIO3) { + debug("unsupported protocol version") + this.emit("connection_error", { + req, + code: Server.errors.UNSUPPORTED_PROTOCOL_VERSION, + message: + Server.errorMessages[Server.errors.UNSUPPORTED_PROTOCOL_VERSION], + context: { + protocol + } + }) + closeConnection(Server.errors.UNSUPPORTED_PROTOCOL_VERSION) + return + } + + let id + try { + id = this.generateId(req) + } catch (e) { + console.debug("error while generating an id") + this.emit("connection_error", { + req, + code: Server.errors.BAD_REQUEST, + message: Server.errorMessages[Server.errors.BAD_REQUEST], + context: { + name: "ID_GENERATION_ERROR", + error: e + } + }) + closeConnection(Server.errors.BAD_REQUEST) + return + } + + console.debug('engine.io server handshaking client "' + id + '"') + + try { + var transport: Transport = new transports[transportName](req) + if ("websocket" !== transportName) { + throw new Error('Unsupport polling at MiaoScript!') + } + // if ("polling" === transportName) { + // transport.maxHttpBufferSize = this.opts.maxHttpBufferSize + // transport.httpCompression = this.opts.httpCompression + // } else if ("websocket" === transportName) { + transport.perMessageDeflate = this.opts.perMessageDeflate + // } + + if (req._query && req._query.b64) { + transport.supportsBinary = false + } else { + transport.supportsBinary = true + } + } catch (e) { + console.ex(e) + this.emit("connection_error", { + req, + code: Server.errors.BAD_REQUEST, + message: Server.errorMessages[Server.errors.BAD_REQUEST], + context: { + name: "TRANSPORT_HANDSHAKE_ERROR", + error: e + } + }) + closeConnection(Server.errors.BAD_REQUEST) + return + } + console.debug(`engine.io server create socket ${id} from transport ${transport.name} protocol ${protocol}`) + const socket = new Socket(id, this, transport, req, protocol) + + transport.on("headers", (headers, req) => { + const isInitialRequest = !req._query.sid + + if (isInitialRequest) { + if (this.opts.cookie) { + headers["Set-Cookie"] = [ + // cookieMod.serialize(this.opts.cookie.name, id, this.opts.cookie) + ] + } + this.emit("initial_headers", headers, req) + } + this.emit("headers", headers, req) + }) + + transport.onRequest(req) + + this.clients[id] = socket + this.clientsCount++ + + socket.once("close", () => { + delete this.clients[id] + this.clientsCount-- + }) + this.emit("connection", socket) + } + + // /** + // * Handles an Engine.IO HTTP Upgrade. + // * + // * @api public + // */ + // handleUpgrade(req, socket, upgradeHead) { + // 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) // eslint-disable-line node/no-deprecated-api + // 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 + */ + onWebSocket(req: Request, socket, websocket: WebSocketClient) { + websocket.on("error", onUpgradeError) + + if ( + transports[req._query.transport] !== undefined && + !transports[req._query.transport].prototype.handlesUpgrades + ) { + console.debug("transport doesnt handle upgraded requests") + 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) { + console.debug("upgrade attempt for closed client") + websocket.close() + } else if (client.upgrading) { + console.debug("transport has already been trying to upgrade") + websocket.close() + } else if (client.upgraded) { + console.debug("transport had already been upgraded") + websocket.close() + } else { + console.debug("upgrading existing transport") + + // transport error handling takes over + websocket.removeListener("error", onUpgradeError) + + const transport = new transports[req._query.transport](req) + if (req._query && req._query.b64) { + transport.supportsBinary = false + } else { + transport.supportsBinary = true + } + transport.perMessageDeflate = this.perMessageDeflate + client.maybeUpgrade(transport) + } + } else { + // transport error handling takes over + websocket.removeListener("error", onUpgradeError) + + // const closeConnection = (errorCode, errorContext) => + // abortUpgrade(socket, errorCode, errorContext) + this.handshake(req._query.transport, req, () => { }) + } + + function onUpgradeError() { + console.debug("websocket error before upgrade") + // websocket.close() not needed + } + } + + /** + * Captures upgrade requests for a http.Server. + * + * @param {http.Server} server + * @param {Object} options + * @api public + */ + attach(server, options: any = {}) { + // let path = (options.path || "/engine.io").replace(/\/$/, "") + + // const destroyUpgradeTimeout = options.destroyUpgradeTimeout || 1000 + + // // normalize path + // path += "/" + + // function check(req) { + // return path === req.url.substr(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 () { + // if (socket.writable && socket.bytesWritten <= 0) { + // return socket.end() + // } + // }, destroyUpgradeTimeout) + // } + // }) + // } + } +} + +// /** +// * Close the HTTP long-polling request +// * +// * @param res - the response object +// * @param errorCode - the error code +// * @param errorContext - additional error context +// * +// * @api private +// */ + +// function abortRequest(res, errorCode, errorContext) { +// 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 +// }) +// ) +// } + +// /** +// * Close the WebSocket connection +// * +// * @param {net.Socket} socket +// * @param {string} errorCode - the error code +// * @param {object} errorContext - additional error context +// * +// * @api private +// */ + +// function abortUpgrade(socket, errorCode, errorContext: any = {}) { +// 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() +// } + +// module.exports = Server + +/* eslint-disable */ + +// /** +// * From https://github.com/nodejs/node/blob/v8.4.0/lib/_http_common.js#L303-L354 +// * +// * True if val contains an invalid field-vchar +// * field-value = *( field-content / obs-fold ) +// * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] +// * field-vchar = VCHAR / obs-text +// * +// * checkInvalidHeaderChar() is currently designed to be inlinable by v8, +// * so take care when making changes to the implementation so that the source +// * code size does not exceed v8's default max_inlined_source_size setting. +// **/ +// // prettier-ignore +// const validHdrChars = [ +// 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, // 0 - 15 +// 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16 - 31 +// 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 32 - 47 +// 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 48 - 63 +// 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 64 - 79 +// 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 80 - 95 +// 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 96 - 111 +// 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, // 112 - 127 +// 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 128 ... +// 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, +// 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, +// 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, +// 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, +// 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, +// 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, +// 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 // ... 255 +// ] + +// 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 +// } +// 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 +// } diff --git a/packages/websocket/src/engine.io/socket.ts b/packages/websocket/src/engine.io/socket.ts new file mode 100644 index 00000000..413e75c8 --- /dev/null +++ b/packages/websocket/src/engine.io/socket.ts @@ -0,0 +1,530 @@ +import { EventEmitter } from "events" +import { Server } from "./server" +import { Transport } from "./transport" +import type { Request } from "../server/request" +// const debug = require("debug")("engine:socket") + +export class Socket extends EventEmitter { + public id: string + private server: Server + private upgrading = false + private upgraded = false + public readyState = "opening" + private writeBuffer = [] + private packetsFn = [] + private sentCallbackFn = [] + private cleanupFn = [] + public request: Request + public protocol: number + public remoteAddress: any + public transport: Transport + + private checkIntervalTimer: NodeJS.Timeout + private upgradeTimeoutTimer: NodeJS.Timeout + private pingTimeoutTimer: NodeJS.Timeout + private pingIntervalTimer: NodeJS.Timeout + + /** + * Client class (abstract). + * + * @api private + */ + constructor(id: string, server: Server, transport: Transport, req: Request, protocol: number) { + super() + this.id = id + this.server = server + 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 + } + + this.checkIntervalTimer = null + this.upgradeTimeoutTimer = null + this.pingTimeoutTimer = null + this.pingIntervalTimer = null + + this.setTransport(transport) + this.onOpen() + } + + /** + * Called upon transport considered open. + * + * @api 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 + }) + ) + + if (this.server.opts.initialPacket) { + this.sendPacket("message", this.server.opts.initialPacket) + } + + this.emit("open") + + 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 + */ + onPacket(packet: { type: any; data: any }) { + if ("open" !== this.readyState) { + console.debug("packet received with closed socket") + return + } + // 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 + + case "pong": + if (this.transport.protocol === 3) { + this.onError("invalid heartbeat direction") + return + } + // debug("got pong") + this.schedulePing() + this.emit("heartbeat") + break + + case "error": + this.onClose("parse error") + break + + case "message": + this.emit("data", packet.data) + this.emit("message", packet.data) + break + } + } + + /** + * Called upon transport error. + * + * @param {Error} error object + * @api private + */ + onError(err: string) { + // debug("transport error") + this.onClose("transport error", err) + } + + /** + * Pings client every `this.pingInterval` and expects response + * within `this.pingTimeout` or closes connection. + * + * @api private + */ + schedulePing() { + clearTimeout(this.pingIntervalTimer) + 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 + */ + resetPingTimeout(timeout: number) { + 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 + */ + setTransport(transport: Transport) { + console.debug(`engine.io socket ${this.id} set transport ${transport.name}`) + 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 + */ + maybeUpgrade(transport: Transport) { + console.debug( + 'might upgrade socket transport from "', this.transport.name, '" to "', transport.name, '"' + ) + + this.upgrading = true + + // set transport upgrade timer + this.upgradeTimeoutTimer = setTimeout(() => { + console.debug("client did not complete upgrade - closing transport") + cleanup() + if ("open" === transport.readyState) { + transport.close() + } + }, this.server.opts.upgradeTimeout) + + const onPacket = (packet: { type: string; data: string }) => { + if ("ping" === packet.type && "probe" === packet.data) { + 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: string) => { + // 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 + */ + clearTransport() { + let cleanup: () => void + + const toCleanUp = this.cleanupFn.length + + for (let i = 0; i < toCleanUp; i++) { + cleanup = this.cleanupFn.shift() + cleanup() + } + + // silence further transport errors and prevent uncaught exceptions + this.transport.on("error", function () { + // debug("error triggered by discarded transport") + }) + + // ensure transport won't stay open + this.transport.close() + + clearTimeout(this.pingTimeoutTimer) + } + + /** + * Called upon transport considered closed. + * Possible reasons: `ping timeout`, `client error`, `parse error`, + * `transport error`, `server close`, `transport close` + */ + onClose(reason: string, description?: string) { + if ("closed" !== this.readyState) { + this.readyState = "closed" + + // 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 + */ + 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 {String} message + * @param {Object} options + * @param {Function} callback + * @return {Socket} for chaining + * @api public + */ + send(data: any, options: any, callback: any) { + this.sendPacket("message", data, options, callback) + return this + } + + write(data: any, options: any, callback?: any) { + this.sendPacket("message", data, options, callback) + return this + } + + /** + * Sends a packet. + * + * @param {String} packet type + * @param {String} optional, data + * @param {Object} options + * @api private + */ + sendPacket(type: string, data?: string, options?: { compress?: any }, callback?: undefined) { + if ("function" === typeof options) { + callback = options + options = null + } + + options = options || {} + options.compress = false !== options.compress + + if ("closing" !== this.readyState && "closed" !== this.readyState) { + // console.debug('sending packet "%s" (%s)', type, data) + + const packet: any = { + type: type, + options: 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 + */ + flush() { + if ( + "closed" !== this.readyState && + this.transport.writable && + this.writeBuffer.length + ) { + console.trace("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 + */ + 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} optional, discard + * @return {Socket} for chaining + * @api public + */ + close(discard?: any) { + 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 + */ + closeTransport(discard: any) { + 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 new file mode 100644 index 00000000..d6367b90 --- /dev/null +++ b/packages/websocket/src/engine.io/transport.ts @@ -0,0 +1,121 @@ +import { EventEmitter } from 'events' +import parser_v4 from "../engine.io-parser" +import type { WebSocketClient } from '../server/client' +/** + * Noop function. + * + * @api private + */ + +function noop() { } + +export abstract class Transport extends EventEmitter { + public sid: string + public req /**http.IncomingMessage */ + public socket: WebSocketClient + public writable: boolean + public readyState: string + public discarded: boolean + public protocol: Number + public parser: any + public perMessageDeflate: any + public supportsBinary: boolean = false + + /** + * 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 = parser_v4//= 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 private + */ + onRequest(req) { + console.debug(`engine.io transport ${this.socket.id} setting request`, JSON.stringify(req)) + 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 private + */ + onError(msg: string, desc?: string) { + if (this.listeners("error").length) { + const err: any = new Error(msg) + err.type = "TransportError" + err.description = desc + this.emit("error", err) + } else { + console.debug(`ignored transport error ${msg} (${desc})`) + } + } + + /** + * Called with parsed out a packets from the data stream. + * + * @param {Object} packet + * @api private + */ + onPacket(packet) { + this.emit("packet", packet) + } + + /** + * Called with the encoded packet data. + * + * @param {String} data + * @api private + */ + onData(data) { + this.onPacket(this.parser.decodePacket(data)) + } + + /** + * Called upon transport close. + * + * @api private + */ + onClose() { + this.readyState = "closed" + this.emit("close") + } + abstract get supportsFraming() + abstract get name() + abstract send(...args: any[]) + abstract doClose(d: Function) +} diff --git a/packages/websocket/src/engine.io/transports/index.ts b/packages/websocket/src/engine.io/transports/index.ts new file mode 100644 index 00000000..1834ad2a --- /dev/null +++ b/packages/websocket/src/engine.io/transports/index.ts @@ -0,0 +1,3 @@ +export default { + websocket: require("./websocket").WebSocket +} diff --git a/packages/websocket/src/engine.io/transports/websocket.ts b/packages/websocket/src/engine.io/transports/websocket.ts new file mode 100644 index 00000000..933ed79e --- /dev/null +++ b/packages/websocket/src/engine.io/transports/websocket.ts @@ -0,0 +1,116 @@ +import { Transport } from '../transport' +// const debug = require("debug")("engine:ws") + +export class WebSocket extends Transport { + public perMessageDeflate: any + + /** + * WebSocket transport + * + * @param {http.IncomingMessage} + * @api public + */ + constructor(req) { + super(req) + this.socket = req.websocket + this.socket.on("message", this.onData.bind(this)) + 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 + } + + /** + * Processes the incoming data. + * + * @param {String} encoded packet + * @api private + */ + onData(data) { + // debug('received "%s"', data) + super.onData(data) + } + + /** + * Writes a packet payload. + * + * @param {Array} packets + * @api private + */ + send(packets) { + // console.log('WebSocket send packets', JSON.stringify(packets)) + const packet = packets.shift() + if (typeof packet === "undefined") { + this.writable = true + this.emit("drain") + return + } + + // always creates a new object since ws modifies it + const opts: any = {} + if (packet.options) { + opts.compress = packet.options.compress + } + + const send = data => { + if (this.perMessageDeflate) { + const len = + "string" === typeof data ? Buffer.byteLength(data) : data.length + if (len < this.perMessageDeflate.threshold) { + opts.compress = false + } + } + console.trace('writing', 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) + } + } + + /** + * Closes the transport. + * + * @api private + */ + doClose(fn) { + // debug("closing") + this.socket.close() + fn && fn() + } +} diff --git a/packages/websocket/src/index.ts b/packages/websocket/src/index.ts index 831fec2d..68be7294 100644 --- a/packages/websocket/src/index.ts +++ b/packages/websocket/src/index.ts @@ -1,7 +1,7 @@ /// /// -import { Server, ServerOptions } from './socket-io' +import { Server, ServerOptions } from './socket.io' interface SocketIOStatic { /** @@ -44,7 +44,7 @@ let io: SocketStatic = function (pipeline: any, options: Partial) } io.Instance = Symbol("@ccms/websocket") export default io -export * from './socket-io' +export * from './socket.io' export * from './client' export * from './server' -export * from './transport' +export * from './engine.io/transport' diff --git a/packages/websocket/src/netty/client.ts b/packages/websocket/src/netty/client.ts index 616adb9e..be267ccc 100644 --- a/packages/websocket/src/netty/client.ts +++ b/packages/websocket/src/netty/client.ts @@ -1,24 +1,26 @@ -import { Transport } from '../transport' -import { AttributeKeys } from './constants' +import { WebSocketClient } from '../server/client' const TextWebSocketFrame = Java.type('io.netty.handler.codec.http.websocketx.TextWebSocketFrame') -export class NettyClient extends Transport { +export class NettyClient extends WebSocketClient { private channel: any - constructor(server: any, channel: any) { - super(server) - this.remoteAddress = channel.remoteAddress() + '' - this.request = channel.attr(AttributeKeys.Request).get() - - this._id = channel.id() + '' + constructor(channel: any) { + super() + this.id = channel.id() + '' this.channel = channel } - doSend(text: string) { - this.channel.writeAndFlush(new TextWebSocketFrame(text)) + send(text: string, opts?: any, callback?: (err?: Error) => void) { + try { + this.channel.writeAndFlush(new TextWebSocketFrame(text)) + callback?.() + } catch (error) { + callback?.(error) + } } - doClose() { + + close() { this.channel.close() } } diff --git a/packages/websocket/src/netty/httprequest.ts b/packages/websocket/src/netty/httprequest.ts index 89fc9a26..d7eb07f4 100644 --- a/packages/websocket/src/netty/httprequest.ts +++ b/packages/websocket/src/netty/httprequest.ts @@ -1,6 +1,7 @@ +import { JavaServerOptions } from '../server' + import { HttpRequestHandlerAdapter } from './adapter' import { AttributeKeys } from './constants' -import { ServerOptions } from 'socket-io' const DefaultHttpResponse = Java.type('io.netty.handler.codec.http.DefaultHttpResponse') const DefaultFullHttpResponse = Java.type('io.netty.handler.codec.http.DefaultFullHttpResponse') @@ -18,7 +19,7 @@ const ChannelFutureListener = Java.type('io.netty.channel.ChannelFutureListener' export class HttpRequestHandler extends HttpRequestHandlerAdapter { private ws: string private root: string - constructor(options: ServerOptions) { + constructor(options: JavaServerOptions) { super() this.root = options.root this.ws = options.path diff --git a/packages/websocket/src/netty/index.ts b/packages/websocket/src/netty/index.ts index 1432e5d7..86bb4336 100644 --- a/packages/websocket/src/netty/index.ts +++ b/packages/websocket/src/netty/index.ts @@ -1,65 +1,70 @@ -import { EventEmitter } from 'events' - -import { ServerOptions } from '../socket-io' -import { ServerEvent } from '../socket-io/constants' +import { JavaServerOptions, ServerEvent, WebSocketServer } from '../server' +import { Request } from '../server/request' import { NettyClient } from './client' -import { Keys } from './constants' +import { AttributeKeys, Keys } from './constants' import { WebSocketDetect } from './websocket_detect' import { WebSocketHandler } from './websocket_handler' -class NettyWebSocketServer extends EventEmitter { - private pipeline: any - private clients: Map +class NettyWebSocketServer extends WebSocketServer { + constructor(pipeline: any, options: JavaServerOptions) { + super(pipeline, options) + } - constructor(pipeline: any, options: ServerOptions) { - super() - this.clients = new Map() - this.pipeline = pipeline - let connectEvent = options.event - try { this.pipeline.remove(Keys.Detect) } catch (error) { } - this.pipeline.addFirst(Keys.Detect, new WebSocketDetect(connectEvent).getHandler()) + protected initialize() { + let connectEvent = this.options.event + try { this.instance.remove(Keys.Detect) } catch (error) { } + this.instance.addFirst(Keys.Detect, new WebSocketDetect(connectEvent).getHandler()) connectEvent.on(ServerEvent.detect, (ctx, channel) => { - channel.pipeline().addFirst(Keys.Handler, new WebSocketHandler(options).getHandler()) + channel.pipeline().addFirst(Keys.Handler, new WebSocketHandler(this.options).getHandler()) ctx.fireChannelRead(channel) }) connectEvent.on(ServerEvent.connect, (ctx) => { - let cid = ctx?.channel().id() + '' - let nettyClient = new NettyClient(this, ctx.channel()) - this.clients.set(cid, nettyClient) - this.emit(ServerEvent.connect, nettyClient) + this.onconnect(ctx) }) connectEvent.on(ServerEvent.message, (ctx, msg) => { - let cid = ctx?.channel().id() + '' - if (this.clients.has(cid)) { - this.emit(ServerEvent.message, this.clients.get(cid), msg.text()) - } else if (global.debug) { - console.error(`unknow client ${ctx} reciver message ${msg.text()}`) - } + this.onmessage(ctx, msg.text()) }) connectEvent.on(ServerEvent.disconnect, (ctx, cause) => { - let cid = ctx?.channel().id() + '' - if (this.clients.has(cid)) { - this.emit(ServerEvent.disconnect, this.clients.get(cid), cause) - } else if (global.debug) { - console.error(`unknow client ${ctx} disconnect cause ${cause}`) - } + this.ondisconnect(ctx, cause) }) - connectEvent.on(ServerEvent.error, (ctx, cause) => { - let cid = ctx?.channel().id() + '' - if (this.clients.has(cid)) { - this.emit(ServerEvent.error, this.clients.get(cid), cause) - } else if (global.debug) { - console.error(`unknow client ${ctx} cause error ${cause}`) - console.ex(cause) - } + connectEvent.on(ServerEvent.error, (ctx, error) => { + this.onerror(ctx, error) }) } - close() { - if (this.pipeline.names().contains(Keys.Detect)) { - this.pipeline.remove(Keys.Detect) + + protected getId(ctx: any) { + try { + return ctx.channel().id() + '' + } catch (error) { + console.log(Object.toString.apply(ctx)) + console.ex(error) + } + } + + protected getRequest(ctx) { + let channel = ctx.channel() + let req = channel.attr(AttributeKeys.Request).get() + let headers = {} + let nativeHeaders = req.headers() + nativeHeaders.forEach(function (header) { + headers[header.getKey()] = header.getValue() + }) + let request = new Request(req.uri(), req.method().name(), headers) + request.connection = { + remoteAddress: channel.remoteAddress() + '' + } + return request + } + + protected getSocket(ctx) { + return new NettyClient(ctx.channel()) + } + + protected doClose() { + if (this.instance.names().contains(Keys.Detect)) { + this.instance.remove(Keys.Detect) } - this.clients.forEach(client => client.close()) } } diff --git a/packages/websocket/src/netty/text_websocket_frame.ts b/packages/websocket/src/netty/text_websocket_frame.ts index deaec789..e0d1fd7f 100644 --- a/packages/websocket/src/netty/text_websocket_frame.ts +++ b/packages/websocket/src/netty/text_websocket_frame.ts @@ -1,11 +1,11 @@ import { EventEmitter } from 'events' -import { ServerOptions } from '../socket-io' -import { ServerEvent } from '../socket-io/constants' +import { JavaServerOptions, ServerEvent } from '../server' + import { TextWebSocketFrameHandlerAdapter } from './adapter' export class TextWebSocketFrameHandler extends TextWebSocketFrameHandlerAdapter { private event: EventEmitter - constructor(options: ServerOptions) { + constructor(options: JavaServerOptions) { super() this.event = options.event } diff --git a/packages/websocket/src/netty/websocket_detect.ts b/packages/websocket/src/netty/websocket_detect.ts index 6b29df01..4122a3fb 100644 --- a/packages/websocket/src/netty/websocket_detect.ts +++ b/packages/websocket/src/netty/websocket_detect.ts @@ -1,6 +1,7 @@ import { EventEmitter } from 'events' import { WebSocketHandlerAdapter } from "./adapter" -import { ServerEvent } from '../socket-io/constants' + +import { ServerEvent } from '../server' export class WebSocketDetect extends WebSocketHandlerAdapter { private event: EventEmitter diff --git a/packages/websocket/src/netty/websocket_handler.ts b/packages/websocket/src/netty/websocket_handler.ts index 8ef10f5e..1b40898b 100644 --- a/packages/websocket/src/netty/websocket_handler.ts +++ b/packages/websocket/src/netty/websocket_handler.ts @@ -1,5 +1,4 @@ -import { ServerOptions } from '../socket-io' -import { ServerEvent } from '../socket-io/constants' +import { JavaServerOptions, ServerEvent } from '../server' import { Keys } from './constants' import { HttpRequestHandler } from './httprequest' @@ -13,8 +12,8 @@ const HttpObjectAggregator = Java.type('io.netty.handler.codec.http.HttpObjectAg const WebSocketServerProtocolHandler = Java.type('io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler') export class WebSocketHandler extends WebSocketHandlerAdapter { - private options: ServerOptions - constructor(options: ServerOptions) { + private options: JavaServerOptions + constructor(options: JavaServerOptions) { super() this.options = options } diff --git a/packages/websocket/src/server/client.ts b/packages/websocket/src/server/client.ts new file mode 100644 index 00000000..e9c953f5 --- /dev/null +++ b/packages/websocket/src/server/client.ts @@ -0,0 +1,7 @@ +import { EventEmitter } from 'events' +export abstract class WebSocketClient extends EventEmitter { + public id: string + public _socket: any + abstract send(text: string, opts?: any, callback?: (err?: Error) => void) + abstract close() +} diff --git a/packages/websocket/src/server/index.ts b/packages/websocket/src/server/index.ts index 788f3f3d..c888d85e 100644 --- a/packages/websocket/src/server/index.ts +++ b/packages/websocket/src/server/index.ts @@ -1,52 +1,90 @@ import { EventEmitter } from 'events' +import { ServerOptions } from '../socket.io' +import { WebSocketClient } from './client' -import { Transport } from '../transport' +import type { Request } from './request' -interface ServerOptions { +export enum ServerEvent { + detect = 'detect', + request = 'request', + upgrade = 'upgrade', + connect = 'connect', + connection = 'connection', + message = 'message', + error = 'error', + disconnecting = 'disconnecting', + disconnect = 'disconnect', +} + +export interface JavaServerOptions extends ServerOptions { event?: EventEmitter root?: string - /** - * name of the path to capture - * @default "/socket.io" - */ - path: string } -interface WebSocketServerImpl extends EventEmitter { - close(): void -} - -export class WebSocketServer extends EventEmitter { - options: Partial - private websocketServer: WebSocketServerImpl - - constructor(instance: any, options: Partial) { +export abstract class WebSocketServer extends EventEmitter { + protected instance: any + protected options: JavaServerOptions + private clients: Map + constructor(instance: any, options: JavaServerOptions) { super() - if (!instance) { throw new Error('instance can\'t be undefiend!') } - this.options = Object.assign({ - event: new EventEmitter(), - path: '/ws', - root: root + '/wwwroot', - }, options) - this.selectServerImpl(instance) + this.instance = instance + this.options = options + this.clients = new Map() + console.debug('create websocket server from ' + this.constructor.name) + this.initialize() } - - on(event: "connect", cb: (transport: Transport) => void): this - on(event: "message", cb: (transport: Transport, text: string) => void): this - on(event: "disconnect", cb: (transport: Transport, reason: string) => void): this - on(event: "error", cb: (transport: Transport, cause: Error) => void): this - on(event: string, cb: (transport: Transport, extra?: any) => void): this { - this.websocketServer.on(event, cb) - return this + protected onconnect(handler: any) { + let id = this.getId(handler) + console.log('client', id, 'connect') + let request = this.getRequest(handler) + request.id = id + let websocket = this.getSocket(handler) + this.clients.set(this.getId(handler), websocket) + this.emit(ServerEvent.connect, request, websocket) } - - private selectServerImpl(instance: any) { - let WebSocketServerImpl = undefined - if (instance.class.name.startsWith('io.netty.channel')) { - WebSocketServerImpl = require("../netty").NettyWebSocketServer - } else { - WebSocketServerImpl = require("../tomcat").TomcatWebSocketServer + protected onmessage(handler: any, message: string) { + this.execute(handler, (websocket) => websocket.emit(ServerEvent.message, message)) + } + protected ondisconnect(handler: any, cause: string) { + this.execute(handler, (websocket) => websocket.emit(ServerEvent.disconnect, cause)) + } + protected onerror(handler: any, error: Error) { + if (global.debug) { + console.ex(error) } - this.websocketServer = new WebSocketServerImpl(instance, this.options) + this.execute(handler, (websocket) => websocket.emit(ServerEvent.error, error)) } + protected execute(handler: any, callback: (websocket: WebSocketClient) => void) { + let id = this.getId(handler) + if (this.clients.has(id)) { + this.clients.has(id) && callback(this.clients.get(id)) + } else { + console.debug('ignore execute', handler, 'callback', callback) + } + } + public close() { + this.clients.forEach(websocket => websocket.close()) + this.doClose() + } + protected abstract initialize(): void + protected abstract getId(handler: any): string + protected abstract getRequest(handler: any): Request + protected abstract getSocket(handler: any): WebSocketClient + protected abstract doClose(): void +} + +export const attach = (instance, options) => { + if (!instance) { throw new Error('instance can\'t be undefiend!') } + options = Object.assign({ + event: new EventEmitter(), + path: '/ws', + root: root + '/wwwroot', + }, options) + let WebSocketServerImpl = undefined + if (instance.class.name.startsWith('io.netty.channel')) { + WebSocketServerImpl = require("../netty").NettyWebSocketServer + } else { + WebSocketServerImpl = require("../tomcat").TomcatWebSocketServer + } + return new WebSocketServerImpl(instance, options) } diff --git a/packages/websocket/src/server/request.ts b/packages/websocket/src/server/request.ts new file mode 100644 index 00000000..45a4c9af --- /dev/null +++ b/packages/websocket/src/server/request.ts @@ -0,0 +1,23 @@ +import { WebSocketClient } from "./client" +interface HttpHeaders { + [name: string]: string +} +interface Connection { + remoteAddress: string +} +export class Request { + public id: string + public url: string + public method: string + public headers: HttpHeaders + public connection: Connection + public websocket: WebSocketClient + + public _query: any + + constructor(url: string, method = "GET", headers = {}) { + this.url = url + this.method = method + this.headers = headers + } +} diff --git a/packages/websocket/src/socket-io/adapter.ts b/packages/websocket/src/socket-io/adapter.ts deleted file mode 100644 index efd25120..00000000 --- a/packages/websocket/src/socket-io/adapter.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { EventEmitter } from 'events' -import { Namespace } from './namespace' -import { Parser } from './parser' -import { Socket } from './socket' - -export type SocketId = string -export type Room = string - -export interface BroadcastFlags { - volatile?: boolean - compress?: boolean - local?: boolean - broadcast?: boolean - binary?: boolean -} - -export interface BroadcastOptions { - rooms: Set - except?: Set - flags?: BroadcastFlags -} - -export class Adapter extends EventEmitter implements Adapter { - rooms: Map> - sids: Map> - private readonly encoder: Parser - parser: Parser - - constructor(readonly nsp: Namespace) { - super() - this.rooms = new Map() - this.sids = new Map() - this.parser = nsp.server._parser - this.encoder = this.parser - } - - /** - * Adds a socket to a list of room. - * - * @param {String} socket id - * @param {String} rooms - * @param {Function} callback - * @api public - */ - addAll(id: SocketId, rooms: Set): Promise | void { - for (const room of rooms) { - if (!this.sids.has(id)) { - this.sids.set(id, new Set()) - } - this.sids.get(id).add(room) - - if (!this.rooms.has(room)) { - this.rooms.set(room, new Set()) - } - this.rooms.get(room).add(id) - } - } - del(id: string, room: string, callback?: (err?: any) => void): void { - if (this.sids.has(id)) { - this.sids.get(id).delete(room) - } - - if (this.rooms.has(room)) { - this.rooms.get(room).delete(id) - if (this.rooms.get(room).size === 0) this.rooms.delete(room) - } - callback && callback.bind(null, null) - } - delAll(id: string): void { - if (!this.sids.has(id)) { - return - } - - for (const room of this.sids.get(id)) { - if (this.rooms.has(room)) { - this.rooms.get(room).delete(id) - if (this.rooms.get(room).size === 0) this.rooms.delete(room) - } - } - - 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 rooms = opts.rooms - const except = opts.except || new Set() - const flags = opts.flags || {} - const packetOpts = { - preEncoded: true, - volatile: flags.volatile, - compress: flags.compress - } - const ids = new Set() - - packet.nsp = this.nsp.name - const encodedPackets = this.encoder.encode(packet) - - if (rooms.size) { - 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) { - socket.packet(encodedPackets as any, packetOpts) - ids.add(id) - } - } - } - } else { - for (const [id] of this.sids) { - if (except.has(id)) continue - const socket = this.nsp.sockets.get(id) - if (socket) socket.packet(encodedPackets as any, packetOpts) - } - } - } - - /** - * 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() - if (rooms.size) { - for (const room of rooms) { - if (!this.rooms.has(room)) continue - for (const id of this.rooms.get(room)) { - if (this.nsp.sockets.has(id)) { - sids.add(id) - } - } - } - } else { - for (const [id] of this.sids) { - if (this.nsp.sockets.has(id)) sids.add(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) - } -} diff --git a/packages/websocket/src/socket-io/client.ts b/packages/websocket/src/socket-io/client.ts deleted file mode 100644 index 0f20dbd4..00000000 --- a/packages/websocket/src/socket-io/client.ts +++ /dev/null @@ -1,360 +0,0 @@ -import { EventEmitter } from 'events' -import { Parser } from './parser' -import { Packet } from './packet' -import { Namespace, Server, Socket } from './index' -import { PacketTypes, SubPacketTypes } from './types' -import { ServerEvent } from './constants' -import { SocketId } from './adapter' -import { Transport } from '../transport' - -const parser = new Parser() - -export class Client extends EventEmitter { - public readonly conn: Transport - /** - * @private - */ - readonly id: string - private readonly server: Server - // private readonly encoder: Encoder - private readonly decoder: any - private sockets: Map - private nsps: Map - private connectTimeout: NodeJS.Timeout - - private checkIntervalTimer: NodeJS.Timeout - private upgradeTimeoutTimer: NodeJS.Timeout - private pingTimeoutTimer: NodeJS.Timeout - private pingIntervalTimer: NodeJS.Timeout - - constructor(server: Server, conn) { - super() - this.server = server - this.conn = conn - // this.encoder = server.encoder - this.decoder = server._parser - this.id = this.conn.id + '' - this.setup() - // ============================= - this.sockets = new Map() - this.nsps = new Map() - // ================== engine.io - this.onOpen() - // ================== Transport - this.conn.on(ServerEvent.disconnect, (reason) => { - this.onclose(reason) - }) - } - /** - * @return the reference to the request that originated the Engine.IO connection - * - * @public - */ - public get request(): any /**IncomingMessage */ { - return this.conn.request - } - /** - * Sets up event listeners. - * - * @private - */ - private setup() { - // @ts-ignore - // this.decoder.on("decoded", this.ondecoded) - this.conn.on("data", this.ondata.bind(this)) - this.conn.on("error", this.onerror.bind(this)) - this.conn.on("close", this.onclose.bind(this)) - console.debug(`setup client ${this.id}`) - this.connectTimeout = setTimeout(() => { - if (this.nsps.size === 0) { - console.debug("no namespace joined yet, close the client") - this.close() - } else { - console.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: object = {}) { - console.debug(`client ${this.id} connecting to namespace ${name} has: ${this.server._nsps.has(name)}`) - if (this.server._nsps.has(name)) { - return this.doConnect(name, auth) - } - - this.server._checkNamespace(name, auth, (dynamicNsp: Namespace) => { - if (dynamicNsp) { - console.debug(`dynamic namespace ${dynamicNsp.name} was created`) - this.doConnect(name, auth) - } else { - console.debug(`creation of namespace ${name} was denied`) - this._packet({ - type: PacketTypes.MESSAGE, - sub_type: SubPacketTypes.ERROR, - nsp: name, - data: { - message: "Invalid namespace" - } - }) - } - }) - } - doConnect(name, auth: object) { - if (this.connectTimeout) { - clearTimeout(this.connectTimeout) - this.connectTimeout = null - } - const nsp = this.server.of(name) - - nsp._add(this, auth, (socket: Socket) => { - this.sockets.set(socket.id, socket) - this.nsps.set(nsp.name, socket) - }) - } - /** - * Disconnects from all namespaces and closes transport. - * - * @private - */ - _disconnect() { - 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) { - if (this.sockets.has(socket.id)) { - this.sockets.delete(socket.id) - this.nsps.delete(socket.nsp.name) - } else { - console.debug(`ignoring remove for ${socket.id}`,) - } - process.nextTick(() => { - if (this.sockets.size == 0) { - this.onclose('no live socket') - } - }) - } - /** - * Closes the underlying connection. - * - * @private - */ - private close() { - console.debug(`client ${this.id} close`) - if ("open" == this.conn.readyState) { - console.debug("forcing transport close") - this.onclose("forced server close") - this.conn.close() - } - } - /** - * Writes a packet to the transport. - * - * @param {Object} packet object - * @param {Object} opts - * @private - */ - _packet(packet: Packet, opts = { preEncoded: false }) { - // opts = opts || {} - // const self = this - - // // this writes to the actual connection - // function writeToEngine(encodedPackets) { - // if (opts.volatile && !self.conn.transport.writable) return - // for (let i = 0; i < encodedPackets.length; i++) { - // self.conn.write(encodedPackets[i], { compress: opts.compress }) - // } - // } - - // if ("open" == this.conn.readyState) { - // debug("writing packet %j", packet) - // if (!opts.preEncoded) { - // // not broadcasting, need to encode - // writeToEngine(this.encoder.encode(packet)) // encode, then write results to engine - // } else { - // // a broadcast pre-encodes a packet - // writeToEngine(packet) - // } - // } else { - // debug("ignoring packet write %j", packet) - // } - if ("open" == this.conn.readyState) { - this.conn.send(opts.preEncoded ? packet as unknown as string : parser.encode(packet)) - } else { - console.debug(`ignoring write packet ${JSON.stringify(packet)} to client ${this.id} is already close!`) - } - } - /** - * Called with incoming transport data. - * - * @private - */ - private ondata(data) { - // try/catch is needed for protocol violations (GH-1880) - try { - this.decoder.add(data) - } catch (e) { - this.onerror(e) - } - } - /** - * Called when parser fully decodes a packet. - * - * @private - */ - ondecoded(packet: Packet) { - if (SubPacketTypes.CONNECT == packet.sub_type) { - this.connect(packet.nsp, packet.data) - } else { - process.nextTick(() => { - const socket = this.nsps.get(packet.nsp) - if (socket) { - socket._onpacket(packet) - } else { - console.debug(`client ${this.id} no socket for namespace ${packet.nsp}.`) - } - }) - } - } - /** - * Handles an error. - * - * @param {Object} err object - * @private - */ - private onerror(err) { - for (const socket of this.sockets.values()) { - socket._onerror(err) - } - this.conn.close() - } - onclose(reason?: string) { - this.conn.readyState = "closing" - // ======= engine.io - this.onClose(reason) - // cleanup connectTimeout - if (this.connectTimeout) { - clearTimeout(this.connectTimeout) - this.connectTimeout = null - } - console.debug(`client ${this.id} close with reason ${reason}`) - // ignore a potential subsequent `close` event - // `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 - } - - destroy() { - // this.conn.removeListener('data', this.ondata); - // this.conn.removeListener('error', this.onerror); - // this.conn.removeListener('close', this.onclose); - // this.decoder.removeListener('decoded', this.ondecoded); - } - - //================== engine.io - onOpen() { - this.conn.readyState = "open" - this._packet({ - type: PacketTypes.OPEN, - data: { - sid: this.id, - upgrades: [], - pingInterval: this.server.options.pingInterval, - pingTimeout: this.server.options.pingTimeout - } - }) - this.schedulePing() - } - - onPacket(packet: Packet) { - if ("open" === this.conn.readyState) { - // export packet event - // debug("packet") - // 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.options.pingInterval + this.server.options.pingTimeout * 2) - switch (packet.type) { - case PacketTypes.PING: - this._packet({ - type: PacketTypes.PONG, - data: packet.data - }) - break - case PacketTypes.PONG: - this.schedulePing() - break - case PacketTypes.UPGRADE: - break - case PacketTypes.MESSAGE: - this.ondecoded(packet) - break - case PacketTypes.CLOSE: - this.onclose() - break - default: - console.log(`client ${this.id} reciver unknow packet type: ${packet.type}`) - } - } else { - console.debug(`packet received with closed client ${this.id}`) - } - } - /** - * Called upon transport considered closed. - * Possible reasons: `ping timeout`, `client error`, `parse error`, - * `transport error`, `server close`, `transport close` - */ - onClose(reason, description?: string) { - // if ("closed" !== this.conn.readyState) { - clearTimeout(this.pingIntervalTimer) - clearTimeout(this.pingTimeoutTimer) - - clearInterval(this.checkIntervalTimer) - this.checkIntervalTimer = null - clearTimeout(this.upgradeTimeoutTimer) - // this.emit("close", reason, description) - // } - } - /** - * Pings client every `this.pingInterval` and expects response - * within `this.pingTimeout` or closes connection. - * - * @api private - */ - schedulePing() { - clearTimeout(this.pingIntervalTimer) - this.pingIntervalTimer = setTimeout(() => { - this.resetPingTimeout(this.server.options.pingTimeout) - process.nextTick(() => this._packet({ type: PacketTypes.PING })) - }, this.server.options.pingInterval) - } - - /** - * Resets ping timeout. - * - * @api private - */ - resetPingTimeout(timeout: number) { - clearTimeout(this.pingTimeoutTimer) - this.pingTimeoutTimer = setTimeout(() => { - if (this.conn.readyState === "closed") return - this.onclose("ping timeout") - }, timeout) - } -} diff --git a/packages/websocket/src/socket-io/constants.ts b/packages/websocket/src/socket-io/constants.ts deleted file mode 100644 index ba29e36c..00000000 --- a/packages/websocket/src/socket-io/constants.ts +++ /dev/null @@ -1,9 +0,0 @@ -export enum ServerEvent { - detect = 'detect', - connect = 'connect', - connection = 'connection', - message = 'message', - error = 'error', - disconnecting = 'disconnecting', - disconnect = 'disconnect', -} diff --git a/packages/websocket/src/socket-io/index.ts b/packages/websocket/src/socket-io/index.ts deleted file mode 100644 index d59234e4..00000000 --- a/packages/websocket/src/socket-io/index.ts +++ /dev/null @@ -1,677 +0,0 @@ -import { EventEmitter } from 'events' - -import { ServerEvent } from './constants' -import { Namespace } from './namespace' -import { Client } from './client' -import { Parser } from './parser' -import { Socket } from './socket' -import { Adapter } from './adapter' -import { Transport } from '../transport' -import { ParentNamespace } from './parent-namespace' - -interface EngineOptions { - /** - * how many ms without a pong packet to consider the connection closed - * @default 5000 - */ - pingTimeout: number - /** - * how many ms before sending a new ping packet - * @default 25000 - */ - pingInterval: number - /** - * how many ms before an uncompleted transport upgrade is cancelled - * @default 10000 - */ - upgradeTimeout: number - /** - * how many bytes or characters a message can be, before closing the session (to avoid DoS). - * @default 1e5 (100 KB) - */ - maxHttpBufferSize: number - /** - * A function that receives a given handshake or upgrade request as its first parameter, - * and can decide whether to continue or not. The second argument is a function that needs - * to be called with the decided information: fn(err, success), where success is a boolean - * value where false means that the request is rejected, and err is an error code. - */ - // allowRequest: ( - // req: http.IncomingMessage, - // fn: (err: string | null | undefined, success: boolean) => void - // ) => void - /** - * the low-level transports that are enabled - * @default ["polling", "websocket"] - */ - // transports: Transport[] - /** - * whether to allow transport upgrades - * @default true - */ - allowUpgrades: boolean - /** - * parameters of the WebSocket permessage-deflate extension (see ws module api docs). Set to false to disable. - * @default false - */ - perMessageDeflate: boolean | object - /** - * parameters of the http compression for the polling transports (see zlib api docs). Set to false to disable. - * @default true - */ - httpCompression: boolean | object - /** - * what WebSocket server implementation to use. Specified module must - * conform to the ws interface (see ws module api docs). Default value is ws. - * An alternative c++ addon is also available by installing uws module. - */ - wsEngine: string - /** - * an optional packet which will be concatenated to the handshake packet emitted by Engine.IO. - */ - initialPacket: any - /** - * configuration of the cookie that contains the client sid to send as part of handshake response headers. This cookie - * might be used for sticky-session. Defaults to not sending any cookie. - * @default false - */ - // cookie: CookieSerializeOptions | boolean - /** - * the options that will be forwarded to the cors module - */ - // cors: CorsOptions -} - -interface AttachOptions { - /** - * name of the path to capture - * @default "/engine.io" - */ - path: string - /** - * destroy unhandled upgrade requests - * @default true - */ - destroyUpgrade: boolean - /** - * milliseconds after which unhandled requests are ended - * @default 1000 - */ - destroyUpgradeTimeout: number -} - -interface EngineAttachOptions extends EngineOptions, AttachOptions { } - -interface ServerOptions extends EngineAttachOptions { - event?: EventEmitter - root?: string - /** - * 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: 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 -} - -interface WebSocketServer extends EventEmitter { - close(): void -} - -class Server { - public readonly sockets: Namespace - - /** - * @private - */ - _parser: Parser - private readonly encoder - - /** - * @private - */ - _nsps: Map - private parentNsps: Map< - | string - | RegExp - | (( - name: string, - query: object, - fn: (err: Error, success: boolean) => void - ) => void), - ParentNamespace - > = new Map(); - private _adapter: Adapter - // private _serveClient: boolean; - private eio - private engine: { ws: any } - private _path: string - private clientPathRegex: RegExp - /** - * @private - */ - _connectTimeout: number - - options: Partial - private websocketServer: WebSocketServer - private allClients: Map - - constructor(instance: any, options: Partial) { - if (!instance) { throw new Error('instance can\'t be undefiend!') } - this.options = Object.assign({ - event: new EventEmitter(), - path: '/socket.io', - root: root + '/wwwroot', - serveClient: false, - connectTimeout: 45000, - wsEngine: process.env.EIO_WS_ENGINE || "ws", - pingTimeout: 5000, - pingInterval: 25000, - upgradeTimeout: 10000, - maxHttpBufferSize: 1e6, - transports: 'websocket', - allowUpgrades: true, - httpCompression: { - threshold: 1024 - }, - cors: false - }, options) - this.initServerConfig() - this.sockets = this.of('/') - this.selectServerImpl(instance) - this.initServer() - } - /** - * Sets/gets whether client code is being served. - * - * @param {Boolean} v - whether to serve client code - * @return {Server|Boolean} self when setting or value when getting - * @public - */ - public serveClient(v: boolean): Server - public serveClient(): boolean - public serveClient(v?: boolean): Server | boolean { - throw new Error("Method not implemented.") - } - /** - * Executes the middleware for an incoming namespace not already created on the server. - * - * @param {String} name - name of incoming namespace - * @param {Object} auth - the auth parameters - * @param {Function} fn - callback - * - * @private - */ - _checkNamespace( - name: string, - auth: object, - fn: (nsp: Namespace) => void - ) { - // if (this.parentNsps.size === 0) return fn(false) - - // const keysIterator = this.parentNsps.keys() - - // const run = () => { - // let nextFn = keysIterator.next() - // if (nextFn.done) { - // return fn(false) - // } - // nextFn.value(name, auth, (err, allow) => { - // if (err || !allow) { - // run() - // } else { - // fn(this.parentNsps.get(nextFn.value).createChild(name)) - // } - // }) - // } - fn(undefined) - } - /** - * Sets the client serving path. - * - * @param {String} v pathname - * @return {Server|String} self when setting or value when getting - * @public - */ - path(): string - path(v: string): Server - path(v?: any): string | Server { - if (!arguments.length) return this._path - - this._path = v.replace(/\/$/, "") - - const escapedPath = this._path.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&") - this.clientPathRegex = new RegExp( - "^" + - escapedPath + - "/socket\\.io(\\.min|\\.msgpack\\.min)?\\.js(\\.map)?$" - ) - return this - } - /** - * Set the delay after which a client without namespace is closed - * @param v - * @public - */ - public connectTimeout(v: number): Server - public connectTimeout(): number - public connectTimeout(v?: number): Server | number { - if (v === undefined) return this._connectTimeout - this._connectTimeout = v - return this - } - /** - * Sets the adapter for rooms. - * - * @param {Adapter} v pathname - * @return {Server|Adapter} self when setting or value when getting - * @public - */ - public adapter(): any - public adapter(v: any) - public adapter(v?): Server | any { - 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 {http.Server|Number} srv - server or port - // * @param {Object} opts - options passed to engine.io - // * @return {Server} self - // * @public - // */ - // public listen(srv: http.Server, opts?: Partial): Server - // public listen(srv: number, opts?: Partial): Server - // public listen(srv: any, opts: Partial = {}): Server { - // return this.attach(srv, opts) - // } - - // /** - // * Attaches socket.io to a server or port. - // * - // * @param {http.Server|Number} srv - server or port - // * @param {Object} opts - options passed to engine.io - // * @return {Server} self - // * @public - // */ - // public attach(srv: http.Server, opts?: Partial): Server - // public attach(port: number, opts?: Partial): Server - // public attach(srv: any, opts: Partial = {}): Server { - // 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) - // } - - // // set engine.io path to `/socket.io` - // opts.path = opts.path || this._path - - // this.initEngine(srv, opts) - - // return this - // } - // /** - // * Initialize engine - // * - // * @param srv - the server to attach to - // * @param opts - options passed to engine.io - // * @private - // */ - // private initEngine(srv: http.Server, opts: Partial) { - // // initialize engine - // debug("creating engine.io instance with opts %j", opts) - // this.eio = engine.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 {Function|http.Server} srv http server - // * @private - // */ - // private attachServe(srv) { - // 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 {http.IncomingMessage} req - // * @param {http.ServerResponse} res - // * @private - // */ - // private serve(req: http.IncomingMessage, res: http.ServerResponse) { - // const filename = req.url.replace(this._path, "") - // 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 etag = req.headers["if-none-match"] - // if (etag) { - // if (expectedEtag == 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) - - // if (!isMap) { - // res.setHeader("X-SourceMap", filename.substring(1) + ".map") - // } - // Server.sendFile(filename, req, res) - // } - - // /** - // * @param filename - // * @param req - // * @param res - // * @private - // */ - // private static sendFile( - // filename: string, - // req: http.IncomingMessage, - // res: http.ServerResponse - // ) { - // const readStream = createReadStream( - // path.join(__dirname, "../client-dist/", filename) - // ) - // const encoding = accepts(req).encodings(["br", "gzip", "deflate"]) - - // const onError = err => { - // 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 {Server} self - // * @public - // */ - // public bind(engine): Server { - // this.engine = engine - // this.engine.on("connection", this.onconnection.bind(this)) - // return this - // } - /** - * Called with each incoming transport connection. - * - * @param {engine.Socket} conn - * @return {Server} self - * @private - */ - private onconnection(conn): Server { - console.debug(`incoming connection with id ${conn.id}`) - let client = new Client(this, conn) - this.allClients.set(conn.id, client) - return this - } - // of(nsp: string): Namespace { - // if (!this._nsps.has(nsp)) { - // console.debug(`create Namespace ${nsp}`) - // this._nsps.set(nsp, new Namespace(this, nsp)) - // } - // return this._nsps.get(nsp) - // } - /** - * Looks up a namespace. - * - * @param {String|RegExp|Function} name nsp name - * @param {Function} [fn] optional, nsp `connection` ev handler - * @public - */ - public of( - name: - | string - | RegExp - | (( - name: string, - query: object, - fn: (err: Error, success: boolean) => void - ) => void), - fn?: (socket: Socket) => void - ) { - if (typeof name === "function" || name instanceof RegExp) { - const parentNsp = new ParentNamespace(this) - console.debug(`initializing parent namespace ${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) { - console.debug(`initializing namespace ${name}`) - nsp = new Namespace(this, name) - this._nsps.set(name, nsp) - } - if (fn) nsp.on("connect", fn) - return nsp - } - close(fn?: () => void): void { - this.clients.length - for (const client of this.allClients.values()) { - client._disconnect() - } - - // this.engine.close() - this.websocketServer.close() - - // if (this.httpServer) { - // this.httpServer.close(fn) - // } else { - fn && fn() - // } - } - on(event: "connection", listener: (socket: Socket) => void): Namespace - on(event: "connect", listener: (socket: Socket) => void): Namespace - on(event: string, listener: Function): Namespace - on(event: any, listener: any): Namespace { - return this.sockets.on(event, listener) - } - to(room: string): Namespace { - return this.sockets.to(room) - } - in(room: string): Namespace { - return this.sockets.in(room) - } - use(fn: (socket: Socket, fn: (err?: any) => void) => void): Namespace { - return this.sockets.use(fn) - } - emit(event: string, ...args: any[]): Namespace { - // @ts-ignore - return this.sockets.emit(event, ...args) - } - send(...args: any[]): Namespace { - return this.sockets.send(...args) - } - write(...args: any[]): Namespace { - return this.sockets.write(...args) - } - clients(...args: any[]): Namespace { - return this.sockets.clients(args[0]) - } - compress(...args: any[]): Namespace { - return this.sockets.compress(args[0]) - } - // =============================== - private initServerConfig() { - this.allClients = new Map() - this._nsps = new Map() - this.connectTimeout(this.options.connectTimeout || 45000) - this._parser = this.options.parser || new Parser() - this.adapter(this.options.adapter || Adapter) - } - private selectServerImpl(instance: any) { - let WebSocketServerImpl = undefined - if (instance.class.name.startsWith('io.netty.channel')) { - WebSocketServerImpl = require("../netty").NettyWebSocketServer - } else { - WebSocketServerImpl = require("../tomcat").TomcatWebSocketServer - } - this.websocketServer = new WebSocketServerImpl(instance, this.options) - } - private initServer() { - this.websocketServer.on(ServerEvent.connect, (transport: Transport) => { - this.onconnection(transport) - }) - this.websocketServer.on(ServerEvent.message, (transport: Transport, text) => { - if (this.allClients.has(transport.id)) { - let client = this.allClients.get(transport.id) - client.onPacket(this._parser.decode(text)) - } else { - console.error(`unknow transport ${transport.id} reciver message ${text}`) - } - }) - this.websocketServer.on(ServerEvent.disconnect, (transport: Transport, reason) => { - if (this.allClients.has(transport.id)) { - this.allClients.get(transport.id).onclose(reason) - this.allClients.delete(transport.id) - } else { - console.error(`unknow transport ${transport?.id} disconnect cause ${reason}`) - } - }) - this.websocketServer.on(ServerEvent.error, (transport: Transport, cause) => { - if (this.allClients.has(transport?.id)) { - let client = this.allClients.get(transport?.id) - if (client.listeners(ServerEvent.error).length) { - client.emit(ServerEvent.error, cause) - } else { - console.error(`client ${client.id} cause error: ${cause}`) - console.ex(cause) - } - } else { - console.error(`unknow transport ${transport?.id} cause error: ${cause}`) - console.ex(cause) - } - }) - } -} - -/** - * Expose main namespace (/). - */ - -const emitterMethods = Object.keys(EventEmitter.prototype).filter(function ( - key -) { - return typeof EventEmitter.prototype[key] === "function" -}) - -emitterMethods.forEach(function (fn) { - Server.prototype[fn] = function () { - return this.sockets[fn].apply(this.sockets, arguments) - } -}) - -export { - Server, - Socket, - Client, - Namespace, - ServerOptions -} diff --git a/packages/websocket/src/socket-io/namespace.ts b/packages/websocket/src/socket-io/namespace.ts deleted file mode 100644 index e6e6f59c..00000000 --- a/packages/websocket/src/socket-io/namespace.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { EventEmitter } from 'events' - -import { Client } from './client' -import { ServerEvent } from './constants' -import { RESERVED_EVENTS, Socket } from './socket' -import { Adapter, Room, SocketId } from './adapter' -import { Server } from './index' -import { Packet } from './packet' -import { PacketTypes, SubPacketTypes } from './types' - -export interface ExtendedError extends Error { - data?: any -} - -export class Namespace extends EventEmitter { - public readonly name: string - public readonly sockets: Map - - public adapter: Adapter - - /** @private */ - readonly server: Server - json: Namespace - - /** @private */ - _fns: Array< - (socket: Socket, next: (err: ExtendedError) => void) => void - > = []; - - /** @private */ - _rooms: Set - - /** @private */ - _flags: any = {} - - /** @private */ - _ids: number = 0 - - constructor(server: Server, name: string) { - super() - this.server = server - this.name = name + '' - this._initAdapter() - // ======================= - this.sockets = new Map() - this._rooms = new Set() - } - _initAdapter() { - // @ts-ignore - this.adapter = new (this.server.adapter())(this) - } - /** - * Sets up namespace middleware. - * - * @return {Namespace} self - * @public - */ - public use( - fn: (socket: Socket, next: (err?: ExtendedError) => void) => void - ): Namespace { - this._fns.push(fn) - return this - } - /** - * Executes the middleware for an incoming client. - * - * @param {Socket} socket - the socket that will get added - * @param {Function} fn - last fn call in the middleware - * @private - */ - private run(socket: Socket, fn: (err: ExtendedError) => void) { - const fns = this._fns.slice(0) - if (!fns.length) return fn(null) - - function run(i) { - 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) - } - to(name: string): Namespace { - this._rooms.add(name) - return this - } - in(name: string): Namespace { - return this.to(name) - } - _add(client: Client, query?: any, fn?: (socket: Socket) => void) { - const socket = new Socket(this, client, query || {}) - console.debug(`client ${client.id} adding socket ${socket.id} to nsp ${this.name}`) - this.run(socket, err => { - process.nextTick(() => { - if ("open" == client.conn.readyState) { - if (err) - 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() - // !!! at java multi thread need direct callback socket - if (fn) fn(socket) - - // fire user-set events - super.emit(ServerEvent.connect, socket) - super.emit(ServerEvent.connection, socket) - } else { - console.debug(`next called after client ${client.id} was closed - ignoring socket`) - } - }) - }) - return socket - } - /** - * Removes a client. Called by each `Socket`. - * - * @private - */ - _remove(socket: Socket): void { - if (this.sockets.has(socket.id)) { - console.debug(`namespace ${this.name} remove socket ${socket.id}`) - this.sockets.delete(socket.id) - } else { - console.debug(`namespace ${this.name} ignoring remove for ${socket.id}`) - } - } - emit(event: string, ...args: any[]): boolean { - if (RESERVED_EVENTS.has(event)) { - throw new Error(`"${event}" is a reserved event name`) - } - // set up packet object - var packet = { - type: PacketTypes.MESSAGE, - sub_type: (this._flags.binary !== undefined ? this._flags.binary : this.hasBin(args)) ? SubPacketTypes.BINARY_EVENT : SubPacketTypes.EVENT, - name: event, - data: args - } - - if ('function' == typeof args[args.length - 1]) { - throw new Error('Callbacks are not supported when broadcasting') - } - - var rooms = new Set(this._rooms) - var flags = Object.assign({}, this._flags) - - // reset flags - this._rooms.clear() - this._flags = {} - - this.adapter.broadcast(packet, { - rooms: new Set(rooms), - flags: flags - }) - // @ts-ignore - return this - } - send(...args: any[]): Namespace { - this.emit('message', ...args) - return this - } - write(...args: any[]): Namespace { - return this.send(...args) - } - /** - * Gets a list of clients. - * - * @return {Namespace} self - * @public - */ - 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?") - } - const rooms = new Set(this._rooms) - this._rooms.clear() - return this.adapter.sockets(rooms) - } - - /** - * Sets the compress flag. - * - * @param {Boolean} compress - if `true`, compresses the sending data - * @return {Namespace} self - * @public - */ - public compress(compress: boolean): Namespace { - 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). - * - * @return {Namespace} self - * @public - */ - public get volatile(): Namespace { - this._flags.volatile = true - return this - } - - /** - * Sets a modifier for a subsequent event emission that the event data will only be broadcast to the current node. - * - * @return {Namespace} self - * @public - */ - public get local(): Namespace { - this._flags.local = true - return this - } - - hasBin(args: any[]) { - return false - } - clients(fn: (sockets: Socket[]) => Namespace): Namespace { - return fn(Object.values(this.sockets)) - } - close() { - this.removeAllListeners(ServerEvent.connect) - this.removeAllListeners(ServerEvent.connection) - Object.values(this.sockets).forEach(socket => socket.disconnect(false)) - } -} diff --git a/packages/websocket/src/socket-io/packet.ts b/packages/websocket/src/socket-io/packet.ts deleted file mode 100644 index 1ce868b2..00000000 --- a/packages/websocket/src/socket-io/packet.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { PacketTypes, SubPacketTypes } from './types' - -export interface Packet { - type: PacketTypes; - sub_type?: SubPacketTypes; - nsp?: string; - id?: number; - name?: string; - data?: any; - attachments?: any; -} diff --git a/packages/websocket/src/socket-io/parent-namespace.ts b/packages/websocket/src/socket-io/parent-namespace.ts deleted file mode 100644 index 88e33d6d..00000000 --- a/packages/websocket/src/socket-io/parent-namespace.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Namespace } from "./namespace" - -export class ParentNamespace extends Namespace { - private static count: number = 0; - private children: Set = new Set(); - - constructor(server) { - super(server, "/_" + ParentNamespace.count++) - } - - _initAdapter() { } - - public emit(...args: any[]): boolean { - this.children.forEach(nsp => { - nsp._rooms = this._rooms - nsp._flags = this._flags - nsp.emit.apply(nsp, args as any) - }) - this._rooms.clear() - this._flags = {} - - return true - } - - createChild(name) { - const namespace = new Namespace(this.server, name) - namespace._fns = this._fns.slice(0) - this.listeners("connect").forEach(listener => - // @ts-ignore - namespace.on("connect", listener) - ) - this.listeners("connection").forEach(listener => - // @ts-ignore - namespace.on("connection", listener) - ) - this.children.add(namespace) - this.server._nsps.set(name, namespace) - return namespace - } -} diff --git a/packages/websocket/src/socket-io/parser.ts b/packages/websocket/src/socket-io/parser.ts deleted file mode 100644 index c86308ac..00000000 --- a/packages/websocket/src/socket-io/parser.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { EventEmitter } from 'events' -import { Packet } from "./packet" -import { PacketTypes, SubPacketTypes } from "./types" - -export class Parser extends EventEmitter { - encode(packet: Packet): string { - let origin = JSON.stringify(packet) - // first is type - let str = '' + packet.type - if (packet.type == PacketTypes.PONG) { - if (packet.data) { str += packet.data }; - return str - } - if (packet.sub_type != undefined) { - str += packet.sub_type - } - // attachments if we have them - if ([SubPacketTypes.BINARY_EVENT, SubPacketTypes.BINARY_ACK].includes(packet.sub_type)) { - str += packet.attachments + '-' - } - // if we have a namespace other than `/` - // we append it followed by a comma `,` - if (packet.nsp && '/' !== packet.nsp) { - str += packet.nsp + ',' - } - // immediately followed by the id - if (null != packet.id) { - str += packet.id - } - if (packet.sub_type == SubPacketTypes.EVENT) { - if (packet.name == undefined) { throw new Error(`SubPacketTypes.EVENT name can't be empty!`) } - packet.data = [packet.name, ...packet.data] - } - // json data - if (null != packet.data) { - let payload = this.tryStringify(packet.data) - if (payload !== false) { - str += payload - } else { - return '4"encode error"' - } - } - console.trace(`encoded ${origin} as ${str}`) - return str - } - tryStringify(str: any) { - try { - return JSON.stringify(str) - } catch (e) { - return false - } - } - decode(str: string): Packet { - let i = 0 - // ignore parse binary - // if ((frame.getByte(0) == 'b' && frame.getByte(1) == '4') - // || frame.getByte(0) == 4 || frame.getByte(0) == 1) { - // return parseBinary(head, frame); - // } - // look up type - let p: Packet = { - type: Number(str.charAt(i)) - } - if (null == PacketTypes[p.type]) { - return this.error('unknown packet type ' + p.type) - } - // if str empty return - if (str.length == i + 1) { - return p - } - // if is ping packet read data and return - if (PacketTypes.PING == p.type) { - p.data = str.substr(++i) - return p - } - // look up sub type - p.sub_type = Number(str.charAt(++i)) - if (null == PacketTypes[p.sub_type]) { - return this.error('unknown sub packet type ' + p.type) - } - // look up attachments if type binary - if ([SubPacketTypes.BINARY_ACK, SubPacketTypes.BINARY_EVENT].includes(p.sub_type)) { - let buf = '' - while (str.charAt(++i) !== '-') { - buf += str.charAt(i) - if (i == str.length) break - } - if (buf != `${Number(buf)}` || str.charAt(i) !== '-') { - return this.error('Illegal attachments') - } - p.attachments = Number(buf) - } - - // look up namespace (if any) - if ('/' === str.charAt(i + 1)) { - p.nsp = '' - while (++i) { - let c = str.charAt(i) - if (',' === c) break - p.nsp += c - if (i === str.length) break - } - } else { - p.nsp = '/' - } - - // handle namespace query - if (p.nsp.indexOf('?') !== -1) { - p.nsp = p.nsp.split('?')[0] - } - - // look up id - let next = str.charAt(i + 1) - if ('' !== next && !isNaN(Number(next))) { - let id = '' - while (++i) { - let c = str.charAt(i) - if (null == c || isNaN(Number(c))) { - --i - break - } - id += str.charAt(i) - if (i === str.length) break - } - p.id = Number(id) - } - - // ignore binary packet - if (p.sub_type == SubPacketTypes.BINARY_EVENT) { - return this.error('not support binary parse...') - } - - // look up json data - if (str.charAt(++i)) { - let payload = this.tryParse(str.substr(i)) - let isPayloadValid = payload !== false && (p.sub_type == SubPacketTypes.ERROR || Array.isArray(payload)) - if (isPayloadValid) { - p.name = payload[0] - p.data = payload.slice(1) - } else { - return this.error('invalid payload ' + str.substr(i)) - } - } - - console.trace(`decoded ${str} as ${JSON.stringify(p)}`) - return p - } - - tryParse(str: string) { - try { - return JSON.parse(str) - } catch (e) { - return false - } - } - - error(error: string): Packet { - return { - type: PacketTypes.MESSAGE, - sub_type: SubPacketTypes.ERROR, - data: 'parser error: ' + error - } - } -} diff --git a/packages/websocket/src/socket-io/socket.ts b/packages/websocket/src/socket-io/socket.ts deleted file mode 100644 index dd085c93..00000000 --- a/packages/websocket/src/socket-io/socket.ts +++ /dev/null @@ -1,491 +0,0 @@ -import { EventEmitter } from 'events' - -import { Packet } from './packet' -import { PacketTypes, SubPacketTypes } from './types' -import { Client } from './client' -import { Namespace } from './namespace' -import * as querystring from 'querystring' -import { ServerEvent } from './constants' -import { Adapter, BroadcastFlags, Room, SocketId } from './adapter' -import { Server } from 'index' - -export const RESERVED_EVENTS = new Set([ - "connect", - "connect_error", - "disconnect", - "disconnecting", - // EventEmitter reserved events: https://nodejs.org/api/events.html#events_event_newlistener - "newListener", - "removeListener" -]) - -/** - * The handshake details - */ -export interface Handshake { - /** - * The headers sent as part of the handshake - */ - headers: object - - /** - * The date of creation (as string) - */ - time: string - - /** - * The ip of the client - */ - address: string - - /** - * Whether the connection is cross-domain - */ - xdomain: boolean - - /** - * Whether the connection is secure - */ - secure: boolean - - /** - * The date of creation (as unix timestamp) - */ - issued: number - - /** - * The request URL string - */ - url: string - - /** - * The query object - */ - query: any - - /** - * The auth object - */ - auth: any -} -export class Socket extends EventEmitter { - nsp: Namespace - - public readonly id: SocketId - public readonly handshake: Handshake - - public connected: boolean - public disconnected: boolean - - private readonly server: Server - private readonly adapter: Adapter - - client: Client - private acks: Map void> - - fns: any[] - private flags: BroadcastFlags = {}; - private _rooms: Set = new Set(); - private _anyListeners: Array<(...args: any[]) => void> - - constructor(nsp: Namespace, client: Client, auth = {}) { - super() - this.nsp = nsp - this.server = nsp.server - this.adapter = this.nsp.adapter - this.id = nsp.name !== '/' ? nsp.name + '#' + client.id : client.id - this.client = client - this.acks = new Map() - this.connected = true - this.disconnected = false - this.handshake = this.buildHandshake(auth) - - this.fns = [] - this.flags = {} - this._rooms = new Set() - } - emit(event: string, ...args: any[]): boolean { - let packet: Packet = { - type: PacketTypes.MESSAGE, - sub_type: (this.flags.binary !== undefined ? this.flags.binary : this.hasBin(args)) ? SubPacketTypes.BINARY_EVENT : SubPacketTypes.EVENT, - name: event, - data: args - } - - // access last argument to see if it's an ACK callback - if (typeof args[args.length - 1] === "function") { - if (this._rooms.size || this.flags.broadcast) { - throw new Error("Callbacks are not supported when broadcasting") - } - - // console.debug("emitting packet with ack id %d", this.nsp._ids) - this.acks.set(this.nsp._ids, args.pop()) - packet.id = this.nsp._ids++ - } - - const rooms = new Set(this._rooms) - const flags = Object.assign({}, this.flags) - - // reset flags - this._rooms.clear() - this.flags = {} - - if (rooms.size || flags.broadcast) { - this.adapter.broadcast(packet, { - except: new Set([this.id]), - rooms: rooms, - flags: flags - }) - } else { - // dispatch packet - this.packet(packet, flags) - } - return true - } - to(name: Room): Socket { - this._rooms.add(name) - return this - } - in(room: string): Socket { - return this.to(room) - } - use(fn: (packet: Packet, next: (err?: any) => void) => void): Socket { - throw new Error("Method not implemented.") - } - send(...args: any[]): Socket { - this.emit("message", ...args) - return this - } - write(...args: any[]): Socket { - return this.send(...args) - } - public join(rooms: Room | Array): Promise | void { - console.debug(`join room ${rooms}`) - - return this.adapter.addAll( - this.id, - new Set(Array.isArray(rooms) ? rooms : [rooms]) - ) - } - /** - * Leaves a room. - * - * @param {String} room - * @return a Promise or nothing, depending on the adapter - * @public - */ - public leave(room: string): Promise | void { - console.debug(`leave room ${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 { - console.debug(`socket ${this.id} connected - writing packet`) - this.join(this.id) - this.packet({ type: PacketTypes.MESSAGE, sub_type: SubPacketTypes.CONNECT, data: { sid: this.id } }) - } - _onpacket(packet: Packet) { - switch (packet.sub_type) { - // 2 - case SubPacketTypes.EVENT: - this.onevent(packet) - break - // 5 - case SubPacketTypes.BINARY_EVENT: - this.onevent(packet) - break - // 3 - case SubPacketTypes.ACK: - this.onack(packet) - break - // 6 - case SubPacketTypes.BINARY_ACK: - this.onack(packet) - break - // 1 - case SubPacketTypes.DISCONNECT: - this.ondisconnect() - break - // 4 - case SubPacketTypes.ERROR: - this._onerror(new Error(packet.data)) - } - } - onevent(packet: Packet) { - if (null != packet.id) { - console.trace(`attaching ack ${packet.id} callback to client ${this.id} event`) - this.dispatch(packet, this.ack(packet.id)) - } else { - this.dispatch(packet) - } - } - ack(id: number) { - let sent = false - return (...args: any[]) => { - if (sent) return - this.packet({ - id: id, - type: PacketTypes.MESSAGE, - sub_type: this.hasBin(args) ? SubPacketTypes.BINARY_ACK : SubPacketTypes.ACK, - data: args - }) - sent = true - } - } - onack(packet: Packet) { - let ack = this.acks.get(packet.id) - if ('function' == typeof ack) { - console.trace(`calling ack ${packet.id} on socket ${this.id} with ${packet.data}`) - ack.apply(this, packet.data) - this.acks.delete(packet.id) - } else { - console.trace(`bad ack ${packet.id} on socket ${this.id}`) - } - } - /** - * Called upon client disconnect packet. - * - * @private - */ - private ondisconnect(): void { - console.debug(`socket ${this.id} got disconnect packet`) - this._onclose("client namespace disconnect") - } - - /** - * Handles a client error. - * - * @private - */ - _onerror(err): void { - if (this.listeners("error").length) { - super.emit("error", err) - } else { - console.error(`Missing error handler on 'socket(${this.id})'.`) - console.error(err.stack) - } - } - - /** - * Called upon closing. Called by `Client`. - * - * @param {String} reason - * @throw {Error} optional error object - * - * @private - */ - _onclose(reason: string) { - if (!this.connected) return this - console.debug(`closing socket ${this.id} - reason: ${reason} connected: ${this.connected}`) - super.emit(ServerEvent.disconnecting, reason) - this.leaveAll() - this.nsp._remove(this) - this.client._remove(this) - this.connected = false - this.disconnected = true - super.emit(ServerEvent.disconnect, reason) - } - - /** - * Produces an `error` packet. - * - * @param {Object} err - error object - * - * @private - */ - _error(err) { - this.packet({ type: PacketTypes.MESSAGE, sub_type: SubPacketTypes.ERROR, data: err }) - } - disconnect(close?: boolean): Socket { - if (!this.connected) return this - if (close) { - this.client._disconnect() - } else { - this.packet({ type: PacketTypes.MESSAGE, sub_type: SubPacketTypes.DISCONNECT }) - this._onclose('server namespace disconnect') - } - return this - } - - compress(compress: boolean): Socket { - throw new Error("Method not implemented.") - } - - /** - * Sets a modifier for a subsequent event emission that the event data may be lost if the client is not ready to - * receive messages (because of network slowness or other issues, or because they’re connected through long polling - * and is in the middle of a request-response cycle). - * - * @return {Socket} self - * @public - */ - public get volatile(): Socket { - 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. - * - * @return {Socket} self - * @public - */ - public get broadcast(): Socket { - this.flags.broadcast = true - return this - } - - /** - * Sets a modifier for a subsequent event emission that the event data will only be broadcast to the current node. - * - * @return {Socket} self - * @public - */ - public get local(): Socket { - this.flags.local = true - return this - } - - /** - * A reference to the request that originated the underlying Engine.IO Socket. - * - * @public - */ - public get request(): any { - return this.client.request - } - - /** - * A reference to the underlying Client transport connection (Engine.IO Socket object). - * - * @public - */ - public get conn() { - return this.client.conn - } - - /** - * @public - */ - public get rooms(): Set { - return this.adapter.socketRooms(this.id) || new Set() - } - - /** - * Adds a listener that will be fired when any event is emitted. The event name is passed as the first argument to the - * callback. - * - * @param listener - * @public - */ - public onAny(listener: (...args: any[]) => void): Socket { - this._anyListeners = this._anyListeners || [] - this._anyListeners.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. - * - * @param listener - * @public - */ - public prependAny(listener: (...args: any[]) => void): Socket { - this._anyListeners = this._anyListeners || [] - this._anyListeners.unshift(listener) - return this - } - - /** - * Removes the listener that will be fired when any event is emitted. - * - * @param listener - * @public - */ - public offAny(listener?: (...args: any[]) => void): Socket { - 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 - */ - public listenersAny() { - return this._anyListeners || [] - } - - // ========================================== - buildHandshake(auth): Handshake { - let requestUri = this.request.uri() - let headers = {} - let nativeHeaders = this.request.headers() - nativeHeaders.forEach(function (header) { - headers[header.getKey()] = header.getValue() - }) - return { - headers: headers, - time: new Date() + '', - address: this.conn.remoteAddress + '', - xdomain: !!headers['origin'], - secure: false, - issued: +new Date(), - url: requestUri, - query: querystring.parse(requestUri.indexOf('?') != -1 ? requestUri.split('?')[1] : ''), - auth - } - } - packet(packet: Packet, opts: any = { preEncoded: false }) { - if (!opts.preEncoded) { - packet.nsp = this.nsp.name - opts.compress = false !== opts.compress - } - try { - this.client._packet(packet, opts) - } catch (error) { - this._onerror(error) - } - } - dispatch(packet: Packet, ack?: () => void) { - if (ack) { this.acks.set(packet.id, ack) } - super.emit(packet.name, ...packet.data, ack) - } - private hasBin(obj: any) { - return false - } -} diff --git a/packages/websocket/src/socket-io/types.ts b/packages/websocket/src/socket-io/types.ts deleted file mode 100644 index 39758563..00000000 --- a/packages/websocket/src/socket-io/types.ts +++ /dev/null @@ -1,18 +0,0 @@ -export enum PacketTypes { - OPEN, - CLOSE, - PING, - PONG, - MESSAGE, - UPGRADE, - NOOP, -} -export enum SubPacketTypes { - CONNECT, - DISCONNECT, - EVENT, - ACK, - ERROR, - BINARY_EVENT, - BINARY_ACK -} diff --git a/packages/websocket/src/socket.io-adapter/index.ts b/packages/websocket/src/socket.io-adapter/index.ts new file mode 100644 index 00000000..fdee138c --- /dev/null +++ b/packages/websocket/src/socket.io-adapter/index.ts @@ -0,0 +1,279 @@ +import { EventEmitter } from "events" + +export type SocketId = string +export type Room = string + +export interface BroadcastFlags { + volatile?: boolean + compress?: boolean + local?: boolean + broadcast?: boolean + binary?: boolean +} + +export interface BroadcastOptions { + rooms: Set + except?: Set + flags?: BroadcastFlags +} + +export class Adapter extends EventEmitter { + 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 + } + + /** + * To be overridden + */ + public init(): Promise | void { } + + /** + * To be overridden + */ + public close(): Promise | void { } + + /** + * 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()) + } + + 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) + } + } + } + + /** + * 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) + } + + this._del(room, id) + } + + private _del(room, id) { + if (this.rooms.has(room)) { + const deleted = this.rooms.get(room).delete(id) + if (deleted) { + this.emit("leave-room", room, id) + } + if (this.rooms.get(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 basePacketOpts = { + preEncoded: true, + volatile: flags.volatile, + compress: flags.compress + } + + packet.nsp = this.nsp.name + const encodedPackets = this.encoder.encode(packet) + + const packetOpts = encodedPackets.map(encodedPacket => { + if (typeof encodedPacket === "string") { + return { + ...basePacketOpts, + wsPreEncoded: "4" + encodedPacket // "4" being the "message" packet type in Engine.IO + } + } else { + return basePacketOpts + } + }) + + this.apply(opts, socket => { + for (let i = 0; i < encodedPackets.length; i++) { + socket.client.writeToEngine(encodedPackets[i], packetOpts[i]) + } + }) + } + + /** + * 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 { + throw new Error( + "this adapter does not support the serverSideEmit() functionality" + ) + } +} diff --git a/packages/websocket/src/socket.io-parser/binary.ts b/packages/websocket/src/socket.io-parser/binary.ts new file mode 100644 index 00000000..dd02b685 --- /dev/null +++ b/packages/websocket/src/socket.io-parser/binary.ts @@ -0,0 +1,78 @@ +import { isBinary } from "./is-binary" + +/** + * Replaces every Buffer | ArrayBuffer | Blob | File in packet with a numbered placeholder. + * + * @param {Object} packet - socket.io event packet + * @return {Object} with deconstructed packet and list of buffers + * @public + */ + +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 } +} + +function _deconstructPacket(data, buffers) { + 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 (data.hasOwnProperty(key)) { + newData[key] = _deconstructPacket(data[key], buffers) + } + } + return newData + } + return data +} + +/** + * Reconstructs a binary packet from its placeholder packet and buffers + * + * @param {Object} packet - event packet with placeholders + * @param {Array} buffers - binary buffers to put in placeholder positions + * @return {Object} reconstructed packet + * @public + */ + +export function reconstructPacket(packet, buffers) { + packet.data = _reconstructPacket(packet.data, buffers) + packet.attachments = undefined // no longer useful + return packet +} + +function _reconstructPacket(data, buffers) { + if (!data) return data + + if (data && data._placeholder) { + return buffers[data.num] // appropriate buffer (should be natural order anyway) + } else if (Array.isArray(data)) { + for (let i = 0; i < data.length; i++) { + data[i] = _reconstructPacket(data[i], buffers) + } + } else if (typeof data === "object") { + for (const key in data) { + if (data.hasOwnProperty(key)) { + data[key] = _reconstructPacket(data[key], buffers) + } + } + } + + return data +} diff --git a/packages/websocket/src/socket.io-parser/index.ts b/packages/websocket/src/socket.io-parser/index.ts new file mode 100644 index 00000000..02811751 --- /dev/null +++ b/packages/websocket/src/socket.io-parser/index.ts @@ -0,0 +1,316 @@ +import EventEmitter = require("events") +import { deconstructPacket, reconstructPacket } from "./binary" +import { isBinary, hasBinary } from "./is-binary" + +// const debug = require("debug")("socket.io-parser") + +/** + * Protocol version. + * + * @public + */ + +export const protocol: number = 5 + +export enum PacketType { + CONNECT, + DISCONNECT, + EVENT, + ACK, + CONNECT_ERROR, + BINARY_EVENT, + BINARY_ACK, +} + +export interface Packet { + type: PacketType + nsp: string + data?: any + id?: number + attachments?: number +} + +/** + * A socket.io Encoder instance + */ + +export class Encoder { + /** + * 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) { + console.trace("encoding packet", JSON.stringify(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)] + } + + /** + * 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) + } + + console.trace("encoded", JSON.stringify(obj), "as", 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 + } +} + +/** + * A socket.io Decoder instance + * + * @return {Object} decoder + */ +export class Decoder extends EventEmitter { + private reconstructor: BinaryReconstructor + + constructor() { + super() + } + + /** + * Decodes an encoded packet string into packet JSON. + * + * @param {String} obj - encoded packet + */ + + public add(obj: any) { + let packet + if (typeof obj === "string") { + 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.emit("decoded", packet) + } + } else { + // non-binary full packet + super.emit("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.emit("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) + } + + // 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 = tryParse(str.substr(i)) + if (Decoder.isPayloadValid(p.type, payload)) { + p.data = payload + } else { + throw new Error("invalid payload") + } + } + + console.trace("decoded", str, "as", p) + return p + } + + 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() + } + } +} + +function tryParse(str) { + try { + return JSON.parse(str) + } catch (e) { + return false + } +} + +/** + * A manager of a binary event's 'buffer sequence'. Should + * be constructed whenever a packet of type BINARY_EVENT is + * decoded. + * + * @param {Object} packet + * @return {BinaryReconstructor} initialized reconstructor + */ + +class BinaryReconstructor { + private reconPack + private buffers: Array = []; + + 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 + } + + /** + * 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 new file mode 100644 index 00000000..505173dd --- /dev/null +++ b/packages/websocket/src/socket.io-parser/is-binary.ts @@ -0,0 +1,65 @@ +const withNativeArrayBuffer: boolean = typeof ArrayBuffer === "function" + +const isView = (obj: any) => { + return typeof ArrayBuffer.isView === "function" + ? ArrayBuffer.isView(obj) + : obj.buffer instanceof ArrayBuffer +} + +const toString = Object.prototype.toString +const withNativeBlob = false +// typeof Blob === "function" || +// (typeof Blob !== "undefined" && +// toString.call(Blob) === "[object BlobConstructor]") +const withNativeFile = false +// typeof File === "function" || +// (typeof File !== "undefined" && +// toString.call(File) === "[object FileConstructor]") + +/** + * Returns true if obj is a Buffer, an ArrayBuffer, a Blob or a File. + * + * @private + */ + +export function isBinary(obj: any) { + 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 (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 ( + 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 +} diff --git a/packages/websocket/src/socket.io/broadcast-operator.ts b/packages/websocket/src/socket.io/broadcast-operator.ts new file mode 100644 index 00000000..0a005275 --- /dev/null +++ b/packages/websocket/src/socket.io/broadcast-operator.ts @@ -0,0 +1,320 @@ +// 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 { + EventParams, + EventNames, + EventsMap, + TypedEventBroadcaster, +} from "./typed-events" + +export class BroadcastOperator + implements TypedEventBroadcaster +{ + constructor( + private readonly adapter: Adapter, + private readonly rooms: Set = new Set(), + private readonly exceptRooms: Set = new Set(), + private readonly flags: BroadcastFlags = {} + ) { } + + /** + * Targets a room when emitting. + * + * @param room + * @return a new BroadcastOperator instance + * @public + */ + public to(room: Room | Room[]): BroadcastOperator { + 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. + * + * @param room + * @return a new BroadcastOperator instance + * @public + */ + public in(room: Room | Room[]): BroadcastOperator { + return this.to(room) + } + + /** + * Excludes a room when emitting. + * + * @param room + * @return a new BroadcastOperator instance + * @public + */ + public except(room: Room | Room[]): BroadcastOperator { + 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. + * + * @param compress - if `true`, compresses the sending data + * @return a new BroadcastOperator instance + * @public + */ + public compress(compress: boolean): BroadcastOperator { + 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). + * + * @return a new BroadcastOperator instance + * @public + */ + public get volatile(): BroadcastOperator { + 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. + * + * @return a new BroadcastOperator instance + * @public + */ + public get local(): BroadcastOperator { + const flags = Object.assign({}, this.flags, { local: true }) + return new BroadcastOperator( + this.adapter, + this.rooms, + this.exceptRooms, + flags + ) + } + + /** + * Emits to all clients. + * + * @return Always true + * @public + */ + public emit>( + ev: Ev, + ...args: EventParams + ): boolean { + if (RESERVED_EVENTS.has(ev)) { + throw new Error(`"${ev}" is a reserved event name`) + } + // set up packet object + const data = [ev, ...args] + const packet = { + type: PacketType.EVENT, + data: data, + } + + if ("function" == typeof data[data.length - 1]) { + throw new Error("Callbacks are not supported when broadcasting") + } + + this.adapter.broadcast(packet, { + rooms: this.rooms, + except: this.exceptRooms, + flags: this.flags, + }) + + return true + } + + /** + * Gets a list of clients. + * + * @public + */ + 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 + * + * @public + */ + public fetchSockets(): Promise[]> { + return this.adapter + .fetchSockets({ + rooms: this.rooms, + except: this.exceptRooms, + }) + .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 + * + * @param room + * @public + */ + public socketsJoin(room: Room | Room[]): void { + this.adapter.addSockets( + { + rooms: this.rooms, + except: this.exceptRooms, + }, + Array.isArray(room) ? room : [room] + ) + } + + /** + * Makes the matching socket instances leave the specified rooms + * + * @param room + * @public + */ + public socketsLeave(room: Room | Room[]): void { + this.adapter.delSockets( + { + rooms: this.rooms, + except: this.exceptRooms, + }, + Array.isArray(room) ? room : [room] + ) + } + + /** + * Makes the matching socket instances disconnect + * + * @param close - whether to close the underlying connection + * @public + */ + public disconnectSockets(close: boolean = false): void { + this.adapter.disconnectSockets( + { + rooms: this.rooms, + except: this.exceptRooms, + }, + close + ) + } +} + +/** + * Format of the data when the Socket instance exists on another Socket.IO server + */ +interface SocketDetails { + id: SocketId + handshake: Handshake + rooms: Room[] + data: any +} + +/** + * Expose of subset of the attributes and methods of the Socket class + */ +export class RemoteSocket + implements TypedEventBroadcaster +{ + public readonly id: SocketId + public readonly handshake: Handshake + public readonly rooms: Set + public readonly data: any + + 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])) + } + + public emit>( + ev: Ev, + ...args: EventParams + ): boolean { + return this.operator.emit(ev, ...args) + } + + /** + * Joins a room. + * + * @param {String|Array} room - room or array of rooms + * @public + */ + public join(room: Room | Room[]): void { + return this.operator.socketsJoin(room) + } + + /** + * Leaves a room. + * + * @param {String} room + * @public + */ + 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 + */ + 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 new file mode 100644 index 00000000..baed82c7 --- /dev/null +++ b/packages/websocket/src/socket.io/client.ts @@ -0,0 +1,331 @@ +// 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 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 EngineIOSocket } from '../engine.io/socket' + +// const debug = debugModule("socket.io:client"); + +interface WriteOptions { + compress?: boolean + volatile?: boolean + preEncoded?: boolean + wsPreEncoded?: string +} + +export class Client< + ListenEvents extends EventsMap, + EmitEvents extends EventsMap, + ServerSideEvents extends EventsMap + > { + public readonly conn: EngineIOSocket + /** + * @private + */ + readonly id: string + private readonly server: Server + private readonly encoder: Encoder + private readonly decoder: any + 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: EngineIOSocket + ) { + 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(): any/** IncomingMessage */ { + return this.conn.request + } + + /** + * Sets up event listeners. + * + * @private + */ + private setup() { + console.debug(`socket.io client setup conn ${this.conn.id}`) + this.onclose = this.onclose.bind(this) + this.ondata = this.ondata.bind(this) + this.onerror = this.onerror.bind(this) + 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) { + console.debug(`no namespace joined yet, close the client ${this.id}`) + this.close() + } else { + console.debug(`the client ${this.id} 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: object = {}): void { + if (this.server._nsps.has(name)) { + console.debug(`socket.io client ${this.id} connecting to namespace ${name}`) + return this.doConnect(name, auth) + } + + this.server._checkNamespace( + name, + auth, + ( + dynamicNspName: + | Namespace + | false + ) => { + if (dynamicNspName) { + console.debug(`dynamic namespace ${dynamicNspName} was created`) + this.doConnect(name, auth) + } else { + console.debug(`creation of namespace ${name} was denied`) + 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 { + console.debug("ignoring remove for", socket.id) + } + // @java-patch disconnect client when no live socket + process.nextTick(() => { + if (this.sockets.size == 0) { + this.onclose('no live socket') + } + }) + } + + /** + * Closes the underlying connection. + * + * @private + */ + private close(): void { + console.debug(`client ${this.id} clise - reason: forcing transport close`) + if ("open" === this.conn.readyState) { + console.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") { + console.debug(`client ${this.id} ignoring packet write ${JSON.stringify(packet)}`) + return + } + const encodedPackets = opts.preEncoded + ? (packet as any[]) // previous versions of the adapter incorrectly used socket.packet() instead of writeToEngine() + : this.encoder.encode(packet as Packet) + for (const encodedPacket of encodedPackets) { + this.writeToEngine(encodedPacket, opts) + } + } + + private writeToEngine( + encodedPacket: String | Buffer, + opts: WriteOptions + ): void { + if (opts.volatile && !this.conn.transport.writable) { + console.debug(`client ${this.id} volatile packet is discarded since the transport is not currently writable`) + return + } + 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) { + this.onerror(e) + } + } + + /** + * Called when parser fully decodes a packet. + * + * @private + */ + private ondecoded(packet: Packet): void { + if (PacketType.CONNECT === packet.type) { + if (this.conn.protocol === 3) { + const parsed = url.parse(packet.nsp, true) + this.connect(parsed.pathname!, parsed.query) + } else { + this.connect(packet.nsp, packet.data) + } + } else { + const socket = this.nsps.get(packet.nsp) + if (socket) { + process.nextTick(function () { + socket._onpacket(packet) + }) + } else { + console.debug(`client ${this.id} no socket for namespace ${packet.nsp}.`) + } + } + } + + /** + * 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: string): void { + console.debug(`client ${this.id} close with reason ${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 + } + } +} diff --git a/packages/websocket/src/socket.io/index.ts b/packages/websocket/src/socket.io/index.ts new file mode 100644 index 00000000..002f1caf --- /dev/null +++ b/packages/websocket/src/socket.io/index.ts @@ -0,0 +1,825 @@ +// import http = require("http"); +// import { createReadStream } from "fs"; +// import { createDeflate, createGzip, createBrotliCompress } from "zlib"; +// import accepts = require("accepts"); +// import { pipeline } from "stream"; +// import path = require("path"); +import engine = require("../engine.io") +import { Client } from './client' +import { EventEmitter } from 'events' +import { 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 { CookieSerializeOptions } from "cookie"; +// import type { CorsOptions } from "cors"; +import type { BroadcastOperator, RemoteSocket } from "./broadcast-operator" +import { + EventsMap, + DefaultEventsMap, + EventParams, + StrictEventEmitter, + EventNames, +} from "./typed-events" + +import type { Socket as EngineIOSocket } from '../engine.io/socket' + +// const clientVersion = require("../package.json").version +// const dotMapRegex = /\.map/ + +// type Transport = "polling" | "websocket"; +type ParentNspNameMatchFn = ( + name: string, + auth: { [key: string]: any }, + fn: (err: Error | null, success: boolean) => void +) => void + +type AdapterConstructor = typeof Adapter | ((nsp: Namespace) => Adapter) + +interface EngineOptions { + /** + * how many ms without a pong packet to consider the connection closed + * @default 5000 + */ + pingTimeout: number + /** + * how many ms before sending a new ping packet + * @default 25000 + */ + pingInterval: number + /** + * how many ms before an uncompleted transport upgrade is cancelled + * @default 10000 + */ + upgradeTimeout: number + /** + * how many bytes or characters a message can be, before closing the session (to avoid DoS). + * @default 1e5 (100 KB) + */ + maxHttpBufferSize: number + /** + * A function that receives a given handshake or upgrade request as its first parameter, + * and can decide whether to continue or not. The second argument is a function that needs + * to be called with the decided information: fn(err, success), where success is a boolean + * value where false means that the request is rejected, and err is an error code. + */ + // allowRequest: ( + // req: http.IncomingMessage, + // fn: (err: string | null | undefined, success: boolean) => void + // ) => void + /** + * the low-level transports that are enabled + * @default ["polling", "websocket"] + */ + // transports: Transport[] + /** + * whether to allow transport upgrades + * @default true + */ + allowUpgrades: boolean + /** + * parameters of the WebSocket permessage-deflate extension (see ws module api docs). Set to false to disable. + * @default false + */ + perMessageDeflate: boolean | object + /** + * parameters of the http compression for the polling transports (see zlib api docs). Set to false to disable. + * @default true + */ + httpCompression: boolean | object + /** + * what WebSocket server implementation to use. Specified module must + * conform to the ws interface (see ws module api docs). Default value is ws. + * An alternative c++ addon is also available by installing uws module. + */ + wsEngine: string + /** + * an optional packet which will be concatenated to the handshake packet emitted by Engine.IO. + */ + initialPacket: any + /** + * configuration of the cookie that contains the client sid to send as part of handshake response headers. This cookie + * might be used for sticky-session. Defaults to not sending any cookie. + * @default false + */ + // cookie: CookieSerializeOptions | boolean + /** + * the options that will be forwarded to the cors module + */ + // cors: CorsOptions + /** + * whether to enable compatibility with Socket.IO v2 clients + * @default false + */ + allowEIO3: boolean +} + +interface AttachOptions { + /** + * name of the path to capture + * @default "/engine.io" + */ + path: string + /** + * destroy unhandled upgrade requests + * @default true + */ + destroyUpgrade: boolean + /** + * milliseconds after which unhandled requests are ended + * @default 1000 + */ + destroyUpgradeTimeout: number +} + +interface EngineAttachOptions extends EngineOptions, AttachOptions { } + +interface ServerOptions extends EngineAttachOptions { + /** + * 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: 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 +} + +export class Server< + ListenEvents extends EventsMap = DefaultEventsMap, + EmitEvents extends EventsMap = ListenEvents, + ServerSideEvents extends EventsMap = DefaultEventsMap + > extends StrictEventEmitter< + ServerSideEvents, + EmitEvents, + ServerReservedEventsMap + > { + public readonly sockets: Namespace< + ListenEvents, + EmitEvents, + ServerSideEvents + > + /** + * 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> = + new Map(); + private parentNsps: Map< + ParentNspNameMatchFn, + ParentNamespace + > = new Map(); + private _adapter?: AdapterConstructor + private _serveClient: boolean + private opts: Partial + private eio + private _path: string + private clientPathRegex: RegExp + /** + * @private + */ + _connectTimeout: number + + // private httpServer: http.Server + + constructor(srv: any, opts: Partial = {}) { + super() + if (!srv) { throw new Error('srv can\'t be undefiend!') } + // 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('/') + // if (srv) this.attach(srv as http.Server); + 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 + */ + 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) { + run() + } else { + const namespace = this.parentNsps + .get(nextFn.value)! + .createChild(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 + */ + 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(\\.min|\\.msgpack\\.min)?\\.js(\\.map)?$" + ) + return this + } + + /** + * Set the delay after which a client without namespace is closed + * @param v + * @public + */ + 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 + */ + 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 + */ + public listen( + srv: any,//http.Server | number, + 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 + */ + public attach( + srv: any,//http.Server | 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 + } + + /** + * Initialize engine + * + * @param srv - the server to attach to + * @param opts - options passed to engine.io + * @private + */ + private initEngine(srv: any, opts: Partial) { + // // initialize engine + console.debug("creating engine.io instance with opts", JSON.stringify(opts)) + this.eio = engine.attach(srv, opts) + + // // 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): 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, "") + // 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 {Server} self + * @public + */ + public bind(engine): Server { + console.debug('engine.io', engine.constructor.name, 'bind to socket.io') + this.engine = engine + this.engine.on("connection", this.onconnection.bind(this)) + return this + } + + /** + * Called with each incoming transport connection. + * + * @param {engine.Socket} conn + * @return {Server} self + * @private + */ + private onconnection(conn: EngineIOSocket): Server { + console.debug(`socket.io index incoming connection with id ${conn.id}`) + let client = new Client(this, conn) + if (conn.protocol === 3) { + // @ts-ignore + client.connect("/") + } + return this + } + + /** + * Looks up a namespace. + * + * @param {String|RegExp|Function} name nsp name + * @param fn optional, nsp `connection` ev handler + * @public + */ + public of( + name: string | RegExp | ParentNspNameMatchFn, + fn?: (socket: Socket) => void + ): Namespace { + if (typeof name === "function" || name instanceof RegExp) { + const parentNsp = new ParentNamespace(this) + console.debug(`initializing parent namespace ${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) { + console.debug("initializing namespace", 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 + */ + public close(fn?: (err?: Error) => void): void { + for (const socket of this.sockets.sockets.values()) { + socket._onclose("server shutting down") + } + + this.engine.close() + + // if (this.httpServer) { + // this.httpServer.close(fn) + // } else { + fn && fn() + // } + } + + /** + * Sets up namespace middleware. + * + * @return self + * @public + */ + public use( + fn: ( + socket: Socket, + next: (err?: ExtendedError) => void + ) => void + ): this { + this.sockets.use(fn) + return this + } + + /** + * Targets a room when emitting. + * + * @param room + * @return self + * @public + */ + public to(room: Room | Room[]): BroadcastOperator { + return this.sockets.to(room) + } + + /** + * Targets a room when emitting. + * + * @param room + * @return self + * @public + */ + public in(room: Room | Room[]): BroadcastOperator { + return this.sockets.in(room) + } + + /** + * Excludes a room when emitting. + * + * @param name + * @return self + * @public + */ + public except(name: Room | Room[]): BroadcastOperator { + return this.sockets.except(name) + } + + /** + * Sends a `message` event to all clients. + * + * @return self + * @public + */ + public send(...args: EventParams): this { + this.sockets.emit("message", ...args) + return this + } + + /** + * Sends a `message` event to all clients. + * + * @return self + * @public + */ + public write(...args: EventParams): this { + this.sockets.emit("message", ...args) + return this + } + + /** + * Emit a packet to other Socket.IO servers + * + * @param ev - the event name + * @param args - an array of arguments, which may include an acknowledgement callback at the end + * @public + */ + public serverSideEmit>( + ev: Ev, + ...args: EventParams + ): boolean { + return this.sockets.serverSideEmit(ev, ...args) + } + + /** + * Gets a list of socket ids. + * + * @public + */ + public allSockets(): Promise> { + return this.sockets.allSockets() + } + + /** + * Sets the compress flag. + * + * @param compress - if `true`, compresses the sending data + * @return self + * @public + */ + public compress(compress: boolean): BroadcastOperator { + 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). + * + * @return self + * @public + */ + public get volatile(): BroadcastOperator { + return this.sockets.volatile + } + + /** + * Sets a modifier for a subsequent event emission that the event data will only be broadcast to the current node. + * + * @return self + * @public + */ + public get local(): BroadcastOperator { + return this.sockets.local + } + + /** + * Returns the matching socket instances + * + * @public + */ + public fetchSockets(): Promise[]> { + return this.sockets.fetchSockets() + } + + /** + * Makes the matching socket instances join the specified rooms + * + * @param room + * @public + */ + public socketsJoin(room: Room | Room[]): void { + return this.sockets.socketsJoin(room) + } + + /** + * Makes the matching socket instances leave the specified rooms + * + * @param room + * @public + */ + public socketsLeave(room: Room | Room[]): void { + return this.sockets.socketsLeave(room) + } + + /** + * Makes the matching socket instances disconnect + * + * @param close - whether to close the underlying connection + * @public + */ + public disconnectSockets(close: boolean = false): void { + return this.sockets.disconnectSockets(close) + } +} + +/** + * Expose main namespace (/). + */ + +const emitterMethods = Object.keys(EventEmitter.prototype).filter(function ( + key +) { + return typeof EventEmitter.prototype[key] === "function" +}) + +emitterMethods.forEach(function (fn) { + Server.prototype[fn] = function () { + return this.sockets[fn].apply(this.sockets, arguments) + } +}) + +export { Socket, ServerOptions, Namespace, BroadcastOperator, RemoteSocket } diff --git a/packages/websocket/src/socket.io/namespace.ts b/packages/websocket/src/socket.io/namespace.ts new file mode 100644 index 00000000..a0242e88 --- /dev/null +++ b/packages/websocket/src/socket.io/namespace.ts @@ -0,0 +1,407 @@ + +import { Socket } from "./socket" +import type { Server } from "./index" +import { + EventParams, + EventNames, + EventsMap, + StrictEventEmitter, + DefaultEventsMap, +} 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" + +// const debug = debugModule("socket.io:namespace"); + +export interface ExtendedError extends Error { + data?: any +} + +export interface NamespaceReservedEventsMap< + ListenEvents extends EventsMap, + EmitEvents extends EventsMap, + ServerSideEvents extends EventsMap + > { + connect: (socket: Socket) => void + connection: ( + socket: Socket + ) => void +} + +export interface ServerReservedEventsMap< + ListenEvents, + EmitEvents, + ServerSideEvents + > extends NamespaceReservedEventsMap< + ListenEvents, + EmitEvents, + ServerSideEvents + > { + new_namespace: ( + namespace: Namespace + ) => void +} + +export const RESERVED_EVENTS: ReadonlySet = new Set< + keyof ServerReservedEventsMap +>(["connect", "connection", "new_namespace"]) + +export class Namespace< + ListenEvents extends EventsMap = DefaultEventsMap, + EmitEvents extends EventsMap = ListenEvents, + ServerSideEvents extends EventsMap = DefaultEventsMap + > extends StrictEventEmitter< + ServerSideEvents, + EmitEvents, + NamespaceReservedEventsMap + > { + public readonly name: string + public readonly sockets: Map< + SocketId, + Socket + > = new Map(); + + public adapter: Adapter + + /** @private */ + readonly server: Server + + /** @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() { + // @ts-ignore + this.adapter = new (this.server.adapter()!)(this) + } + + /** + * Sets up namespace middleware. + * + * @return self + * @public + */ + 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. + * + * @param room + * @return self + * @public + */ + public to(room: Room | Room[]): BroadcastOperator { + return new BroadcastOperator(this.adapter).to(room) + } + + /** + * Targets a room when emitting. + * + * @param room + * @return self + * @public + */ + public in(room: Room | Room[]): BroadcastOperator { + return new BroadcastOperator(this.adapter).in(room) + } + + /** + * Excludes a room when emitting. + * + * @param room + * @return self + * @public + */ + public except(room: Room | Room[]): BroadcastOperator { + return new BroadcastOperator(this.adapter).except(room) + } + + /** + * Adds a new client. + * + * @return {Socket} + * @private + */ + _add( + client: Client, + query, + fn?: (socket: Socket) => void + ): Socket { + const socket = new Socket(this, client, query || {}) + console.debug(`socket.io namespace client ${client.id} adding socket ${socket.id} to nsp ${this.name}`) + this.run(socket, err => { + process.nextTick(() => { + if ("open" == client.conn.readyState) { + if (err) { + if (client.conn.protocol === 3) { + return socket._error(err.data || err.message) + } else { + return socket._error({ + message: err.message, + data: err.data, + }) + } + } + + // track socket + this.sockets.set(socket.id, socket) + console.debug(`socket.io namespace ${this.name} track client ${client.id} socket ${socket.id}`) + + // it's paramount that the internal `onconnect` logic + // fires before user-set events to prevent state order + // violations (such as a disconnection before the connection + // logic is complete) + socket._onconnect() + // @java-patch multi thread need direct callback socket + if (fn) fn(socket) + + // fire user-set events + this.emitReserved("connect", socket) + this.emitReserved("connection", socket) + } else { + console.debug(`next called after client ${client.id} was closed - ignoring socket`) + } + }) + }) + return socket + } + + /** + * Removes a client. Called by each `Socket`. + * + * @private + */ + _remove(socket: Socket): void { + if (this.sockets.has(socket.id)) { + console.debug(`namespace ${this.name} remove socket ${socket.id}`) + this.sockets.delete(socket.id) + } else { + console.debug(`namespace ${this.name} ignoring remove for ${socket.id}`) + } + } + + /** + * Emits to all clients. + * + * @return Always true + * @public + */ + public emit>( + ev: Ev, + ...args: EventParams + ): boolean { + return new BroadcastOperator(this.adapter).emit(ev, ...args) + } + + /** + * Sends a `message` event to all clients. + * + * @return self + * @public + */ + public send(...args: EventParams): this { + this.emit("message", ...args) + return this + } + + /** + * Sends a `message` event to all clients. + * + * @return self + * @public + */ + public write(...args: EventParams): this { + this.emit("message", ...args) + return this + } + + /** + * Emit a packet to other Socket.IO servers + * + * @param ev - the event name + * @param args - an array of arguments, which may include an acknowledgement callback at the end + * @public + */ + public serverSideEmit>( + ev: Ev, + ...args: EventParams + ): boolean { + if (RESERVED_EVENTS.has(ev)) { + throw new Error(`"${ev}" is a reserved event name`) + } + args.unshift(ev) + this.adapter.serverSideEmit(args) + return true + } + + /** + * 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) + } + + /** + * Gets a list of clients. + * + * @return self + * @public + */ + public allSockets(): Promise> { + return new BroadcastOperator(this.adapter).allSockets() + } + + /** + * Sets the compress flag. + * + * @param compress - if `true`, compresses the sending data + * @return self + * @public + */ + public compress(compress: boolean): BroadcastOperator { + return new BroadcastOperator(this.adapter).compress(compress) + } + + /** + * Sets a modifier for a subsequent event emission that the event data may be lost if the client is not ready to + * receive messages (because of network slowness or other issues, or because they’re connected through long polling + * and is in the middle of a request-response cycle). + * + * @return self + * @public + */ + public get volatile(): BroadcastOperator { + return new BroadcastOperator(this.adapter).volatile + } + + /** + * Sets a modifier for a subsequent event emission that the event data will only be broadcast to the current node. + * + * @return self + * @public + */ + public get local(): BroadcastOperator { + return new BroadcastOperator(this.adapter).local + } + + /** + * Returns the matching socket instances + * + * @public + */ + public fetchSockets(): Promise[]> { + return new BroadcastOperator(this.adapter).fetchSockets() + } + + /** + * Makes the matching socket instances join the specified rooms + * + * @param room + * @public + */ + public socketsJoin(room: Room | Room[]): void { + return new BroadcastOperator(this.adapter).socketsJoin(room) + } + + /** + * Makes the matching socket instances leave the specified rooms + * + * @param room + * @public + */ + public socketsLeave(room: Room | Room[]): void { + return new BroadcastOperator(this.adapter).socketsLeave(room) + } + + /** + * Makes the matching socket instances disconnect + * + * @param close - whether to close the underlying connection + * @public + */ + public disconnectSockets(close: boolean = false): void { + return new BroadcastOperator(this.adapter).disconnectSockets(close) + } + + 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`)) + } +} diff --git a/packages/websocket/src/socket.io/parent-namespace.ts b/packages/websocket/src/socket.io/parent-namespace.ts new file mode 100644 index 00000000..9a5c01ab --- /dev/null +++ b/packages/websocket/src/socket.io/parent-namespace.ts @@ -0,0 +1,72 @@ +import { Namespace } from "./namespace" +import type { Server } 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" + +export class ParentNamespace< + ListenEvents extends EventsMap = DefaultEventsMap, + EmitEvents extends EventsMap = ListenEvents, + ServerSideEvents extends EventsMap = DefaultEventsMap + > extends Namespace { + private static count: number = 0; + private children: Set> = new Set(); + + constructor(server: Server) { + super(server, "/_" + ParentNamespace.count++) + } + + _initAdapter() { + 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 + } + + // public emit(...args: any[]): boolean { + // this.children.forEach(nsp => { + // nsp._rooms = this._rooms + // nsp._flags = this._flags + // nsp.emit.apply(nsp, args as any) + // }) + // this._rooms.clear() + // this._flags = {} + + // return true + // } + + createChild( + name: string + ): Namespace { + 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 + } +} diff --git a/packages/websocket/src/socket.io/socket.ts b/packages/websocket/src/socket.io/socket.ts new file mode 100644 index 00000000..a9804a9a --- /dev/null +++ b/packages/websocket/src/socket.io/socket.ts @@ -0,0 +1,727 @@ + +import { Packet, PacketType } from "../socket.io-parser" +import url = require("url") +// import debugModule from "debug" +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" +// import type { IncomingMessage, IncomingHttpHeaders } from "http" +import type { + Adapter, + BroadcastFlags, + Room, + SocketId, +} from "socket.io-adapter" +// import base64id from "base64id" +import type { ParsedUrlQuery } from "querystring" +import { BroadcastOperator } from "./broadcast-operator" + +// const debug = debugModule("socket.io:socket"); + +type ClientReservedEvents = "connect_error" + +export interface SocketReservedEventsMap { + disconnect: (reason: string) => void + disconnecting: (reason: string) => 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 +} + +export const RESERVED_EVENTS: ReadonlySet = new Set< + | ClientReservedEvents + | keyof NamespaceReservedEventsMap + | keyof SocketReservedEventsMap + | keyof EventEmitterReservedEventsMap +>([ + "connect", + "connect_error", + "disconnect", + "disconnecting", + "newListener", + "removeListener", +]) + +/** + * The handshake details + */ +export interface Handshake { + /** + * The headers sent as part of the handshake + */ + headers: any//IncomingHttpHeaders + + /** + * The date of creation (as string) + */ + time: string + + /** + * The ip of the client + */ + address: string + + /** + * Whether the connection is cross-domain + */ + xdomain: boolean + + /** + * Whether the connection is secure + */ + secure: boolean + + /** + * The date of creation (as unix timestamp) + */ + issued: number + + /** + * The request URL string + */ + url: string + + /** + * The query object + */ + query: ParsedUrlQuery + + /** + * The auth object + */ + auth: { [key: string]: any } +} + +export class Socket< + ListenEvents extends EventsMap = DefaultEventsMap, + EmitEvents extends EventsMap = ListenEvents, + ServerSideEvents extends EventsMap = DefaultEventsMap + > extends StrictEventEmitter< + ListenEvents, + EmitEvents, + SocketReservedEventsMap + > { + public readonly id: SocketId + public readonly handshake: Handshake + + /** + * Additional information that can be attached to the Socket instance and which will be used in the fetchSockets method + */ + public data: any = {}; + + public connected: boolean + public disconnected: boolean + + private readonly server: Server + private readonly adapter: Adapter + private acks: Map void> = new Map(); + private fns: Array<(event: Array, next: (err?: Error) => void) => void> = + []; + private flags: BroadcastFlags = {}; + private _anyListeners?: 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: object + ) { + super() + this.nsp = nsp + 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.client = client + this.acks = new Map() + this.connected = true + this.disconnected = false + this.handshake = this.buildHandshake(auth) + } + + buildHandshake(auth): 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!, + query: url.parse(this.request.url!, true).query, + auth, + } + } + + /** + * Emits to this client. + * + * @return Always returns `true`. + * @public + */ + public emit>( + ev: Ev, + ...args: EventParams + ): boolean { + if (RESERVED_EVENTS.has(ev)) { + throw new Error(`"${ev}" is a reserved event name`) + } + 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") { + console.trace("emitting packet with ack id %d", this.nsp._ids) + this.acks.set(this.nsp._ids, data.pop()) + packet.id = this.nsp._ids++ + } + + const flags = Object.assign({}, this.flags) + this.flags = {} + + this.packet(packet, flags) + + return true + } + + /** + * Targets a room when broadcasting. + * + * @param room + * @return self + * @public + */ + public to(room: Room | Room[]): BroadcastOperator { + return this.newBroadcastOperator().to(room) + } + + /** + * Targets a room when broadcasting. + * + * @param room + * @return self + * @public + */ + public in(room: Room | Room[]): BroadcastOperator { + return this.newBroadcastOperator().in(room) + } + + /** + * Excludes a room when broadcasting. + * + * @param room + * @return self + * @public + */ + public except(room: Room | Room[]): BroadcastOperator { + return this.newBroadcastOperator().except(room) + } + + /** + * Sends a `message` event. + * + * @return self + * @public + */ + public send(...args: EventParams): this { + this.emit("message", ...args) + return this + } + + /** + * Sends a `message` event. + * + * @return self + * @public + */ + 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. + * + * @param {String|Array} rooms - room or array of rooms + * @return a Promise or nothing, depending on the adapter + * @public + */ + public join(rooms: Room | Array): Promise | void { + console.debug(`join room ${rooms}`) + + return this.adapter.addAll( + this.id, + new Set(Array.isArray(rooms) ? rooms : [rooms]) + ) + } + + /** + * Leaves a room. + * + * @param {String} room + * @return a Promise or nothing, depending on the adapter + * @public + */ + public leave(room: string): Promise | void { + console.debug(`leave room ${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 { + console.debug(`socket ${this.id} connected - writing packet`) + this.join(this.id) + if (this.conn.protocol === 3) { + this.packet({ type: PacketType.CONNECT }) + } else { + this.packet({ type: PacketType.CONNECT, data: { sid: this.id } }) + } + } + + /** + * Called with each packet. Called by `Client`. + * + * @param {Object} packet + * @private + */ + _onpacket(packet: Packet): void { + console.trace("got packet", JSON.stringify(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 + + case PacketType.CONNECT_ERROR: + this._onerror(new Error(packet.data)) + } + } + + /** + * Called upon event packet. + * + * @param {Packet} packet - packet object + * @private + */ + private onevent(packet: Packet): void { + const args = packet.data || [] + console.trace("emitting event", JSON.stringify(args)) + + if (null != packet.id) { + console.trace("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) + } + + /** + * 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) + console.trace("sending ack", JSON.stringify(args)) + + self.packet({ + id: id, + type: PacketType.ACK, + data: args, + }) + + sent = true + } + } + + /** + * Called upon ack packet. + * + * @private + */ + private onack(packet: Packet): void { + const ack = this.acks.get(packet.id!) + if ("function" == typeof ack) { + console.trace(`socket ${this.id} calling ack ${packet.id} with ${packet.data}`) + ack.apply(this, packet.data) + this.acks.delete(packet.id!) + } else { + console.debug(`socket ${this.id} bad ack`, packet.id) + } + } + + /** + * Called upon client disconnect packet. + * + * @private + */ + private ondisconnect(): void { + console.debug(`socket ${this.id} 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 + * @throw {Error} optional error object + * + * @private + */ + _onclose(reason: string): this | undefined { + if (!this.connected) return this + console.debug(`closing socket ${this.id} - reason: ${reason}`) + this.emitReserved("disconnecting", reason) + this.leaveAll() + this.nsp._remove(this) + this.client._remove(this) + this.connected = false + this.disconnected = true + this.emitReserved("disconnect", reason) + return + } + + /** + * Produces an `error` packet. + * + * @param {Object} err - error object + * + * @private + */ + _error(err): void { + this.packet({ type: PacketType.CONNECT_ERROR, data: err }) + } + + /** + * Disconnects this client. + * + * @param {Boolean} close - if `true`, closes the underlying connection + * @return {Socket} self + * + * @public + */ + 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. + * + * @param {Boolean} compress - if `true`, compresses the sending data + * @return {Socket} self + * @public + */ + 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). + * + * @return {Socket} self + * @public + */ + 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. + * + * @return {Socket} self + * @public + */ + public get broadcast(): BroadcastOperator { + return this.newBroadcastOperator() + } + + /** + * Sets a modifier for a subsequent event emission that the event data will only be broadcast to the current node. + * + * @return {Socket} self + * @public + */ + public get local(): BroadcastOperator { + return this.newBroadcastOperator().local + } + + /** + * Dispatch incoming event to socket listeners. + * + * @param {Array} event - event that will get emitted + * @private + */ + private dispatch(event: [eventName: string, ...args: any[]]): void { + console.trace("dispatching an event", JSON.stringify(event)) + this.run(event, (err) => { + process.nextTick(() => { + if (err) { + return this._onerror(err) + } + if (this.connected) { + super.emitUntyped.apply(this, event) + } else { + console.debug("ignore packet received after disconnection") + } + }) + }) + } + + /** + * Sets up socket middleware. + * + * @param {Function} fn - middleware function (event, next) + * @return {Socket} self + * @public + */ + public use( + fn: (event: Array, 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: [eventName: string, ...args: any[]], + 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) + } + + /** + * A reference to the request that originated the underlying Engine.IO Socket. + * + * @public + */ + public get request(): any /** IncomingMessage */ { + return this.client.request + } + + /** + * A reference to the underlying Client transport connection (Engine.IO Socket object). + * + * @public + */ + public get conn() { + return this.client.conn + } + + /** + * @public + */ + public get rooms(): Set { + return this.adapter.socketRooms(this.id) || new Set() + } + + /** + * Adds a listener that will be fired when any event is emitted. The event name is passed as the first argument to the + * callback. + * + * @param listener + * @public + */ + 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 emitted. The event name is passed as the first argument to the + * callback. The listener is added to the beginning of the listeners array. + * + * @param listener + * @public + */ + public prependAny(listener: (...args: any[]) => void): this { + this._anyListeners = this._anyListeners || [] + this._anyListeners.unshift(listener) + return this + } + + /** + * Removes the listener that will be fired when any event is emitted. + * + * @param listener + * @public + */ + 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 + */ + public listenersAny() { + return this._anyListeners || [] + } + + private newBroadcastOperator(): BroadcastOperator { + const flags = Object.assign({}, this.flags) + this.flags = {} + return new BroadcastOperator( + 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 new file mode 100644 index 00000000..c1d48c10 --- /dev/null +++ b/packages/websocket/src/socket.io/typed-events.ts @@ -0,0 +1,180 @@ +import { EventEmitter } from "events" + +/** + * An events map is an interface that maps event names to their value, which + * represents the type of the `on` listener. + */ +export interface EventsMap { + [event: string]: any +} + +/** + * The default events map, used if no EventsMap is given. Using this EventsMap + * is equivalent to accepting all event names, and any data. + */ +export interface DefaultEventsMap { + [event: string]: (...args: any[]) => void +} + +/** + * Returns a union type containing all the keys of an event map. + */ +export type EventNames = keyof Map & (string | symbol) + +/** The tuple type representing the parameters of an event listener */ +export type EventParams< + Map extends EventsMap, + Ev extends EventNames + > = Parameters + +/** + * The event names that are either in ReservedEvents or in UserEvents + */ +export type ReservedOrUserEventNames< + ReservedEventsMap extends EventsMap, + UserEvents extends EventsMap + > = EventNames | EventNames + +/** + * Type of a listener of a user event or a reserved event. If `Ev` is in + * `ReservedEvents`, the reserved event listener is returned. + */ +export type ReservedOrUserListener< + ReservedEvents extends EventsMap, + UserEvents extends EventsMap, + Ev extends ReservedOrUserEventNames + > = FallbackToUntypedListener< + Ev extends EventNames + ? ReservedEvents[Ev] + : Ev extends EventNames + ? UserEvents[Ev] + : never + > + +/** + * Returns an untyped listener type if `T` is `never`; otherwise, returns `T`. + * + * This is a hack to mitigate https://github.com/socketio/socket.io/issues/3833. + * Needed because of https://github.com/microsoft/TypeScript/issues/41778 + */ +type FallbackToUntypedListener = [T] extends [never] + ? (...args: any[]) => void + : T + +/** + * 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 +} + +/** + * Strictly typed version of an `EventEmitter`. A `TypedEventEmitter` takes type + * parameters for mappings of event names to event data types, and strictly + * types method calls to the `EventEmitter` according to these event maps. + * + * @typeParam ListenEvents - `EventsMap` of user-defined events that can be + * listened to with `on` or `once` + * @typeParam EmitEvents - `EventsMap` of user-defined events that can be + * emitted with `emit` + * @typeParam ReservedEvents - `EventsMap` of reserved events, that can be + * emitted by socket.io with `emitReserved`, and can be listened to with + * `listen`. + */ +export abstract class StrictEventEmitter< + ListenEvents extends EventsMap, + EmitEvents extends EventsMap, + ReservedEvents extends EventsMap = {} + > + extends EventEmitter + 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 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 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) + } + + /** + * 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 + >[] + } +} diff --git a/packages/websocket/src/tomcat/client.ts b/packages/websocket/src/tomcat/client.ts index cf19e8f9..ba1a3263 100644 --- a/packages/websocket/src/tomcat/client.ts +++ b/packages/websocket/src/tomcat/client.ts @@ -1,22 +1,24 @@ -import { Transport } from '../transport' +import { WebSocketClient } from '../server/client' -export class TomcatClient extends Transport { +export class TomcatClient extends WebSocketClient { private session: javax.websocket.Session - constructor(server: any, session: javax.websocket.Session) { - super(server) - this.remoteAddress = session + '' - this.request = { - uri: () => `${session.getRequestURI()}`, - headers: () => [] - } - this._id = session.getId() + '' + constructor(session: javax.websocket.Session) { + super() + this.id = session.getId() + '' this.session = session } - doSend(text: string) { - Java.synchronized(() => this.session.getBasicRemote().sendText(text), this.session)() + send(text: string, opts?: any, callback?: (err?: Error) => void) { + Java.synchronized(() => { + try { + this.session.getBasicRemote().sendText(text) + callback?.() + } catch (error) { + callback?.(error) + } + }, this.session)() } - doClose() { + close() { this.session.close() } } diff --git a/packages/websocket/src/tomcat/index.ts b/packages/websocket/src/tomcat/index.ts index 22e81fd8..7cd65590 100644 --- a/packages/websocket/src/tomcat/index.ts +++ b/packages/websocket/src/tomcat/index.ts @@ -1,60 +1,61 @@ -import { EventEmitter } from 'events' +import { JavaServerOptions, WebSocketServer } from '../server' +import { Request } from '../server/request' -import { ServerOptions } from '../socket-io' -import { ServerEvent } from '../socket-io/constants' -import { ProxyBeanName } from './constants' import { TomcatClient } from './client' +import { ProxyBeanName } from './constants' const ThreadPoolExecutor = Java.type('java.util.concurrent.ThreadPoolExecutor') type TomcatWebSocketSession = javax.websocket.Session -class TomcatWebSocketServer extends EventEmitter { - private beanFactory: any +class TomcatWebSocketServer extends WebSocketServer { private executor: any - private clients: Map - constructor(beanFactory: any, options: ServerOptions) { - super() - this.clients = new Map() - this.beanFactory = beanFactory + constructor(beanFactory: any, options: JavaServerOptions) { + super(beanFactory, options) + } + + protected initialize(): void { this.initThreadPool() - try { this.beanFactory.destroySingleton(ProxyBeanName) } catch (error) { } + try { this.instance.destroySingleton(ProxyBeanName) } catch (error) { } let NashornWebSocketServerProxy = Java.extend(Java.type("pw.yumc.MiaoScript.websocket.WebSocketProxy"), { onOpen: (session: TomcatWebSocketSession) => { - let cid = `${session?.getId()}` - let tomcatClient = new TomcatClient(this, session) - this.clients.set(cid, tomcatClient) - this.emit(ServerEvent.connect, tomcatClient) + this.onconnect(session) }, onMessage: (session: TomcatWebSocketSession, message: string) => { - let cid = `${session?.getId()}` - if (this.clients.has(cid)) { - this.executor.execute(() => this.emit(ServerEvent.message, this.clients.get(cid), message)) - } else { - console.error(`unknow client ${session} reciver message ${message}`) - } + this.onmessage(session, message) }, onClose: (session: TomcatWebSocketSession, reason: any) => { - let cid = `${session?.getId()}` - if (this.clients.has(cid)) { - this.emit(ServerEvent.disconnect, this.clients.get(cid), reason) - } else { - console.error(`unknow client ${session} disconnect cause ${reason}`) - } + this.ondisconnect(session, reason) }, onError: (session: TomcatWebSocketSession, error: Error) => { - let cid = `${session?.getId()}` - if (this.clients.has(cid)) { - this.emit(ServerEvent.error, this.clients.get(cid), error) - } else { - console.error(`unknow client ${session} cause error ${error}`) - console.ex(error) - } + this.onerror(session, error) }, }) - this.beanFactory.registerSingleton(ProxyBeanName, new NashornWebSocketServerProxy()) + this.instance.registerSingleton(ProxyBeanName, new NashornWebSocketServerProxy()) } + + protected getId(session) { + return session?.getId() + '' + } + + protected getRequest(session) { + let request = new Request(session.getRequestURI(), "GET") + request.connection = { + remoteAddress: '' + } + return request + } + + protected getSocket(session) { + return new TomcatClient(session) + } + + protected doClose() { + this.instance.destroySingleton(ProxyBeanName) + this.executor.shutdown() + } + private initThreadPool() { const ThreadPoolTaskExecutor = Java.type('org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor') this.executor = new ThreadPoolTaskExecutor() @@ -66,11 +67,6 @@ class TomcatWebSocketServer extends EventEmitter { this.executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()) this.executor.initialize() } - close() { - this.clients.forEach(client => client.close()) - this.beanFactory.destroySingleton(ProxyBeanName) - this.executor.shutdown() - } } export { diff --git a/packages/websocket/src/transport.ts b/packages/websocket/src/transport.ts deleted file mode 100644 index 66d96cb1..00000000 --- a/packages/websocket/src/transport.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { EventEmitter } from 'events' - -export abstract class Transport extends EventEmitter { - protected _id: string - - server: any - readyState: 'opening' | 'open' | 'closing' | 'closed' - remoteAddress: string - upgraded: boolean - request: any - - constructor(server: any) { - super() - this.server = server - this.readyState = 'open' - this.upgraded = true - } - get id() { - return this._id - } - send(text: string) { - if (this.readyState == 'open') { - this.doSend(text) - } else { - console.debug(`send message ${text} to close client ${this._id}`) - } - } - close() { - if ("closed" === this.readyState || "closing" === this.readyState) { return } - this.doClose() - this.readyState = 'closed' - } - abstract doSend(text: string) - abstract doClose() -}