/ peerjs.lit
peerjs.lit
1 @title Peerjs 2 @add_css lit.css 3 4 @s Peerjs code 5 6 * Peerjs.com is a free "signalling server", where clients can establish 7 peer-to-peer connections. Any clients going there can connect using a unique 8 identifier, and connect to any others they know the identifier for. 9 10 * Our identifier is a random string we're calling an "endpoint", followed by a playerId 11 between 1 and 4. The game page is located at /game/<endpoint>, so all users on the 12 same page can join in the same peer-to-peer network. 13 14 * To connect clients behind NAT routers, we need a TURN server. This is currently supplied 15 by Cloudflare. See Media > Realtime > TURN Server. The account is free. Credentials 16 only last 24 hours, but can be generated on the server using a cron job each day. 17 The /game/index.php script reads the credential file and injects the json into window.TURN_CONFIG 18 which gets used in the constructor below. 19 20 --- src/peer.ts 21 declare const Peer: any; 22 import './input.css'; 23 import { GameState } from './state'; 24 25 export enum MessageType { 26 HANDSHAKE = "HANDSHAKE", 27 READY = "READY", 28 START = "START", 29 KICKED = "KICKED", 30 TURN_TAKEN = "TURN_TAKEN", 31 CHALLENGE = "CHALLENGE", 32 CHALLENGE_SUCCESS = "CHALLENGE_SUCCESS", 33 CHALLENGE_FAILURE = "CHALLENGE_FAILURE", 34 CANT_CHALLENGE = "CANT_CHALLENGE", // can't challenge previous word now 35 ERROR = "ERROR" 36 } 37 38 type connectionId = string; 39 40 export interface ConnectionState { 41 id: connectionId; 42 name: string; 43 state: 'n/a' | 'connected' | 'ready'; 44 } 45 46 export class Connection { 47 peer: any = null; 48 connections: Record<connectionId, any> = {}; 49 playerConnections: Record<connectionId, ConnectionState> = {}; 50 myNickname: string = ""; 51 playerId: number = 1; 52 endpoint: string = ""; 53 readyInterval: any = null; // when set, send out READY signals on a timer 54 55 @{constructor} 56 // we're connected to peerjs.com, now search for the other players: 57 @{autoConnect} 58 // try to connect to another player: 59 @{setupConnection} 60 // process messages coming in from other players 61 @{handleMessage} 62 // send a message to everyone who's connected: 63 @{broadcast} 64 // Send out regular broadcasts that we've clicked on 'Start' 65 @{ImReady} 66 // we're starting, so send a signal to Alpine 67 @{startGame} 68 // boot a player: 69 @{kick} 70 // Is everyone ready to start? 71 @{allReady} 72 // given a nickname, get the ConnectionState: 73 @{nameToState} 74 // what's my peerjs connection id? 75 @{peerId} 76 // when trying to find players, ignore most errors: 77 @{handleError} 78 // shut down peerjs connections 79 @{close} 80 } 81 82 function dispatch(eventName: string, detail: any) { 83 window.dispatchEvent(new CustomEvent(eventName, { detail })); 84 } 85 86 function log(nick: string, msg: string) { 87 dispatch('peer:log', `${nick}: ${msg}`); 88 } 89 --- 90 91 @s constructor 92 --- constructor 93 // endpoint comes from the url: /game/<endpoint> 94 constructor(endpoint: string, nick: string) { 95 this.myNickname = nick; 96 this.endpoint = endpoint; 97 this.init(); 98 } 99 100 init() { 101 const raw = (window as any).TURN_CONFIG; 102 const config = JSON.parse(raw); 103 this.peer = new Peer(this.peerId(), { 104 debug: 1, 105 config 106 }); 107 this.peer.on('open', (id: connectionId) => { 108 log(this.myNickname, "open"); 109 this.playerConnections[id] = { id: id, name: this.myNickname, state: 'n/a' }; 110 this.autoConnect(); 111 }); 112 this.peer.on('connection', (conn: any) => { 113 log(conn.peer, 'connected'); 114 this.setupConnection(conn); 115 }); 116 this.peer.on('error', (err: any) => this.handleError(err)); 117 } 118 --- 119 120 @s autoConnect 121 --- autoConnect 122 autoConnect() { 123 const me = this.peerId(); 124 for (let i = 1; i <= 4; i++) { 125 const who = `${this.endpoint}-${i}`; 126 if (i !== this.playerId) { 127 this.setupConnection(this.peer.connect(who)); 128 } 129 } 130 } 131 --- 132 133 @s setupConnection 134 --- setupConnection 135 setupConnection(conn: any) { 136 // Give the connection some time to prove it's alive 137 /* 138 const timeout = setTimeout(() => { 139 if (!this.playerNicknames[conn.peer]) { 140 conn.close(); 141 } 142 }, 5000); 143 */ 144 const handleOpen = () => { 145 this.connections[conn.peer] = conn; 146 conn.send({ type: 'HANDSHAKE', name: this.myNickname }); 147 }; 148 149 if (this.connections[conn.peer]) { 150 return; 151 } 152 153 if (conn.open) { 154 handleOpen(); 155 } else { 156 conn.on('open', handleOpen); 157 } 158 159 // signals received from other players: 160 conn.on('data', (data: any) => this.handleMessage(conn, data)); 161 conn.on('close', () => { 162 log(conn.peer, 'close'); 163 delete this.connections[conn.peer]; 164 delete this.playerConnections[conn.peer]; 165 }); 166 } 167 --- 168 169 @s peerId 170 --- peerId 171 peerId() { 172 return `${this.endpoint}-${this.playerId}`; 173 } 174 --- 175 176 @s handleError 177 --- handleError 178 handleError(err: any) { 179 switch (err.type) { 180 case 'peer-unavailable': 181 break; 182 case 'unavailable-id': 183 if (this.playerId < 4) { 184 this.playerId++; 185 if (this.peer) { 186 this.peer.off('open'); 187 this.peer.off('error'); 188 this.peer.off('connection'); 189 this.peer.destroy(); 190 } 191 setTimeout(() => { this.init(); }, 100); 192 } 193 break; 194 default: 195 console.log({err}); 196 } 197 } 198 --- 199 200 @s handleMessage 201 --- handleMessage 202 handleMessage(conn: any, data: any) { 203 // Centralized message handling using the CustomEvent bus 204 const eventDetail = { name: data.name, peerId: conn.peer, raw: data }; 205 206 switch (data.type) { 207 case MessageType.HANDSHAKE: 208 this.playerConnections[conn.peer] = { id: conn.peer, name: data.name, state: 'connected' }; 209 dispatch('peer:connected', eventDetail); 210 break; 211 case MessageType.READY: 212 this.playerConnections[conn.peer].state = 'ready'; 213 dispatch('peer:ready', eventDetail); 214 if (this.allReady()) { 215 this.broadcast({ type: MessageType.START, name: this.myNickname }); 216 this.startGame(); 217 } 218 break; 219 case MessageType.START: 220 dispatch('peer:start', eventDetail); 221 this.startGame(); 222 break; 223 case MessageType.TURN_TAKEN: 224 dispatch('peer:turn', data.raw); 225 break; 226 case MessageType.CHALLENGE: 227 dispatch('peer:challenge', data.raw); 228 break; 229 case MessageType.CHALLENGE_SUCCESS: 230 dispatch('peer:challengesuccess', data.raw); 231 break; 232 case MessageType.CHALLENGE_FAILURE: 233 dispatch('peer:challengefailure', data.raw); 234 break; 235 case MessageType.CANT_CHALLENGE: 236 dispatch('peer:cantchallenge', data.raw); 237 break; 238 case MessageType.KICKED: 239 const who = data.name; 240 if (who == this.myNickname) { 241 const me = this.peerId(); 242 delete this.connections[me]; 243 delete this.playerConnections[me]; 244 log(this.myNickname, "close down"); 245 dispatch('peer:closed', eventDetail); 246 return; 247 } 248 dispatch('peer:kicked', eventDetail); 249 break; 250 } 251 } 252 --- 253 254 @s broadcast 255 --- broadcast 256 broadcast(data: any) { 257 /* 258 const gameState = data.raw ? JSON.parse(JSON.stringify(data.raw)) : ''; 259 log(this.myNickname, `broadcast ${data.type} turn: ${gameState.turn}`); 260 */ 261 Object.keys(this.connections).forEach((id) => { 262 const conn = this.connections[id]; 263 if (conn.open) { 264 conn.send(data); 265 } 266 }); 267 } 268 --- 269 270 @s ImReady 271 --- ImReady 272 ImReady() { 273 this.broadcast({ type: 'READY', name: this.myNickname }); 274 this.playerConnections[this.peerId()].state = 'ready'; 275 if (!this.readyInterval) { 276 this.readyInterval = setInterval(() => { 277 if (this.playerConnections[this.peerId()].state !== 'ready') { 278 clearInterval(this.readyInterval); // stop sending 279 this.readyInterval = null; 280 return; 281 } 282 this.broadcast({ type: 'READY', name: this.myNickname }); 283 }, 5000); // 5 seconds 284 } 285 if (this.allReady()) { 286 this.broadcast({ type: 'START', name: this.myNickname }); 287 this.startGame(); 288 } 289 } 290 --- 291 292 @s startGame 293 --- startGame 294 startGame() { 295 window.dispatchEvent(new CustomEvent('start')); 296 if (this.readyInterval) { 297 clearInterval(this.readyInterval); 298 this.readyInterval = null; 299 } 300 } 301 --- 302 303 @s kick 304 --- kick 305 kick(name: string) { 306 this.broadcast({ type: 'KICKED', name }); 307 const peerId = this.nameToState(name)?.id; 308 if (peerId) { 309 delete this.connections[peerId]; 310 delete this.playerConnections[peerId]; 311 } 312 if (this.allReady()) { 313 this.broadcast({ type: 'START', name: this.myNickname }); 314 this.startGame(); 315 } 316 } 317 --- 318 319 @s allReady 320 --- allReady 321 allReady(): boolean { 322 const peerCount = Object.keys(this.connections).length; 323 const readyCount = Object.values(this.playerConnections).filter(p => p.state === 'ready').length; 324 return peerCount > 0 && readyCount >= (peerCount+1); 325 } 326 --- 327 328 @s close 329 --- close 330 close() { 331 this.connections = {}; 332 if (this.peer) { 333 this.peer.disconnect(); 334 this.peer.destroy(); 335 } 336 } 337 --- 338 339 @s nameToState 340 --- nameToState 341 nameToState(name: string): ConnectionState | null { 342 for (const k in Object.keys(this.playerConnections)) { 343 const cstate = this.playerConnections[k]; 344 if (cstate && cstate.name === name) { 345 return cstate; 346 } 347 } 348 return null; 349 } 350 ---