playsocketjs 1.0.7 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,6 +9,7 @@ const TIMEOUT_MS = 5000; // 5 second timeout for operations
9
9
  class PlaySocket {
10
10
  // Core properties
11
11
  #id; // Unique client ID
12
+ #sessionToken; // Unique session token
12
13
  #endpoint;
13
14
  #socket; // WebSocket connection
14
15
  #initialized = false; // Initialization status
@@ -17,7 +18,6 @@ class PlaySocket {
17
18
  // Room properties
18
19
  #storage = {}; // Shared storage object
19
20
  #storageTimestamps = {}; // Tracks update timestamps per key
20
- #isHost = false; // Whether this client is host
21
21
  #roomHost;
22
22
  #roomId; // ID of the host (client only)
23
23
  #connectionCount = 0;
@@ -29,10 +29,14 @@ class PlaySocket {
29
29
  // Async server operations
30
30
  #pendingJoin;
31
31
  #pendingHost;
32
- #pendingInit;
32
+ #pendingRegistration;
33
+ #pendingConnect;
34
+ #pendingReconnect;
33
35
 
34
36
  // Timeouts or intervals
35
37
  #reconnectTimeout;
38
+ #reconnectCount = 0;
39
+ #isReconnecting = false;
36
40
 
37
41
  /**
38
42
  * Create a new PlaySocket instance
@@ -72,7 +76,7 @@ class PlaySocket {
72
76
  */
73
77
  onEvent(event, callback) {
74
78
  const validEvents = [
75
- "status", "error", "instanceDestroyed", "storageUpdated", "hostMigrated", "clientConnected", "clientDisconnected",
79
+ "status", "error", "instanceDestroyed", "storageUpdated", "hostMigrated", "clientConnected", "clientDisconnected"
76
80
  ];
77
81
  if (!validEvents.includes(event)) return console.warn(WARNING_PREFIX + `Invalid event type "${event}".`);
78
82
  if (!this.#callbacks.has(event)) this.#callbacks.set(event, []);
@@ -99,41 +103,45 @@ class PlaySocket {
99
103
  * @returns {Promise} Resolves when connection is established
100
104
  */
101
105
  async init() {
102
- if (this.#initialized) return Promise.reject(new Error("Already initialized."));
103
- if (!this.#endpoint) return Promise.reject(new Error("No websocket endpoint provided."));
106
+ if (this.#initialized) return Promise.reject(new Error("Already initialized"));
107
+ if (!this.#endpoint) return Promise.reject(new Error("No websocket endpoint provided"));
104
108
  this.#triggerEvent("status", "Initializing...");
105
109
 
106
- return Promise.race([
107
- this.#connect(),
108
- this.#createTimeout("Initialization")
109
- ]);
110
- }
110
+ // Connect to WS server
111
+ await this.#connect();
111
112
 
113
+ // Register with server
114
+ await Promise.race([
115
+ new Promise(async (resolve, reject) => {
116
+ this.#pendingRegistration = { resolve, reject }; // Resolve or reject depending on answer to registration msg
112
117
 
113
- /**
114
- * Connect to the WS server and register
115
- * @returns {Promise} Resolves when user is registered on the WS server
116
- * @private
117
- */
118
- async #connect() {
119
- return new Promise((resolve, reject) => {
120
- this.#socket = new WebSocket(this.#endpoint);
121
- this.#setupSocketHandlers(); // Message & close events
122
-
123
- // Resolve or reject depending on answer to registration msg
124
- this.#pendingInit = { resolve, reject };
125
-
126
- // Register with server
127
- this.#socket.onopen = () => {
128
- this.#initialized = true;
118
+ // Register with server
129
119
  this.#sendToServer({
130
120
  type: 'register',
131
121
  id: this.#id,
132
122
  customData: this.#customData || undefined,
133
123
  clientTime: Date.now()
134
124
  });
135
- };
136
- });
125
+ }),
126
+ this.#createTimeout("Registration")
127
+ ]);
128
+ }
129
+
130
+ /**
131
+ * Connect to the WS server and
132
+ * @returns {Promise} Resolves when the connection was established
133
+ * @private
134
+ */
135
+ async #connect() {
136
+ return Promise.race([
137
+ new Promise((resolve, reject) => {
138
+ this.#pendingConnect = { reject };
139
+ this.#socket = new WebSocket(this.#endpoint);
140
+ this.#setupSocketHandlers(); // Message & close events
141
+ this.#socket.onopen = resolve;
142
+ }),
143
+ this.#createTimeout("Connection attempt")
144
+ ]);
137
145
  }
138
146
 
139
147
  /**
@@ -147,12 +155,13 @@ class PlaySocket {
147
155
  if (!message.type) return;
148
156
 
149
157
  switch (message.type) {
150
- case 'connection_accepted':
158
+ case 'join_accepted':
151
159
  // Connected to room
152
160
  if (this.#pendingJoin) {
153
161
  this.#storage = message.storage;
154
162
  this.#storageTimestamps = message.storageTimestamps;
155
- this.#connectionCount = message.participantCount - 1;
163
+ this.#connectionCount = message.participantCount - 1; // Counted without the user themselves
164
+ this.#roomHost = message.host;
156
165
  this.#triggerEvent("status", `Connected to room.`);
157
166
  this.#triggerEvent("storageUpdated", { ...this.#storage });
158
167
  this.#pendingJoin.resolve();
@@ -160,7 +169,7 @@ class PlaySocket {
160
169
  }
161
170
  break;
162
171
 
163
- case 'connection_rejected':
172
+ case 'join_rejected':
164
173
  // Connection to room failed
165
174
  if (this.#pendingJoin) {
166
175
  this.#triggerEvent("status", "Connection rejected: " + message.reason || 'Unknown reason');
@@ -169,6 +178,37 @@ class PlaySocket {
169
178
  }
170
179
  break;
171
180
 
181
+ case 'reconnected':
182
+ // Successfully reconnected
183
+ if (this.#pendingReconnect) {
184
+ this.#isReconnecting = false;
185
+ this.#reconnectCount = 0;
186
+ if (message.roomData) {
187
+ this.#storage = message.roomData.storage;
188
+ this.#storageTimestamps = message.roomData.storageTimestamps;
189
+ this.#connectionCount = message.roomData.participantCount - 1; // Counted without the user themselves
190
+ this.#setHost(message.roomData.host);
191
+ this.#triggerEvent("storageUpdated", { ...this.#storage });
192
+ } else {
193
+ if (this.#roomId) {
194
+ this.#triggerEvent("error", "Reconnected, but room no longer exists.");
195
+ return this.destroy();
196
+ }
197
+ }
198
+ this.#triggerEvent("status", "Reconnected.");
199
+ this.#pendingReconnect.resolve();
200
+ this.#pendingReconnect = null;
201
+ }
202
+ break;
203
+
204
+ case 'reconnection_failed':
205
+ if (this.#pendingReconnect) {
206
+ this.#triggerEvent("status", `Reconnection failed: ${message.reason}`);
207
+ this.#pendingReconnect.resolve();
208
+ this.#pendingReconnect = null;
209
+ }
210
+ break;
211
+
172
212
  case 'room_created':
173
213
  if (this.#pendingHost) {
174
214
  this.#pendingHost.resolve(this.#roomId);
@@ -187,21 +227,23 @@ class PlaySocket {
187
227
  break;
188
228
 
189
229
  case 'id_taken':
190
- if (this.#pendingInit) {
191
- this.#pendingInit.reject(new Error("This id is already in use."));
230
+ if (this.#pendingRegistration) {
231
+ this.#pendingRegistration.reject(new Error("This id is already in use"));
232
+ this.#pendingRegistration = null;
192
233
  this.#triggerEvent("error", "This id is taken.");
193
- this.#pendingInit = null;
194
234
  }
195
235
  break;
196
236
 
197
237
  case 'registered':
198
- if (this.#pendingInit) {
238
+ if (this.#pendingRegistration) {
199
239
  const roundTripTime = Date.now() - message.clientTime;
200
240
  const serverTimeAtClientNow = message.serverTime + (roundTripTime / 2);
201
241
  this.#serverTimeOffset = serverTimeAtClientNow - Date.now();
202
- this.#pendingInit.resolve();
242
+ this.#sessionToken = message.sessionToken;
243
+ this.#initialized = true;
244
+ this.#pendingRegistration.resolve();
245
+ this.#pendingRegistration = null;
203
246
  this.#triggerEvent("status", "Connected to server.");
204
- this.#pendingInit = null;
205
247
  }
206
248
  break;
207
249
 
@@ -217,21 +259,15 @@ class PlaySocket {
217
259
  break;
218
260
 
219
261
  case 'client_disconnected':
220
- this.#isHost = this.#id == message.updatedHost;
221
262
  this.#connectionCount = message.participantCount - 1; // Counted without the user themselves
222
-
223
- // If host has changed...
224
- if (this.#roomHost != message.updatedHost) {
225
- this.#roomHost = message.updatedHost;
226
- this.#triggerEvent("hostMigrated", this.#roomHost);
227
- }
263
+ this.#setHost(message.host);
228
264
 
229
265
  this.#triggerEvent("clientDisconnected", message.client);
230
266
  this.#triggerEvent("status", `Client ${message.client} disconnected.`);
231
267
  break;
232
268
 
233
269
  case 'client_connected':
234
- this.#connectionCount = message.participantCount - 1;
270
+ this.#connectionCount = message.participantCount - 1; // Counted without the user themselves
235
271
  this.#triggerEvent("clientConnected", message.client);
236
272
  this.#triggerEvent("status", `Client ${message.client} connected.`);
237
273
  break;
@@ -244,37 +280,67 @@ class PlaySocket {
244
280
  // Handle socket errors
245
281
  this.#socket.onerror = () => {
246
282
  this.#triggerEvent("error", "WebSocket error.");
247
- if (this.#pendingInit) this.#pendingInit.reject(new Error("WebSocket error."));
283
+ if (this.#pendingConnect) this.#pendingConnect.reject(new Error("WebSocket error"));
248
284
  };
249
285
 
250
286
  // Handle socket close & attempt reconnect
251
287
  this.#socket.onclose = () => {
252
- if (!this.#initialized) return;
253
- this.#triggerEvent("status", "Disconnected from server.");
254
-
255
- this.#reconnectTimeout = setTimeout(async () => {
256
- try {
257
- await this.#connect(); // Will set status to connected if successful
258
-
259
- // Rejoin room if needed
260
- if (this.#roomId) {
261
- this.#triggerEvent("status", "Rejoining room...");
262
- await this.joinRoom(this.#roomId); // If this fails, it will be caught by the outer catch
263
- }
264
- } catch (error) {
265
- this.#triggerEvent("error", "WebSocket connection permanently closed: " + error);
266
- this.destroy();
267
- }
268
- }, 1000);
288
+ if (!this.#initialized || this.#isReconnecting) return;
289
+ this.#triggerEvent("status", "Disconnected.");
290
+ this.#reconnectCount = 0;
291
+ this.#attemptReconnect();
269
292
  };
270
293
  };
271
294
 
295
+ /**
296
+ * Attempt to reconnect with a fixed number of retries
297
+ * @private
298
+ */
299
+ async #attemptReconnect() {
300
+ if (this.#reconnectCount++ >= 3) {
301
+ this.#triggerEvent("error", "Disconnected from server.");
302
+ return this.destroy();
303
+ }
304
+
305
+ this.#triggerEvent("status", `Attempting to reconnect... (${this.#reconnectCount})`);
306
+ this.#isReconnecting = true;
307
+ try {
308
+ await this.#connect();
309
+ await Promise.race([
310
+ new Promise(async (resolve, reject) => {
311
+ this.#pendingReconnect = { resolve, reject };
312
+ this.#sendToServer({
313
+ type: 'reconnect',
314
+ id: this.#id,
315
+ sessionToken: this.#sessionToken
316
+ });
317
+ }),
318
+ this.#createTimeout("Reconnection request")
319
+ ]);
320
+ } catch (error) {
321
+ this.#triggerEvent("status", "Reconnection failed: " + error.message);
322
+ this.#reconnectTimeout = setTimeout(() => this.#attemptReconnect(), 500);
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Update the host in case a new one was chosen
328
+ * @param {string} hostId - Client id of new host
329
+ * @private
330
+ */
331
+ #setHost(hostId) {
332
+ if (this.#roomHost != hostId) {
333
+ this.#roomHost = hostId;
334
+ this.#triggerEvent("hostMigrated", hostId);
335
+ }
336
+ }
337
+
272
338
  /**
273
339
  * Send a message to the server
274
340
  * @private
275
341
  */
276
342
  #sendToServer(data) {
277
- if (!this.#initialized || !this.#socket || this.#socket.readyState !== WebSocket.OPEN) {
343
+ if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN) {
278
344
  return console.error(ERROR_PREFIX + "Cannot send message - not connected.");
279
345
  }
280
346
  try {
@@ -299,7 +365,6 @@ class PlaySocket {
299
365
 
300
366
  return Promise.race([
301
367
  new Promise((resolve, reject) => {
302
- this.#isHost = true;
303
368
  this.#storage = initialStorage;
304
369
  this.#roomId = this.#id; // Create room with your own ID as the room ID to mimic p2p
305
370
  this.#roomHost = this.#id;
@@ -329,9 +394,7 @@ class PlaySocket {
329
394
 
330
395
  return Promise.race([
331
396
  new Promise((resolve, reject) => {
332
- this.#isHost = false;
333
397
  this.#roomId = roomId;
334
- this.#roomHost = roomId;
335
398
  this.#pendingJoin = { resolve, reject };
336
399
 
337
400
  // Send connection request
@@ -436,19 +499,20 @@ class PlaySocket {
436
499
  }
437
500
 
438
501
  // Reset state
439
- this.#isHost = false;
502
+ clearTimeout(this.#reconnectTimeout);
440
503
  this.#roomHost = null;
441
504
  this.#storage = {};
442
505
  this.#storageTimestamps = {};
443
506
  this.#roomId = null;
444
507
  this.#connectionCount = 0;
445
- clearTimeout(this.#reconnectTimeout);
508
+ this.#isReconnecting = false;
509
+ this.#reconnectCount = 0;
446
510
  }
447
511
 
448
512
  // Public getters
449
513
  get connectionCount() { return this.#connectionCount; }
450
514
  get getStorage() { return { ...this.#storage }; }
451
- get isHost() { return this.#isHost; }
515
+ get isHost() { return this.#id == this.#roomHost; }
452
516
  get id() { return this.#id; }
453
517
  }
454
518
 
@@ -27,8 +27,10 @@ function requireServer () {
27
27
  #rooms = {};
28
28
  #clientRooms = new Map(); // ClientId -> RoomId
29
29
  #rateLimits = new Map(); // Rate limiting storage
30
- #callbacks = new Map();
30
+ #callbacks = new Map(); // Event -> [callback functions]
31
31
  #heartbeatInterval;
32
+ #pendingDisconnects = new Map(); // ClientId -> {timeout, roomId}
33
+ #clientTokens = new Map(); // ClientId -> Token
32
34
 
33
35
  /**
34
36
  * Create a new PlaySocketServer instance
@@ -98,10 +100,45 @@ function requireServer () {
98
100
  }
99
101
  ws.clientId = data.id; // Adds the provided id to the ws object as clientId
100
102
  this.#clients.set(data.id, ws);
101
- ws.send(JSON.stringify({ type: 'registered', serverTime: Date.now(), clientTime: data.clientTime }));
103
+ const sessionToken = this.#generateSessionToken();
104
+ this.#clientTokens.set(data.id, sessionToken); // Token is used when reconnecting to prevent impersonation attacks
105
+ ws.send(JSON.stringify({ type: 'registered', sessionToken, serverTime: Date.now(), clientTime: data.clientTime }));
102
106
  this.#triggerEvent("clientRegistered", data.id, data.customData);
103
107
  break;
104
108
 
109
+ case 'reconnect':
110
+ // If user is pending disconnect, respond (otherwise it's too late)
111
+ const pd = this.#pendingDisconnects.get(data.id);
112
+ if (pd && data.sessionToken) {
113
+ if (data.sessionToken !== this.#clientTokens.get(data.id)) {
114
+ ws.send(JSON.stringify({ type: 'reconnection_failed', reason: "Session token does not match" }));
115
+ return;
116
+ }
117
+ clearTimeout(pd.timeout);
118
+ this.#pendingDisconnects.delete(data.id);
119
+
120
+ // Re-assign old client id to the new ws connection
121
+ ws.clientId = data.id;
122
+ this.#clients.set(data.id, ws);
123
+
124
+ // If they were in a room, provide updated room data
125
+ let roomData;
126
+ const formerRoom = this.#rooms[this.#clientRooms.get(data.id)];
127
+ if (formerRoom) {
128
+ roomData = {
129
+ storage: formerRoom.storage,
130
+ storageTimestamps: formerRoom.storageTimestamps,
131
+ participantCount: formerRoom.participants.length,
132
+ host: formerRoom.participants[0]
133
+ };
134
+ }
135
+
136
+ ws.send(JSON.stringify({ type: 'reconnected', roomData }));
137
+ } else {
138
+ ws.send(JSON.stringify({ type: 'reconnection_failed', reason: "Client unknown to server" }));
139
+ }
140
+ break;
141
+
105
142
  case 'create_room':
106
143
  if (!ws.clientId) return;
107
144
  const newRoomId = ws.clientId;
@@ -126,7 +163,7 @@ function requireServer () {
126
163
  break;
127
164
 
128
165
  case 'join_room':
129
- if (!data.roomId) return;
166
+ if (!data.roomId || !ws.clientId) return;
130
167
  const roomId = data.roomId;
131
168
  const room = this.#rooms[roomId];
132
169
 
@@ -134,7 +171,7 @@ function requireServer () {
134
171
  (room.maxSize && room.participants.length >= room.maxSize) ||
135
172
  this.#clientRooms.get(ws.clientId)) {
136
173
  ws.send(JSON.stringify({
137
- type: 'connection_rejected',
174
+ type: 'join_rejected',
138
175
  reason: !room ? 'Room not found' :
139
176
  room.maxSize && room.participants.length >= room.maxSize ? 'Room full' :
140
177
  'Already in a room'
@@ -147,10 +184,11 @@ function requireServer () {
147
184
 
148
185
  // Notify joiner with initial storage
149
186
  ws.send(JSON.stringify({
150
- type: 'connection_accepted',
187
+ type: 'join_accepted',
151
188
  storage: room.storage,
152
189
  storageTimestamps: room.storageTimestamps,
153
- participantCount: room.participants.length
190
+ participantCount: room.participants.length,
191
+ host: room.participants[0]
154
192
  }));
155
193
 
156
194
  // Notify existing participants
@@ -199,6 +237,16 @@ function requireServer () {
199
237
  }
200
238
  }
201
239
 
240
+ /**
241
+ * Generate a random token to prevent malicious reconnect attempts
242
+ * @returns {string} - Token
243
+ */
244
+ #generateSessionToken() {
245
+ let token = '';
246
+ for (let i = 0; i < 16; i++) token += Math.floor(Math.random() * 16).toString(16);
247
+ return token;
248
+ }
249
+
202
250
  /**
203
251
  * Check rate limit using token bucket algorithm
204
252
  * @private
@@ -284,37 +332,40 @@ function requireServer () {
284
332
  */
285
333
  #handleDisconnection(ws) {
286
334
  if (!ws.clientId) return;
287
-
288
- this.#clients.delete(ws.clientId);
289
- this.#rateLimits.delete(ws.clientId);
290
-
291
- const roomId = this.#clientRooms.get(ws.clientId);
292
- const room = roomId ? this.#rooms[roomId] : null;
293
-
294
- if (room) {
295
- room.participants = room.participants?.filter(p => p !== ws.clientId);
296
- this.#clientRooms.delete(ws.clientId);
297
-
298
- // Notify remaining participants
299
- room.participants?.forEach(p => {
300
- const client = this.#clients.get(p);
301
- if (client) {
302
- try {
303
- client.send(JSON.stringify({
304
- type: 'client_disconnected',
305
- updatedHost: room.participants?.[0],
306
- client: ws.clientId,
307
- participantCount: room.participants?.length
308
- }));
309
- } catch (error) {
310
- console.error(`Error notifying client of disconnection:`, error);
335
+ this.#clients.delete(ws.clientId); // Immediately remove from active clients (otherwise, server would try to message this client)
336
+
337
+ // Pending disconnection with 2s grace period to allow for reconnections
338
+ this.#pendingDisconnects.set(ws.clientId, {
339
+ timeout: setTimeout(() => {
340
+ this.#pendingDisconnects.delete(ws.clientId);
341
+ this.#rateLimits.delete(ws.clientId);
342
+ this.#clientTokens.delete(ws.clientId);
343
+ const roomId = this.#clientRooms.get(ws.clientId);
344
+ const room = this.#rooms[roomId];
345
+
346
+ if (room) {
347
+ room.participants = room.participants.filter(p => p !== ws.clientId); // Remove client from room
348
+ this.#clientRooms.delete(ws.clientId);
349
+
350
+ if (room.participants?.length === 0) {
351
+ delete this.#rooms[roomId]; // Delete room if now empty
352
+ } else {
353
+ // Notify remaining participants
354
+ room.participants.forEach(p => {
355
+ const client = this.#clients.get(p);
356
+ if (client) client.send(JSON.stringify({
357
+ type: 'client_disconnected',
358
+ client: ws.clientId,
359
+ host: room.participants[0],
360
+ participantCount: room.participants.length
361
+ }));
362
+ });
311
363
  }
312
364
  }
313
- });
314
- if (room.participants?.length === 0) delete this.#rooms[roomId]; // Delete room if now empty
315
- }
316
365
 
317
- this.#triggerEvent("clientDisconnected", ws.clientId);
366
+ this.#triggerEvent("clientDisconnected", ws.clientId);
367
+ }, 2000)
368
+ });
318
369
  }
319
370
 
320
371
  /**
@@ -354,6 +405,9 @@ function requireServer () {
354
405
  console.log('PlaySocket server stopped');
355
406
  });
356
407
  }
408
+ this.#pendingDisconnects.forEach((data) => {
409
+ clearTimeout(data.timeout);
410
+ });
357
411
  }
358
412
 
359
413
  get getRooms() { return { ...this.#rooms } }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playsocketjs",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "WebSocket wrapper for creating simple multiplayer systems with ease.",
5
5
  "module": "dist/playsocket-client.js",
6
6
  "exports": {