UNPKG

@needle-tools/networking

Version:
356 lines (315 loc) 11.5 kB
const Utils = require("./networking_utils"); // const Stats = require("./stats"); // Stats.init(); const ClientSettings = require("./clientSettings"); class Room { constructor(id) { this.id = id; this.viewId = Utils.getUniqueIDWithSalt(this.id); this.clients = []; this.clientSettings = []; // contains info if someone is allowed to make edits this.owners = {}; this._updateLastMessageTime(); this.state = {}; /** @type {Map<string, string[]>} this state gets deleted from the room when a user disconnects */ this.userBoundState = new Map(); this.isDirty = false; this.clientTimeout = 30; // Stats.onRoomOpened(this); } stateSize() { return this.state ? Object.keys(this.state).length : 0; } userCount() { return this.clients.length; } async loadState() { try { const res = await Utils.loadState(this.id); if (res) { console.log("Successfully loaded state, will now merge and send to clients"); this.state = { ...res, ...this.state }; for (const client of this.clients) { this._sendState(client); } } } catch (err) { console.error("error loading room state", this.id, err); } }; /** * @param {boolean} force */ async saveState(force) { try { if (!this.isDirty && !force) return; console.log("SAVING ROOM STATE:", this.id); this.isDirty = false; await Utils.saveState(this.id, this.state); console.log("ROOM STATE SAVED:", this.id); } catch (err) { console.error("ERR: saving room state", this.id, err); } }; async clearState() { this.isDirty = false; this.state = {} await Utils.saveState(this.id, this.state); }; _updateLastMessageTime() { this.lastMessageTime = Date.now(); }; timeSinceLastMessage() { if (this.lastMessageTime) { const dt = Date.now() - this.lastMessageTime; return dt; } return -1; }; validateClients() { const now = Date.now(); const timeout = 1000 * this.clientTimeout; for (let i = this.clients.length - 1; i >= 0; i--) { const client = this.clients[i]; if (client._lastMessageTime && now - client._lastMessageTime > timeout) { console.log(`User Timeout: ${client.id}`); this.remove(client); } } }; close() { console.log("closing room " + this.id); for (let i = this.clients.length - 1; i >= 0; i--) { const client = this.clients[i]; this.remove(client); } // Stats.onRoomClosed(this); }; add(ws, settings) { if (!this.clients) return; if (ws.room === this) return; if (ws.room) { console.error("can not join multiple rooms for now"); return; } ws.room = this; this.clients.push(ws); if (!settings) settings = new ClientSettings(); this.clientSettings.push(settings); console.log(ws.id + " joined " + this.id + " > total users: " + this.clients.length); const inRoomIds = []; for (const client of this.clients) { inRoomIds.push(client.id); } // if the user is only in view mode dont leak the original room name const roomId = settings.allowEditing ? this.id : this.viewId; Utils.sendUpdate_JoinedRoom(ws, roomId, inRoomIds, this.viewId, settings.allowEditing); const data = { userId: ws.id }; for (const client of this.clients) { if (client === ws) continue; console.log(client.id + " -> " + ws.id); Utils.sendUpdate_UserJoinedRoom(client, data); } this._sendState(ws); // event after all state has been sent Utils.sendUpdate_RoomStateSent(ws, roomId); }; remove(ws) { if (!this.clients) return; if (ws.room !== this) return; ws.room = null; const index = this.clients.indexOf(ws); this.clients.splice(index, 1); this.clientSettings.splice(index, 1); this.isDirty = true; // remove owner for (const key in this.owners) { if (this.owners[key] == ws.id) { const guid = key; console.log(`Removed owner \"${ws.id}\" for \"${guid}\"`); this.owners[key] = null; Utils.sendUpdate_LostOwnership(ws, guid); for (const client of this.clients) { Utils.sendUpdate_LostOwnershipBroadcast(client, guid); } } } console.log(`User \"${ws.id}\" left room \"${this.id}\" > total users: ${this.clients.length}`); Utils.sendUpdate_LeftRoom(ws, this.id); // send user left event to all users still in room const data = { userId: ws.id }; for (const client of this.clients) { if (client === ws) continue; Utils.sendUpdate_UserLeftRoom(client, data); } this.__deleteUserBoundState(ws); }; __deleteUserBoundState(ws) { const userId = ws.id; const array = this.userBoundState.get(userId); if (array) { console.log(`Deleting user bound state for ${userId}: ${array.length} entries`); for (const guid of array) { if (this.state[guid]) { delete this.state[guid]; } } this.userBoundState.delete(userId); } } broadcast(ws, data, isBinary) { // if(!isBinary && typeof data !== "string") { // console.log("STRINGIFY", data, typeof data); // data = JSON.stringify(data); // } for (let i = 0; i < this.clients.length; i++) { const client = this.clients[i]; if (client !== ws && client.readyState === client.OPEN) client.send(data); } }; handleMessage(ws, type, payload, rawData) { const index = this.clients.indexOf(ws); if (index < 0) return; const settings = this.clientSettings[index]; const allowEditing = settings.allowEditing; // TODO: we still want to send SOME messages (even if the client is in view mode) to other clients // e.g. camera or avatar position, so maybe we just want to broadcast to other clients once on connection // if the new person is in view mode? // alternativelly payload should contain if this message should still be sent to everyone this._updateLastMessageTime(); ws._lastMessageTime = Date.now(); if (type === "delete-all-state" && allowEditing) { console.log("Clearing state in room: " + this.id,); this.clearState(); this.broadcast(null, "all-room-state-deleted"); return; } const guid = payload.guid; let allowSave = allowEditing; // never save if editing is not allowed // kind of special message because we still want this to go out to others // but at the same time handle some of it's content server side // e.g. remove previously saved state related to any guid that // comes with this message if (type === "instance-destroyed" || type === "delete-state") { if (allowEditing) { // save this message only if something was not previously created via networking message // e.g. an object that shipped with the scene and then got deleted // in that case we want to keep the deletion message allowSave = this.state[guid] === undefined; if (this.state[guid]) delete this.state[guid]; logStateSize(this.state); } } if (type === "ping") { const res = JSON.stringify({ key: "pong", data: payload }); ws.send(res); return; } // peer id changed or updated if (type === "peer-update-id") { //console.log(type, payload); const { id: peerId } = payload; ws.peerId = peerId; const msg = { key: type, data: { id: ws.id, peerId: ws.peerId } }; this.broadcast(ws, JSON.stringify(msg)); for (const client of this.clients) { if (client === ws) continue; msg.data.id = client.id; msg.data.peerId = client.peerId; ws.send(JSON.stringify(msg)); } } if (type === "request-ownership") { if (!allowEditing) return; console.log("request ownership", guid); const previousOwner = this.owners[guid]; this.owners[guid] = ws.id; if (previousOwner !== undefined) { if (previousOwner === ws.id) return; // is already owned by this person for (let i = 0; i < this.clients.length; i++) { const client = this.clients[i]; if (client.id === previousOwner) { Utils.sendUpdate_LostOwnership(client, guid); break; } } } Utils.sendUpdate_GainedOwnership(ws, guid); for (const client of this.clients) { Utils.sendUpdate_GainedOwnershipBroadcast(client, ws.id, guid); } } // remove ownership else if (type === "remove-ownership") { if (!allowEditing) return; const currentOwner = this.owners[guid]; // only the current owner can remove their own ownership if (currentOwner !== undefined) { if (currentOwner === ws.id) { this.owners[guid] = undefined; if (currentOwner !== undefined) { Utils.sendUpdate_LostOwnership(ws, guid); for (const client of this.clients) { if (client === ws) continue; Utils.sendUpdate_LostOwnershipBroadcast(client, guid); } } } } } else if (type === "request-is-owner") { const id = ws.id; const currentOwner = this.owners[guid]; const isOwner = currentOwner === id; Utils.sendUpdate_IsOwner(ws, guid, isOwner); } else if (type === "request-has-owner") { const currentOwner = this.owners[guid]; const hasOwner = currentOwner !== undefined && currentOwner !== null; Utils.sendUpdate_HasOwner(ws, guid, hasOwner); } // everything not specific else { if (!allowEditing) return; this.isDirty = true; if (guid !== undefined) { if (this.owners[guid] && this.owners[guid] !== ws.id) return; // if state should be deleted when the user disconnects register it here if (payload.deleteStateOnDisconnect === true) { let array = this.userBoundState.get(ws.id); if (!array) { array = []; this.userBoundState.set(ws.id, array); } array.push(guid); } this.broadcast(ws, rawData); if (allowSave) this._saveState(guid, type, payload, rawData); } else { this.broadcast(ws, rawData); if (allowSave) this._saveState(guid, type, payload, rawData); } } }; _saveState(guid, type, payload, rawData) { if (payload.dontSave === true) return; if (!guid) return; const state = this.state; let entry = state[guid]; if (!entry) entry = state[guid] = {}; entry[type] = rawData; }; _sendState(ws) { logStateSize(this.state); for (const guid in this.state) { const objectState = this.state[guid]; for (const type in objectState) { const rawData = objectState[type]; ws.send(rawData); } } }; } module.exports = Room; function logStateSize(stateObject) { const stateSize = stateObject ? Object.keys(stateObject).length : 0; console.log("State has " + stateSize + " entries"); }