playsocketjs 1.0.8 → 1.0.10

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.
@@ -17,7 +17,7 @@ class PlaySocket {
17
17
 
18
18
  // Room properties
19
19
  #storage = {}; // Shared storage object
20
- #storageTimestamps = {}; // Tracks update timestamps per key
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;
@@ -124,7 +124,9 @@ class PlaySocket {
124
124
  });
125
125
  }),
126
126
  this.#createTimeout("Registration")
127
- ]);
127
+ ]).finally(() => {
128
+ this.#pendingRegistration = null;
129
+ });
128
130
  }
129
131
 
130
132
  /**
@@ -141,7 +143,9 @@ class PlaySocket {
141
143
  this.#socket.onopen = resolve;
142
144
  }),
143
145
  this.#createTimeout("Connection attempt")
144
- ]);
146
+ ]).finally(() => {
147
+ this.#pendingConnect = null;
148
+ });
145
149
  }
146
150
 
147
151
  /**
@@ -165,16 +169,14 @@ class PlaySocket {
165
169
  this.#triggerEvent("status", `Connected to room.`);
166
170
  this.#triggerEvent("storageUpdated", { ...this.#storage });
167
171
  this.#pendingJoin.resolve();
168
- this.#pendingJoin = null;
169
172
  }
170
173
  break;
171
174
 
172
175
  case 'join_rejected':
173
176
  // Connection to room failed
174
177
  if (this.#pendingJoin) {
175
- this.#triggerEvent("status", "Connection rejected: " + message.reason || 'Unknown reason');
176
- this.#pendingJoin.reject(new Error("Connection rejected: " + message.reason || 'Unknown reason'));
177
- this.#pendingJoin = null;
178
+ this.#triggerEvent("status", "Connection rejected: " + message.reason);
179
+ this.#pendingJoin.reject(new Error("Connection rejected: " + message.reason));
178
180
  }
179
181
  break;
180
182
 
@@ -197,15 +199,13 @@ class PlaySocket {
197
199
  }
198
200
  this.#triggerEvent("status", "Reconnected.");
199
201
  this.#pendingReconnect.resolve();
200
- this.#pendingReconnect = null;
201
202
  }
202
203
  break;
203
204
 
204
205
  case 'reconnection_failed':
205
206
  if (this.#pendingReconnect) {
206
207
  this.#triggerEvent("status", `Reconnection failed: ${message.reason}`);
207
- this.#pendingReconnect.resolve();
208
- this.#pendingReconnect = null;
208
+ this.#pendingReconnect.reject(new Error("Reconnection failed: " + message.reason));
209
209
  }
210
210
  break;
211
211
 
@@ -214,22 +214,19 @@ class PlaySocket {
214
214
  this.#pendingHost.resolve(this.#roomId);
215
215
  this.#triggerEvent("status", `Room created${this.#pendingHost.maxSize ? ` with max size ${this.#pendingHost.maxSize}.` : '.'}`);
216
216
  this.#triggerEvent("storageUpdated", { ...this.#storage });
217
- this.#pendingHost = null;
218
217
  }
219
218
  break;
220
219
 
221
220
  case 'room_creation_failed':
222
221
  if (this.#pendingHost) {
223
- this.#pendingHost.reject(new Error("Failed to create room: " + message.reason || 'Unknown reason'));
224
- this.#triggerEvent("error", "Failed to create room: " + message.reason || 'Unknown reason');
225
- 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);
226
224
  }
227
225
  break;
228
226
 
229
227
  case 'id_taken':
230
228
  if (this.#pendingRegistration) {
231
229
  this.#pendingRegistration.reject(new Error("This id is already in use"));
232
- this.#pendingRegistration = null;
233
230
  this.#triggerEvent("error", "This id is taken.");
234
231
  }
235
232
  break;
@@ -242,7 +239,6 @@ class PlaySocket {
242
239
  this.#sessionToken = message.sessionToken;
243
240
  this.#initialized = true;
244
241
  this.#pendingRegistration.resolve();
245
- this.#pendingRegistration = null;
246
242
  this.#triggerEvent("status", "Connected to server.");
247
243
  }
248
244
  break;
@@ -316,7 +312,9 @@ class PlaySocket {
316
312
  });
317
313
  }),
318
314
  this.#createTimeout("Reconnection request")
319
- ]);
315
+ ]).finally(() => {
316
+ this.#pendingReconnect = null;
317
+ });
320
318
  } catch (error) {
321
319
  this.#triggerEvent("status", "Reconnection failed: " + error.message);
322
320
  this.#reconnectTimeout = setTimeout(() => this.#attemptReconnect(), 500);
@@ -357,7 +355,7 @@ class PlaySocket {
357
355
  * @param {number} maxSize - Max number of participants
358
356
  * @returns {Promise} Resolves with room ID
359
357
  */
360
- createRoom(initialStorage = {}, maxSize) {
358
+ async createRoom(initialStorage = {}, maxSize) {
361
359
  if (!this.#initialized) {
362
360
  this.#triggerEvent("error", "Cannot create room - not initialized");
363
361
  return Promise.reject(new Error("Not initialized"));
@@ -378,7 +376,9 @@ class PlaySocket {
378
376
  });
379
377
  }),
380
378
  this.#createTimeout("Room creation")
381
- ]);
379
+ ]).finally(() => {
380
+ this.#pendingHost = null;
381
+ });
382
382
  }
383
383
 
384
384
  /**
@@ -386,7 +386,7 @@ class PlaySocket {
386
386
  * @param {string} roomId - ID of the room
387
387
  * @returns {Promise} Resolves when connected
388
388
  */
389
- joinRoom(roomId) {
389
+ async joinRoom(roomId) {
390
390
  if (!this.#initialized) {
391
391
  this.#triggerEvent("error", "Cannot join room - not initialized");
392
392
  return Promise.reject(new Error("Not initialized"));
@@ -405,7 +405,9 @@ class PlaySocket {
405
405
  });
406
406
  }),
407
407
  this.#createTimeout("Room join")
408
- ]);
408
+ ]).finally(() => {
409
+ this.#pendingJoin = null;
410
+ });
409
411
  }
410
412
 
411
413
  /**
@@ -422,6 +424,7 @@ class PlaySocket {
422
424
  value,
423
425
  timestamp
424
426
  });
427
+ // Optimistic update for regular storage updates
425
428
  this.#storage[key] = value;
426
429
  this.#storageTimestamps[key] = timestamp;
427
430
  this.#triggerEvent("storageUpdated", { ...this.#storage });
@@ -435,52 +438,17 @@ class PlaySocket {
435
438
  * @param {*} updateValue - New value for update-matching
436
439
  */
437
440
  updateStorageArray(key, operation, value, updateValue) {
438
- const updatedArray = this.#handleArrayUpdate(key, operation, value, updateValue);
439
- if (JSON.stringify(this.#storage[key]) === JSON.stringify(updatedArray)) return; // Prevent updates without changes
441
+ // Send operation to server (no optimistic update – we can't guarantee that it is correct)
442
+ // If another client has sent an update to the server that is processed first, the end result will differ.
443
+ const timestamp = this.#getTimestamp();
440
444
  this.#sendToServer({
441
445
  type: 'room_storage_array_update',
442
446
  key,
443
447
  operation,
444
448
  value,
445
- updateValue
449
+ updateValue,
450
+ timestamp
446
451
  });
447
- this.#storage[key] = updatedArray;
448
- this.#storageTimestamps[key] = this.#getTimestamp();
449
- this.#triggerEvent("storageUpdated", { ...this.#storage });
450
- }
451
-
452
- /**
453
- * Handle array operations client-side
454
- * @private
455
- */
456
- #handleArrayUpdate(key, operation, value, updateValue) {
457
- let array = (!this.#storage[key] || !Array.isArray(this.#storage[key])) ? [] : [...this.#storage[key]];
458
- const isObject = typeof value === 'object' && value !== null;
459
- const compare = (item) => isObject ? JSON.stringify(item) === JSON.stringify(value) : item === value;
460
-
461
- switch (operation) {
462
- case 'add':
463
- array.push(value);
464
- break;
465
-
466
- case 'add-unique':
467
- if (!array.some(compare)) array.push(value);
468
- break;
469
-
470
- case 'remove-matching':
471
- array = array.filter(item => !compare(item));
472
- break;
473
-
474
- case 'update-matching':
475
- const index = array.findIndex(compare);
476
- if (index !== -1) array[index] = updateValue;
477
- break;
478
-
479
- default:
480
- console.error(ERROR_PREFIX + `Unknown array operation: ${operation}`);
481
- }
482
-
483
- return array;
484
452
  }
485
453
 
486
454
  /**
@@ -490,6 +458,8 @@ class PlaySocket {
490
458
  this.#initialized = false; // Set this immediately to prevent automatic reconnection
491
459
 
492
460
  if (this.#socket) {
461
+ // Signal to the server that it can immediately disconnect this user
462
+ if (this.#socket.readyState === WebSocket.OPEN) this.#sendToServer({ type: 'disconnect' });
493
463
  this.#socket.close();
494
464
  this.#socket = null;
495
465
 
@@ -503,6 +473,7 @@ class PlaySocket {
503
473
  this.#roomHost = null;
504
474
  this.#storage = {};
505
475
  this.#storageTimestamps = {};
476
+ this.#serverTimeOffset = 0;
506
477
  this.#roomId = null;
507
478
  this.#connectionCount = 0;
508
479
  this.#isReconnecting = false;
@@ -214,7 +214,7 @@ function requireServer () {
214
214
  if (data.timestamp > existingTimestamp && JSON.stringify(updateRoom.storage[data.key]) !== JSON.stringify(data.value)) {
215
215
  updateRoom.storage[data.key] = data.value;
216
216
  updateRoom.storageTimestamps[data.key] = data.timestamp;
217
- this.#syncRoomStorageKey(updateRoomId, data.key, data.timestamp);
217
+ this.#syncRoomStorageKey(updateRoom, data.key, data.timestamp);
218
218
  }
219
219
  }
220
220
  break;
@@ -223,14 +223,20 @@ function requireServer () {
223
223
  const arrayRoomId = this.#clientRooms.get(ws.clientId);
224
224
  const arrayRoom = arrayRoomId ? this.#rooms[arrayRoomId] : null;
225
225
  if (arrayRoom && data.key) {
226
+ // No timestamp check for array operations as they are designed to work unordered
226
227
  const updatedArray = this.#arrayUpdate(arrayRoom.storage, data.key, data.operation, data.value, data.updateValue);
227
228
  if (JSON.stringify(arrayRoom.storage[data.key]) !== JSON.stringify(updatedArray)) {
228
229
  arrayRoom.storage[data.key] = updatedArray;
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());
230
+ arrayRoom.storageTimestamps[data.key] = data.timestamp;
231
+ this.#syncRoomStorageKey(arrayRoom, data.key, data.timestamp);
231
232
  }
232
233
  }
233
234
  break;
235
+
236
+ case 'disconnect':
237
+ // Client signals to server that it will will willfully disconnect soon
238
+ ws.willfulDisconnect = true;
239
+ break;
234
240
  }
235
241
  } catch (error) {
236
242
  console.error('Error processing message:', error);
@@ -276,19 +282,20 @@ function requireServer () {
276
282
  }
277
283
 
278
284
  /**
279
- * Sync storage key to all clients in room
285
+ * Sync storage key with all clients in room
280
286
  * @private
287
+ * @param {Object} room
288
+ * @param {string} key
289
+ * @param {number} timestamp - In milliseconds
281
290
  */
282
- #syncRoomStorageKey(roomId, key, timestamp) {
283
- const room = this.#rooms[roomId];
284
- if (!room) return;
285
- room.participants.forEach(p => {
291
+ #syncRoomStorageKey(room, key, timestamp) {
292
+ room?.participants?.forEach(p => {
286
293
  const client = this.#clients.get(p);
287
294
  if (client) {
288
295
  client.send(JSON.stringify({
289
296
  type: 'storage_sync',
290
297
  key,
291
- value: room.storage[key],
298
+ value: room?.storage?.[key],
292
299
  timestamp
293
300
  }));
294
301
  }
@@ -334,38 +341,51 @@ function requireServer () {
334
341
  if (!ws.clientId) return;
335
342
  this.#clients.delete(ws.clientId); // Immediately remove from active clients (otherwise, server would try to message this client)
336
343
 
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
- });
363
- }
364
- }
344
+ if (ws.willfulDisconnect) {
345
+ this.#disconnectClient(ws); // Immediate disconnection
346
+ } else {
347
+ // Pending complete disconnection with 2s grace period to allow for reconnections
348
+ this.#pendingDisconnects.set(ws.clientId, {
349
+ timeout: setTimeout(() => {
350
+ this.#disconnectClient(ws);
351
+ }, 2000)
352
+ });
353
+ }
354
+ }
365
355
 
366
- this.#triggerEvent("clientDisconnected", ws.clientId);
367
- }, 2000)
368
- });
356
+ /**
357
+ * Disconnect a client
358
+ * @private
359
+ * @param {Object} ws
360
+ */
361
+ #disconnectClient(ws) {
362
+ this.#pendingDisconnects.delete(ws.clientId);
363
+ this.#rateLimits.delete(ws.clientId);
364
+ this.#clientTokens.delete(ws.clientId);
365
+ const roomId = this.#clientRooms.get(ws.clientId);
366
+ const room = this.#rooms[roomId];
367
+
368
+ if (room) {
369
+ room.participants = room.participants.filter(p => p !== ws.clientId); // Remove client from room
370
+ this.#clientRooms.delete(ws.clientId);
371
+
372
+ if (room.participants?.length === 0) {
373
+ delete this.#rooms[roomId]; // Delete room if now empty
374
+ } else {
375
+ // Notify remaining participants
376
+ room.participants.forEach(p => {
377
+ const client = this.#clients.get(p);
378
+ if (client) client.send(JSON.stringify({
379
+ type: 'client_disconnected',
380
+ client: ws.clientId,
381
+ host: room.participants[0],
382
+ participantCount: room.participants.length
383
+ }));
384
+ });
385
+ }
386
+ }
387
+
388
+ this.#triggerEvent("clientDisconnected", ws.clientId);
369
389
  }
370
390
 
371
391
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playsocketjs",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
4
4
  "description": "WebSocket wrapper for creating simple multiplayer systems with ease.",
5
5
  "module": "dist/playsocket-client.js",
6
6
  "exports": {