playsocketjs 2.4.2 → 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 +3 -2
- package/dist/playsocket-client.js +383 -395
- package/dist/{playsocket-server.js → playsocket-server.cjs} +967 -1072
- package/dist/playsocket-server.mjs +2646 -0
- package/package.json +7 -4
|
@@ -1,36 +1,26 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var
|
|
4
|
-
var
|
|
5
|
-
|
|
6
|
-
function
|
|
7
|
-
|
|
3
|
+
var ws = require('ws');
|
|
4
|
+
var http = require('node:http');
|
|
5
|
+
|
|
6
|
+
function _interopNamespaceDefault(e) {
|
|
7
|
+
var n = Object.create(null);
|
|
8
|
+
if (e) {
|
|
9
|
+
Object.keys(e).forEach(function (k) {
|
|
10
|
+
if (k !== 'default') {
|
|
11
|
+
var d = Object.getOwnPropertyDescriptor(e, k);
|
|
12
|
+
Object.defineProperty(n, k, d.get ? d : {
|
|
13
|
+
enumerable: true,
|
|
14
|
+
get: function () { return e[k]; }
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
n.default = e;
|
|
20
|
+
return Object.freeze(n);
|
|
8
21
|
}
|
|
9
22
|
|
|
10
|
-
|
|
11
|
-
if (Object.prototype.hasOwnProperty.call(n, '__esModule')) return n;
|
|
12
|
-
var f = n.default;
|
|
13
|
-
if (typeof f == "function") {
|
|
14
|
-
var a = function a () {
|
|
15
|
-
if (this instanceof a) {
|
|
16
|
-
return Reflect.construct(f, arguments, this.constructor);
|
|
17
|
-
}
|
|
18
|
-
return f.apply(this, arguments);
|
|
19
|
-
};
|
|
20
|
-
a.prototype = f.prototype;
|
|
21
|
-
} else a = {};
|
|
22
|
-
Object.defineProperty(a, '__esModule', {value: true});
|
|
23
|
-
Object.keys(n).forEach(function (k) {
|
|
24
|
-
var d = Object.getOwnPropertyDescriptor(n, k);
|
|
25
|
-
Object.defineProperty(a, k, d.get ? d : {
|
|
26
|
-
enumerable: true,
|
|
27
|
-
get: function () {
|
|
28
|
-
return n[k];
|
|
29
|
-
}
|
|
30
|
-
});
|
|
31
|
-
});
|
|
32
|
-
return a;
|
|
33
|
-
}
|
|
23
|
+
var http__namespace = /*#__PURE__*/_interopNamespaceDefault(http);
|
|
34
24
|
|
|
35
25
|
function utf8Count(str) {
|
|
36
26
|
const strLength = str.length;
|
|
@@ -1712,1061 +1702,966 @@ function decode(buffer, options) {
|
|
|
1712
1702
|
const decoder = new Decoder(options);
|
|
1713
1703
|
return decoder.decode(buffer);
|
|
1714
1704
|
}
|
|
1715
|
-
/**
|
|
1716
|
-
* It decodes multiple MessagePack objects in a buffer.
|
|
1717
|
-
* This is corresponding to {@link decodeMultiStream}.
|
|
1718
|
-
*
|
|
1719
|
-
* @throws {@link RangeError} if the buffer is incomplete, including the case where the buffer is empty.
|
|
1720
|
-
* @throws {@link DecodeError} if the buffer contains invalid data.
|
|
1721
|
-
*/
|
|
1722
|
-
function decodeMulti(buffer, options) {
|
|
1723
|
-
const decoder = new Decoder(options);
|
|
1724
|
-
return decoder.decodeMulti(buffer);
|
|
1725
|
-
}
|
|
1726
1705
|
|
|
1727
|
-
//
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1706
|
+
// Custom conflict-free replicated data type system with vector clocks
|
|
1707
|
+
|
|
1708
|
+
const CONSOLE_PREFIX = "PlaySocket CRDT manager: ";
|
|
1709
|
+
|
|
1710
|
+
class CRDTManager {
|
|
1711
|
+
// Storage
|
|
1712
|
+
#replicaId;
|
|
1713
|
+
#keyOperations = new Map();
|
|
1714
|
+
#vectorClock = new Map();
|
|
1715
|
+
|
|
1716
|
+
// Local only
|
|
1717
|
+
#propertyStore = {}; // Current local values per key, as object
|
|
1718
|
+
#lastPropertyStore = {}; // Last property store to compare against
|
|
1719
|
+
|
|
1720
|
+
// Local Garbage Collection
|
|
1721
|
+
#lastGCCheck = 0;
|
|
1722
|
+
#opUuidTimestamp = new Map(); // Map every operation to a timestamp for garbage collection
|
|
1723
|
+
|
|
1724
|
+
// Debug
|
|
1725
|
+
#debug = false;
|
|
1726
|
+
|
|
1727
|
+
/**
|
|
1728
|
+
* Create a new instance
|
|
1729
|
+
*/
|
|
1730
|
+
constructor(debug) {
|
|
1731
|
+
if (debug) this.#debug = true;
|
|
1732
|
+
this.#replicaId = crypto.randomUUID();
|
|
1733
|
+
this.#vectorClock.set(this.#replicaId, 0);
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
/**
|
|
1737
|
+
* Import the entire state of the CRDT manager (this overwrites the old state)
|
|
1738
|
+
* @param {Object} state
|
|
1739
|
+
*/
|
|
1740
|
+
importState(state) {
|
|
1741
|
+
try {
|
|
1742
|
+
const { keyOperations, vectorClock } = state;
|
|
1743
|
+
if (this.#debug) console.log(CONSOLE_PREFIX + "Importing state:", state);
|
|
1744
|
+
|
|
1745
|
+
// Resets
|
|
1746
|
+
this.#opUuidTimestamp.clear();
|
|
1747
|
+
this.#propertyStore = {};
|
|
1748
|
+
this.#lastPropertyStore = {};
|
|
1749
|
+
|
|
1750
|
+
this.#keyOperations = new Map(keyOperations); // Rebuild the operations map
|
|
1751
|
+
this.#vectorClock = new Map(vectorClock); // Rebuild the vector clock map
|
|
1752
|
+
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
|
|
1753
|
+
|
|
1754
|
+
// Map all operation uuids to current timestamp
|
|
1755
|
+
this.#keyOperations.forEach((value, key) => {
|
|
1756
|
+
value?.forEach((operation) => {
|
|
1757
|
+
this.#opUuidTimestamp.set(operation?.uuid, Date.now());
|
|
1758
|
+
});
|
|
1759
|
+
});
|
|
1760
|
+
|
|
1761
|
+
// Process each key to update local values
|
|
1762
|
+
for (const key of this.#keyOperations.keys()) this.#processLocalProperty(key);
|
|
1763
|
+
|
|
1764
|
+
} catch (error) {
|
|
1765
|
+
console.error(CONSOLE_PREFIX + "Failed to import state:", error);
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
/**
|
|
1770
|
+
* Import property update
|
|
1771
|
+
* @param {Object} data - Data to import
|
|
1772
|
+
*/
|
|
1773
|
+
importPropertyUpdate(data) {
|
|
1774
|
+
try {
|
|
1775
|
+
const { key, operation: rawOperation, vectorClock } = data;
|
|
1776
|
+
const operation = this.#sanitizeValue(rawOperation);
|
|
1777
|
+
if (this.#debug) console.log(CONSOLE_PREFIX + "Importing update:", data); // Debug
|
|
1778
|
+
|
|
1779
|
+
// Check key limit to afeguard against too many keys
|
|
1780
|
+
if (this.#keyOperations.size >= 100 && !this.#keyOperations.has(key)) throw new Error('Key limit exceeded!');
|
|
1781
|
+
|
|
1782
|
+
// Get COPY of current ops (or empty array if none yet)
|
|
1783
|
+
const currentOps = [...(this.#keyOperations.get(key) || [])];
|
|
1784
|
+
// Merge vector clocks (always take max value)
|
|
1785
|
+
for (const [id, counter] of vectorClock) {
|
|
1786
|
+
if (!this.#vectorClock.has(id) || this.#vectorClock.get(id) < counter) {
|
|
1787
|
+
this.#vectorClock.set(id, counter);
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
// Safeguard against massive vector clocks
|
|
1792
|
+
if (this.#vectorClock.size > 1000) this.#vectorClock = new Map([...this.#vectorClock].slice(-100));
|
|
1793
|
+
|
|
1794
|
+
// Add new operation if it's not already added
|
|
1795
|
+
const existingUuids = new Set(currentOps.map(op => op.uuid));
|
|
1796
|
+
if (operation.uuid && !existingUuids.has(operation.uuid)) {
|
|
1797
|
+
currentOps.push({ ...operation }); // Add operation
|
|
1798
|
+
this.#opUuidTimestamp.set(operation.uuid, Date.now()); // Add timestamp for gc
|
|
1738
1799
|
}
|
|
1739
|
-
|
|
1800
|
+
|
|
1801
|
+
// Sort, update operations & local value
|
|
1802
|
+
this.#keyOperations.set(key, this.#sortByVectorClock(currentOps));
|
|
1803
|
+
this.#processLocalProperty(key);
|
|
1804
|
+
this.#checkGarbageCollection();
|
|
1805
|
+
|
|
1806
|
+
} catch (error) {
|
|
1807
|
+
console.error(CONSOLE_PREFIX + "Failed to import property update:", error);
|
|
1740
1808
|
}
|
|
1741
1809
|
}
|
|
1742
|
-
|
|
1743
|
-
|
|
1810
|
+
|
|
1811
|
+
/**
|
|
1812
|
+
* Update a property
|
|
1813
|
+
* @param {string} key
|
|
1814
|
+
* @param {string} type
|
|
1815
|
+
* @param {*} value
|
|
1816
|
+
* @param {*} updateValue
|
|
1817
|
+
* @returns {Object} - Returns the property update
|
|
1818
|
+
*/
|
|
1819
|
+
updateProperty(key, type, value, updateValue) {
|
|
1820
|
+
try {
|
|
1821
|
+
// Sanitize inputs
|
|
1822
|
+
value = this.#sanitizeValue(value);
|
|
1823
|
+
updateValue = this.#sanitizeValue(updateValue);
|
|
1824
|
+
|
|
1825
|
+
// Debug log
|
|
1826
|
+
if (this.#debug) console.log(CONSOLE_PREFIX + `Updating property with key ${key}, type ${type}, value ${value} and updateValue ${updateValue}.`);
|
|
1827
|
+
|
|
1828
|
+
// Increment vector clock
|
|
1829
|
+
const counter = this.#vectorClock.get(this.#replicaId) || 0;
|
|
1830
|
+
this.#vectorClock.set(this.#replicaId, counter + 1);
|
|
1831
|
+
|
|
1832
|
+
// Assign shallow COPY of currennt ops or fall back to empty array
|
|
1833
|
+
const currentOps = [...(this.#keyOperations.get(key) || [])];
|
|
1834
|
+
|
|
1835
|
+
// Add operation
|
|
1836
|
+
const newOp = this.#createOperation({ type, value, updateValue }, Array.from(this.#vectorClock.entries()));
|
|
1837
|
+
currentOps.push(newOp);
|
|
1838
|
+
this.#keyOperations.set(key, currentOps); // Update the operations (no need to sort via vector clock since local updates are always the latest)
|
|
1839
|
+
this.#processLocalProperty(key); // Process local value
|
|
1840
|
+
this.#checkGarbageCollection();
|
|
1841
|
+
|
|
1842
|
+
// Return the property update with the new operation (this can be imported using importPropertyUpdate)
|
|
1843
|
+
return {
|
|
1844
|
+
key,
|
|
1845
|
+
operation: { ...newOp },
|
|
1846
|
+
vectorClock: Array.from(this.#vectorClock.entries())
|
|
1847
|
+
};
|
|
1848
|
+
|
|
1849
|
+
} catch (error) {
|
|
1850
|
+
console.error(CONSOLE_PREFIX + `Failed to add operation for key "${key}":`, error);
|
|
1851
|
+
}
|
|
1744
1852
|
}
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1853
|
+
|
|
1854
|
+
#checkGarbageCollection() {
|
|
1855
|
+
const MIN_GC_DELAY = 1000; // Minimum 1s delay between garbage collection runs
|
|
1856
|
+
const MIN_AGE_FOR_GC = 5000; // Garbage collect ops that are older than 5s
|
|
1857
|
+
|
|
1858
|
+
try {
|
|
1859
|
+
if (Date.now() - this.#lastGCCheck < MIN_GC_DELAY) return;
|
|
1860
|
+
this.#lastGCCheck = Date.now();
|
|
1861
|
+
|
|
1862
|
+
for (const [key, operations] of this.#keyOperations.entries()) {
|
|
1863
|
+
if (operations?.length < 5) continue; // Min op amount per key for garbage collection to run (otherwise not worth it)
|
|
1864
|
+
|
|
1865
|
+
let retainCount = operations.length;
|
|
1866
|
+
operations.forEach((op, index) => {
|
|
1867
|
+
// Count how many ops, from last to latest, are older than the min-age in a row
|
|
1868
|
+
if (op.uuid && this.#opUuidTimestamp.has(op.uuid) && (Date.now() - this.#opUuidTimestamp.get(op.uuid)) > MIN_AGE_FOR_GC) {
|
|
1869
|
+
const removeCount = index + 1;
|
|
1870
|
+
retainCount = operations.length - removeCount;
|
|
1871
|
+
this.#opUuidTimestamp.delete(op.uuid); // Remove from timestamps if set to be deleted
|
|
1872
|
+
}
|
|
1873
|
+
});
|
|
1874
|
+
|
|
1875
|
+
if (retainCount < operations.length) {
|
|
1876
|
+
if (this.#debug) console.log(CONSOLE_PREFIX + `Running garbage collection for key ${key} with current operations:`, operations);
|
|
1877
|
+
const retainOps = operations.slice(-retainCount); // Newest ops
|
|
1878
|
+
const removeOps = operations.slice(0, -retainCount); // Oldest ops (start = idx 0, end = retainCount counted from right side)
|
|
1879
|
+
const baselineVectorClock = removeOps[removeOps.length - 1]?.vectorClock || []; // Use the vector clock from the last operation that we remove/overwrite
|
|
1880
|
+
|
|
1881
|
+
// Calculate the value at the point where retained operations start
|
|
1882
|
+
let baselineValue = null;
|
|
1883
|
+
for (const op of removeOps) {
|
|
1884
|
+
if (!op.data) continue;
|
|
1885
|
+
baselineValue = this.#handleOperation(
|
|
1886
|
+
baselineValue,
|
|
1887
|
+
op.data.type,
|
|
1888
|
+
op.data.value,
|
|
1889
|
+
op.data.updateValue
|
|
1890
|
+
);
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
// Create a compact operation with baseline value and appropriate vector clock
|
|
1894
|
+
const compactOp = this.#createOperation({ type: "set", value: baselineValue }, baselineVectorClock);
|
|
1895
|
+
|
|
1896
|
+
// Debug log
|
|
1897
|
+
if (this.#debug) console.log(CONSOLE_PREFIX + "Operations after garbage collection for this key:", [compactOp, ...retainOps]);
|
|
1898
|
+
|
|
1899
|
+
// Combine compact op with retained operations
|
|
1900
|
+
this.#keyOperations.set(key, [compactOp, ...retainOps]);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
} catch (error) {
|
|
1904
|
+
console.error(CONSOLE_PREFIX + "Error running garbage collection:", error);
|
|
1905
|
+
}
|
|
1749
1906
|
}
|
|
1750
|
-
|
|
1751
|
-
|
|
1907
|
+
|
|
1908
|
+
/**
|
|
1909
|
+
* Create an operation object
|
|
1910
|
+
* @param {Object} data
|
|
1911
|
+
* @param {Array} vectorClock
|
|
1912
|
+
* @returns {Object} - Operation object
|
|
1913
|
+
*/
|
|
1914
|
+
#createOperation(data, vectorClock) {
|
|
1915
|
+
const newOperation = {
|
|
1916
|
+
data,
|
|
1917
|
+
vectorClock,
|
|
1918
|
+
source: this.#replicaId,
|
|
1919
|
+
uuid: crypto.randomUUID()
|
|
1920
|
+
};
|
|
1921
|
+
this.#opUuidTimestamp.set(newOperation.uuid, Date.now());
|
|
1922
|
+
return newOperation;
|
|
1752
1923
|
}
|
|
1753
|
-
}
|
|
1754
1924
|
|
|
1755
|
-
/**
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1925
|
+
/**
|
|
1926
|
+
* Process a property's value by applying all operations and set it in the property store
|
|
1927
|
+
* @param {string} key
|
|
1928
|
+
*/
|
|
1929
|
+
#processLocalProperty(key) {
|
|
1930
|
+
try {
|
|
1931
|
+
const ops = this.#keyOperations.get(key);
|
|
1932
|
+
if (!ops?.length) return;
|
|
1933
|
+
|
|
1934
|
+
// Apply all operations in order
|
|
1935
|
+
let value = null;
|
|
1936
|
+
|
|
1937
|
+
for (const op of ops) {
|
|
1938
|
+
if (!op.data) continue;
|
|
1939
|
+
value = this.#handleOperation(
|
|
1940
|
+
value,
|
|
1941
|
+
op.data.type,
|
|
1942
|
+
op.data.value,
|
|
1943
|
+
op.data.updateValue
|
|
1944
|
+
);
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
this.#propertyStore[key] = value; // Save locally
|
|
1948
|
+
} catch (error) {
|
|
1949
|
+
console.error(CONSOLE_PREFIX + `Failed to process property for key ${key}:`, error);
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
/**
|
|
1954
|
+
* Sort by vector clock (causal order)
|
|
1955
|
+
* @param {Array} operations
|
|
1956
|
+
* @returns {Array} - Sorted operations
|
|
1957
|
+
*/
|
|
1958
|
+
#sortByVectorClock(operations) {
|
|
1959
|
+
return [...operations].sort((a, b) => {
|
|
1960
|
+
const clockA = new Map(a.vectorClock || []);
|
|
1961
|
+
const clockB = new Map(b.vectorClock || []);
|
|
1962
|
+
|
|
1963
|
+
// First sort by causal relationship
|
|
1964
|
+
let aGreater = false, bGreater = false;
|
|
1965
|
+
|
|
1966
|
+
const allIds = new Set([...clockA.keys(), ...clockB.keys()]);
|
|
1967
|
+
for (const id of allIds) {
|
|
1968
|
+
const countA = clockA.get(id) || 0;
|
|
1969
|
+
const countB = clockB.get(id) || 0;
|
|
1970
|
+
|
|
1971
|
+
if (countA > countB) aGreater = true;
|
|
1972
|
+
if (countA < countB) bGreater = true;
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
// Determine causal relationship (if one is greater, and the other is smaller, it's clear)
|
|
1976
|
+
if (aGreater && !bGreater) return 1; // a happens after b
|
|
1977
|
+
if (!aGreater && bGreater) return -1; // a happens before b
|
|
1978
|
+
|
|
1979
|
+
// Concurrent (unclear): Compare highest counter in each (sort by which one is ahead)
|
|
1980
|
+
const maxA = Math.max(...Array.from(clockA.values()), 0);
|
|
1981
|
+
const maxB = Math.max(...Array.from(clockB.values()), 0);
|
|
1982
|
+
if (maxA !== maxB) return maxA - maxB;
|
|
1983
|
+
|
|
1984
|
+
// Final tiebreak: Sort by ID of Replica that actually generated the operation
|
|
1985
|
+
return a.source.localeCompare(b.source);
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
/**
|
|
1990
|
+
* Handle an operation
|
|
1991
|
+
* @param {*} curValue
|
|
1992
|
+
* @param {string} type
|
|
1993
|
+
* @param {*} value
|
|
1994
|
+
* @param {*} [updateValue]
|
|
1995
|
+
* @returns {*} - Value after the operation
|
|
1996
|
+
*/
|
|
1997
|
+
#handleOperation(curValue, type, value, updateValue) {
|
|
1998
|
+
try {
|
|
1999
|
+
// Deep copy to avoid reference issues in case value is or contains object(s)
|
|
2000
|
+
curValue = structuredClone(curValue);
|
|
2001
|
+
|
|
2002
|
+
// Set operation
|
|
2003
|
+
if (type === "set") return value;
|
|
2004
|
+
|
|
2005
|
+
// Array operations
|
|
2006
|
+
if (type.startsWith('array')) {
|
|
2007
|
+
if (!Array.isArray(curValue)) curValue = []; // Auto-convert to array if current value isn't one
|
|
2008
|
+
|
|
2009
|
+
// Comparison function
|
|
2010
|
+
const isObject = typeof value === 'object' && value !== null;
|
|
2011
|
+
const compare = (item) => {
|
|
2012
|
+
if (isObject && typeof item === 'object' && item !== null) return JSON.stringify(item) === JSON.stringify(value); // Deep comparison
|
|
2013
|
+
return item === value; // Value comparison
|
|
2014
|
+
};
|
|
2015
|
+
|
|
2016
|
+
switch (type) {
|
|
2017
|
+
case 'array-add':
|
|
2018
|
+
curValue.push(value);
|
|
2019
|
+
break;
|
|
2020
|
+
case 'array-add-unique':
|
|
2021
|
+
if (!curValue.some(compare)) curValue.push(value);
|
|
2022
|
+
break;
|
|
2023
|
+
case 'array-remove-matching':
|
|
2024
|
+
curValue = curValue.filter(item => !compare(item));
|
|
2025
|
+
break;
|
|
2026
|
+
case 'array-update-matching':
|
|
2027
|
+
const index = curValue.findIndex(compare);
|
|
2028
|
+
if (index !== -1) curValue[index] = updateValue;
|
|
2029
|
+
break;
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
return curValue;
|
|
2034
|
+
|
|
2035
|
+
} catch (error) {
|
|
2036
|
+
console.error(CONSOLE_PREFIX + `Failed to handle operation with curValue ${curValue}:`, error);
|
|
2037
|
+
return curValue;
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
/**
|
|
2042
|
+
* Remove HTML to prevent XSS and enforce size limits
|
|
2043
|
+
* @param {Object} obj
|
|
2044
|
+
* @returns {Object} - Sanitized object
|
|
2045
|
+
*/
|
|
2046
|
+
#sanitizeValue(obj) {
|
|
2047
|
+
// Check total serialized size
|
|
2048
|
+
const jsonString = JSON.stringify(obj);
|
|
2049
|
+
if (jsonString?.length > 50000) throw new Error('Value too large!'); // 50KB limit
|
|
2050
|
+
|
|
2051
|
+
if (typeof obj === 'string') return (obj.includes('<') || obj.includes('>')) ? obj.replace(/[<>]/g, '') : obj;
|
|
2052
|
+
if (Array.isArray(obj)) return obj.map(item => this.#sanitizeValue(item));
|
|
2053
|
+
if (obj && typeof obj === 'object') return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, this.#sanitizeValue(v)]));
|
|
2054
|
+
return obj;
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
// Get property store
|
|
2058
|
+
get getPropertyStore() {
|
|
2059
|
+
try {
|
|
2060
|
+
return structuredClone(this.#propertyStore); // Return deep clone
|
|
2061
|
+
} catch {
|
|
2062
|
+
console.error(CONSOLE_PREFIX + "Failed to parse property store");
|
|
2063
|
+
return {};
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
// Check for changes in the local property store
|
|
2068
|
+
get didPropertiesChange() {
|
|
2069
|
+
try {
|
|
2070
|
+
if (JSON.stringify(this.#propertyStore) !== JSON.stringify(this.#lastPropertyStore)) {
|
|
2071
|
+
this.#lastPropertyStore = structuredClone(this.#propertyStore);
|
|
2072
|
+
return true;
|
|
2073
|
+
}
|
|
2074
|
+
} catch { }
|
|
2075
|
+
return false;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
// Get state (can be imported using importState, converts the maps to arrays for serialization)
|
|
2079
|
+
get getState() {
|
|
2080
|
+
return {
|
|
2081
|
+
keyOperations: [...this.#keyOperations.entries()],
|
|
2082
|
+
vectorClock: [...this.#vectorClock.entries()]
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
1772
2085
|
}
|
|
2086
|
+
|
|
1773
2087
|
/**
|
|
1774
|
-
*
|
|
1775
|
-
* @throws {@link DecodeError} if the buffer contains invalid data.
|
|
2088
|
+
* PlaySocketServer - WebSocket server for PlaySocket multiplayer library
|
|
1776
2089
|
*/
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
2090
|
+
class PlaySocketServer {
|
|
2091
|
+
#server;
|
|
2092
|
+
#ownsServer = false;
|
|
2093
|
+
#rateLimitMaxPoints;
|
|
2094
|
+
#wss;
|
|
2095
|
+
#clients = new Map(); // ClientId -> WebSocket instance
|
|
2096
|
+
#rooms = {};
|
|
2097
|
+
#clientRooms = new Map(); // ClientId -> RoomId
|
|
2098
|
+
#rateLimits = new Map(); // Rate limiting storage
|
|
2099
|
+
#callbacks = new Map(); // Event -> [callback functions]
|
|
2100
|
+
#heartbeatInterval;
|
|
2101
|
+
#pendingDisconnects = new Map(); // ClientId -> {timeout, roomId}
|
|
2102
|
+
#clientTokens = new Map(); // ClientId -> Token
|
|
2103
|
+
#roomVersions = new Map(); // RoomId -> Version
|
|
2104
|
+
|
|
2105
|
+
// Debug
|
|
2106
|
+
#debug = false;
|
|
1782
2107
|
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
ExtensionCodec: ExtensionCodec,
|
|
1793
|
-
decode: decode,
|
|
1794
|
-
decodeArrayStream: decodeArrayStream,
|
|
1795
|
-
decodeAsync: decodeAsync,
|
|
1796
|
-
decodeMulti: decodeMulti,
|
|
1797
|
-
decodeMultiStream: decodeMultiStream,
|
|
1798
|
-
decodeTimestampExtension: decodeTimestampExtension,
|
|
1799
|
-
decodeTimestampToTimeSpec: decodeTimestampToTimeSpec,
|
|
1800
|
-
encode: encode,
|
|
1801
|
-
encodeDateToTimeSpec: encodeDateToTimeSpec,
|
|
1802
|
-
encodeTimeSpecToTimestamp: encodeTimeSpecToTimestamp,
|
|
1803
|
-
encodeTimestampExtension: encodeTimestampExtension
|
|
1804
|
-
});
|
|
1805
|
-
|
|
1806
|
-
var require$$2 = /*@__PURE__*/getAugmentedNamespace(dist_esm);
|
|
1807
|
-
|
|
1808
|
-
var crdtManager;
|
|
1809
|
-
var hasRequiredCrdtManager;
|
|
1810
|
-
|
|
1811
|
-
function requireCrdtManager () {
|
|
1812
|
-
if (hasRequiredCrdtManager) return crdtManager;
|
|
1813
|
-
hasRequiredCrdtManager = 1;
|
|
1814
|
-
// Custom conflict-free replicated data type system with vector clocks
|
|
1815
|
-
|
|
1816
|
-
const CONSOLE_PREFIX = "PlaySocket CRDT manager: ";
|
|
1817
|
-
|
|
1818
|
-
class CRDTManager {
|
|
1819
|
-
// Storage
|
|
1820
|
-
#replicaId;
|
|
1821
|
-
#keyOperations = new Map();
|
|
1822
|
-
#vectorClock = new Map();
|
|
1823
|
-
|
|
1824
|
-
// Local only
|
|
1825
|
-
#propertyStore = {}; // Current local values per key, as object
|
|
1826
|
-
#lastPropertyStore = {}; // Last property store to compare against
|
|
1827
|
-
|
|
1828
|
-
// Local Garbage Collection
|
|
1829
|
-
#lastGCCheck = 0;
|
|
1830
|
-
#opUuidTimestamp = new Map(); // Map every operation to a timestamp for garbage collection
|
|
1831
|
-
|
|
1832
|
-
// Debug
|
|
1833
|
-
#debug = false;
|
|
1834
|
-
|
|
1835
|
-
/**
|
|
1836
|
-
* Create a new instance
|
|
1837
|
-
*/
|
|
1838
|
-
constructor(debug) {
|
|
1839
|
-
if (debug) this.#debug = true;
|
|
1840
|
-
this.#replicaId = crypto.randomUUID();
|
|
1841
|
-
this.#vectorClock.set(this.#replicaId, 0);
|
|
1842
|
-
}
|
|
1843
|
-
|
|
1844
|
-
/**
|
|
1845
|
-
* Import the entire state of the CRDT manager (this overwrites the old state)
|
|
1846
|
-
* @param {Object} state
|
|
1847
|
-
*/
|
|
1848
|
-
importState(state) {
|
|
1849
|
-
try {
|
|
1850
|
-
const { keyOperations, vectorClock } = state;
|
|
1851
|
-
if (this.#debug) console.log(CONSOLE_PREFIX + "Importing state:", state);
|
|
1852
|
-
|
|
1853
|
-
// Resets
|
|
1854
|
-
this.#opUuidTimestamp.clear();
|
|
1855
|
-
this.#propertyStore = {};
|
|
1856
|
-
this.#lastPropertyStore = {};
|
|
1857
|
-
|
|
1858
|
-
this.#keyOperations = new Map(keyOperations); // Rebuild the operations map
|
|
1859
|
-
this.#vectorClock = new Map(vectorClock); // Rebuild the vector clock map
|
|
1860
|
-
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
|
|
1861
|
-
|
|
1862
|
-
// Map all operation uuids to current timestamp
|
|
1863
|
-
this.#keyOperations.forEach((value, key) => {
|
|
1864
|
-
value?.forEach((operation) => {
|
|
1865
|
-
this.#opUuidTimestamp.set(operation?.uuid, Date.now());
|
|
1866
|
-
});
|
|
1867
|
-
});
|
|
1868
|
-
|
|
1869
|
-
// Process each key to update local values
|
|
1870
|
-
for (const key of this.#keyOperations.keys()) this.#processLocalProperty(key);
|
|
1871
|
-
|
|
1872
|
-
} catch (error) {
|
|
1873
|
-
console.error(CONSOLE_PREFIX + "Failed to import state:", error);
|
|
1874
|
-
}
|
|
1875
|
-
}
|
|
1876
|
-
|
|
1877
|
-
/**
|
|
1878
|
-
* Import property update
|
|
1879
|
-
* @param {Object} data - Data to import
|
|
1880
|
-
*/
|
|
1881
|
-
importPropertyUpdate(data) {
|
|
1882
|
-
try {
|
|
1883
|
-
const { key, operation: rawOperation, vectorClock } = data;
|
|
1884
|
-
const operation = this.#sanitizeValue(rawOperation);
|
|
1885
|
-
if (this.#debug) console.log(CONSOLE_PREFIX + "Importing update:", data); // Debug
|
|
1886
|
-
|
|
1887
|
-
// Check key limit to afeguard against too many keys
|
|
1888
|
-
if (this.#keyOperations.size >= 100 && !this.#keyOperations.has(key)) throw new Error('Key limit exceeded!');
|
|
1889
|
-
|
|
1890
|
-
// Get COPY of current ops (or empty array if none yet)
|
|
1891
|
-
const currentOps = [...(this.#keyOperations.get(key) || [])];
|
|
1892
|
-
// Merge vector clocks (always take max value)
|
|
1893
|
-
for (const [id, counter] of vectorClock) {
|
|
1894
|
-
if (!this.#vectorClock.has(id) || this.#vectorClock.get(id) < counter) {
|
|
1895
|
-
this.#vectorClock.set(id, counter);
|
|
1896
|
-
}
|
|
1897
|
-
}
|
|
1898
|
-
|
|
1899
|
-
// Safeguard against massive vector clocks
|
|
1900
|
-
if (this.#vectorClock.size > 1000) this.#vectorClock = new Map([...this.#vectorClock].slice(-100));
|
|
1901
|
-
|
|
1902
|
-
// Add new operation if it's not already added
|
|
1903
|
-
const existingUuids = new Set(currentOps.map(op => op.uuid));
|
|
1904
|
-
if (operation.uuid && !existingUuids.has(operation.uuid)) {
|
|
1905
|
-
currentOps.push({ ...operation }); // Add operation
|
|
1906
|
-
this.#opUuidTimestamp.set(operation.uuid, Date.now()); // Add timestamp for gc
|
|
1907
|
-
}
|
|
1908
|
-
|
|
1909
|
-
// Sort, update operations & local value
|
|
1910
|
-
this.#keyOperations.set(key, this.#sortByVectorClock(currentOps));
|
|
1911
|
-
this.#processLocalProperty(key);
|
|
1912
|
-
this.#checkGarbageCollection();
|
|
1913
|
-
|
|
1914
|
-
} catch (error) {
|
|
1915
|
-
console.error(CONSOLE_PREFIX + "Failed to import property update:", error);
|
|
1916
|
-
}
|
|
1917
|
-
}
|
|
1918
|
-
|
|
1919
|
-
/**
|
|
1920
|
-
* Update a property
|
|
1921
|
-
* @param {string} key
|
|
1922
|
-
* @param {string} type
|
|
1923
|
-
* @param {*} value
|
|
1924
|
-
* @param {*} updateValue
|
|
1925
|
-
* @returns {Object} - Returns the property update
|
|
1926
|
-
*/
|
|
1927
|
-
updateProperty(key, type, value, updateValue) {
|
|
1928
|
-
try {
|
|
1929
|
-
// Sanitize inputs
|
|
1930
|
-
value = this.#sanitizeValue(value);
|
|
1931
|
-
updateValue = this.#sanitizeValue(updateValue);
|
|
1932
|
-
|
|
1933
|
-
// Debug log
|
|
1934
|
-
if (this.#debug) console.log(CONSOLE_PREFIX + `Updating property with key ${key}, type ${type}, value ${value} and updateValue ${updateValue}.`);
|
|
1935
|
-
|
|
1936
|
-
// Increment vector clock
|
|
1937
|
-
const counter = this.#vectorClock.get(this.#replicaId) || 0;
|
|
1938
|
-
this.#vectorClock.set(this.#replicaId, counter + 1);
|
|
1939
|
-
|
|
1940
|
-
// Assign shallow COPY of currennt ops or fall back to empty array
|
|
1941
|
-
const currentOps = [...(this.#keyOperations.get(key) || [])];
|
|
1942
|
-
|
|
1943
|
-
// Add operation
|
|
1944
|
-
const newOp = this.#createOperation({ type, value, updateValue }, Array.from(this.#vectorClock.entries()));
|
|
1945
|
-
currentOps.push(newOp);
|
|
1946
|
-
this.#keyOperations.set(key, currentOps); // Update the operations (no need to sort via vector clock since local updates are always the latest)
|
|
1947
|
-
this.#processLocalProperty(key); // Process local value
|
|
1948
|
-
this.#checkGarbageCollection();
|
|
1949
|
-
|
|
1950
|
-
// Return the property update with the new operation (this can be imported using importPropertyUpdate)
|
|
1951
|
-
return {
|
|
1952
|
-
key,
|
|
1953
|
-
operation: { ...newOp },
|
|
1954
|
-
vectorClock: Array.from(this.#vectorClock.entries())
|
|
1955
|
-
};
|
|
1956
|
-
|
|
1957
|
-
} catch (error) {
|
|
1958
|
-
console.error(CONSOLE_PREFIX + `Failed to add operation for key "${key}":`, error);
|
|
1959
|
-
}
|
|
1960
|
-
}
|
|
1961
|
-
|
|
1962
|
-
#checkGarbageCollection() {
|
|
1963
|
-
const MIN_GC_DELAY = 1000; // Minimum 1s delay between garbage collection runs
|
|
1964
|
-
const MIN_AGE_FOR_GC = 5000; // Garbage collect ops that are older than 5s
|
|
1965
|
-
|
|
1966
|
-
try {
|
|
1967
|
-
if (Date.now() - this.#lastGCCheck < MIN_GC_DELAY) return;
|
|
1968
|
-
this.#lastGCCheck = Date.now();
|
|
1969
|
-
|
|
1970
|
-
for (const [key, operations] of this.#keyOperations.entries()) {
|
|
1971
|
-
if (operations?.length < 5) continue; // Min op amount per key for garbage collection to run (otherwise not worth it)
|
|
1972
|
-
|
|
1973
|
-
let retainCount = operations.length;
|
|
1974
|
-
operations.forEach((op, index) => {
|
|
1975
|
-
// Count how many ops, from last to latest, are older than the min-age in a row
|
|
1976
|
-
if (op.uuid && this.#opUuidTimestamp.has(op.uuid) && (Date.now() - this.#opUuidTimestamp.get(op.uuid)) > MIN_AGE_FOR_GC) {
|
|
1977
|
-
const removeCount = index + 1;
|
|
1978
|
-
retainCount = operations.length - removeCount;
|
|
1979
|
-
this.#opUuidTimestamp.delete(op.uuid); // Remove from timestamps if set to be deleted
|
|
1980
|
-
}
|
|
1981
|
-
});
|
|
1982
|
-
|
|
1983
|
-
if (retainCount < operations.length) {
|
|
1984
|
-
if (this.#debug) console.log(CONSOLE_PREFIX + `Running garbage collection for key ${key} with current operations:`, operations);
|
|
1985
|
-
const retainOps = operations.slice(-retainCount); // Newest ops
|
|
1986
|
-
const removeOps = operations.slice(0, -retainCount); // Oldest ops (start = idx 0, end = retainCount counted from right side)
|
|
1987
|
-
const baselineVectorClock = removeOps[removeOps.length - 1]?.vectorClock || []; // Use the vector clock from the last operation that we remove/overwrite
|
|
1988
|
-
|
|
1989
|
-
// Calculate the value at the point where retained operations start
|
|
1990
|
-
let baselineValue = null;
|
|
1991
|
-
for (const op of removeOps) {
|
|
1992
|
-
if (!op.data) continue;
|
|
1993
|
-
baselineValue = this.#handleOperation(
|
|
1994
|
-
baselineValue,
|
|
1995
|
-
op.data.type,
|
|
1996
|
-
op.data.value,
|
|
1997
|
-
op.data.updateValue
|
|
1998
|
-
);
|
|
1999
|
-
}
|
|
2000
|
-
|
|
2001
|
-
// Create a compact operation with baseline value and appropriate vector clock
|
|
2002
|
-
const compactOp = this.#createOperation({ type: "set", value: baselineValue }, baselineVectorClock);
|
|
2003
|
-
|
|
2004
|
-
// Debug log
|
|
2005
|
-
if (this.#debug) console.log(CONSOLE_PREFIX + "Operations after garbage collection for this key:", [compactOp, ...retainOps]);
|
|
2006
|
-
|
|
2007
|
-
// Combine compact op with retained operations
|
|
2008
|
-
this.#keyOperations.set(key, [compactOp, ...retainOps]);
|
|
2009
|
-
}
|
|
2010
|
-
}
|
|
2011
|
-
} catch (error) {
|
|
2012
|
-
console.error(CONSOLE_PREFIX + "Error running garbage collection:", error);
|
|
2013
|
-
}
|
|
2014
|
-
}
|
|
2015
|
-
|
|
2016
|
-
/**
|
|
2017
|
-
* Create an operation object
|
|
2018
|
-
* @param {Object} data
|
|
2019
|
-
* @param {Array} vectorClock
|
|
2020
|
-
* @returns {Object} - Operation object
|
|
2021
|
-
*/
|
|
2022
|
-
#createOperation(data, vectorClock) {
|
|
2023
|
-
const newOperation = {
|
|
2024
|
-
data,
|
|
2025
|
-
vectorClock,
|
|
2026
|
-
source: this.#replicaId,
|
|
2027
|
-
uuid: crypto.randomUUID()
|
|
2028
|
-
};
|
|
2029
|
-
this.#opUuidTimestamp.set(newOperation.uuid, Date.now());
|
|
2030
|
-
return newOperation;
|
|
2031
|
-
}
|
|
2032
|
-
|
|
2033
|
-
/**
|
|
2034
|
-
* Process a property's value by applying all operations and set it in the property store
|
|
2035
|
-
* @param {string} key
|
|
2036
|
-
*/
|
|
2037
|
-
#processLocalProperty(key) {
|
|
2038
|
-
try {
|
|
2039
|
-
const ops = this.#keyOperations.get(key);
|
|
2040
|
-
if (!ops?.length) return;
|
|
2041
|
-
|
|
2042
|
-
// Apply all operations in order
|
|
2043
|
-
let value = null;
|
|
2044
|
-
|
|
2045
|
-
for (const op of ops) {
|
|
2046
|
-
if (!op.data) continue;
|
|
2047
|
-
value = this.#handleOperation(
|
|
2048
|
-
value,
|
|
2049
|
-
op.data.type,
|
|
2050
|
-
op.data.value,
|
|
2051
|
-
op.data.updateValue
|
|
2052
|
-
);
|
|
2053
|
-
}
|
|
2054
|
-
|
|
2055
|
-
this.#propertyStore[key] = value; // Save locally
|
|
2056
|
-
} catch (error) {
|
|
2057
|
-
console.error(CONSOLE_PREFIX + `Failed to process property for key ${key}:`, error);
|
|
2058
|
-
}
|
|
2059
|
-
}
|
|
2060
|
-
|
|
2061
|
-
/**
|
|
2062
|
-
* Sort by vector clock (causal order)
|
|
2063
|
-
* @param {Array} operations
|
|
2064
|
-
* @returns {Array} - Sorted operations
|
|
2065
|
-
*/
|
|
2066
|
-
#sortByVectorClock(operations) {
|
|
2067
|
-
return [...operations].sort((a, b) => {
|
|
2068
|
-
const clockA = new Map(a.vectorClock || []);
|
|
2069
|
-
const clockB = new Map(b.vectorClock || []);
|
|
2070
|
-
|
|
2071
|
-
// First sort by causal relationship
|
|
2072
|
-
let aGreater = false, bGreater = false;
|
|
2073
|
-
|
|
2074
|
-
const allIds = new Set([...clockA.keys(), ...clockB.keys()]);
|
|
2075
|
-
for (const id of allIds) {
|
|
2076
|
-
const countA = clockA.get(id) || 0;
|
|
2077
|
-
const countB = clockB.get(id) || 0;
|
|
2078
|
-
|
|
2079
|
-
if (countA > countB) aGreater = true;
|
|
2080
|
-
if (countA < countB) bGreater = true;
|
|
2081
|
-
}
|
|
2082
|
-
|
|
2083
|
-
// Determine causal relationship (if one is greater, and the other is smaller, it's clear)
|
|
2084
|
-
if (aGreater && !bGreater) return 1; // a happens after b
|
|
2085
|
-
if (!aGreater && bGreater) return -1; // a happens before b
|
|
2086
|
-
|
|
2087
|
-
// Concurrent (unclear): Compare highest counter in each (sort by which one is ahead)
|
|
2088
|
-
const maxA = Math.max(...Array.from(clockA.values()), 0);
|
|
2089
|
-
const maxB = Math.max(...Array.from(clockB.values()), 0);
|
|
2090
|
-
if (maxA !== maxB) return maxA - maxB;
|
|
2091
|
-
|
|
2092
|
-
// Final tiebreak: Sort by ID of Replica that actually generated the operation
|
|
2093
|
-
return a.source.localeCompare(b.source);
|
|
2094
|
-
});
|
|
2095
|
-
}
|
|
2096
|
-
|
|
2097
|
-
/**
|
|
2098
|
-
* Handle an operation
|
|
2099
|
-
* @param {*} curValue
|
|
2100
|
-
* @param {string} type
|
|
2101
|
-
* @param {*} value
|
|
2102
|
-
* @param {*} [updateValue]
|
|
2103
|
-
* @returns {*} - Value after the operation
|
|
2104
|
-
*/
|
|
2105
|
-
#handleOperation(curValue, type, value, updateValue) {
|
|
2106
|
-
try {
|
|
2107
|
-
// Deep copy to avoid reference issues in case value is or contains object(s)
|
|
2108
|
-
curValue = structuredClone(curValue);
|
|
2109
|
-
|
|
2110
|
-
// Set operation
|
|
2111
|
-
if (type === "set") return value;
|
|
2112
|
-
|
|
2113
|
-
// Array operations
|
|
2114
|
-
if (type.startsWith('array')) {
|
|
2115
|
-
if (!Array.isArray(curValue)) curValue = []; // Auto-convert to array if current value isn't one
|
|
2116
|
-
|
|
2117
|
-
// Comparison function
|
|
2118
|
-
const isObject = typeof value === 'object' && value !== null;
|
|
2119
|
-
const compare = (item) => {
|
|
2120
|
-
if (isObject && typeof item === 'object' && item !== null) return JSON.stringify(item) === JSON.stringify(value); // Deep comparison
|
|
2121
|
-
return item === value; // Value comparison
|
|
2122
|
-
};
|
|
2123
|
-
|
|
2124
|
-
switch (type) {
|
|
2125
|
-
case 'array-add':
|
|
2126
|
-
curValue.push(value);
|
|
2127
|
-
break;
|
|
2128
|
-
case 'array-add-unique':
|
|
2129
|
-
if (!curValue.some(compare)) curValue.push(value);
|
|
2130
|
-
break;
|
|
2131
|
-
case 'array-remove-matching':
|
|
2132
|
-
curValue = curValue.filter(item => !compare(item));
|
|
2133
|
-
break;
|
|
2134
|
-
case 'array-update-matching':
|
|
2135
|
-
const index = curValue.findIndex(compare);
|
|
2136
|
-
if (index !== -1) curValue[index] = updateValue;
|
|
2137
|
-
break;
|
|
2138
|
-
}
|
|
2139
|
-
}
|
|
2140
|
-
|
|
2141
|
-
return curValue;
|
|
2142
|
-
|
|
2143
|
-
} catch (error) {
|
|
2144
|
-
console.error(CONSOLE_PREFIX + `Failed to handle operation with curValue ${curValue}:`, error);
|
|
2145
|
-
return curValue;
|
|
2146
|
-
}
|
|
2147
|
-
}
|
|
2148
|
-
|
|
2149
|
-
/**
|
|
2150
|
-
* Remove HTML to prevent XSS and enforce size limits
|
|
2151
|
-
* @param {Object} obj
|
|
2152
|
-
* @returns {Object} - Sanitized object
|
|
2153
|
-
*/
|
|
2154
|
-
#sanitizeValue(obj) {
|
|
2155
|
-
// Check total serialized size
|
|
2156
|
-
const jsonString = JSON.stringify(obj);
|
|
2157
|
-
if (jsonString?.length > 50000) throw new Error('Value too large!'); // 50KB limit
|
|
2158
|
-
|
|
2159
|
-
if (typeof obj === 'string') return (obj.includes('<') || obj.includes('>')) ? obj.replace(/[<>]/g, '') : obj;
|
|
2160
|
-
if (Array.isArray(obj)) return obj.map(item => this.#sanitizeValue(item));
|
|
2161
|
-
if (obj && typeof obj === 'object') return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, this.#sanitizeValue(v)]));
|
|
2162
|
-
return obj;
|
|
2163
|
-
}
|
|
2164
|
-
|
|
2165
|
-
// Get property store
|
|
2166
|
-
get getPropertyStore() {
|
|
2167
|
-
try {
|
|
2168
|
-
return structuredClone(this.#propertyStore); // Return deep clone
|
|
2169
|
-
} catch {
|
|
2170
|
-
console.error(CONSOLE_PREFIX + "Failed to parse property store");
|
|
2171
|
-
return {};
|
|
2172
|
-
}
|
|
2173
|
-
}
|
|
2174
|
-
|
|
2175
|
-
// Check for changes in the local property store
|
|
2176
|
-
get didPropertiesChange() {
|
|
2177
|
-
try {
|
|
2178
|
-
if (JSON.stringify(this.#propertyStore) !== JSON.stringify(this.#lastPropertyStore)) {
|
|
2179
|
-
this.#lastPropertyStore = structuredClone(this.#propertyStore);
|
|
2180
|
-
return true;
|
|
2181
|
-
}
|
|
2182
|
-
} catch { }
|
|
2183
|
-
return false;
|
|
2184
|
-
}
|
|
2185
|
-
|
|
2186
|
-
// Get state (can be imported using importState, converts the maps to arrays for serialization)
|
|
2187
|
-
get getState() {
|
|
2188
|
-
return {
|
|
2189
|
-
keyOperations: [...this.#keyOperations.entries()],
|
|
2190
|
-
vectorClock: [...this.#vectorClock.entries()]
|
|
2191
|
-
};
|
|
2192
|
-
}
|
|
2193
|
-
}
|
|
2194
|
-
|
|
2195
|
-
crdtManager = { CRDTManager };
|
|
2196
|
-
return crdtManager;
|
|
2197
|
-
}
|
|
2108
|
+
/**
|
|
2109
|
+
* Create a new PlaySocketServer instance
|
|
2110
|
+
* @param {Object} options - Server configuration options
|
|
2111
|
+
* @param {HttpServer} [options.server] - Existing http server
|
|
2112
|
+
* @param {number} [options.port=3000] - Port to listen on (if no server provided)
|
|
2113
|
+
* @param {string} [options.path='/socket'] - WebSocket endpoint path
|
|
2114
|
+
*/
|
|
2115
|
+
constructor(options = {}) {
|
|
2116
|
+
const { server, port = 3000, path = '/', debug = false, rateLimit = 20 } = options;
|
|
2198
2117
|
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
}
|
|
2338
|
-
|
|
2339
|
-
ws.clientId = data.id; // Adds the provided id to the ws object as clientId
|
|
2340
|
-
this.#clients.set(data.id, ws);
|
|
2341
|
-
const sessionToken = this.#generateSessionToken();
|
|
2342
|
-
this.#clientTokens.set(data.id, sessionToken); // Token is used when reconnecting to prevent impersonation attacks
|
|
2343
|
-
ws.send(encode({ type: 'registered', id: data.id, sessionToken }), { binary: true });
|
|
2344
|
-
this.#triggerEvent("clientRegistered", data.id, data.customData);
|
|
2345
|
-
break;
|
|
2346
|
-
|
|
2347
|
-
case 'reconnect':
|
|
2348
|
-
// If user is pending disconnect, respond (otherwise it's too late)
|
|
2349
|
-
const pd = this.#pendingDisconnects.get(data.id);
|
|
2350
|
-
if (pd && data.sessionToken) {
|
|
2351
|
-
if (data.sessionToken !== this.#clientTokens.get(data.id)) {
|
|
2352
|
-
ws.send(encode({
|
|
2353
|
-
type: 'reconnection_failed',
|
|
2354
|
-
reason: "Session token does not match."
|
|
2355
|
-
}), { binary: true });
|
|
2356
|
-
return;
|
|
2357
|
-
}
|
|
2358
|
-
clearTimeout(pd.timeout);
|
|
2359
|
-
this.#pendingDisconnects.delete(data.id);
|
|
2360
|
-
|
|
2361
|
-
// Re-assign old client ID to the new ws connection
|
|
2362
|
-
ws.clientId = data.id;
|
|
2363
|
-
this.#clients.set(data.id, ws);
|
|
2364
|
-
|
|
2365
|
-
// If they were in a room, provide updated room data
|
|
2366
|
-
let roomData;
|
|
2367
|
-
const formerRoomId = this.#clientRooms.get(data.id);
|
|
2368
|
-
const formerRoom = this.#rooms[formerRoomId];
|
|
2369
|
-
if (formerRoom) {
|
|
2370
|
-
if (this.#debug) console.log(`State sent for reconnection for room ${formerRoomId}:`, formerRoom.crdtManager.getState);
|
|
2371
|
-
roomData = {
|
|
2372
|
-
state: formerRoom.crdtManager.getState,
|
|
2373
|
-
participantCount: formerRoom.participants.length,
|
|
2374
|
-
host: formerRoom.host,
|
|
2375
|
-
version: this.#roomVersions.get(formerRoomId)
|
|
2376
|
-
};
|
|
2377
|
-
}
|
|
2378
|
-
|
|
2379
|
-
ws.send(encode({ type: 'reconnected', roomData }), { binary: true });
|
|
2380
|
-
} else {
|
|
2381
|
-
ws.send(encode({ type: 'reconnection_failed', reason: "Client unknown to server." }), { binary: true });
|
|
2382
|
-
}
|
|
2383
|
-
break;
|
|
2384
|
-
|
|
2385
|
-
case 'create_room':
|
|
2386
|
-
if (!ws.clientId) return;
|
|
2387
|
-
const newRoomId = ws.clientId;
|
|
2388
|
-
|
|
2389
|
-
if (this.#clientRooms.get(ws.clientId) || this.#rooms[newRoomId]) {
|
|
2390
|
-
ws.send(encode({
|
|
2391
|
-
type: 'room_creation_failed',
|
|
2392
|
-
reason: this.#clientRooms.get(ws.clientId) ? 'Already in a room.' : 'Room ID is taken.'
|
|
2393
|
-
}), { binary: true });
|
|
2394
|
-
return;
|
|
2395
|
-
}
|
|
2396
|
-
|
|
2397
|
-
const roomCrdtManager = new CRDTManager(this.#debug); // Create the room's crdt manager
|
|
2398
|
-
|
|
2399
|
-
// Event callback with potential initial storage modifications
|
|
2400
|
-
const reviewedStorage = await this.#triggerEvent("roomCreationRequested", { roomId: newRoomId, clientId: ws.clientId, initialStorage: structuredClone({ ...data.initialStorage }) });
|
|
2401
|
-
if (typeof reviewedStorage === 'object') data.initialStorage = reviewedStorage;
|
|
2402
|
-
if (reviewedStorage === false) {
|
|
2403
|
-
ws.send(encode({
|
|
2404
|
-
type: 'room_creation_failed',
|
|
2405
|
-
reason: 'Room creation denied.'
|
|
2406
|
-
}), { binary: true });
|
|
2407
|
-
return;
|
|
2408
|
-
}
|
|
2409
|
-
|
|
2410
|
-
// Check if client/creator is still connected
|
|
2411
|
-
if (!this.#clients.has(ws.clientId)) {
|
|
2412
|
-
if (this.#debug) console.log(`Room creation cancelled - client ${ws.clientId} disconnected during event callback.`);
|
|
2413
|
-
return; // Abort room creation if client is no longer connected
|
|
2414
|
-
}
|
|
2415
|
-
|
|
2416
|
-
// Load initial storage if provided
|
|
2417
|
-
if (data.initialStorage) {
|
|
2418
|
-
Object.entries(data.initialStorage)?.forEach(([key, value]) => {
|
|
2419
|
-
roomCrdtManager.updateProperty(key, "set", value);
|
|
2420
|
-
});
|
|
2421
|
-
}
|
|
2422
|
-
|
|
2423
|
-
// Create room
|
|
2424
|
-
this.#roomVersions.set(newRoomId, 0); // Start with version 0
|
|
2425
|
-
this.#rooms[newRoomId] = {
|
|
2426
|
-
participants: [ws.clientId],
|
|
2427
|
-
host: ws.clientId,
|
|
2428
|
-
maxSize: Math.min(Number(data.size), 100) || 100, // Max. limit is 100 clients / room
|
|
2429
|
-
crdtManager: roomCrdtManager
|
|
2430
|
-
};
|
|
2431
|
-
this.#clientRooms.set(ws.clientId, newRoomId); // Add client to the room
|
|
2432
|
-
|
|
2433
|
-
ws.send(encode({ type: 'room_created', state: roomCrdtManager.getState }), { binary: true });
|
|
2434
|
-
this.#triggerEvent("roomCreated", newRoomId);
|
|
2435
|
-
if (this.#debug) console.log("Room created with initial storage:", data.initialStorage);
|
|
2436
|
-
break;
|
|
2437
|
-
|
|
2438
|
-
case 'join_room':
|
|
2439
|
-
if (!data.roomId || !ws.clientId) return;
|
|
2440
|
-
const roomId = data.roomId;
|
|
2441
|
-
const room = this.#rooms[roomId];
|
|
2442
|
-
|
|
2443
|
-
const rejectJoin = (reason) => {
|
|
2444
|
-
ws.send(encode({ type: 'join_rejected', reason }), { binary: true });
|
|
2445
|
-
};
|
|
2446
|
-
|
|
2447
|
-
// Event callback
|
|
2448
|
-
const joinAllowed = await this.#triggerEvent("clientJoinRequested", ws.clientId, roomId);
|
|
2449
|
-
if (joinAllowed === false || typeof joinAllowed === 'string') return rejectJoin(typeof joinAllowed === 'string' ? joinAllowed : 'Denied.');
|
|
2450
|
-
|
|
2451
|
-
if (!room) return rejectJoin("Room not found.");
|
|
2452
|
-
if (this.#clientRooms.get(ws.clientId)) return rejectJoin("Already in a room.");
|
|
2453
|
-
if (room.participants.length >= room.maxSize) return rejectJoin("Room full.");
|
|
2454
|
-
|
|
2455
|
-
room.participants.push(ws.clientId);
|
|
2456
|
-
this.#clientRooms.set(ws.clientId, roomId);
|
|
2457
|
-
|
|
2458
|
-
// Notify joiner with initial storage
|
|
2459
|
-
if (this.#debug) console.log("Room state sent for join:", room.crdtManager.getState);
|
|
2460
|
-
ws.send(encode({
|
|
2461
|
-
type: 'join_accepted',
|
|
2462
|
-
state: room.crdtManager.getState,
|
|
2463
|
-
participantCount: room.participants.length,
|
|
2464
|
-
host: room.host,
|
|
2465
|
-
version: this.#roomVersions.get(roomId)
|
|
2466
|
-
}), { binary: true });
|
|
2467
|
-
|
|
2468
|
-
// Notify existing participants
|
|
2469
|
-
room.participants.forEach(p => {
|
|
2470
|
-
if (p === ws.clientId) return;
|
|
2471
|
-
const client = this.#clients.get(p);
|
|
2472
|
-
if (client) {
|
|
2473
|
-
client.send(encode({
|
|
2474
|
-
type: 'client_connected',
|
|
2475
|
-
client: ws.clientId,
|
|
2476
|
-
participantCount: room.participants.length
|
|
2477
|
-
}), { binary: true });
|
|
2478
|
-
}
|
|
2479
|
-
});
|
|
2480
|
-
this.#triggerEvent("clientJoinedRoom", ws.clientId, roomId);
|
|
2481
|
-
break;
|
|
2482
|
-
|
|
2483
|
-
case 'update_property':
|
|
2484
|
-
const updateRoomId = this.#clientRooms.get(ws.clientId);
|
|
2485
|
-
const updateRoom = updateRoomId ? this.#rooms[updateRoomId] : null;
|
|
2486
|
-
|
|
2487
|
-
if (updateRoom && data.update) {
|
|
2488
|
-
// Check if update is allowed via event callback (provide clone to ensure update integrity)
|
|
2489
|
-
const updateAllowed = await this.#triggerEvent("storageUpdateRequested", { roomId: updateRoomId, clientId: ws.clientId, update: structuredClone(data.update) });
|
|
2490
|
-
if (updateAllowed === false) {
|
|
2491
|
-
ws.send(encode({
|
|
2492
|
-
type: 'property_update_rejected',
|
|
2493
|
-
state: updateRoom.crdtManager.getState
|
|
2494
|
-
}), { binary: true });
|
|
2495
|
-
return;
|
|
2496
|
-
}
|
|
2497
|
-
|
|
2498
|
-
updateRoom.crdtManager.importPropertyUpdate(data.update); // Import update into server state
|
|
2499
|
-
|
|
2500
|
-
// Increment version for this room
|
|
2501
|
-
const currentVersion = this.#roomVersions.get(updateRoomId) + 1;
|
|
2502
|
-
this.#roomVersions.set(updateRoomId, currentVersion);
|
|
2503
|
-
|
|
2504
|
-
updateRoom.participants?.forEach(p => {
|
|
2505
|
-
const client = this.#clients.get(p);
|
|
2506
|
-
if (client) {
|
|
2507
|
-
client.send(encode({
|
|
2508
|
-
type: 'property_updated',
|
|
2509
|
-
update: data.update,
|
|
2510
|
-
version: currentVersion
|
|
2511
|
-
}), { binary: true });
|
|
2512
|
-
}
|
|
2513
|
-
});
|
|
2514
|
-
|
|
2515
|
-
this.#triggerEvent("storageUpdated", { roomId: updateRoomId, clientId: ws.clientId, update: structuredClone(data.update), storage: this.getRoomStorage(updateRoomId) });
|
|
2516
|
-
if (this.#debug) console.log("Property update received and imported:", data.update);
|
|
2517
|
-
}
|
|
2518
|
-
break;
|
|
2519
|
-
|
|
2520
|
-
case 'request':
|
|
2521
|
-
const requestorRoomId = this.#clientRooms.get(ws.clientId) || null;
|
|
2522
|
-
this.#triggerEvent("requestReceived", { roomId: requestorRoomId, clientId: ws.clientId, name: data.request.name, data: data.request.data });
|
|
2523
|
-
break;
|
|
2524
|
-
|
|
2525
|
-
case 'disconnect':
|
|
2526
|
-
// Client signals to server that it will will willfully disconnect soon
|
|
2527
|
-
ws.willfulDisconnect = true;
|
|
2528
|
-
break;
|
|
2529
|
-
}
|
|
2530
|
-
} catch (error) {
|
|
2531
|
-
console.error('Error in message handler:', error);
|
|
2532
|
-
}
|
|
2533
|
-
}
|
|
2534
|
-
|
|
2535
|
-
/**
|
|
2536
|
-
* If the client that disconnected (and is now in the reconnection-phase) was the room host,
|
|
2537
|
-
* pick a new room host immediately to avoid host-less phase (in case important logic is attached to the host)
|
|
2538
|
-
* @param {string} roomId
|
|
2539
|
-
* @param {string} clientId
|
|
2540
|
-
*/
|
|
2541
|
-
#changeHostIfDisconnected(roomId, clientId) {
|
|
2542
|
-
const room = this.#rooms[roomId];
|
|
2543
|
-
if (room && room.host === clientId && room.participants.length > 1) {
|
|
2544
|
-
const participantsWithoutClient = room.participants.filter((e) => e !== clientId);
|
|
2545
|
-
room.host = participantsWithoutClient[0]; // Set new host
|
|
2546
|
-
|
|
2547
|
-
// Inform all participants about the new host
|
|
2548
|
-
participantsWithoutClient.forEach(p => {
|
|
2549
|
-
const client = this.#clients.get(p);
|
|
2550
|
-
if (client) client.send(encode({
|
|
2551
|
-
type: 'host_migrated',
|
|
2552
|
-
newHost: room.host,
|
|
2553
|
-
}), { binary: true });
|
|
2554
|
-
});
|
|
2555
|
-
}
|
|
2556
|
-
}
|
|
2557
|
-
|
|
2558
|
-
/**
|
|
2559
|
-
* Generate a random token to prevent malicious reconnect attempts
|
|
2560
|
-
* @returns {string} - Token
|
|
2561
|
-
*/
|
|
2562
|
-
#generateSessionToken() {
|
|
2563
|
-
let token = '';
|
|
2564
|
-
for (let i = 0; i < 16; i++) token += Math.floor(Math.random() * 16).toString(16);
|
|
2565
|
-
return token;
|
|
2566
|
-
}
|
|
2567
|
-
|
|
2568
|
-
/**
|
|
2569
|
-
* Check rate limit using token bucket algorithm
|
|
2570
|
-
* @private
|
|
2571
|
-
*/
|
|
2572
|
-
#checkRateLimit(connectionId, operationType) {
|
|
2573
|
-
const MAX_POINTS = 20;
|
|
2574
|
-
const now = Date.now();
|
|
2575
|
-
|
|
2576
|
-
if (!this.#rateLimits.has(connectionId)) {
|
|
2577
|
-
this.#rateLimits.set(connectionId, { points: MAX_POINTS, lastReset: now });
|
|
2578
|
-
return true;
|
|
2579
|
-
}
|
|
2580
|
-
|
|
2581
|
-
const limit = this.#rateLimits.get(connectionId);
|
|
2582
|
-
|
|
2583
|
-
// Reset points if interval has passed (1s)
|
|
2584
|
-
if (now - limit.lastReset > 1000) {
|
|
2585
|
-
limit.points = MAX_POINTS;
|
|
2586
|
-
limit.lastReset = now;
|
|
2587
|
-
}
|
|
2588
|
-
|
|
2589
|
-
const pointCost = operationType == "create_room" ? 5 : 1;
|
|
2590
|
-
if (limit.points < pointCost) return false;
|
|
2591
|
-
|
|
2592
|
-
limit.points -= pointCost;
|
|
2593
|
-
return true;
|
|
2594
|
-
}
|
|
2595
|
-
|
|
2596
|
-
/**
|
|
2597
|
-
* Handle client disconnection
|
|
2598
|
-
* @private
|
|
2599
|
-
*/
|
|
2600
|
-
#handleDisconnection(ws) {
|
|
2601
|
-
this.#rateLimits.delete(ws.connectionId);
|
|
2602
|
-
|
|
2603
|
-
if (ws.clientId) {
|
|
2604
|
-
this.#clients.delete(ws.clientId); // Immediately remove from active clients (otherwise, server would try to message this client)
|
|
2605
|
-
|
|
2606
|
-
// If client was in a room, check if they were the host & migrate
|
|
2607
|
-
const roomId = this.#clientRooms.get(ws.clientId);
|
|
2608
|
-
if (roomId != null) this.#changeHostIfDisconnected(roomId, ws.clientId);
|
|
2609
|
-
|
|
2610
|
-
if (ws.willfulDisconnect) {
|
|
2611
|
-
this.#disconnectClient(ws); // Immediate disconnection
|
|
2612
|
-
} else {
|
|
2613
|
-
// Pending complete disconnection with 5s grace period to allow for reconnections
|
|
2614
|
-
this.#pendingDisconnects.set(ws.clientId, {
|
|
2615
|
-
timeout: setTimeout(() => {
|
|
2616
|
-
this.#disconnectClient(ws);
|
|
2617
|
-
}, 5000)
|
|
2618
|
-
});
|
|
2619
|
-
}
|
|
2620
|
-
}
|
|
2621
|
-
}
|
|
2622
|
-
|
|
2623
|
-
/**
|
|
2624
|
-
* Disconnect a client
|
|
2625
|
-
* @private
|
|
2626
|
-
* @param {Object} ws
|
|
2627
|
-
*/
|
|
2628
|
-
#disconnectClient(ws) {
|
|
2629
|
-
this.#pendingDisconnects.delete(ws.clientId);
|
|
2630
|
-
this.#clientTokens.delete(ws.clientId);
|
|
2631
|
-
let roomId = this.#clientRooms.get(ws.clientId);
|
|
2632
|
-
const room = this.#rooms[roomId];
|
|
2633
|
-
|
|
2634
|
-
if (room) {
|
|
2635
|
-
room.participants = room.participants.filter(p => p !== ws.clientId); // Remove client from room
|
|
2636
|
-
this.#clientRooms.delete(ws.clientId);
|
|
2637
|
-
|
|
2638
|
-
if (room.participants?.length === 0) {
|
|
2639
|
-
delete this.#rooms[roomId]; // Delete room if now empty
|
|
2640
|
-
this.#roomVersions.delete(roomId); // Delete room version
|
|
2641
|
-
this.#triggerEvent("roomDestroyed", roomId);
|
|
2642
|
-
if (this.#debug) console.log("Deleted room with id " + roomId + ".");
|
|
2643
|
-
roomId = null; // If room was destroyed, set roomId to null
|
|
2644
|
-
} else {
|
|
2645
|
-
// Notify remaining participants
|
|
2646
|
-
room.participants.forEach(p => {
|
|
2647
|
-
const client = this.#clients.get(p);
|
|
2648
|
-
if (client) client.send(encode({
|
|
2649
|
-
type: 'client_disconnected',
|
|
2650
|
-
client: ws.clientId,
|
|
2651
|
-
participantCount: room.participants.length
|
|
2652
|
-
}), { binary: true });
|
|
2653
|
-
});
|
|
2654
|
-
}
|
|
2655
|
-
}
|
|
2656
|
-
|
|
2657
|
-
this.#triggerEvent("clientDisconnected", ws.clientId, roomId);
|
|
2658
|
-
}
|
|
2659
|
-
|
|
2660
|
-
/**
|
|
2661
|
-
* Register an event callback
|
|
2662
|
-
* @param {string} event - Event name
|
|
2663
|
-
* @param {Function} callback - Callback function
|
|
2664
|
-
*/
|
|
2665
|
-
onEvent(event, callback) {
|
|
2666
|
-
const validEvents = ["clientRegistered", "clientRegistrationRequested", "clientDisconnected", "clientJoinedRoom", "clientJoinRequested", "roomCreated", "roomCreationRequested", "requestReceived", "storageUpdated", "storageUpdateRequested", "roomDestroyed"];
|
|
2667
|
-
if (!validEvents.includes(event)) return console.warn(`Invalid PlaySocket event type "${event}"`);
|
|
2668
|
-
if (!this.#callbacks.has(event)) this.#callbacks.set(event, []);
|
|
2669
|
-
this.#callbacks.get(event).push(callback);
|
|
2670
|
-
}
|
|
2671
|
-
|
|
2672
|
-
/**
|
|
2673
|
-
* Kick a player from the server
|
|
2674
|
-
* @param {string} clientId - Client ID
|
|
2675
|
-
* @param {string} [reason] - Optional reason
|
|
2676
|
-
*/
|
|
2677
|
-
kick(clientId, reason) {
|
|
2678
|
-
const client = this.#clients.get(clientId);
|
|
2679
|
-
if (client) {
|
|
2680
|
-
client.willfulDisconnect = true;
|
|
2681
|
-
client.send(encode({ type: 'kicked', reason }), { binary: true });
|
|
2682
|
-
client.close();
|
|
2683
|
-
}
|
|
2684
|
-
}
|
|
2685
|
-
|
|
2686
|
-
/**
|
|
2687
|
-
* Trigger an event to registered callbacks
|
|
2688
|
-
* @private
|
|
2689
|
-
*/
|
|
2690
|
-
async #triggerEvent(event, ...args) {
|
|
2691
|
-
const callbacks = this.#callbacks.get(event);
|
|
2692
|
-
if (!callbacks) return;
|
|
2693
|
-
|
|
2694
|
-
for (const callback of callbacks) {
|
|
2695
|
-
try {
|
|
2696
|
-
const result = await callback(...args);
|
|
2697
|
-
if (result != null) return result; // Return any non-null/undefined result
|
|
2698
|
-
} catch (error) {
|
|
2699
|
-
console.error(`PlaySocket ${event} callback error:`, error);
|
|
2700
|
-
}
|
|
2701
|
-
}
|
|
2702
|
-
return true;
|
|
2703
|
-
}
|
|
2704
|
-
|
|
2705
|
-
/**
|
|
2706
|
-
* Get snapshot of a room's storage
|
|
2707
|
-
* @returns {Object} - Storage object
|
|
2708
|
-
*/
|
|
2709
|
-
getRoomStorage(roomId) {
|
|
2710
|
-
const room = this.#rooms[roomId];
|
|
2711
|
-
if (room) return room.crdtManager.getPropertyStore;
|
|
2712
|
-
}
|
|
2713
|
-
|
|
2714
|
-
/**
|
|
2715
|
-
* Update a value in a room's storage
|
|
2716
|
-
* @param {string} roomId - Room ID
|
|
2717
|
-
* @param {string} key - Storage key
|
|
2718
|
-
* @param {string} type - Operation type
|
|
2719
|
-
* @param {*} value - New value or value to operate on
|
|
2720
|
-
* @param {*} updateValue - New value for update-matching
|
|
2721
|
-
*/
|
|
2722
|
-
updateRoomStorage(roomId, key, type, value, updateValue) {
|
|
2723
|
-
if (this.#debug) console.log(`Playsocket server property update for room ${roomId}, key ${key}, operation ${type}, value ${value} and updateValue ${updateValue}.`);
|
|
2724
|
-
const room = this.#rooms[roomId];
|
|
2725
|
-
if (room) {
|
|
2726
|
-
const propertyUpdate = room.crdtManager.updateProperty(key, type, value, updateValue);
|
|
2727
|
-
const currentVersion = this.#roomVersions.get(roomId) + 1;
|
|
2728
|
-
this.#roomVersions.set(roomId, currentVersion); // Increment version for this room
|
|
2729
|
-
|
|
2730
|
-
room.participants?.forEach(p => {
|
|
2731
|
-
const client = this.#clients.get(p);
|
|
2732
|
-
if (client) {
|
|
2733
|
-
client.send(encode({
|
|
2734
|
-
type: 'property_updated',
|
|
2735
|
-
update: propertyUpdate,
|
|
2736
|
-
version: currentVersion
|
|
2737
|
-
}), { binary: true });
|
|
2738
|
-
}
|
|
2739
|
-
});
|
|
2740
|
-
this.#triggerEvent("storageUpdated", { roomId, clientId: null, update: structuredClone(propertyUpdate), storage: this.getRoomStorage(roomId) });
|
|
2741
|
-
}
|
|
2742
|
-
}
|
|
2743
|
-
|
|
2744
|
-
/**
|
|
2745
|
-
* Close all client connections, then close the websocket and http server
|
|
2746
|
-
*/
|
|
2747
|
-
stop() {
|
|
2748
|
-
clearInterval(this.#heartbeatInterval);
|
|
2749
|
-
if (this.#wss) {
|
|
2750
|
-
this.#clients.forEach(client => {
|
|
2751
|
-
client.send(encode({ type: 'server_stopped' }), { binary: true });
|
|
2752
|
-
client.close();
|
|
2753
|
-
});
|
|
2754
|
-
this.#wss.close();
|
|
2755
|
-
}
|
|
2756
|
-
if (this.#server && this.#ownsServer) this.#server.close(() => { console.log('PlaySocket server stopped.'); });
|
|
2757
|
-
this.#pendingDisconnects.forEach((data) => {
|
|
2758
|
-
clearTimeout(data.timeout);
|
|
2759
|
-
});
|
|
2760
|
-
}
|
|
2761
|
-
|
|
2762
|
-
get getRooms() { return { ...this.#rooms } }
|
|
2763
|
-
}
|
|
2764
|
-
|
|
2765
|
-
server$1 = PlaySocketServer;
|
|
2766
|
-
return server$1;
|
|
2767
|
-
}
|
|
2118
|
+
if (debug) this.#debug = true; // Enable extra logging
|
|
2119
|
+
|
|
2120
|
+
this.#rateLimitMaxPoints = rateLimit; // Set rate limit
|
|
2121
|
+
|
|
2122
|
+
// Handle server creation / usage
|
|
2123
|
+
if (server) {
|
|
2124
|
+
this.#server = server; // Use provided HTTP server
|
|
2125
|
+
} else {
|
|
2126
|
+
this.#ownsServer = true;
|
|
2127
|
+
this.#server = http__namespace.createServer(); // Create HTTP server and start it
|
|
2128
|
+
this.#server.listen(port, () => {
|
|
2129
|
+
console.log(`PlaySocket server running on port ${port}.`);
|
|
2130
|
+
});
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
// Create WebSocket server
|
|
2134
|
+
this.#wss = new ws.WebSocketServer({
|
|
2135
|
+
server: this.#server,
|
|
2136
|
+
path
|
|
2137
|
+
});
|
|
2138
|
+
|
|
2139
|
+
// Set up ws event handlers
|
|
2140
|
+
this.#wss.on('connection', ws => {
|
|
2141
|
+
ws.connectionId = crypto.randomUUID();
|
|
2142
|
+
ws.isAlive = true;
|
|
2143
|
+
ws.on('pong', () => { ws.isAlive = true; });
|
|
2144
|
+
ws.on('message', msg => this.#handleMessage(ws, msg));
|
|
2145
|
+
ws.on('close', () => this.#handleDisconnection(ws));
|
|
2146
|
+
ws.on('error', (error) => {
|
|
2147
|
+
console.error(`PlaySocket WebSocket connection error for client ${ws.clientId || 'unknown'}:`, error); // Catch conn errors
|
|
2148
|
+
});
|
|
2149
|
+
});
|
|
2150
|
+
|
|
2151
|
+
// Log & catch server-level errors
|
|
2152
|
+
this.#wss.on('error', (error) => {
|
|
2153
|
+
console.error('PlaySocket WebSocket server error:', error);
|
|
2154
|
+
});
|
|
2155
|
+
|
|
2156
|
+
// Start heartbeat
|
|
2157
|
+
this.#heartbeatInterval = setInterval(() => {
|
|
2158
|
+
this.#wss.clients.forEach(ws => {
|
|
2159
|
+
if (!ws.isAlive) return ws.terminate();
|
|
2160
|
+
ws.isAlive = false;
|
|
2161
|
+
ws.ping();
|
|
2162
|
+
});
|
|
2163
|
+
}, 15000);
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
/**
|
|
2167
|
+
* Handle WebSocket message
|
|
2168
|
+
* @private
|
|
2169
|
+
*/
|
|
2170
|
+
async #handleMessage(ws, message) {
|
|
2171
|
+
if (ws.isTerminating) return;
|
|
2172
|
+
try {
|
|
2173
|
+
const data = decode(message);
|
|
2174
|
+
|
|
2175
|
+
// Apply rate limiting to all connections (including unregistered)
|
|
2176
|
+
if (!this.#checkRateLimit(ws.connectionId, data.type)) {
|
|
2177
|
+
if (!ws.isTerminating) {
|
|
2178
|
+
ws.isTerminating = true; // Prevent multiple terminate calls (it is async)
|
|
2179
|
+
ws.terminate();
|
|
2180
|
+
return console.error(`Connection ${ws.connectionId} terminated due to rate limit violations.`);
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
switch (data.type) {
|
|
2185
|
+
case 'register':
|
|
2186
|
+
// Register client ID if provided & check for a duplicate
|
|
2187
|
+
if (data.id && this.#clients.get(data.id)) {
|
|
2188
|
+
ws.send(encode({ type: 'registration_failed', reason: 'ID is taken.' }), { binary: true });
|
|
2189
|
+
return;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
// Generate client ID if none provided
|
|
2193
|
+
if (!data.id) {
|
|
2194
|
+
for (let i = 0; i < 50; i++) {
|
|
2195
|
+
const id = this.#generateId();
|
|
2196
|
+
if (!this.#clients.get(id)) {
|
|
2197
|
+
data.id = id;
|
|
2198
|
+
break;
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
if (!data.id) {
|
|
2202
|
+
ws.send(encode({ type: 'registration_failed', reason: 'No available ID found.' }), { binary: true });
|
|
2203
|
+
throw new Error('Failed to generate unique ID!');
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
// Event callback
|
|
2208
|
+
const registrationAllowed = await this.#triggerEvent("clientRegistrationRequested", data.id, data.customData);
|
|
2209
|
+
if (registrationAllowed === false || typeof registrationAllowed === 'string') {
|
|
2210
|
+
ws.send(encode({
|
|
2211
|
+
type: 'registration_failed',
|
|
2212
|
+
reason: typeof registrationAllowed === 'string' ? registrationAllowed : 'Denied.'
|
|
2213
|
+
}), { binary: true });
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
ws.clientId = data.id; // Adds the provided id to the ws object as clientId
|
|
2218
|
+
this.#clients.set(data.id, ws);
|
|
2219
|
+
const sessionToken = this.#generateSessionToken();
|
|
2220
|
+
this.#clientTokens.set(data.id, sessionToken); // Token is used when reconnecting to prevent impersonation attacks
|
|
2221
|
+
ws.send(encode({ type: 'registered', id: data.id, sessionToken }), { binary: true });
|
|
2222
|
+
this.#triggerEvent("clientRegistered", data.id, data.customData);
|
|
2223
|
+
break;
|
|
2224
|
+
|
|
2225
|
+
case 'reconnect':
|
|
2226
|
+
// If user is pending disconnect, respond (otherwise it's too late)
|
|
2227
|
+
const pd = this.#pendingDisconnects.get(data.id);
|
|
2228
|
+
if (pd && data.sessionToken) {
|
|
2229
|
+
if (data.sessionToken !== this.#clientTokens.get(data.id)) {
|
|
2230
|
+
ws.send(encode({
|
|
2231
|
+
type: 'reconnection_failed',
|
|
2232
|
+
reason: "Session token does not match."
|
|
2233
|
+
}), { binary: true });
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2236
|
+
clearTimeout(pd.timeout);
|
|
2237
|
+
this.#pendingDisconnects.delete(data.id);
|
|
2238
|
+
|
|
2239
|
+
// Re-assign old client ID to the new ws connection
|
|
2240
|
+
ws.clientId = data.id;
|
|
2241
|
+
this.#clients.set(data.id, ws);
|
|
2242
|
+
|
|
2243
|
+
// If they were in a room, provide updated room data
|
|
2244
|
+
let roomData;
|
|
2245
|
+
const formerRoomId = this.#clientRooms.get(data.id);
|
|
2246
|
+
const formerRoom = this.#rooms[formerRoomId];
|
|
2247
|
+
if (formerRoom) {
|
|
2248
|
+
if (this.#debug) console.log(`State sent for reconnection for room ${formerRoomId}:`, formerRoom.crdtManager.getState);
|
|
2249
|
+
roomData = {
|
|
2250
|
+
state: formerRoom.crdtManager.getState,
|
|
2251
|
+
participantCount: formerRoom.participants.length,
|
|
2252
|
+
host: formerRoom.host,
|
|
2253
|
+
version: this.#roomVersions.get(formerRoomId)
|
|
2254
|
+
};
|
|
2255
|
+
}
|
|
2768
2256
|
|
|
2769
|
-
|
|
2770
|
-
|
|
2257
|
+
ws.send(encode({ type: 'reconnected', roomData }), { binary: true });
|
|
2258
|
+
} else {
|
|
2259
|
+
ws.send(encode({ type: 'reconnection_failed', reason: "Client unknown to server." }), { binary: true });
|
|
2260
|
+
}
|
|
2261
|
+
break;
|
|
2262
|
+
|
|
2263
|
+
case 'create_room':
|
|
2264
|
+
if (!ws.clientId) return;
|
|
2265
|
+
|
|
2266
|
+
let newRoomId;
|
|
2267
|
+
|
|
2268
|
+
if (this.#clientRooms.get(ws.clientId)) {
|
|
2269
|
+
ws.send(encode({
|
|
2270
|
+
type: 'room_creation_failed',
|
|
2271
|
+
reason: 'Already in a room.'
|
|
2272
|
+
}), { binary: true });
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
// Generate room ID
|
|
2277
|
+
for (let i = 0; i < 50; i++) {
|
|
2278
|
+
const id = this.#generateId();
|
|
2279
|
+
if (!this.#rooms[id]) {
|
|
2280
|
+
newRoomId = id;
|
|
2281
|
+
break;
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
if (!newRoomId) {
|
|
2286
|
+
ws.send(encode({ type: 'room_creation_failed', reason: 'No available ID found.' }), { binary: true });
|
|
2287
|
+
throw new Error('Failed to generate unique room ID!');
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
const roomCrdtManager = new CRDTManager(this.#debug); // Create the room's crdt manager
|
|
2291
|
+
|
|
2292
|
+
// Event callback with potential initial storage modifications
|
|
2293
|
+
const reviewedStorage = await this.#triggerEvent("roomCreationRequested", { roomId: newRoomId, clientId: ws.clientId, initialStorage: structuredClone({ ...data.initialStorage }) });
|
|
2294
|
+
if (typeof reviewedStorage === 'object') data.initialStorage = reviewedStorage;
|
|
2295
|
+
if (reviewedStorage === false) {
|
|
2296
|
+
ws.send(encode({
|
|
2297
|
+
type: 'room_creation_failed',
|
|
2298
|
+
reason: 'Room creation denied.'
|
|
2299
|
+
}), { binary: true });
|
|
2300
|
+
return;
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
// Check if client/creator is still connected
|
|
2304
|
+
if (!this.#clients.has(ws.clientId)) {
|
|
2305
|
+
if (this.#debug) console.log(`Room creation cancelled - client ${ws.clientId} disconnected during event callback.`);
|
|
2306
|
+
return; // Abort room creation if client is no longer connected
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
// Load initial storage if provided
|
|
2310
|
+
if (data.initialStorage) {
|
|
2311
|
+
Object.entries(data.initialStorage)?.forEach(([key, value]) => {
|
|
2312
|
+
roomCrdtManager.updateProperty(key, "set", value);
|
|
2313
|
+
});
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
// Create room
|
|
2317
|
+
const maxSize = Math.min(Number(data.size), 100) || 100; // Max. limit is 100 clients / room
|
|
2318
|
+
this.#roomVersions.set(newRoomId, 0); // Start with version 0
|
|
2319
|
+
this.#rooms[newRoomId] = {
|
|
2320
|
+
participants: [ws.clientId],
|
|
2321
|
+
host: ws.clientId,
|
|
2322
|
+
maxSize,
|
|
2323
|
+
crdtManager: roomCrdtManager
|
|
2324
|
+
};
|
|
2325
|
+
this.#clientRooms.set(ws.clientId, newRoomId); // Add client to the room
|
|
2326
|
+
|
|
2327
|
+
ws.send(encode({ type: 'room_created', state: roomCrdtManager.getState, roomId: newRoomId, size: maxSize }), { binary: true });
|
|
2328
|
+
this.#triggerEvent("roomCreated", newRoomId);
|
|
2329
|
+
if (this.#debug) console.log(`Room ${newRoomId} created with initial storage:`, data.initialStorage);
|
|
2330
|
+
break;
|
|
2331
|
+
|
|
2332
|
+
case 'join_room':
|
|
2333
|
+
if (!data.roomId || !ws.clientId) return;
|
|
2334
|
+
const roomId = data.roomId;
|
|
2335
|
+
const room = this.#rooms[roomId];
|
|
2336
|
+
|
|
2337
|
+
const rejectJoin = (reason) => {
|
|
2338
|
+
ws.send(encode({ type: 'join_rejected', reason }), { binary: true });
|
|
2339
|
+
};
|
|
2340
|
+
|
|
2341
|
+
// Event callback
|
|
2342
|
+
const joinAllowed = await this.#triggerEvent("clientJoinRequested", ws.clientId, roomId);
|
|
2343
|
+
if (joinAllowed === false || typeof joinAllowed === 'string') return rejectJoin(typeof joinAllowed === 'string' ? joinAllowed : 'Denied.');
|
|
2344
|
+
|
|
2345
|
+
if (!room) return rejectJoin("Room not found.");
|
|
2346
|
+
if (this.#clientRooms.get(ws.clientId)) return rejectJoin("Already in a room.");
|
|
2347
|
+
if (room.participants.length >= room.maxSize) return rejectJoin("Room full.");
|
|
2348
|
+
|
|
2349
|
+
room.participants.push(ws.clientId);
|
|
2350
|
+
this.#clientRooms.set(ws.clientId, roomId);
|
|
2351
|
+
|
|
2352
|
+
// Notify joiner with initial storage
|
|
2353
|
+
if (this.#debug) console.log("Room state sent for join:", room.crdtManager.getState);
|
|
2354
|
+
ws.send(encode({
|
|
2355
|
+
type: 'join_accepted',
|
|
2356
|
+
state: room.crdtManager.getState,
|
|
2357
|
+
participantCount: room.participants.length,
|
|
2358
|
+
host: room.host,
|
|
2359
|
+
version: this.#roomVersions.get(roomId)
|
|
2360
|
+
}), { binary: true });
|
|
2361
|
+
|
|
2362
|
+
// Notify existing participants
|
|
2363
|
+
room.participants.forEach(p => {
|
|
2364
|
+
if (p === ws.clientId) return;
|
|
2365
|
+
const client = this.#clients.get(p);
|
|
2366
|
+
if (client) {
|
|
2367
|
+
client.send(encode({
|
|
2368
|
+
type: 'client_connected',
|
|
2369
|
+
client: ws.clientId,
|
|
2370
|
+
participantCount: room.participants.length
|
|
2371
|
+
}), { binary: true });
|
|
2372
|
+
}
|
|
2373
|
+
});
|
|
2374
|
+
this.#triggerEvent("clientJoinedRoom", ws.clientId, roomId);
|
|
2375
|
+
break;
|
|
2376
|
+
|
|
2377
|
+
case 'update_property':
|
|
2378
|
+
const updateRoomId = this.#clientRooms.get(ws.clientId);
|
|
2379
|
+
const updateRoom = updateRoomId ? this.#rooms[updateRoomId] : null;
|
|
2380
|
+
|
|
2381
|
+
if (updateRoom && data.update) {
|
|
2382
|
+
// Check if update is allowed via event callback (provide clone to ensure update integrity)
|
|
2383
|
+
const updateAllowed = await this.#triggerEvent("storageUpdateRequested", { roomId: updateRoomId, clientId: ws.clientId, update: structuredClone(data.update) });
|
|
2384
|
+
if (updateAllowed === false) {
|
|
2385
|
+
ws.send(encode({
|
|
2386
|
+
type: 'property_update_rejected',
|
|
2387
|
+
state: updateRoom.crdtManager.getState
|
|
2388
|
+
}), { binary: true });
|
|
2389
|
+
return;
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
updateRoom.crdtManager.importPropertyUpdate(data.update); // Import update into server state
|
|
2393
|
+
|
|
2394
|
+
// Increment version for this room
|
|
2395
|
+
const currentVersion = this.#roomVersions.get(updateRoomId) + 1;
|
|
2396
|
+
this.#roomVersions.set(updateRoomId, currentVersion);
|
|
2397
|
+
|
|
2398
|
+
updateRoom.participants?.forEach(p => {
|
|
2399
|
+
const client = this.#clients.get(p);
|
|
2400
|
+
if (client) {
|
|
2401
|
+
client.send(encode({
|
|
2402
|
+
type: 'property_updated',
|
|
2403
|
+
update: data.update,
|
|
2404
|
+
version: currentVersion
|
|
2405
|
+
}), { binary: true });
|
|
2406
|
+
}
|
|
2407
|
+
});
|
|
2408
|
+
|
|
2409
|
+
this.#triggerEvent("storageUpdated", { roomId: updateRoomId, clientId: ws.clientId, update: structuredClone(data.update), storage: this.getRoomStorage(updateRoomId) });
|
|
2410
|
+
if (this.#debug) console.log("Property update received and imported:", data.update);
|
|
2411
|
+
}
|
|
2412
|
+
break;
|
|
2413
|
+
|
|
2414
|
+
case 'request':
|
|
2415
|
+
if (!ws.clientId) return;
|
|
2416
|
+
const requestorRoomId = this.#clientRooms.get(ws.clientId) || null;
|
|
2417
|
+
this.#triggerEvent("requestReceived", { roomId: requestorRoomId, clientId: ws.clientId, name: data.request.name, data: data.request.data });
|
|
2418
|
+
break;
|
|
2419
|
+
|
|
2420
|
+
case 'disconnect':
|
|
2421
|
+
// Client signals to server that it will will willfully disconnect soon
|
|
2422
|
+
ws.willfulDisconnect = true;
|
|
2423
|
+
break;
|
|
2424
|
+
}
|
|
2425
|
+
} catch (error) {
|
|
2426
|
+
console.error('Error in message handler:', error);
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
/**
|
|
2431
|
+
* Generate a readable, 6 digit ID
|
|
2432
|
+
*/
|
|
2433
|
+
#generateId() {
|
|
2434
|
+
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ123456789';
|
|
2435
|
+
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
/**
|
|
2439
|
+
* If the client that disconnected (and is now in the reconnection-phase) was the room host,
|
|
2440
|
+
* pick a new room host immediately to avoid host-less phase (in case important logic is attached to the host)
|
|
2441
|
+
* @param {string} roomId
|
|
2442
|
+
* @param {string} clientId
|
|
2443
|
+
*/
|
|
2444
|
+
#changeHostIfDisconnected(roomId, clientId) {
|
|
2445
|
+
const room = this.#rooms[roomId];
|
|
2446
|
+
if (room && room.host === clientId && room.participants.length > 1) {
|
|
2447
|
+
const participantsWithoutClient = room.participants.filter((e) => e !== clientId);
|
|
2448
|
+
room.host = participantsWithoutClient[0]; // Set new host
|
|
2449
|
+
|
|
2450
|
+
// Inform all participants about the new host
|
|
2451
|
+
participantsWithoutClient.forEach(p => {
|
|
2452
|
+
const client = this.#clients.get(p);
|
|
2453
|
+
if (client) client.send(encode({
|
|
2454
|
+
type: 'host_migrated',
|
|
2455
|
+
newHost: room.host,
|
|
2456
|
+
}), { binary: true });
|
|
2457
|
+
});
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
/**
|
|
2462
|
+
* Generate a random token to prevent malicious reconnect attempts
|
|
2463
|
+
* @returns {string} - Token
|
|
2464
|
+
*/
|
|
2465
|
+
#generateSessionToken() {
|
|
2466
|
+
let token = '';
|
|
2467
|
+
for (let i = 0; i < 16; i++) token += Math.floor(Math.random() * 16).toString(16);
|
|
2468
|
+
return token;
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
/**
|
|
2472
|
+
* Check rate limit using token bucket algorithm
|
|
2473
|
+
* @private
|
|
2474
|
+
*/
|
|
2475
|
+
#checkRateLimit(connectionId, operationType) {
|
|
2476
|
+
const now = Date.now();
|
|
2477
|
+
|
|
2478
|
+
if (!this.#rateLimits.has(connectionId)) {
|
|
2479
|
+
this.#rateLimits.set(connectionId, { points: this.#rateLimitMaxPoints, lastReset: now });
|
|
2480
|
+
return true;
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
const limit = this.#rateLimits.get(connectionId);
|
|
2484
|
+
|
|
2485
|
+
// Reset points if interval has passed (1s)
|
|
2486
|
+
if (now - limit.lastReset > 1000) {
|
|
2487
|
+
limit.points = this.#rateLimitMaxPoints;
|
|
2488
|
+
limit.lastReset = now;
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
const pointCost = operationType == "create_room" ? 5 : 1;
|
|
2492
|
+
if (limit.points < pointCost) return false;
|
|
2493
|
+
|
|
2494
|
+
limit.points -= pointCost;
|
|
2495
|
+
return true;
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
/**
|
|
2499
|
+
* Handle client disconnection
|
|
2500
|
+
* @private
|
|
2501
|
+
*/
|
|
2502
|
+
#handleDisconnection(ws) {
|
|
2503
|
+
this.#rateLimits.delete(ws.connectionId);
|
|
2504
|
+
|
|
2505
|
+
if (ws.clientId) {
|
|
2506
|
+
this.#clients.delete(ws.clientId); // Immediately remove from active clients (otherwise, server would try to message this client)
|
|
2507
|
+
|
|
2508
|
+
// If client was in a room, check if they were the host & migrate
|
|
2509
|
+
const roomId = this.#clientRooms.get(ws.clientId);
|
|
2510
|
+
if (roomId != null) this.#changeHostIfDisconnected(roomId, ws.clientId);
|
|
2511
|
+
|
|
2512
|
+
if (ws.willfulDisconnect) {
|
|
2513
|
+
this.#disconnectClient(ws); // Immediate disconnection
|
|
2514
|
+
} else {
|
|
2515
|
+
// Pending complete disconnection with 5s grace period to allow for reconnections
|
|
2516
|
+
this.#pendingDisconnects.set(ws.clientId, {
|
|
2517
|
+
timeout: setTimeout(() => {
|
|
2518
|
+
this.#disconnectClient(ws);
|
|
2519
|
+
}, 5000)
|
|
2520
|
+
});
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
/**
|
|
2526
|
+
* Disconnect a client
|
|
2527
|
+
* @private
|
|
2528
|
+
* @param {Object} ws
|
|
2529
|
+
*/
|
|
2530
|
+
#disconnectClient(ws) {
|
|
2531
|
+
this.#pendingDisconnects.delete(ws.clientId);
|
|
2532
|
+
this.#clientTokens.delete(ws.clientId);
|
|
2533
|
+
let roomId = this.#clientRooms.get(ws.clientId);
|
|
2534
|
+
const room = this.#rooms[roomId];
|
|
2535
|
+
|
|
2536
|
+
if (room) {
|
|
2537
|
+
room.participants = room.participants.filter(p => p !== ws.clientId); // Remove client from room
|
|
2538
|
+
this.#clientRooms.delete(ws.clientId);
|
|
2539
|
+
|
|
2540
|
+
if (room.participants?.length === 0) {
|
|
2541
|
+
delete this.#rooms[roomId]; // Delete room if now empty
|
|
2542
|
+
this.#roomVersions.delete(roomId); // Delete room version
|
|
2543
|
+
this.#triggerEvent("roomDestroyed", roomId);
|
|
2544
|
+
if (this.#debug) console.log("Deleted room with id " + roomId + ".");
|
|
2545
|
+
roomId = null; // If room was destroyed, set roomId to null
|
|
2546
|
+
} else {
|
|
2547
|
+
// Notify remaining participants
|
|
2548
|
+
room.participants.forEach(p => {
|
|
2549
|
+
const client = this.#clients.get(p);
|
|
2550
|
+
if (client) client.send(encode({
|
|
2551
|
+
type: 'client_disconnected',
|
|
2552
|
+
client: ws.clientId,
|
|
2553
|
+
participantCount: room.participants.length
|
|
2554
|
+
}), { binary: true });
|
|
2555
|
+
});
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
this.#triggerEvent("clientDisconnected", ws.clientId, roomId);
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
/**
|
|
2563
|
+
* Register an event callback
|
|
2564
|
+
* @param {string} event - Event name
|
|
2565
|
+
* @param {Function} callback - Callback function
|
|
2566
|
+
*/
|
|
2567
|
+
onEvent(event, callback) {
|
|
2568
|
+
const validEvents = ["clientRegistered", "clientRegistrationRequested", "clientDisconnected", "clientJoinedRoom", "clientJoinRequested", "roomCreated", "roomCreationRequested", "requestReceived", "storageUpdated", "storageUpdateRequested", "roomDestroyed"];
|
|
2569
|
+
if (!validEvents.includes(event)) return console.warn(`Invalid PlaySocket event type "${event}"`);
|
|
2570
|
+
if (!this.#callbacks.has(event)) this.#callbacks.set(event, []);
|
|
2571
|
+
this.#callbacks.get(event).push(callback);
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
/**
|
|
2575
|
+
* Kick a player from the server
|
|
2576
|
+
* @param {string} clientId - Client ID
|
|
2577
|
+
* @param {string} [reason] - Optional reason
|
|
2578
|
+
*/
|
|
2579
|
+
kick(clientId, reason) {
|
|
2580
|
+
const client = this.#clients.get(clientId);
|
|
2581
|
+
if (client) {
|
|
2582
|
+
client.willfulDisconnect = true;
|
|
2583
|
+
client.send(encode({ type: 'kicked', reason }), { binary: true });
|
|
2584
|
+
client.close();
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
/**
|
|
2589
|
+
* Trigger an event to registered callbacks
|
|
2590
|
+
* @private
|
|
2591
|
+
*/
|
|
2592
|
+
async #triggerEvent(event, ...args) {
|
|
2593
|
+
const callbacks = this.#callbacks.get(event);
|
|
2594
|
+
if (!callbacks) return;
|
|
2595
|
+
|
|
2596
|
+
for (const callback of callbacks) {
|
|
2597
|
+
try {
|
|
2598
|
+
const result = await callback(...args);
|
|
2599
|
+
if (result != null) return result; // Return any non-null/undefined result
|
|
2600
|
+
} catch (error) {
|
|
2601
|
+
console.error(`PlaySocket ${event} callback error:`, error);
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
return true;
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
/**
|
|
2608
|
+
* Get snapshot of a room's storage
|
|
2609
|
+
* @returns {Object} - Storage object
|
|
2610
|
+
*/
|
|
2611
|
+
getRoomStorage(roomId) {
|
|
2612
|
+
const room = this.#rooms[roomId];
|
|
2613
|
+
if (room) return room.crdtManager.getPropertyStore;
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
/**
|
|
2617
|
+
* Update a value in a room's storage
|
|
2618
|
+
* @param {string} roomId - Room ID
|
|
2619
|
+
* @param {string} key - Storage key
|
|
2620
|
+
* @param {string} type - Operation type
|
|
2621
|
+
* @param {*} value - New value or value to operate on
|
|
2622
|
+
* @param {*} updateValue - New value for update-matching
|
|
2623
|
+
*/
|
|
2624
|
+
updateRoomStorage(roomId, key, type, value, updateValue) {
|
|
2625
|
+
if (this.#debug) console.log(`Playsocket server property update for room ${roomId}, key ${key}, operation ${type}, value ${value} and updateValue ${updateValue}.`);
|
|
2626
|
+
const room = this.#rooms[roomId];
|
|
2627
|
+
if (room) {
|
|
2628
|
+
const propertyUpdate = room.crdtManager.updateProperty(key, type, value, updateValue);
|
|
2629
|
+
const currentVersion = this.#roomVersions.get(roomId) + 1;
|
|
2630
|
+
this.#roomVersions.set(roomId, currentVersion); // Increment version for this room
|
|
2631
|
+
|
|
2632
|
+
room.participants?.forEach(p => {
|
|
2633
|
+
const client = this.#clients.get(p);
|
|
2634
|
+
if (client) {
|
|
2635
|
+
client.send(encode({
|
|
2636
|
+
type: 'property_updated',
|
|
2637
|
+
update: propertyUpdate,
|
|
2638
|
+
version: currentVersion
|
|
2639
|
+
}), { binary: true });
|
|
2640
|
+
}
|
|
2641
|
+
});
|
|
2642
|
+
this.#triggerEvent("storageUpdated", { roomId, clientId: null, update: structuredClone(propertyUpdate), storage: this.getRoomStorage(roomId) });
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
/**
|
|
2647
|
+
* Close all client connections, then close the websocket and http server
|
|
2648
|
+
*/
|
|
2649
|
+
stop() {
|
|
2650
|
+
clearInterval(this.#heartbeatInterval);
|
|
2651
|
+
if (this.#wss) {
|
|
2652
|
+
this.#clients.forEach(client => {
|
|
2653
|
+
client.send(encode({ type: 'server_stopped' }), { binary: true });
|
|
2654
|
+
client.close();
|
|
2655
|
+
});
|
|
2656
|
+
this.#wss.close();
|
|
2657
|
+
}
|
|
2658
|
+
if (this.#server && this.#ownsServer) this.#server.close(() => { console.log('PlaySocket server stopped.'); });
|
|
2659
|
+
this.#pendingDisconnects.forEach((data) => {
|
|
2660
|
+
clearTimeout(data.timeout);
|
|
2661
|
+
});
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
get getRooms() { return { ...this.#rooms } }
|
|
2665
|
+
}
|
|
2771
2666
|
|
|
2772
|
-
module.exports =
|
|
2667
|
+
module.exports = PlaySocketServer;
|