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.
@@ -1,36 +1,26 @@
1
1
  'use strict';
2
2
 
3
- var require$$0 = require('ws');
4
- var require$$1 = require('http');
5
-
6
- function getDefaultExportFromCjs (x) {
7
- return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
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
- function getAugmentedNamespace(n) {
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,1086 +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
- // utility for whatwg streams
1728
- function isAsyncIterable(object) {
1729
- return object[Symbol.asyncIterator] != null;
1730
- }
1731
- async function* asyncIterableFromStream(stream) {
1732
- const reader = stream.getReader();
1733
- try {
1734
- while (true) {
1735
- const { done, value } = await reader.read();
1736
- if (done) {
1737
- return;
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
- yield value;
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
- finally {
1743
- reader.releaseLock();
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
- function ensureAsyncIterable(streamLike) {
1747
- if (isAsyncIterable(streamLike)) {
1748
- return streamLike;
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
- else {
1751
- return asyncIterableFromStream(streamLike);
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
- * @throws {@link RangeError} if the buffer is incomplete, including the case where the buffer is empty.
1757
- * @throws {@link DecodeError} if the buffer contains invalid data.
1758
- */
1759
- async function decodeAsync(streamLike, options) {
1760
- const stream = ensureAsyncIterable(streamLike);
1761
- const decoder = new Decoder(options);
1762
- return decoder.decodeAsync(stream);
1763
- }
1764
- /**
1765
- * @throws {@link RangeError} if the buffer is incomplete, including the case where the buffer is empty.
1766
- * @throws {@link DecodeError} if the buffer contains invalid data.
1767
- */
1768
- function decodeArrayStream(streamLike, options) {
1769
- const stream = ensureAsyncIterable(streamLike);
1770
- const decoder = new Decoder(options);
1771
- return decoder.decodeArrayStream(stream);
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
- * @throws {@link RangeError} if the buffer is incomplete, including the case where the buffer is empty.
1775
- * @throws {@link DecodeError} if the buffer contains invalid data.
2088
+ * PlaySocketServer - WebSocket server for PlaySocket multiplayer library
1776
2089
  */
1777
- function decodeMultiStream(streamLike, options) {
1778
- const stream = ensureAsyncIterable(streamLike);
1779
- const decoder = new Decoder(options);
1780
- return decoder.decodeStream(stream);
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
- // Main Functions:
1784
-
1785
- var dist_esm = /*#__PURE__*/Object.freeze({
1786
- __proto__: null,
1787
- DecodeError: DecodeError,
1788
- Decoder: Decoder,
1789
- EXT_TIMESTAMP: EXT_TIMESTAMP,
1790
- Encoder: Encoder,
1791
- ExtData: ExtData,
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
- var server$1;
2200
- var hasRequiredServer;
2201
-
2202
- function requireServer () {
2203
- if (hasRequiredServer) return server$1;
2204
- hasRequiredServer = 1;
2205
- const WebSocket = require$$0;
2206
- const http = require$$1;
2207
- const { encode, decode } = require$$2;
2208
- const { CRDTManager } = requireCrdtManager();
2209
-
2210
- /**
2211
- * PlaySocketServer - WebSocket server for PlaySocket multiplayer library
2212
- */
2213
- class PlaySocketServer {
2214
- #server;
2215
- #ownsServer = false;
2216
- #rateLimitMaxPoints;
2217
- #wss;
2218
- #clients = new Map(); // ClientId -> WebSocket instance
2219
- #rooms = {};
2220
- #clientRooms = new Map(); // ClientId -> RoomId
2221
- #rateLimits = new Map(); // Rate limiting storage
2222
- #callbacks = new Map(); // Event -> [callback functions]
2223
- #heartbeatInterval;
2224
- #pendingDisconnects = new Map(); // ClientId -> {timeout, roomId}
2225
- #clientTokens = new Map(); // ClientId -> Token
2226
- #roomVersions = new Map(); // RoomId -> Version
2227
-
2228
- // Debug
2229
- #debug = false;
2230
-
2231
- /**
2232
- * Create a new PlaySocketServer instance
2233
- * @param {Object} options - Server configuration options
2234
- * @param {HttpServer} [options.server] - Existing http server
2235
- * @param {number} [options.port=3000] - Port to listen on (if no server provided)
2236
- * @param {string} [options.path='/socket'] - WebSocket endpoint path
2237
- */
2238
- constructor(options = {}) {
2239
- const { server, port = 3000, path = '/', debug = false, rateLimit = 20 } = options;
2240
-
2241
- if (debug) this.#debug = true; // Enable extra logging
2242
-
2243
- this.#rateLimitMaxPoints = rateLimit; // Set rate limit
2244
-
2245
- // Handle server creation / usage
2246
- if (server) {
2247
- this.#server = server; // Use provided HTTP server
2248
- } else {
2249
- this.#ownsServer = true;
2250
- this.#server = http.createServer(); // Create HTTP server and start it
2251
- this.#server.listen(port, () => {
2252
- console.log(`PlaySocket server running on port ${port}.`);
2253
- });
2254
- }
2255
-
2256
- // Create WebSocket server
2257
- this.#wss = new WebSocket.Server({
2258
- server: this.#server,
2259
- path
2260
- });
2261
-
2262
- // Set up ws event handlers
2263
- this.#wss.on('connection', ws => {
2264
- ws.connectionId = crypto.randomUUID();
2265
- ws.isAlive = true;
2266
- ws.on('pong', () => { ws.isAlive = true; });
2267
- ws.on('message', msg => this.#handleMessage(ws, msg));
2268
- ws.on('close', () => this.#handleDisconnection(ws));
2269
- ws.on('error', (error) => {
2270
- console.error(`PlaySocket WebSocket connection error for client ${ws.clientId || 'unknown'}:`, error); // Catch conn errors
2271
- });
2272
- });
2273
-
2274
- // Log & catch server-level errors
2275
- this.#wss.on('error', (error) => {
2276
- console.error('PlaySocket WebSocket server error:', error);
2277
- });
2278
-
2279
- // Start heartbeat
2280
- this.#heartbeatInterval = setInterval(() => {
2281
- this.#wss.clients.forEach(ws => {
2282
- if (!ws.isAlive) return ws.terminate();
2283
- ws.isAlive = false;
2284
- ws.ping();
2285
- });
2286
- }, 15000);
2287
- }
2288
-
2289
- /**
2290
- * Handle WebSocket message
2291
- * @private
2292
- */
2293
- async #handleMessage(ws, message) {
2294
- if (ws.isTerminating) return;
2295
- try {
2296
- const data = decode(message);
2297
-
2298
- // Apply rate limiting to all connections (including unregistered)
2299
- if (!this.#checkRateLimit(ws.connectionId, data.type)) {
2300
- if (!ws.isTerminating) {
2301
- ws.isTerminating = true; // Prevent multiple terminate calls (it is async)
2302
- ws.terminate();
2303
- return console.error(`Connection ${ws.connectionId} terminated due to rate limit violations.`);
2304
- }
2305
- }
2306
-
2307
- switch (data.type) {
2308
- case 'register':
2309
- // Register client ID if provided & check for a duplicate
2310
- if (data.id && this.#clients.get(data.id)) {
2311
- ws.send(encode({ type: 'registration_failed', reason: 'ID is taken.' }), { binary: true });
2312
- return;
2313
- }
2314
-
2315
- // Generate client ID if none provided
2316
- if (!data.id) {
2317
- for (let i = 0; i < 50; i++) {
2318
- const id = this.#generateId();
2319
- if (!this.#clients.get(id)) {
2320
- data.id = id;
2321
- break;
2322
- }
2323
- }
2324
- if (!data.id) {
2325
- ws.send(encode({ type: 'registration_failed', reason: 'No available ID found.' }), { binary: true });
2326
- throw new Error('Failed to generate unique ID!');
2327
- }
2328
- }
2329
-
2330
- // Event callback
2331
- const registrationAllowed = await this.#triggerEvent("clientRegistrationRequested", data.id, data.customData);
2332
- if (registrationAllowed === false || typeof registrationAllowed === 'string') {
2333
- ws.send(encode({
2334
- type: 'registration_failed',
2335
- reason: typeof registrationAllowed === 'string' ? registrationAllowed : 'Denied.'
2336
- }), { binary: true });
2337
- return;
2338
- }
2339
-
2340
- ws.clientId = data.id; // Adds the provided id to the ws object as clientId
2341
- this.#clients.set(data.id, ws);
2342
- const sessionToken = this.#generateSessionToken();
2343
- this.#clientTokens.set(data.id, sessionToken); // Token is used when reconnecting to prevent impersonation attacks
2344
- ws.send(encode({ type: 'registered', id: data.id, sessionToken }), { binary: true });
2345
- this.#triggerEvent("clientRegistered", data.id, data.customData);
2346
- break;
2347
-
2348
- case 'reconnect':
2349
- // If user is pending disconnect, respond (otherwise it's too late)
2350
- const pd = this.#pendingDisconnects.get(data.id);
2351
- if (pd && data.sessionToken) {
2352
- if (data.sessionToken !== this.#clientTokens.get(data.id)) {
2353
- ws.send(encode({
2354
- type: 'reconnection_failed',
2355
- reason: "Session token does not match."
2356
- }), { binary: true });
2357
- return;
2358
- }
2359
- clearTimeout(pd.timeout);
2360
- this.#pendingDisconnects.delete(data.id);
2361
-
2362
- // Re-assign old client ID to the new ws connection
2363
- ws.clientId = data.id;
2364
- this.#clients.set(data.id, ws);
2365
-
2366
- // If they were in a room, provide updated room data
2367
- let roomData;
2368
- const formerRoomId = this.#clientRooms.get(data.id);
2369
- const formerRoom = this.#rooms[formerRoomId];
2370
- if (formerRoom) {
2371
- if (this.#debug) console.log(`State sent for reconnection for room ${formerRoomId}:`, formerRoom.crdtManager.getState);
2372
- roomData = {
2373
- state: formerRoom.crdtManager.getState,
2374
- participantCount: formerRoom.participants.length,
2375
- host: formerRoom.host,
2376
- version: this.#roomVersions.get(formerRoomId)
2377
- };
2378
- }
2379
-
2380
- ws.send(encode({ type: 'reconnected', roomData }), { binary: true });
2381
- } else {
2382
- ws.send(encode({ type: 'reconnection_failed', reason: "Client unknown to server." }), { binary: true });
2383
- }
2384
- break;
2385
-
2386
- case 'create_room':
2387
- if (!ws.clientId) return;
2388
-
2389
- let newRoomId;
2390
-
2391
- if (this.#clientRooms.get(ws.clientId)) {
2392
- ws.send(encode({
2393
- type: 'room_creation_failed',
2394
- reason: 'Already in a room.'
2395
- }), { binary: true });
2396
- return;
2397
- }
2398
-
2399
- // Generate room ID
2400
- for (let i = 0; i < 50; i++) {
2401
- const id = this.#generateId();
2402
- if (!this.#rooms[id]) {
2403
- newRoomId = id;
2404
- break;
2405
- }
2406
- }
2407
-
2408
- if (!newRoomId) {
2409
- ws.send(encode({ type: 'room_creation_failed', reason: 'No available ID found.' }), { binary: true });
2410
- throw new Error('Failed to generate unique room ID!');
2411
- }
2412
-
2413
- const roomCrdtManager = new CRDTManager(this.#debug); // Create the room's crdt manager
2414
-
2415
- // Event callback with potential initial storage modifications
2416
- const reviewedStorage = await this.#triggerEvent("roomCreationRequested", { roomId: newRoomId, clientId: ws.clientId, initialStorage: structuredClone({ ...data.initialStorage }) });
2417
- if (typeof reviewedStorage === 'object') data.initialStorage = reviewedStorage;
2418
- if (reviewedStorage === false) {
2419
- ws.send(encode({
2420
- type: 'room_creation_failed',
2421
- reason: 'Room creation denied.'
2422
- }), { binary: true });
2423
- return;
2424
- }
2425
-
2426
- // Check if client/creator is still connected
2427
- if (!this.#clients.has(ws.clientId)) {
2428
- if (this.#debug) console.log(`Room creation cancelled - client ${ws.clientId} disconnected during event callback.`);
2429
- return; // Abort room creation if client is no longer connected
2430
- }
2431
-
2432
- // Load initial storage if provided
2433
- if (data.initialStorage) {
2434
- Object.entries(data.initialStorage)?.forEach(([key, value]) => {
2435
- roomCrdtManager.updateProperty(key, "set", value);
2436
- });
2437
- }
2438
-
2439
- // Create room
2440
- const maxSize = Math.min(Number(data.size), 100) || 100; // Max. limit is 100 clients / room
2441
- this.#roomVersions.set(newRoomId, 0); // Start with version 0
2442
- this.#rooms[newRoomId] = {
2443
- participants: [ws.clientId],
2444
- host: ws.clientId,
2445
- maxSize,
2446
- crdtManager: roomCrdtManager
2447
- };
2448
- this.#clientRooms.set(ws.clientId, newRoomId); // Add client to the room
2449
-
2450
- ws.send(encode({ type: 'room_created', state: roomCrdtManager.getState, roomId: newRoomId, size: maxSize }), { binary: true });
2451
- this.#triggerEvent("roomCreated", newRoomId);
2452
- if (this.#debug) console.log(`Room ${newRoomId} created with initial storage:`, data.initialStorage);
2453
- break;
2454
-
2455
- case 'join_room':
2456
- if (!data.roomId || !ws.clientId) return;
2457
- const roomId = data.roomId;
2458
- const room = this.#rooms[roomId];
2459
-
2460
- const rejectJoin = (reason) => {
2461
- ws.send(encode({ type: 'join_rejected', reason }), { binary: true });
2462
- };
2463
-
2464
- // Event callback
2465
- const joinAllowed = await this.#triggerEvent("clientJoinRequested", ws.clientId, roomId);
2466
- if (joinAllowed === false || typeof joinAllowed === 'string') return rejectJoin(typeof joinAllowed === 'string' ? joinAllowed : 'Denied.');
2467
-
2468
- if (!room) return rejectJoin("Room not found.");
2469
- if (this.#clientRooms.get(ws.clientId)) return rejectJoin("Already in a room.");
2470
- if (room.participants.length >= room.maxSize) return rejectJoin("Room full.");
2471
-
2472
- room.participants.push(ws.clientId);
2473
- this.#clientRooms.set(ws.clientId, roomId);
2474
-
2475
- // Notify joiner with initial storage
2476
- if (this.#debug) console.log("Room state sent for join:", room.crdtManager.getState);
2477
- ws.send(encode({
2478
- type: 'join_accepted',
2479
- state: room.crdtManager.getState,
2480
- participantCount: room.participants.length,
2481
- host: room.host,
2482
- version: this.#roomVersions.get(roomId)
2483
- }), { binary: true });
2484
-
2485
- // Notify existing participants
2486
- room.participants.forEach(p => {
2487
- if (p === ws.clientId) return;
2488
- const client = this.#clients.get(p);
2489
- if (client) {
2490
- client.send(encode({
2491
- type: 'client_connected',
2492
- client: ws.clientId,
2493
- participantCount: room.participants.length
2494
- }), { binary: true });
2495
- }
2496
- });
2497
- this.#triggerEvent("clientJoinedRoom", ws.clientId, roomId);
2498
- break;
2499
-
2500
- case 'update_property':
2501
- const updateRoomId = this.#clientRooms.get(ws.clientId);
2502
- const updateRoom = updateRoomId ? this.#rooms[updateRoomId] : null;
2503
-
2504
- if (updateRoom && data.update) {
2505
- // Check if update is allowed via event callback (provide clone to ensure update integrity)
2506
- const updateAllowed = await this.#triggerEvent("storageUpdateRequested", { roomId: updateRoomId, clientId: ws.clientId, update: structuredClone(data.update) });
2507
- if (updateAllowed === false) {
2508
- ws.send(encode({
2509
- type: 'property_update_rejected',
2510
- state: updateRoom.crdtManager.getState
2511
- }), { binary: true });
2512
- return;
2513
- }
2514
-
2515
- updateRoom.crdtManager.importPropertyUpdate(data.update); // Import update into server state
2516
-
2517
- // Increment version for this room
2518
- const currentVersion = this.#roomVersions.get(updateRoomId) + 1;
2519
- this.#roomVersions.set(updateRoomId, currentVersion);
2520
-
2521
- updateRoom.participants?.forEach(p => {
2522
- const client = this.#clients.get(p);
2523
- if (client) {
2524
- client.send(encode({
2525
- type: 'property_updated',
2526
- update: data.update,
2527
- version: currentVersion
2528
- }), { binary: true });
2529
- }
2530
- });
2531
-
2532
- this.#triggerEvent("storageUpdated", { roomId: updateRoomId, clientId: ws.clientId, update: structuredClone(data.update), storage: this.getRoomStorage(updateRoomId) });
2533
- if (this.#debug) console.log("Property update received and imported:", data.update);
2534
- }
2535
- break;
2536
-
2537
- case 'request':
2538
- if (!ws.clientId) return;
2539
- const requestorRoomId = this.#clientRooms.get(ws.clientId) || null;
2540
- this.#triggerEvent("requestReceived", { roomId: requestorRoomId, clientId: ws.clientId, name: data.request.name, data: data.request.data });
2541
- break;
2542
-
2543
- case 'disconnect':
2544
- // Client signals to server that it will will willfully disconnect soon
2545
- ws.willfulDisconnect = true;
2546
- break;
2547
- }
2548
- } catch (error) {
2549
- console.error('Error in message handler:', error);
2550
- }
2551
- }
2552
-
2553
- /**
2554
- * Generate a readable, 6 digit ID
2555
- */
2556
- #generateId() {
2557
- const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ123456789';
2558
- return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
2559
- }
2560
-
2561
- /**
2562
- * If the client that disconnected (and is now in the reconnection-phase) was the room host,
2563
- * pick a new room host immediately to avoid host-less phase (in case important logic is attached to the host)
2564
- * @param {string} roomId
2565
- * @param {string} clientId
2566
- */
2567
- #changeHostIfDisconnected(roomId, clientId) {
2568
- const room = this.#rooms[roomId];
2569
- if (room && room.host === clientId && room.participants.length > 1) {
2570
- const participantsWithoutClient = room.participants.filter((e) => e !== clientId);
2571
- room.host = participantsWithoutClient[0]; // Set new host
2572
-
2573
- // Inform all participants about the new host
2574
- participantsWithoutClient.forEach(p => {
2575
- const client = this.#clients.get(p);
2576
- if (client) client.send(encode({
2577
- type: 'host_migrated',
2578
- newHost: room.host,
2579
- }), { binary: true });
2580
- });
2581
- }
2582
- }
2583
-
2584
- /**
2585
- * Generate a random token to prevent malicious reconnect attempts
2586
- * @returns {string} - Token
2587
- */
2588
- #generateSessionToken() {
2589
- let token = '';
2590
- for (let i = 0; i < 16; i++) token += Math.floor(Math.random() * 16).toString(16);
2591
- return token;
2592
- }
2593
-
2594
- /**
2595
- * Check rate limit using token bucket algorithm
2596
- * @private
2597
- */
2598
- #checkRateLimit(connectionId, operationType) {
2599
- const now = Date.now();
2600
-
2601
- if (!this.#rateLimits.has(connectionId)) {
2602
- this.#rateLimits.set(connectionId, { points: this.#rateLimitMaxPoints, lastReset: now });
2603
- return true;
2604
- }
2605
-
2606
- const limit = this.#rateLimits.get(connectionId);
2607
-
2608
- // Reset points if interval has passed (1s)
2609
- if (now - limit.lastReset > 1000) {
2610
- limit.points = this.#rateLimitMaxPoints;
2611
- limit.lastReset = now;
2612
- }
2613
-
2614
- const pointCost = operationType == "create_room" ? 5 : 1;
2615
- if (limit.points < pointCost) return false;
2616
-
2617
- limit.points -= pointCost;
2618
- return true;
2619
- }
2620
-
2621
- /**
2622
- * Handle client disconnection
2623
- * @private
2624
- */
2625
- #handleDisconnection(ws) {
2626
- this.#rateLimits.delete(ws.connectionId);
2627
-
2628
- if (ws.clientId) {
2629
- this.#clients.delete(ws.clientId); // Immediately remove from active clients (otherwise, server would try to message this client)
2630
-
2631
- // If client was in a room, check if they were the host & migrate
2632
- const roomId = this.#clientRooms.get(ws.clientId);
2633
- if (roomId != null) this.#changeHostIfDisconnected(roomId, ws.clientId);
2634
-
2635
- if (ws.willfulDisconnect) {
2636
- this.#disconnectClient(ws); // Immediate disconnection
2637
- } else {
2638
- // Pending complete disconnection with 5s grace period to allow for reconnections
2639
- this.#pendingDisconnects.set(ws.clientId, {
2640
- timeout: setTimeout(() => {
2641
- this.#disconnectClient(ws);
2642
- }, 5000)
2643
- });
2644
- }
2645
- }
2646
- }
2647
-
2648
- /**
2649
- * Disconnect a client
2650
- * @private
2651
- * @param {Object} ws
2652
- */
2653
- #disconnectClient(ws) {
2654
- this.#pendingDisconnects.delete(ws.clientId);
2655
- this.#clientTokens.delete(ws.clientId);
2656
- let roomId = this.#clientRooms.get(ws.clientId);
2657
- const room = this.#rooms[roomId];
2658
-
2659
- if (room) {
2660
- room.participants = room.participants.filter(p => p !== ws.clientId); // Remove client from room
2661
- this.#clientRooms.delete(ws.clientId);
2662
-
2663
- if (room.participants?.length === 0) {
2664
- delete this.#rooms[roomId]; // Delete room if now empty
2665
- this.#roomVersions.delete(roomId); // Delete room version
2666
- this.#triggerEvent("roomDestroyed", roomId);
2667
- if (this.#debug) console.log("Deleted room with id " + roomId + ".");
2668
- roomId = null; // If room was destroyed, set roomId to null
2669
- } else {
2670
- // Notify remaining participants
2671
- room.participants.forEach(p => {
2672
- const client = this.#clients.get(p);
2673
- if (client) client.send(encode({
2674
- type: 'client_disconnected',
2675
- client: ws.clientId,
2676
- participantCount: room.participants.length
2677
- }), { binary: true });
2678
- });
2679
- }
2680
- }
2681
-
2682
- this.#triggerEvent("clientDisconnected", ws.clientId, roomId);
2683
- }
2684
-
2685
- /**
2686
- * Register an event callback
2687
- * @param {string} event - Event name
2688
- * @param {Function} callback - Callback function
2689
- */
2690
- onEvent(event, callback) {
2691
- const validEvents = ["clientRegistered", "clientRegistrationRequested", "clientDisconnected", "clientJoinedRoom", "clientJoinRequested", "roomCreated", "roomCreationRequested", "requestReceived", "storageUpdated", "storageUpdateRequested", "roomDestroyed"];
2692
- if (!validEvents.includes(event)) return console.warn(`Invalid PlaySocket event type "${event}"`);
2693
- if (!this.#callbacks.has(event)) this.#callbacks.set(event, []);
2694
- this.#callbacks.get(event).push(callback);
2695
- }
2696
-
2697
- /**
2698
- * Kick a player from the server
2699
- * @param {string} clientId - Client ID
2700
- * @param {string} [reason] - Optional reason
2701
- */
2702
- kick(clientId, reason) {
2703
- const client = this.#clients.get(clientId);
2704
- if (client) {
2705
- client.willfulDisconnect = true;
2706
- client.send(encode({ type: 'kicked', reason }), { binary: true });
2707
- client.close();
2708
- }
2709
- }
2710
-
2711
- /**
2712
- * Trigger an event to registered callbacks
2713
- * @private
2714
- */
2715
- async #triggerEvent(event, ...args) {
2716
- const callbacks = this.#callbacks.get(event);
2717
- if (!callbacks) return;
2718
-
2719
- for (const callback of callbacks) {
2720
- try {
2721
- const result = await callback(...args);
2722
- if (result != null) return result; // Return any non-null/undefined result
2723
- } catch (error) {
2724
- console.error(`PlaySocket ${event} callback error:`, error);
2725
- }
2726
- }
2727
- return true;
2728
- }
2729
-
2730
- /**
2731
- * Get snapshot of a room's storage
2732
- * @returns {Object} - Storage object
2733
- */
2734
- getRoomStorage(roomId) {
2735
- const room = this.#rooms[roomId];
2736
- if (room) return room.crdtManager.getPropertyStore;
2737
- }
2738
-
2739
- /**
2740
- * Update a value in a room's storage
2741
- * @param {string} roomId - Room ID
2742
- * @param {string} key - Storage key
2743
- * @param {string} type - Operation type
2744
- * @param {*} value - New value or value to operate on
2745
- * @param {*} updateValue - New value for update-matching
2746
- */
2747
- updateRoomStorage(roomId, key, type, value, updateValue) {
2748
- if (this.#debug) console.log(`Playsocket server property update for room ${roomId}, key ${key}, operation ${type}, value ${value} and updateValue ${updateValue}.`);
2749
- const room = this.#rooms[roomId];
2750
- if (room) {
2751
- const propertyUpdate = room.crdtManager.updateProperty(key, type, value, updateValue);
2752
- const currentVersion = this.#roomVersions.get(roomId) + 1;
2753
- this.#roomVersions.set(roomId, currentVersion); // Increment version for this room
2754
-
2755
- room.participants?.forEach(p => {
2756
- const client = this.#clients.get(p);
2757
- if (client) {
2758
- client.send(encode({
2759
- type: 'property_updated',
2760
- update: propertyUpdate,
2761
- version: currentVersion
2762
- }), { binary: true });
2763
- }
2764
- });
2765
- this.#triggerEvent("storageUpdated", { roomId, clientId: null, update: structuredClone(propertyUpdate), storage: this.getRoomStorage(roomId) });
2766
- }
2767
- }
2768
-
2769
- /**
2770
- * Close all client connections, then close the websocket and http server
2771
- */
2772
- stop() {
2773
- clearInterval(this.#heartbeatInterval);
2774
- if (this.#wss) {
2775
- this.#clients.forEach(client => {
2776
- client.send(encode({ type: 'server_stopped' }), { binary: true });
2777
- client.close();
2778
- });
2779
- this.#wss.close();
2780
- }
2781
- if (this.#server && this.#ownsServer) this.#server.close(() => { console.log('PlaySocket server stopped.'); });
2782
- this.#pendingDisconnects.forEach((data) => {
2783
- clearTimeout(data.timeout);
2784
- });
2785
- }
2786
-
2787
- get getRooms() { return { ...this.#rooms } }
2788
- }
2789
-
2790
- server$1 = PlaySocketServer;
2791
- return server$1;
2792
- }
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
+ }
2793
2256
 
2794
- var serverExports = requireServer();
2795
- var server = /*@__PURE__*/getDefaultExportFromCjs(serverExports);
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
+ }
2796
2666
 
2797
- module.exports = server;
2667
+ module.exports = PlaySocketServer;