From fb938553124ffbc9f544814cbf55d58ae7bda2a2 Mon Sep 17 00:00:00 2001 From: AntonSeleznev Date: Tue, 12 May 2026 16:18:16 +0300 Subject: [PATCH 1/3] fix online list request (#204) * fix online list request * add basic cmd ws test commands * update check cmd connections * add close * add socketCloseWatchdog * add remove session * on close logs debug * add repl service * update docker-file * add ping socket * update package * fix tcp send failed * update watchdogPingSocket * add logs * update addUserDeviceConnection * update submodule --- .env.example | 11 ++ APIs/JSON/index.js | 6 + APIs/JSON/routes/packet_processor.js | 8 +- APIs/XMPP | 2 +- Dockerfile | 2 +- app/config/default.js | 14 ++ app/lib/repl-tools.js | 144 +++++++++++++++++++++ app/networking/protocol_processors/base.js | 4 +- app/providers/services/session/Provider.js | 3 +- app/providers/services/session/index.js | 44 +++++-- app/utils/watchdog-ping-socket.js | 44 +++++++ index.js | 26 +++- 12 files changed, 289 insertions(+), 19 deletions(-) create mode 100644 app/lib/repl-tools.js create mode 100644 app/utils/watchdog-ping-socket.js diff --git a/.env.example b/.env.example index e51f056b..7c50e63a 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ NODE_ENV=development STANDALONE_NODE=false +WATCHDOG_PING_SOCKET_INTERVAL=60000 # 1 minute APP_PORT=9001 APP_TCP_PORT=8001 @@ -11,6 +12,16 @@ MONGODB_URL=mongodb://127.0.0.1/samadb REDIS_URL=redis://127.0.0.1:6379 +# REPL +# http +APP_REPL_HTTP_PORT=5010 +APP_REPL_HTTP_ACCESS_KEY=repl-wow-key +# socket +APP_REPL_SOCKET_HANDLER=/tmp/net-repl.socket +# file +APP_REPL_FILE_IN=/tmp/pipe-repl.in +APP_REPL_FILE_OUT=/tmp/pipe-repl.out + STORAGE_DRIVER=minio # If you set STORAGE_DRIVER=s3, then fill the below envs diff --git a/APIs/JSON/index.js b/APIs/JSON/index.js index d40c2ece..55f4071c 100644 --- a/APIs/JSON/index.js +++ b/APIs/JSON/index.js @@ -35,4 +35,10 @@ export default class JsonAPI extends BaseAPI { } return this.stringifyMessage(message) } + + pingPackage() { + const ping = { response: { ping: {} } } + + return this.stringifyMessage(ping) + } } diff --git a/APIs/JSON/routes/packet_processor.js b/APIs/JSON/routes/packet_processor.js index 2a473bd7..ca93bd78 100644 --- a/APIs/JSON/routes/packet_processor.js +++ b/APIs/JSON/routes/packet_processor.js @@ -46,18 +46,18 @@ class PacketJsonProcessor extends BasePacketProcessor { } catch (error) { logger.error(error) let errorBackMessage = null - if (json.request) { + if (json?.request) { errorBackMessage = { response: { - id: json.request.id, + id: json.request?.id, error: error.cause || error.message, }, } } else { - const topLevelElement = Object.keys(json)[0] + const topLevelElement = json ? Object.keys(json)[0] : void 0 errorBackMessage = { [topLevelElement]: { - id: json[topLevelElement].id, + id: json?.[topLevelElement]?.id, error: error.cause || error.message, }, } diff --git a/APIs/XMPP b/APIs/XMPP index ba4c3af7..7345b506 160000 --- a/APIs/XMPP +++ b/APIs/XMPP @@ -1 +1 @@ -Subproject commit ba4c3af760d78fb9da77e64949e6186b99878f0f +Subproject commit 7345b506e738263a1ddcc46a48de73c0f9a437ef diff --git a/Dockerfile b/Dockerfile index 0dc79b73..ec87285b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ COPY . . FROM node:22-slim RUN apt-get update && apt-get install -y --no-install-recommends \ - curl \ + curl netcat-openbsd \ && rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/app/config/default.js b/app/config/default.js index 5ea61b78..c73ac88e 100644 --- a/app/config/default.js +++ b/app/config/default.js @@ -7,6 +7,7 @@ const CONFIG = { name: process.env.APP_NAME ?? "SAMA", hostName: process.env.HOSTNAME, isStandAloneNode: process.env.STANDALONE_NODE === CONSTANTS.ENV_TRUE, + watchdogPingSocketInterval: +process.env.WATCHDOG_PING_SOCKET_INTERVAL, }, logger: { logLevel: process.env.LOG_LEVEL ?? "debug", @@ -102,6 +103,19 @@ const CONFIG = { apiKey: process.env.HTTP_ADMIN_API_KEY, }, }, + repl: { + http: { + port: +(process.env.APP_REPL_HTTP_PORT ?? 5010), + accessKey: process.env.APP_REPL_HTTP_ACCESS_KEY, + }, + socket: { + handler: process.env.APP_REPL_SOCKET_HANDLER, + }, + file: { + in: process.env.APP_REPL_FILE_IN, + out: process.env.APP_REPL_FILE_OUT, + }, + }, conversation: { disableChannelsLogic: process.env.CONVERSATION_DISABLE_CHANNELS_LOGIC === CONSTANTS.ENV_TRUE, isEventsEnabled: process.env.CONVERSATION_NOTIFICATIONS_ENABLED === CONSTANTS.ENV_TRUE, diff --git a/app/lib/repl-tools.js b/app/lib/repl-tools.js new file mode 100644 index 00000000..186a9b96 --- /dev/null +++ b/app/lib/repl-tools.js @@ -0,0 +1,144 @@ +import fs from "node:fs" +import fsPromises from "node:fs/promises" +import vm from "node:vm" +import { exec } from "node:child_process" +import repl from "node:repl" +import net from "node:net" +import http from "node:http" + +import { CONSTANTS } from "../constants/constants.js" + +// example: curl --no-progress-meter -sSNT. -H "api-key: ****" localhost:5010 + +const httpReplService = async (replOptions, httpOptions) => { + const { ctx } = replOptions + const { accessKey, port } = httpOptions + + const server = http.createServer((req, res) => { + if (req.headers[CONSTANTS.HTTP_REPL_ACCESS_KEY_HEADER] !== accessKey) { + return res.end("Invalid access-key") + } + + res.setHeader("content-type", "multipart/octet-stream") + + const replService = repl.start({ + prompt: "curl repl> ", + input: req, + output: res, + terminal: false, + useColors: true, + useGlobal: false, + }) + + const context = vm.createContext({ ...ctx }) + + replService.context = context + + req.on("error", (error) => replService.close()) + req.on("end", () => replService.close()) + + res.on("error", (error) => replService.close()) + res.on("end", () => replService.close()) + + replService.on("close", () => !res.closed && res.end("REPL closed")) + replService.on("error", (error) => { + replService.close() + !res.closed && res.end("REPL closed") + !req.closed && req.destroy() + }) + }) + + return new Promise((resolve, reject) => { + server.listen(port, () => resolve(port)) + }) +} + +// example write: cat > ./pipe-repl.in +// example read: tail -f ./pipe-repl.out + +const fileReplService = async (replOptions, fileOptions) => { + const { ctx } = replOptions + const { fileIn, fileOut } = fileOptions + + await fsPromises.rm(fileIn, { force: true }).catch((error) => {}) + await fsPromises.rm(fileOut, { force: true }).catch((error) => {}) + + await new Promise((resolve, reject) => { + exec(`mkfifo ${fileIn}`, (error, out, outErr) => { + if (error) { + return reject(error) + } + resolve() + }) + }) + + const cmdInputPipe = fs.createReadStream(fileIn, { encoding: "utf8" }) + const cmdOutPipe = fs.createWriteStream(fileOut, { encoding: "utf8" }) + + const replService = repl.start({ + prompt: "file repl> ", + input: cmdInputPipe, + output: cmdOutPipe, + terminal: false, + useColors: true, + useGlobal: false, + }) + + const context = vm.createContext({ ...ctx }) + + replService.context = context + + replService.on("error", (error) => { + replService.close() + cmdInputPipe.close() + }) + + cmdInputPipe.on("end", () => { + replService.close() + fileReplService(replOptions, fileOptions) + }) +} + +// example: nc -U ./net-repl.socket + +const netReplService = async (replOptions, fileOptions) => { + const { ctx } = replOptions + const { socketHandler } = fileOptions + + await fsPromises.rm(socketHandler, { force: true }).catch((error) => {}) + + const server = net.createServer((socket) => { + const replService = repl.start({ + prompt: "socket repl> ", + input: socket, + output: socket, + terminal: false, + useColors: true, + useGlobal: false, + }) + + const context = vm.createContext({ ...ctx }) + + replService.context = context + + socket.on("end", () => replService.close()) + socket.on("error", (error) => replService.close()) + replService.on("close", () => !socket.closed && socket.end("REPL closed")) + }) + + return new Promise((resolve, reject) => { + server.listen(socketHandler, () => resolve(socketHandler)) + }) +} + +export const startReplServices = async (replOptions, httpOptions, netOptions, fileOptions) => { + if (httpOptions.accessKey) { + await httpReplService(replOptions, httpOptions) + } + if (netOptions.socketHandler) { + await netReplService(replOptions, netOptions) + } + if (fileOptions.fileIn && fileOptions.fileOut) { + await fileReplService(replOptions, fileOptions) + } +} \ No newline at end of file diff --git a/app/networking/protocol_processors/base.js b/app/networking/protocol_processors/base.js index ca9abf0c..66663ab7 100644 --- a/app/networking/protocol_processors/base.js +++ b/app/networking/protocol_processors/base.js @@ -200,7 +200,7 @@ class BaseProtocolProcessor { } async onClose(socket, code) { - logger.trace("[Close] IP: %s CLIENT_ID: %s CODE: %s", this.socketAddress(socket), socket.clientId, code) + logger.debug("[Close] IP: %s CLIENT_ID: %s CODE: %s", this.socketAddress(socket), socket.clientId, code) socket.isAlive = false @@ -214,7 +214,7 @@ class BaseProtocolProcessor { async updateLastUserLastActivityOnClose(socket) { const { organizationId, userId } = this.sessionService.getSession(socket) ?? {} - logger.trace("[UPDATE_LAST_ACTIVITY][CLOSE] OrgId: %s UserId: %s", organizationId, userId) + logger.debug("[UPDATE_LAST_ACTIVITY][CLOSE] OrgId: %s UserId: %s", organizationId, userId) if (!userId) { return diff --git a/app/providers/services/session/Provider.js b/app/providers/services/session/Provider.js index d5f5fa2e..3896583a 100644 --- a/app/providers/services/session/Provider.js +++ b/app/providers/services/session/Provider.js @@ -8,9 +8,10 @@ const name = "SessionService" class SessionServiceRegisterProvider extends RegisterProvider { register(slc) { const config = slc.use("Config") + const logger = slc.use("Logger").child("[SessionService]") const redisClient = slc.use("RedisClient") - return new SessionService(ACTIVE, config, redisClient) + return new SessionService(ACTIVE, config, logger, redisClient) } } diff --git a/app/providers/services/session/index.js b/app/providers/services/session/index.js index 55f0ce29..1d14eefe 100644 --- a/app/providers/services/session/index.js +++ b/app/providers/services/session/index.js @@ -10,9 +10,10 @@ import { CONSTANTS } from "../../../constants/constants.js" */ class SessionService { - constructor(activeSessions, config, redisConnection) { + constructor(activeSessions, config, logger, redisConnection) { this.activeSessions = activeSessions this.config = config + this.logger = logger this.redisConnection = redisConnection } @@ -21,13 +22,17 @@ class SessionService { } addUserDeviceConnection(socket, organizationId, userId, deviceId) { - const activeConnections = this.activeSessions.DEVICES[userId] const socketsToClose = [] + let activeConnections = this.getUserDevices(userId) + const filterNotSameSocket = activeConnections.filter(connection => connection.socket !== socket) + this.activeSessions.DEVICES[userId] = filterNotSameSocket + activeConnections = this.getUserDevices(userId) + const connection = { socket: socket, deviceId, organizationId } if (activeConnections) { - const devices = activeConnections.filter((connection) => { + const otherDeviceConnections = activeConnections.filter((connection) => { if (connection.deviceId !== deviceId) { return true } else { @@ -35,7 +40,7 @@ class SessionService { return false } }) - this.activeSessions.DEVICES[userId] = [...devices, connection] + this.activeSessions.DEVICES[userId] = [...otherDeviceConnections, connection] } else { this.activeSessions.DEVICES[userId] = [connection] } @@ -102,9 +107,7 @@ class SessionService { async listUserDevice(organizationId, userId) { if (this.config.get("app.isStandAloneNode")) { - return this.getUserDevices(userId) - .map((connection) => connection?.deviceId) - .filter((deviceId) => deviceId !== CONSTANTS.HTTP_DEVICE_ID) + return this.listUserDeviceLocal(userId) } const userKey = this.#usersSetCacheKey(organizationId, userId) @@ -113,6 +116,12 @@ class SessionService { return deviceIds ?? [] } + listUserDeviceLocal(userId) { + return this.getUserDevices(userId) + .map((connection) => connection?.deviceId) + .filter((deviceId) => deviceId !== CONSTANTS.HTTP_DEVICE_ID) + } + async deleteUserDevices(organizationId, userId) { const userKey = this.#usersSetCacheKey(organizationId, userId) @@ -312,10 +321,21 @@ class SessionService { } async removeUserSession(socket, userId, deviceId) { + this.logger.debug("[removeUserSession][args]: %o", { socket: socket?.isAlive, userId, deviceId }) + userId = userId ?? this.getSessionUserId(socket) deviceId = deviceId ?? this.getDeviceId(socket, userId) const orgId = this.getSession(socket)?.organizationId + this.logger.debug("[removeUserSession][vars]: %o [session]: %o [device]: %s", { orgId, userId, deviceId }, this.getSession(socket), this.getDeviceId(socket, userId)) + + const devicesBefore = this.getUserDevices(userId).map((connection) => { + const { socket, ...connectionData } = connection + return { ...connectionData, socket: socket?.clientId } + }) + + this.logger.debug("[removeUserSession][devices][before]: %o %s", devicesBefore, devicesBefore?.length) + const leftActiveConnections = this.getUserDevices(userId).filter(({ deviceId: activeDeviceId }) => activeDeviceId !== deviceId) if (leftActiveConnections?.length) { @@ -325,6 +345,14 @@ class SessionService { } this.activeSessions.SESSIONS.delete(socket) + + const devicesAfter = this.getUserDevices(userId).map((connection) => { + const { socket, ...connectionData } = connection + return { ...connectionData, socket: socket?.clientId } + }) + + this.logger.debug("[removeUserSession][devices][after]: %o %s", devicesAfter, devicesAfter?.length) + if (!deviceId) { return } @@ -393,7 +421,7 @@ class SessionService { (session) => session?.organizationId === organizationId && session?.extraParams[CONSTANTS.SESSION_DEVICE_ID_KEY] !== CONSTANTS.HTTP_DEVICE_ID && - session?.userId + session?.userId && this.listUserDeviceLocal(session?.userId)?.length ) .map((session) => session.userId) .sort((userIdA, userIdB) => userIdA - userIdB) diff --git a/app/utils/watchdog-ping-socket.js b/app/utils/watchdog-ping-socket.js new file mode 100644 index 00000000..fa082064 --- /dev/null +++ b/app/utils/watchdog-ping-socket.js @@ -0,0 +1,44 @@ +import net from "node:net" +import { APIs, BASE_API } from "../networking/APIs.js" + +export const watchdogPingSocket = async (logger, sessionService, onWsCloseCb, onTcpCloseCb) => { + const users = Object.keys(sessionService.activeSessions.DEVICES) + + logger.debug("[start] %s", users.length) + + for (const userId of users) { + const connections = sessionService.activeSessions.DEVICES[userId] ?? [] + for (const connection of connections) { + if (!connection?.socket) { + continue + } + + const isTCP = connection.socket instanceof net.Socket + const pingPackage = APIs[connection.socket?.apiType ?? BASE_API].pingPackage() + + try { + if (isTCP) { + await new Promise((resolve, reject) => { + connection.socket?.write(pingPackage, (error) => error ? reject(error) : resolve()) + }) + } else { + connection.socket?.send(pingPackage) + } + } catch (error) { + logger.error(error, "[error socket send] %s", userId) + if (isTCP) { + await onTcpCloseCb(connection?.socket) + .then(() => logger.debug("[close tcp done] %s", userId)) + .catch(error => logger.error(error, "[close tcp error]")) + } else { + await onWsCloseCb(connection?.socket, 10) + .then(() => logger.debug("[close ws done] %s", userId)) + .catch(error => logger.error(error, "[close ws error]")) + } + await sessionService.removeUserSession(connection?.socket, userId, connection?.deviceId).catch(error => logger.error(error, "[remove]")) + } + } + } + + logger.debug("[finish]") +} \ No newline at end of file diff --git a/index.js b/index.js index fee58008..ca0b0de2 100644 --- a/index.js +++ b/index.js @@ -20,10 +20,12 @@ import HttpProtocol from "./app/networking/protocol_processors/http.js" import { connectToDBPromise } from "./app/lib/db.js" import RedisClient from "./app/lib/redis.js" import OTPSender from "./app/lib/otp_sender.js" +import { startReplServices } from "./app/lib/repl-tools.js" import { APIs } from "./app/networking/APIs.js" import { buildWsEndpoint } from "./app/utils/build_ws_endpoint.js" +import { watchdogPingSocket } from "./app/utils/watchdog-ping-socket.js" if (config.get("app.env") === CONSTANTS.ENVS.PROD) { process.on("unhandledRejection", (reason, promise) => { @@ -190,9 +192,29 @@ await wsProtocolImp.listen(uWSOptions) const httpProtocolImp = new HttpProtocol(sessionService, conversationService, wsProtocolImp.uWSocketServer) await httpProtocolImp.listen({}) +// https://dev.to/mattkrick/replacing-express-with-uwebsockets-48ph + +let tcpProtocolImp = void 0 if (config.get("tcp.isEnabled")) { - const tcpProtocolImp = new TcpProtocol(sessionService, conversationService) + tcpProtocolImp = new TcpProtocol(sessionService, conversationService) await tcpProtocolImp.listen(tcpOptions) } -// https://dev.to/mattkrick/replacing-express-with-uwebsockets-48ph +if (config.get("app.watchdogPingSocketInterval")) { + const socketCloseWatchdogLogger = logger.child("[SocketClosedWatchDog]") + setInterval(() => { + watchdogPingSocket( + socketCloseWatchdogLogger, + sessionService, + (socket, code) => wsProtocolImp.onClose(socket, code), + (socket) => tcpProtocolImp.onClose(socket) + ) + }, config.get("app.watchdogPingSocketInterval")) +} + +await startReplServices( + { ctx: { slc: ServiceLocatorContainer } }, + { accessKey: config.get("repl.http.accessKey"), port: config.get("repl.http.port") }, + { socketHandler: config.get("repl.socket.handler") }, + { fileIn: config.get("repl.file.in"), fileOut: config.get("repl.file.out") } +) From dca52d1b0352fd65493db24d01f21a9b896688eb Mon Sep 17 00:00:00 2001 From: AntonSelezev Date: Tue, 12 May 2026 16:24:20 +0300 Subject: [PATCH 2/3] changelog: v0.36.2 --- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a80fbcad..a7004ceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.36.2 + +### Bug Fixes + +- Fixed invalid user online status when client trying connect 2+ times using one socket but different `deviceId` + ## 0.36.1 ### Bug Fixes diff --git a/package-lock.json b/package-lock.json index e5dc8617..a366146f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sama", - "version": "0.36.1", + "version": "0.36.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sama", - "version": "0.36.1", + "version": "0.36.2", "license": "GPL-3.0", "dependencies": { "@ai-sdk/google": "^2.0.16", diff --git a/package.json b/package.json index ba571c66..fe967cb3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sama", - "version": "0.36.1", + "version": "0.36.2", "description": "Simple but Advanced Messaging Alternative", "main": "index.js", "type": "module", From 11c94dab2525c4a8e8974e32567c751c23aded4e Mon Sep 17 00:00:00 2001 From: AntonSeleznev Date: Tue, 7 Jul 2026 16:18:52 +0300 Subject: [PATCH 3/3] Imp node clustering (#205) * update logs * sync activity cluster * update activity service * imp base clean node data * it can send offline status for died node users * refactoring * fix spec * update config * refactor PacketManager * it can clear destoyed node cache data * update clean node conditions * add reconnecting * add Node Clustering readme * it can close ws with code * reconnecty only ifWas opened * fix typo * start testing * add activty listening * update same-node / cross-node specs * add fixtures * update specs * it can connect with token * test reconnecting * update specs * update specs: add dummy data * update spec titles * update install docker * add hostname to stats * update cors header * add logs * add log * add logs * close cluster socket on delete * fix config * remove session with old node endpoint * add client cluster test * update testing clients cluster * add check last activity * add repl services * fix typo in env * fix env val * update * add netcat install to dockerfile * update addUserDeviceConnection * add keep alive * fix typo * update submodule * use res instead of res.fakeWsSessionKey * watchdogPingSocket ignore HTTP_DEVICE_ID * update * try fix listUserDevice * try fix listUserDevice * update listUserDevice * add to session service filterActualDevices * update addUserDeviceConnection and storeUserNodeData * update package * add logs * add log * it can block create_user/auth if account was blocked * update env * update @sama-communications/sdk * update requestTrackId in error response * add cluster env CLUSTER_ADVERTISE_IP * update workflows * add workflow_dispatch * update workflows tests * update env docker * formating * update submodule * update submodule to main branch --- .cluster-clients-mocharc.json | 17 + .cluster-mocharc.json | 22 + .env.docker | 23 +- .env.example | 16 + .github/workflows/tests.yml | 6 +- .mocharc.json | 1 + APIs/JSON/controllers/http/activity.js | 2 +- APIs/JSON/controllers/http/auth.js | 14 +- APIs/JSON/controllers/http/message.js | 15 +- APIs/JSON/controllers/http/organization.js | 2 +- APIs/JSON/controllers/http/stats.js | 2 +- APIs/JSON/controllers/users.js | 12 +- APIs/JSON/routes/packet_processor.js | 8 +- APIs/XMPP | 2 +- Dockerfile | 2 +- Dockerfile.local | 2 +- README.md | 65 + app/cluster/cluster_manager.js | 381 +++++- app/cluster/cluster_syncer.js | 80 -- app/config/default.js | 2 +- app/config/index.js | 10 +- app/constants/constants.js | 7 + app/constants/errors.js | 4 + app/lib/redis.js | 8 +- app/lib/repl-tools.js | 2 +- app/models/organization.js | 2 +- app/networking/activity_sender.js | 2 +- app/networking/models/DeliverMessage.js | 6 +- app/networking/packet_manager.js | 138 +- app/networking/protocol_processors/base.js | 39 +- app/networking/protocol_processors/http.js | 19 +- app/networking/protocol_processors/tcp.js | 18 +- app/networking/protocol_processors/ws.js | 3 +- app/providers/index.js | 4 + .../operations/activity/retrieve/index.js | 2 +- .../operations/activity/subscribe/index.js | 2 +- .../http/activity/online_list/index.js | 6 +- .../operations/http/message/create/index.js | 6 +- .../operations/http/message/delete/index.js | 6 +- .../operations/http/message/edit/index.js | 6 +- .../operations/http/message/reaction/index.js | 6 +- .../operations/http/message/read/index.js | 6 +- .../operations/http/message/system/index.js | 6 +- .../http/organization/create/index.js | 2 +- .../operations/http/stats/collect/index.js | 2 +- .../operations/http/user/auth/index.js | 4 +- .../operations/http/user/logout/index.js | 10 +- .../operations/user/auth/Provider.js | 3 +- app/providers/operations/user/auth/index.js | 28 +- .../operations/user/connect/index.js | 11 +- .../operations/user/create/Provider.js | 4 +- app/providers/operations/user/create/index.js | 14 +- app/providers/operations/user/logout/index.js | 4 +- .../services/activity_manager/Provider.js | 9 +- .../activity_manager_cluster/Provider.js | 15 + .../activity_manager_cluster/index.js | 125 ++ .../activity_manager_standalone/Provider.js | 16 + .../index.js | 14 +- .../services/cluster_node/Provider.js | 5 +- app/providers/services/cluster_node/index.js | 50 +- app/providers/services/organization/index.js | 6 + app/providers/services/session/index.js | 275 ++-- app/providers/services/stats/index.js | 2 +- app/utils/build_ws_endpoint.js | 4 +- app/utils/get_ip_from_ws_url.js | 1 - app/utils/pass_through_mapper.js | 3 - app/utils/promise-queue-with-jittering.js | 67 + app/utils/sockets-utils.js | 4 +- app/utils/split_ws_endpoint.js | 5 - app/utils/watchdog-ping-socket.js | 15 +- docker-compose-full.yml | 2 +- index.js | 22 +- package-lock.json | 103 +- package.json | 5 + test/activities.spec.js | 22 +- test/cluster.spec.js | 113 -- test/cluster/cluster-clients.spec.js | 245 ++++ test/cluster/cross-node.spec.js | 318 +++++ test/cluster/fixtures.js | 43 + test/cluster/multi-devices.spec.js | 1111 +++++++++++++++++ test/cluster/node-crash.spec.js | 475 +++++++ test/cluster/same-node.spec.js | 299 +++++ test/cluster/utils.js | 211 ++++ test/push_notifications.spec.js | 24 +- test/users.spec.js | 66 + 85 files changed, 4096 insertions(+), 643 deletions(-) create mode 100644 .cluster-clients-mocharc.json create mode 100644 .cluster-mocharc.json delete mode 100644 app/cluster/cluster_syncer.js create mode 100644 app/providers/services/activity_manager/activity_manager_cluster/Provider.js create mode 100644 app/providers/services/activity_manager/activity_manager_cluster/index.js create mode 100644 app/providers/services/activity_manager/activity_manager_standalone/Provider.js rename app/providers/services/activity_manager/{ => activity_manager_standalone}/index.js (84%) delete mode 100644 app/utils/get_ip_from_ws_url.js delete mode 100644 app/utils/pass_through_mapper.js create mode 100644 app/utils/promise-queue-with-jittering.js delete mode 100644 app/utils/split_ws_endpoint.js delete mode 100644 test/cluster.spec.js create mode 100644 test/cluster/cluster-clients.spec.js create mode 100644 test/cluster/cross-node.spec.js create mode 100644 test/cluster/fixtures.js create mode 100644 test/cluster/multi-devices.spec.js create mode 100644 test/cluster/node-crash.spec.js create mode 100644 test/cluster/same-node.spec.js create mode 100644 test/cluster/utils.js diff --git a/.cluster-clients-mocharc.json b/.cluster-clients-mocharc.json new file mode 100644 index 00000000..0c07ea87 --- /dev/null +++ b/.cluster-clients-mocharc.json @@ -0,0 +1,17 @@ +{ + "recursive": true, + "sort": false, + "color": true, + "allow-uncaught": true, + "parallel": false, + "reporter": "list", + "require": [ + "dotenv/config" + ], + "exit": true, + "spec": [ + "./test/cluster/cluster-clients.spec.js" + ], + "exclude": "test/**/*.spec.js", + "timeout": 120000 +} diff --git a/.cluster-mocharc.json b/.cluster-mocharc.json new file mode 100644 index 00000000..531129d7 --- /dev/null +++ b/.cluster-mocharc.json @@ -0,0 +1,22 @@ +{ + "recursive": true, + "sort": false, + "color": true, + "allow-uncaught": true, + "parallel": false, + "reporter": "list", + "require": [ + "dotenv/config", + "./test/cluster/utils.js", + "./test/cluster/fixtures.js" + ], + "exit": true, + "spec": [ + "./test/cluster/same-node.spec.js", + "./test/cluster/cross-node.spec.js", + "./test/cluster/multi-devices.spec.js", + "./test/cluster/node-crash.spec.js" + ], + "exclude": "test/**/*.spec.js", + "timeout": 120000 +} diff --git a/.env.docker b/.env.docker index a04cdf2d..efbf2d0c 100644 --- a/.env.docker +++ b/.env.docker @@ -1,4 +1,4 @@ -NODE_ENV=development +NODE_ENV=testing APP_PORT=9001 @@ -19,18 +19,23 @@ CONVERSATION_MAX_PARTICIPANTS=50 CONVERSATION_PRELOAD_COUNT=30 MESSAGE_PRELOAD_COUNT=30 SEARCH_PRELOAD_COUNT=30 + JWT_ACCESS_SECRET=4c09be6a35bec6d4089cde4be5ca57a6ee85da5739579cbecb0b8253f268eca2 JWT_ACCESS_TOKEN_EXPIRES_IN=10800 JWT_REFRESH_TOKEN_EXPIRES_IN=1209600 JWT_REFRESH_SECRET=4c09be6a35bec6d4089cde4be5ca57a6ee85da5739579cbecb0b8253f268eca2 + ENCRYPTION_MESSAGE_EXPIRED_IN=2592000 # 30 days ENCRYPTION_DEVICE_TOKEN_EXPIRES_IN=2592000 # 30 days +ENCRYPTION_MESSAGE_EXPIRED_IN=2592000 +ENCRYPTION_DEVICE_TOKEN_EXPIRES_IN=2592000 + OPERATIONS_LOG_EXPIRES_IN=1209600 -NODE_CLUSTER_DATA_EXPIRES_IN=30000 +NODE_CLUSTER_DATA_EXPIRES_IN=10000 + FILE_UPLOAD_URL_EXPIRES_IN=3600 FILE_DOWNLOAD_URL_EXPIRES_IN=604800 -ENCRYPTION_MESSAGE_EXPIRED_IN=2592000 -ENCRYPTION_DEVICE_TOKEN_EXPIRES_IN=2592000 + SSL_KEY_FILE_NAME= SSL_CERT_FILE_NAME= @@ -38,4 +43,14 @@ SSL_CERT_FILE_NAME= HOSTNAME=NodeName CORS_ORIGIN=http://localhost:10000 COOKIE_SECRET=4c09be6a35bec6d4089cde4be5ca57a6ee85da5739579cbecb0b8253f268eca2 +HTTP_ADMIN_API_KEY=docker-secret-key + +# Testing +RUN_NODE_1_CMD='APP_PORT=9001 APP_TCP_PORT=8001 CORS_ORIGIN=http://localhost:3001 npm start' +RUN_NODE_2_CMD='APP_PORT=9002 APP_TCP_PORT=8002 CORS_ORIGIN=http://localhost:3002 npm start' + +NODE_1_WS_ENDPOINT=ws://localhost:9001 +NODE_1_HTTP_ENDPOINT=http://localhost:9001 +NODE_2_WS_ENDPOINT=ws://localhost:9002 +NODE_2_HTTP_ENDPOINT=http://localhost:9002 \ No newline at end of file diff --git a/.env.example b/.env.example index 7c50e63a..a965cd96 100644 --- a/.env.example +++ b/.env.example @@ -101,3 +101,19 @@ GOOGLE_GENERATIVE_AI_API_KEY= SERVICE_OTP_TOKEN_EXPIRES_IN=300000 RESEND_SENDER="SAMASupport " RESEND_API_KEY= + + +# Testing +RUN_NODE_1_CMD='APP_PORT=9001 APP_TCP_PORT=8001 CORS_ORIGIN=http://localhost:3001 npm start' +RUN_NODE_2_CMD='APP_PORT=9002 APP_TCP_PORT=8002 CORS_ORIGIN=http://localhost:3002 npm start' + +NODE_1_WS_ENDPOINT=ws://localhost:9001 +NODE_1_HTTP_ENDPOINT=http://localhost:9001 + +NODE_2_WS_ENDPOINT=ws://localhost:9002 +NODE_2_HTTP_ENDPOINT=http://localhost:9002 + +TEST_CLIENTS_COUNT=10 +TEST_CLIENT_ORG_ID=683db99d3874471b4dd36c69 +TEST_CLIENT_WS_ENDPOINT=ws://localhost:9001 +TEST_CLIENT_HTTP_ENDPOINT=http://localhost:9001 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 67cf79ca..09028fa5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,18 +5,22 @@ on: branches: - main + workflow_dispatch: + jobs: run_tests: runs-on: self-hosted steps: - uses: actions/checkout@v3 - name: Build the SAMA resources + env: + NODE_ENV: testing run: docker compose -f docker-compose-full.yml up -d --build - name: Run migrations run: docker compose exec sama-server sh -c "MONGODB_URL=mongodb://172.25.0.4/samatests npm run migrate-mongo-up" - - name: Run tests + - name: Run tests single node run: docker compose exec sama-server sh -c "MONGODB_URL=mongodb://172.25.0.4/samatests npm run test" - name: Stop the SAMA resources diff --git a/.mocharc.json b/.mocharc.json index 96bc1053..02f2b2b3 100644 --- a/.mocharc.json +++ b/.mocharc.json @@ -6,5 +6,6 @@ "node-option": ["experimental-loader=./sama-loader.mjs", "import=dotenv/config"], "exit": true, "spec": "test/**/*.spec.js", + "exclude": "test/cluster/*.spec.js", "timeout": 60000 } diff --git a/APIs/JSON/controllers/http/activity.js b/APIs/JSON/controllers/http/activity.js index be5589b2..9a8c166c 100644 --- a/APIs/JSON/controllers/http/activity.js +++ b/APIs/JSON/controllers/http/activity.js @@ -13,7 +13,7 @@ class HttpActivityController extends BaseHttpController { const response = new Response() const HttpActivityOnlineListOperation = ServiceLocatorContainer.use("HttpActivityOnlineListOperation") - const onlineListResponse = await HttpActivityOnlineListOperation.perform(res.fakeWsSessionKey, payload) + const onlineListResponse = await HttpActivityOnlineListOperation.perform(res, payload) const responsePayload = isCountRequest ? { count: onlineListResponse } : { users: onlineListResponse } diff --git a/APIs/JSON/controllers/http/auth.js b/APIs/JSON/controllers/http/auth.js index efb5ecfe..1bffe660 100644 --- a/APIs/JSON/controllers/http/auth.js +++ b/APIs/JSON/controllers/http/auth.js @@ -16,7 +16,7 @@ class HttpAuthController extends BaseHttpController { const httpAuthOperation = ServiceLocatorContainer.use("HttpUserAuthOperation") const { user, newAccessToken, accessTokenExpiredAt, newRefreshToken } = await httpAuthOperation.perform( - res.fakeWsSessionKey, + res, res.parsedHeaders, res.parsedSignedCookies, payload @@ -43,7 +43,7 @@ class HttpAuthController extends BaseHttpController { async logout(res, req) { const httpLogoutOperation = ServiceLocatorContainer.use("HttpUserLogoutOperation") - const refreshTokenRecord = await httpLogoutOperation.perform(res.fakeWsSessionKey, res.parsedHeaders, res.parsedSignedCookies) + const { refreshTokenRecord, isWasLastUserSession } = await httpLogoutOperation.perform(res, res.parsedHeaders, res.parsedSignedCookies) const httpResponse = new HttpResponse(200, {}, { success: true }).addCookie("refresh_token", refreshTokenRecord.token, { maxAge: 0, @@ -52,15 +52,19 @@ class HttpAuthController extends BaseHttpController { sameSite: "lax", }) - return new Response() - .setHttpResponse(httpResponse) - .updateLastActivityStatus( + const response = new Response().setHttpResponse(httpResponse) + + if (isWasLastUserSession) { + response.updateLastActivityStatus( new LastActivityStatusResponse( refreshTokenRecord.organization_id, refreshTokenRecord.user_id, MAIN_CONSTANTS.LAST_ACTIVITY_STATUS.OFFLINE ) ) + } + + return response } } diff --git a/APIs/JSON/controllers/http/message.js b/APIs/JSON/controllers/http/message.js index a6e74f25..e4ecb56e 100644 --- a/APIs/JSON/controllers/http/message.js +++ b/APIs/JSON/controllers/http/message.js @@ -20,7 +20,7 @@ class HttpMessageController extends BaseHttpController { const response = new Response() const httpMessageCreateOperation = ServiceLocatorContainer.use("HttpMessageCreateOperation") - const createMessageResponse = await httpMessageCreateOperation.perform(res.fakeWsSessionKey, payload) + const createMessageResponse = await httpMessageCreateOperation.perform(res, payload) const { organizationId, messageId, message, deliverMessages, cId, participantIds, modifiedFields, botMessage } = createMessageResponse deliverMessages.forEach((event) => { @@ -48,7 +48,7 @@ class HttpMessageController extends BaseHttpController { const payload = res.parsedBody const messageSendSystemOperation = ServiceLocatorContainer.use("HttpMessageSendSystemOperation") - const { organizationId, cId, recipientsIds, systemMessage } = await messageSendSystemOperation.perform(res.fakeWsSessionKey, payload) + const { organizationId, cId, recipientsIds, systemMessage } = await messageSendSystemOperation.perform(res, payload) const deliverMessage = new DeliverMessage(organizationId, new SystemMessageResponse(systemMessage), true) .setConversationDestination(cId) @@ -63,7 +63,7 @@ class HttpMessageController extends BaseHttpController { const payload = res.parsedBody const messageReadOperation = ServiceLocatorContainer.use("HttpMessageReadOperation") - const { organizationId, readMessagesGroups } = await messageReadOperation.perform(res.fakeWsSessionKey, payload) + const { organizationId, readMessagesGroups } = await messageReadOperation.perform(res, payload) const response = new Response() @@ -79,7 +79,7 @@ class HttpMessageController extends BaseHttpController { const payload = res.parsedBody const messageEditOperation = ServiceLocatorContainer.use("HttpMessageEditOperation") - const { organizationId, cId, participantsIds, editedMessage } = await messageEditOperation.perform(res.fakeWsSessionKey, payload) + const { organizationId, cId, participantsIds, editedMessage } = await messageEditOperation.perform(res, payload) return new Response() .setHttpResponse(new HttpResponse(200, {}, { success: true })) @@ -94,10 +94,7 @@ class HttpMessageController extends BaseHttpController { const payload = res.parsedBody const messageReactionOperation = ServiceLocatorContainer.use("HttpMessageReactionOperation") - const { organizationId, cId, participantsIds, messageReactionsUpdate } = await messageReactionOperation.perform( - res.fakeWsSessionKey, - payload - ) + const { organizationId, cId, participantsIds, messageReactionsUpdate } = await messageReactionOperation.perform(res, payload) return new Response() .setHttpResponse(new HttpResponse(200, {}, { success: true })) @@ -112,7 +109,7 @@ class HttpMessageController extends BaseHttpController { const payload = res.parsedBody const messageDeleteOperation = ServiceLocatorContainer.use("HttpMessageDeleteOperation") - const { organizationId, cId, participantsIds, deletedMessages } = await messageDeleteOperation.perform(res.fakeWsSessionKey, payload) + const { organizationId, cId, participantsIds, deletedMessages } = await messageDeleteOperation.perform(res, payload) const response = new Response() diff --git a/APIs/JSON/controllers/http/organization.js b/APIs/JSON/controllers/http/organization.js index b401fe77..8a00cb71 100644 --- a/APIs/JSON/controllers/http/organization.js +++ b/APIs/JSON/controllers/http/organization.js @@ -12,7 +12,7 @@ class HttpOrganizationController extends BaseHttpController { const response = new Response() const httpOrganizationCreateOperation = ServiceLocatorContainer.use("HttpOrganizationCreateOperation") - const organization = await httpOrganizationCreateOperation.perform(res.fakeWsSessionKey, payload) + const organization = await httpOrganizationCreateOperation.perform(res, payload) return response.setHttpResponse(new HttpResponse(200, {}, { organization: organization.visibleParams() })) } diff --git a/APIs/JSON/controllers/http/stats.js b/APIs/JSON/controllers/http/stats.js index 162b3331..5c781c57 100644 --- a/APIs/JSON/controllers/http/stats.js +++ b/APIs/JSON/controllers/http/stats.js @@ -12,7 +12,7 @@ class HttpServerStatsController extends BaseHttpController { const response = new Response() const httpOrganizationCreateOperation = ServiceLocatorContainer.use("HttpStatsCollectOperation") - const stats = await httpOrganizationCreateOperation.perform(res.fakeWsSessionKey, payload) + const stats = await httpOrganizationCreateOperation.perform(res, payload) return response.setHttpResponse(new HttpResponse(200, {}, stats)) } diff --git a/APIs/JSON/controllers/users.js b/APIs/JSON/controllers/users.js index 87e74b51..e295cc7a 100644 --- a/APIs/JSON/controllers/users.js +++ b/APIs/JSON/controllers/users.js @@ -56,11 +56,15 @@ class UsersController extends BaseJSONController { const { id: requestId } = data const userLogoutOperation = ServiceLocatorContainer.use("UserLogoutOperation") - const { organizationId, userId } = await userLogoutOperation.perform(ws) + const { organizationId, userId, isWasLastUserSession } = await userLogoutOperation.perform(ws) - return new Response() - .addBackMessage({ response: { id: requestId, success: true } }) - .updateLastActivityStatus(new LastActivityStatusResponse(organizationId, userId, MAIN_CONSTANTS.LAST_ACTIVITY_STATUS.OFFLINE)) + const response = new Response().addBackMessage({ response: { id: requestId, success: true } }) + + if (isWasLastUserSession) { + response.updateLastActivityStatus(new LastActivityStatusResponse(organizationId, userId, MAIN_CONSTANTS.LAST_ACTIVITY_STATUS.OFFLINE)) + } + + return response } async send_otp(ws, data) { diff --git a/APIs/JSON/routes/packet_processor.js b/APIs/JSON/routes/packet_processor.js index ca93bd78..573e6021 100644 --- a/APIs/JSON/routes/packet_processor.js +++ b/APIs/JSON/routes/packet_processor.js @@ -33,11 +33,13 @@ class PacketJsonProcessor extends BasePacketProcessor { async processMessageOrError(ws, message) { let responseData let json + let requestTrackId try { json = this.#parseMessage(message) + requestTrackId = json?.request?.id ?? json?.message?.id ?? json?.system_message?.id ?? json?.id updateStoreContext( MAIN_CONSTANTS.LOGGER_BINDINGS_NAMES.REQUEST_ID, - json?.request?.id ?? json?.message?.id ?? json?.system_message?.id ?? json?.id ?? MAIN_CONSTANTS.LOGGER_BINDINGS_NAMES.NO_REQUEST_ID + requestTrackId ?? MAIN_CONSTANTS.LOGGER_BINDINGS_NAMES.NO_REQUEST_ID ) responseData = await this.#processMessage(ws, json) if (!responseData) { @@ -49,7 +51,7 @@ class PacketJsonProcessor extends BasePacketProcessor { if (json?.request) { errorBackMessage = { response: { - id: json.request?.id, + id: json.request?.id ?? requestTrackId, error: error.cause || error.message, }, } @@ -57,7 +59,7 @@ class PacketJsonProcessor extends BasePacketProcessor { const topLevelElement = json ? Object.keys(json)[0] : void 0 errorBackMessage = { [topLevelElement]: { - id: json?.[topLevelElement]?.id, + id: json?.[topLevelElement]?.id ?? requestTrackId, error: error.cause || error.message, }, } diff --git a/APIs/XMPP b/APIs/XMPP index 7345b506..53e1ab5b 160000 --- a/APIs/XMPP +++ b/APIs/XMPP @@ -1 +1 @@ -Subproject commit 7345b506e738263a1ddcc46a48de73c0f9a437ef +Subproject commit 53e1ab5ba00fe910e48450431d2c6aff8bebc26e diff --git a/Dockerfile b/Dockerfile index ec87285b..ca0a01e0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ WORKDIR /app COPY package*.json ./ -RUN npm install +RUN npm install --omit=dev COPY . . diff --git a/Dockerfile.local b/Dockerfile.local index 5f8aa2f6..5151c4e3 100644 --- a/Dockerfile.local +++ b/Dockerfile.local @@ -4,7 +4,7 @@ WORKDIR /app COPY package*.json ./ -RUN npm install +RUN npm install COPY . . diff --git a/README.md b/README.md index c29fa599..a8c60cec 100644 --- a/README.md +++ b/README.md @@ -77,3 +77,68 @@ Want to support us? Buy Me A Coffee + +## Node Clustering + +### What data is stored in Redis by each node ? + +#### sama-node-data + +Record key in redis like `sama-node-data:{node-endpoint}` where `{node-endpoint}` ws url like `ws://192.168.1.13:55495/`, so redis record will be `sama-node-data:ws://192.168.1.13:55495/`. The record is HSET with ttl that equal config value `ws.cluster.nodeExpiresIn` in seconds + 5 seconds. The record has key/values like `ip/port/host` etc. + +#### sama-node-users + +Record key in redis like `sama-node-users:{node-endpoint}`. The record is SET that contains list user users connected to this node, user item is string like `{organizationId}:{userId}:{deviceId}`. Item example: `683db8ecdb9dee54f53304c0:683db9ed29aecb9feb4afb73:38400000-8cf0-11bd-b23e-10b96e40000d` + +#### sama-user-devices + +Record key in redis like `sama-user-devices:{organizationId}:{userId}`. The record is SET that contains list user devices ids connected to all nodes. Item example: `38400000-8cf0-11bd-b23e-10b96e40000d` + +#### sama-user-data + +Record key in redis like `sama-user-data:{userId}:{deviceId}`. The record in HSET that contains extra user session data e.g. active/inactive status + +### How nodes discover each other ? + +Each node write record key in Redis like `sama-node-data:{node-endpoint}` where `{node-endpoint}` it is own ws url. Node create this record on start and update on sync cluster (check method `syncCluster`). In sync method node retrieve all records from Redis with prefix `sama-node-data:`, the parse record ws url part (`{node-endpoint}`) and try establish ws connection if it doesn't already exist. + + +### How nodes connect to each other ? + +If node-A does not have establish connection with node-B, node-A will create ws connection (check method `createConnectionWithNode`). When ws with node-B endpoint (`node-endpoint`) successfully opened - node-A send like 'ping' message with own network data (`ip/port/host` etc.) to node-B (check method `shareCurrentNodeInfo`), node-B send to node-A like 'pong' message with own network data, node-A after receiving 'pong' message - connection successfully established and handshake finished, app resolve promise and save ws connection in local object (check prop `clusterNodesConnections`). Node-B after send 'pong' message start connecting with node-A by same flow + +#### What happens when connection breaks between 2 nodes ? + +If connecting breaks between 2 nodes, node detect it by ws event close, and start reconnecting (check method `startNodeReconnecting` and `promiseQueueWithJittering`, prop `closeNodesConnections`), this method try 3 time create connection until the first successful with delay '1/4/9 seconds', flow like 1 second delay - try connect (if success return) - 4 seconds delay - try connect (if success return) - 9 seconds delay - try connect (if success return) - failed + +### What happens when new node added to cluster ? + +2 Ways (node-A,node-B - running, node-C - new node in cluster) + +- Flow `How nodes discover each other` and then `How nodes connect to each other`. Node-C retrieve redis records (`sama-node-data`) created by node-A,node-B and create ws connections with each node (open ws and then connect flow with 'ping'/'pong'). + +- Node-A,node-B in method `syncCluster` with call with interval that equal config value `ws.cluster.nodeExpiresIn` in ms, retrieve `sama-node-data` records and see new node record created by node-C and then create connection with node-C + +### What happens when existing node removed from cluster ? + +Example with node-A and node-B + +When node-B disconnect from cluster, ws connection breaks and node-A start reconnecting with node-B but it will not be success. +Then on next `syncCluster` iteration (or next + 1 iteration) node-A can't find node-B record in `sama-node-data` records, but has active reconnecting state with node-B, then mean that node-B destroyed because no-one update node-B `sama-node-data` record ttl, then node-A cancel reconnecting and clean redis data creates by node-B (`sama-node-users`/`sama-user-devices`/`sama-user-data`), `sama-node-data` already deleted by redis ttl. + +#### Conditions when node mark other nodes like destroyed and should clean data ? + +It can be detected no `syncCluster` iteration + +- If have active node record `sama-node-users` w/o ttl, but do not have record `sama-node-data` - no-one update record so node is destroyed +- If do not have node record `sama-node-data` but has reconnecting state +- If have connection (check prop `clusterNodesConnections`) but do not have record `sama-node-data` (case when some how ws close was not called) + +#### How its data is cleared from Redis ? + +Check method `cleanDestroyedNodeData` that clean destroyed node data by node-endpoint. +Flow: +- retrieve `sama-node-users` - list of user who was connected to destroyed node +- delete 'smembers' from `sama-user-devices` +- delete `sama-user-data` record +- delete `sama-node-users` diff --git a/app/cluster/cluster_manager.js b/app/cluster/cluster_manager.js index 10757ebc..459d984c 100644 --- a/app/cluster/cluster_manager.js +++ b/app/cluster/cluster_manager.js @@ -1,100 +1,300 @@ +import { StringDecoder } from "node:string_decoder" + import WebSocket from "ws" import uWS from "uWebSockets.js" -import { StringDecoder } from "node:string_decoder" import config from "../config/index.js" import mainLogger from "../logger/index.js" +import BaseProtocolProcessor from "../networking/protocol_processors/base.js" + +import ServiceLocatorContainer from "../common/ServiceLocatorContainer.js" + +import activitySender from "../networking/activity_sender.js" import packetManager from "../networking/packet_manager.js" -import { buildWsEndpoint } from "../utils/build_ws_endpoint.js" -import { getIpFromWsUrl } from "../utils/get_ip_from_ws_url.js" +import { promiseQueueWithJittering, CancelQueueError } from "../utils/promise-queue-with-jittering.js" const logger = mainLogger.child("[ClusterManager]") +const loggerSync = logger.child("[Sync]") +const loggerSender = logger.child("[Sender]") +const loggerReceiver = logger.child("[Receiver]") const decoder = new StringDecoder("utf8") -class ClusterManager { - #clusterNodesWS = {} +const DUPLICATE_CLOSE_WS_CODE = 1004 +const DUPLICATE_CLOSE_WS_REASON = "Duplicate" + +class ReconnectManager { + nodeEndpoint = void 0 + + startFn = void 0 + cancelFn = void 0 + + isFinished = false + + constructor(nodeEndpoint, promiseFn) { + this.nodeEndpoint = nodeEndpoint + + const { start, cancel } = promiseQueueWithJittering(promiseFn, 3, 1_000) + + this.startFn = start + this.cancelFn = cancel + } + + start() { + return this.startFn?.() + } + + cancel() { + this.cancelFn?.() + + this.startFn = void 0 + this.cancelFn = void 0 + + this.isFinished = true + } +} + +class ClusterManager extends BaseProtocolProcessor { + #nodesSyncInterval = void 0 + + #localSocket = void 0 + + clusterNodesConnections = new Map() + reconnectingNodesConnections = new Map() + + async startSyncingClusterNodes(isColdStart) { + if (this.#nodesSyncInterval) { + return + } + + if (isColdStart) { + await this.cleanDestroyedNodeData(config.get("ws.cluster.endpoint")) + } + + this.#nodesSyncInterval = setInterval(() => this.#syncCluster(), config.get("ws.cluster.nodeExpiresIn")) + + if (isColdStart) { + await this.#syncCluster() + } + } + + async stopSyncingClusterNodes() { + clearInterval(this.#nodesSyncInterval) + this.#nodesSyncInterval = void 0 + } + + async #syncCluster() { + const sessionService = ServiceLocatorContainer.use("SessionService") - #localSocket = null + await this.#storeCurrentNode(sessionService.totalSessions()) - get clusterNodesWS() { - return this.#clusterNodesWS + await this.#connectToExistingClusterNodes() + } + + async #storeCurrentNode(usersCount) { + const clusterNodeService = ServiceLocatorContainer.use("ClusterNodeService") + + const addressParams = { + ip_address: config.get("app.ip"), + hostname: config.get("app.hostName"), + port: config.get("ws.cluster.port"), + } + + const optionalParams = { + users_count: usersCount, + } + + await clusterNodeService.upsert(addressParams, optionalParams) + } + + async #connectToExistingClusterNodes() { + const destroyedNodes = new Set() + + loggerSender.debug("[Start]") + loggerSync.debug("[connections] %j %s", Array.from(this.clusterNodesConnections.keys()), this.clusterNodesConnections.size) + loggerSync.debug("[reconnecting] %j %s", Array.from(this.reconnectingNodesConnections.keys()), this.reconnectingNodesConnections.size) + + const clusterNodeService = ServiceLocatorContainer.use("ClusterNodeService") + + const activeNodes = await clusterNodeService.retrieveActive() + loggerSync.debug("[active] %j %s", Array.from(activeNodes), activeNodes.size) + + const storedNodes = await clusterNodeService.retrieveStored() + loggerSync.debug("[stored] %j %s", Array.from(storedNodes), storedNodes.size) + + // check node-users stored and no active + storedNodes.forEach((storedNode) => { + if (!activeNodes.has(storedNode)) { + destroyedNodes.add(storedNode) + } + }) + + // check ws closed and not active + for (const clusterNode of this.reconnectingNodesConnections.keys()) { + if (!activeNodes.has(clusterNode)) { + destroyedNodes.add(clusterNode) + } + } + + // check current connects but not active (probable ws close did not call) + for (const clusterNode of this.clusterNodesConnections.keys()) { + if (!activeNodes.has(clusterNode)) { + destroyedNodes.add(clusterNode) + } + } + + for (const nodeEndpoint of activeNodes) { + const isCurrentNode = config.get("ws.cluster.endpoint") === nodeEndpoint + if (isCurrentNode) { + continue + } + + const existingReconnectManager = this.reconnectingNodesConnections.get(nodeEndpoint) + if (existingReconnectManager && !existingReconnectManager.isFinished) { + continue + } + + try { + loggerSync.debug("[establish connect with node] %s", nodeEndpoint) + await this.createOrRetrieveConnectionWithNode(nodeEndpoint, true) + loggerSync.debug("[connected] %s", nodeEndpoint) + } catch (error) { + loggerSync.error(error, "[connect node][failed] %s", nodeEndpoint) + this.startNodeReconnecting(nodeEndpoint, false) + } + } + + for (const nodeEndpoint of destroyedNodes) { + await this.cleanDestroyedNodeData(nodeEndpoint) + } } #shareCurrentNodeInfo(ws) { ws.send( JSON.stringify({ node_info: { + endpoint: config.get("ws.cluster.endpoint"), ip: config.get("app.ip"), + host: config.get("app.hostName"), + port: config.get("ws.cluster.port"), }, }) ) } - async senderClusterDeliverPacket(nodeUrl, deliverPacket) { - const ip = getIpFromWsUrl(nodeUrl) - const port = nodeUrl.split(":").at(2) + async createOrRetrieveConnectionWithNode(nodeEndpoint, throwIfActiveReconnect) { + const existingWS = this.clusterNodesConnections.get(nodeEndpoint) + if (existingWS) { + return existingWS + } - let recipientClusterNodeWS = this.clusterNodesWS[getIpFromWsUrl(nodeUrl)] + if (throwIfActiveReconnect && this.reconnectingNodesConnections.has(nodeEndpoint)) { + throw new Error("Node reconnecting") + } + + loggerSender.debug("[connect node] %s", nodeEndpoint) + + const ws = await this.createConnectionWithNode(nodeEndpoint) + + this.cancelNodeReconnecting(nodeEndpoint) - if (!recipientClusterNodeWS) { - recipientClusterNodeWS = await this.createSocketWithNode(ip, port) + loggerSender.debug("[connect node][success] %s", nodeEndpoint) + + if (this.clusterNodesConnections.get(nodeEndpoint)) { + ws.close(DUPLICATE_CLOSE_WS_CODE, DUPLICATE_CLOSE_WS_REASON) + return this.clusterNodesConnections.get(nodeEndpoint) } - const clusterPacket = { deliverPacket } - recipientClusterNodeWS.send(JSON.stringify(clusterPacket)) + this.clusterNodesConnections.set(nodeEndpoint, ws) + + return ws } - async createSocketWithNode(ip, port) { - return new Promise((resolve, reject) => { - const existingWS = this.clusterNodesWS[ip] - if (existingWS) { - resolve(existingWS) - return - } + async createConnectionWithNode(nodeEndpoint) { + const nodeSederLogger = loggerSender.child(`[${nodeEndpoint}]`) - const url = buildWsEndpoint(ip, port) - if (!url) { - reject(`[ClusterManager][createSocketWithNode] can't create To Node Socket w/o url`) - return - } + return new Promise((resolve, reject) => { + const ws = new WebSocket(nodeEndpoint) - const ws = new WebSocket(url) + ws.nodeEndpoint = nodeEndpoint ws.on("error", async (event) => { - logger.error(event, "[createSocketWithNode] ws on Error") - reject("[ClusterManager][createSocketWithNode] ws on error") + nodeSederLogger.error(event, "[error]") + reject(new Error(`Error on ${nodeEndpoint}`)) }) ws.on("open", async () => { - logger.debug("[createSocketWithNode] ws on Open url %s", ws.url) + nodeSederLogger.debug("[Open] %s", ws.nodeEndpoint) + ws.isWasOpened = true this.#shareCurrentNodeInfo(ws) }) ws.on("message", async (data) => { - const json = JSON.parse(decoder.write(Buffer.from(data))) + const clusterPacket = JSON.parse(decoder.write(Buffer.from(data))) - logger.trace("ws on Message %j", json) + nodeSederLogger.trace("[packet] %j", clusterPacket) - if (json.node_info) { - const nodeInfo = json.node_info - this.clusterNodesWS[nodeInfo.ip] = ws + if (clusterPacket.node_info) { + const nodeInfo = clusterPacket.node_info + nodeSederLogger.debug("[node handshake finished] %s", nodeInfo.endpoint) resolve(ws) return } - - await packetManager.deliverClusterMessageToUser(json.deliverPacket) }) - ws.on("close", async () => { - logger.debug("[createSocketWithNode] ws on Close %s", ws.url) - delete this.clusterNodesWS[getIpFromWsUrl(ws.url)] + ws.on("close", (code, reason) => { + reason = reason.toString("utf-8") + nodeSederLogger.debug("[Close][%s] IsWasOpened: %s Code: %s Reason: %s", ws.nodeEndpoint, ws.isWasOpened, code, reason) + + if (!ws.isWasOpened) { + return + } + + this.onCloseNode(ws.nodeEndpoint, +code, reason) }) }) } + async startNodeReconnecting(nodeEndpoint, skipIfHashActiveReconnection) { + if (skipIfHashActiveReconnection && this.reconnectingNodesConnections.has(nodeEndpoint)) { + return + } + + const reconnectLogger = logger.child(`[Reconnecting][${nodeEndpoint}]`) + reconnectLogger.debug("[start]") + + this.cancelNodeReconnecting(nodeEndpoint) + + const connectFn = () => this.createOrRetrieveConnectionWithNode(nodeEndpoint, false) + const reconnectManager = new ReconnectManager(nodeEndpoint, connectFn) + + this.reconnectingNodesConnections.set(nodeEndpoint, reconnectManager) + + try { + const result = await reconnectManager.start() + + reconnectLogger.debug("[finish]") + + this.cancelNodeReconnecting(nodeEndpoint, true) + + return result + } catch (error) { + reconnectLogger.error(error, "[error]") + reconnectManager.cancel() + } + } + + cancelNodeReconnecting(nodeEndpoint, isSuccess) { + if (!isSuccess) { + const existingReconnectManager = this.reconnectingNodesConnections.get(nodeEndpoint) + existingReconnectManager?.cancel?.() + } + + this.reconnectingNodesConnections.delete(nodeEndpoint) + } + async createLocalSocket({ isSSL, appOptions, wsOptions, listenOptions }) { return new Promise((resolve) => { this.#localSocket = isSSL ? uWS.SSLApp(appOptions) : uWS.App(appOptions) @@ -103,50 +303,101 @@ class ClusterManager { ...wsOptions, open: (ws) => { - logger.debug("[WS] on Open IP: %s", Buffer.from(ws.getRemoteAddressAsText()).toString()) - }, - - close: async (ws, code, message) => { - logger.debug("[WS] on Close") - for (const nodeIp in this.clusterNodesWS) { - if (this.clusterNodesWS[nodeIp] !== ws) { - continue - } - - delete this.clusterNodesWS[nodeIp] - return - } + loggerReceiver.debug("[Open] IP: %s", Buffer.from(ws.getRemoteAddressAsText()).toString()) }, message: async (ws, message, isBinary) => { - const json = JSON.parse(decoder.write(Buffer.from(message))) + const clusterPacket = JSON.parse(decoder.write(Buffer.from(message))) + + loggerReceiver.trace("[packet] %j", clusterPacket) - logger.trace("[WS] on Message %j", json) + if (clusterPacket.node_info) { + const nodeInfo = clusterPacket.node_info + ws.nodeEndpoint = nodeInfo.endpoint - if (json.node_info) { - const nodeInfo = json.node_info - this.clusterNodesWS[nodeInfo.ip] = ws + loggerReceiver.debug("[node handshake pong] %s", nodeInfo.endpoint) this.#shareCurrentNodeInfo(ws) + await this.createOrRetrieveConnectionWithNode(nodeInfo.endpoint, true).catch((error) => + loggerReceiver.error(error, "[create connection on receiver]") + ) return } - await packetManager.deliverClusterMessageToUser(json.deliverPacket) + if (clusterPacket.deliverPacket) { + await packetManager.deliverClusterMessageToUser(clusterPacket.deliverPacket) + } + }, + + close: (ws, code, reason) => { + reason = decoder.write(Buffer.from(reason)) + loggerReceiver.debug("[Close][%s] Code: %s Reason: %s", ws.nodeEndpoint, code, reason) + this.onCloseNode(ws.nodeEndpoint, +code, reason) }, }) - this.#localSocket.listen(0, listenOptions, (listenSocket) => { + this.#localSocket.listen(parseInt(config.get("ws.cluster.port")) || 0, listenOptions, (listenSocket) => { if (!listenSocket) { - throw new Error(`[ClusterManager][WS] socket.listen error: can't allocate port`) + throw new Error(`[ClusterManager] error: can't allocate port`) } const clusterPort = uWS.us_socket_local_port(listenSocket) - logger.debug("[WS] listening on port %s", clusterPort) + loggerReceiver.debug(" listening on port %s", clusterPort) return resolve(clusterPort) }) }) } + + async senderClusterDeliverPacket(nodeEndpoint, deliverPacket) { + let recipientClusterNodeConnection = this.clusterNodesConnections.get(nodeEndpoint) + + if (!recipientClusterNodeConnection) { + recipientClusterNodeConnection = await this.createOrRetrieveConnectionWithNode(nodeEndpoint, true) + } + + const clusterPacket = { deliverPacket } + loggerSender.trace("[%s][send] %j", nodeEndpoint, clusterPacket) + recipientClusterNodeConnection.send(JSON.stringify(clusterPacket)) + } + + onCloseNode(nodeEndpoint, closeCode, reason) { + if (closeCode === DUPLICATE_CLOSE_WS_CODE && reason === DUPLICATE_CLOSE_WS_REASON) { + return + } + + this.deleteClusterConnection(nodeEndpoint) + this.startNodeReconnecting(nodeEndpoint, true) + } + + deleteClusterConnection(nodeEndpoint) { + const connections = this.clusterNodesConnections.get(nodeEndpoint) + this.clusterNodesConnections.delete(nodeEndpoint) + connections?.close?.() + } + + async cleanDestroyedNodeData(nodeEndpoint) { + logger.debug("[clean node] %s", nodeEndpoint) + + this.deleteClusterConnection(nodeEndpoint) + this.cancelNodeReconnecting(nodeEndpoint) + + const sessionService = ServiceLocatorContainer.use("SessionService") + + const lastUserSessions = await sessionService.clearNodeUsersSession(nodeEndpoint).catch((error) => logger.error(error, "[clean node]")) + + if (!lastUserSessions) { + return + } + + const offlineUserResponses = lastUserSessions.map((userSession) => + activitySender.buildOfflineActivityResponse(userSession.organizationId, userSession.userId) + ) + + for (const offlineUserResponse of offlineUserResponses) { + await this.processAPIResponse(void 0, offlineUserResponse) + } + } } -export default new ClusterManager() +export default new ClusterManager(void 0, void 0) diff --git a/app/cluster/cluster_syncer.js b/app/cluster/cluster_syncer.js deleted file mode 100644 index b39d3e4f..00000000 --- a/app/cluster/cluster_syncer.js +++ /dev/null @@ -1,80 +0,0 @@ -import config from "../config/index.js" -import logger from "../logger/index.js" - -import clusterManager from "./cluster_manager.js" - -import ServiceLocatorContainer from "@sama/common/ServiceLocatorContainer.js" - -class ClusterSyncer { - constructor() { - this.nodes = {} - - this.nodesSyncInterval = null - } - - async startSyncingClusterNodes() { - if (this.nodesSyncInterval) { - return - } - - this.#syncCluster() - - this.nodesSyncInterval = setInterval(() => { - this.#syncCluster() - }, config.get("ws.cluster.nodeExpiresIn")) - } - - async stopSyncingClusterNodes() { - clearInterval(this.nodesSyncInterval) - this.nodesSyncInterval = null - } - - async #retrieveExistingClusterNodes() { - const clusterNodeService = ServiceLocatorContainer.use("ClusterNodeService") - const nodeList = await clusterNodeService.retrieveAll() - - // initiate connect to other node - nodeList.forEach(async (n) => { - if ( - config.get("ws.cluster.port") !== n.port && - `${config.get("app.hostName")}${config.get("ws.cluster.port")}` < `${n.hostname}${n.port}` - ) - try { - await clusterManager.createSocketWithNode(n.ip_address, n.port) - } catch (err) { - logger.error(err, "[ClusterSyncer][createSocketWithNode]") - } - }) - - this.nodes = nodeList - - // TODO - // if some node is gone, we may need to do some cleaning ? - } - - async #storeCurrentNode(usersCount) { - const clusterNodeService = ServiceLocatorContainer.use("ClusterNodeService") - - const addressParams = { - ip_address: config.get("app.ip"), - hostname: config.get("app.hostName"), - port: config.get("ws.cluster.port"), - } - - const optionalParams = { - users_count: usersCount, - } - - await clusterNodeService.create(addressParams, optionalParams) - } - - async #syncCluster() { - const sessionService = ServiceLocatorContainer.use("SessionService") - - await this.#storeCurrentNode(sessionService.sessionsTotal) - - await this.#retrieveExistingClusterNodes() - } -} - -export default new ClusterSyncer() diff --git a/app/config/default.js b/app/config/default.js index c73ac88e..03f264e0 100644 --- a/app/config/default.js +++ b/app/config/default.js @@ -3,7 +3,7 @@ import { CONSTANTS } from "../constants/constants.js" const CONFIG = { app: { env: process.env.NODE_ENV ?? CONSTANTS.ENVS.DEV, - ip: void 0, + ip: process.env.CLUSTER_ADVERTISE_IP, name: process.env.APP_NAME ?? "SAMA", hostName: process.env.HOSTNAME, isStandAloneNode: process.env.STANDALONE_NODE === CONSTANTS.ENV_TRUE, diff --git a/app/config/index.js b/app/config/index.js index 966fe912..c317df56 100644 --- a/app/config/index.js +++ b/app/config/index.js @@ -7,11 +7,7 @@ import Config from "./config.js" const config = new Config(CONFIG_VALUES) config.set("app.hostName", os.hostname(), true) -config.set("app.ip", ip.address()) - -if (config.get("app.isStandAloneNode")) { - config.set("ws.cluster.port", 1000) -} +config.set("app.ip", ip.address(), true) const isSecureWs = config.get("ws.options.ssl.key") && config.get("ws.options.ssl.cert") config.set("ws.options.isSecure", !!isSecureWs) @@ -19,4 +15,8 @@ config.set("ws.options.isSecure", !!isSecureWs) const isTls = config.get("tcp.options.tls.key") && config.get("tcp.options.tls.cert") config.set("tcp.options.isTls", !!isTls) +if (config.get("app.isStandAloneNode")) { + config.set("ws.cluster.port", 1000) +} + export default config diff --git a/app/constants/constants.js b/app/constants/constants.js index 93362362..d16baa0e 100644 --- a/app/constants/constants.js +++ b/app/constants/constants.js @@ -18,6 +18,7 @@ export const CONSTANTS = { SESSION_DEVICE_ID_KEY: "DEVICE_ID", HTTP_DEVICE_ID: "SAMA_HTTP_CLIENT_DEVICE_ID", HTTP_ADMIN_API_KEY_HEADER: "admin-api-key", + HTTP_REPL_ACCESS_KEY_HEADER: "repl-access-key", CHAT_SUMMARY_FILTERS: { LAST_7_DAYS: "last-7-days", LAST_DAY: "last-day", @@ -39,4 +40,10 @@ export const CONSTANTS = { TESTING: "testing", }, ENV_TRUE: "true", + REDIS_PREFIXES: { + NODE_DATA: "sama-node-data", + NODE_USERS: "sama-node-users", + USER_DEVICES: "sama-user-devices", + USER_DATA: "sama-user-data", + }, } diff --git a/app/constants/errors.js b/app/constants/errors.js index 219a6eca..c846007d 100644 --- a/app/constants/errors.js +++ b/app/constants/errors.js @@ -52,6 +52,10 @@ export const ERROR_STATUES = { status: 401, message: "Incorrect username or password.", }, + ORGANIZATION_BLOCKED: { + status: 403, + message: "Organization was blocked.", + }, INCORRECT_OTP_TOKEN: { status: 403, message: "Incorrect token for password recovery." }, DEVICE_ID_MISSED: { status: 422, message: `'device_id' is required.` }, // Contacts --> diff --git a/app/lib/redis.js b/app/lib/redis.js index be7c8b91..09be9b88 100644 --- a/app/lib/redis.js +++ b/app/lib/redis.js @@ -70,10 +70,14 @@ class RedisManager { return matchCount } - async findKeyByPattern(pattern) { + async findKeysByPattern(pattern) { const keys = await this.client.keys(pattern) - return keys?.at(0) + if (!keys?.length) { + return keys + } + + return keys } } diff --git a/app/lib/repl-tools.js b/app/lib/repl-tools.js index 186a9b96..707b3420 100644 --- a/app/lib/repl-tools.js +++ b/app/lib/repl-tools.js @@ -141,4 +141,4 @@ export const startReplServices = async (replOptions, httpOptions, netOptions, fi if (fileOptions.fileIn && fileOptions.fileOut) { await fileReplService(replOptions, fileOptions) } -} \ No newline at end of file +} diff --git a/app/models/organization.js b/app/models/organization.js index b17f0d75..e9819571 100644 --- a/app/models/organization.js +++ b/app/models/organization.js @@ -6,6 +6,6 @@ export default class Organization extends BaseModel { } static get visibleFields() { - return ["_id", "name", "created_at", "updated_at"] + return ["_id", "name", "is_blocked", "block_reason", "created_at", "updated_at"] } } diff --git a/app/networking/activity_sender.js b/app/networking/activity_sender.js index d5d0612e..bed9adfd 100644 --- a/app/networking/activity_sender.js +++ b/app/networking/activity_sender.js @@ -11,7 +11,7 @@ import LastActivityStatusResponse from "@sama/networking/models/LastActivityStat class ActivitySender { detectSocketAPI(socket) { - const api = APIs[socket.apiType ?? BASE_API] + const api = APIs[socket?.apiType ?? BASE_API] return api } diff --git a/app/networking/models/DeliverMessage.js b/app/networking/models/DeliverMessage.js index a592e6bb..39692115 100644 --- a/app/networking/models/DeliverMessage.js +++ b/app/networking/models/DeliverMessage.js @@ -1,5 +1,5 @@ class DeliverMessage { - ws = null + socket = null orgId = null @@ -13,11 +13,11 @@ class DeliverMessage { notSaveInOfflineStorage = false pushQueueMessage = null - constructor(orgId, packet, notSaveInOfflineStorage, ws) { + constructor(orgId, packet, notSaveInOfflineStorage, socket) { this.orgId = orgId this.packet = packet this.notSaveInOfflineStorage = !!notSaveInOfflineStorage - this.ws = ws + this.socket = socket } setOrgId(orgId) { diff --git a/app/networking/packet_manager.js b/app/networking/packet_manager.js index fb3a0812..c3478814 100644 --- a/app/networking/packet_manager.js +++ b/app/networking/packet_manager.js @@ -11,17 +11,13 @@ import { CONSTANTS as MAIN_CONSTANTS } from "../constants/constants.js" const logger = maiLogger.child("[PacketManager]") class PacketManager { - async deliverToUserOnThisNode(userId, packet, deviceId, senderInfo) { + async deliverToUserOnThisNode(userId, exceptDeviceId, packet, senderInfo) { const sessionService = ServiceLocatorContainer.use("SessionService") - let activeDevices = sessionService + const activeDevices = sessionService .getUserDevices(userId) .filter((activeDevice) => activeDevice?.deviceId !== MAIN_CONSTANTS.HTTP_DEVICE_ID) - - if (deviceId) { - activeDevices = activeDevices.filter((recipient) => recipient?.deviceId === deviceId) - } - - activeDevices = activeDevices.filter((recipient) => !!recipient?.socket) + .filter((activeDevice) => !!activeDevice?.socket) + .filter((activeDevice) => activeDevice?.deviceId !== exceptDeviceId) for (const recipient of activeDevices) { try { @@ -57,100 +53,100 @@ class PacketManager { } } - #deliverToUserDevices(socket, nodeConnections, userId, packet, ignoreSelf) { + // async deliverToUserOrUsers(orgId, socket, packet, pushQueueMessage, usersIds, notSaveInOfflineStorage, ignoreSelf) + async deliverToUserOrUsers(sourceOptions, destinationUserIds, payloadOptions) { const sessionService = ServiceLocatorContainer.use("SessionService") - const senderUserSession = sessionService.getSession(socket) - const senderDeviceId = senderUserSession ? sessionService.getDeviceId(socket, senderUserSession.userId) : null + const opLogsService = ServiceLocatorContainer.use("OperationLogsService") + const pushQueueService = ServiceLocatorContainer.use("PushQueueService") - const currentNodeUrl = config.get("ws.cluster.endpoint") + if (!destinationUserIds?.length) { + return + } const senderInfo = { - apiType: socket?.apiType, - session: senderUserSession, - deviceId: senderDeviceId, - node: currentNodeUrl, + organizationId: sourceOptions.organizationId, + session: { organizationId: sourceOptions.organizationId }, + node: config.get("ws.cluster.endpoint"), } - if (config.get("app.isStandAloneNode")) { - Object.keys(nodeConnections).forEach(async (nodeDeviceId) => { - if (senderDeviceId === nodeDeviceId && ignoreSelf) { - return // carbon message - } + if (sourceOptions.socket) { + senderInfo.apiType = sourceOptions.socket?.apiType + senderInfo.session = sessionService.getSession(sourceOptions.socket) ?? senderInfo.session + senderInfo.deviceId = sessionService.getDeviceId(sourceOptions.socket, senderInfo.session.userId) + } - await this.deliverToUserOnThisNode(userId, packet, nodeDeviceId, senderInfo) - }) - } else { - Object.entries(nodeConnections).forEach(async ([nodeDeviceId, extraParams]) => { - const nodeUrl = extraParams[MAIN_CONSTANTS.SESSION_NODE_KEY] + const offlineUsersIds = [] + let currentNodesUserIds = {} + let otherNodes = {} - if (!nodeUrl) { - return - } + for (const userId of destinationUserIds) { + const userConnections = await sessionService.listUserData(sourceOptions.organizationId, userId) + const isOffline = !Object.keys(userConnections ?? {}).length + const isInactive = Object.values(userConnections).some((extraParams) => sessionService.isUserInactive(null, extraParams)) - if (currentNodeUrl === nodeUrl) { - if (senderDeviceId === nodeDeviceId && ignoreSelf) { - return - } + if (isOffline && !payloadOptions.notSaveInOfflineStorage) { + opLogsService.savePacket(userId, payloadOptions.packet) + } - await this.deliverToUserOnThisNode(userId, packet, nodeDeviceId, senderInfo) // carbon message + if (isOffline || isInactive) { + offlineUsersIds.push(userId) + } - return - } + if (isOffline) { + continue + } + + this.reduceUserNodeConnections(senderInfo.node, userId, userConnections, currentNodesUserIds, otherNodes) + } + Object.keys(currentNodesUserIds).forEach(async (userId) => { + const exceptDeviceId = payloadOptions.ignoreSelf ? senderInfo.deviceId : void 0 + + await this.deliverToUserOnThisNode(userId, exceptDeviceId, payloadOptions.packet, senderInfo) + }) + + Object.entries(otherNodes).forEach(([nodeEndpoint, userIds]) => { + Object.keys(userIds).forEach(async (userId) => { try { - const clusterPacket = { userId, packet, senderInfo } - await clusterManager.senderClusterDeliverPacket(nodeUrl, clusterPacket) + const clusterPacket = { userId, packet: payloadOptions.packet, senderInfo } + logger.trace("[Cluster][%s][deliver] %j", nodeEndpoint, clusterPacket) + await clusterManager.senderClusterDeliverPacket(nodeEndpoint, clusterPacket) } catch (error) { - await sessionService.clearNodeUsersSession(nodeUrl) - logger.error(error, "[deliverToUserDevices] createSocketWithNode error") + logger.error(error, "[%s][deliver to other node]", nodeEndpoint) } }) - } - } + }) - async deliverToUserOrUsers(orgId, socket, packet, pushQueueMessage, usersIds, notSaveInOfflineStorage, ignoreSelf) { - const sessionService = ServiceLocatorContainer.use("SessionService") - const opLogsService = ServiceLocatorContainer.use("OperationLogsService") - const pushQueueService = ServiceLocatorContainer.use("PushQueueService") - - if (!usersIds?.length) { - return + if (payloadOptions.pushQueueMessage && offlineUsersIds.length) { + payloadOptions.pushQueueMessage.setRecipientIds(offlineUsersIds) + await pushQueueService.createPush(payloadOptions.pushQueueMessage) } + } - const offlineUsersByPackets = [] - - for (const userId of usersIds) { - const userNodeData = await sessionService.listUserData(orgId, userId) - const isNoConnections = !userNodeData || !Object.keys(userNodeData).length - - if (isNoConnections) { - if (!notSaveInOfflineStorage) { - opLogsService.savePacket(userId, packet) - } - - offlineUsersByPackets.push(userId) + reduceUserNodeConnections(targetNodeEndpoint, userId, nodeConnections, currentNodesAcc, otherNodesAcc) { + for (const connectionsParams of Object.values(nodeConnections)) { + const connectionEndpoint = connectionsParams?.[MAIN_CONSTANTS.SESSION_NODE_KEY] + if (!connectionEndpoint) { continue } - const isInactive = Object.values(userNodeData).some((extraParams) => sessionService.isUserInactive(null, extraParams)) - - if (isInactive) { - offlineUsersByPackets.push(userId) + if (connectionEndpoint === targetNodeEndpoint || config.get("app.isStandAloneNode")) { + currentNodesAcc[userId] = connectionEndpoint + continue } - this.#deliverToUserDevices(socket, userNodeData, userId, packet, ignoreSelf) - } + if (!otherNodesAcc[connectionEndpoint]) { + otherNodesAcc[connectionEndpoint] = {} + } - if (pushQueueMessage && offlineUsersByPackets.length) { - pushQueueMessage.setRecipientIds(offlineUsersByPackets) - await pushQueueService.createPush(pushQueueMessage) + otherNodesAcc[connectionEndpoint][userId] = connectionEndpoint } } async deliverClusterMessageToUser(deliverPacket) { try { const { userId, packet, senderInfo } = deliverPacket - await this.deliverToUserOnThisNode(userId, packet, null, senderInfo) + await this.deliverToUserOnThisNode(userId, void 0, packet, senderInfo) } catch (error) { logger.error(error, "[deliverClusterMessageToUser]") } diff --git a/app/networking/protocol_processors/base.js b/app/networking/protocol_processors/base.js index 66663ab7..d67ef59d 100644 --- a/app/networking/protocol_processors/base.js +++ b/app/networking/protocol_processors/base.js @@ -2,7 +2,7 @@ import { StringDecoder } from "node:string_decoder" import { v4 as uuid } from "uuid" import logger from "../../logger/index.js" -import { createStore, updateStoreContext } from "../../logger/async_store.js" +import { createStore } from "../../logger/async_store.js" import { ERROR_STATUES } from "../../constants/errors.js" import { CONSTANTS as MAIN_CONSTANTS } from "../../constants/constants.js" import { BASE_API, APIs, detectAPIType } from "../APIs.js" @@ -16,7 +16,7 @@ import MappableMessage from "../models/MappableMessage.js" const decoder = new StringDecoder("utf8") -const mapBackMessageFunc = async (ws, packet) => packetMapper.mapPacket(null, ws.apiType, packet, {}, {}) +const mapBackMessageFunc = async (socket, packet) => packetMapper.mapPacket(null, socket.apiType, packet, {}, {}) class BaseProtocolProcessor { constructor(sessionService, conversationService) { @@ -140,7 +140,7 @@ class BaseProtocolProcessor { } async processUpdateLastActivityResponse(socket, response) { - let { organizationId, userId } = this.sessionService.getSession(socket) ?? {} + let { organizationId, userId } = this.sessionService?.getSession(socket) ?? {} organizationId = response.lastActivityStatusResponse.orgId ?? organizationId userId = response.lastActivityStatusResponse.userId ?? userId @@ -152,6 +152,7 @@ class BaseProtocolProcessor { userId, response.lastActivityStatusResponse.status ) + responses.forEach((activityResponse) => response.merge(activityResponse)) return response @@ -161,7 +162,7 @@ class BaseProtocolProcessor { for (const deliverPackage of deliverPackages) { logger.trace("[DELIVER] %o", deliverPackage) - deliverPackage.ws ??= socket + deliverPackage.socket ??= socket if (deliverPackage.userIds?.length) { await this.processDeliverUserMessage(deliverPackage) @@ -173,15 +174,21 @@ class BaseProtocolProcessor { async processDeliverUserMessage(deliverMessage, participantIds) { try { - await packetManager.deliverToUserOrUsers( - deliverMessage.orgId, - deliverMessage.ws, - deliverMessage.packet, - deliverMessage.pushQueueMessage, - participantIds ?? deliverMessage.userIds, - deliverMessage.notSaveInOfflineStorage, - deliverMessage.ignoreSelf - ) + const sourceOptions = { + organizationId: deliverMessage.orgId, + socket: deliverMessage.socket, + } + + const destinationUserIds = participantIds ?? deliverMessage.userIds + + const payloadOptions = { + packet: deliverMessage.packet, + notSaveInOfflineStorage: deliverMessage.notSaveInOfflineStorage, + ignoreSelf: deliverMessage.ignoreSelf, + pushQueueMessage: deliverMessage.pushQueueMessage, + } + + await packetManager.deliverToUserOrUsers(sourceOptions, destinationUserIds, payloadOptions) } catch (error) { logger.error(error, "[PacketManager][error]") } @@ -220,7 +227,11 @@ class BaseProtocolProcessor { return } - await this.sessionService.removeUserSession(socket, userId) + const isWasLastUserSession = await this.sessionService.removeUserSession(socket, userId) + + if (!isWasLastUserSession) { + return + } await this.processAPIResponse(socket, activitySender.buildOfflineActivityResponse(organizationId, userId), true) } diff --git a/app/networking/protocol_processors/http.js b/app/networking/protocol_processors/http.js index e1f1380c..061fc955 100644 --- a/app/networking/protocol_processors/http.js +++ b/app/networking/protocol_processors/http.js @@ -1,6 +1,3 @@ -import os from "node:os" - -import { v4 as uuid } from "uuid" import { CONSTANTS as MAIN_CONSTANTS } from "../../constants/constants.js" import { ERROR_STATUES } from "../../constants/errors.js" @@ -24,8 +21,6 @@ import { asyncLoggerContextStore, createStore, updateStoreContext } from "@sama/ const logger = mainLogger.child("[Http]") const parseBaseParamsMiddleware = async (res, req) => { - res.fakeWsSessionKey = Symbol("Http ws fake session") - res.parsedHeaders = {} req.forEach((headerName, value) => { @@ -52,7 +47,7 @@ const parseBaseParamsMiddleware = async (res, req) => { } const addCorsHeaders = (res, req) => { - res.writeHeader("Access-Control-Allow-Origin", config.get("http.corsOrigin") ?? "*") + res.writeHeader("Access-Control-Allow-Origin", config.get("http.corsOrigin") ?? res.parsedHeaders["origin"] ?? "*") res.writeHeader("Access-Control-Allow-Credentials", "true") res.writeHeader("Access-Control-Allow-Methods", "POST, PUT, DELETE") res.writeHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, API-Key") @@ -134,13 +129,13 @@ class HttpProtocol extends BaseProtocolProcessor { } } - async unbindSession(wsKey) { - const session = this.sessionService.getSession(wsKey) + async unbindSession(res) { + const session = this.sessionService.getSession(res) if (!session?.userId) { return } - await this.sessionService.removeUserSession(wsKey, session.userId, MAIN_CONSTANTS.HTTP_DEVICE_ID) + await this.sessionService.removeUserSession(res, session.userId, MAIN_CONSTANTS.HTTP_DEVICE_ID) } async processHttpResponseMiddleware(res, req, handlerResponse) { @@ -179,7 +174,7 @@ class HttpProtocol extends BaseProtocolProcessor { emptyBody ? res.endWithoutBody() : res.end(bodyStr) }) - this.processAPIResponse(res.fakeWsSessionKey, handlerResponse, true) + this.processAPIResponse(res, handlerResponse, true) .then(() => logger.trace("[processAPIResponse][success]")) .catch((error) => logger.error(error, "[processAPIResponse][Error]")) } @@ -222,13 +217,13 @@ class HttpProtocol extends BaseProtocolProcessor { res.end(error.message ?? ERROR_STATUES.INTERNAL_SERVER.message) }) } finally { - await this.unbindSession(res.fakeWsSessionKey) + await this.unbindSession(res) } } onHttpRequestHandler(preMiddleware = [], handler) { return (res, req) => { - asyncLoggerContextStore.run(this.requestCreateStoreContext(res), () => { + asyncLoggerContextStore.run(this.requestCreateStoreContext(this.extendSocket(res)), () => { return this.requestHandler(req, res, preMiddleware, handler) }) } diff --git a/app/networking/protocol_processors/tcp.js b/app/networking/protocol_processors/tcp.js index 255411f6..ca846aaa 100644 --- a/app/networking/protocol_processors/tcp.js +++ b/app/networking/protocol_processors/tcp.js @@ -3,7 +3,7 @@ import tls from "node:tls" import { CONSTANTS as MAIN_CONSTANTS } from "../../constants/constants.js" import logger from "../../logger/index.js" -import { asyncLoggerContextStore, createStore, updateStoreContext } from "../../logger/async_store.js" +import { asyncLoggerContextStore, updateStoreContext } from "../../logger/async_store.js" import BaseProtocolProcessor from "./base.js" import { APIs, detectAPIType } from "../APIs.js" @@ -176,11 +176,17 @@ class TcpProtocol extends BaseProtocolProcessor { this.prepareSocketsListeners() return new Promise((resolve) => { - this.tcpSocketServer = net.createServer((socket) => { - asyncLoggerContextStore.run(this.requestCreateStoreContext(socket), () => { - this.onOpen(socket) - }) - }) + this.tcpSocketServer = net.createServer( + { + keepAlive: true, + keepAliveInitialDelay: 5_000, + }, + (socket) => { + asyncLoggerContextStore.run(this.requestCreateStoreContext(socket), () => { + this.onOpen(socket) + }) + } + ) this.tcpSocketServer.listen(tcpOptions.port, () => { logger.debug("[TCP] listening on port %s", tcpOptions.port) diff --git a/app/networking/protocol_processors/ws.js b/app/networking/protocol_processors/ws.js index 29f213e9..68999893 100644 --- a/app/networking/protocol_processors/ws.js +++ b/app/networking/protocol_processors/ws.js @@ -1,8 +1,7 @@ import uWS from "uWebSockets.js" -import { CONSTANTS as MAIN_CONSTANTS } from "../../constants/constants.js" import logger from "../../logger/index.js" -import { asyncLoggerContextStore, createStore } from "../../logger/async_store.js" +import { asyncLoggerContextStore } from "../../logger/async_store.js" import BaseProtocolProcessor from "./base.js" import { wsSafeSend } from "../../utils/sockets-utils.js" diff --git a/app/providers/index.js b/app/providers/index.js index ba84305a..6e7ca80b 100644 --- a/app/providers/index.js +++ b/app/providers/index.js @@ -33,6 +33,8 @@ import UserServiceProvider from "./services/user/Provider.js" import BlockListServiceProvider from "./services/block_list/Provider.js" import StorageServiceProvider from "./services/storage/Provider.js" import OperationsLogServiceProvider from "./services/operation_logs/Provider.js" +import ActivityManagerStandaloneServiceProvider from "./services/activity_manager/activity_manager_standalone/Provider.js" +import ActivityManagerClusterServiceProvider from "./services/activity_manager/activity_manager_cluster/Provider.js" import ActivityManagerServiceProvider from "./services/activity_manager/Provider.js" import ConversationServiceProvider from "./services/conversation/Provider.js" import ConversationHandlerServiceProvider from "./services/conversation_handler/Provider.js" @@ -170,6 +172,8 @@ const providers = [ BlockListServiceProvider, StorageServiceProvider, OperationsLogServiceProvider, + ActivityManagerStandaloneServiceProvider, + ActivityManagerClusterServiceProvider, ActivityManagerServiceProvider, ConversationServiceProvider, ConversationHandlerServiceProvider, diff --git a/app/providers/operations/activity/retrieve/index.js b/app/providers/operations/activity/retrieve/index.js index 27a7c167..e2d53fb2 100644 --- a/app/providers/operations/activity/retrieve/index.js +++ b/app/providers/operations/activity/retrieve/index.js @@ -15,7 +15,7 @@ class ActivityUserRetrieveOperation { for (const targetUser of targetUsers) { const userId = targetUser.native_id - const devicesList = await this.sessionService.listUserDevice(organizationId, userId) + const devicesList = await this.sessionService.listUserDevice(organizationId, userId, true) const isUserOnline = !!devicesList?.length diff --git a/app/providers/operations/activity/subscribe/index.js b/app/providers/operations/activity/subscribe/index.js index 7b0d49ab..b8b43a48 100644 --- a/app/providers/operations/activity/subscribe/index.js +++ b/app/providers/operations/activity/subscribe/index.js @@ -22,7 +22,7 @@ class ActivityUserSubscribeOperation { await this.activityManagerService.subscribeObserverToTarget(currentUserId, targetUserId) let targetUserActivityStatus = null - const devicesList = await this.sessionService.listUserDevice(organizationId, targetUserId) + const devicesList = await this.sessionService.listUserDevice(organizationId, targetUserId, true) const isUserOnline = !!devicesList?.length if (isUserOnline) { diff --git a/app/providers/operations/http/activity/online_list/index.js b/app/providers/operations/http/activity/online_list/index.js index 8e451227..706f248c 100644 --- a/app/providers/operations/http/activity/online_list/index.js +++ b/app/providers/operations/http/activity/online_list/index.js @@ -6,12 +6,12 @@ class HttpActivityOnlineListOperation { this.onlineListOperation = onlineListOperation } - async perform(fakeWsSessionKey, payload) { + async perform(res, payload) { const { organizationId, userId, ...requestData } = payload - this.sessionService.addUserDeviceConnection(fakeWsSessionKey, organizationId, userId, MAIN_CONSTANTS.HTTP_DEVICE_ID) + this.sessionService.addUserDeviceConnection(res, organizationId, userId, MAIN_CONSTANTS.HTTP_DEVICE_ID) - const operationResponse = await this.onlineListOperation.perform(fakeWsSessionKey, requestData) + const operationResponse = await this.onlineListOperation.perform(res, requestData) return operationResponse } diff --git a/app/providers/operations/http/message/create/index.js b/app/providers/operations/http/message/create/index.js index fc3e6125..5a9df243 100644 --- a/app/providers/operations/http/message/create/index.js +++ b/app/providers/operations/http/message/create/index.js @@ -6,12 +6,12 @@ class HttpMessageCreateOperation { this.messageCreateOperation = messageCreateOperation } - async perform(fakeWsSessionKey, payload) { + async perform(res, payload) { const { organizationId, senderId, message: messageParams } = payload - this.sessionService.addUserDeviceConnection(fakeWsSessionKey, organizationId, senderId, MAIN_CONSTANTS.HTTP_DEVICE_ID) + this.sessionService.addUserDeviceConnection(res, organizationId, senderId, MAIN_CONSTANTS.HTTP_DEVICE_ID) - const operationResponse = await this.messageCreateOperation.perform(fakeWsSessionKey, messageParams) + const operationResponse = await this.messageCreateOperation.perform(res, messageParams) return operationResponse } diff --git a/app/providers/operations/http/message/delete/index.js b/app/providers/operations/http/message/delete/index.js index e10d6da7..1817378f 100644 --- a/app/providers/operations/http/message/delete/index.js +++ b/app/providers/operations/http/message/delete/index.js @@ -6,12 +6,12 @@ class HttpMessageDeleteOperation { this.messageDeleteOperation = messageDeleteOperation } - async perform(fakeWsSessionKey, payload) { + async perform(res, payload) { const { organizationId, senderId, messageDelete: messageDeleteParams } = payload - this.sessionService.addUserDeviceConnection(fakeWsSessionKey, organizationId, senderId, MAIN_CONSTANTS.HTTP_DEVICE_ID) + this.sessionService.addUserDeviceConnection(res, organizationId, senderId, MAIN_CONSTANTS.HTTP_DEVICE_ID) - const operationResponse = await this.messageDeleteOperation.perform(fakeWsSessionKey, messageDeleteParams) + const operationResponse = await this.messageDeleteOperation.perform(res, messageDeleteParams) return operationResponse } diff --git a/app/providers/operations/http/message/edit/index.js b/app/providers/operations/http/message/edit/index.js index c3197c97..255f242a 100644 --- a/app/providers/operations/http/message/edit/index.js +++ b/app/providers/operations/http/message/edit/index.js @@ -6,12 +6,12 @@ class HttpMessageEditOperation { this.messageEditOperation = messageEditOperation } - async perform(fakeWsSessionKey, payload) { + async perform(res, payload) { const { organizationId, senderId, messageEdit: messageEditParams } = payload - this.sessionService.addUserDeviceConnection(fakeWsSessionKey, organizationId, senderId, MAIN_CONSTANTS.HTTP_DEVICE_ID) + this.sessionService.addUserDeviceConnection(res, organizationId, senderId, MAIN_CONSTANTS.HTTP_DEVICE_ID) - const operationResponse = await this.messageEditOperation.perform(fakeWsSessionKey, messageEditParams) + const operationResponse = await this.messageEditOperation.perform(res, messageEditParams) return operationResponse } diff --git a/app/providers/operations/http/message/reaction/index.js b/app/providers/operations/http/message/reaction/index.js index 355fd7d4..0e330dbe 100644 --- a/app/providers/operations/http/message/reaction/index.js +++ b/app/providers/operations/http/message/reaction/index.js @@ -6,12 +6,12 @@ class HttpMessageReactionOperation { this.messageReactionsUpdateOperation = messageReactionsUpdateOperation } - async perform(fakeWsSessionKey, payload) { + async perform(res, payload) { const { organizationId, senderId, messageReaction: messageReaction } = payload - this.sessionService.addUserDeviceConnection(fakeWsSessionKey, organizationId, senderId, MAIN_CONSTANTS.HTTP_DEVICE_ID) + this.sessionService.addUserDeviceConnection(res, organizationId, senderId, MAIN_CONSTANTS.HTTP_DEVICE_ID) - const operationResponse = await this.messageReactionsUpdateOperation.perform(fakeWsSessionKey, messageReaction) + const operationResponse = await this.messageReactionsUpdateOperation.perform(res, messageReaction) return operationResponse } diff --git a/app/providers/operations/http/message/read/index.js b/app/providers/operations/http/message/read/index.js index d779834f..92b91205 100644 --- a/app/providers/operations/http/message/read/index.js +++ b/app/providers/operations/http/message/read/index.js @@ -6,12 +6,12 @@ class HttpMessageReadOperation { this.messageReadOperation = messageReadOperation } - async perform(fakeWsSessionKey, payload) { + async perform(res, payload) { const { organizationId, senderId, messageRead: messageReadParams } = payload - this.sessionService.addUserDeviceConnection(fakeWsSessionKey, organizationId, senderId, MAIN_CONSTANTS.HTTP_DEVICE_ID) + this.sessionService.addUserDeviceConnection(res, organizationId, senderId, MAIN_CONSTANTS.HTTP_DEVICE_ID) - const operationResponse = await this.messageReadOperation.perform(fakeWsSessionKey, messageReadParams) + const operationResponse = await this.messageReadOperation.perform(res, messageReadParams) return operationResponse } diff --git a/app/providers/operations/http/message/system/index.js b/app/providers/operations/http/message/system/index.js index 91180926..b2e0ab8f 100644 --- a/app/providers/operations/http/message/system/index.js +++ b/app/providers/operations/http/message/system/index.js @@ -6,12 +6,12 @@ class HttpMessageSendSystemOperation { this.messageSendSystemOperation = messageSendSystemOperation } - async perform(fakeWsSessionKey, payload) { + async perform(res, payload) { const { organizationId, senderId, messageSystem: systemMessageParams } = payload - this.sessionService.addUserDeviceConnection(fakeWsSessionKey, organizationId, senderId, MAIN_CONSTANTS.HTTP_DEVICE_ID) + this.sessionService.addUserDeviceConnection(res, organizationId, senderId, MAIN_CONSTANTS.HTTP_DEVICE_ID) - const operationResponse = await this.messageSendSystemOperation.perform(fakeWsSessionKey, systemMessageParams) + const operationResponse = await this.messageSendSystemOperation.perform(res, systemMessageParams) return operationResponse } diff --git a/app/providers/operations/http/organization/create/index.js b/app/providers/operations/http/organization/create/index.js index 66e9d9fd..3ff4c106 100644 --- a/app/providers/operations/http/organization/create/index.js +++ b/app/providers/operations/http/organization/create/index.js @@ -5,7 +5,7 @@ class HttpOrganizationCreateOperation { this.organizationService = organizationService } - async perform(fakeWsSessionKey, payload) { + async perform(res, payload) { const organizationCreateDTO = new RequestOrganizationCreateDTO(payload) const organization = await this.organizationService.create(organizationCreateDTO) diff --git a/app/providers/operations/http/stats/collect/index.js b/app/providers/operations/http/stats/collect/index.js index a205ce1b..e16800e3 100644 --- a/app/providers/operations/http/stats/collect/index.js +++ b/app/providers/operations/http/stats/collect/index.js @@ -3,7 +3,7 @@ class HttpStatsCollectOperation { this.statsService = statsService } - async perform(fakeWsSessionKey, payload) { + async perform(res, payload) { const needFormat = payload?.get("format") const stats = await this.statsService.collectStats(needFormat) diff --git a/app/providers/operations/http/user/auth/index.js b/app/providers/operations/http/user/auth/index.js index d19c9cdb..17a8e586 100644 --- a/app/providers/operations/http/user/auth/index.js +++ b/app/providers/operations/http/user/auth/index.js @@ -7,7 +7,7 @@ class HttpUserAuthOperation { this.userAuthOperation = userAuthOperation } - async perform(fakeWsSessionKey, headers, cookies, payload) { + async perform(res, headers, cookies, payload) { const refreshToken = cookies["refresh_token"] const accessToken = this.helpers.extractAccessTokenFromAuthHeader(headers["authorization"]) @@ -30,7 +30,7 @@ class HttpUserAuthOperation { }) } - const { user, token: newAccessToken } = await this.userAuthOperation.perform(fakeWsSessionKey, userInfo, true) + const { user, token: newAccessToken } = await this.userAuthOperation.perform(res, userInfo, true) const newRefreshToken = await this.userAuthOperation.createRefreshToken(user, device_id) const accessTokenExpiredAt = new Date(newAccessToken.updated_at).getTime() + this.config.get("jwt.access.expiresIn") * 1000 diff --git a/app/providers/operations/http/user/logout/index.js b/app/providers/operations/http/user/logout/index.js index 8e44a99b..8f8fda61 100644 --- a/app/providers/operations/http/user/logout/index.js +++ b/app/providers/operations/http/user/logout/index.js @@ -8,7 +8,7 @@ class HttpUserLogoutOperation { this.userLogoutOperation = userLogoutOperation } - async perform(fakeWsSessionKey, headers, cookies) { + async perform(res, headers, cookies) { const refreshToken = cookies["refresh_token"] const accessToken = this.helpers.extractAccessTokenFromAuthHeader(headers["authorization"]) @@ -29,13 +29,17 @@ class HttpUserLogoutOperation { const { user_id: userId, device_id: deviceId } = refreshTokenRecord const socket = this.sessionService.getUserDevices(userId).find((el) => el.deviceId === deviceId)?.socket + const userDevices = await this.sessionService.listUserDevice(refreshTokenRecord.organization_id, refreshTokenRecord.user_id, true) + let isWasLastUserSession = !userDevices?.length + if (socket) { - await this.userLogoutOperation.perform(socket) + const logoutResult = await this.userLogoutOperation.perform(socket) + isWasLastUserSession = logoutResult.isWasLastUserSession } else { await this.userTokenRepo.deleteByUserId(userId, deviceId) } - return refreshTokenRecord + return { refreshTokenRecord, isWasLastUserSession } } } diff --git a/app/providers/operations/user/auth/Provider.js b/app/providers/operations/user/auth/Provider.js index f5c35f3a..c1abd472 100644 --- a/app/providers/operations/user/auth/Provider.js +++ b/app/providers/operations/user/auth/Provider.js @@ -9,8 +9,9 @@ class UserAuthOperationRegisterProvider extends RegisterProvider { const sessionService = slc.use("SessionService") const userService = slc.use("UserService") const userTokenRepo = slc.use("UserTokenRepository") + const organizationService = slc.use("OrganizationService") - return new UserAuthOperation(config, sessionService, userService, userTokenRepo) + return new UserAuthOperation(config, sessionService, userService, userTokenRepo, organizationService) } } diff --git a/app/providers/operations/user/auth/index.js b/app/providers/operations/user/auth/index.js index 0650d9f0..21b561e3 100644 --- a/app/providers/operations/user/auth/index.js +++ b/app/providers/operations/user/auth/index.js @@ -3,11 +3,12 @@ import jwt from "jsonwebtoken" import { ERROR_STATUES } from "../../../../constants/errors.js" class UserAuthOperation { - constructor(config, sessionService, userService, userTokenRepo) { + constructor(config, sessionService, userService, userTokenRepo, organizationService) { this.config = config this.sessionService = sessionService this.userService = userService this.userTokenRepo = userTokenRepo + this.organizationService = organizationService } async perform(ws, userInfo, omitDeviceConnection) { @@ -18,9 +19,20 @@ class UserAuthOperation { ? await this.#authByToken(userInfo.token, deviceId) : await this.#authByUserInfo(organizationId, userInfo, deviceId) + await this.#validateOrganization(user) + // TODO: close connections if (!omitDeviceConnection) { - const wsToClose = this.sessionService.addUserDeviceConnection(ws, user.organization_id, user.native_id, deviceId) + const { sameDeviceConnection, sameSocketConnection } = this.sessionService.addUserDeviceConnection( + ws, + user.organization_id, + user.native_id, + deviceId + ) + await this.sessionService.storeUserNodeData(ws, user.organization_id, user.native_id, deviceId) + if (sameSocketConnection) { + await this.sessionService.removeAllUserDeviceData(user.organization_id, user.native_id, sameSocketConnection.deviceId) + } } const jwtAccessToken = this.#generateToken( @@ -32,8 +44,6 @@ class UserAuthOperation { const updatedToken = await this.userTokenRepo.updateToken(token, organizationId, user.native_id, deviceId, jwtAccessToken, "access") - await this.sessionService.storeUserNodeData(ws, user.organization_id, user.native_id, deviceId) - const userWithAvatarUrl = (await this.userService.addAvatarUrl([user])).at(0) return { user: userWithAvatarUrl, token: updatedToken } @@ -81,6 +91,16 @@ class UserAuthOperation { return { user, token } } + async #validateOrganization(user) { + const blockStatus = await this.organizationService.isUserFromBlocked(user) + + if (blockStatus.isBlocked) { + throw new Error(ERROR_STATUES.ORGANIZATION_BLOCKED.message, { + cause: Object.assign({}, blockStatus, ERROR_STATUES.ORGANIZATION_BLOCKED), + }) + } + } + async createRefreshToken(user, deviceId) { const jwtToken = this.#generateToken(user, "refresh", this.config.get("jwt.refresh.secret"), +this.config.get("jwt.refresh.expiresIn")) diff --git a/app/providers/operations/user/connect/index.js b/app/providers/operations/user/connect/index.js index 75e8525f..c030cb42 100644 --- a/app/providers/operations/user/connect/index.js +++ b/app/providers/operations/user/connect/index.js @@ -21,9 +21,16 @@ class UserConnectSocketOperation { const user = await this.userService.userRepo.findById(token.user_id) // TODO: close connections - this.sessionService.addUserDeviceConnection(ws, user.organization_id, user.native_id, deviceId) - + const { sameDeviceConnection, sameSocketConnection } = this.sessionService.addUserDeviceConnection( + ws, + user.organization_id, + user.native_id, + deviceId + ) await this.sessionService.storeUserNodeData(ws, user.organization_id, user.native_id, deviceId) + if (sameSocketConnection) { + await this.sessionService.removeAllUserDeviceData(user.organization_id, user.native_id, sameSocketConnection.deviceId) + } return { user } } diff --git a/app/providers/operations/user/create/Provider.js b/app/providers/operations/user/create/Provider.js index 32e4c220..323d9eac 100644 --- a/app/providers/operations/user/create/Provider.js +++ b/app/providers/operations/user/create/Provider.js @@ -5,11 +5,11 @@ const name = "UserCreateOperation" class UserCreateOperationRegisterProvider extends RegisterProvider { register(slc) { - const orgService = slc.use("OrganizationService") + const organizationService = slc.use("OrganizationService") const userService = slc.use("UserService") const contactService = slc.use("ContactService") - return new UserCreateOperation(orgService, userService, contactService) + return new UserCreateOperation(organizationService, userService, contactService) } } diff --git a/app/providers/operations/user/create/index.js b/app/providers/operations/user/create/index.js index a5789939..b76dcb10 100644 --- a/app/providers/operations/user/create/index.js +++ b/app/providers/operations/user/create/index.js @@ -1,14 +1,14 @@ import { ERROR_STATUES } from "../../../../constants/errors.js" class UserCreateOperation { - constructor(orgService, userService, contactsMatchRepository) { - this.orgService = orgService + constructor(organizationService, userService, contactsMatchRepository) { + this.organizationService = organizationService this.userService = userService this.contactsMatchRepository = contactsMatchRepository } async perform(createUserParams) { - const isOrgExist = await this.orgService.isExist(createUserParams.organization_id) + const isOrgExist = await this.organizationService.isExist(createUserParams.organization_id) if (!isOrgExist) { throw new Error(ERROR_STATUES.ORG_NOT_FOUND.message, { @@ -16,6 +16,14 @@ class UserCreateOperation { }) } + const blockStatus = await this.organizationService.isUserFromBlocked(createUserParams) + + if (blockStatus.isBlocked) { + throw new Error(ERROR_STATUES.ORGANIZATION_BLOCKED.message, { + cause: Object.assign({}, blockStatus, ERROR_STATUES.ORGANIZATION_BLOCKED), + }) + } + const existingUser = await this.userService.userRepo.findRegistered( createUserParams.organization_id, createUserParams.login, diff --git a/app/providers/operations/user/logout/index.js b/app/providers/operations/user/logout/index.js index 7e4fa99a..47ba7761 100644 --- a/app/providers/operations/user/logout/index.js +++ b/app/providers/operations/user/logout/index.js @@ -20,11 +20,11 @@ class UserLogoutOperation { await this.encryptionRepo.removeByDeviceId(userId, deviceId) - await this.sessionService.removeUserSession(ws, userId, deviceId) + const isWasLastUserSession = await this.sessionService.removeUserSession(ws, userId, deviceId) await this.userTokenRepo.deleteByUserId(userId, deviceId) - return { organizationId, userId } + return { organizationId, userId, isWasLastUserSession } } } diff --git a/app/providers/services/activity_manager/Provider.js b/app/providers/services/activity_manager/Provider.js index 4a2b5ed3..9ff79a7d 100644 --- a/app/providers/services/activity_manager/Provider.js +++ b/app/providers/services/activity_manager/Provider.js @@ -1,16 +1,13 @@ import RegisterProvider from "../../../common/RegisterProvider.js" -import ActivityManagerService from "./index.js" - -import { ACTIVITY } from "../../../store/activity.js" const name = "ActivityManagerService" class ActivityManagerServiceRegisterProvider extends RegisterProvider { register(slc) { - const userService = slc.use("UserService") + const config = slc.use("Config") - return new ActivityManagerService(ACTIVITY, userService) + return config.get("app.isStandAloneNode") ? slc.use("ActivityManagerStandaloneService") : slc.use("ActivityManagerClusterService") } } -export default new ActivityManagerServiceRegisterProvider({ name, implementationName: ActivityManagerService.name }) +export default new ActivityManagerServiceRegisterProvider({ name, implementationName: name }) diff --git a/app/providers/services/activity_manager/activity_manager_cluster/Provider.js b/app/providers/services/activity_manager/activity_manager_cluster/Provider.js new file mode 100644 index 00000000..97b1d639 --- /dev/null +++ b/app/providers/services/activity_manager/activity_manager_cluster/Provider.js @@ -0,0 +1,15 @@ +import RegisterProvider from "../../../../common/RegisterProvider.js" +import ActivityManagerClusterService from "./index.js" + +const name = "ActivityManagerClusterService" + +class ActivityManagerClusterServiceRegisterProvider extends RegisterProvider { + register(slc) { + const redisClient = slc.use("RedisClient") + const userService = slc.use("UserService") + + return new ActivityManagerClusterService(redisClient, userService) + } +} + +export default new ActivityManagerClusterServiceRegisterProvider({ name, implementationName: ActivityManagerClusterService.name }) diff --git a/app/providers/services/activity_manager/activity_manager_cluster/index.js b/app/providers/services/activity_manager/activity_manager_cluster/index.js new file mode 100644 index 00000000..b370b782 --- /dev/null +++ b/app/providers/services/activity_manager/activity_manager_cluster/index.js @@ -0,0 +1,125 @@ +import { CONSTANTS as MAIN_CONSTANTS } from "../../../../constants/constants.js" + +/* + Structs: + HSET - sama:subscribed_to -> [observerId] - targetId + HSET - sama:subscribers -> [targetId] -> observerId1,observerId2,observerId3 +*/ + +const SUBSCRIBED_TO_KEY = "sama:subscribed_to" +const SUBSCRIBERS_KEY = "sama:subscribers" + +class ActivityManagerClusterService { + constructor(redisConnection, userService) { + this.redisConnection = redisConnection + this.userService = userService + } + + serializeUserId(userId) { + return `${userId}` + } + + deserializeUserId(userId) { + return userId && `${userId}` + } + + // observer - targets relations + async subscribeTarget(observerId) { + const targetId = await this.redisConnection.client.hGet(SUBSCRIBED_TO_KEY, this.serializeUserId(observerId)) + + return targetId ? this.deserializeUserId(targetId) : void 0 + } + + async addSubscribeTarget(observerId, targetId) { + await this.redisConnection.client.hSet(SUBSCRIBED_TO_KEY, this.serializeUserId(observerId), this.serializeUserId(targetId)) + } + + async deleteSubscribeTarget(observerId, targetId) { + await this.redisConnection.client.hDel(SUBSCRIBED_TO_KEY, this.serializeUserId(observerId)) + } + + async clearAllSubscriptionTargets(observerId) { + await this.redisConnection.client.hDel(SUBSCRIBED_TO_KEY, this.serializeUserId(observerId)) + } + + // target - observer relations + async subscribers(targetId) { + const observersIds = await this.redisConnection.client.hGet(SUBSCRIBERS_KEY, this.serializeUserId(targetId)) + + return observersIds ? observersIds.split(",").map((userId) => this.deserializeUserId(userId)) : [] + } + + async addSubscriber(targetId, observerId) { + const observersIdsArr = await this.subscribers(targetId) + + const observersIds = new Set(observersIdsArr) + + observersIds.add(this.deserializeUserId(observerId)) + + const newObserversIds = Array.from(observersIds).join(",") + + await this.redisConnection.client.hSet(SUBSCRIBERS_KEY, this.serializeUserId(targetId), newObserversIds) + } + + async deleteSubscriber(targetId, observerId) { + const observersIdsArr = await this.subscribers(targetId) + + const observersIds = new Set(observersIdsArr) + + observersIds.delete(this.deserializeUserId(observerId)) + + if (!observersIds.size) { + await this.clearSubscribed(targetId) + return + } + + const newObserversIds = Array.from(observersIds).join(",") + + await this.redisConnection.client.hSet(SUBSCRIBERS_KEY, this.serializeUserId(targetId), newObserversIds) + } + + async clearSubscribed(targetId) { + await this.redisConnection.client.hDel(SUBSCRIBERS_KEY, this.serializeUserId(targetId)) + } + + async subscribeObserverToTarget(observerId, targetId) { + await this.unsubscribeObserver(observerId) + + await this.addSubscribeTarget(observerId, targetId) + await this.addSubscriber(targetId, observerId) + } + + async unsubscribeObserver(observerId) { + const targetId = await this.subscribeTarget(observerId) + + await this.deleteSubscribeTarget(observerId) + + if (targetId) { + await this.deleteSubscriber(targetId, observerId) + } + } + + async updateUserActivity(userId, status) { + const currentTime = Math.round(new Date() / 1000) + + await this.userService.updateActivity(userId, currentTime) + + if (status !== MAIN_CONSTANTS.LAST_ACTIVITY_STATUS.ONLINE) { + await this.unsubscribeObserver(userId) + } + + const activitySubscribers = await this.subscribers(userId) + + if (!activitySubscribers.length) { + return + } + + return { + subscribers: activitySubscribers, + targetUserId: userId, + activityStatus: { timestamp: currentTime, status }, + } + } +} + +export default ActivityManagerClusterService diff --git a/app/providers/services/activity_manager/activity_manager_standalone/Provider.js b/app/providers/services/activity_manager/activity_manager_standalone/Provider.js new file mode 100644 index 00000000..6a389cc3 --- /dev/null +++ b/app/providers/services/activity_manager/activity_manager_standalone/Provider.js @@ -0,0 +1,16 @@ +import RegisterProvider from "../../../../common/RegisterProvider.js" +import ActivityManagerStandaloneService from "./index.js" + +import { ACTIVITY } from "../../../../store/activity.js" + +const name = "ActivityManagerStandaloneService" + +class ActivityManagerStandaloneServiceRegisterProvider extends RegisterProvider { + register(slc) { + const userService = slc.use("UserService") + + return new ActivityManagerStandaloneService(ACTIVITY, userService) + } +} + +export default new ActivityManagerStandaloneServiceRegisterProvider({ name, implementationName: ActivityManagerStandaloneService.name }) diff --git a/app/providers/services/activity_manager/index.js b/app/providers/services/activity_manager/activity_manager_standalone/index.js similarity index 84% rename from app/providers/services/activity_manager/index.js rename to app/providers/services/activity_manager/activity_manager_standalone/index.js index c314bd82..c8ee3775 100644 --- a/app/providers/services/activity_manager/index.js +++ b/app/providers/services/activity_manager/activity_manager_standalone/index.js @@ -1,6 +1,6 @@ -import { CONSTANTS as MAIN_CONSTANTS } from "../../../constants/constants.js" +import { CONSTANTS as MAIN_CONSTANTS } from "../../../../constants/constants.js" -class ActivityManagerService { +class ActivityManagerStandaloneService { constructor(ACTIVITY, userService) { this.ACTIVITY = ACTIVITY this.userService = userService @@ -25,7 +25,7 @@ class ActivityManagerService { // target - observer relations subscribers(targetId) { - return this.ACTIVITY.SUBSCRIBERS[targetId] || {} + return Object.keys(this.ACTIVITY.SUBSCRIBERS[targetId] || {}) } addSubscriber(targetId, observerId) { @@ -63,13 +63,13 @@ class ActivityManagerService { async updateUserActivity(userId, status) { const currentTime = Math.round(new Date() / 1000) - if (status !== MAIN_CONSTANTS.LAST_ACTIVITY_STATUS.ONLINE) { - await this.userService.updateActivity(userId, currentTime) + await this.userService.updateActivity(userId, currentTime) + if (status !== MAIN_CONSTANTS.LAST_ACTIVITY_STATUS.ONLINE) { this.unsubscribeObserver(userId) } - const activitySubscribers = Object.keys(this.subscribers(userId)) + const activitySubscribers = this.subscribers(userId) if (!activitySubscribers.length) { return @@ -83,4 +83,4 @@ class ActivityManagerService { } } -export default ActivityManagerService +export default ActivityManagerStandaloneService diff --git a/app/providers/services/cluster_node/Provider.js b/app/providers/services/cluster_node/Provider.js index 9a65b09e..8ce6a22b 100644 --- a/app/providers/services/cluster_node/Provider.js +++ b/app/providers/services/cluster_node/Provider.js @@ -5,9 +5,10 @@ const name = "ClusterNodeService" class ClusterNodeServiceRegisterProvider extends RegisterProvider { register(slc) { - const clusterNodeRepo = slc.use("ClusterNodeRepository") + const config = slc.use("Config") + const redisClient = slc.use("RedisClient") - return new ClusterNodeService(clusterNodeRepo) + return new ClusterNodeService(config, redisClient) } } diff --git a/app/providers/services/cluster_node/index.js b/app/providers/services/cluster_node/index.js index 2f95e762..065b56f9 100644 --- a/app/providers/services/cluster_node/index.js +++ b/app/providers/services/cluster_node/index.js @@ -1,32 +1,52 @@ +import { CONSTANTS as MAIN_CONSTANTS } from "../../../constants/constants.js" +import { buildWsEndpoint } from "../../../utils/build_ws_endpoint.js" + class ClusterNodeService { - constructor(clusterNodeRepo) { - this.clusterNodeRepo = clusterNodeRepo + constructor(config, redisClient) { + this.config = config + this.redisClient = redisClient + } + + #nodeInfoKey(endpoint) { + return `${MAIN_CONSTANTS.REDIS_PREFIXES.NODE_DATA}:${endpoint}` } - async retrieveAll() { - const clusterNodes = await this.clusterNodeRepo.findAll({}) + async retrieveActive() { + const nodeKeys = await this.redisClient.findKeysByPattern(this.#nodeInfoKey("*")) - return clusterNodes + const nodesInfo = [] + + for (const nodeKey of nodeKeys) { + const nodeInfo = await this.redisClient.client.hGetAll(nodeKey) + nodesInfo.push(nodeInfo) + } + + const nodeEndpoints = nodesInfo.map((node) => buildWsEndpoint(node.ip_address, node.port)) + + const activeNodeEndpoints = new Set(nodeEndpoints) + + return activeNodeEndpoints } - async create(addressParams, optionalParams) { - const clusterNodeParams = Object.assign({}, addressParams, optionalParams) + async retrieveStored() { + const nodeKeys = await this.redisClient.findKeysByPattern(`${MAIN_CONSTANTS.REDIS_PREFIXES.NODE_USERS}:*`) - const clusterNode = await this.clusterNodeRepo.create(clusterNodeParams) + const nodeEndpoints = nodeKeys.map((nodeKey) => nodeKey.replace(`${MAIN_CONSTANTS.REDIS_PREFIXES.NODE_USERS}:`, "")) - return clusterNode + return new Set(nodeEndpoints) } async upsert(addressParams, optionalParams) { - const existedNode = await this.clusterNodeRepo.findOne(addressParams) + const currentNodeKey = this.#nodeInfoKey(this.config.get("ws.cluster.endpoint")) - if (existedNode) { - await this.clusterNodeRepo.updateStats(existedNode._id, optionalParams) + const keyValuePairs = Object.entries(Object.assign({}, addressParams, optionalParams)) + .flat() + .map((val) => `${val}`) - return - } + await this.redisClient.client.hSet(currentNodeKey, keyValuePairs) - await this.create(addressParams, optionalParams) + const expireSeconds = Math.round(this.config.get("ws.cluster.nodeExpiresIn") / 1_000) + 5 + await this.redisClient.client.expire(currentNodeKey, expireSeconds) } } diff --git a/app/providers/services/organization/index.js b/app/providers/services/organization/index.js index c6390b38..2805140e 100644 --- a/app/providers/services/organization/index.js +++ b/app/providers/services/organization/index.js @@ -14,6 +14,12 @@ class OrganizationService { return !!organization } + + async isUserFromBlocked(user) { + const organization = await this.organizationRepo.findById(user.organization_id) + + return { isBlocked: organization?.is_blocked, reason: organization?.block_reason } + } } export default OrganizationService diff --git a/app/providers/services/session/index.js b/app/providers/services/session/index.js index 1d14eefe..522c060f 100644 --- a/app/providers/services/session/index.js +++ b/app/providers/services/session/index.js @@ -1,11 +1,9 @@ -import { buildWsEndpoint } from "../../../utils/build_ws_endpoint.js" -import { splitWsEndpoint } from "../../../utils/split_ws_endpoint.js" import { CONSTANTS } from "../../../constants/constants.js" /* Structs: - SET - sama-node:{node-endpoint} -> {userId}:{deviceId} - SET - sama-user:{organizationId}:{userId} -> {deviceId} + SET - sama-node-users:{node-endpoint} -> {organizationId}:{userId}:{deviceId} + SET - sama-user-devices:{organizationId}:{userId} -> {deviceId} HASH - sama-user-data:{userId}:{deviceId} -> extra params */ @@ -22,76 +20,66 @@ class SessionService { } addUserDeviceConnection(socket, organizationId, userId, deviceId) { - const socketsToClose = [] + const activeConnections = this.getUserDevices(userId) - let activeConnections = this.getUserDevices(userId) - const filterNotSameSocket = activeConnections.filter(connection => connection.socket !== socket) - this.activeSessions.DEVICES[userId] = filterNotSameSocket - activeConnections = this.getUserDevices(userId) + const sameSocketConnection = activeConnections.find((connection) => connection.socket === socket) + const sameDeviceConnection = activeConnections.find((connection) => connection.deviceId === deviceId) + + const otherDeviceConnections = activeConnections.filter( + (connection) => connection !== sameSocketConnection && connection !== sameDeviceConnection + ) const connection = { socket: socket, deviceId, organizationId } - if (activeConnections) { - const otherDeviceConnections = activeConnections.filter((connection) => { - if (connection.deviceId !== deviceId) { - return true - } else { - socketsToClose.push(connection.socket) - return false - } - }) - this.activeSessions.DEVICES[userId] = [...otherDeviceConnections, connection] - } else { - this.activeSessions.DEVICES[userId] = [connection] - } + this.activeSessions.DEVICES[userId] = [...otherDeviceConnections, connection] this.setSessionUserId(socket, organizationId, userId, { [CONSTANTS.SESSION_DEVICE_ID_KEY]: deviceId }) - return socketsToClose + return { sameDeviceConnection, sameSocketConnection } } - #nodesSetCacheKey(nodeIp, nodePort, nodeEndpoint) { - return `sama-node:${nodeEndpoint ? nodeEndpoint : buildWsEndpoint(nodeIp, nodePort)}` + #nodesSetCacheKey(nodeEndpoint) { + return `${CONSTANTS.REDIS_PREFIXES.NODE_USERS}:${nodeEndpoint}` } - async addUserDeviceToNode(nodeIp, nodePort, userId, deviceId) { - const nodeKey = this.#nodesSetCacheKey(nodeIp, nodePort) - const userConnectionMember = `${userId}:${deviceId}` + async addUserDeviceToNode(nodeEndpoint, organizationId, userId, deviceId) { + const nodeKey = this.#nodesSetCacheKey(nodeEndpoint) + const userConnectionMember = `${organizationId}:${userId}:${deviceId}` await this.redisConnection.client.sAdd(nodeKey, userConnectionMember) } - async removeUserDeviceFromNode(nodeIp, nodePort, userId, deviceId) { - const nodeKey = this.#nodesSetCacheKey(nodeIp, nodePort) - const userConnectionMember = `${userId}:${deviceId}` + async removeUserDeviceFromNode(nodeEndpoint, organizationId, userId, deviceId) { + const nodeKey = this.#nodesSetCacheKey(nodeEndpoint) + const userConnectionMember = `${organizationId}:${userId}:${deviceId}` await this.redisConnection.client.sRem(nodeKey, userConnectionMember) } - async listNodeUserDevices(nodeIp, nodePort, nodeEndpoint) { - const nodeKey = this.#nodesSetCacheKey(nodeIp, nodePort, nodeEndpoint) + async listNodeUserDevices(nodeEndpoint) { + const nodeKey = this.#nodesSetCacheKey(nodeEndpoint) const usersConnections = await this.redisConnection.client.sMembers(nodeKey) const users = usersConnections.map((userConnection) => { - const [userId, deviceId] = userConnection.split(":") + const [organizationId, userId, deviceId] = userConnection.split(":") - return { userId, deviceId } + return { organizationId, userId, deviceId } }) return users } - async deleteNodeConnections(nodeIp, nodePort, nodeEndpoint) { - const nodeKey = this.#nodesSetCacheKey(nodeIp, nodePort, nodeEndpoint) + async deleteNodeConnections(nodeEndpoint) { + const nodeKey = this.#nodesSetCacheKey(nodeEndpoint) await this.redisConnection.client.del(nodeKey) } #usersSetCacheKey(organizationId, userId) { - return `sama-user:${organizationId}:${userId}` + return `${CONSTANTS.REDIS_PREFIXES.USER_DEVICES}:${organizationId}:${userId}` } #usersHashCacheKey(userId, deviceId) { - return `sama-user-data:${userId}:${deviceId}` + return `${CONSTANTS.REDIS_PREFIXES.USER_DATA}:${userId}:${deviceId}` } async addUserDevice(organizationId, userId, deviceId) { @@ -99,21 +87,60 @@ class SessionService { await this.redisConnection.client.sAdd(userKey, deviceId) } - async removeUserDevice(organizationId, userId, deviceId) { + async removeUserDevice(organizationId, userId, deviceId, returnIsLast) { const userKey = this.#usersSetCacheKey(organizationId, userId) await this.redisConnection.client.sRem(userKey, deviceId) + + if (!returnIsLast) { + return + } + + const leftUserDevices = await this.listUserDevice(organizationId, userId) + + return !leftUserDevices?.length } - async listUserDevice(organizationId, userId) { + async listUserDevice(organizationId, userId, filterActualDevices) { if (this.config.get("app.isStandAloneNode")) { return this.listUserDeviceLocal(userId) } const userKey = this.#usersSetCacheKey(organizationId, userId) - const deviceIds = await this.redisConnection.client.sMembers(userKey) - return deviceIds ?? [] + let deviceIds = await this.redisConnection.client.sMembers(userKey) + deviceIds = deviceIds ?? [] + + if (filterActualDevices) { + deviceIds = await this.filterActualDeviceIds(organizationId, userId, deviceIds) + } + + return deviceIds + } + + async filterActualDeviceIds(organizationId, userId, deviceIds) { + const actualDevices = new Set(deviceIds) + + for (const deviceId of deviceIds) { + const deviceExt = await this.retrieveUserExtraParams(userId, deviceId) + + const noEndpointKey = !deviceExt[CONSTANTS.SESSION_NODE_KEY] + const isSameEndpoint = deviceExt[CONSTANTS.SESSION_NODE_KEY] === this.config.get("ws.cluster.endpoint") + const hasDeviceConnection = this.getUserDevices(userId).find((connection) => connection.deviceId === deviceId) + const isHasExtButNoConnection = isSameEndpoint && !hasDeviceConnection + + if (noEndpointKey || isHasExtButNoConnection) { + actualDevices.delete(deviceId) + await this.removeAllUserDeviceDataTransaction(this.config.get("ws.cluster.endpoint"), organizationId, userId, deviceId) + this.logger.debug( + "[listUserDevice][not actual deviceId]: %o %o", + { organizationId, userId, deviceId }, + { noEndpointKey, isSameEndpoint, hasDeviceConnection } + ) + } + } + + return Array.from(actualDevices) } listUserDeviceLocal(userId) { @@ -134,7 +161,7 @@ class SessionService { .flat() .map((val) => `${val}`) - await this.redisConnection.client.hSet(userHashKey, ...keyValuePairs) + await this.redisConnection.client.hSet(userHashKey, keyValuePairs) } async retrieveUserExtraParams(userId, deviceId) { @@ -154,8 +181,11 @@ class SessionService { } async removeUserData(organizationId, userId, deviceId) { - await this.removeUserDevice(organizationId, userId, deviceId) + const isWasLastUserSession = await this.removeUserDevice(organizationId, userId, deviceId, true) + await this.deleteUserExtraParams(userId, deviceId) + + return isWasLastUserSession } async deleteUserData(organizationId, userId) { @@ -168,6 +198,41 @@ class SessionService { await this.deleteUserDevices(organizationId, userId) } + async removeAllUserDeviceDataTransaction(nodeEndpoint, organizationId, userId, deviceId) { + const userKey = this.#usersSetCacheKey(organizationId, userId) + const userHashKey = this.#usersHashCacheKey(userId, deviceId) + const nodeKey = this.#nodesSetCacheKey(nodeEndpoint ?? this.config.get("ws.cluster.endpoint")) + const userConnectionMember = `${organizationId}:${userId}:${deviceId}` + + const tx = this.redisConnection.client.multi() + + tx.sRem(userKey, deviceId) + tx.del(userHashKey) + tx.sRem(nodeKey, userConnectionMember) + + const multiResult = await tx.exec() + + this.logger.debug("[removeAllUserDeviceDataTransaction][result]: %o %o", { organizationId, userId, deviceId }, multiResult) + + return multiResult + } + + async removeAllUserDeviceData(organizationId, userId, deviceId) { + await this.removeUserDevice(organizationId, userId, deviceId) + const extraParams = await this.retrieveUserExtraParams(userId, deviceId) + await this.deleteUserExtraParams(userId, deviceId) + + const nodeEndpoint = extraParams?.[CONSTANTS.SESSION_NODE_KEY] ?? this.config.get("ws.cluster.endpoint") + + await this.removeUserDeviceFromNode(nodeEndpoint, organizationId, userId, deviceId) + + this.logger.debug("[removeAllUserDeviceData][removed] %o", { organizationId, userId, deviceId }) + + const leftDevices = await this.listUserDevice(organizationId, userId, true) + + return !leftDevices?.length + } + async listUserData(organizationId, userId) { const userData = {} @@ -193,10 +258,30 @@ class SessionService { return userData } - async storeUserNodeData(socket, organizationId, userId, deviceId, nodeIp, nodePort) { - nodeIp ??= this.config.get("app.ip") - nodePort ??= this.config.get("ws.cluster.port") - const nodeEndpoint = buildWsEndpoint(nodeIp, nodePort) + async addUserDeviceDataTransaction(nodeEndpoint, organizationId, userId, deviceId, extParams) { + const userKey = this.#usersSetCacheKey(organizationId, userId) + const userHashKey = this.#usersHashCacheKey(userId, deviceId) + const keyValuePairs = Object.entries(extParams) + .flat() + .map((val) => `${val}`) + const nodeKey = this.#nodesSetCacheKey(nodeEndpoint ?? this.config.get("ws.cluster.endpoint")) + const userConnectionMember = `${organizationId}:${userId}:${deviceId}` + + const tx = this.redisConnection.client.multi() + + tx.sAdd(userKey, deviceId) + tx.hSet(userHashKey, keyValuePairs) + tx.sAdd(nodeKey, userConnectionMember) + + const multiResult = await tx.exec() + + this.logger.debug("[addUserDeviceDataTransaction][result]: %o %o", { organizationId, userId, deviceId }, multiResult) + + return multiResult + } + + async storeUserNodeData(socket, organizationId, userId, deviceId) { + const nodeEndpoint = this.config.get("ws.cluster.endpoint") const session = this.getSession(socket) if (session?.extraParams) { @@ -208,25 +293,27 @@ class SessionService { const userDeviceIds = await this.listUserDevice(organizationId, userId) if (userDeviceIds.includes(deviceId)) { - await this.removeUserData(organizationId, userId, deviceId) - await this.removeUserDeviceFromNode(nodeIp, nodePort, userId, deviceId) + await this.removeAllUserDeviceData(organizationId, userId, deviceId) } - await this.addUserDevice(organizationId, userId, deviceId) - await this.addUserExtraParams(userId, deviceId, { [CONSTANTS.SESSION_NODE_KEY]: nodeEndpoint }) - await this.addUserDeviceToNode(nodeIp, nodePort, userId, deviceId) + await this.addUserDeviceDataTransaction(nodeEndpoint, organizationId, userId, deviceId, { [CONSTANTS.SESSION_NODE_KEY]: nodeEndpoint }) } - async clearNodeUsersSession(nodeUrl) { + async clearNodeUsersSession(nodeEndpoint) { if (this.config.get("app.isStandAloneNode")) return - const userConnections = await this.listNodeUserDevices(void 0, void 0, nodeUrl) + const lastUserSessions = [] - for (const { userId, deviceId } of userConnections) { - await this.removeUserData(null, userId, deviceId) + const userConnections = await this.listNodeUserDevices(nodeEndpoint) + + for (const userData of userConnections) { + const isLastUserSession = await this.removeUserData(userData.organizationId, userData.userId, userData.deviceId) + if (isLastUserSession) lastUserSessions.push(userData) } - await this.deleteNodeConnections(void 0, void 0, nodeUrl) + await this.deleteNodeConnections(nodeEndpoint) + + return lastUserSessions } setSessionUserId(socket, organizationId, userId, extraParams) { @@ -283,7 +370,7 @@ class SessionService { getDeviceId(socket, userId) { if (this.activeSessions.DEVICES[userId]) { - return this.activeSessions.DEVICES[userId].find((el) => el.socket === socket)?.deviceId + return this.activeSessions.DEVICES[userId].find((connection) => connection.socket === socket)?.deviceId } return null @@ -313,64 +400,47 @@ class SessionService { continue } - const [, nodeId, nodePort] = splitWsEndpoint(extraParams[CONSTANTS.SESSION_NODE_KEY]) - await this.removeUserDeviceFromNode(nodeId, nodePort, userId, deviceId) + const nodeEndpoint = extraParams[CONSTANTS.SESSION_NODE_KEY] + await this.removeUserDeviceFromNode(nodeEndpoint, organizationId, userId, deviceId) } await this.deleteUserData(organizationId, userId) } async removeUserSession(socket, userId, deviceId) { - this.logger.debug("[removeUserSession][args]: %o", { socket: socket?.isAlive, userId, deviceId }) - userId = userId ?? this.getSessionUserId(socket) deviceId = deviceId ?? this.getDeviceId(socket, userId) - const orgId = this.getSession(socket)?.organizationId + const organizationId = this.getSession(socket)?.organizationId - this.logger.debug("[removeUserSession][vars]: %o [session]: %o [device]: %s", { orgId, userId, deviceId }, this.getSession(socket), this.getDeviceId(socket, userId)) - - const devicesBefore = this.getUserDevices(userId).map((connection) => { - const { socket, ...connectionData } = connection - return { ...connectionData, socket: socket?.clientId } - }) - - this.logger.debug("[removeUserSession][devices][before]: %o %s", devicesBefore, devicesBefore?.length) + this.logger.debug("[removeUserSession][vars] %o", { organizationId, userId, deviceId }) const leftActiveConnections = this.getUserDevices(userId).filter(({ deviceId: activeDeviceId }) => activeDeviceId !== deviceId) + let isLastConnection = !leftActiveConnections?.length if (leftActiveConnections?.length) { this.activeSessions.DEVICES[userId] = leftActiveConnections } else { delete this.activeSessions.DEVICES[userId] } - this.activeSessions.SESSIONS.delete(socket) - - const devicesAfter = this.getUserDevices(userId).map((connection) => { - const { socket, ...connectionData } = connection - return { ...connectionData, socket: socket?.clientId } - }) - - this.logger.debug("[removeUserSession][devices][after]: %o %s", devicesAfter, devicesAfter?.length) + this.activeSessions.SESSIONS.delete(socket) if (!deviceId) { - return + return isLastConnection } - if (this.config.get("app.isStandAloneNode")) return + if (this.config.get("app.isStandAloneNode")) return isLastConnection - const extraParams = await this.retrieveUserExtraParams(userId, deviceId) + this.logger.debug( + "[removeUserSession][left connections] %o", + leftActiveConnections?.map((con) => con.deviceId) + ) - await this.removeUserDevice(orgId, userId, deviceId) - await this.deleteUserExtraParams(userId, deviceId) + isLastConnection = await this.removeAllUserDeviceData(organizationId, userId, deviceId) - const nodeEndpoint = extraParams?.[CONSTANTS.SESSION_NODE_KEY] - if (!nodeEndpoint) { - return - } + this.logger.debug("[removeUserSession][end] %o %s", { organizationId, userId, deviceId }, isLastConnection) - const [, nodeId, nodePort] = splitWsEndpoint(nodeEndpoint) - await this.removeUserDeviceFromNode(nodeId, nodePort, userId, deviceId) + return isLastConnection } async onlineUsersList(organizationId, offset = 0, limit = 10) { @@ -390,7 +460,17 @@ class SessionService { const userKeys = await this.redisConnection.scanWithPagination("set", matchPattern, offset, limit) - return userKeys.map((userKey) => userKey.split(":").at(-1)) + const userIds = userKeys.map((userKey) => userKey.split(":").at(-1)) + const actualUserIds = new Set(userIds) + + for (const userId of userIds) { + const actualDevices = await this.listUserDevice(organizationId, userId, true) + if (!actualDevices?.length) { + actualUserIds.delete(userId) + } + } + + return Array.from(actualUserIds) } async onlineUsersCountWithNodes(organizationId) { @@ -404,9 +484,7 @@ class SessionService { onlineUsersListLocal(organizationId, offset, limit) { const userIds = this.retrieveLocalActiveSessionUserIds(organizationId) - userIds.slice(offset, offset + limit) - - return userIds + return userIds.slice(offset, offset + limit) } onlineUsersCountLocal(organizationId) { @@ -419,9 +497,10 @@ class SessionService { const userIds = Array.from(this.activeSessions.SESSIONS.values()) .filter( (session) => - session?.organizationId === organizationId && + session?.organizationId?.toString() === organizationId?.toString() && session?.extraParams[CONSTANTS.SESSION_DEVICE_ID_KEY] !== CONSTANTS.HTTP_DEVICE_ID && - session?.userId && this.listUserDeviceLocal(session?.userId)?.length + session?.userId && + this.listUserDeviceLocal(session?.userId)?.length ) .map((session) => session.userId) .sort((userIdA, userIdB) => userIdA - userIdB) diff --git a/app/providers/services/stats/index.js b/app/providers/services/stats/index.js index fd20f857..06c54a0c 100644 --- a/app/providers/services/stats/index.js +++ b/app/providers/services/stats/index.js @@ -142,7 +142,7 @@ class StatsService { } collectStats(format, date = new Date()) { - const stats = {} + const stats = { hostname: this.config.get("app.hostName") } const serverStats = this.collectServerStats(format, date) const usersStats = this.collectUsersStats(format, date) diff --git a/app/utils/build_ws_endpoint.js b/app/utils/build_ws_endpoint.js index bca1cc91..b9b93a73 100644 --- a/app/utils/build_ws_endpoint.js +++ b/app/utils/build_ws_endpoint.js @@ -1,3 +1,5 @@ +import { URL } from "node:url" + import config from "@sama/config/index.js" -export const buildWsEndpoint = (ip, port) => `${config.get("ws.options.isSecure") ? "wss" : "ws"}://${ip}:${port}` +export const buildWsEndpoint = (ip, port) => new URL(`${config.get("ws.options.isSecure") ? "wss" : "ws"}://${ip}:${port}`).toString() diff --git a/app/utils/get_ip_from_ws_url.js b/app/utils/get_ip_from_ws_url.js deleted file mode 100644 index d29ffce3..00000000 --- a/app/utils/get_ip_from_ws_url.js +++ /dev/null @@ -1 +0,0 @@ -export const getIpFromWsUrl = (wsUrl) => wsUrl.split(":")[1].slice(2) diff --git a/app/utils/pass_through_mapper.js b/app/utils/pass_through_mapper.js deleted file mode 100644 index 8ab30ab4..00000000 --- a/app/utils/pass_through_mapper.js +++ /dev/null @@ -1,3 +0,0 @@ -const passThroughMapper = async (model) => model - -export default passThroughMapper diff --git a/app/utils/promise-queue-with-jittering.js b/app/utils/promise-queue-with-jittering.js new file mode 100644 index 00000000..748610f0 --- /dev/null +++ b/app/utils/promise-queue-with-jittering.js @@ -0,0 +1,67 @@ +import { setTimeout } from "node:timers/promises" + +import mainLogger from "../logger/index.js" + +const reconnectLogger = mainLogger.child("[Reconnect]") + +class CancelQueueError extends Error {} + +const promiseQueueWithJittering = (executablePromise, tryCount, delay) => { + let isCanceled = false + + const cancel = () => (isCanceled = true) + const checkIsCanceled = () => isCanceled + + const start = async () => { + if (isCanceled) { + throw new CancelQueueError("Canceled") + } + + const tryDelays = new Array(tryCount).fill(0).map((_, index) => { + const tryCount = index + 1 + const tryDelay = Math.pow(tryCount, 2) * delay + return tryDelay + }) + + let lastError = void 0 + + reconnectLogger.debug("[tryDelays] %j %s", tryDelays, isCanceled) + + for (const tryDelay of tryDelays) { + try { + reconnectLogger.debug("[try] %s %s", tryDelay, isCanceled) + + if (isCanceled) { + throw new CancelQueueError("Canceled") + } + + await setTimeout(tryDelay) + + if (isCanceled) { + throw new CancelQueueError("Canceled") + } + + const successResult = await executablePromise(checkIsCanceled) + + return successResult + } catch (error) { + lastError = error + if (error instanceof CancelQueueError) { + break + } + } + } + + if (lastError) { + throw lastError + } + + if (isCanceled) { + throw new CancelQueueError("Canceled") + } + } + + return { start, cancel } +} + +export { promiseQueueWithJittering, CancelQueueError } diff --git a/app/utils/sockets-utils.js b/app/utils/sockets-utils.js index 50d654b3..d8f50ad0 100644 --- a/app/utils/sockets-utils.js +++ b/app/utils/sockets-utils.js @@ -6,7 +6,7 @@ export async function tcpSafeSend(socket, data) { socket.write(data, (error) => (error ? reject(error) : resolve())) }) } catch (error) { - logger.error(error) + logger.error(error, "[tcp write]") } } @@ -14,7 +14,7 @@ export function wsSafeSend(ws, data) { try { ws.send(data) } catch (error) { - logger.error(error) + logger.error(error, "[ws send]") } } diff --git a/app/utils/split_ws_endpoint.js b/app/utils/split_ws_endpoint.js deleted file mode 100644 index 937e1e8c..00000000 --- a/app/utils/split_ws_endpoint.js +++ /dev/null @@ -1,5 +0,0 @@ -export const splitWsEndpoint = (url) => { - const regex = /^(.*?):\/\/(.*?)(?::([0-9]+))?(\/.*?)?$/ - const [, protocol, ip, port, path] = url.match(regex) - return [protocol, ip, port, path] -} diff --git a/app/utils/watchdog-ping-socket.js b/app/utils/watchdog-ping-socket.js index fa082064..ab9daa8d 100644 --- a/app/utils/watchdog-ping-socket.js +++ b/app/utils/watchdog-ping-socket.js @@ -1,5 +1,6 @@ import net from "node:net" import { APIs, BASE_API } from "../networking/APIs.js" +import { CONSTANTS } from "../constants/constants.js" export const watchdogPingSocket = async (logger, sessionService, onWsCloseCb, onTcpCloseCb) => { const users = Object.keys(sessionService.activeSessions.DEVICES) @@ -9,7 +10,7 @@ export const watchdogPingSocket = async (logger, sessionService, onWsCloseCb, on for (const userId of users) { const connections = sessionService.activeSessions.DEVICES[userId] ?? [] for (const connection of connections) { - if (!connection?.socket) { + if (!connection?.socket || connection?.deviceId === CONSTANTS.HTTP_DEVICE_ID) { continue } @@ -19,7 +20,7 @@ export const watchdogPingSocket = async (logger, sessionService, onWsCloseCb, on try { if (isTCP) { await new Promise((resolve, reject) => { - connection.socket?.write(pingPackage, (error) => error ? reject(error) : resolve()) + connection.socket?.write(pingPackage, (error) => (error ? reject(error) : resolve())) }) } else { connection.socket?.send(pingPackage) @@ -29,16 +30,18 @@ export const watchdogPingSocket = async (logger, sessionService, onWsCloseCb, on if (isTCP) { await onTcpCloseCb(connection?.socket) .then(() => logger.debug("[close tcp done] %s", userId)) - .catch(error => logger.error(error, "[close tcp error]")) + .catch((error) => logger.error(error, "[close tcp error]")) } else { await onWsCloseCb(connection?.socket, 10) .then(() => logger.debug("[close ws done] %s", userId)) - .catch(error => logger.error(error, "[close ws error]")) + .catch((error) => logger.error(error, "[close ws error]")) } - await sessionService.removeUserSession(connection?.socket, userId, connection?.deviceId).catch(error => logger.error(error, "[remove]")) + await sessionService + .removeUserSession(connection?.socket, userId, connection?.deviceId) + .catch((error) => logger.error(error, "[remove]")) } } } logger.debug("[finish]") -} \ No newline at end of file +} diff --git a/docker-compose-full.yml b/docker-compose-full.yml index e6c5fdee..a54b29ef 100644 --- a/docker-compose-full.yml +++ b/docker-compose-full.yml @@ -51,7 +51,7 @@ services: mongo: condition: service_healthy entrypoint: > - sh -c "npm run start" + sh -c 'if [ "$NODE_ENV" = "testing" ]; then tail -f /dev/null; else npm run start; fi' networks: sama_network: ipv4_address: 172.25.0.7 diff --git a/index.js b/index.js index ca0b0de2..2188f1e4 100644 --- a/index.js +++ b/index.js @@ -11,7 +11,6 @@ import RegisterProvider from "./app/common/RegisterProvider.js" import providers from "./app/providers/index.js" import clusterManager from "./app/cluster/cluster_manager.js" -import clusterSyncer from "./app/cluster/cluster_syncer.js" import WsProtocol from "./app/networking/protocol_processors/ws.js" import TcpProtocol from "./app/networking/protocol_processors/tcp.js" @@ -69,12 +68,6 @@ if (config.get("tcp.options.isTls")) { }) } -if (!config.get("app.isStandAloneNode")) { - const clusterPort = await clusterManager.createLocalSocket(uWSOptions) - config.set("ws.cluster.port", clusterPort) -} -config.set("ws.cluster.endpoint", buildWsEndpoint(config.get("app.ip"), config.get("ws.cluster.port"))) - logger.debug("[Config] %s", JSON.stringify(config.toObject(), null, 5)) // perform a database connection when the server starts @@ -167,8 +160,6 @@ for (const api of Object.values(APIs)) { } } -logger.debug("[Config][Merged] %s", JSON.stringify(config.toObject(), null, 5)) - // Boot providers logger.debug("[Boot]") await ServiceLocatorContainer.boot() @@ -177,11 +168,20 @@ logger.debug("[Create singleton]") await ServiceLocatorContainer.createAllSingletonInstances() if (!config.get("app.isStandAloneNode")) { + const clusterPort = await clusterManager.createLocalSocket(uWSOptions) + + config.set("ws.cluster.port", clusterPort) + config.set("ws.cluster.endpoint", buildWsEndpoint(config.get("app.ip"), config.get("ws.cluster.port"))) + // Start Cluster Sync logger.debug("[Start sync]") - await clusterSyncer.startSyncingClusterNodes() + await clusterManager.startSyncingClusterNodes(true) +} else { + config.set("ws.cluster.endpoint", buildWsEndpoint(config.get("app.ip"), config.get("ws.cluster.port"))) } +logger.debug("[Config][Merged] %s", JSON.stringify(config.toObject(), null, 5)) + // Start public protocols const sessionService = ServiceLocatorContainer.use("SessionService") const conversationService = ServiceLocatorContainer.use("ConversationService") @@ -218,3 +218,5 @@ await startReplServices( { socketHandler: config.get("repl.socket.handler") }, { fileIn: config.get("repl.file.in"), fileOut: config.get("repl.file.out") } ) + +logger.debug("[Ready] cluster-ws: %s", config.get("ws.cluster.endpoint")) diff --git a/package-lock.json b/package-lock.json index a366146f..03a5efd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,10 @@ "ws": "^8.12.0" }, "devDependencies": { + "@faker-js/faker": "^10.4.0", + "@sama-communications/sdk": "0.17.1", "c8": "^10.1.3", + "kill-port": "^2.0.1", "mocha": "^10.0.0", "nodemon": "2.0.19", "prettier": "3.2.5" @@ -1040,6 +1043,23 @@ "node": ">=0.1.90" } }, + "node_modules/@faker-js/faker": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.4.0.tgz", + "integrity": "sha512-sDBWI3yLy8EcDzgobvJTWq1MJYzAkQdpjXuPukga9wXonhpMRvd1Izuo2Qgwey2OiEoRIBr35RMU9HJRoOHzpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", + "npm": ">=10" + } + }, "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", @@ -1188,10 +1208,11 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", - "dev": true + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.20", @@ -1415,6 +1436,16 @@ "@redis/client": "^1.0.0" } }, + "node_modules/@sama-communications/sdk": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@sama-communications/sdk/-/sdk-0.17.1.tgz", + "integrity": "sha512-4BhHKh+Tafv2I94E46f52rZpyPOiihC+7XTRYWDAraXZrcActWmQo4/ng8DK6NcLyXWwq0EmOfCfnD2HSS0Zww==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "ws": "8.18.3" + } + }, "node_modules/@sebastianwessel/quickjs": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@sebastianwessel/quickjs/-/quickjs-2.1.1.tgz", @@ -2236,10 +2267,11 @@ "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==" }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -3017,6 +3049,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-them-args": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/get-them-args/-/get-them-args-1.3.2.tgz", + "integrity": "sha512-LRn8Jlk+DwZE4GTlDbT3Hikd1wSHgLMme/+7ddlqKd7ldwR6LjJgTVWzBnR01wnYGe4KgrXjg287RaI22UHmAw==", + "dev": true, + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", @@ -3144,9 +3183,10 @@ } }, "node_modules/hasown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", - "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, @@ -3556,6 +3596,20 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/kill-port": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/kill-port/-/kill-port-2.0.1.tgz", + "integrity": "sha512-e0SVOV5jFo0mx8r7bS29maVWp17qGqLBZ5ricNSajON6//kmb7qqqNnml4twNE8Dtj97UQD+gNFOaipS/q1zzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-them-args": "1.3.2", + "shell-exec": "1.0.2" + }, + "bin": { + "kill-port": "cli.js" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4619,6 +4673,13 @@ "node": ">=8" } }, + "node_modules/shell-exec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/shell-exec/-/shell-exec-1.0.2.tgz", + "integrity": "sha512-jyVd+kU2X+mWKMmGhx4fpWbPsjvD53k9ivqetutVW/BQ+WIZoDoP4d8vUMGezV6saZsiNoW2f9GIhg9Dondohg==", + "dev": true, + "license": "MIT" + }, "node_modules/simple-update-notifier": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz", @@ -4891,6 +4952,21 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -5088,9 +5164,10 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { - "version": "8.14.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", - "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index fe967cb3..ba0ce9c4 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "start": "node --experimental-loader ./sama-loader.mjs --import dotenv/config index.js", "start:dev": "nodemon --experimental-loader ./sama-loader.mjs --import dotenv/config index.js", "test": "mocha", + "test:cluster": "mocha --config ./.cluster-mocharc.json", + "test:cluster:clients": "mocha --config ./.cluster-clients-mocharc.json", "test:cover": "c8 npm run test" }, "repository": { @@ -50,7 +52,10 @@ "ws": "^8.12.0" }, "devDependencies": { + "@faker-js/faker": "^10.4.0", + "@sama-communications/sdk": "0.17.1", "c8": "^10.1.3", + "kill-port": "^2.0.1", "mocha": "^10.0.0", "nodemon": "2.0.19", "prettier": "3.2.5" diff --git a/test/activities.spec.js b/test/activities.spec.js index d983e3eb..77ba6191 100644 --- a/test/activities.spec.js +++ b/test/activities.spec.js @@ -1,7 +1,6 @@ import assert from "node:assert" import ServiceLocatorContainer from "../app/common/ServiceLocatorContainer.js" -import { ACTIVITY } from "../app/store/activity.js" import packetJsonProcessor from "../APIs/JSON/routes/packet_processor.js" import { generateNewOrganizationId, createUserArray, sendLogin, sendLogout } from "./tools/utils.js" @@ -18,8 +17,8 @@ describe("User activities", async () => { before(async () => { orgId = await generateNewOrganizationId() usersIds = await createUserArray(orgId, 3) - currentUserToken1 = (await sendLogin("line_2", orgId, "user_3")).response.user.token currentUserToken = (await sendLogin("line_1", orgId, "user_1")).response.user.token + currentUserToken1 = (await sendLogin("line_2", orgId, "user_3")).response.user.token }) it("should work online list invalid limit", async () => { @@ -97,8 +96,8 @@ describe("User activities", async () => { responseData = responseData.backMessages.at(0) assert.strictEqual(responseData.response.id, requestData.request.id) - assert.equal(activityManagerService.subscribeTarget(usersIds[0]), usersIds[1]) - assert.notEqual(activityManagerService.subscribers(usersIds[1])[usersIds[0]], undefined) + assert.equal(await activityManagerService.subscribeTarget(usersIds[0]), `${usersIds[1]}`) + assert.notEqual((await activityManagerService.subscribers(usersIds[1])).at(0), undefined) assert.notEqual(responseData.response.last_activity, undefined) }) @@ -125,7 +124,7 @@ describe("User activities", async () => { let requestData = { request: { user_last_activity_subscribe: { - id: usersIds[1], + id: `${usersIds[1]}`, }, id: "1", }, @@ -150,9 +149,12 @@ describe("User activities", async () => { assert.strictEqual(responseData.response.id, requestData.request.id) assert.strictEqual(responseData.response.success, true) - assert.equal(ACTIVITY.SUBSCRIBED_TO[usersIds[0]], undefined) - assert.notEqual(ACTIVITY.SUBSCRIBERS[usersIds[1]], undefined) - assert.equal(ACTIVITY.SUBSCRIBERS[usersIds[1]][usersIds[0]], undefined) + assert.equal(await activityManagerService.subscribeTarget(usersIds[0]), undefined) + assert.notEqual(await activityManagerService.subscribers(usersIds[1]), undefined) + assert.equal( + (await activityManagerService.subscribers(usersIds[1])).find((uId) => uId === `${usersIds[0]}`), + undefined + ) }) it("should work getUserStatus", async () => { @@ -188,8 +190,8 @@ describe("User activities", async () => { assert.strictEqual(responseData.response.id, requestData.request.id) assert.strictEqual(responseData.response.success, true) - assert.equal(ACTIVITY.SUBSCRIBED_TO[usersIds[2]], undefined) - assert.deepEqual(ACTIVITY.SUBSCRIBERS[usersIds[1]], {}) + assert.equal(await activityManagerService.subscribeTarget(usersIds[2]), undefined) + assert.equal((await activityManagerService.subscribers(usersIds[1])).length, 0) await sendLogout("line_2", currentUserToken1) }) diff --git a/test/cluster.spec.js b/test/cluster.spec.js deleted file mode 100644 index 892de390..00000000 --- a/test/cluster.spec.js +++ /dev/null @@ -1,113 +0,0 @@ -import ip from "ip" -import uWS from "uWebSockets.js" -import assert from "node:assert" - -import ServiceLocatorContainer from "../app/common/ServiceLocatorContainer.js" -import clusterManager from "../app/cluster/cluster_manager.js" -import { generateNewOrganizationId, createConversation, createUserArray, mockedWS, sendLogin } from "./tools/utils.js" -import packetJsonProcessor from "../APIs/JSON/routes/packet_processor.js" -import packetManager from "../app/networking/packet_manager.js" - -const config = ServiceLocatorContainer.use("Config") -const logger = ServiceLocatorContainer.use("Logger") -const sessionService = ServiceLocatorContainer.use("SessionService") -const userRepo = ServiceLocatorContainer.use("UserRepository") -const conversationRepo = ServiceLocatorContainer.use("ConversationRepository") -const conversationParticipantRepo = ServiceLocatorContainer.use("ConversationParticipantRepository") -const messageRepo = ServiceLocatorContainer.use("MessageRepository") -const messageStatusRepo = ServiceLocatorContainer.use("MessageStatusRepository") - -let orgId = void 0 -let currentConversationId = "" -let usersIds = [] -let deviceId = null -let secondClusterPort = null -let secondSocketResponse = null - -describe("Cluster Message function", async () => { - before(async () => { - orgId = await generateNewOrganizationId() - usersIds = await createUserArray(orgId, 2) - - await sendLogin(mockedWS, orgId, "user_1") - - currentConversationId = await createConversation(mockedWS, null, null, "g", [usersIds[1], usersIds[0]]) - - // emulate user2 connect in other node - deviceId = sessionService.activeSessions.DEVICES[usersIds[0]][0].deviceId - - const SSL_APP_OPTIONS = { - key_file_name: config.get("ws.options.ssl.key"), - cert_file_name: config.get("ws.options.ssl.cert"), - } - const CLUSTER_SOCKET = uWS.SSLApp(SSL_APP_OPTIONS) - CLUSTER_SOCKET.listen(0, (listenSocket) => { - if (listenSocket) { - const clusterPort = uWS.us_socket_local_port(listenSocket) - logger.trace("CLUSTER listening on port %s", clusterPort) - secondClusterPort = clusterPort - } else { - throw "CLUSTER_SOCKET.listen error" - } - }) - - const mockedWS2 = { - send: (data) => { - secondSocketResponse = data - }, - } - clusterManager.clusterNodesWS[ip.address()] = mockedWS2 - - sessionService.addUserDeviceConnection(mockedWS2, orgId, usersIds[1], deviceId) - await sessionService.storeUserNodeData(mockedWS2, orgId, usersIds[1], deviceId, ip.address(), secondClusterPort) - }) - - describe("Send Message to other node", async () => { - it("should work", async () => { - const requestData = { - message: { - id: "xyz", - body: "hey how is going?", - cid: currentConversationId, - }, - } - - const controllerResponse = await packetJsonProcessor.processMessageOrError(mockedWS, JSON.stringify(requestData)) - const deliverMessage = controllerResponse.deliverMessages.at(0) - - assert.notEqual(deliverMessage, undefined) - assert.equal(`${deliverMessage.cId}`, `${currentConversationId}`) - - await packetManager.deliverToUserOrUsers( - orgId, - deliverMessage.ws || mockedWS, - JSON.stringify(deliverMessage.packet), - null, - usersIds, - deliverMessage.notSaveInOfflineStorage - ) - - const response = JSON.parse(secondSocketResponse).deliverPacket - response.packet = JSON.parse(response.packet) - - assert.notEqual(response, undefined) - - assert.strictEqual(response.userId, usersIds[1].toString()) - assert.strictEqual(response.senderInfo.session.userId, usersIds[0].toString()) - - assert.notEqual(response.packet, undefined) - assert.strictEqual(response.packet.message.from, usersIds[0].toString()) - assert.strictEqual(response.packet.message.body, "hey how is going?") - }) - }) - - after(async () => { - await userRepo.deleteMany({}) - await messageRepo.deleteMany({}) - await messageStatusRepo.deleteMany({}) - await conversationRepo.deleteMany({}) - await conversationParticipantRepo.deleteMany({}) - - usersIds = [] - }) -}) diff --git a/test/cluster/cluster-clients.spec.js b/test/cluster/cluster-clients.spec.js new file mode 100644 index 00000000..d6e0f620 --- /dev/null +++ b/test/cluster/cluster-clients.spec.js @@ -0,0 +1,245 @@ +import assert from "node:assert" +import { setTimeout as setTimeoutPromise } from "node:timers/promises" + +import { v4 } from "uuid" + +import { initSdkWithUser, createGroupConversation, createPrivateConversation } from "./utils.js" + +const orgId = process.env.TEST_CLIENT_ORG_ID +const wsEndpoint = process.env.TEST_CLIENT_WS_ENDPOINT +const httpEndpoint = process.env.TEST_CLIENT_HTTP_ENDPOINT + +const clientsCount = +process.env.TEST_CLIENTS_COUNT || 10 +const clientSdkDeviceIds = new Array(clientsCount).fill(0).map((_, i) => `dv-${i + 1}`) + +const clientIdUserPairs = new Map() +const userSdkPairs = new Map() + +let groupConv = void 0 +const privateConversationsByPair = new Map() + +const getPairKey = (userAId, userBId) => [userAId, userBId].sort().join(":") + +const sendGroupMessageWithMarkAble = async (senderSdk, convId) => { + const mId = v4() + const mBody = `TestM-${mId}-original` + const mBodyResponse = `${mBody}-Response` + + const responseMessagesUserIds = new Set() + + const waitPromise = new Promise((resolve, reject) => { + senderSdk.onMessageEvent = (message) => { + if (message.cid === convId && message.body === mBodyResponse) { + responseMessagesUserIds.add(message.from) + } + + if (responseMessagesUserIds.size === clientsCount - 1) { + senderSdk.onMessageEvent = void 0 + resolve() + } + } + }) + + const recipientsSdks = Array.from(userSdkPairs.values()).filter((sdk) => sdk !== senderSdk) + + for (const sdk of recipientsSdks) { + sdk.onMessageEvent = (message) => { + if (message.cid === convId && message.body === mBody) { + sdk.messageCreate({ + cid: convId, + body: mBodyResponse, + mid: v4(), + }) + } + } + } + + await senderSdk.messageCreate({ + cid: convId, + body: mBody, + mid: mId, + }) + + await waitPromise +} + +const sendPrivateMessagesWithMarkAble = async (senderSdk, senderUser) => { + const recipients = Array.from(userSdkPairs.keys()).filter((user) => user !== senderUser) + + for (const recipientUser of recipients) { + const recipientSdk = userSdkPairs.get(recipientUser) + + const pairKey = getPairKey(senderUser.native_id, recipientUser.native_id) + const privateConversation = privateConversationsByPair.get(pairKey) + + const responseMid = v4() + const mId = v4() + const mBody = `TestPM-${mId}-original` + const mBodyResponse = `${mBody}-Response` + + const waitPromise = new Promise((resolve) => { + senderSdk.onMessageEvent = (message) => { + if (message.cid === privateConversation._id && message.body === mBodyResponse && message.from === recipientUser.native_id) { + resolve() + } + } + }) + + recipientSdk.onMessageEvent = (message) => { + if (message.cid === privateConversation._id && message.body === mBody && message.from === senderUser.native_id) { + recipientSdk.messageCreate({ + cid: privateConversation._id, + body: mBodyResponse, + mid: responseMid, + }) + } + } + + await senderSdk.messageCreate({ + cid: privateConversation._id, + body: mBody, + mid: mId, + }) + + await waitPromise + } +} + +const retrieveAllLastActivity = async (senderUser) => { + const senderSdk = userSdkPairs.get(senderUser) + const userIds = Array.from(userSdkPairs.keys()).map((user) => user.native_id) + + const lastActivity = await senderSdk.getUserActivity(userIds) + + for (const [userId, seconds] of Object.entries(lastActivity)) { + assert.ok(seconds === 0) + } +} + +describe("Multiple Clients", () => { + describe("Init", () => { + it("sdk array", async () => { + for (let i = 0; i < clientsCount; ++i) { + const deviceId = clientSdkDeviceIds.at(i) + + const { samaSdk, user } = await initSdkWithUser(orgId, true, `TestUser-${i}`, deviceId, wsEndpoint, httpEndpoint) + + assert.ok(samaSdk) + assert.ok(user) + + clientIdUserPairs.set(deviceId, user) + userSdkPairs.set(user, samaSdk) + } + }) + + it("create group conversation", async () => { + const users = Array.from(userSdkPairs.keys()) + const userIds = users.map((user) => user.native_id) + const ownerSdk = userSdkPairs.get(users.at(0)) + const groupConversation = await createGroupConversation(ownerSdk, userIds) + + assert.ok(groupConversation) + + groupConv = groupConversation + }) + + it("create private conversations for each pair", async () => { + for (const user of userSdkPairs.keys()) { + const userAId = user.native_id + const samaSdk = userSdkPairs.get(user) + const otherUsers = Array.from(userSdkPairs.keys()).filter((user) => user.native_id !== userAId) + + for (const user of otherUsers) { + const userBId = user.native_id + const pairKey = getPairKey(userAId, userBId) + const privateConversation = await createPrivateConversation(samaSdk, userAId, userBId) + + assert.ok(privateConversation) + privateConversationsByPair.set(pairKey, privateConversation) + } + } + }) + }) + + describe("check last activity", () => { + const resetSdkListeners = () => { + for (const sdk of userSdkPairs.values()) { + sdk.onMessageEvent = void 0 + } + } + + beforeEach(resetSdkListeners) + + clientSdkDeviceIds.forEach((deviceId, i) => { + it(`${deviceId} check`, async () => { + const senderUser = clientIdUserPairs.get(deviceId) + await retrieveAllLastActivity(senderUser) + await setTimeoutPromise(50) + }) + }) + + afterEach(resetSdkListeners) + }) + + describe("Send messages", () => { + const resetSdkListeners = () => { + for (const sdk of userSdkPairs.values()) { + sdk.onMessageEvent = void 0 + } + } + + beforeEach(resetSdkListeners) + + clientSdkDeviceIds.forEach((deviceId, i) => { + it(`${deviceId} sender`, async () => { + const senderUser = clientIdUserPairs.get(deviceId) + const senderSdk = userSdkPairs.get(senderUser) + await sendGroupMessageWithMarkAble(senderSdk, groupConv._id) + await setTimeoutPromise(50) + }) + }) + + afterEach(resetSdkListeners) + }) + + describe("Send private messages", () => { + const resetSdkListeners = () => { + for (const sdk of userSdkPairs.values()) { + sdk.onMessageEvent = void 0 + } + } + + beforeEach(resetSdkListeners) + + clientSdkDeviceIds.forEach((deviceId, i) => { + it(`${deviceId} sender to private conversations`, async () => { + const senderUser = clientIdUserPairs.get(deviceId) + const senderSdk = userSdkPairs.get(senderUser) + await sendPrivateMessagesWithMarkAble(senderSdk, senderUser) + await setTimeoutPromise(50) + }) + }) + + afterEach(resetSdkListeners) + }) + + describe("check last activity", () => { + const resetSdkListeners = () => { + for (const sdk of userSdkPairs.values()) { + sdk.onMessageEvent = void 0 + } + } + + beforeEach(resetSdkListeners) + + clientSdkDeviceIds.forEach((deviceId, i) => { + it(`${deviceId} check`, async () => { + const senderUser = clientIdUserPairs.get(deviceId) + await retrieveAllLastActivity(senderUser) + await setTimeoutPromise(50) + }) + }) + + afterEach(resetSdkListeners) + }) +}) diff --git a/test/cluster/cross-node.spec.js b/test/cluster/cross-node.spec.js new file mode 100644 index 00000000..905ad340 --- /dev/null +++ b/test/cluster/cross-node.spec.js @@ -0,0 +1,318 @@ +import assert from "node:assert" +import { setTimeout as setTimeoutPromise } from "node:timers/promises" + +import { v4 } from "uuid" +import { SAMAClient } from "@sama-communications/sdk" + +import { + startOrAccessNodeA, + startOrAccessNodeB, + NODE_1_WS_ENDPOINT, + NODE_1_HTTP_ENDPOINT, + NODE_2_WS_ENDPOINT, + NODE_2_HTTP_ENDPOINT, + dummyDataTestConfig, + userPassword, +} from "./utils.js" + +const configNodeA = { + endpoint: { + ws: NODE_1_WS_ENDPOINT, + http: NODE_1_HTTP_ENDPOINT, + }, + disableAutoReconnect: true, +} + +const configNodeB = { + endpoint: { + ws: NODE_2_WS_ENDPOINT, + http: NODE_2_HTTP_ENDPOINT, + }, + disableAutoReconnect: true, +} + +let samaClientA = void 0 +let samaClientB = void 0 + +let userAToken = void 0 +let userBToken = void 0 + +let nodeA = void 0 +let nodeB = void 0 + +describe("Cross-node behavior", () => { + before(async () => { + console.log("[Before][cross-node]") + + configNodeA.organization_id = dummyDataTestConfig.organizationId + configNodeB.organization_id = dummyDataTestConfig.organizationId + + samaClientA = new SAMAClient(configNodeA) + samaClientA.deviceId = v4() + samaClientB = new SAMAClient(configNodeB) + samaClientB.deviceId = v4() + + nodeA = await startOrAccessNodeA() + nodeB = await startOrAccessNodeB() + + await setTimeoutPromise(500) + }) + + describe("Connect", () => { + it("connect client userA", async () => { + await samaClientA.connect() + const { token } = await samaClientA.socketLogin({ user: { login: dummyDataTestConfig.users.userA.login, password: userPassword } }) + userAToken = token + }) + + it("subscribe userB last activity", async () => { + const activity = await samaClientA.subscribeToUserActivity(dummyDataTestConfig.users.userB.nativeId) + assert.ok(activity[dummyDataTestConfig.users.userB.nativeId] > 0) + }) + + it("connect client userB and check last activity", (done) => { + samaClientB + .connect() + .then(() => + samaClientB.socketLogin({ + user: { login: dummyDataTestConfig.users.userB.login, password: userPassword }, + }) + ) + .then(({ token }) => (userBToken = token)) + + samaClientA.onUserActivityListener = (activity) => { + assert.equal(activity[dummyDataTestConfig.users.userB.nativeId], 0) + done() + } + }) + + it("subscribe userA last activity", async () => { + const activity = await samaClientB.subscribeToUserActivity(dummyDataTestConfig.users.userA.nativeId) + assert.ok(activity[dummyDataTestConfig.users.userA.nativeId] === 0) + }) + }) + + describe("Base messaging", () => { + describe("System", () => { + describe("userA -> userB", () => { + it("event", (done) => { + const mId = v4() + samaClientA.messageSystem({ mid: mId, uids: [dummyDataTestConfig.users.userB.nativeId], x: { one: "1" } }) + + samaClientB.onSystemMessageEvent = (message) => { + assert.equal(message._id, mId) + assert.equal(message.from, dummyDataTestConfig.users.userA.nativeId) + + done() + } + }) + }) + + describe("userB -> userA", () => { + it("event", (done) => { + const mId = v4() + samaClientB.messageSystem({ mid: mId, uids: [dummyDataTestConfig.users.userA.nativeId], x: { two: "2" } }) + + samaClientA.onSystemMessageEvent = (message) => { + assert.equal(message._id, mId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + + done() + } + }) + }) + }) + + describe("Private", () => { + describe("userA -> userB", () => { + let messageId = void 0 + + it("typing", (done) => { + samaClientA.sendTypingStatus({ cid: dummyDataTestConfig.conversations.private.nativeId, status: "start" }) + + samaClientB.onUserTypingListener = (typing) => { + assert.equal(typing.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(typing.from, dummyDataTestConfig.users.userA.nativeId) + done() + } + }) + + it("message", (done) => { + const mId = v4() + const body = `Cluster Test Hello m: ${mId}` + samaClientA.messageCreate({ cid: dummyDataTestConfig.conversations.private.nativeId, body: body, mid: mId, x: { two: "2" } }) + + samaClientB.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userA.nativeId) + assert.equal(message.x["two"], "2") + + messageId = message._id + + done() + } + }) + + it("read status", (done) => { + samaClientB.markConversationAsRead({ cid: dummyDataTestConfig.conversations.private.nativeId, mids: [messageId] }) + + samaClientA.onMessageStatusListener = (status) => { + assert.equal(status.ids.at(0), messageId) + assert.equal(status.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(status.from, dummyDataTestConfig.users.userB.nativeId) + + done() + } + }) + }) + + describe("userB -> userA", () => { + let messageId = void 0 + + it("typing", (done) => { + samaClientB.sendTypingStatus({ cid: dummyDataTestConfig.conversations.private.nativeId, status: "start" }) + + samaClientA.onUserTypingListener = (typing) => { + assert.equal(typing.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(typing.from, dummyDataTestConfig.users.userB.nativeId) + done() + } + }) + + it("message", (done) => { + const mId = v4() + const body = `Cluster Test Hello m: ${mId}` + samaClientB.messageCreate({ cid: dummyDataTestConfig.conversations.private.nativeId, body: body, mid: mId, x: { two: "22" } }) + + samaClientA.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + assert.equal(message.x["two"], "22") + + messageId = message._id + + done() + } + }) + + it("read status", (done) => { + samaClientA.markConversationAsRead({ cid: dummyDataTestConfig.conversations.private.nativeId, mids: [messageId] }) + + samaClientB.onMessageStatusListener = (status) => { + assert.equal(status.ids.at(0), messageId) + assert.equal(status.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(status.from, dummyDataTestConfig.users.userA.nativeId) + + done() + } + }) + }) + }) + + describe("Group", () => { + describe("userA -> userB", () => { + let messageId = void 0 + + it("typing", (done) => { + samaClientA.sendTypingStatus({ cid: dummyDataTestConfig.conversations.group.nativeId, status: "start" }) + + samaClientB.onUserTypingListener = (typing) => { + assert.equal(typing.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(typing.from, dummyDataTestConfig.users.userA.nativeId) + done() + } + }) + + it("message", (done) => { + const mId = v4() + const body = `Cluster Test Hello m: ${mId}` + samaClientA.messageCreate({ cid: dummyDataTestConfig.conversations.group.nativeId, body: body, mid: mId, x: { one: "1" } }) + + samaClientB.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userA.nativeId) + assert.equal(message.x["one"], "1") + + messageId = message._id + + done() + } + }) + + it("read status", (done) => { + samaClientB.markConversationAsRead({ cid: dummyDataTestConfig.conversations.group.nativeId, mids: [messageId] }) + + samaClientA.onMessageStatusListener = (status) => { + assert.equal(status.ids.at(0), messageId) + assert.equal(status.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(status.from, dummyDataTestConfig.users.userB.nativeId) + + done() + } + }) + }) + + describe("userB -> userA", () => { + let messageId = void 0 + + it("typing", (done) => { + samaClientB.sendTypingStatus({ cid: dummyDataTestConfig.conversations.group.nativeId, status: "start" }) + + samaClientA.onUserTypingListener = (typing) => { + assert.equal(typing.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(typing.from, dummyDataTestConfig.users.userB.nativeId) + done() + } + }) + + it("message", (done) => { + const mId = v4() + const body = `Cluster Test Hello m: ${mId}` + samaClientB.messageCreate({ cid: dummyDataTestConfig.conversations.group.nativeId, body: body, mid: mId, x: { one: "11" } }) + + samaClientA.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + assert.equal(message.x["one"], "11") + + messageId = message._id + + done() + } + }) + + it("read status", (done) => { + samaClientA.markConversationAsRead({ cid: dummyDataTestConfig.conversations.group.nativeId, mids: [messageId] }) + + samaClientB.onMessageStatusListener = (status) => { + assert.equal(status.ids.at(0), messageId) + assert.equal(status.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(status.from, dummyDataTestConfig.users.userA.nativeId) + + done() + } + }) + }) + }) + }) + + describe("Activity on disconnect", () => { + it("userA logout", (done) => { + samaClientA.disconnect() + + samaClientB.onUserActivityListener = (activity) => { + assert.ok(activity[dummyDataTestConfig.users.userA.nativeId] > 0) + done() + } + }) + + it("disconnect userB", async () => { + samaClientB.disconnect() + await setTimeoutPromise(200) + }) + }) +}) diff --git a/test/cluster/fixtures.js b/test/cluster/fixtures.js new file mode 100644 index 00000000..fa450d78 --- /dev/null +++ b/test/cluster/fixtures.js @@ -0,0 +1,43 @@ +import killPort from "kill-port" +import { + NODE_1_HTTP_ENDPOINT, + NODE_1_WS_ENDPOINT, + startOrAccessNodeA, + createDummyData, + dummyDataTestConfig, + killNodeA, + killNodeB, +} from "./utils.js" + +const killPortWrap = (port, method) => + killPort(port, method) + .then(() => console.log("[Port]", port, "killed")) + .catch((error) => console.log("[Port]", port, error.message)) + +const killPorts = async () => { + await killPortWrap(9001) + await killPortWrap(9002) + + await killPortWrap(8001) + await killPortWrap(8002) +} + +export async function mochaGlobalSetup() { + console.log("[Global][Start]") + + await killPorts() + + await startOrAccessNodeA() + await createDummyData(NODE_1_WS_ENDPOINT, NODE_1_HTTP_ENDPOINT) + + console.log("[Dummy Date Config]", dummyDataTestConfig) +} + +export async function mochaGlobalTeardown() { + console.log("[Global][Shutdown]") + + killNodeA() + killNodeB() + + await killPorts() +} diff --git a/test/cluster/multi-devices.spec.js b/test/cluster/multi-devices.spec.js new file mode 100644 index 00000000..4cab6be6 --- /dev/null +++ b/test/cluster/multi-devices.spec.js @@ -0,0 +1,1111 @@ +import assert from "node:assert" +import { setTimeout as setTimeoutPromise } from "node:timers/promises" + +import { v4 } from "uuid" +import { SAMAClient } from "@sama-communications/sdk" + +import { + startOrAccessNodeA, + startOrAccessNodeB, + NODE_1_WS_ENDPOINT, + NODE_1_HTTP_ENDPOINT, + NODE_2_WS_ENDPOINT, + NODE_2_HTTP_ENDPOINT, + dummyDataTestConfig, + userPassword, +} from "./utils.js" + +const configNodeA = { + endpoint: { + ws: NODE_1_WS_ENDPOINT, + http: NODE_1_HTTP_ENDPOINT, + }, + disableAutoReconnect: true, +} + +const configNodeB = { + endpoint: { + ws: NODE_2_WS_ENDPOINT, + http: NODE_2_HTTP_ENDPOINT, + }, + disableAutoReconnect: true, +} + +let samaClientA_Device1 = void 0 +let samaClientB_Device1 = void 0 +let samaClientB_Device2 = void 0 + +let userAToken = void 0 +let userBToken = void 0 + +let nodeA = void 0 +let nodeB = void 0 + +describe("Cross-node behavior", () => { + before(async () => { + console.log("[Before][cross-node-multi-devices]") + + configNodeA.organization_id = dummyDataTestConfig.organizationId + configNodeB.organization_id = dummyDataTestConfig.organizationId + + samaClientA_Device1 = new SAMAClient(configNodeA) + samaClientA_Device1.deviceId = v4() + samaClientB_Device1 = new SAMAClient(configNodeB) + samaClientB_Device1.deviceId = v4() + samaClientB_Device2 = new SAMAClient(configNodeB) + samaClientB_Device2.deviceId = v4() + + nodeA = await startOrAccessNodeA() + nodeB = await startOrAccessNodeB() + + await setTimeoutPromise(500) + }) + + describe("Node-A(userA_Device1) - Node-B(userB_Device1,userB_Device2)", () => { + describe("Connect", () => { + it("connect userA_Device1", async () => { + await samaClientA_Device1.connect() + const { token } = await samaClientA_Device1.socketLogin({ + user: { login: dummyDataTestConfig.users.userA.login, password: userPassword }, + }) + userAToken = token + }) + + it("subscribe userB last activity", async () => { + const activity = await samaClientA_Device1.subscribeToUserActivity(dummyDataTestConfig.users.userB.nativeId) + assert.ok(activity[dummyDataTestConfig.users.userB.nativeId] > 0) + }) + + it("connect userB_Device1 and check last activity", (done) => { + samaClientB_Device1 + .connect() + .then(() => + samaClientB_Device1.socketLogin({ + user: { login: dummyDataTestConfig.users.userB.login, password: userPassword }, + }) + ) + .then(({ token }) => (userBToken = token)) + + samaClientA_Device1.onUserActivityListener = (activity) => { + assert.equal(activity[dummyDataTestConfig.users.userB.nativeId], 0) + done() + } + }) + + it("connect userB_Device2 and check last activity", (done) => { + samaClientB_Device2 + .connect() + .then(() => samaClientB_Device2.socketLogin({ user: { login: dummyDataTestConfig.users.userB.login, password: userPassword } })) + samaClientA_Device1.onUserActivityListener = (activity) => { + assert.equal(activity[dummyDataTestConfig.users.userB.nativeId], 0) + done() + } + }) + + it("subscribe userA last activity", async () => { + let activity = await samaClientB_Device1.subscribeToUserActivity(dummyDataTestConfig.users.userA.nativeId) + assert.ok(activity[dummyDataTestConfig.users.userA.nativeId] === 0) + + activity = await samaClientB_Device2.subscribeToUserActivity(dummyDataTestConfig.users.userA.nativeId) + assert.ok(activity[dummyDataTestConfig.users.userA.nativeId] === 0) + }) + }) + + describe("Base messaging", () => { + describe("System", () => { + describe("userA_Device1 -> userB_Device1,userB_Device2", async () => { + it("event", async () => { + const mId = v4() + samaClientA_Device1.messageSystem({ mid: mId, uids: [dummyDataTestConfig.users.userB.nativeId], x: { one: "1" } }) + + const uA_D1_promise = new Promise((resolve, reject) => { + samaClientB_Device1.onSystemMessageEvent = (message) => { + assert.equal(message._id, mId) + assert.equal(message.from, dummyDataTestConfig.users.userA.nativeId) + + resolve() + } + }) + + const uA_D2_promise = new Promise((resolve, reject) => { + samaClientB_Device2.onSystemMessageEvent = (message) => { + assert.equal(message._id, mId) + assert.equal(message.from, dummyDataTestConfig.users.userA.nativeId) + + resolve() + } + }) + + await Promise.all([uA_D1_promise, uA_D2_promise]) + }) + }) + + describe("userB_Device1 sender", () => { + it("event to userA_Device1", (done) => { + const mId = v4() + samaClientB_Device1.messageSystem({ mid: mId, uids: [dummyDataTestConfig.users.userA.nativeId], x: { two: "2" } }) + + samaClientA_Device1.onSystemMessageEvent = (message) => { + assert.equal(message._id, mId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + + done() + } + }) + + it("event to userA_Device1,userB_Device2", async () => { + const mId = v4() + samaClientB_Device1.messageSystem({ + mid: mId, + uids: [dummyDataTestConfig.users.userA.nativeId, dummyDataTestConfig.users.userB.nativeId], + x: { two: "22" }, + }) + + const uA_D1_promise = new Promise((resolve, reject) => { + samaClientA_Device1.onSystemMessageEvent = (message) => { + assert.equal(message._id, mId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + + resolve() + } + }) + + const uB_D2_promise = new Promise((resolve, reject) => { + samaClientB_Device2.onSystemMessageEvent = (message) => { + assert.equal(message._id, mId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + + resolve() + } + }) + + await Promise.all([uA_D1_promise, uB_D2_promise]) + }) + }) + + describe("userB_Device2 sender", () => { + it("event to conversation", async () => { + const mId = v4() + samaClientB_Device2.messageSystem({ mid: mId, cid: dummyDataTestConfig.conversations.private.nativeId, x: { four: "44" } }) + + const uA_D1_promise = new Promise((resolve, reject) => { + samaClientA_Device1.onSystemMessageEvent = (message) => { + assert.equal(message._id, mId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + + resolve() + } + }) + + const uB_D1_promise = new Promise((resolve, reject) => { + samaClientB_Device1.onSystemMessageEvent = (message) => { + assert.equal(message._id, mId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + + resolve() + } + }) + + await Promise.all([uA_D1_promise, uB_D1_promise]) + }) + }) + }) + + describe("Private", () => { + describe("userA_Device1 -> userB_Device1,userB_Device2", () => { + let messageId = void 0 + + it("typing", async () => { + samaClientA_Device1.sendTypingStatus({ cid: dummyDataTestConfig.conversations.private.nativeId, status: "start" }) + + const uB_D1_promise = new Promise((resolve, reject) => { + samaClientB_Device1.onUserTypingListener = (typing) => { + assert.equal(typing.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(typing.from, dummyDataTestConfig.users.userA.nativeId) + resolve() + } + }) + + const uB_D2_promise = new Promise((resolve, reject) => { + samaClientB_Device2.onUserTypingListener = (typing) => { + assert.equal(typing.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(typing.from, dummyDataTestConfig.users.userA.nativeId) + resolve() + } + }) + + await Promise.all([uB_D1_promise, uB_D2_promise]) + }) + + it("message", async () => { + const mId = v4() + const body = `Cluster Test Hello m: ${mId}` + samaClientA_Device1.messageCreate({ + cid: dummyDataTestConfig.conversations.private.nativeId, + body: body, + mid: mId, + x: { two: "2" }, + }) + + const uB_D1_promise = new Promise((resolve, reject) => { + samaClientB_Device1.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userA.nativeId) + assert.equal(message.x["two"], "2") + + messageId = message._id + + resolve() + } + }) + + const uB_D2_promise = new Promise((resolve, reject) => { + samaClientB_Device2.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userA.nativeId) + assert.equal(message.x["two"], "2") + + messageId = message._id + + resolve() + } + }) + + await Promise.all([uB_D1_promise, uB_D2_promise]) + }) + + it("read status", (done) => { + samaClientB_Device2.markConversationAsRead({ cid: dummyDataTestConfig.conversations.private.nativeId, mids: [messageId] }) + + samaClientA_Device1.onMessageStatusListener = (status) => { + assert.equal(status.ids.at(0), messageId) + assert.equal(status.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(status.from, dummyDataTestConfig.users.userB.nativeId) + + done() + } + }) + }) + + describe("userB_Device1 -> userA_Device1,userB_Device2", () => { + let messageId = void 0 + + it("typing", async () => { + samaClientB_Device1.sendTypingStatus({ cid: dummyDataTestConfig.conversations.private.nativeId, status: "start" }) + + const uA_D1_promise = new Promise((resolve, reject) => { + samaClientA_Device1.onUserTypingListener = (typing) => { + assert.equal(typing.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(typing.from, dummyDataTestConfig.users.userB.nativeId) + resolve() + } + }) + + const uB_D2_promise = new Promise((resolve, reject) => { + samaClientB_Device2.onUserTypingListener = (typing) => { + assert.equal(typing.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(typing.from, dummyDataTestConfig.users.userB.nativeId) + resolve() + } + }) + + await Promise.all([uA_D1_promise, uB_D2_promise]) + }) + + it("message", async () => { + const mId = v4() + const body = `Cluster Test Hello m: ${mId}` + samaClientB_Device1.messageCreate({ + cid: dummyDataTestConfig.conversations.private.nativeId, + body: body, + mid: mId, + x: { two: "22" }, + }) + + const uA_D1_promise = new Promise((resolve, reject) => { + samaClientA_Device1.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + assert.equal(message.x["two"], "22") + + messageId = message._id + + resolve() + } + }) + + const uB_D2_promise = new Promise((resolve, reject) => { + samaClientB_Device2.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + assert.equal(message.x["two"], "22") + + messageId = message._id + + resolve() + } + }) + + await Promise.all([uA_D1_promise, uB_D2_promise]) + }) + + it("read status", async () => { + samaClientA_Device1.markConversationAsRead({ cid: dummyDataTestConfig.conversations.private.nativeId, mids: [messageId] }) + + const uB_D1_promise = new Promise((resolve, reject) => { + samaClientB_Device1.onMessageStatusListener = (status) => { + assert.equal(status.ids.at(0), messageId) + assert.equal(status.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(status.from, dummyDataTestConfig.users.userA.nativeId) + + resolve() + } + }) + + const uB_D2_promise = new Promise((resolve, reject) => { + samaClientB_Device2.onMessageStatusListener = (status) => { + assert.equal(status.ids.at(0), messageId) + assert.equal(status.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(status.from, dummyDataTestConfig.users.userA.nativeId) + + resolve() + } + }) + + await Promise.all([uB_D1_promise, uB_D2_promise]) + }) + }) + }) + + describe("Group", () => { + describe("userA_Device1 -> userB_Device1,userB_Device2", () => { + let messageId = void 0 + + it("typing", async () => { + samaClientA_Device1.sendTypingStatus({ cid: dummyDataTestConfig.conversations.group.nativeId, status: "start" }) + + const uB_D1_promise = new Promise((resolve, reject) => { + samaClientB_Device1.onUserTypingListener = (typing) => { + assert.equal(typing.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(typing.from, dummyDataTestConfig.users.userA.nativeId) + resolve() + } + }) + + const uB_D2_promise = new Promise((resolve, reject) => { + samaClientB_Device2.onUserTypingListener = (typing) => { + assert.equal(typing.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(typing.from, dummyDataTestConfig.users.userA.nativeId) + resolve() + } + }) + + await Promise.all([uB_D1_promise, uB_D2_promise]) + }) + + it("message", async () => { + const mId = v4() + const body = `Cluster Test Hello m: ${mId}` + samaClientA_Device1.messageCreate({ + cid: dummyDataTestConfig.conversations.group.nativeId, + body: body, + mid: mId, + x: { two: "2" }, + }) + + const uB_D1_promise = new Promise((resolve, reject) => { + samaClientB_Device1.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userA.nativeId) + assert.equal(message.x["two"], "2") + + messageId = message._id + + resolve() + } + }) + + const uB_D2_promise = new Promise((resolve, reject) => { + samaClientB_Device2.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userA.nativeId) + assert.equal(message.x["two"], "2") + + messageId = message._id + + resolve() + } + }) + + await Promise.all([uB_D1_promise, uB_D2_promise]) + }) + + it("read status", (done) => { + samaClientB_Device2.markConversationAsRead({ cid: dummyDataTestConfig.conversations.group.nativeId, mids: [messageId] }) + + samaClientA_Device1.onMessageStatusListener = (status) => { + assert.equal(status.ids.at(0), messageId) + assert.equal(status.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(status.from, dummyDataTestConfig.users.userB.nativeId) + + done() + } + }) + }) + + describe("userB_Device1 -> userA_Device1,userB_Device2", () => { + let messageId = void 0 + + it("typing", async () => { + samaClientB_Device1.sendTypingStatus({ cid: dummyDataTestConfig.conversations.group.nativeId, status: "start" }) + + const uA_D1_promise = new Promise((resolve, reject) => { + samaClientA_Device1.onUserTypingListener = (typing) => { + assert.equal(typing.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(typing.from, dummyDataTestConfig.users.userB.nativeId) + resolve() + } + }) + + const uB_D2_promise = new Promise((resolve, reject) => { + samaClientB_Device2.onUserTypingListener = (typing) => { + assert.equal(typing.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(typing.from, dummyDataTestConfig.users.userB.nativeId) + resolve() + } + }) + + await Promise.all([uA_D1_promise, uB_D2_promise]) + }) + + it("message", async () => { + const mId = v4() + const body = `Cluster Test Hello m: ${mId}` + samaClientB_Device1.messageCreate({ + cid: dummyDataTestConfig.conversations.group.nativeId, + body: body, + mid: mId, + x: { two: "22" }, + }) + + const uA_D1_promise = new Promise((resolve, reject) => { + samaClientA_Device1.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + assert.equal(message.x["two"], "22") + + messageId = message._id + + resolve() + } + }) + + const uB_D2_promise = new Promise((resolve, reject) => { + samaClientB_Device2.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + assert.equal(message.x["two"], "22") + + messageId = message._id + + resolve() + } + }) + + await Promise.all([uA_D1_promise, uB_D2_promise]) + }) + + it("read status", async () => { + samaClientA_Device1.markConversationAsRead({ cid: dummyDataTestConfig.conversations.group.nativeId, mids: [messageId] }) + + const uB_D1_promise = new Promise((resolve, reject) => { + samaClientB_Device1.onMessageStatusListener = (status) => { + assert.equal(status.ids.at(0), messageId) + assert.equal(status.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(status.from, dummyDataTestConfig.users.userA.nativeId) + + resolve() + } + }) + + const uB_D2_promise = new Promise((resolve, reject) => { + samaClientB_Device2.onMessageStatusListener = (status) => { + assert.equal(status.ids.at(0), messageId) + assert.equal(status.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(status.from, dummyDataTestConfig.users.userA.nativeId) + + resolve() + } + }) + + await Promise.all([uB_D1_promise, uB_D2_promise]) + }) + }) + }) + }) + + describe("Activity on disconnect", () => { + it("userB_Device1 logout (not last activity)", async () => { + samaClientB_Device1.disconnect() + + const promise = new Promise((resolve, reject) => { + setTimeout(() => resolve(), 500) + + samaClientA_Device1.onUserActivityListener = (activity) => { + if (activity[dummyDataTestConfig.users.userB.nativeId] > 0) { + reject(new Error("Should not be called")) + } + } + }) + + await promise + }) + + it("userB_Device1 logout (last activity)", (done) => { + samaClientB_Device2.disconnect() + + samaClientA_Device1.onUserActivityListener = (activity) => { + assert.ok(activity[dummyDataTestConfig.users.userB.nativeId] > 0) + done() + } + }) + + it("disconnect A", async () => { + samaClientA_Device1.disconnect() + await setTimeoutPromise(200) + }) + }) + }) + + describe("Node-A (userA_Device1, userB_Device2) - Node-B (userB_Device1)", () => { + describe("Connect", () => { + it("connect userA_Device1", async () => { + await samaClientA_Device1.connect() + const { token } = await samaClientA_Device1.socketLogin({ + user: { login: dummyDataTestConfig.users.userA.login, password: userPassword }, + }) + userAToken = token + }) + + it("subscribe user B last activity", async () => { + const activity = await samaClientA_Device1.subscribeToUserActivity(dummyDataTestConfig.users.userB.nativeId) + assert.ok(activity[dummyDataTestConfig.users.userB.nativeId] > 0) + }) + + it("connect userB_Device1 and check last activity", (done) => { + samaClientB_Device1 + .connect() + .then(() => + samaClientB_Device1.socketLogin({ + user: { login: dummyDataTestConfig.users.userB.login, password: userPassword }, + }) + ) + .then(({ token }) => (userBToken = token)) + + samaClientA_Device1.onUserActivityListener = (activity) => { + assert.equal(activity[dummyDataTestConfig.users.userB.nativeId], 0) + done() + } + }) + + it("connect userB_Device2 and check last activity", (done) => { + samaClientB_Device2 + .connect(NODE_1_WS_ENDPOINT, NODE_1_HTTP_ENDPOINT) + .then(() => samaClientB_Device2.socketLogin({ user: { login: dummyDataTestConfig.users.userB.login, password: userPassword } })) + samaClientA_Device1.onUserActivityListener = (activity) => { + assert.equal(activity[dummyDataTestConfig.users.userB.nativeId], 0) + done() + } + }) + + it("subscribe user A last activity", async () => { + let activity = await samaClientB_Device1.subscribeToUserActivity(dummyDataTestConfig.users.userA.nativeId) + assert.ok(activity[dummyDataTestConfig.users.userA.nativeId] === 0) + + activity = await samaClientB_Device2.subscribeToUserActivity(dummyDataTestConfig.users.userA.nativeId) + assert.ok(activity[dummyDataTestConfig.users.userA.nativeId] === 0) + }) + }) + + describe("Base messaging", () => { + describe("System", () => { + describe("userA_Device1 -> userB_Device1,userB_Device2", async () => { + it("event", async () => { + const mId = v4() + samaClientA_Device1.messageSystem({ mid: mId, uids: [dummyDataTestConfig.users.userB.nativeId], x: { one: "1" } }) + + const uA_D1_promise = new Promise((resolve, reject) => { + samaClientB_Device1.onSystemMessageEvent = (message) => { + assert.equal(message._id, mId) + assert.equal(message.from, dummyDataTestConfig.users.userA.nativeId) + + resolve() + } + }) + + const uA_D2_promise = new Promise((resolve, reject) => { + samaClientB_Device2.onSystemMessageEvent = (message) => { + assert.equal(message._id, mId) + assert.equal(message.from, dummyDataTestConfig.users.userA.nativeId) + + resolve() + } + }) + + await Promise.all([uA_D1_promise, uA_D2_promise]) + }) + }) + + describe("userB_Device1 sender", () => { + it("event to userA_Device1", (done) => { + const mId = v4() + samaClientB_Device1.messageSystem({ mid: mId, uids: [dummyDataTestConfig.users.userA.nativeId], x: { two: "2" } }) + + samaClientA_Device1.onSystemMessageEvent = (message) => { + assert.equal(message._id, mId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + + done() + } + }) + + it("event to userA_Device1,userB_Device2", async () => { + const mId = v4() + samaClientB_Device1.messageSystem({ + mid: mId, + uids: [dummyDataTestConfig.users.userA.nativeId, dummyDataTestConfig.users.userB.nativeId], + x: { two: "22" }, + }) + + const uA_D1_promise = new Promise((resolve, reject) => { + samaClientA_Device1.onSystemMessageEvent = (message) => { + assert.equal(message._id, mId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + + resolve() + } + }) + + const uB_D2_promise = new Promise((resolve, reject) => { + samaClientB_Device2.onSystemMessageEvent = (message) => { + assert.equal(message._id, mId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + + resolve() + } + }) + + await Promise.all([uA_D1_promise, uB_D2_promise]) + }) + }) + + describe("userB_Device2 sender", () => { + it("event to conversation", async () => { + const mId = v4() + samaClientB_Device2.messageSystem({ mid: mId, cid: dummyDataTestConfig.conversations.private.nativeId, x: { four: "44" } }) + + const uA_D1_promise = new Promise((resolve, reject) => { + samaClientA_Device1.onSystemMessageEvent = (message) => { + assert.equal(message._id, mId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + + resolve() + } + }) + + const uB_D1_promise = new Promise((resolve, reject) => { + samaClientB_Device1.onSystemMessageEvent = (message) => { + assert.equal(message._id, mId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + + resolve() + } + }) + + await Promise.all([uA_D1_promise, uB_D1_promise]) + }) + }) + }) + + describe("Private", () => { + describe("userA_Device1 -> userB_Device1,userB_Device2", () => { + let messageId = void 0 + + it("typing", async () => { + samaClientA_Device1.sendTypingStatus({ cid: dummyDataTestConfig.conversations.private.nativeId, status: "start" }) + + const uB_D1_promise = new Promise((resolve, reject) => { + samaClientB_Device1.onUserTypingListener = (typing) => { + assert.equal(typing.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(typing.from, dummyDataTestConfig.users.userA.nativeId) + resolve() + } + }) + + const uB_D2_promise = new Promise((resolve, reject) => { + samaClientB_Device2.onUserTypingListener = (typing) => { + assert.equal(typing.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(typing.from, dummyDataTestConfig.users.userA.nativeId) + resolve() + } + }) + + await Promise.all([uB_D1_promise, uB_D2_promise]) + }) + + it("message", async () => { + const mId = v4() + const body = `Cluster Test Hello m: ${mId}` + samaClientA_Device1.messageCreate({ + cid: dummyDataTestConfig.conversations.private.nativeId, + body: body, + mid: mId, + x: { two: "2" }, + }) + + const uB_D1_promise = new Promise((resolve, reject) => { + samaClientB_Device1.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userA.nativeId) + assert.equal(message.x["two"], "2") + + messageId = message._id + + resolve() + } + }) + + const uB_D2_promise = new Promise((resolve, reject) => { + samaClientB_Device2.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userA.nativeId) + assert.equal(message.x["two"], "2") + + messageId = message._id + + resolve() + } + }) + + await Promise.all([uB_D1_promise, uB_D2_promise]) + }) + + it("read status", (done) => { + samaClientB_Device2.markConversationAsRead({ cid: dummyDataTestConfig.conversations.private.nativeId, mids: [messageId] }) + + samaClientA_Device1.onMessageStatusListener = (status) => { + assert.equal(status.ids.at(0), messageId) + assert.equal(status.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(status.from, dummyDataTestConfig.users.userB.nativeId) + + done() + } + }) + }) + + describe("userB_Device1 -> userA_Device1,userB_Device2", () => { + let messageId = void 0 + + it("typing", async () => { + samaClientB_Device1.sendTypingStatus({ cid: dummyDataTestConfig.conversations.private.nativeId, status: "start" }) + + const uA_D1_promise = new Promise((resolve, reject) => { + samaClientA_Device1.onUserTypingListener = (typing) => { + assert.equal(typing.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(typing.from, dummyDataTestConfig.users.userB.nativeId) + resolve() + } + }) + + const uB_D2_promise = new Promise((resolve, reject) => { + samaClientB_Device2.onUserTypingListener = (typing) => { + assert.equal(typing.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(typing.from, dummyDataTestConfig.users.userB.nativeId) + resolve() + } + }) + + await Promise.all([uA_D1_promise, uB_D2_promise]) + }) + + it("message", async () => { + const mId = v4() + const body = `Cluster Test Hello m: ${mId}` + samaClientB_Device1.messageCreate({ + cid: dummyDataTestConfig.conversations.private.nativeId, + body: body, + mid: mId, + x: { two: "22" }, + }) + + const uA_D1_promise = new Promise((resolve, reject) => { + samaClientA_Device1.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + assert.equal(message.x["two"], "22") + + messageId = message._id + + resolve() + } + }) + + const uB_D2_promise = new Promise((resolve, reject) => { + samaClientB_Device2.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + assert.equal(message.x["two"], "22") + + messageId = message._id + + resolve() + } + }) + + await Promise.all([uA_D1_promise, uB_D2_promise]) + }) + + it("read status", async () => { + samaClientA_Device1.markConversationAsRead({ cid: dummyDataTestConfig.conversations.private.nativeId, mids: [messageId] }) + + const uB_D1_promise = new Promise((resolve, reject) => { + samaClientB_Device1.onMessageStatusListener = (status) => { + assert.equal(status.ids.at(0), messageId) + assert.equal(status.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(status.from, dummyDataTestConfig.users.userA.nativeId) + + resolve() + } + }) + + const uB_D2_promise = new Promise((resolve, reject) => { + samaClientB_Device2.onMessageStatusListener = (status) => { + assert.equal(status.ids.at(0), messageId) + assert.equal(status.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(status.from, dummyDataTestConfig.users.userA.nativeId) + + resolve() + } + }) + + await Promise.all([uB_D1_promise, uB_D2_promise]) + }) + }) + }) + + describe("Group", () => { + describe("userA_Device1 -> userB_Device1,userB_Device2", () => { + let messageId = void 0 + + it("typing", async () => { + samaClientA_Device1.sendTypingStatus({ cid: dummyDataTestConfig.conversations.group.nativeId, status: "start" }) + + const uB_D1_promise = new Promise((resolve, reject) => { + samaClientB_Device1.onUserTypingListener = (typing) => { + assert.equal(typing.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(typing.from, dummyDataTestConfig.users.userA.nativeId) + resolve() + } + }) + + const uB_D2_promise = new Promise((resolve, reject) => { + samaClientB_Device2.onUserTypingListener = (typing) => { + assert.equal(typing.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(typing.from, dummyDataTestConfig.users.userA.nativeId) + resolve() + } + }) + + await Promise.all([uB_D1_promise, uB_D2_promise]) + }) + + it("message", async () => { + const mId = v4() + const body = `Cluster Test Hello m: ${mId}` + samaClientA_Device1.messageCreate({ + cid: dummyDataTestConfig.conversations.group.nativeId, + body: body, + mid: mId, + x: { two: "2" }, + }) + + const uB_D1_promise = new Promise((resolve, reject) => { + samaClientB_Device1.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userA.nativeId) + assert.equal(message.x["two"], "2") + + messageId = message._id + + resolve() + } + }) + + const uB_D2_promise = new Promise((resolve, reject) => { + samaClientB_Device2.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userA.nativeId) + assert.equal(message.x["two"], "2") + + messageId = message._id + + resolve() + } + }) + + await Promise.all([uB_D1_promise, uB_D2_promise]) + }) + + it("read status", (done) => { + samaClientB_Device2.markConversationAsRead({ cid: dummyDataTestConfig.conversations.group.nativeId, mids: [messageId] }) + + samaClientA_Device1.onMessageStatusListener = (status) => { + assert.equal(status.ids.at(0), messageId) + assert.equal(status.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(status.from, dummyDataTestConfig.users.userB.nativeId) + + done() + } + }) + }) + + describe("userB_Device1 -> userA_Device1,userB_Device2", () => { + let messageId = void 0 + + it("typing", async () => { + samaClientB_Device1.sendTypingStatus({ cid: dummyDataTestConfig.conversations.group.nativeId, status: "start" }) + + const uA_D1_promise = new Promise((resolve, reject) => { + samaClientA_Device1.onUserTypingListener = (typing) => { + assert.equal(typing.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(typing.from, dummyDataTestConfig.users.userB.nativeId) + resolve() + } + }) + + const uB_D2_promise = new Promise((resolve, reject) => { + samaClientB_Device2.onUserTypingListener = (typing) => { + assert.equal(typing.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(typing.from, dummyDataTestConfig.users.userB.nativeId) + resolve() + } + }) + + await Promise.all([uA_D1_promise, uB_D2_promise]) + }) + + it("message", async () => { + const mId = v4() + const body = `Cluster Test Hello m: ${mId}` + samaClientB_Device1.messageCreate({ + cid: dummyDataTestConfig.conversations.group.nativeId, + body: body, + mid: mId, + x: { two: "22" }, + }) + + const uA_D1_promise = new Promise((resolve, reject) => { + samaClientA_Device1.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + assert.equal(message.x["two"], "22") + + messageId = message._id + + resolve() + } + }) + + const uB_D2_promise = new Promise((resolve, reject) => { + samaClientB_Device2.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + assert.equal(message.x["two"], "22") + + messageId = message._id + + resolve() + } + }) + + await Promise.all([uA_D1_promise, uB_D2_promise]) + }) + + it("read status", async () => { + samaClientA_Device1.markConversationAsRead({ cid: dummyDataTestConfig.conversations.group.nativeId, mids: [messageId] }) + + const uB_D1_promise = new Promise((resolve, reject) => { + samaClientB_Device1.onMessageStatusListener = (status) => { + assert.equal(status.ids.at(0), messageId) + assert.equal(status.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(status.from, dummyDataTestConfig.users.userA.nativeId) + + resolve() + } + }) + + const uB_D2_promise = new Promise((resolve, reject) => { + samaClientB_Device2.onMessageStatusListener = (status) => { + assert.equal(status.ids.at(0), messageId) + assert.equal(status.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(status.from, dummyDataTestConfig.users.userA.nativeId) + + resolve() + } + }) + + await Promise.all([uB_D1_promise, uB_D2_promise]) + }) + }) + }) + }) + + describe("Activity on disconnect", () => { + it("userB_Device1 logout (not last activity)", async () => { + samaClientB_Device1.disconnect() + + const promise = new Promise((resolve, reject) => { + setTimeout(() => resolve(), 500) + + samaClientA_Device1.onUserActivityListener = (activity) => { + if (activity[dummyDataTestConfig.users.userB.nativeId] > 0) { + reject(new Error("Should not be called")) + } + } + }) + + await promise + }) + + it("userB_Device1 logout (last activity)", (done) => { + samaClientB_Device2.disconnect() + + samaClientA_Device1.onUserActivityListener = (activity) => { + assert.ok(activity[dummyDataTestConfig.users.userB.nativeId] > 0) + done() + } + }) + + it("disconnect userA", async () => { + samaClientA_Device1.disconnect() + await setTimeoutPromise(200) + }) + }) + }) +}) diff --git a/test/cluster/node-crash.spec.js b/test/cluster/node-crash.spec.js new file mode 100644 index 00000000..87dd0eac --- /dev/null +++ b/test/cluster/node-crash.spec.js @@ -0,0 +1,475 @@ +import assert from "node:assert" +import { setTimeout as setTimeoutPromise } from "node:timers/promises" + +import { v4 } from "uuid" +import { SAMAClient } from "@sama-communications/sdk" + +import { + startOrAccessNodeA, + startOrAccessNodeB, + NODE_1_WS_ENDPOINT, + NODE_1_HTTP_ENDPOINT, + NODE_2_WS_ENDPOINT, + NODE_2_HTTP_ENDPOINT, + dummyDataTestConfig, + userPassword, +} from "./utils.js" + +const configNodeA = { + endpoint: { + ws: NODE_1_WS_ENDPOINT, + http: NODE_1_HTTP_ENDPOINT, + }, + disableAutoReconnect: true, +} + +const configNodeB = { + endpoint: { + ws: NODE_2_WS_ENDPOINT, + http: NODE_2_HTTP_ENDPOINT, + }, + disableAutoReconnect: true, +} + +let samaClientA = void 0 +let samaClientB = void 0 +let samaClientB_Device2 = void 0 + +let userAToken = void 0 +let userBToken = void 0 + +let nodeA = void 0 +let nodeB = void 0 + +describe("Crash Node behavior:", () => { + before(async () => { + console.log("[Before][node-crash]") + + configNodeA.organization_id = dummyDataTestConfig.organizationId + configNodeB.organization_id = dummyDataTestConfig.organizationId + + samaClientA = new SAMAClient(configNodeA) + samaClientA.deviceId = v4() + samaClientB = new SAMAClient(configNodeB) + samaClientB.deviceId = v4() + samaClientB_Device2 = new SAMAClient(configNodeA) + samaClientB_Device2.deviceId = v4() + + nodeA = await startOrAccessNodeA() + nodeB = await startOrAccessNodeB() + + await setTimeoutPromise(500) + }) + + describe("NodeA(userA) - NodeB(userB) - NodeB crash (wait fro clean):", () => { + it("connect client userA", async () => { + await samaClientA.connect() + const { token } = await samaClientA.socketLogin({ user: { login: dummyDataTestConfig.users.userA.login, password: userPassword } }) + userAToken = token + }) + + it("subscribe userB last activity", async () => { + const activity = await samaClientA.subscribeToUserActivity(dummyDataTestConfig.users.userB.nativeId) + assert.ok(activity[dummyDataTestConfig.users.userB.nativeId] > 0) + }) + + it("connect client userB and check last activity", (done) => { + samaClientB + .connect() + .then(() => + samaClientB.socketLogin({ + user: { login: dummyDataTestConfig.users.userB.login, password: userPassword }, + }) + ) + .then(({ token }) => (userBToken = token)) + + samaClientA.onUserActivityListener = (activity) => { + assert.equal(activity[dummyDataTestConfig.users.userB.nativeId], 0) + done() + } + }) + + describe("Messaging:", () => { + it("userA -> userB", (done) => { + const mId = v4() + const body = `Cluster Test Hello m: ${mId}` + samaClientA.messageCreate({ cid: dummyDataTestConfig.conversations.group.nativeId, body: body, mid: mId, x: { two: "2" } }) + + samaClientB.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userA.nativeId) + assert.equal(message.x["two"], "2") + + done() + } + }) + + it("userB -> userA", (done) => { + const mId = v4() + const body = `Cluster Test Hello m: ${mId}` + samaClientB.messageCreate({ cid: dummyDataTestConfig.conversations.group.nativeId, body: body, mid: mId, x: { two: "22" } }) + + samaClientA.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + assert.equal(message.x["two"], "22") + + done() + } + }) + }) + + describe("NodeB Crash", () => { + it("kill NodeB and wait events userB last activity to NodeA", async () => { + const closePromise = new Promise((resolve, reject) => { + nodeA.once(`node_close[${nodeB.clusterEndpoint}]`, () => { + console.log("[node][close]", nodeB.clusterEndpoint) + resolve() + }) + }) + + const startReconnectPromise = new Promise((resolve, reject) => { + nodeA.once(`node_reconnect_start[${nodeB.clusterEndpoint}]`, () => { + console.log("[node][reconnect][start]", nodeB.clusterEndpoint) + resolve() + }) + }) + + const cleanNodeBPromise = new Promise((resolve, reject) => { + nodeA.once(`node_clean[${nodeB.clusterEndpoint}]`, () => { + console.log("[node][clean]", nodeB.clusterEndpoint) + resolve() + }) + }) + + const userBOfflinePromise = new Promise((resolve, reject) => { + samaClientA.onUserActivityListener = async (activity) => { + assert.ok(activity[dummyDataTestConfig.users.userB.nativeId] > 0) + + activity = await samaClientA.subscribeToUserActivity(dummyDataTestConfig.users.userB.nativeId) + assert.ok(activity[dummyDataTestConfig.users.userB.nativeId] > 0) + + resolve() + } + }) + + nodeB.kill() + + await Promise.all([closePromise, startReconnectPromise, cleanNodeBPromise, userBOfflinePromise]) + }) + }) + + describe("Run new NodeB and reconnect userB", () => { + it("start new NodeB", async () => { + nodeB = await startOrAccessNodeB(true) + + const nodeReadyPromise = new Promise((resolve, reject) => { + nodeB.once("node_ready", (nodeClusterWsEndpoint) => { + nodeB.clusterEndpoint = nodeClusterWsEndpoint + console.log("[node][ready]", nodeB.pid, nodeB.clusterEndpoint) + resolve() + }) + }) + + const nodeHandShackedPromise = new Promise((resolve, reject) => { + nodeB.once(`node_connected[${nodeA.clusterEndpoint}]`, (nodeClusterWsEndpoint) => { + console.log("[node][connected]", nodeClusterWsEndpoint) + resolve() + }) + }) + + await Promise.all([nodeReadyPromise, nodeHandShackedPromise]) + }) + + it("connect client userB and check last activity", (done) => { + samaClientB + .connect() + .then(() => samaClientB.socketLogin({ token: userBToken })) + .then(({ token }) => (userBToken = token)) + samaClientA.onUserActivityListener = (activity) => { + assert.equal(activity[dummyDataTestConfig.users.userB.nativeId], 0) + done() + } + }) + + describe("it can send message to each other:", () => { + it("userA -> userB", (done) => { + const mId = v4() + const body = `Cluster Test Hello m: ${mId}` + samaClientA.messageCreate({ cid: dummyDataTestConfig.conversations.group.nativeId, body: body, mid: mId, x: { two: "2" } }) + + samaClientB.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userA.nativeId) + assert.equal(message.x["two"], "2") + + done() + } + }) + + it("userB -> userA", (done) => { + const mId = v4() + const body = `Cluster Test Hello m: ${mId}` + samaClientB.messageCreate({ cid: dummyDataTestConfig.conversations.group.nativeId, body: body, mid: mId, x: { two: "22" } }) + + samaClientA.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + assert.equal(message.x["two"], "22") + + done() + } + }) + }) + }) + }) + + describe("NodeA(userA) - NodeB(userB) - NodeA restarting", () => { + it("NodeA crash", async () => { + const closePromise = new Promise((resolve, reject) => { + nodeB.once(`node_close[${nodeA.clusterEndpoint}]`, () => { + console.log("[node][close]", nodeA.clusterEndpoint) + resolve() + }) + }) + + const startReconnectPromise = new Promise((resolve, reject) => { + nodeB.once(`node_reconnect_start[${nodeA.clusterEndpoint}]`, () => { + console.log("[node][reconnect][start]", nodeA.clusterEndpoint) + resolve() + }) + }) + + nodeA.kill() + + await Promise.all([closePromise, startReconnectPromise]) + }) + + it("check last activity userA", async () => { + const activity = await samaClientB.subscribeToUserActivity(dummyDataTestConfig.users.userA.nativeId) + assert.ok(activity[dummyDataTestConfig.users.userA.nativeId] === 0) + }) + + it("restart NodeA (same cluster port)", async () => { + const nodeAClusterEndpoint = nodeA.clusterEndpoint + + const finishReconnectPromise = new Promise((resolve, reject) => { + nodeB.once(`node_reconnect_finish[${nodeAClusterEndpoint}]`, () => { + console.log("[node][reconnect][finish]", nodeAClusterEndpoint) + resolve() + }) + }) + + const { port } = new URL(nodeAClusterEndpoint) + const nodeEnv = { APP_CLUSTER_PORT: `${port}` } + + nodeA = await startOrAccessNodeA(false, nodeEnv) + + await finishReconnectPromise + }) + + it("connect userA to NodeA", (done) => { + samaClientA + .connect() + .then(() => samaClientA.socketLogin({ token: userAToken })) + .then(({ token }) => (userAToken = token)) + + samaClientB.onUserActivityListener = (activity) => { + assert.equal(activity[dummyDataTestConfig.users.userA.nativeId], 0) + done() + } + }) + + describe("it can send message to each other:", () => { + it("userA -> userB", (done) => { + const mId = v4() + const body = `Cluster Test Hello m: ${mId}` + samaClientA.messageCreate({ cid: dummyDataTestConfig.conversations.group.nativeId, body: body, mid: mId, x: { two: "2" } }) + + samaClientB.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userA.nativeId) + assert.equal(message.x["two"], "2") + + done() + } + }) + + it("userB -> userA", (done) => { + const mId = v4() + const body = `Cluster Test Hello m: ${mId}` + samaClientB.messageCreate({ cid: dummyDataTestConfig.conversations.group.nativeId, body: body, mid: mId, x: { two: "22" } }) + + samaClientA.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + assert.equal(message.x["two"], "22") + + done() + } + }) + }) + }) + + describe("NodeA(userA,userB_Device2) - NodeB(userB_Device1) - NodeB Crush check last activity behavior:", () => { + describe("Connect userB_Device2", () => { + it("subscribe last activity userB", async () => { + const activity = await samaClientA.subscribeToUserActivity(dummyDataTestConfig.users.userB.nativeId) + assert.ok(activity[dummyDataTestConfig.users.userB.nativeId] === 0) + }) + + it("connect userB_Device2 to NodeA", (done) => { + samaClientB_Device2 + .connect() + .then(() => samaClientB_Device2.socketLogin({ user: { login: dummyDataTestConfig.users.userB.login, password: userPassword } })) + + samaClientA.onUserActivityListener = (activity) => { + assert.equal(activity[dummyDataTestConfig.users.userB.nativeId], 0) + done() + } + }) + }) + + describe("kill NodeB check last activity behavior", () => { + it("kill NodeB wait no last activity", async () => { + const closePromise = new Promise((resolve, reject) => { + nodeA.once(`node_close[${nodeB.clusterEndpoint}]`, () => { + console.log("[node][close]", nodeB.clusterEndpoint) + resolve() + }) + }) + + const startReconnectPromise = new Promise((resolve, reject) => { + nodeA.once(`node_reconnect_start[${nodeB.clusterEndpoint}]`, () => { + console.log("[node][reconnect][start]", nodeB.clusterEndpoint) + resolve() + }) + }) + + const cleanNodeBPromise = new Promise((resolve, reject) => { + nodeA.once(`node_clean[${nodeB.clusterEndpoint}]`, () => { + console.log("[node][clean]", nodeB.clusterEndpoint) + resolve() + }) + }) + + const userBNoOfflinePromise = new Promise((resolve, reject) => { + cleanNodeBPromise.then(() => setTimeoutPromise(1_000)).then(() => resolve()) + + samaClientA.onUserActivityListener = async (activity) => { + if (activity[dummyDataTestConfig.users.userB.nativeId] > 0) { + reject(new Error("Should not be called")) + } + } + }) + + nodeB.kill() + + await Promise.all([closePromise, startReconnectPromise, cleanNodeBPromise, userBNoOfflinePromise]) + }) + + it("check userB activity", async () => { + const activity = await samaClientA.subscribeToUserActivity(dummyDataTestConfig.users.userB.nativeId) + assert.ok(activity[dummyDataTestConfig.users.userB.nativeId] === 0) + }) + + it("close last userB connection on NodeA", (done) => { + samaClientB_Device2.disconnect() + + samaClientA.onUserActivityListener = (activity) => { + assert.ok(activity[dummyDataTestConfig.users.userB.nativeId] > 0) + done() + } + }) + }) + + describe("Start new NodeB", () => { + it("start", async () => { + nodeB = await startOrAccessNodeB() + + await setTimeoutPromise(500) + }) + + it("connect userB", (done) => { + samaClientB.connect().then(() => samaClientB.socketLogin({ token: userBToken })) + samaClientA.onUserActivityListener = (activity) => { + assert.equal(activity[dummyDataTestConfig.users.userB.nativeId], 0) + done() + } + }) + }) + }) + + describe("NodeA(userA) - NodeB(userB) - NodeA crush - userA connect to NodeB:", () => { + it("crash NodeA", async () => { + const closePromise = new Promise((resolve, reject) => { + nodeB.once(`node_close[${nodeA.clusterEndpoint}]`, () => { + console.log("[node][close]", nodeA.clusterEndpoint) + resolve() + }) + }) + + const startReconnectPromise = new Promise((resolve, reject) => { + nodeB.once(`node_reconnect_start[${nodeA.clusterEndpoint}]`, () => { + console.log("[node][reconnect][start]", nodeA.clusterEndpoint) + resolve() + }) + }) + + nodeA.kill() + + await Promise.all([closePromise, startReconnectPromise]) + }) + + it("subscribe last activity userB to userA", async () => { + const activity = await samaClientB.subscribeToUserActivity(dummyDataTestConfig.users.userA.nativeId) + assert.ok(activity[dummyDataTestConfig.users.userA.nativeId] === 0) + }) + + it("connect userA to NodeB", (done) => { + samaClientA.connect(NODE_2_WS_ENDPOINT, NODE_2_HTTP_ENDPOINT).then(() => samaClientA.socketLogin({ token: userAToken })) + samaClientB.onUserActivityListener = (activity) => { + assert.equal(activity[dummyDataTestConfig.users.userA.nativeId], 0) + done() + } + }) + + describe("it can send message to each other:", () => { + it("userA -> userB", (done) => { + const mId = v4() + const body = `Cluster Test Hello m: ${mId}` + samaClientA.messageCreate({ cid: dummyDataTestConfig.conversations.group.nativeId, body: body, mid: mId, x: { two: "2" } }) + + samaClientB.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userA.nativeId) + assert.equal(message.x["two"], "2") + + done() + } + }) + + it("userB -> userA", (done) => { + const mId = v4() + const body = `Cluster Test Hello m: ${mId}` + samaClientB.messageCreate({ cid: dummyDataTestConfig.conversations.group.nativeId, body: body, mid: mId, x: { two: "22" } }) + + samaClientA.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + assert.equal(message.x["two"], "22") + + done() + } + }) + }) + }) +}) diff --git a/test/cluster/same-node.spec.js b/test/cluster/same-node.spec.js new file mode 100644 index 00000000..02146585 --- /dev/null +++ b/test/cluster/same-node.spec.js @@ -0,0 +1,299 @@ +import assert from "node:assert" +import { setTimeout as setTimeoutPromise } from "node:timers/promises" + +import { v4 } from "uuid" +import { SAMAClient } from "@sama-communications/sdk" + +import { startOrAccessNodeA, NODE_1_WS_ENDPOINT, NODE_1_HTTP_ENDPOINT, dummyDataTestConfig, userPassword } from "./utils.js" + +const configNodeA = { + endpoint: { + ws: NODE_1_WS_ENDPOINT, + http: NODE_1_HTTP_ENDPOINT, + }, + disableAutoReconnect: true, +} + +let samaClientA = void 0 +let samaClientB = void 0 + +let userAToken = void 0 +let userBToken = void 0 + +let nodeA = void 0 + +describe("Same-node behavior", () => { + before(async () => { + console.log("[Before][same-node]") + + configNodeA.organization_id = dummyDataTestConfig.organizationId + + samaClientA = new SAMAClient(configNodeA) + samaClientA.deviceId = v4() + + samaClientB = new SAMAClient(configNodeA) + samaClientB.deviceId = v4() + + dummyDataTestConfig.conversations.group.nativeId + + nodeA = await startOrAccessNodeA() + }) + + describe("Connect", () => { + it("connect client userA", async () => { + await samaClientA.connect() + const { token } = await samaClientA.socketLogin({ user: { login: dummyDataTestConfig.users.userA.login, password: userPassword } }) + userAToken = token + }) + + it("subscribe userB last activity", async () => { + const activity = await samaClientA.subscribeToUserActivity(dummyDataTestConfig.users.userB.nativeId) + assert.ok(activity[dummyDataTestConfig.users.userB.nativeId] > 0) + }) + + it("connect client userB and check last activity", (done) => { + samaClientB + .connect() + .then(() => + samaClientB.socketLogin({ + user: { login: dummyDataTestConfig.users.userB.login, password: userPassword }, + }) + ) + .then(({ token }) => (userBToken = token)) + + samaClientA.onUserActivityListener = (activity) => { + assert.equal(activity[dummyDataTestConfig.users.userB.nativeId], 0) + done() + } + }) + + it("subscribe userA last activity", async () => { + const activity = await samaClientB.subscribeToUserActivity(dummyDataTestConfig.users.userA.nativeId) + assert.ok(activity[dummyDataTestConfig.users.userA.nativeId] === 0) + }) + }) + + describe("Base messaging", () => { + describe("System", () => { + describe("userA -> userB", () => { + it("event", (done) => { + const mId = v4() + samaClientA.messageSystem({ mid: mId, uids: [dummyDataTestConfig.users.userB.nativeId], x: { one: "1" } }) + + samaClientB.onSystemMessageEvent = (message) => { + assert.equal(message._id, mId) + assert.equal(message.from, dummyDataTestConfig.users.userA.nativeId) + + done() + } + }) + }) + + describe("userB -> userA", () => { + it("event", (done) => { + const mId = v4() + samaClientB.messageSystem({ mid: mId, uids: [dummyDataTestConfig.users.userA.nativeId], x: { two: "2" } }) + + samaClientA.onSystemMessageEvent = (message) => { + assert.equal(message._id, mId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + + done() + } + }) + }) + }) + + describe("Private", () => { + describe("userA -> userB", () => { + let messageId = void 0 + + it("typing", (done) => { + samaClientA.sendTypingStatus({ cid: dummyDataTestConfig.conversations.private.nativeId, status: "start" }) + + samaClientB.onUserTypingListener = (typing) => { + assert.equal(typing.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(typing.from, dummyDataTestConfig.users.userA.nativeId) + done() + } + }) + + it("message", (done) => { + const mId = v4() + const body = `Cluster Test Hello m: ${mId}` + samaClientA.messageCreate({ cid: dummyDataTestConfig.conversations.private.nativeId, body: body, mid: mId, x: { two: "2" } }) + + samaClientB.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userA.nativeId) + assert.equal(message.x["two"], "2") + + messageId = message._id + + done() + } + }) + + it("read status", (done) => { + samaClientB.markConversationAsRead({ cid: dummyDataTestConfig.conversations.private.nativeId, mids: [messageId] }) + + samaClientA.onMessageStatusListener = (status) => { + assert.equal(status.ids.at(0), messageId) + assert.equal(status.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(status.from, dummyDataTestConfig.users.userB.nativeId) + + done() + } + }) + }) + + describe("userB -> userA", () => { + let messageId = void 0 + + it("typing", (done) => { + samaClientB.sendTypingStatus({ cid: dummyDataTestConfig.conversations.private.nativeId, status: "start" }) + + samaClientA.onUserTypingListener = (typing) => { + assert.equal(typing.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(typing.from, dummyDataTestConfig.users.userB.nativeId) + done() + } + }) + + it("message", (done) => { + const mId = v4() + const body = `Cluster Test Hello m: ${mId}` + samaClientB.messageCreate({ cid: dummyDataTestConfig.conversations.private.nativeId, body: body, mid: mId, x: { two: "22" } }) + + samaClientA.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + assert.equal(message.x["two"], "22") + + messageId = message._id + + done() + } + }) + + it("read status", (done) => { + samaClientA.markConversationAsRead({ cid: dummyDataTestConfig.conversations.private.nativeId, mids: [messageId] }) + + samaClientB.onMessageStatusListener = (status) => { + assert.equal(status.ids.at(0), messageId) + assert.equal(status.cid, dummyDataTestConfig.conversations.private.nativeId) + assert.equal(status.from, dummyDataTestConfig.users.userA.nativeId) + + done() + } + }) + }) + }) + + describe("Group", () => { + describe("userA -> userB", () => { + let messageId = void 0 + + it("typing", (done) => { + samaClientA.sendTypingStatus({ cid: dummyDataTestConfig.conversations.group.nativeId, status: "start" }) + + samaClientB.onUserTypingListener = (typing) => { + assert.equal(typing.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(typing.from, dummyDataTestConfig.users.userA.nativeId) + done() + } + }) + + it("message", (done) => { + const mId = v4() + const body = `Cluster Test Hello m: ${mId}` + samaClientA.messageCreate({ cid: dummyDataTestConfig.conversations.group.nativeId, body: body, mid: mId, x: { one: "1" } }) + + samaClientB.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userA.nativeId) + assert.equal(message.x["one"], "1") + + messageId = message._id + + done() + } + }) + + it("read status", (done) => { + samaClientB.markConversationAsRead({ cid: dummyDataTestConfig.conversations.group.nativeId, mids: [messageId] }) + + samaClientA.onMessageStatusListener = (status) => { + assert.equal(status.ids.at(0), messageId) + assert.equal(status.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(status.from, dummyDataTestConfig.users.userB.nativeId) + + done() + } + }) + }) + + describe("userB -> userA", () => { + let messageId = void 0 + + it("typing", (done) => { + samaClientB.sendTypingStatus({ cid: dummyDataTestConfig.conversations.group.nativeId, status: "start" }) + + samaClientA.onUserTypingListener = (typing) => { + assert.equal(typing.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(typing.from, dummyDataTestConfig.users.userB.nativeId) + done() + } + }) + + it("message", (done) => { + const mId = v4() + const body = `Cluster Test Hello m: ${mId}` + samaClientB.messageCreate({ cid: dummyDataTestConfig.conversations.group.nativeId, body: body, mid: mId, x: { one: "11" } }) + + samaClientA.onMessageEvent = (message) => { + assert.equal(message.body, body) + assert.equal(message.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(message.from, dummyDataTestConfig.users.userB.nativeId) + assert.equal(message.x["one"], "11") + + messageId = message._id + + done() + } + }) + + it("read status", (done) => { + samaClientA.markConversationAsRead({ cid: dummyDataTestConfig.conversations.group.nativeId, mids: [messageId] }) + + samaClientB.onMessageStatusListener = (status) => { + assert.equal(status.ids.at(0), messageId) + assert.equal(status.cid, dummyDataTestConfig.conversations.group.nativeId) + assert.equal(status.from, dummyDataTestConfig.users.userA.nativeId) + + done() + } + }) + }) + }) + }) + + describe("Activity on disconnect", () => { + it("userA logout", (done) => { + samaClientA.disconnect() + + samaClientB.onUserActivityListener = (activity) => { + assert.ok(activity[dummyDataTestConfig.users.userA.nativeId] > 0) + done() + } + }) + + it("disconnect client with userB", async () => { + samaClientB.disconnect() + await setTimeoutPromise(200) + }) + }) +}) diff --git a/test/cluster/utils.js b/test/cluster/utils.js new file mode 100644 index 00000000..cc1283b2 --- /dev/null +++ b/test/cluster/utils.js @@ -0,0 +1,211 @@ +import { spawn } from "node:child_process" +import { Transform } from "node:stream" +import { setTimeout as setTimeoutPromise } from "node:timers/promises" +import process from "node:process" + +import { faker } from "@faker-js/faker" +import { SAMAClient } from "@sama-communications/sdk" +import { v4 } from "uuid" + +export const RUN_NODE_1_CMD = process.env.RUN_NODE_1_CMD +export const RUN_NODE_2_CMD = process.env.RUN_NODE_2_CMD + +export const NODE_1_WS_ENDPOINT = process.env.NODE_1_WS_ENDPOINT +export const NODE_2_WS_ENDPOINT = process.env.NODE_2_WS_ENDPOINT +export const NODE_1_HTTP_ENDPOINT = process.env.NODE_1_HTTP_ENDPOINT +export const NODE_2_HTTP_ENDPOINT = process.env.NODE_2_HTTP_ENDPOINT + +export const userPassword = "12345678" +export const dummyDataTestConfig = { + organizationId: void 0, + users: { + userA: { nativeId: void 0, login: void 0, password: userPassword }, + userB: { nativeId: void 0, login: void 0, password: userPassword }, + }, + conversations: { + private: { nativeId: void 0 }, + group: { nativeId: void 0 }, + }, +} + +let nodeA = void 0 +let nodeB = void 0 + +// ws endpoint reg: /wss?:\/\/wss?:\/\/.+\// + +const createPipeStream = (tag, nodeSubprocess) => + new (class extends Transform { + _transform(chunk, encoding, cb) { + chunk = chunk.toString() + const withPrefixChunk = `|-${tag}-|${chunk}` + cb(void 0, withPrefixChunk) + + let regResult = chunk.match(/\[Ready\] cluster-ws: (wss?:\/\/.+\/)/) + if (regResult) { + const nodeClusterWsEndpoint = regResult.at(1) + nodeSubprocess.emit("node_ready", nodeClusterWsEndpoint) + } + + regResult = chunk.match(/\[node handshake finished\] (wss?:\/\/.+\/)/) + if (regResult) { + const nodeClusterWsEndpoint = regResult.at(1) + nodeSubprocess.emit(`node_connected[${nodeClusterWsEndpoint}]`, nodeClusterWsEndpoint) + } + + regResult = chunk.match(/\[clean node\] (wss?:\/\/.+\/)/) + if (regResult) { + const nodeClusterWsEndpoint = regResult.at(1) + nodeSubprocess.emit(`node_clean[${nodeClusterWsEndpoint}]`, nodeClusterWsEndpoint) + } + + regResult = chunk.match(/\[Close\]\[(wss?:\/\/.+\/)\] IsWasOpened/) + if (regResult) { + const nodeClusterWsEndpoint = regResult.at(1) + nodeSubprocess.emit(`node_close[${nodeClusterWsEndpoint}]`, nodeClusterWsEndpoint) + } + + regResult = chunk.match(/\[Reconnecting\]\[(wss?:\/\/.+\/)\]\[start\]/) + if (regResult) { + const nodeClusterWsEndpoint = regResult.at(1) + nodeSubprocess.emit(`node_reconnect_start[${nodeClusterWsEndpoint}]`, nodeClusterWsEndpoint) + } + + regResult = chunk.match(/\[Reconnecting\]\[(wss?:\/\/.+\/)\]\[finish\]/) + if (regResult) { + const nodeClusterWsEndpoint = regResult.at(1) + nodeSubprocess.emit(`node_reconnect_finish[${nodeClusterWsEndpoint}]`, nodeClusterWsEndpoint) + } + } + })() + +export const spawnNode = async (cmd, env, tag, notWaitReady) => { + const nodeSubprocess = spawn(cmd, [], { + shell: true, + env: env ? { ...process.env, ...env } : void 0, + stdio: ["ignore", "pipe", "pipe"], + }) + + const nodeSubprocessReadyPromise = notWaitReady + ? true + : new Promise((resolve, reject) => { + const rejectTimerId = setTimeout(() => reject(new Error("Timeout")), 5_000) + + nodeSubprocess.once("node_ready", (nodeClusterWsEndpoint) => { + nodeSubprocess.clusterEndpoint = nodeClusterWsEndpoint + console.log("[Node][Ready]", tag, nodeSubprocess.pid, nodeSubprocess.clusterEndpoint) + resolve(nodeSubprocess) + clearTimeout(rejectTimerId) + }) + }) + + nodeSubprocess.stdout.pipe(createPipeStream(tag, nodeSubprocess)).pipe(process.stdout) + nodeSubprocess.stderr.pipe(createPipeStream(tag, nodeSubprocess)).pipe(process.stderr) + + await nodeSubprocessReadyPromise + + return nodeSubprocess +} + +export const startOrAccessNodeA = async (notWaitReady, env) => { + return nodeA && !nodeA.killed ? nodeA : await spawnNode(RUN_NODE_1_CMD, env, "NODE_A", notWaitReady).then((node) => (nodeA = node)) +} + +export const startOrAccessNodeB = async (notWaitReady, env) => { + return nodeB && !nodeB.killed ? nodeB : await spawnNode(RUN_NODE_2_CMD, env, "NODE_B", notWaitReady).then((node) => (nodeB = node)) +} + +export const createOrganizationId = async (endpoint, name) => { + const orgName = `Test-${name ?? faker.company.name()}-${faker.string.alphanumeric(8)}` + const payload = JSON.stringify({ name: orgName }) + + const response = await fetch(`${endpoint}/admin/organization`, { + method: "POST", + headers: { "Content-Type": "application/json", "admin-api-key": process.env.HTTP_ADMIN_API_KEY }, + body: payload, + }).then((response) => response.json()) + + const organizationId = response.organization._id + + return organizationId +} + +export const initSdkWithUser = async (organizationId, needLogin, loginPrefix, deviceIdPrefix = "dv", endpointWs, endpointHttp) => { + const sdkConfig = { + endpoint: { + ws: endpointWs, + http: endpointHttp, + }, + organization_id: organizationId, + disableAutoReconnect: true, + } + + const samaSdk = new SAMAClient(sdkConfig) + samaSdk.deviceId = `${deviceIdPrefix}-${v4()}` + + await samaSdk.connect() + + const user = await createUser(samaSdk, loginPrefix) + + if (needLogin) { + await samaSdk.socketLogin({ user: { login: user.login, password: userPassword } }) + } + + return { samaSdk, user } +} + +export const createUser = async (samaSdk, loginPrefix = "TestUser") => { + const login = `${loginPrefix}-${faker.internet.username()}` + + const user = await samaSdk.userCreate({ + login: login, + email: faker.internet.email(), + password: userPassword, + }) + + return user +} + +export const createPrivateConversation = async (samaSdk, userAId, userBId) => { + const privateConversation = await samaSdk.conversationCreate({ + name: `Private-${faker.music.songName()}-${faker.string.alphanumeric(8)}`, + type: "u", + participants: [userAId, userBId], + }) + + return privateConversation +} + +export const createGroupConversation = async (samaSdk, userIds) => { + const groupConversation = await samaSdk.conversationCreate({ + name: `Group-${faker.music.songName()}-${faker.string.alphanumeric(8)}`, + type: "g", + participants: userIds, + }) + + return groupConversation +} + +export const createDummyData = async (endpointWs, endpointHttp) => { + dummyDataTestConfig.organizationId = await createOrganizationId(endpointHttp) + + const { samaSdk, user: userA } = await initSdkWithUser(dummyDataTestConfig.organizationId, true, void 0, void 0, endpointWs, endpointHttp) + dummyDataTestConfig.users.userA.login = userA.login + dummyDataTestConfig.users.userA.nativeId = userA.native_id + + const userB = await createUser(samaSdk) + dummyDataTestConfig.users.userB.login = userB.login + dummyDataTestConfig.users.userB.nativeId = userB.native_id + + const privateConversation = await createPrivateConversation(samaSdk, userA.native_id, userB.native_id) + dummyDataTestConfig.conversations.private.nativeId = privateConversation._id + + const groupConversation = await createGroupConversation(samaSdk, [userA.native_id, userB.native_id]) + dummyDataTestConfig.conversations.group.nativeId = groupConversation._id + + samaSdk.disconnect() + + await setTimeoutPromise(200) +} + +export const killNodeA = () => nodeA?.kill() +export const killNodeB = () => nodeB?.kill() diff --git a/test/push_notifications.spec.js b/test/push_notifications.spec.js index a423a568..5dbd7ede 100644 --- a/test/push_notifications.spec.js +++ b/test/push_notifications.spec.js @@ -577,15 +577,21 @@ describe("PushNotification functions", async () => { const deliverMessage = responseOnMessage.deliverMessages.at(0) - await packetManager.deliverToUserOrUsers( - deliverMessage.orgId, - deliverMessage.ws, - deliverMessage.packet, - deliverMessage.pushQueueMessage, - deliverMessage.userIds, - deliverMessage.notSaveInOfflineStorage, - deliverMessage.ignoreSelf - ) + const sourceOptions = { + organizationId: deliverMessage.orgId, + socket: deliverMessage.socket, + } + + const destinationUserIds = deliverMessage.userIds + + const payloadOptions = { + packet: deliverMessage.packet, + notSaveInOfflineStorage: deliverMessage.notSaveInOfflineStorage, + ignoreSelf: deliverMessage.ignoreSelf, + pushQueueMessage: deliverMessage.pushQueueMessage, + } + + await packetManager.deliverToUserOrUsers(sourceOptions, destinationUserIds, payloadOptions) assert.ok(pushQueueItem) }) diff --git a/test/users.spec.js b/test/users.spec.js index 0ab47e3e..44cad1d9 100644 --- a/test/users.spec.js +++ b/test/users.spec.js @@ -7,6 +7,7 @@ import { generateNewOrganizationId } from "./tools/utils.js" import packetJsonProcessor from "../APIs/JSON/routes/packet_processor.js" const userRepo = ServiceLocatorContainer.use("UserRepository") +const orgRepo = ServiceLocatorContainer.use("OrganizationRepository") let orgId = void 0 let userLogin = [...Array(30)].map(() => Math.random().toString(36)[2]).join("") @@ -266,6 +267,71 @@ describe("User cycle", async () => { }) }) + describe("Create/Login in blocked organization", () => { + before(async () => { + await orgRepo.updateOne({ _id: orgId }, { $set: { is_blocked: true, block_reason: "Out of limits" } }) + }) + + it("create in blocked organization", async () => { + const requestData = { + request: { + user_create: { + organization_id: orgId, + login: userLogin, + email: "email_1_1", + phone: "phone_1_1", + password: "user_paswword_1_1", + device_id: "deveice11", + }, + id: "1_1_blocked", + }, + } + + let responseData = await packetJsonProcessor.processMessageOrError("test", JSON.stringify(requestData)) + responseData = responseData.backMessages.at(0) + + assert.strictEqual(requestData.request.id, responseData.response.id) + assert.deepEqual(responseData.response.error, { + isBlocked: true, + reason: "Out of limits", + status: 403, + message: "Organization was blocked.", + }) + assert.equal(responseData.response.user, void 0) + }) + + it("login in blocked organization", async () => { + const requestData = { + request: { + user_login: { + organization_id: orgId, + device_id: "PC_Home", + login: userLogin, + password: "user_paswword_1", + }, + id: "2_3_blocked", + }, + } + + let responseData = await packetJsonProcessor.processMessageOrError("test", JSON.stringify(requestData)) + + responseData = responseData.backMessages.at(0) + + assert.strictEqual(requestData.request.id, responseData.response.id) + assert.deepEqual(responseData.response.error, { + isBlocked: true, + reason: "Out of limits", + status: 403, + message: "Organization was blocked.", + }) + assert.equal(responseData.response.user, void 0) + }) + + after(async () => { + await orgRepo.updateOne({ _id: orgId }, { $unset: { is_blocked: 1, blocked_reason: 1 } }) + }) + }) + describe("Get Users By IDs", async () => { it(`should fail ids array is empty`, async () => { const requestData = {