playsocketjs 2.5.0 → 2.5.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 +1 -1
- package/dist/playsocket-client.js +380 -392
- package/dist/{playsocket-server.js → playsocket-server.cjs} +967 -1097
- package/dist/playsocket-server.mjs +2646 -0
- package/package.json +7 -4
|
@@ -1679,398 +1679,386 @@ function decode(buffer, options) {
|
|
|
1679
1679
|
return decoder.decode(buffer);
|
|
1680
1680
|
}
|
|
1681
1681
|
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
this.#vectorClock.set(id, counter);
|
|
1770
|
-
}
|
|
1771
|
-
}
|
|
1772
|
-
|
|
1773
|
-
// Safeguard against massive vector clocks
|
|
1774
|
-
if (this.#vectorClock.size > 1000) this.#vectorClock = new Map([...this.#vectorClock].slice(-100));
|
|
1775
|
-
|
|
1776
|
-
// Add new operation if it's not already added
|
|
1777
|
-
const existingUuids = new Set(currentOps.map(op => op.uuid));
|
|
1778
|
-
if (operation.uuid && !existingUuids.has(operation.uuid)) {
|
|
1779
|
-
currentOps.push({ ...operation }); // Add operation
|
|
1780
|
-
this.#opUuidTimestamp.set(operation.uuid, Date.now()); // Add timestamp for gc
|
|
1781
|
-
}
|
|
1782
|
-
|
|
1783
|
-
// Sort, update operations & local value
|
|
1784
|
-
this.#keyOperations.set(key, this.#sortByVectorClock(currentOps));
|
|
1785
|
-
this.#processLocalProperty(key);
|
|
1786
|
-
this.#checkGarbageCollection();
|
|
1787
|
-
|
|
1788
|
-
} catch (error) {
|
|
1789
|
-
console.error(CONSOLE_PREFIX + "Failed to import property update:", error);
|
|
1790
|
-
}
|
|
1791
|
-
}
|
|
1792
|
-
|
|
1793
|
-
/**
|
|
1794
|
-
* Update a property
|
|
1795
|
-
* @param {string} key
|
|
1796
|
-
* @param {string} type
|
|
1797
|
-
* @param {*} value
|
|
1798
|
-
* @param {*} updateValue
|
|
1799
|
-
* @returns {Object} - Returns the property update
|
|
1800
|
-
*/
|
|
1801
|
-
updateProperty(key, type, value, updateValue) {
|
|
1802
|
-
try {
|
|
1803
|
-
// Sanitize inputs
|
|
1804
|
-
value = this.#sanitizeValue(value);
|
|
1805
|
-
updateValue = this.#sanitizeValue(updateValue);
|
|
1806
|
-
|
|
1807
|
-
// Debug log
|
|
1808
|
-
if (this.#debug) console.log(CONSOLE_PREFIX + `Updating property with key ${key}, type ${type}, value ${value} and updateValue ${updateValue}.`);
|
|
1809
|
-
|
|
1810
|
-
// Increment vector clock
|
|
1811
|
-
const counter = this.#vectorClock.get(this.#replicaId) || 0;
|
|
1812
|
-
this.#vectorClock.set(this.#replicaId, counter + 1);
|
|
1813
|
-
|
|
1814
|
-
// Assign shallow COPY of currennt ops or fall back to empty array
|
|
1815
|
-
const currentOps = [...(this.#keyOperations.get(key) || [])];
|
|
1816
|
-
|
|
1817
|
-
// Add operation
|
|
1818
|
-
const newOp = this.#createOperation({ type, value, updateValue }, Array.from(this.#vectorClock.entries()));
|
|
1819
|
-
currentOps.push(newOp);
|
|
1820
|
-
this.#keyOperations.set(key, currentOps); // Update the operations (no need to sort via vector clock since local updates are always the latest)
|
|
1821
|
-
this.#processLocalProperty(key); // Process local value
|
|
1822
|
-
this.#checkGarbageCollection();
|
|
1823
|
-
|
|
1824
|
-
// Return the property update with the new operation (this can be imported using importPropertyUpdate)
|
|
1825
|
-
return {
|
|
1826
|
-
key,
|
|
1827
|
-
operation: { ...newOp },
|
|
1828
|
-
vectorClock: Array.from(this.#vectorClock.entries())
|
|
1829
|
-
};
|
|
1830
|
-
|
|
1831
|
-
} catch (error) {
|
|
1832
|
-
console.error(CONSOLE_PREFIX + `Failed to add operation for key "${key}":`, error);
|
|
1833
|
-
}
|
|
1834
|
-
}
|
|
1835
|
-
|
|
1836
|
-
#checkGarbageCollection() {
|
|
1837
|
-
const MIN_GC_DELAY = 1000; // Minimum 1s delay between garbage collection runs
|
|
1838
|
-
const MIN_AGE_FOR_GC = 5000; // Garbage collect ops that are older than 5s
|
|
1839
|
-
|
|
1840
|
-
try {
|
|
1841
|
-
if (Date.now() - this.#lastGCCheck < MIN_GC_DELAY) return;
|
|
1842
|
-
this.#lastGCCheck = Date.now();
|
|
1843
|
-
|
|
1844
|
-
for (const [key, operations] of this.#keyOperations.entries()) {
|
|
1845
|
-
if (operations?.length < 5) continue; // Min op amount per key for garbage collection to run (otherwise not worth it)
|
|
1846
|
-
|
|
1847
|
-
let retainCount = operations.length;
|
|
1848
|
-
operations.forEach((op, index) => {
|
|
1849
|
-
// Count how many ops, from last to latest, are older than the min-age in a row
|
|
1850
|
-
if (op.uuid && this.#opUuidTimestamp.has(op.uuid) && (Date.now() - this.#opUuidTimestamp.get(op.uuid)) > MIN_AGE_FOR_GC) {
|
|
1851
|
-
const removeCount = index + 1;
|
|
1852
|
-
retainCount = operations.length - removeCount;
|
|
1853
|
-
this.#opUuidTimestamp.delete(op.uuid); // Remove from timestamps if set to be deleted
|
|
1854
|
-
}
|
|
1855
|
-
});
|
|
1856
|
-
|
|
1857
|
-
if (retainCount < operations.length) {
|
|
1858
|
-
if (this.#debug) console.log(CONSOLE_PREFIX + `Running garbage collection for key ${key} with current operations:`, operations);
|
|
1859
|
-
const retainOps = operations.slice(-retainCount); // Newest ops
|
|
1860
|
-
const removeOps = operations.slice(0, -retainCount); // Oldest ops (start = idx 0, end = retainCount counted from right side)
|
|
1861
|
-
const baselineVectorClock = removeOps[removeOps.length - 1]?.vectorClock || []; // Use the vector clock from the last operation that we remove/overwrite
|
|
1862
|
-
|
|
1863
|
-
// Calculate the value at the point where retained operations start
|
|
1864
|
-
let baselineValue = null;
|
|
1865
|
-
for (const op of removeOps) {
|
|
1866
|
-
if (!op.data) continue;
|
|
1867
|
-
baselineValue = this.#handleOperation(
|
|
1868
|
-
baselineValue,
|
|
1869
|
-
op.data.type,
|
|
1870
|
-
op.data.value,
|
|
1871
|
-
op.data.updateValue
|
|
1872
|
-
);
|
|
1873
|
-
}
|
|
1874
|
-
|
|
1875
|
-
// Create a compact operation with baseline value and appropriate vector clock
|
|
1876
|
-
const compactOp = this.#createOperation({ type: "set", value: baselineValue }, baselineVectorClock);
|
|
1877
|
-
|
|
1878
|
-
// Debug log
|
|
1879
|
-
if (this.#debug) console.log(CONSOLE_PREFIX + "Operations after garbage collection for this key:", [compactOp, ...retainOps]);
|
|
1880
|
-
|
|
1881
|
-
// Combine compact op with retained operations
|
|
1882
|
-
this.#keyOperations.set(key, [compactOp, ...retainOps]);
|
|
1883
|
-
}
|
|
1884
|
-
}
|
|
1885
|
-
} catch (error) {
|
|
1886
|
-
console.error(CONSOLE_PREFIX + "Error running garbage collection:", error);
|
|
1887
|
-
}
|
|
1888
|
-
}
|
|
1889
|
-
|
|
1890
|
-
/**
|
|
1891
|
-
* Create an operation object
|
|
1892
|
-
* @param {Object} data
|
|
1893
|
-
* @param {Array} vectorClock
|
|
1894
|
-
* @returns {Object} - Operation object
|
|
1895
|
-
*/
|
|
1896
|
-
#createOperation(data, vectorClock) {
|
|
1897
|
-
const newOperation = {
|
|
1898
|
-
data,
|
|
1899
|
-
vectorClock,
|
|
1900
|
-
source: this.#replicaId,
|
|
1901
|
-
uuid: crypto.randomUUID()
|
|
1902
|
-
};
|
|
1903
|
-
this.#opUuidTimestamp.set(newOperation.uuid, Date.now());
|
|
1904
|
-
return newOperation;
|
|
1905
|
-
}
|
|
1906
|
-
|
|
1907
|
-
/**
|
|
1908
|
-
* Process a property's value by applying all operations and set it in the property store
|
|
1909
|
-
* @param {string} key
|
|
1910
|
-
*/
|
|
1911
|
-
#processLocalProperty(key) {
|
|
1912
|
-
try {
|
|
1913
|
-
const ops = this.#keyOperations.get(key);
|
|
1914
|
-
if (!ops?.length) return;
|
|
1915
|
-
|
|
1916
|
-
// Apply all operations in order
|
|
1917
|
-
let value = null;
|
|
1918
|
-
|
|
1919
|
-
for (const op of ops) {
|
|
1920
|
-
if (!op.data) continue;
|
|
1921
|
-
value = this.#handleOperation(
|
|
1922
|
-
value,
|
|
1923
|
-
op.data.type,
|
|
1924
|
-
op.data.value,
|
|
1925
|
-
op.data.updateValue
|
|
1926
|
-
);
|
|
1927
|
-
}
|
|
1928
|
-
|
|
1929
|
-
this.#propertyStore[key] = value; // Save locally
|
|
1930
|
-
} catch (error) {
|
|
1931
|
-
console.error(CONSOLE_PREFIX + `Failed to process property for key ${key}:`, error);
|
|
1932
|
-
}
|
|
1933
|
-
}
|
|
1934
|
-
|
|
1935
|
-
/**
|
|
1936
|
-
* Sort by vector clock (causal order)
|
|
1937
|
-
* @param {Array} operations
|
|
1938
|
-
* @returns {Array} - Sorted operations
|
|
1939
|
-
*/
|
|
1940
|
-
#sortByVectorClock(operations) {
|
|
1941
|
-
return [...operations].sort((a, b) => {
|
|
1942
|
-
const clockA = new Map(a.vectorClock || []);
|
|
1943
|
-
const clockB = new Map(b.vectorClock || []);
|
|
1944
|
-
|
|
1945
|
-
// First sort by causal relationship
|
|
1946
|
-
let aGreater = false, bGreater = false;
|
|
1947
|
-
|
|
1948
|
-
const allIds = new Set([...clockA.keys(), ...clockB.keys()]);
|
|
1949
|
-
for (const id of allIds) {
|
|
1950
|
-
const countA = clockA.get(id) || 0;
|
|
1951
|
-
const countB = clockB.get(id) || 0;
|
|
1952
|
-
|
|
1953
|
-
if (countA > countB) aGreater = true;
|
|
1954
|
-
if (countA < countB) bGreater = true;
|
|
1955
|
-
}
|
|
1956
|
-
|
|
1957
|
-
// Determine causal relationship (if one is greater, and the other is smaller, it's clear)
|
|
1958
|
-
if (aGreater && !bGreater) return 1; // a happens after b
|
|
1959
|
-
if (!aGreater && bGreater) return -1; // a happens before b
|
|
1960
|
-
|
|
1961
|
-
// Concurrent (unclear): Compare highest counter in each (sort by which one is ahead)
|
|
1962
|
-
const maxA = Math.max(...Array.from(clockA.values()), 0);
|
|
1963
|
-
const maxB = Math.max(...Array.from(clockB.values()), 0);
|
|
1964
|
-
if (maxA !== maxB) return maxA - maxB;
|
|
1965
|
-
|
|
1966
|
-
// Final tiebreak: Sort by ID of Replica that actually generated the operation
|
|
1967
|
-
return a.source.localeCompare(b.source);
|
|
1968
|
-
});
|
|
1969
|
-
}
|
|
1970
|
-
|
|
1971
|
-
/**
|
|
1972
|
-
* Handle an operation
|
|
1973
|
-
* @param {*} curValue
|
|
1974
|
-
* @param {string} type
|
|
1975
|
-
* @param {*} value
|
|
1976
|
-
* @param {*} [updateValue]
|
|
1977
|
-
* @returns {*} - Value after the operation
|
|
1978
|
-
*/
|
|
1979
|
-
#handleOperation(curValue, type, value, updateValue) {
|
|
1980
|
-
try {
|
|
1981
|
-
// Deep copy to avoid reference issues in case value is or contains object(s)
|
|
1982
|
-
curValue = structuredClone(curValue);
|
|
1983
|
-
|
|
1984
|
-
// Set operation
|
|
1985
|
-
if (type === "set") return value;
|
|
1986
|
-
|
|
1987
|
-
// Array operations
|
|
1988
|
-
if (type.startsWith('array')) {
|
|
1989
|
-
if (!Array.isArray(curValue)) curValue = []; // Auto-convert to array if current value isn't one
|
|
1990
|
-
|
|
1991
|
-
// Comparison function
|
|
1992
|
-
const isObject = typeof value === 'object' && value !== null;
|
|
1993
|
-
const compare = (item) => {
|
|
1994
|
-
if (isObject && typeof item === 'object' && item !== null) return JSON.stringify(item) === JSON.stringify(value); // Deep comparison
|
|
1995
|
-
return item === value; // Value comparison
|
|
1996
|
-
};
|
|
1997
|
-
|
|
1998
|
-
switch (type) {
|
|
1999
|
-
case 'array-add':
|
|
2000
|
-
curValue.push(value);
|
|
2001
|
-
break;
|
|
2002
|
-
case 'array-add-unique':
|
|
2003
|
-
if (!curValue.some(compare)) curValue.push(value);
|
|
2004
|
-
break;
|
|
2005
|
-
case 'array-remove-matching':
|
|
2006
|
-
curValue = curValue.filter(item => !compare(item));
|
|
2007
|
-
break;
|
|
2008
|
-
case 'array-update-matching':
|
|
2009
|
-
const index = curValue.findIndex(compare);
|
|
2010
|
-
if (index !== -1) curValue[index] = updateValue;
|
|
2011
|
-
break;
|
|
2012
|
-
}
|
|
2013
|
-
}
|
|
2014
|
-
|
|
2015
|
-
return curValue;
|
|
2016
|
-
|
|
2017
|
-
} catch (error) {
|
|
2018
|
-
console.error(CONSOLE_PREFIX + `Failed to handle operation with curValue ${curValue}:`, error);
|
|
2019
|
-
return curValue;
|
|
2020
|
-
}
|
|
2021
|
-
}
|
|
2022
|
-
|
|
2023
|
-
/**
|
|
2024
|
-
* Remove HTML to prevent XSS and enforce size limits
|
|
2025
|
-
* @param {Object} obj
|
|
2026
|
-
* @returns {Object} - Sanitized object
|
|
2027
|
-
*/
|
|
2028
|
-
#sanitizeValue(obj) {
|
|
2029
|
-
// Check total serialized size
|
|
2030
|
-
const jsonString = JSON.stringify(obj);
|
|
2031
|
-
if (jsonString?.length > 50000) throw new Error('Value too large!'); // 50KB limit
|
|
2032
|
-
|
|
2033
|
-
if (typeof obj === 'string') return (obj.includes('<') || obj.includes('>')) ? obj.replace(/[<>]/g, '') : obj;
|
|
2034
|
-
if (Array.isArray(obj)) return obj.map(item => this.#sanitizeValue(item));
|
|
2035
|
-
if (obj && typeof obj === 'object') return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, this.#sanitizeValue(v)]));
|
|
2036
|
-
return obj;
|
|
2037
|
-
}
|
|
2038
|
-
|
|
2039
|
-
// Get property store
|
|
2040
|
-
get getPropertyStore() {
|
|
2041
|
-
try {
|
|
2042
|
-
return structuredClone(this.#propertyStore); // Return deep clone
|
|
2043
|
-
} catch {
|
|
2044
|
-
console.error(CONSOLE_PREFIX + "Failed to parse property store");
|
|
2045
|
-
return {};
|
|
2046
|
-
}
|
|
2047
|
-
}
|
|
2048
|
-
|
|
2049
|
-
// Check for changes in the local property store
|
|
2050
|
-
get didPropertiesChange() {
|
|
2051
|
-
try {
|
|
2052
|
-
if (JSON.stringify(this.#propertyStore) !== JSON.stringify(this.#lastPropertyStore)) {
|
|
2053
|
-
this.#lastPropertyStore = structuredClone(this.#propertyStore);
|
|
2054
|
-
return true;
|
|
2055
|
-
}
|
|
2056
|
-
} catch { }
|
|
2057
|
-
return false;
|
|
2058
|
-
}
|
|
2059
|
-
|
|
2060
|
-
// Get state (can be imported using importState, converts the maps to arrays for serialization)
|
|
2061
|
-
get getState() {
|
|
2062
|
-
return {
|
|
2063
|
-
keyOperations: [...this.#keyOperations.entries()],
|
|
2064
|
-
vectorClock: [...this.#vectorClock.entries()]
|
|
2065
|
-
};
|
|
2066
|
-
}
|
|
2067
|
-
}
|
|
2068
|
-
|
|
2069
|
-
crdtManager = { CRDTManager };
|
|
2070
|
-
return crdtManager;
|
|
2071
|
-
}
|
|
1682
|
+
// Custom conflict-free replicated data type system with vector clocks
|
|
1683
|
+
|
|
1684
|
+
const CONSOLE_PREFIX = "PlaySocket CRDT manager: ";
|
|
1685
|
+
|
|
1686
|
+
class CRDTManager {
|
|
1687
|
+
// Storage
|
|
1688
|
+
#replicaId;
|
|
1689
|
+
#keyOperations = new Map();
|
|
1690
|
+
#vectorClock = new Map();
|
|
1691
|
+
|
|
1692
|
+
// Local only
|
|
1693
|
+
#propertyStore = {}; // Current local values per key, as object
|
|
1694
|
+
#lastPropertyStore = {}; // Last property store to compare against
|
|
1695
|
+
|
|
1696
|
+
// Local Garbage Collection
|
|
1697
|
+
#lastGCCheck = 0;
|
|
1698
|
+
#opUuidTimestamp = new Map(); // Map every operation to a timestamp for garbage collection
|
|
1699
|
+
|
|
1700
|
+
// Debug
|
|
1701
|
+
#debug = false;
|
|
1702
|
+
|
|
1703
|
+
/**
|
|
1704
|
+
* Create a new instance
|
|
1705
|
+
*/
|
|
1706
|
+
constructor(debug) {
|
|
1707
|
+
if (debug) this.#debug = true;
|
|
1708
|
+
this.#replicaId = crypto.randomUUID();
|
|
1709
|
+
this.#vectorClock.set(this.#replicaId, 0);
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
/**
|
|
1713
|
+
* Import the entire state of the CRDT manager (this overwrites the old state)
|
|
1714
|
+
* @param {Object} state
|
|
1715
|
+
*/
|
|
1716
|
+
importState(state) {
|
|
1717
|
+
try {
|
|
1718
|
+
const { keyOperations, vectorClock } = state;
|
|
1719
|
+
if (this.#debug) console.log(CONSOLE_PREFIX + "Importing state:", state);
|
|
1720
|
+
|
|
1721
|
+
// Resets
|
|
1722
|
+
this.#opUuidTimestamp.clear();
|
|
1723
|
+
this.#propertyStore = {};
|
|
1724
|
+
this.#lastPropertyStore = {};
|
|
1725
|
+
|
|
1726
|
+
this.#keyOperations = new Map(keyOperations); // Rebuild the operations map
|
|
1727
|
+
this.#vectorClock = new Map(vectorClock); // Rebuild the vector clock map
|
|
1728
|
+
if (!this.#vectorClock.has(this.#replicaId)) this.#vectorClock.set(this.#replicaId, 0); // Add own vector clock if it wasn't present in the imported state
|
|
1729
|
+
|
|
1730
|
+
// Map all operation uuids to current timestamp
|
|
1731
|
+
this.#keyOperations.forEach((value, key) => {
|
|
1732
|
+
value?.forEach((operation) => {
|
|
1733
|
+
this.#opUuidTimestamp.set(operation?.uuid, Date.now());
|
|
1734
|
+
});
|
|
1735
|
+
});
|
|
1736
|
+
|
|
1737
|
+
// Process each key to update local values
|
|
1738
|
+
for (const key of this.#keyOperations.keys()) this.#processLocalProperty(key);
|
|
1739
|
+
|
|
1740
|
+
} catch (error) {
|
|
1741
|
+
console.error(CONSOLE_PREFIX + "Failed to import state:", error);
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
/**
|
|
1746
|
+
* Import property update
|
|
1747
|
+
* @param {Object} data - Data to import
|
|
1748
|
+
*/
|
|
1749
|
+
importPropertyUpdate(data) {
|
|
1750
|
+
try {
|
|
1751
|
+
const { key, operation: rawOperation, vectorClock } = data;
|
|
1752
|
+
const operation = this.#sanitizeValue(rawOperation);
|
|
1753
|
+
if (this.#debug) console.log(CONSOLE_PREFIX + "Importing update:", data); // Debug
|
|
1754
|
+
|
|
1755
|
+
// Check key limit to afeguard against too many keys
|
|
1756
|
+
if (this.#keyOperations.size >= 100 && !this.#keyOperations.has(key)) throw new Error('Key limit exceeded!');
|
|
1757
|
+
|
|
1758
|
+
// Get COPY of current ops (or empty array if none yet)
|
|
1759
|
+
const currentOps = [...(this.#keyOperations.get(key) || [])];
|
|
1760
|
+
// Merge vector clocks (always take max value)
|
|
1761
|
+
for (const [id, counter] of vectorClock) {
|
|
1762
|
+
if (!this.#vectorClock.has(id) || this.#vectorClock.get(id) < counter) {
|
|
1763
|
+
this.#vectorClock.set(id, counter);
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
// Safeguard against massive vector clocks
|
|
1768
|
+
if (this.#vectorClock.size > 1000) this.#vectorClock = new Map([...this.#vectorClock].slice(-100));
|
|
2072
1769
|
|
|
2073
|
-
|
|
1770
|
+
// Add new operation if it's not already added
|
|
1771
|
+
const existingUuids = new Set(currentOps.map(op => op.uuid));
|
|
1772
|
+
if (operation.uuid && !existingUuids.has(operation.uuid)) {
|
|
1773
|
+
currentOps.push({ ...operation }); // Add operation
|
|
1774
|
+
this.#opUuidTimestamp.set(operation.uuid, Date.now()); // Add timestamp for gc
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
// Sort, update operations & local value
|
|
1778
|
+
this.#keyOperations.set(key, this.#sortByVectorClock(currentOps));
|
|
1779
|
+
this.#processLocalProperty(key);
|
|
1780
|
+
this.#checkGarbageCollection();
|
|
1781
|
+
|
|
1782
|
+
} catch (error) {
|
|
1783
|
+
console.error(CONSOLE_PREFIX + "Failed to import property update:", error);
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
/**
|
|
1788
|
+
* Update a property
|
|
1789
|
+
* @param {string} key
|
|
1790
|
+
* @param {string} type
|
|
1791
|
+
* @param {*} value
|
|
1792
|
+
* @param {*} updateValue
|
|
1793
|
+
* @returns {Object} - Returns the property update
|
|
1794
|
+
*/
|
|
1795
|
+
updateProperty(key, type, value, updateValue) {
|
|
1796
|
+
try {
|
|
1797
|
+
// Sanitize inputs
|
|
1798
|
+
value = this.#sanitizeValue(value);
|
|
1799
|
+
updateValue = this.#sanitizeValue(updateValue);
|
|
1800
|
+
|
|
1801
|
+
// Debug log
|
|
1802
|
+
if (this.#debug) console.log(CONSOLE_PREFIX + `Updating property with key ${key}, type ${type}, value ${value} and updateValue ${updateValue}.`);
|
|
1803
|
+
|
|
1804
|
+
// Increment vector clock
|
|
1805
|
+
const counter = this.#vectorClock.get(this.#replicaId) || 0;
|
|
1806
|
+
this.#vectorClock.set(this.#replicaId, counter + 1);
|
|
1807
|
+
|
|
1808
|
+
// Assign shallow COPY of currennt ops or fall back to empty array
|
|
1809
|
+
const currentOps = [...(this.#keyOperations.get(key) || [])];
|
|
1810
|
+
|
|
1811
|
+
// Add operation
|
|
1812
|
+
const newOp = this.#createOperation({ type, value, updateValue }, Array.from(this.#vectorClock.entries()));
|
|
1813
|
+
currentOps.push(newOp);
|
|
1814
|
+
this.#keyOperations.set(key, currentOps); // Update the operations (no need to sort via vector clock since local updates are always the latest)
|
|
1815
|
+
this.#processLocalProperty(key); // Process local value
|
|
1816
|
+
this.#checkGarbageCollection();
|
|
1817
|
+
|
|
1818
|
+
// Return the property update with the new operation (this can be imported using importPropertyUpdate)
|
|
1819
|
+
return {
|
|
1820
|
+
key,
|
|
1821
|
+
operation: { ...newOp },
|
|
1822
|
+
vectorClock: Array.from(this.#vectorClock.entries())
|
|
1823
|
+
};
|
|
1824
|
+
|
|
1825
|
+
} catch (error) {
|
|
1826
|
+
console.error(CONSOLE_PREFIX + `Failed to add operation for key "${key}":`, error);
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
#checkGarbageCollection() {
|
|
1831
|
+
const MIN_GC_DELAY = 1000; // Minimum 1s delay between garbage collection runs
|
|
1832
|
+
const MIN_AGE_FOR_GC = 5000; // Garbage collect ops that are older than 5s
|
|
1833
|
+
|
|
1834
|
+
try {
|
|
1835
|
+
if (Date.now() - this.#lastGCCheck < MIN_GC_DELAY) return;
|
|
1836
|
+
this.#lastGCCheck = Date.now();
|
|
1837
|
+
|
|
1838
|
+
for (const [key, operations] of this.#keyOperations.entries()) {
|
|
1839
|
+
if (operations?.length < 5) continue; // Min op amount per key for garbage collection to run (otherwise not worth it)
|
|
1840
|
+
|
|
1841
|
+
let retainCount = operations.length;
|
|
1842
|
+
operations.forEach((op, index) => {
|
|
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) {
|
|
1845
|
+
const removeCount = index + 1;
|
|
1846
|
+
retainCount = operations.length - removeCount;
|
|
1847
|
+
this.#opUuidTimestamp.delete(op.uuid); // Remove from timestamps if set to be deleted
|
|
1848
|
+
}
|
|
1849
|
+
});
|
|
1850
|
+
|
|
1851
|
+
if (retainCount < operations.length) {
|
|
1852
|
+
if (this.#debug) console.log(CONSOLE_PREFIX + `Running garbage collection for key ${key} with current operations:`, operations);
|
|
1853
|
+
const retainOps = operations.slice(-retainCount); // Newest ops
|
|
1854
|
+
const removeOps = operations.slice(0, -retainCount); // Oldest ops (start = idx 0, end = retainCount counted from right side)
|
|
1855
|
+
const baselineVectorClock = removeOps[removeOps.length - 1]?.vectorClock || []; // Use the vector clock from the last operation that we remove/overwrite
|
|
1856
|
+
|
|
1857
|
+
// Calculate the value at the point where retained operations start
|
|
1858
|
+
let baselineValue = null;
|
|
1859
|
+
for (const op of removeOps) {
|
|
1860
|
+
if (!op.data) continue;
|
|
1861
|
+
baselineValue = this.#handleOperation(
|
|
1862
|
+
baselineValue,
|
|
1863
|
+
op.data.type,
|
|
1864
|
+
op.data.value,
|
|
1865
|
+
op.data.updateValue
|
|
1866
|
+
);
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
// Create a compact operation with baseline value and appropriate vector clock
|
|
1870
|
+
const compactOp = this.#createOperation({ type: "set", value: baselineValue }, baselineVectorClock);
|
|
1871
|
+
|
|
1872
|
+
// Debug log
|
|
1873
|
+
if (this.#debug) console.log(CONSOLE_PREFIX + "Operations after garbage collection for this key:", [compactOp, ...retainOps]);
|
|
1874
|
+
|
|
1875
|
+
// Combine compact op with retained operations
|
|
1876
|
+
this.#keyOperations.set(key, [compactOp, ...retainOps]);
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
} catch (error) {
|
|
1880
|
+
console.error(CONSOLE_PREFIX + "Error running garbage collection:", error);
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
/**
|
|
1885
|
+
* Create an operation object
|
|
1886
|
+
* @param {Object} data
|
|
1887
|
+
* @param {Array} vectorClock
|
|
1888
|
+
* @returns {Object} - Operation object
|
|
1889
|
+
*/
|
|
1890
|
+
#createOperation(data, vectorClock) {
|
|
1891
|
+
const newOperation = {
|
|
1892
|
+
data,
|
|
1893
|
+
vectorClock,
|
|
1894
|
+
source: this.#replicaId,
|
|
1895
|
+
uuid: crypto.randomUUID()
|
|
1896
|
+
};
|
|
1897
|
+
this.#opUuidTimestamp.set(newOperation.uuid, Date.now());
|
|
1898
|
+
return newOperation;
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
/**
|
|
1902
|
+
* Process a property's value by applying all operations and set it in the property store
|
|
1903
|
+
* @param {string} key
|
|
1904
|
+
*/
|
|
1905
|
+
#processLocalProperty(key) {
|
|
1906
|
+
try {
|
|
1907
|
+
const ops = this.#keyOperations.get(key);
|
|
1908
|
+
if (!ops?.length) return;
|
|
1909
|
+
|
|
1910
|
+
// Apply all operations in order
|
|
1911
|
+
let value = null;
|
|
1912
|
+
|
|
1913
|
+
for (const op of ops) {
|
|
1914
|
+
if (!op.data) continue;
|
|
1915
|
+
value = this.#handleOperation(
|
|
1916
|
+
value,
|
|
1917
|
+
op.data.type,
|
|
1918
|
+
op.data.value,
|
|
1919
|
+
op.data.updateValue
|
|
1920
|
+
);
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
this.#propertyStore[key] = value; // Save locally
|
|
1924
|
+
} catch (error) {
|
|
1925
|
+
console.error(CONSOLE_PREFIX + `Failed to process property for key ${key}:`, error);
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
/**
|
|
1930
|
+
* Sort by vector clock (causal order)
|
|
1931
|
+
* @param {Array} operations
|
|
1932
|
+
* @returns {Array} - Sorted operations
|
|
1933
|
+
*/
|
|
1934
|
+
#sortByVectorClock(operations) {
|
|
1935
|
+
return [...operations].sort((a, b) => {
|
|
1936
|
+
const clockA = new Map(a.vectorClock || []);
|
|
1937
|
+
const clockB = new Map(b.vectorClock || []);
|
|
1938
|
+
|
|
1939
|
+
// First sort by causal relationship
|
|
1940
|
+
let aGreater = false, bGreater = false;
|
|
1941
|
+
|
|
1942
|
+
const allIds = new Set([...clockA.keys(), ...clockB.keys()]);
|
|
1943
|
+
for (const id of allIds) {
|
|
1944
|
+
const countA = clockA.get(id) || 0;
|
|
1945
|
+
const countB = clockB.get(id) || 0;
|
|
1946
|
+
|
|
1947
|
+
if (countA > countB) aGreater = true;
|
|
1948
|
+
if (countA < countB) bGreater = true;
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
// Determine causal relationship (if one is greater, and the other is smaller, it's clear)
|
|
1952
|
+
if (aGreater && !bGreater) return 1; // a happens after b
|
|
1953
|
+
if (!aGreater && bGreater) return -1; // a happens before b
|
|
1954
|
+
|
|
1955
|
+
// Concurrent (unclear): Compare highest counter in each (sort by which one is ahead)
|
|
1956
|
+
const maxA = Math.max(...Array.from(clockA.values()), 0);
|
|
1957
|
+
const maxB = Math.max(...Array.from(clockB.values()), 0);
|
|
1958
|
+
if (maxA !== maxB) return maxA - maxB;
|
|
1959
|
+
|
|
1960
|
+
// Final tiebreak: Sort by ID of Replica that actually generated the operation
|
|
1961
|
+
return a.source.localeCompare(b.source);
|
|
1962
|
+
});
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
/**
|
|
1966
|
+
* Handle an operation
|
|
1967
|
+
* @param {*} curValue
|
|
1968
|
+
* @param {string} type
|
|
1969
|
+
* @param {*} value
|
|
1970
|
+
* @param {*} [updateValue]
|
|
1971
|
+
* @returns {*} - Value after the operation
|
|
1972
|
+
*/
|
|
1973
|
+
#handleOperation(curValue, type, value, updateValue) {
|
|
1974
|
+
try {
|
|
1975
|
+
// Deep copy to avoid reference issues in case value is or contains object(s)
|
|
1976
|
+
curValue = structuredClone(curValue);
|
|
1977
|
+
|
|
1978
|
+
// Set operation
|
|
1979
|
+
if (type === "set") return value;
|
|
1980
|
+
|
|
1981
|
+
// Array operations
|
|
1982
|
+
if (type.startsWith('array')) {
|
|
1983
|
+
if (!Array.isArray(curValue)) curValue = []; // Auto-convert to array if current value isn't one
|
|
1984
|
+
|
|
1985
|
+
// Comparison function
|
|
1986
|
+
const isObject = typeof value === 'object' && value !== null;
|
|
1987
|
+
const compare = (item) => {
|
|
1988
|
+
if (isObject && typeof item === 'object' && item !== null) return JSON.stringify(item) === JSON.stringify(value); // Deep comparison
|
|
1989
|
+
return item === value; // Value comparison
|
|
1990
|
+
};
|
|
1991
|
+
|
|
1992
|
+
switch (type) {
|
|
1993
|
+
case 'array-add':
|
|
1994
|
+
curValue.push(value);
|
|
1995
|
+
break;
|
|
1996
|
+
case 'array-add-unique':
|
|
1997
|
+
if (!curValue.some(compare)) curValue.push(value);
|
|
1998
|
+
break;
|
|
1999
|
+
case 'array-remove-matching':
|
|
2000
|
+
curValue = curValue.filter(item => !compare(item));
|
|
2001
|
+
break;
|
|
2002
|
+
case 'array-update-matching':
|
|
2003
|
+
const index = curValue.findIndex(compare);
|
|
2004
|
+
if (index !== -1) curValue[index] = updateValue;
|
|
2005
|
+
break;
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
return curValue;
|
|
2010
|
+
|
|
2011
|
+
} catch (error) {
|
|
2012
|
+
console.error(CONSOLE_PREFIX + `Failed to handle operation with curValue ${curValue}:`, error);
|
|
2013
|
+
return curValue;
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
/**
|
|
2018
|
+
* Remove HTML to prevent XSS and enforce size limits
|
|
2019
|
+
* @param {Object} obj
|
|
2020
|
+
* @returns {Object} - Sanitized object
|
|
2021
|
+
*/
|
|
2022
|
+
#sanitizeValue(obj) {
|
|
2023
|
+
// Check total serialized size
|
|
2024
|
+
const jsonString = JSON.stringify(obj);
|
|
2025
|
+
if (jsonString?.length > 50000) throw new Error('Value too large!'); // 50KB limit
|
|
2026
|
+
|
|
2027
|
+
if (typeof obj === 'string') return (obj.includes('<') || obj.includes('>')) ? obj.replace(/[<>]/g, '') : obj;
|
|
2028
|
+
if (Array.isArray(obj)) return obj.map(item => this.#sanitizeValue(item));
|
|
2029
|
+
if (obj && typeof obj === 'object') return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, this.#sanitizeValue(v)]));
|
|
2030
|
+
return obj;
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
// Get property store
|
|
2034
|
+
get getPropertyStore() {
|
|
2035
|
+
try {
|
|
2036
|
+
return structuredClone(this.#propertyStore); // Return deep clone
|
|
2037
|
+
} catch {
|
|
2038
|
+
console.error(CONSOLE_PREFIX + "Failed to parse property store");
|
|
2039
|
+
return {};
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
// Check for changes in the local property store
|
|
2044
|
+
get didPropertiesChange() {
|
|
2045
|
+
try {
|
|
2046
|
+
if (JSON.stringify(this.#propertyStore) !== JSON.stringify(this.#lastPropertyStore)) {
|
|
2047
|
+
this.#lastPropertyStore = structuredClone(this.#propertyStore);
|
|
2048
|
+
return true;
|
|
2049
|
+
}
|
|
2050
|
+
} catch { }
|
|
2051
|
+
return false;
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
// Get state (can be imported using importState, converts the maps to arrays for serialization)
|
|
2055
|
+
get getState() {
|
|
2056
|
+
return {
|
|
2057
|
+
keyOperations: [...this.#keyOperations.entries()],
|
|
2058
|
+
vectorClock: [...this.#vectorClock.entries()]
|
|
2059
|
+
};
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2074
2062
|
|
|
2075
2063
|
/**
|
|
2076
2064
|
* PlaySocket - WebSocket-based multiplayer for games
|
|
@@ -2128,7 +2116,7 @@ class PlaySocket {
|
|
|
2128
2116
|
if (options.endpoint) this.#endpoint = options.endpoint;
|
|
2129
2117
|
if (options.customData) this.#customData = { ...options.customData };
|
|
2130
2118
|
if (options.debug) this.#debug = true; // Enabling extra logging
|
|
2131
|
-
this.#crdtManager = new
|
|
2119
|
+
this.#crdtManager = new CRDTManager(this.#debug);
|
|
2132
2120
|
}
|
|
2133
2121
|
|
|
2134
2122
|
/**
|