playsocketjs 2.7.0 → 3.1.0
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 +17 -14
- package/dist/playsocket-client.js +120 -92
- package/dist/playsocket-server.js +212 -138
- package/package.json +7 -5
package/README.md
CHANGED
|
@@ -67,7 +67,7 @@ socket.onEvent('storageUpdated', storage => (reactiveVariable = storage)); // As
|
|
|
67
67
|
|
|
68
68
|
Interfacing with the synchronized storage (examples):
|
|
69
69
|
```javascript
|
|
70
|
-
const currentState = socket.
|
|
70
|
+
const currentState = socket.storage; // Synchronous, local access
|
|
71
71
|
socket.updateStorage('players', 'array-add-unique', { username: 'Player4', level: 2 }); // Special method to enable conflict-free additions for arrays
|
|
72
72
|
socket.updateStorage('latestPlayer', 'set', 'Player4'); // Regular synced storage update
|
|
73
73
|
```
|
|
@@ -92,20 +92,20 @@ new PlaySocket(id?: string, options: PlaySocketOptions)
|
|
|
92
92
|
|
|
93
93
|
| Option | Type | Default | Description |
|
|
94
94
|
|--------|------|---------|-------------|
|
|
95
|
-
| `endpoint` | string | `
|
|
96
|
-
| `customData` | object | `{}` | Arbitrary data to pass to the "clientRegistered" server event
|
|
95
|
+
| `endpoint` | string | `undefined` | WebSocket server endpoint (e.g., 'wss://example.com/socket') (required) |
|
|
96
|
+
| `customData` | object | `{}` | Arbitrary data to pass to the "clientRegistered" server event |
|
|
97
97
|
| `debug` | boolean | `false` | Set to true to enable extra logging |
|
|
98
98
|
|
|
99
99
|
### Methods
|
|
100
100
|
|
|
101
101
|
| Method | Parameters | Return type | Description |
|
|
102
102
|
|--------|------------|-------------|-------------|
|
|
103
|
-
| `init()` | - | `Promise<string>` | Initialize the WebSocket connection – Returns
|
|
104
|
-
| `createRoom()` | `initialStorage?: object, size?: number` | `Promise<string>` | Create a new room and become host – Returns
|
|
103
|
+
| `init()` | - | `Promise<string>` | Initialize the WebSocket connection – Returns a promise which resolves with the client's ID |
|
|
104
|
+
| `createRoom()` | `initialStorage?: object, size?: number` | `Promise<string>` | Create a new room and become host – Returns a promise which resolves with the room ID. The room participant maximum is 100 |
|
|
105
105
|
| `joinRoom()` | `roomId: string` | `Promise<void>` | Join an existing room |
|
|
106
106
|
| `destroy()` | - | `void` | Use this to leave a room and close the connection |
|
|
107
107
|
| `updateStorage()` | `key: string, type: 'set' \| 'array-add' \| 'array-add-unique' \| 'array-remove-matching' \| 'array-update-matching', value: any, updateValue?: any` | `void` | Update a key in the shared storage (max. 100 keys). Array operation types allow for conflict-free simultaneous array updates. For '-matching' operations, value becomes the value to match, and updateValue the replacement |
|
|
108
|
-
| `sendRequest()` | `name: string, data?: any` | `void` | Send requests to the server with optional custom data (handle
|
|
108
|
+
| `sendRequest()` | `name: string, data?: any` | `void` | Send requests to the server with optional custom data (handle them in the `requestReceived` server event) |
|
|
109
109
|
| `onEvent()` | `event: string, callback: Function` | `void` | Register an event callback |
|
|
110
110
|
|
|
111
111
|
### Event types
|
|
@@ -114,11 +114,12 @@ new PlaySocket(id?: string, options: PlaySocketOptions)
|
|
|
114
114
|
|-------|-------------------|-------------|
|
|
115
115
|
| `status` | `status: string` | Connection status updates |
|
|
116
116
|
| `error` | `error: string` | Error events |
|
|
117
|
+
| `moved` | `roomId: string` | Moved to different room |
|
|
117
118
|
| `instanceDestroyed` | - | Destruction event - triggered by manual .destroy() method invocation or by fatal errors and disconnects |
|
|
118
119
|
| `storageUpdated` | `storage: object` | Storage state changes |
|
|
119
120
|
| `hostMigrated` | `roomId: string` | Host changes |
|
|
120
|
-
| `
|
|
121
|
-
| `
|
|
121
|
+
| `clientJoined` | `clientId: string` | New client joined the room |
|
|
122
|
+
| `clientLeft` | `clientId: string, roomId?: string` | Client left the room |
|
|
122
123
|
|
|
123
124
|
### Properties (Read-only)
|
|
124
125
|
|
|
@@ -126,8 +127,8 @@ new PlaySocket(id?: string, options: PlaySocketOptions)
|
|
|
126
127
|
|----------|------|-------------|
|
|
127
128
|
| `id` | string | Client's unique identifier on the WebSocket server |
|
|
128
129
|
| `isHost` | boolean | If this user is currently assigned the host role |
|
|
129
|
-
| `
|
|
130
|
-
| `
|
|
130
|
+
| `participantCount` | number | Number of active client connections in room (with yourself) |
|
|
131
|
+
| `storage` | object | Retrieve storage object |
|
|
131
132
|
|
|
132
133
|
|
|
133
134
|
|
|
@@ -160,7 +161,7 @@ function shutdown() {
|
|
|
160
161
|
process.exit(0);
|
|
161
162
|
}
|
|
162
163
|
|
|
163
|
-
// Handle both SIGINT
|
|
164
|
+
// Handle both SIGINT and SIGTERM
|
|
164
165
|
process.on('SIGINT', shutdown);
|
|
165
166
|
process.on('SIGTERM', shutdown);
|
|
166
167
|
```
|
|
@@ -192,7 +193,7 @@ function shutdown() {
|
|
|
192
193
|
process.exit(0);
|
|
193
194
|
}
|
|
194
195
|
|
|
195
|
-
// Handle both SIGINT
|
|
196
|
+
// Handle both SIGINT and SIGTERM
|
|
196
197
|
process.on('SIGINT', shutdown);
|
|
197
198
|
process.on('SIGTERM', shutdown);
|
|
198
199
|
```
|
|
@@ -243,6 +244,7 @@ The callback signature is `callback(verified, code?, message?)` where `code` and
|
|
|
243
244
|
|--------|------------|-------------|-------------|
|
|
244
245
|
| `stop()` | - | `void` | Closes all active client connections, the websocket server and the underlying http server if it's standalone |
|
|
245
246
|
| `kick()` | `clientId: string, reason?: string` | `void` | Kick a client by their clientID – this will close their connection and set an error message |
|
|
247
|
+
| `move()` | `clientId: string, roomId: string` | `void` | Move a client, that is already in a room, to a different room |
|
|
246
248
|
| `onEvent()` | `event: string, callback: Function` | `void` | Register a server-side event callback |
|
|
247
249
|
| `getRoomStorage()` | `roomId: string` | `object` | Get a snapshot of the current room storage |
|
|
248
250
|
| `updateRoomStorage()` | `roomId: string, key: string, type: 'set' \| 'array-add' \| 'array-add-unique' \| 'array-remove-matching' \| 'array-update-matching', value: any, updateValue?: any` | `void` | Update a key in the shared room storage from the server |
|
|
@@ -256,8 +258,9 @@ The callback signature is `callback(verified, code?, message?)` where `code` and
|
|
|
256
258
|
| `clientRegistered` | `clientId: string, customData: object` | Client registered with the server | - |
|
|
257
259
|
| `clientRegistrationRequested` | `clientId: string, customData: object` | Client requests to register | Return `false` or rejection reason `string` to block |
|
|
258
260
|
| `clientDisconnected` | `clientId: string` | Client disconnected from the server | - |
|
|
259
|
-
| `clientJoinedRoom` | `clientId: string, roomId: string` | Client joined a room
|
|
261
|
+
| `clientJoinedRoom` | `clientId: string, roomId: string` | Client joined a room | - |
|
|
260
262
|
| `clientJoinRequested` | `clientId: string, roomId: string` | Client requests to join a room | Return `false` or rejection reason `string` to block |
|
|
263
|
+
| `clientLeftRoom` | `clientId: string, roomId: string` | Client left a room | - |
|
|
261
264
|
| `roomCreated` | `roomId: string` | Client created a room | - |
|
|
262
265
|
| `roomDestroyed` | `roomId: string` | Room was destroyed (happens when all participants leave, unless room host is "server") | - |
|
|
263
266
|
| `roomCreationRequested` | `{clientId: string, initialStorage: object}` | Room creation requested by client | Return `object` to override initial storage, `false` to deny |
|
|
@@ -269,7 +272,7 @@ The callback signature is `callback(verified, code?, message?)` where `code` and
|
|
|
269
272
|
|
|
270
273
|
| Property | Type | Description |
|
|
271
274
|
|----------|------|-------------|
|
|
272
|
-
| `
|
|
275
|
+
| `rooms` | object | Retrieve the rooms object |
|
|
273
276
|
|
|
274
277
|
# License
|
|
275
278
|
|
|
@@ -170,6 +170,8 @@ function utf8Decode(bytes, inputOffset, byteLength) {
|
|
|
170
170
|
* ExtData is used to handle Extension Types that are not registered to ExtensionCodec.
|
|
171
171
|
*/
|
|
172
172
|
class ExtData {
|
|
173
|
+
type;
|
|
174
|
+
data;
|
|
173
175
|
constructor(type, data) {
|
|
174
176
|
this.type = type;
|
|
175
177
|
this.data = data;
|
|
@@ -313,13 +315,18 @@ const timestampExtension = {
|
|
|
313
315
|
|
|
314
316
|
// ExtensionCodec to handle MessagePack extensions
|
|
315
317
|
class ExtensionCodec {
|
|
318
|
+
static defaultCodec = new ExtensionCodec();
|
|
319
|
+
// ensures ExtensionCodecType<X> matches ExtensionCodec<X>
|
|
320
|
+
// this will make type errors a lot more clear
|
|
321
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
322
|
+
__brand;
|
|
323
|
+
// built-in extensions
|
|
324
|
+
builtInEncoders = [];
|
|
325
|
+
builtInDecoders = [];
|
|
326
|
+
// custom extensions
|
|
327
|
+
encoders = [];
|
|
328
|
+
decoders = [];
|
|
316
329
|
constructor() {
|
|
317
|
-
// built-in extensions
|
|
318
|
-
this.builtInEncoders = [];
|
|
319
|
-
this.builtInDecoders = [];
|
|
320
|
-
// custom extensions
|
|
321
|
-
this.encoders = [];
|
|
322
|
-
this.decoders = [];
|
|
323
330
|
this.register(timestampExtension);
|
|
324
331
|
}
|
|
325
332
|
register({ type, encode, decode, }) {
|
|
@@ -375,7 +382,6 @@ class ExtensionCodec {
|
|
|
375
382
|
}
|
|
376
383
|
}
|
|
377
384
|
}
|
|
378
|
-
ExtensionCodec.defaultCodec = new ExtensionCodec();
|
|
379
385
|
|
|
380
386
|
function isArrayBufferLike(buffer) {
|
|
381
387
|
return (buffer instanceof ArrayBuffer || (typeof SharedArrayBuffer !== "undefined" && buffer instanceof SharedArrayBuffer));
|
|
@@ -399,8 +405,20 @@ function ensureUint8Array(buffer) {
|
|
|
399
405
|
const DEFAULT_MAX_DEPTH = 100;
|
|
400
406
|
const DEFAULT_INITIAL_BUFFER_SIZE = 2048;
|
|
401
407
|
class Encoder {
|
|
408
|
+
extensionCodec;
|
|
409
|
+
context;
|
|
410
|
+
useBigInt64;
|
|
411
|
+
maxDepth;
|
|
412
|
+
initialBufferSize;
|
|
413
|
+
sortKeys;
|
|
414
|
+
forceFloat32;
|
|
415
|
+
ignoreUndefined;
|
|
416
|
+
forceIntegerToFloat;
|
|
417
|
+
pos;
|
|
418
|
+
view;
|
|
419
|
+
bytes;
|
|
420
|
+
entered = false;
|
|
402
421
|
constructor(options) {
|
|
403
|
-
this.entered = false;
|
|
404
422
|
this.extensionCodec = options?.extensionCodec ?? ExtensionCodec.defaultCodec;
|
|
405
423
|
this.context = options?.context; // needs a type assertion because EncoderOptions has no context property when ContextType is undefined
|
|
406
424
|
this.useBigInt64 = options?.useBigInt64 ?? false;
|
|
@@ -892,9 +910,12 @@ function prettyByte(byte) {
|
|
|
892
910
|
const DEFAULT_MAX_KEY_LENGTH = 16;
|
|
893
911
|
const DEFAULT_MAX_LENGTH_PER_KEY = 16;
|
|
894
912
|
class CachedKeyDecoder {
|
|
913
|
+
hit = 0;
|
|
914
|
+
miss = 0;
|
|
915
|
+
caches;
|
|
916
|
+
maxKeyLength;
|
|
917
|
+
maxLengthPerKey;
|
|
895
918
|
constructor(maxKeyLength = DEFAULT_MAX_KEY_LENGTH, maxLengthPerKey = DEFAULT_MAX_LENGTH_PER_KEY) {
|
|
896
|
-
this.hit = 0;
|
|
897
|
-
this.miss = 0;
|
|
898
919
|
this.maxKeyLength = maxKeyLength;
|
|
899
920
|
this.maxLengthPerKey = maxLengthPerKey;
|
|
900
921
|
// avoid `new Array(N)`, which makes a sparse array,
|
|
@@ -957,10 +978,8 @@ const mapKeyConverter = (key) => {
|
|
|
957
978
|
throw new DecodeError("The type of key must be string or number but " + typeof key);
|
|
958
979
|
};
|
|
959
980
|
class StackPool {
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
this.stackHeadPosition = -1;
|
|
963
|
-
}
|
|
981
|
+
stack = [];
|
|
982
|
+
stackHeadPosition = -1;
|
|
964
983
|
get length() {
|
|
965
984
|
return this.stackHeadPosition + 1;
|
|
966
985
|
}
|
|
@@ -1039,14 +1058,25 @@ catch (e) {
|
|
|
1039
1058
|
const MORE_DATA = new RangeError("Insufficient data");
|
|
1040
1059
|
const sharedCachedKeyDecoder = new CachedKeyDecoder();
|
|
1041
1060
|
class Decoder {
|
|
1061
|
+
extensionCodec;
|
|
1062
|
+
context;
|
|
1063
|
+
useBigInt64;
|
|
1064
|
+
rawStrings;
|
|
1065
|
+
maxStrLength;
|
|
1066
|
+
maxBinLength;
|
|
1067
|
+
maxArrayLength;
|
|
1068
|
+
maxMapLength;
|
|
1069
|
+
maxExtLength;
|
|
1070
|
+
keyDecoder;
|
|
1071
|
+
mapKeyConverter;
|
|
1072
|
+
totalPos = 0;
|
|
1073
|
+
pos = 0;
|
|
1074
|
+
view = EMPTY_VIEW;
|
|
1075
|
+
bytes = EMPTY_BYTES;
|
|
1076
|
+
headByte = HEAD_BYTE_REQUIRED;
|
|
1077
|
+
stack = new StackPool();
|
|
1078
|
+
entered = false;
|
|
1042
1079
|
constructor(options) {
|
|
1043
|
-
this.totalPos = 0;
|
|
1044
|
-
this.pos = 0;
|
|
1045
|
-
this.view = EMPTY_VIEW;
|
|
1046
|
-
this.bytes = EMPTY_BYTES;
|
|
1047
|
-
this.headByte = HEAD_BYTE_REQUIRED;
|
|
1048
|
-
this.stack = new StackPool();
|
|
1049
|
-
this.entered = false;
|
|
1050
1080
|
this.extensionCodec = options?.extensionCodec ?? ExtensionCodec.defaultCodec;
|
|
1051
1081
|
this.context = options?.context; // needs a type assertion because EncoderOptions has no context property when ContextType is undefined
|
|
1052
1082
|
this.useBigInt64 = options?.useBigInt64 ?? false;
|
|
@@ -1669,7 +1699,7 @@ class Decoder {
|
|
|
1669
1699
|
* It decodes a single MessagePack object in a buffer.
|
|
1670
1700
|
*
|
|
1671
1701
|
* This is a synchronous decoding function.
|
|
1672
|
-
* See other variants for asynchronous decoding: {@link decodeAsync}, {@link
|
|
1702
|
+
* See other variants for asynchronous decoding: {@link decodeAsync}, {@link decodeMultiStream}, or {@link decodeArrayStream}.
|
|
1673
1703
|
*
|
|
1674
1704
|
* @throws {@link RangeError} if the buffer is incomplete, including the case where the buffer is empty.
|
|
1675
1705
|
* @throws {@link DecodeError} if the buffer contains invalid data.
|
|
@@ -1679,12 +1709,19 @@ function decode(buffer, options) {
|
|
|
1679
1709
|
return decoder.decode(buffer);
|
|
1680
1710
|
}
|
|
1681
1711
|
|
|
1682
|
-
|
|
1712
|
+
/**
|
|
1713
|
+
* Heartbeat ping interval in milliseconds
|
|
1714
|
+
* Also used as the minimum age for CRDT garbage collection - operations are
|
|
1715
|
+
* retained for at least one heartbeat cycle to ensure even slow clients can never
|
|
1716
|
+
* be desynced as a result of gc
|
|
1717
|
+
*/
|
|
1718
|
+
const HEARTBEAT_INTERVAL = 5000;
|
|
1683
1719
|
|
|
1684
1720
|
const CONSOLE_PREFIX = "PlaySocket CRDT manager: ";
|
|
1685
1721
|
|
|
1686
1722
|
/**
|
|
1687
1723
|
* CRDT Manager
|
|
1724
|
+
* Custom conflict-free replicated data type system with vector clocks
|
|
1688
1725
|
*/
|
|
1689
1726
|
class CRDTManager {
|
|
1690
1727
|
// Storage
|
|
@@ -1692,7 +1729,7 @@ class CRDTManager {
|
|
|
1692
1729
|
#keyOperations = new Map();
|
|
1693
1730
|
#vectorClock = new Map();
|
|
1694
1731
|
|
|
1695
|
-
#propertyStore = {}; // Current local values
|
|
1732
|
+
#propertyStore = {}; // Current local values as key/value store object
|
|
1696
1733
|
#lastPropertyStore = {}; // Last property store to compare against
|
|
1697
1734
|
|
|
1698
1735
|
// Local Garbage Collection
|
|
@@ -1835,7 +1872,6 @@ class CRDTManager {
|
|
|
1835
1872
|
*/
|
|
1836
1873
|
#checkGarbageCollection() {
|
|
1837
1874
|
const MIN_GC_DELAY = 1000; // Minimum 1s delay between garbage collection runs
|
|
1838
|
-
const MIN_AGE_FOR_GC = 5000; // Garbage collect ops that are older than 5s
|
|
1839
1875
|
|
|
1840
1876
|
try {
|
|
1841
1877
|
if (Date.now() - this.#lastGCCheck < MIN_GC_DELAY) return;
|
|
@@ -1847,7 +1883,7 @@ class CRDTManager {
|
|
|
1847
1883
|
let retainCount = operations.length;
|
|
1848
1884
|
operations.forEach((op, index) => {
|
|
1849
1885
|
// Count how many ops, from last to latest, are older than the min-age in a row
|
|
1850
|
-
if (op.uuid && this.#opUuidTimestamp.has(op.uuid) && (Date.now() - this.#opUuidTimestamp.get(op.uuid)) >
|
|
1886
|
+
if (op.uuid && this.#opUuidTimestamp.has(op.uuid) && (Date.now() - this.#opUuidTimestamp.get(op.uuid)) > HEARTBEAT_INTERVAL) {
|
|
1851
1887
|
const removeCount = index + 1;
|
|
1852
1888
|
retainCount = operations.length - removeCount;
|
|
1853
1889
|
this.#opUuidTimestamp.delete(op.uuid); // Remove from timestamps if set to be deleted
|
|
@@ -2041,7 +2077,7 @@ class CRDTManager {
|
|
|
2041
2077
|
* Get property store
|
|
2042
2078
|
* @returns {Record<string, any>} - Object with key value pairs
|
|
2043
2079
|
*/
|
|
2044
|
-
get
|
|
2080
|
+
get propertyStore() {
|
|
2045
2081
|
try {
|
|
2046
2082
|
return structuredClone(this.#propertyStore); // Return deep clone
|
|
2047
2083
|
} catch {
|
|
@@ -2070,7 +2106,7 @@ class CRDTManager {
|
|
|
2070
2106
|
* Export state (can be imported using importState, converts the maps to arrays for serialization)
|
|
2071
2107
|
* @returns {object} - State
|
|
2072
2108
|
*/
|
|
2073
|
-
get
|
|
2109
|
+
get state() {
|
|
2074
2110
|
return {
|
|
2075
2111
|
keyOperations: [...this.#keyOperations.entries()],
|
|
2076
2112
|
vectorClock: [...this.#vectorClock.entries()]
|
|
@@ -2098,7 +2134,7 @@ class PlaySocket {
|
|
|
2098
2134
|
// Room properties
|
|
2099
2135
|
#roomHost;
|
|
2100
2136
|
#inRoom; // If the client is currently in a room or not
|
|
2101
|
-
#
|
|
2137
|
+
#participantCount = 0;
|
|
2102
2138
|
#crdtManager;
|
|
2103
2139
|
#roomVersion = 0; // Update version (used to compare local vs. remote state to detect package loss)
|
|
2104
2140
|
|
|
@@ -2154,7 +2190,7 @@ class PlaySocket {
|
|
|
2154
2190
|
* @param {Function} callback - Callback function
|
|
2155
2191
|
*/
|
|
2156
2192
|
onEvent(event, callback) {
|
|
2157
|
-
const validEvents = ["status", "error", "instanceDestroyed", "storageUpdated", "hostMigrated", "
|
|
2193
|
+
const validEvents = ["status", "error", "moved", "instanceDestroyed", "storageUpdated", "hostMigrated", "clientJoined", "clientLeft"];
|
|
2158
2194
|
if (!validEvents.includes(event)) {
|
|
2159
2195
|
console.warn(WARNING_PREFIX + `Invalid event type "${event}".`);
|
|
2160
2196
|
return;
|
|
@@ -2245,64 +2281,59 @@ class PlaySocket {
|
|
|
2245
2281
|
|
|
2246
2282
|
switch (message.type) {
|
|
2247
2283
|
case 'registration_failed':
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
this.#triggerEvent("error", "Failed to register: " + message.reason);
|
|
2251
|
-
}
|
|
2284
|
+
this.#triggerEvent("error", "Failed to register: " + message.reason);
|
|
2285
|
+
if (this.#pendingRegistration) this.#pendingRegistration.reject(new Error("Failed to register: " + message.reason));
|
|
2252
2286
|
break;
|
|
2253
2287
|
|
|
2254
2288
|
case 'registered':
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
this.#triggerEvent("status", "Connected to server.");
|
|
2260
|
-
}
|
|
2289
|
+
this.#sessionToken = message.sessionToken;
|
|
2290
|
+
this.#initialized = true;
|
|
2291
|
+
if (this.#pendingRegistration) this.#pendingRegistration.resolve(message.id);
|
|
2292
|
+
this.#triggerEvent("status", "Connected to server.");
|
|
2261
2293
|
break;
|
|
2262
2294
|
|
|
2263
2295
|
case 'join_accepted':
|
|
2264
2296
|
// Connected to room
|
|
2297
|
+
if (this.#debug) console.log(LOG_PREFIX + "State received for join:", message.state);
|
|
2298
|
+
this.#inRoom = true;
|
|
2299
|
+
this.#crdtManager.importState(message.state);
|
|
2300
|
+
this.#participantCount = message.participantCount;
|
|
2301
|
+
this.#roomHost = message.host;
|
|
2302
|
+
this.#roomVersion = message.version;
|
|
2303
|
+
this.#triggerEvent("storageUpdated", this.storage);
|
|
2265
2304
|
if (this.#pendingJoin) {
|
|
2266
|
-
|
|
2267
|
-
this.#inRoom = true;
|
|
2268
|
-
this.#crdtManager.importState(message.state);
|
|
2269
|
-
this.#connectionCount = message.participantCount - 1; // Counted without the user themselves
|
|
2270
|
-
this.#roomHost = message.host;
|
|
2271
|
-
this.#roomVersion = message.version;
|
|
2272
|
-
this.#triggerEvent("storageUpdated", this.getStorage);
|
|
2273
|
-
this.#triggerEvent("status", `Connected to room.`);
|
|
2305
|
+
this.#triggerEvent("status", "Connected to room.");
|
|
2274
2306
|
this.#pendingJoin.resolve();
|
|
2307
|
+
} else {
|
|
2308
|
+
this.#triggerEvent("status", "Moved to room.");
|
|
2309
|
+
this.#triggerEvent("moved", message.roomId);
|
|
2275
2310
|
}
|
|
2276
2311
|
break;
|
|
2277
2312
|
|
|
2278
2313
|
case 'join_rejected':
|
|
2279
2314
|
// Connection to room failed
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
this.#pendingJoin.reject(new Error("Failed to join room: " + message.reason));
|
|
2283
|
-
}
|
|
2315
|
+
this.#triggerEvent("status", "Failed to join room: " + message.reason);
|
|
2316
|
+
if (this.#pendingJoin) this.#pendingJoin.reject(new Error("Failed to join room: " + message.reason));
|
|
2284
2317
|
break;
|
|
2285
2318
|
|
|
2286
2319
|
case 'reconnected':
|
|
2287
2320
|
// Successfully reconnected
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
if (message.roomData)
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
return this.destroy();
|
|
2302
|
-
}
|
|
2303
|
-
this.#triggerEvent("status", "Reconnected.");
|
|
2304
|
-
this.#pendingReconnect.resolve();
|
|
2321
|
+
this.#isReconnecting = false;
|
|
2322
|
+
this.#reconnectCount = 0;
|
|
2323
|
+
if (message.roomData) {
|
|
2324
|
+
if (this.#debug) console.log(LOG_PREFIX + "State received for reconnect:", message.roomData.state);
|
|
2325
|
+
this.#crdtManager.importState(message.roomData.state);
|
|
2326
|
+
this.#roomVersion = message.roomData.version;
|
|
2327
|
+
this.#participantCount = message.roomData.participantCount;
|
|
2328
|
+
this.#setHost(message.roomData.host); // Set host before in case there are .isHost checks in the storageUpdate fallback
|
|
2329
|
+
this.#triggerEvent("storageUpdated", this.storage);
|
|
2330
|
+
} else if (this.#inRoom) {
|
|
2331
|
+
// If no room data received, but client thinks they were in a room...
|
|
2332
|
+
this.#triggerEvent("error", "Reconnected, but room no longer exists.");
|
|
2333
|
+
return this.destroy();
|
|
2305
2334
|
}
|
|
2335
|
+
this.#triggerEvent("status", "Reconnected.");
|
|
2336
|
+
if (this.#pendingReconnect) this.#pendingReconnect.resolve();
|
|
2306
2337
|
break;
|
|
2307
2338
|
|
|
2308
2339
|
case 'reconnection_failed':
|
|
@@ -2310,27 +2341,24 @@ class PlaySocket {
|
|
|
2310
2341
|
break;
|
|
2311
2342
|
|
|
2312
2343
|
case 'room_created':
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
}
|
|
2344
|
+
this.#inRoom = true;
|
|
2345
|
+
this.#participantCount = message.participantCount;
|
|
2346
|
+
this.#triggerEvent("status", `Room created.`);
|
|
2347
|
+
this.#crdtManager.importState(message.state);
|
|
2348
|
+
this.#triggerEvent("storageUpdated", this.storage);
|
|
2349
|
+
if (this.#pendingCreate) this.#pendingCreate.resolve(message.roomId);
|
|
2320
2350
|
break;
|
|
2321
2351
|
|
|
2322
2352
|
case 'room_creation_failed':
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
this.#pendingCreate.reject(new Error("Failed to create room: " + message.reason));
|
|
2326
|
-
}
|
|
2353
|
+
this.#triggerEvent("error", "Failed to create room: " + message.reason);
|
|
2354
|
+
if (this.#pendingCreate) this.#pendingCreate.reject(new Error("Failed to create room: " + message.reason));
|
|
2327
2355
|
break;
|
|
2328
2356
|
|
|
2329
2357
|
case 'property_updated':
|
|
2330
2358
|
this.#roomVersion++; // Increment room version
|
|
2331
2359
|
if (this.#debug) console.log(LOG_PREFIX + "Property update received:", message.update);
|
|
2332
2360
|
this.#crdtManager.importPropertyUpdate(message.update);
|
|
2333
|
-
if (this.#crdtManager.didPropertiesChange) this.#triggerEvent("storageUpdated", this.
|
|
2361
|
+
if (this.#crdtManager.didPropertiesChange) this.#triggerEvent("storageUpdated", this.storage);
|
|
2334
2362
|
if (this.#roomVersion != message.version && this.#initialized && this.#socket?.readyState === WebSocket.OPEN) {
|
|
2335
2363
|
this.#triggerEvent("error", "Detected skipped update – forcing reconnect.");
|
|
2336
2364
|
this.#socket?.close();
|
|
@@ -2340,7 +2368,7 @@ class PlaySocket {
|
|
|
2340
2368
|
case 'property_update_rejected':
|
|
2341
2369
|
this.#triggerEvent("error", "Property update rejected. Re-syncing state.");
|
|
2342
2370
|
this.#crdtManager.importState(message.state);
|
|
2343
|
-
this.#triggerEvent("storageUpdated", this.
|
|
2371
|
+
this.#triggerEvent("storageUpdated", this.storage);
|
|
2344
2372
|
break;
|
|
2345
2373
|
|
|
2346
2374
|
case 'kicked':
|
|
@@ -2352,15 +2380,15 @@ class PlaySocket {
|
|
|
2352
2380
|
this.#setHost(message.newHost);
|
|
2353
2381
|
break;
|
|
2354
2382
|
|
|
2355
|
-
case '
|
|
2356
|
-
this.#
|
|
2357
|
-
this.#triggerEvent("
|
|
2383
|
+
case 'client_left':
|
|
2384
|
+
this.#participantCount = message.participantCount;
|
|
2385
|
+
this.#triggerEvent("clientLeft", message.client);
|
|
2358
2386
|
this.#triggerEvent("status", `Client ${message.client} disconnected.`);
|
|
2359
2387
|
break;
|
|
2360
2388
|
|
|
2361
|
-
case '
|
|
2362
|
-
this.#
|
|
2363
|
-
this.#triggerEvent("
|
|
2389
|
+
case 'client_joined':
|
|
2390
|
+
this.#participantCount = message.participantCount;
|
|
2391
|
+
this.#triggerEvent("clientJoined", message.client);
|
|
2364
2392
|
this.#triggerEvent("status", `Client ${message.client} connected.`);
|
|
2365
2393
|
break;
|
|
2366
2394
|
}
|
|
@@ -2523,7 +2551,7 @@ class PlaySocket {
|
|
|
2523
2551
|
type: 'update_property',
|
|
2524
2552
|
update: propUpdate
|
|
2525
2553
|
});
|
|
2526
|
-
if (this.#crdtManager.didPropertiesChange) this.#triggerEvent("storageUpdated", this.
|
|
2554
|
+
if (this.#crdtManager.didPropertiesChange) this.#triggerEvent("storageUpdated", this.storage); // Always trigger callback AFTER send in case msgs are sent in the callback (which would break the order)
|
|
2527
2555
|
}
|
|
2528
2556
|
|
|
2529
2557
|
/**
|
|
@@ -2563,7 +2591,7 @@ class PlaySocket {
|
|
|
2563
2591
|
clearTimeout(this.#reconnectTimeout);
|
|
2564
2592
|
this.#roomHost = null;
|
|
2565
2593
|
this.#inRoom = false;
|
|
2566
|
-
this.#
|
|
2594
|
+
this.#participantCount = 0;
|
|
2567
2595
|
this.#isReconnecting = false;
|
|
2568
2596
|
this.#reconnectCount = 0;
|
|
2569
2597
|
this.#roomVersion = 0;
|
|
@@ -2577,8 +2605,8 @@ class PlaySocket {
|
|
|
2577
2605
|
}
|
|
2578
2606
|
|
|
2579
2607
|
// Public getters
|
|
2580
|
-
get
|
|
2581
|
-
get
|
|
2608
|
+
get participantCount() { return this.#participantCount; }
|
|
2609
|
+
get storage() { return this.#crdtManager.propertyStore; }
|
|
2582
2610
|
get isHost() { return this.#id == this.#roomHost; }
|
|
2583
2611
|
get id() { return this.#id; }
|
|
2584
2612
|
}
|
|
@@ -173,6 +173,8 @@ function utf8Decode(bytes, inputOffset, byteLength) {
|
|
|
173
173
|
* ExtData is used to handle Extension Types that are not registered to ExtensionCodec.
|
|
174
174
|
*/
|
|
175
175
|
class ExtData {
|
|
176
|
+
type;
|
|
177
|
+
data;
|
|
176
178
|
constructor(type, data) {
|
|
177
179
|
this.type = type;
|
|
178
180
|
this.data = data;
|
|
@@ -316,13 +318,18 @@ const timestampExtension = {
|
|
|
316
318
|
|
|
317
319
|
// ExtensionCodec to handle MessagePack extensions
|
|
318
320
|
class ExtensionCodec {
|
|
321
|
+
static defaultCodec = new ExtensionCodec();
|
|
322
|
+
// ensures ExtensionCodecType<X> matches ExtensionCodec<X>
|
|
323
|
+
// this will make type errors a lot more clear
|
|
324
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
325
|
+
__brand;
|
|
326
|
+
// built-in extensions
|
|
327
|
+
builtInEncoders = [];
|
|
328
|
+
builtInDecoders = [];
|
|
329
|
+
// custom extensions
|
|
330
|
+
encoders = [];
|
|
331
|
+
decoders = [];
|
|
319
332
|
constructor() {
|
|
320
|
-
// built-in extensions
|
|
321
|
-
this.builtInEncoders = [];
|
|
322
|
-
this.builtInDecoders = [];
|
|
323
|
-
// custom extensions
|
|
324
|
-
this.encoders = [];
|
|
325
|
-
this.decoders = [];
|
|
326
333
|
this.register(timestampExtension);
|
|
327
334
|
}
|
|
328
335
|
register({ type, encode, decode, }) {
|
|
@@ -378,7 +385,6 @@ class ExtensionCodec {
|
|
|
378
385
|
}
|
|
379
386
|
}
|
|
380
387
|
}
|
|
381
|
-
ExtensionCodec.defaultCodec = new ExtensionCodec();
|
|
382
388
|
|
|
383
389
|
function isArrayBufferLike(buffer) {
|
|
384
390
|
return (buffer instanceof ArrayBuffer || (typeof SharedArrayBuffer !== "undefined" && buffer instanceof SharedArrayBuffer));
|
|
@@ -402,8 +408,20 @@ function ensureUint8Array(buffer) {
|
|
|
402
408
|
const DEFAULT_MAX_DEPTH = 100;
|
|
403
409
|
const DEFAULT_INITIAL_BUFFER_SIZE = 2048;
|
|
404
410
|
class Encoder {
|
|
411
|
+
extensionCodec;
|
|
412
|
+
context;
|
|
413
|
+
useBigInt64;
|
|
414
|
+
maxDepth;
|
|
415
|
+
initialBufferSize;
|
|
416
|
+
sortKeys;
|
|
417
|
+
forceFloat32;
|
|
418
|
+
ignoreUndefined;
|
|
419
|
+
forceIntegerToFloat;
|
|
420
|
+
pos;
|
|
421
|
+
view;
|
|
422
|
+
bytes;
|
|
423
|
+
entered = false;
|
|
405
424
|
constructor(options) {
|
|
406
|
-
this.entered = false;
|
|
407
425
|
this.extensionCodec = options?.extensionCodec ?? ExtensionCodec.defaultCodec;
|
|
408
426
|
this.context = options?.context; // needs a type assertion because EncoderOptions has no context property when ContextType is undefined
|
|
409
427
|
this.useBigInt64 = options?.useBigInt64 ?? false;
|
|
@@ -895,9 +913,12 @@ function prettyByte(byte) {
|
|
|
895
913
|
const DEFAULT_MAX_KEY_LENGTH = 16;
|
|
896
914
|
const DEFAULT_MAX_LENGTH_PER_KEY = 16;
|
|
897
915
|
class CachedKeyDecoder {
|
|
916
|
+
hit = 0;
|
|
917
|
+
miss = 0;
|
|
918
|
+
caches;
|
|
919
|
+
maxKeyLength;
|
|
920
|
+
maxLengthPerKey;
|
|
898
921
|
constructor(maxKeyLength = DEFAULT_MAX_KEY_LENGTH, maxLengthPerKey = DEFAULT_MAX_LENGTH_PER_KEY) {
|
|
899
|
-
this.hit = 0;
|
|
900
|
-
this.miss = 0;
|
|
901
922
|
this.maxKeyLength = maxKeyLength;
|
|
902
923
|
this.maxLengthPerKey = maxLengthPerKey;
|
|
903
924
|
// avoid `new Array(N)`, which makes a sparse array,
|
|
@@ -960,10 +981,8 @@ const mapKeyConverter = (key) => {
|
|
|
960
981
|
throw new DecodeError("The type of key must be string or number but " + typeof key);
|
|
961
982
|
};
|
|
962
983
|
class StackPool {
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
this.stackHeadPosition = -1;
|
|
966
|
-
}
|
|
984
|
+
stack = [];
|
|
985
|
+
stackHeadPosition = -1;
|
|
967
986
|
get length() {
|
|
968
987
|
return this.stackHeadPosition + 1;
|
|
969
988
|
}
|
|
@@ -1042,14 +1061,25 @@ catch (e) {
|
|
|
1042
1061
|
const MORE_DATA = new RangeError("Insufficient data");
|
|
1043
1062
|
const sharedCachedKeyDecoder = new CachedKeyDecoder();
|
|
1044
1063
|
class Decoder {
|
|
1064
|
+
extensionCodec;
|
|
1065
|
+
context;
|
|
1066
|
+
useBigInt64;
|
|
1067
|
+
rawStrings;
|
|
1068
|
+
maxStrLength;
|
|
1069
|
+
maxBinLength;
|
|
1070
|
+
maxArrayLength;
|
|
1071
|
+
maxMapLength;
|
|
1072
|
+
maxExtLength;
|
|
1073
|
+
keyDecoder;
|
|
1074
|
+
mapKeyConverter;
|
|
1075
|
+
totalPos = 0;
|
|
1076
|
+
pos = 0;
|
|
1077
|
+
view = EMPTY_VIEW;
|
|
1078
|
+
bytes = EMPTY_BYTES;
|
|
1079
|
+
headByte = HEAD_BYTE_REQUIRED;
|
|
1080
|
+
stack = new StackPool();
|
|
1081
|
+
entered = false;
|
|
1045
1082
|
constructor(options) {
|
|
1046
|
-
this.totalPos = 0;
|
|
1047
|
-
this.pos = 0;
|
|
1048
|
-
this.view = EMPTY_VIEW;
|
|
1049
|
-
this.bytes = EMPTY_BYTES;
|
|
1050
|
-
this.headByte = HEAD_BYTE_REQUIRED;
|
|
1051
|
-
this.stack = new StackPool();
|
|
1052
|
-
this.entered = false;
|
|
1053
1083
|
this.extensionCodec = options?.extensionCodec ?? ExtensionCodec.defaultCodec;
|
|
1054
1084
|
this.context = options?.context; // needs a type assertion because EncoderOptions has no context property when ContextType is undefined
|
|
1055
1085
|
this.useBigInt64 = options?.useBigInt64 ?? false;
|
|
@@ -1672,7 +1702,7 @@ class Decoder {
|
|
|
1672
1702
|
* It decodes a single MessagePack object in a buffer.
|
|
1673
1703
|
*
|
|
1674
1704
|
* This is a synchronous decoding function.
|
|
1675
|
-
* See other variants for asynchronous decoding: {@link decodeAsync}, {@link
|
|
1705
|
+
* See other variants for asynchronous decoding: {@link decodeAsync}, {@link decodeMultiStream}, or {@link decodeArrayStream}.
|
|
1676
1706
|
*
|
|
1677
1707
|
* @throws {@link RangeError} if the buffer is incomplete, including the case where the buffer is empty.
|
|
1678
1708
|
* @throws {@link DecodeError} if the buffer contains invalid data.
|
|
@@ -1682,12 +1712,19 @@ function decode(buffer, options) {
|
|
|
1682
1712
|
return decoder.decode(buffer);
|
|
1683
1713
|
}
|
|
1684
1714
|
|
|
1685
|
-
|
|
1715
|
+
/**
|
|
1716
|
+
* Heartbeat ping interval in milliseconds
|
|
1717
|
+
* Also used as the minimum age for CRDT garbage collection - operations are
|
|
1718
|
+
* retained for at least one heartbeat cycle to ensure even slow clients can never
|
|
1719
|
+
* be desynced as a result of gc
|
|
1720
|
+
*/
|
|
1721
|
+
const HEARTBEAT_INTERVAL = 5000;
|
|
1686
1722
|
|
|
1687
1723
|
const CONSOLE_PREFIX = "PlaySocket CRDT manager: ";
|
|
1688
1724
|
|
|
1689
1725
|
/**
|
|
1690
1726
|
* CRDT Manager
|
|
1727
|
+
* Custom conflict-free replicated data type system with vector clocks
|
|
1691
1728
|
*/
|
|
1692
1729
|
class CRDTManager {
|
|
1693
1730
|
// Storage
|
|
@@ -1695,7 +1732,7 @@ class CRDTManager {
|
|
|
1695
1732
|
#keyOperations = new Map();
|
|
1696
1733
|
#vectorClock = new Map();
|
|
1697
1734
|
|
|
1698
|
-
#propertyStore = {}; // Current local values
|
|
1735
|
+
#propertyStore = {}; // Current local values as key/value store object
|
|
1699
1736
|
#lastPropertyStore = {}; // Last property store to compare against
|
|
1700
1737
|
|
|
1701
1738
|
// Local Garbage Collection
|
|
@@ -1838,7 +1875,6 @@ class CRDTManager {
|
|
|
1838
1875
|
*/
|
|
1839
1876
|
#checkGarbageCollection() {
|
|
1840
1877
|
const MIN_GC_DELAY = 1000; // Minimum 1s delay between garbage collection runs
|
|
1841
|
-
const MIN_AGE_FOR_GC = 5000; // Garbage collect ops that are older than 5s
|
|
1842
1878
|
|
|
1843
1879
|
try {
|
|
1844
1880
|
if (Date.now() - this.#lastGCCheck < MIN_GC_DELAY) return;
|
|
@@ -1850,7 +1886,7 @@ class CRDTManager {
|
|
|
1850
1886
|
let retainCount = operations.length;
|
|
1851
1887
|
operations.forEach((op, index) => {
|
|
1852
1888
|
// Count how many ops, from last to latest, are older than the min-age in a row
|
|
1853
|
-
if (op.uuid && this.#opUuidTimestamp.has(op.uuid) && (Date.now() - this.#opUuidTimestamp.get(op.uuid)) >
|
|
1889
|
+
if (op.uuid && this.#opUuidTimestamp.has(op.uuid) && (Date.now() - this.#opUuidTimestamp.get(op.uuid)) > HEARTBEAT_INTERVAL) {
|
|
1854
1890
|
const removeCount = index + 1;
|
|
1855
1891
|
retainCount = operations.length - removeCount;
|
|
1856
1892
|
this.#opUuidTimestamp.delete(op.uuid); // Remove from timestamps if set to be deleted
|
|
@@ -2044,7 +2080,7 @@ class CRDTManager {
|
|
|
2044
2080
|
* Get property store
|
|
2045
2081
|
* @returns {Record<string, any>} - Object with key value pairs
|
|
2046
2082
|
*/
|
|
2047
|
-
get
|
|
2083
|
+
get propertyStore() {
|
|
2048
2084
|
try {
|
|
2049
2085
|
return structuredClone(this.#propertyStore); // Return deep clone
|
|
2050
2086
|
} catch {
|
|
@@ -2073,7 +2109,7 @@ class CRDTManager {
|
|
|
2073
2109
|
* Export state (can be imported using importState, converts the maps to arrays for serialization)
|
|
2074
2110
|
* @returns {object} - State
|
|
2075
2111
|
*/
|
|
2076
|
-
get
|
|
2112
|
+
get state() {
|
|
2077
2113
|
return {
|
|
2078
2114
|
keyOperations: [...this.#keyOperations.entries()],
|
|
2079
2115
|
vectorClock: [...this.#vectorClock.entries()]
|
|
@@ -2084,6 +2120,7 @@ class CRDTManager {
|
|
|
2084
2120
|
/* eslint-disable jsdoc/require-returns */
|
|
2085
2121
|
|
|
2086
2122
|
const MAX_ROOM_SIZE = 500;
|
|
2123
|
+
const RECONNECT_GRACE_PERIOD = 5000; // Exported for use in tests
|
|
2087
2124
|
|
|
2088
2125
|
/**
|
|
2089
2126
|
* @typedef {import('node:http').Server} HttpServer
|
|
@@ -2170,7 +2207,7 @@ class PlaySocketServer {
|
|
|
2170
2207
|
ws.isAlive = false;
|
|
2171
2208
|
ws.ping();
|
|
2172
2209
|
});
|
|
2173
|
-
},
|
|
2210
|
+
}, HEARTBEAT_INTERVAL);
|
|
2174
2211
|
}
|
|
2175
2212
|
|
|
2176
2213
|
/**
|
|
@@ -2262,9 +2299,9 @@ class PlaySocketServer {
|
|
|
2262
2299
|
// If the room has no host (they were the only one and disconnected), restore them as host
|
|
2263
2300
|
if (formerRoom.host === null) formerRoom.host = data.id;
|
|
2264
2301
|
|
|
2265
|
-
if (this.#debug) console.log(`State sent for reconnection for room ${formerRoomId}:`, formerRoom.crdtManager.
|
|
2302
|
+
if (this.#debug) console.log(`State sent for reconnection for room ${formerRoomId}:`, formerRoom.crdtManager.state);
|
|
2266
2303
|
roomData = {
|
|
2267
|
-
state: formerRoom.crdtManager.
|
|
2304
|
+
state: formerRoom.crdtManager.state,
|
|
2268
2305
|
participantCount: formerRoom.participants.length,
|
|
2269
2306
|
host: formerRoom.host,
|
|
2270
2307
|
version: this.#roomVersions.get(formerRoomId)
|
|
@@ -2306,7 +2343,7 @@ class PlaySocketServer {
|
|
|
2306
2343
|
|
|
2307
2344
|
this.#rooms[newRoom.id].participants.push(ws.clientId); // Add client to the room
|
|
2308
2345
|
this.#clientRooms.set(ws.clientId, newRoom.id); // Add client to the client-room map
|
|
2309
|
-
ws.send(encode({ type: 'room_created', state: newRoom.state, roomId: newRoom.id }), { binary: true });
|
|
2346
|
+
ws.send(encode({ type: 'room_created', state: newRoom.state, roomId: newRoom.id, participantCount: this.#rooms[newRoom.id].participants.length }), { binary: true });
|
|
2310
2347
|
} catch (error) {
|
|
2311
2348
|
console.error("PlaySocket error creating room:", error);
|
|
2312
2349
|
ws.send(encode({ type: 'room_creation_failed', reason: error.message }), { binary: true });
|
|
@@ -2317,7 +2354,6 @@ class PlaySocketServer {
|
|
|
2317
2354
|
case 'join_room': {
|
|
2318
2355
|
if (!data.roomId || !ws.clientId) return;
|
|
2319
2356
|
const roomId = data.roomId;
|
|
2320
|
-
const room = this.#rooms[roomId];
|
|
2321
2357
|
|
|
2322
2358
|
const rejectJoin = (reason) => {
|
|
2323
2359
|
ws.send(encode({ type: 'join_rejected', reason }), { binary: true });
|
|
@@ -2327,47 +2363,12 @@ class PlaySocketServer {
|
|
|
2327
2363
|
const joinAllowed = await this.#triggerEvent("clientJoinRequested", ws.clientId, roomId);
|
|
2328
2364
|
if (joinAllowed === false || typeof joinAllowed === 'string') return rejectJoin(typeof joinAllowed === 'string' ? joinAllowed : 'Denied.');
|
|
2329
2365
|
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
room.participants.push(ws.clientId);
|
|
2335
|
-
this.#clientRooms.set(ws.clientId, roomId);
|
|
2336
|
-
|
|
2337
|
-
// Notify joiner with initial storage
|
|
2338
|
-
if (this.#debug) console.log("Room state sent for join:", room.crdtManager.getState);
|
|
2339
|
-
ws.send(encode({
|
|
2340
|
-
type: 'join_accepted',
|
|
2341
|
-
state: room.crdtManager.getState,
|
|
2342
|
-
participantCount: room.participants.length,
|
|
2343
|
-
host: room.host,
|
|
2344
|
-
version: this.#roomVersions.get(roomId)
|
|
2345
|
-
}), { binary: true });
|
|
2346
|
-
|
|
2347
|
-
// If the room has no host (previous host disconnected and was the only participant), make joiner the host
|
|
2348
|
-
// Since the room had to be empty, only the joiner needs to be notified
|
|
2349
|
-
const becameHost = room.host === null;
|
|
2350
|
-
if (becameHost) {
|
|
2351
|
-
room.host = ws.clientId;
|
|
2352
|
-
ws.send(encode({
|
|
2353
|
-
type: 'host_migrated',
|
|
2354
|
-
newHost: room.host,
|
|
2355
|
-
}), { binary: true });
|
|
2366
|
+
try {
|
|
2367
|
+
this.#joinRoom(ws.clientId, roomId);
|
|
2368
|
+
} catch (error) {
|
|
2369
|
+
rejectJoin(error.message);
|
|
2356
2370
|
}
|
|
2357
2371
|
|
|
2358
|
-
// Notify existing participants
|
|
2359
|
-
room.participants.forEach(p => {
|
|
2360
|
-
if (p === ws.clientId) return;
|
|
2361
|
-
const client = this.#clients.get(p);
|
|
2362
|
-
if (client) {
|
|
2363
|
-
client.send(encode({
|
|
2364
|
-
type: 'client_connected',
|
|
2365
|
-
client: ws.clientId,
|
|
2366
|
-
participantCount: room.participants.length
|
|
2367
|
-
}), { binary: true });
|
|
2368
|
-
}
|
|
2369
|
-
});
|
|
2370
|
-
this.#triggerEvent("clientJoinedRoom", ws.clientId, roomId);
|
|
2371
2372
|
break;
|
|
2372
2373
|
}
|
|
2373
2374
|
|
|
@@ -2381,7 +2382,7 @@ class PlaySocketServer {
|
|
|
2381
2382
|
if (updateAllowed === false) {
|
|
2382
2383
|
ws.send(encode({
|
|
2383
2384
|
type: 'property_update_rejected',
|
|
2384
|
-
state: room.crdtManager.
|
|
2385
|
+
state: room.crdtManager.state
|
|
2385
2386
|
}), { binary: true });
|
|
2386
2387
|
return;
|
|
2387
2388
|
}
|
|
@@ -2436,29 +2437,28 @@ class PlaySocketServer {
|
|
|
2436
2437
|
}
|
|
2437
2438
|
|
|
2438
2439
|
/**
|
|
2439
|
-
*
|
|
2440
|
-
*
|
|
2441
|
-
* @param {string}
|
|
2442
|
-
* @param {string} clientId - ID of the disconnected client
|
|
2440
|
+
* Migrate the host role to another participant
|
|
2441
|
+
* @param {string} roomId - ID of the room
|
|
2442
|
+
* @param {string} clientId - ID of the current host
|
|
2443
2443
|
*/
|
|
2444
|
-
#
|
|
2444
|
+
#migrateHost(roomId, clientId) {
|
|
2445
2445
|
const room = this.#rooms[roomId];
|
|
2446
|
-
if (room
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
});
|
|
2460
|
-
}
|
|
2461
|
-
}
|
|
2446
|
+
if (!room) throw new Error("Room not found.");
|
|
2447
|
+
if (room.host !== clientId) throw new Error("Client is not the host.");
|
|
2448
|
+
|
|
2449
|
+
const remainingParticipants = room.participants.filter(p => p !== clientId);
|
|
2450
|
+
|
|
2451
|
+
if (remainingParticipants.length >= 1) {
|
|
2452
|
+
// Inform all participants about the new host
|
|
2453
|
+
room.host = remainingParticipants[0];
|
|
2454
|
+
remainingParticipants.forEach(p => {
|
|
2455
|
+
const client = this.#clients.get(p);
|
|
2456
|
+
if (client) client.send(encode({
|
|
2457
|
+
type: 'host_migrated',
|
|
2458
|
+
newHost: room.host,
|
|
2459
|
+
}), { binary: true });
|
|
2460
|
+
});
|
|
2461
|
+
} else room.host = null; // The next person to join will become the host
|
|
2462
2462
|
}
|
|
2463
2463
|
|
|
2464
2464
|
/**
|
|
@@ -2514,16 +2514,15 @@ class PlaySocketServer {
|
|
|
2514
2514
|
|
|
2515
2515
|
// If client was in a room, check if they were the host & migrate
|
|
2516
2516
|
const roomId = this.#clientRooms.get(ws.clientId);
|
|
2517
|
-
if (roomId != null) this.#
|
|
2517
|
+
if (roomId != null && this.#rooms[roomId]?.host === ws.clientId) this.#migrateHost(roomId, ws.clientId);
|
|
2518
2518
|
|
|
2519
|
-
if (ws.willfulDisconnect)
|
|
2520
|
-
|
|
2521
|
-
} else {
|
|
2519
|
+
if (ws.willfulDisconnect) this.#disconnectClient(ws); // Immediate disconnection
|
|
2520
|
+
else {
|
|
2522
2521
|
// Pending complete disconnection with 5s grace period to allow for reconnections
|
|
2523
2522
|
this.#pendingDisconnects.set(ws.clientId, {
|
|
2524
2523
|
timeout: setTimeout(() => {
|
|
2525
2524
|
this.#disconnectClient(ws);
|
|
2526
|
-
},
|
|
2525
|
+
}, RECONNECT_GRACE_PERIOD)
|
|
2527
2526
|
});
|
|
2528
2527
|
}
|
|
2529
2528
|
}
|
|
@@ -2537,30 +2536,9 @@ class PlaySocketServer {
|
|
|
2537
2536
|
#disconnectClient(ws) {
|
|
2538
2537
|
this.#pendingDisconnects.delete(ws.clientId);
|
|
2539
2538
|
this.#clientTokens.delete(ws.clientId);
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
if (room) {
|
|
2544
|
-
room.participants = room.participants.filter(p => p !== ws.clientId); // Remove client from room
|
|
2545
|
-
this.#clientRooms.delete(ws.clientId);
|
|
2546
|
-
|
|
2547
|
-
if (room.participants?.length === 0 && room.host !== "server") {
|
|
2548
|
-
this.destroyRoom(roomId); // Destroy room if now empty & not created by server
|
|
2549
|
-
roomId = null; // If room was destroyed, set roomId to null
|
|
2550
|
-
} else {
|
|
2551
|
-
// Notify remaining participants
|
|
2552
|
-
room.participants.forEach(p => {
|
|
2553
|
-
const client = this.#clients.get(p);
|
|
2554
|
-
if (client) client.send(encode({
|
|
2555
|
-
type: 'client_disconnected',
|
|
2556
|
-
client: ws.clientId,
|
|
2557
|
-
participantCount: room.participants.length
|
|
2558
|
-
}), { binary: true });
|
|
2559
|
-
});
|
|
2560
|
-
}
|
|
2561
|
-
}
|
|
2562
|
-
|
|
2563
|
-
this.#triggerEvent("clientDisconnected", ws.clientId, roomId);
|
|
2539
|
+
const roomId = this.#clientRooms.get(ws.clientId);
|
|
2540
|
+
if (roomId && this.#rooms[roomId]) this.#leaveRoom(ws.clientId, roomId);
|
|
2541
|
+
this.#triggerEvent("clientDisconnected", ws.clientId);
|
|
2564
2542
|
}
|
|
2565
2543
|
|
|
2566
2544
|
/**
|
|
@@ -2569,7 +2547,7 @@ class PlaySocketServer {
|
|
|
2569
2547
|
* @param {Function} callback - Callback function
|
|
2570
2548
|
*/
|
|
2571
2549
|
onEvent(event, callback) {
|
|
2572
|
-
const validEvents = ["clientRegistered", "clientRegistrationRequested", "clientDisconnected", "clientJoinedRoom", "clientJoinRequested", "roomCreated", "roomCreationRequested", "requestReceived", "storageUpdated", "storageUpdateRequested", "roomDestroyed"];
|
|
2550
|
+
const validEvents = ["clientRegistered", "clientRegistrationRequested", "clientDisconnected", "clientJoinedRoom", "clientLeftRoom", "clientJoinRequested", "roomCreated", "roomCreationRequested", "requestReceived", "storageUpdated", "storageUpdateRequested", "roomDestroyed"];
|
|
2573
2551
|
if (!validEvents.includes(event)) {
|
|
2574
2552
|
console.warn(`Invalid PlaySocket event type "${event}"`);
|
|
2575
2553
|
return;
|
|
@@ -2578,17 +2556,37 @@ class PlaySocketServer {
|
|
|
2578
2556
|
}
|
|
2579
2557
|
|
|
2580
2558
|
/**
|
|
2581
|
-
* Kick a
|
|
2559
|
+
* Kick a client from the server
|
|
2582
2560
|
* @param {string} clientId - Client ID
|
|
2583
2561
|
* @param {string} [reason] - Optional reason
|
|
2584
2562
|
*/
|
|
2585
2563
|
kick(clientId, reason) {
|
|
2586
2564
|
const client = this.#clients.get(clientId);
|
|
2587
|
-
if (client)
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2565
|
+
if (!client) return; // Client not connected, skip
|
|
2566
|
+
client.willfulDisconnect = true;
|
|
2567
|
+
client.send(encode({ type: 'kicked', reason }), { binary: true });
|
|
2568
|
+
client.close();
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
/**
|
|
2572
|
+
* Move client into a different room
|
|
2573
|
+
* @param {string} clientId - Client ID
|
|
2574
|
+
* @param {string} roomId - Room ID (Target)
|
|
2575
|
+
*/
|
|
2576
|
+
move(clientId, roomId) {
|
|
2577
|
+
const client = this.#clients.get(clientId);
|
|
2578
|
+
const oldRoomId = this.#clientRooms.get(clientId);
|
|
2579
|
+
const oldRoom = this.#rooms[oldRoomId];
|
|
2580
|
+
const targetRoom = this.#rooms[roomId];
|
|
2581
|
+
if (!client) throw new Error("Client not found.");
|
|
2582
|
+
if (!oldRoomId) throw new Error("Client is not in a room.");
|
|
2583
|
+
if (!oldRoom) throw new Error("Client room not found.");
|
|
2584
|
+
if (oldRoomId === roomId) throw new Error("Client is already in target room.");
|
|
2585
|
+
if (!targetRoom) throw new Error("Target room not found.");
|
|
2586
|
+
if (targetRoom.participants.length >= targetRoom.size) throw new Error("Target room is full.");
|
|
2587
|
+
if (oldRoom.host === clientId) this.#migrateHost(oldRoomId, clientId);
|
|
2588
|
+
this.#leaveRoom(clientId, oldRoomId);
|
|
2589
|
+
this.#joinRoom(clientId, roomId);
|
|
2592
2590
|
}
|
|
2593
2591
|
|
|
2594
2592
|
/**
|
|
@@ -2620,7 +2618,7 @@ class PlaySocketServer {
|
|
|
2620
2618
|
*/
|
|
2621
2619
|
getRoomStorage(roomId) {
|
|
2622
2620
|
const room = this.#rooms[roomId];
|
|
2623
|
-
if (room) return room.crdtManager.
|
|
2621
|
+
if (room) return room.crdtManager.propertyStore;
|
|
2624
2622
|
}
|
|
2625
2623
|
|
|
2626
2624
|
/**
|
|
@@ -2654,7 +2652,7 @@ class PlaySocketServer {
|
|
|
2654
2652
|
}
|
|
2655
2653
|
|
|
2656
2654
|
/**
|
|
2657
|
-
* Create a room
|
|
2655
|
+
* Create a room
|
|
2658
2656
|
* @param {object} [initialStorage] - Optional initial storage object
|
|
2659
2657
|
* @param {number} [size] - Max. room size, up to 500
|
|
2660
2658
|
* @param {string} [host] - Host ID, defaults to "server" (when set to "server", room will not be deleted if all clients leave)
|
|
@@ -2671,7 +2669,7 @@ class PlaySocketServer {
|
|
|
2671
2669
|
}
|
|
2672
2670
|
}
|
|
2673
2671
|
|
|
2674
|
-
if (!newRoomId) throw new Error("No available ID found.")
|
|
2672
|
+
if (!newRoomId) throw new Error("No available ID found.");
|
|
2675
2673
|
const roomCrdtManager = new CRDTManager(this.#debug);
|
|
2676
2674
|
|
|
2677
2675
|
if (initialStorage) Object.entries(initialStorage)?.forEach(([key, value]) => {
|
|
@@ -2688,7 +2686,83 @@ class PlaySocketServer {
|
|
|
2688
2686
|
|
|
2689
2687
|
this.#triggerEvent("roomCreated", newRoomId);
|
|
2690
2688
|
if (this.#debug) console.log(`Room ${newRoomId} created with initial storage:`, initialStorage);
|
|
2691
|
-
return { state: roomCrdtManager.
|
|
2689
|
+
return { state: roomCrdtManager.state, id: newRoomId };
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
/**
|
|
2693
|
+
* Make a client join a room
|
|
2694
|
+
* @param {string} clientId - Client ID
|
|
2695
|
+
* @param {string} roomId - Room ID
|
|
2696
|
+
*/
|
|
2697
|
+
#joinRoom(clientId, roomId) {
|
|
2698
|
+
const room = this.#rooms[roomId];
|
|
2699
|
+
const client = this.#clients.get(clientId);
|
|
2700
|
+
if (!room) throw new Error("Room not found.");
|
|
2701
|
+
if (!client) throw new Error("Client not found.");
|
|
2702
|
+
if (this.#clientRooms.get(clientId)) throw new Error("Already in a room.");
|
|
2703
|
+
if (room.participants.length >= room.size) throw new Error("Room full.");
|
|
2704
|
+
|
|
2705
|
+
room.participants.push(clientId);
|
|
2706
|
+
this.#clientRooms.set(clientId, roomId);
|
|
2707
|
+
|
|
2708
|
+
// Notify joiner with initial storage
|
|
2709
|
+
if (this.#debug) console.log("Room state sent for join:", room.crdtManager.state);
|
|
2710
|
+
client.send(encode({
|
|
2711
|
+
type: 'join_accepted',
|
|
2712
|
+
roomId,
|
|
2713
|
+
state: room.crdtManager.state,
|
|
2714
|
+
participantCount: room.participants.length,
|
|
2715
|
+
host: room.host,
|
|
2716
|
+
version: this.#roomVersions.get(roomId)
|
|
2717
|
+
}), { binary: true });
|
|
2718
|
+
|
|
2719
|
+
// If the room has no host (previous host disconnected and was the only participant), make joiner the host
|
|
2720
|
+
// Since the room had to be empty, only the joiner needs to be notified
|
|
2721
|
+
const becameHost = room.host === null;
|
|
2722
|
+
if (becameHost) {
|
|
2723
|
+
room.host = clientId;
|
|
2724
|
+
client.send(encode({
|
|
2725
|
+
type: 'host_migrated',
|
|
2726
|
+
newHost: room.host,
|
|
2727
|
+
}), { binary: true });
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
// Notify existing participants
|
|
2731
|
+
room.participants.forEach(p => {
|
|
2732
|
+
if (p === clientId) return;
|
|
2733
|
+
const client = this.#clients.get(p);
|
|
2734
|
+
if (client) {
|
|
2735
|
+
client.send(encode({
|
|
2736
|
+
type: 'client_joined',
|
|
2737
|
+
client: clientId,
|
|
2738
|
+
participantCount: room.participants.length
|
|
2739
|
+
}), { binary: true });
|
|
2740
|
+
}
|
|
2741
|
+
});
|
|
2742
|
+
this.#triggerEvent("clientJoinedRoom", clientId, roomId);
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
#leaveRoom(clientId, roomId) {
|
|
2746
|
+
const room = this.#rooms[roomId];
|
|
2747
|
+
if (!room) throw new Error("Room not found.");
|
|
2748
|
+
|
|
2749
|
+
room.participants = room.participants.filter(p => p !== clientId); // Remove client from room
|
|
2750
|
+
this.#clientRooms.delete(clientId);
|
|
2751
|
+
this.#triggerEvent("clientLeftRoom", clientId, roomId); // Before potential room destruction to allow for accessing its data in callback
|
|
2752
|
+
|
|
2753
|
+
if (room.participants.length === 0 && room.host !== "server") {
|
|
2754
|
+
this.destroyRoom(roomId); // Destroy room if now empty & not by server
|
|
2755
|
+
} else {
|
|
2756
|
+
// Notify remaining participants
|
|
2757
|
+
room.participants.forEach(p => {
|
|
2758
|
+
const client = this.#clients.get(p);
|
|
2759
|
+
if (client) client.send(encode({
|
|
2760
|
+
type: 'client_left',
|
|
2761
|
+
client: clientId,
|
|
2762
|
+
participantCount: room.participants.length
|
|
2763
|
+
}), { binary: true });
|
|
2764
|
+
});
|
|
2765
|
+
}
|
|
2692
2766
|
}
|
|
2693
2767
|
|
|
2694
2768
|
/**
|
|
@@ -2697,9 +2771,9 @@ class PlaySocketServer {
|
|
|
2697
2771
|
*/
|
|
2698
2772
|
destroyRoom(roomId) {
|
|
2699
2773
|
const room = this.#rooms[roomId];
|
|
2700
|
-
if (!room)
|
|
2774
|
+
if (!room) throw new Error("Room not found.");
|
|
2701
2775
|
|
|
2702
|
-
// Disconnect
|
|
2776
|
+
// Disconnect clients if still in room
|
|
2703
2777
|
const participants = room.participants;
|
|
2704
2778
|
participants.forEach((clientId) => {
|
|
2705
2779
|
this.kick(clientId, "Room destroyed by server.");
|
|
@@ -2727,7 +2801,7 @@ class PlaySocketServer {
|
|
|
2727
2801
|
});
|
|
2728
2802
|
}
|
|
2729
2803
|
|
|
2730
|
-
get
|
|
2804
|
+
get rooms() { return structuredClone(this.#rooms) }
|
|
2731
2805
|
}
|
|
2732
2806
|
|
|
2733
|
-
export { PlaySocketServer as default };
|
|
2807
|
+
export { RECONNECT_GRACE_PERIOD, PlaySocketServer as default };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "playsocketjs",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "WebSocket wrapper for creating simple multiplayer systems with ease.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "dist/playsocket-client.js",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
19
|
"start": "node example/example-server.js",
|
|
20
|
-
"test": "
|
|
20
|
+
"test": "npm run build && npx playwright test --config e2e/playwright.config.js",
|
|
21
|
+
"test:headed": "npm run build && npx playwright test --config e2e/playwright.config.js --headed",
|
|
21
22
|
"build": "rollup -c rollup.config.js",
|
|
22
23
|
"lint": "eslint ./src"
|
|
23
24
|
},
|
|
@@ -39,11 +40,12 @@
|
|
|
39
40
|
},
|
|
40
41
|
"homepage": "https://github.com/therealPaulPlay/PlaySocketJS#readme",
|
|
41
42
|
"devDependencies": {
|
|
43
|
+
"@playwright/test": "^1.58.1",
|
|
42
44
|
"@rollup/plugin-node-resolve": "^16.0.0",
|
|
43
45
|
"eslint": "^9.35.0",
|
|
44
46
|
"eslint-plugin-jsdoc": "^54.4.0",
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
+
"globals": "^16.3.0",
|
|
48
|
+
"rollup": "^4.31.0"
|
|
47
49
|
},
|
|
48
50
|
"peerDependencies": {
|
|
49
51
|
"ws": "^8.18.1"
|
|
@@ -51,4 +53,4 @@
|
|
|
51
53
|
"dependencies": {
|
|
52
54
|
"@msgpack/msgpack": "^3.1.1"
|
|
53
55
|
}
|
|
54
|
-
}
|
|
56
|
+
}
|