UNPKG

cloudlink.js

Version:

Easily host and connect to cloudlink 4 servers

286 lines (276 loc) 11.7 kB
const EventEmitter = require("events"); const ws = require("ws"); const { v4: uuidv4 } = require('uuid'); const serverVersion = '0.2.0'; const statusNames = { 0: "Test", 1: "Echo", 100: "OK", 101: "Syntax", 102: "Datatype", 103: "ID not found", 104: "ID not specific enough", 105: "Internal server error", 106: "Empty packet", 107: "ID already set", 108: "Refused", 109: "Invalid command", 110: "Command disabled", 111: "ID required", 112: "ID conflict", 113: "Too large", 114: "JSON error", 115: "Room not joined" } class User { constructor(id, uuid, ip, ws){ Object.defineProperties(this, { id: { value: id, writable: false, enumerable: true }, uuid: { value: uuid, writable: false, enumerable: true }, ip: { value: ip, writable: false, enumerable: true }, ws: { value: ws, writable: false, enumerable: true } }) this.username = undefined; this.platform = {}; this.handshaked = false; this.linkedRooms = new Set(['default']); } get [Symbol.toStringTag](){ return "User"; } /** * Kicks/Disconnects the user from the server */ kick(){ this.ws.close(1000) } /** * Sends a private message to the user * @param {string} message Message to send */ sendPrivateMessage(message){ this.ws.send(JSON.stringify({cmd: "pmsg", val: message})) } /** * Sends a private variable to the user * @param {string} name * @param {string} value */ sendPrivateVariable(name, value){ this.ws.send(JSON.stringify({cmd: "pvar", val: value, name: name})) } } class Server extends EventEmitter { #users; #roomIndex = new Map; constructor(){ super(); this.websocket = undefined; this.#users = {}; this.commands = {}; /** Server message of the day that gets sent to users when they connect */ this.motd = ''; this.maxUsers = -1; /** Send packets to only the rooms it needs to, but comes at the cost of performance */ this.optimizeSending = false; /** Send the user IP back for "My IP address" block */ this.proxyIp = false; this.globalMessage = ''; this.#roomIndex.set("default", new Set()) } #findUser(id, clientRooms){ return Object.values(this.#users) .find(u => u.username===id && u.linkedRooms.find(room => clientRooms.includes(room))!==undefined ) || null } #getUsersInRoom(room){ return Array.from(this.#roomIndex.get(room) || []) } #sendStatus(ws, code, listener, data){ const codeName = statusNames[code]||"Unknown" ws.send(JSON.stringify({"cmd":"statuscode","code":`I:${code} | ${codeName}`,"code_id":code,"listener":listener,...(data&&{val:data})})) if(code > 100 && code < 116) ws.close(3000+code) } #broadcast(json, rooms=['default']){ for (const room of rooms){ for (const client of this.#getUsersInRoom(room)){ client.ws.send(JSON.stringify({...json, ...{rooms: room}})) } } } #userToUserObject(user){ return {id: user.id, uuid: user.uuid, ...(user.username?{username: user.username}:{})} } #processMessage(msg, client, req){ let json; try{ json = JSON.parse(msg) }catch{ this.#sendStatus(client.ws, 144); client.ws.close(); return } if(!json.cmd){ this.#sendStatus(client.ws, 101, json.listener) } if(!client.handshaked && json.cmd !== 'handshake') return //User has not handshaked, ignore the command switch(json.cmd){ case 'handshake': client.handshaked = true; client.platform = json.val; if(this.proxyIp) client.ws.send(JSON.stringify({"cmd":"client_ip","val":req.socket.remoteAddress})); client.ws.send(JSON.stringify({"cmd":"server_version","val":serverVersion})); if(this.motd !== '') client.ws.send(JSON.stringify({"cmd":"motd","val":this.motd})); client.ws.send(JSON.stringify({cmd: "client_obj", val: {id: client.id, uuid: client.uuid}})); client.ws.send(JSON.stringify({cmd: "ulist", mode: "set", val: this.#getUsersInRoom('default').map(u => this.#userToUserObject(u))})); for (const user of this.#getUsersInRoom('default')){ if(user.id === client.id) continue; user.ws.send(JSON.stringify({cmd: "ulist", mode: "add", val: this.#userToUserObject(client)})) } this.#sendStatus(client.ws, 100, json.listener); this.emit('userJoin', client); break; case 'setid': client.username = json.val; client.linkedRooms.forEach(room => this.#broadcast({cmd: "ulist", mode: "set", val: this.#getUsersInRoom(room).map(u => this.#userToUserObject(u)), rooms: room}) ); this.#sendStatus(client.ws, 100, json.listener, this.#userToUserObject(client)) break; case 'link': const oldRooms1 = client.linkedRooms; client.linkedRooms = new Set(json.val); for (const room of json.val){ if(!this.#roomIndex.has(room)) this.#roomIndex.set(room, new Set()); this.#roomIndex.get(room).add(client); client.ws.send(JSON.stringify({cmd: "ulist", mode: "set", val: this.#getUsersInRoom(room).map(u => this.#userToUserObject(u)), rooms: room})); for (const user of this.#getUsersInRoom(room)){ if(user.id === client.id) continue; user.ws.send(JSON.stringify({cmd: "ulist", mode: "add", val: this.#userToUserObject(client)})); } } for (const room of oldRooms1){ this.#roomIndex.get(room)?.delete(client); if(this.#roomIndex.get(room).size === 0 && room!=='default') this.#roomIndex.delete(room); //memory optimization :shrug: for (const user of this.#getUsersInRoom(room)){ if(user.id === client.id) continue; //This shouldn't happen, but its here as a failsafe. user.ws.send(JSON.stringify({cmd: "ulist", mode: "remove", val: this.#userToUserObject(client)})); } } this.#sendStatus(client.ws, 100, json.listener) break; case 'unlink': const oldRooms2 = client.linkedRooms; client.linkedRooms = ['default']; if(!this.#roomIndex.has('default')) //this also shouldn't happen, another failsafe. this.#roomIndex.set('default', new Set()); this.#roomIndex.get('default').add(client); client.ws.send(JSON.stringify({cmd: "ulist", mode: "set", val: this.#getUsersInRoom('default').map(u => this.#userToUserObject(u))})); for (const room of oldRooms2){ if(this.#roomIndex.get(room).size === 0 && room!=='default') this.#roomIndex.delete(room); for (const user of this.#getUsersInRoom(room)){ if(user.id === client.id) continue; user.ws.send(JSON.stringify({cmd: "ulist", mode: "remove", val: this.#userToUserObject(client)})); } } this.#sendStatus(client.ws, 100, json.listener); break; case 'gmsg': this.globalMessage = json.val; this.#broadcast({cmd: "gmsg", val: json.val}, client.linkedRooms); this.emit('globalMessage', json.val); break; case 'gvar': this.#broadcast({cmd: "gvar", val: json.val, name: json.name}, client.linkedRooms); break; case 'pmsg': const userpmsg = this.#findUser(json.id, client.linkedRooms); if(userpmsg) userpmsg.ws.send(JSON.stringify({cmd: "pmsg", val: json.val})); break; case 'pvar': const userpvar = this.#findUser(json.id, client.linkedRooms); if(userpvar) userpvar.ws.send(JSON.stringify({cmd: "pvar", val: json.val, name: json.name})); break; default: if(this.commands[json.cmd] && (typeof this.commands[json.cmd] === 'function')){ this.commands[json.cmd](client, json.val, json.id||null); }else{ this.#sendStatus(client.ws, 109, json.listener); } } } /** * Start listening on a local port * @param {Number} port Local port to listen on * @param {*} callback Callback when server starts listening (optional) */ listen(port, callback){ this.websocket = new ws.Server({ port }); this.websocket.on('connection', (ws, req) => { const clientId = uuidv4(); this.#users[clientId] = new User(String(Math.floor(Math.random() * 1e19)).padStart(16, '0'), clientId, req.socket.remoteAddress, ws); this.#roomIndex.get('default').add(this.#users[clientId]); ws.on('message', msg => this.#processMessage(msg, this.#users[clientId], req)); ws.on('close', () => { const client = this.#users[clientId]; for (const room of client.linkedRooms){ this.#roomIndex.get(room)?.delete(client); for (const user of this.#getUsersInRoom(room)){ if(user.id === client.id) continue; user.ws.send(JSON.stringify({cmd: "ulist", mode: "set", val: this.#getUsersInRoom(room).map(u => this.#userToUserObject(u))})); //ulist mode "remove" is broken extension side (from what I've discovered), so just use mode "set" for now. } } this.emit('userLeave', this.#users[clientId]); delete this.#users[clientId]; }); }) this.websocket.on('listening', () => { if(typeof callback === 'function') callback(); this.emit('listening'); }) } get users(){ return Object.values(this.#users); } /** * Sends a global message to all users in a room (default if none) * @param {string} message Message to send * @param {Array} rooms Rooms to send to */ sendGlobalMessage(message, rooms){ this.#broadcast({ cmd: "gmsg", val: message }, rooms); } /** * Sends a global variable to all users in a room (default if none) * @param {string} name Variable name * @param {string} value Variable value * @param {Array} rooms Rooms to send to */ sendGlobalVariable(name, value, rooms){ this.globalMessage = value; this.#broadcast({cmd: "gvar", val: value, name: name}, rooms); } } module.exports = Server