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(); const UserAttachedNameStorage = new Map(); // uid=>username resolve const ReverseNameToUidStorage = new Map(); /** @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)}`; // this should probably be a while or something 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 <= 1) { webSocket.send(EncodeCommandString(["connect", 0]), true); break; } let getChannelId = args[1]; if(server.numSubscribers(`channels/${getChannelId}`) >= 2) { // this room is full webSocket.send(EncodeCommandString(["connect", 0]), true); break; } if(typeof UserAttachedNameStorage.get(webSocket.getUserData().uid) === "undefined") { // user doesn't have a username webSocket.send(EncodeCommandString(["connect", 0]), true); break; } if(typeof ChannelStorage.get(getChannelId) === "undefined") { // make the channel right then and there ChannelStorage.set(getChannelId, {"owner": webSocket.getUserData().uid}); }; if(webSocket.isSubscribed(`channels/${getChannelId}`) === true) { // already in this channel webSocket.send(EncodeCommandString(["connect", 0]), true); break; }; if(typeof UserAttachedChannelStorage.get(webSocket.getUserData().uid) !== "undefined") { // already in a channel that isn't the one we're trying to join webSocket.send(EncodeCommandString(["connect", 0]), true); break; } webSocket.send(EncodeCommandString(["connect", 1, 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 presence 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 "sync": { // client is alive... 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); } //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())); 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}`)});