const config = {port: 9091}; var uidcounter = 0; Object.freeze(config); // Config should not be modified after initialization! const ChannelStorage = new Map(); const UserAttachedChannelStorage = new Map(); //uid=>channel resolve const UserAttachedNameStorage = new Map(); // uid=>username resolve const ReverseNameToUidStorage = new Map(); // username=>uid resolve /** @section guacamole **/ const ParseCommandString = function(instruction) { if(typeof instruction !== "string") { return; } let position = -1; let sections = new Array(); for(;;) { let length = instruction.indexOf('.', position + 1); if(length === -1) { break; } position = (parseInt(instruction.slice(position + 1, length)) + length) + 1; // todo: not overflow sections.push(instruction.slice(length + 1, position) .replace(/'/g, "'") .replace(/"/g, '"') .replace(///g, '/') .replace(/</g, '<') .replace(/>/g, '>') .replace(/&/g, '&') ); if(instruction.slice(position, position + 1) === ';') { break; } } return sections; } const EncodeCommandString = function(sections) { if(Array.isArray(sections) === false) { return; } let instruction = new String(); const argv = sections; for (let argc = 0; argc < sections.length ; argc++) { if(typeof sections[argc] !== "string") { argv[argc] = sections[argc].toString(); } argv[argc] = argv[argc] .replace(/'/g, "'") .replace(/"/g, '"') .replace(/\//g, '/' ) .replace(//g, '>' ) .replace(/&/g, '&' ) ; instruction = instruction.concat(argv[argc].length.toString(), ".", argv[argc]) instruction += (argc === sections.length - 1) ? ';' : ','; } return instruction; } /** @section sv_functions **/ const MessageParser = function(webSocket, message, isBinary) { const msg = new TextDecoder("utf-8").decode(message); if(isBinary) { // TODO: command functions try { const args = ParseCommandString(msg); switch(args[0]) { case "rename": { // todo: make users be able to rename based on parameters given to rename var nickname = `guest${Math.floor(Math.random() * 99999)}`; if(nickname.length > 32) { // if it's too long nickname = `guest${Math.floor(Math.random() * 99999)}`; } // this should probably be a while or something (only do this if the name exists) if(ReverseNameToUidStorage.has(nickname) === true) { if(ReverseNameToUidStorage.get(nickname) !== webSocket.getUserData().uid) { nickname = `guest${Math.floor(Math.random() * 99999)}`; } } UserAttachedNameStorage.set(webSocket.getUserData().uid, nickname); ReverseNameToUidStorage.set(nickname, webSocket.getUserData().uid); webSocket.send(EncodeCommandString(["rename", 0, "", nickname]), true); break; } case "connect": { if(args.length <= 2) { webSocket.send(EncodeCommandString(["error", "need more arguments", 768]), true); break; } let getChannelId = args[1]; if(server.numSubscribers(`channels/${getChannelId}`) >= 2) { // this room is full webSocket.send(EncodeCommandString(["error", "channel has too many members", 513]), true); break; } if(UserAttachedNameStorage.has(webSocket.getUserData().uid) === false) { // user doesn't have a username webSocket.send(EncodeCommandString(["error", "you need a nickname", 769]), true); break; } if(ChannelStorage.has(getChannelId) === false) { // make the channel right then and there console.log(`creating #${getChannelId} for (${webSocket.getUserData().uid})`); ChannelStorage.set(getChannelId, {"owner": webSocket.getUserData().uid, "fourCC": args[2]}); }; if(ChannelStorage.get(getChannelId).fourCC !== args[2]) { // if the fourCC is not the same as the channel, error as such webSocket.send(EncodeCommandString(["error", `data is incompatible! (You: ${args[2]}, Remote: ${ChannelStorage.get(getChannelId).fourCC}.`, 256]), true); break; } if(webSocket.isSubscribed(`channels/${getChannelId}`) === true) { // already in this channel webSocket.send(EncodeCommandString(["error", "already in this channel", 517]), true); break; }; if(UserAttachedChannelStorage.has(webSocket.getUserData().uid) === true) { // already in a channel that isn't the one we're trying to join webSocket.send(EncodeCommandString(["error", "already in another channel", 797]), true); break; } console.log(`user ${UserAttachedNameStorage.get(webSocket.getUserData().uid)} (${webSocket.getUserData().uid}) joined #${getChannelId}`); webSocket.send(EncodeCommandString(["ready", server.numSubscribers(`channels/${getChannelId}`)]), true); webSocket.subscribe(`channels/${getChannelId}`); UserAttachedChannelStorage.set(webSocket.getUserData().uid, getChannelId); // do we keep adduser? if(ChannelStorage.get(getChannelId).owner !== webSocket.getUserData().uid) { // send other user's presense if we don't own the channel (if we're not owner) webSocket.send(EncodeCommandString(["adduser", 1, UserAttachedNameStorage.get(ChannelStorage.get(getChannelId).owner)]), true); } // send to other user (or nobody) that we joined this channel webSocket.publish(`channels/${getChannelId}`, EncodeCommandString(["adduser", 1, UserAttachedNameStorage.get(webSocket.getUserData().uid)]), true); break; } case "disconnect": { if(UserAttachedChannelStorage.has(webSocket.getUserData().uid) === true) { console.log(`user ${UserAttachedNameStorage.get(webSocket.getUserData().uid)} (${webSocket.getUserData().uid}) left #${UserAttachedChannelStorage.get(webSocket.getUserData().uid)}`); webSocket.unsubscribe(`channels/${UserAttachedChannelStorage.get(webSocket.getUserData().uid)}`); webSocket.publish( `channels/${UserAttachedChannelStorage.get(webSocket.getUserData().uid)}`, EncodeCommandString(["remuser", UserAttachedNameStorage.get(webSocket.getUserData().uid)]), true ); // if room is empty, delete it if(server.numSubscribers(`channels/${UserAttachedChannelStorage.get(webSocket.getUserData().uid)}`) === 0) { console.log(`deleting #${UserAttachedChannelStorage.get(webSocket.getUserData().uid)} since (${webSocket.getUserData().uid}) left, leaving it with no members`); ChannelStorage.delete(UserAttachedChannelStorage.get(webSocket.getUserData().uid)); } UserAttachedChannelStorage.delete(webSocket.getUserData().uid); // user is disconnecting next frame! } return; } case "sync": { // client is alive... TODO check if this is too ahead/behind break; } default: { console.warn("unknown command", args); } } } catch(e) { console.log(e); webSocket.end(1003, new TextEncoder("utf-8").encode("Unknown format.")); return; } return; } else { // send data to channel webSocket.publish(`channels/${UserAttachedChannelStorage.get(webSocket.getUserData().uid)}`, message, false); // ask other user if alive still webSocket.publish(`channels/${UserAttachedChannelStorage.get(webSocket.getUserData().uid)}`, EncodeCommandString(["sync", (new Date).getTime()]), true); webSocket.ping("pulse"); } //console.log(`${new TextDecoder("utf-8").decode(webSocket.getRemoteAddressAsText())} message: ${msg}`); } const UserStateRequest = function(webSocket, state, data = null) { switch(state) { case "open": { //console.log("%s client hello", new TextDecoder("utf-8").decode(webSocket.getRemoteAddressAsText())); webSocket.subscribe("system"); webSocket.ping("welcome"); break; } case "close": { // close event gets WS disconnect code and the client's termination message (avaliable if data.code is 1000, 3xxx, or 4xxx, but only 1000 is standard) // but we DO NOT get to access any WS methods, since they've been closed, so we don't actually know who left. if(data.code === 1000) { console.log(`client exiting, reason: ${new TextDecoder("utf-8").decode(data.message)}`); } break; } } } /** @section creation **/ const server = require("uWebSockets.js").App({}); /** @section config **/ server.ws("/connect", { // options "compression": require("uWebSockets.js").DEDICATED_COMPRESSOR_3KB, // do we need a compressor for this kind of protocol, uWS actively despises compression, so it's worth considering. // here be events "upgrade": function(rs, rq, cx) { rs.upgrade({"uid": (uidcounter++)}, /* very important, this sets a UserData object (which can contain anything we like) to the WSConnection instance. This can only be done here. */ rq.getHeader("sec-websocket-key"), rq.getHeader("sec-websocket-protocol"), rq.getHeader("sec-websocket-extensions"), cx); }, "message": MessageParser, "open": function(ws) {UserStateRequest(ws, "open")}, "close": function(ws, code, message) {UserStateRequest(ws, "close", {code, message})}, "drain": function(ws) {console.log(`WS going through back-pressure!: ${ws.getBufferedAmount()}`)} }); server.listen(config.port, function(token) {console.log(token ? `open on ${config.port}` : `failed to listen to ${config.port}`)}); var serverIsClosing = false; require("process").on('SIGINT', function() { if(serverIsClosing) {return}; server.publish("system", EncodeCommandString(["error", "server is closing", 0]), true); serverIsClosing = true; require("process").on('SIGINT', function(){console.warn("emergency shutdown was engaged, clients may be confused!");require("process").exit()}); console.log("shutting down server, giving clients 5 seconds..."); setTimeout(function(){console.log("server stopping now");server.close();require("process").exit()}, 5000) });