diff --git a/admin.js b/admin.js index 6eeacc8..3c96b1b 100644 --- a/admin.js +++ b/admin.js @@ -40,17 +40,19 @@ function JSONStringifyCircular(obj) { const seen = new WeakSet(); return JSON.stringify (obj, (key, value) => { if (typeof value === "object" && value !== null) { - if (seen.has(value)) { - return; - } - seen.add(value); + if (seen.has(value)) { + return; + } + + seen.add(value); } + return value; }); } module.exports = { - init: function (io, syncServer, chatServer) { + init: function (io, logger, syncServer, chatServer) { var admin = io.of('/admin'); admin.use((socket, next) => { @@ -58,11 +60,12 @@ module.exports = { next(); }); - admin.on('connection', function(socket) { //TODO finish or remove. + admin.on('connection', function(socket) { + //TODO finish or remove. + // TODO(Brandon): log connection here socket.emit("adminInfo", socket.id); socket.on('getAllSessions0', function() { - this.sessions = syncServer.sessions; socket.emit('receiveAllSessions0', JSONStringifyCircular(Array.from(sessions.entries()))); @@ -94,7 +97,6 @@ module.exports = { }); socket.on('sessionsWithDetails', function () { - this.sessions = syncServer.sessions; var result = fromEntries(this.sessions); @@ -103,17 +105,14 @@ module.exports = { }); socket.on('stateClientsSockets', function () { - this.sessions = syncServer.sessions; var stateClientsSockets = {}; this.sessions.forEach((session, session_id, map) => { - stateClientsSockets[session_id] = {}; stateClientsSockets[session_id].state = { - clients: session.clients, entities: session.entities, scene: session.scene, @@ -123,7 +122,6 @@ module.exports = { stateClientsSockets[session_id].clientsAndSockets = []; for (var socket_id in session.sockets) { - var client_id = session.sockets[socket_id].client_id; stateClientsSockets[session_id].clientsAndSockets.push(`${client_id} - ${socket_id}`); @@ -149,13 +147,11 @@ module.exports = { var sockets = io.of("/").sockets; for (var socket_id in sockets) { - let curSocketObj = sockets[socket_id]; socketsAndRooms[socket_id] = []; for (var room_id in curSocketObj.rooms) { - socketsAndRooms[socket_id].push(room_id); } } @@ -179,5 +175,9 @@ module.exports = { socket.emit('clients', JSON.stringify(sessionToClient)); }); }); + + logger.info(`Admin namespace is waiting for connections...`); + + return admin; } }; \ No newline at end of file diff --git a/chat.js b/chat.js index b74dc30..cc2c9c9 100644 --- a/chat.js +++ b/chat.js @@ -45,7 +45,7 @@ module.exports = { var chat = io.of('/chat'); chat.on('connection', function(socket) { - if (logger) logger.info(`Chat connection: ${socket.id}`); + // TODO(Brandon): log connection here // setup text chat relay socket.on('micText', function(data) { @@ -142,5 +142,9 @@ module.exports = { } }); }); + + logger.info(`Chat namespace is waiting for connections...`); + + return chat; } }; diff --git a/package.json b/package.json index 67ff87e..e99be69 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Relay server for Komodo VR client application", "main": "serve.js", "scripts": { - "test": "nyc mocha --exit" + "test": "nyc mocha --debug-brk --exit" }, "author": "Grainger IDEA Lab", "license": "NCSA", diff --git a/serve.js b/serve.js index 6920028..9e84d66 100644 --- a/serve.js +++ b/serve.js @@ -76,12 +76,12 @@ if (config.db.host && config.db.host != "") { testQuery = pool.query(`SHOW TABLES;`, (err, res) => { if (err) { - if (logger) logger.error(err); + if (logger) logger.error(`Tried to connect to database: ${err}`); process.exit(); + } else { + if (logger) logger.info(`Database initialized with ${res.length} tables.`); } - - else { if (logger) logger.info(`Database initialized with ${res.length} tables.`); } }); if (logger) logger.info(`Database pool created: host: ${config.db.host}, database: ${config.db.database}.`); @@ -97,8 +97,8 @@ io.listen(PORT, { if (logger) logger.info(`Komodo relay is running on :${PORT}`); -syncServer.init(io, pool, logger); +var chatNamespace = chatServer.init(io, logger); -chatServer.init(io, logger); +var adminNamespace = adminServer.init(io, logger, syncServer, chatServer); -adminServer.init(io, syncServer, chatServer); \ No newline at end of file +syncServer.init(io, pool, logger, chatNamespace, adminNamespace); \ No newline at end of file diff --git a/session.js b/session.js index e69de29..956ce29 100644 --- a/session.js +++ b/session.js @@ -0,0 +1,231 @@ +class Session { + constructor(id) { + this.id = id; + this.sockets = {}; // socket.id -> client_id + this.clients = []; + this.entities = []; + this.scene = null; + this.isRecording = false; + this.start = Date.now(); + this.recordingStart = 0; + this.seq = 0; + // NOTE(rob) = DEPRECATED; use message_buffer. 8/3/2021 + // writers = { + // pos = { + // buffer = Buffer.alloc(this.positionWriteBufferSize()); + // cursor = 0 + // }; + // int = { + // buffer = Buffer.alloc(this.interactionWriteBufferSize()); + // cursor = 0 + // } + // }; + this.message_buffer = []; + } + + getId() { + return this.id; + } + + getSockets() { + return this.sockets; + } + + addSocket(socket, client_id) { + this.sockets[socket.id] = { client_id: client_id, socket: socket }; + } + + // Returns success = true if operation succeeded or false if socket or session with id were null. + // Returns isInSession if socket is in this.sockets. + hasSocket(socket) { + return socket.id in this.sockets; + } + + removeSocket(socket) { + let isInSession = this.hasSocket(socket); + + if (!isInSession) { + return false; + } + + delete this.sockets[socket.id]; + + return true; + } + + getSocketsFromClientId(client_id, excluded_socket_id) { + if (this.sockets == null) { + this.logErrorSessionClientSocketAction( + this.id, + client_id, + null, + `tried to get session sockets from client ID, but this.sockets was null` + ); + + return null; + } + + var result = []; + + for (var candidate_socket_id in this.sockets) { + let isCorrectId = + this.sockets[candidate_socket_id].client_id == client_id; + + let doExclude = + this.sockets[candidate_socket_id].socket.id == excluded_socket_id; + + if (isCorrectId && !doExclude) { + result.push(this.sockets[candidate_socket_id].socket); + } + } + + return result; + } + + getClients() { + return this.clients; + } + + addClient(client_id) { + if ( + this.clients == null || + typeof this.clients === "undefined" || + this.clients.length == 0 + ) { + this.clients = [client_id]; + + return true; + } + + this.clients.push(client_id); + + return true; + } + + removeClient(client_id) { + if (this.clients == null) { + return false; + } + + let index = this.clients.indexOf(client_id); + + if (this.clients.length == 0 || this.clients.indexOf(client_id) == -1) { + //client_id is not in the array, so we don't need to remove it. + return false; + } + + this.clients.splice(index, 1); + } + + removeDuplicateClients(client_id) { + if (this.clients == null) { + this.logErrorSessionClientSocketAction( + this.id, + client_id, + null, + `tried to remove duplicate client from session, but this.clients was null` + ); + + return; + } + + if (this.clients.length == 0) { + return; + } + + const first_instance = this.clients.indexOf(client_id); + + for (let i = 0; i < this.clients.length; i += 1) { + if (i != first_instance && this.clients[i] == client_id) { + this.clients.splice(i, 1); + } + } + } + + hasClient (client_id) { + const numInstances = this.getNumClientInstances(client_id); + + if (numInstances >= 1) { + return true; + } + + return false; + } + + getNumClientInstances(client_id) { + if (this.clients == null) { + this.logErrorSessionClientSocketAction( + this.id, + client_id, + null, + `Could not get number of client instances -- session was null or session.clients was null.` + ); + + return -1; + } + + var count = 0; + + this.clients.forEach((value) => { + if (value == client_id) { + count += 1; + } + }); + + return count; + } + + getTotalNumInstancesForAllClients () { + if (this.clients == null) { + this.logWarningSessionClientSocketAction( + this.id, + null, + null, + `the session's session.clients was null.` + ); + + return -1; + } + + return session.clients.length; + } + + getClientIdFromSocket(socket) { + if (this.sockets == null) { + this.logErrorSessionClientSocketAction( + this.id, + client_id, + null, + `tried to get client ID from session socket, but this.sockets was null` + ); + + return null; + } + + if (socket.id == null) { + this.logErrorSessionClientSocketAction( + this.id, + client_id, + null, + `tried to get client ID from session socket, but socket.id was null` + ); + + return null; + } + + if (this.sockets[socket.id] == null) { + this.logErrorSessionClientSocketAction( + this.id, + client_id, + null, + `tried to get client ID from session socket, but this.sockets[socket.id] was null` + ); + + return null; + } + + return this.sockets[socket.id].client_id; + } +} + +module.exports = Session; diff --git a/socket-activity-monitor.js b/socket-activity-monitor.js new file mode 100644 index 0000000..10a52ed --- /dev/null +++ b/socket-activity-monitor.js @@ -0,0 +1,60 @@ +// University of Illinois/NCSA +// Open Source License +// http://otm.illinois.edu/disclose-protect/illinois-open-source-license + +// Copyright (c) 2020 Grainger Engineering Library Information Center. All rights reserved. + +// Developed by: IDEA Lab +// Grainger Engineering Library Information Center - University of Illinois Urbana-Champaign +// https://library.illinois.edu/enx + +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal with +// the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to +// do so, subject to the following conditions: +// * Redistributions of source code must retain the above copyright notice, +// this list of conditions and the following disclaimers. +// * Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimers in the documentation +// and/or other materials provided with the distribution. +// * Neither the names of IDEA Lab, Grainger Engineering Library Information Center, +// nor the names of its contributors may be used to endorse or promote products +// derived from this Software without specific prior written permission. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE +// SOFTWARE. + +/* jshint esversion: 6 */ + +require('./session.js'); + +class SocketActivityMonitor { + constructor() { + this.socketTimes = new Map(); // socket ID -> timestamp + } + + addOrUpdateTime(socketId) { + this.updateTime(socketId); + } + + updateTime(socketId) { + this.socketTimes.set(socketId, Date.now()); + } + + getDeltaTime(socketId) { + return Date.now() - this.socketTimes.get(socketId); + } + + remove(socketId) { + this.socketTimes.delete(socketId); + } +} + +module.exports = SocketActivityMonitor; \ No newline at end of file diff --git a/socket-repair-center.js b/socket-repair-center.js new file mode 100644 index 0000000..5b26d44 --- /dev/null +++ b/socket-repair-center.js @@ -0,0 +1,93 @@ +// University of Illinois/NCSA +// Open Source License +// http://otm.illinois.edu/disclose-protect/illinois-open-source-license + +// Copyright (c) 2020 Grainger Engineering Library Information Center. All rights reserved. + +// Developed by: IDEA Lab +// Grainger Engineering Library Information Center - University of Illinois Urbana-Champaign +// https://library.illinois.edu/enx + +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal with +// the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to +// do so, subject to the following conditions: +// * Redistributions of source code must retain the above copyright notice, +// this list of conditions and the following disclaimers. +// * Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimers in the documentation +// and/or other materials provided with the distribution. +// * Neither the names of IDEA Lab, Grainger Engineering Library Information Center, +// nor the names of its contributors may be used to endorse or promote products +// derived from this Software without specific prior written permission. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE +// SOFTWARE. + +/* jshint esversion: 6 */ + +require('./session.js'); + +require('./socket-activity-monitor.js'); + +class SocketRepairCenter { + constructor(minRepairWaitTime, sessionManager, socketIOActionManager, socketActivityMonitor, logger) { + this.minRepairWaitTime = minRepairWaitTime; + + this.sessionManager = sessionManager; + + this.socketIOActionManager = socketIOActionManager; + + this.socketActivityMonitor = socketActivityMonitor; + + this.logger = logger; + + this.sockets = new Map(); // socket IDs -> sockets + } + + // Accept a new socket needing to be repaired. + add (socket) { + this.logger.logInfoSessionClientSocketAction(null, null, socket.id, `Added socket to repair center.`); + + this.sockets.set(socket.id, socket); + } + + // Set the socket free, to be with the session manager. + remove (socket) { + this.sockets.delete(socket.id); + } + + hasSocket (socket) { + return this.sockets.has(socket.id); + } + + repairSocketIfEligible (socket, session_id, client_id) { + if (!this.hasSocket(socket)) { + return; + } + + // this.logger.logInfoSessionClientSocketAction(null, null, null, `deltaTime: ${this.sockets.size}`); + let deltaTime = this.socketActivityMonitor.getDeltaTime(socket.id); + + // this.logger.logInfoSessionClientSocketAction(null, null, id, `deltaTime: ${deltaTime}`); + + if (deltaTime > this.minRepairWaitTime) { + this.logger.logInfoSessionClientSocketAction(null, null, socket.id, `Repair user: ...`); + + this.sessionManager.repair(socket, session_id, client_id); + + this.socketActivityMonitor.updateTime(socket.id); + + this.remove(socket); + } + } +} + +module.exports = SocketRepairCenter; \ No newline at end of file diff --git a/sync.js b/sync.js index 5b7eda0..fe6a479 100644 --- a/sync.js +++ b/sync.js @@ -34,17 +34,25 @@ /* jshint esversion: 6 */ // configuration -const config = require('./config'); +const config = require("./config"); -const fs = require('fs'); +const fs = require("fs"); -const path = require('path'); +const path = require("path"); -const util = require('util'); -const { syslog } = require('winston/lib/winston/config'); +const util = require("util"); + +const { syslog } = require("winston/lib/winston/config"); + +const Session = require("./session"); + +const SocketRepairCenter = require("./socket-repair-center"); +const SocketActivityMonitor = require("./socket-activity-monitor"); +const chat = require("./chat"); +const { debug } = require("console"); // event data globals -// NOTE(rob): deprecated. +// NOTE(rob): deprecated. // const POS_FIELDS = 14; // const POS_BYTES_PER_FIELD = 4; // const POS_COUNT = 10000; @@ -56,1683 +64,2854 @@ const { syslog } = require('winston/lib/winston/config'); // TODO(rob): finish deprecate. // const INTERACTION_LOOK = 0; // const INTERACTION_LOOK_END = 1; -const INTERACTION_RENDER = 2; -const INTERACTION_RENDER_END = 3; +const INTERACTION_RENDER = 2; +const INTERACTION_RENDER_END = 3; // const INTERACTION_GRAB = 4; // const INTERACTION_GRAB_END = 5; -const INTERACTION_SCENE_CHANGE = 6; +const INTERACTION_SCENE_CHANGE = 6; // const INTERACTION_UNSET = 7; // NOTE(rob): this value is currently unused. 2020-12-1 -const INTERACTION_LOCK = 8; -const INTERACTION_LOCK_END = 9; +const INTERACTION_LOCK = 8; +const INTERACTION_LOCK_END = 9; + +const SYNC_OBJECTS = 3; + +const STATE_VERSION = 2; + +const SERVER_NAME = "Komodo Dev (IL)"; + +const SYNC_NAMESPACE = "/sync"; //TODO refactor this.sessions into instances of the Session object. // Courtesy of Casey Foster on Stack Overflow // https://stackoverflow.com/a/14368628 function compareKeys(a, b) { - var aKeys = Object.keys(a).sort(); + var aKeys = Object.keys(a).sort(); - var bKeys = Object.keys(b).sort(); + var bKeys = Object.keys(b).sort(); - return JSON.stringify(aKeys) === JSON.stringify(bKeys); + return JSON.stringify(aKeys) === JSON.stringify(bKeys); } +const SocketIOEvents = { + connection: "connection", + disconnect: "disconnect", + disconnecting: "disconnecting", + error: "error" +}; + +const KomodoReceiveEvents = { + requestToJoinSession: "join", + leave: "leave", + sessionInfo: "sessionInfo", + requestOwnStateCatchup: "state", + draw: "draw", + message: "message", + update: "update", + interact: "interact", + start_recording: "start_recording", + end_recording: "end_recording", + playback: "playback", +}; + +const KomodoSendEvents = { + connectionError: "connectionError", + interactionUpdate: "interactionUpdate", + clientJoined: "joined", + failedToJoin: "failedToJoin", + successfullyJoined: "successfullyJoined", + left: "left", + failedToLeave: "failedToLeave", + successfullyLeft: "successfullyLeft", + disconnected: "disconnected", + serverName: "serverName", + sessionInfo: "sessionInfo", + state: "state", + draw: "draw", + message: "message", + relayUpdate: "relayUpdate", + notifyBump: "bump", + rejectUser: "rejectUser", +}; + +const KomodoMessages = { + interaction: { + type: "interaction", + minLength: 5, //TODO: what should this number be? + indices: { + sourceId: 3, + targetId: 4, + interactionType: 5, + }, + }, + sync: { + type: "sync", + minLength: 4, //TODO: what should this number be? + indices: { + entityId: 3, + entityType: 4, + }, + } +}; + +// see https://socket.io/docs/v2/server-api/index.html + +const DisconnectKnownReasons = { + // the disconnection was initiated by the server + "server namespace disconnect": { + doReconnect: false, + }, + // The socket was manually disconnected using socket.disconnect() + "client namespace disconnect": { + doReconnect: false, + }, + // The connection was closed (example: the user has lost connection, or the network was changed from WiFi to 4G) + "transport close": { + doReconnect: true, + }, + // The connection has encountered an error (example: the server was killed during a HTTP long-polling cycle) + "transport error": { + doReconnect: true, + }, + // The server did not send a PING within the pingInterval + pingTimeout range. + "ping timeout": { + doReconnect: true, + }, +}; + +const doReconnectOnUnknownReason = true; + module.exports = { - // NOTE(rob): deprecated. sessions must use message_buffer. - // // write buffers are multiples of corresponding chunks - // positionWriteBufferSize: function () { - // return POS_COUNT * positionChunkSize(); - // }, - - // // write buffers are multiples of corresponding chunks - // interactionWriteBufferSize: function () { - // return INT_COUNT * interactionChunkSize(); - // }, - - logInfoSessionClientSocketAction: function (session_id, client_id, socket_id, action) { - if (session_id == null) { - session_id = "---"; - } + // NOTE(rob): deprecated. sessions must use message_buffer. + // // write buffers are multiples of corresponding chunks + // positionWriteBufferSize: function () { + // return POS_COUNT * positionChunkSize(); + // }, + + // // write buffers are multiples of corresponding chunks + // interactionWriteBufferSize: function () { + // return INT_COUNT * interactionChunkSize(); + // }, + + logInfoSessionClientSocketAction: function ( + session_id, + client_id, + socket_id, + action + ) { + if (session_id == null) { + session_id = "---"; + } - session_id = `s${session_id}`; + session_id = `s${session_id}`; - if (client_id == null) { - client_id = "---"; - } + if (client_id == null) { + client_id = "---"; + } - client_id = `c${client_id}`; + client_id = `c${client_id}`; - if (socket_id == null) { - socket_id = "---................."; - } + if (socket_id == null) { + socket_id = "---......................."; + } - if (action == null) { - action = "---"; - } + if (action == null) { + action = "---"; + } - if (!this.logger) { - return; - } + if (!this.logger) { + return; + } - if (this.logger) this.logger.info(` ${socket_id} ${session_id} ${client_id} ${action}`); - }, + if (this.logger) + this.logger.info( + `${socket_id}\t${session_id}\t${client_id}\t${action}` + ); + }, + + logErrorSessionClientSocketAction: function ( + session_id, + client_id, + socket_id, + action + ) { + if (session_id == null) { + session_id = "---"; + } - logErrorSessionClientSocketAction: function (session_id, client_id, socket_id, action) { - if (session_id == null) { - session_id = "---"; - } + session_id = `s${session_id}`; - session_id = `s${session_id}`; + if (client_id == null) { + client_id = "---"; + } - if (client_id == null) { - client_id = "---"; - } + client_id = `c${client_id}`; - client_id = `c${client_id}`; + if (socket_id == null) { + socket_id = "---......................."; + } - if (socket_id == null) { - socket_id = "---................."; - } + if (action == null) { + action = "---"; + } - if (action == null) { - action = "---"; - } + if (!this.logger) { + return; + } - if (!this.logger) { - return; - } + if (this.logger) + this.logger.error( + `${socket_id}\t${session_id}\t${client_id}\t${action}` + ); + }, + + logWarningSessionClientSocketAction: function ( + session_id, + client_id, + socket_id, + action + ) { + if (session_id == null) { + session_id = "---"; + } - if (this.logger) this.logger.error(`${socket_id} ${session_id} ${client_id} ${action}`); - }, + session_id = `s${session_id}`; - logWarningSessionClientSocketAction: function (session_id, client_id, socket_id, action) { - if (session_id == null) { - session_id = "---"; - } + if (client_id == null) { + client_id = "---"; + } - session_id = `s${session_id}`; + client_id = `c${client_id}`; - if (client_id == null) { - client_id = "---"; - } + if (socket_id == null) { + socket_id = "---......................."; + } + + if (action == null) { + action = "---"; + } - client_id = `c${client_id}`; + if (!this.logger) { + return; + } - if (socket_id == null) { - socket_id = "---................."; - } + if (this.logger) + this.logger.warn( + `${socket_id}\t${session_id}\t${client_id}\t${action}` + ); + }, + + // generate formatted path for session capture files + getCapturePath: function (session_id, start, type) { + return path.join( + __dirname, + config.capture.path, + session_id.toString(), + start.toString(), + type + ); + }, + + start_recording: function (pool, session_id) { + // TODO(rob): require client id and token + console.log( + `start_recording called with pool: ${pool}, session: ${session_id}` + ); + let session = this.sessions.get(session_id); + if (!session) { + this.logErrorSessionClientSocketAction( + session_id, + null, + null, + `Tried to start recording, but session was null` + ); + return; + } - if (action == null) { - action = "---"; - } + if (session && !session.isRecording) { + session.isRecording = true; + session.recordingStart = Date.now(); + let path = this.getCapturePath(session_id, session.recordingStart, ""); + fs.mkdir(path, { recursive: true }, (err) => { + if (err) + if (this.logger) + this.logger.warn(`Error creating capture path: ${err}`); + }); + let capture_id = session_id + "_" + session.recordingStart; + session.capture_id = capture_id; + if (pool) { + pool.query( + "INSERT INTO captures(capture_id, session_id, start) VALUES(?, ?, ?)", + [capture_id, session_id, session.recordingStart], + (err, res) => { + if (err != undefined) { + if (this.logger) + this.logger.error( + `Error writing recording start event to database: ${err} ${res}` + ); + } + } + ); + } + + if (this.logger) this.logger.info(`Capture started: ${session_id}`); + } else if (session && session.isRecording) { + if (this.logger) + this.logger.warn( + `Requested session capture, but session is already recording: ${session_id}` + ); + } + }, + + // define end_recording event handler, use on socket event as well as on server cleanup for empty sessions + end_recording: function (pool, session_id) { + if (session_id) { + let session = this.sessions.get(session_id); + if (session && session.isRecording) { + session.isRecording = false; + if (this.logger) this.logger.info(`Capture ended: ${session_id}`); + // write out the buffers if not empty, but only up to where the cursor is + + // NOTE(rob): deprecated, use messages. + // let pos_writer = session.writers.pos; + // if (pos_writer.cursor > 0) { + // let path = this.getCapturePath(session_id, session.recordingStart, 'pos'); + // let wstream = fs.createWriteStream(path, { flags: 'a' }); + // wstream.write(pos_writer.buffer.slice(0, pos_writer.cursor)); + // wstream.close(); + // pos_writer.cursor = 0; + // } + // let int_writer = session.writers.int; + // if (int_writer.cursor > 0) { + // let path = this.getCapturePath(session_id, session.recordingStart, 'int'); + // let wstream = fs.createWriteStream(path, { flags: 'a' }); + // wstream.write(int_writer.buffer.slice(0, int_writer.cursor)); + // wstream.close(); + // int_writer.cursor = 0; + // } + + // write out message buffer. + let path = this.getCapturePath( + session_id, + session.recordingStart, + "data" + ); // [capturesDirectoryHere]/[session_id_here]/[session.recordingStartHere]/data + fs.writeFile(path, JSON.stringify(session.message_buffer), (e) => { + if (e) { + console.log(`Error writing message buffer: ${e}`); + } + }); + //TODO(Brandon): add success event here. Possibly notify Unity client. + // reset the buffer. + session.message_buffer = []; + + // write the capture end event to database + if (pool) { + let capture_id = session.capture_id; + pool.query( + "UPDATE captures SET end = ? WHERE capture_id = ?", + [Date.now(), capture_id], + (err, res) => { + if (err != undefined) { + if (this.logger) + this.logger.error( + `Error writing recording end event to database: ${err} ${res}` + ); + } + } + ); + session.capture_id = null; + } + } else if (session && !session.isRecording) { + if (this.logger) + this.logger.warn( + `Requested to end session capture, but capture is already ended: ${session_id}` + ); + session.capture_id = null; + } else { + if (this.logger) + this.logger.warn(`Error ending capture for session: ${session_id}`); + } + } + }, + + record_message_data: function (data) { + if (data) { + let session = this.sessions.get(data.session_id); + + if (!session) { + this.logErrorSessionClientSocketAction( + data.session_id, + null, + null, + `Tried to record message data, but session was null` + ); + + return; + } + + // calculate a canonical session sequence number for this message from session start and message timestamp. + // NOTE(rob): investigate how we might timestamp incoming packets WHEN THEY ARE RECEIVED BY THE NETWORKING LAYER, ie. not + // when they are handled by the socket.io library. From a business logic perspective, the canonical order of events is based + // on when they arrive at the relay server, NOT when the client emits them. 8/3/2021 + data.seq = data.ts - session.recordingStart; + + data.capture_id = session.capture_id; // copy capture id session property and attach it to the message data. + + let session_id = data.session_id; + + let client_id = data.client_id; + + if (typeof data.message != `object`) { + try { + data.message = JSON.parse(data.message); + } catch (e) { + // if (this.logger) this.logger.warn(`Failed to parse message payload: ${message} ${e}`); + console.log(`Failed to parse message payload: ${data.message}; ${e}`); + + return; + } + } + + if (!session_id || !client_id) { + this.logErrorSessionClientSocketAction( + session_id, + null, + null, + `Tried to record message data. One of these properties is missing. session_id: ${session_id}, client_id: ${client_id}, message: ${data}` + ); + + return; + } + + if (session.message_buffer) { + // TODO(rob): find optimal buffer size + // if (session.message_buffer.length < MESSAGE_BUFFER_MAX_SIZE) { + // this.session.message_buffer.push(data) + // } else + + session.message_buffer.push(data); + + // DEBUG(rob): + // let mb_str = JSON.stringify(session.message_buffer); + // let bytes = new util.TextEncoder().encode(mb_str).length; + // console.log(`Session ${data.session_id} message buffer size: ${bytes} bytes`); + } + } else { + this.logErrorSessionClientSocketAction( + null, + null, + null, + `message was null` + ); + } + }, + + handlePlayback: function (io, data) { + // TODO(rob): need to use playback object to track seq and group by playback_id, + // so users can request to pause playback, maybe rewind? + if (this.logger) this.logger.info(`Playback request: ${data.playback_id}`); + let client_id = data.client_id; + let session_id = data.session_id; + let playback_id = data.playback_id; + + let capture_id = null; + let start = null; + + if (client_id && session_id && playback_id) { + capture_id = playback_id.split("_")[0]; + start = playback_id.split("_")[1]; + // TODO(rob): check that this client has permission to playback this session + } else { + console.log("Invalid playback request:", data); + return; + } + + // Everything looks good, getting ref to session. + let session = this.sessions.get(session_id); + + // playback sequence counter + let current_seq = 0; + // let audioStarted = false; + + // NOTE(rob): deprecated; playback data must use message system. + // check that all params are valid + // if (capture_id && start) { + // // TODO(rob): Mar 3 2021 -- audio playback on hold to focus on data. + // // build audio file manifest + // // if (this.logger) this.logger.info(`Buiding audio file manifest for capture replay: ${playback_id}`) + // // let audioManifest = []; + // // let baseAudioPath = this.getCapturePath(capture_id, start, 'audio'); + // // if(fs.existsSync(baseAudioPath)) { // TODO(rob): change this to async operation + // // let items = fs.readdirSync(baseAudioPath); // TODO(rob): change this to async operation + // // items.forEach(clientDir => { + // // let clientPath = path.join(baseAudioPath, clientDir) + // // let files = fs.readdirSync(clientPath) // TODO(rob): change this to async operation + // // files.forEach(file => { + // // let client_id = clientDir; + // // let seq = file.split('.')[0]; + // // let audioFilePath = path.join(clientPath, file); + // // let item = { + // // seq: seq, + // // client_id: client_id, + // // path: audioFilePath, + // // data: null + // // } + // // audioManifest.push(item); + // // }); + // // }); + // // } + + // // // emit audio manifest to connected clients + // // io.of('chat').to(session_id.toString()).emit(KomodoSendEvents.playbackAudioManifest', audioManifest); + + // // // stream all audio files for caching and playback by client + // // audioManifest.forEach((file) => { + // // fs.readFile(file.path, (err, data) => { + // // file.data = data; + // // if(err) if (this.logger) this.logger.error(`Error reading audio file: ${file.path}`); + // // // console.log('emitting audio packet:', file); + // // io.of('chat').to(session_id.toString()).emit(KomodoSendEvents.playbackAudioData', file); + // // }); + // // }); + + // // position streaming + // let capturePath = this.getCapturePath(capture_id, start, 'pos'); + // let stream = fs.createReadStream(capturePath, { highWaterMark: positionChunkSize() }); + + // // set actual playback start time + // let playbackStart = Date.now(); + + // // position data emit loop + // stream.on(KomodoReceiveEvents.data, function(chunk) { + // stream.pause(); + + // // start data buffer loop + // let buff = Buffer.from(chunk); + // let farr = new Float32Array(chunk.byteLength / 4); + // for (var i = 0; i < farr.length; i++) { + // farr[i] = buff.readFloatLE(i * 4); + // } + // var arr = Array.from(farr); + + // let timer = setInterval( () => { + // current_seq = Date.now() - playbackStart; + + // // console.log(`=== POS === current seq ${current_seq}; arr seq ${arr[POS_FIELDS-1]}`); + + // if (arr[POS_FIELDS-1] <= current_seq) { + // // alias client and entity id with prefix if entity type is not an asset + // if (arr[4] != 3) { + // arr[2] = 90000 + arr[2]; + // arr[3] = 90000 + arr[3]; + // } + // // if (!audioStarted) { + // // // HACK(rob): trigger clients to begin playing buffered audio + // // audioStarted = true; + // // io.of('chat').to(session_id.toString()).emit(KomodoSendEvents.startPlaybackAudio'); + // // } + // io.to(session_id.toString()).emit(KomodoSendEvents.relayUpdate', arr); + // stream.resume(); + // clearInterval(timer); + // } + // }, 1); + // }); + + // stream.on(KomodoReceiveEvents.error, function(err) { + // if (this.logger) this.logger.error(`Error creating position playback stream for ${playback_id} ${start}: ${err}`); + // io.to(session_id.toString()).emit(KomodoSendEvents.playbackEnd'); + // }); + + // stream.on(KomodoReceiveEvents.end, function() { + // if (this.logger) this.logger.info(`End of pos data for playback session: ${session_id}`); + // io.to(session_id.toString()).emit(KomodoSendEvents.playbackEnd'); + // }); + + // // interaction streaming + // let ipath = this.getCapturePath(capture_id, start, 'int'); + // let istream = fs.createReadStream(ipath, { highWaterMark: interactionChunkSize() }); + + // istream.on(KomodoReceiveEvents.data, function(chunk) { + // istream.pause(); + + // let buff = Buffer.from(chunk); + // let farr = new Int32Array(chunk.byteLength / 4); + // for (var i = 0; i < farr.length; i++) { + // farr[i] = buff.readInt32LE(i * 4); + // } + // var arr = Array.from(farr); + + // let timer = setInterval( () => { + // // console.log(`=== INT === current seq ${current_seq}; arr seq ${arr[INT_FIELDS-1]}`); + + // if (arr[INT_FIELDS-1] <= current_seq) { + // io.to(session_id.toString()).emit(KomodoSendEvents.interactionUpdate', arr); + // istream.resume(); + // clearInterval(timer); + // } + // }, 1); + + // }); + + // istream.on(KomodoReceiveEvents.error, function(err) { + // if (this.logger) this.logger.error(`Error creating interaction playback stream for session ${session_id}: ${err}`); + // io.to(session_id.toString()).emit(KomodoSendEvents.interactionpPlaybackEnd'); + // }); + + // istream.on(KomodoReceiveEvents.end, function() { + // if (this.logger) this.logger.info(`End of int data for playback session: ${session_id}`); + // io.to(session_id.toString()).emit(KomodoSendEvents.interactionPlaybackEnd'); + // }); + // } + }, + + isValidRelayPacket: function (data) { + let session_id = data[1]; + + let client_id = data[2]; + + if (session_id && client_id) { + let session = this.sessions.get(session_id); + + if (!session) { + return; + } + + // check if the incoming packet is from a client who is valid for this session + return session.hasClient(client_id); + } + }, - if (!this.logger) { - return; + // NOTE(rob): DEPRECATED. 8/5/21. + // writeRecordedRelayData: function (data) { + // if (!data) { + // throw new ReferenceError ("data was null"); + // } + + // let session_id = data[1]; + + // let session = this.sessions.get(session_id); + + // if (!session) { + // throw new ReferenceError ("session was null"); + // } + + // if (!session.isRecording) { + // return; + // } + + // // calculate and write session sequence number using client timestamp + // data[POS_FIELDS-1] = data[POS_FIELDS-1] - session.recordingStart; + + // // get reference to session writer (buffer and cursor) + // let writer = session.writers.pos; + + // if (positionChunkSize() + writer.cursor > writer.buffer.byteLength) { + // // if buffer is full, dump to disk and reset the cursor + // let path = this.getCapturePath(session_id, session.recordingStart, 'pos'); + + // let wstream = fs.createWriteStream(path, { flags: 'a' }); + + // wstream.write(writer.buffer.slice(0, writer.cursor)); + + // wstream.close(); + + // writer.cursor = 0; + // } + + // for (let i = 0; i < data.length; i++) { + // writer.buffer.writeFloatLE(data[i], (i*POS_BYTES_PER_FIELD) + writer.cursor); + // } + + // writer.cursor += positionChunkSize(); + // }, + + updateSessionState: function (data) { + if (!data || data.length < 5) { + this.logErrorSessionClientSocketAction( + null, + null, + null, + `Tried to update session state, but data was null or not long enough` + ); + + return; + } + + let session_id = data[1]; + + let session = this.sessions.get(session_id); + + if (!session) { + this.logErrorSessionClientSocketAction( + session_id, + null, + null, + `Tried to update session state, but there was no such session` + ); + + return; + } + + // update session state with latest entity positions + let entity_type = data[4]; + + if (entity_type == 3) { + let entity_id = data[3]; + + let i = session.entities.findIndex((e) => e.id == entity_id); + + if (i != -1) { + session.entities[i].latest = data; + } else { + let entity = { + id: entity_id, + latest: data, + render: true, + locked: false, + }; + + session.entities.push(entity); + } + } + }, + + handleInteraction: function (socket, data) { + let session_id = data[1]; + let client_id = data[2]; + + if (session_id && client_id) { + // relay interaction events to all connected clients + socket + .to(session_id.toString()) + .emit(KomodoSendEvents.interactionUpdate, data); + + // do session state update if needed + let source_id = data[3]; + let target_id = data[4]; + let interaction_type = data[5]; + let session = this.sessions.get(session_id); + if (!session) return; + + // check if the incoming packet is from a client who is valid for this session + if (!session.hasClient(client_id)) { + return; + } + + // entity should be rendered + if (interaction_type == INTERACTION_RENDER) { + let i = session.entities.findIndex((e) => e.id == target_id); + if (i != -1) { + session.entities[i].render = true; + } else { + let entity = { + id: target_id, + latest: [], + render: true, + locked: false, + }; + session.entities.push(entity); + } + } + + // entity should stop being rendered + if (interaction_type == INTERACTION_RENDER_END) { + let i = session.entities.findIndex((e) => e.id == target_id); + if (i != -1) { + session.entities[i].render = false; + } else { + let entity = { + id: target_id, + latest: data, + render: false, + locked: false, + }; + session.entities.push(entity); + } + } + + // scene has changed + if (interaction_type == INTERACTION_SCENE_CHANGE) { + session.scene = target_id; + } + + // entity is locked + if (interaction_type == INTERACTION_LOCK) { + let i = session.entities.findIndex((e) => e.id == target_id); + if (i != -1) { + session.entities[i].locked = true; + } else { + let entity = { + id: target_id, + latest: [], + render: false, + locked: true, + }; + session.entities.push(entity); + } + } + + // entity is unlocked + if (interaction_type == INTERACTION_LOCK_END) { + let i = session.entities.findIndex((e) => e.id == target_id); + if (i != -1) { + session.entities[i].locked = false; + } else { + let entity = { + id: target_id, + latest: [], + render: false, + locked: false, + }; + session.entities.push(entity); + } + } + + // NOTE(rob): deprecated, use messages. + // write to file as binary data + // if (session.isRecording) { + // // calculate and write session sequence number + // data[INT_FIELDS-1] = data[INT_FIELDS-1] - session.recordingStart; + + // // get reference to session writer (buffer and cursor) + // let writer = session.writers.int; + + // if (interactionChunkSize() + writer.cursor > writer.buffer.byteLength) { + // // if buffer is full, dump to disk and reset the cursor + // let path = this.getCapturePath(session_id, session.recordingStart, 'int'); + // let wstream = fs.createWriteStream(path, { flags: 'a' }); + // wstream.write(writer.buffer.slice(0, writer.cursor)); + // wstream.close(); + // writer.cursor = 0; + // } + // for (let i = 0; i < data.length; i++) { + // writer.buffer.writeInt32LE(data[i], (i*INT_BYTES_PER_FIELD) + writer.cursor); + // } + // writer.cursor += interactionChunkSize(); + // } + } + }, + + getState: function (socket, session_id, version) { + let session = this.sessions.get(session_id); + + if (!session) { + this.stateErrorAction( + socket, + "The session was null, so no state could be found." + ); + + return { session_id: -1, state: null }; + } + + let state = {}; + + // check requested api version + if (version === 2) { + state = { + clients: session.getClients(), + entities: session.entities, + scene: session.scene, + isRecording: session.isRecording, + }; + } else { + // version 1 or no api version indicated + + let entities = []; + + let locked = []; + + for (let i = 0; i < session.entities.length; i++) { + entities.push(session.entities[i].id); + + if (session.entities[i].locked) { + locked.push(session.entities[i].id); } + } - if (this.logger) this.logger.warn(` ${socket_id} ${session_id} ${client_id} ${action}`); - }, + state = { + clients: session.getClients(), + entities: entities, + locked: locked, + scene: session.scene, + isRecording: session.isRecording, + }; + } + + return state; + }, + + handleStateCatchupRequest: function (socket, data) { + if (!socket) { + this.logErrorSessionClientSocketAction( + null, + null, + null, + `tried to handle state, but socket was null` + ); + + return { session_id: -1, state: null }; + } + + if (!data) { + this.logErrorSessionClientSocketAction( + null, + null, + socket.id, + `tried to handle state, but data was null` + ); + + return { session_id: -1, state: null }; + } + + let session_id = data.session_id; + + let client_id = data.client_id; + + let version = data.version; + + this.logInfoSessionClientSocketAction( + session_id, + client_id, + socket.id, + `Received state catch-up request, version ${data.version}` + ); + + if (!session_id || !client_id) { + this.connectionAuthorizationErrorAction( + socket, + "You must provide a session ID and a client ID in the URL options." + ); + + return { session_id: -1, state: null }; + } + + return { + session_id: session_id, + state: this.getState(socket, session_id, version) + }; + }, + + // returns true on success and false on failure + addClientToSession: function (session_id, client_id) { + let { success, session } = this.getSession(session_id); + + if (!success) { + this.logWarningSessionClientSocketAction( + session_id, + client_id, + null, + `failed to get session when adding client to session. Not proceeding.` + ); + + return false; + } + + session.addClient(client_id); + + return true; + }, + + removeDuplicateClientsFromSession: function (session_id, client_id) { + let { success, session } = this.getSession(session_id); + + if (!success) { + this.logErrorSessionClientSocketAction( + null, + client_id, + null, + `tried to remove duplicate client from session, but failed to get session` + ); + + return; + } + + if (session == null) { + this.logErrorSessionClientSocketAction( + null, + client_id, + null, + `tried to remove duplicate client from session, but session was null` + ); + + return; + } + + session.removeDuplicateClients(client_id); + }, + + removeClientFromSession: function (session_id, client_id) { + let { success, session } = this.getSession(session_id); + + if ( !success || session == null ) { + this.logErrorSessionClientSocketAction( + null, + client_id, + null, + `tried to remove client from session, but session was null` + ); + + return false; + } + + session.removeClient(client_id); + + this.logInfoSessionClientSocketAction( + session_id, + client_id, + null, + `Removed client from session.` + ); + + return true; + }, + + // returns true iff socket was successfully joined to session + addSocketAndClientToSession: function ( + err, + socket, + session_id, + client_id, + do_bump_duplicates + ) { + var reason; + + if (!this.failedToJoinAction) { + this.logWarningSessionClientSocketAction( + session_id, + client_id, + null, + `in addSocketAndClientToSession, failedToJoinAction callback was not provided. Proceeding anyways.` + ); + } + + if (!socket) { + reason = `tried to handle join, but socket was null`; + + this.logErrorSessionClientSocketAction( + session_id, + client_id, + null, + `Failed to join: ${reason}` + ); + + // don't call failedToJoinAction here because we don't have a socket. + + return false; + } + + if (err) { + reason = `Error joining client to session: ${err}`; + + this.logErrorSessionClientSocketAction( + session_id, + client_id, + socket.id, + `Failed to join: ${reason}` + ); + + this.failedToJoinAction(session_id, reason); + + return false; + } + + let session = this.getOrCreateSession(session_id); + + success = session.addClient(client_id); + + if (!success) { + reason = `tried to make socket and client join session, but adding client to session failed.`; + + this.logErrorSessionClientSocketAction( + session_id, + client_id, + socket.id, + `Failed to join: ${reason}` + ); + + this.failedToJoinAction(session_id, reason); + + return false; + } + + this.bumpDuplicateSockets( + session_id, + client_id, + do_bump_duplicates, + socket.id + ); + + if (do_bump_duplicates) { + session.removeDuplicateClients(client_id); + } + + // socket to client mapping + session.addSocket(socket, client_id); + + if (!this.successfullyJoinedAction) { + this.logWarningSessionClientSocketAction( + session_id, + client_id, + socket.id, + `in addSocketAndClientToSession, successfullyJoinedAction callback was not provided. Skipping.` + ); + + return true; + } + + this.logErrorSessionClientSocketAction( + session_id, + client_id, + socket.id, + "Successfully joined." + ); + + this.successfullyJoinedAction(session_id, client_id, socket); - // generate formatted path for session capture files - getCapturePath: function (session_id, start, type) { - return path.join(__dirname, config.capture.path, session_id.toString(), start.toString(), type); - }, + return true; + }, + + tryToRemoveSocketAndClientFromSessionThenNotifyLeft: function (err, session_id, client_id, socket) { + var success; + var reason; + + if (err) { + this.logErrorSessionClientSocketAction( + session_id, + client_id, + socket.id, + `in tryToRemoveSocketAndClientFromSessionThenNotifyLeft, ${err}` + ); + + return; + } + + if (!this.failedToLeaveAction) { + this.logWarningSessionClientSocketAction( + session_id, + client_id, + socket.id, + `in tryToRemoveSocketAndClientFromSessionThenNotifyLeft, failedToLeaveAction callback was not provided. Skipping.` + ); + + return; + } - start_recording: function (pool, session_id) {// TODO(rob): require client id and token - console.log(`start_recording called with pool: ${pool}, session: ${session_id}`) - let session = this.sessions.get(session_id); - if (!session) { - this.logErrorSessionClientSocketAction(session_id, null, null, `Tried to start recording, but session was null`); - return; - } + if (!socket) { + reason = `tryToRemoveSocketAndClientFromSessionThenNotifyLeft: socket was null`; - if (session && !session.isRecording) { - session.isRecording = true; - session.recordingStart = Date.now(); - let path = this.getCapturePath(session_id, session.recordingStart, ''); - fs.mkdir(path, { recursive: true }, (err) => { - if(err) if (this.logger) this.logger.warn(`Error creating capture path: ${err}`); - }); - let capture_id = session_id+'_'+session.recordingStart; - session.capture_id = capture_id; - if (pool) { - pool.query( - "INSERT INTO captures(capture_id, session_id, start) VALUES(?, ?, ?)", [capture_id, session_id, session.recordingStart], - (err, res) => { - if (err != undefined) { - if (this.logger) this.logger.error(`Error writing recording start event to database: ${err} ${res}`); - } - } - ); - } + this.logErrorSessionClientSocketAction( + session_id, + client_id, + null, + reason + ); - if (this.logger) this.logger.info(`Capture started: ${session_id}`); - } else if (session && session.isRecording) { - if (this.logger) this.logger.warn(`Requested session capture, but session is already recording: ${session_id}`); - } - }, - - // define end_recording event handler, use on socket event as well as on server cleanup for empty sessions - end_recording: function (pool, session_id) { - if (session_id) { - let session = this.sessions.get(session_id); - if (session && session.isRecording) { - session.isRecording = false; - if (this.logger) this.logger.info(`Capture ended: ${session_id}`); - // write out the buffers if not empty, but only up to where the cursor is - - // NOTE(rob): deprecated, use messages. - // let pos_writer = session.writers.pos; - // if (pos_writer.cursor > 0) { - // let path = this.getCapturePath(session_id, session.recordingStart, 'pos'); - // let wstream = fs.createWriteStream(path, { flags: 'a' }); - // wstream.write(pos_writer.buffer.slice(0, pos_writer.cursor)); - // wstream.close(); - // pos_writer.cursor = 0; - // } - // let int_writer = session.writers.int; - // if (int_writer.cursor > 0) { - // let path = this.getCapturePath(session_id, session.recordingStart, 'int'); - // let wstream = fs.createWriteStream(path, { flags: 'a' }); - // wstream.write(int_writer.buffer.slice(0, int_writer.cursor)); - // wstream.close(); - // int_writer.cursor = 0; - // } - - // write out message buffer. - let path = this.getCapturePath(session_id, session.recordingStart, 'data'); - fs.writeFile(path, JSON.stringify(session.message_buffer), (e) => { if (e) {console.log(`Error writing message buffer: ${e}`);} }); - //TODO(Brandon): add success event here. Possibly notify Unity client. - // reset the buffer. - session.message_buffer = []; - - // write the capture end event to database - if (pool) { - let capture_id = session.capture_id; - pool.query( - "UPDATE captures SET end = ? WHERE capture_id = ?", [Date.now(), capture_id], - (err, res) => { - if (err != undefined) { - if (this.logger) this.logger.error(`Error writing recording end event to database: ${err} ${res}`); - } - } - ); - session.capture_id = null; - } - } else if (session && !session.isRecording) { - if (this.logger) this.logger.warn(`Requested to end session capture, but capture is already ended: ${session_id}`); - session.capture_id = null; - } else { - if (this.logger) this.logger.warn(`Error ending capture for session: ${session_id}`); - } - } - }, + // don't call failedToLeaveAction here because we don't have a socket. + return; + } + + if (err) { + reason = `Error joining client to session: ${err}`; + + this.logErrorSessionClientSocketAction( + session_id, + client_id, + socket.id, + err + ); + + this.failedToLeaveAction(session_id, reason, socket); + + return; + } + + success = session.removeSocket(socket); + + if (!success) { + reason = `removeSocketFromSession failed`; + + this.failedToLeaveAction(session_id, reason, socket); + + return; + } - record_message_data: function (data) { - if (data) { - let session = this.sessions.get(data.session_id); + success = session.removeClient(client_id); - if (!session) { - this.logErrorSessionClientSocketAction(data.session_id, null, null, `Tried to record message data, but session was null`); + if (!success) { + reason = `session.removeClient failed`; + + this.failedToLeaveAction(session_id, reason, socket); + + return; + } + + if (!this.successfullyLeftAction) { + this.logWarningSessionClientSocketAction( + session_id, + client_id, + socket.id, + `in removeSocketAndClientFromSession, successfullyLeftAction callback was not provided. Skipping.` + ); + + return; + } + + this.successfullyLeftAction(session_id, client_id, socket); - return; - } + this.logInfoSessionClientSocketAction( + session_id, + client_id, + socket.id, + `Left.` + ); + }, + + notifyBump: function (session_id, socket) { + if (this.notifyBumpAction == null) { + this.logWarningSessionClientSocketAction( + session_id, + null, + socket.id, + `notifyBumpAction callback was not provided` + ); + } - // calculate a canonical session sequence number for this message from session start and message timestamp. - // NOTE(rob): investigate how we might timestamp incoming packets WHEN THEY ARE RECEIVED BY THE NETWORKING LAYER, ie. not - // when they are handled by the socket.io library. From a business logic perspective, the canonical order of events is based - // on when they arrive at the relay server, NOT when the client emits them. 8/3/2021 - data.seq = data.ts - session.recordingStart; + this.notifyBumpAction(session_id, socket); + }, + + makeSocketLeaveSession: function (session_id, socket) { + if (this.makeSocketLeaveSessionAction == null) { + this.logWarningSessionClientSocketAction( + session_id, + null, + socket.id, + `makeSocketLeaveSessionAction callback was not provided` + ); + } - data.capture_id = session.capture_id; // copy capture id session property and attach it to the message data. + this.makeSocketLeaveSessionAction(session_id, socket); + }, + + //TODO rewrite this so that do_bump_duplicates and socket_id become ids_to_keep + bumpDuplicateSockets: function ( + session_id, + client_id, + do_bump_duplicates, + socket_id + ) { + let { success, session } = this.getSession(session_id); + + if (session == null) { + this.logErrorSessionClientSocketAction( + null, + client_id, + socket_id, + `tried to bump duplicate sockets, but session was null` + ); + + return; + } - let session_id = data.session_id; + let sockets; - let client_id = data.client_id; + if (do_bump_duplicates) { + sockets = session.getSocketsFromClientId( + client_id, + socket_id + ); + } else { + sockets = session.getSocketsFromClientId(client_id, null); + } - if (typeof data.message != `object`) { - try { - data.message = JSON.parse(data.message); - } catch (e) { - // if (this.logger) this.logger.warn(`Failed to parse message payload: ${message} ${e}`); - console.log(`Failed to parse message payload: ${data.message}; ${e}`); + let self = this; - return; - } - } + if (!sockets) { + this.logWarningSessionClientSocketAction( + session_id, + client_id, + socket_id, + `tried to bump duplicate sockets, but result of getSocketsFromClientId was null. Proceeding anyways.` + ); + } - if (!session_id || !client_id) { - this.logErrorSessionClientSocketAction(session_id, null, null, `Tried to record message data. One of these properties is missing. session_id: ${session_id}, client_id: ${client_id}, message: ${data}`); + sockets.forEach((socket) => { + self.removeSocketAndClientFromSession(socket, session_id, client_id); - return; - } + self.notifyBump(session_id, socket); - if (session.message_buffer) { - // TODO(rob): find optimal buffer size - // if (session.message_buffer.length < MESSAGE_BUFFER_MAX_SIZE) { - // this.session.message_buffer.push(data) - // } else + self.makeSocketLeaveSession(session_id, socket); - session.message_buffer.push(data); + self.disconnectSocket(socket, session_id, client_id); + }); + }, - // DEBUG(rob): - // let mb_str = JSON.stringify(session.message_buffer); - // let bytes = new util.TextEncoder().encode(mb_str).length; - // console.log(`Session ${data.session_id} message buffer size: ${bytes} bytes`); - } - } else { - this.logErrorSessionClientSocketAction(null, null, null, `message was null`); - } - }, + writeEventToConnections: function (event, session_id, client_id) { + if (event && session_id && client_id) { //TODO(Brandon): support session_id = null and client_id = null + if (!this.pool) { + this.logErrorSessionClientSocketAction( + session_id, + client_id, + null, + "pool was null" + ); - handlePlayback: function (io, data) { - // TODO(rob): need to use playback object to track seq and group by playback_id, - // so users can request to pause playback, maybe rewind? - if (this.logger) this.logger.info(`Playback request: ${data.playback_id}`); - let client_id = data.client_id; - let session_id = data.session_id; - let playback_id = data.playback_id; - - let capture_id = null; - let start = null; - - if (client_id && session_id && playback_id) { - capture_id = playback_id.split('_')[0]; - start = playback_id.split('_')[1]; - // TODO(rob): check that this client has permission to playback this session - } else { - console.log("Invalid playback request:", data); - return; + return; + } + + if (this.pool == null) { + this.logger.error( + `Failed to log event to database: ${event}, ${session_id}, ${client_id}: this.pool was null`); + + return; + } + + this.pool.query( + "INSERT INTO connections(timestamp, session_id, client_id, event) VALUES(?, ?, ?, ?)", + [Date.now(), session_id, client_id, event], + + (err, res) => { + if (err != undefined) { + this.logErrorSessionClientSocketAction( + session_id, + client_id, + null, + `Error writing ${event} event to database: ${err} ${res}` + ); + } } + ); + } + }, + + getClientIdFromSessionSocket: function (session_id, socket) { + let { success, session } = this.getSession(session_id); + + if (session == null) { + this.logErrorSessionClientSocketAction( + null, + client_id, + null, + `tried to get client ID from session socket, but session was null` + ); + + return null; + } + }, + + getSessionSocketsFromClientId: function ( + session_id, + client_id, + excluded_socket_id + ) { + let { success, session } = this.getSession(session_id); + + if (session == null) { + this.logErrorSessionClientSocketAction( + null, + client_id, + null, + `tried to get session sockets from client ID, but session was null` + ); + + return null; + } + + return session.getSocketsFromClientId(client_id, excluded_socket_id); + }, - // Everything looks good, getting ref to session. - let session = this.sessions.get(session_id); + isClientInSession: function (session_id, client_id) { + let { success, session } = this.getSession(session_id); - // playback sequence counter - let current_seq = 0; - // let audioStarted = false; - - // NOTE(rob): deprecated; playback data must use message system. - // check that all params are valid - // if (capture_id && start) { - // // TODO(rob): Mar 3 2021 -- audio playback on hold to focus on data. - // // build audio file manifest - // // if (this.logger) this.logger.info(`Buiding audio file manifest for capture replay: ${playback_id}`) - // // let audioManifest = []; - // // let baseAudioPath = this.getCapturePath(capture_id, start, 'audio'); - // // if(fs.existsSync(baseAudioPath)) { // TODO(rob): change this to async operation - // // let items = fs.readdirSync(baseAudioPath); // TODO(rob): change this to async operation - // // items.forEach(clientDir => { - // // let clientPath = path.join(baseAudioPath, clientDir) - // // let files = fs.readdirSync(clientPath) // TODO(rob): change this to async operation - // // files.forEach(file => { - // // let client_id = clientDir; - // // let seq = file.split('.')[0]; - // // let audioFilePath = path.join(clientPath, file); - // // let item = { - // // seq: seq, - // // client_id: client_id, - // // path: audioFilePath, - // // data: null - // // } - // // audioManifest.push(item); - // // }); - // // }); - // // } - - // // // emit audio manifest to connected clients - // // io.of('chat').to(session_id.toString()).emit('playbackAudioManifest', audioManifest); - - // // // stream all audio files for caching and playback by client - // // audioManifest.forEach((file) => { - // // fs.readFile(file.path, (err, data) => { - // // file.data = data; - // // if(err) if (this.logger) this.logger.error(`Error reading audio file: ${file.path}`); - // // // console.log('emitting audio packet:', file); - // // io.of('chat').to(session_id.toString()).emit('playbackAudioData', file); - // // }); - // // }); - - // // position streaming - // let capturePath = this.getCapturePath(capture_id, start, 'pos'); - // let stream = fs.createReadStream(capturePath, { highWaterMark: positionChunkSize() }); - - // // set actual playback start time - // let playbackStart = Date.now(); - - // // position data emit loop - // stream.on('data', function(chunk) { - // stream.pause(); - - // // start data buffer loop - // let buff = Buffer.from(chunk); - // let farr = new Float32Array(chunk.byteLength / 4); - // for (var i = 0; i < farr.length; i++) { - // farr[i] = buff.readFloatLE(i * 4); - // } - // var arr = Array.from(farr); - - // let timer = setInterval( () => { - // current_seq = Date.now() - playbackStart; - - // // console.log(`=== POS === current seq ${current_seq}; arr seq ${arr[POS_FIELDS-1]}`); - - // if (arr[POS_FIELDS-1] <= current_seq) { - // // alias client and entity id with prefix if entity type is not an asset - // if (arr[4] != 3) { - // arr[2] = 90000 + arr[2]; - // arr[3] = 90000 + arr[3]; - // } - // // if (!audioStarted) { - // // // HACK(rob): trigger clients to begin playing buffered audio - // // audioStarted = true; - // // io.of('chat').to(session_id.toString()).emit('startPlaybackAudio'); - // // } - // io.to(session_id.toString()).emit('relayUpdate', arr); - // stream.resume(); - // clearInterval(timer); - // } - // }, 1); - // }); - - // stream.on('error', function(err) { - // if (this.logger) this.logger.error(`Error creating position playback stream for ${playback_id} ${start}: ${err}`); - // io.to(session_id.toString()).emit('playbackEnd'); - // }); - - // stream.on('end', function() { - // if (this.logger) this.logger.info(`End of pos data for playback session: ${session_id}`); - // io.to(session_id.toString()).emit('playbackEnd'); - // }); - - // // interaction streaming - // let ipath = this.getCapturePath(capture_id, start, 'int'); - // let istream = fs.createReadStream(ipath, { highWaterMark: interactionChunkSize() }); - - // istream.on('data', function(chunk) { - // istream.pause(); - - // let buff = Buffer.from(chunk); - // let farr = new Int32Array(chunk.byteLength / 4); - // for (var i = 0; i < farr.length; i++) { - // farr[i] = buff.readInt32LE(i * 4); - // } - // var arr = Array.from(farr); - - // let timer = setInterval( () => { - // // console.log(`=== INT === current seq ${current_seq}; arr seq ${arr[INT_FIELDS-1]}`); - - // if (arr[INT_FIELDS-1] <= current_seq) { - // io.to(session_id.toString()).emit('interactionUpdate', arr); - // istream.resume(); - // clearInterval(timer); - // } - // }, 1); - - // }); - - // istream.on('error', function(err) { - // if (this.logger) this.logger.error(`Error creating interaction playback stream for session ${session_id}: ${err}`); - // io.to(session_id.toString()).emit('interactionpPlaybackEnd'); - // }); - - // istream.on('end', function() { - // if (this.logger) this.logger.info(`End of int data for playback session: ${session_id}`); - // io.to(session_id.toString()).emit('interactionPlaybackEnd'); - // }); - // } - }, + if (!success) { + return false; + } - isValidRelayPacket: function (data) { - let session_id = data[1]; + return session.hasClient(client_id); + }, - let client_id = data[2]; - - if (session_id && client_id) { - let session = this.sessions.get(session_id); + // returns number of client instances of the same ID on success; returns -1 on failure; + getNumClientInstancesForSession: function (session_id, client_id) { + let { success, session } = this.getSession(session_id); - if (!session) { - return; - } + if (session == null) { + this.logErrorSessionClientSocketAction( + session_id, + client_id, + null, + `Could not get number of client instances -- session was null` + ); - // check if the incoming packet is from a client who is valid for this session + return -1; + } - for (let i = 0; i < session.clients.length; i += 1) { - if (client_id == session.clients[i]) { - return true; - } - } + return session.getNumClientInstances(client_id); + }, - return false; - } - }, + // Return true iff removing the socket succeeded. + removeSocketFromSession: function (socket, session_id) { + let session = this.sessions.get(session_id); - // NOTE(rob): DEPRECATED. 8/5/21. - // writeRecordedRelayData: function (data) { - // if (!data) { - // throw new ReferenceError ("data was null"); - // } + if (!session) { + this.logWarningSessionClientSocketAction( + session_id, + null, + null, + `tried to removeSocketFromSession, but session was not found.` + ); - // let session_id = data[1]; + return false; + } - // let session = this.sessions.get(session_id); + return session.removeSocket(socket); + }, - // if (!session) { - // throw new ReferenceError ("session was null"); - // } + disconnectSocket: function (socket, session_id, client_id) { + if (!socket) { + this.logErrorSessionClientSocketAction( + session_id, + client_id, + null, + `tried removing socket from session, but socket was null` + ); - // if (!session.isRecording) { - // return; - // } + return; + } - // // calculate and write session sequence number using client timestamp - // data[POS_FIELDS-1] = data[POS_FIELDS-1] - session.recordingStart; + if (!this.disconnectAction) { + this.logWarningSessionClientSocketAction( + session_id, + client_id, + socket.id, + `in disconnectSocket, disconnectedAction callback was not provided` + ); + } - // // get reference to session writer (buffer and cursor) - // let writer = session.writers.pos; + this.disconnectAction(socket, session_id, client_id); + }, - // if (positionChunkSize() + writer.cursor > writer.buffer.byteLength) { - // // if buffer is full, dump to disk and reset the cursor - // let path = this.getCapturePath(session_id, session.recordingStart, 'pos'); + // cleanup socket and client references in session state if reconnect fails + removeSocketAndClientFromSession: function (socket, session_id, client_id) { + if (!socket) { + this.logErrorSessionClientSocketAction( + session_id, + client_id, + null, + `tried removing socket from session, but socket was null` + ); - // let wstream = fs.createWriteStream(path, { flags: 'a' }); + return; + } - // wstream.write(writer.buffer.slice(0, writer.cursor)); + let session = this.sessions.get(session_id); - // wstream.close(); + session.removeClient(client_id); - // writer.cursor = 0; - // } + session.removeSocket(socket); + }, - // for (let i = 0; i < data.length; i++) { - // writer.buffer.writeFloatLE(data[i], (i*POS_BYTES_PER_FIELD) + writer.cursor); - // } + getTotalNumInstancesForAllClientsForSession: function (session_id) { + let session = this.sessions.get(session_id); - // writer.cursor += positionChunkSize(); - // }, + if (!session) { + this.logWarningSessionClientSocketAction( + session_id, + null, + null, + `tried to get number of clients for a session, but it was not found.` + ); - updateSessionState: function (data) { - if (!data || data.length < 5) { - this.logErrorSessionClientSocketAction(null, null, null, `Tried to update session state, but data was null or not long enough`); - - return; - } + return -1; + } - let session_id = data[1]; + return session.getTotalNumInstancesForAllClients(); + }, - let session = this.sessions.get(session_id); + try_to_end_recording: function (session_id) { + let session = this.sessions.get(session_id); - if (!session) { - this.logErrorSessionClientSocketAction(session_id, null, null, `Tried to update session state, but there was no such session`); + if (!session) { + this.logWarningSessionClientSocketAction( + session_id, + null, + null, + `tried to end recording for session ${session_id}, but it was not found.` + ); - return; - } + return; + } - // update session state with latest entity positions - let entity_type = data[4]; + if (!session.isRecording) { + return; + } - if (entity_type == 3) { - let entity_id = data[3]; + this.logInfoSessionClientSocketAction( + session_id, + null, + null, + `Stopping recording for empty session` + ); + + this.end_recording(session_id); + }, + + // clean up session from sessions map if empty, write + cleanUpSessionIfEmpty: function (session_id) { + if (this.getNumClientInstancesForSession(session_id) >= 0) { + // don't clean up if there are still clients in the session + return; + } - let i = session.entities.findIndex(e => e.id == entity_id); + this.logInfoSessionClientSocketAction( + session_id, + null, + null, + `Ending empty session` + ); - if (i != -1) { - session.entities[i].latest = data; - } else { - let entity = { - id: entity_id, - latest: data, - render: true, - locked: false - }; + this.try_to_end_recording(session_id); - session.entities.push(entity); - } - } - }, + this.sessions.delete(session_id); + }, - handleInteraction: function (socket, data) { - let session_id = data[1]; - let client_id = data[2]; + // if a session exists, return it. Otherwise, create one with default values, register it, and return it. + getOrCreateSession: function (session_id) { + let { success, session } = this.getSession(session_id); - if (session_id && client_id) { - // relay interaction events to all connected clients - socket.to(session_id.toString()).emit('interactionUpdate', data); - - // do session state update if needed - let source_id = data[3]; - let target_id = data[4]; - let interaction_type = data[5]; - let session = this.sessions.get(session_id); - if (!session) return; - - // check if the incoming packet is from a client who is valid for this session - let joined = false; - for (let i=0; i < session.clients.length; i++) { - if (client_id == session.clients[i]) { - joined = true; - break; - } - } + if (success) { + return session; + } - if (!joined) return; - - // entity should be rendered - if (interaction_type == INTERACTION_RENDER) { - let i = session.entities.findIndex(e => e.id == target_id); - if (i != -1) { - session.entities[i].render = true; - } else { - let entity = { - id: target_id, - latest: [], - render: true, - locked: false - }; - session.entities.push(entity); - } - } + return this.createSession(session_id); + }, - // entity should stop being rendered - if (interaction_type == INTERACTION_RENDER_END) { - let i = session.entities.findIndex(e => e.id == target_id); - if (i != -1) { - session.entities[i].render = false; - } else { - let entity = { - id: target_id, - latest: data, - render: false, - locked: false - }; - session.entities.push(entity); - } - } + getSession: function (session_id) { + let _session = this.sessions.get(session_id); - // scene has changed - if (interaction_type == INTERACTION_SCENE_CHANGE) { - session.scene = target_id; - } + if (_session != null && typeof _session != "undefined") { + return { + success: true, - // entity is locked - if (interaction_type == INTERACTION_LOCK) { - let i = session.entities.findIndex(e => e.id == target_id); - if (i != -1) { - session.entities[i].locked = true; - } else { - let entity = { - id: target_id, - latest: [], - render: false, - locked: true - }; - session.entities.push(entity); - } - } + session: _session, + }; + } - // entity is unlocked - if (interaction_type == INTERACTION_LOCK_END) { - let i = session.entities.findIndex(e => e.id == target_id); - if (i != -1) { - session.entities[i].locked = false; - } else { - let entity = { - id: target_id, - latest: [], - render: false, - locked: false - }; - session.entities.push(entity); - } - } + return { + success: false, - // NOTE(rob): deprecated, use messages. - // write to file as binary data - // if (session.isRecording) { - // // calculate and write session sequence number - // data[INT_FIELDS-1] = data[INT_FIELDS-1] - session.recordingStart; - - // // get reference to session writer (buffer and cursor) - // let writer = session.writers.int; - - // if (interactionChunkSize() + writer.cursor > writer.buffer.byteLength) { - // // if buffer is full, dump to disk and reset the cursor - // let path = this.getCapturePath(session_id, session.recordingStart, 'int'); - // let wstream = fs.createWriteStream(path, { flags: 'a' }); - // wstream.write(writer.buffer.slice(0, writer.cursor)); - // wstream.close(); - // writer.cursor = 0; - // } - // for (let i = 0; i < data.length; i++) { - // writer.buffer.writeInt32LE(data[i], (i*INT_BYTES_PER_FIELD) + writer.cursor); - // } - // writer.cursor += interactionChunkSize(); - // } - } - }, + session: null, + }; + }, - handleState: function (socket, data) { - if(!socket) { - this.logErrorSessionClientSocketAction(null, null, null, `tried to handle state, but socket was null`); + initialize_recording_writers: function () {}, - return { session_id: -1, state: null }; - } + createSession: function (session_id) { + this.logInfoSessionClientSocketAction( + session_id, + null, + null, + `Creating session: ${session_id}` + ); - if (!data) { - this.logErrorSessionClientSocketAction(null, null, socket.id, `tried to handle state, but data was null`); - - return { session_id: -1, state: null }; - } - - let session_id = data.session_id; - - let client_id = data.client_id; - - this.logInfoSessionClientSocketAction(session_id, client_id, socket.id, `State: ${JSON.stringify(data)}`); - - if (!session_id || !client_id) { - this.connectionAuthorizationErrorAction(socket, "You must provide a session ID and a client ID in the URL options."); + this.sessions.set(session_id, new Session(session_id)); - return { session_id: -1, state: null }; - } - - let version = data.version; + session = this.sessions.get(session_id); - let session = this.sessions.get(session_id); + return session; + }, - if (!session) { - this.stateErrorAction(socket, "The session was null, so no state could be found."); + processReconnectionAttempt: function (err, socket, session_id, client_id) { + this.logInfoSessionClientSocketAction( + session_id, + client_id, + socket.id, + "Processing reconnection attempt." + ); - return { session_id: -1, state: null }; - } + this.getOrCreateSession(session_id); - let state = {}; + let success = this.addSocketAndClientToSession(err, socket, session_id, client_id); - // check requested api version - if (version === 2) { - state = { - clients: session.clients, - entities: session.entities, - scene: session.scene, - isRecording: session.isRecording - }; - } else { // version 1 or no api version indicated + if (!success) { + this.logInfoSessionClientSocketAction( + session_id, + client_id, + socket.id, + "failed to reconnect" + ); - let entities = []; + this.tryToRemoveSocketAndClientFromSessionAndNotifyLeft(socket, session_id, client_id); - let locked = []; + this.disconnectSocket(socket, session_id, client_id); - for (let i = 0; i < session.entities.length; i++) { - entities.push(session.entities[i].id); + this.cleanUpSessionIfEmpty(session_id); - if (session.entities[i].locked) { - locked.push(session.entities[i].id); - } - } + return false; + } - state = { - clients: session.clients, - entities: entities, - locked: locked, - scene: session.scene, - isRecording: session.isRecording - }; - } + ////TODO does this need to be called here???? this.bumpOldSockets(session_id, client_id, socket.id); - return { session_id, state }; - }, + this.logInfoSessionClientSocketAction( + session_id, + client_id, + socket.id, + "successfully reconnected" + ); - // returns true on success and false on failure - addClientToSession: function (session, client_id, do_create_session) { - if (session == null && !do_create_session) { - this.logErrorSessionClientSocketAction(null, client_id, null, `tried to add client to session, but session was null and do_create_session was false`); + return true; + }, - return false; - } + // Remove this function once we upgrade the server and client to use the "/sync" namespace + isInChatNamespace: function (socket) { + if (!socket) { + return false; + } - if (session == null && do_create_session) { - session = this.createSession(); - } + if (!this.chatNamespace) { + return false; + } - if (session.clients == null || - typeof session.clients === "undefined" || - session.clients.length == 0) { - session.clients = [ client_id ]; + let connectedIds = Object.keys(this.chatNamespace.connected); - return true; - } + if (connectedIds == null) { + return false; + } - session.clients.push(client_id); + return connectedIds.includes(`${this.chatNamespace.name}#${socket.id}`); + }, - return true; - }, + // Remove this function once we upgrade the server and client to use the "/sync" namespace + isInAdminNamespace: function (socket) { + if (!socket) { + return false; + } - removeDuplicateClientsFromSession: function (session, client_id) { - if (session == null) { - this.logErrorSessionClientSocketAction(null, client_id, null, `tried to remove duplicate client from session, but session was null`); + if (!this.chatNamespace) { + return false; + } - return; - } + let connectedIds = Object.keys(this.adminNamespace.connected); - if (session.clients == null) { - this.logErrorSessionClientSocketAction(session.id, client_id, null, `tried to remove duplicate client from session, but session.clients was null`); + if (connectedIds == null) { + return false; + } - return; - } + return connectedIds.includes(`${this.adminNamespace.name}#${socket.id}`); + }, + + isSocketInSession: function (session_id, socket) { + if (!socket) { + this.logWarningSessionClientSocketAction( + session_id, + null, + null, + `hasSocket: socket was null.` + ); + + return { + success: false, + isInSession: null, + }; + } - if (session.clients.length == 0) { - return; - } + let session = this.sessions.get(session_id); - const first_instance = session.clients.indexOf(client_id); + if (!session) { + this.logWarningSessionClientSocketAction( + session_id, + null, + socket.id, + `Could not find session when trying to remove a socket from it.` + ); - for (let i = 0; i < session.clients.length; i += 1) { - if (i != first_instance && session.clients[i] == client_id) { - session.clients.splice(i, 1); - } - } - }, + return { + success: false, + isInSession: null, + }; + } - removeClientFromSession: function (session, client_id) { - if (session == null) { - this.logErrorSessionClientSocketAction(null, client_id, null, `tried to remove client from session, but session was null`); + return { + success: true, + isInSession: session.hasSocket(socket), + }; + }, - return; - } + whoDisconnected: function (socket) { + for (var s in this.sessions) { + const session_id = s[0]; - if (session.clients == null) { - this.logErrorSessionClientSocketAction(session.id, client_id, null, `tried to remove client from session, but session.clients was null`); + let session = s[1]; - return; - } + let { success, isInSession } = isSocketInSession(session_id, socket); - let index = session.clients.indexOf(client_id); + if (!success || !isInSession) { + // This isn't the right session, so keep looking. + continue; + } - if (session.clients.length == 0 || - session.clients.indexOf(client_id) == -1) { - //client_id is not in the array, so we don't need to remove it. - this.logWarningSessionClientSocketAction(null, null, client_id, `Tried removing client from session.clients, but it was not there. Proceeding anyways.`); + // We found the right session. + return { + session_id: session_id, - return; - } + client_id: session.getClientIdFromSocket(socket), + }; + } - session.clients.splice(index, 1); - }, + return { + session_id: null, - addSocketToSession: function (session, socket, client_id) { - if (!session) { - this.logErrorSessionClientSocketAction(null, client_id, socket.id, `tried to add socket to session, but session was null`); + client_id: null, + }; + }, - return; - } + doTryReconnecting: function (reason) { + if (doReconnectOnUnknownReason) { + return true; + } - session.sockets[socket.id] = { client_id: client_id, socket: socket }; - }, + return (DisconnectKnownReasons.hasOwnProperty(reason) && + DisconnectKnownReasons[reason].doReconnect); + }, - // returns true iff socket was successfully joined to session - handleJoin: function (err, socket, session_id, client_id, do_bump_duplicates) { - if (!socket) { - this.logErrorSessionClientSocketAction(session_id, client_id, null, `tried to handle join, but socket was null`); - - return false; - } + handleDisconnecting: function(socket, reason) { + }, - if (!this.joinSessionAction) { - this.logWarningSessionClientSocketAction(session_id, client_id, socket.id, `in handleJoin, joinSessionAction callback was not provided. Proceeding anyways.`); - } + // Returns true if socket is still connected + handleDisconnect: function (socket, reason) { + if (!socket) { + this.logErrorSessionClientSocketAction( + null, + null, + null, + `tried handling disconnect, but socket was null` + ); - if (err) { - this.logErrorSessionClientSocketAction(session_id, client_id, socket.id, `Error joining client to session: ${err}`); + return false; + } - return false; - } + this.logInfoSessionClientSocketAction( + null, + null, + socket.id, + `Disconnecting.` + ); + + if (!this.reconnectAction) { + this.logErrorSessionClientSocketAction( + null, + null, + socket.id, + `in handleDisconnect, reconnectAction callback was not provided` + ); + + return false; + } - let { success, session } = this.getSession(session_id); + // Check disconnect event reason and handle + const { session_id, client_id } = this.whoDisconnected(socket); - if (!success || !session) { - this.logWarningSessionClientSocketAction(session_id, client_id, socket.id, "session was null when adding socket to session. Creating a session for you."); + if (session_id == null) { + //socket not found in our records. This will happen for komodo-unity versions v0.3.2 and below, which handle "sync" actions on the main server namespace. + this.logInfoSessionClientSocketAction( + null, + null, + socket.id, + `- session_id not found.` + ); - session = this.createSession(session_id); - } + return true; + } + + if (client_id == null) { + //client not found in our records. This will happen for komodo-unity versions v0.3.2 and below, which handle "sync" actions on the main server namespace. + this.logInfoSessionClientSocketAction( + null, + null, + socket.id, + `- client_id not found.` + ); + + return true; + } - success = this.addClientToSession(session, client_id); + if (this.doTryReconnecting(reason)) { + this.logErrorSessionClientSocketAction( + null, + null, + socket.id, + `- trying to reconnect and rejoin user. Reason: ${reason}` + ); - if (!success) { - this.logErrorSessionClientSocketAction(session_id, client_id, socket.id, `tried to handle join, but adding client to session failed.`); + // Try to reconnect the socket + let success = this.reconnectAction( + reason, + socket, + session_id, + client_id, + session + ); + + if (!success) { + this.socketActivityMonitor.addOrUpdate(socket.id); + + this.socketRepairCenter.add(socket); + + return false; + } + + return true; + } + + this.logErrorSessionClientSocketAction( + null, + null, + socket.id, + `- not trying to reconnect user. Reason: ${reason}` + ); + + // Try to reconnect the socket + let success = this.reconnectAction( + reason, + socket, + session_id, + client_id, + session + ); + + this.tryToRemoveSocketAndClientFromSessionThenNotifyLeft(null, session_id, client_id, socket); + + this.disconnectSocket(socket, session_id, client_id); + + this.cleanUpSessionIfEmpty(session_id); + + return false; + }, + + createCapturesDirectory: function () { + if (!fs.existsSync(config.capture.path)) { + this.logInfoSessionClientSocketAction( + null, + null, + null, + `Creating directory for session captures: ${config.capture.path}` + ); + + fs.mkdirSync(config.capture.path); + } + }, + + getSessions: function () { + return this.sessions; + }, + + initGlobals: function () { + this.sessions = new Map(); + + this.socketActivityMonitor = new SocketActivityMonitor(); + + this.socketRepairCenter = new SocketRepairCenter(2000, this, this, this.socketActivityMonitor, this); + }, + +// Check if message payload is pre-parsed. + // mutates data.message + // returns data.message + parseMessageIfNeeded: function (data, session_id, client_id) { + // TODO(Brandon): evaluate whether to unpack here or keep as a string. + if (typeof data.message == `object`) { + //payload is already parsed. + return data.message; + } + + try { + // parse and replace message payload + data.message = JSON.parse(data.message); + + return data.message; + } catch (e) { + this.logWarningSessionClientSocketAction(session_id, client_id, "n/a", + `Failed to parse 'interaction' message payload: ${data.message}; ` + ); + + return data.message; + } + }, + + getEntityFromState: function (session, id) { + let i = session.entities.findIndex((candidateEntity) => candidateEntity.id == id); + + if (i == -1) { + return null; + } + + return session.entities[i]; + }, + + applyShowInteractionToState: function (session, target_id) { + let foundEntity = this.getEntityFromState(session, target_id); + + if (foundEntity == null) { + this.logInfoSessionClientSocketAction("unk", "unk", "unk", `apply show interaction to state: no entity with target_id ${target_id} found. Creating one.`); - return; - } + let entity = { + id: target_id, + latest: {}, + render: true, + locked: false, + }; - this.bumpDuplicateSockets(session, client_id, do_bump_duplicates, socket.id); + session.entities.push(entity); - if (do_bump_duplicates) { - this.removeDuplicateClientsFromSession(session, client_id); - } + return; + } - // socket to client mapping - this.addSocketToSession(session, socket, client_id); + foundEntity.render = true; + }, - this.joinSessionAction(session_id, client_id); + applyHideInteractionToState: function (session, target_id) { + let foundEntity = this.getEntityFromState(session, target_id); - // socket successfully joined to session - return true; - }, + if (foundEntity == null) { + this.logInfoSessionClientSocketAction("unk", "unk", "unk", `apply hide interaction to state: no entity with target_id ${target_id} found. Creating one.`); + + let entity = { + id: target_id, + latest: {}, + render: false, + locked: false, + }; - //TODO rewrite this so that do_bump_duplicates and socket_id become ids_to_keep - bumpDuplicateSockets: function (session, client_id, do_bump_duplicates, socket_id) { - if (session == null) { - this.logErrorSessionClientSocketAction(null, client_id, socket_id, `tried to bump duplicate sockets, but session was null`); + session.entities.push(entity); + + return; + } - return; - } + foundEntity.render = false; + }, - let session_id = this.getSessionIdFromSession(session); + applyLockInteractionToState: function (session, target_id) { + let foundEntity = this.getEntityFromState(session, target_id); - if (this.bumpAction == null) { - this.logWarningSessionClientSocketAction(session.id, client_id, socket_id, `in bumpDuplicateSockets, bumpAction callback was not provided`); - } - - let sockets; + if (foundEntity == null) { + this.logInfoSessionClientSocketAction("unk", "unk", "unk", `apply lock interaction to state: no entity with target_id ${target_id} found. Creating one.`); - if (do_bump_duplicates) { - sockets = this.getSessionSocketsFromClientId(session, client_id, socket_id); - } else { - sockets = this.getSessionSocketsFromClientId(session, client_id, null); - } + let entity = { + id: target_id, + latest: {}, // TODO(Brandon): investigate this. data.message? + render: true, + locked: true, + }; - let self = this; + session.entities.push(entity); - if (!sockets) { - this.logWarningSessionClientSocketAction(session.id, client_id, socket_id, `tried to bump duplicate sockets, but result of getSessionSocketsFromClientId was null. Proceeding anyways.`); - } - - sockets.forEach((socket) => { - self.bumpAction(session_id, socket); + return; + } - self.removeSocketFromSession(socket, session_id, client_id); - }); - }, + foundEntity.locked = true; + }, - writeEventToConnections: function (event, session_id, client_id) { - if (event && session_id && client_id) { - if (!this.pool) { - this.logErrorSessionClientSocketAction(session_id, client_id, null, "pool was null"); + applyUnlockInteractionToState: function (session, target_id) { + let foundEntity = this.getEntityFromState(session, target_id); - return; - } - - if (this.pool) { - this.pool.query( - "INSERT INTO connections(timestamp, session_id, client_id, event) VALUES(?, ?, ?, ?)", [Date.now(), session_id, client_id, event], - - (err, res) => { - if (err != undefined) { - this.logErrorSessionClientSocketAction(session_id, client_id, null, `Error writing ${event} event to database: ${err} ${res}`); - } - } - ); - } - } else { - this.logger.error(`Failed to log event to database: ${event}, ${session_id}, ${client_id}`); - } - }, + if (foundEntity == null) { + this.logInfoSessionClientSocketAction("unk", "unk", "unk", `apply unlock interaction to state: no entity with target_id ${target_id} found. Creating one.`); + + let entity = { + id: target_id, + latest: {}, // TODO(Brandon): investigate this. data.message? + render: true, + locked: false, + }; - // returns session ID on success; returns -1 on failure - // TODO(Brandon): deprecate and remove 8/10/21 - getSessionIdFromSession: function (session) { - let result = -1; + session.entities.push(entity); - if (session == null || typeof session === "undefined") { - this.logErrorSessionClientSocketAction(null, null, null, `tried to get session ID from session, but session was null or undefined`); + return; + } - return result; - } + foundEntity.locked = false; + }, - if (typeof session !== "object") { - this.logErrorSessionClientSocketAction(null, null, null, `tried to get session ID from session, but session was not an object`); + applyStartMoveInteractionToState: function (session, target_id) { + }, - return result; - } + applyEndMoveInteractionToState: function (session, target_id) { + }, - if (session.clients == null || typeof session.clients === "undefined") { - this.logErrorSessionClientSocketAction(session.id, null, null, `session.clients was null or undefined`); + applyDrawToState: function () { + }, - return result; - } + applyEraseToState: function () { + }, - if (session.sockets == null || typeof session.sockets === "undefined") { - this.logErrorSessionClientSocketAction(session.id, null, null, `session.sockets was null or undefined`); + applyInteractionMessageToState: function (session, target_id, interaction_type) { + // entity should be rendered + if (interaction_type == INTERACTION_RENDER) { + this.applyShowInteractionToState(session, target_id); + } - return result; - } + // entity should stop being rendered + if (interaction_type == INTERACTION_RENDER_END) { + this.applyHideInteractionToState(session, target_id); + } - this.sessions.forEach((candidate_session, candidate_session_id) => { - if (candidate_session.clients == null || - typeof candidate_session.clients === "undefined") { - return; // return from the inner function only. - } - - if (candidate_session.sockets == null || - typeof candidate_session.sockets === "undefined") { - return; // return from the inner function only. - } + // scene has changed + if (interaction_type == INTERACTION_SCENE_CHANGE) { + session.scene = target_id; + } - if (candidate_session.sockets.size != session.sockets.size) { - return; // return from the inner function only. - } + // entity is locked + if (interaction_type == INTERACTION_LOCK) { + this.applyLockInteractionToState(session, target_id); + } - if (compareKeys(candidate_session.sockets, session.sockets)) { - result = candidate_session_id; - } - }); + // entity is unlocked + if (interaction_type == INTERACTION_LOCK_END) { + this.applyUnlockInteractionToState(session, target_id); + } + }, - return result; - }, - - getSessionSocketsFromClientId: function (session, client_id, excluded_socket_id) { - if (session == null) { - this.logErrorSessionClientSocketAction(null, client_id, null, `tried to get session sockets from client ID, but session was null`); + applyObjectsSyncPackedArrayToState: function (session, packedArray) { + let entity_id = packedArray[KomodoMessages.sync.indices.entityId]; - return null; - } + let foundEntity = this.getEntityFromState(session, entity_id); - if (session.sockets == null) { - this.logErrorSessionClientSocketAction(session.id, client_id, null, `tried to get session sockets from client ID, but session.sockets was null`); + if (foundEntity == null) { + this.logInfoSessionClientSocketAction("unk", "unk", "unk", `apply sync message to state: no entity with target_id ${target_id} found. Creating one.`); + + let entity = { + id: entity_id, + latest: packedArray, + render: true, + locked: false, + }; - return null; - } + session.entities.push(entity); - var result = []; + return; + } - for (var candidate_socket_id in session.sockets) { - let isCorrectId = session.sockets[candidate_socket_id].client_id == client_id; + foundEntity.latest = packedArray; + }, - let doExclude = (session.sockets[candidate_socket_id].socket.id == excluded_socket_id); + applyObjectsSyncToState: function (session, message) { + if (message == null) { + //TODO: do something other than fail silently, which we need to do now - if (isCorrectId && !doExclude) { - result.push(session.sockets[candidate_socket_id].socket); - } - } + return; + } - return result; - }, + let foundEntity = this.getEntityFromState(session, message.entityId); - // returns number of client instances of the same ID on success; returns -1 on failure; - getNumClientInstancesForClient: function (session_id, client_id) { - let session = this.sessions.get(session_id); + if (foundEntity == null) { + this.logInfoSessionClientSocketAction(null, null, null, `Apply sync message to state: no entity with entityId ${message.entityId} found. Creating one.`); + + let entity = { + id: message.entityId, + latest: message, + render: true, + locked: false, + }; - if (session == null || session.clients == null) { - this.logErrorSessionClientSocketAction(session_id, client_id, null, `Could not get number of client instances -- session was null or session.clients was null.`); + session.entities.push(entity); - return -1; - } + return; + } - var count = 0; + foundEntity.latest = message; + }, - session.clients.forEach((value) => { - if (value == client_id) { - count += 1; - } - }); + applySyncMessageToState: function (session, message) { + if (message == null) { + //TODO: do something other than fail silently, which we need to do now - return count; - }, + return; + } - // cleanup socket and client references in session state if reconnect fails - removeSocketFromSession: function (socket, session_id, client_id) { - if (!socket) { - this.logErrorSessionClientSocketAction(session_id, client_id, null, `tried removing socket from session, but socket was null`); + // update session state with latest entity positions + if (message.entityType == SYNC_OBJECTS) { + this.applyObjectsSyncToState(session, message); + } + }, + + getMetadataFromMessage: function (data, socket) { + if (data == null) { + this.logErrorSessionClientSocketAction( + null, + null, + socket.id, + "tried to process message, but data was null" + ); + + return { + success: false, + session_id: null, + client_id: null, + }; + } + + let session_id = data.session_id; + + if (!session_id) { + this.logErrorSessionClientSocketAction( + null, + null, + socket.id, + "tried to process message, but session_id was null" + ); + + return { + success: false, + session_id: null, + client_id: data.client_id, + }; + } - return; - } + let client_id = data.client_id; + + if (!client_id) { + this.logErrorSessionClientSocketAction( + session_id, + null, + socket.id, + "tried to process message, but client_id was null" + ); + + return { + success: false, + session_id: data.session_id, + client_id: null, + }; + } - if (!this.disconnectAction) { - this.logWarningSessionClientSocketAction(null, client_id, socket.id, `in removeSocketFromSession, disconnectAction callback was not provided`); - } + return { + success: true, + session_id: data.session_id, + client_id: data.client_id, + }; + }, + + isSocketInRoom: function (socket, session_id) { + if (!socket) { + this.logInfoSessionClientSocketAction(session_id, + client_id, + socket.id, + "isSocketInRoom: socket was null." + ); + } - this.disconnectAction(socket, session_id, client_id); + if (!socket.rooms) { + this.logInfoSessionClientSocketAction(session_id, + client_id, + socket.id, + "isSocketInRoom: socket.rooms was null." + ); + } - // clean up - let session = this.sessions.get(session_id); + let roomIds = Object.keys(socket.rooms); - if (!session) { - this.logWarningSessionClientSocketAction(session_id, client_id, socket.id, `Could not find session when trying to remove a socket from it.`); + return roomIds.includes(session_id); + }, - return; - } + rejectUser: function (socket, reason) { + socketRepairCenter.set(socket.id, socket); - if (!(socket.id in session.sockets)) { - this.logErrorSessionClientSocketAction(session_id, client_id, socket.id, `tried removing socket from session.sockets, but it was not found.`); + if (!this.rejectUserAction) { + this.logErrorSessionClientSocketAction( + null, + null, + socket.id, + "in rejectUser, no rejectUserAction callback was provided." + ); - return; - } + return; + } - // remove socket->client mapping - delete session.sockets[socket.id]; + this.rejectUserAction(socket, reason); - this.logInfoSessionClientSocketAction(session_id, client_id, socket.id, `Removed client from session.`); - - this.removeClientFromSession(session, client_id); - }, + this.disconnectSocket(socket, session_id, client_id); - getNumClientInstances: function (session_id) { - let session = this.sessions.get(session_id); + this.removeSocketAndClientFromSession(socket, session_id, client_id); + }, - if (!session) { - this.logWarningSessionClientSocketAction(session_id, null, null, `tried to get number of clients for a session, but it was not found.`); + // currently unused. + applyInteractionPackedArrayToState: function (data, type, packedArray, session_id, client_id, socket) { + if (message.length < KomodoMessages.interaction.minLength) { + this.logErrorSessionClientSocketAction(session_id, client_id, socket.id, "poorly formed interaction message: data.message.length was incorrect"); - return -1; - } + return; + } - if (session.clients == null) { - this.logWarningSessionClientSocketAction(session_id, null, null, `the session's session.clients was null.`); + let source_id = message[KomodoMessages.interaction.indices.sourceId]; - return -1; - } + if (source_id == null) { + this.logErrorSessionClientSocketAction(session_id, client_id, socket.id, `poorly formed interaction message: ${JSON.stringify(message)}`); + } - return session.clients.length; - }, + let target_id = message[KomodoMessages.interaction.indices.targetId]; - try_to_end_recording: function (session_id) { - let session = this.sessions.get(session_id); + if (target_id == null) { + this.logErrorSessionClientSocketAction(session_id, client_id, socket.id, `poorly formed interaction message: ${message.toString()}`); + } - if (!session) { - this.logWarningSessionClientSocketAction(session_id, null, null, `tried to end recording for session ${session_id}, but it was not found.`); + let interaction_type = message[KomodoMessages.interaction.indices.interactionType]; - return; - } + if (interaction_type == null) { + this.logErrorSessionClientSocketAction(session_id, client_id, socket.id, `poorly formed interaction message: ${message.toString()}`); + } - if (!session.isRecording) { - return; - } + this.applyInteractionMessageToState(session, target_id, interaction_type); + }, - this.logInfoSessionClientSocketAction(session_id, null, null, `Stopping recording for empty session`); + // Not currently used. + applySyncPackedArrayToState: function(data, type, packedArray, session_id, client_id, socket) { + let entity_type = data.message[KomodoMessages.sync.indices.entityType]; - this.end_recording(session_id); - }, + if (entity_type == null) { + this.logErrorSessionClientSocketAction(null, null, null, JSON.stringify(data.message)); + } - // clean up session from sessions map if empty, write - cleanUpSessionIfEmpty: function (session_id) { - if (this.getNumClientInstancesForClient(session_id) >= 0) { - // don't clean up if there are still clients in the session - return; - } + if (entity_type == SYNC_OBJECTS) { + this.applyObjectsSyncToState(session, data); + } + }, - this.logInfoSessionClientSocketAction(session_id, null, null, `Ending empty session`); + applyMessageToState: function (data, type, message, session, client_id, socket) { + if (message == null) { + //TODO: do something other than fail silently. - this.try_to_end_recording(session_id); - - this.sessions.delete(session_id); - }, + return; + } - // if a session exists, return it. Otherwise, create one with default values, register it, and return it. - getOrCreateSession: function (session_id) { - let { success, session } = this.getSession(session_id); + // get reference to session and parse message payload for state updates, if needed. + if (type == KomodoMessages.interaction.type) { + this.applyInteractionMessageToState(session, message.targetEntity_id, message.interactionType); + } - if (success) { - return session; - } + if (data.type == KomodoMessages.sync.type) { + this.applySyncMessageToState(session, message); + } + }, + + repair: function (socket, session_id, client_id) { + let session = this.getOrCreateSession(session_id); + + this.addClientToSessionIfNeeded(socket, session, client_id); + + this.addSocketToSessionIfNeeded(socket, session, client_id); + + this.joinSocketToRoomIfNeeded(socket, session); + + let result = this.handleStateCatchupRequest( + socket, + { + session_id: session_id, + client_id: client_id, + version: STATE_VERSION + } + ); + + if (result.session_id == -1 || !result.state) { + this.logWarningSessionClientSocketAction( + result.session_id, + client_id, + socket.id, + "repair: state was null" + ); + + return; + } - return this.createSession(session_id); - }, + this.logInfoSessionClientSocketAction( + session_id, + null, + socket.id, + `Sending state catch-up: ${JSON.stringify(result.state)}` + ); + + this.sendStateCatchUpAction(socket, result.state); + }, + + addClientToSessionIfNeeded: function (socket, session, client_id) { + if (!session.hasClient(client_id)) { + this.logInfoSessionClientSocketAction( + session.getId(), + client_id, + socket.id, + "- Client is not in session. Adding client and proceeding." + ); + + session.addClient(client_id); + + // TODO: consider doing this.rejectUserAction(socket, "User has a socket but client is not in session."); + } + }, + + // check if the incoming packet is from a client who is valid for this session + addSocketToSessionIfNeeded: function (socket, session, client_id) { + let socketResult = session.hasSocket(socket); + + if (!socketResult) { + this.logInfoSessionClientSocketAction( + session.id, + client_id, + socket.id, + "- Socket is not in session. Adding socket and proceeding." + ); + + session.addSocket(socket, client_id); + + // TODO: consider doing this.rejectUserAction(socket, "User has a socket but socket is not in session."); + } + }, + + // Check if the socket is in a SocketIO room. + joinSocketToRoomIfNeeded: function (socket, session) { + if(!this.isSocketInRoom(socket, session.getId())) { + this.logInfoSessionClientSocketAction( + session.getId(), + null, + socket.id, + "- Socket is not joined to SocketIO room. Joining socket and proceeding." + ); + + this.joinSocketToRoomAction(session.getId(), socket); + + // TODO: consider doing this.rejectUserAction(socket, "User has a socket but socket is not in session."); + } + }, + + processMessage: function (data, socket) { + let { success, session_id, client_id } = this.getMetadataFromMessage(data, socket); + + if (!client_id || !session_id) { + this.connectionAuthorizationErrorAction( + socket, + "You must provide a client ID and a session ID in the message metadata. Disconnecting" + ); + + this.disconnectAction( + socket, + session_id, + client_id + ); + + return; + } - getSession: function (session_id) { - let _session = this.sessions.get(session_id); + if (!success) { + return; + } - if (_session != null && typeof _session != "undefined") { - return { - success: true, + this.socketRepairCenter.repairSocketIfEligible(socket, session_id, client_id); - session: _session - }; - } + // Don't process a message for a socket... + // * whose socket record isn't in the session + // * whose client record isn't in the session + // * who isn't joined to the (SocketIO) room + if (this.socketRepairCenter.hasSocket(socket)) { + return; + } + + this.socketActivityMonitor.updateTime(socket.id); - return { - success: false, + let session = this.sessions.get(session_id); - session: null - }; - }, + if (!session) { + this.logErrorSessionClientSocketAction( + session_id, + client_id, + socket.id, + "tried to process message, but session was not found. Creating session and proceeding." + ); - initialize_recording_writers: function () { - }, + session = this.createSession(session_id); //TODO(Brandon): review if we should - createSession: function (session_id) { - this.logInfoSessionClientSocketAction(session_id, null, null, `Creating session: ${session_id}`); - - this.sessions.set(session_id, { - id: session_id, - sockets: {}, // socket.id -> client_id - clients: [], - entities: [], - scene: null, - isRecording: false, - start: Date.now(), - recordingStart: 0, - seq: 0, - // NOTE(rob): DEPRECATED, use message_buffer. 8/3/2021 - // writers: { - // pos: { - // buffer: Buffer.alloc(this.positionWriteBufferSize()), - // cursor: 0 - // }, - // int: { - // buffer: Buffer.alloc(this.interactionWriteBufferSize()), - // cursor: 0 - // } - // }, - message_buffer: [] - }); + return; + } - session = this.sessions.get(session_id); - - return session; - }, + if (!data.type) { + this.logErrorSessionClientSocketAction( + session_id, + client_id, + socket.id, + "tried to process message, but type was null" + ); - processReconnectionAttempt: function (err, socket, session_id, client_id) { - let success = this.handleJoin(err, socket, session_id, client_id, true); + return; + } - if (!success) { - this.logInfoSessionClientSocketAction(session_id, client_id, socket.id, 'failed to reconnect'); + if (!data.message) { + this.logErrorSessionClientSocketAction( + session_id, + client_id, + socket.id, + "tried to process message, but data.message was null" + ); - this.removeSocketFromSession(socket, session_id, client_id); + return; + } + + // `message` here will be in the legacy packed-array format. + + // relay the message + this.messageAction(socket, session_id, data); - this.cleanUpSessionIfEmpty(session_id); + if (!data.message.length) { + this.logErrorSessionClientSocketAction(session_id, client_id, socket.id, "tried to process message, but data.message.length was 0."); - return false; - } + return; + } - ////TODO does this need to be called here???? this.bumpOldSockets(session_id, client_id, socket.id); + data.message = this.parseMessageIfNeeded(data, session_id, client_id); - this.logInfoSessionClientSocketAction(session_id, client_id, socket.id, 'successfully reconnected'); + //TODO remove this.logInfoSessionClientSocketAction(null, null, null, data.message); - return true; - }, + this.applyMessageToState(data, data.type, data.message, session, client_id, socket); - whoDisconnected: function (socket) { - for (var s in this.sessions) { - const session_id = s[0]; + // data capture + if (session.isRecording) { + this.record_message_data(data); + } + }, - let session = s[1]; + init: function (io, pool, logger, chatNamespace, adminNamespace) { + this.initGlobals(); - if (!(socket.id in session.sockets)) { - // This isn't the right session, so keep looking. - continue; - } + this.createCapturesDirectory(); - // We found the right session. + if (logger == null) { + console.warn("No logger was found."); + } - return { - session_id: session_id, + this.logger = logger; - client_id: client_id - }; - } + if (!this.logger) { + console.error("Failed to init logger. Exiting."); + process.exit(); + } - return { - session_id: null, + if (chatNamespace == null) { + this.logger.warn("No chatNamespace was found."); + } - client_id: null - }; - }, + this.chatNamespace = chatNamespace; - // returns true if socket is still connected - handleDisconnect: function (socket, reason) { - if (!socket) { - this.logErrorSessionClientSocketAction(null, null, null, `tried handling disconnect, but socket was null`); + if (adminNamespace == null) { + this.logger.warn("No adminNamespace was found."); + } - return false; - } + this.adminNamespace = adminNamespace; - if (!this.reconnectAction) { - this.logErrorSessionClientSocketAction(null, null, socket.id, `in handleDisconnect, reconnectAction callback was not provided`); + this.logInfoSessionClientSocketAction( + "Sess.", + "Client", + "Socket ID.................", + "Message" + ); - return false; - } + if (pool == null) { + if (this.logger) this.logger.warn("No MySQL Pool was found."); + } - // Check disconnect event reason and handle - // see https://socket.io/docs/v2/server-api/index.html - - let knownReasons = { - // the disconnection was initiated by the server - "server namespace disconnect": { - doReconnect: false, - }, - // The socket was manually disconnected using socket.disconnect() - "client namespace disconnect": { - doReconnect: false, - }, - // The connection was closed (example: the user has lost connection, or the network was changed from WiFi to 4G) - "transport close": { - doReconnect: false, - }, - // The connection has encountered an error (example: the server was killed during a HTTP long-polling cycle) - "transport error": { - doReconnect: false, - }, - // The server did not send a PING within the pingInterval + pingTimeout range. - "ping timeout": { - doReconnect: true, - }, - }; + this.pool = pool; - let doReconnectOnUnknownReason = true; + let self = this; - // find which session this socket is in - for (var s of this.sessions) { - let session_id = s[0]; + if (io == null) { + if (this.logger) this.logger.warn("No SocketIO server was found."); + } - let session = s[1]; + this.connectionAuthorizationErrorAction = function (socket, message) { + socket.emit(KomodoSendEvents.connectionError, message); + }; + + this.messageAction = function (socket, session_id, data) { + socket.to(session_id.toString()).emit(KomodoSendEvents.message, data); + }; + + this.notifyBumpAction = function (session_id, socket) { + self.logInfoSessionClientSocketAction( + session_id, + null, + socket.id, + `Notifying about bump` + ); + + // Let the client know it has been bumped + socket.emit(KomodoSendEvents.notifyBump, session_id); + }; + + this.rejectUserAction = function (socket, reason) { + self.logInfoSessionClientSocketAction( + null, + null, + socket.id, + `Rejecting` + ); + + // Let the client know it has been bumped + socket.emit(KomodoSendEvents.rejectUser, reason); + }; + + this.makeSocketLeaveSessionAction = function (session_id, socket) { + socket.leave(session_id.toString(), (err) => { + this.failedToLeaveAction(session_id, `Failed to leave during bump: ${err}.`, socket); + }); + }; + + // TODO(Brandon) -- review if this function is needed. I detached it 10/16/21. + this.disconnectSocketAfterDelayAction = function (session_id, socket) { + self.logInfoSessionClientSocketAction( + session_id, + null, + socket.id, + `Disconnecting: ...` + ); + + setTimeout(() => { + socket.disconnect(true); + + self.logInfoSessionClientSocketAction( + session_id, + null, + socket.id, + `Disconnecting: Done.` + ); + }, 500); // delay half a second and then bump the old socket + }; + + this.requestToJoinSessionAction = function (session_id, client_id, socket) { + self.logInfoSessionClientSocketAction( + session_id, + client_id, + socket.id, + `Processing request to join session.` + ); + + socket.join(session_id.toString(), (err) => { + self.addSocketAndClientToSession( + err, + socket, + session_id, + client_id, + true + ); + }); + }; + + this.joinSocketToRoomAction = function (session_id, socket) { + socket.join(session_id.toString(), (err) => { + if (err) { + self.logErrorSessionClientSocketAction( + session_id, + null, + socket.id, + `Tried to join socket to SocketIO room. Error: ${err}` + ); + } + }); + }; + + this.failedToJoinAction = function (session_id, reason, socket) { + socket.emit(KomodoSendEvents.failedToJoin, session_id, reason); + }; + + this.successfullyJoinedAction = function (session_id, client_id, socket) { + // write join event to database + self.writeEventToConnections("connect", session_id, client_id); + + // tell other clients that a client joined + socket.to(session_id.toString()).emit(KomodoSendEvents.clientJoined, client_id); + + // tell the joining client that they successfully joined + socket.emit(KomodoSendEvents.successfullyJoined, session_id); + }; + + //TODO(Brandon) -- somewhere, handle request to leave and log to server output. + + this.requestToLeaveSessionAction = function (session_id, client_id, socket) { + socket.leave(session_id.toString(), (err) => { + self.tryToRemoveSocketAndClientFromSessionThenNotifyLeft(err, session_id, client_id, socket); + }); + }; + + this.failedToLeaveAction = function (session_id, reason, socket) { + socket.emit(KomodoSendEvents.failedToLeave, session_id, reason); + }; + + this.successfullyLeftAction = function (session_id, client_id, socket) { + // notify others the client has left + socket.to(session_id.toString()).emit(KomodoSendEvents.left, client_id); + + // tell the leaving client that they successfully left + socket.emit(KomodoSendEvents.successfullyLeft, session_id); + }; + + this.disconnectAction = function (socket, session_id, client_id) { + //disconnect the client + socket.disconnect(); + + if (!session_id) { + return; + } + + if (!client_id) { + return; + } + + // notify others the client has disconnected + socket + .to(session_id.toString()) + .emit(KomodoSendEvents.disconnected, client_id); + }; + + this.stateErrorAction = function (socket, message) { + socket.emit(KomodoSendEvents.stateError, message); + }; + + // returns true for successful reconnection + this.reconnectAction = function ( + reason, + socket, + session_id, + client_id, + session + ) { + self.logInfoSessionClientSocketAction( + session_id, + client_id, + socket.id, + `Client was disconnected; attempting to reconnect. Disconnect reason: , clients: ${JSON.stringify( + session.getClients() + )}` + ); + + //TODO -- do we need to rejoin it manually? + socket.join(session_id.toString(), (err) => { + self.processReconnectionAttempt(err, socket, session_id, client_id); + }); + }; + + this.sendStateCatchUpAction = function (socket, state) { + socket.emit(KomodoSendEvents.state, state); + }; + + // main relay handler + io.of(SYNC_NAMESPACE).on(SocketIOEvents.connection, function (socket) { + socket.emit(KomodoSendEvents.serverName, `${SERVER_NAME} + ${SYNC_NAMESPACE}`); + + self.logInfoSessionClientSocketAction( + null, + null, + socket.id, + `Connected to sync namespace` + ); + + self.socketRepairCenter.add(socket); + + self.socketActivityMonitor.updateTime(socket.id); + + // self.socketRepairCenter.repairSocketIfEligible(); + + socket.on(KomodoReceiveEvents.sessionInfo, function (session_id) { + let session = self.sessions.get(session_id); - if (!(socket.id in session.sockets)) { - // This isn't the right session, so keep looking. - continue; - } + if (!session) { + self.logWarningSessionClientSocketAction( + session_id, + null, + socket.id, + `Requested session, but it does not exist.` + ); - // We found the right session. - - let client_id = session.sockets[socket.id].client_id; + return; + } - if ((knownReasons.hasOwnProperty(reason) && knownReasons[reason].doReconnect) || doReconnectOnUnknownReason) { - return this.reconnectAction(reason, socket, session_id, client_id, session); - } + socket.to(session_id.toString()).emit(KomodoSendEvents.sessionInfo, session); + }); - //Disconnect the socket + socket.on(KomodoReceiveEvents.requestToJoinSession, function (data) { + let session_id = data[0]; - this.logInfoSessionClientSocketAction(session_id, client_id, socket.id, `Client was disconnected, probably because an old socket was bumped. Reason: ${reason}, clients: ${JSON.stringify(session.clients)}`); + let client_id = data[1]; - this.removeSocketFromSession(socket, session_id, client_id); + self.logInfoSessionClientSocketAction( + session_id, + client_id, + socket.id, + `Asked to join` + ); - this.cleanUpSessionIfEmpty(session_id); + if (!client_id || !session_id) { + self.connectionAuthorizationErrorAction( + socket, + "You must provide a client ID and a session ID in the URL options." + ); - return false; // Don't continue to check other sessions. + return; } - //socket not found in our records. This will happen for komodo-unity versions v0.3.2 and below, which handle "sync" actions on the main server namespace. - this.logInfoSessionClientSocketAction(null, null, socket.id, `disconnected. Not found in sessions. Probably ok.)`); - }, + //TODO does this need to be called here???? self.bumpOldSockets(session_id, client_id, socket.id); - createCapturesDirectory: function () { - if (!fs.existsSync(config.capture.path)) { - this.logInfoSessionClientSocketAction(null, null, null, `Creating directory for session captures: ${config.capture.path}`); + if (!self.requestToJoinSessionAction) { + self.logErrorSessionClientSocketAction( + session_id, + client_id, + socket.id, + `in socket.on(${KomodoReceiveEvents.requestToJoinSession}), requestToJoinSessionAction callback was not provided` + ); - fs.mkdirSync(config.capture.path); + return; } - }, - getSessions: function () { - return this.sessions; - }, + self.requestToJoinSessionAction(session_id, client_id, socket); + }); - initGlobals: function () { - this.sessions = new Map(); - }, + // When a client requests a state catch-up, send the current session state. Supports versioning. + socket.on(KomodoReceiveEvents.requestOwnStateCatchup, function (data) { + let { session_id, state } = self.handleStateCatchupRequest(socket, data); + + if (session_id == -1 || !state) { + self.logWarningSessionClientSocketAction( + session_id, + null, + socket.id, + "state was null" + ); - init: function (io, pool, logger) { - this.initGlobals(); + return; + } - this.createCapturesDirectory(); + self.logInfoSessionClientSocketAction( + session_id, + null, + socket.id, + `Sending state catch-up: ${JSON.stringify(state)}` + ); - if (logger == null) { - console.warn("No logger was found."); + //TODO -- refactor this so that sendStateCatchupAction gets called within handleStateCatchupRequest or something like that. + try { + // emit versioned state data + socket.emit(KomodoSendEvents.state, state); // Behavior as of 10/7/21: Sends the state only to the client who requested it. + } catch (err) { + this.logErrorSessionClientSocketAction( + session_id, + null, + socket.id, + err.message + ); } + }); + + socket.on(KomodoReceiveEvents.draw, function (data) { + let session_id = data[1]; + + let client_id = data[2]; + + if (!session_id) { + this.logErrorSessionClientSocketAction( + null, + client_id, + socket.id, + "tried to process draw event, but there was no session_id." + ); - this.logger = logger; - if (!this.logger) { - console.error("Failed to init logger. Exiting."); - process.exit(); + return; } - this.logInfoSessionClientSocketAction("Session ID", "Client ID", "Socket ID", "Message"); + if (!client_id) { + this.logWarningSessionClientSocketAction( + session_id, + null, + socket.id, + "tried to process draw event, but there was no client_id. Proceeding anyways." + ); - if (pool == null) { - if (this.logger) this.logger.warn("No MySQL Pool was found."); + return; } - this.pool = pool; + socket.to(session_id.toString()).emit(KomodoSendEvents.draw, data); + }); + + // general message relay + // TODO(rob): this is where all event data will eventually end up + // we will be doing compares on the data.type value for to-be-defined const values + // of the various interactions we care about, eg. grab, drop, start/end recording, etc. + // in order to update the session state accordingly. we will probably need to protect against + // garbage values that might be passed by devs who are overwriting reserved message events. + socket.on(KomodoReceiveEvents.message, function (data) { + if (socket == null) { + self.logErrorSessionClientSocketAction( + null, + null, + null, + "tried to process message, but socket was null" + ); + } - let self = this; + self.processMessage(data, socket); + }); - if (io == null) { - if (this.logger) this.logger.warn("No SocketIO server was found."); + // client position update handler + socket.on(KomodoReceiveEvents.update, function (data) { + if (!self.isValidRelayPacket(data)) { + return; } - this.connectionAuthorizationErrorAction = function (socket, message) { - socket.emit("connectionError", message); - }; + let session_id = data[1]; - this.bumpAction = function (session_id, socket) { - self.logInfoSessionClientSocketAction(session_id, null, socket.id, `leaving session`); + // relay packet if client is valid + socket + .to(session_id.toString()) + .emit(KomodoSendEvents.relayUpdate, data); - socket.leave(session_id.toString(), (err) => { - if (err) { - self.logErrorSessionClientSocketAction(session_id, null, socket.id, err); + // self.writeRecordedRelayData(data); NOTE(rob): DEPRECATED. 8/5/21. - return; - } - }); + self.updateSessionState(data); + }); - self.logInfoSessionClientSocketAction(session_id, null, socket.id, `Disconnecting: ...`); + // handle interaction events + // see `INTERACTION_XXX` declarations for type values + socket.on(KomodoReceiveEvents.interact, function (data) { + self.handleInteraction(socket, data); + }); - setTimeout(() => { - socket.disconnect(true); + // session capture handler + socket.on(KomodoReceiveEvents.start_recording, function (session_id) { + self.start_recording(pool, session_id); + }); - self.logInfoSessionClientSocketAction(session_id, null, socket.id, `Disconnecting: Done.`); - }, 500); // delay half a second and then bump the old socket - }; + socket.on(KomodoReceiveEvents.end_recording, function (session_id) { + self.end_recording(pool, session_id); + }); - this.joinSessionAction = function (session_id, client_id) { - io.to(session_id.toString()).emit('joined', client_id); - }; + socket.on(KomodoReceiveEvents.playback, function (data) { + self.handlePlayback(io, data); + }); - this.disconnectAction = function (socket, session_id, client_id) { - // notify and log event - socket.to(session_id.toString()).emit('disconnected', client_id); - }; + socket.on(SocketIOEvents.disconnect, function (reason) { + const { session_id, client_id } = self.whoDisconnected(socket); - this.stateErrorAction = function (socket, message) { - socket.emit('stateError', message); - }; + let didReconnect = self.handleDisconnect(socket, reason); - // returns true for successful reconnection - this.reconnectAction = function (reason, socket, session_id, client_id, session) { - self.logInfoSessionClientSocketAction(session_id, client_id, socket.id, `Client was disconnected; attempting to reconnect. Disconnect reason: ${reason}, clients: ${JSON.stringify(session.clients)}`); - - socket.join(session_id.toString(), (err) => { - self.processReconnectionAttempt(err, socket, session_id, client_id); - }); - }; + if (didReconnect) { + // log reconnect event with timestamp to db + self.writeEventToConnections("reconnect", session_id, client_id); + return; + } - // main relay handler - io.on('connection', function(socket) { - self.logInfoSessionClientSocketAction(null, null, socket.id, `Session connection`); - - socket.on('sessionInfo', function (session_id) { - let session = self.sessions.get(session_id); - - if (!session) { - self.logWarningSessionClientSocketAction(session_id, null, socket.id, `Requested session, but it does not exist.`); - - return; - } - - socket.to(session_id.toString()).emit('sessionInfo', session); - }); - - //Note: "join" is our own event name, and should not be confused with socket.join. (It does not automatically listen for socket.join either.) - socket.on('join', function(data) { - let session_id = data[0]; - - let client_id = data[1]; - - self.logInfoSessionClientSocketAction(session_id, client_id, socket.id, `Asked to join`); - - if (!client_id || !session_id) { - self.connectionAuthorizationErrorAction(socket, "You must provide a client ID and a session ID in the URL options."); - - return; - } - - //TODO does this need to be called here???? self.bumpOldSockets(session_id, client_id, socket.id); - - // relay server joins connecting client to session room - socket.join(session_id.toString(), (err) => { - let success = self.handleJoin(err, socket, session_id, client_id, true); - - if (success) { - // write join event to database - self.writeEventToConnections("connect", session_id, client_id); - } - }); - }); - - socket.on('state', function(data) { - let { session_id, state } = self.handleState(socket, data); - - if (session_id == -1 || !state) { - self.logWarningSessionClientSocketAction(session_id, null, socket.id, "state was null"); - - return; - } - - try { - // emit versioned state data - io.to(session_id).emit('state', state); - } catch (err) { - this.logErrorSessionClientSocketAction(session_id, null, socket.id, err.message); - } - }); - - socket.on('draw', function(data) { - let session_id = data[1]; - - let client_id = data[2]; - - if (session_id && client_id) { - socket.to(session_id.toString()).emit('draw', data); - } - }); - - // general message relay - // TODO(rob): this is where all event data will eventually end up - // we will be doing compares on the data.type value for to-be-defined const values - // of the various interactions we care about, eg. grab, drop, start/end recording, etc. - // in order to update the session state accordingly. we will probably need to protect against - // garbage values that might be passed by devs who are overwriting reserved message events. - socket.on('message', function(data) { - if (data) { - let session_id = data.session_id; - let client_id = data.client_id; - let type = data.type; - - if (session_id && client_id && type && data.message) { - // relay the message - socket.to(session_id.toString()).emit('message', data); - - // get reference to session and parse message payload for state updates, if needed. - let session = self.sessions.get(session_id); - if (session) { - - if (type == "interaction") { - // `message` here will be in the legacy packed-array format. - - // NOTE(rob): the following code is copypasta from the old interactionUpdate handler. 7/21/2021 - - // check if the incoming packet is from a client who is valid for this session - let joined = false; - for (let i=0; i < session.clients.length; i++) { - if (client_id == session.clients[i]) { - joined = true; - break; - } - } - - if (!joined) return; - - if (!data.message.length) return; // no message payload, nothing to do. - - // Check if message payload is pre-parsed. - // TODO(Brandon): evaluate whether to unpack here or keep as a string. - if (typeof data.message != `object`) { - try { - // parse and replace message payload - data.message = JSON.parse(data.message); - } catch (e) { - console.log(`Failed to parse 'interaction' message payload: ${data.message}; ${e}`); - return; - } - } - - let source_id = data.message[3]; - let target_id = data.message[4]; - let interaction_type = data.message[5]; - - // entity should be rendered - if (interaction_type == INTERACTION_RENDER) { - let i = session.entities.findIndex(e => e.id == target_id); - if (i != -1) { - session.entities[i].render = true; - } else { - let entity = { - id: target_id, - latest: [], - render: true, - locked: false - }; - session.entities.push(entity); - } - } - - // entity should stop being rendered - if (interaction_type == INTERACTION_RENDER_END) { - let i = session.entities.findIndex(e => e.id == target_id); - if (i != -1) { - session.entities[i].render = false; - } else { - let entity = { - id: target_id, - latest: data.message, - render: false, - locked: false - }; - session.entities.push(entity); - } - } - - // scene has changed - if (interaction_type == INTERACTION_SCENE_CHANGE) { - session.scene = target_id; - } - - // entity is locked - if (interaction_type == INTERACTION_LOCK) { - let i = session.entities.findIndex(e => e.id == target_id); - if (i != -1) { - session.entities[i].locked = true; - } else { - let entity = { - id: target_id, - latest: [], - render: false, - locked: true - }; - session.entities.push(entity); - } - } - - // entity is unlocked - if (interaction_type == INTERACTION_LOCK_END) { - let i = session.entities.findIndex(e => e.id == target_id); - if (i != -1) { - session.entities[i].locked = false; - } else { - let entity = { - id: target_id, - latest: [], - render: false, - locked: false - }; - session.entities.push(entity); - } - } - } - - if (type == "sync") { - // update session state with latest entity positions - - if (!data.message.length) return; // no message payload, nothing to do. - - // Check if message payload is pre-parsed. - // TODO(Brandon): evaluate whether to unpack here or keep as a string. - if (typeof data.message != `object`) { - try { - data.message = JSON.parse(data.message); - } catch (e) { - console.log(`Failed to parse 'sync' message payload: ${data.message}; ${e}`); - return; - } - } - - let entity_type = data.message[4]; - - if (entity_type == 3) { - let entity_id = data.message[3]; - - let i = session.entities.findIndex(e => e.id == entity_id); - - if (i != -1) { - session.entities[i].latest = data.message; - } else { - let entity = { - id: entity_id, - latest: data.message, - render: true, - locked: false - }; - - session.entities.push(entity); - } - } - } - - // data capture - if (session.isRecording) { - self.record_message_data(data); - } - } - } - } - }); - - // client position update handler - socket.on('update', function(data) { - if (!self.isValidRelayPacket(data)) { - return; - } - - let session_id = data[1]; - - // relay packet if client is valid - socket.to(session_id.toString()).emit('relayUpdate', data); - - // self.writeRecordedRelayData(data); NOTE(rob): DEPRECATED. 8/5/21. - - self.updateSessionState(data); - }); - - // handle interaction events - // see `INTERACTION_XXX` declarations for type values - socket.on('interact', function(data) { - self.handleInteraction(socket, data); - }); - - // session capture handler - socket.on('start_recording', function (session_id) { - self.start_recording(pool, session_id); - }); - - socket.on('end_recording', function (session_id) { - self.end_recording(pool, session_id); - }); - - socket.on('playback', function(data) { - self.handlePlayback(io, data); - }); - - socket.on('disconnect', function (reason) { - const { session_id, client_id } = self.whoDisconnected(socket); - - let didReconnect = self.handleDisconnect(socket, reason); - - if (didReconnect) { - // log reconnect event with timestamp to db - self.writeEventToConnections("reconnect", session_id, client_id); - return; - } - - // log reconnect event with timestamp to db - self.writeEventToConnections("disconnect", session_id, client_id); - }); - }); - } -}; \ No newline at end of file + // log reconnect event with timestamp to db + self.writeEventToConnections("disconnect", session_id, client_id); + }); + + socket.on(SocketIOEvents.disconnecting, function (reason) { + self.handleDisconnecting(socket, reason); + }); + + socket.on(SocketIOEvents.error, function (err) { + self.logErrorSessionClientSocketAction(null, null, socket.id || "null", err); + }); + }); + + logger.info(`Sync namespace is waiting for connections...`); + }, +}; diff --git a/test/test-session.js b/test/test-session.js new file mode 100644 index 0000000..74cd67f --- /dev/null +++ b/test/test-session.js @@ -0,0 +1,239 @@ +/* jshint esversion: 6 */ + +// TODO: add test for getting state +// TODO: add test for connecting without valid credentials + +var assert = require("assert"); + +var should = require("should"); + +const { debug } = require("winston"); + +const Session = require("../session"); + +const SESSION_ID = 123; + +const CLIENT_ID = 456; + +const DUMMY_SOCKET_A = { "dummy": "socketA", "id": "DEADBEEF" }; + +const DUMMY_SOCKET_B = { "dummy": "socketB", "id": "LIVEBEEF" }; + +const DUMMY_SOCKET_C = { "dummy": "socketC", "id": "SCHRBEEF" }; + +describe("Session", function (done) { + beforeEach(function () { + }); + + //TODO implement this if we ever keep a global list of clients + + /* + it("should have 0 clients on startup", function () { + }); + */ + + it("should create one singular, correct sessions object", function () { + const session_id = 123; + + let session = new Session(session_id); + + let sessionType = typeof session; + + sessionType.should.not.equal("undefined"); + + session.should.not.equal(null); + + const expectedSession = { + id: session_id, + sockets: {}, // socket.id -> client_id + clients: [], + entities: [], + scene: null, + isRecording: false, + start: Date.now(), + recordingStart: 0, + seq: 0, + // NOTE(rob): DEPRECATED, use message_buffer. 8/3/2021 + // writers: { + // pos: { + // buffer: Buffer.alloc(syncServer.positionWriteBufferSize()), + // cursor: 0 + // }, + // int: { + // buffer: Buffer.alloc(syncServer.interactionWriteBufferSize()), + // cursor: 0 + // } + // }, + message_buffer: [] + }; + + session.id.should.equal(expectedSession.id); + + assert.deepStrictEqual(session.sockets, expectedSession.sockets); + + assert.deepStrictEqual(session.clients, expectedSession.clients); + + assert.deepStrictEqual(session.entities, expectedSession.entities); + + assert.deepStrictEqual(session.scene, expectedSession.scene); + + assert.deepStrictEqual(session.isRecording, expectedSession.isRecording); + + // Do not check start time for strict equality. + assert(Math.abs(session.start - expectedSession.start) < 1000); + + assert.deepStrictEqual(session.recordingStart, expectedSession.recordingStart); + + assert.deepStrictEqual(session.seq, expectedSession.seq); + + assert.deepStrictEqual(session.message_buffer, expectedSession.message_buffer); + }); + + it("should append a valid client to an empty session", function () { + let session = new Session(); + + session.addClient(CLIENT_ID); + + let expectedClients = [ CLIENT_ID ]; + + session.clients.should.eql(expectedClients); + }); + + it("should append a duplicate client to a session", function () { + let session = new Session(); + + session.addClient(CLIENT_ID); + + let expectedClients = [ CLIENT_ID ]; + + session.clients.should.eql(expectedClients); + + session.addClient(CLIENT_ID); + + expectedClients = [ CLIENT_ID, CLIENT_ID ]; + + session.clients.should.eql(expectedClients); + }); + + it("should reduce two duplicate clients to one client", function () { + let session = new Session(); + + session.clients.length.should.equal(0); + + session.addClient(CLIENT_ID); + + session.clients.length.should.equal(1); + + session.addClient(CLIENT_ID); + + session.clients.length.should.equal(2); + + session.removeDuplicateClients(CLIENT_ID); + + session.clients.length.should.equal(1); + + let expectedClients = [ CLIENT_ID ]; + + session.clients.should.eql(expectedClients); + }); + + it("should return true if it found a socket", function () { + let session = new Session(); + + Object.keys(session.sockets).length.should.equal(0); + + session.addSocket(DUMMY_SOCKET_A, CLIENT_ID); + + let success = session.hasSocket(DUMMY_SOCKET_A); + + success.should.equal(true); + }); + + it("should return false if it couldn't find a socket", function () { + let session = new Session(); + + Object.keys(session.sockets).length.should.equal(0); + + session.addSocket(DUMMY_SOCKET_A, CLIENT_ID); + + let success = session.hasSocket(DUMMY_SOCKET_B); + + success.should.equal(false); + }); + + it("should be able to remove a socket", function () { + let session = new Session(); + + Object.keys(session.sockets).length.should.equal(0); + + session.addSocket(DUMMY_SOCKET_A, CLIENT_ID); + + Object.keys(session.sockets).length.should.equal(1); + + session.addSocket(DUMMY_SOCKET_B, CLIENT_ID); + + Object.keys(session.sockets).length.should.equal(2); + + let success = session.removeSocket(DUMMY_SOCKET_A); + + success.should.equal(true); + + Object.keys(session.sockets).length.should.equal(1); + + let expectedSockets = {}; + + expectedSockets[DUMMY_SOCKET_B.id] = { client_id: CLIENT_ID, socket: DUMMY_SOCKET_B }; + + session.sockets.should.eql(expectedSockets); + }); + + it("should return all session sockets for a given client ID", function () { + let session = new Session(); + + session.clients = [CLIENT_ID]; + + session.sockets = { + socketA: { client_id: CLIENT_ID, socket: DUMMY_SOCKET_A } + }; + + let sockets = session.getSocketsFromClientId(CLIENT_ID, null); + + sockets.should.eql([ DUMMY_SOCKET_A ]); + + session.addSocket(DUMMY_SOCKET_B, CLIENT_ID); + + // syncServer.notifyBumpAndMakeSocketLeaveSessionAction = function (session_id, socket) { + // session_id.should.equal(SESSION_ID); + + // socket.should.eql( { dummy: "socketA", id: "DEADBEEF" } ); + // }; + + // syncServer.sessions.set(SESSION_ID, { + // clients: [CLIENT_ID, CLIENT_ID], + // sockets: { + // socketA: { client_id: CLIENT_ID, socket: DUMMY_SOCKET_A }, + // socketB: { client_id: CLIENT_ID, socket: DUMMY_SOCKET_B } + // } + // }); + + sockets = session.getSocketsFromClientId(CLIENT_ID, null); + + sockets.should.eql([ DUMMY_SOCKET_A, DUMMY_SOCKET_B ]); + }); + + it("should exclude a socket when requesting session sockets", function () { + let session = new Session(); + + session.clients = [ CLIENT_ID, CLIENT_ID, CLIENT_ID ]; + + session.sockets = { + socketA: { client_id: CLIENT_ID, socket: DUMMY_SOCKET_A }, + socketB: { client_id: CLIENT_ID, socket: DUMMY_SOCKET_B }, + socketC: { client_id: CLIENT_ID, socket: DUMMY_SOCKET_C }, + }; + + let sockets = session.getSocketsFromClientId(CLIENT_ID, DUMMY_SOCKET_C.id); + + sockets.should.eql( [ DUMMY_SOCKET_A, DUMMY_SOCKET_B ] ); + }); +}); diff --git a/test/test-sync-clients-and-sockets.js b/test/test-sync-clients-and-sockets.js new file mode 100644 index 0000000..542f3a5 --- /dev/null +++ b/test/test-sync-clients-and-sockets.js @@ -0,0 +1,191 @@ +/* jshint esversion: 6 */ + +// TODO: add test for getting state +// TODO: add test for connecting without valid credentials + +var assert = require("assert"); + +var should = require("should"); + +const { debug } = require("winston"); + +const syncServer = require("../sync"); + +const Session = require("../session"); + +const SESSION_ID = 123; + +const CLIENT_ID = 456; + +const DUMMY_SOCKET_A = { "dummy": "socketA", "id": "DEADBEEF" }; + +const DUMMY_SOCKET_B = { "dummy": "socketB", "id": "LIVEBEEF" }; + +const DUMMY_SOCKET_C = { "dummy": "socketC", "id": "SCHRBEEF" }; + +describe("Sync Server: Clients and Sockets", function (done) { + beforeEach(function () { + syncServer.notifyBumpAction = function () { + throw Error("An unexpected bump occurred."); + }; + + syncServer.reconnectAction = function () { + throw Error("An unexpected reconnect occurred."); + }; + + syncServer.disconnectedAction = function () { + throw Error("An unexpected disconnect occurred."); + }; + + syncServer.requestToJoinSessionAction = function (session_id, client_id) { + session_id.should.equal(SESSION_ID); + + client_id.should.equal(CLIENT_ID); + }; + + syncServer.initGlobals(); + }); + + //TODO implement this if we ever keep a global list of clients + + /* + it("should have 0 clients on startup", function () { + }); + */ + + it("should return an error when appending a valid client to a null session", function () { + let sessions = new Map (); + + syncServer.sessions = sessions; + + let success = syncServer.addClientToSession(null, CLIENT_ID); + + success.should.eql(false); + }); + + it("should be able to bump one existing socket", function () { + let session = new Session(); + + session.clients = [ CLIENT_ID, CLIENT_ID ]; + + session.sockets = { }; + + session.sockets[DUMMY_SOCKET_A.id] = { client_id: CLIENT_ID, socket: DUMMY_SOCKET_A }; + + session.sockets[DUMMY_SOCKET_B.id] = { client_id: CLIENT_ID, socket: DUMMY_SOCKET_B }; + + syncServer.sessions.set(SESSION_ID, session); + + let outputSession = syncServer.sessions.get(SESSION_ID); + + Object.keys(outputSession.sockets).should.eql( [ DUMMY_SOCKET_A.id, DUMMY_SOCKET_B.id ] ); + + let bumpCount = 0; + + syncServer.notifyBumpAction = function (session_id, socket) { + session_id.should.equal(SESSION_ID); + + socket.should.equal(DUMMY_SOCKET_A); + + bumpCount += 1; + }; + + let leaveCount = 0; + + syncServer.makeSocketLeaveSessionAction = function (session_id, socket) { + session_id.should.equal(SESSION_ID); + + socket.should.equal(DUMMY_SOCKET_A); + + leaveCount += 1; + }; + + let disconnectCount = 0; + + syncServer.disconnectedAction = function (socket, session_id, client_id) { + session_id.should.equal(SESSION_ID); + + client_id.should.equal(CLIENT_ID); + + socket.should.equal(DUMMY_SOCKET_A); + + disconnectCount += 1; + }; + + syncServer.bumpDuplicateSockets(SESSION_ID, CLIENT_ID, true, DUMMY_SOCKET_B.id); + + bumpCount.should.eql(1); + + leaveCount.should.eql(1); + + disconnectCount.should.eql(1); + + outputSession = syncServer.sessions.get(SESSION_ID); + + Object.keys(outputSession.sockets).should.eql( [ DUMMY_SOCKET_B.id ] ); + }); + + it("should be able to bump two existing sockets", function () { + let session = new Session(); + + session.clients = [ CLIENT_ID, CLIENT_ID, CLIENT_ID ]; + + session.sockets = { }; + + session.sockets[DUMMY_SOCKET_A.id] = { client_id: CLIENT_ID, socket: DUMMY_SOCKET_A }; + + session.sockets[DUMMY_SOCKET_B.id] = { client_id: CLIENT_ID, socket: DUMMY_SOCKET_B }; + + session.sockets[DUMMY_SOCKET_C.id] = { client_id: CLIENT_ID, socket: DUMMY_SOCKET_C }; + + syncServer.sessions.set(SESSION_ID, session); + + let outputSession = syncServer.sessions.get(SESSION_ID); + + Object.keys(outputSession.sockets).should.eql( [ DUMMY_SOCKET_A.id, DUMMY_SOCKET_B.id, DUMMY_SOCKET_C.id ] ); + + let bumpCount = 0; + + syncServer.notifyBumpAction = function (session_id, socket) { + session_id.should.equal(SESSION_ID); + + socket.should.be.oneOf(DUMMY_SOCKET_A, DUMMY_SOCKET_B); + + bumpCount += 1; + }; + + let leaveCount = 0; + + syncServer.makeSocketLeaveSessionAction = function (session_id, socket) { + session_id.should.equal(SESSION_ID); + + socket.should.be.oneOf(DUMMY_SOCKET_A, DUMMY_SOCKET_B); + + leaveCount += 1; + }; + + let disconnectCount = 0; + + syncServer.disconnectedAction = function (socket, session_id, client_id) { + session_id.should.equal(SESSION_ID); + + client_id.should.equal(CLIENT_ID); + + socket.should.be.oneOf(DUMMY_SOCKET_A, DUMMY_SOCKET_B); + + disconnectCount += 1; + }; + + syncServer.bumpDuplicateSockets(SESSION_ID, CLIENT_ID, true, DUMMY_SOCKET_C.id); + + bumpCount.should.eql(2); + + leaveCount.should.eql(2); + + disconnectCount.should.eql(2); + + outputSession = syncServer.sessions.get(SESSION_ID); + + Object.keys(outputSession.sockets).should.eql( [ DUMMY_SOCKET_C.id ] ); + }); +}); diff --git a/test/test-sync-integration.js b/test/test-sync-integration.js new file mode 100644 index 0000000..91b69c5 --- /dev/null +++ b/test/test-sync-integration.js @@ -0,0 +1,183 @@ +/* jshint esversion: 6 */ + +// TODO: add test for getting state +// TODO: add test for connecting without valid credentials + +var assert = require("assert"); + +var should = require("should"); + +const { debug } = require("winston"); + +const syncServer = require("../sync"); + +const SESSION_ID = 123; + +const CLIENT_ID = 456; + +const DUMMY_SOCKET_A = { "dummy": "socketA", "id": "DEADBEEF" }; + +const DUMMY_SOCKET_B = { "dummy": "socketB", "id": "LIVEBEEF" }; + +const DUMMY_SOCKET_C = { "dummy": "socketC", "id": "SCHRBEEF" }; + +describe("Sync Server: Integration", function (done) { + beforeEach(function () { + syncServer.notifyBumpAction = function () { + throw Error("An unexpected bump occurred."); + }; + + syncServer.reconnectAction = function () { + throw Error("An unexpected reconnect occurred."); + }; + + syncServer.disconnectedAction = function () { + throw Error("An unexpected disconnect occurred."); + }; + + syncServer.requestToJoinSessionAction = function (session_id, client_id, socket) { + session_id.should.equal(SESSION_ID); + + client_id.should.equal(CLIENT_ID); + }; + + syncServer.initGlobals(); + }); + + it("should create a correct session object when a client joins", function () { + let success = syncServer.addSocketAndClientToSession(null, DUMMY_SOCKET_A, SESSION_ID, CLIENT_ID, true); + + success.should.equal(true); // we passed in err = null, so it should succeed. + + sessions = syncServer.getSessions(); + + sessions.size.should.equal(1); + + let singularEntry; + + // TODO(Brandon) - are we supposed to dip into the syncServer.sessions variable directly like this? + + for (let entry of sessions) { + singularEntry = entry; + } + + singularEntry[0].should.equal(SESSION_ID); + + //TODO - factor this out into a separate test? - it("should create a correct clients array" + + const expectedClients = [ CLIENT_ID ]; + + assert(singularEntry[1].clients != null); + + singularEntry[1].clients.length.should.equal(expectedClients.length); + + singularEntry[1].clients[0].should.equal(expectedClients[0]); + + //TODO - factor this out into a separate test? - it("should create a correct sockets object" + + const expectedSockets = { client_id: CLIENT_ID, socket: DUMMY_SOCKET_A }; + + let numSockets = Object.keys(singularEntry[1].sockets).length; + + numSockets.should.equal(1); + + singularEntry[1].sockets[DUMMY_SOCKET_A.id].should.eql(expectedSockets); + + // + + //this.addClientToSession(session, client_id); + + //this.bumpDuplicateSockets(session, client_id, do_bump_duplicates, socket.id); + + // socket to client mapping + //this.addSocketToSession(session, socket, client_id); + + //this.requestToJoinSessionAction(session_id, client_id); + }); + + it("should create a correct clients array", function () { + let success = syncServer.addSocketAndClientToSession(null, DUMMY_SOCKET_A, SESSION_ID, CLIENT_ID, true); + + sessions = syncServer.getSessions(); + + let singularEntry; + + for (let entry of sessions) { + singularEntry = entry; + } + + const expectedClients = [ CLIENT_ID ]; + + singularEntry[1].clients.length.should.equal(expectedClients.length); + + singularEntry[1].clients[0].should.equal(expectedClients[0]); + }); + + it("should create a correct sockets object", function () { + let success = syncServer.addSocketAndClientToSession(null, DUMMY_SOCKET_A, SESSION_ID, CLIENT_ID, true); + + sessions = syncServer.getSessions(); + + let singularEntry; + + for (let entry of sessions) { + singularEntry = entry; + } + + const socketA = { client_id: CLIENT_ID, socket: DUMMY_SOCKET_A }; + + let numSockets = Object.keys(singularEntry[1].sockets).length; + + numSockets.should.equal(1); + + singularEntry[1].sockets[DUMMY_SOCKET_A.id].should.eql(socketA); + }); + + it("should perform a bump properly", function () { + syncServer.notifyBumpAction = function (session_id, socket) { + session_id.should.equal(SESSION_ID); + + socket.should.eql( { dummy: "socketA", id: "DEADBEEF" } ); + }; + + syncServer.disconnectedAction = function (socket, session_id, client_id) { + socket.should.eql( { dummy: "socketA", id: "DEADBEEF" } ); + + session_id.should.equal(SESSION_ID); + + client_id.should.equal(CLIENT_ID); + }; + + let success = syncServer.addSocketAndClientToSession(null, DUMMY_SOCKET_A, SESSION_ID, CLIENT_ID, true); + + success = syncServer.addSocketAndClientToSession(null, DUMMY_SOCKET_B, SESSION_ID, CLIENT_ID, true); + + success.should.equal(true); // we passed in err = null, so it should succeed. + + sessions = syncServer.getSessions(); + + // TODO(Brandon) - are we supposed to dip into the syncServer.sessions variable directly like this? + + for (let entry of sessions) { + singularEntry = entry; + } + + const expectedClients = [ CLIENT_ID ]; + + singularEntry[1].clients.length.should.equal(expectedClients.length); + + singularEntry[1].clients[0].should.equal(expectedClients[0]); + + numSockets = Object.keys(singularEntry[1].sockets).length; + + numSockets.should.equal(1); + + const socketB = { client_id: CLIENT_ID, socket: DUMMY_SOCKET_B }; + + assert(singularEntry[1].sockets[DUMMY_SOCKET_B.id] != null); + + singularEntry[1].sockets[DUMMY_SOCKET_B.id].should.eql(socketB); + + assert(singularEntry[1].sockets[DUMMY_SOCKET_A.id] == null); + }); +}); \ No newline at end of file diff --git a/test/test-sync-sessions.js b/test/test-sync-sessions.js new file mode 100644 index 0000000..cda00da --- /dev/null +++ b/test/test-sync-sessions.js @@ -0,0 +1,113 @@ +/* jshint esversion: 6 */ + +// TODO: add test for getting state +// TODO: add test for connecting without valid credentials + +var assert = require("assert"); + +var should = require("should"); + +const { debug } = require("winston"); + +const syncServer = require("../sync"); + +const SESSION_ID = 123; + +const CLIENT_ID = 456; + +const DUMMY_SOCKET_A = { "dummy": "socketA", "id": "DEADBEEF" }; + +const DUMMY_SOCKET_B = { "dummy": "socketB", "id": "LIVEBEEF" }; + +const DUMMY_SOCKET_C = { "dummy": "socketC", "id": "SCHRBEEF" }; + +describe("Sync Server: Sessions", function (done) { + beforeEach(function () { + syncServer.initGlobals(); + + syncServer.notifyBumpAndMakeSocketLeaveSessionAction = function () { + throw Error("An unexpected bump occurred."); + }; + + syncServer.reconnectAction = function () { + throw Error("An unexpected reconnect occurred."); + }; + + syncServer.disconnectedAction = function () { + throw Error("An unexpected disconnect occurred."); + }; + }); + + it("should have 0 sessions on startup", function () { + let sessions = syncServer.getSessions(); + + sessions.size.should.equal(0); + }); + + it("should create one singular sessions object", function () { + const session_id = 123; + + let sessions = syncServer.getSessions(); + + sessions.size.should.equal(0); + + syncServer.createSession(session_id); + + sessions = syncServer.getSessions(); + + let count = 0; + + let singularEntry; + + // TODO(Brandon) - are we supposed to dip into the syncServer.sessions variable directly like this? + + for (let entry of sessions) { + count += 1; + + singularEntry = entry; + } + + count.should.equal(1); + + let sessionType = typeof singularEntry; + + sessionType.should.not.equal("undefined"); + + singularEntry[0].should.equal(session_id); + }); + + it("should return failure on getting a nonexistent session", function () { + let { success, session } = syncServer.getSession(SESSION_ID); + + success.should.equal(false); + + let sessionType = typeof session; + + sessionType.should.not.equal("undefined"); + + assert.strictEqual(session, null); + }); + + it("should return success for getting an existing session", function () { + let inputSession = { + clients: [ CLIENT_ID ], + sockets: { + socketA: { client_id: CLIENT_ID, socket: DUMMY_SOCKET_A } + } + }; + + syncServer.sessions.set(SESSION_ID, inputSession); + + let { success, session } = syncServer.getSession(SESSION_ID); + + success.should.equal(true); + + let sessionType = typeof session; + + sessionType.should.not.equal("undefined"); + + assert(session !== null); + + session.should.eql(inputSession); + }); +}); \ No newline at end of file diff --git a/test/test-sync.js b/test/test-sync.js deleted file mode 100644 index 7b84d60..0000000 --- a/test/test-sync.js +++ /dev/null @@ -1,518 +0,0 @@ -/* jshint esversion: 6 */ - -// TODO: add test for getting state -// TODO: add test for connecting without valid credentials - -var assert = require("assert"); - -var should = require("should"); - -const { debug } = require("winston"); - -const syncServer = require("../sync"); - -const SESSION_ID = 123; - -const CLIENT_ID = 456; - -const DUMMY_SOCKET_A = { "dummy": "socketA", "id": "DEADBEEF" }; - -const DUMMY_SOCKET_B = { "dummy": "socketB", "id": "LIVEBEEF" }; - -const DUMMY_SOCKET_C = { "dummy": "socketC", "id": "SCHRBEEF" }; - -describe("Sync Server: Sessions", function (done) { - beforeEach(function () { - syncServer.initGlobals(); - - syncServer.bumpAction = function () { - throw Error("An unexpected bump occurred."); - }; - - syncServer.reconnectAction = function () { - throw Error("An unexpected reconnect occurred."); - }; - - syncServer.disconnectAction = function () { - throw Error("An unexpected disconnect occurred."); - }; - }); - - it("should have 0 sessions on startup", function () { - let sessions = syncServer.getSessions(); - - sessions.size.should.equal(0); - }); - - it("should create one singular, correct sessions object", function () { - const session_id = 123; - - let sessions = syncServer.getSessions(); - - sessions.size.should.equal(0); - - syncServer.createSession(session_id); - - sessions = syncServer.getSessions(); - - let count = 0; - - let singularEntry; - - // TODO(Brandon) - are we supposed to dip into the syncServer.sessions variable directly like this? - - for (let entry of sessions) { - count += 1; - - singularEntry = entry; - } - - count.should.equal(1); - - singularEntry[0].should.equal(session_id); - - const expectedSession = { - sockets: {}, // socket.id -> client_id - clients: [], - entities: [], - scene: null, - isRecording: false, - start: Date.now(), - recordingStart: 0, - seq: 0, - // NOTE(rob): DEPRECATED, use message_buffer. 8/3/2021 - // writers: { - // pos: { - // buffer: Buffer.alloc(syncServer.positionWriteBufferSize()), - // cursor: 0 - // }, - // int: { - // buffer: Buffer.alloc(syncServer.interactionWriteBufferSize()), - // cursor: 0 - // } - // }, - message_buffer: [] - }; - - assert.deepStrictEqual(singularEntry[1].sockets, expectedSession.sockets); - - assert.deepStrictEqual(singularEntry[1].clients, expectedSession.clients); - - assert.deepStrictEqual(singularEntry[1].entities, expectedSession.entities); - - assert.deepStrictEqual(singularEntry[1].scene, expectedSession.scene); - - assert.deepStrictEqual(singularEntry[1].isRecording, expectedSession.isRecording); - - // Do not check start time for strict equality. - assert(Math.abs(singularEntry[1].start - expectedSession.start) < 1000); - - assert.deepStrictEqual(singularEntry[1].recordingStart, expectedSession.recordingStart); - - assert.deepStrictEqual(singularEntry[1].seq, expectedSession.seq); - - // NOTE(rob): DEPRECATED, use message_buffer. 8/3/2021 - // assert.deepStrictEqual(singularEntry[1].writers, expectedSession.writers); - - assert.deepStrictEqual(singularEntry[1].message_buffer, expectedSession.message_buffer); - - }); - - it("should return failure on getting a nonexistent session", function () { - let { success, session } = syncServer.getSession(SESSION_ID); - - success.should.equal(false); - - assert.strictEqual(session, null); - }); - - it("should return success for getting an existing session", function () { - let inputSession = { - clients: [ CLIENT_ID ], - sockets: { - socketA: { client_id: CLIENT_ID, socket: DUMMY_SOCKET_A } - } - }; - - syncServer.sessions.set(SESSION_ID, inputSession); - - let { success, session } = syncServer.getSession(SESSION_ID); - - success.should.equal(true); - - assert(session !== null); - - session.should.eql(inputSession); - }); -}); - -describe("Sync Server: Clients and Sockets", function (done) { - beforeEach(function () { - syncServer.bumpAction = function () { - throw Error("An unexpected bump occurred."); - }; - - syncServer.reconnectAction = function () { - throw Error("An unexpected reconnect occurred."); - }; - - syncServer.disconnectAction = function () { - throw Error("An unexpected disconnect occurred."); - }; - - syncServer.joinSessionAction = function (session_id, client_id) { - session_id.should.equal(SESSION_ID); - - client_id.should.equal(CLIENT_ID); - }; - - syncServer.initGlobals(); - }); - - //TODO implement this if we ever keep a global list of clients - - /* - it("should have 0 clients on startup", function () { - }); - */ - - it("should append a valid client to an empty session", function () { - let sessions = new Map (); - - let session = { - clients: [ ] - }; - - sessions.set(SESSION_ID, session); - - syncServer.sessions = sessions; - - syncServer.addClientToSession(session, CLIENT_ID); - - let expectedClients = [ CLIENT_ID ]; - - session.clients.should.eql(expectedClients); - }); - - it("should create a session when appending a valid client to a null session, appropriately", function () { - let sessions = new Map (); - - syncServer.sessions = sessions; - - syncServer.addClientToSession(null, CLIENT_ID, true); - - let expectedClients = [ CLIENT_ID ]; - - session.clients.should.eql(expectedClients); - }); - - it("should return an error when appending a valid client to a null session, appropriately", function () { - let sessions = new Map (); - - syncServer.sessions = sessions; - - let success = syncServer.addClientToSession(null, CLIENT_ID, false); - - success.should.eql(false); - }); - - it("should append a duplicate client to a session", function () { - let sessions = new Map (); - - let session = { - clients: [ CLIENT_ID ] - }; - - sessions.set(SESSION_ID, session); - - syncServer.sessions = sessions; - - syncServer.addClientToSession(session, CLIENT_ID); - - let expectedClients = [ CLIENT_ID, CLIENT_ID ]; - - session.clients.should.eql(expectedClients); - }); - - it("should be able to bump an existing socket", function () { - let session = { - clients: [ CLIENT_ID, CLIENT_ID, CLIENT_ID ], - sockets: { } - }; - - session.sockets[DUMMY_SOCKET_A.id] = { client_id: CLIENT_ID, socket: DUMMY_SOCKET_A }; - - session.sockets[DUMMY_SOCKET_B.id] = { client_id: CLIENT_ID, socket: DUMMY_SOCKET_B }; - - session.sockets[DUMMY_SOCKET_C.id] = { client_id: CLIENT_ID, socket: DUMMY_SOCKET_C }; - - syncServer.sessions.set(SESSION_ID, session); - - let outputSession = syncServer.sessions.get(SESSION_ID); - - Object.keys(outputSession.sockets).should.eql( [ DUMMY_SOCKET_A.id, DUMMY_SOCKET_B.id, DUMMY_SOCKET_C.id ] ); - - let bumpCount = 0; - - syncServer.bumpAction = function (session_id, socket) { - session_id.should.equal(SESSION_ID); - - socket.should.be.oneOf(DUMMY_SOCKET_A, DUMMY_SOCKET_B); - - bumpCount += 1; - }; - - let disconnectCount = 0; - - syncServer.disconnectAction = function (socket, session_id, client_id) { - session_id.should.equal(SESSION_ID); - - client_id.should.equal(CLIENT_ID); - - socket.should.be.oneOf(DUMMY_SOCKET_A, DUMMY_SOCKET_B); - - disconnectCount += 1; - }; - - syncServer.bumpDuplicateSockets(session, CLIENT_ID, true, DUMMY_SOCKET_C.id); - - bumpCount.should.eql(2); - - disconnectCount.should.eql(2); - - outputSession = syncServer.sessions.get(SESSION_ID); - - Object.keys(outputSession.sockets).should.eql( [ DUMMY_SOCKET_C.id ] ); - }); - - it("should reduce two duplicate clients to one client", function () { - syncServer.createSession(SESSION_ID); - - let { success, session } = syncServer.getSession(SESSION_ID); - - session.should.not.eql(null); - - session.clients.should.not.eql(null); - - session.clients.length.should.equal(0); - - syncServer.addClientToSession(session, CLIENT_ID); - - session.clients.length.should.equal(1); - - syncServer.addClientToSession(session, CLIENT_ID); - - session.clients.length.should.equal(2); - - syncServer.removeDuplicateClientsFromSession(session, CLIENT_ID); - - session.clients.length.should.equal(1); - }); - - it("should return all session sockets for a given client ID", function () { - syncServer.sessions = new Map (); - - let session = { - clients: [CLIENT_ID], - sockets: { - socketA: { client_id: CLIENT_ID, socket: DUMMY_SOCKET_A } - } - }; - - syncServer.sessions.set(SESSION_ID, session); - - let sockets = syncServer.getSessionSocketsFromClientId(session, CLIENT_ID, null); - - sockets.should.eql([ DUMMY_SOCKET_A ]); - - syncServer.bumpAction = function (session_id, socket) { - session_id.should.equal(SESSION_ID); - - socket.should.eql( { dummy: "socketA", id: "DEADBEEF" } ); - }; - - syncServer.sessions.set(SESSION_ID, { - clients: [CLIENT_ID, CLIENT_ID], - sockets: { - socketA: { client_id: CLIENT_ID, socket: DUMMY_SOCKET_A }, - socketB: { client_id: CLIENT_ID, socket: DUMMY_SOCKET_B } - } - }); - - session = syncServer.sessions.get(SESSION_ID); - - sockets = syncServer.getSessionSocketsFromClientId(session, CLIENT_ID, null); - - sockets.should.eql([ DUMMY_SOCKET_A, DUMMY_SOCKET_B ]); - }); - - it("should exclude a socket when requesting session sockets", function () { - let session = { - clients: [ CLIENT_ID, CLIENT_ID, CLIENT_ID ], - sockets: { - socketA: { client_id: CLIENT_ID, socket: DUMMY_SOCKET_A }, - socketB: { client_id: CLIENT_ID, socket: DUMMY_SOCKET_B }, - socketC: { client_id: CLIENT_ID, socket: DUMMY_SOCKET_C }, - } - }; - - syncServer.sessions.set(SESSION_ID, session); - - let sockets = syncServer.getSessionSocketsFromClientId(session, CLIENT_ID, DUMMY_SOCKET_C.id); - - sockets.should.eql( [ DUMMY_SOCKET_A, DUMMY_SOCKET_B ] ); - }); -}); - -describe("Sync Server: Integration", function (done) { - beforeEach(function () { - syncServer.bumpAction = function () { - throw Error("An unexpected bump occurred."); - }; - - syncServer.reconnectAction = function () { - throw Error("An unexpected reconnect occurred."); - }; - - syncServer.disconnectAction = function () { - throw Error("An unexpected disconnect occurred."); - }; - - syncServer.joinSessionAction = function (session_id, client_id) { - session_id.should.equal(SESSION_ID); - - client_id.should.equal(CLIENT_ID); - }; - - syncServer.initGlobals(); - }); - - it("should create a correct session object when a client joins", function () { - let success = syncServer.handleJoin(null, DUMMY_SOCKET_A, SESSION_ID, CLIENT_ID, true); - - success.should.equal(true); // we passed in err = null, so it should succeed. - - sessions = syncServer.getSessions(); - - sessions.size.should.equal(1); - - let singularEntry; - - // TODO(Brandon) - are we supposed to dip into the syncServer.sessions variable directly like this? - - for (let entry of sessions) { - singularEntry = entry; - } - - singularEntry[0].should.equal(SESSION_ID); - - //TODO - factor this out into a separate test? - it("should create a correct clients array" - - const expectedClients = [ CLIENT_ID ]; - - assert(singularEntry[1].clients != null); - - singularEntry[1].clients.length.should.equal(expectedClients.length); - - singularEntry[1].clients[0].should.equal(expectedClients[0]); - - //TODO - factor this out into a separate test? - it("should create a correct sockets object" - - const expectedSockets = { client_id: CLIENT_ID, socket: DUMMY_SOCKET_A }; - - let numSockets = Object.keys(singularEntry[1].sockets).length; - - numSockets.should.equal(1); - - singularEntry[1].sockets[DUMMY_SOCKET_A.id].should.eql(expectedSockets); - - // - - //this.addClientToSession(session, client_id); - - //this.bumpDuplicateSockets(session, client_id, do_bump_duplicates, socket.id); - - // socket to client mapping - //this.addSocketToSession(session, socket, client_id); - - //this.joinSessionAction(session_id, client_id); - }); - - it("should perform a bump properly", function () { - let success = syncServer.handleJoin(null, DUMMY_SOCKET_A, SESSION_ID, CLIENT_ID, true); - - sessions = syncServer.getSessions(); - - let singularEntry; - - // TODO(Brandon) - are we supposed to dip into the syncServer.sessions variable directly like this? - - for (let entry of sessions) { - singularEntry = entry; - } - - //TODO - factor this out into a separate test? - it("should create a correct clients array" - - const expectedClients = [ CLIENT_ID ]; - - singularEntry[1].clients.length.should.equal(expectedClients.length); - - singularEntry[1].clients[0].should.equal(expectedClients[0]); - - //TODO - factor this out into a separate test? - it("should create a correct sockets object" - - const socketA = { client_id: CLIENT_ID, socket: DUMMY_SOCKET_A }; - - let numSockets = Object.keys(singularEntry[1].sockets).length; - - numSockets.should.equal(1); - - singularEntry[1].sockets[DUMMY_SOCKET_A.id].should.eql(socketA); - - // duplicated here - - syncServer.bumpAction = function (session_id, socket) { - session_id.should.equal(SESSION_ID); - - socket.should.eql( { dummy: "socketA", id: "DEADBEEF" } ); - }; - - syncServer.disconnectAction = function (socket, session_id, client_id) { - socket.should.eql( { dummy: "socketA", id: "DEADBEEF" } ); - - session_id.should.equal(SESSION_ID); - - client_id.should.equal(CLIENT_ID); - }; - - success = syncServer.handleJoin(null, DUMMY_SOCKET_B, SESSION_ID, CLIENT_ID, true); - - success.should.equal(true); // we passed in err = null, so it should succeed. - - sessions = syncServer.getSessions(); - - // TODO(Brandon) - are we supposed to dip into the syncServer.sessions variable directly like this? - - for (let entry of sessions) { - singularEntry = entry; - } - - //TODO - factor this out into a separate test? - it("should create a correct clients array" - - singularEntry[1].clients.length.should.equal(expectedClients.length); - - singularEntry[1].clients[0].should.equal(expectedClients[0]); - - //TODO - factor this out into a separate test? - it("should create a correct sockets object" - - numSockets = Object.keys(singularEntry[1].sockets).length; - - numSockets.should.equal(1); - - const socketB = { client_id: CLIENT_ID, socket: DUMMY_SOCKET_B }; - - assert(singularEntry[1].sockets[DUMMY_SOCKET_B.id] != null); - - singularEntry[1].sockets[DUMMY_SOCKET_B.id].should.eql(socketB); - }); -}); \ No newline at end of file