playsocketjs 1.1.0 → 1.1.1

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.
package/README.md CHANGED
@@ -71,6 +71,7 @@ Creates a new PlaySocket instance with a specified ID and configuration options.
71
71
  #### Configuration options
72
72
  - `endpoint`: WebSocket server endpoint (e.g., 'wss://example.com/socket')
73
73
  - `customData`: You can pass arbitrary data to the "clientRegistered" server event (optional)
74
+ - `debug`: Set this property to true to enable extra logging
74
75
 
75
76
  ### Methods
76
77
 
@@ -192,6 +193,7 @@ Creates a new PlaySocket Server instance with configuration options.
192
193
  - `port`: Port to listen on (default: 3000, used only if no server provided)
193
194
  - `path`: WebSocket endpoint path (default: '/')
194
195
  - `server`: Existing http server (optional)
196
+ - `debug`: Set this property to true to enable extra logging
195
197
 
196
198
  ### Methods
197
199
 
@@ -1693,8 +1693,6 @@ function requireCrdtManager () {
1693
1693
  // Storage
1694
1694
  #replicaId;
1695
1695
  #keyOperations = new Map();
1696
-
1697
- // Vector clock
1698
1696
  #vectorClock = new Map();
1699
1697
 
1700
1698
  // Local only
@@ -1703,14 +1701,19 @@ function requireCrdtManager () {
1703
1701
 
1704
1702
  // Local Garbage Collection
1705
1703
  #lastGCCheck = 0;
1706
- #opUuuidTimestamp = new Map(); // Map every operation to a timestamp for garbage collection
1704
+ #opUuidTimestamp = new Map(); // Map every operation to a timestamp for garbage collection
1705
+
1706
+ // Debug
1707
+ #debug = false;
1707
1708
 
1708
1709
  /**
1709
1710
  * Create a new instance
1710
- * @param {string} [replicaId] - Choose a uuid (falls back to random uuid)
1711
+ * @param {string} replicaId - Choose a uuid (falls back to random uuid)
1711
1712
  */
1712
- constructor(replicaId) {
1713
- this.#replicaId = replicaId || crypto.randomUUID();
1713
+ constructor(replicaId, debug) {
1714
+ if (!replicaId) return console.error("No replicaId provided!");
1715
+ if (debug) this.#debug = true;
1716
+ this.#replicaId = replicaId;
1714
1717
  this.#vectorClock.set(this.#replicaId, 0);
1715
1718
  }
1716
1719
 
@@ -1721,8 +1724,13 @@ function requireCrdtManager () {
1721
1724
  importState(state) {
1722
1725
  try {
1723
1726
  const { keyOperations, vectorClock } = state;
1727
+ if (this.#debug) console.log(CONSOLE_PREFIX + "Importing state:", state);
1728
+
1729
+ // Resets
1730
+ this.#opUuidTimestamp.clear();
1731
+ this.#propertyStore = {};
1732
+ this.#lastPropertyStore = {};
1724
1733
 
1725
- this.#opUuuidTimestamp.clear(); // Reset the uuid timestamps since it might contain uuid's that no longer exist in the new state
1726
1734
  this.#keyOperations = new Map(keyOperations); // Rebuild the map
1727
1735
  this.#vectorClock = new Map(vectorClock); // Also rebuild the map here
1728
1736
  if (!this.#vectorClock.has(this.#replicaId)) this.#vectorClock.set(this.#replicaId, 0); // Reset own vector clock if it wasn't present in the imported state
@@ -1730,7 +1738,7 @@ function requireCrdtManager () {
1730
1738
  // Map operation uuids to timestamp
1731
1739
  this.#keyOperations.forEach((value, key) => {
1732
1740
  value?.forEach((operation) => {
1733
- this.#opUuuidTimestamp.set(operation?.uuid, Date.now());
1741
+ this.#opUuidTimestamp.set(operation?.uuid, Date.now());
1734
1742
  });
1735
1743
  });
1736
1744
 
@@ -1743,72 +1751,38 @@ function requireCrdtManager () {
1743
1751
  }
1744
1752
 
1745
1753
  /**
1746
- * Export only the last operation of a property for a single key
1747
- * This can still be imported using importProperty
1748
- * @param {string} key
1749
- * @returns {Object} - Data export
1750
- */
1751
- exportPropertyLastOpOnly(key) {
1752
- const lastOp = this.#keyOperations.get(key)?.[this.#keyOperations.get(key)?.length - 1];
1753
- return {
1754
- key,
1755
- operations: lastOp ? [lastOp] : [],
1756
- vectorClock: Array.from(this.#vectorClock.entries())
1757
- };
1758
- }
1759
-
1760
- /**
1761
- * Export property for a single key
1762
- * @param {string} key
1763
- * @returns {Object} - Data export
1764
- */
1765
- exportProperty(key) {
1766
- return {
1767
- key,
1768
- operations: [...(this.#keyOperations.get(key) || [])],
1769
- vectorClock: Array.from(this.#vectorClock.entries())
1770
- };
1771
- }
1772
-
1773
- /**
1774
- * Import property for a single key and merge changes
1754
+ * Import property update
1775
1755
  * @param {Object} data - Data to import
1776
1756
  */
1777
- importProperty(data) {
1757
+ importPropertyUpdate(data) {
1778
1758
  try {
1779
- const { key, operations, vectorClock } = data;
1759
+ const { key, operation, vectorClock } = data;
1760
+ if (this.#debug) console.log(CONSOLE_PREFIX + "Importing update:", data); // Debug
1780
1761
 
1781
1762
  // Get COPY of current ops (or empty array if none yet)
1782
1763
  const currentOps = [...(this.#keyOperations.get(key) || [])];
1783
1764
 
1784
1765
  // Merge vector clocks (always take max value)
1785
- if (vectorClock) {
1786
- for (const [id, counter] of vectorClock) {
1787
- if (!this.#vectorClock.has(id) || this.#vectorClock.get(id) < counter) {
1788
- this.#vectorClock.set(id, counter);
1789
- }
1766
+ for (const [id, counter] of vectorClock) {
1767
+ if (!this.#vectorClock.has(id) || this.#vectorClock.get(id) < counter) {
1768
+ this.#vectorClock.set(id, counter);
1790
1769
  }
1791
1770
  }
1792
1771
 
1793
- // Safeguard against attack where new clients would rapidly connect and disconnect to fill up the vectorClock
1772
+ // Saveguard
1794
1773
  if (this.#vectorClock.size > 1000) this.#vectorClock = new Map([...this.#vectorClock].slice(-100));
1795
1774
 
1796
- // Add new operations (diff) in correct order
1775
+ // Add new operation if it's not already added
1797
1776
  const existingUuids = new Set(currentOps.map(op => op.uuid));
1798
- if (operations?.length) {
1799
- for (const op of operations) {
1800
- if (op?.uuid && !existingUuids.has(op.uuid)) {
1801
- currentOps.push({ ...op }); // Add operation
1802
- this.#opUuuidTimestamp.set(op?.uuid, Date.now()); // Add timestamp for gc
1803
- }
1804
- }
1777
+ if (operation?.uuid && !existingUuids.has(operation.uuid)) {
1778
+ currentOps.push({ ...operation }); // Add operation
1779
+ this.#opUuidTimestamp.set(operation?.uuid, Date.now()); // Add timestamp for gc
1805
1780
  }
1806
1781
 
1807
1782
  // Sort, update operations & local value
1808
1783
  this.#keyOperations.set(key, this.#sortByVectorClock(currentOps));
1809
1784
  this.#processLocalProperty(key);
1810
-
1811
- this.#checkGarbageCollection(); // Check if GC should run
1785
+ this.#checkGarbageCollection();
1812
1786
 
1813
1787
  } catch (error) {
1814
1788
  console.error(CONSOLE_PREFIX + "Failed to import property:", error);
@@ -1821,9 +1795,13 @@ function requireCrdtManager () {
1821
1795
  * @param {string} operation
1822
1796
  * @param {*} value
1823
1797
  * @param {*} updateValue
1798
+ * @returns {Object} - Returns the property update
1824
1799
  */
1825
1800
  updateProperty(key, operation, value, updateValue) {
1826
1801
  try {
1802
+ // Debug log
1803
+ if (this.#debug) console.log(CONSOLE_PREFIX + `Updating property with key ${key}, operation ${operation}, value ${value} and updateValue ${updateValue}.`);
1804
+
1827
1805
  // Increment vector clock
1828
1806
  const counter = this.#vectorClock.get(this.#replicaId) || 0;
1829
1807
  this.#vectorClock.set(this.#replicaId, counter + 1);
@@ -1834,13 +1812,16 @@ function requireCrdtManager () {
1834
1812
  // Add operation
1835
1813
  const newOp = this.#createOperation({ operation, value, updateValue }, Array.from(this.#vectorClock.entries()));
1836
1814
  currentOps.push(newOp);
1815
+ this.#keyOperations.set(key, currentOps); // Update the operations (no need to sort via vector clock since local updates are always the latest)
1816
+ this.#processLocalProperty(key); // Process local value
1817
+ this.#checkGarbageCollection();
1837
1818
 
1838
- // Sort and process
1839
- this.#keyOperations.set(key, this.#sortByVectorClock(currentOps));
1840
- this.#opUuuidTimestamp.set(newOp.uuid, Date.now());
1841
- this.#processLocalProperty(key);
1842
-
1843
- this.#checkGarbageCollection(); // Check if GC should run
1819
+ // Return the property update with the new operation (this can be imported using importPropertyUpdate)
1820
+ return {
1821
+ key,
1822
+ operation: { ...newOp },
1823
+ vectorClock: Array.from(this.#vectorClock.entries())
1824
+ };
1844
1825
 
1845
1826
  } catch (error) {
1846
1827
  console.error(CONSOLE_PREFIX + `Failed to add operation for key "${key}":`, error);
@@ -1859,15 +1840,16 @@ function requireCrdtManager () {
1859
1840
 
1860
1841
  let retainCount = operations.length;
1861
1842
  operations.forEach((op, index) => {
1862
- // Count how many ops, from last to latest, are >10s old in a row
1863
- if (op.uuid && this.#opUuuidTimestamp.has(op.uuid) && (Date.now() - this.#opUuuidTimestamp.get(op.uuid)) > MIN_AGE_FOR_GC) {
1843
+ // Count how many ops, from last to latest, are older than the min-age in a row
1844
+ if (op.uuid && this.#opUuidTimestamp.has(op.uuid) && (Date.now() - this.#opUuidTimestamp.get(op.uuid)) > MIN_AGE_FOR_GC) {
1864
1845
  const removeCount = index + 1;
1865
1846
  retainCount = operations.length - removeCount;
1866
- this.#opUuuidTimestamp.delete(op.uuid); // Remove from timestamps if set to be deleted
1847
+ this.#opUuidTimestamp.delete(op.uuid); // Remove from timestamps if set to be deleted
1867
1848
  }
1868
1849
  });
1869
1850
 
1870
1851
  if (retainCount < operations.length) {
1852
+ if (this.#debug) console.log(CONSOLE_PREFIX + `Running garbage collection for key ${key} with current operations:`, operations);
1871
1853
  const retainOps = operations.slice(-retainCount); // newest ops
1872
1854
  const removeOps = operations.slice(0, -retainCount); // oldest ops (start = idx 0, end = retainCount counted from right side)
1873
1855
  const baselineVectorClock = removeOps[removeOps.length - 1]?.vectorClock || []; // Use the vector clock from the last operation that we remove/overwrite
@@ -1888,6 +1870,9 @@ function requireCrdtManager () {
1888
1870
  // Create a compact operation with baseline value and appropriate vector clock
1889
1871
  const compactOp = this.#createOperation({ operation: "set", value: baselineValue }, baselineVectorClock);
1890
1872
 
1873
+ // Debug log
1874
+ if (this.#debug) console.log(CONSOLE_PREFIX + "Operations after garbage collection for this key:", [compactOp, ...retainOps]);
1875
+
1891
1876
  // Combine compact op with retained operations
1892
1877
  this.#keyOperations.set(key, [compactOp, ...retainOps]);
1893
1878
  }
@@ -1907,7 +1892,7 @@ function requireCrdtManager () {
1907
1892
  source: this.#replicaId,
1908
1893
  uuid: crypto.randomUUID()
1909
1894
  };
1910
- this.#opUuuidTimestamp.set(newOperation.uuid, Date.now());
1895
+ this.#opUuidTimestamp.set(newOperation.uuid, Date.now());
1911
1896
  return newOperation;
1912
1897
  }
1913
1898
 
@@ -2062,7 +2047,8 @@ var crdtManagerExports = requireCrdtManager();
2062
2047
 
2063
2048
  const ERROR_PREFIX = "PlaySocket error: ";
2064
2049
  const WARNING_PREFIX = "PlaySocket warning: ";
2065
- const TIMEOUT_MS = 5000; // 5 second timeout for operations
2050
+ const LOG_PREFIX = "PlaySocket log: ";
2051
+ const TIMEOUT_MS = 3000; // 3 second timeout for WS messages
2066
2052
 
2067
2053
  class PlaySocket {
2068
2054
  // Core properties
@@ -2078,22 +2064,28 @@ class PlaySocket {
2078
2064
  #roomId; // ID of the host (client only)
2079
2065
  #connectionCount = 0;
2080
2066
  #crdtManager;
2067
+ #roomVersion = 0; // Update version (used to compare local vs. remote state to detect package loss)
2068
+ #sentUpdates = new Set(); // Contains update uuids
2081
2069
 
2082
2070
  // Event handling
2083
2071
  #callbacks = new Map(); // Event callbacks
2084
2072
 
2085
2073
  // Async server operations
2086
2074
  #pendingJoin;
2087
- #pendingHost;
2075
+ #pendingCreate;
2088
2076
  #pendingRegistration;
2089
2077
  #pendingConnect;
2090
2078
  #pendingReconnect;
2079
+ #pendingStateRequest;
2091
2080
 
2092
2081
  // Timeouts or intervals
2093
2082
  #reconnectTimeout;
2094
2083
  #reconnectCount = 0;
2095
2084
  #isReconnecting = false;
2096
2085
 
2086
+ // Debug
2087
+ #debug = false;
2088
+
2097
2089
  /**
2098
2090
  * Create a new PlaySocket instance
2099
2091
  * @param {string} id - Unique identifier for this client
@@ -2105,7 +2097,8 @@ class PlaySocket {
2105
2097
  this.#id = id;
2106
2098
  if (options.endpoint) this.#endpoint = options.endpoint;
2107
2099
  if (options.customData) this.#customData = { ...options.customData };
2108
- this.#crdtManager = new crdtManagerExports.CRDTManager(this.#id);
2100
+ if (options.debug) this.#debug = true; // Enabling extra logging
2101
+ this.#crdtManager = new crdtManagerExports.CRDTManager(this.#id, this.#debug);
2109
2102
  }
2110
2103
 
2111
2104
  /**
@@ -2210,9 +2203,11 @@ class PlaySocket {
2210
2203
  case 'join_accepted':
2211
2204
  // Connected to room
2212
2205
  if (this.#pendingJoin) {
2206
+ if (this.#debug) console.log(LOG_PREFIX + "State received for join:", message.state);
2213
2207
  this.#crdtManager.importState(message.state);
2214
2208
  this.#connectionCount = message.participantCount - 1; // Counted without the user themselves
2215
2209
  this.#roomHost = message.host;
2210
+ this.#roomVersion = message.version;
2216
2211
  this.#triggerEvent("storageUpdated", this.getStorage);
2217
2212
  this.#triggerEvent("status", `Connected to room.`);
2218
2213
  this.#pendingJoin.resolve();
@@ -2233,7 +2228,9 @@ class PlaySocket {
2233
2228
  this.#isReconnecting = false;
2234
2229
  this.#reconnectCount = 0;
2235
2230
  if (message.roomData) {
2231
+ if (this.#debug) console.log(LOG_PREFIX + "State received for reconnect:", message.roomData.state);
2236
2232
  this.#crdtManager.importState(message.roomData.state);
2233
+ this.#roomVersion = message.roomData.version;
2237
2234
  this.#connectionCount = message.roomData.participantCount - 1; // Counted without the user themselves
2238
2235
  this.#setHost(message.roomData.host); // Set host before in case there are .isHost checks in the storageUpdate fallback
2239
2236
  this.#triggerEvent("storageUpdated", this.getStorage);
@@ -2255,16 +2252,16 @@ class PlaySocket {
2255
2252
  break;
2256
2253
 
2257
2254
  case 'room_created':
2258
- if (this.#pendingHost) {
2259
- this.#pendingHost.resolve(this.#roomId);
2260
- this.#triggerEvent("status", `Room created${this.#pendingHost.maxSize ? ` with max size ${this.#pendingHost.maxSize}.` : '.'}`);
2255
+ if (this.#pendingCreate) {
2256
+ this.#triggerEvent("status", `Room created${this.#pendingCreate.maxSize ? ` with max size ${this.#pendingCreate.maxSize}.` : '.'}`);
2257
+ this.#pendingCreate.resolve(this.#roomId);
2261
2258
  }
2262
2259
  break;
2263
2260
 
2264
2261
  case 'room_creation_failed':
2265
- if (this.#pendingHost) {
2266
- this.#pendingHost.reject(new Error("Failed to create room: " + message.reason));
2262
+ if (this.#pendingCreate) {
2267
2263
  this.#triggerEvent("error", "Failed to create room: " + message.reason);
2264
+ this.#pendingCreate.reject(new Error("Failed to create room: " + message.reason));
2268
2265
  }
2269
2266
  break;
2270
2267
 
@@ -2284,9 +2281,27 @@ class PlaySocket {
2284
2281
  }
2285
2282
  break;
2286
2283
 
2287
- case 'property_sync':
2288
- if (message.property) this.#crdtManager.importProperty(message.property);
2284
+ case 'state_sync':
2285
+ if (this.#pendingStateRequest) {
2286
+ if (this.#debug) console.log(LOG_PREFIX + "State received following state request:", message.state);
2287
+ this.#roomVersion = message.version;
2288
+ this.#crdtManager.importState(message.state);
2289
+ this.#triggerEvent("storageUpdated", this.getStorage);
2290
+ this.#triggerEvent("status", "State synchronized.");
2291
+ this.#pendingStateRequest.resolve();
2292
+ }
2293
+ break;
2294
+
2295
+ case 'property_update':
2296
+ this.#roomVersion++; // Increment room version
2297
+ if (this.#debug) console.log(LOG_PREFIX + "Property update received:", message.update);
2298
+ this.#sentUpdates.delete(message.uuid); // If this is our sent update, delete it so that timeout check knows it was received
2299
+ this.#crdtManager.importPropertyUpdate(message.update);
2289
2300
  if (this.#crdtManager.didPropertiesChange) this.#triggerEvent("storageUpdated", this.getStorage);
2301
+ if (this.#roomVersion != message.version) {
2302
+ this.#triggerEvent("error", "Detected missing update – requesting full state.");
2303
+ this.#requestStateSync();
2304
+ }
2290
2305
  break;
2291
2306
 
2292
2307
  case 'client_disconnected':
@@ -2350,11 +2365,34 @@ class PlaySocket {
2350
2365
  this.#pendingReconnect = null;
2351
2366
  });
2352
2367
  } catch (error) {
2368
+ if (!this.#initialized) return;
2353
2369
  this.#triggerEvent("status", "Reconnection failed: " + error.message);
2354
2370
  this.#reconnectTimeout = setTimeout(() => this.#attemptReconnect(), 500);
2355
2371
  }
2356
2372
  }
2357
2373
 
2374
+ /**
2375
+ * If the local state is out of sync, request the room state
2376
+ */
2377
+ async #requestStateSync() {
2378
+ if (this.#pendingStateRequest) return; // Return if already requested
2379
+ try {
2380
+ Promise.race([
2381
+ new Promise(async (resolve) => {
2382
+ this.#pendingStateRequest = { resolve };
2383
+ this.#sendToServer({ type: 'request_state' });
2384
+ }),
2385
+ this.#createTimeout("State request")
2386
+ ]).finally(() => {
2387
+ this.#pendingStateRequest = null;
2388
+ });
2389
+ } catch (error) {
2390
+ if (!this.#initialized) return;
2391
+ this.#triggerEvent("error", "Failed to re-sync state: " + error.message);
2392
+ this.destroy();
2393
+ }
2394
+ }
2395
+
2358
2396
  /**
2359
2397
  * Update the host in case a new one was chosen
2360
2398
  * @param {string} hostId - Client id of new host
@@ -2402,7 +2440,7 @@ class PlaySocket {
2402
2440
  });
2403
2441
  this.#roomId = this.#id; // Create room with your own ID as the room ID to mimic p2p
2404
2442
  this.#roomHost = this.#id;
2405
- this.#pendingHost = { maxSize, resolve, reject };
2443
+ this.#pendingCreate = { maxSize, resolve, reject };
2406
2444
  this.#sendToServer({
2407
2445
  type: 'create_room',
2408
2446
  state: this.#crdtManager.getState,
@@ -2412,7 +2450,7 @@ class PlaySocket {
2412
2450
  }),
2413
2451
  this.#createTimeout("Room creation")
2414
2452
  ]).finally(() => {
2415
- this.#pendingHost = null;
2453
+ this.#pendingCreate = null;
2416
2454
  });
2417
2455
  }
2418
2456
 
@@ -2451,12 +2489,22 @@ class PlaySocket {
2451
2489
  * @param {*} value - New value
2452
2490
  */
2453
2491
  updateStorage(key, value) {
2454
- this.#crdtManager.updateProperty(key, "set", value);
2492
+ if (this.#debug) console.log(LOG_PREFIX + "Property set update for key '" + key + "':", value);
2493
+ const propUpdate = this.#crdtManager.updateProperty(key, "set", value);
2494
+ const updateUuid = crypto.randomUUID();
2495
+ this.#sentUpdates.add(updateUuid);
2455
2496
  this.#sendToServer({
2456
2497
  type: 'property_update',
2457
2498
  key,
2458
- property: this.#crdtManager.exportPropertyLastOpOnly(key)
2499
+ update: propUpdate,
2500
+ uuid: updateUuid
2459
2501
  });
2502
+ setTimeout(() => {
2503
+ if (this.#initialized && this.#sentUpdates.has(updateUuid)) {
2504
+ this.#triggerEvent("error", "Sent update did not go through – requesting state.");
2505
+ this.#requestStateSync();
2506
+ }
2507
+ }, TIMEOUT_MS);
2460
2508
  if (this.#crdtManager.didPropertiesChange) this.#triggerEvent("storageUpdated", this.getStorage); // Always trigger callback after send in case msgs are sent in the callback (which would break the order)
2461
2509
  }
2462
2510
 
@@ -2468,12 +2516,22 @@ class PlaySocket {
2468
2516
  * @param {*} updateValue - New value for update-matching
2469
2517
  */
2470
2518
  updateStorageArray(key, operation, value, updateValue) {
2471
- this.#crdtManager.updateProperty(key, "array-" + operation, value, updateValue);
2519
+ if (this.#debug) console.log(LOG_PREFIX + `Property array update for key '${key}', operation '${operation}', value '${value}' and updateValue '${updateValue}'.`);
2520
+ const propUpdate = this.#crdtManager.updateProperty(key, "array-" + operation, value, updateValue);
2521
+ const updateUuid = crypto.randomUUID();
2522
+ this.#sentUpdates.add(updateUuid);
2472
2523
  this.#sendToServer({
2473
2524
  type: 'property_update',
2474
2525
  key,
2475
- property: this.#crdtManager.exportPropertyLastOpOnly(key)
2526
+ update: propUpdate,
2527
+ uuid: updateUuid
2476
2528
  });
2529
+ setTimeout(() => {
2530
+ if (this.#initialized && this.#sentUpdates.has(updateUuid)) {
2531
+ this.#triggerEvent("error", "Sent update did not go through – requesting state.");
2532
+ this.#requestStateSync();
2533
+ }
2534
+ }, TIMEOUT_MS);
2477
2535
  if (this.#crdtManager.didPropertiesChange) this.#triggerEvent("storageUpdated", this.getStorage);
2478
2536
  }
2479
2537
 
@@ -2501,13 +2559,16 @@ class PlaySocket {
2501
2559
  this.#connectionCount = 0;
2502
2560
  this.#isReconnecting = false;
2503
2561
  this.#reconnectCount = 0;
2562
+ this.#roomVersion = 0;
2563
+ this.#sentUpdates.clear();
2504
2564
 
2505
2565
  // Reject timeouts if currently active
2506
2566
  if (this.#pendingJoin) this.#pendingJoin.reject();
2507
- if (this.#pendingHost) this.#pendingHost.reject();
2567
+ if (this.#pendingCreate) this.#pendingCreate.reject();
2508
2568
  if (this.#pendingRegistration) this.#pendingRegistration.reject();
2509
2569
  if (this.#pendingConnect) this.#pendingConnect.reject();
2510
2570
  if (this.#pendingReconnect) this.#pendingReconnect.reject();
2571
+ if (this.#pendingStateRequest) this.#pendingStateRequest.reject();
2511
2572
  }
2512
2573
 
2513
2574
  // Public getters
@@ -1819,8 +1819,6 @@ function requireCrdtManager () {
1819
1819
  // Storage
1820
1820
  #replicaId;
1821
1821
  #keyOperations = new Map();
1822
-
1823
- // Vector clock
1824
1822
  #vectorClock = new Map();
1825
1823
 
1826
1824
  // Local only
@@ -1829,14 +1827,19 @@ function requireCrdtManager () {
1829
1827
 
1830
1828
  // Local Garbage Collection
1831
1829
  #lastGCCheck = 0;
1832
- #opUuuidTimestamp = new Map(); // Map every operation to a timestamp for garbage collection
1830
+ #opUuidTimestamp = new Map(); // Map every operation to a timestamp for garbage collection
1831
+
1832
+ // Debug
1833
+ #debug = false;
1833
1834
 
1834
1835
  /**
1835
1836
  * Create a new instance
1836
- * @param {string} [replicaId] - Choose a uuid (falls back to random uuid)
1837
+ * @param {string} replicaId - Choose a uuid (falls back to random uuid)
1837
1838
  */
1838
- constructor(replicaId) {
1839
- this.#replicaId = replicaId || crypto.randomUUID();
1839
+ constructor(replicaId, debug) {
1840
+ if (!replicaId) return console.error("No replicaId provided!");
1841
+ if (debug) this.#debug = true;
1842
+ this.#replicaId = replicaId;
1840
1843
  this.#vectorClock.set(this.#replicaId, 0);
1841
1844
  }
1842
1845
 
@@ -1847,8 +1850,13 @@ function requireCrdtManager () {
1847
1850
  importState(state) {
1848
1851
  try {
1849
1852
  const { keyOperations, vectorClock } = state;
1853
+ if (this.#debug) console.log(CONSOLE_PREFIX + "Importing state:", state);
1854
+
1855
+ // Resets
1856
+ this.#opUuidTimestamp.clear();
1857
+ this.#propertyStore = {};
1858
+ this.#lastPropertyStore = {};
1850
1859
 
1851
- this.#opUuuidTimestamp.clear(); // Reset the uuid timestamps since it might contain uuid's that no longer exist in the new state
1852
1860
  this.#keyOperations = new Map(keyOperations); // Rebuild the map
1853
1861
  this.#vectorClock = new Map(vectorClock); // Also rebuild the map here
1854
1862
  if (!this.#vectorClock.has(this.#replicaId)) this.#vectorClock.set(this.#replicaId, 0); // Reset own vector clock if it wasn't present in the imported state
@@ -1856,7 +1864,7 @@ function requireCrdtManager () {
1856
1864
  // Map operation uuids to timestamp
1857
1865
  this.#keyOperations.forEach((value, key) => {
1858
1866
  value?.forEach((operation) => {
1859
- this.#opUuuidTimestamp.set(operation?.uuid, Date.now());
1867
+ this.#opUuidTimestamp.set(operation?.uuid, Date.now());
1860
1868
  });
1861
1869
  });
1862
1870
 
@@ -1869,72 +1877,38 @@ function requireCrdtManager () {
1869
1877
  }
1870
1878
 
1871
1879
  /**
1872
- * Export only the last operation of a property for a single key
1873
- * This can still be imported using importProperty
1874
- * @param {string} key
1875
- * @returns {Object} - Data export
1876
- */
1877
- exportPropertyLastOpOnly(key) {
1878
- const lastOp = this.#keyOperations.get(key)?.[this.#keyOperations.get(key)?.length - 1];
1879
- return {
1880
- key,
1881
- operations: lastOp ? [lastOp] : [],
1882
- vectorClock: Array.from(this.#vectorClock.entries())
1883
- };
1884
- }
1885
-
1886
- /**
1887
- * Export property for a single key
1888
- * @param {string} key
1889
- * @returns {Object} - Data export
1890
- */
1891
- exportProperty(key) {
1892
- return {
1893
- key,
1894
- operations: [...(this.#keyOperations.get(key) || [])],
1895
- vectorClock: Array.from(this.#vectorClock.entries())
1896
- };
1897
- }
1898
-
1899
- /**
1900
- * Import property for a single key and merge changes
1880
+ * Import property update
1901
1881
  * @param {Object} data - Data to import
1902
1882
  */
1903
- importProperty(data) {
1883
+ importPropertyUpdate(data) {
1904
1884
  try {
1905
- const { key, operations, vectorClock } = data;
1885
+ const { key, operation, vectorClock } = data;
1886
+ if (this.#debug) console.log(CONSOLE_PREFIX + "Importing update:", data); // Debug
1906
1887
 
1907
1888
  // Get COPY of current ops (or empty array if none yet)
1908
1889
  const currentOps = [...(this.#keyOperations.get(key) || [])];
1909
1890
 
1910
1891
  // Merge vector clocks (always take max value)
1911
- if (vectorClock) {
1912
- for (const [id, counter] of vectorClock) {
1913
- if (!this.#vectorClock.has(id) || this.#vectorClock.get(id) < counter) {
1914
- this.#vectorClock.set(id, counter);
1915
- }
1892
+ for (const [id, counter] of vectorClock) {
1893
+ if (!this.#vectorClock.has(id) || this.#vectorClock.get(id) < counter) {
1894
+ this.#vectorClock.set(id, counter);
1916
1895
  }
1917
1896
  }
1918
1897
 
1919
- // Safeguard against attack where new clients would rapidly connect and disconnect to fill up the vectorClock
1898
+ // Saveguard
1920
1899
  if (this.#vectorClock.size > 1000) this.#vectorClock = new Map([...this.#vectorClock].slice(-100));
1921
1900
 
1922
- // Add new operations (diff) in correct order
1901
+ // Add new operation if it's not already added
1923
1902
  const existingUuids = new Set(currentOps.map(op => op.uuid));
1924
- if (operations?.length) {
1925
- for (const op of operations) {
1926
- if (op?.uuid && !existingUuids.has(op.uuid)) {
1927
- currentOps.push({ ...op }); // Add operation
1928
- this.#opUuuidTimestamp.set(op?.uuid, Date.now()); // Add timestamp for gc
1929
- }
1930
- }
1903
+ if (operation?.uuid && !existingUuids.has(operation.uuid)) {
1904
+ currentOps.push({ ...operation }); // Add operation
1905
+ this.#opUuidTimestamp.set(operation?.uuid, Date.now()); // Add timestamp for gc
1931
1906
  }
1932
1907
 
1933
1908
  // Sort, update operations & local value
1934
1909
  this.#keyOperations.set(key, this.#sortByVectorClock(currentOps));
1935
1910
  this.#processLocalProperty(key);
1936
-
1937
- this.#checkGarbageCollection(); // Check if GC should run
1911
+ this.#checkGarbageCollection();
1938
1912
 
1939
1913
  } catch (error) {
1940
1914
  console.error(CONSOLE_PREFIX + "Failed to import property:", error);
@@ -1947,9 +1921,13 @@ function requireCrdtManager () {
1947
1921
  * @param {string} operation
1948
1922
  * @param {*} value
1949
1923
  * @param {*} updateValue
1924
+ * @returns {Object} - Returns the property update
1950
1925
  */
1951
1926
  updateProperty(key, operation, value, updateValue) {
1952
1927
  try {
1928
+ // Debug log
1929
+ if (this.#debug) console.log(CONSOLE_PREFIX + `Updating property with key ${key}, operation ${operation}, value ${value} and updateValue ${updateValue}.`);
1930
+
1953
1931
  // Increment vector clock
1954
1932
  const counter = this.#vectorClock.get(this.#replicaId) || 0;
1955
1933
  this.#vectorClock.set(this.#replicaId, counter + 1);
@@ -1960,13 +1938,16 @@ function requireCrdtManager () {
1960
1938
  // Add operation
1961
1939
  const newOp = this.#createOperation({ operation, value, updateValue }, Array.from(this.#vectorClock.entries()));
1962
1940
  currentOps.push(newOp);
1941
+ this.#keyOperations.set(key, currentOps); // Update the operations (no need to sort via vector clock since local updates are always the latest)
1942
+ this.#processLocalProperty(key); // Process local value
1943
+ this.#checkGarbageCollection();
1963
1944
 
1964
- // Sort and process
1965
- this.#keyOperations.set(key, this.#sortByVectorClock(currentOps));
1966
- this.#opUuuidTimestamp.set(newOp.uuid, Date.now());
1967
- this.#processLocalProperty(key);
1968
-
1969
- this.#checkGarbageCollection(); // Check if GC should run
1945
+ // Return the property update with the new operation (this can be imported using importPropertyUpdate)
1946
+ return {
1947
+ key,
1948
+ operation: { ...newOp },
1949
+ vectorClock: Array.from(this.#vectorClock.entries())
1950
+ };
1970
1951
 
1971
1952
  } catch (error) {
1972
1953
  console.error(CONSOLE_PREFIX + `Failed to add operation for key "${key}":`, error);
@@ -1985,15 +1966,16 @@ function requireCrdtManager () {
1985
1966
 
1986
1967
  let retainCount = operations.length;
1987
1968
  operations.forEach((op, index) => {
1988
- // Count how many ops, from last to latest, are >10s old in a row
1989
- if (op.uuid && this.#opUuuidTimestamp.has(op.uuid) && (Date.now() - this.#opUuuidTimestamp.get(op.uuid)) > MIN_AGE_FOR_GC) {
1969
+ // Count how many ops, from last to latest, are older than the min-age in a row
1970
+ if (op.uuid && this.#opUuidTimestamp.has(op.uuid) && (Date.now() - this.#opUuidTimestamp.get(op.uuid)) > MIN_AGE_FOR_GC) {
1990
1971
  const removeCount = index + 1;
1991
1972
  retainCount = operations.length - removeCount;
1992
- this.#opUuuidTimestamp.delete(op.uuid); // Remove from timestamps if set to be deleted
1973
+ this.#opUuidTimestamp.delete(op.uuid); // Remove from timestamps if set to be deleted
1993
1974
  }
1994
1975
  });
1995
1976
 
1996
1977
  if (retainCount < operations.length) {
1978
+ if (this.#debug) console.log(CONSOLE_PREFIX + `Running garbage collection for key ${key} with current operations:`, operations);
1997
1979
  const retainOps = operations.slice(-retainCount); // newest ops
1998
1980
  const removeOps = operations.slice(0, -retainCount); // oldest ops (start = idx 0, end = retainCount counted from right side)
1999
1981
  const baselineVectorClock = removeOps[removeOps.length - 1]?.vectorClock || []; // Use the vector clock from the last operation that we remove/overwrite
@@ -2014,6 +1996,9 @@ function requireCrdtManager () {
2014
1996
  // Create a compact operation with baseline value and appropriate vector clock
2015
1997
  const compactOp = this.#createOperation({ operation: "set", value: baselineValue }, baselineVectorClock);
2016
1998
 
1999
+ // Debug log
2000
+ if (this.#debug) console.log(CONSOLE_PREFIX + "Operations after garbage collection for this key:", [compactOp, ...retainOps]);
2001
+
2017
2002
  // Combine compact op with retained operations
2018
2003
  this.#keyOperations.set(key, [compactOp, ...retainOps]);
2019
2004
  }
@@ -2033,7 +2018,7 @@ function requireCrdtManager () {
2033
2018
  source: this.#replicaId,
2034
2019
  uuid: crypto.randomUUID()
2035
2020
  };
2036
- this.#opUuuidTimestamp.set(newOperation.uuid, Date.now());
2021
+ this.#opUuidTimestamp.set(newOperation.uuid, Date.now());
2037
2022
  return newOperation;
2038
2023
  }
2039
2024
 
@@ -2205,6 +2190,10 @@ function requireServer () {
2205
2190
  #heartbeatInterval;
2206
2191
  #pendingDisconnects = new Map(); // ClientId -> {timeout, roomId}
2207
2192
  #clientTokens = new Map(); // ClientId -> Token
2193
+ #roomVersions = new Map(); // RoomId -> Version
2194
+
2195
+ // Debug
2196
+ #debug = false;
2208
2197
 
2209
2198
  /**
2210
2199
  * Create a new PlaySocketServer instance
@@ -2214,7 +2203,9 @@ function requireServer () {
2214
2203
  * @param {string} [options.path='/socket'] - WebSocket endpoint path
2215
2204
  */
2216
2205
  constructor(options = {}) {
2217
- const { server, port = 3000, path = '/' } = options;
2206
+ const { server, port = 3000, path = '/', debug = false } = options;
2207
+
2208
+ if (debug) this.#debug = true; // Enable extra logging
2218
2209
 
2219
2210
  // Handle server creation / usage
2220
2211
  if (server) {
@@ -2223,7 +2214,7 @@ function requireServer () {
2223
2214
  this.#ownsServer = true;
2224
2215
  this.#server = http.createServer(); // Create HTTP server and start it
2225
2216
  this.#server.listen(port, () => {
2226
- console.log(`PlaySocket server running on port ${port}`);
2217
+ console.log(`PlaySocket server running on port ${port}.`);
2227
2218
  });
2228
2219
  }
2229
2220
 
@@ -2300,12 +2291,15 @@ function requireServer () {
2300
2291
 
2301
2292
  // If they were in a room, provide updated room data
2302
2293
  let roomData;
2303
- const formerRoom = this.#rooms[this.#clientRooms.get(data.id)];
2294
+ const formerRoomId = this.#clientRooms.get(data.id);
2295
+ const formerRoom = this.#rooms[formerRoomId];
2304
2296
  if (formerRoom) {
2297
+ if (this.#debug) console.log(`State sent for reconnection for room ${formerRoomId}:`, formerRoom.crdtManager.getState);
2305
2298
  roomData = {
2306
2299
  state: formerRoom.crdtManager.getState,
2307
2300
  participantCount: formerRoom.participants.length,
2308
- host: formerRoom.participants[0]
2301
+ host: formerRoom.participants[0],
2302
+ version: this.#roomVersions.get(formerRoomId)
2309
2303
  };
2310
2304
  }
2311
2305
 
@@ -2315,6 +2309,18 @@ function requireServer () {
2315
2309
  }
2316
2310
  break;
2317
2311
 
2312
+ case 'request_state':
2313
+ const stateRoomId = this.#clientRooms.get(ws.clientId);
2314
+ const stateRoom = stateRoomId ? this.#rooms[stateRoomId] : null;
2315
+ if (stateRoom) {
2316
+ ws.send(encode({
2317
+ type: 'state_sync',
2318
+ state: stateRoom.crdtManager.getState,
2319
+ version: this.#roomVersions.get(stateRoomId)
2320
+ }), { binary: true });
2321
+ }
2322
+ break;
2323
+
2318
2324
  case 'create_room':
2319
2325
  if (!ws.clientId) return;
2320
2326
  const newRoomId = ws.clientId;
@@ -2326,15 +2332,19 @@ function requireServer () {
2326
2332
  return;
2327
2333
  }
2328
2334
 
2329
- // Create room object
2335
+ // Create room
2336
+ this.#roomVersions.set(newRoomId, 0); // Start with version 0
2330
2337
  this.#rooms[newRoomId] = {
2331
2338
  participants: [ws.clientId],
2332
2339
  maxSize: data.size || null,
2333
- crdtManager: new CRDTManager()
2340
+ crdtManager: new CRDTManager(crypto.randomUUID(), this.#debug)
2334
2341
  };
2335
2342
 
2336
2343
  // Load state if provided
2337
- if (data.state) this.#rooms[newRoomId].crdtManager.importState(data.state);
2344
+ if (data.state) {
2345
+ this.#rooms[newRoomId].crdtManager.importState(data.state);
2346
+ if (this.#debug) console.log("Room created with initial state:", data.state);
2347
+ }
2338
2348
 
2339
2349
  this.#clientRooms.set(ws.clientId, newRoomId); // Add client to their room
2340
2350
  ws.send(encode({ type: 'room_created' }), { binary: true });
@@ -2362,11 +2372,13 @@ function requireServer () {
2362
2372
  this.#clientRooms.set(ws.clientId, roomId);
2363
2373
 
2364
2374
  // Notify joiner with initial storage
2375
+ if (this.#debug) console.log("Room state sent for join:", room.crdtManager.getState);
2365
2376
  ws.send(encode({
2366
2377
  type: 'join_accepted',
2367
2378
  state: room.crdtManager.getState,
2368
2379
  participantCount: room.participants.length,
2369
- host: room.participants[0]
2380
+ host: room.participants[0],
2381
+ version: this.#roomVersions.get(roomId)
2370
2382
  }), { binary: true });
2371
2383
 
2372
2384
  // Notify existing participants
@@ -2387,15 +2399,23 @@ function requireServer () {
2387
2399
  case 'property_update':
2388
2400
  const updateRoomId = this.#clientRooms.get(ws.clientId);
2389
2401
  const updateRoom = updateRoomId ? this.#rooms[updateRoomId] : null;
2390
-
2391
- if (updateRoom && data.key && data.property) {
2392
- updateRoom.crdtManager.importProperty(data.property);
2402
+
2403
+ if (updateRoom && data.key && data.update && data.uuid) {
2404
+ updateRoom.crdtManager.importPropertyUpdate(data.update);
2405
+
2406
+ // Increment version for this room
2407
+ const currentVersion = this.#roomVersions.get(updateRoomId) + 1;
2408
+ this.#roomVersions.set(updateRoomId, currentVersion);
2409
+
2410
+ if (this.#debug) console.log("Property update received and imported:", data.update);
2393
2411
  updateRoom.participants?.forEach(p => {
2394
2412
  const client = this.#clients.get(p);
2395
2413
  if (client) {
2396
2414
  client.send(encode({
2397
- type: 'property_sync',
2398
- property: updateRoom.crdtManager.exportPropertyLastOpOnly(data.key)
2415
+ type: 'property_update',
2416
+ update: data.update,
2417
+ version: currentVersion,
2418
+ uuid: data.uuid
2399
2419
  }), { binary: true });
2400
2420
  }
2401
2421
  });
@@ -2488,6 +2508,8 @@ function requireServer () {
2488
2508
 
2489
2509
  if (room.participants?.length === 0) {
2490
2510
  delete this.#rooms[roomId]; // Delete room if now empty
2511
+ this.#roomVersions.delete(roomId); // Delete room version
2512
+ if (this.#debug) console.log("Deleted room with id " + roomId + ".");
2491
2513
  } else {
2492
2514
  // Notify remaining participants
2493
2515
  room.participants.forEach(p => {
@@ -2539,7 +2561,7 @@ function requireServer () {
2539
2561
  }
2540
2562
  if (this.#server && this.#ownsServer) {
2541
2563
  this.#server.close(() => {
2542
- console.log('PlaySocket server stopped');
2564
+ console.log('PlaySocket server stopped.');
2543
2565
  });
2544
2566
  }
2545
2567
  this.#pendingDisconnects.forEach((data) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playsocketjs",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "WebSocket wrapper for creating simple multiplayer systems with ease.",
5
5
  "module": "dist/playsocket-client.js",
6
6
  "exports": {