playsocketjs 1.0.7 → 1.0.9

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,8 +17,7 @@ class PlaySocket {
16
17
 
17
18
  // Room properties
18
19
  #storage = {}; // Shared storage object
19
- #storageTimestamps = {}; // Tracks update timestamps per key
20
- #isHost = false; // Whether this client is host
20
+ #storageTimestamps = {}; // Tracks update timestamps per key to ensure that not-yet synced updates don't get overwritten
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,40 +103,48 @@ 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
- }
111
-
112
-
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
110
+ // Connect to WS server
111
+ await this.#connect();
122
112
 
123
- // Resolve or reject depending on answer to registration msg
124
- this.#pendingInit = { resolve, reject };
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
125
117
 
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
- };
125
+ }),
126
+ this.#createTimeout("Registration")
127
+ ]).finally(() => {
128
+ this.#pendingRegistration = null;
129
+ });
130
+ }
131
+
132
+ /**
133
+ * Connect to the WS server and
134
+ * @returns {Promise} Resolves when the connection was established
135
+ * @private
136
+ */
137
+ async #connect() {
138
+ return Promise.race([
139
+ new Promise((resolve, reject) => {
140
+ this.#pendingConnect = { reject };
141
+ this.#socket = new WebSocket(this.#endpoint);
142
+ this.#setupSocketHandlers(); // Message & close events
143
+ this.#socket.onopen = resolve;
144
+ }),
145
+ this.#createTimeout("Connection attempt")
146
+ ]).finally(() => {
147
+ this.#pendingConnect = null;
136
148
  });
137
149
  }
138
150
 
@@ -147,25 +159,53 @@ class PlaySocket {
147
159
  if (!message.type) return;
148
160
 
149
161
  switch (message.type) {
150
- case 'connection_accepted':
162
+ case 'join_accepted':
151
163
  // Connected to room
152
164
  if (this.#pendingJoin) {
153
165
  this.#storage = message.storage;
154
166
  this.#storageTimestamps = message.storageTimestamps;
155
- this.#connectionCount = message.participantCount - 1;
167
+ this.#connectionCount = message.participantCount - 1; // Counted without the user themselves
168
+ this.#roomHost = message.host;
156
169
  this.#triggerEvent("status", `Connected to room.`);
157
170
  this.#triggerEvent("storageUpdated", { ...this.#storage });
158
171
  this.#pendingJoin.resolve();
159
- this.#pendingJoin = null;
160
172
  }
161
173
  break;
162
174
 
163
- case 'connection_rejected':
175
+ case 'join_rejected':
164
176
  // Connection to room failed
165
177
  if (this.#pendingJoin) {
166
- this.#triggerEvent("status", "Connection rejected: " + message.reason || 'Unknown reason');
167
- this.#pendingJoin.reject(new Error("Connection rejected: " + message.reason || 'Unknown reason'));
168
- this.#pendingJoin = null;
178
+ this.#triggerEvent("status", "Connection rejected: " + message.reason);
179
+ this.#pendingJoin.reject(new Error("Connection rejected: " + message.reason));
180
+ }
181
+ break;
182
+
183
+ case 'reconnected':
184
+ // Successfully reconnected
185
+ if (this.#pendingReconnect) {
186
+ this.#isReconnecting = false;
187
+ this.#reconnectCount = 0;
188
+ if (message.roomData) {
189
+ this.#storage = message.roomData.storage;
190
+ this.#storageTimestamps = message.roomData.storageTimestamps;
191
+ this.#connectionCount = message.roomData.participantCount - 1; // Counted without the user themselves
192
+ this.#setHost(message.roomData.host);
193
+ this.#triggerEvent("storageUpdated", { ...this.#storage });
194
+ } else {
195
+ if (this.#roomId) {
196
+ this.#triggerEvent("error", "Reconnected, but room no longer exists.");
197
+ return this.destroy();
198
+ }
199
+ }
200
+ this.#triggerEvent("status", "Reconnected.");
201
+ this.#pendingReconnect.resolve();
202
+ }
203
+ break;
204
+
205
+ case 'reconnection_failed':
206
+ if (this.#pendingReconnect) {
207
+ this.#triggerEvent("status", `Reconnection failed: ${message.reason}`);
208
+ this.#pendingReconnect.reject(new Error("Reconnection failed: " + message.reason));
169
209
  }
170
210
  break;
171
211
 
@@ -174,34 +214,32 @@ class PlaySocket {
174
214
  this.#pendingHost.resolve(this.#roomId);
175
215
  this.#triggerEvent("status", `Room created${this.#pendingHost.maxSize ? ` with max size ${this.#pendingHost.maxSize}.` : '.'}`);
176
216
  this.#triggerEvent("storageUpdated", { ...this.#storage });
177
- this.#pendingHost = null;
178
217
  }
179
218
  break;
180
219
 
181
220
  case 'room_creation_failed':
182
221
  if (this.#pendingHost) {
183
- this.#pendingHost.reject(new Error("Failed to create room: " + message.reason || 'Unknown reason'));
184
- this.#triggerEvent("error", "Failed to create room: " + message.reason || 'Unknown reason');
185
- this.#pendingHost = null;
222
+ this.#pendingHost.reject(new Error("Failed to create room: " + message.reason));
223
+ this.#triggerEvent("error", "Failed to create room: " + message.reason);
186
224
  }
187
225
  break;
188
226
 
189
227
  case 'id_taken':
190
- if (this.#pendingInit) {
191
- this.#pendingInit.reject(new Error("This id is already in use."));
228
+ if (this.#pendingRegistration) {
229
+ this.#pendingRegistration.reject(new Error("This id is already in use"));
192
230
  this.#triggerEvent("error", "This id is taken.");
193
- this.#pendingInit = null;
194
231
  }
195
232
  break;
196
233
 
197
234
  case 'registered':
198
- if (this.#pendingInit) {
235
+ if (this.#pendingRegistration) {
199
236
  const roundTripTime = Date.now() - message.clientTime;
200
237
  const serverTimeAtClientNow = message.serverTime + (roundTripTime / 2);
201
238
  this.#serverTimeOffset = serverTimeAtClientNow - Date.now();
202
- this.#pendingInit.resolve();
239
+ this.#sessionToken = message.sessionToken;
240
+ this.#initialized = true;
241
+ this.#pendingRegistration.resolve();
203
242
  this.#triggerEvent("status", "Connected to server.");
204
- this.#pendingInit = null;
205
243
  }
206
244
  break;
207
245
 
@@ -216,22 +254,27 @@ class PlaySocket {
216
254
  }
217
255
  break;
218
256
 
257
+ case 'storage_operation':
258
+ if (message.key) {
259
+ const updatedArray = this.#handleArrayUpdate(message.key, message.operation, message.value, message.updateValue);
260
+ if (JSON.stringify(this.#storage[message.key]) !== JSON.stringify(updatedArray)) {
261
+ this.#storage[message.key] = updatedArray;
262
+ this.#storageTimestamps[message.key] = message.timestamp;
263
+ this.#triggerEvent("storageUpdated", { ...this.#storage });
264
+ }
265
+ }
266
+ break;
267
+
219
268
  case 'client_disconnected':
220
- this.#isHost = this.#id == message.updatedHost;
221
269
  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
- }
270
+ this.#setHost(message.host);
228
271
 
229
272
  this.#triggerEvent("clientDisconnected", message.client);
230
273
  this.#triggerEvent("status", `Client ${message.client} disconnected.`);
231
274
  break;
232
275
 
233
276
  case 'client_connected':
234
- this.#connectionCount = message.participantCount - 1;
277
+ this.#connectionCount = message.participantCount - 1; // Counted without the user themselves
235
278
  this.#triggerEvent("clientConnected", message.client);
236
279
  this.#triggerEvent("status", `Client ${message.client} connected.`);
237
280
  break;
@@ -244,37 +287,69 @@ class PlaySocket {
244
287
  // Handle socket errors
245
288
  this.#socket.onerror = () => {
246
289
  this.#triggerEvent("error", "WebSocket error.");
247
- if (this.#pendingInit) this.#pendingInit.reject(new Error("WebSocket error."));
290
+ if (this.#pendingConnect) this.#pendingConnect.reject(new Error("WebSocket error"));
248
291
  };
249
292
 
250
293
  // Handle socket close & attempt reconnect
251
294
  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);
295
+ if (!this.#initialized || this.#isReconnecting) return;
296
+ this.#triggerEvent("status", "Disconnected.");
297
+ this.#reconnectCount = 0;
298
+ this.#attemptReconnect();
269
299
  };
270
300
  };
271
301
 
302
+ /**
303
+ * Attempt to reconnect with a fixed number of retries
304
+ * @private
305
+ */
306
+ async #attemptReconnect() {
307
+ if (this.#reconnectCount++ >= 3) {
308
+ this.#triggerEvent("error", "Disconnected from server.");
309
+ return this.destroy();
310
+ }
311
+
312
+ this.#triggerEvent("status", `Attempting to reconnect... (${this.#reconnectCount})`);
313
+ this.#isReconnecting = true;
314
+ try {
315
+ await this.#connect();
316
+ await Promise.race([
317
+ new Promise(async (resolve, reject) => {
318
+ this.#pendingReconnect = { resolve, reject };
319
+ this.#sendToServer({
320
+ type: 'reconnect',
321
+ id: this.#id,
322
+ sessionToken: this.#sessionToken
323
+ });
324
+ }),
325
+ this.#createTimeout("Reconnection request")
326
+ ]).finally(() => {
327
+ this.#pendingReconnect = null;
328
+ });
329
+ } catch (error) {
330
+ this.#triggerEvent("status", "Reconnection failed: " + error.message);
331
+ this.#reconnectTimeout = setTimeout(() => this.#attemptReconnect(), 500);
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Update the host in case a new one was chosen
337
+ * @param {string} hostId - Client id of new host
338
+ * @private
339
+ */
340
+ #setHost(hostId) {
341
+ if (this.#roomHost != hostId) {
342
+ this.#roomHost = hostId;
343
+ this.#triggerEvent("hostMigrated", hostId);
344
+ }
345
+ }
346
+
272
347
  /**
273
348
  * Send a message to the server
274
349
  * @private
275
350
  */
276
351
  #sendToServer(data) {
277
- if (!this.#initialized || !this.#socket || this.#socket.readyState !== WebSocket.OPEN) {
352
+ if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN) {
278
353
  return console.error(ERROR_PREFIX + "Cannot send message - not connected.");
279
354
  }
280
355
  try {
@@ -299,7 +374,6 @@ class PlaySocket {
299
374
 
300
375
  return Promise.race([
301
376
  new Promise((resolve, reject) => {
302
- this.#isHost = true;
303
377
  this.#storage = initialStorage;
304
378
  this.#roomId = this.#id; // Create room with your own ID as the room ID to mimic p2p
305
379
  this.#roomHost = this.#id;
@@ -313,7 +387,9 @@ class PlaySocket {
313
387
  });
314
388
  }),
315
389
  this.#createTimeout("Room creation")
316
- ]);
390
+ ]).finally(() => {
391
+ this.#pendingHost = null;
392
+ });
317
393
  }
318
394
 
319
395
  /**
@@ -329,9 +405,7 @@ class PlaySocket {
329
405
 
330
406
  return Promise.race([
331
407
  new Promise((resolve, reject) => {
332
- this.#isHost = false;
333
408
  this.#roomId = roomId;
334
- this.#roomHost = roomId;
335
409
  this.#pendingJoin = { resolve, reject };
336
410
 
337
411
  // Send connection request
@@ -342,7 +416,9 @@ class PlaySocket {
342
416
  });
343
417
  }),
344
418
  this.#createTimeout("Room join")
345
- ]);
419
+ ]).finally(() => {
420
+ this.#pendingJoin = null;
421
+ });
346
422
  }
347
423
 
348
424
  /**
@@ -374,15 +450,17 @@ class PlaySocket {
374
450
  updateStorageArray(key, operation, value, updateValue) {
375
451
  const updatedArray = this.#handleArrayUpdate(key, operation, value, updateValue);
376
452
  if (JSON.stringify(this.#storage[key]) === JSON.stringify(updatedArray)) return; // Prevent updates without changes
453
+ const timestamp = this.#getTimestamp();
377
454
  this.#sendToServer({
378
455
  type: 'room_storage_array_update',
379
456
  key,
380
457
  operation,
381
458
  value,
382
- updateValue
459
+ updateValue,
460
+ timestamp
383
461
  });
384
462
  this.#storage[key] = updatedArray;
385
- this.#storageTimestamps[key] = this.#getTimestamp();
463
+ this.#storageTimestamps[key] = timestamp;
386
464
  this.#triggerEvent("storageUpdated", { ...this.#storage });
387
465
  }
388
466
 
@@ -427,6 +505,8 @@ class PlaySocket {
427
505
  this.#initialized = false; // Set this immediately to prevent automatic reconnection
428
506
 
429
507
  if (this.#socket) {
508
+ // Signal to the server that it can immediately disconnect this user
509
+ if (this.#socket.readyState === WebSocket.OPEN) this.#sendToServer({ type: 'disconnect' });
430
510
  this.#socket.close();
431
511
  this.#socket = null;
432
512
 
@@ -436,19 +516,21 @@ class PlaySocket {
436
516
  }
437
517
 
438
518
  // Reset state
439
- this.#isHost = false;
519
+ clearTimeout(this.#reconnectTimeout);
440
520
  this.#roomHost = null;
441
521
  this.#storage = {};
442
522
  this.#storageTimestamps = {};
523
+ this.#serverTimeOffset = 0;
443
524
  this.#roomId = null;
444
525
  this.#connectionCount = 0;
445
- clearTimeout(this.#reconnectTimeout);
526
+ this.#isReconnecting = false;
527
+ this.#reconnectCount = 0;
446
528
  }
447
529
 
448
530
  // Public getters
449
531
  get connectionCount() { return this.#connectionCount; }
450
532
  get getStorage() { return { ...this.#storage }; }
451
- get isHost() { return this.#isHost; }
533
+ get isHost() { return this.#id == this.#roomHost; }
452
534
  get id() { return this.#id; }
453
535
  }
454
536
 
@@ -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
@@ -176,7 +214,7 @@ function requireServer () {
176
214
  if (data.timestamp > existingTimestamp && JSON.stringify(updateRoom.storage[data.key]) !== JSON.stringify(data.value)) {
177
215
  updateRoom.storage[data.key] = data.value;
178
216
  updateRoom.storageTimestamps[data.key] = data.timestamp;
179
- this.#syncRoomStorageKey(updateRoomId, data.key, data.timestamp);
217
+ this.#syncRoomStorageKey(updateRoomId, ws.clientId, data.key, data.timestamp);
180
218
  }
181
219
  }
182
220
  break;
@@ -185,20 +223,36 @@ function requireServer () {
185
223
  const arrayRoomId = this.#clientRooms.get(ws.clientId);
186
224
  const arrayRoom = arrayRoomId ? this.#rooms[arrayRoomId] : null;
187
225
  if (arrayRoom && data.key) {
226
+ // No timestamp check for array operations as they are designed to work unordered
188
227
  const updatedArray = this.#arrayUpdate(arrayRoom.storage, data.key, data.operation, data.value, data.updateValue);
189
228
  if (JSON.stringify(arrayRoom.storage[data.key]) !== JSON.stringify(updatedArray)) {
190
229
  arrayRoom.storage[data.key] = updatedArray;
191
- arrayRoom.storageTimestamps[data.key] = Date.now(); // New date to ensure that they are applied regardless of age (they don't overwrite – they transform)
192
- this.#syncRoomStorageKey(arrayRoomId, data.key, Date.now());
230
+ arrayRoom.storageTimestamps[data.key] = data.timestamp;
231
+ this.#syncRoomStorageOperation(arrayRoomId, ws.clientId, data.key, data.operation, data.value, data.updateValue, data.timestamp);
193
232
  }
194
233
  }
195
234
  break;
235
+
236
+ case 'disconnect':
237
+ // Client signals to server that it will will willfully disconnect soon
238
+ ws.willfulDisconnect = true;
239
+ break;
196
240
  }
197
241
  } catch (error) {
198
242
  console.error('Error processing message:', error);
199
243
  }
200
244
  }
201
245
 
246
+ /**
247
+ * Generate a random token to prevent malicious reconnect attempts
248
+ * @returns {string} - Token
249
+ */
250
+ #generateSessionToken() {
251
+ let token = '';
252
+ for (let i = 0; i < 16; i++) token += Math.floor(Math.random() * 16).toString(16);
253
+ return token;
254
+ }
255
+
202
256
  /**
203
257
  * Check rate limit using token bucket algorithm
204
258
  * @private
@@ -228,13 +282,18 @@ function requireServer () {
228
282
  }
229
283
 
230
284
  /**
231
- * Sync storage key to all clients in room
285
+ * Sync storage key with all clients in room
232
286
  * @private
287
+ * @param {string} roomId
288
+ * @param {string} requesterId - Client id of whom initiated the operation
289
+ * @param {string} key
290
+ * @param {number} timestamp - In milliseconds
233
291
  */
234
- #syncRoomStorageKey(roomId, key, timestamp) {
292
+ #syncRoomStorageKey(roomId, requesterId, key, timestamp) {
235
293
  const room = this.#rooms[roomId];
236
294
  if (!room) return;
237
295
  room.participants.forEach(p => {
296
+ if (p == requesterId) return; // Don't sync with the requester
238
297
  const client = this.#clients.get(p);
239
298
  if (client) {
240
299
  client.send(JSON.stringify({
@@ -247,6 +306,36 @@ function requireServer () {
247
306
  });
248
307
  }
249
308
 
309
+ /**
310
+ * Sync storage operations with all clients in room
311
+ * @private
312
+ * @param {string} roomId
313
+ * @param {string} requesterId - Client id of whom initiated the operation
314
+ * @param {string} key
315
+ * @param {string} operation
316
+ * @param {*} value
317
+ * @param {*} updateValue
318
+ * @param {number} timestamp - In milliseconds
319
+ */
320
+ #syncRoomStorageOperation(roomId, requesterId, key, operation, value, updateValue, timestamp) {
321
+ const room = this.#rooms[roomId];
322
+ if (!room) return;
323
+ room.participants.forEach(p => {
324
+ if (p == requesterId) return; // Don't send the operation to the requester
325
+ const client = this.#clients.get(p);
326
+ if (client) {
327
+ client.send(JSON.stringify({
328
+ type: 'storage_operation',
329
+ key,
330
+ operation,
331
+ value,
332
+ updateValue,
333
+ timestamp
334
+ }));
335
+ }
336
+ });
337
+ }
338
+
250
339
  /**
251
340
  * Handle array operations
252
341
  * @private
@@ -284,34 +373,50 @@ function requireServer () {
284
373
  */
285
374
  #handleDisconnection(ws) {
286
375
  if (!ws.clientId) return;
376
+ this.#clients.delete(ws.clientId); // Immediately remove from active clients (otherwise, server would try to message this client)
287
377
 
288
- this.#clients.delete(ws.clientId);
289
- this.#rateLimits.delete(ws.clientId);
378
+ if (ws.willfulDisconnect) {
379
+ this.#disconnectClient(ws); // Immediate disconnection
380
+ } else {
381
+ // Pending complete disconnection with 2s grace period to allow for reconnections
382
+ this.#pendingDisconnects.set(ws.clientId, {
383
+ timeout: setTimeout(() => {
384
+ this.#disconnectClient(ws);
385
+ }, 2000)
386
+ });
387
+ }
388
+ }
290
389
 
390
+ /**
391
+ * Disconnect a client
392
+ * @private
393
+ * @param {Object} ws
394
+ */
395
+ #disconnectClient(ws) {
396
+ this.#pendingDisconnects.delete(ws.clientId);
397
+ this.#rateLimits.delete(ws.clientId);
398
+ this.#clientTokens.delete(ws.clientId);
291
399
  const roomId = this.#clientRooms.get(ws.clientId);
292
- const room = roomId ? this.#rooms[roomId] : null;
400
+ const room = this.#rooms[roomId];
293
401
 
294
402
  if (room) {
295
- room.participants = room.participants?.filter(p => p !== ws.clientId);
403
+ room.participants = room.participants.filter(p => p !== ws.clientId); // Remove client from room
296
404
  this.#clientRooms.delete(ws.clientId);
297
405
 
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);
311
- }
312
- }
313
- });
314
- if (room.participants?.length === 0) delete this.#rooms[roomId]; // Delete room if now empty
406
+ if (room.participants?.length === 0) {
407
+ delete this.#rooms[roomId]; // Delete room if now empty
408
+ } else {
409
+ // Notify remaining participants
410
+ room.participants.forEach(p => {
411
+ const client = this.#clients.get(p);
412
+ if (client) client.send(JSON.stringify({
413
+ type: 'client_disconnected',
414
+ client: ws.clientId,
415
+ host: room.participants[0],
416
+ participantCount: room.participants.length
417
+ }));
418
+ });
419
+ }
315
420
  }
316
421
 
317
422
  this.#triggerEvent("clientDisconnected", ws.clientId);
@@ -354,6 +459,9 @@ function requireServer () {
354
459
  console.log('PlaySocket server stopped');
355
460
  });
356
461
  }
462
+ this.#pendingDisconnects.forEach((data) => {
463
+ clearTimeout(data.timeout);
464
+ });
357
465
  }
358
466
 
359
467
  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.9",
4
4
  "description": "WebSocket wrapper for creating simple multiplayer systems with ease.",
5
5
  "module": "dist/playsocket-client.js",
6
6
  "exports": {