playsocketjs 1.0.12 → 1.0.14

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,9 +17,11 @@ class PlaySocket {
17
17
 
18
18
  // Room properties
19
19
  #storage = {}; // Shared storage object
20
+ #storageTimestamps = {}; // Tracks update timestamps per key to ensure that not-yet synced updates don't get overwritten
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
@@ -59,6 +61,14 @@ class PlaySocket {
59
61
  );
60
62
  }
61
63
 
64
+ /**
65
+ * Get synchronized timestamp
66
+ * @private
67
+ */
68
+ #getTimestamp() {
69
+ return Date.now() + this.#serverTimeOffset;
70
+ }
71
+
62
72
  /**
63
73
  * Register an event callback
64
74
  * @param {string} event - Event name
@@ -109,7 +119,8 @@ class PlaySocket {
109
119
  this.#sendToServer({
110
120
  type: 'register',
111
121
  id: this.#id,
112
- customData: this.#customData || undefined
122
+ customData: this.#customData || undefined,
123
+ clientTime: Date.now()
113
124
  });
114
125
  }),
115
126
  this.#createTimeout("Registration")
@@ -152,6 +163,7 @@ class PlaySocket {
152
163
  // Connected to room
153
164
  if (this.#pendingJoin) {
154
165
  this.#storage = message.storage;
166
+ this.#storageTimestamps = message.storageTimestamps;
155
167
  this.#connectionCount = message.participantCount - 1; // Counted without the user themselves
156
168
  this.#roomHost = message.host;
157
169
  this.#triggerEvent("status", `Connected to room.`);
@@ -175,6 +187,7 @@ class PlaySocket {
175
187
  this.#reconnectCount = 0;
176
188
  if (message.roomData) {
177
189
  this.#storage = message.roomData.storage;
190
+ this.#storageTimestamps = message.roomData.storageTimestamps;
178
191
  this.#connectionCount = message.roomData.participantCount - 1; // Counted without the user themselves
179
192
  this.#setHost(message.roomData.host);
180
193
  this.#triggerEvent("storageUpdated", { ...this.#storage });
@@ -220,6 +233,9 @@ class PlaySocket {
220
233
 
221
234
  case 'registered':
222
235
  if (this.#pendingRegistration) {
236
+ const roundTripTime = Date.now() - message.clientTime;
237
+ const serverTimeAtClientNow = message.serverTime + (roundTripTime / 2);
238
+ this.#serverTimeOffset = serverTimeAtClientNow - Date.now();
223
239
  this.#sessionToken = message.sessionToken;
224
240
  this.#initialized = true;
225
241
  this.#pendingRegistration.resolve();
@@ -229,8 +245,21 @@ class PlaySocket {
229
245
 
230
246
  case 'storage_sync':
231
247
  if (message.key) {
232
- if (JSON.stringify(this.#storage[message.key]) !== JSON.stringify(message.value)) {
248
+ const localTimestamp = this.#storageTimestamps[message.key] || 0;
249
+ if (message.timestamp > localTimestamp && JSON.stringify(this.#storage[message.key]) !== JSON.stringify(message.value)) {
233
250
  this.#storage[message.key] = message.value;
251
+ this.#storageTimestamps[message.key] = message.timestamp;
252
+ this.#triggerEvent("storageUpdated", { ...this.#storage });
253
+ }
254
+ }
255
+ break;
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;
234
263
  this.#triggerEvent("storageUpdated", { ...this.#storage });
235
264
  }
236
265
  }
@@ -399,13 +428,16 @@ class PlaySocket {
399
428
  */
400
429
  updateStorage(key, value) {
401
430
  if (JSON.stringify(this.#storage[key]) === JSON.stringify(value)) return; // Prevent updates without changes
431
+ const timestamp = this.#getTimestamp();
402
432
  this.#sendToServer({
403
433
  type: 'room_storage_update',
404
434
  key,
405
- value
435
+ value,
436
+ timestamp
406
437
  });
407
- // Optimistic update for regular storage updates
438
+ // Optimistic update
408
439
  this.#storage[key] = value;
440
+ this.#storageTimestamps[key] = timestamp;
409
441
  this.#triggerEvent("storageUpdated", { ...this.#storage });
410
442
  }
411
443
 
@@ -417,8 +449,8 @@ class PlaySocket {
417
449
  * @param {*} updateValue - New value for update-matching
418
450
  */
419
451
  updateStorageArray(key, operation, value, updateValue) {
420
- // Send operation to server (no optimistic update – we can't guarantee that it is correct)
421
- // If another client has sent an update to the server that is processed first, the end result will differ.
452
+ const updatedArray = this.#handleArrayUpdate(key, operation, value, updateValue);
453
+ if (JSON.stringify(this.#storage[key]) === JSON.stringify(updatedArray)) return; // Prevent updates without changes
422
454
  this.#sendToServer({
423
455
  type: 'room_storage_array_update',
424
456
  key,
@@ -426,6 +458,44 @@ class PlaySocket {
426
458
  value,
427
459
  updateValue
428
460
  });
461
+ // Optimistic update
462
+ this.#storage[key] = updatedArray;
463
+ this.#storageTimestamps[key] = this.#getTimestamp();
464
+ this.#triggerEvent("storageUpdated", { ...this.#storage });
465
+ }
466
+
467
+ /**
468
+ * Handle array operations client-side
469
+ * @private
470
+ */
471
+ #handleArrayUpdate(key, operation, value, updateValue) {
472
+ let array = (!this.#storage[key] || !Array.isArray(this.#storage[key])) ? [] : [...this.#storage[key]];
473
+ const isObject = typeof value === 'object' && value !== null;
474
+ const compare = (item) => isObject ? JSON.stringify(item) === JSON.stringify(value) : item === value;
475
+
476
+ switch (operation) {
477
+ case 'add':
478
+ array.push(value);
479
+ break;
480
+
481
+ case 'add-unique':
482
+ if (!array.some(compare)) array.push(value);
483
+ break;
484
+
485
+ case 'remove-matching':
486
+ array = array.filter(item => !compare(item));
487
+ break;
488
+
489
+ case 'update-matching':
490
+ const index = array.findIndex(compare);
491
+ if (index !== -1) array[index] = updateValue;
492
+ break;
493
+
494
+ default:
495
+ console.error(ERROR_PREFIX + `Unknown array operation: ${operation}`);
496
+ }
497
+
498
+ return array;
429
499
  }
430
500
 
431
501
  /**
@@ -449,6 +519,8 @@ class PlaySocket {
449
519
  clearTimeout(this.#reconnectTimeout);
450
520
  this.#roomHost = null;
451
521
  this.#storage = {};
522
+ this.#storageTimestamps = {};
523
+ this.#serverTimeOffset = 0;
452
524
  this.#roomId = null;
453
525
  this.#connectionCount = 0;
454
526
  this.#isReconnecting = false;
@@ -457,7 +529,7 @@ class PlaySocket {
457
529
 
458
530
  // Public getters
459
531
  get connectionCount() { return this.#connectionCount; }
460
- get getStorage() { return { ...this.#storage }; }
532
+ get getStorage() { return JSON.parse(JSON.stringify(this.#storage)); }
461
533
  get isHost() { return this.#id == this.#roomHost; }
462
534
  get id() { return this.#id; }
463
535
  }
@@ -102,7 +102,7 @@ function requireServer () {
102
102
  this.#clients.set(data.id, ws);
103
103
  const sessionToken = this.#generateSessionToken();
104
104
  this.#clientTokens.set(data.id, sessionToken); // Token is used when reconnecting to prevent impersonation attacks
105
- ws.send(JSON.stringify({ type: 'registered', sessionToken }));
105
+ ws.send(JSON.stringify({ type: 'registered', sessionToken, serverTime: Date.now(), clientTime: data.clientTime }));
106
106
  this.#triggerEvent("clientRegistered", data.id, data.customData);
107
107
  break;
108
108
 
@@ -127,6 +127,7 @@ function requireServer () {
127
127
  if (formerRoom) {
128
128
  roomData = {
129
129
  storage: formerRoom.storage,
130
+ storageTimestamps: formerRoom.storageTimestamps,
130
131
  participantCount: formerRoom.participants.length,
131
132
  host: formerRoom.participants[0]
132
133
  };
@@ -152,7 +153,8 @@ function requireServer () {
152
153
  this.#rooms[newRoomId] = {
153
154
  participants: [ws.clientId],
154
155
  maxSize: data.size || null,
155
- storage: data.storage || {}
156
+ storage: data.storage || {},
157
+ storageTimestamps: {},
156
158
  };
157
159
 
158
160
  this.#clientRooms.set(ws.clientId, newRoomId);
@@ -184,6 +186,7 @@ function requireServer () {
184
186
  ws.send(JSON.stringify({
185
187
  type: 'join_accepted',
186
188
  storage: room.storage,
189
+ storageTimestamps: room.storageTimestamps,
187
190
  participantCount: room.participants.length,
188
191
  host: room.participants[0]
189
192
  }));
@@ -207,9 +210,11 @@ function requireServer () {
207
210
  const updateRoomId = this.#clientRooms.get(ws.clientId);
208
211
  const updateRoom = updateRoomId ? this.#rooms[updateRoomId] : null;
209
212
  if (updateRoom && data.key) {
210
- if (JSON.stringify(updateRoom.storage[data.key]) !== JSON.stringify(data.value)) {
213
+ const existingTimestamp = updateRoom.storageTimestamps[data.key] || 0;
214
+ if (data.timestamp > existingTimestamp && JSON.stringify(updateRoom.storage[data.key]) !== JSON.stringify(data.value)) {
211
215
  updateRoom.storage[data.key] = data.value;
212
- this.#syncRoomStorageKey(updateRoom, data.key, ws.clientId);
216
+ updateRoom.storageTimestamps[data.key] = data.timestamp;
217
+ this.#syncRoomStorageKey(updateRoom, data.key, data.timestamp);
213
218
  }
214
219
  }
215
220
  break;
@@ -218,10 +223,12 @@ function requireServer () {
218
223
  const arrayRoomId = this.#clientRooms.get(ws.clientId);
219
224
  const arrayRoom = arrayRoomId ? this.#rooms[arrayRoomId] : null;
220
225
  if (arrayRoom && data.key) {
226
+ // No timestamp check for array operations as they are designed to work unordered
221
227
  const updatedArray = this.#arrayUpdate(arrayRoom.storage, data.key, data.operation, data.value, data.updateValue);
222
228
  if (JSON.stringify(arrayRoom.storage[data.key]) !== JSON.stringify(updatedArray)) {
223
229
  arrayRoom.storage[data.key] = updatedArray;
224
- this.#syncRoomStorageKey(arrayRoom, data.key);
230
+ arrayRoom.storageTimestamps[data.key] = Date.now(); // Assign new date to ensure that this update is always being applied
231
+ this.#syncRoomStorageOperation(arrayRoom, ws.clientId, data.key, data.operation, data.value, data.updateValue, Date.now());
225
232
  }
226
233
  }
227
234
  break;
@@ -277,19 +284,48 @@ function requireServer () {
277
284
  /**
278
285
  * Sync storage key with all clients in room
279
286
  * @private
280
- * @param {Object} room
287
+ * @param {string} room
288
+ * @param {string} requesterId - Client id of whom initiated the operation
281
289
  * @param {string} key
282
- * @param {string} exclude
290
+ * @param {number} timestamp - In milliseconds
283
291
  */
284
- #syncRoomStorageKey(room, key, exclude) {
292
+ #syncRoomStorageKey(room, key, timestamp) {
285
293
  room?.participants?.forEach(p => {
286
- if (p === exclude) return;
287
294
  const client = this.#clients.get(p);
288
295
  if (client) {
289
296
  client.send(JSON.stringify({
290
297
  type: 'storage_sync',
291
298
  key,
292
- value: room?.storage?.[key]
299
+ value: room?.storage[key],
300
+ timestamp
301
+ }));
302
+ }
303
+ });
304
+ }
305
+
306
+ /**
307
+ * Sync storage operations with all clients in room
308
+ * @private
309
+ * @param {string} room
310
+ * @param {string} requesterId - Client id of whom initiated the operation
311
+ * @param {string} key
312
+ * @param {string} operation
313
+ * @param {*} value
314
+ * @param {*} updateValue
315
+ * @param {number} timestamp - In milliseconds
316
+ */
317
+ #syncRoomStorageOperation(room, requesterId, key, operation, value, updateValue, timestamp) {
318
+ room?.participants?.forEach(p => {
319
+ if (p == requesterId) return; // Don't send the operation to the requester
320
+ const client = this.#clients.get(p);
321
+ if (client) {
322
+ client.send(JSON.stringify({
323
+ type: 'storage_operation',
324
+ key,
325
+ operation,
326
+ value,
327
+ updateValue,
328
+ timestamp
293
329
  }));
294
330
  }
295
331
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playsocketjs",
3
- "version": "1.0.12",
3
+ "version": "1.0.14",
4
4
  "description": "WebSocket wrapper for creating simple multiplayer systems with ease.",
5
5
  "module": "dist/playsocket-client.js",
6
6
  "exports": {