server.js 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. const WebSocket = require("ws");
  2. const crypto = require("crypto");
  3. const MAX_PEERS = 4096;
  4. const MAX_LOBBIES = 1024;
  5. const PORT = 9080;
  6. const ALFNUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
  7. const NO_LOBBY_TIMEOUT = 1000;
  8. const SEAL_CLOSE_TIMEOUT = 10000;
  9. const PING_INTERVAL = 10000;
  10. const STR_NO_LOBBY = "Have not joined lobby yet";
  11. const STR_HOST_DISCONNECTED = "Room host has disconnected";
  12. const STR_ONLY_HOST_CAN_SEAL = "Only host can seal the lobby";
  13. const STR_SEAL_COMPLETE = "Seal complete";
  14. const STR_TOO_MANY_LOBBIES = "Too many lobbies open, disconnecting";
  15. const STR_ALREADY_IN_LOBBY = "Already in a lobby";
  16. const STR_LOBBY_DOES_NOT_EXISTS = "Lobby does not exists";
  17. const STR_LOBBY_IS_SEALED = "Lobby is sealed";
  18. const STR_INVALID_FORMAT = "Invalid message format";
  19. const STR_NEED_LOBBY = "Invalid message when not in a lobby";
  20. const STR_SERVER_ERROR = "Server error, lobby not found";
  21. const STR_INVALID_DEST = "Invalid destination";
  22. const STR_INVALID_CMD = "Invalid command";
  23. const STR_TOO_MANY_PEERS = "Too many peers connected";
  24. const STR_INVALID_TRANSFER_MODE = "Invalid transfer mode, must be text";
  25. function randomInt (low, high) {
  26. return Math.floor(Math.random() * (high - low + 1) + low);
  27. }
  28. function randomId () {
  29. return Math.abs(new Int32Array(crypto.randomBytes(4).buffer)[0]);
  30. }
  31. function randomSecret () {
  32. let out = "";
  33. for (let i = 0; i < 16; i++) {
  34. out += ALFNUM[randomInt(0, ALFNUM.length - 1)];
  35. }
  36. return out;
  37. }
  38. const wss = new WebSocket.Server({ port: PORT });
  39. class ProtoError extends Error {
  40. constructor (code, message) {
  41. super(message);
  42. this.code = code;
  43. }
  44. }
  45. class Peer {
  46. constructor (id, ws) {
  47. this.id = id;
  48. this.ws = ws;
  49. this.lobby = "";
  50. // Close connection after 1 sec if client has not joined a lobby
  51. this.timeout = setTimeout(() => {
  52. if (!this.lobby) ws.close(4000, STR_NO_LOBBY);
  53. }, NO_LOBBY_TIMEOUT);
  54. }
  55. }
  56. class Lobby {
  57. constructor (name, host) {
  58. this.name = name;
  59. this.host = host;
  60. this.peers = [];
  61. this.sealed = false;
  62. this.closeTimer = -1;
  63. }
  64. getPeerId (peer) {
  65. if (this.host === peer.id) return 1;
  66. return peer.id;
  67. }
  68. join (peer) {
  69. const assigned = this.getPeerId(peer);
  70. peer.ws.send(`I: ${assigned}\n`);
  71. this.peers.forEach((p) => {
  72. p.ws.send(`N: ${assigned}\n`);
  73. peer.ws.send(`N: ${this.getPeerId(p)}\n`);
  74. });
  75. this.peers.push(peer);
  76. }
  77. leave (peer) {
  78. const idx = this.peers.findIndex((p) => peer === p);
  79. if (idx === -1) return false;
  80. const assigned = this.getPeerId(peer);
  81. const close = assigned === 1;
  82. this.peers.forEach((p) => {
  83. // Room host disconnected, must close.
  84. if (close) p.ws.close(4000, STR_HOST_DISCONNECTED);
  85. // Notify peer disconnect.
  86. else p.ws.send(`D: ${assigned}\n`);
  87. });
  88. this.peers.splice(idx, 1);
  89. if (close && this.closeTimer >= 0) {
  90. // We are closing already.
  91. clearTimeout(this.closeTimer);
  92. this.closeTimer = -1;
  93. }
  94. return close;
  95. }
  96. seal (peer) {
  97. // Only host can seal
  98. if (peer.id !== this.host) {
  99. throw new ProtoError(4000, STR_ONLY_HOST_CAN_SEAL);
  100. }
  101. this.sealed = true;
  102. this.peers.forEach((p) => {
  103. p.ws.send("S: \n");
  104. });
  105. console.log(`Peer ${peer.id} sealed lobby ${this.name} ` +
  106. `with ${this.peers.length} peers`);
  107. this.closeTimer = setTimeout(() => {
  108. // Close peer connection to host (and thus the lobby)
  109. this.peers.forEach((p) => {
  110. p.ws.close(1000, STR_SEAL_COMPLETE);
  111. });
  112. }, SEAL_CLOSE_TIMEOUT);
  113. }
  114. }
  115. const lobbies = new Map();
  116. let peersCount = 0;
  117. function joinLobby (peer, pLobby) {
  118. let lobbyName = pLobby;
  119. if (lobbyName === "") {
  120. if (lobbies.size >= MAX_LOBBIES) {
  121. throw new ProtoError(4000, STR_TOO_MANY_LOBBIES);
  122. }
  123. // Peer must not already be in a lobby
  124. if (peer.lobby !== "") {
  125. throw new ProtoError(4000, STR_ALREADY_IN_LOBBY);
  126. }
  127. lobbyName = randomSecret();
  128. lobbies.set(lobbyName, new Lobby(lobbyName, peer.id));
  129. console.log(`Peer ${peer.id} created lobby ${lobbyName}`);
  130. console.log(`Open lobbies: ${lobbies.size}`);
  131. }
  132. const lobby = lobbies.get(lobbyName);
  133. if (!lobby) throw new ProtoError(4000, STR_LOBBY_DOES_NOT_EXISTS);
  134. if (lobby.sealed) throw new ProtoError(4000, STR_LOBBY_IS_SEALED);
  135. peer.lobby = lobbyName;
  136. console.log(`Peer ${peer.id} joining lobby ${lobbyName} ` +
  137. `with ${lobby.peers.length} peers`);
  138. lobby.join(peer);
  139. peer.ws.send(`J: ${lobbyName}\n`);
  140. }
  141. function parseMsg (peer, msg) {
  142. const sep = msg.indexOf("\n");
  143. if (sep < 0) throw new ProtoError(4000, STR_INVALID_FORMAT);
  144. const cmd = msg.slice(0, sep);
  145. if (cmd.length < 3) throw new ProtoError(4000, STR_INVALID_FORMAT);
  146. const data = msg.slice(sep);
  147. // Lobby joining.
  148. if (cmd.startsWith("J: ")) {
  149. joinLobby(peer, cmd.substr(3).trim());
  150. return;
  151. }
  152. if (!peer.lobby) throw new ProtoError(4000, STR_NEED_LOBBY);
  153. const lobby = lobbies.get(peer.lobby);
  154. if (!lobby) throw new ProtoError(4000, STR_SERVER_ERROR);
  155. // Lobby sealing.
  156. if (cmd.startsWith("S: ")) {
  157. lobby.seal(peer);
  158. return;
  159. }
  160. // Message relaying format:
  161. //
  162. // [O|A|C]: DEST_ID\n
  163. // PAYLOAD
  164. //
  165. // O: Client is sending an offer.
  166. // A: Client is sending an answer.
  167. // C: Client is sending a candidate.
  168. let destId = parseInt(cmd.substr(3).trim());
  169. // Dest is not an ID.
  170. if (!destId) throw new ProtoError(4000, STR_INVALID_DEST);
  171. if (destId === 1) destId = lobby.host;
  172. const dest = lobby.peers.find((e) => e.id === destId);
  173. // Dest is not in this room.
  174. if (!dest) throw new ProtoError(4000, STR_INVALID_DEST);
  175. function isCmd (what) {
  176. return cmd.startsWith(`${what}: `);
  177. }
  178. if (isCmd("O") || isCmd("A") || isCmd("C")) {
  179. dest.ws.send(cmd[0] + ": " + lobby.getPeerId(peer) + data);
  180. return;
  181. }
  182. throw new ProtoError(4000, STR_INVALID_CMD);
  183. }
  184. wss.on("connection", (ws) => {
  185. if (peersCount >= MAX_PEERS) {
  186. ws.close(4000, STR_TOO_MANY_PEERS);
  187. return;
  188. }
  189. peersCount++;
  190. const id = randomId();
  191. const peer = new Peer(id, ws);
  192. ws.on("message", (message) => {
  193. if (typeof message !== "string") {
  194. ws.close(4000, STR_INVALID_TRANSFER_MODE);
  195. return;
  196. }
  197. try {
  198. parseMsg(peer, message);
  199. } catch (e) {
  200. const code = e.code || 4000;
  201. console.log(`Error parsing message from ${id}:\n` +
  202. message);
  203. ws.close(code, e.message);
  204. }
  205. });
  206. ws.on("close", (code, reason) => {
  207. peersCount--;
  208. console.log(`Connection with peer ${peer.id} closed ` +
  209. `with reason ${code}: ${reason}`);
  210. if (peer.lobby && lobbies.has(peer.lobby) &&
  211. lobbies.get(peer.lobby).leave(peer)) {
  212. lobbies.delete(peer.lobby);
  213. console.log(`Deleted lobby ${peer.lobby}`);
  214. console.log(`Open lobbies: ${lobbies.size}`);
  215. peer.lobby = "";
  216. }
  217. if (peer.timeout >= 0) {
  218. clearTimeout(peer.timeout);
  219. peer.timeout = -1;
  220. }
  221. });
  222. ws.on("error", (error) => {
  223. console.error(error);
  224. });
  225. });
  226. const interval = setInterval(() => { // eslint-disable-line no-unused-vars
  227. wss.clients.forEach((ws) => {
  228. ws.ping();
  229. });
  230. }, PING_INTERVAL);