/ 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  ---