server.js 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  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. const CMD = {
  26. JOIN: 0, // eslint-disable-line sort-keys
  27. ID: 1, // eslint-disable-line sort-keys
  28. PEER_CONNECT: 2, // eslint-disable-line sort-keys
  29. PEER_DISCONNECT: 3, // eslint-disable-line sort-keys
  30. OFFER: 4, // eslint-disable-line sort-keys
  31. ANSWER: 5, // eslint-disable-line sort-keys
  32. CANDIDATE: 6, // eslint-disable-line sort-keys
  33. SEAL: 7, // eslint-disable-line sort-keys
  34. };
  35. function randomInt(low, high) {
  36. return Math.floor(Math.random() * (high - low + 1) + low);
  37. }
  38. function randomId() {
  39. return Math.abs(new Int32Array(crypto.randomBytes(4).buffer)[0]);
  40. }
  41. function randomSecret() {
  42. let out = '';
  43. for (let i = 0; i < 16; i++) {
  44. out += ALFNUM[randomInt(0, ALFNUM.length - 1)];
  45. }
  46. return out;
  47. }
  48. function ProtoMessage(type, id, data) {
  49. return JSON.stringify({
  50. 'type': type,
  51. 'id': id,
  52. 'data': data || '',
  53. });
  54. }
  55. const wss = new WebSocket.Server({ port: PORT });
  56. class ProtoError extends Error {
  57. constructor(code, message) {
  58. super(message);
  59. this.code = code;
  60. }
  61. }
  62. class Peer {
  63. constructor(id, ws) {
  64. this.id = id;
  65. this.ws = ws;
  66. this.lobby = '';
  67. // Close connection after 1 sec if client has not joined a lobby
  68. this.timeout = setTimeout(() => {
  69. if (!this.lobby) {
  70. ws.close(4000, STR_NO_LOBBY);
  71. }
  72. }, NO_LOBBY_TIMEOUT);
  73. }
  74. }
  75. class Lobby {
  76. constructor(name, host, mesh) {
  77. this.name = name;
  78. this.host = host;
  79. this.mesh = mesh;
  80. this.peers = [];
  81. this.sealed = false;
  82. this.closeTimer = -1;
  83. }
  84. getPeerId(peer) {
  85. if (this.host === peer.id) {
  86. return 1;
  87. }
  88. return peer.id;
  89. }
  90. join(peer) {
  91. const assigned = this.getPeerId(peer);
  92. peer.ws.send(ProtoMessage(CMD.ID, assigned, this.mesh ? 'true' : ''));
  93. this.peers.forEach((p) => {
  94. p.ws.send(ProtoMessage(CMD.PEER_CONNECT, assigned));
  95. peer.ws.send(ProtoMessage(CMD.PEER_CONNECT, this.getPeerId(p)));
  96. });
  97. this.peers.push(peer);
  98. }
  99. leave(peer) {
  100. const idx = this.peers.findIndex((p) => peer === p);
  101. if (idx === -1) {
  102. return false;
  103. }
  104. const assigned = this.getPeerId(peer);
  105. const close = assigned === 1;
  106. this.peers.forEach((p) => {
  107. if (close) { // Room host disconnected, must close.
  108. p.ws.close(4000, STR_HOST_DISCONNECTED);
  109. } else { // Notify peer disconnect.
  110. p.ws.send(ProtoMessage(CMD.PEER_DISCONNECT, assigned));
  111. }
  112. });
  113. this.peers.splice(idx, 1);
  114. if (close && this.closeTimer >= 0) {
  115. // We are closing already.
  116. clearTimeout(this.closeTimer);
  117. this.closeTimer = -1;
  118. }
  119. return close;
  120. }
  121. seal(peer) {
  122. // Only host can seal
  123. if (peer.id !== this.host) {
  124. throw new ProtoError(4000, STR_ONLY_HOST_CAN_SEAL);
  125. }
  126. this.sealed = true;
  127. this.peers.forEach((p) => {
  128. p.ws.send(ProtoMessage(CMD.SEAL, 0));
  129. });
  130. console.log(`Peer ${peer.id} sealed lobby ${this.name} `
  131. + `with ${this.peers.length} peers`);
  132. this.closeTimer = setTimeout(() => {
  133. // Close peer connection to host (and thus the lobby)
  134. this.peers.forEach((p) => {
  135. p.ws.close(1000, STR_SEAL_COMPLETE);
  136. });
  137. }, SEAL_CLOSE_TIMEOUT);
  138. }
  139. }
  140. const lobbies = new Map();
  141. let peersCount = 0;
  142. function joinLobby(peer, pLobby, mesh) {
  143. let lobbyName = pLobby;
  144. if (lobbyName === '') {
  145. if (lobbies.size >= MAX_LOBBIES) {
  146. throw new ProtoError(4000, STR_TOO_MANY_LOBBIES);
  147. }
  148. // Peer must not already be in a lobby
  149. if (peer.lobby !== '') {
  150. throw new ProtoError(4000, STR_ALREADY_IN_LOBBY);
  151. }
  152. lobbyName = randomSecret();
  153. lobbies.set(lobbyName, new Lobby(lobbyName, peer.id, mesh));
  154. console.log(`Peer ${peer.id} created lobby ${lobbyName}`);
  155. console.log(`Open lobbies: ${lobbies.size}`);
  156. }
  157. const lobby = lobbies.get(lobbyName);
  158. if (!lobby) {
  159. throw new ProtoError(4000, STR_LOBBY_DOES_NOT_EXISTS);
  160. }
  161. if (lobby.sealed) {
  162. throw new ProtoError(4000, STR_LOBBY_IS_SEALED);
  163. }
  164. peer.lobby = lobbyName;
  165. console.log(`Peer ${peer.id} joining lobby ${lobbyName} `
  166. + `with ${lobby.peers.length} peers`);
  167. lobby.join(peer);
  168. peer.ws.send(ProtoMessage(CMD.JOIN, 0, lobbyName));
  169. }
  170. function parseMsg(peer, msg) {
  171. let json = null;
  172. try {
  173. json = JSON.parse(msg);
  174. } catch (e) {
  175. throw new ProtoError(4000, STR_INVALID_FORMAT);
  176. }
  177. const type = typeof (json['type']) === 'number' ? Math.floor(json['type']) : -1;
  178. const id = typeof (json['id']) === 'number' ? Math.floor(json['id']) : -1;
  179. const data = typeof (json['data']) === 'string' ? json['data'] : '';
  180. if (type < 0 || id < 0) {
  181. throw new ProtoError(4000, STR_INVALID_FORMAT);
  182. }
  183. // Lobby joining.
  184. if (type === CMD.JOIN) {
  185. joinLobby(peer, data, id === 0);
  186. return;
  187. }
  188. if (!peer.lobby) {
  189. throw new ProtoError(4000, STR_NEED_LOBBY);
  190. }
  191. const lobby = lobbies.get(peer.lobby);
  192. if (!lobby) {
  193. throw new ProtoError(4000, STR_SERVER_ERROR);
  194. }
  195. // Lobby sealing.
  196. if (type === CMD.SEAL) {
  197. lobby.seal(peer);
  198. return;
  199. }
  200. // Message relaying format:
  201. //
  202. // {
  203. // "type": CMD.[OFFER|ANSWER|CANDIDATE],
  204. // "id": DEST_ID,
  205. // "data": PAYLOAD
  206. // }
  207. if (type === CMD.OFFER || type === CMD.ANSWER || type === CMD.CANDIDATE) {
  208. let destId = id;
  209. if (id === 1) {
  210. destId = lobby.host;
  211. }
  212. const dest = lobby.peers.find((e) => e.id === destId);
  213. // Dest is not in this room.
  214. if (!dest) {
  215. throw new ProtoError(4000, STR_INVALID_DEST);
  216. }
  217. dest.ws.send(ProtoMessage(type, lobby.getPeerId(peer), data));
  218. return;
  219. }
  220. throw new ProtoError(4000, STR_INVALID_CMD);
  221. }
  222. wss.on('connection', (ws) => {
  223. if (peersCount >= MAX_PEERS) {
  224. ws.close(4000, STR_TOO_MANY_PEERS);
  225. return;
  226. }
  227. peersCount++;
  228. const id = randomId();
  229. const peer = new Peer(id, ws);
  230. ws.on('message', (message) => {
  231. if (typeof message !== 'string') {
  232. ws.close(4000, STR_INVALID_TRANSFER_MODE);
  233. return;
  234. }
  235. try {
  236. parseMsg(peer, message);
  237. } catch (e) {
  238. const code = e.code || 4000;
  239. console.log(`Error parsing message from ${id}:\n${
  240. message}`);
  241. ws.close(code, e.message);
  242. }
  243. });
  244. ws.on('close', (code, reason) => {
  245. peersCount--;
  246. console.log(`Connection with peer ${peer.id} closed `
  247. + `with reason ${code}: ${reason}`);
  248. if (peer.lobby && lobbies.has(peer.lobby)
  249. && lobbies.get(peer.lobby).leave(peer)) {
  250. lobbies.delete(peer.lobby);
  251. console.log(`Deleted lobby ${peer.lobby}`);
  252. console.log(`Open lobbies: ${lobbies.size}`);
  253. peer.lobby = '';
  254. }
  255. if (peer.timeout >= 0) {
  256. clearTimeout(peer.timeout);
  257. peer.timeout = -1;
  258. }
  259. });
  260. ws.on('error', (error) => {
  261. console.error(error);
  262. });
  263. });
  264. const interval = setInterval(() => { // eslint-disable-line no-unused-vars
  265. wss.clients.forEach((ws) => {
  266. ws.ping();
  267. });
  268. }, PING_INTERVAL);