playsocketjs 1.0.6 → 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
@@ -16,10 +17,11 @@ class PlaySocket {
16
17
 
17
18
  // Room properties
18
19
  #storage = {}; // Shared storage object
19
- #isHost = false; // Whether this client is host
20
+ #storageTimestamps = {}; // Tracks update timestamps per key
20
21
  #roomHost;
21
22
  #roomId; // ID of the host (client only)
22
23
  #connectionCount = 0;
24
+ #serverTimeOffset = 0; // Offset between server time and local time
23
25
 
24
26
  // Event handling
25
27
  #callbacks = new Map(); // Event callbacks
@@ -27,10 +29,14 @@ class PlaySocket {
27
29
  // Async server operations
28
30
  #pendingJoin;
29
31
  #pendingHost;
30
- #pendingInit;
32
+ #pendingRegistration;
33
+ #pendingConnect;
34
+ #pendingReconnect;
31
35
 
32
36
  // Timeouts or intervals
33
37
  #reconnectTimeout;
38
+ #reconnectCount = 0;
39
+ #isReconnecting = false;
34
40
 
35
41
  /**
36
42
  * Create a new PlaySocket instance
@@ -55,6 +61,14 @@ class PlaySocket {
55
61
  );
56
62
  }
57
63
 
64
+ /**
65
+ * Get synchronized timestamp
66
+ * @private
67
+ */
68
+ #getTimestamp() {
69
+ return Date.now() + this.#serverTimeOffset;
70
+ }
71
+
58
72
  /**
59
73
  * Register an event callback
60
74
  * @param {string} event - Event name
@@ -62,7 +76,7 @@ class PlaySocket {
62
76
  */
63
77
  onEvent(event, callback) {
64
78
  const validEvents = [
65
- "status", "error", "instanceDestroyed", "storageUpdated", "hostMigrated", "clientConnected", "clientDisconnected",
79
+ "status", "error", "instanceDestroyed", "storageUpdated", "hostMigrated", "clientConnected", "clientDisconnected"
66
80
  ];
67
81
  if (!validEvents.includes(event)) return console.warn(WARNING_PREFIX + `Invalid event type "${event}".`);
68
82
  if (!this.#callbacks.has(event)) this.#callbacks.set(event, []);
@@ -89,40 +103,45 @@ class PlaySocket {
89
103
  * @returns {Promise} Resolves when connection is established
90
104
  */
91
105
  async init() {
92
- if (this.#initialized) return Promise.reject(new Error("Already initialized."));
93
- 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"));
94
108
  this.#triggerEvent("status", "Initializing...");
95
109
 
96
- return Promise.race([
97
- this.#connect(),
98
- this.#createTimeout("Initialization")
110
+ // Connect to WS server
111
+ await this.#connect();
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
117
+
118
+ // Register with server
119
+ this.#sendToServer({
120
+ type: 'register',
121
+ id: this.#id,
122
+ customData: this.#customData || undefined,
123
+ clientTime: Date.now()
124
+ });
125
+ }),
126
+ this.#createTimeout("Registration")
99
127
  ]);
100
128
  }
101
129
 
102
-
103
130
  /**
104
- * Connect to the WS server and register
105
- * @returns {Promise} Resolves when user is registered on the WS server
131
+ * Connect to the WS server and
132
+ * @returns {Promise} Resolves when the connection was established
106
133
  * @private
107
134
  */
108
135
  async #connect() {
109
- return new Promise((resolve, reject) => {
110
- this.#socket = new WebSocket(this.#endpoint);
111
- this.#setupSocketHandlers(); // Message & close events
112
-
113
- // Resolve or reject depending on answer to registration msg
114
- this.#pendingInit = { resolve, reject };
115
-
116
- // Register with server
117
- this.#socket.onopen = () => {
118
- this.#initialized = true;
119
- this.#sendToServer({
120
- type: 'register',
121
- id: this.#id,
122
- customData: this.#customData || undefined
123
- });
124
- };
125
- });
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
+ ]);
126
145
  }
127
146
 
128
147
  /**
@@ -136,11 +155,13 @@ class PlaySocket {
136
155
  if (!message.type) return;
137
156
 
138
157
  switch (message.type) {
139
- case 'connection_accepted':
158
+ case 'join_accepted':
140
159
  // Connected to room
141
160
  if (this.#pendingJoin) {
142
161
  this.#storage = message.storage;
143
- this.#connectionCount = message.participantCount - 1;
162
+ this.#storageTimestamps = message.storageTimestamps;
163
+ this.#connectionCount = message.participantCount - 1; // Counted without the user themselves
164
+ this.#roomHost = message.host;
144
165
  this.#triggerEvent("status", `Connected to room.`);
145
166
  this.#triggerEvent("storageUpdated", { ...this.#storage });
146
167
  this.#pendingJoin.resolve();
@@ -148,7 +169,7 @@ class PlaySocket {
148
169
  }
149
170
  break;
150
171
 
151
- case 'connection_rejected':
172
+ case 'join_rejected':
152
173
  // Connection to room failed
153
174
  if (this.#pendingJoin) {
154
175
  this.#triggerEvent("status", "Connection rejected: " + message.reason || 'Unknown reason');
@@ -157,6 +178,37 @@ class PlaySocket {
157
178
  }
158
179
  break;
159
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
+
160
212
  case 'room_created':
161
213
  if (this.#pendingHost) {
162
214
  this.#pendingHost.resolve(this.#roomId);
@@ -175,44 +227,47 @@ class PlaySocket {
175
227
  break;
176
228
 
177
229
  case 'id_taken':
178
- if (this.#pendingInit) {
179
- 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;
180
233
  this.#triggerEvent("error", "This id is taken.");
181
- this.#pendingInit = null;
182
234
  }
183
235
  break;
184
236
 
185
237
  case 'registered':
186
- if (this.#pendingInit) {
187
- this.#pendingInit.resolve();
238
+ if (this.#pendingRegistration) {
239
+ const roundTripTime = Date.now() - message.clientTime;
240
+ const serverTimeAtClientNow = message.serverTime + (roundTripTime / 2);
241
+ this.#serverTimeOffset = serverTimeAtClientNow - Date.now();
242
+ this.#sessionToken = message.sessionToken;
243
+ this.#initialized = true;
244
+ this.#pendingRegistration.resolve();
245
+ this.#pendingRegistration = null;
188
246
  this.#triggerEvent("status", "Connected to server.");
189
- this.#pendingInit = null;
190
247
  }
191
248
  break;
192
249
 
193
250
  case 'storage_sync':
194
- if (message.key && JSON.stringify(this.#storage[message.key]) !== JSON.stringify(message.value)) {
195
- this.#storage[message.key] = message.value;
196
- this.#triggerEvent("storageUpdated", { ...this.#storage });
251
+ if (message.key) {
252
+ const localTimestamp = this.#storageTimestamps[message.key] || 0;
253
+ if (message.timestamp > localTimestamp && JSON.stringify(this.#storage[message.key]) !== JSON.stringify(message.value)) {
254
+ this.#storage[message.key] = message.value;
255
+ this.#storageTimestamps[message.key] = message.timestamp;
256
+ this.#triggerEvent("storageUpdated", { ...this.#storage });
257
+ }
197
258
  }
198
259
  break;
199
260
 
200
261
  case 'client_disconnected':
201
- this.#isHost = this.#id == message.updatedHost;
202
262
  this.#connectionCount = message.participantCount - 1; // Counted without the user themselves
203
-
204
- // If host has changed...
205
- if (this.#roomHost != message.updatedHost) {
206
- this.#roomHost = message.updatedHost;
207
- this.#triggerEvent("hostMigrated", this.#roomHost);
208
- }
263
+ this.#setHost(message.host);
209
264
 
210
265
  this.#triggerEvent("clientDisconnected", message.client);
211
266
  this.#triggerEvent("status", `Client ${message.client} disconnected.`);
212
267
  break;
213
268
 
214
269
  case 'client_connected':
215
- this.#connectionCount = message.participantCount - 1;
270
+ this.#connectionCount = message.participantCount - 1; // Counted without the user themselves
216
271
  this.#triggerEvent("clientConnected", message.client);
217
272
  this.#triggerEvent("status", `Client ${message.client} connected.`);
218
273
  break;
@@ -225,37 +280,67 @@ class PlaySocket {
225
280
  // Handle socket errors
226
281
  this.#socket.onerror = () => {
227
282
  this.#triggerEvent("error", "WebSocket error.");
228
- if (this.#pendingInit) this.#pendingInit.reject(new Error("WebSocket error."));
283
+ if (this.#pendingConnect) this.#pendingConnect.reject(new Error("WebSocket error"));
229
284
  };
230
285
 
231
286
  // Handle socket close & attempt reconnect
232
287
  this.#socket.onclose = () => {
233
- if (!this.#initialized) return;
234
- this.#triggerEvent("status", "Disconnected from server.");
235
-
236
- this.#reconnectTimeout = setTimeout(async () => {
237
- try {
238
- await this.#connect(); // Will set status to connected if successful
239
-
240
- // Rejoin room if needed
241
- if (this.#roomId) {
242
- this.#triggerEvent("status", "Rejoining room...");
243
- await this.joinRoom(this.#roomId); // If this fails, it will be caught by the outer catch
244
- }
245
- } catch (error) {
246
- this.#triggerEvent("error", "WebSocket connection permanently closed: " + error);
247
- this.destroy();
248
- }
249
- }, 1000);
288
+ if (!this.#initialized || this.#isReconnecting) return;
289
+ this.#triggerEvent("status", "Disconnected.");
290
+ this.#reconnectCount = 0;
291
+ this.#attemptReconnect();
250
292
  };
251
293
  };
252
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
+
253
338
  /**
254
339
  * Send a message to the server
255
340
  * @private
256
341
  */
257
342
  #sendToServer(data) {
258
- if (!this.#initialized || !this.#socket || this.#socket.readyState !== WebSocket.OPEN) {
343
+ if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN) {
259
344
  return console.error(ERROR_PREFIX + "Cannot send message - not connected.");
260
345
  }
261
346
  try {
@@ -280,7 +365,6 @@ class PlaySocket {
280
365
 
281
366
  return Promise.race([
282
367
  new Promise((resolve, reject) => {
283
- this.#isHost = true;
284
368
  this.#storage = initialStorage;
285
369
  this.#roomId = this.#id; // Create room with your own ID as the room ID to mimic p2p
286
370
  this.#roomHost = this.#id;
@@ -310,9 +394,7 @@ class PlaySocket {
310
394
 
311
395
  return Promise.race([
312
396
  new Promise((resolve, reject) => {
313
- this.#isHost = false;
314
397
  this.#roomId = roomId;
315
- this.#roomHost = roomId;
316
398
  this.#pendingJoin = { resolve, reject };
317
399
 
318
400
  // Send connection request
@@ -333,12 +415,15 @@ class PlaySocket {
333
415
  */
334
416
  updateStorage(key, value) {
335
417
  if (JSON.stringify(this.#storage[key]) === JSON.stringify(value)) return; // Prevent updates without changes
418
+ const timestamp = this.#getTimestamp();
336
419
  this.#sendToServer({
337
420
  type: 'room_storage_update',
338
421
  key,
339
- value
422
+ value,
423
+ timestamp
340
424
  });
341
425
  this.#storage[key] = value;
426
+ this.#storageTimestamps[key] = timestamp;
342
427
  this.#triggerEvent("storageUpdated", { ...this.#storage });
343
428
  }
344
429
 
@@ -360,6 +445,7 @@ class PlaySocket {
360
445
  updateValue
361
446
  });
362
447
  this.#storage[key] = updatedArray;
448
+ this.#storageTimestamps[key] = this.#getTimestamp();
363
449
  this.#triggerEvent("storageUpdated", { ...this.#storage });
364
450
  }
365
451
 
@@ -413,18 +499,20 @@ class PlaySocket {
413
499
  }
414
500
 
415
501
  // Reset state
416
- this.#isHost = false;
502
+ clearTimeout(this.#reconnectTimeout);
417
503
  this.#roomHost = null;
418
504
  this.#storage = {};
505
+ this.#storageTimestamps = {};
419
506
  this.#roomId = null;
420
507
  this.#connectionCount = 0;
421
- clearTimeout(this.#reconnectTimeout);
508
+ this.#isReconnecting = false;
509
+ this.#reconnectCount = 0;
422
510
  }
423
511
 
424
512
  // Public getters
425
513
  get connectionCount() { return this.#connectionCount; }
426
514
  get getStorage() { return { ...this.#storage }; }
427
- get isHost() { return this.#isHost; }
515
+ get isHost() { return this.#id == this.#roomHost; }
428
516
  get id() { return this.#id; }
429
517
  }
430
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' }));
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;
@@ -117,6 +154,7 @@ function requireServer () {
117
154
  participants: [ws.clientId],
118
155
  maxSize: data.size || null,
119
156
  storage: data.storage || {},
157
+ storageTimestamps: {},
120
158
  };
121
159
 
122
160
  this.#clientRooms.set(ws.clientId, newRoomId);
@@ -125,7 +163,7 @@ function requireServer () {
125
163
  break;
126
164
 
127
165
  case 'join_room':
128
- if (!data.roomId) return;
166
+ if (!data.roomId || !ws.clientId) return;
129
167
  const roomId = data.roomId;
130
168
  const room = this.#rooms[roomId];
131
169
 
@@ -133,7 +171,7 @@ function requireServer () {
133
171
  (room.maxSize && room.participants.length >= room.maxSize) ||
134
172
  this.#clientRooms.get(ws.clientId)) {
135
173
  ws.send(JSON.stringify({
136
- type: 'connection_rejected',
174
+ type: 'join_rejected',
137
175
  reason: !room ? 'Room not found' :
138
176
  room.maxSize && room.participants.length >= room.maxSize ? 'Room full' :
139
177
  'Already in a room'
@@ -146,9 +184,11 @@ function requireServer () {
146
184
 
147
185
  // Notify joiner with initial storage
148
186
  ws.send(JSON.stringify({
149
- type: 'connection_accepted',
187
+ type: 'join_accepted',
150
188
  storage: room.storage,
151
- participantCount: room.participants.length
189
+ storageTimestamps: room.storageTimestamps,
190
+ participantCount: room.participants.length,
191
+ host: room.participants[0]
152
192
  }));
153
193
 
154
194
  // Notify existing participants
@@ -169,9 +209,13 @@ function requireServer () {
169
209
  case 'room_storage_update':
170
210
  const updateRoomId = this.#clientRooms.get(ws.clientId);
171
211
  const updateRoom = updateRoomId ? this.#rooms[updateRoomId] : null;
172
- if (updateRoom && data.key && JSON.stringify(updateRoom.storage[data.key]) !== JSON.stringify(data.value)) {
173
- updateRoom.storage[data.key] = data.value;
174
- this.#syncRoomStorageKey(updateRoomId, data.key);
212
+ if (updateRoom && data.key) {
213
+ const existingTimestamp = updateRoom.storageTimestamps[data.key] || 0;
214
+ if (data.timestamp > existingTimestamp && JSON.stringify(updateRoom.storage[data.key]) !== JSON.stringify(data.value)) {
215
+ updateRoom.storage[data.key] = data.value;
216
+ updateRoom.storageTimestamps[data.key] = data.timestamp;
217
+ this.#syncRoomStorageKey(updateRoomId, data.key, data.timestamp);
218
+ }
175
219
  }
176
220
  break;
177
221
 
@@ -182,7 +226,8 @@ function requireServer () {
182
226
  const updatedArray = this.#arrayUpdate(arrayRoom.storage, data.key, data.operation, data.value, data.updateValue);
183
227
  if (JSON.stringify(arrayRoom.storage[data.key]) !== JSON.stringify(updatedArray)) {
184
228
  arrayRoom.storage[data.key] = updatedArray;
185
- this.#syncRoomStorageKey(arrayRoomId, data.key);
229
+ arrayRoom.storageTimestamps[data.key] = Date.now(); // New date to ensure that they are applied regardless of age (they don't overwrite – they transform)
230
+ this.#syncRoomStorageKey(arrayRoomId, data.key, Date.now());
186
231
  }
187
232
  }
188
233
  break;
@@ -192,6 +237,16 @@ function requireServer () {
192
237
  }
193
238
  }
194
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
+
195
250
  /**
196
251
  * Check rate limit using token bucket algorithm
197
252
  * @private
@@ -224,7 +279,7 @@ function requireServer () {
224
279
  * Sync storage key to all clients in room
225
280
  * @private
226
281
  */
227
- #syncRoomStorageKey(roomId, key) {
282
+ #syncRoomStorageKey(roomId, key, timestamp) {
228
283
  const room = this.#rooms[roomId];
229
284
  if (!room) return;
230
285
  room.participants.forEach(p => {
@@ -233,7 +288,8 @@ function requireServer () {
233
288
  client.send(JSON.stringify({
234
289
  type: 'storage_sync',
235
290
  key,
236
- value: room.storage[key]
291
+ value: room.storage[key],
292
+ timestamp
237
293
  }));
238
294
  }
239
295
  });
@@ -276,37 +332,40 @@ function requireServer () {
276
332
  */
277
333
  #handleDisconnection(ws) {
278
334
  if (!ws.clientId) return;
279
-
280
- this.#clients.delete(ws.clientId);
281
- this.#rateLimits.delete(ws.clientId);
282
-
283
- const roomId = this.#clientRooms.get(ws.clientId);
284
- const room = roomId ? this.#rooms[roomId] : null;
285
-
286
- if (room) {
287
- room.participants = room.participants?.filter(p => p !== ws.clientId);
288
- this.#clientRooms.delete(ws.clientId);
289
-
290
- // Notify remaining participants
291
- room.participants?.forEach(p => {
292
- const client = this.#clients.get(p);
293
- if (client) {
294
- try {
295
- client.send(JSON.stringify({
296
- type: 'client_disconnected',
297
- updatedHost: room.participants?.[0],
298
- client: ws.clientId,
299
- participantCount: room.participants?.length
300
- }));
301
- } catch (error) {
302
- 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
+ });
303
363
  }
304
364
  }
305
- });
306
- if (room.participants?.length === 0) delete this.#rooms[roomId]; // Delete room if now empty
307
- }
308
365
 
309
- this.#triggerEvent("clientDisconnected", ws.clientId);
366
+ this.#triggerEvent("clientDisconnected", ws.clientId);
367
+ }, 2000)
368
+ });
310
369
  }
311
370
 
312
371
  /**
@@ -346,6 +405,9 @@ function requireServer () {
346
405
  console.log('PlaySocket server stopped');
347
406
  });
348
407
  }
408
+ this.#pendingDisconnects.forEach((data) => {
409
+ clearTimeout(data.timeout);
410
+ });
349
411
  }
350
412
 
351
413
  get getRooms() { return { ...this.#rooms } }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playsocketjs",
3
- "version": "1.0.6",
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": {